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 {
140 store: SessionStore,
141 max_size_bytes: u64,
142 delta_encoder: DeltaEncoder,
143 #[allow(dead_code)]
146 turn_counter: std::cell::Cell<u64>,
147 #[allow(dead_code)]
149 ref_tracker: std::cell::RefCell<HashMap<String, RefEntry>>,
150 max_ref_age: Duration,
155 compaction_marker: std::cell::Cell<Option<chrono::DateTime<chrono::Utc>>>,
159 lsh: std::cell::RefCell<crate::minhash_lsh::MinHashLsh>,
163 lsh_hashes: std::cell::RefCell<Vec<String>>,
165}
166
167impl CacheManager {
168 pub fn new(store: SessionStore, max_size_bytes: u64) -> Self {
174 Self::with_ref_age_duration(store, max_size_bytes, Duration::from_secs(30 * 60))
175 }
176
177 #[doc(hidden)]
183 pub fn with_ref_age(store: SessionStore, max_size_bytes: u64, max_ref_age_turns: u64) -> Self {
184 Self::with_ref_age_duration(
185 store,
186 max_size_bytes,
187 Duration::from_secs(max_ref_age_turns.saturating_mul(60)),
188 )
189 }
190
191 pub fn with_ref_age_duration(
193 store: SessionStore,
194 max_size_bytes: u64,
195 max_ref_age: Duration,
196 ) -> Self {
197 Self {
198 store,
199 max_size_bytes,
200 delta_encoder: DeltaEncoder::new(),
201 turn_counter: std::cell::Cell::new(0),
202 ref_tracker: std::cell::RefCell::new(HashMap::new()),
203 max_ref_age,
204 compaction_marker: std::cell::Cell::new(None),
205 lsh: std::cell::RefCell::new(crate::minhash_lsh::MinHashLsh::new()),
206 lsh_hashes: std::cell::RefCell::new(Vec::new()),
207 }
208 }
209
210 fn index_for_similarity(&self, hash: &str, data: &str) {
214 const MAX_INDEXED: usize = 512;
215 let mut hashes = self.lsh_hashes.borrow_mut();
216 if hashes.len() >= MAX_INDEXED || hashes.iter().any(|h| h == hash) {
217 return;
218 }
219 let doc_id = hashes.len() as u64;
220 self.lsh.borrow_mut().insert(doc_id, data);
221 hashes.push(hash.to_string());
222 }
223
224 pub fn sha256_hex(bytes: &[u8]) -> String {
226 let mut hasher = Sha256::new();
227 hasher.update(bytes);
228 format!("{:x}", hasher.finalize())
229 }
230
231 pub fn advance_turn(&self) {
235 self.turn_counter.set(self.turn_counter.get() + 1);
236 }
237
238 pub fn current_turn(&self) -> u64 {
240 self.turn_counter.get()
241 }
242
243 pub fn notify_compaction(&self) {
257 let now = chrono::Utc::now();
258 self.compaction_marker.set(Some(now));
259 self.ref_tracker.borrow_mut().clear();
260 let _ = self
266 .store
267 .set_metadata("last_compaction_at", &now.to_rfc3339());
268 }
269
270 fn is_ref_fresh(&self, hash: &str) -> bool {
285 if let Ok(true) = self.store.is_cache_entry_pinned(hash) {
289 return true;
290 }
291 let accessed = match self.store.get_cache_entry_accessed_at(hash) {
292 Ok(Some(ts)) => ts,
293 _ => return false,
294 };
295 if let Some(marker) = self.compaction_marker.get() {
297 if accessed < marker {
298 return false;
299 }
300 }
301 if let Ok(Some(raw)) = self.store.get_metadata("last_compaction_at") {
305 if let Ok(marker) = raw.parse::<chrono::DateTime<chrono::Utc>>() {
306 if accessed < marker {
307 return false;
308 }
309 }
310 }
311 let age = (chrono::Utc::now() - accessed)
312 .to_std()
313 .unwrap_or(Duration::from_secs(0));
314 age < self.max_ref_age
315 }
316
317 fn record_ref_sent(&self, hash: &str) {
323 let _ = self.store.touch_cache_entry(hash);
324 }
325
326 pub fn get_or_compress(
334 &self,
335 path: &Path,
336 content: &[u8],
337 pipeline: &CompressionPipeline,
338 ) -> Result<CacheResult> {
339 self.get_or_compress_inner(path, content, pipeline, false)
340 }
341
342 pub fn get_or_compress_lossless(
348 &self,
349 path: &Path,
350 content: &[u8],
351 pipeline: &CompressionPipeline,
352 ) -> Result<CacheResult> {
353 self.get_or_compress_inner(path, content, pipeline, true)
354 }
355
356 fn get_or_compress_inner(
357 &self,
358 _path: &Path,
359 content: &[u8],
360 pipeline: &CompressionPipeline,
361 lossless: bool,
362 ) -> Result<CacheResult> {
363 let hash = Self::sha256_hex(content);
364
365 let exists = self.store.cache_entry_exists(&hash)?;
370 if exists {
371 if self.is_ref_fresh(&hash) {
372 let hash_prefix = &hash[..16];
374 let inline_ref = format!("§ref:{hash_prefix}§");
375 self.record_ref_sent(&hash);
377 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache hit record error: {e}"));
378 return Ok(CacheResult::Dedup {
379 inline_ref,
380 token_cost: 13,
381 });
382 } else {
383 let _ = self.store.record_cache_hit().map_err(|e| eprintln!("[sqz] cache stale-hit record error: {e}"));
386 let text = String::from_utf8_lossy(content).into_owned();
387 let ctx = SessionContext {
388 session_id: "cache".to_string(),
389 };
390 let preset = Preset::default();
391 let compressed = if lossless {
392 pipeline.compress_lossless(&text, &ctx, &preset)?
393 } else {
394 pipeline.compress(&text, &ctx, &preset)?
395 };
396 self.record_ref_sent(&hash);
398 return Ok(CacheResult::Fresh { output: compressed });
399 }
400 }
401
402 if let Some(hit) = self.check_slice_with_meta(content)? {
404 return Ok(CacheResult::Dedup {
405 inline_ref: hit.inline_ref,
406 token_cost: hit.token_cost,
407 });
408 }
409
410 let text = String::from_utf8_lossy(content).into_owned();
412 let _ = self.store.record_cache_miss().map_err(|e| eprintln!("[sqz] cache miss record error: {e}"));
413 if let Some(delta_result) = self.try_delta_encode(&text)? {
414 let ctx = SessionContext {
416 session_id: "cache".to_string(),
417 };
418 let preset = Preset::default();
419 let compressed = if lossless {
420 pipeline.compress_lossless(&text, &ctx, &preset)?
421 } else {
422 pipeline.compress(&text, &ctx, &preset)?
423 };
424 self.store
426 .save_cache_entry_with_original(&hash, &compressed, Some(content))?;
427 self.index_for_similarity(&hash, &compressed.data);
428 self.record_ref_sent(&hash);
429
430 let token_cost = (delta_result.delta_text.len() / 4) as u32;
431 return Ok(CacheResult::Delta {
432 delta_text: delta_result.delta_text,
433 token_cost: token_cost.max(5),
434 similarity: delta_result.similarity,
435 });
436 }
437
438 let ctx = SessionContext {
439 session_id: "cache".to_string(),
440 };
441 let preset = Preset::default();
442 let compressed = if lossless {
443 pipeline.compress_lossless(&text, &ctx, &preset)?
444 } else {
445 pipeline.compress(&text, &ctx, &preset)?
446 };
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 summary_template: "... and {remaining} more items".into(),
1076 }),
1077 custom_transforms: Some(CustomTransformsConfig { enabled: true }),
1078 },
1079 tool_selection: ToolSelectionConfig {
1080 max_tools: 5,
1081 similarity_threshold: 0.7,
1082 default_tools: vec![],
1083 },
1084 budget: BudgetConfig {
1085 warning_threshold: 0.70,
1086 ceiling_threshold: 0.85,
1087 default_window_size: 200_000,
1088 agents: Default::default(),
1089 },
1090 terse_mode: TerseModeConfig {
1091 enabled: false,
1092 level: crate::preset::TerseLevel::Moderate,
1093 },
1094 model: ModelConfig {
1095 family: "anthropic".into(),
1096 primary: "claude-sonnet-4-20250514".into(),
1097 local: String::new(),
1098 complexity_threshold: 0.4,
1099 pricing: None,
1100 },
1101 }
1102 }
1103
1104 fn make_pipeline() -> CompressionPipeline {
1105 CompressionPipeline::new(&test_preset())
1106 }
1107
1108 #[test]
1109 fn first_read_is_miss() {
1110 let (store, _dir) = in_memory_store();
1111 let cm = CacheManager::new(store, u64::MAX);
1112 let pipeline = make_pipeline();
1113 let content = b"hello world";
1114 let result = cm
1115 .get_or_compress(Path::new("file.txt"), content, &pipeline)
1116 .unwrap();
1117 assert!(matches!(result, CacheResult::Fresh { .. }));
1118 }
1119
1120 #[test]
1125 fn get_or_compress_lossless_does_not_truncate_source() {
1126 let pipeline = make_pipeline();
1127
1128 let mut segs = Vec::new();
1131 for i in 0..24 {
1133 if i % 2 == 0 {
1134 segs.push(format!("SEG{i} llllllllllllllllllllllllllllllllllllllll"));
1135 } else {
1136 segs.push(format!(
1137 "SEG{i} the quick brown fox jumps over {i} lazy dogs by rivers"
1138 ));
1139 }
1140 }
1141 let content = segs.join("\n\n");
1142 assert!(content.len() > 1000);
1143
1144 let (s1, _d1) = in_memory_store();
1146 let lossy = CacheManager::new(s1, u64::MAX);
1147 match lossy
1148 .get_or_compress(Path::new("x"), content.as_bytes(), &pipeline)
1149 .unwrap()
1150 {
1151 CacheResult::Fresh { output } => {
1152 assert!(output.data.contains("omitted"), "lossy path should truncate");
1153 }
1154 _ => panic!("expected Fresh on a cold read"),
1155 }
1156
1157 let (s2, _d2) = in_memory_store();
1159 let cm = CacheManager::new(s2, u64::MAX);
1160 match cm
1161 .get_or_compress_lossless(Path::new("src/lib.rs"), content.as_bytes(), &pipeline)
1162 .unwrap()
1163 {
1164 CacheResult::Fresh { output } => {
1165 assert!(
1166 !output.data.contains("omitted"),
1167 "file-read path truncated content: {}",
1168 output.data
1169 );
1170 for i in 0..10 {
1171 assert!(
1172 output.data.contains(&format!("SEG{i}")),
1173 "file-read path dropped SEG{i}"
1174 );
1175 }
1176 }
1177 _ => panic!("expected CacheResult::Fresh on a cold read"),
1178 }
1179 }
1180
1181 #[test]
1184 fn lsh_finds_near_duplicate_beyond_lru_window() {
1185 let (store, _dir) = in_memory_store();
1186 let cm = CacheManager::new(store, u64::MAX);
1187 let pipeline = make_pipeline();
1188
1189 let base: String = (0..40)
1190 .map(|i| format!("stable log line number {i} with steady content here\n"))
1191 .collect();
1192 cm.get_or_compress(Path::new("base"), base.as_bytes(), &pipeline)
1193 .unwrap();
1194
1195 for n in 0..12 {
1197 let filler: String = (0..30)
1198 .map(|i| format!("filler doc {n} row {i} totally different subject matter\n"))
1199 .collect();
1200 cm.get_or_compress(Path::new("filler"), filler.as_bytes(), &pipeline)
1201 .unwrap();
1202 }
1203
1204 let variant = base.replace(
1206 "stable log line number 7 with steady content here",
1207 "stable log line number 7 with slightly altered content",
1208 );
1209 let result = cm
1210 .get_or_compress(Path::new("variant"), variant.as_bytes(), &pipeline)
1211 .unwrap();
1212 let kind = match &result {
1213 CacheResult::Dedup { .. } => "Dedup",
1214 CacheResult::Delta { .. } => "Delta",
1215 CacheResult::Fresh { .. } => "Fresh",
1216 };
1217 assert!(
1218 matches!(result, CacheResult::Delta { .. }),
1219 "near-duplicate beyond the LRU window must resolve via LSH, got {kind}"
1220 );
1221 }
1222
1223 #[test]
1224 fn second_read_is_hit() {
1225 let (store, _dir) = in_memory_store();
1226 let cm = CacheManager::new(store, u64::MAX);
1227 let pipeline = make_pipeline();
1228 let content = b"hello world";
1229 let path = Path::new("file.txt");
1230
1231 cm.get_or_compress(path, content, &pipeline).unwrap();
1233
1234 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1236 match result {
1237 CacheResult::Dedup {
1238 inline_ref,
1239 token_cost,
1240 } => {
1241 assert!(inline_ref.starts_with("§ref:"));
1242 assert!(inline_ref.ends_with('§'));
1243 assert_eq!(token_cost, 13);
1244 }
1245 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => panic!("expected cache hit"),
1246 }
1247 }
1248
1249 #[test]
1250 fn different_content_is_miss() {
1251 let (store, _dir) = in_memory_store();
1252 let cm = CacheManager::new(store, u64::MAX);
1253 let pipeline = make_pipeline();
1254 let path = Path::new("file.txt");
1255
1256 cm.get_or_compress(path, b"content v1", &pipeline).unwrap();
1257 let result = cm
1258 .get_or_compress(path, b"content v2", &pipeline)
1259 .unwrap();
1260 assert!(matches!(result, CacheResult::Fresh { .. } | CacheResult::Delta { .. }));
1261 }
1262
1263 #[test]
1264 fn evict_lru_frees_bytes_when_over_limit() {
1265 let (store, _dir) = in_memory_store();
1266 let cm = CacheManager::new(store, 1);
1268 let pipeline = make_pipeline();
1269 let path = Path::new("f.txt");
1270
1271 cm.get_or_compress(path, b"entry one", &pipeline).unwrap();
1273 cm.get_or_compress(path, b"entry two", &pipeline).unwrap();
1274 cm.get_or_compress(path, b"entry three", &pipeline).unwrap();
1275
1276 let freed = cm.evict_lru().unwrap();
1277 assert!(freed > 0, "expected bytes to be freed");
1278 }
1279
1280 #[test]
1281 fn evict_lru_no_op_when_under_limit() {
1282 let (store, _dir) = in_memory_store();
1283 let cm = CacheManager::new(store, u64::MAX);
1284 let pipeline = make_pipeline();
1285
1286 cm.get_or_compress(Path::new("f.txt"), b"data", &pipeline)
1287 .unwrap();
1288
1289 let freed = cm.evict_lru().unwrap();
1290 assert_eq!(freed, 0);
1291 }
1292
1293 #[test]
1294 fn invalidate_removes_entry() {
1295 let dir = tempfile::tempdir().unwrap();
1296 let file_path = dir.path().join("test.txt");
1297 std::fs::write(&file_path, b"some content").unwrap();
1298
1299 let store_path = dir.path().join("store.db");
1300 let store = SessionStore::open_or_create(&store_path).unwrap();
1301 let cm = CacheManager::new(store, u64::MAX);
1302 let pipeline = make_pipeline();
1303
1304 let content = std::fs::read(&file_path).unwrap();
1306 cm.get_or_compress(&file_path, &content, &pipeline).unwrap();
1307
1308 let hit = cm
1310 .get_or_compress(&file_path, &content, &pipeline)
1311 .unwrap();
1312 assert!(matches!(hit, CacheResult::Dedup { .. }));
1313
1314 cm.invalidate(&file_path).unwrap();
1315
1316 let miss = cm
1317 .get_or_compress(&file_path, &content, &pipeline)
1318 .unwrap();
1319 assert!(matches!(miss, CacheResult::Fresh { .. }));
1320 }
1321
1322 #[test]
1323 fn invalidate_nonexistent_path_is_noop() {
1324 let (store, _dir) = in_memory_store();
1325 let cm = CacheManager::new(store, u64::MAX);
1326 cm.invalidate(Path::new("/nonexistent/path/file.txt"))
1328 .unwrap();
1329 }
1330
1331 #[test]
1339 fn stale_ref_returns_fresh_instead_of_dedup() {
1340 let (store, _dir) = in_memory_store();
1341 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::ZERO);
1343 let pipeline = make_pipeline();
1344 let content = b"hello world";
1345 let path = Path::new("file.txt");
1346
1347 cm.get_or_compress(path, content, &pipeline).unwrap();
1349
1350 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1352 assert!(
1353 matches!(result, CacheResult::Fresh { .. }),
1354 "stale ref (TTL=0) should return Fresh, not Dedup"
1355 );
1356 }
1357
1358 #[test]
1359 fn fresh_ref_returns_dedup() {
1360 let (store, _dir) = in_memory_store();
1361 let cm = CacheManager::with_ref_age_duration(
1363 store,
1364 u64::MAX,
1365 Duration::from_secs(86_400),
1366 );
1367 let pipeline = make_pipeline();
1368 let content = b"hello world";
1369 let path = Path::new("file.txt");
1370
1371 cm.get_or_compress(path, content, &pipeline).unwrap();
1372 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1373 assert!(
1374 matches!(result, CacheResult::Dedup { .. }),
1375 "fresh ref should dedup"
1376 );
1377 }
1378
1379 #[test]
1380 fn notify_compaction_invalidates_all_refs() {
1381 let (store, _dir) = in_memory_store();
1382 let cm = CacheManager::with_ref_age_duration(
1383 store,
1384 u64::MAX,
1385 Duration::from_secs(86_400),
1386 );
1387 let pipeline = make_pipeline();
1388 let path = Path::new("file.txt");
1389
1390 cm.get_or_compress(path, b"content A", &pipeline).unwrap();
1392 cm.get_or_compress(path, b"content B", &pipeline).unwrap();
1393 assert!(matches!(
1394 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1395 CacheResult::Dedup { .. }
1396 ));
1397 assert!(matches!(
1398 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1399 CacheResult::Dedup { .. }
1400 ));
1401
1402 std::thread::sleep(std::time::Duration::from_millis(10));
1407 cm.notify_compaction();
1408
1409 assert!(matches!(
1411 cm.get_or_compress(path, b"content A", &pipeline).unwrap(),
1412 CacheResult::Fresh { .. }
1413 ));
1414 assert!(matches!(
1415 cm.get_or_compress(path, b"content B", &pipeline).unwrap(),
1416 CacheResult::Fresh { .. }
1417 ));
1418 }
1419
1420 #[test]
1421 fn ref_refreshed_after_resend() {
1422 let (store, _dir) = in_memory_store();
1423 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1426 let pipeline = make_pipeline();
1427 let content = b"hello world";
1428 let path = Path::new("file.txt");
1429
1430 cm.get_or_compress(path, content, &pipeline).unwrap();
1431 std::thread::sleep(std::time::Duration::from_millis(10));
1434 cm.notify_compaction();
1435
1436 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1439 assert!(matches!(result, CacheResult::Fresh { .. }));
1440
1441 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1443 assert!(
1444 matches!(result, CacheResult::Dedup { .. }),
1445 "ref should be fresh after re-send"
1446 );
1447 }
1448
1449 #[test]
1450 fn check_dedup_returns_none_for_stale_ref() {
1451 let pipeline = make_pipeline();
1452 let content = b"test content";
1453 let path = Path::new("file.txt");
1454
1455 let (store, _dir) = in_memory_store();
1457 let fresh =
1458 CacheManager::with_ref_age_duration(store, u64::MAX, Duration::from_secs(86_400));
1459 fresh.get_or_compress(path, content, &pipeline).unwrap();
1460 assert!(fresh.check_dedup(content).unwrap().is_some());
1461
1462 let (store2, _dir2) = in_memory_store();
1464 let stale = CacheManager::with_ref_age_duration(store2, u64::MAX, Duration::ZERO);
1465 stale.get_or_compress(path, content, &pipeline).unwrap();
1466 assert!(
1467 stale.check_dedup(content).unwrap().is_none(),
1468 "stale ref should not be returned by check_dedup"
1469 );
1470 }
1471
1472 #[test]
1473 fn pinned_ref_stays_fresh_past_ttl() {
1474 let (store, _dir) = in_memory_store();
1475 let cm = CacheManager::with_ref_age_duration(
1476 store,
1477 u64::MAX,
1478 Duration::from_millis(10),
1479 );
1480 let pipeline = make_pipeline();
1481 let content = b"wiki: stable knowledge base content";
1482 let path = Path::new("wiki.md");
1483
1484 cm.get_or_compress(path, content, &pipeline).unwrap();
1486 let hash = CacheManager::sha256_hex(content);
1487 assert!(cm.store.pin_cache_entry(&hash).unwrap());
1488
1489 std::thread::sleep(std::time::Duration::from_millis(40));
1491
1492 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1494 assert!(
1495 matches!(result, CacheResult::Dedup { .. }),
1496 "pinned entry should stay fresh past TTL"
1497 );
1498 assert!(cm.check_dedup(content).unwrap().is_some());
1499 }
1500
1501 #[test]
1502 fn unpin_returns_to_ttl_behaviour() {
1503 let (store, _dir) = in_memory_store();
1504 let cm = CacheManager::with_ref_age_duration(
1505 store,
1506 u64::MAX,
1507 Duration::from_millis(10),
1508 );
1509 let pipeline = make_pipeline();
1510 let content = b"transient session content";
1511 let path = Path::new("file.txt");
1512
1513 cm.get_or_compress(path, content, &pipeline).unwrap();
1514 let hash = CacheManager::sha256_hex(content);
1515 cm.store.pin_cache_entry(&hash).unwrap();
1516
1517 std::thread::sleep(std::time::Duration::from_millis(25));
1519 assert!(matches!(
1520 cm.get_or_compress(path, content, &pipeline).unwrap(),
1521 CacheResult::Dedup { .. }
1522 ));
1523
1524 cm.store.unpin_cache_entry(&hash).unwrap();
1526 std::thread::sleep(std::time::Duration::from_millis(25));
1527 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1528 assert!(
1529 matches!(result, CacheResult::Fresh { .. }),
1530 "unpinned entry should obey TTL"
1531 );
1532 }
1533
1534 #[test]
1535 fn pinned_ref_survives_compaction_marker() {
1536 let (store, _dir) = in_memory_store();
1537 let cm = CacheManager::with_ref_age_duration(
1538 store,
1539 u64::MAX,
1540 Duration::from_secs(3600),
1541 );
1542 let pipeline = make_pipeline();
1543 let content = b"system prompt: shared across agents";
1544 let path = Path::new("system.md");
1545
1546 cm.get_or_compress(path, content, &pipeline).unwrap();
1547 let hash = CacheManager::sha256_hex(content);
1548 cm.store.pin_cache_entry(&hash).unwrap();
1549
1550 std::thread::sleep(std::time::Duration::from_millis(10));
1552 cm.notify_compaction();
1553
1554 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1557 assert!(
1558 matches!(result, CacheResult::Dedup { .. }),
1559 "pinned entry should survive compaction"
1560 );
1561 }
1562
1563 #[test]
1564 fn pin_visible_across_cachemanager_instances() {
1565 let dir = tempfile::tempdir().unwrap();
1570 let path = dir.path().join("shared.db");
1571
1572 let store_a = SessionStore::open_or_create(&path).unwrap();
1573 let cm_a = CacheManager::with_ref_age_duration(
1574 store_a,
1575 u64::MAX,
1576 Duration::from_millis(5),
1577 );
1578 let pipeline = make_pipeline();
1579 let content = b"shared wiki content for multi-agent dedup";
1580
1581 cm_a.get_or_compress(Path::new("wiki.md"), content, &pipeline)
1582 .unwrap();
1583 let hash = CacheManager::sha256_hex(content);
1584 cm_a.store.pin_cache_entry(&hash).unwrap();
1585
1586 drop(cm_a);
1588 std::thread::sleep(std::time::Duration::from_millis(20));
1589
1590 let store_b = SessionStore::open_or_create(&path).unwrap();
1591 let cm_b = CacheManager::with_ref_age_duration(
1592 store_b,
1593 u64::MAX,
1594 Duration::from_millis(5),
1595 );
1596 let result = cm_b
1597 .get_or_compress(Path::new("wiki.md"), content, &pipeline)
1598 .unwrap();
1599 assert!(
1600 matches!(result, CacheResult::Dedup { .. }),
1601 "second agent should see the pin from the shared store"
1602 );
1603 }
1604
1605 #[test]
1606 fn advance_turn_increments_counter() {
1607 let (store, _dir) = in_memory_store();
1609 let cm = CacheManager::new(store, u64::MAX);
1610 assert_eq!(cm.current_turn(), 0);
1611 cm.advance_turn();
1612 assert_eq!(cm.current_turn(), 1);
1613 cm.advance_turn();
1614 assert_eq!(cm.current_turn(), 2);
1615 }
1616
1617 #[test]
1620 fn expand_returns_original_bytes_for_new_entry() {
1621 let (store, _dir) = in_memory_store();
1625 let cm = CacheManager::new(store, u64::MAX);
1626 let pipeline = make_pipeline();
1627 let path = Path::new("x.txt");
1628 let content = b"hello\nworld\nthis is the original\n";
1629
1630 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1632 let CacheResult::Fresh { .. } = first else {
1633 panic!("first read should be a Fresh miss, got something else");
1634 };
1635
1636 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1638 let inline_ref = match second {
1639 CacheResult::Dedup { inline_ref, .. } => inline_ref,
1640 _ => panic!("second read should dedup"),
1641 };
1642
1643 let prefix = inline_ref
1645 .strip_prefix("§ref:")
1646 .and_then(|s| s.strip_suffix('§'))
1647 .expect("unexpected ref format");
1648
1649 let result = cm.expand_prefix(prefix).unwrap().expect("expand hit expected");
1650 match result {
1651 ExpandResult::Original { bytes, hash } => {
1652 assert_eq!(bytes, content, "expand must return exact original bytes");
1653 assert!(
1654 hash.starts_with(prefix),
1655 "returned full hash {hash} must start with prefix {prefix}"
1656 );
1657 assert_eq!(hash.len(), 64, "full SHA-256 must be 64 hex chars");
1658 }
1659 ExpandResult::CompressedOnly { .. } => {
1660 panic!("new cache entries must carry the original bytes");
1661 }
1662 }
1663 }
1664
1665 #[test]
1666 fn expand_prefix_rejects_nonhex_input() {
1667 let (store, _dir) = in_memory_store();
1672 let cm = CacheManager::new(store, u64::MAX);
1673 assert!(cm.expand_prefix("").unwrap().is_none(), "empty prefix is no-match");
1674 assert!(cm.expand_prefix("not a hex").unwrap().is_none());
1675 assert!(cm.expand_prefix("ABCDEF").unwrap().is_none(), "uppercase hex should not match (sqz emits lowercase)");
1676 assert!(cm.expand_prefix("g0g0g0").unwrap().is_none());
1677 }
1678
1679 #[test]
1680 fn expand_prefix_returns_none_for_unknown_prefix() {
1681 let (store, _dir) = in_memory_store();
1684 let cm = CacheManager::new(store, u64::MAX);
1685 let pipeline = make_pipeline();
1686 let _ = cm
1688 .get_or_compress(
1689 Path::new("y.txt"),
1690 b"some cached content that won't match the prefix below",
1691 &pipeline,
1692 )
1693 .unwrap();
1694 assert!(
1695 cm.expand_prefix("0000000000000000").unwrap().is_none(),
1696 "prefix that matches nothing must return None, not error"
1697 );
1698 }
1699
1700 #[test]
1701 fn expand_prefix_errors_on_ambiguous_match() {
1702 let (store, _dir) = in_memory_store();
1707 let compressed = crate::types::CompressedContent {
1709 data: "compressed-a".to_string(),
1710 tokens_compressed: 1,
1711 tokens_original: 10,
1712 stages_applied: vec![],
1713 compression_ratio: 0.1,
1714 provenance: Default::default(),
1715 verify: None,
1716 };
1717 store
1718 .save_cache_entry_with_original(
1719 &format!("ab{}", "0".repeat(62)),
1720 &compressed,
1721 Some(b"content a"),
1722 )
1723 .unwrap();
1724 store
1725 .save_cache_entry_with_original(
1726 &format!("ab{}", "1".repeat(62)),
1727 &compressed,
1728 Some(b"content b"),
1729 )
1730 .unwrap();
1731
1732 let cm = CacheManager::new(store, u64::MAX);
1733 let ok_a = cm.expand_prefix(&format!("ab{}", "0")).unwrap().unwrap();
1735 assert!(matches!(ok_a, ExpandResult::Original { .. }));
1736 let err = cm.expand_prefix("ab").unwrap_err();
1738 let msg = err.to_string();
1739 assert!(
1740 msg.contains("multiple entries") || msg.contains("longer prefix"),
1741 "ambiguity error should mention multiple matches / longer prefix, got: {msg}"
1742 );
1743 }
1744
1745 #[test]
1746 fn expand_returns_compressed_only_for_pre_migration_entry() {
1747 let (store, _dir) = in_memory_store();
1752 let compressed = crate::types::CompressedContent {
1753 data: "the compressed version".to_string(),
1754 tokens_compressed: 4,
1755 tokens_original: 100,
1756 stages_applied: vec!["condense".to_string()],
1757 compression_ratio: 0.04,
1758 provenance: Default::default(),
1759 verify: None,
1760 };
1761 let hash = format!("deadbeef{}", "0".repeat(56));
1763 store.save_cache_entry(&hash, &compressed).unwrap();
1764
1765 let cm = CacheManager::new(store, u64::MAX);
1766 let result = cm
1767 .expand_prefix("deadbeef")
1768 .unwrap()
1769 .expect("deadbeef prefix should match");
1770 match result {
1771 ExpandResult::CompressedOnly { compressed, hash: h } => {
1772 assert_eq!(compressed, "the compressed version");
1773 assert_eq!(h, hash);
1774 }
1775 ExpandResult::Original { .. } => {
1776 panic!("legacy entry without original bytes should return CompressedOnly");
1777 }
1778 }
1779 }
1780
1781 #[test]
1782 fn expand_preserves_non_utf8_original_bytes() {
1783 let (store, _dir) = in_memory_store();
1789 let cm = CacheManager::new(store, u64::MAX);
1790 let pipeline = make_pipeline();
1791
1792 let content: Vec<u8> = vec![
1794 b'h', b'e', b'l', b'l', b'o', b'\n',
1795 0xFF, 0xFE, 0xFD,
1796 b'\n',
1797 ];
1798 let path = Path::new("bin.dat");
1799 let _ = cm.get_or_compress(path, &content, &pipeline).unwrap();
1800 let hash = CacheManager::sha256_hex(&content);
1802 let result = cm.expand_prefix(&hash[..16]).unwrap().unwrap();
1803 match result {
1804 ExpandResult::Original { bytes, .. } => {
1805 assert_eq!(bytes, content, "non-UTF-8 bytes must round-trip exactly");
1806 }
1807 ExpandResult::CompressedOnly { .. } => {
1808 panic!("fresh entry should have original bytes");
1809 }
1810 }
1811 }
1812
1813 #[test]
1814 fn dedup_survives_cache_manager_restart() {
1815 let dir = tempfile::tempdir().unwrap();
1821 let db_path = dir.path().join("cache.db");
1822 let pipeline = make_pipeline();
1823 let content = b"a substantial chunk of content to dedup";
1824 let path = Path::new("x.txt");
1825
1826 {
1828 let store = SessionStore::open_or_create(&db_path).unwrap();
1829 let cm = CacheManager::with_ref_age_duration(
1830 store,
1831 u64::MAX,
1832 Duration::from_secs(3600),
1833 );
1834 let first = cm.get_or_compress(path, content, &pipeline).unwrap();
1835 assert!(matches!(first, CacheResult::Fresh { .. }));
1836 }
1837
1838 {
1840 let store = SessionStore::open_or_create(&db_path).unwrap();
1841 let cm = CacheManager::with_ref_age_duration(
1842 store,
1843 u64::MAX,
1844 Duration::from_secs(3600),
1845 );
1846 let second = cm.get_or_compress(path, content, &pipeline).unwrap();
1847 assert!(
1848 matches!(second, CacheResult::Dedup { .. }),
1849 "second-process read must dedup — this was broken before the April 18 fix"
1850 );
1851 }
1852 }
1853
1854 #[test]
1855 fn compaction_from_one_process_invalidates_refs_in_another() {
1856 let dir = tempfile::tempdir().unwrap();
1868 let db_path = dir.path().join("cache.db");
1869 let pipeline = make_pipeline();
1870 let content = b"content that needs stale-marking after compaction";
1871 let path = Path::new("file.txt");
1872 let ttl = Duration::from_secs(3600);
1873
1874 {
1876 let store = SessionStore::open_or_create(&db_path).unwrap();
1877 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1878 cm.get_or_compress(path, content, &pipeline).unwrap();
1879 }
1880 std::thread::sleep(Duration::from_millis(10));
1882
1883 {
1886 let store = SessionStore::open_or_create(&db_path).unwrap();
1887 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1888 cm.notify_compaction();
1889 }
1890
1891 {
1895 let store = SessionStore::open_or_create(&db_path).unwrap();
1896 let cm = CacheManager::with_ref_age_duration(store, u64::MAX, ttl);
1897 let result = cm.get_or_compress(path, content, &pipeline).unwrap();
1898 assert!(
1899 matches!(result, CacheResult::Fresh { .. }),
1900 "post-compaction read from a fresh process must re-send Fresh; \
1901 returning Dedup would be a dangling-ref bug"
1902 );
1903 }
1904 }
1905
1906 use proptest::prelude::*;
1907
1908 proptest! {
1916 #[test]
1921 fn prop_cache_deduplication(
1922 content in proptest::collection::vec(any::<u8>(), 1..=1000usize),
1923 ) {
1924 let (store, _dir) = in_memory_store();
1925 let cm = CacheManager::new(store, u64::MAX);
1926 let pipeline = make_pipeline();
1927 let path = Path::new("file.txt");
1928
1929 let first = cm.get_or_compress(path, &content, &pipeline).unwrap();
1931 prop_assert!(
1932 matches!(first, CacheResult::Fresh { .. }),
1933 "first read should be a cache miss"
1934 );
1935
1936 let second = cm.get_or_compress(path, &content, &pipeline).unwrap();
1937 match second {
1938 CacheResult::Dedup { inline_ref, token_cost } => {
1939 prop_assert_eq!(
1940 token_cost, 13,
1941 "cache hit should report ~13 reference tokens"
1942 );
1943 prop_assert!(
1944 inline_ref.starts_with("§ref:"),
1945 "reference token should start with §ref:"
1946 );
1947 prop_assert!(
1948 inline_ref.ends_with('§'),
1949 "reference token should end with §"
1950 );
1951 }
1952 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
1953 prop_assert!(false, "second read should be a cache hit, not a miss");
1954 }
1955 }
1956 }
1957 }
1958
1959 proptest! {
1967 #[test]
1972 fn prop_cache_invalidation_on_content_change(
1973 content_a in proptest::collection::vec(any::<u8>(), 1..=500usize),
1974 content_b in proptest::collection::vec(any::<u8>(), 1..=500usize),
1975 ) {
1976 prop_assume!(content_a != content_b);
1978
1979 let (store, _dir) = in_memory_store();
1980 let cm = CacheManager::new(store, u64::MAX);
1981 let pipeline = make_pipeline();
1982 let path = Path::new("file.txt");
1983
1984 let r1 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1986 prop_assert!(
1987 matches!(r1, CacheResult::Fresh { .. }),
1988 "first read of content_a should be a miss"
1989 );
1990
1991 let r2 = cm.get_or_compress(path, &content_a, &pipeline).unwrap();
1992 prop_assert!(
1993 matches!(r2, CacheResult::Dedup { .. }),
1994 "second read of content_a should be a hit"
1995 );
1996
1997 let r3 = cm.get_or_compress(path, &content_b, &pipeline).unwrap();
1998 prop_assert!(
1999 matches!(r3, CacheResult::Fresh { .. } | CacheResult::Delta { .. }),
2000 "read with changed content should be a cache miss or delta"
2001 );
2002 }
2003 }
2004
2005 proptest! {
2013 #[test]
2018 fn prop_cache_lru_eviction(
2019 entries in proptest::collection::vec(
2021 proptest::collection::vec(any::<u8>(), 10..=200usize),
2022 2..=8usize,
2023 ),
2024 ) {
2025 let mut unique_entries: Vec<Vec<u8>> = Vec::new();
2027 for e in &entries {
2028 if !unique_entries.contains(e) {
2029 unique_entries.push(e.clone());
2030 }
2031 }
2032 prop_assume!(unique_entries.len() >= 2);
2033
2034 let (store, _dir) = in_memory_store();
2035 let cm = CacheManager::new(store, 1);
2037 let pipeline = make_pipeline();
2038 let path = Path::new("f.txt");
2039
2040 for entry in &unique_entries {
2042 cm.get_or_compress(path, entry, &pipeline).unwrap();
2043 }
2044
2045 let freed = cm.evict_lru().unwrap();
2047
2048 prop_assert!(freed > 0, "evict_lru should free bytes when over limit");
2050
2051 let freed_again = cm.evict_lru().unwrap();
2054 prop_assert_eq!(
2055 freed_again, 0,
2056 "second evict_lru call should free 0 bytes (already at or below limit)"
2057 );
2058 }
2059 }
2060
2061 proptest! {
2070 #[test]
2081 fn prop_cache_persistence_across_sessions(
2082 content in proptest::collection::vec(any::<u8>(), 1..=500usize),
2083 ) {
2084 use crate::session_store::SessionStore;
2085
2086 let dir = tempfile::tempdir().unwrap();
2087 let db_path = dir.path().join("cache.db");
2088 let path = Path::new("file.txt");
2089
2090 {
2092 let store = SessionStore::open_or_create(&db_path).unwrap();
2093 let cm = CacheManager::with_ref_age_duration(
2095 store,
2096 u64::MAX,
2097 Duration::from_secs(3600),
2098 );
2099 let pipeline = make_pipeline();
2100
2101 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2102 prop_assert!(
2103 matches!(r, CacheResult::Fresh { .. }),
2104 "first-ever read should be a miss"
2105 );
2106 }
2107
2108 {
2110 let store = SessionStore::open_or_create(&db_path).unwrap();
2111 let cm = CacheManager::with_ref_age_duration(
2112 store,
2113 u64::MAX,
2114 Duration::from_secs(3600),
2115 );
2116 let pipeline = make_pipeline();
2117
2118 let r = cm.get_or_compress(path, &content, &pipeline).unwrap();
2123 match r {
2124 CacheResult::Dedup { token_cost, .. } => {
2125 prop_assert_eq!(
2126 token_cost, 13,
2127 "first read after restart must be a 13-token dedup ref"
2128 );
2129 }
2130 CacheResult::Fresh { .. } | CacheResult::Delta { .. } => {
2131 prop_assert!(
2132 false,
2133 "first read after restart must dedup — this was the \
2134 April 18 bug and its fix is the whole reason this \
2135 test exists"
2136 );
2137 }
2138 }
2139 }
2140 }
2141 }
2142}