Skip to main content

lmm_agent/cognition/
loop.rs

1// Copyright 2026 Mahmoud Harmouch.
2//
3// Licensed under the MIT license
4// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
5// option. This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! # `ThinkLoop` - the PID-style closed-loop controller.
9//!
10//! `ThinkLoop` implements a **discrete-time feedback control system** where:
11//!
12//! - The **setpoint** is the agent's natural-language goal.
13//! - The **plant** is the `SearchOracle` (DuckDuckGo or offline stub).
14//! - The **error signal** is the Jaccard token-distance between goal and observation.
15//! - The **controller** is a PI (proportional + integral) gain schedule.
16//! - The **feedback path** is the reward-weighted memory update.
17//!
18//! ## Control Law
19//!
20//! ```text
21//! e(k)   = 1 - Jaccard(goal, y(k))          // error
22//! I(k)   = clamp(I(k-1) + e(k), 0, 100)     // integral (anti-windup)
23//! K(k)   = clamp(Kp + Ki·I(k), 0.1, 10)     // gain schedule
24//! r(k)   = (1 - e(k)) · K(k)                // reward
25//! ```
26//!
27//! The loop terminates when any of the following hold:
28//!
29//! 1. `e(k) < convergence_threshold`               - **converged**.
30//! 2. Reward declines for `stall_patience` steps   - **stalled**.
31//! 3. `k == max_iterations`                        - **iteration cap**.
32//!
33//! ## Examples
34//!
35//! ```rust
36//! use lmm_agent::cognition::r#loop::ThinkLoop;
37//! use lmm_agent::cognition::search::SearchOracle;
38//!
39//! #[tokio::main]
40//! async fn main() {
41//!     let mut oracle = SearchOracle::new(5);
42//!     let mut lp = ThinkLoop::new("What is Rust?", 10, 0.25, 1.0, 0.05);
43//!     let result = lp.run(&mut oracle).await;
44//!     assert!(result.steps <= 10);
45//! }
46//! ```
47//!
48//! ## See Also
49//!
50//! * [PID controller - Wikipedia](https://en.wikipedia.org/wiki/PID_controller)
51//! * [Feedback - Wikipedia](https://en.wikipedia.org/wiki/Feedback)
52
53use crate::cognition::goal::GoalEvaluator;
54use crate::cognition::memory::{ColdStore, HotStore, MemoryEntry};
55use crate::cognition::reflect::Reflector;
56use crate::cognition::search::SearchOracle;
57use crate::cognition::signal::CognitionSignal;
58use crate::types::ThinkResult;
59
60/// The closed-loop controller that drives the agent's reasoning process.
61///
62/// Build via [`ThinkLoop::new`] or [`ThinkLoop::builder`], then call
63/// `.run(&mut oracle).await` to obtain a [`ThinkResult`].
64///
65/// # Examples
66///
67/// ```rust
68/// use lmm_agent::cognition::r#loop::ThinkLoop;
69/// use lmm_agent::cognition::search::SearchOracle;
70///
71/// #[tokio::main]
72/// async fn main() {
73///     let mut oracle = SearchOracle::new(3);
74///     let mut lp = ThinkLoop::new("Rust memory safety", 5, 0.3, 1.0, 0.05);
75///     let result = lp.run(&mut oracle).await;
76///     println!("converged={} steps={} error={:.3}", result.converged, result.steps, result.final_error);
77/// }
78/// ```
79#[derive(Debug)]
80pub struct ThinkLoop {
81    /// Natural-language goal (setpoint).
82    pub goal: String,
83    /// Maximum number of feedback iterations.
84    pub max_iterations: usize,
85    /// Jaccard-error threshold below which convergence is declared.
86    pub convergence_threshold: f64,
87    /// Proportional gain constant (Kp).
88    pub k_proportional: f64,
89    /// Integral gain constant (Ki) - stored for documentation; applied in `CognitionSignal`.
90    pub k_integral: f64,
91    /// Consecutive reward-declining steps before stall detection triggers.
92    pub stall_patience: usize,
93    /// Reward score threshold for promoting hot entries to cold store.
94    pub promotion_threshold: f64,
95    /// Short-term memory for the current run.
96    pub hot: HotStore,
97    /// Long-term memory archive.
98    pub cold: ColdStore,
99}
100
101impl ThinkLoop {
102    /// Constructs a `ThinkLoop` with the given parameters.
103    ///
104    /// # Arguments
105    ///
106    /// * `goal`                  - natural-language goal / setpoint.
107    /// * `max_iterations`        - iteration cap (≥ 1).
108    /// * `convergence_threshold` - Jaccard error threshold ∈ [0, 1].
109    /// * `k_proportional`        - proportional gain Kp.
110    /// * `k_integral`            - integral gain Ki.
111    pub fn new(
112        goal: impl Into<String>,
113        max_iterations: usize,
114        convergence_threshold: f64,
115        k_proportional: f64,
116        k_integral: f64,
117    ) -> Self {
118        Self {
119            goal: goal.into(),
120            max_iterations: max_iterations.max(1),
121            convergence_threshold: convergence_threshold.clamp(0.0, 1.0),
122            k_proportional,
123            k_integral,
124            stall_patience: 3,
125            promotion_threshold: 0.5,
126            hot: HotStore::new(16),
127            cold: ColdStore::default(),
128        }
129    }
130
131    /// Returns a builder for ergonomic construction.
132    pub fn builder(goal: impl Into<String>) -> ThinkLoopBuilder {
133        ThinkLoopBuilder::new(goal)
134    }
135
136    /// Sets the stall patience.
137    pub fn stall_patience(mut self, n: usize) -> Self {
138        self.stall_patience = n.max(1);
139        self
140    }
141
142    /// Sets the hot→cold promotion reward threshold.
143    pub fn promotion_threshold(mut self, t: f64) -> Self {
144        self.promotion_threshold = t.clamp(0.0, 1.0);
145        self
146    }
147
148    /// Runs the closed-loop think cycle and returns a [`ThinkResult`].
149    ///
150    /// # Examples
151    ///
152    /// ```rust
153    /// #[tokio::main]
154    /// async fn main() {
155    ///     use lmm_agent::cognition::r#loop::ThinkLoop;
156    ///     use lmm_agent::cognition::search::SearchOracle;
157    ///
158    ///     let mut oracle = SearchOracle::new(5);
159    ///     let mut lp = ThinkLoop::new("Rust memory model", 10, 0.25, 1.0, 0.05);
160    ///     let r = lp.run(&mut oracle).await;
161    ///     assert!(r.steps > 0);
162    /// }
163    /// ```
164    pub async fn run(&mut self, oracle: &mut SearchOracle) -> ThinkResult {
165        let evaluator = GoalEvaluator::new(self.convergence_threshold);
166        let mut integral = 0.0_f64;
167        let mut prev_reward = f64::NEG_INFINITY;
168        let mut stall_streak = 0_usize;
169        let mut converged = false;
170        let mut final_error = 1.0_f64;
171        let mut signals: Vec<CognitionSignal> = Vec::with_capacity(self.max_iterations);
172
173        for step in 0..self.max_iterations {
174            let query = Reflector::formulate_query(&self.goal, &self.hot);
175
176            let observation = oracle.fetch(&query).await;
177
178            let signal = CognitionSignal::new(
179                step,
180                query.clone(),
181                observation.clone(),
182                self.k_proportional,
183                integral,
184            );
185            integral = signal.integral;
186            let current_reward = signal.reward;
187            final_error = signal.error;
188
189            let fact_content = if observation.is_empty() {
190                String::new()
191            } else {
192                observation
193            };
194
195            if !fact_content.is_empty() {
196                self.hot
197                    .push(MemoryEntry::new(fact_content, current_reward, step));
198            }
199
200            signals.push(signal);
201
202            if evaluator.is_converged(final_error) {
203                converged = true;
204                break;
205            }
206
207            if current_reward < prev_reward {
208                stall_streak += 1;
209                if stall_streak >= self.stall_patience {
210                    break;
211                }
212            } else {
213                stall_streak = 0;
214            }
215            prev_reward = current_reward;
216        }
217
218        Reflector::drain_to_cold(&mut self.hot, &mut self.cold, self.promotion_threshold);
219
220        let steps = signals.len();
221        ThinkResult {
222            converged,
223            steps,
224            final_error,
225            memory_snapshot: self.hot.snapshot(),
226            signals,
227        }
228    }
229}
230
231/// Fluent builder for [`ThinkLoop`].
232///
233/// # Examples
234///
235/// ```rust
236/// use lmm_agent::cognition::r#loop::ThinkLoop;
237///
238/// let lp = ThinkLoop::builder("What is ownership?")
239///     .max_iterations(5)
240///     .convergence_threshold(0.3)
241///     .build();
242/// assert_eq!(lp.max_iterations, 5);
243/// ```
244pub struct ThinkLoopBuilder {
245    goal: String,
246    max_iterations: usize,
247    convergence_threshold: f64,
248    k_proportional: f64,
249    k_integral: f64,
250    stall_patience: usize,
251    promotion_threshold: f64,
252    hot_capacity: usize,
253}
254
255impl ThinkLoopBuilder {
256    fn new(goal: impl Into<String>) -> Self {
257        Self {
258            goal: goal.into(),
259            max_iterations: 10,
260            convergence_threshold: 0.25,
261            k_proportional: 1.0,
262            k_integral: 0.05,
263            stall_patience: 3,
264            promotion_threshold: 0.5,
265            hot_capacity: 16,
266        }
267    }
268
269    /// Sets the iteration cap (default: 10).
270    pub fn max_iterations(mut self, n: usize) -> Self {
271        self.max_iterations = n;
272        self
273    }
274    /// Sets the convergence threshold (default: 0.25).
275    pub fn convergence_threshold(mut self, t: f64) -> Self {
276        self.convergence_threshold = t;
277        self
278    }
279    /// Sets the proportional gain Kp (default: 1.0).
280    pub fn k_proportional(mut self, kp: f64) -> Self {
281        self.k_proportional = kp;
282        self
283    }
284    /// Sets the integral gain Ki (default: 0.05).
285    pub fn k_integral(mut self, ki: f64) -> Self {
286        self.k_integral = ki;
287        self
288    }
289    /// Sets the stall patience (default: 3).
290    pub fn stall_patience(mut self, n: usize) -> Self {
291        self.stall_patience = n;
292        self
293    }
294    /// Sets the hot→cold promotion threshold (default: 0.5).
295    pub fn promotion_threshold(mut self, t: f64) -> Self {
296        self.promotion_threshold = t;
297        self
298    }
299    /// Sets the hot store capacity (default: 16).
300    pub fn hot_capacity(mut self, cap: usize) -> Self {
301        self.hot_capacity = cap;
302        self
303    }
304
305    /// Builds the [`ThinkLoop`].
306    pub fn build(self) -> ThinkLoop {
307        ThinkLoop {
308            goal: self.goal,
309            max_iterations: self.max_iterations.max(1),
310            convergence_threshold: self.convergence_threshold.clamp(0.0, 1.0),
311            k_proportional: self.k_proportional,
312            k_integral: self.k_integral,
313            stall_patience: self.stall_patience.max(1),
314            promotion_threshold: self.promotion_threshold.clamp(0.0, 1.0),
315            hot: HotStore::new(self.hot_capacity.max(1)),
316            cold: ColdStore::default(),
317        }
318    }
319}
320
321// Copyright 2026 Mahmoud Harmouch.
322//
323// Licensed under the MIT license
324// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
325// option. This file may not be copied, modified, or distributed
326// except according to those terms.