Skip to main content

aft/
cache_freshness.rs

1use rayon::prelude::*;
2#[cfg(debug_assertions)]
3use std::cell::Cell;
4use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7#[cfg(debug_assertions)]
8use std::sync::atomic::{AtomicUsize, Ordering};
9use std::sync::{Mutex, OnceLock};
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12pub const CONTENT_HASH_SIZE_CAP: u64 = 4 * 1024 * 1024;
13
14#[cfg(debug_assertions)]
15static STRICT_VERIFY_FILE_CALLS: AtomicUsize = AtomicUsize::new(0);
16#[cfg(debug_assertions)]
17thread_local! {
18    static HASH_FILE_IF_SMALL_CALLS: Cell<usize> = const { Cell::new(0) };
19}
20#[cfg(any(debug_assertions, test))]
21static WATCHED_HASH_FILE: OnceLock<Mutex<Option<(PathBuf, usize)>>> = OnceLock::new();
22
23const VERIFY_MEMO_TTL: Duration = Duration::from_secs(10 * 60);
24static VERIFY_MEMO: OnceLock<Mutex<VerifyMemo>> = OnceLock::new();
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
27pub(crate) enum VerifyArtifact {
28    Search,
29    Semantic,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub(crate) enum WarmVerifyPlan {
34    Skip,
35    StatFirst,
36    Strict,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub(crate) enum VerifyStrategy {
41    StatFirst,
42    Strict,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) struct ArtifactGeneration {
47    size: u64,
48    modified_nanos: Option<u128>,
49}
50
51#[derive(Debug, Clone)]
52struct VerifyMemoEntry {
53    verify_completed_at: Instant,
54    generation: ArtifactGeneration,
55    invalidated: bool,
56}
57
58#[derive(Default)]
59struct VerifyMemo {
60    entries: BTreeMap<(PathBuf, VerifyArtifact), VerifyMemoEntry>,
61    invalidation_tickets: BTreeMap<PathBuf, u64>,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub struct FileFreshness {
66    pub mtime: SystemTime,
67    pub size: u64,
68    pub content_hash: blake3::Hash,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum FreshnessVerdict {
73    HotFresh,
74    ContentFresh {
75        new_mtime: SystemTime,
76        new_size: u64,
77    },
78    Stale,
79    Deleted,
80}
81
82pub fn hash_bytes(bytes: &[u8]) -> blake3::Hash {
83    blake3::hash(bytes)
84}
85
86pub fn hash_file_if_small(path: &Path, size: u64) -> std::io::Result<Option<blake3::Hash>> {
87    if size > CONTENT_HASH_SIZE_CAP {
88        return Ok(None);
89    }
90    #[cfg(debug_assertions)]
91    HASH_FILE_IF_SMALL_CALLS.with(|calls| calls.set(calls.get() + 1));
92    #[cfg(any(debug_assertions, test))]
93    {
94        let mut watched = WATCHED_HASH_FILE
95            .get_or_init(|| Mutex::new(None))
96            .lock()
97            .unwrap_or_else(std::sync::PoisonError::into_inner);
98        if let Some((watched_path, count)) = watched.as_mut() {
99            if watched_path == path {
100                *count += 1;
101            }
102        }
103    }
104    fs::read(path).map(|bytes| Some(hash_bytes(&bytes)))
105}
106
107pub fn metadata_matches(path: &Path, cached: &FileFreshness) -> std::io::Result<bool> {
108    let metadata = fs::metadata(path)?;
109    let new_size = metadata.len();
110    let new_mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
111    Ok(new_size == cached.size && new_mtime == cached.mtime)
112}
113
114pub fn zero_hash() -> blake3::Hash {
115    blake3::Hash::from_bytes([0u8; 32])
116}
117
118pub(crate) fn artifact_generation(path: &Path) -> Option<ArtifactGeneration> {
119    let metadata = fs::metadata(path).ok()?;
120    let modified_nanos = metadata
121        .modified()
122        .ok()
123        .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok())
124        .map(|duration| duration.as_nanos());
125    Some(ArtifactGeneration {
126        size: metadata.len(),
127        modified_nanos,
128    })
129}
130
131fn verify_memo() -> &'static Mutex<VerifyMemo> {
132    VERIFY_MEMO.get_or_init(|| Mutex::new(VerifyMemo::default()))
133}
134
135pub(crate) fn warm_verify_plan(
136    root: &Path,
137    artifact: VerifyArtifact,
138    generation: Option<ArtifactGeneration>,
139) -> WarmVerifyPlan {
140    let Some(generation) = generation else {
141        return WarmVerifyPlan::Strict;
142    };
143    let memo = verify_memo()
144        .lock()
145        .unwrap_or_else(std::sync::PoisonError::into_inner);
146    let Some(entry) = memo.entries.get(&(root.to_path_buf(), artifact)) else {
147        return WarmVerifyPlan::Strict;
148    };
149    if entry.generation != generation {
150        return WarmVerifyPlan::Strict;
151    }
152    if entry.invalidated || entry.verify_completed_at.elapsed() >= VERIFY_MEMO_TTL {
153        WarmVerifyPlan::StatFirst
154    } else {
155        WarmVerifyPlan::Skip
156    }
157}
158
159pub(crate) fn capture_verify_ticket(root: &Path) -> u64 {
160    verify_memo()
161        .lock()
162        .unwrap_or_else(std::sync::PoisonError::into_inner)
163        .invalidation_tickets
164        .get(root)
165        .copied()
166        .unwrap_or(0)
167}
168
169pub(crate) fn record_verify_completed_if_unchanged(
170    root: &Path,
171    artifact: VerifyArtifact,
172    generation: Option<ArtifactGeneration>,
173    ticket: u64,
174) -> bool {
175    let Some(generation) = generation else {
176        return false;
177    };
178    let mut memo = verify_memo()
179        .lock()
180        .unwrap_or_else(std::sync::PoisonError::into_inner);
181    let current_ticket = memo.invalidation_tickets.get(root).copied().unwrap_or(0);
182    if current_ticket != ticket {
183        return false;
184    }
185    memo.entries.insert(
186        (root.to_path_buf(), artifact),
187        VerifyMemoEntry {
188            verify_completed_at: Instant::now(),
189            generation,
190            invalidated: false,
191        },
192    );
193    true
194}
195
196/// Seed the search-index verification memo for integration lifecycle tests.
197#[doc(hidden)]
198pub fn seed_search_verify_memo_for_test(root: &Path, artifact_path: &Path) -> bool {
199    let ticket = capture_verify_ticket(root);
200    record_verify_completed_if_unchanged(
201        root,
202        VerifyArtifact::Search,
203        artifact_generation(artifact_path),
204        ticket,
205    )
206}
207
208/// Return the current search warm-verify plan as a stable test label.
209#[doc(hidden)]
210pub fn search_warm_verify_plan_for_test(root: &Path, artifact_path: &Path) -> &'static str {
211    match warm_verify_plan(
212        root,
213        VerifyArtifact::Search,
214        artifact_generation(artifact_path),
215    ) {
216        WarmVerifyPlan::Skip => "skip",
217        WarmVerifyPlan::StatFirst => "stat_first",
218        WarmVerifyPlan::Strict => "strict",
219    }
220}
221
222#[cfg(test)]
223pub(crate) fn record_verify_completed(
224    root: &Path,
225    artifact: VerifyArtifact,
226    generation: Option<ArtifactGeneration>,
227) {
228    let ticket = capture_verify_ticket(root);
229    let _ = record_verify_completed_if_unchanged(root, artifact, generation, ticket);
230}
231
232pub(crate) fn invalidate_verify_memo(root: &Path) {
233    let mut memo = verify_memo()
234        .lock()
235        .unwrap_or_else(std::sync::PoisonError::into_inner);
236    let ticket = memo
237        .invalidation_tickets
238        .entry(root.to_path_buf())
239        .or_insert(0);
240    *ticket = ticket.wrapping_add(1);
241    for ((memo_root, _), entry) in &mut memo.entries {
242        if memo_root == root {
243            entry.invalidated = true;
244        }
245    }
246}
247
248/// Forget all completed verification for a root after an interval in which its
249/// filesystem watcher was stopped. A normal watcher invalidation can use
250/// stat-first verification, but an unobserved interval must hash content because
251/// size and mtime may both have been preserved by an external writer.
252pub(crate) fn invalidate_verify_memo_strict(root: &Path) {
253    let mut memo = verify_memo()
254        .lock()
255        .unwrap_or_else(std::sync::PoisonError::into_inner);
256    let ticket = memo
257        .invalidation_tickets
258        .entry(root.to_path_buf())
259        .or_insert(0);
260    *ticket = ticket.wrapping_add(1);
261    memo.entries.retain(|(memo_root, _), _| memo_root != root);
262}
263
264pub fn collect(path: &Path) -> std::io::Result<FileFreshness> {
265    let metadata = fs::metadata(path)?;
266    let mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
267    let size = metadata.len();
268    let content_hash = hash_file_if_small(path, size)?.unwrap_or_else(zero_hash);
269    Ok(FileFreshness {
270        mtime,
271        size,
272        content_hash,
273    })
274}
275
276pub fn verify_file(path: &Path, cached: &FileFreshness) -> FreshnessVerdict {
277    verify_file_inner(path, cached, false)
278}
279
280pub fn verify_file_strict(path: &Path, cached: &FileFreshness) -> FreshnessVerdict {
281    #[cfg(debug_assertions)]
282    {
283        STRICT_VERIFY_FILE_CALLS.fetch_add(1, Ordering::Relaxed);
284        strict_verify_paths_for_debug()
285            .lock()
286            .unwrap_or_else(std::sync::PoisonError::into_inner)
287            .push(path.to_path_buf());
288    }
289    verify_file_inner(path, cached, true)
290}
291
292/// Verify semantic cache file freshness in a private bounded Rayon pool.
293///
294/// Do not use the global pool here: load-time strict verification can hash every
295/// indexed file, and the semantic load/build already runs beside the bridge's
296/// single dispatch thread. Match the half-cores/cap-8 policy used by the search
297/// and callgraph cold-build pools.
298pub(crate) fn verify_files_strict_bounded<K: Send>(
299    files: Vec<(K, PathBuf, FileFreshness)>,
300) -> Vec<(K, PathBuf, FreshnessVerdict)> {
301    verify_files_bounded(files, VerifyStrategy::Strict)
302}
303
304pub(crate) fn verify_files_bounded<K: Send>(
305    files: Vec<(K, PathBuf, FileFreshness)>,
306    strategy: VerifyStrategy,
307) -> Vec<(K, PathBuf, FreshnessVerdict)> {
308    fn verify_one<K>(
309        (key, path, cached): (K, PathBuf, FileFreshness),
310        strategy: VerifyStrategy,
311    ) -> (K, PathBuf, FreshnessVerdict) {
312        let verdict = match strategy {
313            VerifyStrategy::StatFirst => verify_file(&path, &cached),
314            VerifyStrategy::Strict => verify_file_strict(&path, &cached),
315        };
316        (key, path, verdict)
317    }
318
319    if files.len() <= 1 {
320        return files
321            .into_iter()
322            .map(|file| verify_one(file, strategy))
323            .collect();
324    }
325
326    match rayon::ThreadPoolBuilder::new()
327        .num_threads(strict_verify_pool_size())
328        .thread_name(|index| format!("aft-semantic-verify-{index}"))
329        .build()
330    {
331        Ok(pool) => pool.install(|| {
332            files
333                .into_par_iter()
334                .map(|file| verify_one(file, strategy))
335                .collect()
336        }),
337        Err(_) => files
338            .into_iter()
339            .map(|file| verify_one(file, strategy))
340            .collect(),
341    }
342}
343
344fn strict_verify_pool_size() -> usize {
345    std::thread::available_parallelism()
346        .map(|parallelism| parallelism.get())
347        .unwrap_or(1)
348        .div_ceil(2)
349        .clamp(1, 8)
350}
351
352#[cfg(debug_assertions)]
353fn strict_verify_paths_for_debug() -> &'static std::sync::Mutex<Vec<PathBuf>> {
354    static STRICT_VERIFY_FILE_PATHS: OnceLock<std::sync::Mutex<Vec<PathBuf>>> = OnceLock::new();
355    STRICT_VERIFY_FILE_PATHS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
356}
357
358#[cfg(debug_assertions)]
359#[doc(hidden)]
360pub fn reset_verify_file_strict_count_for_debug() {
361    STRICT_VERIFY_FILE_CALLS.store(0, Ordering::Relaxed);
362    strict_verify_paths_for_debug()
363        .lock()
364        .unwrap_or_else(std::sync::PoisonError::into_inner)
365        .clear();
366}
367
368#[cfg(debug_assertions)]
369#[doc(hidden)]
370pub fn verify_file_strict_count_for_debug() -> usize {
371    STRICT_VERIFY_FILE_CALLS.load(Ordering::Relaxed)
372}
373
374/// Strict-verification count attributed to one root. The bare process-global
375/// count is meaningless under parallel tests (any concurrent semantic or
376/// search load increments it); tests asserting "MY operation ran no strict
377/// verification" must count only paths under their own fixture root.
378#[cfg(debug_assertions)]
379#[doc(hidden)]
380pub fn verify_file_strict_count_under_for_debug(root: &Path) -> usize {
381    strict_verify_paths_for_debug()
382        .lock()
383        .unwrap_or_else(std::sync::PoisonError::into_inner)
384        .iter()
385        .filter(|path| path.starts_with(root))
386        .count()
387}
388
389#[cfg(debug_assertions)]
390#[doc(hidden)]
391pub fn reset_hash_file_if_small_count_for_debug() {
392    HASH_FILE_IF_SMALL_CALLS.with(|calls| calls.set(0));
393}
394
395#[cfg(debug_assertions)]
396#[doc(hidden)]
397pub fn hash_file_if_small_count_for_debug() -> usize {
398    HASH_FILE_IF_SMALL_CALLS.with(Cell::get)
399}
400
401#[cfg(any(debug_assertions, test))]
402#[doc(hidden)]
403pub fn watch_hash_file_for_debug(path: &Path) {
404    *WATCHED_HASH_FILE
405        .get_or_init(|| Mutex::new(None))
406        .lock()
407        .unwrap_or_else(std::sync::PoisonError::into_inner) = Some((path.to_path_buf(), 0));
408}
409
410#[cfg(any(debug_assertions, test))]
411#[doc(hidden)]
412pub fn watched_hash_file_count_for_debug() -> usize {
413    WATCHED_HASH_FILE
414        .get_or_init(|| Mutex::new(None))
415        .lock()
416        .unwrap_or_else(std::sync::PoisonError::into_inner)
417        .as_ref()
418        .map_or(0, |(_, count)| *count)
419}
420
421fn verify_file_inner(
422    path: &Path,
423    cached: &FileFreshness,
424    hash_matching_metadata: bool,
425) -> FreshnessVerdict {
426    let Ok(metadata) = fs::metadata(path) else {
427        return FreshnessVerdict::Deleted;
428    };
429    let new_size = metadata.len();
430    let new_mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
431    if new_size == cached.size && new_mtime == cached.mtime {
432        if hash_matching_metadata {
433            if new_size > CONTENT_HASH_SIZE_CAP || cached.content_hash == zero_hash() {
434                return FreshnessVerdict::Stale;
435            }
436            return match hash_file_if_small(path, new_size) {
437                Ok(Some(hash)) if hash == cached.content_hash => FreshnessVerdict::HotFresh,
438                _ => FreshnessVerdict::Stale,
439            };
440        }
441        return FreshnessVerdict::HotFresh;
442    }
443    if new_size != cached.size || new_size > CONTENT_HASH_SIZE_CAP {
444        return FreshnessVerdict::Stale;
445    }
446    match hash_file_if_small(path, new_size) {
447        Ok(Some(hash)) if hash == cached.content_hash => FreshnessVerdict::ContentFresh {
448            new_mtime,
449            new_size,
450        },
451        _ => FreshnessVerdict::Stale,
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458    use std::io::Write;
459
460    fn write(path: &Path, bytes: &[u8]) {
461        fs::write(path, bytes).unwrap();
462    }
463
464    /// Phase-3 gating benchmark: stat-all (non-strict verify_file) vs hash-all
465    /// (strict verify_file_strict) cost across a real repo's file set, with NO
466    /// file changed (the steady-state warm freshness pass). Decides Option B:
467    /// if stat-all is cheap even at large file counts, replace the per-edit
468    /// hash-all with stat-diff-first.
469    ///
470    ///   AFT_BENCH_REPO=/path/to/repo cargo test -p agent-file-tools --lib \
471    ///     --release -- --ignored --nocapture --test-threads=1 \
472    ///     freshness_stat_vs_hash_benchmark
473    #[test]
474    #[ignore = "manual benchmark; needs AFT_BENCH_REPO"]
475    fn freshness_stat_vs_hash_benchmark() {
476        use std::time::Instant;
477        let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
478            eprintln!("AFT_BENCH_REPO unset; skipping");
479            return;
480        };
481        let root = std::path::PathBuf::from(&repo);
482        let files: Vec<std::path::PathBuf> = crate::callgraph::walk_project_files(&root).collect();
483
484        // Cold pass: collect freshness records (this is the cold-build cost, not
485        // what we're optimizing — just needed to seed the warm comparison).
486        let records: Vec<(std::path::PathBuf, FileFreshness)> = files
487            .iter()
488            .filter_map(|p| collect(p).ok().map(|f| (p.clone(), f)))
489            .collect();
490
491        eprintln!(
492            "\n=== freshness stat-vs-hash benchmark ===\nrepo: {}\nfiles walked: {}  freshness records: {}",
493            root.display(),
494            files.len(),
495            records.len()
496        );
497
498        // 3 iterations each, report medians. Interleave to share cache effects.
499        let mut stat_ms = Vec::new();
500        let mut hash_ms = Vec::new();
501        for _ in 0..3 {
502            let t = Instant::now();
503            let mut stat_hot = 0usize;
504            for (path, cached) in &records {
505                // Non-strict: stat only; hashes ONLY if (mtime,size) differ.
506                // With no file changed, this is pure stat — the Option B cost.
507                if matches!(verify_file(path, cached), FreshnessVerdict::HotFresh) {
508                    stat_hot += 1;
509                }
510            }
511            stat_ms.push(t.elapsed().as_micros());
512
513            let t = Instant::now();
514            let mut hash_hot = 0usize;
515            for (path, cached) in &records {
516                // Strict: stat + content-hash every file (today's per-edit cost).
517                if matches!(verify_file_strict(path, cached), FreshnessVerdict::HotFresh) {
518                    hash_hot += 1;
519                }
520            }
521            hash_ms.push(t.elapsed().as_micros());
522
523            eprintln!("  iter: stat_hot={stat_hot} hash_hot={hash_hot}");
524        }
525        stat_ms.sort_unstable();
526        hash_ms.sort_unstable();
527        let stat_med = stat_ms[1] as f64 / 1000.0;
528        let hash_med = hash_ms[1] as f64 / 1000.0;
529        eprintln!(
530            "SUMMARY  files={}  stat_all_median={:.2}ms  hash_all_median={:.2}ms  speedup={:.1}x",
531            records.len(),
532            stat_med,
533            hash_med,
534            hash_med / stat_med.max(0.001)
535        );
536    }
537
538    #[test]
539    fn hot_fresh_when_mtime_size_match() {
540        let dir = tempfile::tempdir().unwrap();
541        let path = dir.path().join("a.txt");
542        write(&path, b"same");
543        let fresh = collect(&path).unwrap();
544        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::HotFresh);
545    }
546
547    #[test]
548    fn strict_hashes_small_file_when_metadata_matches() {
549        let dir = tempfile::tempdir().unwrap();
550        let path = dir.path().join("a.txt");
551        let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
552        write(&path, b"alpha");
553        filetime::set_file_mtime(&path, original_mtime).unwrap();
554        let fresh = collect(&path).unwrap();
555
556        assert_eq!(
557            verify_file_strict(&path, &fresh),
558            FreshnessVerdict::HotFresh
559        );
560
561        write(&path, b"bravo");
562        filetime::set_file_mtime(&path, original_mtime).unwrap();
563
564        // Stat-diff freshness intentionally treats same-size, same-mtime edits as
565        // fresh; Tier-2's staleness ceiling heals this accepted residual with a
566        // periodic strict pass instead of hashing every file on each edit.
567        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::HotFresh);
568        assert_eq!(verify_file_strict(&path, &fresh), FreshnessVerdict::Stale);
569    }
570
571    #[test]
572    fn strict_stale_when_large_file_hash_was_not_cached() {
573        let dir = tempfile::tempdir().unwrap();
574        let path = dir.path().join("big.bin");
575        let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
576        let file = fs::File::create(&path).unwrap();
577        file.set_len(CONTENT_HASH_SIZE_CAP + 1).unwrap();
578        filetime::set_file_mtime(&path, original_mtime).unwrap();
579        let fresh = collect(&path).unwrap();
580
581        assert_eq!(fresh.size, CONTENT_HASH_SIZE_CAP + 1);
582        assert_eq!(fresh.content_hash, zero_hash());
583        // Non-strict stat-diff trusts unchanged metadata for over-cap files and
584        // avoids strict's needless rescan of large unchanged files.
585        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::HotFresh);
586        assert_eq!(verify_file_strict(&path, &fresh), FreshnessVerdict::Stale);
587    }
588
589    #[test]
590    fn content_fresh_when_only_mtime_changes() {
591        let dir = tempfile::tempdir().unwrap();
592        let path = dir.path().join("a.txt");
593        write(&path, b"same");
594        let fresh = collect(&path).unwrap();
595        let mut file = fs::OpenOptions::new().append(true).open(&path).unwrap();
596        file.write_all(b"").unwrap();
597        file.sync_all().unwrap();
598        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 0)).unwrap();
599        assert!(matches!(
600            verify_file(&path, &fresh),
601            FreshnessVerdict::ContentFresh { .. }
602        ));
603    }
604
605    #[test]
606    fn stale_when_size_changes() {
607        let dir = tempfile::tempdir().unwrap();
608        let path = dir.path().join("a.txt");
609        write(&path, b"same");
610        let fresh = collect(&path).unwrap();
611        write(&path, b"different");
612        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::Stale);
613    }
614
615    #[test]
616    fn deleted_when_missing() {
617        let dir = tempfile::tempdir().unwrap();
618        let path = dir.path().join("a.txt");
619        write(&path, b"same");
620        let fresh = collect(&path).unwrap();
621        fs::remove_file(&path).unwrap();
622        assert_eq!(verify_file(&path, &fresh), FreshnessVerdict::Deleted);
623    }
624
625    #[test]
626    fn over_cap_hash_is_not_computed() {
627        let dir = tempfile::tempdir().unwrap();
628        let path = dir.path().join("big.bin");
629        fs::write(&path, vec![0u8; CONTENT_HASH_SIZE_CAP as usize + 1]).unwrap();
630        assert!(hash_file_if_small(&path, CONTENT_HASH_SIZE_CAP + 1)
631            .unwrap()
632            .is_none());
633    }
634}
635
636#[cfg(test)]
637mod warm_reload_tests {
638    use super::*;
639
640    #[test]
641    fn stat_first_hashes_only_files_with_changed_metadata_while_cold_verify_is_strict() {
642        let dir = tempfile::tempdir().unwrap();
643        let path = dir.path().join("warm.rs");
644        fs::write(&path, b"fn warm() {}\n").unwrap();
645        let original_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
646        filetime::set_file_mtime(&path, original_mtime).unwrap();
647        let freshness = collect(&path).unwrap();
648
649        watch_hash_file_for_debug(&path);
650        let unchanged = verify_files_bounded(
651            vec![((), path.clone(), freshness)],
652            VerifyStrategy::StatFirst,
653        );
654        assert_eq!(unchanged[0].2, FreshnessVerdict::HotFresh);
655        assert_eq!(watched_hash_file_count_for_debug(), 0);
656
657        watch_hash_file_for_debug(&path);
658        let cold = verify_files_strict_bounded(vec![((), path.clone(), freshness)]);
659        assert_eq!(cold[0].2, FreshnessVerdict::HotFresh);
660        assert_eq!(watched_hash_file_count_for_debug(), 1);
661
662        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(1, 0)).unwrap();
663        watch_hash_file_for_debug(&path);
664        let changed_stat = verify_files_bounded(
665            vec![((), path.clone(), freshness)],
666            VerifyStrategy::StatFirst,
667        );
668        assert!(matches!(
669            changed_stat[0].2,
670            FreshnessVerdict::ContentFresh { .. }
671        ));
672        assert_eq!(watched_hash_file_count_for_debug(), 1);
673    }
674
675    #[test]
676    fn verify_memo_skips_hits_and_invalidates_on_watcher_or_generation_change() {
677        let dir = tempfile::tempdir().unwrap();
678        let root = dir.path().join("root");
679        let artifact = dir.path().join("cache.bin");
680        fs::create_dir(&root).unwrap();
681        fs::write(&artifact, b"generation-one").unwrap();
682        let generation_one = artifact_generation(&artifact).unwrap();
683
684        assert_eq!(
685            warm_verify_plan(&root, VerifyArtifact::Search, Some(generation_one)),
686            WarmVerifyPlan::Strict
687        );
688        record_verify_completed(&root, VerifyArtifact::Search, Some(generation_one));
689        assert_eq!(
690            warm_verify_plan(&root, VerifyArtifact::Search, Some(generation_one)),
691            WarmVerifyPlan::Skip
692        );
693
694        invalidate_verify_memo(&root);
695        assert_eq!(
696            warm_verify_plan(&root, VerifyArtifact::Search, Some(generation_one)),
697            WarmVerifyPlan::StatFirst
698        );
699
700        fs::write(&artifact, b"generation-two-is-different").unwrap();
701        let generation_two = artifact_generation(&artifact).unwrap();
702        assert_ne!(generation_one, generation_two);
703        assert_eq!(
704            warm_verify_plan(&root, VerifyArtifact::Search, Some(generation_two)),
705            WarmVerifyPlan::Strict
706        );
707    }
708
709    #[test]
710    fn invalidation_between_verify_and_record_prevents_false_fresh_memo() {
711        let dir = tempfile::tempdir().unwrap();
712        let root = dir.path().join("root");
713        let artifact = dir.path().join("cache.bin");
714        fs::create_dir(&root).unwrap();
715        fs::write(&artifact, b"stable-generation").unwrap();
716        let generation = artifact_generation(&artifact).unwrap();
717
718        let stale_ticket = capture_verify_ticket(&root);
719        invalidate_verify_memo(&root);
720        assert!(!record_verify_completed_if_unchanged(
721            &root,
722            VerifyArtifact::Search,
723            Some(generation),
724            stale_ticket,
725        ));
726        assert_eq!(
727            warm_verify_plan(&root, VerifyArtifact::Search, Some(generation)),
728            WarmVerifyPlan::Strict
729        );
730
731        let current_ticket = capture_verify_ticket(&root);
732        assert!(record_verify_completed_if_unchanged(
733            &root,
734            VerifyArtifact::Search,
735            Some(generation),
736            current_ticket,
737        ));
738        assert_eq!(
739            warm_verify_plan(&root, VerifyArtifact::Search, Some(generation)),
740            WarmVerifyPlan::Skip
741        );
742    }
743
744    #[cfg(unix)]
745    fn process_cpu_time() -> Duration {
746        let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
747        let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
748        assert_eq!(result, 0, "getrusage should report process CPU time");
749        let usage = unsafe { usage.assume_init() };
750        let seconds = (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) as u64;
751        let micros = (usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) as u64;
752        Duration::from_secs(seconds) + Duration::from_micros(micros)
753    }
754
755    /// Measures the configure -> eviction -> rebind freshness component against
756    /// a real repository. The first pass models a cold strict verification; the
757    /// second models the unchanged-generation memo hit after in-process eviction.
758    #[cfg(unix)]
759    #[test]
760    #[ignore = "manual CPU measurement; needs AFT_BENCH_REPO"]
761    fn configure_eviction_rebind_cpu_measurement() {
762        let root = PathBuf::from(
763            std::env::var("AFT_BENCH_REPO").expect("AFT_BENCH_REPO must name a repository"),
764        );
765        let records = crate::callgraph::walk_project_files(&root)
766            .filter_map(|path| collect(&path).ok().map(|freshness| (path, freshness)))
767            .collect::<Vec<_>>();
768        let artifact_dir = tempfile::tempdir().unwrap();
769        let artifact = artifact_dir.path().join("cache.bin");
770        fs::write(&artifact, b"stable-generation").unwrap();
771        let generation = artifact_generation(&artifact).unwrap();
772
773        let strict_inputs = records
774            .iter()
775            .enumerate()
776            .map(|(index, (path, freshness))| (index, path.clone(), *freshness))
777            .collect();
778        let before = process_cpu_time();
779        let _ = verify_files_strict_bounded(strict_inputs);
780        let strict_cpu = process_cpu_time().saturating_sub(before);
781        record_verify_completed(&root, VerifyArtifact::Search, Some(generation));
782
783        let before = process_cpu_time();
784        if warm_verify_plan(&root, VerifyArtifact::Search, Some(generation)) != WarmVerifyPlan::Skip
785        {
786            panic!("unchanged artifact generation should skip rebind verification");
787        }
788        let memo_hit_cpu = process_cpu_time().saturating_sub(before);
789
790        invalidate_verify_memo(&root);
791        let stat_inputs = records
792            .iter()
793            .enumerate()
794            .map(|(index, (path, freshness))| (index, path.clone(), *freshness))
795            .collect();
796        let before = process_cpu_time();
797        let _ = verify_files_bounded(stat_inputs, VerifyStrategy::StatFirst);
798        let stat_first_cpu = process_cpu_time().saturating_sub(before);
799
800        eprintln!(
801            "configure-eviction-rebind CPU: files={} cold_strict={:?} memo_rebind={:?} invalidated_stat_first={:?}",
802            records.len(), strict_cpu, memo_hit_cpu, stat_first_cpu
803        );
804    }
805}