Skip to main content

arbor_graph/
confidence.rs

1//! Confidence scoring for impact analysis
2//!
3//! Provides explainable risk levels (Low/Medium/High) based on graph structure.
4
5use crate::ImpactAnalysis;
6
7/// Confidence level for an analysis result
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum ConfidenceLevel {
10    /// High confidence - well-connected node with clear edges
11    High,
12    /// Medium confidence - some uncertainty exists
13    Medium,
14    /// Low confidence - significant unknowns
15    Low,
16}
17
18impl std::fmt::Display for ConfidenceLevel {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            ConfidenceLevel::High => write!(f, "High"),
22            ConfidenceLevel::Medium => write!(f, "Medium"),
23            ConfidenceLevel::Low => write!(f, "Low"),
24        }
25    }
26}
27
28/// Reasons explaining the confidence level
29#[derive(Debug, Clone)]
30pub struct ConfidenceExplanation {
31    pub level: ConfidenceLevel,
32    pub reasons: Vec<String>,
33    pub suggestions: Vec<String>,
34}
35
36impl ConfidenceExplanation {
37    /// Compute confidence from an impact analysis
38    pub fn from_analysis(analysis: &ImpactAnalysis) -> Self {
39        let mut reasons = Vec::new();
40        let mut suggestions = Vec::new();
41
42        let upstream_count = analysis.upstream.len();
43        let downstream_count = analysis.downstream.len();
44        let total = analysis.total_affected;
45
46        // Determine base confidence from connectivity
47        let level = if upstream_count == 0 && downstream_count == 0 {
48            // Isolated node
49            reasons.push("Node appears isolated (no detected connections)".to_string());
50            suggestions
51                .push("Verify if this is called dynamically or from external code".to_string());
52            ConfidenceLevel::Low
53        } else if upstream_count == 0 {
54            // Entry point
55            reasons.push("Node is an entry point (no internal callers)".to_string());
56            reasons.push(format!("Has {} downstream dependencies", downstream_count));
57            if downstream_count > 5 {
58                suggestions.push("Consider impact on downstream dependencies".to_string());
59                ConfidenceLevel::Medium
60            } else {
61                ConfidenceLevel::High
62            }
63        } else if downstream_count == 0 {
64            // Leaf/utility node
65            reasons.push("Node is a utility (no outgoing dependencies)".to_string());
66            reasons.push(format!("Called by {} upstream nodes", upstream_count));
67            ConfidenceLevel::High
68        } else {
69            // Connected node
70            reasons.push(format!(
71                "{} callers, {} dependencies",
72                upstream_count, downstream_count
73            ));
74
75            if total > 50 {
76                reasons.push("Very large blast radius".to_string());
77                suggestions
78                    .push("This change affects a significant portion of the codebase".to_string());
79                ConfidenceLevel::Low
80            } else if total > 20 {
81                reasons.push("Large blast radius detected".to_string());
82                suggestions
83                    .push("Consider breaking this change into smaller refactors".to_string());
84                ConfidenceLevel::Medium
85            } else {
86                reasons.push("Well-connected with manageable impact".to_string());
87                ConfidenceLevel::High
88            }
89        };
90
91        // Add structural insights
92        if total > 0 {
93            let direct_count = analysis
94                .upstream
95                .iter()
96                .filter(|n| n.hop_distance == 1)
97                .count();
98            if direct_count > 0 {
99                reasons.push(format!("{} nodes will break immediately", direct_count));
100            }
101        }
102
103        // Standard disclaimer
104        suggestions.push("Tests still recommended for behavioral verification".to_string());
105
106        Self {
107            level,
108            reasons,
109            suggestions,
110        }
111    }
112}
113
114/// Node role classification
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116pub enum NodeRole {
117    /// Entry point - receives control from outside
118    EntryPoint,
119    /// Utility - helper function called by others
120    Utility,
121    /// Core logic - central to the domain
122    CoreLogic,
123    /// Isolated - no detected connections
124    Isolated,
125    /// Adapter - boundary between layers
126    Adapter,
127}
128
129impl std::fmt::Display for NodeRole {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        match self {
132            NodeRole::EntryPoint => write!(f, "Entry Point"),
133            NodeRole::Utility => write!(f, "Utility"),
134            NodeRole::CoreLogic => write!(f, "Core Logic"),
135            NodeRole::Isolated => write!(f, "Isolated"),
136            NodeRole::Adapter => write!(f, "Adapter"),
137        }
138    }
139}
140
141impl NodeRole {
142    /// Determine role from impact analysis
143    pub fn from_analysis(analysis: &ImpactAnalysis) -> Self {
144        let has_upstream = !analysis.upstream.is_empty();
145        let has_downstream = !analysis.downstream.is_empty();
146
147        match (has_upstream, has_downstream) {
148            (false, false) => NodeRole::Isolated,
149            (false, true) => NodeRole::EntryPoint,
150            (true, false) => NodeRole::Utility,
151            (true, true) => {
152                // Distinguish between adapter and core logic
153                let upstream_count = analysis.upstream.len();
154                let downstream_count = analysis.downstream.len();
155
156                // Adapters typically have few callers but many dependencies (or vice versa)
157                if (upstream_count <= 2 && downstream_count > 5)
158                    || (downstream_count <= 2 && upstream_count > 5)
159                {
160                    NodeRole::Adapter
161                } else {
162                    NodeRole::CoreLogic
163                }
164            }
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::{AffectedNode, EdgeKind, ImpactDirection, ImpactSeverity, NodeInfo};
173
174    fn node_info(id: &str) -> NodeInfo {
175        NodeInfo {
176            id: id.to_string(),
177            name: id.to_string(),
178            qualified_name: id.to_string(),
179            kind: "function".to_string(),
180            file: "test.rs".to_string(),
181            line_start: 1,
182            line_end: 1,
183            signature: None,
184            centrality: 0.0,
185        }
186    }
187
188    fn affected(id: &str, hop_distance: usize, direction: ImpactDirection) -> AffectedNode {
189        AffectedNode {
190            node_id: crate::NodeId::new(hop_distance),
191            node_info: node_info(id),
192            severity: ImpactSeverity::from_hops(hop_distance),
193            hop_distance,
194            entry_edge: EdgeKind::Calls,
195            direction,
196        }
197    }
198
199    fn analysis(upstream: usize, downstream: usize, total_affected: usize) -> ImpactAnalysis {
200        let upstream_nodes = (0..upstream)
201            .map(|i| {
202                let hop = if i % 2 == 0 { 1 } else { 2 };
203                affected(&format!("u{i}"), hop, ImpactDirection::Upstream)
204            })
205            .collect();
206
207        let downstream_nodes = (0..downstream)
208            .map(|i| {
209                let hop = if i % 2 == 0 { 1 } else { 2 };
210                affected(&format!("d{i}"), hop, ImpactDirection::Downstream)
211            })
212            .collect();
213
214        ImpactAnalysis {
215            target: node_info("target"),
216            upstream: upstream_nodes,
217            downstream: downstream_nodes,
218            total_affected,
219            max_depth: 3,
220            query_time_ms: 1,
221        }
222    }
223
224    #[test]
225    fn test_confidence_level_display() {
226        assert_eq!(ConfidenceLevel::High.to_string(), "High");
227        assert_eq!(ConfidenceLevel::Medium.to_string(), "Medium");
228        assert_eq!(ConfidenceLevel::Low.to_string(), "Low");
229    }
230
231    #[test]
232    fn test_node_role_display() {
233        assert_eq!(NodeRole::EntryPoint.to_string(), "Entry Point");
234        assert_eq!(NodeRole::Utility.to_string(), "Utility");
235        assert_eq!(NodeRole::CoreLogic.to_string(), "Core Logic");
236        assert_eq!(NodeRole::Isolated.to_string(), "Isolated");
237        assert_eq!(NodeRole::Adapter.to_string(), "Adapter");
238    }
239
240    #[test]
241    fn test_confidence_connected_thresholds_regression() {
242        let medium_case = analysis(10, 20, 30);
243        let low_case = analysis(20, 40, 60);
244
245        let medium = ConfidenceExplanation::from_analysis(&medium_case);
246        let low = ConfidenceExplanation::from_analysis(&low_case);
247
248        assert_eq!(medium.level, ConfidenceLevel::Medium);
249        assert_eq!(low.level, ConfidenceLevel::Low);
250        assert!(medium
251            .reasons
252            .iter()
253            .any(|r| r.contains("Large blast radius")));
254        assert!(low
255            .reasons
256            .iter()
257            .any(|r| r.contains("Very large blast radius")));
258    }
259
260    #[test]
261    fn test_confidence_entry_point_matrix_120_cases() {
262        let mut cases = 0;
263        for downstream in 1..=120 {
264            let a = analysis(0, downstream, downstream);
265            let explanation = ConfidenceExplanation::from_analysis(&a);
266            let expected = if downstream > 5 {
267                ConfidenceLevel::Medium
268            } else {
269                ConfidenceLevel::High
270            };
271            assert_eq!(
272                explanation.level, expected,
273                "entry-point mismatch for downstream={downstream}"
274            );
275            cases += 1;
276        }
277        assert_eq!(cases, 120);
278    }
279
280    #[test]
281    fn test_confidence_utility_matrix_120_cases() {
282        let mut cases = 0;
283        for upstream in 1..=120 {
284            let a = analysis(upstream, 0, upstream);
285            let explanation = ConfidenceExplanation::from_analysis(&a);
286            assert_eq!(
287                explanation.level,
288                ConfidenceLevel::High,
289                "utility mismatch for upstream={upstream}"
290            );
291            cases += 1;
292        }
293        assert_eq!(cases, 120);
294    }
295
296    #[test]
297    fn test_confidence_connected_matrix_121_cases() {
298        let mut cases = 0;
299        for upstream in 1..=11 {
300            for downstream in 1..=11 {
301                // Ensure we exercise all connected-tier branches deterministically:
302                // <=20 (High), 21..=50 (Medium), >50 (Low)
303                let total = match (upstream + downstream) % 3 {
304                    0 => 15,
305                    1 => 35,
306                    _ => 70,
307                };
308
309                let expected = if total > 50 {
310                    ConfidenceLevel::Low
311                } else if total > 20 {
312                    ConfidenceLevel::Medium
313                } else {
314                    ConfidenceLevel::High
315                };
316
317                let a = analysis(upstream, downstream, total);
318                let explanation = ConfidenceExplanation::from_analysis(&a);
319                assert_eq!(
320                    explanation.level, expected,
321                    "connected mismatch for upstream={upstream}, downstream={downstream}, total={total}"
322                );
323                cases += 1;
324            }
325        }
326        assert_eq!(cases, 121);
327    }
328
329    #[test]
330    fn test_node_role_matrix_121_cases() {
331        let mut cases = 0;
332        for upstream in 0..=10 {
333            for downstream in 0..=10 {
334                let a = analysis(upstream, downstream, upstream + downstream);
335                let role = NodeRole::from_analysis(&a);
336                let expected = match (upstream > 0, downstream > 0) {
337                    (false, false) => NodeRole::Isolated,
338                    (false, true) => NodeRole::EntryPoint,
339                    (true, false) => NodeRole::Utility,
340                    (true, true) => {
341                        if (upstream <= 2 && downstream > 5) || (downstream <= 2 && upstream > 5) {
342                            NodeRole::Adapter
343                        } else {
344                            NodeRole::CoreLogic
345                        }
346                    }
347                };
348
349                assert_eq!(
350                    role, expected,
351                    "role mismatch for upstream={upstream}, downstream={downstream}"
352                );
353                cases += 1;
354            }
355        }
356        assert_eq!(cases, 121);
357    }
358
359    #[test]
360    fn test_confidence_standard_suggestion_always_present() {
361        for (upstream, downstream, total) in [
362            (0, 0, 0),
363            (0, 8, 8),
364            (12, 0, 12),
365            (4, 4, 15),
366            (4, 20, 30),
367            (20, 20, 70),
368        ] {
369            let a = analysis(upstream, downstream, total);
370            let explanation = ConfidenceExplanation::from_analysis(&a);
371            assert!(
372                explanation
373                    .suggestions
374                    .iter()
375                    .any(|s| s.contains("Tests still recommended")),
376                "missing standard suggestion for upstream={upstream}, downstream={downstream}, total={total}"
377            );
378        }
379    }
380}