1use crate::builder::DependencyGraph;
7use crate::error_overlay::OverlaidError;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ImpactScore {
14 pub node_id: String,
16 pub direct_errors: usize,
18 pub downstream_errors: usize,
20 pub pagerank_score: f64,
22 pub total_impact: f64,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PatientZero {
29 pub node_id: String,
31 pub impact_score: f64,
33 pub direct_errors: usize,
35 pub downstream_affected: usize,
37 pub fix_priority: usize,
39 pub estimated_fix_impact: usize,
41}
42
43pub struct ImpactScorer<'a> {
45 graph: &'a DependencyGraph,
46 errors: &'a [OverlaidError],
47 damping: f64,
49 iterations: usize,
51}
52
53impl<'a> ImpactScorer<'a> {
54 pub fn new(graph: &'a DependencyGraph, errors: &'a [OverlaidError]) -> Self {
56 Self {
57 graph,
58 errors,
59 damping: 0.85,
60 iterations: 20,
61 }
62 }
63
64 pub fn with_damping(mut self, damping: f64) -> Self {
66 self.damping = damping.clamp(0.0, 1.0);
67 self
68 }
69
70 pub fn with_iterations(mut self, iterations: usize) -> Self {
72 self.iterations = iterations.max(1);
73 self
74 }
75
76 pub fn calculate_impact(&self) -> Vec<ImpactScore> {
78 let node_ids = self.graph.node_ids();
79 if node_ids.is_empty() {
80 return vec![];
81 }
82
83 let direct_errors = self.count_direct_errors();
85
86 let pagerank_scores = self.calculate_pagerank(&node_ids);
88
89 let downstream_errors = self.calculate_downstream_errors(&node_ids, &direct_errors);
91
92 node_ids
94 .iter()
95 .map(|id| {
96 let direct = *direct_errors.get(id).unwrap_or(&0);
97 let downstream = *downstream_errors.get(id).unwrap_or(&0);
98 let pagerank = *pagerank_scores.get(id).unwrap_or(&0.0);
99
100 let total = direct as f64
102 + 0.5 * downstream as f64
103 + 10.0 * pagerank * direct.max(1) as f64;
104
105 ImpactScore {
106 node_id: id.clone(),
107 direct_errors: direct,
108 downstream_errors: downstream,
109 pagerank_score: pagerank,
110 total_impact: total,
111 }
112 })
113 .collect()
114 }
115
116 fn count_direct_errors(&self) -> HashMap<String, usize> {
118 let mut counts = HashMap::new();
119 for error in self.errors {
120 if let Some(ref node_id) = error.node_id {
121 *counts.entry(node_id.clone()).or_insert(0) += 1;
122 }
123 }
124 counts
125 }
126
127 fn calculate_pagerank(&self, node_ids: &[String]) -> HashMap<String, f64> {
129 let n = node_ids.len();
130 if n == 0 {
131 return HashMap::new();
132 }
133
134 let mut scores: HashMap<String, f64> = node_ids
136 .iter()
137 .map(|id| (id.clone(), 1.0 / n as f64))
138 .collect();
139
140 let mut out_degrees: HashMap<String, usize> = HashMap::new();
142 for id in node_ids {
143 let outgoing = self.graph.outgoing_edges(id);
144 out_degrees.insert(id.clone(), outgoing.len());
145 }
146
147 for _ in 0..self.iterations {
149 let mut new_scores: HashMap<String, f64> = HashMap::new();
150
151 for id in node_ids {
152 let mut rank = (1.0 - self.damping) / n as f64;
153
154 let incoming = self.graph.incoming_edges(id);
156 for (source, _) in incoming {
157 let source_out = *out_degrees.get(&source.id).unwrap_or(&1);
158 let source_score = *scores.get(&source.id).unwrap_or(&0.0);
159 rank += self.damping * source_score / source_out.max(1) as f64;
160 }
161
162 new_scores.insert(id.clone(), rank);
163 }
164
165 scores = new_scores;
166 }
167
168 scores
169 }
170
171 fn calculate_downstream_errors(
173 &self,
174 node_ids: &[String],
175 direct_errors: &HashMap<String, usize>,
176 ) -> HashMap<String, usize> {
177 let mut downstream: HashMap<String, usize> = HashMap::new();
178
179 for id in node_ids {
180 let incoming = self.graph.incoming_edges(id);
182 let mut total_downstream = 0;
183
184 for (caller, _) in incoming {
185 total_downstream += direct_errors.get(&caller.id).unwrap_or(&0);
187 }
188
189 downstream.insert(id.clone(), total_downstream);
190 }
191
192 downstream
193 }
194
195 pub fn identify_patient_zeros(&self, scores: &[ImpactScore], top_n: usize) -> Vec<PatientZero> {
197 let mut sorted_scores: Vec<_> = scores.iter().collect();
198 sorted_scores.sort_by(|a, b| {
199 b.total_impact
200 .partial_cmp(&a.total_impact)
201 .unwrap_or(std::cmp::Ordering::Equal)
202 });
203
204 sorted_scores
205 .into_iter()
206 .take(top_n)
207 .enumerate()
208 .filter(|(_, score)| score.total_impact > 0.0)
209 .map(|(idx, score)| {
210 let downstream_affected = self.graph.incoming_edges(&score.node_id).len();
211
212 PatientZero {
213 node_id: score.node_id.clone(),
214 impact_score: score.total_impact,
215 direct_errors: score.direct_errors,
216 downstream_affected,
217 fix_priority: idx + 1,
218 estimated_fix_impact: score.direct_errors + score.downstream_errors,
219 }
220 })
221 .collect()
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use crate::builder::GraphBuilder;
229 use crate::error_overlay::ErrorOverlay;
230
231 #[test]
232 fn test_impact_scorer_empty() {
233 let python = r#"
234def foo():
235 return 42
236"#;
237
238 let mut builder = GraphBuilder::new();
239 let graph = builder.build_from_source(python).unwrap();
240
241 let errors: Vec<OverlaidError> = vec![];
242 let scorer = ImpactScorer::new(&graph, &errors);
243 let scores = scorer.calculate_impact();
244
245 assert_eq!(scores.len(), 1);
246 assert_eq!(scores[0].direct_errors, 0);
247 }
248
249 #[test]
250 fn test_impact_scorer_with_errors() {
251 let python = r#"
252def problematic():
253 return "bug"
254
255def caller():
256 return problematic()
257"#;
258
259 let mut builder = GraphBuilder::new();
260 let graph = builder.build_from_source(python).unwrap();
261
262 let overlay = ErrorOverlay::new(&graph);
263 let raw_errors = vec![
264 ("E0308".to_string(), "type mismatch".to_string(), 20),
265 ("E0308".to_string(), "type mismatch".to_string(), 50),
266 ];
267 let overlaid = overlay.overlay_errors(&raw_errors);
268
269 let scorer = ImpactScorer::new(&graph, &overlaid);
270 let scores = scorer.calculate_impact();
271
272 assert!(!scores.is_empty());
273 }
274
275 #[test]
276 fn test_patient_zero_ranking() {
277 let python = r#"
278def root_cause():
279 return "bug"
280
281def a():
282 return root_cause()
283
284def b():
285 return root_cause()
286
287def c():
288 return root_cause()
289"#;
290
291 let mut builder = GraphBuilder::new();
292 let graph = builder.build_from_source(python).unwrap();
293
294 let overlay = ErrorOverlay::new(&graph);
296 let raw_errors = vec![
297 ("E0308".to_string(), "error in a".to_string(), 50),
298 ("E0308".to_string(), "error in b".to_string(), 80),
299 ("E0308".to_string(), "error in c".to_string(), 110),
300 ];
301 let overlaid = overlay.overlay_errors(&raw_errors);
302
303 let scorer = ImpactScorer::new(&graph, &overlaid);
304 let scores = scorer.calculate_impact();
305 let patient_zeros = scorer.identify_patient_zeros(&scores, 3);
306
307 assert!(!patient_zeros.is_empty());
310 }
311
312 #[test]
313 fn test_pagerank_convergence() {
314 let python = r#"
315def a():
316 return b()
317
318def b():
319 return c()
320
321def c():
322 return 1
323"#;
324
325 let mut builder = GraphBuilder::new();
326 let graph = builder.build_from_source(python).unwrap();
327
328 let errors: Vec<OverlaidError> = vec![];
329 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(50);
330 let scores = scorer.calculate_impact();
331
332 let c_score = scores.iter().find(|s| s.node_id == "c");
334 assert!(c_score.is_some());
335 }
336
337 #[test]
338 fn test_impact_scorer_empty_graph() {
339 let graph = DependencyGraph::new();
340 let errors: Vec<OverlaidError> = vec![];
341 let scorer = ImpactScorer::new(&graph, &errors);
342 let scores = scorer.calculate_impact();
343 assert!(scores.is_empty());
344 }
345
346 #[test]
347 fn test_with_damping_clamped() {
348 let graph = DependencyGraph::new();
349 let errors: Vec<OverlaidError> = vec![];
350
351 let scorer = ImpactScorer::new(&graph, &errors).with_damping(2.0);
353 assert_eq!(scorer.damping, 1.0);
354
355 let scorer = ImpactScorer::new(&graph, &errors).with_damping(-0.5);
357 assert_eq!(scorer.damping, 0.0);
358
359 let scorer = ImpactScorer::new(&graph, &errors).with_damping(0.5);
361 assert!((scorer.damping - 0.5).abs() < f64::EPSILON);
362 }
363
364 #[test]
365 fn test_with_iterations_minimum_1() {
366 let graph = DependencyGraph::new();
367 let errors: Vec<OverlaidError> = vec![];
368
369 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(0);
371 assert_eq!(scorer.iterations, 1);
372
373 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(100);
375 assert_eq!(scorer.iterations, 100);
376 }
377
378 #[test]
379 fn test_identify_patient_zeros_empty_scores() {
380 let graph = DependencyGraph::new();
381 let errors: Vec<OverlaidError> = vec![];
382 let scorer = ImpactScorer::new(&graph, &errors);
383
384 let patient_zeros = scorer.identify_patient_zeros(&[], 5);
385 assert!(patient_zeros.is_empty());
386 }
387
388 #[test]
389 fn test_patient_zero_fix_priority_ordering() {
390 let python = r#"
391def bug_a():
392 return "x"
393
394def bug_b():
395 return bug_a()
396
397def caller1():
398 return bug_a()
399
400def caller2():
401 return bug_a()
402"#;
403 let mut builder = GraphBuilder::new();
404 let graph = builder.build_from_source(python).unwrap();
405
406 let overlay = ErrorOverlay::new(&graph);
407 let raw_errors = vec![
408 ("E0308".to_string(), "error".to_string(), 10),
409 ("E0308".to_string(), "error".to_string(), 50),
410 ("E0308".to_string(), "error".to_string(), 80),
411 ];
412 let overlaid = overlay.overlay_errors(&raw_errors);
413
414 let scorer = ImpactScorer::new(&graph, &overlaid);
415 let scores = scorer.calculate_impact();
416 let pzs = scorer.identify_patient_zeros(&scores, 5);
417
418 for (i, pz) in pzs.iter().enumerate() {
420 assert_eq!(pz.fix_priority, i + 1);
421 }
422 }
423
424 #[test]
425 fn test_patient_zero_impact_score_positive() {
426 let python = r#"
427def root():
428 return "bug"
429
430def user():
431 return root()
432"#;
433 let mut builder = GraphBuilder::new();
434 let graph = builder.build_from_source(python).unwrap();
435
436 let overlay = ErrorOverlay::new(&graph);
437 let raw_errors = vec![("E0308".to_string(), "error".to_string(), 10)];
438 let overlaid = overlay.overlay_errors(&raw_errors);
439
440 let scorer = ImpactScorer::new(&graph, &overlaid);
441 let scores = scorer.calculate_impact();
442 let pzs = scorer.identify_patient_zeros(&scores, 5);
443
444 for pz in &pzs {
446 assert!(pz.impact_score > 0.0);
447 }
448 }
449
450 #[test]
451 fn test_patient_zero_top_n_limit() {
452 let python = r#"
453def a():
454 return 1
455def b():
456 return 2
457def c():
458 return 3
459def d():
460 return 4
461"#;
462 let mut builder = GraphBuilder::new();
463 let graph = builder.build_from_source(python).unwrap();
464
465 let overlay = ErrorOverlay::new(&graph);
466 let raw_errors = vec![
468 ("E0308".to_string(), "e1".to_string(), 10),
469 ("E0308".to_string(), "e2".to_string(), 30),
470 ("E0308".to_string(), "e3".to_string(), 50),
471 ("E0308".to_string(), "e4".to_string(), 70),
472 ];
473 let overlaid = overlay.overlay_errors(&raw_errors);
474
475 let scorer = ImpactScorer::new(&graph, &overlaid);
476 let scores = scorer.calculate_impact();
477
478 let pzs = scorer.identify_patient_zeros(&scores, 2);
480 assert!(pzs.len() <= 2);
481 }
482
483 #[test]
484 fn test_impact_score_serde_roundtrip() {
485 let score = ImpactScore {
486 node_id: "foo".to_string(),
487 direct_errors: 3,
488 downstream_errors: 5,
489 pagerank_score: 0.42,
490 total_impact: 7.2,
491 };
492
493 let json = serde_json::to_string(&score).unwrap();
494 let deserialized: ImpactScore = serde_json::from_str(&json).unwrap();
495
496 assert_eq!(deserialized.node_id, "foo");
497 assert_eq!(deserialized.direct_errors, 3);
498 assert_eq!(deserialized.downstream_errors, 5);
499 assert!((deserialized.pagerank_score - 0.42).abs() < f64::EPSILON);
500 }
501
502 #[test]
503 fn test_patient_zero_serde_roundtrip() {
504 let pz = PatientZero {
505 node_id: "root_cause".to_string(),
506 impact_score: 15.5,
507 direct_errors: 2,
508 downstream_affected: 4,
509 fix_priority: 1,
510 estimated_fix_impact: 6,
511 };
512
513 let json = serde_json::to_string(&pz).unwrap();
514 let deserialized: PatientZero = serde_json::from_str(&json).unwrap();
515
516 assert_eq!(deserialized.node_id, "root_cause");
517 assert_eq!(deserialized.fix_priority, 1);
518 assert_eq!(deserialized.estimated_fix_impact, 6);
519 }
520
521 #[test]
522 fn test_direct_errors_counted_correctly() {
523 let python = "def target():\n pass\n";
524 let mut builder = GraphBuilder::new();
525 let graph = builder.build_from_source(python).unwrap();
526
527 let overlaid = vec![
528 OverlaidError {
529 code: "E0308".to_string(),
530 message: "a".to_string(),
531 rust_line: 1,
532 python_line_estimate: 2,
533 node_id: Some("target".to_string()),
534 association_confidence: 0.9,
535 upstream_suspects: vec![],
536 },
537 OverlaidError {
538 code: "E0308".to_string(),
539 message: "b".to_string(),
540 rust_line: 2,
541 python_line_estimate: 2,
542 node_id: Some("target".to_string()),
543 association_confidence: 0.9,
544 upstream_suspects: vec![],
545 },
546 ];
547
548 let scorer = ImpactScorer::new(&graph, &overlaid);
549 let scores = scorer.calculate_impact();
550
551 let target_score = scores.iter().find(|s| s.node_id == "target").unwrap();
552 assert_eq!(target_score.direct_errors, 2);
553 }
554
555 #[test]
560 fn test_s9b7_impact_score_debug_clone() {
561 let score = ImpactScore {
562 node_id: "n".to_string(),
563 direct_errors: 1,
564 downstream_errors: 2,
565 pagerank_score: 0.5,
566 total_impact: 3.0,
567 };
568 let debug = format!("{:?}", score);
569 assert!(debug.contains("ImpactScore"));
570 let cloned = score.clone();
571 assert_eq!(cloned.node_id, "n");
572 }
573
574 #[test]
575 fn test_s9b7_patient_zero_debug_clone() {
576 let pz = PatientZero {
577 node_id: "root".to_string(),
578 impact_score: 10.0,
579 direct_errors: 3,
580 downstream_affected: 2,
581 fix_priority: 1,
582 estimated_fix_impact: 5,
583 };
584 let debug = format!("{:?}", pz);
585 assert!(debug.contains("PatientZero"));
586 let cloned = pz.clone();
587 assert_eq!(cloned.fix_priority, 1);
588 }
589
590 #[test]
591 fn test_s9b7_count_direct_errors_no_node_id() {
592 let python = "def foo():\n pass\n";
593 let mut builder = GraphBuilder::new();
594 let graph = builder.build_from_source(python).unwrap();
595 let overlaid = vec![OverlaidError {
597 code: "E0308".to_string(),
598 message: "a".to_string(),
599 rust_line: 1,
600 python_line_estimate: 1,
601 node_id: None,
602 association_confidence: 0.0,
603 upstream_suspects: vec![],
604 }];
605 let scorer = ImpactScorer::new(&graph, &overlaid);
606 let scores = scorer.calculate_impact();
607 let target_score = scores.iter().find(|s| s.node_id == "foo").unwrap();
608 assert_eq!(target_score.direct_errors, 0);
609 }
610
611 #[test]
612 fn test_s9b7_identify_patient_zeros_filters_zero_impact() {
613 let python = "def foo():\n pass\n\ndef bar():\n pass\n";
614 let mut builder = GraphBuilder::new();
615 let graph = builder.build_from_source(python).unwrap();
616 let errors: Vec<OverlaidError> = vec![];
617 let scorer = ImpactScorer::new(&graph, &errors);
618 let scores = scorer.calculate_impact();
619 let pzs = scorer.identify_patient_zeros(&scores, 10);
623 for pz in &pzs {
624 assert!(pz.impact_score > 0.0);
625 }
626 }
627
628 #[test]
629 fn test_s9b7_with_damping_normal_value() {
630 let graph = DependencyGraph::new();
631 let errors: Vec<OverlaidError> = vec![];
632 let scorer = ImpactScorer::new(&graph, &errors).with_damping(0.9);
633 assert!((scorer.damping - 0.9).abs() < f64::EPSILON);
634 }
635
636 #[test]
637 fn test_s9b7_downstream_errors_with_caller_errors() {
638 let python = r#"
639def callee():
640 return 1
641
642def caller():
643 return callee()
644"#;
645 let mut builder = GraphBuilder::new();
646 let graph = builder.build_from_source(python).unwrap();
647 let overlaid = vec![OverlaidError {
648 code: "E0308".to_string(),
649 message: "err".to_string(),
650 rust_line: 1,
651 python_line_estimate: 1,
652 node_id: Some("caller".to_string()),
653 association_confidence: 0.9,
654 upstream_suspects: vec![],
655 }];
656 let scorer = ImpactScorer::new(&graph, &overlaid);
657 let scores = scorer.calculate_impact();
658 let callee_score = scores.iter().find(|s| s.node_id == "callee").unwrap();
660 assert_eq!(callee_score.downstream_errors, 1);
661 }
662
663 #[test]
664 fn test_pagerank_scores_all_nonnegative() {
665 let python = r#"
666def a():
667 return b()
668def b():
669 return c()
670def c():
671 return 1
672"#;
673 let mut builder = GraphBuilder::new();
674 let graph = builder.build_from_source(python).unwrap();
675
676 let errors: Vec<OverlaidError> = vec![];
677 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(100);
678 let scores = scorer.calculate_impact();
679
680 for score in &scores {
682 assert!(
683 score.pagerank_score >= 0.0,
684 "Negative pagerank for {}",
685 score.node_id
686 );
687 }
688 let a_pr = scores.iter().find(|s| s.node_id == "a").unwrap().pagerank_score;
690 let c_pr = scores.iter().find(|s| s.node_id == "c").unwrap().pagerank_score;
691 assert!(c_pr >= a_pr, "c should have higher pagerank than a");
692 }
693
694 #[test]
699 fn test_s12_pagerank_with_cycle() {
700 let python = r#"
702def a():
703 return b()
704
705def b():
706 return a()
707"#;
708 let mut builder = GraphBuilder::new();
709 let graph = builder.build_from_source(python).unwrap();
710
711 let errors: Vec<OverlaidError> = vec![];
712 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(50);
713 let scores = scorer.calculate_impact();
714
715 for score in &scores {
717 assert!(!score.pagerank_score.is_nan(), "NaN pagerank for {}", score.node_id);
718 assert!(score.pagerank_score >= 0.0, "Negative pagerank for {}", score.node_id);
719 }
720 }
721
722 #[test]
723 fn test_s12_pagerank_zero_damping() {
724 let python = r#"
726def a():
727 return b()
728def b():
729 return 1
730"#;
731 let mut builder = GraphBuilder::new();
732 let graph = builder.build_from_source(python).unwrap();
733
734 let errors: Vec<OverlaidError> = vec![];
735 let scorer = ImpactScorer::new(&graph, &errors)
736 .with_damping(0.0)
737 .with_iterations(20);
738 let scores = scorer.calculate_impact();
739
740 let n = scores.len() as f64;
742 for score in &scores {
743 let expected = 1.0 / n;
744 assert!(
745 (score.pagerank_score - expected).abs() < 0.01,
746 "Node {} expected pagerank ~{}, got {}",
747 score.node_id, expected, score.pagerank_score
748 );
749 }
750 }
751
752 #[test]
753 fn test_s12_pagerank_max_damping() {
754 let python = r#"
755def source():
756 return sink()
757def sink():
758 return 1
759"#;
760 let mut builder = GraphBuilder::new();
761 let graph = builder.build_from_source(python).unwrap();
762
763 let errors: Vec<OverlaidError> = vec![];
764 let scorer = ImpactScorer::new(&graph, &errors)
765 .with_damping(1.0)
766 .with_iterations(50);
767 let scores = scorer.calculate_impact();
768
769 for score in &scores {
770 assert!(!score.pagerank_score.is_nan());
771 assert!(score.pagerank_score >= 0.0);
772 }
773 }
774
775 #[test]
776 fn test_s12_sink_node_pagerank() {
777 let python = r#"
779def caller():
780 return sink()
781def sink():
782 return 42
783"#;
784 let mut builder = GraphBuilder::new();
785 let graph = builder.build_from_source(python).unwrap();
786
787 let errors: Vec<OverlaidError> = vec![];
788 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(100);
789 let scores = scorer.calculate_impact();
790
791 let sink_score = scores.iter().find(|s| s.node_id == "sink").unwrap();
792 assert!(sink_score.pagerank_score > 0.0, "Sink node should have positive pagerank");
793 }
794
795 #[test]
796 fn test_s12_self_referencing_node() {
797 let python = r#"
799def recursive():
800 return recursive()
801"#;
802 let mut builder = GraphBuilder::new();
803 let graph = builder.build_from_source(python).unwrap();
804
805 let errors: Vec<OverlaidError> = vec![];
806 let scorer = ImpactScorer::new(&graph, &errors).with_iterations(20);
807 let scores = scorer.calculate_impact();
808
809 assert_eq!(scores.len(), 1);
810 assert!(!scores[0].pagerank_score.is_nan());
811 assert!(scores[0].pagerank_score > 0.0);
812 }
813
814 #[test]
815 fn test_s12_downstream_errors_multiple_callers() {
816 let python = r#"
817def target():
818 return 1
819
820def caller_a():
821 return target()
822
823def caller_b():
824 return target()
825"#;
826 let mut builder = GraphBuilder::new();
827 let graph = builder.build_from_source(python).unwrap();
828
829 let overlaid = vec![
831 OverlaidError {
832 code: "E0308".to_string(),
833 message: "a".to_string(),
834 rust_line: 1,
835 python_line_estimate: 4,
836 node_id: Some("caller_a".to_string()),
837 association_confidence: 0.9,
838 upstream_suspects: vec![],
839 },
840 OverlaidError {
841 code: "E0308".to_string(),
842 message: "b".to_string(),
843 rust_line: 2,
844 python_line_estimate: 7,
845 node_id: Some("caller_b".to_string()),
846 association_confidence: 0.9,
847 upstream_suspects: vec![],
848 },
849 ];
850
851 let scorer = ImpactScorer::new(&graph, &overlaid);
852 let scores = scorer.calculate_impact();
853
854 let target_score = scores.iter().find(|s| s.node_id == "target").unwrap();
856 assert_eq!(target_score.downstream_errors, 2);
857 }
858
859 #[test]
860 fn test_s12_patient_zero_top_n_zero() {
861 let python = "def a():\n return 1\n";
862 let mut builder = GraphBuilder::new();
863 let graph = builder.build_from_source(python).unwrap();
864 let overlaid = vec![OverlaidError {
865 code: "E0308".to_string(),
866 message: "e".to_string(),
867 rust_line: 1,
868 python_line_estimate: 1,
869 node_id: Some("a".to_string()),
870 association_confidence: 0.9,
871 upstream_suspects: vec![],
872 }];
873 let scorer = ImpactScorer::new(&graph, &overlaid);
874 let scores = scorer.calculate_impact();
875 let pzs = scorer.identify_patient_zeros(&scores, 0);
876 assert!(pzs.is_empty());
877 }
878
879 #[test]
880 fn test_s12_patient_zero_top_n_exceeds_nodes() {
881 let python = "def single():\n return 1\n";
882 let mut builder = GraphBuilder::new();
883 let graph = builder.build_from_source(python).unwrap();
884 let overlaid = vec![OverlaidError {
885 code: "E0308".to_string(),
886 message: "e".to_string(),
887 rust_line: 1,
888 python_line_estimate: 1,
889 node_id: Some("single".to_string()),
890 association_confidence: 0.9,
891 upstream_suspects: vec![],
892 }];
893 let scorer = ImpactScorer::new(&graph, &overlaid);
894 let scores = scorer.calculate_impact();
895 let pzs = scorer.identify_patient_zeros(&scores, 100);
897 assert!(pzs.len() <= scores.len());
898 }
899
900 #[test]
901 fn test_s12_impact_total_formula() {
902 let python = "def only():\n return 1\n";
904 let mut builder = GraphBuilder::new();
905 let graph = builder.build_from_source(python).unwrap();
906 let overlaid = vec![
907 OverlaidError {
908 code: "E0308".to_string(),
909 message: "e1".to_string(),
910 rust_line: 1,
911 python_line_estimate: 1,
912 node_id: Some("only".to_string()),
913 association_confidence: 0.9,
914 upstream_suspects: vec![],
915 },
916 OverlaidError {
917 code: "E0308".to_string(),
918 message: "e2".to_string(),
919 rust_line: 2,
920 python_line_estimate: 1,
921 node_id: Some("only".to_string()),
922 association_confidence: 0.9,
923 upstream_suspects: vec![],
924 },
925 ];
926 let scorer = ImpactScorer::new(&graph, &overlaid);
927 let scores = scorer.calculate_impact();
928 let only = scores.iter().find(|s| s.node_id == "only").unwrap();
929 assert_eq!(only.direct_errors, 2);
930 let expected = 2.0 + 10.0 * only.pagerank_score * 2.0;
932 assert!((only.total_impact - expected).abs() < 0.001);
933 }
934
935 #[test]
936 fn test_s12_patient_zero_estimated_fix_impact() {
937 let python = r#"
938def root():
939 return 1
940def user():
941 return root()
942"#;
943 let mut builder = GraphBuilder::new();
944 let graph = builder.build_from_source(python).unwrap();
945 let overlaid = vec![
946 OverlaidError {
947 code: "E0308".to_string(),
948 message: "e1".to_string(),
949 rust_line: 1,
950 python_line_estimate: 2,
951 node_id: Some("root".to_string()),
952 association_confidence: 0.9,
953 upstream_suspects: vec![],
954 },
955 OverlaidError {
956 code: "E0308".to_string(),
957 message: "e2".to_string(),
958 rust_line: 2,
959 python_line_estimate: 4,
960 node_id: Some("user".to_string()),
961 association_confidence: 0.9,
962 upstream_suspects: vec![],
963 },
964 ];
965 let scorer = ImpactScorer::new(&graph, &overlaid);
966 let scores = scorer.calculate_impact();
967 let pzs = scorer.identify_patient_zeros(&scores, 5);
968 for pz in &pzs {
969 let score = scores.iter().find(|s| s.node_id == pz.node_id).unwrap();
971 assert_eq!(pz.estimated_fix_impact, score.direct_errors + score.downstream_errors);
972 }
973 }
974}