oxicode_ai/utils/
thinking_loop.rs1use std::collections::HashSet;
28
29const VERBATIM_TAIL_WINDOW: usize = 250;
31const VERBATIM_MIN_REPEATED_CHARS: usize = 180;
33const VERBATIM_MAX_UNIT: usize = 60;
35const VERBATIM_MIN_COUNT: usize = 4;
37
38const SEGMENT_CHAR_CAP: usize = 700;
41const SEGMENT_MIN_NORM_CHARS: usize = 60;
43const SEGMENT_WINDOW: usize = 16;
46const SEGMENT_SIMILARITY: f64 = 0.8;
49const SEGMENT_MIN_COUNT: usize = 8;
51const SEGMENT_MIN_CLUSTER: usize = 4;
53
54const LEX_NOVELTY_WINDOW: usize = 8;
57const LEX_STALL_NOVELTY_FLOOR: f64 = 0.2;
61const LEX_STALL_MIN_RUN: usize = 8;
64
65pub const THINKING_LOOP_MARKER: &str = "thinking loop detected";
69
70#[derive(Debug, Default)]
72pub struct ThinkingLoopDetector {
73 tail: String,
74 pending: String,
75 window: Vec<HashSet<String>>,
76 count: usize,
77 word_window: Vec<HashSet<String>>,
78 lex_stall_run: usize,
79 anchor_window: Vec<HashSet<String>>,
80 fired: bool,
81}
82
83impl ThinkingLoopDetector {
84 pub fn new() -> Self {
86 Self::default()
87 }
88
89 pub fn push(&mut self, delta: &str) -> Option<String> {
95 if self.fired || delta.is_empty() {
96 return None;
97 }
98
99 self.tail.push_str(delta);
101 let tail_chars = self.tail.chars().count();
102 if tail_chars > VERBATIM_TAIL_WINDOW {
103 let skip = tail_chars - VERBATIM_TAIL_WINDOW;
104 self.tail = self.tail.chars().skip(skip).collect();
105 }
106 if let Some((unit, times)) = detect_verbatim_repetition(&self.tail) {
107 self.fired = true;
108 let trimmed = unit.trim();
109 return Some(format!(
110 "{THINKING_LOOP_MARKER}: repeated \"{trimmed}\" {times}× back-to-back"
111 ));
112 }
113
114 self.pending.push_str(delta);
116 loop {
117 let boundary = find_blank_line(&self.pending);
118 let raw: String;
119 match boundary {
120 Some(range) => {
121 let consumed: String = self.pending.drain(..range.end_byte).collect();
125 raw = consumed
126 .char_indices()
127 .take(range.start_char)
128 .map(|(_, c)| c)
129 .collect();
130 }
131 None => {
132 let pending_chars = self.pending.chars().count();
133 if pending_chars > SEGMENT_CHAR_CAP {
134 let split_at = self
135 .pending
136 .char_indices()
137 .nth(SEGMENT_CHAR_CAP)
138 .map(|(b, _)| b)
139 .unwrap_or(self.pending.len());
140 raw = self.pending.drain(..split_at).collect();
141 } else {
142 return None;
143 }
144 }
145 }
146 let mut rest = raw;
147 while !rest.is_empty() {
148 let chunk_len = rest.chars().count().min(SEGMENT_CHAR_CAP);
149 let split_at = rest
150 .char_indices()
151 .nth(chunk_len)
152 .map(|(b, _)| b)
153 .unwrap_or(rest.len());
154 let chunk: String = rest.drain(..split_at).collect();
155 if let Some(hit) = self.consume_segment(&chunk) {
156 self.fired = true;
157 return Some(hit);
158 }
159 }
160 }
161 }
162
163 pub fn flush(&mut self) -> Option<String> {
166 if self.fired || self.pending.is_empty() {
167 return None;
168 }
169 let mut rest = std::mem::take(&mut self.pending);
170 while !rest.is_empty() {
171 let chunk_len = rest.chars().count().min(SEGMENT_CHAR_CAP);
172 let split_at = rest
173 .char_indices()
174 .nth(chunk_len)
175 .map(|(b, _)| b)
176 .unwrap_or(rest.len());
177 let chunk: String = rest.drain(..split_at).collect();
178 if let Some(hit) = self.consume_segment(&chunk) {
179 self.fired = true;
180 return Some(hit);
181 }
182 }
183 None
184 }
185
186 pub fn reset(&mut self) {
188 *self = Self::default();
189 }
190
191 pub fn fired(&self) -> bool {
193 self.fired
194 }
195
196 fn consume_segment(&mut self, raw: &str) -> Option<String> {
197 let stripped = strip_summary_headers(raw);
198 let normalized = normalize_segment(&stripped);
199 if normalized.chars().count() < SEGMENT_MIN_NORM_CHARS {
200 return None;
201 }
202
203 let fingerprint = trigram_shingles(&normalized);
205 let mut cluster = 1usize;
206 for prev in &self.window {
207 if jaccard(&fingerprint, prev) >= SEGMENT_SIMILARITY {
208 cluster += 1;
209 }
210 }
211
212 let words: HashSet<String> = normalized
214 .split_whitespace()
215 .filter(|w| !w.is_empty())
216 .map(|w| w.to_string())
217 .collect();
218 let mut prior_vocab: HashSet<String> = HashSet::new();
219 for set in &self.word_window {
220 for w in set {
221 prior_vocab.insert(w.clone());
222 }
223 }
224 let unseen = words.iter().filter(|w| !prior_vocab.contains(*w)).count();
225 let novelty = if prior_vocab.is_empty() {
226 1.0
227 } else {
228 unseen as f64 / words.len().max(1) as f64
229 };
230
231 let anchors = extract_concrete_anchors(&stripped);
232 let mut new_anchor = false;
233 for anchor in &anchors {
234 let is_new = self.anchor_window.iter().all(|seen| !seen.contains(anchor));
235 if is_new {
236 new_anchor = true;
237 break;
238 }
239 }
240
241 if novelty <= LEX_STALL_NOVELTY_FLOOR && !new_anchor {
242 self.lex_stall_run += 1;
243 } else {
244 self.lex_stall_run = 0;
245 }
246
247 self.window.push(fingerprint);
248 if self.window.len() > SEGMENT_WINDOW {
249 self.window.remove(0);
250 }
251 self.word_window.push(words);
252 if self.word_window.len() > LEX_NOVELTY_WINDOW {
253 self.word_window.remove(0);
254 }
255 self.anchor_window.push(anchors);
256 if self.anchor_window.len() > LEX_NOVELTY_WINDOW {
257 self.anchor_window.remove(0);
258 }
259 self.count += 1;
260
261 if self.count >= SEGMENT_MIN_COUNT {
262 if cluster >= SEGMENT_MIN_CLUSTER {
263 return Some(format!(
264 "{THINKING_LOOP_MARKER}: {cluster} near-identical segments within the last {SEGMENT_WINDOW}"
265 ));
266 }
267 if self.lex_stall_run >= LEX_STALL_MIN_RUN {
268 return Some(format!(
269 "{THINKING_LOOP_MARKER}: {} low-information segments recycling recent wording",
270 self.lex_stall_run
271 ));
272 }
273 }
274 None
275 }
276}
277
278struct CharRange {
281 start_char: usize,
282 end_byte: usize,
283}
284
285fn find_blank_line(pending: &str) -> Option<CharRange> {
289 let chars: Vec<char> = pending.chars().collect();
290 let mut i = 0;
291 while i + 1 < chars.len() {
292 if chars[i] == '\n' {
293 let mut j = i + 1;
294 while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t' || chars[j] == '\n') {
295 j += 1;
296 }
297 if j > i + 1 {
298 let mut bytes_consumed = 0usize;
299 for (k, c) in chars.iter().enumerate() {
300 if k >= j {
301 break;
302 }
303 bytes_consumed += c.len_utf8();
304 }
305 return Some(CharRange {
306 start_char: i,
307 end_byte: bytes_consumed,
308 });
309 }
310 }
311 i += 1;
312 }
313 None
314}
315
316fn detect_verbatim_repetition(text: &str) -> Option<(String, usize)> {
319 let chars: Vec<char> = text.chars().collect();
320 if chars.len() < VERBATIM_MIN_REPEATED_CHARS {
321 return None;
322 }
323 let window_size = chars.len().min(VERBATIM_TAIL_WINDOW);
324 let search_space = &chars[chars.len() - window_size..];
325
326 for len in 2..=VERBATIM_MAX_UNIT {
327 if search_space.len() < len * 4 {
328 continue;
329 }
330 let unit: String = search_space[search_space.len() - len..].iter().collect();
331 if !unit
332 .chars()
333 .any(|c| c.is_alphabetic() || is_pictographic(c))
334 {
335 continue;
336 }
337
338 let mut count = 0usize;
339 let mut pos = search_space.len();
340 while pos >= len {
341 let slice = &search_space[pos - len..pos];
342 let candidate: String = slice.iter().collect();
343 if candidate == unit {
344 count += 1;
345 pos -= len;
346 } else {
347 break;
348 }
349 }
350 if count >= VERBATIM_MIN_COUNT && len * count >= VERBATIM_MIN_REPEATED_CHARS {
351 return Some((unit, count));
352 }
353 }
354 None
355}
356
357fn is_pictographic(c: char) -> bool {
360 let cp = c as u32;
361 matches!(
362 cp,
363 0x1F300..=0x1F5FF
364 | 0x1F600..=0x1F64F
365 | 0x1F680..=0x1F6FF
366 | 0x1F700..=0x1F77F
367 | 0x1F780..=0x1F7FF
368 | 0x1F800..=0x1F8FF
369 | 0x1F900..=0x1F9FF
370 | 0x1FA00..=0x1FA6F
371 | 0x1FA70..=0x1FAFF
372 | 0x2600..=0x26FF
373 | 0x2700..=0x27BF
374 )
375}
376
377fn strip_summary_headers(s: &str) -> String {
379 use std::fmt::Write;
380 let mut out = String::with_capacity(s.len());
381 for line in s.lines() {
382 let trimmed = line.trim_start();
383 if trimmed.starts_with('#') {
384 let mut hash_count = 0;
385 for c in trimmed.chars() {
386 if c == '#' {
387 hash_count += 1;
388 } else {
389 break;
390 }
391 }
392 if (1..=6).contains(&hash_count) {
393 let after = &trimmed[hash_count..];
394 if after.starts_with(' ') || after.starts_with('\t') {
395 continue;
396 }
397 }
398 }
399 if trimmed.starts_with("**")
400 && trimmed.len() >= 4
401 && trimmed[2..].trim_end().ends_with("**")
402 {
403 continue;
404 }
405 if trimmed.starts_with("***")
406 && trimmed.len() >= 6
407 && trimmed[3..].trim_end().ends_with("***")
408 {
409 continue;
410 }
411 let _ = writeln!(out, "{line}");
412 }
413 out
414}
415
416fn normalize_segment(segment: &str) -> String {
419 use std::fmt::Write;
420 let lower: String = segment.to_lowercase();
421 let unbackticked = lower.replace('`', " ");
422 let mut out = String::with_capacity(unbackticked.len());
423 let mut prev_space = true;
424 for c in unbackticked.chars() {
425 if c.is_ascii_alphanumeric() {
426 out.push(c);
427 prev_space = false;
428 } else if !prev_space {
429 out.push(' ');
430 prev_space = true;
431 }
432 }
433 let mut filtered = String::with_capacity(out.len());
434 for token in out.split_whitespace() {
435 if token.chars().any(|c| c.is_ascii_lowercase()) {
436 let _ = write!(filtered, "{token} ");
437 }
438 }
439 filtered.trim_end().to_string()
440}
441
442fn trigram_shingles(normalized: &str) -> HashSet<String> {
444 let words: Vec<&str> = normalized.split_whitespace().collect();
445 let mut shingles = HashSet::new();
446 if words.len() < 3 {
447 if !words.is_empty() {
448 shingles.insert(words.join(" "));
449 }
450 return shingles;
451 }
452 for i in 0..=words.len() - 3 {
453 shingles.insert(format!("{} {} {}", words[i], words[i + 1], words[i + 2]));
454 }
455 shingles
456}
457
458fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
459 if a.is_empty() || b.is_empty() {
460 return 0.0;
461 }
462 let (small, large) = if a.len() < b.len() { (a, b) } else { (b, a) };
463 let mut intersection = 0usize;
464 for x in small {
465 if large.contains(x) {
466 intersection += 1;
467 }
468 }
469 let union = a.len() + b.len() - intersection;
470 if union == 0 {
471 0.0
472 } else {
473 intersection as f64 / union as f64
474 }
475}
476
477fn extract_concrete_anchors(segment: &str) -> HashSet<String> {
480 let mut out = HashSet::new();
481
482 let mut cur = String::new();
484 let mut in_backtick = false;
485 for c in segment.chars() {
486 if c == '`' {
487 if in_backtick {
488 let trimmed = cur.trim();
489 if !trimmed.is_empty() {
490 out.insert(trimmed.to_lowercase());
491 }
492 cur.clear();
493 in_backtick = false;
494 } else {
495 in_backtick = true;
496 }
497 continue;
498 }
499 if in_backtick {
500 cur.push(c);
501 }
502 }
503 if in_backtick {
504 let trimmed = cur.trim();
505 if !trimmed.is_empty() {
506 out.insert(trimmed.to_lowercase());
507 }
508 }
509
510 for token in segment.split_whitespace() {
512 if token.contains('/') && token.chars().any(|c| c.is_alphabetic()) {
513 out.insert(token.to_lowercase());
514 }
515 }
516
517 for raw_token in segment.split_whitespace() {
519 let token: String = raw_token
520 .trim_matches(|c: char| !c.is_alphanumeric() && c != '_')
521 .to_string();
522 if token.is_empty() {
523 continue;
524 }
525 let only_word = token.chars().all(|c| c.is_alphanumeric() || c == '_');
526 if !only_word {
527 continue;
528 }
529 let has_snake = token.contains('_');
530 let has_camel = token
531 .chars()
532 .enumerate()
533 .any(|(i, c)| i > 0 && c.is_ascii_uppercase());
534 if has_snake || has_camel {
535 out.insert(token.to_lowercase());
536 }
537 }
538 out
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544
545 #[test]
546 fn verbatim_repetition_short_unit_loop() {
547 let unit = "🌊".repeat(200);
548 let hit = detect_verbatim_repetition(&unit);
549 assert!(hit.is_some(), "expected verbatim detection");
550 let (u, n) = hit.unwrap();
551 assert!(u.contains('🌊'));
552 assert!(n >= 4);
553 }
554
555 #[test]
556 fn verbatim_repetition_too_short_no_loop() {
557 assert!(detect_verbatim_repetition("ababab").is_none());
558 }
559
560 #[test]
561 fn verbatim_repetition_punctuation_only_no_loop() {
562 let s = "----".repeat(60);
563 assert!(detect_verbatim_repetition(&s).is_none());
564 }
565
566 #[test]
567 fn detector_push_fires_on_verbatim_loop() {
568 let mut det = ThinkingLoopDetector::new();
569 let unit = "ab ".repeat(120);
570 let hit = det.push(&unit);
571 assert!(hit.is_some(), "verbatim loop should fire");
572 let reason = hit.unwrap();
573 assert!(reason.contains(THINKING_LOOP_MARKER));
574 assert!(reason.contains("back-to-back"));
575 }
576
577 #[test]
578 fn detector_fires_once_then_sticky() {
579 let mut det = ThinkingLoopDetector::new();
580 let unit = "ab ".repeat(120);
581 let first = det.push(&unit);
582 let second = det.push(&unit);
583 assert!(first.is_some());
584 assert!(second.is_none(), "after firing, pushes are no-ops");
585 assert!(det.fired());
586 }
587
588 #[test]
589 fn detector_reset_clears_state() {
590 let mut det = ThinkingLoopDetector::new();
591 let _ = det.push(&"ab ".repeat(120));
592 assert!(det.fired());
593 det.reset();
594 assert!(!det.fired());
595 }
596
597 #[test]
598 fn detector_empty_push_is_noop() {
599 let mut det = ThinkingLoopDetector::new();
600 assert!(det.push("").is_none());
601 }
602
603 #[test]
604 fn normalize_segment_lowercases_and_drops_numbers() {
605 let n = normalize_segment("The QUICK brown 123 fox jumps over `code`");
606 assert!(n.contains("the"));
607 assert!(n.contains("quick"));
608 assert!(n.contains("brown"));
609 assert!(!n.contains("123"));
610 }
611
612 #[test]
613 fn trigram_shingles_three_words() {
614 let s = trigram_shingles("the quick brown");
615 assert_eq!(s.len(), 1);
616 assert!(s.contains("the quick brown"));
617 }
618
619 #[test]
620 fn trigram_shingles_short_input_passthrough() {
621 let s = trigram_shingles("hello");
622 assert_eq!(s.len(), 1);
623 assert!(s.contains("hello"));
624 }
625
626 #[test]
627 fn jaccard_identical_sets() {
628 let a: HashSet<String> = ["a", "b", "c"].iter().map(|s| s.to_string()).collect();
629 let b = a.clone();
630 assert!((jaccard(&a, &b) - 1.0).abs() < 1e-6);
631 }
632
633 #[test]
634 fn jaccard_disjoint_sets() {
635 let a: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
636 let b: HashSet<String> = ["b"].iter().map(|s| s.to_string()).collect();
637 assert!((jaccard(&a, &b) - 0.0).abs() < 1e-6);
638 }
639
640 #[test]
641 fn jaccard_empty_set_zero() {
642 let a: HashSet<String> = HashSet::new();
643 let b: HashSet<String> = ["a"].iter().map(|s| s.to_string()).collect();
644 assert!((jaccard(&a, &b) - 0.0).abs() < 1e-6);
645 }
646
647 #[test]
648 fn strip_summary_headers_drops_atx_and_bold() {
649 let input = "## Heading\n**bold title**\nactual content";
650 let stripped = strip_summary_headers(input);
651 assert!(!stripped.contains("Heading"));
652 assert!(!stripped.contains("bold title"));
653 assert!(stripped.contains("actual content"));
654 }
655
656 #[test]
657 fn extract_concrete_anchors_catches_code_spans() {
658 let s = "Look at `oxicode_ai::Message` and `lib.rs` for details";
659 let anchors = extract_concrete_anchors(s);
660 assert!(anchors.contains("oxicode_ai::message"));
661 assert!(anchors.contains("lib.rs"));
662 }
663
664 #[test]
665 fn extract_concrete_anchors_catches_paths() {
666 let s = "see src/main.rs and crates/foo/Cargo.toml";
667 let anchors = extract_concrete_anchors(s);
668 assert!(anchors.iter().any(|a| a.contains("src/main.rs")));
669 assert!(anchors.iter().any(|a| a.contains("crates/foo/cargo.toml")));
670 }
671
672 #[test]
673 fn extract_concrete_anchors_catches_snake_case() {
674 let s = "Consider the variable my_var_name.";
675 let anchors = extract_concrete_anchors(s);
676 assert!(anchors.contains("my_var_name"));
677 }
678
679 #[test]
680 fn extract_concrete_anchors_catches_camel_case() {
681 let s = "Use the MyCoolClass implementation.";
682 let anchors = extract_concrete_anchors(s);
683 assert!(anchors.contains("mycoolclass"));
684 }
685}