Skip to main content

rustant_core/
explanation.rs

1//! # Decision / Tool Selection Explanations
2//!
3//! This module provides structured explanations for every decision the agent
4//! makes -- tool selections, parameter choices, task decompositions, and error
5//! recovery strategies.  Each [`DecisionExplanation`] captures the full
6//! reasoning chain, considered alternatives, contextual factors, and a
7//! confidence score so that users (and auditors) can understand *why* the
8//! agent acted the way it did.
9//!
10//! Use [`ExplanationBuilder`] for ergonomic, fluent construction of
11//! explanations.
12
13use crate::types::RiskLevel;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18// ---------------------------------------------------------------------------
19// Core data model
20// ---------------------------------------------------------------------------
21
22/// A complete explanation for a single agent decision.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DecisionExplanation {
25    /// Unique identifier for this decision.
26    pub decision_id: Uuid,
27    /// When the decision was made.
28    pub timestamp: DateTime<Utc>,
29    /// The kind of decision that was made.
30    pub decision_type: DecisionType,
31    /// Ordered chain of reasoning steps that led to the decision.
32    pub reasoning_chain: Vec<ReasoningStep>,
33    /// Alternatives that were evaluated but ultimately not chosen.
34    pub considered_alternatives: Vec<AlternativeAction>,
35    /// Agent confidence in this decision, clamped to `[0.0, 1.0]`.
36    pub confidence: f32,
37    /// Contextual factors that influenced the decision.
38    pub context_factors: Vec<ContextFactor>,
39}
40
41/// The category of decision the agent made.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub enum DecisionType {
44    /// The agent selected a specific tool to invoke.
45    ToolSelection {
46        /// Name of the tool that was selected.
47        selected_tool: String,
48    },
49    /// The agent chose a particular value for a tool parameter.
50    ParameterChoice {
51        /// The tool whose parameter is being set.
52        tool: String,
53        /// The parameter name.
54        parameter: String,
55    },
56    /// The agent decomposed a high-level task into sub-tasks.
57    TaskDecomposition {
58        /// Descriptions of the resulting sub-tasks.
59        sub_tasks: Vec<String>,
60    },
61    /// The agent is recovering from an error.
62    ErrorRecovery {
63        /// Description of the error that occurred.
64        error: String,
65        /// The recovery strategy chosen.
66        strategy: String,
67    },
68}
69
70/// A single step in the agent's reasoning chain.
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ReasoningStep {
73    /// 1-based index of this step within the chain.
74    pub step_number: usize,
75    /// Human-readable description of the reasoning.
76    pub description: String,
77    /// Optional supporting evidence (e.g. a memory excerpt or metric).
78    pub evidence: Option<String>,
79}
80
81/// An alternative action that the agent considered but did not select.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AlternativeAction {
84    /// The tool that was considered.
85    pub tool_name: String,
86    /// Why this alternative was not chosen.
87    pub reason_not_selected: String,
88    /// Estimated risk level of this alternative.
89    pub estimated_risk: RiskLevel,
90}
91
92/// A contextual factor that influenced the decision.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ContextFactor {
95    /// Description of the factor (e.g. "user expressed urgency").
96    pub factor: String,
97    /// Whether this factor nudged the decision positively, negatively, or had
98    /// no net effect.
99    pub influence: FactorInfluence,
100}
101
102/// The direction of influence a context factor has on a decision.
103#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum FactorInfluence {
105    /// The factor pushed the decision in a favourable direction.
106    Positive,
107    /// The factor pushed the decision in an unfavourable direction.
108    Negative,
109    /// The factor was noted but had no net directional effect.
110    Neutral,
111}
112
113// ---------------------------------------------------------------------------
114// Builder
115// ---------------------------------------------------------------------------
116
117/// Fluent builder for constructing [`DecisionExplanation`] instances.
118pub struct ExplanationBuilder {
119    decision_type: DecisionType,
120    reasoning_chain: Vec<ReasoningStep>,
121    considered_alternatives: Vec<AlternativeAction>,
122    confidence: f32,
123    context_factors: Vec<ContextFactor>,
124}
125
126impl ExplanationBuilder {
127    /// Create a new builder for the given [`DecisionType`].
128    ///
129    /// The default confidence is `0.5`.
130    pub fn new(decision_type: DecisionType) -> Self {
131        Self {
132            decision_type,
133            reasoning_chain: Vec::new(),
134            considered_alternatives: Vec::new(),
135            confidence: 0.5,
136            context_factors: Vec::new(),
137        }
138    }
139
140    /// Append a reasoning step.
141    ///
142    /// Steps are automatically numbered in the order they are added (starting
143    /// from 1).
144    pub fn add_reasoning_step(
145        &mut self,
146        description: impl Into<String>,
147        evidence: Option<&str>,
148    ) -> &mut Self {
149        let step_number = self.reasoning_chain.len() + 1;
150        self.reasoning_chain.push(ReasoningStep {
151            step_number,
152            description: description.into(),
153            evidence: evidence.map(String::from),
154        });
155        self
156    }
157
158    /// Record an alternative that was considered but not selected.
159    pub fn add_alternative(&mut self, tool: &str, reason: &str, risk: RiskLevel) -> &mut Self {
160        self.considered_alternatives.push(AlternativeAction {
161            tool_name: tool.to_owned(),
162            reason_not_selected: reason.to_owned(),
163            estimated_risk: risk,
164        });
165        self
166    }
167
168    /// Record a contextual factor that influenced the decision.
169    pub fn add_context_factor(&mut self, factor: &str, influence: FactorInfluence) -> &mut Self {
170        self.context_factors.push(ContextFactor {
171            factor: factor.to_owned(),
172            influence,
173        });
174        self
175    }
176
177    /// Set the confidence score.  Values outside `[0.0, 1.0]` are clamped.
178    pub fn set_confidence(&mut self, confidence: f32) -> &mut Self {
179        self.confidence = confidence.clamp(0.0, 1.0);
180        self
181    }
182
183    /// Consume the builder and produce the final [`DecisionExplanation`].
184    pub fn build(self) -> DecisionExplanation {
185        DecisionExplanation {
186            decision_id: Uuid::new_v4(),
187            timestamp: Utc::now(),
188            decision_type: self.decision_type,
189            reasoning_chain: self.reasoning_chain,
190            considered_alternatives: self.considered_alternatives,
191            confidence: self.confidence,
192            context_factors: self.context_factors,
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Tests
199// ---------------------------------------------------------------------------
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    // -- Builder basics -----------------------------------------------------
206
207    #[test]
208    fn test_builder_basic() {
209        let explanation = ExplanationBuilder::new(DecisionType::ToolSelection {
210            selected_tool: "read_file".into(),
211        })
212        .build();
213
214        assert!(!explanation.decision_id.is_nil());
215        assert!(explanation.reasoning_chain.is_empty());
216        assert!(explanation.considered_alternatives.is_empty());
217        assert!(explanation.context_factors.is_empty());
218    }
219
220    #[test]
221    fn test_builder_with_reasoning_steps() {
222        let mut builder = ExplanationBuilder::new(DecisionType::ToolSelection {
223            selected_tool: "write_file".into(),
224        });
225        builder.add_reasoning_step("Step one", None);
226        builder.add_reasoning_step("Step two", Some("evidence"));
227        let explanation = builder.build();
228
229        assert_eq!(explanation.reasoning_chain.len(), 2);
230        assert_eq!(explanation.reasoning_chain[0].step_number, 1);
231        assert_eq!(explanation.reasoning_chain[1].step_number, 2);
232        assert_eq!(explanation.reasoning_chain[0].description, "Step one");
233        assert_eq!(
234            explanation.reasoning_chain[1].evidence.as_deref(),
235            Some("evidence")
236        );
237    }
238
239    #[test]
240    fn test_builder_with_alternatives() {
241        let mut builder = ExplanationBuilder::new(DecisionType::ToolSelection {
242            selected_tool: "read_file".into(),
243        });
244        builder.add_alternative("shell_exec", "Too risky", RiskLevel::Execute);
245        builder.add_alternative("network_fetch", "Not needed", RiskLevel::Network);
246        let explanation = builder.build();
247
248        assert_eq!(explanation.considered_alternatives.len(), 2);
249        assert_eq!(
250            explanation.considered_alternatives[0].tool_name,
251            "shell_exec"
252        );
253        assert_eq!(
254            explanation.considered_alternatives[0].estimated_risk,
255            RiskLevel::Execute
256        );
257        assert_eq!(
258            explanation.considered_alternatives[1].reason_not_selected,
259            "Not needed"
260        );
261    }
262
263    #[test]
264    fn test_builder_with_context_factors() {
265        let mut builder = ExplanationBuilder::new(DecisionType::ToolSelection {
266            selected_tool: "read_file".into(),
267        });
268        builder.add_context_factor("User is admin", FactorInfluence::Positive);
269        builder.add_context_factor("Network is slow", FactorInfluence::Negative);
270        builder.add_context_factor("Disk usage normal", FactorInfluence::Neutral);
271        let explanation = builder.build();
272
273        assert_eq!(explanation.context_factors.len(), 3);
274        assert_eq!(
275            explanation.context_factors[0].influence,
276            FactorInfluence::Positive
277        );
278        assert_eq!(
279            explanation.context_factors[1].influence,
280            FactorInfluence::Negative
281        );
282        assert_eq!(
283            explanation.context_factors[2].influence,
284            FactorInfluence::Neutral
285        );
286    }
287
288    #[test]
289    fn test_builder_default_confidence() {
290        let explanation = ExplanationBuilder::new(DecisionType::ToolSelection {
291            selected_tool: "noop".into(),
292        })
293        .build();
294
295        assert!((explanation.confidence - 0.5).abs() < f32::EPSILON);
296    }
297
298    // -- Serialization ------------------------------------------------------
299
300    #[test]
301    fn test_serialization_roundtrip() {
302        let mut builder = ExplanationBuilder::new(DecisionType::ToolSelection {
303            selected_tool: "read_file".into(),
304        });
305        builder.add_reasoning_step("Check permissions", Some("policy"));
306        builder.add_alternative("write_file", "Not applicable", RiskLevel::Write);
307        builder.add_context_factor("Sandbox active", FactorInfluence::Positive);
308        builder.set_confidence(0.85);
309        let original = builder.build();
310
311        let json = serde_json::to_string(&original).expect("serialize");
312        let restored: DecisionExplanation = serde_json::from_str(&json).expect("deserialize");
313
314        assert_eq!(original.decision_id, restored.decision_id);
315        assert!((original.confidence - restored.confidence).abs() < f32::EPSILON);
316        assert_eq!(
317            original.reasoning_chain.len(),
318            restored.reasoning_chain.len()
319        );
320        assert_eq!(
321            original.considered_alternatives.len(),
322            restored.considered_alternatives.len()
323        );
324    }
325
326    #[test]
327    fn test_deserialization_missing_fields() {
328        let bad_json = r#"{
329            "decision_id": "00000000-0000-0000-0000-000000000000",
330            "timestamp": "2025-01-01T00:00:00Z",
331            "decision_type": { "ToolSelection": { "selected_tool": "x" } },
332            "reasoning_chain": [],
333            "considered_alternatives": [],
334            "confidence": 0.5
335        }"#;
336
337        let result = serde_json::from_str::<DecisionExplanation>(bad_json);
338        assert!(result.is_err(), "Missing field should cause an error");
339    }
340
341    // -- DecisionType variants -----------------------------------------------
342
343    #[test]
344    fn test_decision_type_variants() {
345        let tool = DecisionType::ToolSelection {
346            selected_tool: "grep".into(),
347        };
348        let param = DecisionType::ParameterChoice {
349            tool: "grep".into(),
350            parameter: "pattern".into(),
351        };
352        let decomp = DecisionType::TaskDecomposition {
353            sub_tasks: vec!["a".into(), "b".into()],
354        };
355        let recovery = DecisionType::ErrorRecovery {
356            error: "timeout".into(),
357            strategy: "retry".into(),
358        };
359
360        let jsons: Vec<String> = [&tool, &param, &decomp, &recovery]
361            .iter()
362            .map(|v| serde_json::to_string(v).unwrap())
363            .collect();
364
365        let unique: std::collections::HashSet<&String> = jsons.iter().collect();
366        assert_eq!(unique.len(), 4);
367    }
368
369    // -- Scenario-oriented tests --------------------------------------------
370
371    #[test]
372    fn test_explanation_builder_tool_selection() {
373        let mut builder = ExplanationBuilder::new(DecisionType::ToolSelection {
374            selected_tool: "read_file".into(),
375        });
376        builder.add_reasoning_step("User wants to view a config", None);
377        builder.add_reasoning_step("read_file has ReadOnly risk", Some("risk matrix"));
378        builder.add_alternative("shell_exec", "Unnecessary privileges", RiskLevel::Execute);
379        builder.set_confidence(0.92);
380        let explanation = builder.build();
381
382        match &explanation.decision_type {
383            DecisionType::ToolSelection { selected_tool } => {
384                assert_eq!(selected_tool, "read_file");
385            }
386            other => panic!("Expected ToolSelection, got {:?}", other),
387        }
388        assert_eq!(explanation.reasoning_chain.len(), 2);
389        assert_eq!(explanation.considered_alternatives.len(), 1);
390        assert!((explanation.confidence - 0.92).abs() < f32::EPSILON);
391    }
392
393    #[test]
394    fn test_explanation_builder_error_recovery() {
395        let mut builder = ExplanationBuilder::new(DecisionType::ErrorRecovery {
396            error: "connection reset".into(),
397            strategy: "exponential backoff".into(),
398        });
399        builder.add_reasoning_step("Network call failed", Some("HTTP 503"));
400        builder.add_reasoning_step("Retrying with backoff", None);
401        builder.set_confidence(0.7);
402        let explanation = builder.build();
403
404        match &explanation.decision_type {
405            DecisionType::ErrorRecovery { error, strategy } => {
406                assert_eq!(error, "connection reset");
407                assert_eq!(strategy, "exponential backoff");
408            }
409            other => panic!("Expected ErrorRecovery, got {:?}", other),
410        }
411        assert!((explanation.confidence - 0.7).abs() < f32::EPSILON);
412    }
413
414    // -- Confidence clamping ------------------------------------------------
415
416    #[test]
417    fn test_confidence_clamping() {
418        let mut builder_high = ExplanationBuilder::new(DecisionType::ToolSelection {
419            selected_tool: "x".into(),
420        });
421        builder_high.set_confidence(1.5);
422        let too_high = builder_high.build();
423        assert!((too_high.confidence - 1.0).abs() < f32::EPSILON);
424
425        let mut builder_low = ExplanationBuilder::new(DecisionType::ToolSelection {
426            selected_tool: "x".into(),
427        });
428        builder_low.set_confidence(-0.3);
429        let too_low = builder_low.build();
430        assert!(too_low.confidence.abs() < f32::EPSILON);
431    }
432
433    // -- Edge cases ---------------------------------------------------------
434
435    #[test]
436    fn test_empty_explanation() {
437        let explanation = ExplanationBuilder::new(DecisionType::ToolSelection {
438            selected_tool: String::new(),
439        })
440        .build();
441
442        assert!(explanation.reasoning_chain.is_empty());
443        assert!(explanation.considered_alternatives.is_empty());
444        assert!(explanation.context_factors.is_empty());
445        assert!((explanation.confidence - 0.5).abs() < f32::EPSILON);
446    }
447
448    #[test]
449    fn test_full_explanation() {
450        let mut builder = ExplanationBuilder::new(DecisionType::TaskDecomposition {
451            sub_tasks: vec!["lint".into(), "test".into(), "deploy".into()],
452        });
453        builder.add_reasoning_step("Decompose CI pipeline", Some("pipeline.yml"));
454        builder.add_reasoning_step("Lint first for fast feedback", None);
455        builder.add_reasoning_step("Deploy only after tests pass", Some("policy #5"));
456        builder.add_alternative("single_step", "Too monolithic", RiskLevel::Destructive);
457        builder.add_alternative("manual_deploy", "Slow", RiskLevel::Network);
458        builder.add_context_factor("CI environment available", FactorInfluence::Positive);
459        builder.add_context_factor("Production freeze in effect", FactorInfluence::Negative);
460        builder.add_context_factor("Team size is medium", FactorInfluence::Neutral);
461        builder.set_confidence(0.88);
462        let explanation = builder.build();
463
464        assert_eq!(explanation.reasoning_chain.len(), 3);
465        assert_eq!(explanation.considered_alternatives.len(), 2);
466        assert_eq!(explanation.context_factors.len(), 3);
467        assert!((explanation.confidence - 0.88).abs() < f32::EPSILON);
468
469        match &explanation.decision_type {
470            DecisionType::TaskDecomposition { sub_tasks } => {
471                assert_eq!(sub_tasks.len(), 3);
472                assert_eq!(sub_tasks[0], "lint");
473            }
474            other => panic!("Expected TaskDecomposition, got {:?}", other),
475        }
476
477        for (i, step) in explanation.reasoning_chain.iter().enumerate() {
478            assert_eq!(step.step_number, i + 1);
479        }
480    }
481
482    #[test]
483    fn test_reasoning_step_with_evidence() {
484        let step = ReasoningStep {
485            step_number: 1,
486            description: "Evaluated risk".into(),
487            evidence: Some("audit log entry #42".into()),
488        };
489        assert_eq!(step.evidence.as_deref(), Some("audit log entry #42"));
490    }
491
492    #[test]
493    fn test_reasoning_step_without_evidence() {
494        let step = ReasoningStep {
495            step_number: 1,
496            description: "Intuitive judgement".into(),
497            evidence: None,
498        };
499        assert!(step.evidence.is_none());
500    }
501}