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(crate) 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(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
311pub(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
332pub(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
345fn 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 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 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 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
383fn 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
428fn 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())); }
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; 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, 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}