Skip to main content

aivcs_core/memory/
decision.rs

1//! Decision Recording and Learning — EPIC5 Phase 1
2//!
3//! Captures agent decisions with rationale and outcomes for learning and analysis.
4
5use oxidized_state::{DecisionRecord, MemoryProvenanceRecord, SurrealHandle};
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use uuid::Uuid;
9
10use crate::{AivcsError, Result};
11
12/// Configuration for decision recording
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DecisionRecorderConfig {
15    /// Enable decision recording
16    pub enabled: bool,
17    /// Capture decisions from run events
18    pub capture_from_events: bool,
19    /// Capture decisions from tool calls
20    pub capture_from_tools: bool,
21    /// Maximum decision record size (bytes)
22    pub max_decision_size: usize,
23}
24
25impl Default for DecisionRecorderConfig {
26    fn default() -> Self {
27        DecisionRecorderConfig {
28            enabled: true,
29            capture_from_events: true,
30            capture_from_tools: true,
31            max_decision_size: 10_000,
32        }
33    }
34}
35
36/// Origin of the decision capture request.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
38#[serde(rename_all = "snake_case")]
39pub enum DecisionCaptureSource {
40    Event,
41    Tool,
42    Manual,
43}
44
45/// Records agent decisions with rationale for learning
46pub struct DecisionRecorder {
47    handle: Arc<SurrealHandle>,
48    config: DecisionRecorderConfig,
49}
50
51impl DecisionRecorder {
52    /// Create a new decision recorder
53    pub fn new(handle: Arc<SurrealHandle>, config: DecisionRecorderConfig) -> Self {
54        DecisionRecorder { handle, config }
55    }
56
57    /// Create with default configuration
58    pub fn with_default_config(handle: Arc<SurrealHandle>) -> Self {
59        Self::new(handle, DecisionRecorderConfig::default())
60    }
61
62    /// Record a decision from a run
63    ///
64    /// This captures decisions made during execution with context and rationale.
65    pub async fn record_decision(
66        &self,
67        commit_id: String,
68        task: String,
69        action: String,
70        rationale: String,
71        confidence: f32,
72    ) -> Result<String> {
73        self.record_decision_with_source(
74            commit_id,
75            task,
76            action,
77            rationale,
78            confidence,
79            DecisionCaptureSource::Manual,
80        )
81        .await
82    }
83
84    /// Record a decision and apply source-specific capture policy.
85    pub async fn record_decision_with_source(
86        &self,
87        commit_id: String,
88        task: String,
89        action: String,
90        rationale: String,
91        confidence: f32,
92        source: DecisionCaptureSource,
93    ) -> Result<String> {
94        if !self.config.enabled {
95            return Ok("decision_recording_disabled".to_string());
96        }
97        if !self.should_capture(source) {
98            return Ok("decision_capture_disabled_for_source".to_string());
99        }
100
101        // Validate confidence is in range [0.0, 1.0]
102        if !(0.0..=1.0).contains(&confidence) {
103            return Err(AivcsError::StorageError(
104                "confidence must be between 0.0 and 1.0".to_string(),
105            ));
106        }
107        self.validate_payload_size(&commit_id, &task, &action, &rationale)?;
108
109        let decision_id = Uuid::new_v4().to_string();
110
111        let decision = DecisionRecord::new(
112            decision_id.clone(),
113            commit_id,
114            task,
115            action,
116            rationale,
117            confidence,
118        );
119
120        // Insert into database using SurrealHandle
121        self.handle
122            .save_decision(&decision)
123            .await
124            .map_err(|e| AivcsError::StorageError(format!("Failed to record decision: {}", e)))?;
125
126        Ok(decision_id)
127    }
128
129    /// Record decision outcome
130    pub async fn record_decision_outcome(
131        &self,
132        decision_id: &str,
133        outcome_json: String,
134    ) -> Result<()> {
135        if !self.config.enabled {
136            return Ok(());
137        }
138        self.handle
139            .update_decision_outcome(decision_id, outcome_json)
140            .await
141            .map_err(|e| {
142                AivcsError::StorageError(format!("Failed to update decision outcome: {}", e))
143            })?;
144
145        Ok(())
146    }
147
148    /// Record memory provenance for a memory
149    pub async fn record_provenance(&self, provenance: MemoryProvenanceRecord) -> Result<String> {
150        if !self.config.enabled {
151            return Ok("provenance_recording_disabled".to_string());
152        }
153
154        let memory_id = provenance.memory_id.clone();
155
156        // Insert into database using SurrealHandle
157        self.handle
158            .save_provenance(&provenance)
159            .await
160            .map_err(|e| AivcsError::StorageError(format!("Failed to record provenance: {}", e)))?;
161
162        Ok(memory_id)
163    }
164
165    /// Get decision history for a task
166    pub async fn get_decision_history(
167        &self,
168        task: &str,
169        limit: usize,
170    ) -> Result<Vec<DecisionRecord>> {
171        if !self.config.enabled {
172            return Ok(vec![]);
173        }
174
175        self.handle
176            .get_decision_history(task, limit)
177            .await
178            .map_err(|e| {
179                AivcsError::StorageError(format!("Failed to query decision history: {}", e))
180            })
181    }
182
183    /// Calculate decision success rate for an action
184    pub async fn get_decision_success_rate(&self, action: &str) -> Result<f32> {
185        if !self.config.enabled {
186            return Ok(0.0);
187        }
188
189        // For now, return a placeholder value
190        // In Phase 2, this will query the database for actual success statistics
191        let _action = action;
192        Ok(0.0)
193    }
194
195    /// Invalidate decision provenance when a run fails
196    pub async fn invalidate_provenance_on_failure(&self, commit_id: &str) -> Result<()> {
197        if !self.config.enabled {
198            return Ok(());
199        }
200
201        // In Phase 2, this will implement the actual invalidation logic
202        let _commit_id = commit_id;
203        Ok(())
204    }
205
206    fn should_capture(&self, source: DecisionCaptureSource) -> bool {
207        should_capture_source(&self.config, source)
208    }
209
210    fn validate_payload_size(
211        &self,
212        commit_id: &str,
213        task: &str,
214        action: &str,
215        rationale: &str,
216    ) -> Result<()> {
217        let payload_size = decision_payload_size(commit_id, task, action, rationale);
218        if payload_size > self.config.max_decision_size {
219            return Err(AivcsError::StorageError(format!(
220                "decision payload exceeds max_decision_size ({} > {})",
221                payload_size, self.config.max_decision_size
222            )));
223        }
224        Ok(())
225    }
226}
227
228fn should_capture_source(config: &DecisionRecorderConfig, source: DecisionCaptureSource) -> bool {
229    match source {
230        DecisionCaptureSource::Event => config.capture_from_events,
231        DecisionCaptureSource::Tool => config.capture_from_tools,
232        DecisionCaptureSource::Manual => true,
233    }
234}
235
236fn decision_payload_size(commit_id: &str, task: &str, action: &str, rationale: &str) -> usize {
237    commit_id.len() + task.len() + action.len() + rationale.len()
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[tokio::test]
245    async fn test_decision_recorder_disabled() {
246        let config = DecisionRecorderConfig {
247            enabled: false,
248            ..Default::default()
249        };
250
251        // We can't easily test without a real database, but we verify config works
252        assert!(!config.enabled);
253    }
254
255    #[test]
256    fn test_decision_recorder_config_default() {
257        let config = DecisionRecorderConfig::default();
258
259        assert!(config.enabled);
260        assert!(config.capture_from_events);
261        assert!(config.capture_from_tools);
262        assert_eq!(config.max_decision_size, 10_000);
263    }
264
265    #[test]
266    fn test_confidence_validation() {
267        // Confidence should be in [0.0, 1.0]
268        assert!((0.0..=1.0).contains(&0.5));
269        assert!((0.0..=1.0).contains(&0.0));
270        assert!((0.0..=1.0).contains(&1.0));
271    }
272
273    #[test]
274    fn test_should_capture_honors_source_flags() {
275        let config = DecisionRecorderConfig {
276            enabled: true,
277            capture_from_events: false,
278            capture_from_tools: true,
279            max_decision_size: 10_000,
280        };
281
282        assert!(!should_capture_source(
283            &config,
284            DecisionCaptureSource::Event
285        ));
286        assert!(should_capture_source(&config, DecisionCaptureSource::Tool));
287        assert!(should_capture_source(
288            &config,
289            DecisionCaptureSource::Manual
290        ));
291    }
292
293    #[test]
294    fn test_validate_payload_size_enforces_limit() {
295        assert_eq!(decision_payload_size("abc", "def", "ghi", "jkl"), 12);
296    }
297
298    #[tokio::test]
299    async fn test_record_decision_rejects_payload_over_limit() {
300        let handle = Arc::new(SurrealHandle::setup_db().await.unwrap());
301        let recorder = DecisionRecorder::new(
302            handle,
303            DecisionRecorderConfig {
304                enabled: true,
305                capture_from_events: true,
306                capture_from_tools: true,
307                max_decision_size: 8,
308            },
309        );
310
311        let err = recorder
312            .record_decision(
313                "abc".to_string(),
314                "def".to_string(),
315                "ghi".to_string(),
316                "jkl".to_string(),
317                0.5,
318            )
319            .await
320            .unwrap_err();
321        assert!(err
322            .to_string()
323            .contains("decision payload exceeds max_decision_size"));
324    }
325
326    #[tokio::test]
327    async fn test_record_decision_with_source_respects_capture_flags() {
328        let handle = Arc::new(SurrealHandle::setup_db().await.unwrap());
329        let recorder = DecisionRecorder::new(
330            handle,
331            DecisionRecorderConfig {
332                enabled: true,
333                capture_from_events: false,
334                capture_from_tools: true,
335                max_decision_size: 10_000,
336            },
337        );
338
339        let status = recorder
340            .record_decision_with_source(
341                "commit-1".to_string(),
342                "task-1".to_string(),
343                "action-1".to_string(),
344                "rationale-1".to_string(),
345                0.7,
346                DecisionCaptureSource::Event,
347            )
348            .await
349            .unwrap();
350
351        assert_eq!(status, "decision_capture_disabled_for_source");
352    }
353
354    #[tokio::test]
355    async fn test_record_decision_outcome_persists() {
356        let handle = Arc::new(SurrealHandle::setup_db().await.unwrap());
357        let recorder = DecisionRecorder::with_default_config(handle.clone());
358
359        let decision_id = recorder
360            .record_decision(
361                "commit-2".to_string(),
362                "task-2".to_string(),
363                "action-2".to_string(),
364                "rationale-2".to_string(),
365                0.8,
366            )
367            .await
368            .unwrap();
369
370        recorder
371            .record_decision_outcome(&decision_id, r#"{"status":"success"}"#.to_string())
372            .await
373            .unwrap();
374
375        let persisted = handle.get_decision(&decision_id).await.unwrap().unwrap();
376        assert_eq!(
377            persisted.outcome,
378            Some(r#"{"status":"success"}"#.to_string())
379        );
380        assert!(persisted.outcome_at.is_some());
381    }
382}