Skip to main content

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}