Skip to main content

rac_engine/
derived_cache.rs

1//! Content-addressed derived-index cache (ADR-099/ADR-112) — port of
2//! `services/derived_cache.py` `DerivedIndexCache.load_or_build` plus the
3//! stat-manifest freshness rungs of `services/freshness.py` the one-shot
4//! path consumes (INDEX-PLAN B3).
5//!
6//! Every failure mode degrades to a fresh build: enabling the cache can only
7//! change latency, never an answer or an exit code (ADR-080).
8
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11use rayon::prelude::*;
12
13use crate::derived::{DerivedIndex, SCHEMA_VERSION};
14use crate::index_store::{
15    manifest_root_key, open_freshness_manifest, open_store, remove_store, store_dir,
16    write_freshness_manifest, write_store, FileState, MmapIndexReader,
17};
18use crate::walk::find_markdown_files;
19
20pub const CACHE_DIR_ENV: &str = "DECIDED_CACHE_DIR";
21
22/// Whether the persistent cache is active for this invocation (ADR-112):
23/// on by default; `--no-cache` per invocation, non-empty `DECIDED_NO_CACHE`
24/// environment-wide.
25pub fn cache_enabled(cache_flag: bool) -> bool {
26    cache_flag && std::env::var("DECIDED_NO_CACHE").unwrap_or_default().is_empty()
27}
28
29/// The derived-cache directory ladder: `DECIDED_CACHE_DIR` >
30/// `$XDG_CACHE_HOME/decisions/derived` > `~/.cache/decisions/derived` >
31/// `<tmp>/decided-cache/decisions/derived` (the homeless floor — never raises).
32pub fn default_cache_dir() -> PathBuf {
33    if let Ok(dir) = std::env::var(CACHE_DIR_ENV) {
34        if !dir.is_empty() {
35            return PathBuf::from(dir);
36        }
37    }
38    let base = match std::env::var("XDG_CACHE_HOME") {
39        Ok(xdg) if !xdg.is_empty() => PathBuf::from(xdg),
40        _ => match std::env::var("HOME") {
41            Ok(home) if !home.is_empty() => Path::new(&home).join(".cache"),
42            _ => std::env::temp_dir().join("decided-cache"),
43        },
44    };
45    base.join("decided").join("derived")
46}
47
48// ---------------------------------------------------------------------------
49// Freshness rungs (services/freshness.py stat_scan + hash recomposition)
50// ---------------------------------------------------------------------------
51
52fn stat_pair(path: &Path) -> Option<(u64, u64)> {
53    let meta = std::fs::metadata(path).ok()?;
54    let mtime_ns = meta
55        .modified()
56        .ok()?
57        .duration_since(std::time::UNIX_EPOCH)
58        .ok()?
59        .as_nanos() as u64;
60    Some((meta.len(), mtime_ns))
61}
62
63/// Diff the corpus against `prev_manifest` by stat, content-confirming
64/// changes. Returns the rebuilt manifest (scan order) and the changed set.
65pub fn stat_scan(
66    root_str: &str,
67    prev_manifest: &[(String, FileState)],
68    content_confirm_all: bool,
69    recursive: bool,
70) -> (Vec<(String, FileState)>, BTreeSet<String>) {
71    let prev: std::collections::HashMap<&str, &FileState> = prev_manifest
72        .iter()
73        .map(|(rel, state)| (rel.as_str(), state))
74        .collect();
75    let discovery_started = crate::timing::start();
76    let entries = find_markdown_files(root_str, recursive);
77    crate::timing::emit_since(
78        "stat.discovery",
79        discovery_started,
80        &[("files", entries.len() as u64)],
81    );
82    // Metadata probes dominate the warm scan at large corpus sizes and are
83    // independent. Indexed parallel collection preserves walk order, which is
84    // part of the manifest/hash contract.
85    let metadata_started = crate::timing::start();
86    let scan_entry = |entry: &crate::walk::WalkEntry| {
87        let rel = entry.components.join("/");
88        let Some((size, mtime_ns)) = stat_pair(&entry.abs) else {
89            return None; // vanished between enumeration and stat
90        };
91        if !content_confirm_all {
92            if let Some(prev_state) = prev.get(rel.as_str()) {
93                if prev_state.size == size && prev_state.mtime_ns == mtime_ns {
94                    return Some((rel, (*prev_state).clone(), false)); // S5 accepted
95                }
96            }
97        }
98        let digest = crate::index_store::content_hash(&entry.abs);
99        let changed_content = match prev.get(rel.as_str()) {
100            Some(prev_state) => prev_state.content_hash != digest,
101            None => true,
102        };
103        Some((
104            rel.clone(),
105            FileState {
106                content_hash: digest,
107                size,
108                mtime_ns,
109            },
110            changed_content,
111        ))
112    };
113    let scanned: Vec<Option<(String, FileState, bool)>> =
114        entries.par_iter().map(scan_entry).collect();
115    crate::timing::emit_since(
116        "stat.metadata",
117        metadata_started,
118        &[("files", entries.len() as u64)],
119    );
120    let mut changed: BTreeSet<String> = BTreeSet::new();
121    let mut new_manifest: Vec<(String, FileState)> = Vec::with_capacity(scanned.len());
122    for (rel, state, changed_content) in scanned.into_iter().flatten() {
123        if changed_content {
124            changed.insert(rel.clone());
125        }
126        new_manifest.push((rel, state));
127    }
128    let present: std::collections::HashSet<&str> =
129        new_manifest.iter().map(|(rel, _)| rel.as_str()).collect();
130    for (rel, _) in prev_manifest {
131        if !present.contains(rel.as_str()) {
132            changed.insert(rel.clone()); // removed — enumeration is truth
133        }
134    }
135    (new_manifest, changed)
136}
137
138/// Reproduce `corpus_content_hash` from the manifest's cached hashes.
139pub fn corpus_hash_from_manifest(
140    root_str: &str,
141    manifest: &[(String, FileState)],
142    recursive: bool,
143) -> String {
144    let by_rel: std::collections::HashMap<&str, &FileState> = manifest
145        .iter()
146        .map(|(rel, state)| (rel.as_str(), state))
147        .collect();
148    let mut hasher = crate::sha256::Sha256::new();
149    for entry in find_markdown_files(root_str, recursive) {
150        let rel = entry.components.join("/");
151        let digest = match by_rel.get(rel.as_str()) {
152            Some(state) => state.content_hash.clone(),
153            None => crate::index_store::content_hash(&entry.abs),
154        };
155        hasher.update(rel.as_bytes());
156        hasher.update(b"\0");
157        hasher.update(digest.as_bytes());
158        hasher.update(b"\0");
159    }
160    hasher.hexdigest()
161}
162
163/// Recompose the corpus hash from a complete scan-order manifest without a
164/// second filesystem walk. `stat_scan` always returns exactly this shape.
165pub fn corpus_hash_from_complete_manifest(manifest: &[(String, FileState)]) -> String {
166    let mut hasher = crate::sha256::Sha256::new();
167    for (rel, state) in manifest {
168        hasher.update(rel.as_bytes());
169        hasher.update(b"\0");
170        hasher.update(state.content_hash.as_bytes());
171        hasher.update(b"\0");
172    }
173    hasher.hexdigest()
174}
175
176// ---------------------------------------------------------------------------
177// Marker file — the fail-closed schema gate beside the store.
178// ---------------------------------------------------------------------------
179
180fn marker_path(cache_dir: &Path, corpus_hash: &str) -> PathBuf {
181    cache_dir.join(format!("{corpus_hash}.json"))
182}
183
184fn marker_valid(cache_dir: &Path, corpus_hash: &str) -> bool {
185    let Ok(text) = std::fs::read_to_string(marker_path(cache_dir, corpus_hash)) else {
186        return false;
187    };
188    let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
189        return false;
190    };
191    value
192        .as_object()
193        .and_then(|obj| obj.get("schema_version"))
194        .and_then(|v| v.as_str())
195        == Some(SCHEMA_VERSION)
196}
197
198/// The tracker's compaction gate (INDEX-PLAN B6): write the marker for an
199/// already-landed store.
200pub fn write_marker_public(cache_dir: &Path, corpus_hash: &str) -> bool {
201    write_marker(cache_dir, corpus_hash, true)
202}
203
204fn write_marker(cache_dir: &Path, corpus_hash: &str, store_written: bool) -> bool {
205    if !store_written {
206        return false;
207    }
208    if std::fs::create_dir_all(cache_dir).is_err() {
209        return false;
210    }
211    // json.dumps default separators over an insertion-ordered dict.
212    let payload =
213        format!("{{\"schema_version\": \"{SCHEMA_VERSION}\", \"corpus_hash\": \"{corpus_hash}\"}}");
214    let tmp = cache_dir.join(format!(
215        ".{corpus_hash}.{}.tmp",
216        std::process::id()
217    ));
218    if std::fs::write(&tmp, payload).is_err() {
219        let _ = std::fs::remove_file(&tmp);
220        return false;
221    }
222    if std::fs::rename(&tmp, marker_path(cache_dir, corpus_hash)).is_err() {
223        let _ = std::fs::remove_file(&tmp);
224        return false;
225    }
226    true
227}
228
229// ---------------------------------------------------------------------------
230// load_or_build — the whole cache surface.
231// ---------------------------------------------------------------------------
232
233/// What `load_or_build` returns: a memory-mapped store view (the warm path),
234/// or the freshly built structures when the store could not be written or
235/// reopened (ADR-080 — never a failure).
236pub enum ReadModel {
237    View(MmapIndexReader),
238    Fresh(DerivedIndex),
239}
240
241pub struct DerivedIndexCache {
242    pub cache_dir: PathBuf,
243}
244
245impl Default for DerivedIndexCache {
246    fn default() -> Self {
247        Self {
248            cache_dir: default_cache_dir(),
249        }
250    }
251}
252
253impl DerivedIndexCache {
254    pub fn load_or_build(&self, directory: &str, recursive: bool, verify: bool) -> ReadModel {
255        // Freshness: the key is recomputed every call through the persisted
256        // stat manifest (ADR-112); `verify` or a missing manifest forces the
257        // content-confirm-all floor, and the rewrite self-heals either way.
258        let root_key = manifest_root_key(directory, recursive);
259        let prev = if verify {
260            None
261        } else {
262            open_freshness_manifest(&self.cache_dir, &root_key)
263        };
264        let manifest_missing = prev.is_none();
265        let confirm_all = verify || manifest_missing;
266        let prev_manifest = prev.unwrap_or_default();
267        let scan_started = crate::timing::start();
268        let (manifest, changed) = stat_scan(directory, &prev_manifest, confirm_all, recursive);
269        crate::timing::emit_since(
270            "cache.discovery_stat",
271            scan_started,
272            &[
273                ("files", manifest.len() as u64),
274                ("changed", changed.len() as u64),
275            ],
276        );
277        let hash_started = crate::timing::start();
278        let corpus_hash = corpus_hash_from_complete_manifest(&manifest);
279        crate::timing::emit_since(
280            "cache.corpus_hash",
281            hash_started,
282            &[("files", manifest.len() as u64)],
283        );
284        // Best-effort persistence: the manifest is a latency structure only.
285        let manifest_started = crate::timing::start();
286        let manifest_dirty = manifest_missing || manifest != prev_manifest;
287        let manifest_written =
288            !manifest_dirty || write_freshness_manifest(&self.cache_dir, &root_key, &manifest);
289        crate::timing::emit_since(
290            "cache.manifest_write",
291            manifest_started,
292            &[
293                ("files", manifest.len() as u64),
294                ("dirty", u64::from(manifest_dirty)),
295                ("success", u64::from(manifest_written)),
296            ],
297        );
298        if marker_valid(&self.cache_dir, &corpus_hash) {
299            let open_started = crate::timing::start();
300            if let Some(view) = open_store(&self.cache_dir, &corpus_hash, SCHEMA_VERSION) {
301                crate::timing::emit_since(
302                    "cache.store_open",
303                    open_started,
304                    &[("hit", 1), ("documents", u64::from(view.doc_count))],
305                );
306                return ReadModel::View(view);
307            }
308            crate::timing::emit_since("cache.store_open", open_started, &[("hit", 0)]);
309            // Marker claimed a store but it is unusable: clear it so the
310            // rebuild below writes fresh rather than skipping the dead dir.
311            remove_store(&self.cache_dir, &corpus_hash);
312        }
313        // Cold miss: build the store from nothing with the parallel fragment
314        // fan-out (ADR-107/108) — byte-identical to the serial build, only
315        // faster to produce; the DECIDED_TIMING scorecard line rides here.
316        let build_started = crate::timing::start();
317        let (derived, mut stats) =
318            crate::parallel_build::build_derived_index_parallel(directory, recursive, None);
319        crate::timing::emit_since(
320            "cache.cold_build",
321            build_started,
322            &[("documents", derived.index_entries.len() as u64)],
323        );
324        let write_start = std::time::Instant::now();
325        let store_write_started = crate::timing::start();
326        let store_written = write_store(&self.cache_dir, &corpus_hash, SCHEMA_VERSION, &derived);
327        crate::timing::emit_since(
328            "cache.store_write",
329            store_write_started,
330            &[("written", u64::from(store_written))],
331        );
332        stats.write_ms = write_start.elapsed().as_secs_f64() * 1000.0;
333        crate::parallel_build::emit_build_timing(&stats);
334        if write_marker(&self.cache_dir, &corpus_hash, store_written) {
335            if let Some(view) = open_store(&self.cache_dir, &corpus_hash, SCHEMA_VERSION) {
336                return ReadModel::View(view);
337            }
338        }
339        ReadModel::Fresh(derived)
340    }
341
342    /// Whether a store directory currently exists for `corpus_hash`.
343    pub fn store_present(&self, corpus_hash: &str) -> bool {
344        store_dir(&self.cache_dir, corpus_hash).is_dir()
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn complete_manifest_hash_uses_scan_order_and_content_only() {
354        let manifest = vec![
355            (
356                "a.md".to_string(),
357                FileState {
358                    content_hash: "hash-a".to_string(),
359                    size: 10,
360                    mtime_ns: 20,
361                },
362            ),
363            (
364                "nested/b.md".to_string(),
365                FileState {
366                    content_hash: "hash-b".to_string(),
367                    size: 30,
368                    mtime_ns: 40,
369                },
370            ),
371        ];
372
373        assert_eq!(
374            corpus_hash_from_complete_manifest(&manifest),
375            crate::sha256::hexdigest(b"a.md\0hash-a\0nested/b.md\0hash-b\0")
376        );
377
378        let mut stat_only_change = manifest.clone();
379        stat_only_change[0].1.size += 1;
380        stat_only_change[0].1.mtime_ns += 1;
381        assert_eq!(
382            corpus_hash_from_complete_manifest(&manifest),
383            corpus_hash_from_complete_manifest(&stat_only_change)
384        );
385    }
386}