lunaris_extract/cached.rs
1//! Content-addressed extraction cache — [`CachedExtractor`] decorator.
2//!
3//! LLM extraction dominates graph-ingest wall clock (a MiniMax-M3 call runs
4//! ~23s; ~80% of graph-ON LongMemEval time). This decorator wraps any
5//! [`Extractor`] and guarantees each distinct (prompt-template, namespace,
6//! chunk) triple hits the LLM at most once; afterwards the chunk's
7//! [`RawExtraction`] replays from a filesystem cache at filesystem speed.
8//!
9//! ## Key derivation
10//!
11//! `key = blake3(namespace || 0x00 || build_prompt(chunk))`
12//!
13//! `llm_extractor::build_prompt` (crate-private) is the single prompt source shared
14//! by `LlmExtractor` and `CloudApiExtractor`, and it embeds the chunk text,
15//! the heading path AND the template itself — so editing the template
16//! auto-invalidates every stale entry with no manual version bump. The
17//! namespace carries model identity (extraction differs per model).
18//!
19//! ## Replay semantics
20//!
21//! Chunk ids are fresh Ulids on every ingest, so a replayed entry's
22//! `source_chunk_id` is rewritten to the CURRENT chunk id — provenance
23//! follows the live ingest, not the fill pass. Everything else (entity ids,
24//! fact ids, timestamps) replays byte-identically, which makes re-ingest
25//! deterministic — a property the live extractor cannot offer (the prompt's
26//! "else today" date instruction and sampling drift both disappear).
27//!
28//! ## Failure design
29//!
30//! The cache is an accelerator, never a dependency:
31//! - a corrupt/unreadable entry is a MISS (re-extracted and healed), not an
32//! error;
33//! - a failed write is a `tracing::warn`, extraction still succeeds;
34//! - entries are written to a unique tmp file then `rename`d, so concurrent
35//! fill processes on the same directory never observe torn JSON.
36
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::sync::atomic::{AtomicU64, Ordering};
40
41use async_trait::async_trait;
42use lunaris_core::LunarisError;
43use ulid::Ulid;
44
45use crate::Extractor;
46use crate::llm_extractor::build_prompt;
47use crate::types::{ChunkInput, RawExtraction, RawExtractionBatch};
48
49/// Hit/miss counters for observability — the LME harness prints these after
50/// ingest so a mis-wired cache (0 hits on a warm dir) is visible immediately.
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub struct CacheStats {
53 pub hits: u64,
54 pub misses: u64,
55}
56
57/// Filesystem-backed content-addressed cache around any [`Extractor`].
58pub struct CachedExtractor {
59 inner: Arc<dyn Extractor>,
60 dir: PathBuf,
61 namespace: String,
62 hits: AtomicU64,
63 misses: AtomicU64,
64}
65
66impl std::fmt::Debug for CachedExtractor {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 f.debug_struct("CachedExtractor")
69 .field("dir", &self.dir)
70 .field("namespace", &self.namespace)
71 .field("stats", &self.stats())
72 .finish_non_exhaustive()
73 }
74}
75
76impl CachedExtractor {
77 /// Wrap `inner`, persisting entries under `dir` (created if absent).
78 ///
79 /// `namespace` must identify the model producing extractions (e.g.
80 /// `"MiniMax-M3"`) — entries never replay across namespaces.
81 pub fn new(
82 inner: Arc<dyn Extractor>,
83 dir: impl AsRef<Path>,
84 namespace: &str,
85 ) -> std::io::Result<Self> {
86 let dir = dir.as_ref().to_path_buf();
87 std::fs::create_dir_all(&dir)?;
88 Ok(Self {
89 inner,
90 dir,
91 namespace: namespace.to_owned(),
92 hits: AtomicU64::new(0),
93 misses: AtomicU64::new(0),
94 })
95 }
96
97 /// Per-chunk hit/miss counts since construction.
98 pub fn stats(&self) -> CacheStats {
99 CacheStats {
100 hits: self.hits.load(Ordering::Relaxed),
101 misses: self.misses.load(Ordering::Relaxed),
102 }
103 }
104
105 fn entry_path(&self, chunk: &ChunkInput) -> PathBuf {
106 let mut hasher = blake3::Hasher::new();
107 hasher.update(self.namespace.as_bytes());
108 hasher.update(&[0u8]);
109 hasher.update(build_prompt(chunk).as_bytes());
110 self.dir.join(format!("{}.json", hasher.finalize().to_hex()))
111 }
112
113 /// Read + parse one entry. ANY failure (absent, unreadable, corrupt,
114 /// schema drift) is `None` — the caller re-extracts and heals the entry.
115 fn read_entry(&self, chunk: &ChunkInput) -> Option<RawExtraction> {
116 let bytes = std::fs::read(self.entry_path(chunk)).ok()?;
117 serde_json::from_slice::<RawExtraction>(&bytes).ok()
118 }
119
120 /// Best-effort atomic write: unique tmp file in the same directory, then
121 /// rename. Concurrent fill processes race benignly (last rename wins;
122 /// every candidate holds identical-shape valid JSON).
123 fn write_entry(&self, chunk: &ChunkInput, raw: &RawExtraction) {
124 let path = self.entry_path(chunk);
125 let tmp = self.dir.join(format!(".tmp-{}-{}", std::process::id(), Ulid::new()));
126 let result = serde_json::to_vec(raw)
127 .map_err(std::io::Error::other)
128 .and_then(|bytes| std::fs::write(&tmp, bytes))
129 .and_then(|()| std::fs::rename(&tmp, &path));
130 if let Err(e) = result {
131 let _ = std::fs::remove_file(&tmp);
132 tracing::warn!(err = %e, path = %path.display(), "extraction cache write failed; continuing uncached");
133 }
134 }
135}
136
137#[async_trait]
138impl Extractor for CachedExtractor {
139 async fn extract(
140 &self,
141 episode_id: Ulid,
142 chunks: &[ChunkInput],
143 ) -> Result<RawExtractionBatch, LunarisError> {
144 if chunks.is_empty() {
145 return Ok(RawExtractionBatch::default());
146 }
147
148 // Probe the cache per chunk; replayed entries adopt the CURRENT
149 // chunk id (fresh per ingest — provenance must follow it).
150 let mut out: Vec<Option<RawExtraction>> = vec![None; chunks.len()];
151 let mut miss_idx: Vec<usize> = Vec::new();
152 for (i, chunk) in chunks.iter().enumerate() {
153 match self.read_entry(chunk) {
154 Some(mut raw) => {
155 raw.source_chunk_id = chunk.chunk_id;
156 out[i] = Some(raw);
157 }
158 None => miss_idx.push(i),
159 }
160 }
161 self.hits.fetch_add((chunks.len() - miss_idx.len()) as u64, Ordering::Relaxed);
162 self.misses.fetch_add(miss_idx.len() as u64, Ordering::Relaxed);
163
164 // Misses go to the inner extractor as ONE batch (its per-batch
165 // timeout / fallback semantics stay intact), aligned by index per
166 // the Extractor contract (one RawExtraction per input chunk, in
167 // input order).
168 if !miss_idx.is_empty() {
169 let miss_chunks: Vec<ChunkInput> =
170 miss_idx.iter().map(|&i| chunks[i].clone()).collect();
171 let batch = self.inner.extract(episode_id, &miss_chunks).await?;
172 for (j, &i) in miss_idx.iter().enumerate() {
173 let raw = batch.by_chunk.get(j).cloned().unwrap_or_else(|| RawExtraction {
174 source_chunk_id: chunks[i].chunk_id,
175 ..Default::default()
176 });
177 self.write_entry(&chunks[i], &raw);
178 out[i] = Some(raw);
179 }
180 }
181
182 Ok(RawExtractionBatch {
183 by_chunk: out
184 .into_iter()
185 .enumerate()
186 .map(|(i, o)| {
187 o.unwrap_or_else(|| RawExtraction {
188 source_chunk_id: chunks[i].chunk_id,
189 ..Default::default()
190 })
191 })
192 .collect(),
193 })
194 }
195
196 fn applies(&self) -> bool {
197 self.inner.applies()
198 }
199}