Skip to main content

a3s_code_core/
outcome_memory.rs

1//! Constraints become Active memory because a change was kept, not because
2//! the model mentioned them.
3//!
4//! Accept binds the change-set digest. Revert and reject stay negative
5//! evidence and are absent from Active recall. The binding is the ledger the
6//! promoting host writes; restart reloads that file.
7
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10
11const SECRET_MARKERS: &[&str] = &[
12    "sk-",
13    "api_key=",
14    "API_KEY=",
15    "AKIA",
16    "ghp_",
17    "BEGIN PRIVATE KEY",
18    "Bearer ",
19];
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum OutcomeKind {
24    Accept,
25    Revert,
26    Reject,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct OutcomeRecord {
31    pub change_digest: String,
32    pub constraint: String,
33    pub outcome: OutcomeKind,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37pub struct OutcomeLedger {
38    records: Vec<OutcomeRecord>,
39    /// Digest of the last promoted isolation change set. Not Active memory
40    /// until the host records an accept against it.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    last_promoted_digest: Option<String>,
43}
44
45impl OutcomeLedger {
46    pub fn accept(&mut self, change_digest: &str, constraint: &str) -> bool {
47        self.record(change_digest, constraint, OutcomeKind::Accept)
48    }
49
50    pub fn revert(&mut self, change_digest: &str, constraint: &str) -> bool {
51        self.record(change_digest, constraint, OutcomeKind::Revert)
52    }
53
54    pub fn reject(&mut self, change_digest: &str, constraint: &str) -> bool {
55        self.record(change_digest, constraint, OutcomeKind::Reject)
56    }
57
58    /// Remember the promoted change-set digest. This does not activate recall.
59    pub fn note_promoted(&mut self, digest: &str) -> bool {
60        if digest.trim().is_empty() {
61            return false;
62        }
63        self.last_promoted_digest = Some(digest.to_string());
64        true
65    }
66
67    pub fn last_promoted_digest(&self) -> Option<&str> {
68        self.last_promoted_digest.as_deref()
69    }
70
71    fn record(&mut self, change_digest: &str, constraint: &str, outcome: OutcomeKind) -> bool {
72        if change_digest.trim().is_empty() || constraint.trim().is_empty() {
73            return false;
74        }
75        if contains_secret(constraint) {
76            return false;
77        }
78        self.records.retain(|record| {
79            !(record.change_digest == change_digest && record.constraint == constraint)
80        });
81        self.records.push(OutcomeRecord {
82            change_digest: change_digest.to_string(),
83            constraint: constraint.to_string(),
84            outcome,
85        });
86        true
87    }
88
89    pub fn active_recall(&self) -> Vec<&OutcomeRecord> {
90        self.records
91            .iter()
92            .filter(|record| {
93                record.outcome == OutcomeKind::Accept && !record.change_digest.is_empty()
94            })
95            .collect()
96    }
97
98    pub fn save(&self, path: &Path) -> std::io::Result<()> {
99        if let Some(parent) = path.parent() {
100            std::fs::create_dir_all(parent)?;
101        }
102        std::fs::write(path, serde_json::to_vec(self).unwrap_or_default())
103    }
104
105    pub fn load(path: &Path) -> std::io::Result<Self> {
106        let bytes = std::fs::read(path)?;
107        serde_json::from_slice(&bytes).map_err(std::io::Error::other)
108    }
109}
110
111fn contains_secret(text: &str) -> bool {
112    SECRET_MARKERS.iter().any(|marker| text.contains(marker))
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn reverted_change_is_absent_from_active_recall() {
121        let mut ledger = OutcomeLedger::default();
122        assert!(ledger.accept("digest-keep", "prefer explicit errors"));
123        assert!(ledger.revert("digest-drop", "do not unwrap"));
124        let active = ledger.active_recall();
125        assert_eq!(active.len(), 1);
126        assert_eq!(active[0].constraint, "prefer explicit errors");
127        assert!(active
128            .iter()
129            .all(|record| !record.constraint.contains("unwrap")));
130    }
131
132    #[test]
133    fn kept_change_is_active_only_with_a_digest() {
134        let mut ledger = OutcomeLedger::default();
135        assert!(!ledger.accept("", "orphan constraint"));
136        assert!(ledger.accept("digest-1", "name the missing evidence"));
137        let active = ledger.active_recall();
138        assert_eq!(active.len(), 1);
139        assert_eq!(active[0].change_digest, "digest-1");
140    }
141
142    #[test]
143    fn restart_retains_the_outcome_binding() {
144        let dir = tempfile::tempdir().unwrap();
145        let path = dir.path().join("outcomes.json");
146        let mut ledger = OutcomeLedger::default();
147        assert!(ledger.accept("digest-1", "keep the gate"));
148        assert!(ledger.revert("digest-2", "dropped idea"));
149        ledger.save(&path).unwrap();
150        let loaded = OutcomeLedger::load(&path).unwrap();
151        assert_eq!(loaded.active_recall().len(), 1);
152        assert_eq!(loaded.active_recall()[0].change_digest, "digest-1");
153    }
154
155    #[test]
156    fn promoted_digest_survives_restart_without_becoming_active() {
157        let dir = tempfile::tempdir().unwrap();
158        let path = dir.path().join("outcomes.json");
159        let mut ledger = OutcomeLedger::default();
160        assert!(ledger.note_promoted("digest-kept"));
161        ledger.save(&path).unwrap();
162        let loaded = OutcomeLedger::load(&path).unwrap();
163        assert_eq!(loaded.last_promoted_digest(), Some("digest-kept"));
164        assert!(loaded.active_recall().is_empty());
165        let digest = loaded.last_promoted_digest().unwrap().to_string();
166        let mut loaded = loaded;
167        assert!(loaded.accept(&digest, "name the missing evidence"));
168        assert_eq!(loaded.active_recall()[0].change_digest, "digest-kept");
169    }
170
171    #[test]
172    fn secret_shaped_hunk_is_not_recalled() {
173        let mut ledger = OutcomeLedger::default();
174        assert!(!ledger.accept("digest-1", "token=sk-live-secret-value"));
175        assert!(ledger.active_recall().is_empty());
176    }
177}