1use crate::core::models::DAG;
4use std::collections::{HashMap, HashSet, VecDeque};
5
6pub type DelayByCourse = HashMap<String, usize>;
8
9pub type BlockingByCourse = HashMap<String, usize>;
11
12pub type ComplexityByCourse = HashMap<String, usize>;
14
15pub type CentralityByCourse = HashMap<String, usize>;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CourseMetrics {
21 pub delay: usize,
23 pub blocking: usize,
25 pub complexity: usize,
27 pub centrality: usize,
29}
30
31impl CourseMetrics {
32 #[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
42pub type CurriculumMetrics = HashMap<String, CourseMetrics>;
44
45pub 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
78pub 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
109pub fn compute_blocking(dag: &DAG) -> Result<BlockingByCourse, String> {
122 let outgoing = build_outgoing_edges(dag);
123 let indegree = build_indegree_counts(dag);
124
125 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
140pub 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
166pub 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 let _ = topological_order(&dag.courses, &outgoing, &indegree)?;
191
192 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 let mut centrality: HashMap<String, usize> =
209 dag.courses.iter().map(|c| (c.clone(), 0)).collect();
210
211 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
223fn 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
253fn 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 if path.len() <= 2 {
284 return;
286 }
287
288 let path_length = path.len();
289 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
313fn 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
346fn 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 visited.len() - 1
373}
374
375fn 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
394fn 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
415fn 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
436fn 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
489fn 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
530fn 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 assert_eq!(blocking.get("A"), Some(&3));
622 assert_eq!(blocking.get("B"), Some(&1));
624 assert_eq!(blocking.get("C"), Some(&0));
626 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 assert_eq!(blocking.get("A"), Some(&2));
640 assert_eq!(blocking.get("B"), Some(&1));
642 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 assert_eq!(complexity.get("A"), Some(&5));
671 assert_eq!(complexity.get("B"), Some(&4));
673 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 assert_eq!(complexity.get("MATH156"), Some(&9));
689 assert_eq!(complexity.get("CS150B"), Some(&22));
691 assert_eq!(complexity.get("CS165"), Some(&17));
693 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 assert_eq!(centrality.get("A"), Some(&0));
708 assert_eq!(centrality.get("B"), Some(&3));
710 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 assert_eq!(centrality.get("A"), Some(&0));
726 assert_eq!(centrality.get("B"), Some(&3));
728 assert_eq!(centrality.get("C"), Some(&0));
730 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 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 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 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 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 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 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}