lmm_agent/cognition/memory.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//! # Two-tier agent memory: `HotStore` and `ColdStore`.
9//!
10//! Inspired by the **integrator** and **capacitor** components of feedback control
11//! circuits, the agent keeps two distinct memory tiers:
12//!
13//! | Tier | Analogy | Behaviour |
14//! |-------------|-------------------|----------------------------------------------|
15//! | `HotStore` | Working register | Bounded FIFO; oldest entry evicted when full |
16//! | `ColdStore` | Capacitor / LTM | Unbounded archive; entries never deleted |
17//!
18//! High-reward entries are *promoted* from hot to cold via `drain_to_cold`, which
19//! is called by the `Reflector` at the end of each `ThinkLoop` run.
20//!
21//! ## Examples
22//!
23//! ```rust
24//! use lmm_agent::cognition::memory::{HotStore, ColdStore, MemoryEntry};
25//!
26//! let mut hot = HotStore::new(3);
27//! hot.push(MemoryEntry::new("first observation".into(), 0.8, 0));
28//! hot.push(MemoryEntry::new("second observation".into(), 0.5, 1));
29//! assert_eq!(hot.len(), 2);
30//!
31//! let mut cold = ColdStore::default();
32//! hot.drain_to_cold(&mut cold, 0.7);
33//! assert_eq!(cold.len(), 1); // only score ≥ 0.7 promoted
34//! ```
35//!
36//! ## See Also
37//!
38//! * [Cognitive architecture - Wikipedia](https://en.wikipedia.org/wiki/Cognitive_architecture)
39//! * [Memory - Wikipedia](https://en.wikipedia.org/wiki/Memory_(psychology))
40
41use std::collections::VecDeque;
42
43/// A single entry in either tier of agent memory.
44///
45/// # Examples
46///
47/// ```rust
48/// use lmm_agent::cognition::memory::MemoryEntry;
49///
50/// let entry = MemoryEntry::new("Rust owns memory safely.".into(), 0.92, 3);
51/// assert_eq!(entry.content, "Rust owns memory safely.");
52/// assert_eq!(entry.score, 0.92);
53/// assert_eq!(entry.timestamp, 3);
54/// ```
55#[derive(Debug, Clone, PartialEq)]
56pub struct MemoryEntry {
57 /// The raw text content of this memory (DDG observation, generated text, etc.).
58 pub content: String,
59
60 /// Relevance / reward score assigned when this entry was created, ∈ [0, ∞).
61 pub score: f64,
62
63 /// The loop step at which this entry was recorded.
64 pub timestamp: usize,
65}
66
67impl MemoryEntry {
68 /// Constructs a new [`MemoryEntry`].
69 pub fn new(content: String, score: f64, timestamp: usize) -> Self {
70 Self {
71 content,
72 score,
73 timestamp,
74 }
75 }
76}
77
78/// Bounded FIFO short-term memory.
79///
80/// When the store is full and a new entry is pushed, the **oldest** entry is
81/// silently evicted. This mirrors the limited capacity of a control register.
82///
83/// # Examples
84///
85/// ```rust
86/// use lmm_agent::cognition::memory::{HotStore, MemoryEntry};
87///
88/// let mut store = HotStore::new(2);
89/// store.push(MemoryEntry::new("a".into(), 0.5, 0));
90/// store.push(MemoryEntry::new("b".into(), 0.9, 1));
91/// store.push(MemoryEntry::new("c".into(), 0.7, 2)); // evicts "a"
92/// assert_eq!(store.len(), 2);
93/// assert_eq!(store.entries()[0].content, "b");
94/// ```
95#[derive(Debug, Clone)]
96pub struct HotStore {
97 /// Maximum number of entries that can be held simultaneously.
98 pub capacity: usize,
99 entries: VecDeque<MemoryEntry>,
100}
101
102impl HotStore {
103 /// Creates a new `HotStore` with the given maximum capacity.
104 ///
105 /// # Panics
106 ///
107 /// Panics if `capacity == 0`.
108 pub fn new(capacity: usize) -> Self {
109 assert!(capacity > 0, "HotStore capacity must be > 0");
110 Self {
111 capacity,
112 entries: VecDeque::with_capacity(capacity),
113 }
114 }
115
116 /// Appends a new entry, evicting the oldest when at capacity.
117 pub fn push(&mut self, entry: MemoryEntry) {
118 if self.entries.len() >= self.capacity {
119 self.entries.pop_front();
120 }
121 self.entries.push_back(entry);
122 }
123
124 /// Returns the number of entries currently held.
125 pub fn len(&self) -> usize {
126 self.entries.len()
127 }
128
129 /// Returns `true` when the store contains no entries.
130 pub fn is_empty(&self) -> bool {
131 self.entries.is_empty()
132 }
133
134 /// Returns an ordered slice of all current entries (oldest → newest).
135 pub fn entries(&self) -> &VecDeque<MemoryEntry> {
136 &self.entries
137 }
138
139 /// Returns the top-`n` entries most relevant to `query` using token-overlap scoring.
140 ///
141 /// # Examples
142 ///
143 /// ```rust
144 /// use lmm_agent::cognition::memory::{HotStore, MemoryEntry};
145 ///
146 /// let mut store = HotStore::new(10);
147 /// store.push(MemoryEntry::new("Rust ownership model".into(), 0.8, 0));
148 /// store.push(MemoryEntry::new("Python garbage collector".into(), 0.6, 1));
149 /// let top = store.relevant("Rust memory", 1);
150 /// assert_eq!(top[0].content, "Rust ownership model");
151 /// ```
152 pub fn relevant(&self, query: &str, top_n: usize) -> Vec<&MemoryEntry> {
153 let query_tokens: std::collections::HashSet<String> = query
154 .split_whitespace()
155 .map(|w| w.to_ascii_lowercase())
156 .collect();
157
158 let mut scored: Vec<(&MemoryEntry, usize)> = self
159 .entries
160 .iter()
161 .map(|e| {
162 let entry_tokens: std::collections::HashSet<String> = e
163 .content
164 .split_whitespace()
165 .map(|w| w.to_ascii_lowercase())
166 .collect();
167 let overlap = query_tokens.intersection(&entry_tokens).count();
168 (e, overlap)
169 })
170 .collect();
171
172 scored.sort_by_key(|b| std::cmp::Reverse(b.1));
173 scored.into_iter().take(top_n).map(|(e, _)| e).collect()
174 }
175
176 /// Moves entries whose score meets or exceeds `threshold` into `cold`.
177 ///
178 /// Promoted entries are **removed** from the hot store; entries below
179 /// threshold are retained.
180 ///
181 /// # Examples
182 ///
183 /// ```rust
184 /// use lmm_agent::cognition::memory::{HotStore, ColdStore, MemoryEntry};
185 ///
186 /// let mut hot = HotStore::new(5);
187 /// hot.push(MemoryEntry::new("high value".into(), 0.9, 0));
188 /// hot.push(MemoryEntry::new("low value".into(), 0.2, 1));
189 /// let mut cold = ColdStore::default();
190 /// hot.drain_to_cold(&mut cold, 0.7);
191 /// assert_eq!(cold.len(), 1);
192 /// assert_eq!(hot.len(), 1);
193 /// ```
194 pub fn drain_to_cold(&mut self, cold: &mut ColdStore, threshold: f64) {
195 let mut retain = VecDeque::new();
196 while let Some(entry) = self.entries.pop_front() {
197 if entry.score >= threshold {
198 cold.promote(entry);
199 } else {
200 retain.push_back(entry);
201 }
202 }
203 self.entries = retain;
204 }
205
206 /// Clears all entries from the hot store.
207 pub fn clear(&mut self) {
208 self.entries.clear();
209 }
210
211 /// Returns a snapshot of all content strings (newest-first).
212 pub fn snapshot(&self) -> Vec<String> {
213 self.entries
214 .iter()
215 .rev()
216 .map(|e| e.content.clone())
217 .collect()
218 }
219}
220
221/// Unbounded long-term memory archive. Entries are **never** deleted.
222///
223/// `recall` returns top-N entries by a blended score of reward × recency.
224///
225/// # Examples
226///
227/// ```rust
228/// use lmm_agent::cognition::memory::{ColdStore, MemoryEntry};
229///
230/// let mut cold = ColdStore::default();
231/// cold.promote(MemoryEntry::new("fact about Rust".into(), 0.9, 0));
232/// assert_eq!(cold.len(), 1);
233/// let recalled = cold.recall("Rust", 1);
234/// assert_eq!(recalled[0].content, "fact about Rust");
235/// ```
236#[derive(Debug, Clone, Default)]
237pub struct ColdStore {
238 entries: Vec<MemoryEntry>,
239}
240
241impl ColdStore {
242 /// Appends an entry to the archive.
243 pub fn promote(&mut self, entry: MemoryEntry) {
244 self.entries.push(entry);
245 }
246
247 /// Returns the total number of entries in the archive.
248 pub fn len(&self) -> usize {
249 self.entries.len()
250 }
251
252 /// Returns `true` when no entries have been archived.
253 pub fn is_empty(&self) -> bool {
254 self.entries.is_empty()
255 }
256
257 /// Returns all archived entries (insertion order).
258 pub fn all(&self) -> &[MemoryEntry] {
259 &self.entries
260 }
261
262 /// Returns the top-`n` entries most relevant to `query`, blending
263 /// token-overlap with a recency factor.
264 ///
265 /// # Examples
266 ///
267 /// ```rust
268 /// use lmm_agent::cognition::memory::{ColdStore, MemoryEntry};
269 ///
270 /// let mut cold = ColdStore::default();
271 /// cold.promote(MemoryEntry::new("old fact".into(), 0.5, 0));
272 /// cold.promote(MemoryEntry::new("Rust ownership facts recent".into(), 0.8, 5));
273 /// let top = cold.recall("Rust", 1);
274 /// assert_eq!(top[0].content, "Rust ownership facts recent");
275 /// ```
276 pub fn recall(&self, query: &str, top_n: usize) -> Vec<&MemoryEntry> {
277 let query_tokens: std::collections::HashSet<String> = query
278 .split_whitespace()
279 .map(|w| w.to_ascii_lowercase())
280 .collect();
281
282 let total = self.entries.len();
283 let mut scored: Vec<(&MemoryEntry, f64)> = self
284 .entries
285 .iter()
286 .enumerate()
287 .map(|(i, e)| {
288 let entry_tokens: std::collections::HashSet<String> = e
289 .content
290 .split_whitespace()
291 .map(|w| w.to_ascii_lowercase())
292 .collect();
293 let overlap = query_tokens.intersection(&entry_tokens).count() as f64;
294 let recency = (i + 1) as f64 / total as f64;
295 let blended = (e.score + overlap * 0.1) * (0.7 + 0.3 * recency);
296 (e, blended)
297 })
298 .collect();
299
300 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
301 scored.into_iter().take(top_n).map(|(e, _)| e).collect()
302 }
303
304 /// Returns a snapshot of all content strings (newest-first).
305 pub fn snapshot(&self) -> Vec<String> {
306 self.entries
307 .iter()
308 .rev()
309 .map(|e| e.content.clone())
310 .collect()
311 }
312}
313
314// Copyright 2026 Mahmoud Harmouch.
315//
316// Licensed under the MIT license
317// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
318// option. This file may not be copied, modified, or distributed
319// except according to those terms.