1use 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
22pub struct FileKnowledgeBase {
26 inner: MemoryKnowledgeBase,
27 path: PathBuf,
28 offset: u64,
31}
32
33impl FileKnowledgeBase {
34 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 pub fn path(&self) -> &Path {
57 &self.path
58 }
59
60 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 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 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 file.lock()?;
148 let write = (&file).write_all(line.as_bytes());
149 let sync = write.and_then(|()| file.sync_data());
153 let unlock = file.unlock();
154 drop(file);
155 sync?;
156 unlock?;
157 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 #[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 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 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 #[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 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 assert_eq!(a.len(), 1);
368
369 b.record(record("from_b", 0.2)).unwrap();
371 assert_eq!(b.len(), 2);
372
373 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 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 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 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 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 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}