diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..80b2dd1 Binary files /dev/null and b/.DS_Store differ diff --git a/Cargo.lock b/Cargo.lock index 41abdc4..4ab4959 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1369,7 +1369,7 @@ dependencies = [ [[package]] name = "nu-analytics" -version = "0.5.2" +version = "0.5.4" dependencies = [ "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 30c0949..88ceee4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "nu-analytics" -version = "0.5.2" +version = "0.5.4" edition = "2021" -authors = ["Albert Lionelle "] +authors = ["Albert Lionelle ","Hasnain Sikora "] description = "A CLI tool for computing Curricular Analytics metrics. Based off metrics from CurricularAnalytics.org. Currently, only basic metrics and additional reporting features." license = "MIT" repository = "https://github.com/NeuCurricularAnalytics/NuAnalytics" diff --git a/src/assets/Degree-schema.yaml b/src/assets/Degree-schema.yaml index a4ec053..f1fcb29 100644 --- a/src/assets/Degree-schema.yaml +++ b/src/assets/Degree-schema.yaml @@ -391,7 +391,8 @@ courses: # Scheduling prerequisites: "prereq_expr | null" # Boolean expression (see syntax below) - corequisites: "[string] | null" # Courses that must be taken concurrently + corequisites: "[string] | null" # Courses that must be taken concurrently or before + strict_corequisites: "[string] | null" # Courses that must be taken in the exact same term typically_offered: "[string] | null" # ["fall", "spring", "summer"] # Grade requirements diff --git a/src/cli/args.rs b/src/cli/args.rs index 31e9f40..fcc127f 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -272,6 +272,23 @@ pub enum DegreeSubcommand { /// metrics across the programs. The value is the school name. #[arg(long, value_name = "NAME")] school: Option, + + /// Compute earliest-semester stats for a specific target course and + /// print them as JSON to stdout. When set alongside `--no-report + /// --no-csv`, this is the fastest way to query a single course's + /// first-semester number without generating full reports. + /// + /// Example: --target-course CSE475 + #[arg(long, value_name = "COURSE_ID")] + target_course: Option, + + /// When `--target-course` is set, write the full analysis JSON + /// (course complexity, plan stats, target_course_stats, etc.) to + /// this path in addition to printing `target_course_stats` to stdout. + /// + /// Example: --metrics-out metrics/Tulane__CMPS2200.json + #[arg(long, value_name = "PATH", requires = "target_course")] + metrics_out: Option, }, /// Trim a degree program to a single entry path per course. @@ -364,6 +381,43 @@ pub enum DegreeSubcommand { #[arg(short, long, value_name = "PATH")] out: Option, }, + + /// Normalize degree file(s) to a flat, format-agnostic course set. + /// + /// Accepts unified JSON, YAML, or raw ai-landscape cluster pipeline files + /// (the same three formats as `degree convert`). Each input produces one + /// `.normalized.json` file per program with courses keyed by + /// normalized code and prerequisites in AND-of-OR list form. + /// + /// Intended as the common representation for test-suite comparisons between + /// automated fetchers (e.g. `degree-author` vs. the ai-landscape pipeline). + /// + /// # Examples + /// ```sh + /// # Normalize a cluster pipeline file (one output per program) + /// nuanalytics degree normalize Northeastern_University.json -o out/ + /// + /// # Normalize a unified JSON (single output) + /// nuanalytics degree normalize neu__bscs.unified.json -o out/ + /// + /// # Normalize a degree-author YAML + /// nuanalytics degree normalize neu-khoury-bscs-boston.yaml -o out/ + /// ``` + Normalize { + /// Source file(s). Shell wildcards are expanded by the shell. + #[arg(value_name = "FILES", num_args = 1..)] + files: Vec, + + /// Output destination. Without `-o`, each normalized file is written + /// next to its input as `.normalized.json`. With `-o`, the value + /// is a file (single non-cluster input only) or a directory. + #[arg(short, long, value_name = "PATH")] + out: Option, + + /// Pretty-print the JSON output (default is compact, one line). + #[arg(long)] + pretty: bool, + }, } #[derive(Debug, Subcommand)] diff --git a/src/cli/commands/degree.rs b/src/cli/commands/degree.rs index 67e523b..f72454a 100644 --- a/src/cli/commands/degree.rs +++ b/src/cli/commands/degree.rs @@ -119,7 +119,6 @@ pub fn print_graph(degree_path: &Path, verbose: bool) -> Result<(), String> { Ok(()) } - /// Load degree program and build course graph fn load_and_build_graph( degree_path: &Path, @@ -483,6 +482,12 @@ pub struct AnalyzeOptions { /// When set, treat the inputs as programs of one school and also emit a /// combined `_school_report.json` rolling up degree-level metrics. pub school: Option, + /// When set, compute and print earliest-semester stats for this course as + /// JSON to stdout. Pair with `--no-report --no-csv` for a fast query. + pub target_course: Option, + /// When set alongside `target_course`, write the full analysis JSON + /// (course complexity, plan stats, target_course_stats) to this path. + pub metrics_out: Option, } /// Run `degree validate` over one or more files. @@ -954,7 +959,12 @@ fn analyze_child_flags(o: &AnalyzeOptions) -> Vec { fn run_analyze_inprocess(files: &[PathBuf], options: &AnalyzeOptions, config: &Config) { let Some(school_name) = options.school.clone() else { run_batch(files, |path| { - analyze_degree(path, options, config).map(|_| ()) + match analyze_degree(path, options, config) { + Ok(_) => Ok(()), + // Sentinel used by --target-course fast path: not a real error. + Err(ref e) if e.starts_with("__target_course_done__") => Ok(()), + Err(e) => Err(e), + } }); return; }; @@ -1662,6 +1672,42 @@ fn analyze_degree( options: &AnalyzeOptions, config: &Config, ) -> Result { + // Fast path: when --target-course is set, call execute_json directly and + // print target_course_stats as JSON to stdout, then exit. This avoids + // generating full reports and is the intended interface for the testsuite. + if let Some(ref course_id) = options.target_course { + let raw = std::fs::read_to_string(degree_path) + .map_err(|e| format!("Failed to read {}: {e}", degree_path.display()))?; + let json_out = nu_analytics::mcp::tools::analyze::execute_json( + &raw, + options.max_plans, + options.include_courses.as_deref(), + false, + None, + false, + false, + None, + None, + Some(course_id.as_str()), + ); + let response: serde_json::Value = + serde_json::from_str(&json_out).unwrap_or(serde_json::json!({"error": json_out})); + let stats = &response["target_course_stats"]; + println!( + "{}", + serde_json::to_string_pretty(stats).unwrap_or_default() + ); + // Optionally save the full analysis JSON to disk. + if let Some(ref out_path) = options.metrics_out { + if let Some(parent) = out_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(out_path, &json_out); + } + // Return a minimal rollup — caller ignores it in this mode. + return Err(format!("__target_course_done__{course_id}")); + } + // Load and validate the degree program, then run the shared single-degree // analysis on it. This is the in-process, non-worker-pool seam shared with // the `--from-db` path (which supplies an already-loaded program). @@ -1786,6 +1832,139 @@ pub fn run_schema(out: Option<&Path>) { } } +/// Run `degree normalize` over one or more inputs, emitting a flat normalized +/// course set per program as `.normalized.json`. +/// +/// Accepts the same three input shapes as `degree convert`: +/// - Cluster pipeline JSON (`course_verifier` / `course_scraper`) → one file per program +/// - Unified degree JSON → one file +/// - Degree YAML → one file +pub fn run_normalize(files: &[PathBuf], out: Option<&Path>, pretty: bool, verbose: bool) { + let dir_mode = out.map_or(false, |p| { + p.is_dir() || p.to_string_lossy().ends_with(std::path::MAIN_SEPARATOR) + }) || files.len() > 1; + + let mut total_programs: usize = 0; + let mut total_warnings: usize = 0; + + for file in files { + match normalize_file(file, out, dir_mode, pretty, verbose) { + Ok((programs, warnings)) => { + total_programs += programs; + total_warnings += warnings; + } + Err(e) => eprintln!("✗ {}: {e}", file.display()), + } + } + + if files.len() > 1 { + println!("Normalized {total_programs} program(s) with {total_warnings} warning(s)"); + } +} + +/// Normalize one input file. Returns `(programs_written, warnings)`. +fn normalize_file( + input: &Path, + out: Option<&Path>, + dir_mode: bool, + pretty: bool, + verbose: bool, +) -> Result<(usize, usize), String> { + use nu_analytics::core::degree::{ + convert_landscape, extract_cluster_programs, normalize_program, + }; + + let contents = std::fs::read_to_string(input) + .map_err(|e| format!("Failed to read {}: {e}", input.display()))?; + + if is_json_path(input) { + if let Ok(value) = serde_json::from_str::(&contents) { + if let Some(cluster_programs) = extract_cluster_programs(&value) { + let out_dir = cluster_out_dir(input, out); + let stem = file_stem_or(input, "degree"); + let mut warnings = 0usize; + for (prog_name, landscape_prog) in &cluster_programs { + let result = convert_landscape(landscape_prog); + warnings += result.warnings.len(); + let normalized = normalize_program(&result.program, None, Some(prog_name)); + let out_path = out_dir.join(format!( + "{stem}{}{}.normalized.json", + CLUSTER_NAME_SEP, + slug_filename(prog_name), + )); + write_normalized(&normalized, &out_path, pretty, verbose)?; + } + println!( + "✓ {}: {} program(s), {} warning(s)", + input.display(), + cluster_programs.len(), + warnings, + ); + return Ok((cluster_programs.len(), warnings)); + } + } + } + + // Single-program path: unified JSON or YAML. + let program = + load_degree_auto(input).map_err(|e| format!("Failed to load {}: {e}", input.display()))?; + let normalized = normalize_program(&program, None, None); + let out_path = resolve_normalize_output(input, out, dir_mode); + write_normalized(&normalized, &out_path, pretty, verbose)?; + println!("✓ {} -> {}", input.display(), out_path.display()); + Ok((1, 0)) +} + +/// Output path for a single-program normalize: `.normalized.json`. +fn resolve_normalize_output(input: &Path, out: Option<&Path>, dir_mode: bool) -> PathBuf { + let stem = file_stem_or(input, "degree"); + resolve_output_path(input, out, dir_mode, &format!("{stem}.normalized.json")) +} + +/// Serialize a [`NormalizedProgram`] and write it to `path`. +fn write_normalized( + normalized: &nu_analytics::core::degree::NormalizedProgram, + path: &Path, + pretty: bool, + verbose: bool, +) -> Result<(), String> { + let json = if pretty { + serde_json::to_string_pretty(normalized) + } else { + serde_json::to_string(normalized) + } + .map_err(|e| format!("Failed to serialize normalized output: {e}"))?; + + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; + } + } + std::fs::write(path, &json).map_err(|e| format!("Failed to write {}: {e}", path.display()))?; + + if verbose { + eprintln!(" wrote {}", path.display()); + } + Ok(()) +} + +/// Build a filesystem-safe slug from an arbitrary program name for use in +/// cluster output filenames (mirrors the slug logic in `convert_cluster`). +fn slug_filename(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '_' + } + }) + .collect::() + .trim_matches('_') + .to_string() +} + /// Run `degree convert` over one or more inputs, emitting unified JSON. pub fn run_convert(files: &[PathBuf], out: Option<&Path>, pretty: bool, verbose: bool) { // Directory mode when -o is a directory, or when multiple inputs share one -o. diff --git a/src/cli/main.rs b/src/cli/main.rs index 09c0f9c..b1599d2 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -108,6 +108,8 @@ fn run_degree(subcommand: DegreeSubcommand, config: &Config, verbose: bool) { include, jobs, school, + target_course, + metrics_out, } => { let options = commands::degree::AnalyzeOptions { calc_strategy: calc_strategy.map(|s| s.to_string()), @@ -123,6 +125,8 @@ fn run_degree(subcommand: DegreeSubcommand, config: &Config, verbose: bool) { include_courses: include, jobs, school, + target_course, + metrics_out, }; // Exactly one of {files, --from-db} must be provided. The DB path // is single-program (no worker pool); the file path is unchanged. @@ -170,6 +174,9 @@ fn run_degree(subcommand: DegreeSubcommand, config: &Config, verbose: bool) { DegreeSubcommand::Schema { out } => { commands::degree::run_schema(out.as_deref()); } + DegreeSubcommand::Normalize { files, out, pretty } => { + commands::degree::run_normalize(&files, out.as_deref(), pretty, verbose); + } } } diff --git a/src/core/degree/mod.rs b/src/core/degree/mod.rs index 649b296..26e5967 100644 --- a/src/core/degree/mod.rs +++ b/src/core/degree/mod.rs @@ -27,6 +27,7 @@ pub mod course_reference; pub mod gen_ed_tracker; pub mod json_parser; pub mod landscape_convert; +pub mod normalize; pub mod plan_generator; pub mod plan_selector; pub mod plan_validation; @@ -98,3 +99,6 @@ pub use plan_validation::{ // Re-export gen-ed tracking types pub use gen_ed_tracker::{GenEdSummary, GenEdTracker}; + +// Re-export normalize types +pub use normalize::{normalize_program, NormalizedCourse, NormalizedProgram}; diff --git a/src/core/degree/normalize.rs b/src/core/degree/normalize.rs new file mode 100644 index 0000000..588f3c1 --- /dev/null +++ b/src/core/degree/normalize.rs @@ -0,0 +1,280 @@ +//! Normalize a [`DegreeProgram`] into a flat, format-agnostic course set. +//! +//! Used by the test-suite pipeline to produce a common representation that +//! can be diffed against a ground-truth `course_verifier` record regardless +//! of whether the source was a hand-authored YAML, a `degree convert` unified +//! JSON, or a raw ai-landscape cluster file. +//! +//! Output shape (per program): +//! ```jsonc +//! { +//! "institution": "Northeastern University", +//! "program": "BS in Artificial Intelligence", +//! "courses": { +//! "CS3000": { +//! "title": "Algorithms and Data", +//! "credit_hours": 4.0, +//! "prerequisites": [["CS2100", "DS2500"], ["CS1800"]], +//! "corequisites": [], +//! "strict_corequisites": [] +//! } +//! } +//! } +//! ``` +//! +//! `prerequisites` uses **AND-of-OR** (outer = AND groups, inner = OR +//! alternatives within each group) to match the `course_verifier` convention. +//! Each alternative may be a compound `"A and B"` string when an AND clause +//! appeared inside an OR branch. + +use std::collections::HashMap; + +use serde::Serialize; + +use crate::core::models::DegreeProgram; +use crate::core::prerequisite_parser::{parse_to_ast, PrereqExpr}; + +// --------------------------------------------------------------------------- +// Output types +// --------------------------------------------------------------------------- + +/// Flat, format-agnostic representation of one course. +#[derive(Debug, Clone, Serialize)] +pub struct NormalizedCourse { + /// Course title / name. + pub title: String, + /// Credit hours (defaults to 0.0 when unknown). + pub credit_hours: f32, + /// AND-of-OR prerequisite groups. Outer list = AND; inner list = OR + /// alternatives. An empty outer list means no prerequisites. + pub prerequisites: Vec>, + /// Flat list of co-requisite course codes (normalized, no spaces). + pub corequisites: Vec, + /// Flat list of strict co-requisite course codes. + pub strict_corequisites: Vec, +} + +/// Normalized representation of one degree program. +#[derive(Debug, Clone, Serialize)] +pub struct NormalizedProgram { + /// Institution name (empty string if unknown). + pub institution: String, + /// Program / degree name (empty string if unknown). + pub program: String, + /// Courses keyed by normalized course code (no spaces, e.g. `"CS3000"`). + pub courses: HashMap, +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Normalize a [`DegreeProgram`] into a [`NormalizedProgram`]. +/// +/// The institution and program name are taken from `program.degree`; pass +/// `institution_override` / `program_override` to supply them when the +/// loaded degree lacks metadata (e.g. a partially-converted cluster record). +#[must_use] +pub fn normalize_program( + program: &DegreeProgram, + institution_override: Option<&str>, + program_override: Option<&str>, +) -> NormalizedProgram { + let institution = institution_override + .map(str::to_owned) + .or_else(|| program.degree.institution.clone()) + .unwrap_or_default(); + + let program_name = program_override + .map(str::to_owned) + .unwrap_or_else(|| program.degree.name.clone()); + + let courses = program + .courses + .iter() + .map(|(key, course)| { + let normalized = NormalizedCourse { + title: course.name.clone(), + credit_hours: course.credit_hours, + prerequisites: prereq_raw_to_and_of_or(course.prerequisites_raw.as_deref()), + corequisites: normalize_codes(&course.corequisites), + strict_corequisites: normalize_codes(&course.strict_corequisites), + }; + (key.clone(), normalized) + }) + .collect(); + + NormalizedProgram { + institution, + program: program_name, + courses, + } +} + +// --------------------------------------------------------------------------- +// Prerequisite conversion +// --------------------------------------------------------------------------- + +/// Parse a raw prerequisite expression string and convert it to AND-of-OR. +/// +/// Returns an empty `Vec` for a missing or empty expression. +fn prereq_raw_to_and_of_or(raw: Option<&str>) -> Vec> { + let Some(s) = raw else { return vec![] }; + let trimmed = s.trim(); + if trimmed.is_empty() { + return vec![]; + } + match parse_to_ast(trimmed) { + Some(expr) => expr_to_and_of_or(&expr), + None => vec![], + } +} + +/// Recursively convert a [`PrereqExpr`] to AND-of-OR lists. +/// +/// - `Course(c)` → `[[c]]` +/// - `All([A, B])` → flatten: one OR-group per child +/// - `Any([A, B])` → one OR-group with all alternatives +/// +/// An `All` nested inside an `Any` alternative is joined with `" and "` to +/// produce a compound alternative string, matching the `course_verifier` +/// convention for inner AND expressions. +fn expr_to_and_of_or(expr: &PrereqExpr) -> Vec> { + match expr { + PrereqExpr::Course(c) => vec![vec![normalize_code(c)]], + PrereqExpr::All(parts) => parts.iter().flat_map(expr_to_and_of_or).collect(), + PrereqExpr::Any(alts) => { + let group: Vec = alts.iter().map(expr_to_or_alt).collect(); + if group.is_empty() { + vec![] + } else { + vec![group] + } + } + } +} + +/// Render one OR-alternative, collapsing any nested AND into a `"A and B"` string. +fn expr_to_or_alt(expr: &PrereqExpr) -> String { + match expr { + PrereqExpr::Course(c) => normalize_code(c), + PrereqExpr::All(parts) => parts + .iter() + .map(expr_to_or_alt) + .collect::>() + .join(" and "), + PrereqExpr::Any(alts) => alts + .first() + .map_or_else(String::new, expr_to_or_alt), + } +} + +// --------------------------------------------------------------------------- +// Code normalization +// --------------------------------------------------------------------------- + +/// Strip spaces and brackets from a course code, e.g. `"CS 3000"` → `"CS3000"`. +fn normalize_code(code: &str) -> String { + code.chars() + .filter(|c| !c.is_whitespace() && *c != '[' && *c != ']') + .collect() +} + +/// Normalize a slice of course codes. +fn normalize_codes(codes: &[String]) -> Vec { + codes.iter().map(|c| normalize_code(c)).collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn single_course() { + let expr = PrereqExpr::Course("CS3000".into()); + assert_eq!(expr_to_and_of_or(&expr), vec![vec!["CS3000"]]); + } + + #[test] + fn and_of_courses() { + // CS2100 & CS1800 → [["CS2100"], ["CS1800"]] + let expr = PrereqExpr::All(vec![ + PrereqExpr::Course("CS2100".into()), + PrereqExpr::Course("CS1800".into()), + ]); + assert_eq!( + expr_to_and_of_or(&expr), + vec![vec!["CS2100"], vec!["CS1800"]] + ); + } + + #[test] + fn or_of_courses() { + // CS2100 | DS2500 → [["CS2100", "DS2500"]] + let expr = PrereqExpr::Any(vec![ + PrereqExpr::Course("CS2100".into()), + PrereqExpr::Course("DS2500".into()), + ]); + assert_eq!( + expr_to_and_of_or(&expr), + vec![vec!["CS2100", "DS2500"]] + ); + } + + #[test] + fn and_of_or() { + // (CS2100 | DS2500) & CS1800 → [["CS2100","DS2500"], ["CS1800"]] + let expr = PrereqExpr::All(vec![ + PrereqExpr::Any(vec![ + PrereqExpr::Course("CS2100".into()), + PrereqExpr::Course("DS2500".into()), + ]), + PrereqExpr::Course("CS1800".into()), + ]); + assert_eq!( + expr_to_and_of_or(&expr), + vec![vec!["CS2100", "DS2500"], vec!["CS1800"]] + ); + } + + #[test] + fn and_inside_or_becomes_compound_string() { + // (A & B) | C → [["A and B", "C"]] + let expr = PrereqExpr::Any(vec![ + PrereqExpr::All(vec![ + PrereqExpr::Course("MAC2312".into()), + PrereqExpr::Course("MAD2104".into()), + ]), + PrereqExpr::Course("MAC2313".into()), + ]); + assert_eq!( + expr_to_and_of_or(&expr), + vec![vec!["MAC2312 and MAD2104", "MAC2313"]] + ); + } + + #[test] + fn empty_prereq_raw() { + assert!(prereq_raw_to_and_of_or(None).is_empty()); + assert!(prereq_raw_to_and_of_or(Some("")).is_empty()); + assert!(prereq_raw_to_and_of_or(Some(" ")).is_empty()); + } + + #[test] + fn parse_expression_string() { + // Round-trip through the real parser + let result = prereq_raw_to_and_of_or(Some("(CS2100 | DS2500) & CS1800")); + assert_eq!(result, vec![vec!["CS2100", "DS2500"], vec!["CS1800"]]); + } + + #[test] + fn normalize_code_strips_spaces_and_brackets() { + assert_eq!(normalize_code("CS 3000"), "CS3000"); + assert_eq!(normalize_code("CS3000[B]"), "CS3000B"); + assert_eq!(normalize_code("CS3000"), "CS3000"); + } +} diff --git a/src/core/degree/plan_selector.rs b/src/core/degree/plan_selector.rs index aa266c4..c59c823 100644 --- a/src/core/degree/plan_selector.rs +++ b/src/core/degree/plan_selector.rs @@ -29,6 +29,9 @@ pub struct PlanScore { /// Whether this plan assumes calculus readiness pub is_calc_ready: bool, + + /// Average chain length across all courses in the plan + pub avg_chain_length: f64, } impl PlanScore { @@ -309,6 +312,13 @@ impl<'a> PlanSelector<'a> { let total_complexity: usize = course_metrics.values().map(|m| m.complexity).sum(); let longest_delay = course_metrics.values().map(|m| m.delay).max().unwrap_or(0); + let n = course_metrics.len(); + #[allow(clippy::cast_precision_loss)] + let avg_chain_length = if n > 0 { + course_metrics.values().map(|m| m.chain_length).sum::() as f64 / n as f64 + } else { + 0.0 + }; // Find the course(s) with the longest delay and trace back to find the chain // Use plan_dag for chain computation too @@ -325,6 +335,7 @@ impl<'a> PlanSelector<'a> { longest_delay, longest_delay_chain, is_calc_ready: false, + avg_chain_length, }; ScoredPlan { @@ -438,7 +449,7 @@ impl<'a> PlanSelector<'a> { /// /// A plan is calc-ready if it contains calculus courses. /// All such plans are considered calc-ready candidates. - fn is_calc_ready_plan(&self, variant: &PlanVariant) -> bool { + pub fn is_calc_ready_plan(&self, variant: &PlanVariant) -> bool { // Check if any course matches calculus course codes or patterns variant.courses.iter().any(|course| { // Direct match with calculus courses @@ -677,6 +688,7 @@ mod tests { centrality: 3 + i, delay: 2 + i, blocking: 1 + i, + chain_length: 1 + i, }, ) }) @@ -691,6 +703,7 @@ mod tests { longest_delay: 6, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 2.5, }; let score2 = PlanScore { terms_required: 9, @@ -698,6 +711,7 @@ mod tests { longest_delay: 7, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 3.0, }; assert!(score1.is_shorter_than(&score2)); @@ -795,6 +809,7 @@ mod tests { longest_delay: 0, longest_delay_chain: vec![], is_calc_ready: false, + avg_chain_length: 0.0, }, schedule: { use crate::core::report::term_scheduler::Term; @@ -835,6 +850,7 @@ mod tests { longest_delay: 2, longest_delay_chain: vec![], is_calc_ready: is_calc, + avg_chain_length: 0.0, }, schedule: TermPlan { terms: vec![Term { diff --git a/src/core/metrics.rs b/src/core/metrics.rs index 62e1cc3..adf155b 100644 --- a/src/core/metrics.rs +++ b/src/core/metrics.rs @@ -15,6 +15,15 @@ pub type ComplexityByCourse = HashMap; /// Centrality per course keyed by course code (e.g., "CS2510"). pub type CentralityByCourse = HashMap; +/// Chain length per course keyed by course code (e.g., "CS2510"). +/// +/// Chain length is the number of courses in the longest incoming prerequisite +/// chain up to and including the course itself. A standalone course has a +/// chain length of 1; a course with one prerequisite has a chain length of 2, +/// and so on. This answers "how many semesters deep into the program is this +/// course?" independent of what comes after it. +pub type ChainLengthByCourse = HashMap; + /// Metrics for a single course #[derive(Debug, Clone, PartialEq, Eq)] pub struct CourseMetrics { @@ -26,6 +35,8 @@ pub struct CourseMetrics { pub complexity: usize, /// Centrality (sum of path lengths through this course) pub centrality: usize, + /// Chain length (longest incoming prerequisite chain, including this course) + pub chain_length: usize, } impl CourseMetrics { @@ -52,6 +63,7 @@ pub fn compute_all_metrics(dag: &DAG) -> Result { let blocking = compute_blocking(dag)?; let complexity = compute_complexity(&delay, &blocking)?; let centrality = compute_centrality(dag)?; + let chain_length = compute_chain_length(dag)?; let mut metrics = CurriculumMetrics::new(); @@ -60,6 +72,7 @@ pub fn compute_all_metrics(dag: &DAG) -> Result { let blocking_val = blocking.get(course).copied().unwrap_or(0); let complexity_val = complexity.get(course).copied().unwrap_or(0); let centrality_val = centrality.get(course).copied().unwrap_or(0); + let chain_length_val = chain_length.get(course).copied().unwrap_or(1); metrics.insert( course.clone(), @@ -68,6 +81,7 @@ pub fn compute_all_metrics(dag: &DAG) -> Result { blocking: blocking_val, complexity: complexity_val, centrality: centrality_val, + chain_length: chain_length_val, }, ); } @@ -220,6 +234,38 @@ pub fn compute_centrality(dag: &DAG) -> Result { Ok(centrality) } +/// Compute the chain length for every course in the requisite graph. +/// +/// Chain length is the number of courses in the longest incoming prerequisite +/// chain ending at (and including) the course. A standalone course with no +/// prerequisites has a chain length of 1; a course one step deep has 2; etc. +/// +/// This differs from delay factor, which measures the longest *full* path +/// through the course (both incoming prerequisites and outgoing dependents). +/// Chain length only looks backwards — it answers "how far into the program +/// must a student get before they can take this course?" +/// +/// # Errors +/// +/// Returns an error if the graph contains a cycle. +pub fn compute_chain_length(dag: &DAG) -> Result { + let outgoing = build_outgoing_edges(dag); + let indegree = build_indegree_counts(dag); + let topo_order = topological_order(&dag.courses, &outgoing, &indegree)?; + let longest_to = longest_paths_to(&topo_order, dag); + + let chain_lengths = dag + .courses + .iter() + .map(|course| { + let incoming_edges = longest_to.get(course).copied().unwrap_or(0); + (course.clone(), incoming_edges + 1) + }) + .collect(); + + Ok(chain_lengths) +} + /// Enumerate all paths from source to sink and update centrality counts. /// /// This helper function initiates a depth-first search to find all paths between @@ -770,6 +816,7 @@ mod tests { assert_eq!(a_metrics.blocking, 2); assert_eq!(a_metrics.complexity, 5); assert_eq!(a_metrics.centrality, 0); + assert_eq!(a_metrics.chain_length, 1); // Check B let b_metrics = all_metrics.get("B").expect("B metrics"); @@ -777,6 +824,7 @@ mod tests { assert_eq!(b_metrics.blocking, 1); assert_eq!(b_metrics.complexity, 4); assert_eq!(b_metrics.centrality, 3); + assert_eq!(b_metrics.chain_length, 2); // Check C let c_metrics = all_metrics.get("C").expect("C metrics"); @@ -784,6 +832,61 @@ mod tests { assert_eq!(c_metrics.blocking, 0); assert_eq!(c_metrics.complexity, 3); assert_eq!(c_metrics.centrality, 0); + assert_eq!(c_metrics.chain_length, 3); + } + + #[test] + fn computes_chain_length_on_simple_chain() { + // CS1 -> CS2 -> CS3: lengths should be 1, 2, 3 + let mut dag = DAG::new(); + dag.add_prerequisite("CS2".to_string(), "CS1"); + dag.add_prerequisite("CS3".to_string(), "CS2"); + + let chain = compute_chain_length(&dag).expect("chain lengths"); + + assert_eq!(chain.get("CS1"), Some(&1)); + assert_eq!(chain.get("CS2"), Some(&2)); + assert_eq!(chain.get("CS3"), Some(&3)); + } + + #[test] + fn chain_length_standalone_course_is_one() { + let mut dag = DAG::new(); + dag.add_course("A".to_string()); + + let chain = compute_chain_length(&dag).expect("chain lengths"); + assert_eq!(chain.get("A"), Some(&1)); + } + + #[test] + fn chain_length_diamond_uses_longest_incoming_path() { + // A -> B, A -> C, B -> D, C -> D + // D has two paths from A: length 3 via either B or C + let mut dag = DAG::new(); + dag.add_prerequisite("B".to_string(), "A"); + dag.add_prerequisite("C".to_string(), "A"); + dag.add_prerequisite("D".to_string(), "B"); + dag.add_prerequisite("D".to_string(), "C"); + + let chain = compute_chain_length(&dag).expect("chain lengths"); + + assert_eq!(chain.get("A"), Some(&1)); + assert_eq!(chain.get("B"), Some(&2)); + assert_eq!(chain.get("C"), Some(&2)); + assert_eq!(chain.get("D"), Some(&3)); + } + + #[test] + fn chain_length_counts_corequisites_as_edges() { + let mut dag = DAG::new(); + dag.add_corequisite("B".to_string(), "A"); + dag.add_prerequisite("C".to_string(), "B"); + + let chain = compute_chain_length(&dag).expect("chain lengths"); + + assert_eq!(chain.get("A"), Some(&1)); + assert_eq!(chain.get("B"), Some(&2)); + assert_eq!(chain.get("C"), Some(&3)); } #[test] @@ -854,6 +957,7 @@ mod tests { blocking: 3, complexity: 8, centrality: 10, + chain_length: 4, }; let (complexity, blocking, delay, centrality) = metrics.as_export_tuple(); diff --git a/src/core/metrics_export.rs b/src/core/metrics_export.rs index 2abd3bc..90715fd 100644 --- a/src/core/metrics_export.rs +++ b/src/core/metrics_export.rs @@ -375,7 +375,7 @@ fn write_csv_header( writeln!(file, "Courses")?; writeln!( file, - "Course ID,Course Name,Prefix,Number,Prerequisites,Corequisites,Strict-Corequisites,Credit Hours,Institution,Canonical Name,Complexity,Blocking,Delay,Centrality" + "Course ID,Course Name,Prefix,Number,Prerequisites,Corequisites,Strict-Corequisites,Credit Hours,Institution,Canonical Name,Complexity,Blocking,Delay,Centrality,Chain Length" )?; Ok(()) @@ -407,9 +407,10 @@ fn write_csv_courses( let coreqs = format_course_keys_as_csv(course.corequisites.iter(), school); let strict_coreqs = format_course_keys_as_csv(course.strict_corequisites.iter(), school); - let (complexity, blocking, delay, centrality) = metrics_data.map_or((0, 0, 0, 0), |m| { - (m.complexity, m.blocking, m.delay, m.centrality) - }); + let (complexity, blocking, delay, centrality, chain_length) = + metrics_data.map_or((0, 0, 0, 0, 0), |m| { + (m.complexity, m.blocking, m.delay, m.centrality, m.chain_length) + }); // Scale complexity for quarter systems #[allow(clippy::cast_precision_loss)] @@ -417,7 +418,7 @@ fn write_csv_courses( writeln!( file, - "{},{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",{},\"{}\",\"{}\",{:.1},{},{},{}", + "{},{},\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",{},\"{}\",\"{}\",{:.1},{},{},{},{}", csv_id, course.name, course.prefix, @@ -431,7 +432,8 @@ fn write_csv_courses( scaled_complexity, blocking, delay, - centrality + centrality, + chain_length )?; } diff --git a/src/core/report/degree_report.rs b/src/core/report/degree_report.rs index 73c1e94..49e9f89 100644 --- a/src/core/report/degree_report.rs +++ b/src/core/report/degree_report.rs @@ -695,6 +695,7 @@ mod tests { centrality: 5, delay: 3, blocking: 2, + chain_length: 2, }, ); agg.add_plan(&metrics, 120.0); @@ -711,6 +712,7 @@ mod tests { longest_delay: 5, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 0.0, }, schedule: TermPlan::new(8, false, 15.0), course_metrics: HashMap::new(), @@ -775,6 +777,7 @@ mod tests { longest_delay: 1, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 0.0, }, schedule: TermPlan::new(terms, false, 15.0), course_metrics: HashMap::new(), diff --git a/src/core/report/formats/html.rs b/src/core/report/formats/html.rs index e0060be..a663042 100644 --- a/src/core/report/formats/html.rs +++ b/src/core/report/formats/html.rs @@ -235,6 +235,7 @@ mod tests { blocking: 1, delay: 1, centrality: 1, + chain_length: 1, }, ); metrics.insert( @@ -244,6 +245,7 @@ mod tests { blocking: 0, delay: 2, centrality: 1, + chain_length: 2, }, ); diff --git a/src/core/report/plan_export.rs b/src/core/report/plan_export.rs index 9224e19..d8bab1f 100644 --- a/src/core/report/plan_export.rs +++ b/src/core/report/plan_export.rs @@ -879,6 +879,7 @@ mod tests { centrality: 5, delay: 3, blocking: 2, + chain_length: 2, }, ); @@ -890,6 +891,7 @@ mod tests { longest_delay: 5, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 0.0, }, schedule: TermPlan::new(8, false, 15.0), course_metrics: metrics, @@ -1001,6 +1003,7 @@ mod tests { centrality: 5, delay: 3, blocking: 2, + chain_length: 2, }, ); agg.add_plan(&metrics, 120.0); diff --git a/src/core/report/unified_report.rs b/src/core/report/unified_report.rs index 2c19a5f..41f8b65 100644 --- a/src/core/report/unified_report.rs +++ b/src/core/report/unified_report.rs @@ -322,6 +322,7 @@ mod tests { centrality: c / 2, delay: c / 5, blocking: c / 3, + chain_length: 1, }, ); agg.add_plan(&cm, 4.0); diff --git a/src/core/report/visualization/curriculum_graph.rs b/src/core/report/visualization/curriculum_graph.rs index f470b6d..f0b06dd 100644 --- a/src/core/report/visualization/curriculum_graph.rs +++ b/src/core/report/visualization/curriculum_graph.rs @@ -566,6 +566,7 @@ mod tests { blocking: 1, complexity: 2, centrality: 1, + chain_length: 1, }, ); metrics.insert( @@ -575,6 +576,7 @@ mod tests { blocking: 0, complexity: 2, centrality: 0, + chain_length: 2, }, ); @@ -640,6 +642,7 @@ mod tests { blocking: 5, complexity: 8, centrality: 1, + chain_length: 2, }, ); @@ -696,6 +699,7 @@ mod tests { blocking: 0, complexity: 1, centrality: 0, + chain_length: 1, }, ); @@ -843,6 +847,7 @@ mod tests { blocking: 4, complexity: 6, centrality: 0, + chain_length: 1, }, ); @@ -861,6 +866,7 @@ mod tests { longest_delay: 2, longest_delay_chain: vec!["CS101".to_string()], is_calc_ready: false, + avg_chain_length: 1.0, }, schedule, course_metrics: course_metrics.clone(), diff --git a/src/core/report/visualization/mermaid.rs b/src/core/report/visualization/mermaid.rs index de3a216..f37cd1a 100644 --- a/src/core/report/visualization/mermaid.rs +++ b/src/core/report/visualization/mermaid.rs @@ -204,6 +204,7 @@ mod tests { blocking: 1, complexity: 2, centrality: 1, + chain_length: 1, }, ); metrics.insert( @@ -213,6 +214,7 @@ mod tests { blocking: 0, complexity: 2, centrality: 1, + chain_length: 2, }, ); diff --git a/src/core/statistics/aggregator.rs b/src/core/statistics/aggregator.rs index fcda4b6..1bf3022 100644 --- a/src/core/statistics/aggregator.rs +++ b/src/core/statistics/aggregator.rs @@ -51,6 +51,8 @@ pub struct CourseAggregator { pub delay: WelfordAccumulator, /// Blocking factor accumulator pub blocking: WelfordAccumulator, + /// Chain length accumulator + pub chain_length: WelfordAccumulator, /// Reservoir for complexity quantiles pub complexity_reservoir: QuantileReservoir, } @@ -64,6 +66,7 @@ impl CourseAggregator { centrality: WelfordAccumulator::new(), delay: WelfordAccumulator::new(), blocking: WelfordAccumulator::new(), + chain_length: WelfordAccumulator::new(), complexity_reservoir: QuantileReservoir::new(reservoir_size), } } @@ -75,6 +78,7 @@ impl CourseAggregator { self.centrality.push(metrics.centrality as f64); self.delay.push(metrics.delay as f64); self.blocking.push(metrics.blocking as f64); + self.chain_length.push(metrics.chain_length as f64); self.complexity_reservoir.push(metrics.complexity as f64); } @@ -91,6 +95,7 @@ impl CourseAggregator { self.centrality.merge(&other.centrality); self.delay.merge(&other.delay); self.blocking.merge(&other.blocking); + self.chain_length.merge(&other.chain_length); self.complexity_reservoir.merge(&other.complexity_reservoir); } } @@ -116,6 +121,8 @@ pub struct AggregatedCourseStats { pub delay: MetricStats, /// Blocking factor statistics pub blocking: MetricStats, + /// Chain length statistics + pub chain_length: MetricStats, } /// Statistics for a single metric @@ -210,6 +217,10 @@ pub struct AggregatedDegreeStats { pub longest_delay: MetricStats, /// Total credits statistics pub total_credits: MetricStats, + /// Average chain length per plan (mean of per-course chain lengths) + pub avg_chain_length: MetricStats, + /// Minimum chain length per plan (shortest chain in each plan) + pub min_chain_length: MetricStats, } /// Quantile storage mode - either exact or approximate @@ -291,6 +302,10 @@ pub struct MetricsAggregator { degree_delay: WelfordAccumulator, /// Degree-level credits accumulator degree_credits: WelfordAccumulator, + /// Degree-level average chain length accumulator + degree_avg_chain_length: WelfordAccumulator, + /// Degree-level minimum chain length accumulator + degree_min_chain_length: WelfordAccumulator, /// Storage for degree complexity quantiles degree_complexity_quantiles: QuantileStorage, /// Storage for degree delay quantiles @@ -311,6 +326,8 @@ impl MetricsAggregator { degree_complexity: WelfordAccumulator::new(), degree_delay: WelfordAccumulator::new(), degree_credits: WelfordAccumulator::new(), + degree_avg_chain_length: WelfordAccumulator::new(), + degree_min_chain_length: WelfordAccumulator::new(), degree_complexity_quantiles: QuantileStorage::new(exact_mode, reservoir_size), degree_delay_quantiles: QuantileStorage::new(exact_mode, reservoir_size), plan_count: 0, @@ -332,11 +349,25 @@ impl MetricsAggregator { // Compute degree-level metrics let total_complexity: usize = course_metrics.values().map(|m| m.complexity).sum(); let longest_delay: usize = course_metrics.values().map(|m| m.delay).max().unwrap_or(0); + let n = course_metrics.len(); + let chain_len_sum: usize = course_metrics.values().map(|m| m.chain_length).sum(); + let avg_chain_length = if n > 0 { + chain_len_sum as f64 / n as f64 + } else { + 0.0 + }; + let min_chain_length: usize = course_metrics + .values() + .map(|m| m.chain_length) + .min() + .unwrap_or(0); // Update Welford accumulators for mean/stddev self.degree_complexity.push(total_complexity as f64); self.degree_delay.push(longest_delay as f64); self.degree_credits.push(total_credits); + self.degree_avg_chain_length.push(avg_chain_length); + self.degree_min_chain_length.push(min_chain_length as f64); // Update quantile storage self.degree_complexity_quantiles @@ -375,6 +406,8 @@ impl MetricsAggregator { &self.degree_delay_quantiles, ), total_credits: MetricStats::from_welford_only(&self.degree_credits), + avg_chain_length: MetricStats::from_welford_only(&self.degree_avg_chain_length), + min_chain_length: MetricStats::from_welford_only(&self.degree_min_chain_length), } } @@ -393,6 +426,7 @@ impl MetricsAggregator { centrality: MetricStats::from_welford_only(&agg.centrality), delay: MetricStats::from_welford_only(&agg.delay), blocking: MetricStats::from_welford_only(&agg.blocking), + chain_length: MetricStats::from_welford_only(&agg.chain_length), }) } @@ -410,6 +444,10 @@ impl MetricsAggregator { self.degree_complexity.merge(&other.degree_complexity); self.degree_delay.merge(&other.degree_delay); self.degree_credits.merge(&other.degree_credits); + self.degree_avg_chain_length + .merge(&other.degree_avg_chain_length); + self.degree_min_chain_length + .merge(&other.degree_min_chain_length); self.degree_complexity_quantiles .merge(&other.degree_complexity_quantiles); self.degree_delay_quantiles @@ -450,6 +488,7 @@ mod tests { centrality: 5, delay: 3, blocking: 7, + chain_length: 2, } } @@ -462,6 +501,7 @@ mod tests { centrality: 10, delay: 6, blocking: 14, + chain_length: 3, }); assert_eq!(agg.plan_count(), 2); @@ -494,6 +534,7 @@ mod tests { centrality: i * 5, delay: i * 2, blocking: i * 3, + chain_length: i, }, ); agg.add_plan(&course_metrics, (i * 4) as f64); @@ -535,6 +576,7 @@ mod tests { centrality: i / 2, delay: i % 10 + 1, blocking: i / 3, + chain_length: 1, }, ); course_metrics.insert( @@ -544,6 +586,7 @@ mod tests { centrality: i, delay: (i % 10) + 2, blocking: i / 2, + chain_length: 2, }, ); agg.add_plan(&course_metrics, 8.0); diff --git a/src/mcp/cache.rs b/src/mcp/cache.rs index ba2d5e3..a46c259 100644 --- a/src/mcp/cache.rs +++ b/src/mcp/cache.rs @@ -171,6 +171,7 @@ fn make_artifact_key( include_courses: Option<&[String]>, random_seed: Option, analysis_timeout_seconds: Option, + target_course: Option<&str>, ) -> ArtifactKey { let mut hasher = DefaultHasher::new(); yaml.hash(&mut hasher); @@ -195,6 +196,9 @@ fn make_artifact_key( // with a longer timeout shouldn't be served the previously time- // truncated entry. analysis_timeout_seconds.hash(&mut hasher); + // Different target courses produce different target_course_stats on the + // artifacts, so they must not share a cache entry. + target_course.hash(&mut hasher); hasher.finish() } @@ -277,6 +281,7 @@ pub(crate) fn cached_artifacts( include_courses: Option<&[String]>, random_seed: Option, analysis_timeout_seconds: Option, + target_course: Option<&str>, ) -> Result, String> { let key = make_artifact_key( yaml, @@ -284,6 +289,7 @@ pub(crate) fn cached_artifacts( include_courses, random_seed, analysis_timeout_seconds, + target_course, ); // Drop the lock guard before returning the Arc on a hit. @@ -301,6 +307,7 @@ pub(crate) fn cached_artifacts( include_courses, random_seed, analysis_timeout_seconds, + target_course, )?; let arc = Arc::new(artifacts); ARTIFACT_CACHE @@ -398,6 +405,7 @@ mod tests { Some(&["CS101".into(), "CS201".into()]), None, None, + None, ); let b = make_artifact_key( "yaml", @@ -405,6 +413,7 @@ mod tests { Some(&["CS201".into(), "CS101".into()]), None, None, + None, ); assert_eq!(a, b); } @@ -417,8 +426,8 @@ mod tests { // (suffix-marker in the comment) so concurrent tests can't evict // the entry between the two calls in this thread. let yaml = "degree:\n id: t-cache-arc\n institution: T\n program: T\n total_credits: 8\n gpa_minimum: 2.0\n\nrequirements:\n intro:\n name: Intro\n type: all\n category: major\n courses: [CS101, CS201]\n\ncourses:\n CS101:\n title: Intro\n prefix: CS\n number: \"101\"\n credits: 4\n CS201:\n title: Adv\n prefix: CS\n number: \"201\"\n credits: 4\n prerequisites_raw: \"CS101\"\n# unique-marker: cached_artifacts arc-eq test\n"; - let first = cached_artifacts(yaml, Some(50), None, None, None).expect("first build"); - let second = cached_artifacts(yaml, Some(50), None, None, None).expect("cache hit"); + let first = cached_artifacts(yaml, Some(50), None, None, None, None).expect("first build"); + let second = cached_artifacts(yaml, Some(50), None, None, None, None).expect("cache hit"); assert!( Arc::ptr_eq(&first, &second), "second call must hit the cache and return the same Arc" @@ -430,8 +439,8 @@ mod tests { // None and Some(vec![]) are both "no constraint" semantically, but // the cache key separates them so swapping forms doesn't accidentally // collide (build_artifacts treats them identically; we err on safety). - let none = make_artifact_key("yaml", Some(10), None, None, None); - let empty = make_artifact_key("yaml", Some(10), Some(&[]), None, None); + let none = make_artifact_key("yaml", Some(10), None, None, None, None); + let empty = make_artifact_key("yaml", Some(10), Some(&[]), None, None, None); assert_ne!(none, empty); } @@ -439,13 +448,22 @@ mod tests { fn test_make_artifact_key_distinguishes_max_plans() { // Different `max_plans` must produce different keys — otherwise a // capped-at-50 result would be returned for a caller asking for 500. - let small = make_artifact_key("yaml", Some(50), None, None, None); - let large = make_artifact_key("yaml", Some(500), None, None, None); + let small = make_artifact_key("yaml", Some(50), None, None, None, None); + let large = make_artifact_key("yaml", Some(500), None, None, None, None); assert_ne!(small, large); - let none = make_artifact_key("yaml", None, None, None, None); + let none = make_artifact_key("yaml", None, None, None, None, None); assert_ne!(small, none); } + #[test] + fn test_make_artifact_key_distinguishes_target_course() { + let no_target = make_artifact_key("yaml", Some(50), None, None, None, None); + let with_target = make_artifact_key("yaml", Some(50), None, None, None, Some("CS4100")); + let diff_target = make_artifact_key("yaml", Some(50), None, None, None, Some("CS3800")); + assert_ne!(no_target, with_target); + assert_ne!(with_target, diff_target); + } + #[test] fn test_yaml_cache_get_returns_none_for_expired_entry() { // Backdate the insertion to before the TTL window so the next `get` @@ -472,8 +490,8 @@ mod tests { // smaller result set for a caller asking for more plans. let yaml = crate::mcp::tools::samples::yaml_for_key("csu") .expect("csu sample key must resolve to embedded YAML"); - let a = cached_artifacts(yaml, Some(25), None, None, None).expect("first build"); - let b = cached_artifacts(yaml, Some(50), None, None, None).expect("second build"); + let a = cached_artifacts(yaml, Some(25), None, None, None, None).expect("first build"); + let b = cached_artifacts(yaml, Some(50), None, None, None, None).expect("second build"); assert!( !Arc::ptr_eq(&a, &b), "different max_plans must produce distinct cache entries" @@ -487,7 +505,7 @@ mod tests { // most recent ones survive. let yaml = crate::mcp::tools::samples::yaml_for_key("csu") .expect("csu sample key must resolve to embedded YAML"); - let sample = crate::mcp::tools::analyze::build_artifacts(yaml, Some(10), None, None, None) + let sample = crate::mcp::tools::analyze::build_artifacts(yaml, Some(10), None, None, None, None) .expect("build"); let shared = Arc::new(sample); diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 9685bac..a39ad7f 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -165,6 +165,7 @@ impl NuAnalyticsMcpServer { let random_seed = req.random_seed; let analysis_timeout_seconds = req.analysis_timeout_seconds; let max_plans = req.max_plans; + let target_course = req.target_course; let plan_indices: Option> = req .plan_indices .as_deref() @@ -185,6 +186,7 @@ impl NuAnalyticsMcpServer { include_placeholder_metrics, random_seed, analysis_timeout_seconds, + target_course.as_deref(), ) }) } diff --git a/src/mcp/tools/analyze.rs b/src/mcp/tools/analyze.rs index fba9753..9e9763d 100644 --- a/src/mcp/tools/analyze.rs +++ b/src/mcp/tools/analyze.rs @@ -14,6 +14,7 @@ use crate::core::degree::{ }; use crate::core::metrics::compute_all_metrics; use crate::core::models::{Course, CourseGraph, School, DAG}; +use crate::core::report::term_scheduler::TermScheduler; use crate::core::report::visualization::{spec_from_scored_plan, CurriculumGraphSpec}; use crate::core::report::SchedulerConfig; use crate::core::statistics::{AggregatorConfig, MetricStats, MetricsAggregator}; @@ -131,6 +132,19 @@ pub struct AnalyzeDegreeRequest { )] pub include_placeholder_metrics: Option, + /// Course ID to compute earliest-semester statistics for (e.g. `"CS4100"`). + /// + /// When set, the response gains a `target_course_stats` object with the + /// minimum and average semester (chain length) at which the course appears + /// across all generated plans, split by all plans vs calc-ready plans. + /// Plans that do not contain the target course are silently skipped. + /// If the course appears in no plans at all, `target_course_stats.error` + /// is set instead. + #[schemars( + description = "Course ID to compute earliest-semester stats for (e.g. \"CS4100\"). Returns min/avg semester across all plans, split by calc-ready vs all." + )] + pub target_course: Option, + /// Seed for the random-sample reservoir. When `None` the seed is /// derived from the YAML body so a given `(yaml, max_plans, /// include_courses)` tuple always returns the same Random Sample plan @@ -161,6 +175,41 @@ pub struct AnalyzeDegreeRequest { pub analysis_timeout_seconds: Option, } +/// Term-reach statistics for one slice of plans (all plans, or calc-ready only). +#[derive(Debug, Default, Clone, Serialize)] +pub struct TargetTermStats { + /// Number of plans that contained the target course. + pub plans_containing: usize, + /// Minimum chain-length (semesters) seen across all containing plans. + #[serde(skip_serializing_if = "Option::is_none")] + pub earliest_term: Option, + /// Mean chain-length across all containing plans. + #[serde(skip_serializing_if = "Option::is_none")] + pub avg_term: Option, + /// How many plans landed on each term number. Sorted by term for readability. + pub term_distribution: std::collections::BTreeMap, +} + +/// Earliest-semester statistics for a specific target course. +/// +/// Populated on `analyze_degree` responses when `target_course` is set. +/// `all_plans` covers every plan that contained the course. `calc_ready_plans` +/// is the subset of those where the plan also includes a recognised calculus +/// course — i.e., plans where calc is part of the degree track and a +/// calc-ready student would reach the target faster. +#[derive(Debug, Clone, Serialize)] +pub struct TargetCourseStats { + /// The course ID that was looked up. + pub course_id: String, + /// Set when the target course did not appear in any generated plan. + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + /// Stats across all plans that contained the target course. + pub all_plans: TargetTermStats, + /// Stats restricted to plans that also include a calculus course. + pub calc_ready_plans: TargetTermStats, +} + /// Serializable metric statistics (includes quartiles for box plots). /// /// Implements `Default` for zero-state fallback when a course has not been @@ -236,6 +285,8 @@ pub struct CourseMetricsJson { pub delay: MetricStatsJson, /// Blocking factor — number of downstream courses gated by this one. pub blocking: MetricStatsJson, + /// Chain length — longest incoming prerequisite chain including this course. + pub chain_length: MetricStatsJson, /// `true` when this entry is a synthetic placeholder course (`ELEC_*`, /// `FE*`). Placeholders are filtered out by default; the field is only /// emitted when `include_placeholder_metrics=true` brings them back. @@ -313,6 +364,10 @@ pub struct AnalysisResponse { pub longest_delay: Option, /// Aggregate total credits statistics pub total_credits: Option, + /// Aggregate average chain length per plan (mean of per-course chain lengths) + pub avg_chain_length: Option, + /// Aggregate minimum chain length per plan (shortest chain in each plan) + pub min_chain_length: Option, /// Selected special plans pub selected_plans: Vec, @@ -324,6 +379,11 @@ pub struct AnalysisResponse { #[serde(skip_serializing_if = "Vec::is_empty")] pub per_course_metrics: Vec, + /// Earliest-semester stats for the requested target course. + /// Only present when `target_course` was set on the request. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_course_stats: Option, + /// Structured hints about the next MCP call worth making, based on the /// analyze outcome (truncation, long critical path, small full /// population, etc.). @@ -406,6 +466,7 @@ pub fn execute( include_placeholder_metrics: bool, random_seed: Option, analysis_timeout_seconds: Option, + target_course: Option<&str>, ) -> AnalysisResponse { match crate::mcp::cache::cached_artifacts( yaml_content, @@ -413,6 +474,7 @@ pub fn execute( include_courses, random_seed, analysis_timeout_seconds, + target_course, ) { Ok(artifacts) => build_response( &artifacts, @@ -462,6 +524,8 @@ pub(crate) struct AnalysisArtifacts { pub time_limit_reached: bool, /// Wall-clock duration of the plan-generation phase in milliseconds. pub time_elapsed_ms: u64, + /// Earliest-semester stats for the target course, if one was requested. + pub target_course_stats: Option, } impl AnalysisArtifacts { @@ -502,6 +566,7 @@ pub(crate) fn build_artifacts( include_courses: Option<&[String]>, random_seed: Option, analysis_timeout_seconds: Option, + target_course: Option<&str>, ) -> Result { let max = max_plans.unwrap_or(DEFAULT_MAX_PLANS); let include = include_courses.map(<[String]>::to_vec).unwrap_or_default(); @@ -559,6 +624,8 @@ pub(crate) fn build_artifacts( // setup are cheap and fixed-cost; what the caller cares about budgeting // is the loop below. let loop_start = Instant::now(); + let mut target_all_terms: Vec = Vec::new(); + let mut target_calc_ready_terms: Vec = Vec::new(); let selected = { let mut selector = PlanSelector::new(&school, &dag, selector_config); let ctx = AnalysisCtx { @@ -575,6 +642,9 @@ pub(crate) fn build_artifacts( deadline, &mut aggregator, &mut selector, + target_course, + &mut target_all_terms, + &mut target_calc_ready_terms, ); plans_processed = processed; time_limit_reached = hit_limit; @@ -585,6 +655,26 @@ pub(crate) fn build_artifacts( // belt-and-braces guard against future clamp loosening. let time_elapsed_ms = u64::try_from(loop_start.elapsed().as_millis()).unwrap_or(u64::MAX); + let target_course_stats = target_course.map(|course_id| { + if target_all_terms.is_empty() { + TargetCourseStats { + course_id: course_id.to_string(), + error: Some(format!( + "Course '{course_id}' did not appear in any of the {plans_processed} generated plans." + )), + all_plans: TargetTermStats::default(), + calc_ready_plans: TargetTermStats::default(), + } + } else { + TargetCourseStats { + course_id: course_id.to_string(), + error: None, + all_plans: build_target_term_stats(&target_all_terms), + calc_ready_plans: build_target_term_stats(&target_calc_ready_terms), + } + } + }); + Ok(AnalysisArtifacts { program, school, @@ -598,9 +688,30 @@ pub(crate) fn build_artifacts( seed_used, time_limit_reached, time_elapsed_ms, + target_course_stats, }) } +/// Compute `TargetTermStats` from a collected list of per-plan chain lengths. +fn build_target_term_stats(terms: &[usize]) -> TargetTermStats { + if terms.is_empty() { + return TargetTermStats::default(); + } + let mut dist = std::collections::BTreeMap::new(); + for &t in terms { + *dist.entry(t).or_insert(0usize) += 1; + } + let earliest = terms.iter().copied().min(); + #[allow(clippy::cast_precision_loss)] + let avg = terms.iter().sum::() as f64 / terms.len() as f64; + TargetTermStats { + plans_containing: terms.len(), + earliest_term: earliest, + avg_term: Some(avg), + term_distribution: dist, + } +} + /// Stable seed derived from the YAML body. /// /// Reuses `DefaultHasher` so the value is consistent within a process run. @@ -633,8 +744,11 @@ fn parse_error_response(error: &str) -> AnalysisResponse { complexity: None, longest_delay: None, total_credits: None, + avg_chain_length: None, + min_chain_length: None, selected_plans: vec![], per_course_metrics: vec![], + target_course_stats: None, tool_followups: vec![ToolFollowup { tool: TOOL_VALIDATE_DEGREE, reason: "analyze_degree couldn't parse the YAML; validate_degree surfaces the parse error in a more structured form.".to_string(), @@ -671,6 +785,9 @@ fn run_plan_analysis( deadline: Option, aggregator: &mut MetricsAggregator, selector: &mut PlanSelector<'_>, + target_course: Option<&str>, + target_all_terms: &mut Vec, + target_calc_ready_terms: &mut Vec, ) -> (usize, bool) { let mut plans_processed = 0; let mut time_limit_reached = false; @@ -708,6 +825,29 @@ fn run_plan_analysis( let expanded_variant = build_expanded_variant(&variant, &expanded, ctx.school, ctx.target_credits); + // Accumulate the actual scheduled term for the target course. + // Must use expanded_variant.courses (includes prerequisite courses) + // to match the scheduler input used by the plan selector — otherwise + // courses without prerequisites get placed in wrong terms. + if let Some(target) = target_course { + if expanded_variant.courses.contains(&target.to_string()) { + let scheduler = + TermScheduler::new(ctx.school, &plan_dag, SchedulerConfig::default()); + let schedule = scheduler.schedule(&expanded_variant.courses); + let term = schedule + .terms + .iter() + .find(|t| t.courses.contains(&target.to_string())) + .map(|t| t.number); + if let Some(t) = term { + target_all_terms.push(t); + if selector.is_calc_ready_plan(&variant) { + target_calc_ready_terms.push(t); + } + } + } + } + aggregator.add_plan(&course_metrics, f64::from(expanded_variant.total_credits)); selector.process_plan(&expanded_variant, &course_metrics, &plan_dag); @@ -840,8 +980,11 @@ fn build_response( complexity: Some(complexity_stats), longest_delay: Some(metric_stats_json(°ree_stats.longest_delay)), total_credits: Some(metric_stats_json(°ree_stats.total_credits)), + avg_chain_length: Some(metric_stats_json(°ree_stats.avg_chain_length)), + min_chain_length: Some(metric_stats_json(°ree_stats.min_chain_length)), selected_plans, per_course_metrics, + target_course_stats: artifacts.target_course_stats.clone(), tool_followups, notes, time_limit_reached: artifacts.time_limit_reached, @@ -878,6 +1021,7 @@ fn build_per_course_metrics( centrality: metric_stats_json(&s.centrality), delay: metric_stats_json(&s.delay), blocking: metric_stats_json(&s.blocking), + chain_length: metric_stats_json(&s.chain_length), placeholder, }) }) @@ -1047,6 +1191,7 @@ pub fn execute_json( include_placeholder_metrics: bool, random_seed: Option, analysis_timeout_seconds: Option, + target_course: Option<&str>, ) -> String { let response = execute( yaml_content, @@ -1058,6 +1203,7 @@ pub fn execute_json( include_placeholder_metrics, random_seed, analysis_timeout_seconds, + target_course, ); serde_json::to_string_pretty(&response) .unwrap_or_else(|e| format!("{{\"error\": \"Failed to serialize response: {e}\"}}")) @@ -1407,6 +1553,7 @@ courses: false, None, None, + None, ); assert!(response.success, "error: {:?}", response.error); assert!(response.plans_analyzed > 0); @@ -1459,6 +1606,7 @@ courses: false, None, None, + None, ); assert!(capped.success, "error: {:?}", capped.error); assert!( @@ -1481,6 +1629,7 @@ courses: false, None, None, + None, ); assert!(full.success, "error: {:?}", full.error); assert!( @@ -1498,7 +1647,7 @@ courses: // Direct coverage of the shared pipeline entry point. Every artifact // field must be populated so sibling tools (the HTML report) don't // have to defensively check for empty/None state. - let artifacts = build_artifacts(TEST_YAML, Some(10), None, None, None) + let artifacts = build_artifacts(TEST_YAML, Some(10), None, None, None, None) .expect("build_artifacts on valid YAML"); assert_eq!(artifacts.program.degree.name, "Test Program"); assert_eq!( @@ -1519,7 +1668,7 @@ courses: // AnalysisArtifacts deliberately doesn't derive Debug (it owns a // MetricsAggregator that wouldn't print usefully anyway), so use a // match instead of `unwrap_err` to interrogate the failure. - let result = build_artifacts("not: valid: yaml: {{", Some(10), None, None, None); + let result = build_artifacts("not: valid: yaml: {{", Some(10), None, None, None, None); let Err(err) = result else { panic!("expected parse failure for malformed YAML"); }; @@ -1538,6 +1687,7 @@ courses: Some(&["CS101".to_string()]), None, None, + None, ) .unwrap(); for (_cat, plan) in artifacts.selected.iter() { @@ -1562,6 +1712,7 @@ courses: false, None, None, + None, ); assert!(response.success); assert!(response.is_full_population); @@ -1578,7 +1729,7 @@ courses: #[test] fn test_artifacts_is_full_population_when_under_cap() { // TEST_YAML has only one valid plan; max=500 means we never hit the cap. - let artifacts = build_artifacts(TEST_YAML, Some(500), None, None, None).unwrap(); + let artifacts = build_artifacts(TEST_YAML, Some(500), None, None, None, None).unwrap(); assert!(artifacts.is_full_population()); assert_eq!(artifacts.population_size(), artifacts.plans_processed); } @@ -1595,6 +1746,7 @@ courses: false, None, None, + None, ); assert!(!response.success); assert!(response.error.is_some()); @@ -1612,6 +1764,7 @@ courses: false, None, None, + None, ); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(parsed["success"].as_bool().unwrap()); @@ -1630,6 +1783,7 @@ courses: false, None, None, + None, ); for plan in &response.selected_plans { assert!( @@ -1654,6 +1808,7 @@ courses: false, None, None, + None, ); assert!(response.success, "error: {:?}", response.error); assert!(response.plans_analyzed > 0); @@ -1688,6 +1843,7 @@ courses: false, None, None, + None, ); assert!(response.success); assert!(!response.selected_plans.is_empty()); @@ -1708,6 +1864,7 @@ courses: false, None, None, + None, )) .unwrap(); for plan in json["selected_plans"].as_array().unwrap() { @@ -1730,6 +1887,7 @@ courses: false, None, None, + None, ); assert!(response.success); assert!(!response.selected_plans.is_empty()); @@ -1760,6 +1918,7 @@ courses: false, None, None, + None, ); assert!(response.success); let mut plans = response.selected_plans.into_iter(); @@ -1790,6 +1949,7 @@ courses: false, None, None, + None, ); assert!(response.success); for plan in &response.selected_plans { @@ -1813,6 +1973,7 @@ courses: false, None, None, + None, ); assert!(response.success); for plan in &response.selected_plans { @@ -1839,6 +2000,7 @@ courses: false, None, None, + None, ); assert!(response.success); assert!(!response.was_truncated); @@ -1860,6 +2022,7 @@ courses: false, None, None, + None, ); assert!(off.per_course_metrics.is_empty()); let off_json = serde_json::to_string(&off).unwrap(); @@ -1880,6 +2043,7 @@ courses: false, None, None, + None, ); assert!(on.success); assert!( @@ -1937,6 +2101,7 @@ courses: centrality: MetricStatsJson::default(), delay: MetricStatsJson::default(), blocking: MetricStatsJson::default(), + chain_length: MetricStatsJson::default(), placeholder: false, }, CourseMetricsJson { @@ -1946,6 +2111,7 @@ courses: centrality: MetricStatsJson::default(), delay: MetricStatsJson::default(), blocking: MetricStatsJson::default(), + chain_length: MetricStatsJson::default(), placeholder: true, }, ] @@ -1977,7 +2143,7 @@ courses: // Exercise via the CSU sample which exercises the full pipeline. let yaml = crate::mcp::tools::samples::yaml_for_key("csu") .expect("csu sample key must resolve to embedded YAML"); - let off = execute(yaml, Some(10), None, false, None, true, false, None, None); + let off = execute(yaml, Some(10), None, false, None, true, false, None, None, None); assert!(off.success, "error: {:?}", off.error); for entry in &off.per_course_metrics { assert!( @@ -1988,7 +2154,7 @@ courses: assert!(!entry.placeholder); } - let on = execute(yaml, Some(10), None, false, None, true, true, None, None); + let on = execute(yaml, Some(10), None, false, None, true, true, None, None, None); assert!(on.success); for entry in &on.per_course_metrics { assert_eq!( @@ -2035,6 +2201,7 @@ courses: false, Some(seed), None, + None, ); assert_eq!(response.seed_used, seed); } @@ -2045,7 +2212,7 @@ courses: .expect("csu sample key must resolve to embedded YAML"); // build_artifacts directly so cache-eviction races don't muddy the // assertion — same path the cached_artifacts wrapper uses on miss. - let artifacts = build_artifacts(csu, Some(50), None, None, None) + let artifacts = build_artifacts(csu, Some(50), None, None, None, None) .expect("csu sample must analyze cleanly"); assert_eq!(artifacts.seed_used, default_seed_for_yaml(csu)); } @@ -2063,6 +2230,7 @@ courses: false, None, None, + None, ); assert!(response.is_full_population); assert_eq!(response.sampling_method, "exhaustive"); @@ -2080,6 +2248,7 @@ courses: false, None, None, + None, ); // Default seed is non-zero (DefaultHasher.finish() on non-empty input // virtually never returns 0). @@ -2101,6 +2270,7 @@ courses: false, None, None, + None, ); assert!(!response.time_limit_reached); assert!( @@ -2122,7 +2292,7 @@ courses: #[test] fn test_artifact_records_time_metrics() { - let artifacts = build_artifacts(TEST_YAML, Some(10), None, None, None).unwrap(); + let artifacts = build_artifacts(TEST_YAML, Some(10), None, None, None, None).unwrap(); assert!(!artifacts.time_limit_reached); // Clock granularity isn't guaranteed — `time_elapsed_ms == 0` is // legitimate on very fast machines. Just assert non-saturating. @@ -2150,6 +2320,7 @@ courses: false, None, Some(1), + None, ); assert!(response.success, "error: {:?}", response.error); assert!( @@ -2191,7 +2362,7 @@ courses: // `0` clamps up to MIN_ANALYSIS_TIMEOUT_SECS (1 s) — would otherwise // build a deadline equal to `Instant::now()` and trip on iteration 0. // Just confirm we get artifacts back (i.e. no panic from `Duration`). - let artifacts_zero = build_artifacts(TEST_YAML, Some(10), None, None, Some(0)) + let artifacts_zero = build_artifacts(TEST_YAML, Some(10), None, None, Some(0), None) .expect("0 must clamp up to 1 s and analyze cleanly"); assert!( artifacts_zero.time_elapsed_ms < 2_000, @@ -2202,7 +2373,7 @@ courses: // TEST_YAML completes in milliseconds, the budget is irrelevant — // we just need the call to succeed without overflow on the deadline // construction. - let artifacts_huge = build_artifacts(TEST_YAML, Some(10), None, None, Some(100_000)) + let artifacts_huge = build_artifacts(TEST_YAML, Some(10), None, None, Some(100_000), None) .expect("100_000 must clamp down to 600 s and analyze cleanly"); assert!( !artifacts_huge.time_limit_reached, @@ -2217,9 +2388,9 @@ courses: // cache entries (otherwise a long-deadline retry would see the // earlier short-deadline truncated result). use std::sync::Arc; - let a = crate::mcp::cache::cached_artifacts(TEST_YAML, Some(10), None, None, Some(30)) + let a = crate::mcp::cache::cached_artifacts(TEST_YAML, Some(10), None, None, Some(30), None) .expect("first build"); - let b = crate::mcp::cache::cached_artifacts(TEST_YAML, Some(10), None, None, Some(60)) + let b = crate::mcp::cache::cached_artifacts(TEST_YAML, Some(10), None, None, Some(60), None) .expect("second build"); assert!( !Arc::ptr_eq(&a, &b), diff --git a/src/mcp/tools/course_detail.rs b/src/mcp/tools/course_detail.rs index a175ebc..280c155 100644 --- a/src/mcp/tools/course_detail.rs +++ b/src/mcp/tools/course_detail.rs @@ -166,7 +166,7 @@ pub fn execute( } if include_analysis { - match crate::mcp::cache::cached_artifacts(yaml_content, max_plans, None, None, None) { + match crate::mcp::cache::cached_artifacts(yaml_content, max_plans, None, None, None, None) { Ok(artifacts) => build_response_with_analysis(course_id, &artifacts), Err(e) => error_response(course_id, e), } diff --git a/src/mcp/tools/degrees.rs b/src/mcp/tools/degrees.rs index 04ce3fa..d38149a 100644 --- a/src/mcp/tools/degrees.rs +++ b/src/mcp/tools/degrees.rs @@ -531,7 +531,7 @@ async fn fetch_program_detail(client: &Arc, id: &str) -> Option) -> serde_json::Value { let response = crate::mcp::tools::analyze::execute( - yaml, max_plans, None, false, None, false, false, None, None, + yaml, max_plans, None, false, None, false, false, None, None, None, ); if !response.success { return serde_json::json!({ diff --git a/src/mcp/tools/pipeline.rs b/src/mcp/tools/pipeline.rs index de4e563..79cfaa6 100644 --- a/src/mcp/tools/pipeline.rs +++ b/src/mcp/tools/pipeline.rs @@ -180,6 +180,7 @@ pub fn execute( false, None, None, + None, )) }; diff --git a/src/mcp/tools/plan_graph.rs b/src/mcp/tools/plan_graph.rs index 2b27dd2..f8def71 100644 --- a/src/mcp/tools/plan_graph.rs +++ b/src/mcp/tools/plan_graph.rs @@ -171,7 +171,7 @@ pub fn execute( return error_response("Provide plan_category OR plan_index, not both."); } - let artifacts = match cached_artifacts(yaml_content, max_plans, include_courses, None, None) { + let artifacts = match cached_artifacts(yaml_content, max_plans, include_courses, None, None, None) { Ok(a) => a, Err(e) => return error_response(e), }; diff --git a/src/mcp/tools/report.rs b/src/mcp/tools/report.rs index 354f907..aaaa048 100644 --- a/src/mcp/tools/report.rs +++ b/src/mcp/tools/report.rs @@ -196,6 +196,7 @@ pub fn execute( include_courses, None, None, + None, ) { Ok(a) => a, Err(e) => return error_response(&e), diff --git a/tests/rs/asu_target_course.rs b/tests/rs/asu_target_course.rs new file mode 100644 index 0000000..3a9e1b8 --- /dev/null +++ b/tests/rs/asu_target_course.rs @@ -0,0 +1,47 @@ +use nu_analytics::mcp::tools::analyze::execute_json; + +#[test] +fn asu_bscs_cse475_target_course() { + let json_content = include_str!("/tmp/asu_unified.json/Arizona_State_University__Bachelor_of_Science_Computer_Science.unified.json"); + let result = execute_json( + json_content, + Some(200), + None, + false, + None, + false, + false, + None, + None, + Some("DAT402"), + ); + println!("{result}"); +} + +#[test] +fn calstatela_cs4963_schedule() { + let json_content = include_str!("/tmp/first_sem_unified/California_State_University-Los_Angeles_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = execute_json( + json_content, + Some(10), + None, + false, + None, + false, + false, + None, + None, + Some("CS4963"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let shortest = v["selected_plans"].as_array().unwrap() + .iter().find(|p| p["category"] == "Shortest Path").unwrap(); + println!("Shortest path: {} terms, {} credits", shortest["terms"], shortest["credits"]); + for term in shortest["schedule"].as_array().unwrap() { + let courses: Vec<&str> = term["courses"].as_array().unwrap() + .iter().map(|c| c.as_str().unwrap()).collect(); + let marker = if courses.contains(&"CS4963") { " ← CS4963" } else { "" }; + println!(" Term {:2} ({:5.1}cr): {:?}{}", term["term"], term["credits"], courses, marker); + } + println!("\ntarget_course_stats:\n{}", serde_json::to_string_pretty(&v["target_course_stats"]).unwrap()); +} diff --git a/tests/rs/first_sem_cases.rs b/tests/rs/first_sem_cases.rs new file mode 100644 index 0000000..517e4b7 --- /dev/null +++ b/tests/rs/first_sem_cases.rs @@ -0,0 +1,381 @@ + +use nu_analytics::mcp::tools::analyze::execute_json; + + #[test] + fn tc_tulane_cmps2200__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Tulane_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CMPS2200"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Tulane", "CMPS2200", "reasonable_true", earliest, 3, calc_earliest); + } + #[test] + fn tc_tulane_cmps3340__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Tulane_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CMPS3340"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Tulane", "CMPS3340", "reasonable_true", earliest, 2, calc_earliest); + } + #[test] + fn tc_coc_csci218__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/College_of_Charleston_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI218"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "CoC", "CSCI218", "reasonable_true", earliest, 2, calc_earliest); + } + #[test] + fn tc_coc_csci495__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/College_of_Charleston_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI495"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "CoC", "CSCI495", "reasonable_true", earliest, 4, calc_earliest); + } + #[test] + fn tc_bowdoin_csci2101__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Bowdoin_College_degree_webpage__Major_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI2101"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Bowdoin", "CSCI2101", "reasonable_true", earliest, 3, calc_earliest); + } + #[test] + fn tc_bowdoin_csci3465__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Bowdoin_College_degree_webpage__Major_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI3465"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Bowdoin", "CSCI3465", "reasonable_true", earliest, 5, calc_earliest); + } + #[test] + fn tc_nmsu_csci2220__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/New_Mexico_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI2220"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "NMSU", "CSCI2220", "reasonable_true", earliest, 3, calc_earliest); + } + #[test] + fn tc_nmsu_csci4270__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/New_Mexico_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI4270"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "NMSU", "CSCI4270", "reasonable_true", earliest, 6, calc_earliest); + } + #[test] + fn tc_liberty_csis316__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Liberty_University_degree_webpage__Bachelor_of_Science_in_Computer_Science_(General).unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSIS316"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Liberty", "CSIS316", "reasonable_true", earliest, 2, calc_earliest); + } + #[test] + fn tc_liberty_cscn354__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Liberty_University_degree_webpage__Bachelor_of_Science_in_Computer_Science_(General).unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCN354"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Liberty", "CSCN354", "reasonable_true", earliest, 2, calc_earliest); + } + #[test] + fn tc_ric_data245__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Rhode_Island_College_degree-BS_webpage__Bachelor_of_Science_in_Artificial_Intelligence.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("DATA245"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "RIC", "DATA245", "reasonable_true", earliest, 2, calc_earliest); + } + #[test] + fn tc_ric_csci446__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/Rhode_Island_College_degree-BS_webpage__Bachelor_of_Science_in_Artificial_Intelligence.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI446"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "RIC", "CSCI446", "reasonable_true", earliest, 5, calc_earliest); + } + #[test] + fn tc_calstatela_cs4963__reasonable_true() { + let content = include_str!("/tmp/first_sem_unified/California_State_University-Los_Angeles_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CS4963"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "CalStateLA", "CS4963", "reasonable_true", earliest, 10, calc_earliest); + } + #[test] + fn tc_tulane_cmps3340__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/Tulane_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CMPS3340"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Tulane", "CMPS3340", "reasonable_false", earliest, 2, calc_earliest); + } + #[test] + fn tc_nmsu_csci4270__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/New_Mexico_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI4270"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "NMSU", "CSCI4270", "reasonable_false", earliest, 6, calc_earliest); + } + #[test] + fn tc_calstatela_cs4963__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/California_State_University-Los_Angeles_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CS4963"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "CalStateLA", "CS4963", "reasonable_false", earliest, 8, calc_earliest); + } + #[test] + fn tc_metro_cs4050__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/Metropolitan_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CS4050"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Metro", "CS4050", "reasonable_false", earliest, 6, calc_earliest); + } + #[test] + fn tc_ric_csci446__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/Rhode_Island_College_degree-BS_webpage__Bachelor_of_Science_in_Artificial_Intelligence.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CSCI446"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "RIC", "CSCI446", "reasonable_false", earliest, 4, calc_earliest); + } + #[test] + fn tc_wku_stat402__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/Western_Kentucky_University_certificate_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("STAT402"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "WKU", "STAT402", "reasonable_false", earliest, 4, calc_earliest); + } + #[test] + fn tc_txstate_cs4398__reasonable_false() { + let content = include_str!("/tmp/first_sem_unified/Texas_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("CS4398"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "TxState", "CS4398", "reasonable_false", earliest, 7, calc_earliest); + } + #[test] + fn tc_asu_mat266__calc_ready() { + let content = include_str!("/tmp/first_sem_unified/Arizona_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("MAT266"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "ASU", "MAT266", "calc_ready", earliest, 2, calc_earliest); + } + #[test] + fn tc_uaa_matha252f__calc_ready() { + let content = include_str!("/tmp/first_sem_unified/University_of_Alaska_Anchorage_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("MATHA252F"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "UAA", "MATHA252F", "calc_ready", earliest, 2, calc_earliest); + } + #[test] + fn tc_syracuse_mat397__calc_ready() { + let content = include_str!("/tmp/first_sem_unified/Syracuse_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("MAT397"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Syracuse", "MAT397", "calc_ready", earliest, 3, calc_earliest); + } + #[test] + fn tc_asu_mat266__not_calc_ready() { + let content = include_str!("/tmp/first_sem_unified/Arizona_State_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("MAT266"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "ASU", "MAT266", "not_calc_ready", earliest, 2, calc_earliest); + } + #[test] + fn tc_uaa_matha252f__not_calc_ready() { + let content = include_str!("/tmp/first_sem_unified/University_of_Alaska_Anchorage_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("MATHA252F"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "UAA", "MATHA252F", "not_calc_ready", earliest, 3, calc_earliest); + } + #[test] + fn tc_syracuse_mat397__not_calc_ready() { + let content = include_str!("/tmp/first_sem_unified/Syracuse_University_degree_webpage__Bachelor_of_Science_in_Computer_Science.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("MAT397"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Syracuse", "MAT397", "not_calc_ready", earliest, 4, calc_earliest); + } + #[test] + fn tc_bellevue_ai240__not_calc_ready() { + let content = include_str!("/tmp/first_sem_unified/Bellevue_College_degree-BS_webpage__Bachelor_of_Applied_Science_in_Software_Development,_Artificial_Intelligence_Concentration.unified.json"); + let result = nu_analytics::mcp::tools::analyze::execute_json( + content, Some(200), None, false, None, false, false, None, None, Some("AI240"), + ); + let v: serde_json::Value = serde_json::from_str(&result).unwrap(); + let earliest = v["target_course_stats"]["all_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + let calc_earliest = v["target_course_stats"]["calc_ready_plans"]["earliest_term"] + .as_u64().unwrap_or(0) as usize; + println!("{} | {} | group={} | got_all={} expected={} | got_calc={}", + "Bellevue", "AI240", "not_calc_ready", earliest, 4, calc_earliest); + } diff --git a/tests/rs/mod.rs b/tests/rs/mod.rs index a2e1c44..17e64d4 100644 --- a/tests/rs/mod.rs +++ b/tests/rs/mod.rs @@ -1,5 +1,7 @@ //! Rust integration test modules +pub mod asu_target_course; +pub mod first_sem_cases; pub mod cli_degree; pub mod course_graph; pub mod course_syntax; diff --git a/tests/rs/plan_generation.rs b/tests/rs/plan_generation.rs index 3f12ca0..3c28159 100644 --- a/tests/rs/plan_generation.rs +++ b/tests/rs/plan_generation.rs @@ -233,6 +233,7 @@ fn test_plan_score_comparison() { longest_delay: 6, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 2.5, }; let score2 = PlanScore { @@ -241,6 +242,7 @@ fn test_plan_score_comparison() { longest_delay: 7, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 3.0, }; let score3 = PlanScore { @@ -249,6 +251,7 @@ fn test_plan_score_comparison() { longest_delay: 5, longest_delay_chain: Vec::new(), is_calc_ready: false, + avg_chain_length: 2.0, }; // Basic comparison diff --git a/tests/rs/statistics.rs b/tests/rs/statistics.rs index f88220d..b9f4994 100644 --- a/tests/rs/statistics.rs +++ b/tests/rs/statistics.rs @@ -207,6 +207,7 @@ fn test_metrics_aggregation_integration() { centrality: 3 + (plan_num % 3), delay: 2 + (plan_num % 4), blocking: 3 + (plan_num % 3), + chain_length: 1, }, ); @@ -218,6 +219,7 @@ fn test_metrics_aggregation_integration() { centrality: 5 + (plan_num % 5), delay: 4 + (plan_num % 3), blocking: 4 + (plan_num % 4), + chain_length: 2, }, ); @@ -229,6 +231,7 @@ fn test_metrics_aggregation_integration() { centrality: 8 + (plan_num % 4), delay: 6 + (plan_num % 5), blocking: 6 + (plan_num % 6), + chain_length: 3, }, );