Skip to main content

logicaffeine_compile/vm/
tier_cache.rs

1//! Tier cache (HOTSWAP §P12): persist a compiled [`FnBytecode`] keyed by
2//! `(source, optimization-config, tier)` so a re-run skips re-optimization.
3//!
4//! Soundness rests on the key: a hit means the EXACT same source was compiled at the
5//! same config and tier. A `FnBytecode`'s `Op`s carry interner-relative `Symbol`
6//! indices, which are only meaningful within the interner that produced them — but
7//! re-parsing identical source reproduces the same interning order, so a same-source
8//! hit's indices resolve to the same symbols. Changing one byte of source (or the
9//! config, or the tier) changes the key → a miss → recompile. (m8: tier is in the key.)
10//!
11//! `encode`/`decode`/`cache_key` are platform-agnostic so the browser warm tier
12//! (P13) can store the same wire bytes through OPFS (`Vfs`); [`store`]/[`load`] are the
13//! desktop on-disk sidecar.
14
15use super::fn_bytecode::FnBytecode;
16
17/// The compiler-identity component of the cache key. A `FnBytecode`'s `Op`s carry
18/// interner-relative `Symbol` indices, and the interning ORDER is fixed by the compiler
19/// binary + the build-time-baked `lexicon.json` + the pre-seeded primitives — none of
20/// which appear in the user `source`. So the key MUST fold in a compiler-version stamp,
21/// or a post-upgrade run of byte-identical source would hit a stale entry whose indices
22/// now resolve to different symbols (a silent miscompile). This mirrors `aot_cache_key`
23/// folding in the `rustc` version. `CARGO_PKG_VERSION` is bumped in lockstep across all
24/// crates on every release (and any lexicon/interning change ships in a release), so it
25/// is the correct cross-release invalidator.
26const COMPILER_STAMP: &str = concat!("logos-tiercache-v1-", env!("CARGO_PKG_VERSION"));
27
28/// `(compiler, source, config, tier)` → a stable hex key. FNV-1a over the compiler
29/// stamp, the source bytes, the config bitset, and the tier.
30pub fn cache_key(source: &str, config_bits: u64, tier: u8) -> String {
31    format!("{:016x}", key_hash(source, config_bits, tier))
32}
33
34fn key_hash(source: &str, config_bits: u64, tier: u8) -> u64 {
35    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
36    let mut mix = |bytes: &[u8]| {
37        for &b in bytes {
38            h ^= b as u64;
39            h = h.wrapping_mul(0x0000_0100_0000_01b3);
40        }
41    };
42    mix(COMPILER_STAMP.as_bytes());
43    mix(source.as_bytes());
44    mix(&config_bits.to_le_bytes());
45    mix(&[tier]);
46    h
47}
48
49/// FNV-1a over the JSON payload — a content checksum so a truncated / bit-flipped entry
50/// that still parses as JSON is rejected rather than installed (see `decode`).
51fn payload_checksum(json: &str) -> u64 {
52    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
53    for &b in json.as_bytes() {
54        h ^= b as u64;
55        h = h.wrapping_mul(0x0000_0100_0000_01b3);
56    }
57    h
58}
59
60/// Serialize a body to the cache wire format — bit-exact (`f64` by bits, `Symbol` by
61/// index), so a decoded body is byte-identical to the one that was stored. A `checksum`
62/// line precedes the JSON so corruption is caught on the way back in.
63pub fn encode(fnbc: &FnBytecode) -> String {
64    let json = serde_json::to_string(fnbc).expect("FnBytecode serializes");
65    format!("{:016x}\n{json}", payload_checksum(&json))
66}
67
68/// Deserialize a body; `None` on ANY corruption — a corrupt entry is genuinely just a
69/// miss (the VM recompiles). Rejects: a missing/garbled checksum line, a checksum that
70/// does not match the payload (truncation / bit-flip), or non-deserializable JSON.
71pub fn decode(s: &str) -> Option<FnBytecode> {
72    let (sum, json) = s.split_once('\n')?;
73    let expected = u64::from_str_radix(sum.trim(), 16).ok()?;
74    if payload_checksum(json) != expected {
75        return None;
76    }
77    serde_json::from_str(json).ok()
78}
79
80#[cfg(not(target_arch = "wasm32"))]
81fn cache_path(dir: &std::path::Path, key: &str) -> std::path::PathBuf {
82    dir.join(format!("{key}.bc"))
83}
84
85/// Store `fnbc` under `(source, config, tier)` in `dir` (created if absent).
86/// Best-effort — a write failure just means the next run recompiles.
87#[cfg(not(target_arch = "wasm32"))]
88pub fn store(
89    dir: &std::path::Path,
90    source: &str,
91    config_bits: u64,
92    tier: u8,
93    fnbc: &FnBytecode,
94) -> std::io::Result<()> {
95    std::fs::create_dir_all(dir)?;
96    std::fs::write(cache_path(dir, &cache_key(source, config_bits, tier)), encode(fnbc))
97}
98
99/// Load the body cached for `(source, config, tier)` from `dir`; `None` on miss or
100/// corruption.
101#[cfg(not(target_arch = "wasm32"))]
102pub fn load(
103    dir: &std::path::Path,
104    source: &str,
105    config_bits: u64,
106    tier: u8,
107) -> Option<FnBytecode> {
108    let s = std::fs::read_to_string(cache_path(dir, &cache_key(source, config_bits, tier))).ok()?;
109    decode(&s)
110}