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/// Make a state root ignore itself, the moment it first exists.
237///
238/// `.scema/` holds decision records full of absolute paths, four append-only memory logs,
239/// and — when the daemon has run — a 256-bit pairing token. None of that is meaningful in
240/// somebody else's clone, and the token is a secret sitting inside a git working tree.
241///
242/// The ignore is written **inside** the directory rather than into the project's own
243/// `.gitignore`, for two reasons. A self-ignoring directory works whatever the project's
244/// ignore rules say, and whatever VCS it uses; and no library has any business rewriting a
245/// file the whole repository shares.
246///
247/// It is called from every place that can bring the root into existence — the record store,
248/// the memory store, and the daemon's token write — because whichever of those runs *first*
249/// is the one that creates it, and that varies by which surface the operator reached for.
250/// `scema init` writes the same file, so an operator who set the directory up deliberately
251/// and one who got it as a side effect end up with the same protection.
252///
253/// Failure is deliberately silent. This is a courtesy on the way to doing something else,
254/// and an unwritable `.gitignore` must not turn a successful `decide` into an error — the
255/// record is the thing the caller asked for. A pre-existing file is never overwritten,
256/// because an operator who edited it meant it.
257pub fn self_ignore(root: &Path) {
258    let marker = root.join(".gitignore");
259    if marker.exists() {
260        return;
261    }
262    let _ = fs::write(
263        &marker,
264        "# Machine-local agent state: decision records cite absolute paths, memory is a\n         # per-checkout history, and omnid.token is a secret. None of it belongs in a commit.\n         *\n",
265    );
266}
267
268/// The four logs on disk.
269pub struct MemoryStore {
270    root: PathBuf,
271}
272
273impl MemoryStore {
274    /// Memory lives under `<root>/memory/`. Nothing is created until the first write.
275    pub fn new(root: impl Into<PathBuf>) -> Self {
276        MemoryStore { root: root.into() }
277    }
278
279    fn dir(&self) -> PathBuf {
280        self.root.join("memory")
281    }
282
283    fn path(&self, kind: MemoryKind) -> PathBuf {
284        self.dir().join(kind.filename())
285    }
286
287    /// Append one record.
288    ///
289    /// Appends rather than the tmp-and-rename used for snapshot files: a single `write` of
290    /// one line to a file opened with `append` is what the bot's trade log already relies
291    /// on, and rewriting the whole log to add a line would lose history on a crash.
292    pub fn remember(&self, record: &MemoryRecord) -> Result<()> {
293        let dir = self.dir();
294        fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
295        self_ignore(&self.root);
296        let path = self.path(record.kind);
297        let mut line = serde_json::to_string(record)?;
298        line.push('\n');
299        let mut f = OpenOptions::new()
300            .create(true)
301            .append(true)
302            .open(&path)
303            .with_context(|| format!("opening {}", path.display()))?;
304        f.write_all(line.as_bytes())
305            .with_context(|| format!("appending to {}", path.display()))?;
306        Ok(())
307    }
308
309    /// Every record of a kind, oldest first.
310    ///
311    /// A line that will not parse is **skipped and counted**, not fatal: one corrupt line —
312    /// a half-written record from a killed process — must not make the agent amnesiac. The
313    /// count comes back so a caller can surface it rather than swallow it.
314    pub fn read_all(&self, kind: MemoryKind) -> Result<(Vec<MemoryRecord>, usize)> {
315        let path = self.path(kind);
316        if !path.exists() {
317            return Ok((vec![], 0));
318        }
319        let f = fs::File::open(&path).with_context(|| format!("opening {}", path.display()))?;
320        let mut out = Vec::new();
321        let mut corrupt = 0usize;
322        for line in BufReader::new(f).lines() {
323            let line = line?;
324            if line.trim().is_empty() {
325                continue;
326            }
327            match serde_json::from_str::<MemoryRecord>(&line) {
328                Ok(r) => out.push(r),
329                Err(_) => corrupt += 1,
330            }
331        }
332        Ok((out, corrupt))
333    }
334
335    /// Matching records, most recent first.
336    pub fn recall(&self, kind: MemoryKind, query: &Recall) -> Result<Vec<MemoryRecord>> {
337        let (all, _) = self.read_all(kind)?;
338        let mut hits: Vec<MemoryRecord> = all.into_iter().filter(|r| query.matches(r)).collect();
339        hits.sort_by_key(|r| std::cmp::Reverse(r.at));
340        if let Some(n) = query.limit {
341            hits.truncate(n);
342        }
343        Ok(hits)
344    }
345
346    /// Score past projections against outcomes, honestly.
347    ///
348    /// Joins counterfactuals to realisations on `(decision, hypothesis)`. Everything that
349    /// does not join is `unresolved` — which, for branches the agent declined to take, is
350    /// the overwhelming majority and always will be.
351    pub fn calibration(&self) -> Result<Calibration> {
352        let (records, _) = self.read_all(MemoryKind::Counterfactual)?;
353        let mut projected: Vec<(String, String, f64)> = Vec::new();
354        let mut realised: Vec<(String, String, f64)> = Vec::new();
355        for r in &records {
356            match &r.body {
357                MemoryBody::Counterfactual { decision, hypothesis, projected: p, .. } => {
358                    projected.push((decision.clone(), hypothesis.clone(), *p))
359                }
360                MemoryBody::Realisation { decision, hypothesis, realised: v, .. } => {
361                    realised.push((decision.clone(), hypothesis.clone(), *v))
362                }
363                _ => {}
364            }
365        }
366        let mut errors: Vec<f64> = Vec::new();
367        for (d, h, p) in &projected {
368            if let Some((_, _, v)) = realised.iter().find(|(rd, rh, _)| rd == d && rh == h) {
369                errors.push((p - v).abs());
370            }
371        }
372        let recorded = projected.len();
373        let resolved = errors.len();
374        Ok(Calibration {
375            recorded,
376            resolved,
377            unresolved: recorded - resolved,
378            mean_abs_error: if errors.is_empty() {
379                None
380            } else {
381                Some(errors.iter().sum::<f64>() / errors.len() as f64)
382            },
383        })
384    }
385
386    /// Total records per kind, for `scema remember --stats`.
387    pub fn counts(&self) -> Result<Vec<(MemoryKind, usize, usize)>> {
388        MemoryKind::all()
389            .iter()
390            .map(|k| {
391                let (rs, corrupt) = self.read_all(*k)?;
392                Ok((*k, rs.len(), corrupt))
393            })
394            .collect()
395    }
396
397    pub fn root(&self) -> &Path {
398        &self.root
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    fn tmp() -> PathBuf {
407        let p = std::env::temp_dir().join(format!(
408            "scema-omni-mem-{}-{}",
409            std::process::id(),
410            std::time::SystemTime::now()
411                .duration_since(std::time::UNIX_EPOCH)
412                .unwrap()
413                .as_nanos()
414        ));
415        fs::create_dir_all(&p).unwrap();
416        p
417    }
418
419    fn cf(store: &MemoryStore, id: &str, decision: &str, hypothesis: &str, projected: f64) {
420        store
421            .remember(&MemoryRecord::new(
422                id,
423                MemoryKind::Counterfactual,
424                1,
425                decision,
426                MemoryBody::Counterfactual {
427                    decision: decision.into(),
428                    hypothesis: hypothesis.into(),
429                    statement: "s".into(),
430                    projected,
431                    reason: "outranked".into(),
432                },
433                "test",
434            ))
435            .unwrap();
436    }
437
438    #[test]
439    fn untaken_branches_are_counted_and_never_scored() {
440        // The rule the crate exists to hold. Two rejected branches, no outcomes: the report
441        // must say "2 unresolved" and refuse to produce an error figure.
442        let dir = tmp();
443        let s = MemoryStore::new(&dir);
444        cf(&s, "m1", "d1", "h2", 0.31);
445        cf(&s, "m2", "d1", "h3", 0.11);
446        let c = s.calibration().unwrap();
447        assert_eq!(c.recorded, 2);
448        assert_eq!(c.resolved, 0);
449        assert_eq!(c.unresolved, 2);
450        assert_eq!(c.mean_abs_error, None, "no evidence must not print as perfect accuracy");
451        fs::remove_dir_all(&dir).ok();
452    }
453
454    #[test]
455    fn a_realisation_resolves_exactly_its_own_branch() {
456        let dir = tmp();
457        let s = MemoryStore::new(&dir);
458        cf(&s, "m1", "d1", "h2", 0.30);
459        cf(&s, "m2", "d1", "h3", 0.10);
460        s.remember(&MemoryRecord::new(
461            "m3",
462            MemoryKind::Counterfactual,
463            2,
464            "d1",
465            MemoryBody::Realisation {
466                decision: "d1".into(),
467                hypothesis: "h2".into(),
468                realised: 0.20,
469                note: "ran it later".into(),
470            },
471            "test",
472        ))
473        .unwrap();
474        let c = s.calibration().unwrap();
475        assert_eq!(c.resolved, 1);
476        assert_eq!(c.unresolved, 1);
477        assert!((c.mean_abs_error.unwrap() - 0.10).abs() < 1e-9);
478        fs::remove_dir_all(&dir).ok();
479    }
480
481    #[test]
482    fn a_realisation_for_a_different_decision_does_not_resolve_anything() {
483        let dir = tmp();
484        let s = MemoryStore::new(&dir);
485        cf(&s, "m1", "d1", "h2", 0.30);
486        s.remember(&MemoryRecord::new(
487            "m2",
488            MemoryKind::Counterfactual,
489            2,
490            "d9",
491            MemoryBody::Realisation {
492                decision: "d9".into(),
493                hypothesis: "h2".into(),
494                realised: 0.9,
495                note: "different decision entirely".into(),
496            },
497            "test",
498        ))
499        .unwrap();
500        assert_eq!(s.calibration().unwrap().resolved, 0);
501        fs::remove_dir_all(&dir).ok();
502    }
503
504    #[test]
505    fn a_corrupt_line_is_skipped_and_counted_rather_than_fatal() {
506        let dir = tmp();
507        let s = MemoryStore::new(&dir);
508        s.remember(&MemoryRecord::new(
509            "m1",
510            MemoryKind::Episodic,
511            1,
512            "x",
513            MemoryBody::Episode {
514                what: "did a thing".into(),
515                outcome: Outcome::Succeeded,
516                evidence: vec![],
517            },
518            "test",
519        ))
520        .unwrap();
521        let path = dir.join("memory").join("episodic.jsonl");
522        let mut f = OpenOptions::new().append(true).open(&path).unwrap();
523        f.write_all(b"{ this is not json\n").unwrap();
524
525        let (records, corrupt) = s.read_all(MemoryKind::Episodic).unwrap();
526        assert_eq!(records.len(), 1, "one bad line must not make the agent amnesiac");
527        assert_eq!(corrupt, 1, "and it must not be swallowed either");
528        fs::remove_dir_all(&dir).ok();
529    }
530
531    #[test]
532    fn recall_returns_most_recent_first() {
533        let dir = tmp();
534        let s = MemoryStore::new(&dir);
535        for (id, at) in [("a", 10), ("b", 30), ("c", 20)] {
536            s.remember(&MemoryRecord::new(
537                id,
538                MemoryKind::Semantic,
539                at,
540                "rpc",
541                MemoryBody::Belief { claim: id.into(), support: 1, contradiction: 0 },
542                "test",
543            ))
544            .unwrap();
545        }
546        let hits = s.recall(MemoryKind::Semantic, &Recall::about("rpc").limit(2)).unwrap();
547        assert_eq!(hits.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), vec!["b", "c"]);
548        fs::remove_dir_all(&dir).ok();
549    }
550
551    #[test]
552    fn reading_a_store_that_was_never_written_is_empty_not_an_error() {
553        let s = MemoryStore::new(tmp().join("nope"));
554        assert!(s.recall(MemoryKind::Episodic, &Recall::default()).unwrap().is_empty());
555        assert_eq!(s.calibration().unwrap().recorded, 0);
556    }
557}