1use crate::types::RiskLevel;
14use chrono::{DateTime, Utc};
15use serde::{Deserialize, Serialize};
16use uuid::Uuid;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct DecisionExplanation {
25 pub decision_id: Uuid,
27 pub timestamp: DateTime<Utc>,
29 pub decision_type: DecisionType,
31 pub reasoning_chain: Vec<ReasoningStep>,
33 pub considered_alternatives: Vec<AlternativeAction>,
35 pub confidence: f32,
37 pub context_factors: Vec<ContextFactor>,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
43pub enum DecisionType {
44 ToolSelection {
46 selected_tool: String,
48 },
49 ParameterChoice {
51 tool: String,
53 parameter: String,
55 },
56 TaskDecomposition {
58 sub_tasks: Vec<String>,
60 },
61 ErrorRecovery {
63 error: String,
65 strategy: String,
67 },
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct ReasoningStep {
73 pub step_number: usize,
75 pub description: String,
77 pub evidence: Option<String>,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct AlternativeAction {
84 pub tool_name: String,
86 pub reason_not_selected: String,
88 pub estimated_risk: RiskLevel,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct ContextFactor {
95 pub factor: String,
97 pub influence: FactorInfluence,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
104pub enum FactorInfluence {
105 Positive,
107 Negative,
109 Neutral,
111}
112
113pub 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 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 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 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 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 pub fn set_confidence(&mut self, confidence: f32) -> &mut Self {
179 self.confidence = confidence.clamp(0.0, 1.0);
180 self
181 }
182
183 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#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[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 #[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 #[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, ¶m, &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 #[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 #[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 #[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}