Skip to main content

scema_memory/
lib.rs

1//! # scema-memory — four memories, one of which is unusual
2//!
3//! A vector store is not a memory; it is a search index over things that were said. This
4//! crate keeps four kinds, separated because they answer different questions and decay at
5//! different rates:
6//!
7//! | Kind | Question | Example |
8//! |---|---|---|
9//! | [`MemoryBody::Episode`] | what happened? | "deployed X, it failed, cause was an RPC timeout" |
10//! | [`MemoryBody::Belief`] | what do I hold to be true? | "this RPC provider degrades under load" |
11//! | [`MemoryBody::Procedure`] | how is this done? | "seed pools → verify graph → run arb" |
12//! | [`MemoryBody::Counterfactual`] | what would the branch I rejected have done? | "H₂ was projected at 0.31 and not taken" |
13//!
14//! ## The fourth one is the point, and it is mostly unanswerable
15//!
16//! A counterfactual records a branch the agent *declined*. Its projected utility is known —
17//! it was computed — and its realised utility almost never is, because nobody ran it. That
18//! asymmetry is the design, not a gap to fill in later.
19//!
20//! It is the same asymmetry the bot's own `calibration.rs` lives with: a bullish call
21//! resolves against realised PnL, a bearish one almost never resolves because the bot
22//! avoided that pool. The rule that falls out of it is the one that matters:
23//!
24//! > **Unresolved counterfactuals are counted, never scored.**
25//!
26//! [`Calibration`] therefore reports `resolved` and `unresolved` as separate integers and
27//! computes error only over the first. An implementation that imputed outcomes for
28//! untaken branches — from a model, from a neighbour, from a prior — would be generating
29//! its own training signal, and every subsequent decision would be tuned to a fiction.
30//!
31//! ## Storage
32//!
33//! Append-only JSONL, one file per kind, under `<root>/memory/`. The same convention as
34//! `scematica-trades.jsonl` in the bot workspace, for the same reason: an append-only log
35//! cannot lose an earlier belief when a later one contradicts it, and contradiction is
36//! information. Nothing here rewrites or deletes a line.
37
38use std::fs::{self, OpenOptions};
39use std::io::{BufRead, BufReader, Write};
40use std::path::{Path, PathBuf};
41
42use anyhow::{Context, Result};
43use serde::{Deserialize, Serialize};
44
45/// Which memory a record belongs to. Determines the file it lands in.
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "lowercase")]
48pub enum MemoryKind {
49    Episodic,
50    Semantic,
51    Procedural,
52    Counterfactual,
53}
54
55impl MemoryKind {
56    pub fn filename(&self) -> &'static str {
57        match self {
58            MemoryKind::Episodic => "episodic.jsonl",
59            MemoryKind::Semantic => "semantic.jsonl",
60            MemoryKind::Procedural => "procedural.jsonl",
61            MemoryKind::Counterfactual => "counterfactual.jsonl",
62        }
63    }
64
65    pub fn all() -> [MemoryKind; 4] {
66        [
67            MemoryKind::Episodic,
68            MemoryKind::Semantic,
69            MemoryKind::Procedural,
70            MemoryKind::Counterfactual,
71        ]
72    }
73}
74
75/// How something went.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "lowercase")]
78pub enum Outcome {
79    Succeeded,
80    Failed,
81    /// Started and neither finished nor failed. A real state, and the one an agent is most
82    /// tempted to round to `Failed`.
83    Abandoned,
84    /// Nobody checked. Distinct from `Abandoned`: the action may well have worked.
85    Unobserved,
86}
87
88/// The content of a memory.
89#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "body", rename_all = "snake_case")]
91pub enum MemoryBody {
92    /// Something that happened.
93    Episode {
94        what: String,
95        outcome: Outcome,
96        evidence: Vec<String>,
97    },
98    /// Something the agent holds to be true, with the tally that supports it.
99    ///
100    /// `support` and `contradiction` are both kept rather than folded into one confidence,
101    /// because "believed on 40 observations, contradicted by 39" and "believed on 1
102    /// observation" produce the same ratio and are not the same belief.
103    Belief {
104        claim: String,
105        support: u32,
106        contradiction: u32,
107    },
108    /// A way of doing something, and how it has gone.
109    Procedure {
110        name: String,
111        steps: Vec<String>,
112        successes: u32,
113        failures: u32,
114    },
115    /// A branch that was considered and not taken.
116    ///
117    /// `projected` is what the simulator said. There is no `realised` field: an outcome for
118    /// an untaken branch would have to be invented. Resolution, in the rare case the branch
119    /// is later run, arrives as a separate [`MemoryBody::Realisation`].
120    Counterfactual {
121        decision: String,
122        hypothesis: String,
123        statement: String,
124        projected: f64,
125        /// Why it lost: outranked, forbidden, contested, or the whole decision abstained.
126        reason: String,
127    },
128    /// A measured outcome for a branch, which may resolve a counterfactual.
129    Realisation {
130        decision: String,
131        hypothesis: String,
132        realised: f64,
133        note: String,
134    },
135}
136
137/// One line in one of the four logs.
138#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
139pub struct MemoryRecord {
140    pub id: String,
141    pub kind: MemoryKind,
142    /// Unix seconds.
143    pub at: i64,
144    /// What this is about — an entity locator, a decision id, a subsystem name. Recall
145    /// filters on it.
146    pub subject: String,
147    pub tags: Vec<String>,
148    pub body: MemoryBody,
149    /// What produced this record: an observer name, a decision id, `operator`.
150    pub source: String,
151}
152
153impl MemoryRecord {
154    pub fn new(
155        id: impl Into<String>,
156        kind: MemoryKind,
157        at: i64,
158        subject: impl Into<String>,
159        body: MemoryBody,
160        source: impl Into<String>,
161    ) -> Self {
162        MemoryRecord {
163            id: id.into(),
164            kind,
165            at,
166            subject: subject.into(),
167            tags: vec![],
168            body,
169            source: source.into(),
170        }
171    }
172
173    pub fn tagged(mut self, tag: impl Into<String>) -> Self {
174        self.tags.push(tag.into());
175        self
176    }
177}
178
179/// What to recall.
180#[derive(Clone, Debug, Default)]
181pub struct Recall {
182    /// Case-insensitive substring of `subject`.
183    pub subject: Option<String>,
184    pub tag: Option<String>,
185    /// Only records at or after this unix second.
186    pub since: Option<i64>,
187    /// Most recent first; `None` for all.
188    pub limit: Option<usize>,
189}
190
191impl Recall {
192    pub fn about(subject: impl Into<String>) -> Self {
193        Recall { subject: Some(subject.into()), ..Default::default() }
194    }
195
196    pub fn limit(mut self, n: usize) -> Self {
197        self.limit = Some(n);
198        self
199    }
200
201    fn matches(&self, r: &MemoryRecord) -> bool {
202        if let Some(s) = &self.subject {
203            if !r.subject.to_lowercase().contains(&s.to_lowercase()) {
204                return false;
205            }
206        }
207        if let Some(t) = &self.tag {
208            if !r.tags.iter().any(|x| x == t) {
209                return false;
210            }
211        }
212        if let Some(since) = self.since {
213            if r.at < since {
214                return false;
215            }
216        }
217        true
218    }
219}
220
221/// How well past projections matched reality — over the branches that were resolvable.
222#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
223pub struct Calibration {
224    /// Counterfactuals recorded in total.
225    pub recorded: usize,
226    /// Those with a matching [`MemoryBody::Realisation`].
227    pub resolved: usize,
228    /// Those without. **Counted, never scored** — see the crate note.
229    pub unresolved: usize,
230    /// Mean absolute error over the resolved ones only, or `None` when none resolved.
231    ///
232    /// `None` rather than `0.0`: a perfect score and no evidence must not print alike.
233    pub mean_abs_error: Option<f64>,
234}
235
236/// The four logs on disk.
237pub struct MemoryStore {
238    root: PathBuf,
239}
240
241impl MemoryStore {
242    /// Memory lives under `<root>/memory/`. Nothing is created until the first write.
243    pub fn new(root: impl Into<PathBuf>) -> Self {
244        MemoryStore { root: root.into() }
245    }
246
247    fn dir(&self) -> PathBuf {
248        self.root.join("memory")
249    }
250
251    fn path(&self, kind: MemoryKind) -> PathBuf {
252        self.dir().join(kind.filename())
253    }
254
255    /// Append one record.
256    ///
257    /// Appends rather than the tmp-and-rename used for snapshot files: a single `write` of
258    /// one line to a file opened with `append` is what the bot's trade log already relies
259    /// on, and rewriting the whole log to add a line would lose history on a crash.
260    pub fn remember(&self, record: &MemoryRecord) -> Result<()> {
261        let dir = self.dir();
262        fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
263        let path = self.path(record.kind);
264        let mut line = serde_json::to_string(record)?;
265        line.push('\n');
266        let mut f = OpenOptions::new()
267            .create(true)
268            .append(true)
269            .open(&path)
270            .with_context(|| format!("opening {}", path.display()))?;
271        f.write_all(line.as_bytes())
272            .with_context(|| format!("appending to {}", path.display()))?;
273        Ok(())
274    }
275
276    /// Every record of a kind, oldest first.
277    ///
278    /// A line that will not parse is **skipped and counted**, not fatal: one corrupt line —
279    /// a half-written record from a killed process — must not make the agent amnesiac. The
280    /// count comes back so a caller can surface it rather than swallow it.
281    pub fn read_all(&self, kind: MemoryKind) -> Result<(Vec<MemoryRecord>, usize)> {
282        let path = self.path(kind);
283        if !path.exists() {
284            return Ok((vec![], 0));
285        }
286        let f = fs::File::open(&path).with_context(|| format!("opening {}", path.display()))?;
287        let mut out = Vec::new();
288        let mut corrupt = 0usize;
289        for line in BufReader::new(f).lines() {
290            let line = line?;
291            if line.trim().is_empty() {
292                continue;
293            }
294            match serde_json::from_str::<MemoryRecord>(&line) {
295                Ok(r) => out.push(r),
296                Err(_) => corrupt += 1,
297            }
298        }
299        Ok((out, corrupt))
300    }
301
302    /// Matching records, most recent first.
303    pub fn recall(&self, kind: MemoryKind, query: &Recall) -> Result<Vec<MemoryRecord>> {
304        let (all, _) = self.read_all(kind)?;
305        let mut hits: Vec<MemoryRecord> = all.into_iter().filter(|r| query.matches(r)).collect();
306        hits.sort_by_key(|r| std::cmp::Reverse(r.at));
307        if let Some(n) = query.limit {
308            hits.truncate(n);
309        }
310        Ok(hits)
311    }
312
313    /// Score past projections against outcomes, honestly.
314    ///
315    /// Joins counterfactuals to realisations on `(decision, hypothesis)`. Everything that
316    /// does not join is `unresolved` — which, for branches the agent declined to take, is
317    /// the overwhelming majority and always will be.
318    pub fn calibration(&self) -> Result<Calibration> {
319        let (records, _) = self.read_all(MemoryKind::Counterfactual)?;
320        let mut projected: Vec<(String, String, f64)> = Vec::new();
321        let mut realised: Vec<(String, String, f64)> = Vec::new();
322        for r in &records {
323            match &r.body {
324                MemoryBody::Counterfactual { decision, hypothesis, projected: p, .. } => {
325                    projected.push((decision.clone(), hypothesis.clone(), *p))
326                }
327                MemoryBody::Realisation { decision, hypothesis, realised: v, .. } => {
328                    realised.push((decision.clone(), hypothesis.clone(), *v))
329                }
330                _ => {}
331            }
332        }
333        let mut errors: Vec<f64> = Vec::new();
334        for (d, h, p) in &projected {
335            if let Some((_, _, v)) = realised.iter().find(|(rd, rh, _)| rd == d && rh == h) {
336                errors.push((p - v).abs());
337            }
338        }
339        let recorded = projected.len();
340        let resolved = errors.len();
341        Ok(Calibration {
342            recorded,
343            resolved,
344            unresolved: recorded - resolved,
345            mean_abs_error: if errors.is_empty() {
346                None
347            } else {
348                Some(errors.iter().sum::<f64>() / errors.len() as f64)
349            },
350        })
351    }
352
353    /// Total records per kind, for `scema remember --stats`.
354    pub fn counts(&self) -> Result<Vec<(MemoryKind, usize, usize)>> {
355        MemoryKind::all()
356            .iter()
357            .map(|k| {
358                let (rs, corrupt) = self.read_all(*k)?;
359                Ok((*k, rs.len(), corrupt))
360            })
361            .collect()
362    }
363
364    pub fn root(&self) -> &Path {
365        &self.root
366    }
367}
368
369#[cfg(test)]
370mod tests {
371    use super::*;
372
373    fn tmp() -> PathBuf {
374        let p = std::env::temp_dir().join(format!(
375            "scema-omni-mem-{}-{}",
376            std::process::id(),
377            std::time::SystemTime::now()
378                .duration_since(std::time::UNIX_EPOCH)
379                .unwrap()
380                .as_nanos()
381        ));
382        fs::create_dir_all(&p).unwrap();
383        p
384    }
385
386    fn cf(store: &MemoryStore, id: &str, decision: &str, hypothesis: &str, projected: f64) {
387        store
388            .remember(&MemoryRecord::new(
389                id,
390                MemoryKind::Counterfactual,
391                1,
392                decision,
393                MemoryBody::Counterfactual {
394                    decision: decision.into(),
395                    hypothesis: hypothesis.into(),
396                    statement: "s".into(),
397                    projected,
398                    reason: "outranked".into(),
399                },
400                "test",
401            ))
402            .unwrap();
403    }
404
405    #[test]
406    fn untaken_branches_are_counted_and_never_scored() {
407        // The rule the crate exists to hold. Two rejected branches, no outcomes: the report
408        // must say "2 unresolved" and refuse to produce an error figure.
409        let dir = tmp();
410        let s = MemoryStore::new(&dir);
411        cf(&s, "m1", "d1", "h2", 0.31);
412        cf(&s, "m2", "d1", "h3", 0.11);
413        let c = s.calibration().unwrap();
414        assert_eq!(c.recorded, 2);
415        assert_eq!(c.resolved, 0);
416        assert_eq!(c.unresolved, 2);
417        assert_eq!(c.mean_abs_error, None, "no evidence must not print as perfect accuracy");
418        fs::remove_dir_all(&dir).ok();
419    }
420
421    #[test]
422    fn a_realisation_resolves_exactly_its_own_branch() {
423        let dir = tmp();
424        let s = MemoryStore::new(&dir);
425        cf(&s, "m1", "d1", "h2", 0.30);
426        cf(&s, "m2", "d1", "h3", 0.10);
427        s.remember(&MemoryRecord::new(
428            "m3",
429            MemoryKind::Counterfactual,
430            2,
431            "d1",
432            MemoryBody::Realisation {
433                decision: "d1".into(),
434                hypothesis: "h2".into(),
435                realised: 0.20,
436                note: "ran it later".into(),
437            },
438            "test",
439        ))
440        .unwrap();
441        let c = s.calibration().unwrap();
442        assert_eq!(c.resolved, 1);
443        assert_eq!(c.unresolved, 1);
444        assert!((c.mean_abs_error.unwrap() - 0.10).abs() < 1e-9);
445        fs::remove_dir_all(&dir).ok();
446    }
447
448    #[test]
449    fn a_realisation_for_a_different_decision_does_not_resolve_anything() {
450        let dir = tmp();
451        let s = MemoryStore::new(&dir);
452        cf(&s, "m1", "d1", "h2", 0.30);
453        s.remember(&MemoryRecord::new(
454            "m2",
455            MemoryKind::Counterfactual,
456            2,
457            "d9",
458            MemoryBody::Realisation {
459                decision: "d9".into(),
460                hypothesis: "h2".into(),
461                realised: 0.9,
462                note: "different decision entirely".into(),
463            },
464            "test",
465        ))
466        .unwrap();
467        assert_eq!(s.calibration().unwrap().resolved, 0);
468        fs::remove_dir_all(&dir).ok();
469    }
470
471    #[test]
472    fn a_corrupt_line_is_skipped_and_counted_rather_than_fatal() {
473        let dir = tmp();
474        let s = MemoryStore::new(&dir);
475        s.remember(&MemoryRecord::new(
476            "m1",
477            MemoryKind::Episodic,
478            1,
479            "x",
480            MemoryBody::Episode {
481                what: "did a thing".into(),
482                outcome: Outcome::Succeeded,
483                evidence: vec![],
484            },
485            "test",
486        ))
487        .unwrap();
488        let path = dir.join("memory").join("episodic.jsonl");
489        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
490        f.write_all(b"{ this is not json\n").unwrap();
491
492        let (records, corrupt) = s.read_all(MemoryKind::Episodic).unwrap();
493        assert_eq!(records.len(), 1, "one bad line must not make the agent amnesiac");
494        assert_eq!(corrupt, 1, "and it must not be swallowed either");
495        fs::remove_dir_all(&dir).ok();
496    }
497
498    #[test]
499    fn recall_returns_most_recent_first() {
500        let dir = tmp();
501        let s = MemoryStore::new(&dir);
502        for (id, at) in [("a", 10), ("b", 30), ("c", 20)] {
503            s.remember(&MemoryRecord::new(
504                id,
505                MemoryKind::Semantic,
506                at,
507                "rpc",
508                MemoryBody::Belief { claim: id.into(), support: 1, contradiction: 0 },
509                "test",
510            ))
511            .unwrap();
512        }
513        let hits = s.recall(MemoryKind::Semantic, &Recall::about("rpc").limit(2)).unwrap();
514        assert_eq!(hits.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), vec!["b", "c"]);
515        fs::remove_dir_all(&dir).ok();
516    }
517
518    #[test]
519    fn reading_a_store_that_was_never_written_is_empty_not_an_error() {
520        let s = MemoryStore::new(tmp().join("nope"));
521        assert!(s.recall(MemoryKind::Episodic, &Recall::default()).unwrap().is_empty());
522        assert_eq!(s.calibration().unwrap().recorded, 0);
523    }
524}