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)]
91#[allow(dead_code)]
92struct RefEntry {
93 last_sent_turn: u64,
95}
96
97pub struct CacheManager {
115 store: SessionStore,
116 max_size_bytes: u64,
117 delta_encoder: DeltaEncoder,
118 #[allow(dead_code)]
121 turn_counter: std::cell::Cell<u64>,
122 #[allow(dead_code)]
124 ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
125 max_ref_age: Duration,
130 compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
134 lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
138 lsh_hashes: std::cell::RefCell<Vec<String>>,
140}
141
142impl CacheManager {
143 pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
149 Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
150 }
151
152 #[doc(hidden)]
158 pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
159 Self::with_ref_age_duration(
160 store,
161 max_size_bytes,
162 Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
163 )
164 }
165
166 pub fn with_ref_age_duration(
168 store: SessionStore,
169 max_size_bytes: u64,
170 max_ref_age: Duration,
171 ) -> Self {
172 Self {
173 store,
174 max_size_bytes,
175 delta_encoder: DeltaEncoder::new(),
176 turn_counter: std::cell::Cell::new(0),
177 ref_tracker: std::cell::RefCell::new(HashMap::new()),
178 max_ref_age,
179 compaction_marker: std::cell::Cell::new(None),
180 lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
181 lsh_hashes: std::cell::RefCell::new(Vec::new()),
182 }
183 }
184
185 fn index_for_similarity(&self, hash: &str, data: &str) {
189 const MAX_INDEXED: usize = 512;
190 let mut hashes = self.lsh_hashes.borrow_mut();
191 if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
192 return;
193 }
194 let doc_id = hashes.len() as u64;
195 self.lsh.borrow_mut().insert(doc_id, data);
196 hashes.push(hash.to_string());
197 }
198
199 pub fn sha256_hex(bytes: &[u8]) -> String {
201 let mut hasher = Sha256::new();
202 hasher.update(bytes);
203 format!("{:x}", hasher.finalize())
204 }
205
206 pub fn advance_turn(&self) {
210 self.turn_counter.set(self.turn_counter.get() + 1);
211 }
212
213 pub fn current_turn(&self) -> u64 {
215 self.turn_counter.get()
216 }
217
218 pub fn notify_compaction(&self) {
232 let now = chrono::Utc::now();
233 self.compaction_marker.set(Some(now));
234 self.ref_tracker.borrow_mut().clear();
235 let _ = self
241 .store
242 .set_metadata("last_compaction_at", &now.to_rfc3339());
243 }
244
245 fn is_ref_fresh(&self, hash: &str) -> bool {
260 if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
264 return true;
265 }
266 let accessed = match self.store.get_cache_entry_accessed_at(hash) {
267 Ok(Some(ts)) => ts,
268 _ => return false,
269 };
270 if let Some(marker) = self.compaction_marker.get() {
272 if accessed < marker {
273 return false;
274 }
275 }
276 if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
280 if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
281 if accessed < marker {
282 return false;
283 }
284 }
285 }
286 let age = (chrono::Utc::now() - accessed)
287 .to_std()
288 .unwrap_or(Duration::from_secs(0));
289 age < self.max_ref_age
290 }
291
292 fn record_ref_sent(&self, hash: &str) {
298 let _ = self.store.touch_cache_entry(hash);
299 }
300
301 pub fn get_or_compress(
309 &self,
310 path: &Path,
311 content: &[u8],
312 pipeline: &CompressionPipeline,
313 ) -> Result<CacheResult> {
314 self.get_or_compress_inner(path, content, pipeline, false)
315 }
316
317 pub fn get_or_compress_lossless(
323 &self,
324 path: &Path,
325 content: &[u8],
326 pipeline: &CompressionPipeline,
327 ) -> Result<CacheResult> {
328 self.get_or_compress_inner(path, content, pipeline, true)
329 }
330
331 fn get_or_compress_inner(
332 &self,
333 _path: &Path,
334 content: &[u8],
335 pipeline: &CompressionPipeline,
336 lossless: bool,
337 ) -> Result<CacheResult> {
338 let hash = Self::sha256_hex(content);
339
340 let exists = self.store.cache_entry_exists(&hash)?;
345 if exists {
346 if self.is_ref_fresh(&hash) {
347 let hash_prefix = &hash[..16];
349 let inline_ref = format!("§ref:{hash_prefix}§");
350 self.record_ref_sent(&hash);
352 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
353 return Ok(CacheResult::Dedup {
354 inline_ref,
355 token_cost: 13,
356 });
357 } else {
358 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
361 let text = String::from_utf8_lossy(content).into_owned();
362 let ctx = SessionContext {
363 session_id: "cache".to_string(),
364 };
365 let preset = Preset::default();
366 let compressed = if lossless {
367 pipeline.compress_lossless(&text, &ctx, &preset)?
368 } else {
369 pipeline.compress(&text, &ctx, &preset)?
370 };
371 self.record_ref_sent(&hash);
373 return Ok(CacheResult::Fresh { output: compressed });
374 }
375 }
376
377 let text = String::from_utf8_lossy(content).into_owned();
379 let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
380 if let Some(delta_result) = self.try_delta_encode(&text)? {
381 let ctx = SessionContext {
383 session_id: "cache".to_string(),
384 };
385 let preset = Preset::default();
386 let compressed = if lossless {
387 pipeline.compress_lossless(&text, &ctx, &preset)?
388 } else {
389 pipeline.compress(&text, &ctx, &preset)?
390 };
391 self.store
393 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
394 self.index_for_similarity(&hash, &compressed.data);
395 self.record_ref_sent(&hash);
396
397 let token_cost = (delta_result.delta_text.len() / 4) as u32;
398 return Ok(CacheResult::Delta {
399 delta_text: delta_result.delta_text,
400 token_cost: token_cost.max(5),
401 similarity: delta_result.similarity,
402 });
403 }
404
405 let ctx = SessionContext {
406 session_id: "cache".to_string(),
407 };
408 let preset = Preset::default();
409 let compressed = if lossless {
410 pipeline.compress_lossless(&text, &ctx, &preset)?
411 } else {
412 pipeline.compress(&text, &ctx, &preset)?
413 };
414 self.store
415 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
416 self.index_for_similarity(&hash, &compressed.data);
417 self.record_ref_sent(&hash);
419
420 Ok(CacheResult::Fresh { output: compressed })
421 }
422
423 fn try_delta_encode(
433 &self,
434 new_content: &str,
435 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
436 let candidates: Vec<String> = {
438 let lsh = self.lsh.borrow();
439 let hashes = self.lsh_hashes.borrow();
440 let query_sig = crate::minhash_lsh::MinHashLsh::compute_signature(new_content);
441 let mut scored: Vec<(f64, &String)> = lsh
442 .query(new_content)
443 .into_iter()
444 .filter_map(|doc_id| {
445 let hash = hashes.get(doc_id as usize)?;
446 let sim = lsh.get_signature(doc_id)?.jaccard_similarity(&query_sig);
447 (sim >= 0.5).then_some((sim, hash))
448 })
449 .collect();
450 scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
451 scored.into_iter().take(5).map(|(_, h)| h.clone()).collect()
452 };
453 for hash in &candidates {
454 if let Some(delta) = self.try_delta_against(hash, new_content)? {
455 return Ok(Some(delta));
456 }
457 }
458
459 let entries = self.store.list_cache_entries_lru()?;
461 let check_count = entries.len().min(10);
462 for (hash, _) in entries.iter().rev().take(check_count) {
463 if candidates.iter().any(|c| c == hash) {
464 continue;
465 }
466 if let Some(delta) = self.try_delta_against(hash, new_content)? {
467 return Ok(Some(delta));
468 }
469 }
470
471 Ok(None)
472 }
473
474 fn try_delta_against(
475 &self,
476 hash: &str,
477 new_content: &str,
478 ) -> Result<Option<crate::delta_encoder::DeltaResult>> {
479 if let Some(cached) = self.store.get_cache_entry(hash)? {
480 let hash_prefix = &hash[..hash.len().min(16)];
481 if let Ok(Some(delta)) = self.delta_encoder.encode(&cached.data, new_content, hash_prefix) {
482 if delta.delta_text.len() < new_content.len() {
483 return Ok(Some(delta));
484 }
485 }
486 }
487 Ok(None)
488 }
489
490 pub fn check_dedup(&self, content: &[u8]) -> Result<Option<String>> {
499 Ok(self.check_dedup_with_meta(content)?.map(|hit| hit.inline_ref))
500 }
501
502 pub fn check_dedup_with_meta(&self, content: &[u8]) -> Result<Option<DedupHit>> {
507 let hash = Self::sha256_hex(content);
508 let prev_accessed_at = self.store.get_cache_entry_accessed_at(&hash).unwrap_or(None);
511 let fresh = self.is_ref_fresh(&hash);
512 if fresh {
513 let hash_prefix = &hash[..16];
514 self.record_ref_sent(&hash);
515 Ok(Some(DedupHit {
516 inline_ref: format!("§ref:{hash_prefix}§"),
517 hash,
518 prev_accessed_at,
519 }))
520 } else {
521 Ok(None)
524 }
525 }
526
527 pub fn store_compressed(
535 &self,
536 original_content: &[u8],
537 compressed: &CompressedContent,
538 ) -> Result<()> {
539 let hash = Self::sha256_hex(original_content);
540 self.store
541 .save_cache_entry_with_original(&hash, compressed, Some(original_content))?;
542 self.index_for_similarity(&hash, &compressed.data);
543 self.record_ref_sent(&hash);
544 Ok(())
545 }
546
547 pub fn expand_prefix(&self, prefix: &str) -> Result<Option<ExpandResult>> {
573 let Some((hash, compressed_entry)) = self.store.get_cache_entry_by_prefix(prefix)? else {
574 return Ok(None);
575 };
576
577 let _ = self.store.log_regret("expand", "", &hash, 0.0);
582
583 if let Some(bytes) = self.store.get_cache_entry_original(&hash)? {
586 return Ok(Some(ExpandResult::Original { hash, bytes }));
587 }
588 Ok(Some(ExpandResult::CompressedOnly {
589 hash,
590 compressed: compressed_entry.data,
591 }))
592 }
593
594 pub fn invalidate(&self, path: &Path) -> Result<()> {
599 if !path.exists() {
600 return Ok(());
601 }
602 let bytes = std::fs::read(path)?;
603 let hash = Self::sha256_hex(&bytes);
604 self.store.delete_cache_entry(&hash)?;
605 Ok(())
606 }
607
608 pub fn evict_lru(&self) -> Result<u64> {
613 let entries = self.store.list_cache_entries_lru()?;
614
615 let total: u64 = entries.iter().map(|(_, sz)| sz).sum();
617 if total <= self.max_size_bytes {
618 return Ok(0);
619 }
620
621 let mut freed: u64 = 0;
622 let mut remaining = total;
623
624 for (hash, size) in &entries {
625 if remaining <= self.max_size_bytes {
626 break;
627 }
628 self.store.delete_cache_entry(hash)?;
629 freed += size;
630 remaining -= size;
631 }
632
633 Ok(freed)
634 }
635}
636
637#[cfg(test)]
640mod tests {
641 use super::*;
642 use crate::preset::{
643 BudgetConfig, CollapseArraysConfig, CompressionConfig, CondenseConfig,
644 CustomTransformsConfig, ModelConfig, PresetMeta, StripNullsConfig, TerseModeConfig,
645 ToolSelectionConfig, TruncateStringsConfig,
646 };
647 use crate::session_store::SessionStore;
648
649 fn in_memory_store() -> (SessionStore, tempfile::TempDir) {
650 let dir = tempfile::tempdir().unwrap();
651 let path = dir.path().join("test.db");
652 let store = SessionStore::open_or_create(&path).unwrap();
653 (store, dir)
654 }
655
656 #[test]
657 fn check_dedup_with_meta_reports_previous_access() {
658 let (store, _dir) = in_memory_store();
659 let cm = CacheManager::new(store, u64::MAX);
660 let pipeline = make_pipeline();
661 let content = b"meta test content that is unique to this test";
662
663 assert!(cm.check_dedup_with_meta(content).unwrap().is_none());
665
666 cm.get_or_compress(Path::new("meta.txt"), content, &pipeline)
667 .unwrap();
668 let before = chrono::Utc::now();
669 let hit = cm
670 .check_dedup_with_meta(content)
671 .unwrap()
672 .expect("expected dedup hit");
673 assert!(hit.inline_ref.starts_with("§ref:"));
674 assert_eq!(hit.hash, CacheManager::sha256_hex(content));
675 let prev = hit.prev_accessed_at.expect("expected prior access time");
678 assert!(prev <= before + chrono::Duration::seconds(1));
679 }
680
681 #[test]
682 fn expand_prefix_logs_an_expand_regret() {
683 let (store, _dir) = in_memory_store();
684 let cm = CacheManager::new(store, u64::MAX);
685 let pipeline = make_pipeline();
686 let content = b"expand regret test content";
687 cm.get_or_compress(Path::new("regret.txt"), content, &pipeline)
688 .unwrap();
689
690 let hash = CacheManager::sha256_hex(content);
691 let result = cm.expand_prefix(&hash[..16]).unwrap();
692 assert!(result.is_some());
693
694 let regret = cm.store.regret_stats().unwrap();
695 assert_eq!(regret.expands, 1);
696 assert_eq!(regret.reruns, 0);
697 }
698
699 fn test_preset() -> Preset {
700 Preset {
701 preset: PresetMeta {
702 name: "test".into(),
703 version: "1.0".into(),
704 description: String::new(),
705 },
706 compression: CompressionConfig {
707 stages: vec![],
708 keep_fields: None,
709 strip_fields: None,
710 condense: Some(CondenseConfig {
711 enabled: true,
712 max_repeated_lines: 3,
713 }),
714 git_diff_fold: None,
715 strip_nulls: Some(StripNullsConfig { enabled: true }),
716 flatten: None,
717 truncate_strings: Some(TruncateStringsConfig {
718 enabled: true,
719 max_length: 500,
720 }),
721 collapse_arrays: Some(CollapseArraysConfig {
722 enabled: true,
723 max_items: 5,
724 summary_template: "... and {remaining} more items".into(),
725 }),
726 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
727 },
728 tool_selection: ToolSelectionConfig {
729 max_tools: 5,
730 similarity_threshold: 0.7,
731 default_tools: vec![],
732 },
733 budget: BudgetConfig {
734 warning_threshold: 0.70,
735 ceiling_threshold: 0.85,
736 default_window_size: 200_000,
737 agents: Default::default(),
738 },
739 terse_mode: TerseModeConfig {
740 enabled: false,
741 level: crate::preset::TerseLevel::Moderate,
742 },
743 model: ModelConfig {
744 family: "anthropic".into(),
745 primary: "claude-sonnet-4-20250514".into(),
746 local: String::new(),
747 complexity_threshold: 0.4,
748 pricing: None,
749 },
750 }
751 }
752
753 fn make_pipeline() -> CompressionPipeline {
754 CompressionPipeline::new(&test_preset())
755 }
756
757 #[test]
758 fn first_read_is_miss() {
759 let (store, _dir) = in_memory_store();
760 let cm = CacheManager::new(store, u64::MAX);
761 let pipeline = make_pipeline();
762 let content = b"hello world";
763 let result = cm
764 .get_or_compress(Path::new("file.txt"), content, &pipeline)
765 .unwrap();
766 assert!(matches!(result, CacheResult::Fresh { .. }));
767 }
768
769 #[test]
774 fn get_or_compress_lossless_does_not_truncate_source() {
775 let pipeline = make_pipeline();
776
777 let mut segs = Vec::new();
780 for i in 0..24 {
782 if i % 2 == 0 {
783 segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
784 } else {
785 segs.push(format!(
786 "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
787 ));
788 }
789 }
790 let content = segs.join("\n\n");
791 assert!(content.len() > 1000);
792
793 let (s1, _d1) = in_memory_store();
795 let lossy = CacheManager::new(s1, u64::MAX);
796 match lossy
797 .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
798 .unwrap()
799 {
800 CacheResult::Fresh { output } => {
801 assert!(output.data.contains("omitted"), "lossy path should truncate");
802 }
803 _ => panic!("expected Fresh on a cold read"),
804 }
805
806 let (s2, _d2) = in_memory_store();
808 let cm = CacheManager::new(s2, u64::MAX);
809 match cm
810 .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
811 .unwrap()
812 {
813 CacheResult::Fresh { output } => {
814 assert!(
815 !output.data.contains("omitted"),
816 "file-read path truncated content: {}",
817 output.data
818 );
819 for i in 0..10 {
820 assert!(
821 output.data.contains(&format!("SEG{i}")),
822 "file-read path dropped SEG{i}"
823 );
824 }
825 }
826 _ => panic!("expected CacheResult::Fresh on a cold read"),
827 }
828 }
829
830 #[test]
833 fn lsh_finds_near_duplicate_beyond_lru_window() {
834 let (store, _dir) = in_memory_store();
835 let cm = CacheManager::new(store, u64::MAX);
836 let pipeline = make_pipeline();
837
838 let base: String = (0..40)
839 .map(|i| format!("stable log line number {i} with steady content here\n"))
840 .collect();
841 cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
842 .unwrap();
843
844 for n in 0..12 {
846 let filler: String = (0..30)
847 .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
848 .collect();
849 cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
850 .unwrap();
851 }
852
853 let variant = base.replace(
855 "stable log line number 7 with steady content here",
856 "stable log line number 7 with slightly altered content",
857 );
858 let result = cm
859 .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
860 .unwrap();
861 let kind = match &result {
862 CacheResult::Dedup { .. } => "Dedup",
863 CacheResult::Delta { .. } => "Delta",
864 CacheResult::Fresh { .. } => "Fresh",
865 };
866 assert!(
867 matches!(result, CacheResult::Delta { .. }),
868 "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
869 );
870 }
871
872 #[test]
873 fn second_read_is_hit() {
874 let (store, _dir) = in_memory_store();
875 let cm = CacheManager::new(store, u64::MAX);
876 let pipeline = make_pipeline();
877 let content = b"hello world";
878 let path = Path::new("file.txt");
879
880 cm.get_or_compress(path, content, &pipeline).unwrap();
882
883 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
885 match result {
886 CacheResult::Dedup {
887 inline_ref,
888 token_cost,
889 } => {
890 assert!(inline_ref.starts_with("§ref:"));
891 assert!(inline_ref.ends_with('§'));
892 assert_eq!(token_cost, 13);
893 }
894 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
895 }
896 }
897
898 #[test]
899 fn different_content_is_miss() {
900 let (store, _dir) = in_memory_store();
901 let cm = CacheManager::new(store, u64::MAX);
902 let pipeline = make_pipeline();
903 let path = Path::new("file.txt");
904
905 cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
906 let result = cm
907 .get_or_compress(path, b"content v2", &pipeline)
908 .unwrap();
909 assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
910 }
911
912 #[test]
913 fn evict_lru_frees_bytes_when_over_limit() {
914 let (store, _dir) = in_memory_store();
915 let cm = CacheManager::new(store, 1);
917 let pipeline = make_pipeline();
918 let path = Path::new("f.txt");
919
920 cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
922 cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
923 cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
924
925 let freed = cm.evict_lru().unwrap();
926 assert!(freed > 0, "expected bytes to be freed");
927 }
928
929 #[test]
930 fn evict_lru_no_op_when_under_limit() {
931 let (store, _dir) = in_memory_store();
932 let cm = CacheManager::new(store, u64::MAX);
933 let pipeline = make_pipeline();
934
935 cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
936 .unwrap();
937
938 let freed = cm.evict_lru().unwrap();
939 assert_eq!(freed, 0);
940 }
941
942 #[test]
943 fn invalidate_removes_entry() {
944 let dir = tempfile::tempdir().unwrap();
945 let file_path = dir.path().join("test.txt");
946 std::fs::write(&file_path, b"some content").unwrap();
947
948 let store_path = dir.path().join("store.db");
949 let store = SessionStore::open_or_create(&store_path).unwrap();
950 let cm = CacheManager::new(store, u64::MAX);
951 let pipeline = make_pipeline();
952
953 let content = std::fs::read(&file_path).unwrap();
955 cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
956
957 let hit = cm
959 .get_or_compress(&file_path, &content, &pipeline)
960 .unwrap();
961 assert!(matches!(hit, CacheResult::Dedup { .. }));
962
963 cm.invalidate(&file_path).unwrap();
964
965 let miss = cm
966 .get_or_compress(&file_path, &content, &pipeline)
967 .unwrap();
968 assert!(matches!(miss, CacheResult::Fresh { .. }));
969 }
970
971 #[test]
972 fn invalidate_nonexistent_path_is_noop() {
973 let (store, _dir) = in_memory_store();
974 let cm = CacheManager::new(store, u64::MAX);
975 cm.invalidate(Path::new("/nonexistent/path/file.txt"))
977 .unwrap();
978 }
979
980 #[test]
988 fn stale_ref_returns_fresh_instead_of_dedup() {
989 let (store, _dir) = in_memory_store();
990 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
992 let pipeline = make_pipeline();
993 let content = b"hello world";
994 let path = Path::new("file.txt");
995
996 cm.get_or_compress(path, content, &pipeline).unwrap();
998
999 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1001 assert!(
1002 matches!(result, CacheResult::Fresh { .. }),
1003 "stale ref (TTL=0) should return Fresh, not Dedup"
1004 );
1005 }
1006
1007 #[test]
1008 fn fresh_ref_returns_dedup() {
1009 let (store, _dir) = in_memory_store();
1010 let cm = CacheManager::with_ref_age_duration(
1012 store,
1013 u64::MAX,
1014 Duration::from_secs(86_400),
1015 );
1016 let pipeline = make_pipeline();
1017 let content = b"hello world";
1018 let path = Path::new("file.txt");
1019
1020 cm.get_or_compress(path, content, &pipeline).unwrap();
1021 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1022 assert!(
1023 matches!(result, CacheResult::Dedup { .. }),
1024 "fresh ref should dedup"
1025 );
1026 }
1027
1028 #[test]
1029 fn notify_compaction_invalidates_all_refs() {
1030 let (store, _dir) = in_memory_store();
1031 let cm = CacheManager::with_ref_age_duration(
1032 store,
1033 u64::MAX,
1034 Duration::from_secs(86_400),
1035 );
1036 let pipeline = make_pipeline();
1037 let path = Path::new("file.txt");
1038
1039 cm.get_or_compress(path, b"content A", &pipeline).unwrap();
1041 cm.get_or_compress(path, b"content B", &pipeline).unwrap();
1042 assert!(matches!(
1043 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1044 CacheResult::Dedup { .. }
1045 ));
1046 assert!(matches!(
1047 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1048 CacheResult::Dedup { .. }
1049 ));
1050
1051 std::thread::sleep(std::time::Duration::from_millis(10));
1056 cm.notify_compaction();
1057
1058 assert!(matches!(
1060 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1061 CacheResult::Fresh { .. }
1062 ));
1063 assert!(matches!(
1064 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1065 CacheResult::Fresh { .. }
1066 ));
1067 }
1068
1069 #[test]
1070 fn ref_refreshed_after_resend() {
1071 let (store, _dir) = in_memory_store();
1072 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1075 let pipeline = make_pipeline();
1076 let content = b"hello world";
1077 let path = Path::new("file.txt");
1078
1079 cm.get_or_compress(path, content, &pipeline).unwrap();
1080 std::thread::sleep(std::time::Duration::from_millis(10));
1083 cm.notify_compaction();
1084
1085 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1088 assert!(matches!(result, CacheResult::Fresh { .. }));
1089
1090 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1092 assert!(
1093 matches!(result, CacheResult::Dedup { .. }),
1094 "ref should be fresh after re-send"
1095 );
1096 }
1097
1098 #[test]
1099 fn check_dedup_returns_none_for_stale_ref() {
1100 let pipeline = make_pipeline();
1101 let content = b"test content";
1102 let path = Path::new("file.txt");
1103
1104 let (store, _dir) = in_memory_store();
1106 let fresh =
1107 CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1108 fresh.get_or_compress(path, content, &pipeline).unwrap();
1109 assert!(fresh.check_dedup(content).unwrap().is_some());
1110
1111 let (store2, _dir2) = in_memory_store();
1113 let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1114 stale.get_or_compress(path, content, &pipeline).unwrap();
1115 assert!(
1116 stale.check_dedup(content).unwrap().is_none(),
1117 "stale ref should not be returned by check_dedup"
1118 );
1119 }
1120
1121 #[test]
1122 fn pinned_ref_stays_fresh_past_ttl() {
1123 let (store, _dir) = in_memory_store();
1124 let cm = CacheManager::with_ref_age_duration(
1125 store,
1126 u64::MAX,
1127 Duration::from_millis(10),
1128 );
1129 let pipeline = make_pipeline();
1130 let content = b"wiki: stable knowledge base content";
1131 let path = Path::new("wiki.md");
1132
1133 cm.get_or_compress(path, content, &pipeline).unwrap();
1135 let hash = CacheManager::sha256_hex(content);
1136 assert!(cm.store.pin_cache_entry(&hash).unwrap());
1137
1138 std::thread::sleep(std::time::Duration::from_millis(40));
1140
1141 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1143 assert!(
1144 matches!(result, CacheResult::Dedup { .. }),
1145 "pinned entry should stay fresh past TTL"
1146 );
1147 assert!(cm.check_dedup(content).unwrap().is_some());
1148 }
1149
1150 #[test]
1151 fn unpin_returns_to_ttl_behaviour() {
1152 let (store, _dir) = in_memory_store();
1153 let cm = CacheManager::with_ref_age_duration(
1154 store,
1155 u64::MAX,
1156 Duration::from_millis(10),
1157 );
1158 let pipeline = make_pipeline();
1159 let content = b"transient session content";
1160 let path = Path::new("file.txt");
1161
1162 cm.get_or_compress(path, content, &pipeline).unwrap();
1163 let hash = CacheManager::sha256_hex(content);
1164 cm.store.pin_cache_entry(&hash).unwrap();
1165
1166 std::thread::sleep(std::time::Duration::from_millis(25));
1168 assert!(matches!(
1169 cm.get_or_compress(path, content, &pipeline).unwrap(),
1170 CacheResult::Dedup { .. }
1171 ));
1172
1173 cm.store.unpin_cache_entry(&hash).unwrap();
1175 std::thread::sleep(std::time::Duration::from_millis(25));
1176 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1177 assert!(
1178 matches!(result, CacheResult::Fresh { .. }),
1179 "unpinned entry should obey TTL"
1180 );
1181 }
1182
1183 #[test]
1184 fn pinned_ref_survives_compaction_marker() {
1185 let (store, _dir) = in_memory_store();
1186 let cm = CacheManager::with_ref_age_duration(
1187 store,
1188 u64::MAX,
1189 Duration::from_secs(3600),
1190 );
1191 let pipeline = make_pipeline();
1192 let content = b"system prompt: shared across agents";
1193 let path = Path::new("system.md");
1194
1195 cm.get_or_compress(path, content, &pipeline).unwrap();
1196 let hash = CacheManager::sha256_hex(content);
1197 cm.store.pin_cache_entry(&hash).unwrap();
1198
1199 std::thread::sleep(std::time::Duration::from_millis(10));
1201 cm.notify_compaction();
1202
1203 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1206 assert!(
1207 matches!(result, CacheResult::Dedup { .. }),
1208 "pinned entry should survive compaction"
1209 );
1210 }
1211
1212 #[test]
1213 fn pin_visible_across_cachemanager_instances() {
1214 let dir = tempfile::tempdir().unwrap();
1219 let path = dir.path().join("shared.db");
1220
1221 let store_a = SessionStore::open_or_create(&path).unwrap();
1222 let cm_a = CacheManager::with_ref_age_duration(
1223 store_a,
1224 u64::MAX,
1225 Duration::from_millis(5),
1226 );
1227 let pipeline = make_pipeline();
1228 let content = b"shared wiki content for multi-agent dedup";
1229
1230 cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1231 .unwrap();
1232 let hash = CacheManager::sha256_hex(content);
1233 cm_a.store.pin_cache_entry(&hash).unwrap();
1234
1235 drop(cm_a);
1237 std::thread::sleep(std::time::Duration::from_millis(20));
1238
1239 let store_b = SessionStore::open_or_create(&path).unwrap();
1240 let cm_b = CacheManager::with_ref_age_duration(
1241 store_b,
1242 u64::MAX,
1243 Duration::from_millis(5),
1244 );
1245 let result = cm_b
1246 .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1247 .unwrap();
1248 assert!(
1249 matches!(result, CacheResult::Dedup { .. }),
1250 "second agent should see the pin from the shared store"
1251 );
1252 }
1253
1254 #[test]
1255 fn advance_turn_increments_counter() {
1256 let (store, _dir) = in_memory_store();
1258 let cm = CacheManager::new(store, u64::MAX);
1259 assert_eq!(cm.current_turn(), 0);
1260 cm.advance_turn();
1261 assert_eq!(cm.current_turn(), 1);
1262 cm.advance_turn();
1263 assert_eq!(cm.current_turn(), 2);
1264 }
1265
1266 #[test]
1269 fn expand_returns_original_bytes_for_new_entry() {
1270 let (store, _dir) = in_memory_store();
1274 let cm = CacheManager::new(store, u64::MAX);
1275 let pipeline = make_pipeline();
1276 let path = Path::new("x.txt");
1277 let content = b"hello\nworld\nthis is the original\n";
1278
1279 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1281 let CacheResult::Fresh { .. } = first else {
1282 panic!("first read should be a Fresh miss, got something else");
1283 };
1284
1285 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1287 let inline_ref = match second {
1288 CacheResult::Dedup { inline_ref, .. } => inline_ref,
1289 _ => panic!("second read should dedup"),
1290 };
1291
1292 let prefix = inline_ref
1294 .strip_prefix("§ref:")
1295 .and_then(|s| s.strip_suffix('§'))
1296 .expect("unexpected ref format");
1297
1298 let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1299 match result {
1300 ExpandResult::Original { bytes, hash } => {
1301 assert_eq!(bytes, content, "expand must return exact original bytes");
1302 assert!(
1303 hash.starts_with(prefix),
1304 "returned full hash {hash} must start with prefix {prefix}"
1305 );
1306 assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1307 }
1308 ExpandResult::CompressedOnly { .. } => {
1309 panic!("new cache entries must carry the original bytes");
1310 }
1311 }
1312 }
1313
1314 #[test]
1315 fn expand_prefix_rejects_nonhex_input() {
1316 let (store, _dir) = in_memory_store();
1321 let cm = CacheManager::new(store, u64::MAX);
1322 assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1323 assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1324 assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1325 assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1326 }
1327
1328 #[test]
1329 fn expand_prefix_returns_none_for_unknown_prefix() {
1330 let (store, _dir) = in_memory_store();
1333 let cm = CacheManager::new(store, u64::MAX);
1334 let pipeline = make_pipeline();
1335 let _ = cm
1337 .get_or_compress(
1338 Path::new("y.txt"),
1339 b"some cached content that won't match the prefix below",
1340 &pipeline,
1341 )
1342 .unwrap();
1343 assert!(
1344 cm.expand_prefix("0000000000000000").unwrap().is_none(),
1345 "prefix that matches nothing must return None, not error"
1346 );
1347 }
1348
1349 #[test]
1350 fn expand_prefix_errors_on_ambiguous_match() {
1351 let (store, _dir) = in_memory_store();
1356 let compressed = crate::types::CompressedContent {
1358 data: "compressed-a".to_string(),
1359 tokens_compressed: 1,
1360 tokens_original: 10,
1361 stages_applied: vec![],
1362 compression_ratio: 0.1,
1363 provenance: Default::default(),
1364 verify: None,
1365 };
1366 store
1367 .save_cache_entry_with_original(
1368 &format!("ab{}", "0".repeat(62)),
1369 &compressed,
1370 Some(b"content a"),
1371 )
1372 .unwrap();
1373 store
1374 .save_cache_entry_with_original(
1375 &format!("ab{}", "1".repeat(62)),
1376 &compressed,
1377 Some(b"content b"),
1378 )
1379 .unwrap();
1380
1381 let cm = CacheManager::new(store, u64::MAX);
1382 let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1384 assert!(matches!(ok_a, ExpandResult::Original { .. }));
1385 let err = cm.expand_prefix("ab").unwrap_err();
1387 let msg = err.to_string();
1388 assert!(
1389 msg.contains("multiple entries") || msg.contains("longer prefix"),
1390 "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1391 );
1392 }
1393
1394 #[test]
1395 fn expand_returns_compressed_only_for_pre_migration_entry() {
1396 let (store, _dir) = in_memory_store();
1401 let compressed = crate::types::CompressedContent {
1402 data: "the compressed version".to_string(),
1403 tokens_compressed: 4,
1404 tokens_original: 100,
1405 stages_applied: vec!["condense".to_string()],
1406 compression_ratio: 0.04,
1407 provenance: Default::default(),
1408 verify: None,
1409 };
1410 let hash = format!("deadbeef{}", "0".repeat(56));
1412 store.save_cache_entry(&hash, &compressed).unwrap();
1413
1414 let cm = CacheManager::new(store, u64::MAX);
1415 let result = cm
1416 .expand_prefix("deadbeef")
1417 .unwrap()
1418 .expect("deadbeef prefix should match");
1419 match result {
1420 ExpandResult::CompressedOnly { compressed, hash: h } => {
1421 assert_eq!(compressed, "the compressed version");
1422 assert_eq!(h, hash);
1423 }
1424 ExpandResult::Original { .. } => {
1425 panic!("legacy entry without original bytes should return CompressedOnly");
1426 }
1427 }
1428 }
1429
1430 #[test]
1431 fn expand_preserves_non_utf8_original_bytes() {
1432 let (store, _dir) = in_memory_store();
1438 let cm = CacheManager::new(store, u64::MAX);
1439 let pipeline = make_pipeline();
1440
1441 let content: Vec<u8> = vec![
1443 b'h', b'e', b'l', b'l', b'o', b'\n',
1444 0xFF, 0xFE, 0xFD,
1445 b'\n',
1446 ];
1447 let path = Path::new("bin.dat");
1448 let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1449 let hash = CacheManager::sha256_hex(&content);
1451 let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1452 match result {
1453 ExpandResult::Original { bytes, .. } => {
1454 assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1455 }
1456 ExpandResult::CompressedOnly { .. } => {
1457 panic!("fresh entry should have original bytes");
1458 }
1459 }
1460 }
1461
1462 #[test]
1463 fn dedup_survives_cache_manager_restart() {
1464 let dir = tempfile::tempdir().unwrap();
1470 let db_path = dir.path().join("cache.db");
1471 let pipeline = make_pipeline();
1472 let content = b"a substantial chunk of content to dedup";
1473 let path = Path::new("x.txt");
1474
1475 {
1477 let store = SessionStore::open_or_create(&db_path).unwrap();
1478 let cm = CacheManager::with_ref_age_duration(
1479 store,
1480 u64::MAX,
1481 Duration::from_secs(3600),
1482 );
1483 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1484 assert!(matches!(first, CacheResult::Fresh { .. }));
1485 }
1486
1487 {
1489 let store = SessionStore::open_or_create(&db_path).unwrap();
1490 let cm = CacheManager::with_ref_age_duration(
1491 store,
1492 u64::MAX,
1493 Duration::from_secs(3600),
1494 );
1495 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1496 assert!(
1497 matches!(second, CacheResult::Dedup { .. }),
1498 "second-process read must dedup — this was broken before the April 18 fix"
1499 );
1500 }
1501 }
1502
1503 #[test]
1504 fn compaction_from_one_process_invalidates_refs_in_another() {
1505 let dir = tempfile::tempdir().unwrap();
1517 let db_path = dir.path().join("cache.db");
1518 let pipeline = make_pipeline();
1519 let content = b"content that needs stale-marking after compaction";
1520 let path = Path::new("file.txt");
1521 let ttl = Duration::from_secs(3600);
1522
1523 {
1525 let store = SessionStore::open_or_create(&db_path).unwrap();
1526 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1527 cm.get_or_compress(path, content, &pipeline).unwrap();
1528 }
1529 std::thread::sleep(Duration::from_millis(10));
1531
1532 {
1535 let store = SessionStore::open_or_create(&db_path).unwrap();
1536 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1537 cm.notify_compaction();
1538 }
1539
1540 {
1544 let store = SessionStore::open_or_create(&db_path).unwrap();
1545 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1546 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1547 assert!(
1548 matches!(result, CacheResult::Fresh { .. }),
1549 "post-compaction read from a fresh process must re-send Fresh; \
1550 returning Dedup would be a dangling-ref bug"
1551 );
1552 }
1553 }
1554
1555 use proptest::prelude::*;
1556
1557 proptest! {
1565 #[test]
1570 fn prop_cache_deduplication(
1571 content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1572 ) {
1573 let (store, _dir) = in_memory_store();
1574 let cm = CacheManager::new(store, u64::MAX);
1575 let pipeline = make_pipeline();
1576 let path = Path::new("file.txt");
1577
1578 let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1580 prop_assert!(
1581 matches!(first, CacheResult::Fresh { .. }),
1582 "first read should be a cache miss"
1583 );
1584
1585 let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1586 match second {
1587 CacheResult::Dedup { inline_ref, token_cost } => {
1588 prop_assert_eq!(
1589 token_cost, 13,
1590 "cache hit should report ~13 reference tokens"
1591 );
1592 prop_assert!(
1593 inline_ref.starts_with("§ref:"),
1594 "reference token should start with §ref:"
1595 );
1596 prop_assert!(
1597 inline_ref.ends_with('§'),
1598 "reference token should end with §"
1599 );
1600 }
1601 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1602 prop_assert!(false, "second read should be a cache hit, not a miss");
1603 }
1604 }
1605 }
1606 }
1607
1608 proptest! {
1616 #[test]
1621 fn prop_cache_invalidation_on_content_change(
1622 content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1623 content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1624 ) {
1625 prop_assume!(content_a != content_b);
1627
1628 let (store, _dir) = in_memory_store();
1629 let cm = CacheManager::new(store, u64::MAX);
1630 let pipeline = make_pipeline();
1631 let path = Path::new("file.txt");
1632
1633 let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1635 prop_assert!(
1636 matches!(r1, CacheResult::Fresh { .. }),
1637 "first read of content_a should be a miss"
1638 );
1639
1640 let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1641 prop_assert!(
1642 matches!(r2, CacheResult::Dedup { .. }),
1643 "second read of content_a should be a hit"
1644 );
1645
1646 let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1647 prop_assert!(
1648 matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
1649 "read with changed content should be a cache miss or delta"
1650 );
1651 }
1652 }
1653
1654 proptest! {
1662 #[test]
1667 fn prop_cache_lru_eviction(
1668 entries in proptest::collection::vec(
1670 proptest::collection::vec(any::<u8>(), 10..=200usize),
1671 2..=8usize,
1672 ),
1673 ) {
1674 let mut unique_entries: Vec<Vec<u8>> = Vec::new();
1676 for e in &entries {
1677 if !unique_entries.contains(e) {
1678 unique_entries.push(e.clone());
1679 }
1680 }
1681 prop_assume!(unique_entries.len() >= 2);
1682
1683 let (store, _dir) = in_memory_store();
1684 let cm = CacheManager::new(store, 1);
1686 let pipeline = make_pipeline();
1687 let path = Path::new("f.txt");
1688
1689 for entry in &unique_entries {
1691 cm.get_or_compress(path, entry, &pipeline).unwrap();
1692 }
1693
1694 let freed = cm.evict_lru().unwrap();
1696
1697 prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
1699
1700 let freed_again = cm.evict_lru().unwrap();
1703 prop_assert_eq!(
1704 freed_again, 0,
1705 "second evict_lru call should free 0 bytes (already at or below limit)"
1706 );
1707 }
1708 }
1709
1710 proptest! {
1719 #[test]
1730 fn prop_cache_persistence_across_sessions(
1731 content in proptest::collection::vec(any::<u8>(), 1..=500usize),
1732 ) {
1733 use crate::session_store::SessionStore;
1734
1735 let dir = tempfile::tempdir().unwrap();
1736 let db_path = dir.path().join("cache.db");
1737 let path = Path::new("file.txt");
1738
1739 {
1741 let store = SessionStore::open_or_create(&db_path).unwrap();
1742 let cm = CacheManager::with_ref_age_duration(
1744 store,
1745 u64::MAX,
1746 Duration::from_secs(3600),
1747 );
1748 let pipeline = make_pipeline();
1749
1750 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1751 prop_assert!(
1752 matches!(r, CacheResult::Fresh { .. }),
1753 "first-ever read should be a miss"
1754 );
1755 }
1756
1757 {
1759 let store = SessionStore::open_or_create(&db_path).unwrap();
1760 let cm = CacheManager::with_ref_age_duration(
1761 store,
1762 u64::MAX,
1763 Duration::from_secs(3600),
1764 );
1765 let pipeline = make_pipeline();
1766
1767 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
1772 match r {
1773 CacheResult::Dedup { token_cost, .. } => {
1774 prop_assert_eq!(
1775 token_cost, 13,
1776 "first read after restart must be a 13-token dedup ref"
1777 );
1778 }
1779 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1780 prop_assert!(
1781 false,
1782 "first read after restart must dedup — this was the \
1783 April 18 bug and its fix is the whole reason this \
1784 test exists"
1785 );
1786 }
1787 }
1788 }
1789 }
1790 }
1791}