nu_analytics/core/
metrics.rs

1//! Complexity and curriculum metrics
2
3use crate::core::models::DAG;
4use std::collections::{HashMap, HashSet, VecDeque};
5
6/// Delay factor per course keyed by course code (e.g., "CS2510").
7pub type DelayByCourse = HashMap<String, usize>;
8
9/// Blocking factor per course keyed by course code (e.g., "CS2510").
10pub type BlockingByCourse = HashMap<String, usize>;
11
12/// Structural complexity per course keyed by course code (e.g., "CS2510").
13pub type ComplexityByCourse = HashMap<String, usize>;
14
15/// Centrality per course keyed by course code (e.g., "CS2510").
16pub type CentralityByCourse = HashMap<String, usize>;
17
18/// Metrics for a single course
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CourseMetrics {
21    /// Delay factor (longest requisite path length in vertices)
22    pub delay: usize,
23    /// Blocking factor (number of courses blocked)
24    pub blocking: usize,
25    /// Structural complexity (delay + blocking)
26    pub complexity: usize,
27    /// Centrality (sum of path lengths through this course)
28    pub centrality: usize,
29}
30
31impl CourseMetrics {
32    /// Get all metrics as a tuple for convenient unpacking.
33    ///
34    /// # Returns
35    /// A tuple of (complexity, blocking, delay, centrality) in export order.
36    #[must_use]
37    pub const fn as_export_tuple(&self) -> (usize, usize, usize, usize) {
38        (self.complexity, self.blocking, self.delay, self.centrality)
39    }
40}
41
42/// All metrics for a curriculum, keyed by course code
43pub type CurriculumMetrics = HashMap<String, CourseMetrics>;
44
45/// Compute all metrics for every course in the requisite graph.
46///
47/// # Errors
48///
49/// Returns an error if the graph contains a cycle.
50pub fn compute_all_metrics(dag: &DAG) -> Result<CurriculumMetrics, String> {
51    let delay = compute_delay(dag)?;
52    let blocking = compute_blocking(dag)?;
53    let complexity = compute_complexity(&delay, &blocking)?;
54    let centrality = compute_centrality(dag)?;
55
56    let mut metrics = CurriculumMetrics::new();
57
58    for course in &dag.courses {
59        let delay_val = delay.get(course).copied().unwrap_or(0);
60        let blocking_val = blocking.get(course).copied().unwrap_or(0);
61        let complexity_val = complexity.get(course).copied().unwrap_or(0);
62        let centrality_val = centrality.get(course).copied().unwrap_or(0);
63
64        metrics.insert(
65            course.clone(),
66            CourseMetrics {
67                delay: delay_val,
68                blocking: blocking_val,
69                complexity: complexity_val,
70                centrality: centrality_val,
71            },
72        );
73    }
74
75    Ok(metrics)
76}
77
78/// Compute the delay factor for every course in the requisite graph.
79///
80/// The delay factor of a course is the length (in vertices) of the longest
81/// path in the requisite DAG that contains that course. Both prerequisites
82/// and corequisites are treated as edges when forming paths.
83///
84/// # Errors
85///
86/// Returns an error if the graph contains a cycle because longest-path
87/// computation assumes a DAG.
88pub fn compute_delay(dag: &DAG) -> Result<DelayByCourse, String> {
89    let outgoing = build_outgoing_edges(dag);
90    let indegree = build_indegree_counts(dag);
91
92    let topo_order = topological_order(&dag.courses, &outgoing, &indegree)?;
93    let longest_to = longest_paths_to(&topo_order, dag);
94    let longest_from = longest_paths_from(&topo_order, &outgoing);
95
96    let delays = dag
97        .courses
98        .iter()
99        .map(|course| {
100            let to_len = longest_to.get(course).copied().unwrap_or(0);
101            let from_len = longest_from.get(course).copied().unwrap_or(0);
102            (course.clone(), to_len + from_len + 1)
103        })
104        .collect();
105
106    Ok(delays)
107}
108
109/// Compute the blocking factor for every course in the requisite graph.
110///
111/// The blocking factor of a course is the number of other courses in the
112/// curriculum that are reachable from it (i.e., courses that have this course
113/// somewhere in their prerequisite or corequisite chain). A high blocking factor
114/// indicates a gateway course that blocks access to many other courses.
115///
116/// # Errors
117///
118/// Returns an error if the graph contains a cycle (though blocking factor
119/// computation itself doesn't strictly require acyclicity, we verify it for
120/// consistency with other metrics).
121pub fn compute_blocking(dag: &DAG) -> Result<BlockingByCourse, String> {
122    let outgoing = build_outgoing_edges(dag);
123    let indegree = build_indegree_counts(dag);
124
125    // Verify DAG is acyclic
126    let _ = topological_order(&dag.courses, &outgoing, &indegree)?;
127
128    let blocking = dag
129        .courses
130        .iter()
131        .map(|course| {
132            let reachable_count = count_reachable(course, &outgoing);
133            (course.clone(), reachable_count)
134        })
135        .collect();
136
137    Ok(blocking)
138}
139
140/// Compute the structural complexity for every course.
141///
142/// Structural complexity is defined as the sum of delay factor and blocking
143/// factor. It captures both the length of prerequisite chains leading to a
144/// course (delay) and the number of courses that depend on it (blocking).
145///
146/// # Errors
147///
148/// Returns an error if the input maps have mismatched keys.
149pub fn compute_complexity(
150    delay: &DelayByCourse,
151    blocking: &BlockingByCourse,
152) -> Result<ComplexityByCourse, String> {
153    let mut complexity = HashMap::new();
154
155    for (course, delay_val) in delay {
156        let blocking_val = blocking
157            .get(course)
158            .ok_or_else(|| format!("Course '{course}' missing from blocking map"))?;
159
160        complexity.insert(course.clone(), delay_val + blocking_val);
161    }
162
163    Ok(complexity)
164}
165
166/// Compute the centrality for every course in the requisite graph.
167///
168/// Centrality of a course is the sum of the lengths of all source-to-sink paths
169/// that pass through that course. Source and sink vertices have centrality 0.
170/// This metric identifies courses that are central to many pathways through
171/// the curriculum.
172///
173/// # Performance Characteristics
174///
175/// - **Typical time complexity**: $O(P \times E)$ where P = number of unique paths, E = edges
176/// - **Worst-case complexity**: $O(2^E)$ for highly connected DAGs with many diamonds
177/// - **Note**: For curricula with highly parallel course sequences (many independent prerequisites
178///   leading to the same courses), the number of paths can grow exponentially. This can make
179///   computation slow for large, complex curricula.
180///
181/// # Errors
182///
183/// Returns an error if the graph contains a cycle.
184pub fn compute_centrality(dag: &DAG) -> Result<CentralityByCourse, String> {
185    let outgoing = build_outgoing_edges(dag);
186    let incoming = build_incoming_edges(dag);
187    let indegree = build_indegree_counts(dag);
188
189    // Verify DAG is acyclic
190    let _ = topological_order(&dag.courses, &outgoing, &indegree)?;
191
192    // Find sources (no incoming edges) and sinks (no outgoing edges)
193    let sources: Vec<String> = dag
194        .courses
195        .iter()
196        .filter(|c| incoming.get(*c).is_none_or(Vec::is_empty))
197        .cloned()
198        .collect();
199
200    let sinks: Vec<String> = dag
201        .courses
202        .iter()
203        .filter(|c| outgoing.get(*c).is_none_or(Vec::is_empty))
204        .cloned()
205        .collect();
206
207    // Initialize centrality to 0 for all courses
208    let mut centrality: HashMap<String, usize> =
209        dag.courses.iter().map(|c| (c.clone(), 0)).collect();
210
211    // For each source, enumerate all paths to all sinks
212    for source in &sources {
213        for sink in &sinks {
214            if source != sink {
215                enumerate_paths_and_update_centrality(source, sink, &outgoing, &mut centrality);
216            }
217        }
218    }
219
220    Ok(centrality)
221}
222
223/// Enumerate all paths from source to sink and update centrality counts.
224///
225/// This helper function initiates a depth-first search to find all paths between
226/// two nodes in the curriculum graph. For each path found, the centrality count
227/// is incremented for all intermediate nodes (excluding source and sink).
228///
229/// # Arguments
230/// * `source` - Starting node for the path search
231/// * `sink` - Target node to reach
232/// * `outgoing` - Map of outgoing edges from each course
233/// * `centrality` - Mutable map to accumulate centrality counts
234///
235/// # Behavior
236/// Only intermediate nodes (not source or sink) receive centrality updates.
237/// If a path contains only 2 nodes, no intermediate nodes exist and nothing is updated.
238fn enumerate_paths_and_update_centrality(
239    source: &str,
240    sink: &str,
241    outgoing: &HashMap<String, Vec<String>>,
242    centrality: &mut HashMap<String, usize>,
243) {
244    let mut path = Vec::new();
245    let mut visited = HashSet::new();
246
247    path.push(source.to_string());
248    visited.insert(source.to_string());
249
250    dfs_paths(source, sink, &mut path, &mut visited, outgoing, centrality);
251}
252
253/// DFS helper to find all paths from current node to target.
254///
255/// Uses depth-first search with backtracking to enumerate all possible paths
256/// from the current node to a target node. For each complete path discovered,
257/// the centrality counts are updated for all intermediate nodes.
258///
259/// # Arguments
260/// * `current` - The node currently being explored
261/// * `target` - The destination node to reach
262/// * `path` - The current path being built (includes nodes visited so far)
263/// * `visited` - Set of nodes already in the current path (prevents cycles)
264/// * `outgoing` - Map of outgoing edges from each course
265/// * `centrality` - Mutable map to accumulate centrality counts
266///
267/// # Algorithm
268/// Uses backtracking to explore all neighbors of the current node. When the target
269/// is reached, intermediate nodes (all except first and last) have their centrality
270/// incremented by the path length. The visited set prevents revisiting nodes within
271/// a single path (necessary for correctness even though DAG shouldn't have cycles).
272fn dfs_paths(
273    current: &str,
274    target: &str,
275    path: &mut Vec<String>,
276    visited: &mut HashSet<String>,
277    outgoing: &HashMap<String, Vec<String>>,
278    centrality: &mut HashMap<String, usize>,
279) {
280    if current == target {
281        // Found a complete path - add its length to centrality of intermediate nodes only
282        // (exclude the source at path[0] and sink at path[len-1])
283        if path.len() <= 2 {
284            // If path has only source and sink, no intermediate nodes
285            return;
286        }
287
288        let path_length = path.len();
289        // Skip first (source) and last (sink) elements
290        for course in path.iter().skip(1).take(path.len() - 2) {
291            if let Some(count) = centrality.get_mut(course) {
292                *count += path_length;
293            }
294        }
295        return;
296    }
297
298    if let Some(neighbors) = outgoing.get(current) {
299        for neighbor in neighbors {
300            if !visited.contains(neighbor) {
301                visited.insert(neighbor.clone());
302                path.push(neighbor.clone());
303
304                dfs_paths(neighbor, target, path, visited, outgoing, centrality);
305
306                path.pop();
307                visited.remove(neighbor);
308            }
309        }
310    }
311}
312
313/// Collect related courses (prerequisites and corequisites) for a given course.
314///
315/// This is a helper function used by `build_incoming_edges()`, `build_outgoing_edges()`,
316/// and `build_indegree_counts()` to centralize the logic of extracting prerequisite and
317/// corequisite relationships from different parts of the DAG structure.
318///
319/// # Arguments
320/// * `course` - The course key to get relationships for
321/// * `primary_map` - First map to check (usually dependencies or dependents)
322/// * `secondary_map` - Second map to check (usually corequisites or `coreq_dependents`)
323///
324/// # Returns
325/// A sorted vector of all related course keys
326fn collect_and_sort_related_courses(
327    course: &str,
328    primary_map: &HashMap<String, Vec<String>>,
329    secondary_map: &HashMap<String, Vec<String>>,
330) -> Vec<String> {
331    let mut neighbors: HashSet<String> = HashSet::new();
332
333    if let Some(related) = primary_map.get(course) {
334        neighbors.extend(related.iter().cloned());
335    }
336
337    if let Some(related) = secondary_map.get(course) {
338        neighbors.extend(related.iter().cloned());
339    }
340
341    let mut sorted: Vec<String> = neighbors.into_iter().collect();
342    sorted.sort();
343    sorted
344}
345
346/// Count the number of courses reachable from a given course via breadth-first search
347///
348/// # Arguments
349/// * `start` - The course key to start from
350/// * `outgoing` - Map of outgoing edges from each course
351///
352/// # Returns
353/// The count of reachable courses (excluding the start course itself)
354fn count_reachable(start: &str, outgoing: &HashMap<String, Vec<String>>) -> usize {
355    let mut visited = HashSet::new();
356    let mut queue = VecDeque::new();
357
358    queue.push_back(start.to_string());
359    visited.insert(start.to_string());
360
361    while let Some(course) = queue.pop_front() {
362        if let Some(neighbors) = outgoing.get(&course) {
363            for neighbor in neighbors {
364                if visited.insert(neighbor.clone()) {
365                    queue.push_back(neighbor.clone());
366                }
367            }
368        }
369    }
370
371    // Subtract 1 because we don't count the starting course itself
372    visited.len() - 1
373}
374
375/// Build a map of incoming edges (prerequisites and corequisites) for each course
376///
377/// # Arguments
378/// * `dag` - The directed acyclic graph of course prerequisites
379///
380/// # Returns
381/// A map from each course to its sorted list of prerequisite and corequisite courses
382fn build_incoming_edges(dag: &DAG) -> HashMap<String, Vec<String>> {
383    let mut incoming = HashMap::new();
384
385    for course in &dag.courses {
386        let related =
387            collect_and_sort_related_courses(course, &dag.dependencies, &dag.corequisites);
388        incoming.insert(course.clone(), related);
389    }
390
391    incoming
392}
393
394/// Build a map of outgoing edges (dependents) for each course
395///
396/// Creates the reverse graph where edges point from prerequisites to courses that require them.
397///
398/// # Arguments
399/// * `dag` - The directed acyclic graph of course prerequisites
400///
401/// # Returns
402/// A map from each course to its sorted list of dependent courses
403fn build_outgoing_edges(dag: &DAG) -> HashMap<String, Vec<String>> {
404    let mut outgoing = HashMap::new();
405
406    for course in &dag.courses {
407        let related =
408            collect_and_sort_related_courses(course, &dag.dependents, &dag.coreq_dependents);
409        outgoing.insert(course.clone(), related);
410    }
411
412    outgoing
413}
414
415/// Calculate the in-degree (number of incoming edges) for each course
416///
417/// The in-degree represents how many prerequisites and corequisites a course has.
418///
419/// # Arguments
420/// * `dag` - The directed acyclic graph of course prerequisites
421///
422/// # Returns
423/// A map from each course to its in-degree count
424fn build_indegree_counts(dag: &DAG) -> HashMap<String, usize> {
425    let mut indegree = HashMap::new();
426
427    for course in &dag.courses {
428        let related =
429            collect_and_sort_related_courses(course, &dag.dependencies, &dag.corequisites);
430        indegree.insert(course.clone(), related.len());
431    }
432
433    indegree
434}
435
436/// Compute a topological ordering of courses using Kahn's algorithm
437///
438/// # Arguments
439/// * `courses` - List of all course keys
440/// * `outgoing` - Map of outgoing edges from each course
441/// * `indegree` - Map of in-degree counts for each course
442///
443/// # Returns
444/// A topologically sorted list of courses
445///
446/// # Errors
447/// Returns an error if a cycle is detected in the graph
448fn topological_order(
449    courses: &[String],
450    outgoing: &HashMap<String, Vec<String>>,
451    indegree: &HashMap<String, usize>,
452) -> Result<Vec<String>, String> {
453    let mut indegree_mut = indegree.clone();
454    let mut queue: VecDeque<String> = courses
455        .iter()
456        .filter(|c| indegree_mut.get(*c).copied().unwrap_or(0) == 0)
457        .cloned()
458        .collect();
459
460    let mut order = Vec::with_capacity(courses.len());
461
462    while let Some(course) = queue.pop_front() {
463        order.push(course.clone());
464
465        if let Some(children) = outgoing.get(&course) {
466            for child in children {
467                let entry = indegree_mut
468                    .get_mut(child)
469                    .ok_or_else(|| format!("Course '{child}' missing from indegree map"))?;
470
471                if *entry > 0 {
472                    *entry -= 1;
473                }
474
475                if *entry == 0 {
476                    queue.push_back(child.clone());
477                }
478            }
479        }
480    }
481
482    if order.len() != courses.len() {
483        return Err("Cycle detected in requisite graph; cannot compute delay factors".to_string());
484    }
485
486    Ok(order)
487}
488
489/// Compute the longest path length from any root to each course
490///
491/// Uses dynamic programming over the topological order to find the longest
492/// incoming path to each course.
493///
494/// # Arguments
495/// * `topo_order` - Topologically sorted list of courses
496/// * `dag` - The directed acyclic graph of course prerequisites
497///
498/// # Returns
499/// A map from each course to its longest incoming path length
500fn longest_paths_to(topo_order: &[String], dag: &DAG) -> HashMap<String, usize> {
501    let mut longest = HashMap::new();
502
503    for course in topo_order {
504        let mut best = 0usize;
505
506        if let Some(prereqs) = dag.dependencies.get(course) {
507            for parent in prereqs {
508                let candidate = longest.get(parent).copied().unwrap_or(0) + 1;
509                if candidate > best {
510                    best = candidate;
511                }
512            }
513        }
514
515        if let Some(coreqs) = dag.corequisites.get(course) {
516            for parent in coreqs {
517                let candidate = longest.get(parent).copied().unwrap_or(0) + 1;
518                if candidate > best {
519                    best = candidate;
520                }
521            }
522        }
523
524        longest.insert(course.clone(), best);
525    }
526
527    longest
528}
529
530/// Compute the longest path length from each course to any leaf
531///
532/// Uses dynamic programming over the reverse topological order to find the
533/// longest outgoing path from each course.
534///
535/// # Arguments
536/// * `topo_order` - Topologically sorted list of courses
537/// * `outgoing` - Map of outgoing edges from each course
538///
539/// # Returns
540/// A map from each course to its longest outgoing path length
541fn longest_paths_from(
542    topo_order: &[String],
543    outgoing: &HashMap<String, Vec<String>>,
544) -> HashMap<String, usize> {
545    let mut longest = HashMap::new();
546
547    for course in topo_order.iter().rev() {
548        let mut best = 0usize;
549
550        if let Some(children) = outgoing.get(course) {
551            for child in children {
552                let candidate = longest.get(child).copied().unwrap_or(0) + 1;
553                if candidate > best {
554                    best = candidate;
555                }
556            }
557        }
558
559        longest.insert(course.clone(), best);
560    }
561
562    longest
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568    use crate::core::planner::parse_curriculum_csv;
569
570    #[test]
571    fn computes_delay_on_simple_dag() {
572        let mut dag = DAG::new();
573        dag.add_prerequisite("B".to_string(), "A");
574        dag.add_prerequisite("D".to_string(), "B");
575        dag.add_prerequisite("C".to_string(), "A");
576
577        let delays = compute_delay(&dag).expect("delay factors");
578
579        assert_eq!(delays.get("A"), Some(&3));
580        assert_eq!(delays.get("B"), Some(&3));
581        assert_eq!(delays.get("C"), Some(&2));
582        assert_eq!(delays.get("D"), Some(&3));
583    }
584
585    #[test]
586    fn counts_corequisites_as_edges() {
587        let mut dag = DAG::new();
588        dag.add_corequisite("B".to_string(), "A");
589        dag.add_prerequisite("C".to_string(), "B");
590
591        let delays = compute_delay(&dag).expect("delay factors");
592
593        assert_eq!(delays.get("A"), Some(&3));
594        assert_eq!(delays.get("B"), Some(&3));
595        assert_eq!(delays.get("C"), Some(&3));
596    }
597
598    #[test]
599    fn matches_sample_delay_values() {
600        let school = parse_curriculum_csv("samples/correct/Colostate_CSDegree_w_metrics.csv")
601            .expect("parse sample curriculum");
602        let dag = school.build_dag();
603        let delays = compute_delay(&dag).expect("delay factors");
604
605        assert_eq!(delays.get("MATH156"), Some(&3));
606        assert_eq!(delays.get("CS165"), Some(&6));
607        assert_eq!(delays.get("CO150"), Some(&2));
608        assert_eq!(delays.get("CS320"), Some(&4));
609    }
610
611    #[test]
612    fn computes_blocking_on_simple_dag() {
613        let mut dag = DAG::new();
614        dag.add_prerequisite("B".to_string(), "A");
615        dag.add_prerequisite("D".to_string(), "B");
616        dag.add_prerequisite("C".to_string(), "A");
617
618        let blocking = compute_blocking(&dag).expect("blocking factors");
619
620        // A blocks B, C, D (3 courses)
621        assert_eq!(blocking.get("A"), Some(&3));
622        // B blocks D (1 course)
623        assert_eq!(blocking.get("B"), Some(&1));
624        // C blocks nothing
625        assert_eq!(blocking.get("C"), Some(&0));
626        // D blocks nothing
627        assert_eq!(blocking.get("D"), Some(&0));
628    }
629
630    #[test]
631    fn blocking_counts_corequisites() {
632        let mut dag = DAG::new();
633        dag.add_corequisite("B".to_string(), "A");
634        dag.add_prerequisite("C".to_string(), "B");
635
636        let blocking = compute_blocking(&dag).expect("blocking factors");
637
638        // A blocks B and C (via coreq edge to B)
639        assert_eq!(blocking.get("A"), Some(&2));
640        // B blocks C
641        assert_eq!(blocking.get("B"), Some(&1));
642        // C blocks nothing
643        assert_eq!(blocking.get("C"), Some(&0));
644    }
645
646    #[test]
647    fn matches_sample_blocking_values() {
648        let school = parse_curriculum_csv("samples/correct/Colostate_CSDegree_w_metrics.csv")
649            .expect("parse sample curriculum");
650        let dag = school.build_dag();
651        let blocking = compute_blocking(&dag).expect("blocking factors");
652
653        assert_eq!(blocking.get("MATH156"), Some(&6));
654        assert_eq!(blocking.get("CS150B"), Some(&16));
655        assert_eq!(blocking.get("CS165"), Some(&11));
656        assert_eq!(blocking.get("CS220"), Some(&2));
657    }
658
659    #[test]
660    fn computes_complexity_from_delay_and_blocking() {
661        let mut dag = DAG::new();
662        dag.add_prerequisite("B".to_string(), "A");
663        dag.add_prerequisite("C".to_string(), "B");
664
665        let delay = compute_delay(&dag).expect("delay");
666        let blocking = compute_blocking(&dag).expect("blocking");
667        let complexity = compute_complexity(&delay, &blocking).expect("complexity");
668
669        // A: delay=3, blocking=2, complexity=5
670        assert_eq!(complexity.get("A"), Some(&5));
671        // B: delay=3, blocking=1, complexity=4
672        assert_eq!(complexity.get("B"), Some(&4));
673        // C: delay=3, blocking=0, complexity=3
674        assert_eq!(complexity.get("C"), Some(&3));
675    }
676
677    #[test]
678    fn matches_sample_complexity_values() {
679        let school = parse_curriculum_csv("samples/correct/Colostate_CSDegree_w_metrics.csv")
680            .expect("parse sample curriculum");
681        let dag = school.build_dag();
682
683        let delay = compute_delay(&dag).expect("delay");
684        let blocking = compute_blocking(&dag).expect("blocking");
685        let complexity = compute_complexity(&delay, &blocking).expect("complexity");
686
687        // MATH156: delay=3, blocking=6, complexity=9
688        assert_eq!(complexity.get("MATH156"), Some(&9));
689        // CS150B: delay=6, blocking=16, complexity=22
690        assert_eq!(complexity.get("CS150B"), Some(&22));
691        // CS165: delay=6, blocking=11, complexity=17
692        assert_eq!(complexity.get("CS165"), Some(&17));
693        // CS220: delay=3, blocking=2, complexity=5
694        assert_eq!(complexity.get("CS220"), Some(&5));
695    }
696
697    #[test]
698    fn computes_centrality_simple_chain() {
699        let mut dag = DAG::new();
700        dag.add_prerequisite("B".to_string(), "A");
701        dag.add_prerequisite("C".to_string(), "B");
702
703        let centrality = compute_centrality(&dag).expect("centrality");
704
705        // One path A->B->C of length 3
706        // A: source, centrality=0
707        assert_eq!(centrality.get("A"), Some(&0));
708        // B: intermediate node in path A->B->C (length 3)
709        assert_eq!(centrality.get("B"), Some(&3));
710        // C: sink, centrality=0
711        assert_eq!(centrality.get("C"), Some(&0));
712    }
713
714    #[test]
715    fn computes_centrality_with_fork() {
716        let mut dag = DAG::new();
717        dag.add_prerequisite("B".to_string(), "A");
718        dag.add_prerequisite("C".to_string(), "A");
719        dag.add_prerequisite("D".to_string(), "B");
720
721        let centrality = compute_centrality(&dag).expect("centrality");
722
723        // Paths: A->B->D (length 3), A->C (length 2)
724        // A: source, centrality=0
725        assert_eq!(centrality.get("A"), Some(&0));
726        // B: intermediate in path A->B->D (length 3)
727        assert_eq!(centrality.get("B"), Some(&3));
728        // C: sink (in path A->C), centrality=0
729        assert_eq!(centrality.get("C"), Some(&0));
730        // D: sink, centrality=0
731        assert_eq!(centrality.get("D"), Some(&0));
732    }
733
734    #[test]
735    fn matches_sample_centrality_values() {
736        let school = parse_curriculum_csv("samples/correct/Colostate_CSDegree_w_metrics.csv")
737            .expect("parse sample curriculum");
738        let dag = school.build_dag();
739        let centrality = compute_centrality(&dag).expect("centrality");
740
741        // Sources and sinks should have centrality 0
742        assert_eq!(centrality.get("MATH156"), Some(&0));
743        assert_eq!(centrality.get("CS150B"), Some(&0));
744        assert_eq!(centrality.get("CO150"), Some(&0));
745
746        // Intermediate courses should have non-zero centrality
747        assert_eq!(centrality.get("CS164"), Some(&44));
748        assert_eq!(centrality.get("CS220"), Some(&12));
749    }
750
751    #[test]
752    fn compute_all_metrics_combines_all_metrics() {
753        let mut dag = DAG::new();
754        dag.add_prerequisite("B".to_string(), "A");
755        dag.add_prerequisite("C".to_string(), "B");
756
757        let all_metrics = compute_all_metrics(&dag).expect("all metrics");
758
759        // Check A
760        let a_metrics = all_metrics.get("A").expect("A metrics");
761        assert_eq!(a_metrics.delay, 3);
762        assert_eq!(a_metrics.blocking, 2);
763        assert_eq!(a_metrics.complexity, 5);
764        assert_eq!(a_metrics.centrality, 0);
765
766        // Check B
767        let b_metrics = all_metrics.get("B").expect("B metrics");
768        assert_eq!(b_metrics.delay, 3);
769        assert_eq!(b_metrics.blocking, 1);
770        assert_eq!(b_metrics.complexity, 4);
771        assert_eq!(b_metrics.centrality, 3);
772
773        // Check C
774        let c_metrics = all_metrics.get("C").expect("C metrics");
775        assert_eq!(c_metrics.delay, 3);
776        assert_eq!(c_metrics.blocking, 0);
777        assert_eq!(c_metrics.complexity, 3);
778        assert_eq!(c_metrics.centrality, 0);
779    }
780
781    #[test]
782    fn test_delay_empty_dag() {
783        let dag = DAG::new();
784        let delay = compute_delay(&dag).expect("empty dag");
785        assert!(delay.is_empty(), "Empty DAG should produce no delays");
786    }
787
788    #[test]
789    fn test_blocking_empty_dag() {
790        let dag = DAG::new();
791        let blocking = compute_blocking(&dag).expect("empty dag");
792        assert!(
793            blocking.is_empty(),
794            "Empty DAG should produce no blocking factors"
795        );
796    }
797
798    #[test]
799    fn test_delay_single_course() {
800        let mut dag = DAG::new();
801        dag.add_course("A".to_string());
802
803        let delay = compute_delay(&dag).expect("single course");
804        assert_eq!(
805            delay.get("A"),
806            Some(&1),
807            "Single course with no prerequisites should have delay of 1"
808        );
809    }
810
811    #[test]
812    fn test_blocking_single_course() {
813        let mut dag = DAG::new();
814        dag.add_course("A".to_string());
815
816        let blocking = compute_blocking(&dag).expect("single course");
817        assert_eq!(
818            blocking.get("A"),
819            Some(&0),
820            "Single course with no dependents should have blocking of 0"
821        );
822    }
823
824    #[test]
825    fn test_corequisites_cycle_detection() {
826        let mut dag = DAG::new();
827        dag.add_corequisite("A".to_string(), "B");
828        dag.add_corequisite("B".to_string(), "A");
829
830        // This creates a cycle through corequisites, which should be detected
831        let delay_result = compute_delay(&dag);
832        assert!(
833            delay_result.is_err(),
834            "Should detect cycle through corequisites"
835        );
836        assert!(
837            delay_result.unwrap_err().contains("Cycle"),
838            "Error message should mention cycle detection"
839        );
840    }
841
842    #[test]
843    fn test_course_metrics_export_tuple() {
844        let metrics = CourseMetrics {
845            delay: 5,
846            blocking: 3,
847            complexity: 8,
848            centrality: 10,
849        };
850
851        let (complexity, blocking, delay, centrality) = metrics.as_export_tuple();
852        assert_eq!(complexity, 8);
853        assert_eq!(blocking, 3);
854        assert_eq!(delay, 5);
855        assert_eq!(centrality, 10);
856    }
857}