Skip to main content

lean_ctx/core/
relevance_tracker.rs

1#![allow(clippy::cast_precision_loss)]
2//! Proactive context expansion (#1122): automatically injects previously
3//! compressed data when it becomes relevant to the current request.
4//!
5//! When lean-ctx compresses content (via CCR tee-store), this module indexes
6//! keywords from the original. On subsequent tool calls, if the current request
7//! context matches indexed keywords above a threshold, the relevant compressed
8//! content is proactively expanded and appended.
9//!
10//! Determinism (#498): expansion decisions are pure functions of
11//! (query_terms, stored_entries, budget). No timestamps in scoring —
12//! age eviction uses monotonic seq_ticks.
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::sync::Mutex;
16use std::time::{SystemTime, UNIX_EPOCH};
17
18const DEFAULT_BUDGET_TOKENS: usize = 2000;
19const DEFAULT_THRESHOLD: f64 = 0.6;
20const MAX_ENTRIES: usize = 100;
21const MAX_KEYWORDS_PER_ENTRY: usize = 20;
22const CHARS_PER_TOKEN: usize = 4;
23const DEFAULT_MAX_AGE_SECS: u64 = 3600;
24
25static TRACKER: std::sync::LazyLock<Mutex<RelevanceTracker>> =
26    std::sync::LazyLock::new(|| Mutex::new(RelevanceTracker::new()));
27
28/// Access the global relevance tracker.
29pub(crate) fn global() -> &'static Mutex<RelevanceTracker> {
30    &TRACKER
31}
32
33/// Entry representing one piece of compressed content.
34#[derive(Debug, Clone)]
35pub struct CompressedContentEntry {
36    pub handle: String,
37    pub keywords: Vec<String>,
38    pub source_tool: &'static str,
39    pub original_tokens: usize,
40    pub compressed_tokens: usize,
41    pub timestamp: u64,
42    pub seq_tick: u64,
43}
44
45/// Match result for proactive expansion.
46#[derive(Debug, Clone)]
47pub struct ExpansionMatch {
48    pub handle: String,
49    pub score: f64,
50    pub estimated_tokens: usize,
51}
52
53/// The relevance tracker maintains a keyword index of all compressed content.
54pub struct RelevanceTracker {
55    entries: Vec<CompressedContentEntry>,
56    seq_counter: u64,
57    budget_tokens: usize,
58    threshold: f64,
59    max_age_secs: u64,
60    disabled_handles: BTreeSet<String>,
61}
62
63impl Default for RelevanceTracker {
64    fn default() -> Self {
65        Self::new()
66    }
67}
68
69impl RelevanceTracker {
70    pub fn new() -> Self {
71        Self {
72            entries: Vec::new(),
73            seq_counter: 0,
74            budget_tokens: DEFAULT_BUDGET_TOKENS,
75            threshold: DEFAULT_THRESHOLD,
76            max_age_secs: DEFAULT_MAX_AGE_SECS,
77            disabled_handles: BTreeSet::new(),
78        }
79    }
80
81    pub fn with_config(budget_tokens: usize, threshold: f64) -> Self {
82        Self::with_config_and_age(budget_tokens, threshold, DEFAULT_MAX_AGE_SECS)
83    }
84
85    pub fn with_config_and_age(budget_tokens: usize, threshold: f64, max_age_secs: u64) -> Self {
86        Self {
87            entries: Vec::new(),
88            seq_counter: 0,
89            budget_tokens,
90            threshold: if threshold.is_finite() {
91                threshold.clamp(0.0, 1.0)
92            } else {
93                DEFAULT_THRESHOLD
94            },
95            max_age_secs,
96            disabled_handles: BTreeSet::new(),
97        }
98    }
99
100    /// Update runtime settings without discarding already indexed entries.
101    pub fn configure(&mut self, budget_tokens: usize, threshold: f64, max_age_secs: u64) {
102        self.budget_tokens = budget_tokens;
103        self.threshold = if threshold.is_finite() {
104            threshold.clamp(0.0, 1.0)
105        } else {
106            DEFAULT_THRESHOLD
107        };
108        self.max_age_secs = max_age_secs;
109    }
110
111    /// Register a new compressed content entry with extracted keywords.
112    pub fn register(
113        &mut self,
114        handle: String,
115        original_content: &str,
116        source_tool: &'static str,
117        original_tokens: usize,
118        compressed_tokens: usize,
119    ) {
120        self.register_at(
121            handle,
122            original_content,
123            source_tool,
124            original_tokens,
125            compressed_tokens,
126            now_secs(),
127        );
128    }
129
130    /// Register content with an explicit timestamp for deterministic tests and
131    /// replayed session state.
132    pub fn register_at(
133        &mut self,
134        handle: String,
135        original_content: &str,
136        source_tool: &'static str,
137        original_tokens: usize,
138        compressed_tokens: usize,
139        timestamp: u64,
140    ) {
141        self.seq_counter += 1;
142
143        let keywords = extract_keywords(original_content);
144
145        if let Some(existing) = self.entries.iter_mut().find(|e| e.handle == handle) {
146            existing.keywords = keywords;
147            existing.source_tool = source_tool;
148            existing.original_tokens = original_tokens;
149            existing.compressed_tokens = compressed_tokens;
150            existing.timestamp = timestamp;
151            existing.seq_tick = self.seq_counter;
152            self.disabled_handles.remove(&existing.handle);
153            return;
154        }
155
156        let entry = CompressedContentEntry {
157            handle,
158            keywords,
159            source_tool,
160            original_tokens,
161            compressed_tokens,
162            timestamp,
163            seq_tick: self.seq_counter,
164        };
165
166        self.entries.push(entry);
167
168        // Evict oldest entries when over limit
169        if self.entries.len() > MAX_ENTRIES {
170            self.entries.sort_by_key(|e| e.seq_tick);
171            self.entries.drain(..self.entries.len() - MAX_ENTRIES);
172        }
173    }
174
175    /// Stop proactive expansion for one archive after a caller reports a bounce.
176    pub fn disable_handle(&mut self, handle: &str) {
177        self.disabled_handles.insert(handle.to_string());
178    }
179
180    /// Find entries matching the current query context. Returns matches
181    /// sorted by score (highest first), within the token budget.
182    pub fn find_matches(&self, query_context: &str) -> Vec<ExpansionMatch> {
183        if self.entries.is_empty() {
184            return Vec::new();
185        }
186
187        let query_terms = extract_query_terms(query_context);
188        if query_terms.is_empty() {
189            return Vec::new();
190        }
191
192        let now = now_secs();
193        let mut scored: Vec<(usize, f64)> = self
194            .entries
195            .iter()
196            .enumerate()
197            .filter_map(|(i, entry)| {
198                if self.disabled_handles.contains(&entry.handle)
199                    || (self.max_age_secs > 0
200                        && now.saturating_sub(entry.timestamp) > self.max_age_secs)
201                {
202                    return None;
203                }
204                let score = bm25_score(&query_terms, &entry.keywords);
205                if score >= self.threshold {
206                    Some((i, score))
207                } else {
208                    None
209                }
210            })
211            .collect();
212
213        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
214
215        let mut budget_remaining = self.budget_tokens;
216        let mut matches = Vec::new();
217
218        for (idx, score) in scored {
219            let entry = &self.entries[idx];
220            let available_savings = entry
221                .original_tokens
222                .saturating_sub(entry.compressed_tokens);
223            let estimated_tokens = entry
224                .original_tokens
225                .min(available_savings)
226                .min(budget_remaining);
227            if estimated_tokens == 0 {
228                break;
229            }
230            matches.push(ExpansionMatch {
231                handle: entry.handle.clone(),
232                score,
233                estimated_tokens,
234            });
235            budget_remaining = budget_remaining.saturating_sub(entry.original_tokens);
236            if budget_remaining == 0 {
237                break;
238            }
239        }
240
241        matches
242    }
243
244    /// Check if proactive expansion should trigger for a given context.
245    /// Returns the formatted expansion block if matches found.
246    pub fn expand_if_relevant(&self, query_context: &str) -> Option<String> {
247        let matches = self.find_matches(query_context);
248        if matches.is_empty() {
249            return None;
250        }
251
252        let mut block = String::from("\n--- PROACTIVE CONTEXT ---\n");
253        block.push_str("Previously compressed data relevant to this request:\n\n");
254
255        let mut expanded = false;
256        for m in &matches {
257            if let Some(content) = load_from_ccr(&m.handle, self.budget_tokens) {
258                let preview = truncate_to_budget(&content, m.estimated_tokens);
259                block.push_str(&format!(
260                    "From {}: (relevance {:.0}%)\n",
261                    m.handle,
262                    m.score * 100.0
263                ));
264                block.push_str(&preview);
265                block.push_str("\n\n");
266                expanded = true;
267            }
268        }
269
270        if !expanded {
271            return None;
272        }
273        block.push_str("--- END PROACTIVE CONTEXT ---");
274        Some(block)
275    }
276
277    /// Reset the tracker (for testing).
278    #[cfg(test)]
279    pub fn reset(&mut self) {
280        self.entries.clear();
281        self.seq_counter = 0;
282        self.disabled_handles.clear();
283    }
284}
285
286fn now_secs() -> u64 {
287    SystemTime::now()
288        .duration_since(UNIX_EPOCH)
289        .map_or(0, |duration| duration.as_secs())
290}
291
292/// Register a CCR artifact for later proactive expansion.
293pub(crate) fn register_compressed(
294    handle: String,
295    original_content: &str,
296    source_tool: &'static str,
297    original_tokens: usize,
298    compressed_tokens: usize,
299) {
300    if let Ok(mut tracker) = global().lock() {
301        tracker.register(
302            handle,
303            original_content,
304            source_tool,
305            original_tokens,
306            compressed_tokens,
307        );
308    }
309}
310
311/// Return a response suffix when the current request matches archived content.
312/// Configuration is read here so a running process observes config changes.
313pub(crate) fn proactive_context(query_context: &str) -> Option<String> {
314    let cfg = crate::core::config::Config::load();
315    if !cfg.proactive_expansion_effective() {
316        return None;
317    }
318
319    let mut tracker = global().lock().ok()?;
320    tracker.configure(
321        cfg.proactive_expansion_budget_tokens_effective(),
322        cfg.proactive_expansion_threshold_effective(),
323        cfg.proactive_expansion_max_age_secs_effective(),
324    );
325    let block = tracker.expand_if_relevant(query_context)?;
326    crate::core::context_overhead::record_proactive_injection(crate::core::tokens::count_tokens(
327        &block,
328    ));
329    Some(block)
330}
331
332/// Bounce-aware query path used by file reads. A path already pinned to full
333/// delivery must not receive additional proactive context.
334pub(crate) fn proactive_context_for_path(query_context: &str, path: &str) -> Option<String> {
335    if crate::core::bounce_tracker::global()
336        .lock()
337        .ok()
338        .is_some_and(|tracker| tracker.should_force_full(path))
339    {
340        return None;
341    }
342    proactive_context(query_context)
343}
344
345// --- BM25 Scoring ---
346
347fn bm25_score(query_terms: &[String], doc_keywords: &[String]) -> f64 {
348    if doc_keywords.is_empty() || query_terms.is_empty() {
349        return 0.0;
350    }
351
352    // Term frequency in document
353    let doc_tf: BTreeMap<&str, usize> = {
354        let mut tf = BTreeMap::new();
355        for kw in doc_keywords {
356            *tf.entry(kw.as_str()).or_insert(0) += 1;
357        }
358        tf
359    };
360
361    let doc_len = doc_keywords.len() as f64;
362    let avg_doc_len = MAX_KEYWORDS_PER_ENTRY as f64;
363    let k1 = 1.2;
364    let b = 0.75;
365
366    let mut score = 0.0;
367    for term in query_terms {
368        let tf = *doc_tf.get(term.as_str()).unwrap_or(&0) as f64;
369        if tf == 0.0 {
370            continue;
371        }
372        // Simplified BM25 (single document, no IDF corpus)
373        let numerator = tf * (k1 + 1.0);
374        let denominator = tf + k1 * (1.0 - b + b * (doc_len / avg_doc_len));
375        score += numerator / denominator;
376    }
377
378    // Normalize to [0, 1]
379    let max_possible = query_terms.len() as f64 * (k1 + 1.0) / (1.0 + k1 * (1.0 - b));
380    (score / max_possible).min(1.0)
381}
382
383// --- Keyword Extraction ---
384
385fn extract_keywords(content: &str) -> Vec<String> {
386    let mut term_freq: BTreeMap<String, usize> = BTreeMap::new();
387
388    for word in content.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') {
389        let lower = word.to_lowercase();
390        if lower.len() >= 3 && lower.len() <= 50 && !is_stopword(&lower) {
391            *term_freq.entry(lower).or_insert(0) += 1;
392        }
393    }
394
395    let mut terms: Vec<(String, usize)> = term_freq.into_iter().collect();
396    terms.sort_by_key(|b| std::cmp::Reverse(b.1));
397    terms.truncate(MAX_KEYWORDS_PER_ENTRY);
398    terms.into_iter().map(|(k, _)| k).collect()
399}
400
401fn extract_query_terms(context: &str) -> Vec<String> {
402    let mut terms = Vec::new();
403    for word in context.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') {
404        let lower = word.to_lowercase();
405        if lower.len() >= 3 && !is_stopword(&lower) {
406            terms.push(lower);
407        }
408    }
409    terms.sort();
410    terms.dedup();
411    terms
412}
413
414fn is_stopword(word: &str) -> bool {
415    const STOPWORDS: &[&str] = &[
416        "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
417        "our", "out", "has", "his", "how", "its", "let", "may", "new", "now", "old", "see", "way",
418        "who", "did", "get", "got", "him", "hit", "lot", "set", "try", "use", "from", "have",
419        "that", "this", "with", "will", "been", "each", "make", "like", "long", "look", "many",
420        "most", "over", "such", "take", "than", "them", "then", "very", "when", "come", "here",
421        "just", "made", "more", "also", "what", "into", "only", "some", "could", "would", "should",
422        "there", "their", "which", "about", "these", "other", "where", "after", "being", "those",
423        "still",
424    ];
425    STOPWORDS.contains(&word)
426}
427
428// --- CCR Integration ---
429
430fn load_from_ccr(handle: &str, _budget: usize) -> Option<String> {
431    let path = crate::proxy::ccr::resolve_tee(handle)?;
432    std::fs::read_to_string(path).ok()
433}
434
435fn truncate_to_budget(content: &str, max_tokens: usize) -> String {
436    let max_chars = max_tokens.saturating_mul(CHARS_PER_TOKEN);
437    if content.len() <= max_chars {
438        return content.to_string();
439    }
440    let mut end = max_chars;
441    while !content.is_char_boundary(end) && end > 0 {
442        end -= 1;
443    }
444    format!("{}…", &content[..end])
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450
451    #[test]
452    fn keyword_extraction_basic() {
453        let content = "The proxy forward function handles HTTP requests with authentication tokens";
454        let keywords = extract_keywords(content);
455        assert!(keywords.contains(&"proxy".to_string()));
456        assert!(keywords.contains(&"forward".to_string()));
457        assert!(keywords.contains(&"function".to_string()));
458        assert!(!keywords.contains(&"the".to_string())); // stopword
459    }
460
461    #[test]
462    fn bm25_scores_matching_docs_higher() {
463        let query = vec!["proxy".to_string(), "forward".to_string()];
464        let matching_doc = vec![
465            "proxy".to_string(),
466            "forward".to_string(),
467            "http".to_string(),
468        ];
469        let non_matching_doc = vec![
470            "database".to_string(),
471            "query".to_string(),
472            "insert".to_string(),
473        ];
474
475        let score_match = bm25_score(&query, &matching_doc);
476        let score_no_match = bm25_score(&query, &non_matching_doc);
477
478        assert!(score_match > score_no_match);
479        assert!(score_match > 0.0);
480        assert_eq!(score_no_match, 0.0);
481    }
482
483    #[test]
484    fn register_and_find() {
485        let mut tracker = RelevanceTracker::new();
486        tracker.threshold = 0.3; // lower for testing
487
488        tracker.register(
489            "html_abc123.log".to_string(),
490            "The proxy forward module handles upstream HTTP requests with authentication",
491            "ctx_shell",
492            500,
493            50,
494        );
495
496        let matches = tracker.find_matches("How does the proxy forward requests?");
497        assert!(!matches.is_empty());
498        assert_eq!(matches[0].handle, "html_abc123.log");
499    }
500
501    #[test]
502    fn respects_token_budget() {
503        let mut tracker = RelevanceTracker::with_config(100, 0.1);
504
505        for i in 0..10 {
506            tracker.register(
507                format!("entry_{i}.log"),
508                "proxy forward authentication tokens request handling",
509                "ctx_shell",
510                500, // 500 tokens each
511                50,
512            );
513        }
514
515        let matches = tracker.find_matches("proxy forward authentication");
516        let total_estimated: usize = matches.iter().map(|m| m.estimated_tokens).sum();
517        assert!(
518            total_estimated <= 100,
519            "Should respect budget of 100 tokens"
520        );
521    }
522
523    #[test]
524    fn evicts_old_entries() {
525        let mut tracker = RelevanceTracker::new();
526        for i in 0..150 {
527            tracker.register(
528                format!("entry_{i}.log"),
529                &format!("content for entry number {i}"),
530                "ctx_shell",
531                100,
532                10,
533            );
534        }
535        assert!(tracker.entries.len() <= MAX_ENTRIES);
536    }
537
538    #[test]
539    fn scoring_is_deterministic() {
540        let query = vec![
541            "proxy".to_string(),
542            "forward".to_string(),
543            "http".to_string(),
544        ];
545        let doc = vec![
546            "proxy".to_string(),
547            "forward".to_string(),
548            "request".to_string(),
549        ];
550
551        let s1 = bm25_score(&query, &doc);
552        let s2 = bm25_score(&query, &doc);
553        assert_eq!(s1, s2);
554    }
555
556    #[test]
557    fn empty_tracker_returns_no_matches() {
558        let tracker = RelevanceTracker::new();
559        assert!(tracker.find_matches("anything").is_empty());
560    }
561}
562
563#[cfg(test)]
564mod edge_tests {
565    use super::*;
566
567    #[test]
568    fn handles_empty_content_registration() {
569        let mut tracker = RelevanceTracker::new();
570        tracker.register("empty.log".into(), "", "ctx_shell", 0, 0);
571        assert!(tracker.find_matches("anything").is_empty());
572    }
573
574    #[test]
575    fn stopwords_are_excluded() {
576        let keywords = extract_keywords("the and for are but not this that with from");
577        assert!(keywords.is_empty());
578    }
579
580    #[test]
581    fn bm25_handles_empty_inputs() {
582        assert_eq!(bm25_score(&[], &["test".into()]), 0.0);
583        assert_eq!(bm25_score(&["test".into()], &[]), 0.0);
584        assert_eq!(bm25_score(&[], &[]), 0.0);
585    }
586
587    #[test]
588    fn default_impl_works() {
589        let tracker = RelevanceTracker::default();
590        assert!(tracker.find_matches("test").is_empty());
591    }
592}