Skip to main content

axon_frontend/
compilation_cache.rs

1//! v2.76.0 — the EMS compilation cache: content-addressed, with
2//! GHC-style early cutoff. Nix model: a compile is a pure function of its
3//! inputs, so matching inputs ⇒ the recorded outcome is the outcome.
4//!
5//! # What is actually cached (stated precisely, so the claim can be true)
6//!
7//! The unit of *persisted skip* is a module's **validation** (the
8//! type-check — by far the expensive pass; the 680k-line checker dwarfs
9//! parse + IR generation). Parsing and IR generation always re-run: IR
10//! nodes are Serialize-only by design (consumers re-derive from source),
11//! so the honest cache skips what it can prove skippable and recomputes
12//! the cheap, total passes.
13//!
14//! - **Module validation hit** — a module whose `content_hash` AND
15//!   dependency `interface_hash` set match a recorded CLEAN validation
16//!   skips its per-module type-check.
17//! - **Early cutoff** — a dependency edited without changing its public
18//!   surface (comment, body-only edit) keeps its `interface_hash`, so
19//!   every dependent's key still matches: the dependents skip
20//!   re-validation. This is real, observable via [`CacheStats`], and
21//!   sound because per-module validation consumes only interface facts
22//! (v2.76.0) — nothing body-derived is cached per-dependent.
23//! - **The merged revalidation re-runs whenever any module changed.**
24//!   Cross-module semantics are global; v1 does not scope it. When NO
25//!   module changed, the project-level entry marks the whole compile
26//!   clean and the driver skips validation entirely.
27//!
28//! # Laws
29//!
30//! 1. Source hash changed → module miss.
31//! 2. Any dependency interface hash changed → module miss.
32//! 3. Both match a recorded clean validation → hit.
33//! 4. Dependency source changed, interface stable → dependents still hit
34//!    (early cutoff).
35//! 5. Writes are atomic (`.tmp` + rename). A corrupt or unreadable cache
36//!    is NOT an error: it self-heals by re-deriving from source (the
37//!    boot-hydrate doctrine — a cache is never the source of truth).
38//! 6. The manifest pins `schema_version` + `axi_format` + the compiler
39//!    version; any mismatch busts the cache wholesale.
40//!
41//! Only CLEAN validations are recorded: a failing module re-validates
42//! every run so diagnostics re-emit from source, never from a replay.
43
44use std::collections::BTreeMap;
45use std::path::{Path, PathBuf};
46
47use serde::{Deserialize, Serialize};
48
49use crate::module_interface::AXI_FORMAT_VERSION;
50
51/// On-disk manifest schema version.
52pub const CACHE_SCHEMA_VERSION: u32 = 1;
53
54/// Cache directory name, created beside the entry file.
55pub const CACHE_DIR_NAME: &str = ".axon_cache";
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
58struct ModuleEntry {
59    content_hash: String,
60    /// Dotted dependency path → its interface hash at validation time.
61    dep_interfaces: BTreeMap<String, String>,
62    interface_hash: String,
63}
64
65/// One persisted diagnostic (merged-gate warnings only — errors are
66/// NEVER cached; a failing compile re-derives from source every run).
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct CachedDiagnostic {
69    pub file: String,
70    pub line: u32,
71    pub column: u32,
72    pub message: String,
73}
74
75/// The whole-project entry: recorded ONLY after a fully-clean compile
76/// (per-module passes AND the merged revalidation). This is what makes
77/// the full-hit skip of the merged gate SOUND: a project whose previous
78/// run ended in a cross-module error has no entry, so the merged gate
79/// re-runs and the error re-emits.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81struct ProjectEntry {
82    /// SHA-256 over the sorted (module, content_hash) pairs.
83    key: String,
84    merged_warnings: Vec<CachedDiagnostic>,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize)]
88struct CacheManifest {
89    schema_version: u32,
90    axi_format: u32,
91    compiler_version: String,
92    /// Dotted module path → its last CLEAN validation inputs.
93    modules: BTreeMap<String, ModuleEntry>,
94    /// The last fully-clean whole-project compile, if any.
95    #[serde(default)]
96    project: Option<ProjectEntry>,
97}
98
99impl CacheManifest {
100    fn fresh() -> Self {
101        CacheManifest {
102            schema_version: CACHE_SCHEMA_VERSION,
103            axi_format: AXI_FORMAT_VERSION,
104            compiler_version: env!("CARGO_PKG_VERSION").to_string(),
105            modules: BTreeMap::new(),
106            project: None,
107        }
108    }
109
110    fn is_current(&self) -> bool {
111        self.schema_version == CACHE_SCHEMA_VERSION
112            && self.axi_format == AXI_FORMAT_VERSION
113            && self.compiler_version == env!("CARGO_PKG_VERSION")
114    }
115}
116
117/// Observable cache behavior — the tests' witness that the laws run.
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
119pub struct CacheStats {
120    /// Modules whose validation was skipped (laws 3–4).
121    pub validation_hits: usize,
122    /// Modules that re-validated (laws 1–2, or first sight).
123    pub validation_misses: usize,
124    /// Subset of hits where at least one dependency's SOURCE had changed
125    /// while its interface stayed stable (law 4 — the early cutoff).
126    pub early_cutoffs: usize,
127}
128
129/// The EMS compilation cache. All operations are infallible by contract:
130/// any I/O or shape problem degrades to "no cache" (law 5).
131pub struct CompilationCache {
132    root: PathBuf,
133    manifest: CacheManifest,
134    /// Content hashes seen by the PREVIOUS manifest, kept to detect the
135    /// early-cutoff condition before entries are overwritten.
136    previous_content: BTreeMap<String, String>,
137    pub stats: CacheStats,
138    dirty: bool,
139}
140
141impl CompilationCache {
142    /// Open (or initialize) the cache under `dir` (typically
143    /// `<entry dir>/.axon_cache`). Never fails: a corrupt manifest, a
144    /// version mismatch, or an unreadable directory yields a fresh cache.
145    pub fn open(dir: &Path) -> CompilationCache {
146        let manifest_path = dir.join("manifest.json");
147        let manifest = std::fs::read_to_string(&manifest_path)
148            .ok()
149            .and_then(|s| serde_json::from_str::<CacheManifest>(&s).ok())
150            .filter(CacheManifest::is_current)
151            .unwrap_or_else(CacheManifest::fresh);
152        let previous_content = manifest
153            .modules
154            .iter()
155            .map(|(k, v)| (k.clone(), v.content_hash.clone()))
156            .collect();
157        CompilationCache {
158            root: dir.to_path_buf(),
159            manifest,
160            previous_content,
161            stats: CacheStats::default(),
162            dirty: false,
163        }
164    }
165
166    /// Law 3/4 — may this module skip re-validation? Records the
167    /// hit/miss/early-cutoff in [`CacheStats`].
168    pub fn validation_hit(
169        &mut self,
170        module: &str,
171        content_hash: &str,
172        dep_interfaces: &BTreeMap<String, String>,
173    ) -> bool {
174        let hit = self
175            .manifest
176            .modules
177            .get(module)
178            .map(|e| e.content_hash == content_hash && &e.dep_interfaces == dep_interfaces)
179            .unwrap_or(false);
180        if hit {
181            self.stats.validation_hits += 1;
182            // Early cutoff: some dependency's source changed under a
183            // stable interface. Detect against the previous manifest.
184            let cutoff = dep_interfaces.keys().any(|dep| {
185                match (
186                    self.previous_content.get(dep),
187                    self.manifest.modules.get(dep),
188                ) {
189                    // The dep re-validated this run under a NEW content
190                    // hash while our recorded interface for it matched —
191                    // i.e. body changed, surface stable.
192                    (Some(prev), Some(entry)) => &entry.content_hash != prev,
193                    _ => false,
194                }
195            });
196            if cutoff {
197                self.stats.early_cutoffs += 1;
198            }
199        } else {
200            self.stats.validation_misses += 1;
201        }
202        hit
203    }
204
205    /// Record a CLEAN validation (never a failing one — diagnostics must
206    /// re-emit from source) and persist the module's `.axi`.
207    pub fn record_clean(
208        &mut self,
209        module: &str,
210        content_hash: &str,
211        dep_interfaces: BTreeMap<String, String>,
212        interface_hash: &str,
213        axi_json: &str,
214    ) {
215        self.manifest.modules.insert(
216            module.to_string(),
217            ModuleEntry {
218                content_hash: content_hash.to_string(),
219                dep_interfaces,
220                interface_hash: interface_hash.to_string(),
221            },
222        );
223        self.dirty = true;
224        let axi_dir = self.root.join("interfaces");
225        let _ = std::fs::create_dir_all(&axi_dir);
226        let _ = atomic_write(&axi_dir.join(format!("{module}.axi")), axi_json.as_bytes());
227    }
228
229    /// The recorded merged-gate warnings for a fully-clean project whose
230    /// key matches — `Some` authorizes skipping the merged revalidation
231    /// (its outcome is provably identical), `None` demands it re-run
232    /// (contents changed, or the previous run was not fully clean).
233    pub fn project_warnings(&self, key: &str) -> Option<Vec<CachedDiagnostic>> {
234        self.manifest
235            .project
236            .as_ref()
237            .filter(|p| p.key == key)
238            .map(|p| p.merged_warnings.clone())
239    }
240
241    /// Record a fully-clean whole-project compile (per-module passes AND
242    /// merged gate) with the merged gate's warnings.
243    pub fn record_project(&mut self, key: &str, merged_warnings: Vec<CachedDiagnostic>) {
244        self.manifest.project = Some(ProjectEntry {
245            key: key.to_string(),
246            merged_warnings,
247        });
248        self.dirty = true;
249    }
250
251    /// Any compile that did NOT end fully clean must drop the project
252    /// entry, so the merged gate re-runs next time (soundness of the
253    /// full-hit skip).
254    pub fn clear_project(&mut self) {
255        if self.manifest.project.is_some() {
256            self.manifest.project = None;
257            self.dirty = true;
258        }
259    }
260
261    /// Flush the manifest (atomic). Infallible by contract.
262    pub fn flush(&mut self) {
263        if !self.dirty {
264            return;
265        }
266        let _ = std::fs::create_dir_all(&self.root);
267        if let Ok(json) = serde_json::to_string_pretty(&self.manifest) {
268            let _ = atomic_write(&self.root.join("manifest.json"), json.as_bytes());
269        }
270        self.dirty = false;
271    }
272}
273
274/// Atomic file write: temp sibling + rename (law 5). Windows rename over
275/// an existing file fails, so the stale target is removed first — the
276/// worst crash outcome is a MISSING cache entry, which self-heals.
277fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
278    let tmp = path.with_extension("tmp");
279    std::fs::write(&tmp, bytes)?;
280    let _ = std::fs::remove_file(path);
281    std::fs::rename(&tmp, path)
282}
283
284// ════════════════════════════════════════════════════════════════════
285//  Unit tests (integration suite: tests/cache_laws.rs)
286// ════════════════════════════════════════════════════════════════════
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn deps(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
293        pairs
294            .iter()
295            .map(|(k, v)| (k.to_string(), v.to_string()))
296            .collect()
297    }
298
299    #[test]
300    fn miss_then_hit_then_source_invalidation() {
301        let dir = std::env::temp_dir().join(format!(
302            "axon_cache_test_{}_{:?}",
303            std::process::id(),
304            std::thread::current().id()
305        ));
306        let _ = std::fs::remove_dir_all(&dir);
307
308        let mut c = CompilationCache::open(&dir);
309        assert!(!c.validation_hit("m", "h1", &deps(&[])));
310        c.record_clean("m", "h1", deps(&[]), "i1", "{}");
311        c.flush();
312
313        let mut c2 = CompilationCache::open(&dir);
314        assert!(c2.validation_hit("m", "h1", &deps(&[])), "law 3");
315        assert!(!c2.validation_hit("m", "h2", &deps(&[])), "law 1");
316        assert_eq!(c2.stats.validation_hits, 1);
317        assert_eq!(c2.stats.validation_misses, 1);
318
319        let _ = std::fs::remove_dir_all(&dir);
320    }
321
322    #[test]
323    fn dependency_interface_invalidates() {
324        let dir = std::env::temp_dir().join(format!(
325            "axon_cache_dep_{}_{:?}",
326            std::process::id(),
327            std::thread::current().id()
328        ));
329        let _ = std::fs::remove_dir_all(&dir);
330
331        let mut c = CompilationCache::open(&dir);
332        c.record_clean("main", "h1", deps(&[("lib", "i1")]), "im", "{}");
333        c.flush();
334
335        let mut c2 = CompilationCache::open(&dir);
336        assert!(c2.validation_hit("main", "h1", &deps(&[("lib", "i1")])));
337        assert!(!c2.validation_hit("main", "h1", &deps(&[("lib", "i2")])), "law 2");
338
339        let _ = std::fs::remove_dir_all(&dir);
340    }
341
342    #[test]
343    fn corrupt_manifest_self_heals() {
344        let dir = std::env::temp_dir().join(format!(
345            "axon_cache_heal_{}_{:?}",
346            std::process::id(),
347            std::thread::current().id()
348        ));
349        let _ = std::fs::remove_dir_all(&dir);
350        std::fs::create_dir_all(&dir).unwrap();
351        std::fs::write(dir.join("manifest.json"), b"{ not json").unwrap();
352
353        let mut c = CompilationCache::open(&dir); // law 5: no panic, no error
354        assert!(!c.validation_hit("m", "h1", &deps(&[])));
355
356        let _ = std::fs::remove_dir_all(&dir);
357    }
358
359    #[test]
360    fn schema_version_busts_wholesale() {
361        let dir = std::env::temp_dir().join(format!(
362            "axon_cache_ver_{}_{:?}",
363            std::process::id(),
364            std::thread::current().id()
365        ));
366        let _ = std::fs::remove_dir_all(&dir);
367        std::fs::create_dir_all(&dir).unwrap();
368        let stale = serde_json::json!({
369            "schema_version": 0,
370            "axi_format": 0,
371            "compiler_version": "0.0.0",
372            "modules": { "m": { "content_hash": "h1", "dep_interfaces": {}, "interface_hash": "i1" } }
373        });
374        std::fs::write(dir.join("manifest.json"), stale.to_string()).unwrap();
375
376        let mut c = CompilationCache::open(&dir);
377        assert!(!c.validation_hit("m", "h1", &deps(&[])), "law 6");
378
379        let _ = std::fs::remove_dir_all(&dir);
380    }
381}