Skip to main content

larc_loops/
lib.rs

1//! # larc-loops
2//!
3//! Agentic loop convergence traits — strategy-agnostic interfaces for deciding
4//! when to halt, continue, or escalate an agentic execution loop.
5//!
6//! Provides [`ConvergenceResult`] and six convergence strategy traits. Implement
7//! whichever fits your loop topology; compose multiple traits for hybrid strategies.
8//!
9//! ## Traits
10//!
11//! | Trait | Topology |
12//! |-------|----------|
13//! | [`BlastScore`] | Compound risk/score threshold |
14//! | [`ConvergenceGate`] | Gate-by-gate with phase-back |
15//! | [`NPassVerifier`] | N independent verification rounds |
16//! | [`QueueDrain`] | Bounded queue exhaustion |
17//! | [`InterestDecay`] | Simulated annealing cooling |
18//! | [`IntervalWatch`] | Interval polling with deadline |
19//!
20//! ## Quick start
21//!
22//! ```rust
23//! use larc_loops::{ConvergenceResult, BlastScore};
24//!
25//! struct ScoreCheck;
26//! impl BlastScore for ScoreCheck {
27//!     fn check(&self, score: f64, threshold: f64) -> ConvergenceResult {
28//!         if score <= threshold {
29//!             ConvergenceResult::Converged
30//!         } else {
31//!             ConvergenceResult::Blocked {
32//!                 reason: format!("blast score {score:.2} > {threshold:.2}"),
33//!             }
34//!         }
35//!     }
36//! }
37//! ```
38
39// ── ConvergenceResult ─────────────────────────────────────────────────────────
40
41/// Outcome of a single convergence check.
42#[derive(Debug, Clone, PartialEq)]
43pub enum ConvergenceResult {
44    /// Loop should continue — threshold not yet reached.
45    Continue,
46    /// Loop has converged — enough evidence to halt or advance.
47    Converged,
48    /// Loop cannot converge on this path — caller should phase-back or abort.
49    Blocked { reason: String },
50}
51
52impl ConvergenceResult {
53    /// Returns `true` if the result is [`Converged`](Self::Converged).
54    #[must_use]
55    pub fn is_converged(&self) -> bool {
56        matches!(self, Self::Converged)
57    }
58
59    /// Returns `true` if the result is [`Blocked`](Self::Blocked).
60    #[must_use]
61    pub fn is_blocked(&self) -> bool {
62        matches!(self, Self::Blocked { .. })
63    }
64}
65
66// ── BlastScore ────────────────────────────────────────────────────────────────
67
68/// Convergence via compound risk scoring.
69///
70/// The loop converges when accumulated evidence or a normalised risk estimate
71/// crosses a caller-defined threshold. Suitable for FAIR/Bowtie-style blast
72/// score models where confidence accumulates incrementally.
73pub trait BlastScore: Send + Sync {
74    /// Evaluate whether the current blast score satisfies the convergence threshold.
75    ///
76    /// `score` is a `[0.0, 1.0]` normalised risk estimate; `threshold` is the
77    /// minimum score at which the loop should advance.
78    fn check(&self, score: f64, threshold: f64) -> ConvergenceResult;
79}
80
81// ── ConvergenceGate ───────────────────────────────────────────────────────────
82
83/// Gate-by-gate convergence with phase-back capability.
84///
85/// Each gate must pass before the next can be evaluated. A failing gate
86/// returns `Blocked` with the owning gate label, allowing the caller to
87/// phase-back to the correct remediation point.
88pub trait ConvergenceGate: Send + Sync {
89    /// Evaluate a single gate pass.
90    ///
91    /// Returns [`Converged`](ConvergenceResult::Converged) if the gate passes,
92    /// or [`Blocked`](ConvergenceResult::Blocked) with the owning gate label if not.
93    fn evaluate_gate(&self, gate_label: &str, passed: bool) -> ConvergenceResult;
94}
95
96// ── NPassVerifier ─────────────────────────────────────────────────────────────
97
98/// Convergence via N independent verification rounds.
99///
100/// The loop converges when `required_passes` rounds all succeed (unanimous),
101/// or when a majority threshold is reached under a configurable policy.
102/// Suitable for adversarial verification where a single pass may be unreliable.
103pub trait NPassVerifier: Send + Sync {
104    /// Record the result of pass `pass_index` and check whether convergence is reached.
105    ///
106    /// `pass_index` is 0-based. `passed` indicates whether this pass succeeded.
107    /// `required_passes` is the total number of passes required for convergence.
108    fn record_pass(&self, pass_index: u32, passed: bool, required_passes: u32)
109        -> ConvergenceResult;
110}
111
112// ── QueueDrain ────────────────────────────────────────────────────────────────
113
114/// Convergence via bounded queue exhaustion.
115///
116/// The loop converges when the processing queue is empty or falls below a
117/// caller-defined residual threshold. Suitable for work-stealing or
118/// fan-out loops that drain a finite item set.
119pub trait QueueDrain: Send + Sync {
120    /// Check whether the queue has drained to the point of convergence.
121    ///
122    /// `remaining` is the number of unprocessed items. `initial_size` is the
123    /// queue size at the start of the drain loop.
124    fn check_drained(&self, remaining: usize, initial_size: usize) -> ConvergenceResult;
125}
126
127// ── InterestDecay ─────────────────────────────────────────────────────────────
128
129/// Convergence via simulated annealing cooling (Kirkpatrick 1983).
130#[doc(hidden)] // Not yet assigned to a strategy; reserved for future use.
131///
132/// Models a "temperature" that decreases over time, making convergence
133/// progressively more likely as the loop explores the solution space.
134///
135/// Not yet assigned to a specific strategy; reserved for future use.
136pub trait InterestDecay: Send + Sync {
137    /// Evaluate whether the current temperature has cooled to the convergence threshold.
138    ///
139    /// `temperature` decreases monotonically. `min_temperature` is the threshold
140    /// at which the loop is considered converged.
141    fn check_cooled(&self, temperature: f64, min_temperature: f64) -> ConvergenceResult;
142}
143
144// ── IntervalWatch ─────────────────────────────────────────────────────────────
145
146/// Convergence via classic interval polling.
147///
148/// Models a watched condition that is polled on a fixed interval; convergence
149/// occurs when the condition becomes true within a deadline.
150///
151/// Not yet assigned to a specific strategy; reserved for future use.
152#[doc(hidden)] // Not yet assigned to a strategy; reserved for future use.
153pub trait IntervalWatch: Send + Sync {
154    /// Check whether the watched condition has become true.
155    ///
156    /// `elapsed_ms` is the total time elapsed since the watch began.
157    /// `deadline_ms` is the maximum time before the watch expires.
158    fn check_condition(&self, elapsed_ms: u64, deadline_ms: u64) -> ConvergenceResult;
159}
160
161// ── Tests ─────────────────────────────────────────────────────────────────────
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    struct ScoreCheck;
168    impl BlastScore for ScoreCheck {
169        fn check(&self, score: f64, threshold: f64) -> ConvergenceResult {
170            if score <= threshold {
171                ConvergenceResult::Converged
172            } else {
173                ConvergenceResult::Blocked {
174                    reason: format!("score {score:.2} exceeds threshold {threshold:.2}"),
175                }
176            }
177        }
178    }
179
180    #[test]
181    fn blast_score_converges_at_or_below_threshold() {
182        let b = ScoreCheck;
183        assert_eq!(b.check(0.5, 0.5), ConvergenceResult::Converged);
184        assert_eq!(b.check(0.4, 0.5), ConvergenceResult::Converged);
185    }
186
187    #[test]
188    fn blast_score_blocks_above_threshold() {
189        let b = ScoreCheck;
190        assert!(matches!(
191            b.check(0.9, 0.5),
192            ConvergenceResult::Blocked { .. }
193        ));
194    }
195
196    #[test]
197    fn convergence_result_helpers() {
198        assert!(ConvergenceResult::Converged.is_converged());
199        assert!(!ConvergenceResult::Continue.is_converged());
200        assert!(ConvergenceResult::Blocked { reason: "x".into() }.is_blocked());
201        assert!(!ConvergenceResult::Converged.is_blocked());
202    }
203
204    #[test]
205    fn convergence_result_clone_and_eq() {
206        let r = ConvergenceResult::Blocked {
207            reason: "test".into(),
208        };
209        assert_eq!(r.clone(), r);
210    }
211}