1use std::collections::HashMap;
2use std::path::Path;
3use std::time::Duration;
4
5use sha2::{Digest, Sha256};
6
7use crate::delta_encoder::DeltaEncoder;
8use crate::error::Result;
9use crate::pipeline::{CompressionPipeline, SessionContext};
10use crate::preset::Preset;
11use crate::session_store::SessionStore;
12use crate::types::CompressedContent;
13
14pub enum CacheResult {
21 Dedup {
23 inline_ref: String,
25 token_cost: u32,
27 },
28 Delta {
30 delta_text: String,
32 token_cost: u32,
34 similarity: f64,
36 },
37 Fresh { output: CompressedContent },
39}
40
41#[derive(Debug, Clone)]
50pub enum ExpandResult {
51 Original {
54 hash: String,
57 bytes: Vec<u8>,
59 },
60 CompressedOnly {
64 hash: String,
65 compressed: String,
67 },
68}
69
70#[derive(Debug, Clone)]
73pub struct DedupHit {
74 pub inline_ref: String,
76 pub hash: String,
78 pub prev_accessed_at: Option<chrono::DateTime<chrono::Utc>>,
81}
82
83#[derive(Debug, Clone)]
87pub struct SliceHit {
88 pub inline_ref: String,
91 pub hash: String,
93 pub start_line: usize,
94 pub end_line: usize,
95 pub total_lines: usize,
97 pub token_cost: u32,
99}
100
101const MIN_SLICE_BYTES: usize = 160;
105const SLICE_CANDIDATES: usize = 32;
107
108#[derive(Debug, Clone)]
116#[allow(dead_code)]
117struct RefEntry {
118 last_sent_turn: u64,
120}
121
122pub struct CacheManager {
141 store: SessionStore,
142 max_size_bytes: u64,
143 delta_encoder: DeltaEncoder,
144 #[allow(dead_code)]
147 turn_counter: std::cell::Cell<u64>,
148 #[allow(dead_code)]
150 ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
151 max_ref_age: Duration,
156 compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
160 lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
164 lsh_hashes: std::cell::RefCell<Vec<String>>,
166}
167
168impl CacheManager {
169 pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
176 Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
177 }
178
179 #[doc(hidden)]
185 pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
186 Self::with_ref_age_duration(
187 store,
188 max_size_bytes,
189 Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
190 )
191 }
192
193 pub fn with_ref_age_duration(
195 store: SessionStore,
196 max_size_bytes: u64,
197 max_ref_age: Duration,
198 ) -> Self {
199 Self {
200 store,
201 max_size_bytes,
202 delta_encoder: DeltaEncoder::new(),
203 turn_counter: std::cell::Cell::new(0),
204 ref_tracker: std::cell::RefCell::new(HashMap::new()),
205 max_ref_age,
206 compaction_marker: std::cell::Cell::new(None),
207 lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
208 lsh_hashes: std::cell::RefCell::new(Vec::new()),
209 }
210 }
211
212 fn index_for_similarity(&self, hash: &str, data: &str) {
216 const MAX_INDEXED: usize = 512;
217 let mut hashes = self.lsh_hashes.borrow_mut();
218 if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
219 return;
220 }
221 let doc_id = hashes.len() as u64;
222 self.lsh.borrow_mut().insert(doc_id, data);
223 hashes.push(hash.to_string());
224 }
225
226 pub fn sha256_hex(bytes: &[u8]) -> String {
228 let mut hasher = Sha256::new();
229 hasher.update(bytes);
230 format!("{:x}", hasher.finalize())
231 }
232
233 pub fn advance_turn(&self) {
237 self.turn_counter.set(self.turn_counter.get() + 1);
238 }
239
240 pub fn current_turn(&self) -> u64 {
242 self.turn_counter.get()
243 }
244
245 pub fn notify_compaction(&self) {
259 let now = chrono::Utc::now();
260 self.compaction_marker.set(Some(now));
261 self.ref_tracker.borrow_mut().clear();
262 let _ = self
268 .store
269 .set_metadata("last_compaction_at", &now.to_rfc3339());
270 }
271
272 fn is_ref_fresh(&self, hash: &str) -> bool {
287 if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
291 return true;
292 }
293 let accessed = match self.store.get_cache_entry_accessed_at(hash) {
294 Ok(Some(ts)) => ts,
295 _ => return false,
296 };
297 if let Some(marker) = self.compaction_marker.get() {
299 if accessed < marker {
300 return false;
301 }
302 }
303 if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
307 if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
308 if accessed < marker {
309 return false;
310 }
311 }
312 }
313 let age = (chrono::Utc::now() - accessed)
314 .to_std()
315 .unwrap_or(Duration::from_secs(0));
316 age < self.max_ref_age
317 }
318
319 fn record_ref_sent(&self, hash: &str) {
325 let _ = self.store.touch_cache_entry(hash);
326 }
327
328 pub fn get_or_compress(
336 &self,
337 path: &Path,
338 content: &[u8],
339 pipeline: &CompressionPipeline,
340 ) -> Result<CacheResult> {
341 let ctx = SessionContext {
342 session_id: "cache".to_string(),
343 };
344 let preset = Preset::default();
345 self.get_or_compress_inner(path, content, &|text| pipeline.compress(text, &ctx, &preset))
346 }
347
348 pub fn get_or_compress_with(
352 &self,
353 path: &Path,
354 content: &[u8],
355 compress: &dyn Fn(&str) -> Result<CompressedContent>,
356 ) -> Result<CacheResult> {
357 self.get_or_compress_inner(path, content, compress)
358 }
359
360 pub fn get_or_compress_lossless(
367 &self,
368 path: &Path,
369 content: &[u8],
370 pipeline: &CompressionPipeline,
371 ) -> Result<CacheResult> {
372 let ctx = SessionContext {
373 session_id: "cache".to_string(),
374 };
375 let preset = Preset::default();
376 self.get_or_compress_inner(path, content, &|text| {
377 pipeline.compress_lossless(text, &ctx, &preset)
378 })
379 }
380
381 fn get_or_compress_inner(
382 &self,
383 _path: &Path,
384 content: &[u8],
385 compress: &dyn Fn(&str) -> Result<CompressedContent>,
386 ) -> Result<CacheResult> {
387 let hash = Self::sha256_hex(content);
388
389 let exists = self.store.cache_entry_exists(&hash)?;
394 if exists {
395 if self.is_ref_fresh(&hash) {
396 let hash_prefix = &hash[..16];
398 let inline_ref = format!("§ref:{hash_prefix}§");
399 self.record_ref_sent(&hash);
401 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
402 return Ok(CacheResult::Dedup {
403 inline_ref,
404 token_cost: 13,
405 });
406 } else {
407 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
410 let text = String::from_utf8_lossy(content).into_owned();
411 let compressed = compress(&text)?;
412 self.record_ref_sent(&hash);
414 return Ok(CacheResult::Fresh { output: compressed });
415 }
416 }
417
418 if let Some(hit) = self.check_slice_with_meta(content)? {
420 return Ok(CacheResult::Dedup {
421 inline_ref: hit.inline_ref,
422 token_cost: hit.token_cost,
423 });
424 }
425
426 let text = String::from_utf8_lossy(content).into_owned();
428 let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
429 if let Some(delta_result) = self.try_delta_encode(&text)? {
430 let compressed = compress(&text)?;
432 self.store
434 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
435 self.index_for_similarity(&hash, &compressed.data);
436 self.record_ref_sent(&hash);
437
438 let token_cost = (delta_result.delta_text.len() / 4) as u32;
439 return Ok(CacheResult::Delta {
440 delta_text: delta_result.delta_text,
441 token_cost: token_cost.max(5),
442 similarity: delta_result.similarity,
443 });
444 }
445
446 let compressed = compress(&text)?;
447 self.store
448 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
449 self.index_for_similarity(&hash, &compressed.data);
450 self.record_ref_sent(&hash);
452
453 Ok(CacheResult::Fresh { output: compressed })
454 }
455
456 fn try_delta_encode(
466 &self,
467 new_content: &str,
468 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
469 let candidates: Vec<String> = {
471 let lsh = self.lsh.borrow();
472 let hashes = self.lsh_hashes.borrow();
473 let query_sig = crate::minhash_lsh::MinHashLsh::compute_signature(new_content);
474 let mut scored: Vec<(f64, &String)> = lsh
475 .query(new_content)
476 .into_iter()
477 .filter_map(|doc_id| {
478 let hash = hashes.get(doc_id as usize)?;
479 let sim = lsh.get_signature(doc_id)?.jaccard_similarity(&query_sig);
480 (sim >= 0.5).then_some((sim, hash))
481 })
482 .collect();
483 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
484 scored.into_iter().take(5).map(|(_, h)| h.clone()).collect()
485 };
486 for hash in &candidates {
487 if let Some(delta) = self.try_delta_against(hash, new_content)? {
488 return Ok(Some(delta));
489 }
490 }
491
492 let entries = self.store.list_cache_entries_lru()?;
494 let check_count = entries.len().min(10);
495 for (hash, _) in entries.iter().rev().take(check_count) {
496 if candidates.iter().any(|c| c == hash) {
497 continue;
498 }
499 if let Some(delta) = self.try_delta_against(hash, new_content)? {
500 return Ok(Some(delta));
501 }
502 }
503
504 Ok(None)
505 }
506
507 fn try_delta_against(
508 &self,
509 hash: &str,
510 new_content: &str,
511 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
512 if let Some(cached) = self.store.get_cache_entry(hash)? {
513 let hash_prefix = &hash[..hash.len().min(16)];
514 if let Ok(Some(delta)) = self.delta_encoder.encode(&cached.data, new_content, hash_prefix) {
515 if delta.delta_text.len() < new_content.len() {
516 return Ok(Some(delta));
517 }
518 }
519 }
520 Ok(None)
521 }
522
523 pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
532 Ok(self.check_dedup_with_meta(content)?.map(|hit| hit.inline_ref))
533 }
534
535 pub fn check_dedup_with_meta(&self, content: &[u8]) -> Result<Option<DedupHit>> {
540 let hash = Self::sha256_hex(content);
541 let prev_accessed_at = self.store.get_cache_entry_accessed_at(&hash).unwrap_or(None);
544 let fresh = self.is_ref_fresh(&hash);
545 if fresh {
546 let hash_prefix = &hash[..16];
547 self.record_ref_sent(&hash);
548 Ok(Some(DedupHit {
549 inline_ref: format!("§ref:{hash_prefix}§"),
550 hash,
551 prev_accessed_at,
552 }))
553 } else {
554 Ok(None)
557 }
558 }
559
560 pub fn check_slice_with_meta(&self, content: &[u8]) -> Result<Option<SliceHit>> {
572 let slice = strip_one_trailing_newline(content);
573 if slice.len() < MIN_SLICE_BYTES || !slice.contains(&b'\n') {
574 return Ok(None);
575 }
576 for candidate in self.store.recent_originals(slice.len(), SLICE_CANDIDATES)? {
577 let Some((start_line, end_line)) = find_line_range(&candidate.original, slice) else {
578 continue;
579 };
580 if !self.is_ref_fresh(&candidate.hash) {
581 continue;
582 }
583 let served: CompressedContent = match serde_json::from_str(&candidate.data_json) {
584 Ok(c) => c,
585 Err(_) => continue,
586 };
587 if !served.data.as_bytes().windows(slice.len()).any(|w| w == slice) {
588 continue;
589 }
590 let total_lines = count_lines(&candidate.original);
591 let hash_prefix = &candidate.hash[..16];
592 let inline_ref = if start_line == 1 && end_line == total_lines {
593 format!("§ref:{hash_prefix}§")
594 } else {
595 format!("§ref:{hash_prefix}:L{start_line}-{end_line}§")
596 };
597 let token_cost = 13 + (inline_ref.len().saturating_sub(22) as u32).div_ceil(3);
598 self.record_ref_sent(&candidate.hash);
599 let _ = self.store.record_cache_hit();
600 return Ok(Some(SliceHit {
601 inline_ref,
602 hash: candidate.hash,
603 start_line,
604 end_line,
605 total_lines,
606 token_cost,
607 }));
608 }
609 Ok(None)
610 }
611
612 pub fn store_compressed(
620 &self,
621 original_content: &[u8],
622 compressed: &CompressedContent,
623 ) -> Result<()> {
624 let hash = Self::sha256_hex(original_content);
625 self.store
626 .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
627 self.index_for_similarity(&hash, &compressed.data);
628 self.record_ref_sent(&hash);
629 Ok(())
630 }
631
632 pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
658 let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
659 return Ok(None);
660 };
661
662 let _ = self.store.log_regret("expand", "", &hash, 0.0);
667
668 if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
671 return Ok(Some(ExpandResult::Original { hash, bytes }));
672 }
673 Ok(Some(ExpandResult::CompressedOnly {
674 hash,
675 compressed: compressed_entry.data,
676 }))
677 }
678
679 pub fn expand_ref(&self, raw: &str) -> Result<Option<ExpandResult>> {
684 let (prefix, range) = parse_ref_token(raw);
685 let Some(result) = self.expand_prefix(prefix)? else {
686 return Ok(None);
687 };
688 let Some((start, end)) = range else {
689 return Ok(Some(result));
690 };
691 Ok(Some(match result {
692 ExpandResult::Original { hash, bytes } => ExpandResult::Original {
693 hash,
694 bytes: slice_lines(&bytes, start, end).to_vec(),
695 },
696 ExpandResult::CompressedOnly { hash, compressed } => ExpandResult::CompressedOnly {
697 hash,
698 compressed: String::from_utf8_lossy(slice_lines(compressed.as_bytes(), start, end))
699 .into_owned(),
700 },
701 }))
702 }
703
704 pub fn invalidate(&self, path: &Path) -> Result<()> {
709 if !path.exists() {
710 return Ok(());
711 }
712 let bytes = std::fs::read(path)?;
713 let hash = Self::sha256_hex(&bytes);
714 self.store.delete_cache_entry(&hash)?;
715 Ok(())
716 }
717
718 pub fn evict_lru(&self) -> Result<u64> {
723 let entries = self.store.list_cache_entries_lru()?;
724
725 let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
727 if total <= self.max_size_bytes {
728 return Ok(0);
729 }
730
731 let mut freed: u64 = 0;
732 let mut remaining = total;
733
734 for (hash, size) in &entries {
735 if remaining <= self.max_size_bytes {
736 break;
737 }
738 self.store.delete_cache_entry(hash)?;
739 freed += size;
740 remaining -= size;
741 }
742
743 Ok(freed)
744 }
745}
746
747fn strip_one_trailing_newline(bytes: &[u8]) -> &[u8] {
748 match bytes {
749 [rest @ .., b'\n'] => rest,
750 _ => bytes,
751 }
752}
753
754fn count_lines(bytes: &[u8]) -> usize {
755 let body = strip_one_trailing_newline(bytes);
756 if body.is_empty() {
757 0
758 } else {
759 1 + body.iter().filter(|&&b| b == b'\n').count()
760 }
761}
762
763fn find_line_range(original: &[u8], slice: &[u8]) -> Option<(usize, usize)> {
766 if slice.is_empty() || slice.len() > original.len() {
767 return None;
768 }
769 let mut from = 0;
770 while from + slice.len() <= original.len() {
771 let rel = original[from..].windows(slice.len()).position(|w| w == slice)?;
772 let at = from + rel;
773 let end = at + slice.len();
774 let starts_line = at == 0 || original[at - 1] == b'\n';
775 let ends_line = end == original.len() || original[end] == b'\n';
776 if starts_line && ends_line {
777 let start_line = 1 + original[..at].iter().filter(|&&b| b == b'\n').count();
778 let end_line = start_line + slice.iter().filter(|&&b| b == b'\n').count();
779 return Some((start_line, end_line));
780 }
781 from = at + 1;
782 }
783 None
784}
785
786pub fn parse_ref_token(raw: &str) -> (&str, Option<(usize, usize)>) {
789 let body = raw
790 .trim()
791 .trim_start_matches('§')
792 .trim_start_matches("ref:")
793 .trim_end_matches('§')
794 .trim();
795 let Some((prefix, range)) = body.split_once(":L") else {
796 return (body, None);
797 };
798 let parsed = range
799 .split_once('-')
800 .and_then(|(a, b)| Some((a.parse::<usize>().ok()?, b.parse::<usize>().ok()?)))
801 .filter(|(a, b)| *a >= 1 && b >= a);
802 match parsed {
803 Some(r) => (prefix, Some(r)),
804 None => (body, None),
805 }
806}
807
808fn slice_lines(bytes: &[u8], start: usize, end: usize) -> &[u8] {
810 let mut line = 1;
811 let mut begin = None;
812 for (i, &b) in bytes.iter().enumerate() {
813 if line == start && begin.is_none() {
814 begin = Some(i);
815 }
816 if b == b'\n' {
817 if line == end {
818 return &bytes[begin.unwrap_or(i)..=i];
819 }
820 line += 1;
821 }
822 }
823 match begin {
824 Some(b) => &bytes[b..],
825 None => &[],
826 }
827}
828
829#[cfg(test)]
832mod tests {
833 use super::*;
834 use crate::preset::{
835 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
836 CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
837 ToolSelectionConfig, TruncateStringsConfig,
838 };
839 use crate::session_store::SessionStore;
840
841 fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
842 let dir = tempfile::tempdir().unwrap();
843 let path = dir.path().join("test.db");
844 let store = SessionStore::open_or_create(&path).unwrap();
845 (store, dir)
846 }
847
848 #[test]
849 fn check_dedup_with_meta_reports_previous_access() {
850 let (store, _dir) = in_memory_store();
851 let cm = CacheManager::new(store, u64::MAX);
852 let pipeline = make_pipeline();
853 let content = b"meta test content that is unique to this test";
854
855 assert!(cm.check_dedup_with_meta(content).unwrap().is_none());
857
858 cm.get_or_compress(Path::new("meta.txt"), content, &pipeline)
859 .unwrap();
860 let before = chrono::Utc::now();
861 let hit = cm
862 .check_dedup_with_meta(content)
863 .unwrap()
864 .expect("expected dedup hit");
865 assert!(hit.inline_ref.starts_with("§ref:"));
866 assert_eq!(hit.hash, CacheManager::sha256_hex(content));
867 let prev = hit.prev_accessed_at.expect("expected prior access time");
870 assert!(prev <= before + chrono::Duration::seconds(1));
871 }
872
873 fn source_file(lines: usize) -> Vec<u8> {
874 let mut s = String::new();
875 for i in 1..=lines {
876 s.push_str(&format!("def handler_{i:03}(request):\n return verify(request, scope='h{i:03}')\n"));
877 }
878 s.into_bytes()
879 }
880
881 fn lines_of(bytes: &[u8], start: usize, end: usize) -> Vec<u8> {
882 slice_lines(bytes, start, end).to_vec()
883 }
884
885 #[test]
886 fn slice_of_fresh_original_returns_line_range_ref() {
887 let (store, _dir) = in_memory_store();
888 let cm = CacheManager::new(store, u64::MAX);
889 let pipeline = make_pipeline();
890 let file = source_file(60);
891 cm.get_or_compress_lossless(Path::new("auth.py"), &file, &pipeline).unwrap();
892 let hash = CacheManager::sha256_hex(&file);
893
894 let slice = lines_of(&file, 41, 60);
895 let hit = cm.check_slice_with_meta(&slice).unwrap().expect("slice hit");
896 assert_eq!(hit.inline_ref, format!("§ref:{}:L41-60§", &hash[..16]));
897 assert_eq!((hit.start_line, hit.end_line, hit.total_lines), (41, 60, 120));
898 assert!(hit.token_cost > 13 && hit.token_cost < 25, "{}", hit.token_cost);
899
900 match cm.get_or_compress_lossless(Path::new("auth.py"), &slice, &pipeline).unwrap() {
902 CacheResult::Dedup { inline_ref, .. } => assert_eq!(inline_ref, hit.inline_ref),
903 _ => panic!("expected slice dedup"),
904 }
905 let mut no_nl = slice.clone();
907 no_nl.pop();
908 assert_eq!(cm.check_slice_with_meta(&no_nl).unwrap().unwrap().inline_ref, hit.inline_ref);
909 }
910
911 #[test]
912 fn slice_ref_requires_line_boundaries_and_size() {
913 let (store, _dir) = in_memory_store();
914 let cm = CacheManager::new(store, u64::MAX);
915 let pipeline = make_pipeline();
916 let file = source_file(60);
917 cm.get_or_compress_lossless(Path::new("auth.py"), &file, &pipeline).unwrap();
918
919 let mid = &lines_of(&file, 10, 20)[4..];
921 assert!(cm.check_slice_with_meta(mid).unwrap().is_none());
922 let one = lines_of(&file, 10, 10);
924 assert!(cm.check_slice_with_meta(&one).unwrap().is_none());
925 let two = lines_of(&file, 10, 11);
927 assert!(two.len() < MIN_SLICE_BYTES);
928 assert!(cm.check_slice_with_meta(&two).unwrap().is_none());
929 let other = source_file(6).into_iter().map(|b| if b == b'h' { b'x' } else { b }).collect::<Vec<_>>();
931 assert!(cm.check_slice_with_meta(&other).unwrap().is_none());
932 }
933
934 #[test]
935 fn slice_ref_not_served_after_compaction() {
936 let (store, _dir) = in_memory_store();
937 let cm = CacheManager::new(store, u64::MAX);
938 let pipeline = make_pipeline();
939 let file = source_file(60);
940 cm.get_or_compress_lossless(Path::new("auth.py"), &file, &pipeline).unwrap();
941 let slice = lines_of(&file, 1, 30);
942 assert!(cm.check_slice_with_meta(&slice).unwrap().is_some());
943
944 std::thread::sleep(std::time::Duration::from_millis(20));
945 cm.notify_compaction();
946 assert!(cm.check_slice_with_meta(&slice).unwrap().is_none(), "model no longer has the original");
947 }
948
949 #[test]
950 fn slice_ref_only_for_lines_the_model_actually_saw() {
951 let (store, _dir) = in_memory_store();
952 let cm = CacheManager::new(store, u64::MAX);
953 let file = source_file(60);
954 let served = String::from_utf8(lines_of(&file, 1, 60)).unwrap();
956 let compressed = CompressedContent {
957 data: served,
958 tokens_compressed: 100,
959 tokens_original: 200,
960 stages_applied: vec!["entropy_truncate".into()],
961 compression_ratio: 0.5,
962 provenance: Default::default(),
963 verify: None,
964 };
965 cm.store_compressed(&file, &compressed).unwrap();
966
967 assert!(cm.check_slice_with_meta(&lines_of(&file, 10, 40)).unwrap().is_some(), "served lines are referenceable");
968 assert!(cm.check_slice_with_meta(&lines_of(&file, 70, 100)).unwrap().is_none(), "dropped lines must be re-sent");
969 }
970
971 #[test]
972 fn whole_file_minus_trailing_newline_gets_plain_ref() {
973 let (store, _dir) = in_memory_store();
974 let cm = CacheManager::new(store, u64::MAX);
975 let pipeline = make_pipeline();
976 let file = source_file(30);
977 cm.get_or_compress_lossless(Path::new("a.py"), &file, &pipeline).unwrap();
978 let hash = CacheManager::sha256_hex(&file);
979 let mut no_nl = file.clone();
980 no_nl.pop();
981 let hit = cm.check_slice_with_meta(&no_nl).unwrap().unwrap();
982 assert_eq!(hit.inline_ref, format!("§ref:{}§", &hash[..16]));
983 }
984
985 #[test]
986 fn expand_ref_parses_every_token_form_and_slices_lines() {
987 assert_eq!(parse_ref_token("a1b2c3d4"), ("a1b2c3d4", None));
988 assert_eq!(parse_ref_token("§ref:a1b2c3d4§"), ("a1b2c3d4", None));
989 assert_eq!(parse_ref_token(" ref:a1b2c3d4 "), ("a1b2c3d4", None));
990 assert_eq!(parse_ref_token("§ref:a1b2c3d4:L40-80§"), ("a1b2c3d4", Some((40, 80))));
991 assert_eq!(parse_ref_token("a1b2c3d4:L7-7"), ("a1b2c3d4", Some((7, 7))));
992 assert_eq!(parse_ref_token("a1b2c3d4:L9-3"), ("a1b2c3d4:L9-3", None), "inverted range is not a range");
993
994 let (store, _dir) = in_memory_store();
995 let cm = CacheManager::new(store, u64::MAX);
996 let pipeline = make_pipeline();
997 let file = source_file(20);
998 cm.get_or_compress_lossless(Path::new("a.py"), &file, &pipeline).unwrap();
999 let hash = CacheManager::sha256_hex(&file);
1000
1001 match cm.expand_ref(&format!("§ref:{}:L5-8§", &hash[..16])).unwrap().unwrap() {
1002 ExpandResult::Original { bytes, .. } => assert_eq!(bytes, lines_of(&file, 5, 8)),
1003 _ => panic!("expected original bytes"),
1004 }
1005 match cm.expand_ref(&hash[..16]).unwrap().unwrap() {
1006 ExpandResult::Original { bytes, .. } => assert_eq!(bytes, file),
1007 _ => panic!("expected original bytes"),
1008 }
1009 match cm.expand_ref(&format!("{}:L39-99", &hash[..16])).unwrap().unwrap() {
1011 ExpandResult::Original { bytes, .. } => assert_eq!(bytes, lines_of(&file, 39, 40)),
1012 _ => panic!("expected original bytes"),
1013 }
1014 }
1015
1016 #[test]
1017 fn line_helpers() {
1018 let text = b"a\nbb\nccc\ndddd\n";
1019 assert_eq!(count_lines(text), 4);
1020 assert_eq!(count_lines(b"a\nbb"), 2);
1021 assert_eq!(count_lines(b""), 0);
1022 assert_eq!(find_line_range(text, b"bb\nccc"), Some((2, 3)));
1023 assert_eq!(find_line_range(text, b"b\nccc"), None);
1024 assert_eq!(find_line_range(text, b"bb\ncc"), None);
1025 assert_eq!(find_line_range(text, b"a\nbb\nccc\ndddd"), Some((1, 4)));
1026 assert_eq!(slice_lines(text, 2, 3), b"bb\nccc\n");
1027 assert_eq!(slice_lines(text, 4, 9), b"dddd\n");
1028 assert_eq!(slice_lines(text, 5, 9), b"");
1029 assert_eq!(slice_lines(b"x\ny", 2, 2), b"y");
1030 }
1031
1032 #[test]
1033 fn expand_prefix_logs_an_expand_regret() {
1034 let (store, _dir) = in_memory_store();
1035 let cm = CacheManager::new(store, u64::MAX);
1036 let pipeline = make_pipeline();
1037 let content = b"expand regret test content";
1038 cm.get_or_compress(Path::new("regret.txt"), content, &pipeline)
1039 .unwrap();
1040
1041 let hash = CacheManager::sha256_hex(content);
1042 let result = cm.expand_prefix(&hash[..16]).unwrap();
1043 assert!(result.is_some());
1044
1045 let regret = cm.store.regret_stats().unwrap();
1046 assert_eq!(regret.expands, 1);
1047 assert_eq!(regret.reruns, 0);
1048 }
1049
1050 fn test_preset() -> Preset {
1051 Preset {
1052 preset: PresetMeta {
1053 name: "test".into(),
1054 version: "1.0".into(),
1055 description: String::new(),
1056 },
1057 compression: CompressionConfig {
1058 stages: vec![],
1059 keep_fields: None,
1060 strip_fields: None,
1061 condense: Some(CondenseConfig {
1062 enabled: true,
1063 max_repeated_lines: 3,
1064 }),
1065 git_diff_fold: None,
1066 strip_nulls: Some(StripNullsConfig { enabled: true }),
1067 flatten: None,
1068 truncate_strings: Some(TruncateStringsConfig {
1069 enabled: true,
1070 max_length: 500,
1071 }),
1072 collapse_arrays: Some(CollapseArraysConfig {
1073 enabled: true,
1074 max_items: 5,
1075 max_chars: 32_000,
1076 summary_template: "... and {remaining} more items".into(),
1077 }),
1078 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
1079 },
1080 tool_selection: ToolSelectionConfig {
1081 max_tools: 5,
1082 similarity_threshold: 0.7,
1083 default_tools: vec![],
1084 },
1085 budget: BudgetConfig {
1086 warning_threshold: 0.70,
1087 ceiling_threshold: 0.85,
1088 default_window_size: 200_000,
1089 agents: Default::default(),
1090 },
1091 terse_mode: TerseModeConfig {
1092 enabled: false,
1093 level: crate::preset::TerseLevel::Moderate,
1094 },
1095 model: ModelConfig {
1096 family: "anthropic".into(),
1097 primary: "claude-sonnet-4-20250514".into(),
1098 local: String::new(),
1099 complexity_threshold: 0.4,
1100 pricing: None,
1101 },
1102 }
1103 }
1104
1105 fn make_pipeline() -> CompressionPipeline {
1106 CompressionPipeline::new(&test_preset())
1107 }
1108
1109 #[test]
1110 fn first_read_is_miss() {
1111 let (store, _dir) = in_memory_store();
1112 let cm = CacheManager::new(store, u64::MAX);
1113 let pipeline = make_pipeline();
1114 let content = b"hello world";
1115 let result = cm
1116 .get_or_compress(Path::new("file.txt"), content, &pipeline)
1117 .unwrap();
1118 assert!(matches!(result, CacheResult::Fresh { .. }));
1119 }
1120
1121 #[test]
1126 fn get_or_compress_lossless_does_not_truncate_source() {
1127 let pipeline = make_pipeline();
1128
1129 let mut segs = Vec::new();
1132 for i in 0..24 {
1134 if i % 2 == 0 {
1135 segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
1136 } else {
1137 segs.push(format!(
1138 "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
1139 ));
1140 }
1141 }
1142 let content = segs.join("\n\n");
1143 assert!(content.len() > 1000);
1144
1145 let (s1, _d1) = in_memory_store();
1147 let lossy = CacheManager::new(s1, u64::MAX);
1148 match lossy
1149 .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
1150 .unwrap()
1151 {
1152 CacheResult::Fresh { output } => {
1153 assert!(output.data.contains("omitted"), "lossy path should truncate");
1154 }
1155 _ => panic!("expected Fresh on a cold read"),
1156 }
1157
1158 let (s2, _d2) = in_memory_store();
1160 let cm = CacheManager::new(s2, u64::MAX);
1161 match cm
1162 .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
1163 .unwrap()
1164 {
1165 CacheResult::Fresh { output } => {
1166 assert!(
1167 !output.data.contains("omitted"),
1168 "file-read path truncated content: {}",
1169 output.data
1170 );
1171 for i in 0..10 {
1172 assert!(
1173 output.data.contains(&format!("SEG{i}")),
1174 "file-read path dropped SEG{i}"
1175 );
1176 }
1177 }
1178 _ => panic!("expected CacheResult::Fresh on a cold read"),
1179 }
1180 }
1181
1182 #[test]
1185 fn lsh_finds_near_duplicate_beyond_lru_window() {
1186 let (store, _dir) = in_memory_store();
1187 let cm = CacheManager::new(store, u64::MAX);
1188 let pipeline = make_pipeline();
1189
1190 let base: String = (0..40)
1191 .map(|i| format!("stable log line number {i} with steady content here\n"))
1192 .collect();
1193 cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
1194 .unwrap();
1195
1196 for n in 0..12 {
1198 let filler: String = (0..30)
1199 .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
1200 .collect();
1201 cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
1202 .unwrap();
1203 }
1204
1205 let variant = base.replace(
1207 "stable log line number 7 with steady content here",
1208 "stable log line number 7 with slightly altered content",
1209 );
1210 let result = cm
1211 .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
1212 .unwrap();
1213 let kind = match &result {
1214 CacheResult::Dedup { .. } => "Dedup",
1215 CacheResult::Delta { .. } => "Delta",
1216 CacheResult::Fresh { .. } => "Fresh",
1217 };
1218 assert!(
1219 matches!(result, CacheResult::Delta { .. }),
1220 "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
1221 );
1222 }
1223
1224 #[test]
1225 fn second_read_is_hit() {
1226 let (store, _dir) = in_memory_store();
1227 let cm = CacheManager::new(store, u64::MAX);
1228 let pipeline = make_pipeline();
1229 let content = b"hello world";
1230 let path = Path::new("file.txt");
1231
1232 cm.get_or_compress(path, content, &pipeline).unwrap();
1234
1235 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1237 match result {
1238 CacheResult::Dedup {
1239 inline_ref,
1240 token_cost,
1241 } => {
1242 assert!(inline_ref.starts_with("§ref:"));
1243 assert!(inline_ref.ends_with('§'));
1244 assert_eq!(token_cost, 13);
1245 }
1246 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
1247 }
1248 }
1249
1250 #[test]
1251 fn different_content_is_miss() {
1252 let (store, _dir) = in_memory_store();
1253 let cm = CacheManager::new(store, u64::MAX);
1254 let pipeline = make_pipeline();
1255 let path = Path::new("file.txt");
1256
1257 cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
1258 let result = cm
1259 .get_or_compress(path, b"content v2", &pipeline)
1260 .unwrap();
1261 assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
1262 }
1263
1264 #[test]
1265 fn evict_lru_frees_bytes_when_over_limit() {
1266 let (store, _dir) = in_memory_store();
1267 let cm = CacheManager::new(store, 1);
1269 let pipeline = make_pipeline();
1270 let path = Path::new("f.txt");
1271
1272 cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
1274 cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
1275 cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
1276
1277 let freed = cm.evict_lru().unwrap();
1278 assert!(freed > 0, "expected bytes to be freed");
1279 }
1280
1281 #[test]
1282 fn evict_lru_no_op_when_under_limit() {
1283 let (store, _dir) = in_memory_store();
1284 let cm = CacheManager::new(store, u64::MAX);
1285 let pipeline = make_pipeline();
1286
1287 cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
1288 .unwrap();
1289
1290 let freed = cm.evict_lru().unwrap();
1291 assert_eq!(freed, 0);
1292 }
1293
1294 #[test]
1295 fn invalidate_removes_entry() {
1296 let dir = tempfile::tempdir().unwrap();
1297 let file_path = dir.path().join("test.txt");
1298 std::fs::write(&file_path, b"some content").unwrap();
1299
1300 let store_path = dir.path().join("store.db");
1301 let store = SessionStore::open_or_create(&store_path).unwrap();
1302 let cm = CacheManager::new(store, u64::MAX);
1303 let pipeline = make_pipeline();
1304
1305 let content = std::fs::read(&file_path).unwrap();
1307 cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
1308
1309 let hit = cm
1311 .get_or_compress(&file_path, &content, &pipeline)
1312 .unwrap();
1313 assert!(matches!(hit, CacheResult::Dedup { .. }));
1314
1315 cm.invalidate(&file_path).unwrap();
1316
1317 let miss = cm
1318 .get_or_compress(&file_path, &content, &pipeline)
1319 .unwrap();
1320 assert!(matches!(miss, CacheResult::Fresh { .. }));
1321 }
1322
1323 #[test]
1324 fn invalidate_nonexistent_path_is_noop() {
1325 let (store, _dir) = in_memory_store();
1326 let cm = CacheManager::new(store, u64::MAX);
1327 cm.invalidate(Path::new("/nonexistent/path/file.txt"))
1329 .unwrap();
1330 }
1331
1332 #[test]
1340 fn stale_ref_returns_fresh_instead_of_dedup() {
1341 let (store, _dir) = in_memory_store();
1342 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
1344 let pipeline = make_pipeline();
1345 let content = b"hello world";
1346 let path = Path::new("file.txt");
1347
1348 cm.get_or_compress(path, content, &pipeline).unwrap();
1350
1351 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1353 assert!(
1354 matches!(result, CacheResult::Fresh { .. }),
1355 "stale ref (TTL=0) should return Fresh, not Dedup"
1356 );
1357 }
1358
1359 #[test]
1360 fn fresh_ref_returns_dedup() {
1361 let (store, _dir) = in_memory_store();
1362 let cm = CacheManager::with_ref_age_duration(
1364 store,
1365 u64::MAX,
1366 Duration::from_secs(86_400),
1367 );
1368 let pipeline = make_pipeline();
1369 let content = b"hello world";
1370 let path = Path::new("file.txt");
1371
1372 cm.get_or_compress(path, content, &pipeline).unwrap();
1373 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1374 assert!(
1375 matches!(result, CacheResult::Dedup { .. }),
1376 "fresh ref should dedup"
1377 );
1378 }
1379
1380 #[test]
1381 fn notify_compaction_invalidates_all_refs() {
1382 let (store, _dir) = in_memory_store();
1383 let cm = CacheManager::with_ref_age_duration(
1384 store,
1385 u64::MAX,
1386 Duration::from_secs(86_400),
1387 );
1388 let pipeline = make_pipeline();
1389 let path = Path::new("file.txt");
1390
1391 cm.get_or_compress(path, b"content A", &pipeline).unwrap();
1393 cm.get_or_compress(path, b"content B", &pipeline).unwrap();
1394 assert!(matches!(
1395 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1396 CacheResult::Dedup { .. }
1397 ));
1398 assert!(matches!(
1399 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1400 CacheResult::Dedup { .. }
1401 ));
1402
1403 std::thread::sleep(std::time::Duration::from_millis(10));
1408 cm.notify_compaction();
1409
1410 assert!(matches!(
1412 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1413 CacheResult::Fresh { .. }
1414 ));
1415 assert!(matches!(
1416 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1417 CacheResult::Fresh { .. }
1418 ));
1419 }
1420
1421 #[test]
1422 fn ref_refreshed_after_resend() {
1423 let (store, _dir) = in_memory_store();
1424 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1427 let pipeline = make_pipeline();
1428 let content = b"hello world";
1429 let path = Path::new("file.txt");
1430
1431 cm.get_or_compress(path, content, &pipeline).unwrap();
1432 std::thread::sleep(std::time::Duration::from_millis(10));
1435 cm.notify_compaction();
1436
1437 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1440 assert!(matches!(result, CacheResult::Fresh { .. }));
1441
1442 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1444 assert!(
1445 matches!(result, CacheResult::Dedup { .. }),
1446 "ref should be fresh after re-send"
1447 );
1448 }
1449
1450 #[test]
1451 fn check_dedup_returns_none_for_stale_ref() {
1452 let pipeline = make_pipeline();
1453 let content = b"test content";
1454 let path = Path::new("file.txt");
1455
1456 let (store, _dir) = in_memory_store();
1458 let fresh =
1459 CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1460 fresh.get_or_compress(path, content, &pipeline).unwrap();
1461 assert!(fresh.check_dedup(content).unwrap().is_some());
1462
1463 let (store2, _dir2) = in_memory_store();
1465 let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1466 stale.get_or_compress(path, content, &pipeline).unwrap();
1467 assert!(
1468 stale.check_dedup(content).unwrap().is_none(),
1469 "stale ref should not be returned by check_dedup"
1470 );
1471 }
1472
1473 #[test]
1474 fn pinned_ref_stays_fresh_past_ttl() {
1475 let (store, _dir) = in_memory_store();
1476 let cm = CacheManager::with_ref_age_duration(
1477 store,
1478 u64::MAX,
1479 Duration::from_millis(10),
1480 );
1481 let pipeline = make_pipeline();
1482 let content = b"wiki: stable knowledge base content";
1483 let path = Path::new("wiki.md");
1484
1485 cm.get_or_compress(path, content, &pipeline).unwrap();
1487 let hash = CacheManager::sha256_hex(content);
1488 assert!(cm.store.pin_cache_entry(&hash).unwrap());
1489
1490 std::thread::sleep(std::time::Duration::from_millis(40));
1492
1493 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1495 assert!(
1496 matches!(result, CacheResult::Dedup { .. }),
1497 "pinned entry should stay fresh past TTL"
1498 );
1499 assert!(cm.check_dedup(content).unwrap().is_some());
1500 }
1501
1502 #[test]
1503 fn unpin_returns_to_ttl_behaviour() {
1504 let (store, _dir) = in_memory_store();
1505 let cm = CacheManager::with_ref_age_duration(
1506 store,
1507 u64::MAX,
1508 Duration::from_millis(10),
1509 );
1510 let pipeline = make_pipeline();
1511 let content = b"transient session content";
1512 let path = Path::new("file.txt");
1513
1514 cm.get_or_compress(path, content, &pipeline).unwrap();
1515 let hash = CacheManager::sha256_hex(content);
1516 cm.store.pin_cache_entry(&hash).unwrap();
1517
1518 std::thread::sleep(std::time::Duration::from_millis(25));
1520 assert!(matches!(
1521 cm.get_or_compress(path, content, &pipeline).unwrap(),
1522 CacheResult::Dedup { .. }
1523 ));
1524
1525 cm.store.unpin_cache_entry(&hash).unwrap();
1527 std::thread::sleep(std::time::Duration::from_millis(25));
1528 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1529 assert!(
1530 matches!(result, CacheResult::Fresh { .. }),
1531 "unpinned entry should obey TTL"
1532 );
1533 }
1534
1535 #[test]
1536 fn pinned_ref_survives_compaction_marker() {
1537 let (store, _dir) = in_memory_store();
1538 let cm = CacheManager::with_ref_age_duration(
1539 store,
1540 u64::MAX,
1541 Duration::from_secs(3600),
1542 );
1543 let pipeline = make_pipeline();
1544 let content = b"system prompt: shared across agents";
1545 let path = Path::new("system.md");
1546
1547 cm.get_or_compress(path, content, &pipeline).unwrap();
1548 let hash = CacheManager::sha256_hex(content);
1549 cm.store.pin_cache_entry(&hash).unwrap();
1550
1551 std::thread::sleep(std::time::Duration::from_millis(10));
1553 cm.notify_compaction();
1554
1555 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1558 assert!(
1559 matches!(result, CacheResult::Dedup { .. }),
1560 "pinned entry should survive compaction"
1561 );
1562 }
1563
1564 #[test]
1565 fn pin_visible_across_cachemanager_instances() {
1566 let dir = tempfile::tempdir().unwrap();
1571 let path = dir.path().join("shared.db");
1572
1573 let store_a = SessionStore::open_or_create(&path).unwrap();
1574 let cm_a = CacheManager::with_ref_age_duration(
1575 store_a,
1576 u64::MAX,
1577 Duration::from_millis(5),
1578 );
1579 let pipeline = make_pipeline();
1580 let content = b"shared wiki content for multi-agent dedup";
1581
1582 cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1583 .unwrap();
1584 let hash = CacheManager::sha256_hex(content);
1585 cm_a.store.pin_cache_entry(&hash).unwrap();
1586
1587 drop(cm_a);
1589 std::thread::sleep(std::time::Duration::from_millis(20));
1590
1591 let store_b = SessionStore::open_or_create(&path).unwrap();
1592 let cm_b = CacheManager::with_ref_age_duration(
1593 store_b,
1594 u64::MAX,
1595 Duration::from_millis(5),
1596 );
1597 let result = cm_b
1598 .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1599 .unwrap();
1600 assert!(
1601 matches!(result, CacheResult::Dedup { .. }),
1602 "second agent should see the pin from the shared store"
1603 );
1604 }
1605
1606 #[test]
1607 fn advance_turn_increments_counter() {
1608 let (store, _dir) = in_memory_store();
1610 let cm = CacheManager::new(store, u64::MAX);
1611 assert_eq!(cm.current_turn(), 0);
1612 cm.advance_turn();
1613 assert_eq!(cm.current_turn(), 1);
1614 cm.advance_turn();
1615 assert_eq!(cm.current_turn(), 2);
1616 }
1617
1618 #[test]
1621 fn expand_returns_original_bytes_for_new_entry() {
1622 let (store, _dir) = in_memory_store();
1626 let cm = CacheManager::new(store, u64::MAX);
1627 let pipeline = make_pipeline();
1628 let path = Path::new("x.txt");
1629 let content = b"hello\nworld\nthis is the original\n";
1630
1631 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1633 let CacheResult::Fresh { .. } = first else {
1634 panic!("first read should be a Fresh miss, got something else");
1635 };
1636
1637 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1639 let inline_ref = match second {
1640 CacheResult::Dedup { inline_ref, .. } => inline_ref,
1641 _ => panic!("second read should dedup"),
1642 };
1643
1644 let prefix = inline_ref
1646 .strip_prefix("§ref:")
1647 .and_then(|s| s.strip_suffix('§'))
1648 .expect("unexpected ref format");
1649
1650 let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1651 match result {
1652 ExpandResult::Original { bytes, hash } => {
1653 assert_eq!(bytes, content, "expand must return exact original bytes");
1654 assert!(
1655 hash.starts_with(prefix),
1656 "returned full hash {hash} must start with prefix {prefix}"
1657 );
1658 assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1659 }
1660 ExpandResult::CompressedOnly { .. } => {
1661 panic!("new cache entries must carry the original bytes");
1662 }
1663 }
1664 }
1665
1666 #[test]
1667 fn expand_prefix_rejects_nonhex_input() {
1668 let (store, _dir) = in_memory_store();
1673 let cm = CacheManager::new(store, u64::MAX);
1674 assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1675 assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1676 assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1677 assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1678 }
1679
1680 #[test]
1681 fn expand_prefix_returns_none_for_unknown_prefix() {
1682 let (store, _dir) = in_memory_store();
1685 let cm = CacheManager::new(store, u64::MAX);
1686 let pipeline = make_pipeline();
1687 let _ = cm
1689 .get_or_compress(
1690 Path::new("y.txt"),
1691 b"some cached content that won't match the prefix below",
1692 &pipeline,
1693 )
1694 .unwrap();
1695 assert!(
1696 cm.expand_prefix("0000000000000000").unwrap().is_none(),
1697 "prefix that matches nothing must return None, not error"
1698 );
1699 }
1700
1701 #[test]
1702 fn expand_prefix_errors_on_ambiguous_match() {
1703 let (store, _dir) = in_memory_store();
1708 let compressed = crate::types::CompressedContent {
1710 data: "compressed-a".to_string(),
1711 tokens_compressed: 1,
1712 tokens_original: 10,
1713 stages_applied: vec![],
1714 compression_ratio: 0.1,
1715 provenance: Default::default(),
1716 verify: None,
1717 };
1718 store
1719 .save_cache_entry_with_original(
1720 &format!("ab{}", "0".repeat(62)),
1721 &compressed,
1722 Some(b"content a"),
1723 )
1724 .unwrap();
1725 store
1726 .save_cache_entry_with_original(
1727 &format!("ab{}", "1".repeat(62)),
1728 &compressed,
1729 Some(b"content b"),
1730 )
1731 .unwrap();
1732
1733 let cm = CacheManager::new(store, u64::MAX);
1734 let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1736 assert!(matches!(ok_a, ExpandResult::Original { .. }));
1737 let err = cm.expand_prefix("ab").unwrap_err();
1739 let msg = err.to_string();
1740 assert!(
1741 msg.contains("multiple entries") || msg.contains("longer prefix"),
1742 "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1743 );
1744 }
1745
1746 #[test]
1747 fn expand_returns_compressed_only_for_pre_migration_entry() {
1748 let (store, _dir) = in_memory_store();
1753 let compressed = crate::types::CompressedContent {
1754 data: "the compressed version".to_string(),
1755 tokens_compressed: 4,
1756 tokens_original: 100,
1757 stages_applied: vec!["condense".to_string()],
1758 compression_ratio: 0.04,
1759 provenance: Default::default(),
1760 verify: None,
1761 };
1762 let hash = format!("deadbeef{}", "0".repeat(56));
1764 store.save_cache_entry(&hash, &compressed).unwrap();
1765
1766 let cm = CacheManager::new(store, u64::MAX);
1767 let result = cm
1768 .expand_prefix("deadbeef")
1769 .unwrap()
1770 .expect("deadbeef prefix should match");
1771 match result {
1772 ExpandResult::CompressedOnly { compressed, hash: h } => {
1773 assert_eq!(compressed, "the compressed version");
1774 assert_eq!(h, hash);
1775 }
1776 ExpandResult::Original { .. } => {
1777 panic!("legacy entry without original bytes should return CompressedOnly");
1778 }
1779 }
1780 }
1781
1782 #[test]
1783 fn expand_preserves_non_utf8_original_bytes() {
1784 let (store, _dir) = in_memory_store();
1790 let cm = CacheManager::new(store, u64::MAX);
1791 let pipeline = make_pipeline();
1792
1793 let content: Vec<u8> = vec![
1795 b'h', b'e', b'l', b'l', b'o', b'\n',
1796 0xFF, 0xFE, 0xFD,
1797 b'\n',
1798 ];
1799 let path = Path::new("bin.dat");
1800 let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1801 let hash = CacheManager::sha256_hex(&content);
1803 let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1804 match result {
1805 ExpandResult::Original { bytes, .. } => {
1806 assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1807 }
1808 ExpandResult::CompressedOnly { .. } => {
1809 panic!("fresh entry should have original bytes");
1810 }
1811 }
1812 }
1813
1814 #[test]
1815 fn dedup_survives_cache_manager_restart() {
1816 let dir = tempfile::tempdir().unwrap();
1822 let db_path = dir.path().join("cache.db");
1823 let pipeline = make_pipeline();
1824 let content = b"a substantial chunk of content to dedup";
1825 let path = Path::new("x.txt");
1826
1827 {
1829 let store = SessionStore::open_or_create(&db_path).unwrap();
1830 let cm = CacheManager::with_ref_age_duration(
1831 store,
1832 u64::MAX,
1833 Duration::from_secs(3600),
1834 );
1835 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1836 assert!(matches!(first, CacheResult::Fresh { .. }));
1837 }
1838
1839 {
1841 let store = SessionStore::open_or_create(&db_path).unwrap();
1842 let cm = CacheManager::with_ref_age_duration(
1843 store,
1844 u64::MAX,
1845 Duration::from_secs(3600),
1846 );
1847 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1848 assert!(
1849 matches!(second, CacheResult::Dedup { .. }),
1850 "second-process read must dedup — this was broken before the April 18 fix"
1851 );
1852 }
1853 }
1854
1855 #[test]
1856 fn compaction_from_one_process_invalidates_refs_in_another() {
1857 let dir = tempfile::tempdir().unwrap();
1869 let db_path = dir.path().join("cache.db");
1870 let pipeline = make_pipeline();
1871 let content = b"content that needs stale-marking after compaction";
1872 let path = Path::new("file.txt");
1873 let ttl = Duration::from_secs(3600);
1874
1875 {
1877 let store = SessionStore::open_or_create(&db_path).unwrap();
1878 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1879 cm.get_or_compress(path, content, &pipeline).unwrap();
1880 }
1881 std::thread::sleep(Duration::from_millis(10));
1883
1884 {
1887 let store = SessionStore::open_or_create(&db_path).unwrap();
1888 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1889 cm.notify_compaction();
1890 }
1891
1892 {
1896 let store = SessionStore::open_or_create(&db_path).unwrap();
1897 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1898 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1899 assert!(
1900 matches!(result, CacheResult::Fresh { .. }),
1901 "post-compaction read from a fresh process must re-send Fresh; \
1902 returning Dedup would be a dangling-ref bug"
1903 );
1904 }
1905 }
1906
1907 use proptest::prelude::*;
1908
1909 proptest! {
1917 #[test]
1922 fn prop_cache_deduplication(
1923 content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1924 ) {
1925 let (store, _dir) = in_memory_store();
1926 let cm = CacheManager::new(store, u64::MAX);
1927 let pipeline = make_pipeline();
1928 let path = Path::new("file.txt");
1929
1930 let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1932 prop_assert!(
1933 matches!(first, CacheResult::Fresh { .. }),
1934 "first read should be a cache miss"
1935 );
1936
1937 let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1938 match second {
1939 CacheResult::Dedup { inline_ref, token_cost } => {
1940 prop_assert_eq!(
1941 token_cost, 13,
1942 "cache hit should report ~13 reference tokens"
1943 );
1944 prop_assert!(
1945 inline_ref.starts_with("§ref:"),
1946 "reference token should start with §ref:"
1947 );
1948 prop_assert!(
1949 inline_ref.ends_with('§'),
1950 "reference token should end with §"
1951 );
1952 }
1953 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1954 prop_assert!(false, "second read should be a cache hit, not a miss");
1955 }
1956 }
1957 }
1958 }
1959
1960 proptest! {
1968 #[test]
1973 fn prop_cache_invalidation_on_content_change(
1974 content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1975 content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1976 ) {
1977 prop_assume!(content_a != content_b);
1979
1980 let (store, _dir) = in_memory_store();
1981 let cm = CacheManager::new(store, u64::MAX);
1982 let pipeline = make_pipeline();
1983 let path = Path::new("file.txt");
1984
1985 let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1987 prop_assert!(
1988 matches!(r1, CacheResult::Fresh { .. }),
1989 "first read of content_a should be a miss"
1990 );
1991
1992 let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1993 prop_assert!(
1994 matches!(r2, CacheResult::Dedup { .. }),
1995 "second read of content_a should be a hit"
1996 );
1997
1998 let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1999 prop_assert!(
2000 matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
2001 "read with changed content should be a cache miss or delta"
2002 );
2003 }
2004 }
2005
2006 proptest! {
2014 #[test]
2019 fn prop_cache_lru_eviction(
2020 entries in proptest::collection::vec(
2022 proptest::collection::vec(any::<u8>(), 10..=200usize),
2023 2..=8usize,
2024 ),
2025 ) {
2026 let mut unique_entries: Vec<Vec<u8>> = Vec::new();
2028 for e in &entries {
2029 if !unique_entries.contains(e) {
2030 unique_entries.push(e.clone());
2031 }
2032 }
2033 prop_assume!(unique_entries.len() >= 2);
2034
2035 let (store, _dir) = in_memory_store();
2036 let cm = CacheManager::new(store, 1);
2038 let pipeline = make_pipeline();
2039 let path = Path::new("f.txt");
2040
2041 for entry in &unique_entries {
2043 cm.get_or_compress(path, entry, &pipeline).unwrap();
2044 }
2045
2046 let freed = cm.evict_lru().unwrap();
2048
2049 prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
2051
2052 let freed_again = cm.evict_lru().unwrap();
2055 prop_assert_eq!(
2056 freed_again, 0,
2057 "second evict_lru call should free 0 bytes (already at or below limit)"
2058 );
2059 }
2060 }
2061
2062 proptest! {
2071 #[test]
2082 fn prop_cache_persistence_across_sessions(
2083 content in proptest::collection::vec(any::<u8>(), 1..=500usize),
2084 ) {
2085 use crate::session_store::SessionStore;
2086
2087 let dir = tempfile::tempdir().unwrap();
2088 let db_path = dir.path().join("cache.db");
2089 let path = Path::new("file.txt");
2090
2091 {
2093 let store = SessionStore::open_or_create(&db_path).unwrap();
2094 let cm = CacheManager::with_ref_age_duration(
2096 store,
2097 u64::MAX,
2098 Duration::from_secs(3600),
2099 );
2100 let pipeline = make_pipeline();
2101
2102 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2103 prop_assert!(
2104 matches!(r, CacheResult::Fresh { .. }),
2105 "first-ever read should be a miss"
2106 );
2107 }
2108
2109 {
2111 let store = SessionStore::open_or_create(&db_path).unwrap();
2112 let cm = CacheManager::with_ref_age_duration(
2113 store,
2114 u64::MAX,
2115 Duration::from_secs(3600),
2116 );
2117 let pipeline = make_pipeline();
2118
2119 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2124 match r {
2125 CacheResult::Dedup { token_cost, .. } => {
2126 prop_assert_eq!(
2127 token_cost, 13,
2128 "first read after restart must be a 13-token dedup ref"
2129 );
2130 }
2131 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
2132 prop_assert!(
2133 false,
2134 "first read after restart must dedup — this was the \
2135 April 18 bug and its fix is the whole reason this \
2136 test exists"
2137 );
2138 }
2139 }
2140 }
2141 }
2142 }
2143}