loopctl/detection/convergence.rs
1//! Convergence detection for agent loops.
2//!
3//! This module detects when an agent's responses become semantically similar
4//! across multiple consecutive turns, indicating that the agent has converged —
5//! i.e., it is stuck repeating the same type of response without making
6//! meaningful progress toward its goal.
7//!
8//! Loop detection (handled by the `loop_detector` module)
9//! checks for repeated *operations* with the same result, while convergence
10//! detection focuses on the *textual content* of the agent's responses, using
11//! lightweight Jaccard similarity on word-level tokens.
12//!
13//! # How It Works
14//!
15//! The [`ConvergenceDetector`] maintains a sliding window of recent response
16//! strings. Each new response is compared against every prior response in the
17//! window using [`ConvergenceDetector::compute_similarity`]. When the number
18//! of consecutive similar responses reaches the configured
19//! [`ConvergenceConfig::window_size`], convergence is flagged and the
20//! configured [`ConvergenceAction`] is returned.
21//!
22//! # Provided Types
23//!
24//! - **[`ConvergenceConfig`]** — Configuration: window size, similarity
25//! threshold, and action.
26//! - **[`ConvergenceDetector`]** — The detector itself; feeds responses and
27//! checks convergence.
28//! - **[`ConvergenceStatus`]** — Result of a convergence check, including
29//! similarity score and action.
30//! - **[`ConvergenceAction`]** — What to do when convergence is detected.
31//! - **[`ConvergenceConfigError`]** — Validation errors from invalid config.
32//!
33//! # Relationship to Other Detection Modules
34//!
35//! | Module | What it detects | Granularity |
36//! |-----------------------|-------------------------|-------------|
37//! | `convergence` (this) | Similar *response text* | Turn-level |
38//! | `loop_detector` | Repeated *operations* | Tool-level |
39//! | `detection_manager` | Orchestrates both | Top-level |
40//!
41//! # Quick Start
42//!
43//! ```rust
44//! use loopctl::detection::convergence::{
45//! ConvergenceConfig, ConvergenceDetector, ConvergenceAction,
46//! };
47//!
48//! let config = ConvergenceConfig {
49//! enabled: true,
50//! window_size: 3,
51//! similarity_threshold: 0.9,
52//! on_converge: ConvergenceAction::Warn,
53//! };
54//! let mut detector = ConvergenceDetector::new(config)?;
55//!
56//! // Feed identical responses to trigger convergence
57//! let status = detector.add_response("I am working on the task.");
58//! assert!(!status.detected);
59//! let status = detector.add_response("I am working on the task.");
60//! let status = detector.add_response("I am working on the task.");
61//! assert!(status.detected); // 3 consecutive similar responses
62//! # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
63//! ```
64//!
65//! # Known Limitations
66//!
67//! This detector uses **Jaccard similarity** on whitespace-tokenized words:
68//!
69//! - **Semantic blindness**: Responses that are semantically identical but
70//! use different vocabulary will not be detected as convergent.
71//! - **Word-order sensitivity**: Rearranging words may reduce similarity
72//! below the threshold, even when the meaning is unchanged.
73//! - **Punctuation/whitespace sensitivity**: Tokenization is purely
74//! whitespace-based, so minor formatting changes can affect results.
75//! - **False positives**: Boilerplate-heavy responses with shared prefixes
76//! (e.g., "Let me check that for you...") may trigger false positives.
77//!
78//! Choose a conservative `threshold` (see [`ConvergenceConfig`]) to
79//! minimise false positives, and consider combining with
80//! [`LoopDetector`](super::LoopDetector) for complementary pattern detection.
81
82use std::collections::HashSet;
83use std::collections::VecDeque;
84
85use serde::{Deserialize, Serialize};
86
87// ===================================================
88// ConvergenceAction
89// ===================================================
90
91/// Action to take when convergence is detected.
92///
93/// When the [`ConvergenceDetector`] determines that the agent's recent responses
94/// are semantically similar, it returns a [`ConvergenceStatus`] carrying one
95/// of these actions. The caller (typically the `DetectionManager`) interprets
96/// the action to decide how to proceed.
97///
98/// The default is [`ConvergenceAction::Stop`], which halts the agent loop.
99///
100/// # Choosing an Action
101///
102/// | Scenario | Recommended Variant |
103/// |--------------------------------|-------------------------------------------------|
104/// | Unattended batch processing | [`Stop`](ConvergenceAction::Stop) |
105/// | Long-running daemon | [`Warn`](ConvergenceAction::Warn) |
106/// | Multi-phase pipeline | [`SwitchPhase`](ConvergenceAction::SwitchPhase) |
107/// | Interactive / REPL session | [`AskUser`](ConvergenceAction::AskUser) |
108/// | Long context / token budget | [`Compact`](ConvergenceAction::Compact) |
109///
110///
111/// # Example
112///
113/// ```rust
114/// use loopctl::detection::convergence::ConvergenceAction;
115///
116/// let action = ConvergenceAction::Warn;
117/// match action {
118/// ConvergenceAction::Stop => println!("Halting"),
119/// ConvergenceAction::Warn => println!("Continuing with warning"),
120/// ConvergenceAction::SwitchPhase => println!("Switching phase"),
121/// ConvergenceAction::AskUser => println!("Asking user"),
122/// ConvergenceAction::Compact => println!("Compacting history"),
123/// _ => println!("Other action"),
124/// }
125/// ```
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128#[non_exhaustive]
129pub enum ConvergenceAction {
130 /// Stop the agent loop entirely.
131 ///
132 /// The agent has converged and is unlikely to make further progress.
133 /// Default action — halt the agent loop and report the situation
134 /// to the user and await new instructions.
135 ///
136 /// The `DetectionManager` will
137 /// propagate this as a terminal signal, causing the outer agent loop
138 /// to exit cleanly.
139 #[default]
140 Stop,
141
142 /// Continue execution but emit a warning log.
143 ///
144 /// Useful in long-running sessions where occasional convergence is
145 /// acceptable but worth flagging for diagnostics. The agent loop
146 /// continues running; the warning is surfaced through the
147 /// logging pipeline.
148 Warn,
149
150 /// Switch to a different agent phase or strategy.
151 ///
152 /// The caller should transition the agent to an alternative processing
153 /// phase (e.g., from "exploration" to "refinement") to break out of
154 /// the converged pattern.
155 ///
156 /// The specific phase transition is left to the caller's discretion;
157 /// the detector signals that the current strategy has stagnated.
158 SwitchPhase,
159
160 /// Ask the user for guidance before continuing.
161 ///
162 /// Pauses execution and prompts the user to provide direction. Best
163 /// suited for interactive agent sessions where human oversight is
164 /// available.
165 ///
166 /// When this action is returned, the agent loop should yield control
167 /// back to the UI layer so the user can provide additional context
168 /// or modify the task prompt.
169 AskUser,
170
171 /// Compact the conversation history to free context window space.
172 ///
173 /// Triggers a summarization or truncation of the conversation history
174 /// to reduce token usage and break the converged pattern. The agent
175 /// loop should invoke its compaction strategy (e.g., summarizing older
176 /// turns, keeping only the most recent N exchanges, or pruning
177 /// low-relevance messages).
178 ///
179 /// Useful when convergence is caused by the agent repeatedly
180 /// revisiting earlier context — compaction removes the redundant
181 /// history that may be driving the repetition.
182 Compact,
183}
184
185// ===================================================
186// ConvergenceConfigError
187// ===================================================
188
189/// Error returned when [`ConvergenceConfig`] validation fails.
190///
191/// [`ConvergenceDetector::new`] validates the configuration before
192/// constructing a detector. If any constraint is violated, it returns
193/// one of these variants with enough context to produce an actionable
194/// diagnostic message.
195///
196/// # Example
197///
198/// ```rust
199/// use loopctl::detection::convergence::{ConvergenceConfig, ConvergenceDetector, ConvergenceConfigError};
200///
201/// let bad_config = ConvergenceConfig {
202/// window_size: 1,
203/// ..Default::default()
204/// };
205/// let err = ConvergenceDetector::new(bad_config).unwrap_err();
206/// assert!(matches!(err, ConvergenceConfigError::WindowTooSmall { actual: 1 }));
207/// ```
208#[derive(Debug, Clone, PartialEq, thiserror::Error)]
209pub enum ConvergenceConfigError {
210 /// `window_size` is below the minimum of 2.
211 ///
212 /// Convergence requires at least one pair of consecutive responses,
213 /// so a window of 1 (or 0) is meaningless.
214 #[error("window_size must be at least 2, got {actual}")]
215 WindowTooSmall {
216 /// The invalid window size that was provided.
217 actual: usize,
218 },
219
220 /// `similarity_threshold` is outside the valid range `[0.0, 1.0]`.
221 ///
222 /// Jaccard similarity always produces a value in this range; a
223 /// threshold outside it would never (or always) trigger.
224 #[error("similarity_threshold must be in [0.0, 1.0], got {actual}")]
225 ThresholdOutOfRange {
226 /// The invalid threshold value that was provided.
227 actual: f32,
228 },
229}
230
231// ===================================================
232// ConvergenceConfig
233// ===================================================
234
235/// Configuration for convergence detection.
236///
237/// Controls the sensitivity and behavior of the [`ConvergenceDetector`].
238/// Passed to [`ConvergenceDetector::new`] at construction time.
239///
240/// # Defaults
241///
242/// | Field | Default |
243/// |-----------------------|----------|
244/// | `enabled` | `true` |
245/// | `window_size` | `3` |
246/// | `similarity_threshold`| `0.95` |
247/// | `on_converge` | `Stop` |
248///
249/// # Tuning Guidelines
250///
251/// - **Lower `similarity_threshold`** (e.g., `0.8`) catches paraphrased
252/// repetitions but may produce false positives when the agent naturally
253/// revisits topics.
254/// - **Larger `window_size`** (e.g., `5`) requires longer streaks before
255/// declaring convergence, reducing sensitivity to brief repetitions.
256/// - **Combine with [`ConvergenceAction::Warn`]** during development to
257/// calibrate thresholds before switching to [`ConvergenceAction::Stop`].
258///
259/// # Example
260///
261/// ```rust
262/// use loopctl::detection::convergence::{ConvergenceConfig, ConvergenceAction, ConvergenceDetector};
263///
264/// let config = ConvergenceConfig {
265/// enabled: true,
266/// window_size: 5,
267/// similarity_threshold: 0.85,
268/// on_converge: ConvergenceAction::Warn,
269/// };
270/// let detector = ConvergenceDetector::new(config)?;
271/// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
272/// ```
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct ConvergenceConfig {
275 /// Whether convergence detection is active. Defaults to `true`.
276 pub enabled: bool,
277 /// Consecutive similar responses required to declare convergence. Must be ≥ 2. Defaults to `3`.
278 pub window_size: usize,
279 /// Jaccard similarity threshold (0.0–1.0). Defaults to `0.95`.
280 pub similarity_threshold: f32,
281 /// Action on convergence. Defaults to [`ConvergenceAction::Stop`].
282 #[serde(default)]
283 pub on_converge: ConvergenceAction,
284}
285
286impl Default for ConvergenceConfig {
287 /// Produce a configuration with production-ready defaults.
288 ///
289 /// The defaults are tuned for typical agent workloads:
290 ///
291 /// | Field | Default |
292 /// |-------------------------------------------------------------------|-----------------------------|
293 /// | [`enabled`](ConvergenceConfig::enabled) | `true` |
294 /// | [`window_size`](ConvergenceConfig::window_size) | `3` |
295 /// | [`similarity_threshold`](ConvergenceConfig::similarity_threshold) | `0.95` |
296 /// | [`on_converge`](ConvergenceConfig::on_converge) | [`ConvergenceAction::Stop`] |
297 ///
298 /// # Example
299 ///
300 /// ```rust
301 /// use loopctl::detection::convergence::ConvergenceConfig;
302 ///
303 /// let config = ConvergenceConfig::default();
304 /// assert!(config.enabled);
305 /// assert_eq!(config.window_size, 3);
306 /// ```
307 fn default() -> Self {
308 Self {
309 enabled: true,
310 window_size: 3,
311 similarity_threshold: 0.95,
312 on_converge: ConvergenceAction::Stop,
313 }
314 }
315}
316
317// ===================================================
318// ConvergenceStatus
319// ===================================================
320
321/// Convergence status with details.
322///
323/// Returned by [`ConvergenceDetector::add_response`] and
324/// [`ConvergenceDetector::check_convergence`] to communicate whether
325/// convergence was detected and, if so, the evidence and recommended action.
326///
327/// # Construction
328///
329/// Use [`ConvergenceStatus::no_convergence`] to create a "no convergence"
330/// sentinel. A "converged" status is returned by the detector
331/// when the [`ConvergenceConfig::window_size`] threshold is met.
332///
333/// # Example
334///
335/// ```rust
336/// use loopctl::detection::convergence::{ConvergenceConfig, ConvergenceDetector};
337///
338/// let config = ConvergenceConfig {
339/// window_size: 3,
340/// similarity_threshold: 0.5,
341/// ..Default::default()
342/// };
343/// let mut detector = ConvergenceDetector::new(config)?;
344///
345/// let response = "same text";
346/// let status = detector.add_response(response);
347/// if status.detected {
348/// println!("Converged after {} turns", status.consecutive_count);
349/// println!("Similarity: {:.2}%", status.similarity_score * 100.0);
350/// println!("Action: {:?}", status.action);
351/// }
352/// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
353/// ```
354#[derive(Debug, Clone, Default)]
355pub struct ConvergenceStatus {
356 /// `true` when `consecutive_count >= window_size`. Other fields still valid when `false`.
357 pub detected: bool,
358 /// Resets to `1` when similarity falls below threshold.
359 pub consecutive_count: usize,
360 /// Highest Jaccard similarity (0.0–1.0). `0.0` when no comparison was made.
361 pub similarity_score: f32,
362 /// Responses that exceeded the similarity threshold. Cleared on dissimilar response.
363 pub similar_responses: Vec<String>,
364 /// Forwarded from [`ConvergenceConfig::on_converge`]; see [`ConvergenceAction`].
365 pub action: ConvergenceAction,
366}
367
368impl ConvergenceStatus {
369 /// Create a status indicating no convergence.
370 ///
371 /// Returns a [`ConvergenceStatus`] with `detected = false`,
372 /// `consecutive_count = 0`, `similarity_score = 0.0`, and empty
373 /// `similar_responses`. The `action` defaults to
374 /// [`ConvergenceAction::Stop`] but is irrelevant since `detected` is
375 /// `false`.
376 ///
377 /// # Example
378 ///
379 /// ```rust
380 /// use loopctl::detection::convergence::ConvergenceStatus;
381 ///
382 /// let status = ConvergenceStatus::no_convergence();
383 /// assert!(!status.detected);
384 /// assert_eq!(status.consecutive_count, 0);
385 /// ```
386 #[must_use]
387 pub fn no_convergence() -> Self {
388 Self {
389 detected: false,
390 consecutive_count: 0,
391 similarity_score: 0.0,
392 similar_responses: Vec::new(),
393 action: ConvergenceAction::Stop,
394 }
395 }
396}
397
398// ===================================================
399// ConvergenceDetector
400// ===================================================
401
402/// Detects when agent responses have converged (become semantically similar).
403///
404/// Maintains a sliding window of recent response strings and computes
405/// pairwise Jaccard similarity to determine whether the agent is stuck
406/// producing near-identical output. When enough consecutive responses
407/// exceed the configured similarity threshold, convergence is reported.
408///
409/// # Construction
410///
411/// Prefer [`ConvergenceDetector::new`] with a custom [`ConvergenceConfig`],
412/// or [`ConvergenceDetector::default_detector`] for sensible defaults.
413///
414/// # Example
415///
416/// ```rust
417/// use loopctl::detection::convergence::ConvergenceDetector;
418///
419/// let mut detector = ConvergenceDetector::default_detector()?;
420///
421/// // Feed responses — identical content triggers convergence
422/// let s1 = detector.add_response("Reading file contents...");
423/// assert!(!s1.detected);
424/// let s2 = detector.add_response("Reading file contents...");
425/// let s3 = detector.add_response("Reading file contents...");
426/// assert!(s3.detected);
427///
428/// // Reset for a fresh start
429/// detector.clear();
430/// assert!(detector.window().is_empty());
431/// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
432/// ```
433#[derive(Debug)]
434pub struct ConvergenceDetector {
435 /// Immutable config set at construction.
436 config: ConvergenceConfig,
437 /// Bounded by `window_size`, ordered oldest→newest.
438 pub(super) window: VecDeque<String>,
439 /// Resets to `1` on dissimilar response; convergence at `window_size`.
440 pub(super) consecutive_count: usize,
441 /// Deduplicated similar responses; cleared when streak breaks.
442 similar_responses: Vec<String>,
443}
444
445impl ConvergenceDetector {
446 /// Create a new convergence detector with the given configuration.
447 ///
448 /// Validates the configuration before constructing the detector.
449 /// Returns [`ConvergenceConfigError::WindowTooSmall`] if
450 /// [`ConvergenceConfig::window_size`] is less than 2, or
451 /// [`ConvergenceConfigError::ThresholdOutOfRange`] if
452 /// [`ConvergenceConfig::similarity_threshold`] is outside `[0.0, 1.0]`.
453 ///
454 /// The detector starts with an empty window and a zero consecutive
455 /// count — no convergence can be detected until at least
456 /// `window_size` responses have been added.
457 ///
458 /// # Example
459 ///
460 /// ```rust
461 /// use loopctl::detection::convergence::{ConvergenceConfig, ConvergenceDetector};
462 ///
463 /// let config = ConvergenceConfig {
464 /// window_size: 5,
465 /// similarity_threshold: 0.85,
466 /// ..Default::default()
467 /// };
468 /// let detector = ConvergenceDetector::new(config)?;
469 /// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
470 /// ```
471 ///
472 /// # Errors
473 ///
474 /// Returns [`ConvergenceConfigError::WindowTooSmall`] if `window_size < 2`,
475 /// or [`ConvergenceConfigError::ThresholdOutOfRange`]
476 /// if `similarity_threshold` is outside `[0.0, 1.0]`.
477 pub fn new(config: ConvergenceConfig) -> Result<Self, ConvergenceConfigError> {
478 if config.window_size < 2 {
479 return Err(ConvergenceConfigError::WindowTooSmall {
480 actual: config.window_size,
481 });
482 }
483 if !(0.0..=1.0).contains(&config.similarity_threshold) {
484 return Err(ConvergenceConfigError::ThresholdOutOfRange {
485 actual: config.similarity_threshold,
486 });
487 }
488 let capacity = config.window_size;
489 Ok(Self {
490 config,
491 window: VecDeque::with_capacity(capacity),
492 consecutive_count: 0,
493 similar_responses: Vec::new(),
494 })
495 }
496
497 /// Create a detector with default configuration.
498 ///
499 /// Equivalent to `ConvergenceDetector::new(ConvergenceConfig::default())`.
500 /// Uses a window of 3 and a similarity threshold of 0.95.
501 ///
502 /// Prefer this for quick prototyping; switch to
503 /// [`ConvergenceDetector::new`] when you need custom thresholds.
504 ///
505 /// # Default Values
506 ///
507 /// | Setting | Value |
508 /// |-----------------------|--------|
509 /// | `enabled` | `true` |
510 /// | `window_size` | `3` |
511 /// | `similarity_threshold`| `0.95` |
512 /// | `on_converge` | `Stop` |
513 ///
514 /// # Example
515 ///
516 /// ```rust
517 /// use loopctl::detection::convergence::ConvergenceDetector;
518 ///
519 /// let detector = ConvergenceDetector::default_detector()?;
520 /// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
521 /// ```
522 ///
523 /// # Errors
524 ///
525 /// Returns [`ConvergenceConfigError`] if the default config fails
526 /// validation.
527 pub fn default_detector() -> Result<Self, ConvergenceConfigError> {
528 Self::new(ConvergenceConfig::default())
529 }
530
531 /// Add a response and check for convergence in one step.
532 ///
533 /// Primary entry point. The response is compared against
534 /// every prior response in the window. If any comparison exceeds
535 /// [`ConvergenceConfig::similarity_threshold`], the consecutive count
536 /// is incremented; otherwise it resets to `1`.
537 ///
538 /// When the consecutive count reaches [`ConvergenceConfig::window_size`],
539 /// `detected` is set to `true` in the returned [`ConvergenceStatus`].
540 ///
541 /// # Returns
542 ///
543 /// A [`ConvergenceStatus`] indicating whether convergence was detected,
544 /// the current streak count, the highest similarity score, and the
545 /// recommended action.
546 ///
547 /// # Early Returns
548 ///
549 /// If [`ConvergenceConfig::enabled`] is `false` or `response` is empty,
550 /// returns [`ConvergenceStatus::no_convergence`] immediately.
551 ///
552 /// # Example
553 ///
554 /// ```rust
555 /// use loopctl::detection::convergence::ConvergenceDetector;
556 ///
557 /// let mut detector = ConvergenceDetector::default_detector()?;
558 /// let status = detector.add_response("Working on task...");
559 /// assert!(!status.detected); // First response, no comparison possible
560 /// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
561 /// ```
562 pub fn add_response(&mut self, response: &str) -> ConvergenceStatus {
563 if !self.config.enabled || response.is_empty() {
564 return ConvergenceStatus::no_convergence();
565 }
566
567 let mut max_similarity = 0.0;
568 for prev_response in &self.window {
569 let similarity = Self::compute_similarity(response, prev_response);
570 if similarity > max_similarity {
571 max_similarity = similarity;
572 }
573 }
574
575 let prev_is_similar = match self.window.back() {
576 Some(prev) => {
577 let sim = Self::compute_similarity(response, prev);
578 if sim >= self.config.similarity_threshold {
579 if !self.similar_responses.contains(&response.to_string()) {
580 self.similar_responses.push(response.to_string());
581 }
582 true
583 } else {
584 false
585 }
586 }
587 None => false,
588 };
589
590 if self.window.is_empty() {
591 self.consecutive_count = 1;
592 self.similar_responses.push(response.to_string());
593 } else if prev_is_similar {
594 self.consecutive_count = self.consecutive_count.saturating_add(1);
595 } else {
596 self.consecutive_count = 1;
597 self.similar_responses.clear();
598 self.similar_responses.push(response.to_string());
599 }
600
601 if self.window.len() >= self.config.window_size {
602 self.window.pop_front();
603 }
604 self.window.push_back(response.to_string());
605
606 let detected = self.consecutive_count >= self.config.window_size;
607
608 ConvergenceStatus {
609 detected,
610 consecutive_count: self.consecutive_count,
611 similarity_score: max_similarity,
612 similar_responses: self.similar_responses.clone(),
613 action: self.config.on_converge,
614 }
615 }
616
617 /// Check for convergence without adding a new response.
618 ///
619 /// Inspects the current window and consecutive count to determine
620 /// whether convergence has already been reached. Does not modify
621 /// detector state.
622 ///
623 /// # Returns
624 ///
625 /// A [`ConvergenceStatus`] reflecting the current detector state.
626 /// Note that `similarity_score` is `0.0` because no new comparison
627 /// is performed.
628 ///
629 /// # Example
630 ///
631 /// ```rust
632 /// use loopctl::detection::convergence::{ConvergenceConfig, ConvergenceDetector};
633 ///
634 /// let config = ConvergenceConfig {
635 /// window_size: 3,
636 /// similarity_threshold: 0.95,
637 /// ..Default::default()
638 /// };
639 /// let mut detector = ConvergenceDetector::new(config)?;
640 /// detector.add_response("Response one about apples");
641 /// detector.add_response("Response two about oranges");
642 /// detector.add_response("Response three about bananas");
643 /// let status = detector.check_convergence();
644 /// assert!(!status.detected);
645 /// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
646 /// ```
647 #[must_use]
648 pub fn check_convergence(&self) -> ConvergenceStatus {
649 if !self.config.enabled {
650 return ConvergenceStatus::no_convergence();
651 }
652
653 if self.window.len() < self.config.window_size {
654 return ConvergenceStatus::no_convergence();
655 }
656
657 ConvergenceStatus {
658 detected: self.consecutive_count >= self.config.window_size,
659 consecutive_count: self.consecutive_count,
660 similarity_score: 0.0,
661 similar_responses: self.similar_responses.clone(),
662 action: self.config.on_converge,
663 }
664 }
665
666 /// Clear all detection state.
667 ///
668 /// Resets the sliding window, consecutive count, and accumulated
669 /// similar responses. Useful at the start of a new task or after
670 /// the agent has been re-prompted.
671 ///
672 /// # Example
673 ///
674 /// ```rust
675 /// use loopctl::detection::convergence::ConvergenceDetector;
676 ///
677 /// let mut detector = ConvergenceDetector::default_detector()?;
678 /// detector.add_response("task in progress");
679 /// detector.clear();
680 /// assert!(detector.window().is_empty());
681 /// assert_eq!(detector.consecutive_count(), 0);
682 /// # Ok::<(), loopctl::detection::convergence::ConvergenceConfigError>(())
683 /// ```
684 pub fn clear(&mut self) {
685 self.window.clear();
686 self.consecutive_count = 0;
687 self.similar_responses.clear();
688 }
689
690 /// Get a reference to the current sliding window of responses.
691 ///
692 /// Primarily useful for testing and diagnostics — consumers can inspect
693 /// what the detector has seen without modifying state.
694 ///
695 /// # Returns
696 ///
697 /// A reference to the [`VecDeque`] of response strings.
698 /// The deque is ordered from oldest to newest.
699 #[must_use]
700 pub fn window(&self) -> &VecDeque<String> {
701 &self.window
702 }
703
704 /// Get a reference to the current configuration.
705 ///
706 /// Allows consumers to inspect thresholds and the configured action
707 /// without needing to store a separate copy of the config.
708 ///
709 /// # Returns
710 ///
711 /// A reference to the [`ConvergenceConfig`] this detector was created with.
712 /// The config is immutable for the lifetime of the detector.
713 #[must_use]
714 pub fn config(&self) -> &ConvergenceConfig {
715 &self.config
716 }
717
718 /// Get the number of consecutive similar responses.
719 ///
720 /// Current streak counter. When it reaches
721 /// [`ConvergenceConfig::window_size`], convergence is detected.
722 ///
723 /// # Returns
724 ///
725 /// The current consecutive-similar count. Returns `0` if no responses
726 /// have been added yet.
727 #[must_use]
728 pub fn consecutive_count(&self) -> usize {
729 self.consecutive_count
730 }
731
732 /// Compute the Jaccard similarity between two strings.
733 ///
734 /// Normalizes both strings (lowercased, non-alphanumeric replaced with
735 /// spaces), splits into word sets, then computes the ratio of
736 /// intersection size to union size.
737 ///
738 /// Returns `0.0` if either input is empty.
739 #[allow(clippy::cast_precision_loss)]
740 #[must_use]
741 pub fn compute_similarity(a: &str, b: &str) -> f32 {
742 if a.is_empty() || b.is_empty() {
743 return 0.0;
744 }
745
746 let a_norm = Self::normalize_text(a);
747 let b_norm = Self::normalize_text(b);
748
749 // Use Jaccard similarity on words
750 let a_words: HashSet<&str> = a_norm.split_whitespace().collect();
751 let b_words: HashSet<&str> = b_norm.split_whitespace().collect();
752
753 if a_words.is_empty() || b_words.is_empty() {
754 return 0.0;
755 }
756
757 let intersection = a_words.intersection(&b_words).count();
758 let union = a_words.union(&b_words).count();
759
760 if union == 0 {
761 return 0.0;
762 }
763
764 intersection as f32 / union as f32
765 }
766
767 fn normalize_text(text: &str) -> String {
768 text.to_lowercase()
769 .chars()
770 .map(|c| if c.is_alphanumeric() { c } else { ' ' })
771 .collect()
772 }
773}
774
775impl Default for ConvergenceDetector {
776 /// Produce a [`ConvergenceDetector`] with the default [`ConvergenceConfig`].
777 ///
778 /// This impl exists so that parent types (e.g. [`DetectionManager`]) can
779 /// derive or delegate `Default` without going through a fallible constructor.
780 ///
781 /// [`DetectionManager`]: crate::detection::manager::DetectionManager
782 fn default() -> Self {
783 let config = ConvergenceConfig::default();
784 let window_capacity = config.window_size;
785 Self {
786 config,
787 window: VecDeque::with_capacity(window_capacity),
788 consecutive_count: 0,
789 similar_responses: Vec::new(),
790 }
791 }
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797
798 #[test]
799 fn test_convergence_detection() {
800 let config = ConvergenceConfig {
801 window_size: 3,
802 similarity_threshold: 0.5,
803 ..Default::default()
804 };
805 let mut detector = ConvergenceDetector::new(config).unwrap();
806 let status1 = detector.add_response("alpha");
807 assert!(!status1.detected, "first response: no comparison possible");
808 assert_eq!(
809 status1.consecutive_count, 1,
810 "first response: starts a streak of 1"
811 );
812
813 let status2 = detector.add_response("beta");
814 assert!(!status2.detected, "second response: different from first");
815 assert_eq!(
816 status2.consecutive_count, 1,
817 "second response: new streak of 1"
818 );
819
820 let status3 = detector.add_response("beta");
821 assert!(!status3.detected, "third response: streak of 2, need 3");
822 assert_eq!(status3.consecutive_count, 2);
823
824 let status4 = detector.add_response("beta");
825 assert!(
826 status4.detected,
827 "fourth response: three consecutive 'beta'"
828 );
829 assert!(status4.consecutive_count >= 3);
830 }
831
832 #[test]
833 fn test_no_convergence_different_responses() {
834 let config = ConvergenceConfig {
835 window_size: 3,
836 similarity_threshold: 0.95,
837 ..Default::default()
838 };
839 let mut detector = ConvergenceDetector::new(config).unwrap();
840 detector.add_response("Response one about apples");
841 detector.add_response("Response two about oranges");
842 detector.add_response("Response three about bananas");
843 let status = detector.check_convergence();
844 assert!(!status.detected);
845 }
846
847 #[test]
848 fn test_convergence_disabled() {
849 let config = ConvergenceConfig {
850 enabled: false,
851 ..Default::default()
852 };
853 let mut detector = ConvergenceDetector::new(config).unwrap();
854 detector.add_response("Same");
855 detector.add_response("Same");
856 detector.add_response("Same");
857 let status = detector.check_convergence();
858 assert!(!status.detected);
859 }
860
861 #[test]
862 fn test_similarity_computation() {
863 let sim = ConvergenceDetector::compute_similarity("hello world", "hello world");
864 assert!(sim > 0.99);
865 let sim = ConvergenceDetector::compute_similarity("hello world", "hello there");
866 assert!(sim > 0.3 && sim < 0.7);
867 let sim = ConvergenceDetector::compute_similarity("hello world", "goodbye moon");
868 assert!(sim < 0.3);
869 }
870
871 #[test]
872 fn test_clear_detector() {
873 let mut detector = ConvergenceDetector::default_detector().unwrap();
874 detector.add_response("Test");
875 detector.add_response("Test");
876 assert!(!detector.window.is_empty());
877 detector.clear();
878 assert!(detector.window.is_empty());
879 assert_eq!(detector.consecutive_count, 0);
880 }
881
882 #[test]
883 fn test_config_window_too_small() {
884 let config = ConvergenceConfig {
885 window_size: 1,
886 ..Default::default()
887 };
888 let err = ConvergenceDetector::new(config).unwrap_err();
889 assert!(matches!(
890 err,
891 ConvergenceConfigError::WindowTooSmall { actual: 1 }
892 ));
893 assert!(err.to_string().contains("at least 2"));
894 }
895
896 #[test]
897 fn test_config_window_zero() {
898 let config = ConvergenceConfig {
899 window_size: 0,
900 ..Default::default()
901 };
902 let err = ConvergenceDetector::new(config).unwrap_err();
903 assert!(matches!(
904 err,
905 ConvergenceConfigError::WindowTooSmall { actual: 0 }
906 ));
907 }
908
909 #[test]
910 fn test_config_threshold_too_high() {
911 let config = ConvergenceConfig {
912 similarity_threshold: 1.5,
913 ..Default::default()
914 };
915 let err = ConvergenceDetector::new(config).unwrap_err();
916 assert!(matches!(
917 err,
918 ConvergenceConfigError::ThresholdOutOfRange { .. }
919 ));
920 assert!(err.to_string().contains("[0.0, 1.0]"));
921 }
922
923 #[test]
924 fn test_config_threshold_negative() {
925 let config = ConvergenceConfig {
926 similarity_threshold: -0.1,
927 ..Default::default()
928 };
929 let err = ConvergenceDetector::new(config).unwrap_err();
930 assert!(matches!(
931 err,
932 ConvergenceConfigError::ThresholdOutOfRange { .. }
933 ));
934 }
935
936 #[test]
937 fn test_streak_resets_on_dissimilar_response() {
938 let config = ConvergenceConfig {
939 window_size: 3,
940 similarity_threshold: 0.5,
941 ..Default::default()
942 };
943 let mut detector = ConvergenceDetector::new(config).unwrap();
944
945 // Two similar responses build a streak of 2.
946 let s1 = detector.add_response("same text");
947 assert_eq!(s1.consecutive_count, 1);
948 let s2 = detector.add_response("same text");
949 assert_eq!(s2.consecutive_count, 2);
950
951 // A dissimilar response breaks the streak and resets to 1.
952 let s3 = detector.add_response("completely different content here");
953 assert_eq!(
954 s3.consecutive_count, 1,
955 "dissimilar response should reset streak"
956 );
957
958 // Another similar-to-immediately-previous response starts fresh.
959 let s4 = detector.add_response("completely different content here");
960 assert_eq!(s4.consecutive_count, 2);
961 }
962
963 #[test]
964 fn test_alternating_responses_never_converge() {
965 // The streak must only compare against the *immediately previous*
966 // response, not any similar response in the window. Alternating
967 // A / B / A / B should never build a streak longer than 1.
968 let config = ConvergenceConfig {
969 window_size: 3,
970 similarity_threshold: 0.5,
971 ..Default::default()
972 };
973 let mut detector = ConvergenceDetector::new(config).unwrap();
974
975 detector.add_response("alpha alpha alpha");
976 detector.add_response("beta beta beta");
977 detector.add_response("alpha alpha alpha");
978 detector.add_response("beta beta beta");
979 detector.add_response("alpha alpha alpha");
980
981 // Even though "alpha" appeared 3 times, they were never consecutive,
982 // so the streak should never reach window_size.
983 let status = detector.check_convergence();
984 assert!(
985 !status.detected,
986 "alternating responses must not trigger convergence"
987 );
988 assert!(
989 status.consecutive_count <= 1,
990 "alternating responses should not build a streak"
991 );
992 }
993
994 #[test]
995 fn test_similar_after_gap_starts_fresh_streak() {
996 // A, A (streak=2), B (streak resets to 1), A (streak=1, NOT 3)
997 let config = ConvergenceConfig {
998 window_size: 3,
999 similarity_threshold: 0.5,
1000 ..Default::default()
1001 };
1002 let mut detector = ConvergenceDetector::new(config).unwrap();
1003
1004 detector.add_response("same text");
1005 let s2 = detector.add_response("same text");
1006 assert_eq!(s2.consecutive_count, 2);
1007
1008 detector.add_response("totally different stuff");
1009
1010 // "same text" again — similar to the *previous* response? No.
1011 // Previous was "totally different stuff", so streak should be 1.
1012 let s4 = detector.add_response("same text");
1013 assert_eq!(
1014 s4.consecutive_count, 1,
1015 "similar response after a gap should start a fresh streak"
1016 );
1017 }
1018
1019 #[test]
1020 fn test_converge_then_break_then_re_converge() {
1021 let config = ConvergenceConfig {
1022 window_size: 3,
1023 similarity_threshold: 0.5,
1024 ..Default::default()
1025 };
1026 let mut detector = ConvergenceDetector::new(config).unwrap();
1027
1028 // Build to convergence.
1029 detector.add_response("loop loop loop");
1030 detector.add_response("loop loop loop");
1031 let s3 = detector.add_response("loop loop loop");
1032 assert!(s3.detected);
1033
1034 // Break the streak.
1035 let s4 = detector.add_response("something entirely new and different");
1036 assert!(!s4.detected);
1037
1038 // Build back up — need 3 consecutive again, not just 1 more.
1039 let s5 = detector.add_response("something entirely new and different");
1040 assert!(!s5.detected, "only 2 consecutive so far");
1041
1042 let s6 = detector.add_response("something entirely new and different");
1043 assert!(s6.detected, "3 consecutive of the new response");
1044 }
1045
1046 #[test]
1047 fn test_config_threshold_boundary_valid() {
1048 // 0.0 and 1.0 are valid boundary values
1049 let config_zero = ConvergenceConfig {
1050 similarity_threshold: 0.0,
1051 ..Default::default()
1052 };
1053 assert!(ConvergenceDetector::new(config_zero).is_ok());
1054
1055 let config_one = ConvergenceConfig {
1056 similarity_threshold: 1.0,
1057 ..Default::default()
1058 };
1059 assert!(ConvergenceDetector::new(config_one).is_ok());
1060 }
1061}