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 a_alpha.len() >= 3
539 && b_alpha.len() >= 3
540 && (a_alpha.contains(&b_alpha) || b_alpha.contains(&a_alpha))
541}
542
543fn canonical_json(value: &serde_json::Value) -> String {
544 match value {
545 serde_json::Value::Object(map) => {
546 let mut keys: Vec<&String> = map.keys().collect();
547 keys.sort();
548 let entries: Vec<String> = keys
549 .iter()
550 .map(|k| format!("{}:{}", k, canonical_json(&map[*k])))
551 .collect();
552 format!("{{{}}}", entries.join(","))
553 }
554 serde_json::Value::Array(arr) => {
555 let entries: Vec<String> = arr.iter().map(canonical_json).collect();
556 format!("[{}]", entries.join(","))
557 }
558 _ => value.to_string(),
559 }
560}
561
562#[cfg(test)]
563mod tests {
564 use super::*;
565
566 fn test_config(normal: u32, reduced: u32, blocked: u32) -> LoopDetectionConfig {
567 LoopDetectionConfig {
568 normal_threshold: normal,
569 reduced_threshold: reduced,
570 blocked_threshold: blocked,
571 window_secs: 300,
572 search_group_limit: 10,
573 tool_total_limits: std::collections::HashMap::new(),
574 }
575 }
576
577 #[test]
578 fn normal_calls_pass_through() {
579 let mut detector = LoopDetector::new();
580 let r1 = detector.record_call("ctx_read", "abc123");
581 assert_eq!(r1.level, ThrottleLevel::Normal);
582 assert_eq!(r1.call_count, 1);
583 assert!(r1.message.is_none());
584 }
585
586 #[test]
587 fn repeated_calls_trigger_reduced() {
588 let _lock = crate::core::data_dir::test_env_lock();
589 crate::test_env::set_var("LEAN_CTX_META", "1");
590 let cfg = LoopDetectionConfig::default();
591 let mut detector = LoopDetector::with_config(&cfg);
592 for _ in 0..cfg.normal_threshold {
593 detector.record_call("ctx_read", "same_fp");
594 }
595 let result = detector.record_call("ctx_read", "same_fp");
596 assert_eq!(result.level, ThrottleLevel::Reduced);
597 assert!(result.message.is_some());
598 crate::test_env::remove_var("LEAN_CTX_META");
599 }
600
601 #[test]
602 fn excessive_calls_get_blocked_when_enabled() {
603 let cfg = LoopDetectionConfig {
605 blocked_threshold: 6,
606 ..Default::default()
607 };
608 let mut detector = LoopDetector::with_config(&cfg);
609 for _ in 0..cfg.blocked_threshold {
610 detector.record_call("ctx_shell", "same_fp");
611 }
612 let result = detector.record_call("ctx_shell", "same_fp");
613 assert_eq!(result.level, ThrottleLevel::Blocked);
614 assert!(result.message.unwrap().contains("LOOP DETECTED"));
615 }
616
617 #[test]
618 fn blocking_disabled_by_default() {
619 let cfg = LoopDetectionConfig::default();
621 assert_eq!(cfg.blocked_threshold, 0);
622 let mut detector = LoopDetector::with_config(&cfg);
623 for _ in 0..100 {
625 detector.record_call("ctx_shell", "same_fp");
626 }
627 let result = detector.record_call("ctx_shell", "same_fp");
628 assert_ne!(result.level, ThrottleLevel::Blocked);
630 }
631
632 #[test]
633 fn different_args_tracked_separately() {
634 let mut detector = LoopDetector::new();
635 for _ in 0..10 {
636 detector.record_call("ctx_read", "fp_a");
637 }
638 let result = detector.record_call("ctx_read", "fp_b");
639 assert_eq!(result.level, ThrottleLevel::Normal);
640 assert_eq!(result.call_count, 1);
641 }
642
643 #[test]
644 fn fingerprint_deterministic() {
645 let args = serde_json::json!({"path": "test.rs", "mode": "full"});
646 let fp1 = LoopDetector::fingerprint(&args);
647 let fp2 = LoopDetector::fingerprint(&args);
648 assert_eq!(fp1, fp2);
649 }
650
651 #[test]
652 fn fingerprint_order_independent() {
653 let a = serde_json::json!({"mode": "full", "path": "test.rs"});
654 let b = serde_json::json!({"path": "test.rs", "mode": "full"});
655 assert_eq!(LoopDetector::fingerprint(&a), LoopDetector::fingerprint(&b));
656 }
657
658 #[test]
659 fn stats_shows_duplicates() {
660 let mut detector = LoopDetector::new();
661 for _ in 0..5 {
662 detector.record_call("ctx_read", "fp_a");
663 }
664 detector.record_call("ctx_shell", "fp_b");
665 let stats = detector.stats();
666 assert_eq!(stats.len(), 1);
667 assert_eq!(stats[0].1, 5);
668 }
669
670 #[test]
671 fn reset_clears_state() {
672 let mut detector = LoopDetector::new();
673 for _ in 0..5 {
674 detector.record_call("ctx_read", "fp_a");
675 }
676 detector.reset();
677 let result = detector.record_call("ctx_read", "fp_a");
678 assert_eq!(result.call_count, 1);
679 }
680
681 #[test]
682 fn custom_thresholds_from_config() {
683 let cfg = test_config(1, 2, 3);
684 let mut detector = LoopDetector::with_config(&cfg);
685 detector.record_call("ctx_read", "fp");
686 let r = detector.record_call("ctx_read", "fp");
687 assert_eq!(r.level, ThrottleLevel::Reduced);
688 detector.record_call("ctx_read", "fp");
689 let r = detector.record_call("ctx_read", "fp");
690 assert_eq!(r.level, ThrottleLevel::Blocked);
691 }
692
693 #[test]
694 fn similar_patterns_detected() {
695 assert!(patterns_are_similar("compress", "compress"));
696 assert!(patterns_are_similar("compress", "compression"));
697 assert!(patterns_are_similar("compress.*data", "compress"));
698 assert!(!patterns_are_similar("foo", "bar"));
699 assert!(!patterns_are_similar("ab", "cd"));
700 }
701
702 #[test]
703 fn search_group_tracking_when_blocking_enabled() {
704 let cfg = LoopDetectionConfig {
706 search_group_limit: 5,
707 blocked_threshold: 6, ..Default::default()
709 };
710 let mut detector = LoopDetector::with_config(&cfg);
711 for i in 0..5 {
712 let fp = format!("fp_{i}");
713 let r = detector.record_search("ctx_search", &fp, Some(&format!("pattern_{i}")));
714 assert_ne!(r.level, ThrottleLevel::Blocked, "call {i} should not block");
715 }
716 let r = detector.record_search("ctx_search", "fp_5", Some("pattern_5"));
717 assert_eq!(r.level, ThrottleLevel::Blocked);
718 assert!(r.message.unwrap().contains("search calls"));
719 }
720
721 #[test]
722 fn similar_search_patterns_trigger_block_when_enabled() {
723 let cfg = LoopDetectionConfig {
725 blocked_threshold: 6,
726 ..Default::default()
727 };
728 let mut detector = LoopDetector::with_config(&cfg);
729 let variants = [
730 "compress",
731 "compression",
732 "compress.*data",
733 "compress_output",
734 "compressor",
735 "compress_result",
736 "compress_file",
737 ];
738 for (i, pat) in variants
739 .iter()
740 .enumerate()
741 .take(cfg.blocked_threshold as usize)
742 {
743 detector.record_search("ctx_search", &format!("fp_{i}"), Some(pat));
744 }
745 let r = detector.record_search("ctx_search", "fp_new", Some("compress_all"));
746 assert_eq!(r.level, ThrottleLevel::Blocked);
747 }
748
749 #[test]
750 fn is_search_tool_detection() {
751 assert!(LoopDetector::is_search_tool("ctx_search"));
752 assert!(LoopDetector::is_search_tool("ctx_semantic_search"));
753 assert!(!LoopDetector::is_search_tool("ctx_read"));
754 assert!(!LoopDetector::is_search_tool("ctx_shell"));
755 }
756
757 #[test]
758 fn is_search_shell_command_detection() {
759 assert!(LoopDetector::is_search_shell_command("grep -r foo ."));
760 assert!(LoopDetector::is_search_shell_command("rg pattern src/"));
761 assert!(LoopDetector::is_search_shell_command("find . -name '*.rs'"));
762 assert!(!LoopDetector::is_search_shell_command("cargo build"));
763 assert!(!LoopDetector::is_search_shell_command("git status"));
764 }
765
766 #[test]
767 fn correction_fresh_reread_detected() {
768 let mut detector = LoopDetector::new();
769 detector.record_read_for_correction("src/main.rs", "full", false);
771 detector.record_read_for_correction("src/lib.rs", "full", false);
772 detector.record_read_for_correction("src/util.rs", "full", false);
773 detector.record_read_for_correction("src/main.rs", "full", false);
775 assert_eq!(detector.correction_count(), 0);
776 detector.record_read_for_correction("src/main.rs", "full", true);
778 assert_eq!(detector.correction_count(), 1);
779 }
780
781 #[test]
782 fn correction_mode_bounce_detected() {
783 let mut detector = LoopDetector::new();
784 for i in 0..COLD_START_CALLS {
786 detector.record_read_for_correction(&format!("f{i}.rs"), "full", false);
787 }
788 detector.record_read_for_correction("src/cache.rs", "map", false);
790 assert_eq!(detector.correction_count(), 0);
791 detector.record_read_for_correction("src/cache.rs", "full", false);
793 assert_eq!(detector.correction_count(), 1);
794 }
795
796 #[test]
797 fn retrieve_count_tracks_signals_and_resets() {
798 let mut detector = LoopDetector::new();
801 assert_eq!(detector.retrieve_count(), 0);
802 detector.record_retrieve();
803 detector.record_retrieve();
804 detector.record_retrieve();
805 assert_eq!(detector.retrieve_count(), 3, "three re-fetches counted");
806 assert_eq!(detector.correction_count(), 0);
808 detector.reset();
809 assert_eq!(
810 detector.retrieve_count(),
811 0,
812 "reset clears retrieve signals"
813 );
814 }
815
816 #[test]
817 fn correction_shell_rerun_detected() {
818 let mut detector = LoopDetector::new();
819 for i in 0..COLD_START_CALLS {
821 detector.record_shell_for_correction(&format!("echo {i}"));
822 }
823 detector.record_shell_for_correction("cargo test --lib");
825 assert_eq!(detector.correction_count(), 0);
826 detector.record_shell_for_correction("cargo test --lib");
828 assert_eq!(detector.correction_count(), 1);
829 }
830
831 #[test]
832 fn correction_rate_calculation() {
833 let mut detector = LoopDetector::new();
834 for i in 0..COLD_START_CALLS {
835 detector.record_shell_for_correction(&format!("init{i}"));
836 }
837 detector.record_shell_for_correction("cargo check");
838 detector.record_shell_for_correction("cargo check");
839 detector.record_shell_for_correction("cargo check");
840 assert_eq!(detector.correction_count(), 2);
842 assert!(detector.correction_rate() > 0.0);
843 }
844
845 #[test]
846 fn correction_cold_start_ignored() {
847 let mut detector = LoopDetector::new();
848 detector.record_shell_for_correction("cargo check");
850 detector.record_shell_for_correction("cargo check");
851 detector.record_shell_for_correction("cargo check");
852 assert_eq!(detector.correction_count(), 0);
853 }
854
855 #[test]
856 fn search_block_message_has_guidance_when_blocking_enabled() {
857 let cfg = LoopDetectionConfig {
859 blocked_threshold: 6,
860 search_group_limit: 8,
861 ..Default::default()
862 };
863 let mut detector = LoopDetector::with_config(&cfg);
864 for i in 0..10 {
865 detector.record_search("ctx_search", &format!("fp_{i}"), Some("compress"));
866 }
867 let r = detector.record_search("ctx_search", "fp_new", Some("compress"));
868 assert_eq!(r.level, ThrottleLevel::Blocked);
869 let msg = r.message.unwrap();
870 assert!(msg.contains("ctx_tree"));
871 assert!(msg.contains("path"));
872 assert!(msg.contains("ctx_read"));
873 }
874
875 #[test]
876 fn error_outcome_undoes_pre_dispatch_count() {
877 let cfg = test_config(2, 4, 0);
878 let mut detector = LoopDetector::with_config(&cfg);
879
880 detector.record_call("ctx_read", "fp1");
881 detector.record_call("ctx_read", "fp1");
882 detector.record_error_outcome("ctx_read", "fp1");
883
884 let r = detector.record_call("ctx_read", "fp1");
885 assert_eq!(r.call_count, 2, "error should have undone one count");
886 assert_eq!(r.level, ThrottleLevel::Normal);
887 }
888
889 #[test]
890 fn repeated_errors_dont_trigger_reduced() {
891 let cfg = test_config(2, 4, 0);
892 let mut detector = LoopDetector::with_config(&cfg);
893
894 for _ in 0..5 {
895 detector.record_call("ctx_read", "fp1");
896 detector.record_error_outcome("ctx_read", "fp1");
897 }
898
899 let r = detector.record_call("ctx_read", "fp1");
900 assert_eq!(
901 r.level,
902 ThrottleLevel::Normal,
903 "5 failed retries should not throttle"
904 );
905 }
906
907 #[test]
908 fn mixed_success_and_error_correct_count() {
909 let cfg = test_config(2, 4, 0);
910 let mut detector = LoopDetector::with_config(&cfg);
911
912 detector.record_call("ctx_read", "fp1");
913 detector.record_error_outcome("ctx_read", "fp1");
914 detector.record_call("ctx_read", "fp1");
915 detector.record_error_outcome("ctx_read", "fp1");
916 detector.record_call("ctx_read", "fp1");
917 assert_eq!(detector.record_call("ctx_read", "fp1").call_count, 2);
919 }
920
921 #[test]
922 fn error_outcome_on_nonexistent_key_is_noop() {
923 let mut detector = LoopDetector::new();
924 detector.record_error_outcome("ctx_read", "never_called");
925 let r = detector.record_call("ctx_read", "never_called");
926 assert_eq!(r.call_count, 1);
927 }
928
929 #[test]
930 fn error_outcome_doesnt_go_negative() {
931 let mut detector = LoopDetector::new();
932 detector.record_call("ctx_read", "fp1");
933 detector.record_error_outcome("ctx_read", "fp1");
934 detector.record_error_outcome("ctx_read", "fp1");
935 let r = detector.record_call("ctx_read", "fp1");
936 assert_eq!(r.call_count, 1, "count should never go below 0");
937 }
938
939 #[test]
940 fn error_in_tool_a_doesnt_affect_tool_b() {
941 let cfg = test_config(2, 4, 0);
942 let mut detector = LoopDetector::with_config(&cfg);
943
944 for _ in 0..5 {
945 detector.record_call("ctx_read", "fp1");
946 detector.record_error_outcome("ctx_read", "fp1");
947 }
948
949 let r = detector.record_call("ctx_shell", "fp_shell");
950 assert_eq!(r.call_count, 1);
951 assert_eq!(r.level, ThrottleLevel::Normal);
952 }
953
954 #[test]
955 fn different_fingerprints_independent_after_errors() {
956 let cfg = test_config(2, 4, 0);
957 let mut detector = LoopDetector::with_config(&cfg);
958
959 detector.record_call("ctx_read", "fp_a");
960 detector.record_error_outcome("ctx_read", "fp_a");
961
962 detector.record_call("ctx_read", "fp_b");
963 let r = detector.record_call("ctx_read", "fp_b");
964 assert_eq!(r.call_count, 2);
965
966 let r_a = detector.record_call("ctx_read", "fp_a");
967 assert_eq!(r_a.call_count, 1, "fp_a count should be reset to 0 then +1");
968 }
969
970 #[test]
971 fn correction_degrade_recovery_after_prune() {
972 let mut detector = LoopDetector::new();
973 for i in 0..4u32 {
974 detector.record_read_for_correction(&format!("warmup{i}.rs"), "full", false);
975 }
976 detector.record_read_for_correction("target.rs", "full", false);
977 detector.record_read_for_correction("target.rs", "full", true);
978 assert!(detector.correction_count() > 0);
979 detector.prune_corrections();
980 assert!(detector.correction_count() >= 1);
983 }
984
985 #[test]
986 fn success_after_errors_resets_to_normal() {
987 let cfg = test_config(2, 4, 0);
988 let mut detector = LoopDetector::with_config(&cfg);
989
990 for _ in 0..3 {
991 detector.record_call("ctx_read", "fp1");
992 detector.record_error_outcome("ctx_read", "fp1");
993 }
994
995 let r = detector.record_call("ctx_read", "fp1");
996 assert_eq!(r.level, ThrottleLevel::Normal);
997 assert_eq!(r.call_count, 1);
998 }
999
1000 #[test]
1001 fn throttle_result_default_is_normal() {
1002 let r = ThrottleResult::default();
1003 assert_eq!(r.level, ThrottleLevel::Normal);
1004 assert_eq!(r.call_count, 0);
1005 assert!(r.message.is_none());
1006 }
1007}