Skip to main content

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.