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#[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#[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
248pub(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
292pub(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#[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 #[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 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 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 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 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 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 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 #[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}