lean_ctx/core/context_kernel/
context_dedup.rs1use std::collections::{HashMap, HashSet};
4use std::time::Instant;
5
6const DEFAULT_MAX_ENTRIES: usize = 1000;
7const HASH_LENGTH: usize = 16;
8
9#[derive(Debug, Clone)]
11pub struct ContentFingerprint {
12 pub hash: String,
14 pub token_estimate: usize,
16 pub delivered_at: Instant,
18}
19
20#[derive(Debug, Clone, PartialEq)]
22pub enum DedupResult {
23 Fresh,
25 Unchanged {
27 hash: String,
29 saved_tokens: usize,
31 },
32}
33
34#[derive(Debug)]
37pub struct ContextDedup {
38 fingerprints: HashMap<String, ContentFingerprint>,
39 max_entries: usize,
40}
41
42impl ContextDedup {
43 pub fn new(max_entries: usize) -> Self {
45 Self {
46 fingerprints: HashMap::new(),
47 max_entries: if max_entries == 0 {
48 DEFAULT_MAX_ENTRIES
49 } else {
50 max_entries
51 },
52 }
53 }
54
55 pub fn check_and_record(&mut self, path: &str, content: &str) -> DedupResult {
57 let hash = content_hash(content);
58 if let Some(fingerprint) = self.fingerprints.get(path)
59 && fingerprint.hash == hash
60 {
61 return DedupResult::Unchanged {
62 hash,
63 saved_tokens: fingerprint.token_estimate,
64 };
65 }
66
67 if !self.fingerprints.contains_key(path) && self.fingerprints.len() >= self.max_entries {
68 self.evict_oldest();
69 }
70 self.fingerprints.insert(
71 path.to_owned(),
72 ContentFingerprint {
73 hash,
74 token_estimate: estimate_tokens(content),
75 delivered_at: Instant::now(),
76 },
77 );
78 DedupResult::Fresh
79 }
80
81 pub fn invalidate(&mut self, path: &str) {
83 self.fingerprints.remove(path);
84 }
85
86 pub fn clear(&mut self) {
88 self.fingerprints.clear();
89 }
90
91 pub fn len(&self) -> usize {
93 self.fingerprints.len()
94 }
95
96 pub fn is_empty(&self) -> bool {
98 self.fingerprints.is_empty()
99 }
100
101 pub fn evict_oldest(&mut self) {
103 let oldest = self
104 .fingerprints
105 .iter()
106 .min_by_key(|(_, fingerprint)| fingerprint.delivered_at)
107 .map(|(path, _)| path.clone());
108 if let Some(path) = oldest {
109 self.fingerprints.remove(&path);
110 }
111 }
112}
113
114pub fn format_unchanged_stub(path: &str, hash: &str) -> String {
116 let short_hash = &hash[..hash.len().min(8)];
117 format!("→ {path} unchanged (ref:{short_hash}), already in context\n")
118}
119
120pub fn dedup_kernel_blocks(blocks: &str, seen_hashes: &mut HashSet<String>) -> String {
122 let starts = kernel_block_starts(blocks);
123 let Some(&first_start) = starts.first() else {
124 return blocks.to_owned();
125 };
126
127 let mut output = String::with_capacity(blocks.len());
128 output.push_str(&blocks[..first_start]);
129 for (index, &start) in starts.iter().enumerate() {
130 let end = starts.get(index + 1).copied().unwrap_or(blocks.len());
131 let block = &blocks[start..end];
132 let hash = content_hash(block.trim_end());
133 if seen_hashes.insert(hash.clone()) {
134 output.push_str(block);
135 } else {
136 output.push_str(&format_unchanged_stub("kernel context", &hash));
137 }
138 }
139 output
140}
141
142pub fn estimate_tokens(content: &str) -> usize {
144 content.len() / 4
145}
146
147fn content_hash(content: &str) -> String {
148 blake3::hash(content.as_bytes()).to_hex()[..HASH_LENGTH].to_owned()
149}
150
151fn kernel_block_starts(blocks: &str) -> Vec<usize> {
152 let mut starts = Vec::new();
153 let mut offset = 0;
154 for line in blocks.split_inclusive('\n') {
155 if matches!(
156 line.trim_end_matches(['\r', '\n']),
157 "## Context Kernel" | "--- kernel context ---"
158 ) {
159 starts.push(offset);
160 }
161 offset += line.len();
162 }
163 starts
164}
165
166#[cfg(test)]
167mod tests {
168 use super::{
169 ContextDedup, DedupResult, content_hash, dedup_kernel_blocks, estimate_tokens,
170 format_unchanged_stub,
171 };
172 use std::collections::HashSet;
173
174 #[test]
175 fn fresh_on_first_read() {
176 let mut dedup = ContextDedup::new(1000);
177 assert_eq!(
178 dedup.check_and_record("src/lib.rs", "content"),
179 DedupResult::Fresh
180 );
181 }
182
183 #[test]
184 fn unchanged_on_repeat_read() {
185 let mut dedup = ContextDedup::new(1000);
186 dedup.check_and_record("src/lib.rs", "eight888");
187 assert_eq!(
188 dedup.check_and_record("src/lib.rs", "eight888"),
189 DedupResult::Unchanged {
190 hash: content_hash("eight888"),
191 saved_tokens: 2,
192 }
193 );
194 }
195
196 #[test]
197 fn fresh_after_content_change() {
198 let mut dedup = ContextDedup::new(1000);
199 dedup.check_and_record("src/lib.rs", "before");
200 assert_eq!(
201 dedup.check_and_record("src/lib.rs", "after"),
202 DedupResult::Fresh
203 );
204 }
205
206 #[test]
207 fn stub_format_is_short() {
208 let stub = format_unchanged_stub("src/lib.rs", "0123456789abcdef");
209 assert!(estimate_tokens(&stub) <= 20);
210 assert!(stub.contains("ref:01234567"));
211 }
212
213 #[test]
214 fn dedup_kernel_blocks_removes_duplicates() {
215 let block = "## Context Kernel\nshared enrichment\n";
216 let input = block.repeat(3);
217 let mut seen = HashSet::new();
218 let output = dedup_kernel_blocks(&input, &mut seen);
219 assert_eq!(output.matches("shared enrichment").count(), 1);
220 assert_eq!(output.matches("kernel context unchanged").count(), 2);
221 assert_eq!(seen.len(), 1);
222 }
223
224 #[test]
225 fn bounded_eviction() {
226 let mut dedup = ContextDedup::new(1000);
227 dedup.check_and_record("oldest", "first");
228 for index in 0..1000 {
229 dedup.check_and_record(&format!("path-{index}"), &format!("content-{index}"));
230 }
231 assert_eq!(dedup.len(), 1000);
232 assert_eq!(
233 dedup.check_and_record("oldest", "first"),
234 DedupResult::Fresh
235 );
236 }
237
238 #[test]
239 fn hash_is_deterministic() {
240 assert_eq!(content_hash("stable"), content_hash("stable"));
241 assert_eq!(content_hash("stable").len(), 16);
242 }
243
244 #[test]
245 fn invalidate_and_clear_forget_entries() {
246 let mut dedup = ContextDedup::new(10);
247 dedup.check_and_record("a", "one");
248 dedup.invalidate("a");
249 assert_eq!(dedup.check_and_record("a", "one"), DedupResult::Fresh);
250 dedup.clear();
251 assert!(dedup.is_empty());
252 }
253}