Skip to main content

ucm_reason/
explanation.rs

1//! Explanation engine — traceable reasoning chains for every conclusion.
2//!
3//! Every output from the reasoning engine includes an explanation_chain:
4//! a sequence of ReasoningStep structs that trace from raw evidence
5//! through inferences to final conclusions. This is what makes
6//! the system auditable and debuggable.
7//!
8//! Example chain for "processPayment is indirectly impacted":
9//! Step 1: evidence="git diff shows validateToken() signature changed"
10//!         inference="Return type changed from boolean to Result<Claims, AuthError>"
11//!         confidence=1.0
12//! Step 2: evidence="Static analysis: 3 call sites found via reverse BFS"
13//!         inference="All callers must handle new Result type"
14//!         confidence=0.95
15//! Step 3: evidence="API logs: /checkout called 1.2M times/day via authMiddleware"
16//!         inference="Payment flow is high-traffic indirect dependency"
17//!         confidence=0.72
18
19use serde::{Deserialize, Serialize};
20
21/// A single step in a reasoning chain — the atomic unit of explanation.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ReasoningStep {
24    /// Step number in the chain
25    pub step: usize,
26    /// What data / observation this step is based on
27    pub evidence: String,
28    /// What conclusion was drawn from the evidence
29    pub inference: String,
30    /// How confident we are in this inference [0.0, 1.0]
31    pub confidence: f64,
32}
33
34/// A complete explanation chain — tells the full story of a conclusion.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct ExplanationChain {
37    /// Human-readable summary of the conclusion
38    pub summary: String,
39    /// The ordered sequence of reasoning steps
40    pub steps: Vec<ReasoningStep>,
41    /// Overall confidence (product of step confidences, or custom)
42    pub overall_confidence: f64,
43}
44
45impl ExplanationChain {
46    pub fn new(summary: impl Into<String>) -> Self {
47        Self {
48            summary: summary.into(),
49            steps: Vec::new(),
50            overall_confidence: 1.0,
51        }
52    }
53
54    /// Add a reasoning step to the chain.
55    pub fn add_step(
56        &mut self,
57        evidence: impl Into<String>,
58        inference: impl Into<String>,
59        confidence: f64,
60    ) -> &mut Self {
61        let step_num = self.steps.len() + 1;
62        self.steps.push(ReasoningStep {
63            step: step_num,
64            evidence: evidence.into(),
65            inference: inference.into(),
66            confidence,
67        });
68        // Update overall confidence as product
69        self.overall_confidence = self.steps.iter().map(|s| s.confidence).product();
70        self
71    }
72
73    /// Convert to a human-readable narrative.
74    pub fn to_narrative(&self) -> String {
75        let mut narrative = format!("**{}**\n\n", self.summary);
76        for step in &self.steps {
77            narrative.push_str(&format!(
78                "Step {}: Based on {} → concluded {} (confidence: {:.0}%)\n",
79                step.step,
80                step.evidence,
81                step.inference,
82                step.confidence * 100.0
83            ));
84        }
85        narrative.push_str(&format!(
86            "\nOverall confidence: {:.0}%",
87            self.overall_confidence * 100.0
88        ));
89        narrative
90    }
91}
92
93/// Build an explanation for why an entity IS impacted.
94pub fn explain_impact(entity_name: &str, path: &[String], confidence: f64) -> ExplanationChain {
95    let mut chain = ExplanationChain::new(format!("{entity_name} is impacted by this change"));
96
97    if path.len() <= 1 {
98        chain.add_step(
99            "Direct reference to changed entity found in code",
100            format!("{entity_name} directly references the changed code"),
101            confidence,
102        );
103    } else {
104        chain.add_step(
105            format!(
106                "Graph traversal found dependency path: {}",
107                path.join(" → ")
108            ),
109            format!(
110                "{entity_name} is transitively dependent via {} hops",
111                path.len() - 1
112            ),
113            confidence,
114        );
115
116        if confidence < 0.85 {
117            chain.add_step(
118                format!("Transitive confidence decays over {} hops", path.len() - 1),
119                "Each intermediate dependency reduces certainty",
120                confidence,
121            );
122        }
123    }
124
125    chain
126}
127
128/// Build an explanation for why an entity is NOT impacted.
129pub fn explain_not_impacted(entity_name: &str, reason: &str, confidence: f64) -> ExplanationChain {
130    let mut chain = ExplanationChain::new(format!("{entity_name} is NOT impacted by this change"));
131
132    chain.add_step(
133        format!("Analyzed graph connectivity for {entity_name}"),
134        reason.to_string(),
135        confidence,
136    );
137
138    chain
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn test_explanation_chain() {
147        let mut chain = ExplanationChain::new("authMiddleware is directly impacted");
148        chain.add_step(
149            "git diff shows validateToken() signature changed",
150            "Return type changed from boolean to Result<Claims, AuthError>",
151            1.0,
152        );
153        chain.add_step(
154            "Static analysis: authMiddleware imports validateToken",
155            "authMiddleware must handle new Result type",
156            0.95,
157        );
158
159        assert_eq!(chain.steps.len(), 2);
160        assert!((chain.overall_confidence - 0.95).abs() < 0.01);
161
162        let narrative = chain.to_narrative();
163        assert!(narrative.contains("Step 1"));
164        assert!(narrative.contains("Step 2"));
165    }
166
167    #[test]
168    fn test_explain_impact() {
169        let chain = explain_impact(
170            "processPayment",
171            &[
172                "validateToken".into(),
173                "authMiddleware".into(),
174                "processPayment".into(),
175            ],
176            0.72,
177        );
178        assert!(!chain.steps.is_empty());
179        assert!(chain.summary.contains("processPayment"));
180    }
181
182    #[test]
183    fn test_explain_not_impacted() {
184        let chain = explain_not_impacted(
185            "generateReport",
186            "No graph path exists; uses separate admin auth flow",
187            0.88,
188        );
189        assert!(chain.summary.contains("NOT impacted"));
190    }
191}