1use oxidized_state::{DecisionRecord, MemoryProvenanceRecord, SurrealHandle};
6use serde::{Deserialize, Serialize};
7use std::sync::Arc;
8use uuid::Uuid;
9
10use crate::{AivcsError, Result};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct DecisionRecorderConfig {
15 pub enabled: bool,
17 pub capture_from_events: bool,
19 pub capture_from_tools: bool,
21 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#[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
45pub struct DecisionRecorder {
47 handle: Arc<SurrealHandle>,
48 config: DecisionRecorderConfig,
49}
50
51impl DecisionRecorder {
52 pub fn new(handle: Arc<SurrealHandle>, config: DecisionRecorderConfig) -> Self {
54 DecisionRecorder { handle, config }
55 }
56
57 pub fn with_default_config(handle: Arc<SurrealHandle>) -> Self {
59 Self::new(handle, DecisionRecorderConfig::default())
60 }
61
62 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 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 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 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 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 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 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 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 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 let _action = action;
192 Ok(0.0)
193 }
194
195 pub async fn invalidate_provenance_on_failure(&self, commit_id: &str) -> Result<()> {
197 if !self.config.enabled {
198 return Ok(());
199 }
200
201 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 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 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}