Skip to main content

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}