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)]
78#[allow(dead_code)]
79struct RefEntry {
80 last_sent_turn: u64,
82}
83
84pub struct CacheManager {
102 store: SessionStore,
103 max_size_bytes: u64,
104 delta_encoder: DeltaEncoder,
105 #[allow(dead_code)]
108 turn_counter: std::cell::Cell<u64>,
109 #[allow(dead_code)]
111 ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
112 max_ref_age: Duration,
117 compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
121 lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
125 lsh_hashes: std::cell::RefCell<Vec<String>>,
127}
128
129impl CacheManager {
130 pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
136 Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
137 }
138
139 #[doc(hidden)]
145 pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
146 Self::with_ref_age_duration(
147 store,
148 max_size_bytes,
149 Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
150 )
151 }
152
153 pub fn with_ref_age_duration(
155 store: SessionStore,
156 max_size_bytes: u64,
157 max_ref_age: Duration,
158 ) -> Self {
159 Self {
160 store,
161 max_size_bytes,
162 delta_encoder: DeltaEncoder::new(),
163 turn_counter: std::cell::Cell::new(0),
164 ref_tracker: std::cell::RefCell::new(HashMap::new()),
165 max_ref_age,
166 compaction_marker: std::cell::Cell::new(None),
167 lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
168 lsh_hashes: std::cell::RefCell::new(Vec::new()),
169 }
170 }
171
172 fn index_for_similarity(&self, hash: &str, data: &str) {
176 const MAX_INDEXED: usize = 512;
177 let mut hashes = self.lsh_hashes.borrow_mut();
178 if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
179 return;
180 }
181 let doc_id = hashes.len() as u64;
182 self.lsh.borrow_mut().insert(doc_id, data);
183 hashes.push(hash.to_string());
184 }
185
186 pub fn sha256_hex(bytes: &[u8]) -> String {
188 let mut hasher = Sha256::new();
189 hasher.update(bytes);
190 format!("{:x}", hasher.finalize())
191 }
192
193 pub fn advance_turn(&self) {
197 self.turn_counter.set(self.turn_counter.get() + 1);
198 }
199
200 pub fn current_turn(&self) -> u64 {
202 self.turn_counter.get()
203 }
204
205 pub fn notify_compaction(&self) {
219 let now = chrono::Utc::now();
220 self.compaction_marker.set(Some(now));
221 self.ref_tracker.borrow_mut().clear();
222 let _ = self
228 .store
229 .set_metadata("last_compaction_at", &now.to_rfc3339());
230 }
231
232 fn is_ref_fresh(&self, hash: &str) -> bool {
247 if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
251 return true;
252 }
253 let accessed = match self.store.get_cache_entry_accessed_at(hash) {
254 Ok(Some(ts)) => ts,
255 _ => return false,
256 };
257 if let Some(marker) = self.compaction_marker.get() {
259 if accessed < marker {
260 return false;
261 }
262 }
263 if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
267 if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
268 if accessed < marker {
269 return false;
270 }
271 }
272 }
273 let age = (chrono::Utc::now() - accessed)
274 .to_std()
275 .unwrap_or(Duration::from_secs(0));
276 age < self.max_ref_age
277 }
278
279 fn record_ref_sent(&self, hash: &str) {
285 let _ = self.store.touch_cache_entry(hash);
286 }
287
288 pub fn get_or_compress(
296 &self,
297 path: &Path,
298 content: &[u8],
299 pipeline: &CompressionPipeline,
300 ) -> Result<CacheResult> {
301 self.get_or_compress_inner(path, content, pipeline, false)
302 }
303
304 pub fn get_or_compress_lossless(
310 &self,
311 path: &Path,
312 content: &[u8],
313 pipeline: &CompressionPipeline,
314 ) -> Result<CacheResult> {
315 self.get_or_compress_inner(path, content, pipeline, true)
316 }
317
318 fn get_or_compress_inner(
319 &self,
320 _path: &Path,
321 content: &[u8],
322 pipeline: &CompressionPipeline,
323 lossless: bool,
324 ) -> Result<CacheResult> {
325 let hash = Self::sha256_hex(content);
326
327 let exists = self.store.cache_entry_exists(&hash)?;
332 if exists {
333 if self.is_ref_fresh(&hash) {
334 let hash_prefix = &hash[..16];
336 let inline_ref = format!("§ref:{hash_prefix}§");
337 self.record_ref_sent(&hash);
339 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
340 return Ok(CacheResult::Dedup {
341 inline_ref,
342 token_cost: 13,
343 });
344 } else {
345 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
348 let text = String::from_utf8_lossy(content).into_owned();
349 let ctx = SessionContext {
350 session_id: "cache".to_string(),
351 };
352 let preset = Preset::default();
353 let compressed = if lossless {
354 pipeline.compress_lossless(&text, &ctx, &preset)?
355 } else {
356 pipeline.compress(&text, &ctx, &preset)?
357 };
358 self.record_ref_sent(&hash);
360 return Ok(CacheResult::Fresh { output: compressed });
361 }
362 }
363
364 let text = String::from_utf8_lossy(content).into_owned();
366 let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
367 if let Some(delta_result) = self.try_delta_encode(&text)? {
368 let ctx = SessionContext {
370 session_id: "cache".to_string(),
371 };
372 let preset = Preset::default();
373 let compressed = if lossless {
374 pipeline.compress_lossless(&text, &ctx, &preset)?
375 } else {
376 pipeline.compress(&text, &ctx, &preset)?
377 };
378 self.store
380 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
381 self.index_for_similarity(&hash, &compressed.data);
382 self.record_ref_sent(&hash);
383
384 let token_cost = (delta_result.delta_text.len() / 4) as u32;
385 return Ok(CacheResult::Delta {
386 delta_text: delta_result.delta_text,
387 token_cost: token_cost.max(5),
388 similarity: delta_result.similarity,
389 });
390 }
391
392 let ctx = SessionContext {
393 session_id: "cache".to_string(),
394 };
395 let preset = Preset::default();
396 let compressed = if lossless {
397 pipeline.compress_lossless(&text, &ctx, &preset)?
398 } else {
399 pipeline.compress(&text, &ctx, &preset)?
400 };
401 self.store
402 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
403 self.index_for_similarity(&hash, &compressed.data);
404 self.record_ref_sent(&hash);
406
407 Ok(CacheResult::Fresh { output: compressed })
408 }
409
410 fn try_delta_encode(
420 &self,
421 new_content: &str,
422 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
423 let candidates: Vec<String> = {
425 let lsh = self.lsh.borrow();
426 let hashes = self.lsh_hashes.borrow();
427 let query_sig = crate::minhash_lsh::MinHashLsh::compute_signature(new_content);
428 let mut scored: Vec<(f64, &String)> = lsh
429 .query(new_content)
430 .into_iter()
431 .filter_map(|doc_id| {
432 let hash = hashes.get(doc_id as usize)?;
433 let sim = lsh.get_signature(doc_id)?.jaccard_similarity(&query_sig);
434 (sim >= 0.5).then_some((sim, hash))
435 })
436 .collect();
437 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
438 scored.into_iter().take(5).map(|(_, h)| h.clone()).collect()
439 };
440 for hash in &candidates {
441 if let Some(delta) = self.try_delta_against(hash, new_content)? {
442 return Ok(Some(delta));
443 }
444 }
445
446 let entries = self.store.list_cache_entries_lru()?;
448 let check_count = entries.len().min(10);
449 for (hash, _) in entries.iter().rev().take(check_count) {
450 if candidates.iter().any(|c| c == hash) {
451 continue;
452 }
453 if let Some(delta) = self.try_delta_against(hash, new_content)? {
454 return Ok(Some(delta));
455 }
456 }
457
458 Ok(None)
459 }
460
461 fn try_delta_against(
462 &self,
463 hash: &str,
464 new_content: &str,
465 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
466 if let Some(cached) = self.store.get_cache_entry(hash)? {
467 let hash_prefix = &hash[..hash.len().min(16)];
468 if let Ok(Some(delta)) = self.delta_encoder.encode(&cached.data, new_content, hash_prefix) {
469 if delta.delta_text.len() < new_content.len() {
470 return Ok(Some(delta));
471 }
472 }
473 }
474 Ok(None)
475 }
476
477 pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
486 let hash = Self::sha256_hex(content);
487 let fresh = self.is_ref_fresh(&hash);
489 if fresh {
490 let hash_prefix = &hash[..16];
491 self.record_ref_sent(&hash);
492 Ok(Some(format!("§ref:{hash_prefix}§")))
493 } else {
494 Ok(None)
497 }
498 }
499
500 pub fn store_compressed(
508 &self,
509 original_content: &[u8],
510 compressed: &CompressedContent,
511 ) -> Result<()> {
512 let hash = Self::sha256_hex(original_content);
513 self.store
514 .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
515 self.index_for_similarity(&hash, &compressed.data);
516 self.record_ref_sent(&hash);
517 Ok(())
518 }
519
520 pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
546 let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
547 return Ok(None);
548 };
549
550 if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
553 return Ok(Some(ExpandResult::Original { hash, bytes }));
554 }
555 Ok(Some(ExpandResult::CompressedOnly {
556 hash,
557 compressed: compressed_entry.data,
558 }))
559 }
560
561 pub fn invalidate(&self, path: &Path) -> Result<()> {
566 if !path.exists() {
567 return Ok(());
568 }
569 let bytes = std::fs::read(path)?;
570 let hash = Self::sha256_hex(&bytes);
571 self.store.delete_cache_entry(&hash)?;
572 Ok(())
573 }
574
575 pub fn evict_lru(&self) -> Result<u64> {
580 let entries = self.store.list_cache_entries_lru()?;
581
582 let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
584 if total <= self.max_size_bytes {
585 return Ok(0);
586 }
587
588 let mut freed: u64 = 0;
589 let mut remaining = total;
590
591 for (hash, size) in &entries {
592 if remaining <= self.max_size_bytes {
593 break;
594 }
595 self.store.delete_cache_entry(hash)?;
596 freed += size;
597 remaining -= size;
598 }
599
600 Ok(freed)
601 }
602}
603
604#[cfg(test)]
607mod tests {
608 use super::*;
609 use crate::preset::{
610 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
611 CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
612 ToolSelectionConfig, TruncateStringsConfig,
613 };
614 use crate::session_store::SessionStore;
615
616 fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
617 let dir = tempfile::tempdir().unwrap();
618 let path = dir.path().join("test.db");
619 let store = SessionStore::open_or_create(&path).unwrap();
620 (store, dir)
621 }
622
623 fn test_preset() -> Preset {
624 Preset {
625 preset: PresetMeta {
626 name: "test".into(),
627 version: "1.0".into(),
628 description: String::new(),
629 },
630 compression: CompressionConfig {
631 stages: vec![],
632 keep_fields: None,
633 strip_fields: None,
634 condense: Some(CondenseConfig {
635 enabled: true,
636 max_repeated_lines: 3,
637 }),
638 git_diff_fold: None,
639 strip_nulls: Some(StripNullsConfig { enabled: true }),
640 flatten: None,
641 truncate_strings: Some(TruncateStringsConfig {
642 enabled: true,
643 max_length: 500,
644 }),
645 collapse_arrays: Some(CollapseArraysConfig {
646 enabled: true,
647 max_items: 5,
648 summary_template: "... and {remaining} more items".into(),
649 }),
650 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
651 },
652 tool_selection: ToolSelectionConfig {
653 max_tools: 5,
654 similarity_threshold: 0.7,
655 default_tools: vec![],
656 },
657 budget: BudgetConfig {
658 warning_threshold: 0.70,
659 ceiling_threshold: 0.85,
660 default_window_size: 200_000,
661 agents: Default::default(),
662 },
663 terse_mode: TerseModeConfig {
664 enabled: false,
665 level: crate::preset::TerseLevel::Moderate,
666 },
667 model: ModelConfig {
668 family: "anthropic".into(),
669 primary: "claude-sonnet-4-20250514".into(),
670 local: String::new(),
671 complexity_threshold: 0.4,
672 pricing: None,
673 },
674 }
675 }
676
677 fn make_pipeline() -> CompressionPipeline {
678 CompressionPipeline::new(&test_preset())
679 }
680
681 #[test]
682 fn first_read_is_miss() {
683 let (store, _dir) = in_memory_store();
684 let cm = CacheManager::new(store, u64::MAX);
685 let pipeline = make_pipeline();
686 let content = b"hello world";
687 let result = cm
688 .get_or_compress(Path::new("file.txt"), content, &pipeline)
689 .unwrap();
690 assert!(matches!(result, CacheResult::Fresh { .. }));
691 }
692
693 #[test]
698 fn get_or_compress_lossless_does_not_truncate_source() {
699 let pipeline = make_pipeline();
700
701 let mut segs = Vec::new();
704 for i in 0..24 {
706 if i % 2 == 0 {
707 segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
708 } else {
709 segs.push(format!(
710 "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
711 ));
712 }
713 }
714 let content = segs.join("\n\n");
715 assert!(content.len() > 1000);
716
717 let (s1, _d1) = in_memory_store();
719 let lossy = CacheManager::new(s1, u64::MAX);
720 match lossy
721 .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
722 .unwrap()
723 {
724 CacheResult::Fresh { output } => {
725 assert!(output.data.contains("omitted"), "lossy path should truncate");
726 }
727 _ => panic!("expected Fresh on a cold read"),
728 }
729
730 let (s2, _d2) = in_memory_store();
732 let cm = CacheManager::new(s2, u64::MAX);
733 match cm
734 .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
735 .unwrap()
736 {
737 CacheResult::Fresh { output } => {
738 assert!(
739 !output.data.contains("omitted"),
740 "file-read path truncated content: {}",
741 output.data
742 );
743 for i in 0..10 {
744 assert!(
745 output.data.contains(&format!("SEG{i}")),
746 "file-read path dropped SEG{i}"
747 );
748 }
749 }
750 _ => panic!("expected CacheResult::Fresh on a cold read"),
751 }
752 }
753
754 #[test]
757 fn lsh_finds_near_duplicate_beyond_lru_window() {
758 let (store, _dir) = in_memory_store();
759 let cm = CacheManager::new(store, u64::MAX);
760 let pipeline = make_pipeline();
761
762 let base: String = (0..40)
763 .map(|i| format!("stable log line number {i} with steady content here\n"))
764 .collect();
765 cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
766 .unwrap();
767
768 for n in 0..12 {
770 let filler: String = (0..30)
771 .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
772 .collect();
773 cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
774 .unwrap();
775 }
776
777 let variant = base.replace(
779 "stable log line number 7 with steady content here",
780 "stable log line number 7 with slightly altered content",
781 );
782 let result = cm
783 .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
784 .unwrap();
785 let kind = match &result {
786 CacheResult::Dedup { .. } => "Dedup",
787 CacheResult::Delta { .. } => "Delta",
788 CacheResult::Fresh { .. } => "Fresh",
789 };
790 assert!(
791 matches!(result, CacheResult::Delta { .. }),
792 "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
793 );
794 }
795
796 #[test]
797 fn second_read_is_hit() {
798 let (store, _dir) = in_memory_store();
799 let cm = CacheManager::new(store, u64::MAX);
800 let pipeline = make_pipeline();
801 let content = b"hello world";
802 let path = Path::new("file.txt");
803
804 cm.get_or_compress(path, content, &pipeline).unwrap();
806
807 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
809 match result {
810 CacheResult::Dedup {
811 inline_ref,
812 token_cost,
813 } => {
814 assert!(inline_ref.starts_with("§ref:"));
815 assert!(inline_ref.ends_with('§'));
816 assert_eq!(token_cost, 13);
817 }
818 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
819 }
820 }
821
822 #[test]
823 fn different_content_is_miss() {
824 let (store, _dir) = in_memory_store();
825 let cm = CacheManager::new(store, u64::MAX);
826 let pipeline = make_pipeline();
827 let path = Path::new("file.txt");
828
829 cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
830 let result = cm
831 .get_or_compress(path, b"content v2", &pipeline)
832 .unwrap();
833 assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
834 }
835
836 #[test]
837 fn evict_lru_frees_bytes_when_over_limit() {
838 let (store, _dir) = in_memory_store();
839 let cm = CacheManager::new(store, 1);
841 let pipeline = make_pipeline();
842 let path = Path::new("f.txt");
843
844 cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
846 cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
847 cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
848
849 let freed = cm.evict_lru().unwrap();
850 assert!(freed > 0, "expected bytes to be freed");
851 }
852
853 #[test]
854 fn evict_lru_no_op_when_under_limit() {
855 let (store, _dir) = in_memory_store();
856 let cm = CacheManager::new(store, u64::MAX);
857 let pipeline = make_pipeline();
858
859 cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
860 .unwrap();
861
862 let freed = cm.evict_lru().unwrap();
863 assert_eq!(freed, 0);
864 }
865
866 #[test]
867 fn invalidate_removes_entry() {
868 let dir = tempfile::tempdir().unwrap();
869 let file_path = dir.path().join("test.txt");
870 std::fs::write(&file_path, b"some content").unwrap();
871
872 let store_path = dir.path().join("store.db");
873 let store = SessionStore::open_or_create(&store_path).unwrap();
874 let cm = CacheManager::new(store, u64::MAX);
875 let pipeline = make_pipeline();
876
877 let content = std::fs::read(&file_path).unwrap();
879 cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
880
881 let hit = cm
883 .get_or_compress(&file_path, &content, &pipeline)
884 .unwrap();
885 assert!(matches!(hit, CacheResult::Dedup { .. }));
886
887 cm.invalidate(&file_path).unwrap();
888
889 let miss = cm
890 .get_or_compress(&file_path, &content, &pipeline)
891 .unwrap();
892 assert!(matches!(miss, CacheResult::Fresh { .. }));
893 }
894
895 #[test]
896 fn invalidate_nonexistent_path_is_noop() {
897 let (store, _dir) = in_memory_store();
898 let cm = CacheManager::new(store, u64::MAX);
899 cm.invalidate(Path::new("/nonexistent/path/file.txt"))
901 .unwrap();
902 }
903
904 #[test]
912 fn stale_ref_returns_fresh_instead_of_dedup() {
913 let (store, _dir) = in_memory_store();
914 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
916 let pipeline = make_pipeline();
917 let content = b"hello world";
918 let path = Path::new("file.txt");
919
920 cm.get_or_compress(path, content, &pipeline).unwrap();
922
923 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
925 assert!(
926 matches!(result, CacheResult::Fresh { .. }),
927 "stale ref (TTL=0) should return Fresh, not Dedup"
928 );
929 }
930
931 #[test]
932 fn fresh_ref_returns_dedup() {
933 let (store, _dir) = in_memory_store();
934 let cm = CacheManager::with_ref_age_duration(
936 store,
937 u64::MAX,
938 Duration::from_secs(86_400),
939 );
940 let pipeline = make_pipeline();
941 let content = b"hello world";
942 let path = Path::new("file.txt");
943
944 cm.get_or_compress(path, content, &pipeline).unwrap();
945 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
946 assert!(
947 matches!(result, CacheResult::Dedup { .. }),
948 "fresh ref should dedup"
949 );
950 }
951
952 #[test]
953 fn notify_compaction_invalidates_all_refs() {
954 let (store, _dir) = in_memory_store();
955 let cm = CacheManager::with_ref_age_duration(
956 store,
957 u64::MAX,
958 Duration::from_secs(86_400),
959 );
960 let pipeline = make_pipeline();
961 let path = Path::new("file.txt");
962
963 cm.get_or_compress(path, b"content A", &pipeline).unwrap();
965 cm.get_or_compress(path, b"content B", &pipeline).unwrap();
966 assert!(matches!(
967 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
968 CacheResult::Dedup { .. }
969 ));
970 assert!(matches!(
971 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
972 CacheResult::Dedup { .. }
973 ));
974
975 std::thread::sleep(std::time::Duration::from_millis(10));
980 cm.notify_compaction();
981
982 assert!(matches!(
984 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
985 CacheResult::Fresh { .. }
986 ));
987 assert!(matches!(
988 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
989 CacheResult::Fresh { .. }
990 ));
991 }
992
993 #[test]
994 fn ref_refreshed_after_resend() {
995 let (store, _dir) = in_memory_store();
996 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
999 let pipeline = make_pipeline();
1000 let content = b"hello world";
1001 let path = Path::new("file.txt");
1002
1003 cm.get_or_compress(path, content, &pipeline).unwrap();
1004 std::thread::sleep(std::time::Duration::from_millis(10));
1007 cm.notify_compaction();
1008
1009 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1012 assert!(matches!(result, CacheResult::Fresh { .. }));
1013
1014 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1016 assert!(
1017 matches!(result, CacheResult::Dedup { .. }),
1018 "ref should be fresh after re-send"
1019 );
1020 }
1021
1022 #[test]
1023 fn check_dedup_returns_none_for_stale_ref() {
1024 let pipeline = make_pipeline();
1025 let content = b"test content";
1026 let path = Path::new("file.txt");
1027
1028 let (store, _dir) = in_memory_store();
1030 let fresh =
1031 CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1032 fresh.get_or_compress(path, content, &pipeline).unwrap();
1033 assert!(fresh.check_dedup(content).unwrap().is_some());
1034
1035 let (store2, _dir2) = in_memory_store();
1037 let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1038 stale.get_or_compress(path, content, &pipeline).unwrap();
1039 assert!(
1040 stale.check_dedup(content).unwrap().is_none(),
1041 "stale ref should not be returned by check_dedup"
1042 );
1043 }
1044
1045 #[test]
1046 fn pinned_ref_stays_fresh_past_ttl() {
1047 let (store, _dir) = in_memory_store();
1048 let cm = CacheManager::with_ref_age_duration(
1049 store,
1050 u64::MAX,
1051 Duration::from_millis(10),
1052 );
1053 let pipeline = make_pipeline();
1054 let content = b"wiki: stable knowledge base content";
1055 let path = Path::new("wiki.md");
1056
1057 cm.get_or_compress(path, content, &pipeline).unwrap();
1059 let hash = CacheManager::sha256_hex(content);
1060 assert!(cm.store.pin_cache_entry(&hash).unwrap());
1061
1062 std::thread::sleep(std::time::Duration::from_millis(40));
1064
1065 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1067 assert!(
1068 matches!(result, CacheResult::Dedup { .. }),
1069 "pinned entry should stay fresh past TTL"
1070 );
1071 assert!(cm.check_dedup(content).unwrap().is_some());
1072 }
1073
1074 #[test]
1075 fn unpin_returns_to_ttl_behaviour() {
1076 let (store, _dir) = in_memory_store();
1077 let cm = CacheManager::with_ref_age_duration(
1078 store,
1079 u64::MAX,
1080 Duration::from_millis(10),
1081 );
1082 let pipeline = make_pipeline();
1083 let content = b"transient session content";
1084 let path = Path::new("file.txt");
1085
1086 cm.get_or_compress(path, content, &pipeline).unwrap();
1087 let hash = CacheManager::sha256_hex(content);
1088 cm.store.pin_cache_entry(&hash).unwrap();
1089
1090 std::thread::sleep(std::time::Duration::from_millis(25));
1092 assert!(matches!(
1093 cm.get_or_compress(path, content, &pipeline).unwrap(),
1094 CacheResult::Dedup { .. }
1095 ));
1096
1097 cm.store.unpin_cache_entry(&hash).unwrap();
1099 std::thread::sleep(std::time::Duration::from_millis(25));
1100 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1101 assert!(
1102 matches!(result, CacheResult::Fresh { .. }),
1103 "unpinned entry should obey TTL"
1104 );
1105 }
1106
1107 #[test]
1108 fn pinned_ref_survives_compaction_marker() {
1109 let (store, _dir) = in_memory_store();
1110 let cm = CacheManager::with_ref_age_duration(
1111 store,
1112 u64::MAX,
1113 Duration::from_secs(3600),
1114 );
1115 let pipeline = make_pipeline();
1116 let content = b"system prompt: shared across agents";
1117 let path = Path::new("system.md");
1118
1119 cm.get_or_compress(path, content, &pipeline).unwrap();
1120 let hash = CacheManager::sha256_hex(content);
1121 cm.store.pin_cache_entry(&hash).unwrap();
1122
1123 std::thread::sleep(std::time::Duration::from_millis(10));
1125 cm.notify_compaction();
1126
1127 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1130 assert!(
1131 matches!(result, CacheResult::Dedup { .. }),
1132 "pinned entry should survive compaction"
1133 );
1134 }
1135
1136 #[test]
1137 fn pin_visible_across_cachemanager_instances() {
1138 let dir = tempfile::tempdir().unwrap();
1143 let path = dir.path().join("shared.db");
1144
1145 let store_a = SessionStore::open_or_create(&path).unwrap();
1146 let cm_a = CacheManager::with_ref_age_duration(
1147 store_a,
1148 u64::MAX,
1149 Duration::from_millis(5),
1150 );
1151 let pipeline = make_pipeline();
1152 let content = b"shared wiki content for multi-agent dedup";
1153
1154 cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1155 .unwrap();
1156 let hash = CacheManager::sha256_hex(content);
1157 cm_a.store.pin_cache_entry(&hash).unwrap();
1158
1159 drop(cm_a);
1161 std::thread::sleep(std::time::Duration::from_millis(20));
1162
1163 let store_b = SessionStore::open_or_create(&path).unwrap();
1164 let cm_b = CacheManager::with_ref_age_duration(
1165 store_b,
1166 u64::MAX,
1167 Duration::from_millis(5),
1168 );
1169 let result = cm_b
1170 .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1171 .unwrap();
1172 assert!(
1173 matches!(result, CacheResult::Dedup { .. }),
1174 "second agent should see the pin from the shared store"
1175 );
1176 }
1177
1178 #[test]
1179 fn advance_turn_increments_counter() {
1180 let (store, _dir) = in_memory_store();
1182 let cm = CacheManager::new(store, u64::MAX);
1183 assert_eq!(cm.current_turn(), 0);
1184 cm.advance_turn();
1185 assert_eq!(cm.current_turn(), 1);
1186 cm.advance_turn();
1187 assert_eq!(cm.current_turn(), 2);
1188 }
1189
1190 #[test]
1193 fn expand_returns_original_bytes_for_new_entry() {
1194 let (store, _dir) = in_memory_store();
1198 let cm = CacheManager::new(store, u64::MAX);
1199 let pipeline = make_pipeline();
1200 let path = Path::new("x.txt");
1201 let content = b"hello\nworld\nthis is the original\n";
1202
1203 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1205 let CacheResult::Fresh { .. } = first else {
1206 panic!("first read should be a Fresh miss, got something else");
1207 };
1208
1209 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1211 let inline_ref = match second {
1212 CacheResult::Dedup { inline_ref, .. } => inline_ref,
1213 _ => panic!("second read should dedup"),
1214 };
1215
1216 let prefix = inline_ref
1218 .strip_prefix("§ref:")
1219 .and_then(|s| s.strip_suffix('§'))
1220 .expect("unexpected ref format");
1221
1222 let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1223 match result {
1224 ExpandResult::Original { bytes, hash } => {
1225 assert_eq!(bytes, content, "expand must return exact original bytes");
1226 assert!(
1227 hash.starts_with(prefix),
1228 "returned full hash {hash} must start with prefix {prefix}"
1229 );
1230 assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1231 }
1232 ExpandResult::CompressedOnly { .. } => {
1233 panic!("new cache entries must carry the original bytes");
1234 }
1235 }
1236 }
1237
1238 #[test]
1239 fn expand_prefix_rejects_nonhex_input() {
1240 let (store, _dir) = in_memory_store();
1245 let cm = CacheManager::new(store, u64::MAX);
1246 assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1247 assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1248 assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1249 assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1250 }
1251
1252 #[test]
1253 fn expand_prefix_returns_none_for_unknown_prefix() {
1254 let (store, _dir) = in_memory_store();
1257 let cm = CacheManager::new(store, u64::MAX);
1258 let pipeline = make_pipeline();
1259 let _ = cm
1261 .get_or_compress(
1262 Path::new("y.txt"),
1263 b"some cached content that won't match the prefix below",
1264 &pipeline,
1265 )
1266 .unwrap();
1267 assert!(
1268 cm.expand_prefix("0000000000000000").unwrap().is_none(),
1269 "prefix that matches nothing must return None, not error"
1270 );
1271 }
1272
1273 #[test]
1274 fn expand_prefix_errors_on_ambiguous_match() {
1275 let (store, _dir) = in_memory_store();
1280 let compressed = crate::types::CompressedContent {
1282 data: "compressed-a".to_string(),
1283 tokens_compressed: 1,
1284 tokens_original: 10,
1285 stages_applied: vec![],
1286 compression_ratio: 0.1,
1287 provenance: Default::default(),
1288 verify: None,
1289 };
1290 store
1291 .save_cache_entry_with_original(
1292 &format!("ab{}", "0".repeat(62)),
1293 &compressed,
1294 Some(b"content a"),
1295 )
1296 .unwrap();
1297 store
1298 .save_cache_entry_with_original(
1299 &format!("ab{}", "1".repeat(62)),
1300 &compressed,
1301 Some(b"content b"),
1302 )
1303 .unwrap();
1304
1305 let cm = CacheManager::new(store, u64::MAX);
1306 let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1308 assert!(matches!(ok_a, ExpandResult::Original { .. }));
1309 let err = cm.expand_prefix("ab").unwrap_err();
1311 let msg = err.to_string();
1312 assert!(
1313 msg.contains("multiple entries") || msg.contains("longer prefix"),
1314 "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1315 );
1316 }
1317
1318 #[test]
1319 fn expand_returns_compressed_only_for_pre_migration_entry() {
1320 let (store, _dir) = in_memory_store();
1325 let compressed = crate::types::CompressedContent {
1326 data: "the compressed version".to_string(),
1327 tokens_compressed: 4,
1328 tokens_original: 100,
1329 stages_applied: vec!["condense".to_string()],
1330 compression_ratio: 0.04,
1331 provenance: Default::default(),
1332 verify: None,
1333 };
1334 let hash = format!("deadbeef{}", "0".repeat(56));
1336 store.save_cache_entry(&hash, &compressed).unwrap();
1337
1338 let cm = CacheManager::new(store, u64::MAX);
1339 let result = cm
1340 .expand_prefix("deadbeef")
1341 .unwrap()
1342 .expect("deadbeef prefix should match");
1343 match result {
1344 ExpandResult::CompressedOnly { compressed, hash: h } => {
1345 assert_eq!(compressed, "the compressed version");
1346 assert_eq!(h, hash);
1347 }
1348 ExpandResult::Original { .. } => {
1349 panic!("legacy entry without original bytes should return CompressedOnly");
1350 }
1351 }
1352 }
1353
1354 #[test]
1355 fn expand_preserves_non_utf8_original_bytes() {
1356 let (store, _dir) = in_memory_store();
1362 let cm = CacheManager::new(store, u64::MAX);
1363 let pipeline = make_pipeline();
1364
1365 let content: Vec<u8> = vec![
1367 b'h', b'e', b'l', b'l', b'o', b'\n',
1368 0xFF, 0xFE, 0xFD,
1369 b'\n',
1370 ];
1371 let path = Path::new("bin.dat");
1372 let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1373 let hash = CacheManager::sha256_hex(&content);
1375 let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1376 match result {
1377 ExpandResult::Original { bytes, .. } => {
1378 assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1379 }
1380 ExpandResult::CompressedOnly { .. } => {
1381 panic!("fresh entry should have original bytes");
1382 }
1383 }
1384 }
1385
1386 #[test]
1387 fn dedup_survives_cache_manager_restart() {
1388 let dir = tempfile::tempdir().unwrap();
1394 let db_path = dir.path().join("cache.db");
1395 let pipeline = make_pipeline();
1396 let content = b"a substantial chunk of content to dedup";
1397 let path = Path::new("x.txt");
1398
1399 {
1401 let store = SessionStore::open_or_create(&db_path).unwrap();
1402 let cm = CacheManager::with_ref_age_duration(
1403 store,
1404 u64::MAX,
1405 Duration::from_secs(3600),
1406 );
1407 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1408 assert!(matches!(first, CacheResult::Fresh { .. }));
1409 }
1410
1411 {
1413 let store = SessionStore::open_or_create(&db_path).unwrap();
1414 let cm = CacheManager::with_ref_age_duration(
1415 store,
1416 u64::MAX,
1417 Duration::from_secs(3600),
1418 );
1419 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1420 assert!(
1421 matches!(second, CacheResult::Dedup { .. }),
1422 "second-process read must dedup — this was broken before the April 18 fix"
1423 );
1424 }
1425 }
1426
1427 #[test]
1428 fn compaction_from_one_process_invalidates_refs_in_another() {
1429 let dir = tempfile::tempdir().unwrap();
1441 let db_path = dir.path().join("cache.db");
1442 let pipeline = make_pipeline();
1443 let content = b"content that needs stale-marking after compaction";
1444 let path = Path::new("file.txt");
1445 let ttl = Duration::from_secs(3600);
1446
1447 {
1449 let store = SessionStore::open_or_create(&db_path).unwrap();
1450 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1451 cm.get_or_compress(path, content, &pipeline).unwrap();
1452 }
1453 std::thread::sleep(Duration::from_millis(10));
1455
1456 {
1459 let store = SessionStore::open_or_create(&db_path).unwrap();
1460 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1461 cm.notify_compaction();
1462 }
1463
1464 {
1468 let store = SessionStore::open_or_create(&db_path).unwrap();
1469 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1470 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1471 assert!(
1472 matches!(result, CacheResult::Fresh { .. }),
1473 "post-compaction read from a fresh process must re-send Fresh; \
1474 returning Dedup would be a dangling-ref bug"
1475 );
1476 }
1477 }
1478
1479 use proptest::prelude::*;
1480
1481 proptest! {
1489 #[test]
1494 fn prop_cache_deduplication(
1495 content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1496 ) {
1497 let (store, _dir) = in_memory_store();
1498 let cm = CacheManager::new(store, u64::MAX);
1499 let pipeline = make_pipeline();
1500 let path = Path::new("file.txt");
1501
1502 let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1504 prop_assert!(
1505 matches!(first, CacheResult::Fresh { .. }),
1506 "first read should be a cache miss"
1507 );
1508
1509 let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1510 match second {
1511 CacheResult::Dedup { inline_ref, token_cost } => {
1512 prop_assert_eq!(
1513 token_cost, 13,
1514 "cache hit should report ~13 reference tokens"
1515 );
1516 prop_assert!(
1517 inline_ref.starts_with("§ref:"),
1518 "reference token should start with §ref:"
1519 );
1520 prop_assert!(
1521 inline_ref.ends_with('§'),
1522 "reference token should end with §"
1523 );
1524 }
1525 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1526 prop_assert!(false, "second read should be a cache hit, not a miss");
1527 }
1528 }
1529 }
1530 }
1531
1532 proptest! {
1540 #[test]
1545 fn prop_cache_invalidation_on_content_change(
1546 content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1547 content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1548 ) {
1549 prop_assume!(content_a != content_b);
1551
1552 let (store, _dir) = in_memory_store();
1553 let cm = CacheManager::new(store, u64::MAX);
1554 let pipeline = make_pipeline();
1555 let path = Path::new("file.txt");
1556
1557 let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1559 prop_assert!(
1560 matches!(r1, CacheResult::Fresh { .. }),
1561 "first read of content_a should be a miss"
1562 );
1563
1564 let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1565 prop_assert!(
1566 matches!(r2, CacheResult::Dedup { .. }),
1567 "second read of content_a should be a hit"
1568 );
1569
1570 let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1571 prop_assert!(
1572 matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
1573 "read with changed content should be a cache miss or delta"
1574 );
1575 }
1576 }
1577
1578 proptest! {
1586 #[test]
1591 fn prop_cache_lru_eviction(
1592 entries in proptest::collection::vec(
1594 proptest::collection::vec(any::<u8>(), 10..=200usize),
1595 2..=8usize,
1596 ),
1597 ) {
1598 let mut unique_entries: Vec<Vec<u8>> = Vec::new();
1600 for e in &entries {
1601 if !unique_entries.contains(e) {
1602 unique_entries.push(e.clone());
1603 }
1604 }
1605 prop_assume!(unique_entries.len() >= 2);
1606
1607 let (store, _dir) = in_memory_store();
1608 let cm = CacheManager::new(store, 1);
1610 let pipeline = make_pipeline();
1611 let path = Path::new("f.txt");
1612
1613 for entry in &unique_entries {
1615 cm.get_or_compress(path, entry, &pipeline).unwrap();
1616 }
1617
1618 let freed = cm.evict_lru().unwrap();
1620
1621 prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
1623
1624 let freed_again = cm.evict_lru().unwrap();
1627 prop_assert_eq!(
1628 freed_again, 0,
1629 "second evict_lru call should free 0 bytes (already at or below limit)"
1630 );
1631 }
1632 }
1633
1634 proptest! {
1643 #[test]
1654 fn prop_cache_persistence_across_sessions(
1655 content in proptest::collection::vec(any::<u8>(), 1..=500usize),
1656 ) {
1657 use crate::session_store::SessionStore;
1658
1659 let dir = tempfile::tempdir().unwrap();
1660 let db_path = dir.path().join("cache.db");
1661 let path = Path::new("file.txt");
1662
1663 {
1665 let store = SessionStore::open_or_create(&db_path).unwrap();
1666 let cm = CacheManager::with_ref_age_duration(
1668 store,
1669 u64::MAX,
1670 Duration::from_secs(3600),
1671 );
1672 let pipeline = make_pipeline();
1673
1674 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1675 prop_assert!(
1676 matches!(r, CacheResult::Fresh { .. }),
1677 "first-ever read should be a miss"
1678 );
1679 }
1680
1681 {
1683 let store = SessionStore::open_or_create(&db_path).unwrap();
1684 let cm = CacheManager::with_ref_age_duration(
1685 store,
1686 u64::MAX,
1687 Duration::from_secs(3600),
1688 );
1689 let pipeline = make_pipeline();
1690
1691 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1696 match r {
1697 CacheResult::Dedup { token_cost, .. } => {
1698 prop_assert_eq!(
1699 token_cost, 13,
1700 "first read after restart must be a 13-token dedup ref"
1701 );
1702 }
1703 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1704 prop_assert!(
1705 false,
1706 "first read after restart must dedup — this was the \
1707 April 18 bug and its fix is the whole reason this \
1708 test exists"
1709 );
1710 }
1711 }
1712 }
1713 }
1714 }
1715}