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}