1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use super::config::LoopDetectionConfig;
5
6const SEARCH_TOOLS: &[&str] = &["ctx_search", "ctx_semantic_search"];
7
8const SEARCH_SHELL_PREFIXES: &[&str] = &["grep ", "rg ", "find ", "fd ", "ag ", "ack "];
9
10const CORRECTION_WINDOW: Duration = Duration::from_mins(2);
11const MODE_BOUNCE_WINDOW: Duration = Duration::from_secs(30);
12const SHELL_RERUN_WINDOW: Duration = Duration::from_mins(1);
13const COLD_START_CALLS: u32 = 3;
14
15#[derive(Debug, Clone, PartialEq)]
17pub enum CorrectionKind {
18 FreshReRead,
19 ShellReRun,
20 ModeBounce,
21}
22
23#[derive(Debug, Clone)]
25pub struct LoopDetector {
26 call_history: HashMap<String, Vec<Instant>>,
27 duplicate_counts: HashMap<String, u32>,
28 tool_total_counts: HashMap<String, u32>,
29 tool_total_limits: HashMap<String, u32>,
30 search_group_history: Vec<Instant>,
31 recent_search_patterns: Vec<String>,
32 normal_threshold: u32,
33 reduced_threshold: u32,
34 blocked_threshold: u32,
35 window: Duration,
36 search_group_limit: u32,
37 correction_signals: Vec<(Instant, CorrectionKind)>,
39 recent_reads: HashMap<String, (Instant, String)>,
40 recent_commands: HashMap<String, Instant>,
41 total_calls: u32,
42 retrieve_signals: Vec<Instant>,
46}
47
48#[derive(Debug, Clone, PartialEq)]
50pub enum ThrottleLevel {
51 Normal,
52 Reduced,
53 Blocked,
54}
55
56#[derive(Debug, Clone)]
58pub struct ThrottleResult {
59 pub level: ThrottleLevel,
60 pub call_count: u32,
61 pub message: Option<String>,
62}
63
64impl Default for ThrottleResult {
65 fn default() -> Self {
66 Self {
67 level: ThrottleLevel::Normal,
68 call_count: 0,
69 message: None,
70 }
71 }
72}
73
74impl Default for LoopDetector {
75 fn default() -> Self {
76 Self::new()
77 }
78}
79
80impl LoopDetector {
81 pub fn new() -> Self {
83 Self::with_config(&LoopDetectionConfig::default())
84 }
85
86 pub fn with_config(cfg: &LoopDetectionConfig) -> Self {
89 Self {
90 call_history: HashMap::new(),
91 duplicate_counts: HashMap::new(),
92 tool_total_counts: HashMap::new(),
93 tool_total_limits: cfg.tool_total_limits.clone(),
94 search_group_history: Vec::new(),
95 recent_search_patterns: Vec::new(),
96 normal_threshold: cfg.normal_threshold.max(1),
97 reduced_threshold: cfg.reduced_threshold.max(2),
98 blocked_threshold: cfg.blocked_threshold,
99 window: Duration::from_secs(cfg.window_secs),
100 search_group_limit: if cfg.blocked_threshold == 0 {
101 u32::MAX
102 } else {
103 cfg.search_group_limit.max(3)
104 },
105 correction_signals: Vec::new(),
106 recent_reads: HashMap::new(),
107 recent_commands: HashMap::new(),
108 total_calls: 0,
109 retrieve_signals: Vec::new(),
110 }
111 }
112
113 pub fn record_call(&mut self, tool: &str, args_fingerprint: &str) -> ThrottleResult {
115 let now = Instant::now();
116 self.prune_window(now);
117
118 let total = self.tool_total_counts.entry(tool.to_string()).or_insert(0);
120 *total += 1;
121 let total_count = *total;
122
123 if let Some(&limit) = self.tool_total_limits.get(tool)
124 && total_count > limit
125 {
126 let msg = if crate::core::protocol::meta_visible() {
127 Some(format!(
128 "Warning: {tool} called {total_count}x total (limit: {limit}). \
129 Consider ctx_compress or narrowing scope."
130 ))
131 } else {
132 None
133 };
134 return ThrottleResult {
135 level: ThrottleLevel::Reduced,
136 call_count: total_count,
137 message: msg,
138 };
139 }
140
141 let key = format!("{tool}:{args_fingerprint}");
142 let entries = self.call_history.entry(key.clone()).or_default();
143 entries.push(now);
144 let count = entries.len() as u32;
145 *self.duplicate_counts.entry(key).or_default() = count;
146
147 if self.blocked_threshold > 0 && count > self.blocked_threshold {
148 return ThrottleResult {
149 level: ThrottleLevel::Blocked,
150 call_count: count,
151 message: Some(self.block_message(tool, count)),
152 };
153 }
154 if count > self.reduced_threshold {
155 if !crate::core::protocol::meta_visible() {
156 return ThrottleResult {
157 level: ThrottleLevel::Reduced,
158 call_count: count,
159 message: None,
160 };
161 }
162 return ThrottleResult {
163 level: ThrottleLevel::Reduced,
164 call_count: count,
165 message: Some(format!(
166 "Warning: {tool} called {count}x with same args. \
167 Results reduced. Try a different approach or narrow your scope."
168 )),
169 };
170 }
171 if count > self.normal_threshold {
172 if !crate::core::protocol::meta_visible() {
173 return ThrottleResult {
174 level: ThrottleLevel::Reduced,
175 call_count: count,
176 message: None,
177 };
178 }
179 return ThrottleResult {
180 level: ThrottleLevel::Reduced,
181 call_count: count,
182 message: Some(format!(
183 "Note: {tool} called {count}x with similar args. Consider narrowing scope."
184 )),
185 };
186 }
187 ThrottleResult {
188 level: ThrottleLevel::Normal,
189 call_count: count,
190 message: None,
191 }
192 }
193
194 pub fn record_error_outcome(&mut self, tool: &str, args_fingerprint: &str) {
197 let key = format!("{tool}:{args_fingerprint}");
198 if let Some(entries) = self.call_history.get_mut(&key) {
199 entries.pop();
200 let count = entries.len() as u32;
201 self.duplicate_counts.insert(key, count);
202 }
203 }
204
205 pub fn record_search(
208 &mut self,
209 tool: &str,
210 args_fingerprint: &str,
211 search_pattern: Option<&str>,
212 ) -> ThrottleResult {
213 let now = Instant::now();
214
215 self.search_group_history.push(now);
216 let search_count = self.search_group_history.len() as u32;
217
218 let similar_count = if let Some(pat) = search_pattern {
219 let sc = self.count_similar_patterns(pat);
220 if !pat.is_empty() {
221 self.recent_search_patterns.push(pat.to_string());
222 if self.recent_search_patterns.len() > 15 {
223 self.recent_search_patterns.remove(0);
224 }
225 }
226 sc
227 } else {
228 0
229 };
230
231 if self.blocked_threshold > 0 && similar_count >= self.blocked_threshold {
233 return ThrottleResult {
234 level: ThrottleLevel::Blocked,
235 call_count: similar_count,
236 message: Some(self.search_block_message(similar_count)),
237 };
238 }
239
240 if self.blocked_threshold > 0 && search_count > self.search_group_limit {
242 return ThrottleResult {
243 level: ThrottleLevel::Blocked,
244 call_count: search_count,
245 message: Some(self.search_group_block_message(search_count)),
246 };
247 }
248
249 if similar_count >= self.reduced_threshold {
250 if !crate::core::protocol::meta_visible() {
251 return ThrottleResult {
252 level: ThrottleLevel::Reduced,
253 call_count: similar_count,
254 message: None,
255 };
256 }
257 return ThrottleResult {
258 level: ThrottleLevel::Reduced,
259 call_count: similar_count,
260 message: Some(format!(
261 "Warning: You've searched for similar patterns {similar_count}x. \
262 Narrow your search with the 'path' parameter or try ctx_tree first."
263 )),
264 };
265 }
266
267 if search_count > self.search_group_limit.saturating_sub(3) {
268 let per_fp = self.record_call(tool, args_fingerprint);
269 if per_fp.level != ThrottleLevel::Normal {
270 return per_fp;
271 }
272 if !crate::core::protocol::meta_visible() {
273 return ThrottleResult {
274 level: ThrottleLevel::Reduced,
275 call_count: search_count,
276 message: None,
277 };
278 }
279 return ThrottleResult {
280 level: ThrottleLevel::Reduced,
281 call_count: search_count,
282 message: Some(format!(
283 "Note: {search_count} search calls in the last {}s. \
284 Use ctx_tree to orient first, then scope searches with 'path'.",
285 self.window.as_secs()
286 )),
287 };
288 }
289
290 self.record_call(tool, args_fingerprint)
291 }
292
293 pub fn is_search_tool(tool: &str) -> bool {
295 SEARCH_TOOLS.contains(&tool)
296 }
297
298 pub fn is_search_shell_command(command: &str) -> bool {
300 let cmd = command.trim_start();
301 SEARCH_SHELL_PREFIXES.iter().any(|p| cmd.starts_with(p))
302 }
303
304 pub fn fingerprint(args: &serde_json::Value) -> String {
306 use std::collections::hash_map::DefaultHasher;
307 use std::hash::{Hash, Hasher};
308
309 let canonical = canonical_json(args);
310 let mut hasher = DefaultHasher::new();
311 canonical.hash(&mut hasher);
312 format!("{:016x}", hasher.finish())
313 }
314
315 pub fn stats(&self) -> Vec<(String, u32)> {
317 let mut entries: Vec<(String, u32)> = self
318 .duplicate_counts
319 .iter()
320 .filter(|&(_, &count)| count > 1)
321 .map(|(k, &v)| (k.clone(), v))
322 .collect();
323 entries.sort_by_key(|x| std::cmp::Reverse(x.1));
324 entries
325 }
326
327 pub fn record_read_for_correction(&mut self, path: &str, mode: &str, fresh: bool) {
331 self.total_calls += 1;
332 let now = Instant::now();
333
334 if self.total_calls <= COLD_START_CALLS {
335 self.recent_reads
336 .insert(path.to_string(), (now, mode.to_string()));
337 return;
338 }
339
340 if fresh
341 && let Some((prev_time, _)) = self.recent_reads.get(path)
342 && now.duration_since(*prev_time) < CORRECTION_WINDOW
343 {
344 self.correction_signals
345 .push((now, CorrectionKind::FreshReRead));
346 }
347
348 if mode == "full"
349 && let Some((prev_time, prev_mode)) = self.recent_reads.get(path)
350 {
351 let is_bounce = (prev_mode == "map" || prev_mode == "signatures")
352 && now.duration_since(*prev_time) < MODE_BOUNCE_WINDOW;
353 if is_bounce {
354 self.correction_signals
355 .push((now, CorrectionKind::ModeBounce));
356 }
357 }
358
359 self.recent_reads
360 .insert(path.to_string(), (now, mode.to_string()));
361 }
362
363 pub fn record_shell_for_correction(&mut self, command: &str) {
365 self.total_calls += 1;
366 let now = Instant::now();
367
368 if self.total_calls <= COLD_START_CALLS {
369 self.recent_commands.insert(command.to_string(), now);
370 return;
371 }
372
373 let key = normalize_shell_command(command);
374 if let Some(prev_time) = self.recent_commands.get(&key)
375 && now.duration_since(*prev_time) < SHELL_RERUN_WINDOW
376 {
377 self.correction_signals
378 .push((now, CorrectionKind::ShellReRun));
379 }
380 self.recent_commands.insert(key, now);
381 }
382
383 pub fn correction_count(&self) -> u32 {
385 let now = Instant::now();
386 self.correction_signals
387 .iter()
388 .filter(|(t, _)| now.duration_since(*t) < CORRECTION_WINDOW)
389 .count() as u32
390 }
391
392 pub fn record_retrieve(&mut self) {
396 self.retrieve_signals.push(Instant::now());
397 }
398
399 pub fn retrieve_count(&self) -> u32 {
401 let now = Instant::now();
402 self.retrieve_signals
403 .iter()
404 .filter(|t| now.duration_since(**t) < CORRECTION_WINDOW)
405 .count() as u32
406 }
407
408 pub fn correction_rate(&self) -> f64 {
410 let count = self.correction_count();
411 if count == 0 {
412 return 0.0;
413 }
414 let window_mins = CORRECTION_WINDOW.as_secs_f64() / 60.0;
415 f64::from(count) / window_mins
416 }
417
418 pub fn prune_corrections(&mut self) {
420 let now = Instant::now();
421 self.correction_signals
422 .retain(|(t, _)| now.duration_since(*t) < CORRECTION_WINDOW);
423 self.recent_reads
424 .retain(|_, (t, _)| now.duration_since(*t) < CORRECTION_WINDOW);
425 self.recent_commands
426 .retain(|_, t| now.duration_since(*t) < CORRECTION_WINDOW);
427 self.retrieve_signals
428 .retain(|t| now.duration_since(*t) < CORRECTION_WINDOW);
429 }
430
431 pub fn reset(&mut self) {
433 self.call_history.clear();
434 self.duplicate_counts.clear();
435 self.search_group_history.clear();
436 self.recent_search_patterns.clear();
437 self.correction_signals.clear();
438 self.recent_reads.clear();
439 self.recent_commands.clear();
440 self.total_calls = 0;
441 self.retrieve_signals.clear();
442 }
443
444 fn prune_window(&mut self, now: Instant) {
445 for entries in self.call_history.values_mut() {
446 entries.retain(|t| now.duration_since(*t) < self.window);
447 }
448 self.call_history.retain(|_, v| !v.is_empty());
454 let live = &self.call_history;
455 self.duplicate_counts.retain(|k, _| live.contains_key(k));
456 self.search_group_history
457 .retain(|t| now.duration_since(*t) < self.window);
458 }
459
460 fn count_similar_patterns(&self, new_pattern: &str) -> u32 {
461 let new_lower = new_pattern.to_lowercase();
462 let new_root = extract_alpha_root(&new_lower);
463
464 let mut count = 0u32;
465 for existing in &self.recent_search_patterns {
466 let existing_lower = existing.to_lowercase();
467 if patterns_are_similar(&new_lower, &existing_lower) {
468 count += 1;
469 } else if new_root.len() >= 4 {
470 let existing_root = extract_alpha_root(&existing_lower);
471 if existing_root.len() >= 4
472 && (new_root.starts_with(&existing_root)
473 || existing_root.starts_with(&new_root))
474 {
475 count += 1;
476 }
477 }
478 }
479 count
480 }
481
482 fn block_message(&self, tool: &str, count: u32) -> String {
483 if Self::is_search_tool(tool) {
484 self.search_block_message(count)
485 } else {
486 format!(
487 "LOOP DETECTED: {tool} called {count}x with same/similar args. \
488 Call blocked. Change your approach — the current strategy is not working."
489 )
490 }
491 }
492
493 #[allow(clippy::unused_self)]
494 fn search_block_message(&self, count: u32) -> String {
495 format!(
496 "LOOP DETECTED: You've searched {count}x with similar patterns. STOP searching and change strategy. \
497 1) Use ctx_tree to understand the project structure first. \
498 2) Narrow your search with the 'path' parameter to a specific directory. \
499 3) Use ctx_read with mode='map' to understand a file before searching more."
500 )
501 }
502
503 fn search_group_block_message(&self, count: u32) -> String {
504 format!(
505 "LOOP DETECTED: {count} search calls in {}s — too many. STOP and rethink. \
506 1) Use ctx_tree to map the project structure. \
507 2) Pick ONE specific directory and search there with the 'path' parameter. \
508 3) Read files with ctx_read mode='map' instead of searching blindly.",
509 self.window.as_secs()
510 )
511 }
512}
513
514fn normalize_shell_command(cmd: &str) -> String {
515 cmd.split_whitespace()
516 .take(5)
517 .collect::<Vec<_>>()
518 .join(" ")
519 .to_lowercase()
520}
521
522fn extract_alpha_root(pattern: &str) -> String {
523 pattern
524 .chars()
525 .take_while(|c| c.is_alphanumeric())
526 .collect()
527}
528
529fn patterns_are_similar(a: &str, b: &str) -> bool {
530 if a == b {
531 return true;
532 }
533 if a.contains(b) || b.contains(a) {
534 return true;
535 }
536 let a_alpha: String = a.chars().filter(|c| c.is_alphanumeric()).collect();
537 let b_alpha: String = b.chars().filter(|c| c.is_alphanumeric()).collect();
538 if a_alpha.len() >= 3
539 && b_alpha.len() >= 3
540 && (a_alpha.contains(&b_alpha) || b_alpha.contains(&a_alpha))
541 {
542 return true;
543 }
544 false
545}
546
547fn canonical_json(value: &serde_json::Value) -> String {
548 match value {
549 serde_json::Value::Object(map) => {
550 let mut keys: Vec<&String> = map.keys().collect();
551 keys.sort();
552 let entries: Vec<String> = keys
553 .iter()
554 .map(|k| format!("{}:{}", k, canonical_json(&map[*k])))
555 .collect();
556 format!("{{{}}}", entries.join(","))
557 }
558 serde_json::Value::Array(arr) => {
559 let entries: Vec<String> = arr.iter().map(canonical_json).collect();
560 format!("[{}]", entries.join(","))
561 }
562 _ => value.to_string(),
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn test_config(normal: u32, reduced: u32, blocked: u32) -> LoopDetectionConfig {
571 LoopDetectionConfig {
572 normal_threshold: normal,
573 reduced_threshold: reduced,
574 blocked_threshold: blocked,
575 window_secs: 300,
576 search_group_limit: 10,
577 tool_total_limits: std::collections::HashMap::new(),
578 }
579 }
580
581 #[test]
582 fn normal_calls_pass_through() {
583 let mut detector = LoopDetector::new();
584 let r1 = detector.record_call("ctx_read", "abc123");
585 assert_eq!(r1.level, ThrottleLevel::Normal);
586 assert_eq!(r1.call_count, 1);
587 assert!(r1.message.is_none());
588 }
589
590 #[test]
591 fn repeated_calls_trigger_reduced() {
592 let _lock = crate::core::data_dir::test_env_lock();
593 crate::test_env::set_var("LEAN_CTX_META", "1");
594 let cfg = LoopDetectionConfig::default();
595 let mut detector = LoopDetector::with_config(&cfg);
596 for _ in 0..cfg.normal_threshold {
597 detector.record_call("ctx_read", "same_fp");
598 }
599 let result = detector.record_call("ctx_read", "same_fp");
600 assert_eq!(result.level, ThrottleLevel::Reduced);
601 assert!(result.message.is_some());
602 crate::test_env::remove_var("LEAN_CTX_META");
603 }
604
605 #[test]
606 fn excessive_calls_get_blocked_when_enabled() {
607 let cfg = LoopDetectionConfig {
609 blocked_threshold: 6,
610 ..Default::default()
611 };
612 let mut detector = LoopDetector::with_config(&cfg);
613 for _ in 0..cfg.blocked_threshold {
614 detector.record_call("ctx_shell", "same_fp");
615 }
616 let result = detector.record_call("ctx_shell", "same_fp");
617 assert_eq!(result.level, ThrottleLevel::Blocked);
618 assert!(result.message.unwrap().contains("LOOP DETECTED"));
619 }
620
621 #[test]
622 fn blocking_disabled_by_default() {
623 let cfg = LoopDetectionConfig::default();
625 assert_eq!(cfg.blocked_threshold, 0);
626 let mut detector = LoopDetector::with_config(&cfg);
627 for _ in 0..100 {
629 detector.record_call("ctx_shell", "same_fp");
630 }
631 let result = detector.record_call("ctx_shell", "same_fp");
632 assert_ne!(result.level, ThrottleLevel::Blocked);
634 }
635
636 #[test]
637 fn different_args_tracked_separately() {
638 let mut detector = LoopDetector::new();
639 for _ in 0..10 {
640 detector.record_call("ctx_read", "fp_a");
641 }
642 let result = detector.record_call("ctx_read", "fp_b");
643 assert_eq!(result.level, ThrottleLevel::Normal);
644 assert_eq!(result.call_count, 1);
645 }
646
647 #[test]
648 fn fingerprint_deterministic() {
649 let args = serde_json::json!({"path": "test.rs", "mode": "full"});
650 let fp1 = LoopDetector::fingerprint(&args);
651 let fp2 = LoopDetector::fingerprint(&args);
652 assert_eq!(fp1, fp2);
653 }
654
655 #[test]
656 fn fingerprint_order_independent() {
657 let a = serde_json::json!({"mode": "full", "path": "test.rs"});
658 let b = serde_json::json!({"path": "test.rs", "mode": "full"});
659 assert_eq!(LoopDetector::fingerprint(&a), LoopDetector::fingerprint(&b));
660 }
661
662 #[test]
663 fn stats_shows_duplicates() {
664 let mut detector = LoopDetector::new();
665 for _ in 0..5 {
666 detector.record_call("ctx_read", "fp_a");
667 }
668 detector.record_call("ctx_shell", "fp_b");
669 let stats = detector.stats();
670 assert_eq!(stats.len(), 1);
671 assert_eq!(stats[0].1, 5);
672 }
673
674 #[test]
675 fn reset_clears_state() {
676 let mut detector = LoopDetector::new();
677 for _ in 0..5 {
678 detector.record_call("ctx_read", "fp_a");
679 }
680 detector.reset();
681 let result = detector.record_call("ctx_read", "fp_a");
682 assert_eq!(result.call_count, 1);
683 }
684
685 #[test]
686 fn custom_thresholds_from_config() {
687 let cfg = test_config(1, 2, 3);
688 let mut detector = LoopDetector::with_config(&cfg);
689 detector.record_call("ctx_read", "fp");
690 let r = detector.record_call("ctx_read", "fp");
691 assert_eq!(r.level, ThrottleLevel::Reduced);
692 detector.record_call("ctx_read", "fp");
693 let r = detector.record_call("ctx_read", "fp");
694 assert_eq!(r.level, ThrottleLevel::Blocked);
695 }
696
697 #[test]
698 fn similar_patterns_detected() {
699 assert!(patterns_are_similar("compress", "compress"));
700 assert!(patterns_are_similar("compress", "compression"));
701 assert!(patterns_are_similar("compress.*data", "compress"));
702 assert!(!patterns_are_similar("foo", "bar"));
703 assert!(!patterns_are_similar("ab", "cd"));
704 }
705
706 #[test]
707 fn search_group_tracking_when_blocking_enabled() {
708 let cfg = LoopDetectionConfig {
710 search_group_limit: 5,
711 blocked_threshold: 6, ..Default::default()
713 };
714 let mut detector = LoopDetector::with_config(&cfg);
715 for i in 0..5 {
716 let fp = format!("fp_{i}");
717 let r = detector.record_search("ctx_search", &fp, Some(&format!("pattern_{i}")));
718 assert_ne!(r.level, ThrottleLevel::Blocked, "call {i} should not block");
719 }
720 let r = detector.record_search("ctx_search", "fp_5", Some("pattern_5"));
721 assert_eq!(r.level, ThrottleLevel::Blocked);
722 assert!(r.message.unwrap().contains("search calls"));
723 }
724
725 #[test]
726 fn similar_search_patterns_trigger_block_when_enabled() {
727 let cfg = LoopDetectionConfig {
729 blocked_threshold: 6,
730 ..Default::default()
731 };
732 let mut detector = LoopDetector::with_config(&cfg);
733 let variants = [
734 "compress",
735 "compression",
736 "compress.*data",
737 "compress_output",
738 "compressor",
739 "compress_result",
740 "compress_file",
741 ];
742 for (i, pat) in variants
743 .iter()
744 .enumerate()
745 .take(cfg.blocked_threshold as usize)
746 {
747 detector.record_search("ctx_search", &format!("fp_{i}"), Some(pat));
748 }
749 let r = detector.record_search("ctx_search", "fp_new", Some("compress_all"));
750 assert_eq!(r.level, ThrottleLevel::Blocked);
751 }
752
753 #[test]
754 fn is_search_tool_detection() {
755 assert!(LoopDetector::is_search_tool("ctx_search"));
756 assert!(LoopDetector::is_search_tool("ctx_semantic_search"));
757 assert!(!LoopDetector::is_search_tool("ctx_read"));
758 assert!(!LoopDetector::is_search_tool("ctx_shell"));
759 }
760
761 #[test]
762 fn is_search_shell_command_detection() {
763 assert!(LoopDetector::is_search_shell_command("grep -r foo ."));
764 assert!(LoopDetector::is_search_shell_command("rg pattern src/"));
765 assert!(LoopDetector::is_search_shell_command("find . -name '*.rs'"));
766 assert!(!LoopDetector::is_search_shell_command("cargo build"));
767 assert!(!LoopDetector::is_search_shell_command("git status"));
768 }
769
770 #[test]
771 fn correction_fresh_reread_detected() {
772 let mut detector = LoopDetector::new();
773 detector.record_read_for_correction("src/main.rs", "full", false);
775 detector.record_read_for_correction("src/lib.rs", "full", false);
776 detector.record_read_for_correction("src/util.rs", "full", false);
777 detector.record_read_for_correction("src/main.rs", "full", false);
779 assert_eq!(detector.correction_count(), 0);
780 detector.record_read_for_correction("src/main.rs", "full", true);
782 assert_eq!(detector.correction_count(), 1);
783 }
784
785 #[test]
786 fn correction_mode_bounce_detected() {
787 let mut detector = LoopDetector::new();
788 for i in 0..COLD_START_CALLS {
790 detector.record_read_for_correction(&format!("f{i}.rs"), "full", false);
791 }
792 detector.record_read_for_correction("src/cache.rs", "map", false);
794 assert_eq!(detector.correction_count(), 0);
795 detector.record_read_for_correction("src/cache.rs", "full", false);
797 assert_eq!(detector.correction_count(), 1);
798 }
799
800 #[test]
801 fn retrieve_count_tracks_signals_and_resets() {
802 let mut detector = LoopDetector::new();
805 assert_eq!(detector.retrieve_count(), 0);
806 detector.record_retrieve();
807 detector.record_retrieve();
808 detector.record_retrieve();
809 assert_eq!(detector.retrieve_count(), 3, "three re-fetches counted");
810 assert_eq!(detector.correction_count(), 0);
812 detector.reset();
813 assert_eq!(
814 detector.retrieve_count(),
815 0,
816 "reset clears retrieve signals"
817 );
818 }
819
820 #[test]
821 fn correction_shell_rerun_detected() {
822 let mut detector = LoopDetector::new();
823 for i in 0..COLD_START_CALLS {
825 detector.record_shell_for_correction(&format!("echo {i}"));
826 }
827 detector.record_shell_for_correction("cargo test --lib");
829 assert_eq!(detector.correction_count(), 0);
830 detector.record_shell_for_correction("cargo test --lib");
832 assert_eq!(detector.correction_count(), 1);
833 }
834
835 #[test]
836 fn correction_rate_calculation() {
837 let mut detector = LoopDetector::new();
838 for i in 0..COLD_START_CALLS {
839 detector.record_shell_for_correction(&format!("init{i}"));
840 }
841 detector.record_shell_for_correction("cargo check");
842 detector.record_shell_for_correction("cargo check");
843 detector.record_shell_for_correction("cargo check");
844 assert_eq!(detector.correction_count(), 2);
846 assert!(detector.correction_rate() > 0.0);
847 }
848
849 #[test]
850 fn correction_cold_start_ignored() {
851 let mut detector = LoopDetector::new();
852 detector.record_shell_for_correction("cargo check");
854 detector.record_shell_for_correction("cargo check");
855 detector.record_shell_for_correction("cargo check");
856 assert_eq!(detector.correction_count(), 0);
857 }
858
859 #[test]
860 fn search_block_message_has_guidance_when_blocking_enabled() {
861 let cfg = LoopDetectionConfig {
863 blocked_threshold: 6,
864 search_group_limit: 8,
865 ..Default::default()
866 };
867 let mut detector = LoopDetector::with_config(&cfg);
868 for i in 0..10 {
869 detector.record_search("ctx_search", &format!("fp_{i}"), Some("compress"));
870 }
871 let r = detector.record_search("ctx_search", "fp_new", Some("compress"));
872 assert_eq!(r.level, ThrottleLevel::Blocked);
873 let msg = r.message.unwrap();
874 assert!(msg.contains("ctx_tree"));
875 assert!(msg.contains("path"));
876 assert!(msg.contains("ctx_read"));
877 }
878
879 #[test]
880 fn error_outcome_undoes_pre_dispatch_count() {
881 let cfg = test_config(2, 4, 0);
882 let mut detector = LoopDetector::with_config(&cfg);
883
884 detector.record_call("ctx_read", "fp1");
885 detector.record_call("ctx_read", "fp1");
886 detector.record_error_outcome("ctx_read", "fp1");
887
888 let r = detector.record_call("ctx_read", "fp1");
889 assert_eq!(r.call_count, 2, "error should have undone one count");
890 assert_eq!(r.level, ThrottleLevel::Normal);
891 }
892
893 #[test]
894 fn repeated_errors_dont_trigger_reduced() {
895 let cfg = test_config(2, 4, 0);
896 let mut detector = LoopDetector::with_config(&cfg);
897
898 for _ in 0..5 {
899 detector.record_call("ctx_read", "fp1");
900 detector.record_error_outcome("ctx_read", "fp1");
901 }
902
903 let r = detector.record_call("ctx_read", "fp1");
904 assert_eq!(
905 r.level,
906 ThrottleLevel::Normal,
907 "5 failed retries should not throttle"
908 );
909 }
910
911 #[test]
912 fn mixed_success_and_error_correct_count() {
913 let cfg = test_config(2, 4, 0);
914 let mut detector = LoopDetector::with_config(&cfg);
915
916 detector.record_call("ctx_read", "fp1");
917 detector.record_error_outcome("ctx_read", "fp1");
918 detector.record_call("ctx_read", "fp1");
919 detector.record_error_outcome("ctx_read", "fp1");
920 detector.record_call("ctx_read", "fp1");
921 assert_eq!(detector.record_call("ctx_read", "fp1").call_count, 2);
923 }
924
925 #[test]
926 fn error_outcome_on_nonexistent_key_is_noop() {
927 let mut detector = LoopDetector::new();
928 detector.record_error_outcome("ctx_read", "never_called");
929 let r = detector.record_call("ctx_read", "never_called");
930 assert_eq!(r.call_count, 1);
931 }
932
933 #[test]
934 fn error_outcome_doesnt_go_negative() {
935 let mut detector = LoopDetector::new();
936 detector.record_call("ctx_read", "fp1");
937 detector.record_error_outcome("ctx_read", "fp1");
938 detector.record_error_outcome("ctx_read", "fp1");
939 let r = detector.record_call("ctx_read", "fp1");
940 assert_eq!(r.call_count, 1, "count should never go below 0");
941 }
942
943 #[test]
944 fn error_in_tool_a_doesnt_affect_tool_b() {
945 let cfg = test_config(2, 4, 0);
946 let mut detector = LoopDetector::with_config(&cfg);
947
948 for _ in 0..5 {
949 detector.record_call("ctx_read", "fp1");
950 detector.record_error_outcome("ctx_read", "fp1");
951 }
952
953 let r = detector.record_call("ctx_shell", "fp_shell");
954 assert_eq!(r.call_count, 1);
955 assert_eq!(r.level, ThrottleLevel::Normal);
956 }
957
958 #[test]
959 fn different_fingerprints_independent_after_errors() {
960 let cfg = test_config(2, 4, 0);
961 let mut detector = LoopDetector::with_config(&cfg);
962
963 detector.record_call("ctx_read", "fp_a");
964 detector.record_error_outcome("ctx_read", "fp_a");
965
966 detector.record_call("ctx_read", "fp_b");
967 let r = detector.record_call("ctx_read", "fp_b");
968 assert_eq!(r.call_count, 2);
969
970 let r_a = detector.record_call("ctx_read", "fp_a");
971 assert_eq!(r_a.call_count, 1, "fp_a count should be reset to 0 then +1");
972 }
973
974 #[test]
975 fn correction_degrade_recovery_after_prune() {
976 let mut detector = LoopDetector::new();
977 for i in 0..4u32 {
978 detector.record_read_for_correction(&format!("warmup{i}.rs"), "full", false);
979 }
980 detector.record_read_for_correction("target.rs", "full", false);
981 detector.record_read_for_correction("target.rs", "full", true);
982 assert!(detector.correction_count() > 0);
983 detector.prune_corrections();
984 assert!(detector.correction_count() >= 1);
987 }
988
989 #[test]
990 fn success_after_errors_resets_to_normal() {
991 let cfg = test_config(2, 4, 0);
992 let mut detector = LoopDetector::with_config(&cfg);
993
994 for _ in 0..3 {
995 detector.record_call("ctx_read", "fp1");
996 detector.record_error_outcome("ctx_read", "fp1");
997 }
998
999 let r = detector.record_call("ctx_read", "fp1");
1000 assert_eq!(r.level, ThrottleLevel::Normal);
1001 assert_eq!(r.call_count, 1);
1002 }
1003
1004 #[test]
1005 fn throttle_result_default_is_normal() {
1006 let r = ThrottleResult::default();
1007 assert_eq!(r.level, ThrottleLevel::Normal);
1008 assert_eq!(r.call_count, 0);
1009 assert!(r.message.is_none());
1010 }
1011}