Skip to main content

somatize_memory/
file_kb.rs

1//! File-backed knowledge base: append-only JSONL persistence.
2//!
3//! Wraps [`MemoryKnowledgeBase`] with a durable log: on open, existing
4//! records are loaded from the file (tolerating a torn trailing line
5//! from a crash mid-write); every [`record`](KnowledgeBase::record)
6//! appends one JSON line and delegates. Queries delegate unchanged.
7//!
8//! Because the log is strictly append-only, [`refresh`] can pick up
9//! another process's writes by reading from a byte offset instead of
10//! re-parsing the file: a long-lived reader (the MCP server) sees runs
11//! finishing in another terminal without reopening anything.
12//!
13//! [`refresh`]: KnowledgeBase::refresh
14
15use crate::knowledge_base::{KnowledgeBase, MemoryKnowledgeBase};
16use crate::record::ExperimentRecord;
17use somatize_core::error::{Result, SomaError};
18use std::fs::{self, OpenOptions};
19use std::io::{Read, Seek, SeekFrom, Write};
20use std::path::{Path, PathBuf};
21
22/// JSONL-backed [`KnowledgeBase`] (one [`ExperimentRecord`] per line).
23///
24/// Default location: `.soma/experiments.jsonl`.
25pub struct FileKnowledgeBase {
26    inner: MemoryKnowledgeBase,
27    path: PathBuf,
28    /// Bytes of the log already folded into `inner`. Only ever advanced
29    /// past a complete, newline-terminated region.
30    offset: u64,
31}
32
33impl FileKnowledgeBase {
34    /// Open (or create) the knowledge base at `path`, loading every
35    /// parseable line. A corrupt trailing line — the signature of a
36    /// crash mid-append — is skipped with a warning; a corrupt line in
37    /// the middle of the file is an error.
38    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
39        let path = path.as_ref().to_path_buf();
40        if let Some(parent) = path.parent()
41            && !parent.as_os_str().is_empty()
42        {
43            fs::create_dir_all(parent)?;
44        }
45
46        let mut kb = Self {
47            inner: MemoryKnowledgeBase::new(),
48            path,
49            offset: 0,
50        };
51        kb.load_from_offset(true)?;
52        Ok(kb)
53    }
54
55    /// Path of the backing JSONL file.
56    pub fn path(&self) -> &Path {
57        &self.path
58    }
59
60    /// Read everything after `self.offset` and fold it in.
61    ///
62    /// `strict` distinguishes the two callers: on `open`, a corrupt
63    /// line that is not the last one is a real error worth surfacing;
64    /// on `refresh`, the tail is expected to be racing a writer, so an
65    /// unparseable final line is simply left for next time.
66    fn load_from_offset(&mut self, strict: bool) -> Result<usize> {
67        if !self.path.exists() {
68            return Ok(0);
69        }
70        let mut file = fs::File::open(&self.path)?;
71        let size = file.metadata()?.len();
72        if size < self.offset {
73            // Truncated or replaced underneath us (a `kb reindex` in
74            // another process): start over rather than read garbage.
75            self.inner = MemoryKnowledgeBase::new();
76            self.offset = 0;
77        }
78        if size == self.offset {
79            return Ok(0);
80        }
81        file.seek(SeekFrom::Start(self.offset))?;
82        let mut chunk = String::new();
83        file.read_to_string(&mut chunk)?;
84
85        // Only consume up to the last newline: a partially written
86        // final line stays unread, and its bytes are re-read next time.
87        let complete = match chunk.rfind('\n') {
88            Some(i) => &chunk[..=i],
89            None => "",
90        };
91        let leftover = &chunk[complete.len()..];
92        if !leftover.trim().is_empty() {
93            tracing::warn!(
94                "knowledge base: {} has an unterminated trailing line; deferring it",
95                self.path.display()
96            );
97        }
98
99        let lines: Vec<&str> = complete.lines().filter(|l| !l.trim().is_empty()).collect();
100        let mut loaded = 0;
101        for (i, line) in lines.iter().enumerate() {
102            match serde_json::from_str::<ExperimentRecord>(line) {
103                Ok(record) => {
104                    self.inner.record(record)?;
105                    loaded += 1;
106                }
107                Err(e) if !strict || i == lines.len() - 1 => {
108                    tracing::warn!(
109                        "knowledge base: skipping corrupt line in {}: {e}",
110                        self.path.display()
111                    );
112                }
113                Err(e) => {
114                    return Err(SomaError::Serialization(format!(
115                        "corrupt experiment record at {}:{}: {e}",
116                        self.path.display(),
117                        i + 1
118                    )));
119                }
120            }
121        }
122        self.offset += complete.len() as u64;
123        Ok(loaded)
124    }
125}
126
127impl KnowledgeBase for FileKnowledgeBase {
128    fn record(&mut self, experiment: ExperimentRecord) -> Result<()> {
129        let mut line = serde_json::to_string(&experiment)
130            .map_err(|e| SomaError::Serialization(e.to_string()))?;
131        line.push('\n');
132
133        let file = OpenOptions::new()
134            .create(true)
135            .append(true)
136            .open(&self.path)?;
137
138        // One line, one write, under an exclusive advisory lock.
139        //
140        // `writeln!` was two writes (payload, then newline), and nothing
141        // serialised concurrent writers. Two processes recording at once
142        // could therefore interleave *inside* a record — and a torn line in
143        // the middle of the file is the one corruption `load_from_offset`
144        // cannot treat as a racing tail, so it fails the whole open. The
145        // lock removes the interleaving; a single `write_all` keeps the
146        // record whole even against a writer that ignores the lock.
147        file.lock()?;
148        let write = (&file).write_all(line.as_bytes());
149        // `sync_data`, not just a flush: the pool is meant to survive the
150        // crash of the run that was appending to it, and buffered bytes in
151        // the page cache do not.
152        let sync = write.and_then(|()| file.sync_data());
153        let unlock = file.unlock();
154        drop(file);
155        sync?;
156        unlock?;
157        // Fold the log back in rather than pushing `experiment`
158        // straight into memory. The log is then the single source of
159        // what this handle holds: our own line and anything another
160        // process appended since we last looked both land exactly
161        // once, and the offset can never drift.
162        self.load_from_offset(false)?;
163        Ok(())
164    }
165
166    fn all(&self) -> Result<Vec<ExperimentRecord>> {
167        self.inner.all()
168    }
169
170    fn get(&self, id: &str) -> Result<Option<ExperimentRecord>> {
171        self.inner.get(id)
172    }
173
174    fn len(&self) -> usize {
175        self.inner.len()
176    }
177
178    fn refresh(&mut self) -> Result<usize> {
179        self.load_from_offset(false)
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::collections::BTreeMap;
187
188    fn record(id: &str, f1: f64) -> ExperimentRecord {
189        ExperimentRecord::new(id, format!("experiment {id}"))
190            .with_research_line("mos")
191            .with_metrics(BTreeMap::from([("f1".to_string(), f1)]))
192    }
193
194    #[test]
195    fn open_record_reopen_roundtrip() {
196        let dir = tempfile::tempdir().unwrap();
197        let path = dir.path().join("experiments.jsonl");
198
199        {
200            let mut kb = FileKnowledgeBase::open(&path).unwrap();
201            kb.record(record("e1", 0.8)).unwrap();
202            kb.record(record("e2", 0.9)).unwrap();
203            assert_eq!(kb.len(), 2);
204        }
205
206        let kb = FileKnowledgeBase::open(&path).unwrap();
207        assert_eq!(kb.len(), 2);
208        assert!(kb.get("e1").unwrap().is_some());
209        assert_eq!(kb.experiments_in_line("mos").unwrap().len(), 2);
210    }
211
212    /// Several writers on one pool must not shred each other's records.
213    ///
214    /// Records are deliberately large: a small line slips through a
215    /// racy append by luck, a 64 KiB one does not. Without the exclusive
216    /// lock the interleaving lands *mid-file*, which
217    /// `corrupt_middle_line_is_an_error` (below) shows is fatal to the
218    /// whole pool — not just to the record that lost the race.
219    #[test]
220    fn concurrent_writers_do_not_tear_each_others_records() {
221        let dir = tempfile::tempdir().unwrap();
222        let path = dir.path().join("experiments.jsonl");
223
224        const WRITERS: usize = 8;
225        const EACH: usize = 6;
226        let bulk = "x".repeat(64 * 1024);
227
228        std::thread::scope(|scope| {
229            for w in 0..WRITERS {
230                let path = &path;
231                let bulk = &bulk;
232                scope.spawn(move || {
233                    let mut kb = FileKnowledgeBase::open(path).unwrap();
234                    for i in 0..EACH {
235                        let id = format!("w{w}-{i}");
236                        kb.record(
237                            ExperimentRecord::new(&id, format!("{bulk}-{id}"))
238                                .with_research_line("mos"),
239                        )
240                        .unwrap();
241                    }
242                });
243            }
244        });
245
246        // A torn line in the middle makes `open` fail outright, so simply
247        // getting a handle back is already half the assertion.
248        let kb = FileKnowledgeBase::open(&path).unwrap();
249        assert_eq!(kb.len(), WRITERS * EACH);
250        for w in 0..WRITERS {
251            for i in 0..EACH {
252                let id = format!("w{w}-{i}");
253                assert!(kb.get(&id).unwrap().is_some(), "{id} was lost");
254            }
255        }
256    }
257
258    #[test]
259    fn corrupt_trailing_line_is_tolerated() {
260        let dir = tempfile::tempdir().unwrap();
261        let path = dir.path().join("experiments.jsonl");
262        {
263            let mut kb = FileKnowledgeBase::open(&path).unwrap();
264            kb.record(record("e1", 0.8)).unwrap();
265        }
266        // Simulate a crash mid-append.
267        let mut file = OpenOptions::new().append(true).open(&path).unwrap();
268        write!(file, "{{\"id\": \"e2\", \"name\": tru").unwrap();
269        drop(file);
270
271        let kb = FileKnowledgeBase::open(&path).unwrap();
272        assert_eq!(kb.len(), 1);
273    }
274
275    #[test]
276    fn corrupt_middle_line_is_an_error() {
277        let dir = tempfile::tempdir().unwrap();
278        let path = dir.path().join("experiments.jsonl");
279        {
280            let mut kb = FileKnowledgeBase::open(&path).unwrap();
281            kb.record(record("e1", 0.8)).unwrap();
282        }
283        let content = fs::read_to_string(&path).unwrap();
284        fs::write(&path, format!("not json\n{content}")).unwrap();
285        assert!(FileKnowledgeBase::open(&path).is_err());
286    }
287
288    #[test]
289    fn creates_parent_directories() {
290        let dir = tempfile::tempdir().unwrap();
291        let path = dir.path().join(".soma").join("experiments.jsonl");
292        let mut kb = FileKnowledgeBase::open(&path).unwrap();
293        kb.record(record("e1", 0.5)).unwrap();
294        assert!(path.exists());
295    }
296
297    #[test]
298    fn empty_file_yields_empty_kb() {
299        let dir = tempfile::tempdir().unwrap();
300        let path = dir.path().join("experiments.jsonl");
301        fs::write(&path, "").unwrap();
302        let kb = FileKnowledgeBase::open(&path).unwrap();
303        assert!(kb.is_empty());
304        assert_eq!(kb.path(), path.as_path());
305    }
306
307    #[test]
308    fn blank_lines_between_records_are_tolerated() {
309        let dir = tempfile::tempdir().unwrap();
310        let path = dir.path().join("experiments.jsonl");
311        {
312            let mut kb = FileKnowledgeBase::open(&path).unwrap();
313            kb.record(record("e1", 0.8)).unwrap();
314            kb.record(record("e2", 0.9)).unwrap();
315        }
316        let content = fs::read_to_string(&path).unwrap();
317        let padded = content.replace('\n', "\n\n");
318        fs::write(&path, format!("\n{padded}")).unwrap();
319
320        let kb = FileKnowledgeBase::open(&path).unwrap();
321        assert_eq!(kb.len(), 2);
322    }
323
324    /// CONTRACT (pinned): a file whose ONLY line is corrupt is
325    /// classified as a torn tail — it opens as an empty KB with a
326    /// warning rather than erroring. Loud failure requires at least
327    /// one valid record before the corruption.
328    #[test]
329    fn single_fully_corrupt_line_opens_empty() {
330        let dir = tempfile::tempdir().unwrap();
331        let path = dir.path().join("experiments.jsonl");
332        fs::write(&path, "{definitely not json\n").unwrap();
333        let kb = FileKnowledgeBase::open(&path).unwrap();
334        assert!(kb.is_empty());
335    }
336
337    #[test]
338    fn unicode_content_roundtrips() {
339        let dir = tempfile::tempdir().unwrap();
340        let path = dir.path().join("experiments.jsonl");
341        {
342            let mut kb = FileKnowledgeBase::open(&path).unwrap();
343            let rec = ExperimentRecord::new("π-experimento", "atención emoción 🧠")
344                .with_research_line("línea-ñ")
345                .with_notes("multi\nline\nnotes stay one JSONL line");
346            kb.record(rec).unwrap();
347        }
348        let kb = FileKnowledgeBase::open(&path).unwrap();
349        assert_eq!(kb.len(), 1);
350        let rec = kb.get("π-experimento").unwrap().unwrap();
351        assert_eq!(rec.name, "atención emoción 🧠");
352        assert_eq!(rec.research_line.as_deref(), Some("línea-ñ"));
353        // Embedded newlines are JSON-escaped: still one record per line.
354        let lines = fs::read_to_string(&path).unwrap().lines().count();
355        assert_eq!(lines, 1);
356    }
357
358    #[test]
359    fn two_handles_share_the_append_log() {
360        let dir = tempfile::tempdir().unwrap();
361        let path = dir.path().join("experiments.jsonl");
362        let mut a = FileKnowledgeBase::open(&path).unwrap();
363        let mut b = FileKnowledgeBase::open(&path).unwrap();
364
365        a.record(record("from_a", 0.1)).unwrap();
366        // `a` has not looked at the log since, so it still sees one…
367        assert_eq!(a.len(), 1);
368
369        // …while `b` reads the log to append, so it lands with both.
370        b.record(record("from_b", 0.2)).unwrap();
371        assert_eq!(b.len(), 2);
372
373        // A stale handle catches up on demand, exactly once.
374        assert_eq!(a.refresh().unwrap(), 1);
375        assert_eq!(a.len(), 2);
376        assert_eq!(a.refresh().unwrap(), 0);
377        assert_eq!(a.len(), 2);
378
379        let c = FileKnowledgeBase::open(&path).unwrap();
380        assert_eq!(c.len(), 2);
381        assert!(c.get("from_a").unwrap().is_some());
382        assert!(c.get("from_b").unwrap().is_some());
383    }
384
385    #[test]
386    fn refresh_sees_a_line_appended_by_another_process() {
387        let dir = tempfile::tempdir().unwrap();
388        let path = dir.path().join("experiments.jsonl");
389        let mut reader = FileKnowledgeBase::open(&path).unwrap();
390        assert!(reader.is_empty());
391
392        // Another process finishes a run mid-session.
393        let mut writer = FileKnowledgeBase::open(&path).unwrap();
394        writer.record(record("appeared", 0.9)).unwrap();
395
396        assert!(reader.get("appeared").unwrap().is_none(), "not yet visible");
397        assert_eq!(reader.refresh().unwrap(), 1);
398        assert!(reader.get("appeared").unwrap().is_some());
399    }
400
401    #[test]
402    fn refresh_defers_a_half_written_line_until_it_is_complete() {
403        let dir = tempfile::tempdir().unwrap();
404        let path = dir.path().join("experiments.jsonl");
405        let mut reader = FileKnowledgeBase::open(&path).unwrap();
406
407        let complete = serde_json::to_string(&record("done", 0.5)).unwrap();
408        let partial = serde_json::to_string(&record("torn", 0.6)).unwrap();
409        let torn_prefix = &partial[..partial.len() / 2];
410        fs::write(&path, format!("{complete}\n{torn_prefix}")).unwrap();
411
412        assert_eq!(reader.refresh().unwrap(), 1);
413        assert!(reader.get("done").unwrap().is_some());
414        assert!(reader.get("torn").unwrap().is_none());
415
416        // The writer finishes its line; the deferred bytes are re-read.
417        fs::write(&path, format!("{complete}\n{partial}\n")).unwrap();
418        assert_eq!(reader.refresh().unwrap(), 1);
419        assert!(reader.get("torn").unwrap().is_some());
420        assert_eq!(reader.len(), 2);
421    }
422
423    #[test]
424    fn refresh_recovers_from_the_log_being_rewritten_shorter() {
425        // `soma kb reindex` replaces the journal wholesale. A reader
426        // holding a byte offset past the new end must not read garbage.
427        let dir = tempfile::tempdir().unwrap();
428        let path = dir.path().join("experiments.jsonl");
429        let mut reader = FileKnowledgeBase::open(&path).unwrap();
430        {
431            let mut writer = FileKnowledgeBase::open(&path).unwrap();
432            for id in ["e1", "e2", "e3"] {
433                writer.record(record(id, 0.5)).unwrap();
434            }
435        }
436        reader.refresh().unwrap();
437        assert_eq!(reader.len(), 3);
438
439        let single = serde_json::to_string(&record("only", 0.1)).unwrap();
440        fs::write(&path, format!("{single}\n")).unwrap();
441        reader.refresh().unwrap();
442        assert_eq!(reader.len(), 1);
443        assert!(reader.get("only").unwrap().is_some());
444        assert!(reader.get("e1").unwrap().is_none());
445    }
446
447    #[cfg(unix)]
448    #[test]
449    fn record_io_failure_leaves_memory_untouched() {
450        use std::os::unix::fs::PermissionsExt;
451
452        let dir = tempfile::tempdir().unwrap();
453        let path = dir.path().join("experiments.jsonl");
454        let mut kb = FileKnowledgeBase::open(&path).unwrap();
455        kb.record(record("e1", 0.5)).unwrap();
456
457        // Make the file unwritable: the append fails BEFORE the
458        // in-memory mutation, so the KB stays consistent with disk.
459        fs::set_permissions(&path, fs::Permissions::from_mode(0o444)).unwrap();
460        assert!(kb.record(record("e2", 0.9)).is_err());
461        assert_eq!(kb.len(), 1);
462        assert!(kb.get("e2").unwrap().is_none());
463        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
464    }
465
466    #[test]
467    fn queries_delegate_over_rehydrated_records() {
468        let dir = tempfile::tempdir().unwrap();
469        let path = dir.path().join("experiments.jsonl");
470        {
471            let mut kb = FileKnowledgeBase::open(&path).unwrap();
472            kb.record(record("e1", 0.6).with_parent("e0")).unwrap();
473            kb.record(record("e0", 0.5)).unwrap();
474            kb.record(record("e2", 0.8).with_parent("e0")).unwrap();
475        }
476        // Everything below runs over records loaded from disk.
477        let kb = FileKnowledgeBase::open(&path).unwrap();
478        assert_eq!(kb.experiments_in_line("mos").unwrap().len(), 3);
479        assert_eq!(kb.children("e0").unwrap().len(), 2);
480        assert!(!kb.search("experiment", 10).unwrap().is_empty());
481        let lines = kb.research_lines().unwrap();
482        assert_eq!(lines.len(), 1);
483        assert_eq!(lines[0].name, "mos");
484        let trajectory = kb.trajectory("mos", "f1").unwrap();
485        assert_eq!(trajectory.len(), 3);
486        assert!(kb.promising_lines("f1").is_ok());
487        assert!(kb.change_points("mos", "f1", 0.5).is_ok());
488    }
489}