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.