Skip to main content

depyler_graph/
impact.rs

1//! Impact Scoring Module
2//!
3//! Implements a PageRank-style algorithm to identify "Patient Zero" -
4//! the nodes that cause the most downstream errors.
5
6use crate::builder::DependencyGraph;
7use crate::error_overlay::OverlaidError;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Impact score for a node
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ImpactScore {
14    /// Node ID
15    pub node_id: String,
16    /// Direct error count at this node
17    pub direct_errors: usize,
18    /// Downstream errors caused by this node
19    pub downstream_errors: usize,
20    /// PageRank-style score (higher = more impact)
21    pub pagerank_score: f64,
22    /// Combined impact score
23    pub total_impact: f64,
24}
25
26/// Patient Zero - a node identified as root cause
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PatientZero {
29    /// Node ID
30    pub node_id: String,
31    /// Impact score
32    pub impact_score: f64,
33    /// Number of direct errors
34    pub direct_errors: usize,
35    /// Number of downstream nodes affected
36    pub downstream_affected: usize,
37    /// Recommended fix priority (1 = highest)
38    pub fix_priority: usize,
39    /// Estimated errors fixed if this node is fixed
40    pub estimated_fix_impact: usize,
41}
42
43/// Calculates impact scores for nodes in the graph
44pub struct ImpactScorer<'a> {
45    graph: &'a DependencyGraph,
46    errors: &'a [OverlaidError],
47    /// Damping factor for PageRank (typically 0.85)
48    damping: f64,
49    /// Number of iterations for PageRank convergence
50    iterations: usize,
51}
52
53impl<'a> ImpactScorer<'a> {
54    /// Create a new impact scorer
55    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    /// Set damping factor
65    pub fn with_damping(mut self, damping: f64) -> Self {
66        self.damping = damping.clamp(0.0, 1.0);
67        self
68    }
69
70    /// Set number of iterations
71    pub fn with_iterations(mut self, iterations: usize) -> Self {
72        self.iterations = iterations.max(1);
73        self
74    }
75
76    /// Calculate impact scores for all nodes
77    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        // Step 1: Count direct errors per node
84        let direct_errors = self.count_direct_errors();
85
86        // Step 2: Calculate PageRank scores
87        let pagerank_scores = self.calculate_pagerank(&node_ids);
88
89        // Step 3: Calculate downstream errors
90        let downstream_errors = self.calculate_downstream_errors(&node_ids, &direct_errors);
91
92        // Step 4: Combine into impact scores
93        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                // Total impact = direct errors + weighted downstream + pagerank bonus
101                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    /// Count direct errors at each node
117    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    /// Calculate PageRank scores
128    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        // Initialize scores uniformly
135        let mut scores: HashMap<String, f64> = node_ids
136            .iter()
137            .map(|id| (id.clone(), 1.0 / n as f64))
138            .collect();
139
140        // Calculate out-degrees
141        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        // Iterate to convergence
148        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                // Sum contributions from incoming edges
155                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    /// Calculate downstream errors (errors in nodes that depend on this one)
172    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            // Find all nodes that call this node
181            let incoming = self.graph.incoming_edges(id);
182            let mut total_downstream = 0;
183
184            for (caller, _) in incoming {
185                // Count errors in the caller
186                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    /// Identify Patient Zero nodes (highest impact)
196    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        // Errors in all callers
295        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        // root_cause should have highest downstream impact
308        // Note: exact ranking depends on error-to-node mapping
309        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        // c should have highest PageRank (most called)
333        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        // Damping > 1.0 should be clamped to 1.0
352        let scorer = ImpactScorer::new(&graph, &errors).with_damping(2.0);
353        assert_eq!(scorer.damping, 1.0);
354
355        // Damping < 0.0 should be clamped to 0.0
356        let scorer = ImpactScorer::new(&graph, &errors).with_damping(-0.5);
357        assert_eq!(scorer.damping, 0.0);
358
359        // Normal damping preserved
360        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        // 0 iterations should become 1
370        let scorer = ImpactScorer::new(&graph, &errors).with_iterations(0);
371        assert_eq!(scorer.iterations, 1);
372
373        // Normal iterations preserved
374        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        // Fix priorities should be sequential starting from 1
419        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        // All patient zeros should have positive impact
445        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        // Errors at different lines to associate with different nodes
467        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        // Request only top 2
479        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    // ========================================================================
556    // S9B7: Coverage tests for impact scoring
557    // ========================================================================
558
559    #[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        // Error without node_id should not count
596        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        // With no errors, total_impact should be based on pagerank only
620        // With damping=0.85 and uniform init, total_impact = 10*pr*max(direct,1)
621        // The filter is > 0.0, so nodes with positive pagerank will be included
622        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        // callee has caller as incoming, and caller has 1 direct error
659        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        // All PageRank scores should be non-negative
681        for score in &scores {
682            assert!(
683                score.pagerank_score >= 0.0,
684                "Negative pagerank for {}",
685                score.node_id
686            );
687        }
688        // Chain: a -> b -> c. c is most depended-upon, should have highest pagerank
689        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    // ========================================================================
695    // S12: Deep coverage tests for impact scoring
696    // ========================================================================
697
698    #[test]
699    fn test_s12_pagerank_with_cycle() {
700        // a -> b -> a (cyclic dependency)
701        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        // Both nodes should have valid (non-NaN, non-negative) scores
716        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        // With damping=0, all nodes should get equal scores = 1/n
725        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        // With damping=0, pagerank = (1-0)/n = 1/n for all nodes
741        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        // Sink node has no outgoing edges; should converge correctly
778        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        // Recursive function: a calls itself
798        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        // Both callers have errors
830        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        // target has both callers as incoming, each with 1 error -> downstream = 2
855        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        // Request 100 patient zeros but only 1 node with positive impact
896        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        // total = direct + 0.5 * downstream + 10 * pagerank * max(direct, 1)
903        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        // total = 2 + 0.5*0 + 10 * pagerank * max(2, 1) = 2 + 20*pagerank
931        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            // estimated_fix_impact = direct_errors + downstream_errors
970            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}