1#![allow(clippy::cast_precision_loss)]
2use 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
28pub fn global() -> &'static Mutex<RelevanceTracker> {
30 &TRACKER
31}
32
33#[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#[derive(Debug, Clone)]
47pub struct ExpansionMatch {
48 pub handle: String,
49 pub score: f64,
50 pub estimated_tokens: usize,
51}
52
53pub 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 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 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 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 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 pub fn disable_handle(&mut self, handle: &str) {
177 self.disabled_handles.insert(handle.to_string());
178 }
179
180 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 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 #[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
292pub 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
311pub 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
332pub 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
344fn 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 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 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 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
382fn 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
427fn 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())); }
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; 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, 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}