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 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 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 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 fn proactive_context_for_path(query_context: &str, path: &str) -> Option<String> {
335    if crate::core::bounce_tracker::global()
336        .lock()
337        .is_ok_and(|tracker| tracker.should_force_full(path))
338    {
339        return None;
340    }
341    proactive_context(query_context)
342}
343
344// --- BM25 Scoring ---
345
346fn bm25_score(query_terms: &[String], doc_keywords: &[String]) -> f64 {
347    if doc_keywords.is_empty() || query_terms.is_empty() {
348        return 0.0;
349    }
350
351    // Term frequency in document
352    let doc_tf: BTreeMap<&str, usize> = {
353        let mut tf = BTreeMap::new();
354        for kw in doc_keywords {
355            *tf.entry(kw.as_str()).or_insert(0) += 1;
356        }
357        tf
358    };
359
360    let doc_len = doc_keywords.len() as f64;
361    let avg_doc_len = MAX_KEYWORDS_PER_ENTRY as f64;
362    let k1 = 1.2;
363    let b = 0.75;
364
365    let mut score = 0.0;
366    for term in query_terms {
367        let tf = *doc_tf.get(term.as_str()).unwrap_or(&0) as f64;
368        if tf == 0.0 {
369            continue;
370        }
371        // Simplified BM25 (single document, no IDF corpus)
372        let numerator = tf * (k1 + 1.0);
373        let denominator = tf + k1 * (1.0 - b + b * (doc_len / avg_doc_len));
374        score += numerator / denominator;
375    }
376
377    // Normalize to [0, 1]
378    let max_possible = query_terms.len() as f64 * (k1 + 1.0) / (1.0 + k1 * (1.0 - b));
379    (score / max_possible).min(1.0)
380}
381
382// --- Keyword Extraction ---
383
384fn extract_keywords(content: &str) -> Vec<String> {
385    let mut term_freq: BTreeMap<String, usize> = BTreeMap::new();
386
387    for word in content.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') {
388        let lower = word.to_lowercase();
389        if lower.len() >= 3 && lower.len() <= 50 && !is_stopword(&lower) {
390            *term_freq.entry(lower).or_insert(0) += 1;
391        }
392    }
393
394    let mut terms: Vec<(String, usize)> = term_freq.into_iter().collect();
395    terms.sort_by_key(|b| std::cmp::Reverse(b.1));
396    terms.truncate(MAX_KEYWORDS_PER_ENTRY);
397    terms.into_iter().map(|(k, _)| k).collect()
398}
399
400fn extract_query_terms(context: &str) -> Vec<String> {
401    let mut terms = Vec::new();
402    for word in context.split(|c: char| !c.is_alphanumeric() && c != '_' && c != '-') {
403        let lower = word.to_lowercase();
404        if lower.len() >= 3 && !is_stopword(&lower) {
405            terms.push(lower);
406        }
407    }
408    terms.sort();
409    terms.dedup();
410    terms
411}
412
413fn is_stopword(word: &str) -> bool {
414    const STOPWORDS: &[&str] = &[
415        "the", "and", "for", "are", "but", "not", "you", "all", "can", "had", "her", "was", "one",
416        "our", "out", "has", "his", "how", "its", "let", "may", "new", "now", "old", "see", "way",
417        "who", "did", "get", "got", "him", "hit", "lot", "set", "try", "use", "from", "have",
418        "that", "this", "with", "will", "been", "each", "make", "like", "long", "look", "many",
419        "most", "over", "such", "take", "than", "them", "then", "very", "when", "come", "here",
420        "just", "made", "more", "also", "what", "into", "only", "some", "could", "would", "should",
421        "there", "their", "which", "about", "these", "other", "where", "after", "being", "those",
422        "still",
423    ];
424    STOPWORDS.contains(&word)
425}
426
427// --- CCR Integration ---
428
429fn load_from_ccr(handle: &str, _budget: usize) -> Option<String> {
430    crate::core::ccr::read_tee(handle)
431}
432
433fn truncate_to_budget(content: &str, max_tokens: usize) -> String {
434    let max_chars = max_tokens.saturating_mul(CHARS_PER_TOKEN);
435    if content.len() <= max_chars {
436        return content.to_string();
437    }
438    let mut end = max_chars;
439    while !content.is_char_boundary(end) && end > 0 {
440        end -= 1;
441    }
442    format!("{}…", &content[..end])
443}
444
445#[cfg(test)]
446pub mod tests {
447    use super::*;
448
449    #[test]
450    fn keyword_extraction_basic() {
451        let content = "The proxy forward function handles HTTP requests with authentication tokens";
452        let keywords = extract_keywords(content);
453        assert!(keywords.contains(&"proxy".to_string()));
454        assert!(keywords.contains(&"forward".to_string()));
455        assert!(keywords.contains(&"function".to_string()));
456        assert!(!keywords.contains(&"the".to_string())); // stopword
457    }
458
459    #[test]
460    fn bm25_scores_matching_docs_higher() {
461        let query = vec!["proxy".to_string(), "forward".to_string()];
462        let matching_doc = vec![
463            "proxy".to_string(),
464            "forward".to_string(),
465            "http".to_string(),
466        ];
467        let non_matching_doc = vec![
468            "database".to_string(),
469            "query".to_string(),
470            "insert".to_string(),
471        ];
472
473        let score_match = bm25_score(&query, &matching_doc);
474        let score_no_match = bm25_score(&query, &non_matching_doc);
475
476        assert!(score_match > score_no_match);
477        assert!(score_match > 0.0);
478        assert_eq!(score_no_match, 0.0);
479    }
480
481    #[test]
482    fn register_and_find() {
483        let mut tracker = RelevanceTracker::new();
484        tracker.threshold = 0.3; // lower for testing
485
486        tracker.register(
487            "html_abc123.log".to_string(),
488            "The proxy forward module handles upstream HTTP requests with authentication",
489            "ctx_shell",
490            500,
491            50,
492        );
493
494        let matches = tracker.find_matches("How does the proxy forward requests?");
495        assert!(!matches.is_empty());
496        assert_eq!(matches[0].handle, "html_abc123.log");
497    }
498
499    #[test]
500    fn respects_token_budget() {
501        let mut tracker = RelevanceTracker::with_config(100, 0.1);
502
503        for i in 0..10 {
504            tracker.register(
505                format!("entry_{i}.log"),
506                "proxy forward authentication tokens request handling",
507                "ctx_shell",
508                500, // 500 tokens each
509                50,
510            );
511        }
512
513        let matches = tracker.find_matches("proxy forward authentication");
514        let total_estimated: usize = matches.iter().map(|m| m.estimated_tokens).sum();
515        assert!(
516            total_estimated <= 100,
517            "Should respect budget of 100 tokens"
518        );
519    }
520
521    #[test]
522    fn evicts_old_entries() {
523        let mut tracker = RelevanceTracker::new();
524        for i in 0..150 {
525            tracker.register(
526                format!("entry_{i}.log"),
527                &format!("content for entry number {i}"),
528                "ctx_shell",
529                100,
530                10,
531            );
532        }
533        assert!(tracker.entries.len() <= MAX_ENTRIES);
534    }
535
536    #[test]
537    fn scoring_is_deterministic() {
538        let query = vec![
539            "proxy".to_string(),
540            "forward".to_string(),
541            "http".to_string(),
542        ];
543        let doc = vec![
544            "proxy".to_string(),
545            "forward".to_string(),
546            "request".to_string(),
547        ];
548
549        let s1 = bm25_score(&query, &doc);
550        let s2 = bm25_score(&query, &doc);
551        assert_eq!(s1, s2);
552    }
553
554    #[test]
555    fn empty_tracker_returns_no_matches() {
556        let tracker = RelevanceTracker::new();
557        assert!(tracker.find_matches("anything").is_empty());
558    }
559}
560
561#[cfg(test)]
562pub mod edge_tests {
563    use super::*;
564
565    #[test]
566    fn handles_empty_content_registration() {
567        let mut tracker = RelevanceTracker::new();
568        tracker.register("empty.log".into(), "", "ctx_shell", 0, 0);
569        assert!(tracker.find_matches("anything").is_empty());
570    }
571
572    #[test]
573    fn stopwords_are_excluded() {
574        let keywords = extract_keywords("the and for are but not this that with from");
575        assert!(keywords.is_empty(), "{keywords:?}");
576    }
577
578    #[test]
579    fn bm25_handles_empty_inputs() {
580        assert_eq!(bm25_score(&[], &["test".into()]), 0.0);
581        assert_eq!(bm25_score(&["test".into()], &[]), 0.0);
582        assert_eq!(bm25_score(&[], &[]), 0.0);
583    }
584
585    #[test]
586    fn default_impl_works() {
587        let tracker = RelevanceTracker::default();
588        assert!(tracker.find_matches("test").is_empty());
589    }
590}