Skip to main content

xlog_prob/compilation/
disk_cache.rs

1//! On-disk circuit artifact cache.
2//!
3//! Caches compiled circuit topology so d-DNNF compilation is skipped on warm starts.
4//! Stores topology and metadata only -- weights change per query.
5
6use std::fs;
7use std::io::Write;
8use std::path::{Path, PathBuf};
9
10use xlog_core::{Result, XlogError};
11
12use crate::xgcf::XgcfNodeType;
13
14const MAGIC: u32 = 0x584C4743; // "XLGC"
15const FORMAT_VERSION: u32 = 1;
16
17/// Header size in bytes:
18/// magic(4) + version(4) + cnf_hash(8) + config_hash(8) + random_vars_hash(8) +
19/// sm(4) + num_nodes(4) + num_edges(4) + num_levels(4) + root(4) + max_var(4) +
20/// has_free_var_mask(1) + padding(3) = 60
21const HEADER_SIZE: usize = 60;
22
23#[derive(Debug, Clone)]
24pub(crate) struct CircuitCacheKey {
25    pub cnf_hash: u64,
26    pub config_hash: u64,
27    pub random_vars_hash: u64,
28    pub sm: u32,
29}
30
31#[derive(Debug)]
32pub(crate) struct CircuitArtifact {
33    pub num_nodes: u32,
34    pub num_edges: u32,
35    pub num_levels: u32,
36    pub root: u32,
37    pub max_var: u32,
38    pub has_free_var_mask: bool,
39    pub node_type: Vec<u8>,
40    pub child_offsets: Vec<u32>,
41    pub child_indices: Vec<u32>,
42    pub lit: Vec<i32>,
43    pub decision_var: Vec<u32>,
44    pub decision_child_false: Vec<u32>,
45    pub decision_child_true: Vec<u32>,
46    pub level_nodes: Vec<u32>,
47    pub level_offsets: Vec<u32>,
48    pub free_var_mask: Vec<u8>,
49}
50
51/// Resolve the cache directory.
52///
53/// Priority: `XLOG_CIRCUIT_CACHE_DIR` env var > `XDG_CACHE_HOME`/xlog/circuits >
54/// `HOME`/.cache/xlog/circuits.
55pub(crate) fn cache_dir() -> PathBuf {
56    if let Ok(dir) = std::env::var("XLOG_CIRCUIT_CACHE_DIR") {
57        PathBuf::from(dir)
58    } else if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
59        PathBuf::from(xdg).join("xlog").join("circuits")
60    } else {
61        PathBuf::from(std::env::var("HOME").unwrap_or_default())
62            .join(".cache")
63            .join("xlog")
64            .join("circuits")
65    }
66}
67
68fn artifact_filename(key: &CircuitCacheKey) -> String {
69    format!(
70        "{:016x}_{:016x}_{:016x}_{:08x}_{:08x}.bin",
71        key.cnf_hash, key.config_hash, key.random_vars_hash, key.sm, FORMAT_VERSION,
72    )
73}
74
75/// Write a circuit artifact to the on-disk cache.
76///
77/// Uses atomic rename (write to `.tmp`, then rename) so readers never see a partial file.
78pub(crate) fn write_artifact(key: &CircuitCacheKey, artifact: &CircuitArtifact) -> Result<()> {
79    write_artifact_to(&cache_dir(), key, artifact)
80}
81
82/// Read a circuit artifact from the on-disk cache.
83///
84/// Returns `Ok(None)` if the cache file does not exist or fails validation (stale entry).
85/// Returns `Err` only on genuine IO errors after the file has been opened.
86pub(crate) fn read_artifact(key: &CircuitCacheKey) -> Result<Option<CircuitArtifact>> {
87    read_artifact_from(&cache_dir(), key)
88}
89
90/// Best-effort eviction of a stale cache entry.
91///
92/// Used when a cached circuit fails validity against the CURRENT compile (e.g. its
93/// var universe no longer matches the freshly-encoded CNF after an engine change):
94/// the canonical PIR hash keeps semantically-stable cache identity, so entries
95/// written by an earlier engine whose encoding differed can collide with the same
96/// key. Such entries are staleness, not corruption — remove and recompile.
97pub(crate) fn evict_artifact(key: &CircuitCacheKey) {
98    evict_artifact_from(&cache_dir(), key);
99}
100
101fn evict_artifact_from(dir: &Path, key: &CircuitCacheKey) {
102    let _ = fs::remove_file(dir.join(artifact_filename(key)));
103}
104
105// ---------------------------------------------------------------------------
106// Internal implementations that accept an explicit directory (testable).
107// ---------------------------------------------------------------------------
108
109fn io_err(e: std::io::Error) -> XlogError {
110    XlogError::Compilation(format!("circuit cache IO error: {}", e))
111}
112
113/// Read `count` little-endian u32 values from `data` starting at `offset`.
114///
115/// Unlike `bytemuck::cast_slice`, this does not require 4-byte alignment of the
116/// source slice, which matters when the preceding u8 array (`node_type`) has a
117/// length that is not a multiple of 4.
118fn read_u32_vec(data: &[u8], offset: usize, count: usize) -> Vec<u32> {
119    (0..count)
120        .map(|i| {
121            let s = offset + i * 4;
122            u32::from_le_bytes([data[s], data[s + 1], data[s + 2], data[s + 3]])
123        })
124        .collect()
125}
126
127/// Read `count` little-endian i32 values from `data` starting at `offset`.
128fn read_i32_vec(data: &[u8], offset: usize, count: usize) -> Vec<i32> {
129    (0..count)
130        .map(|i| {
131            let s = offset + i * 4;
132            i32::from_le_bytes([data[s], data[s + 1], data[s + 2], data[s + 3]])
133        })
134        .collect()
135}
136
137fn checked_bytes(elems: usize, bytes_per_elem: usize) -> Option<usize> {
138    elems.checked_mul(bytes_per_elem)
139}
140
141fn checked_sum(parts: &[usize]) -> Option<usize> {
142    parts
143        .iter()
144        .try_fold(0usize, |acc, part| acc.checked_add(*part))
145}
146
147fn valid_node_type(ty: u8) -> bool {
148    ty <= XgcfNodeType::Decision as u8
149}
150
151fn artifact_topology_is_valid(artifact: &CircuitArtifact) -> bool {
152    if artifact.num_nodes == 0 || artifact.num_levels == 0 || artifact.root >= artifact.num_nodes {
153        return false;
154    }
155
156    let num_nodes = artifact.num_nodes as usize;
157    let num_edges = artifact.num_edges as usize;
158    let num_levels = artifact.num_levels as usize;
159    let Ok(max_var) = usize::try_from(artifact.max_var) else {
160        return false;
161    };
162    let Some(free_var_mask_len) = max_var.checked_add(1) else {
163        return false;
164    };
165
166    if artifact.node_type.len() != num_nodes
167        || artifact.child_offsets.len() != num_nodes + 1
168        || artifact.child_indices.len() != num_edges
169        || artifact.lit.len() != num_nodes
170        || artifact.decision_var.len() != num_nodes
171        || artifact.decision_child_false.len() != num_nodes
172        || artifact.decision_child_true.len() != num_nodes
173        || artifact.level_nodes.len() != num_nodes
174        || artifact.level_offsets.len() != num_levels + 1
175        || artifact.free_var_mask.len() != free_var_mask_len
176    {
177        return false;
178    }
179
180    if artifact.child_offsets.first().copied() != Some(0)
181        || artifact.child_offsets.last().copied() != Some(artifact.num_edges)
182    {
183        return false;
184    }
185    let mut prev = 0u32;
186    for &offset in &artifact.child_offsets {
187        if offset < prev || offset > artifact.num_edges {
188            return false;
189        }
190        prev = offset;
191    }
192    if artifact
193        .child_indices
194        .iter()
195        .any(|&child| child >= artifact.num_nodes)
196    {
197        return false;
198    }
199
200    if artifact.level_offsets.first().copied() != Some(0)
201        || artifact.level_offsets.last().copied() != Some(artifact.num_nodes)
202    {
203        return false;
204    }
205    let mut prev = 0u32;
206    for &offset in &artifact.level_offsets {
207        if offset < prev || offset > artifact.num_nodes {
208            return false;
209        }
210        prev = offset;
211    }
212    if artifact
213        .level_nodes
214        .iter()
215        .any(|&node| node >= artifact.num_nodes)
216    {
217        return false;
218    }
219
220    for idx in 0..num_nodes {
221        let ty = artifact.node_type[idx];
222        if !valid_node_type(ty) {
223            return false;
224        }
225        match ty {
226            t if t == XgcfNodeType::Lit as u8
227                && artifact.lit[idx].unsigned_abs() > artifact.max_var =>
228            {
229                return false;
230            }
231            t if t == XgcfNodeType::Decision as u8 => {
232                if artifact.decision_var[idx] > artifact.max_var {
233                    return false;
234                }
235                if artifact.decision_child_false[idx] >= artifact.num_nodes
236                    || artifact.decision_child_true[idx] >= artifact.num_nodes
237                {
238                    return false;
239                }
240            }
241            _ => {}
242        }
243    }
244
245    true
246}
247
248fn write_artifact_to(dir: &Path, key: &CircuitCacheKey, artifact: &CircuitArtifact) -> Result<()> {
249    fs::create_dir_all(dir).map_err(io_err)?;
250
251    let path = dir.join(artifact_filename(key));
252    let tmp = path.with_extension("tmp");
253    let mut f = fs::File::create(&tmp).map_err(io_err)?;
254
255    // Write header
256    f.write_all(&MAGIC.to_le_bytes()).map_err(io_err)?;
257    f.write_all(&FORMAT_VERSION.to_le_bytes()).map_err(io_err)?;
258    f.write_all(&key.cnf_hash.to_le_bytes()).map_err(io_err)?;
259    f.write_all(&key.config_hash.to_le_bytes())
260        .map_err(io_err)?;
261    f.write_all(&key.random_vars_hash.to_le_bytes())
262        .map_err(io_err)?;
263    f.write_all(&key.sm.to_le_bytes()).map_err(io_err)?;
264    f.write_all(&artifact.num_nodes.to_le_bytes())
265        .map_err(io_err)?;
266    f.write_all(&artifact.num_edges.to_le_bytes())
267        .map_err(io_err)?;
268    f.write_all(&artifact.num_levels.to_le_bytes())
269        .map_err(io_err)?;
270    f.write_all(&artifact.root.to_le_bytes()).map_err(io_err)?;
271    f.write_all(&artifact.max_var.to_le_bytes())
272        .map_err(io_err)?;
273    f.write_all(&[artifact.has_free_var_mask as u8])
274        .map_err(io_err)?;
275    f.write_all(&[0u8; 3]).map_err(io_err)?; // padding
276
277    // Write arrays as raw bytes
278    f.write_all(&artifact.node_type).map_err(io_err)?;
279    f.write_all(bytemuck::cast_slice(&artifact.child_offsets))
280        .map_err(io_err)?;
281    f.write_all(bytemuck::cast_slice(&artifact.child_indices))
282        .map_err(io_err)?;
283    f.write_all(bytemuck::cast_slice(&artifact.lit))
284        .map_err(io_err)?;
285    f.write_all(bytemuck::cast_slice(&artifact.decision_var))
286        .map_err(io_err)?;
287    f.write_all(bytemuck::cast_slice(&artifact.decision_child_false))
288        .map_err(io_err)?;
289    f.write_all(bytemuck::cast_slice(&artifact.decision_child_true))
290        .map_err(io_err)?;
291    f.write_all(bytemuck::cast_slice(&artifact.level_nodes))
292        .map_err(io_err)?;
293    f.write_all(bytemuck::cast_slice(&artifact.level_offsets))
294        .map_err(io_err)?;
295    f.write_all(&artifact.free_var_mask).map_err(io_err)?;
296
297    drop(f);
298    fs::rename(&tmp, &path).map_err(io_err)?;
299
300    evict_if_needed_in(dir)?;
301    Ok(())
302}
303
304fn read_artifact_from(dir: &Path, key: &CircuitCacheKey) -> Result<Option<CircuitArtifact>> {
305    let path = dir.join(artifact_filename(key));
306
307    let data = match fs::read(&path) {
308        Ok(d) => d,
309        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
310        Err(e) => {
311            return Err(XlogError::Compilation(format!(
312                "Failed to read cache file: {}",
313                e
314            )))
315        }
316    };
317
318    parse_artifact(&data, key)
319}
320
321/// Parse a circuit artifact from raw file bytes, validating header and key.
322fn parse_artifact(data: &[u8], key: &CircuitCacheKey) -> Result<Option<CircuitArtifact>> {
323    // 1. Check minimum header size
324    if data.len() < HEADER_SIZE {
325        return Ok(None);
326    }
327
328    let mut cursor = 0usize;
329
330    macro_rules! read_u32 {
331        () => {{
332            let val = u32::from_le_bytes([
333                data[cursor],
334                data[cursor + 1],
335                data[cursor + 2],
336                data[cursor + 3],
337            ]);
338            cursor += 4;
339            val
340        }};
341    }
342
343    macro_rules! read_u64 {
344        () => {{
345            let val = u64::from_le_bytes([
346                data[cursor],
347                data[cursor + 1],
348                data[cursor + 2],
349                data[cursor + 3],
350                data[cursor + 4],
351                data[cursor + 5],
352                data[cursor + 6],
353                data[cursor + 7],
354            ]);
355            cursor += 8;
356            val
357        }};
358    }
359
360    // 2. Validate magic
361    let magic = read_u32!();
362    if magic != MAGIC {
363        return Ok(None);
364    }
365
366    // 3. Validate format version
367    let version = read_u32!();
368    if version != FORMAT_VERSION {
369        return Ok(None);
370    }
371
372    // 4. Validate key fields
373    let cnf_hash = read_u64!();
374    let config_hash = read_u64!();
375    let random_vars_hash = read_u64!();
376    let sm = read_u32!();
377
378    if cnf_hash != key.cnf_hash
379        || config_hash != key.config_hash
380        || random_vars_hash != key.random_vars_hash
381        || sm != key.sm
382    {
383        return Ok(None);
384    }
385
386    // 5. Parse metadata from header
387    let num_nodes = read_u32!();
388    let num_edges = read_u32!();
389    let num_levels = read_u32!();
390    let root = read_u32!();
391    let max_var = read_u32!();
392    let has_free_var_mask = data[cursor] != 0;
393    cursor += 1;
394    cursor += 3; // skip padding
395
396    debug_assert_eq!(cursor, HEADER_SIZE);
397
398    if num_nodes == 0 || num_levels == 0 || root >= num_nodes {
399        return Ok(None);
400    }
401
402    // 6. Calculate expected array sizes with checked arithmetic. The disk cache is
403    // untrusted process state; malformed metadata must be treated as a stale entry.
404    let Ok(num_nodes_usize) = usize::try_from(num_nodes) else {
405        return Ok(None);
406    };
407    let Ok(num_edges_usize) = usize::try_from(num_edges) else {
408        return Ok(None);
409    };
410    let Ok(num_levels_usize) = usize::try_from(num_levels) else {
411        return Ok(None);
412    };
413    let Ok(max_var_usize) = usize::try_from(max_var) else {
414        return Ok(None);
415    };
416
417    let Some(child_offsets_elems) = num_nodes_usize.checked_add(1) else {
418        return Ok(None);
419    };
420    let Some(level_offsets_elems) = num_levels_usize.checked_add(1) else {
421        return Ok(None);
422    };
423    let Some(free_var_mask_bytes) = max_var_usize.checked_add(1) else {
424        return Ok(None);
425    };
426
427    let node_type_bytes = num_nodes_usize;
428    let Some(child_offsets_bytes) = checked_bytes(child_offsets_elems, 4) else {
429        return Ok(None);
430    };
431    let Some(child_indices_bytes) = checked_bytes(num_edges_usize, 4) else {
432        return Ok(None);
433    };
434    let Some(lit_bytes) = checked_bytes(num_nodes_usize, 4) else {
435        return Ok(None);
436    };
437    let Some(decision_var_bytes) = checked_bytes(num_nodes_usize, 4) else {
438        return Ok(None);
439    };
440    let Some(decision_child_false_bytes) = checked_bytes(num_nodes_usize, 4) else {
441        return Ok(None);
442    };
443    let Some(decision_child_true_bytes) = checked_bytes(num_nodes_usize, 4) else {
444        return Ok(None);
445    };
446    let Some(level_nodes_bytes) = checked_bytes(num_nodes_usize, 4) else {
447        return Ok(None);
448    };
449    let Some(level_offsets_bytes) = checked_bytes(level_offsets_elems, 4) else {
450        return Ok(None);
451    };
452
453    let Some(expected_total) = checked_sum(&[
454        HEADER_SIZE,
455        node_type_bytes,
456        child_offsets_bytes,
457        child_indices_bytes,
458        lit_bytes,
459        decision_var_bytes,
460        decision_child_false_bytes,
461        decision_child_true_bytes,
462        level_nodes_bytes,
463        level_offsets_bytes,
464        free_var_mask_bytes,
465    ]) else {
466        return Ok(None);
467    };
468
469    if data.len() < expected_total {
470        return Ok(None);
471    }
472
473    // 7. Parse arrays from raw bytes.
474    //
475    // We use from_le_bytes helpers instead of bytemuck::cast_slice because
476    // the cursor may not be 4-byte aligned after reading the u8 node_type
477    // array (e.g. when num_nodes is not a multiple of 4).
478    let node_type = data[cursor..cursor + node_type_bytes].to_vec();
479    cursor += node_type_bytes;
480
481    let child_offsets = read_u32_vec(data, cursor, child_offsets_elems);
482    cursor += child_offsets_bytes;
483
484    let child_indices = read_u32_vec(data, cursor, num_edges_usize);
485    cursor += child_indices_bytes;
486
487    let lit = read_i32_vec(data, cursor, num_nodes_usize);
488    cursor += lit_bytes;
489
490    let decision_var = read_u32_vec(data, cursor, num_nodes_usize);
491    cursor += decision_var_bytes;
492
493    let decision_child_false = read_u32_vec(data, cursor, num_nodes_usize);
494    cursor += decision_child_false_bytes;
495
496    let decision_child_true = read_u32_vec(data, cursor, num_nodes_usize);
497    cursor += decision_child_true_bytes;
498
499    let level_nodes = read_u32_vec(data, cursor, num_nodes_usize);
500    cursor += level_nodes_bytes;
501
502    let level_offsets = read_u32_vec(data, cursor, level_offsets_elems);
503    cursor += level_offsets_bytes;
504
505    let free_var_mask = data[cursor..cursor + free_var_mask_bytes].to_vec();
506    // cursor += free_var_mask_bytes; // not needed after last read
507
508    let artifact = CircuitArtifact {
509        num_nodes,
510        num_edges,
511        num_levels,
512        root,
513        max_var,
514        has_free_var_mask,
515        node_type,
516        child_offsets,
517        child_indices,
518        lit,
519        decision_var,
520        decision_child_false,
521        decision_child_true,
522        level_nodes,
523        level_offsets,
524        free_var_mask,
525    };
526
527    if !artifact_topology_is_valid(&artifact) {
528        return Ok(None);
529    }
530
531    Ok(Some(artifact))
532}
533
534/// Evict oldest cache entries when total cache size exceeds the limit.
535///
536/// Default limit is 512 MB, configurable via `XLOG_CIRCUIT_CACHE_MAX_MB`.
537fn evict_if_needed_in(dir: &Path) -> Result<()> {
538    let max_mb: u64 = std::env::var("XLOG_CIRCUIT_CACHE_MAX_MB")
539        .ok()
540        .and_then(|v| v.parse().ok())
541        .unwrap_or(512);
542    evict_if_needed_in_with_limit(dir, max_mb)
543}
544
545fn evict_if_needed_in_with_limit(dir: &Path, max_mb: u64) -> Result<()> {
546    let max_bytes = max_mb * 1024 * 1024;
547
548    let entries = match fs::read_dir(dir) {
549        Ok(e) => e,
550        Err(_) => return Ok(()), // directory gone or unreadable, nothing to evict
551    };
552
553    // Collect .bin files with their size and mtime.
554    let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
555    let mut total_size: u64 = 0;
556
557    for entry in entries {
558        let entry = match entry {
559            Ok(e) => e,
560            Err(_) => continue,
561        };
562        let path = entry.path();
563        if path.extension().and_then(|e| e.to_str()) != Some("bin") {
564            continue;
565        }
566        let meta = match entry.metadata() {
567            Ok(m) => m,
568            Err(_) => continue,
569        };
570        if !meta.is_file() {
571            continue;
572        }
573        let size = meta.len();
574        let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
575        total_size += size;
576        files.push((path, size, mtime));
577    }
578
579    if total_size <= max_bytes {
580        return Ok(());
581    }
582
583    // Sort by mtime ascending (oldest first)
584    files.sort_by_key(|&(_, _, mtime)| mtime);
585
586    for (path, size, _) in &files {
587        if total_size <= max_bytes {
588            break;
589        }
590        // Best-effort: ignore errors when removing individual files during eviction.
591        if fs::remove_file(path).is_ok() {
592            total_size = total_size.saturating_sub(*size);
593        }
594    }
595
596    Ok(())
597}
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602    use std::sync::atomic::{AtomicU64, Ordering};
603
604    // Use a unique directory per test to avoid interference between parallel tests.
605    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
606
607    fn test_cache_dir() -> PathBuf {
608        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
609        let pid = std::process::id();
610        let dir = std::env::temp_dir()
611            .join("xlog_disk_cache_test")
612            .join(format!("{}_{}", pid, id));
613        fs::create_dir_all(&dir).expect("create test cache dir");
614        dir
615    }
616
617    fn make_key(cnf_hash: u64) -> CircuitCacheKey {
618        CircuitCacheKey {
619            cnf_hash,
620            config_hash: 0xDEADBEEF,
621            random_vars_hash: 0xCAFEBABE,
622            sm: 89,
623        }
624    }
625
626    fn make_artifact() -> CircuitArtifact {
627        // A small circuit: 4 nodes, 3 edges, 2 levels, max_var=2
628        CircuitArtifact {
629            num_nodes: 4,
630            num_edges: 3,
631            num_levels: 2,
632            root: 0,
633            max_var: 2,
634            has_free_var_mask: true,
635            node_type: vec![1, 2, 3, 4],
636            child_offsets: vec![0, 1, 2, 3, 3], // num_nodes + 1 = 5
637            child_indices: vec![1, 2, 3],       // num_edges = 3
638            lit: vec![0, 1, -1, 2],             // num_nodes = 4
639            decision_var: vec![0, 1, 2, 0],
640            decision_child_false: vec![0, 2, 3, 0],
641            decision_child_true: vec![0, 1, 3, 0],
642            level_nodes: vec![0, 1, 2, 3], // num_nodes = 4
643            level_offsets: vec![0, 1, 4],  // num_levels + 1 = 3
644            free_var_mask: vec![0, 1, 0],  // max_var + 1 = 3
645        }
646    }
647
648    #[test]
649    fn test_roundtrip() {
650        let dir = test_cache_dir();
651
652        let key = make_key(0x1234);
653        let artifact = make_artifact();
654
655        write_artifact_to(&dir, &key, &artifact).expect("write should succeed");
656
657        let loaded = read_artifact_from(&dir, &key)
658            .expect("read should not error")
659            .expect("read should return Some");
660
661        assert_eq!(loaded.num_nodes, artifact.num_nodes);
662        assert_eq!(loaded.num_edges, artifact.num_edges);
663        assert_eq!(loaded.num_levels, artifact.num_levels);
664        assert_eq!(loaded.root, artifact.root);
665        assert_eq!(loaded.max_var, artifact.max_var);
666        assert_eq!(loaded.has_free_var_mask, artifact.has_free_var_mask);
667        assert_eq!(loaded.node_type, artifact.node_type);
668        assert_eq!(loaded.child_offsets, artifact.child_offsets);
669        assert_eq!(loaded.child_indices, artifact.child_indices);
670        assert_eq!(loaded.lit, artifact.lit);
671        assert_eq!(loaded.decision_var, artifact.decision_var);
672        assert_eq!(loaded.decision_child_false, artifact.decision_child_false);
673        assert_eq!(loaded.decision_child_true, artifact.decision_child_true);
674        assert_eq!(loaded.level_nodes, artifact.level_nodes);
675        assert_eq!(loaded.level_offsets, artifact.level_offsets);
676        assert_eq!(loaded.free_var_mask, artifact.free_var_mask);
677
678        let _ = fs::remove_dir_all(&dir);
679    }
680
681    /// Regression test: num_nodes=5 is not a multiple of 4, so the u32
682    /// arrays after node_type start at a non-4-byte-aligned offset.
683    /// The old bytemuck::cast_slice read path would panic here.
684    #[test]
685    fn test_roundtrip_unaligned_num_nodes() {
686        let dir = test_cache_dir();
687
688        let key = make_key(0x5555);
689        // 5 nodes, 4 edges, 3 levels, max_var=3
690        let artifact = CircuitArtifact {
691            num_nodes: 5,
692            num_edges: 4,
693            num_levels: 3,
694            root: 0,
695            max_var: 3,
696            has_free_var_mask: false,
697            node_type: vec![1, 2, 3, 4, 5],
698            child_offsets: vec![0, 1, 2, 3, 4, 4], // num_nodes + 1 = 6
699            child_indices: vec![1, 2, 3, 4],       // num_edges = 4
700            lit: vec![0, 1, -1, 2, -2],            // num_nodes = 5
701            decision_var: vec![0, 1, 2, 3, 0],
702            decision_child_false: vec![0, 2, 3, 4, 0],
703            decision_child_true: vec![0, 1, 3, 4, 0],
704            level_nodes: vec![0, 1, 2, 3, 4], // num_nodes = 5
705            level_offsets: vec![0, 1, 3, 5],  // num_levels + 1 = 4
706            free_var_mask: vec![0, 1, 0, 1],  // max_var + 1 = 4
707        };
708
709        write_artifact_to(&dir, &key, &artifact).expect("write should succeed");
710
711        let loaded = read_artifact_from(&dir, &key)
712            .expect("read should not error")
713            .expect("read should return Some");
714
715        assert_eq!(loaded.num_nodes, artifact.num_nodes);
716        assert_eq!(loaded.num_edges, artifact.num_edges);
717        assert_eq!(loaded.num_levels, artifact.num_levels);
718        assert_eq!(loaded.root, artifact.root);
719        assert_eq!(loaded.max_var, artifact.max_var);
720        assert_eq!(loaded.has_free_var_mask, artifact.has_free_var_mask);
721        assert_eq!(loaded.node_type, artifact.node_type);
722        assert_eq!(loaded.child_offsets, artifact.child_offsets);
723        assert_eq!(loaded.child_indices, artifact.child_indices);
724        assert_eq!(loaded.lit, artifact.lit);
725        assert_eq!(loaded.decision_var, artifact.decision_var);
726        assert_eq!(loaded.decision_child_false, artifact.decision_child_false);
727        assert_eq!(loaded.decision_child_true, artifact.decision_child_true);
728        assert_eq!(loaded.level_nodes, artifact.level_nodes);
729        assert_eq!(loaded.level_offsets, artifact.level_offsets);
730        assert_eq!(loaded.free_var_mask, artifact.free_var_mask);
731
732        let _ = fs::remove_dir_all(&dir);
733    }
734
735    #[test]
736    fn test_mismatched_key_returns_none() {
737        let dir = test_cache_dir();
738
739        let key = make_key(0xAAAA);
740        let artifact = make_artifact();
741
742        write_artifact_to(&dir, &key, &artifact).expect("write should succeed");
743
744        // Different cnf_hash => different file path, so returns None (file not found)
745        let different_key = make_key(0xBBBB);
746        let result = read_artifact_from(&dir, &different_key).expect("read should not error");
747        assert!(result.is_none(), "mismatched key should return None");
748
749        let _ = fs::remove_dir_all(&dir);
750    }
751
752    #[test]
753    fn test_missing_file_returns_none() {
754        let dir = test_cache_dir();
755
756        let key = make_key(0x9999);
757        let result = read_artifact_from(&dir, &key).expect("read should not error");
758        assert!(result.is_none(), "missing file should return None");
759
760        let _ = fs::remove_dir_all(&dir);
761    }
762
763    #[test]
764    fn test_truncated_file_returns_none() {
765        let dir = test_cache_dir();
766
767        let key = make_key(0x7777);
768        let artifact = make_artifact();
769        write_artifact_to(&dir, &key, &artifact).expect("write should succeed");
770
771        // Truncate the file to half its header
772        let path = dir.join(artifact_filename(&key));
773        let data = fs::read(&path).unwrap();
774        fs::write(&path, &data[..HEADER_SIZE / 2]).unwrap();
775
776        let result = read_artifact_from(&dir, &key).expect("read should not error");
777        assert!(result.is_none(), "truncated file should return None");
778
779        let _ = fs::remove_dir_all(&dir);
780    }
781
782    #[test]
783    fn test_corrupted_magic_returns_none() {
784        let dir = test_cache_dir();
785
786        let key = make_key(0x6666);
787        let artifact = make_artifact();
788        write_artifact_to(&dir, &key, &artifact).expect("write should succeed");
789
790        // Corrupt the magic bytes
791        let path = dir.join(artifact_filename(&key));
792        let mut data = fs::read(&path).unwrap();
793        data[0] = 0xFF;
794        data[1] = 0xFF;
795        fs::write(&path, &data).unwrap();
796
797        let result = read_artifact_from(&dir, &key).expect("read should not error");
798        assert!(result.is_none(), "corrupted magic should return None");
799
800        let _ = fs::remove_dir_all(&dir);
801    }
802
803    #[test]
804    fn test_eviction() {
805        let dir = test_cache_dir();
806
807        let key1 = make_key(0x1111);
808        let key2 = make_key(0x2222);
809        let artifact = make_artifact();
810
811        // Write two artifacts without eviction (using the internal write_artifact_to
812        // which calls evict_if_needed_in with the default 512MB limit).
813        write_artifact_to(&dir, &key1, &artifact).expect("write 1 should succeed");
814        write_artifact_to(&dir, &key2, &artifact).expect("write 2 should succeed");
815
816        // Both should be readable
817        assert!(read_artifact_from(&dir, &key1).unwrap().is_some());
818        assert!(read_artifact_from(&dir, &key2).unwrap().is_some());
819
820        // Now force eviction with 0 MB limit
821        evict_if_needed_in_with_limit(&dir, 0).expect("eviction should succeed");
822
823        // All files should have been evicted
824        let r1 = read_artifact_from(&dir, &key1).unwrap();
825        let r2 = read_artifact_from(&dir, &key2).unwrap();
826        assert!(r1.is_none(), "key1 should have been evicted");
827        assert!(r2.is_none(), "key2 should have been evicted");
828
829        let _ = fs::remove_dir_all(&dir);
830    }
831
832    #[test]
833    fn test_evict_artifact_removes_stale_entry() {
834        // Regression (engine defect #2): a cached circuit whose var universe no
835        // longer matches the freshly-encoded CNF (canonical PIR hash keeps
836        // semantically-stable identity across engine changes that alter the
837        // encoding) must be evictable so the compile path can fall back to a
838        // fresh compile instead of failing with a var-mismatch compile error.
839        let dir = test_cache_dir();
840        let key = make_key(0xA11CE);
841        let artifact = make_artifact();
842
843        write_artifact_to(&dir, &key, &artifact).expect("write should succeed");
844        assert!(read_artifact_from(&dir, &key).expect("read ok").is_some());
845
846        evict_artifact_from(&dir, &key);
847        assert!(
848            read_artifact_from(&dir, &key).expect("read ok").is_none(),
849            "evicted entry must read as a cache miss"
850        );
851        // Eviction of a missing entry is a no-op, not an error.
852        evict_artifact_from(&dir, &key);
853
854        let _ = fs::remove_dir_all(&dir);
855    }
856}