Skip to main content

lean_ctx/core/context_kernel/
context_dedup.rs

1//! Content deduplication for context delivered to language models.
2
3use std::collections::{HashMap, HashSet};
4use std::time::Instant;
5
6const DEFAULT_MAX_ENTRIES: usize = 1000;
7const HASH_LENGTH: usize = 16;
8
9/// Tracks the identity of content that was delivered to the LLM.
10#[derive(Debug, Clone)]
11pub struct ContentFingerprint {
12    /// Blake3 content hash, truncated to 16 hexadecimal characters.
13    pub hash: String,
14    /// Approximate number of tokens in the delivered content.
15    pub token_estimate: usize,
16    /// Time at which this version of the content was delivered.
17    pub delivered_at: Instant,
18}
19
20/// Whether content needs to be sent or was already delivered.
21#[derive(Debug, Clone, PartialEq)]
22pub enum DedupResult {
23    /// Content is new or changed — must be sent.
24    Fresh,
25    /// Content is unchanged since last delivery — send a stub instead.
26    Unchanged {
27        /// Fingerprint of the content already in context.
28        hash: String,
29        /// Estimated tokens avoided by suppressing the duplicate.
30        saved_tokens: usize,
31    },
32}
33
34/// Bounded content deduplication tracker.
35/// Remembers what was sent to the LLM to avoid resending unchanged content.
36#[derive(Debug)]
37pub struct ContextDedup {
38    fingerprints: HashMap<String, ContentFingerprint>,
39    max_entries: usize,
40}
41
42impl ContextDedup {
43    /// Creates a tracker limited to `max_entries`; zero selects the default of 1000.
44    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    /// Checks whether `content` for `path` changed and records fresh content.
56    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    /// Forgets content previously recorded for `path`.
82    pub fn invalidate(&mut self, path: &str) {
83        self.fingerprints.remove(path);
84    }
85
86    /// Forgets every recorded content fingerprint.
87    pub fn clear(&mut self) {
88        self.fingerprints.clear();
89    }
90
91    /// Returns the number of tracked paths.
92    pub fn len(&self) -> usize {
93        self.fingerprints.len()
94    }
95
96    /// Returns whether the tracker contains no fingerprints.
97    pub fn is_empty(&self) -> bool {
98        self.fingerprints.is_empty()
99    }
100
101    /// Removes the least recently delivered fingerprint, if any.
102    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
114/// Formats a compact reference to unchanged content already in context.
115pub 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
120/// Replaces repeated Context Kernel blocks with compact reference stubs.
121pub 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
142/// Estimates token usage using an average of four UTF-8 bytes per token.
143pub 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}