Skip to main content

aivcs_core/memory/
rationale.rs

1//! Decision rationale capture for agent reasoning traces.
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6/// Outcome of a decision after execution.
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum RationaleOutcome {
10    Success,
11    Failure,
12    Partial,
13    Skipped,
14}
15
16/// A captured decision rationale with reasoning and alternatives.
17#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
18pub struct DecisionRationale {
19    pub decision: String,
20    pub reasoning: String,
21    pub alternatives_considered: Vec<String>,
22    pub constraints: Vec<String>,
23    pub confidence: f64,
24}
25
26impl DecisionRationale {
27    pub fn new(decision: &str, reasoning: &str) -> Self {
28        Self {
29            decision: decision.into(),
30            reasoning: reasoning.into(),
31            alternatives_considered: Vec::new(),
32            constraints: Vec::new(),
33            confidence: 0.0,
34        }
35    }
36
37    pub fn with_alternative(mut self, alt: &str) -> Self {
38        self.alternatives_considered.push(alt.into());
39        self
40    }
41
42    pub fn with_constraint(mut self, constraint: &str) -> Self {
43        self.constraints.push(constraint.into());
44        self
45    }
46
47    pub fn with_confidence(mut self, confidence: f64) -> Self {
48        self.confidence = confidence.clamp(0.0, 1.0);
49        self
50    }
51}
52
53/// A rationale entry linked to a specific run and event.
54#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct RationaleEntry {
56    pub rationale: DecisionRationale,
57    pub run_id: String,
58    pub event_seq: u64,
59    pub decided_at: DateTime<Utc>,
60    pub outcome: Option<RationaleOutcome>,
61    pub tags: Vec<String>,
62}
63
64impl RationaleEntry {
65    pub fn new(rationale: DecisionRationale, run_id: &str, event_seq: u64) -> Self {
66        Self {
67            rationale,
68            run_id: run_id.into(),
69            event_seq,
70            decided_at: Utc::now(),
71            outcome: None,
72            tags: Vec::new(),
73        }
74    }
75
76    pub fn with_outcome(mut self, outcome: RationaleOutcome) -> Self {
77        self.outcome = Some(outcome);
78        self
79    }
80
81    pub fn with_tag(mut self, tag: &str) -> Self {
82        self.tags.push(tag.into());
83        self
84    }
85
86    /// Rough token estimate: chars / 4.
87    pub fn token_estimate(&self) -> usize {
88        let chars = self.rationale.decision.len()
89            + self.rationale.reasoning.len()
90            + self
91                .rationale
92                .alternatives_considered
93                .iter()
94                .map(|s| s.len())
95                .sum::<usize>()
96            + self
97                .rationale
98                .constraints
99                .iter()
100                .map(|s| s.len())
101                .sum::<usize>();
102        (chars / 4).max(1)
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn test_rationale_builder() {
112        let r = DecisionRationale::new("do X", "because Y")
113            .with_alternative("do Z")
114            .with_constraint("must be fast")
115            .with_confidence(0.85);
116
117        assert_eq!(r.decision, "do X");
118        assert_eq!(r.reasoning, "because Y");
119        assert_eq!(r.alternatives_considered, vec!["do Z"]);
120        assert_eq!(r.constraints, vec!["must be fast"]);
121        assert!((r.confidence - 0.85).abs() < f64::EPSILON);
122    }
123
124    #[test]
125    fn test_confidence_clamped() {
126        let r = DecisionRationale::new("a", "b").with_confidence(2.0);
127        assert!((r.confidence - 1.0).abs() < f64::EPSILON);
128
129        let r = DecisionRationale::new("a", "b").with_confidence(-1.0);
130        assert!(r.confidence.abs() < f64::EPSILON);
131    }
132
133    #[test]
134    fn test_rationale_entry_token_estimate() {
135        let r = DecisionRationale::new("decision", "reasoning");
136        let e = RationaleEntry::new(r, "run1", 1);
137        assert!(e.token_estimate() > 0);
138    }
139
140    #[test]
141    fn test_serde_roundtrip() {
142        let e = RationaleEntry::new(
143            DecisionRationale::new("d", "r")
144                .with_alternative("a")
145                .with_confidence(0.5),
146            "run",
147            1,
148        )
149        .with_outcome(RationaleOutcome::Success)
150        .with_tag("test");
151
152        let json = serde_json::to_string(&e).unwrap();
153        let back: RationaleEntry = serde_json::from_str(&json).unwrap();
154        assert_eq!(e, back);
155    }
156}