Skip to main content

lc_memory/
semantic.rs

1// lc-memory/src/semantic.rs
2//! B4 (v0.22.4): two-tier semantic memory — a unified [`MemoryStore`] abstraction
3//! (namespaced key-value + semantic recall) with a short-/long-term split and
4//! weighted-decay ranking.
5//!
6//! # Why a second memory abstraction
7//!
8//! [`crate::BaseMemory`] manages **conversation history** (messages injected into the
9//! next prompt). The types in this module manage **knowledge**: durable facts about the
10//! user / task extracted from completed turns, addressable by namespace (e.g. one per
11//! user or session) and retrievable by meaning rather than by recency in a transcript.
12//!
13//! # The two tiers
14//!
15//! - [`ShortTermMemory`] is the in-thread working tier: bounded per namespace
16//!   (`capacity`, FIFO eviction), exact key-value lookups plus semantic search purely by
17//!   similarity. Nothing is persisted; a dropped store is a forgotten store.
18//! - [`LongTermMemory`] is unbounded and ranks candidates the way generative-agent
19//!   systems do: `score = w_similarity · sim + w_recency · 2^(−age/half_life) +
20//!   w_importance · importance` ([`DecayWeights`]). Frequently re-accessed memories
21//!   stay fresh; old, unimportant ones naturally sink — nothing is deleted on the read
22//!   path.
23//! - [`TwoTierMemory`] wires the two together: writes land in the short tier;
24//!   [`consolidate`](TwoTierMemory::consolidate) promotes entries that clear the
25//!   [`PromotionPolicy`] (high importance **or** enough re-accesses). Recall searches
26//!   both tiers with one uniform weighted formula and de-duplicates by key
27//!   (short tier wins on collision).
28//!
29//! # Semantics without a mandatory embedding dependency
30//!
31//! [`SemanticScorer`] is a pluggable trait; the always-available [`LexicalScorer`]
32//! ranks by cosine over term-frequency vectors (Unicode-aware tokenization), so the
33//! whole abstraction and its tests run offline with zero extra dependencies. An
34//! embedding-backed scorer can be supplied with `with_scorer` without touching the
35//! stores.
36//!
37//! All time-dependent logic takes an injected clock, so decay/consolidation tests are
38//! pure — no sleeps.
39
40use async_trait::async_trait;
41use std::collections::{HashMap, VecDeque};
42use std::sync::{Arc, Mutex};
43use std::time::{Duration, SystemTime};
44
45use super::MemoryError;
46
47/// Injectable wall clock (defaults to [`SystemTime::now`]).
48pub type Clock = Arc<dyn Fn() -> SystemTime + Send + Sync>;
49
50fn real_clock() -> Clock {
51    Arc::new(SystemTime::now)
52}
53
54// ───────────────────────────── data model ─────────────────────────────
55
56/// A storable unit of knowledge.
57///
58/// `importance` is clamped into `[0, 1]` by the stores; `key` must be stable within a
59/// namespace (re-putting the same key updates the entry).
60#[derive(Debug, Clone, PartialEq)]
61pub struct MemoryItem {
62    /// Stable identifier within the namespace.
63    pub key: String,
64    /// The memory text, matched by semantic search.
65    pub text: String,
66    /// Importance in `[0, 1]` (higher resists decay and promotes sooner).
67    pub importance: f64,
68    /// Arbitrary structured annotations (user id, source turn, …).
69    pub metadata: HashMap<String, String>,
70}
71
72impl MemoryItem {
73    /// Creates a memory with default importance `0.5` and no metadata.
74    pub fn new(key: impl Into<String>, text: impl Into<String>) -> Self {
75        Self {
76            key: key.into(),
77            text: text.into(),
78            importance: 0.5,
79            metadata: HashMap::new(),
80        }
81    }
82
83    /// Sets the importance (clamped to `[0, 1]` at write time anyway).
84    pub fn with_importance(mut self, importance: f64) -> Self {
85        self.importance = importance.clamp(0.0, 1.0);
86        self
87    }
88
89    /// Adds one metadata annotation.
90    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
91        self.metadata.insert(key.into(), value.into());
92        self
93    }
94
95    /// Replaces the whole metadata map.
96    pub fn with_metadata(mut self, metadata: HashMap<String, String>) -> Self {
97        self.metadata = metadata;
98        self
99    }
100}
101
102/// A memory with its lifecycle bookkeeping.
103#[derive(Debug, Clone)]
104pub struct StoredMemory {
105    /// The stored item.
106    pub item: MemoryItem,
107    /// First write time.
108    pub created_at: SystemTime,
109    /// Most recent access (put/get/search hit).
110    pub last_access_at: SystemTime,
111    /// Number of accesses since the entry was created.
112    pub access_count: u64,
113}
114
115impl StoredMemory {
116    fn fresh(item: MemoryItem, now: SystemTime) -> Self {
117        Self {
118            item,
119            created_at: now,
120            last_access_at: now,
121            access_count: 1,
122        }
123    }
124
125    fn touch(&mut self, now: SystemTime) {
126        self.last_access_at = now;
127        self.access_count = self.access_count.saturating_add(1);
128    }
129}
130
131/// Which tier a recall hit came from.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum MemoryTier {
134    /// The bounded in-thread working tier.
135    Short,
136    /// The decayed long-term tier.
137    Long,
138}
139
140/// A recall result.
141#[derive(Debug, Clone, PartialEq)]
142pub struct MemoryHit {
143    /// Stable key within the namespace.
144    pub key: String,
145    /// The memory text.
146    pub text: String,
147    /// Final ranking score in `[0, 1]`.
148    pub score: f64,
149    /// Importance in `[0, 1]`.
150    pub importance: f64,
151    /// Originating tier.
152    pub tier: MemoryTier,
153    /// Entry metadata.
154    pub metadata: HashMap<String, String>,
155}
156
157impl MemoryHit {
158    fn from_stored(stored: &StoredMemory, score: f64, tier: MemoryTier) -> Self {
159        Self {
160            key: stored.item.key.clone(),
161            text: stored.item.text.clone(),
162            score,
163            importance: stored.item.importance,
164            tier,
165            metadata: stored.item.metadata.clone(),
166        }
167    }
168}
169
170/// A semantic recall query against one namespace.
171#[derive(Debug, Clone)]
172pub struct MemoryQuery<'q> {
173    /// Namespace to search (isolated from every other namespace).
174    pub namespace: &'q str,
175    /// Query text.
176    pub text: &'q str,
177    /// Maximum hits to return.
178    pub k: usize,
179    /// Hits scoring below this are dropped.
180    pub min_score: f64,
181}
182
183impl<'q> MemoryQuery<'q> {
184    /// Creates a query with defaults `k = 5`, `min_score = 0.0`.
185    pub fn new(namespace: &'q str, text: &'q str) -> Self {
186        Self {
187            namespace,
188            text,
189            k: 5,
190            min_score: 0.0,
191        }
192    }
193
194    /// Sets the maximum number of hits.
195    pub fn k(mut self, k: usize) -> Self {
196        self.k = k.max(1);
197        self
198    }
199
200    /// Sets the minimum score filter.
201    pub fn min_score(mut self, min_score: f64) -> Self {
202        self.min_score = min_score;
203        self
204    }
205}
206
207/// Namespaced key-value store with semantic recall.
208///
209/// Implementations are expected to be cheaply `Arc`-shareable (`Send + Sync`) and never
210/// to panic on the read path; failures surface as [`MemoryError`].
211#[async_trait]
212pub trait MemoryStore: Send + Sync {
213    /// Writes/updates an item in the namespace. Re-putting an existing key refreshes
214    /// its content and access time.
215    async fn put(&self, namespace: &str, item: MemoryItem) -> Result<(), MemoryError>;
216
217    /// Exact key lookup (`None` when absent). Counts as an access.
218    async fn get(&self, namespace: &str, key: &str) -> Result<Option<MemoryItem>, MemoryError>;
219
220    /// Semantic recall, best score first, capped at `query.k`.
221    async fn search(&self, query: &MemoryQuery<'_>) -> Result<Vec<MemoryHit>, MemoryError>;
222
223    /// Removes one entry; returns whether it existed.
224    async fn forget(&self, namespace: &str, key: &str) -> Result<bool, MemoryError>;
225
226    /// Drops every entry in the namespace; returns the number removed.
227    async fn clear_namespace(&self, namespace: &str) -> Result<usize, MemoryError>;
228
229    /// Number of entries stored under the namespace.
230    async fn len_namespace(&self, namespace: &str) -> Result<usize, MemoryError>;
231}
232
233fn validate(namespace: &str, item: &MemoryItem) -> Result<(), MemoryError> {
234    if namespace.trim().is_empty() {
235        return Err(MemoryError::Other(
236            "memory namespace must not be empty".into(),
237        ));
238    }
239    if item.key.trim().is_empty() {
240        return Err(MemoryError::Other("memory key must not be empty".into()));
241    }
242    if item.text.trim().is_empty() {
243        return Err(MemoryError::Other("memory text must not be empty".into()));
244    }
245    Ok(())
246}
247
248// ───────────────────────────── scoring ─────────────────────────────
249
250/// Pluggable semantic similarity between two texts.
251///
252/// Implementations return a value in `[0, 1]` (1 = identical meaning). Embedding-based
253/// scorers can back this trait; the default [`LexicalScorer`] needs no model.
254#[async_trait]
255pub trait SemanticScorer: Send + Sync {
256    /// Similarity between `query` and `document` in `[0, 1]`.
257    async fn similarity(&self, query: &str, document: &str) -> f64;
258}
259
260/// Dependency-free scorer: cosine similarity over Unicode word term-frequency vectors.
261///
262/// Deterministic and offline — the reference scorer for the working tier and tests.
263#[derive(Debug, Default, Clone)]
264pub struct LexicalScorer;
265
266impl LexicalScorer {
267    /// Creates the scorer.
268    pub fn new() -> Self {
269        Self
270    }
271
272    fn token_vector(text: &str) -> HashMap<String, f64> {
273        let mut v: HashMap<String, f64> = HashMap::new();
274        for token in text
275            .split(|c: char| !c.is_alphanumeric())
276            .filter(|t| !t.is_empty())
277        {
278            *v.entry(token.to_lowercase()).or_insert(0.0) += 1.0;
279        }
280        v
281    }
282
283    /// Pure cosine over term-frequency vectors (exposed for direct testing).
284    pub fn score(query: &str, document: &str) -> f64 {
285        let a = Self::token_vector(query);
286        let b = Self::token_vector(document);
287        if a.is_empty() || b.is_empty() {
288            return 0.0;
289        }
290        // Iterate the smaller map.
291        let (small, large) = if a.len() <= b.len() {
292            (&a, &b)
293        } else {
294            (&b, &a)
295        };
296        let mut dot = 0.0;
297        for (term, freq) in small {
298            dot += freq * large.get(term).copied().unwrap_or(0.0);
299        }
300        let norm_a: f64 = a.values().map(|v| v * v).sum::<f64>().sqrt();
301        let norm_b: f64 = b.values().map(|v| v * v).sum::<f64>().sqrt();
302        if norm_a == 0.0 || norm_b == 0.0 {
303            return 0.0;
304        }
305        dot / (norm_a * norm_b)
306    }
307}
308
309#[async_trait]
310impl SemanticScorer for LexicalScorer {
311    async fn similarity(&self, query: &str, document: &str) -> f64 {
312        Self::score(query, document)
313    }
314}
315
316// ───────────────────────── short-term tier ─────────────────────────
317
318#[derive(Debug)]
319struct ShortNamespace {
320    entries: HashMap<String, StoredMemory>,
321    /// Insertion order for FIFO eviction (one entry per present key).
322    order: VecDeque<String>,
323}
324
325impl ShortNamespace {
326    fn new() -> Self {
327        Self {
328            entries: HashMap::new(),
329            order: VecDeque::new(),
330        }
331    }
332}
333
334/// Bounded in-thread working memory (one FIFO queue per namespace).
335///
336/// Recall ranks by raw scorer similarity. Cloning is cheap when shared via `Arc`.
337pub struct ShortTermMemory {
338    inner: Mutex<HashMap<String, ShortNamespace>>,
339    capacity: usize,
340    scorer: Arc<dyn SemanticScorer>,
341    clock: Clock,
342}
343
344impl std::fmt::Debug for ShortTermMemory {
345    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346        f.debug_struct("ShortTermMemory")
347            .field("capacity", &self.capacity)
348            .finish_non_exhaustive()
349    }
350}
351
352impl ShortTermMemory {
353    /// Creates a store with the given per-namespace capacity and the default
354    /// [`LexicalScorer`].
355    pub fn new(capacity: usize) -> Self {
356        Self::with_scorer(capacity, Arc::new(LexicalScorer::new()))
357    }
358
359    /// Creates a store with a custom semantic scorer.
360    pub fn with_scorer(capacity: usize, scorer: Arc<dyn SemanticScorer>) -> Self {
361        Self {
362            inner: Mutex::new(HashMap::new()),
363            capacity: capacity.max(1),
364            scorer,
365            clock: real_clock(),
366        }
367    }
368
369    /// Overrides the wall clock (tests).
370    #[cfg(test)]
371    fn with_clock(mut self, clock: Clock) -> Self {
372        self.clock = clock;
373        self
374    }
375
376    /// Per-namespace entry capacity.
377    pub fn capacity(&self) -> usize {
378        self.capacity
379    }
380
381    /// Clones the entries of one namespace without holding the lock across await
382    /// (the async scorer must not run under a std Mutex).
383    fn snapshot(&self, namespace: &str) -> Vec<StoredMemory> {
384        let inner = self.inner.lock().unwrap();
385        inner
386            .get(namespace)
387            .map(|ns| ns.entries.values().cloned().collect())
388            .unwrap_or_default()
389    }
390
391    /// Refreshes last-access metadata for the given keys present in the namespace.
392    fn touch(&self, namespace: &str, keys: &[String]) {
393        let now = (self.clock)();
394        let mut inner = self.inner.lock().unwrap();
395        if let Some(ns) = inner.get_mut(namespace) {
396            for key in keys {
397                if let Some(stored) = ns.entries.get_mut(key) {
398                    stored.touch(now);
399                }
400            }
401        }
402    }
403}
404
405#[async_trait]
406impl MemoryStore for ShortTermMemory {
407    async fn put(&self, namespace: &str, item: MemoryItem) -> Result<(), MemoryError> {
408        validate(namespace, &item)?;
409        let now = (self.clock)();
410        let mut inner = self
411            .inner
412            .lock()
413            .map_err(|e| MemoryError::SaveError(format!("short-term lock poisoned: {e}")))?;
414        let ns = inner
415            .entry(namespace.to_string())
416            .or_insert_with(ShortNamespace::new);
417        let item = MemoryItem {
418            importance: item.importance.clamp(0.0, 1.0),
419            ..item
420        };
421        match ns.entries.get_mut(&item.key) {
422            Some(existing) => {
423                existing.item = item;
424                existing.touch(now);
425            }
426            None => {
427                if ns.entries.len() >= self.capacity {
428                    if let Some(oldest) = ns.order.pop_front() {
429                        ns.entries.remove(&oldest);
430                    }
431                }
432                ns.order.push_back(item.key.clone());
433                ns.entries
434                    .insert(item.key.clone(), StoredMemory::fresh(item, now));
435            }
436        }
437        Ok(())
438    }
439
440    async fn get(&self, namespace: &str, key: &str) -> Result<Option<MemoryItem>, MemoryError> {
441        let now = (self.clock)();
442        let mut inner = self
443            .inner
444            .lock()
445            .map_err(|e| MemoryError::LoadError(format!("short-term lock poisoned: {e}")))?;
446        Ok(inner.get_mut(namespace).and_then(|ns| {
447            ns.entries.get_mut(key).map(|stored| {
448                stored.touch(now);
449                stored.item.clone()
450            })
451        }))
452    }
453
454    async fn search(&self, query: &MemoryQuery<'_>) -> Result<Vec<MemoryHit>, MemoryError> {
455        if query.namespace.trim().is_empty() {
456            return Err(MemoryError::Other(
457                "memory namespace must not be empty".into(),
458            ));
459        }
460        let entries = self.snapshot(query.namespace);
461        let mut scored: Vec<MemoryHit> = Vec::with_capacity(entries.len());
462        for stored in &entries {
463            let sim = self.scorer.similarity(query.text, &stored.item.text).await;
464            if sim >= query.min_score {
465                scored.push(MemoryHit::from_stored(stored, sim, MemoryTier::Short));
466            }
467        }
468        scored.sort_by(|a, b| {
469            b.score
470                .partial_cmp(&a.score)
471                .unwrap_or(std::cmp::Ordering::Equal)
472        });
473        let hit_keys: Vec<String> = scored.iter().take(query.k).map(|h| h.key.clone()).collect();
474        scored.truncate(query.k);
475        self.touch(query.namespace, &hit_keys);
476        Ok(scored)
477    }
478
479    async fn forget(&self, namespace: &str, key: &str) -> Result<bool, MemoryError> {
480        let mut inner = self
481            .inner
482            .lock()
483            .map_err(|e| MemoryError::SaveError(format!("short-term lock poisoned: {e}")))?;
484        let removed = inner
485            .get_mut(namespace)
486            .map(|ns| {
487                if ns.entries.remove(key).is_some() {
488                    ns.order.retain(|k| k != key);
489                    true
490                } else {
491                    false
492                }
493            })
494            .unwrap_or(false);
495        Ok(removed)
496    }
497
498    async fn clear_namespace(&self, namespace: &str) -> Result<usize, MemoryError> {
499        let mut inner = self
500            .inner
501            .lock()
502            .map_err(|e| MemoryError::SaveError(format!("short-term lock poisoned: {e}")))?;
503        Ok(inner
504            .remove(namespace)
505            .map(|ns| ns.entries.len())
506            .unwrap_or(0))
507    }
508
509    async fn len_namespace(&self, namespace: &str) -> Result<usize, MemoryError> {
510        let inner = self
511            .inner
512            .lock()
513            .map_err(|e| MemoryError::LoadError(format!("short-term lock poisoned: {e}")))?;
514        Ok(inner.get(namespace).map(|ns| ns.entries.len()).unwrap_or(0))
515    }
516}
517
518// ───────────────────────── long-term tier ──────────────────────────
519
520/// Weights and half-life of the long-term ranking formula.
521#[derive(Debug, Clone, Copy, PartialEq)]
522pub struct DecayWeights {
523    /// Weight of semantic similarity.
524    pub similarity: f64,
525    /// Weight of recency.
526    pub recency: f64,
527    /// Weight of importance.
528    pub importance: f64,
529    /// Age at which the recency term is 0.5.
530    pub recency_half_life: Duration,
531}
532
533impl Default for DecayWeights {
534    fn default() -> Self {
535        Self {
536            similarity: 0.7,
537            recency: 0.15,
538            importance: 0.15,
539            recency_half_life: Duration::from_secs(7 * 24 * 3600),
540        }
541    }
542}
543
544impl DecayWeights {
545    /// Creates weights with the default 0.7 / 0.15 / 0.15 mix and a 7-day half-life.
546    pub fn new() -> Self {
547        Self::default()
548    }
549
550    /// Sets all three mix weights (need not sum to 1; the score is a plain weighted sum).
551    pub fn with_weights(mut self, similarity: f64, recency: f64, importance: f64) -> Self {
552        self.similarity = similarity;
553        self.recency = recency;
554        self.importance = importance;
555        self
556    }
557
558    /// Sets the recency half-life.
559    pub fn with_half_life(mut self, half_life: Duration) -> Self {
560        self.recency_half_life = half_life;
561        self
562    }
563
564    /// Pure ranking score. `age` is time since last access; `similarity`/`importance`
565    /// are in `[0, 1]`.
566    pub fn score(&self, similarity: f64, importance: f64, age: Duration) -> f64 {
567        let half = self.recency_half_life.as_secs_f64().max(f64::MIN_POSITIVE);
568        let recency = (-age.as_secs_f64() / half * std::f64::consts::LN_2).exp();
569        let raw =
570            self.similarity * similarity + self.recency * recency + self.importance * importance;
571        raw.clamp(0.0, 1.0)
572    }
573}
574
575/// Unbounded long-term memory with weighted-decay ranking.
576pub struct LongTermMemory {
577    inner: Mutex<HashMap<String, HashMap<String, StoredMemory>>>,
578    weights: DecayWeights,
579    scorer: Arc<dyn SemanticScorer>,
580    clock: Clock,
581}
582
583impl std::fmt::Debug for LongTermMemory {
584    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
585        f.debug_struct("LongTermMemory")
586            .field("weights", &self.weights)
587            .finish_non_exhaustive()
588    }
589}
590
591impl LongTermMemory {
592    /// Creates a store with default [`DecayWeights`] and [`LexicalScorer`].
593    pub fn new() -> Self {
594        Self::with_config(DecayWeights::default(), Arc::new(LexicalScorer::new()))
595    }
596
597    /// Creates a store with explicit weights and scorer.
598    pub fn with_config(weights: DecayWeights, scorer: Arc<dyn SemanticScorer>) -> Self {
599        Self {
600            inner: Mutex::new(HashMap::new()),
601            weights,
602            scorer,
603            clock: real_clock(),
604        }
605    }
606
607    /// Overrides the wall clock (tests).
608    #[cfg(test)]
609    fn with_clock(mut self, clock: Clock) -> Self {
610        self.clock = clock;
611        self
612    }
613
614    /// Ranking configuration.
615    pub fn weights(&self) -> &DecayWeights {
616        &self.weights
617    }
618
619    /// Direct upsert used by [`TwoTierMemory`] promotion (merges with an existing entry).
620    fn upsert(&self, namespace: &str, incoming: StoredMemory) -> Result<(), MemoryError> {
621        let mut inner = self
622            .inner
623            .lock()
624            .map_err(|e| MemoryError::SaveError(format!("long-term lock poisoned: {e}")))?;
625        let map = inner.entry(namespace.to_string()).or_default();
626        match map.get_mut(&incoming.item.key) {
627            None => {
628                map.insert(incoming.item.key.clone(), incoming);
629            }
630            Some(existing) => {
631                // Merge: keep the older creation time, the freshest access, summed
632                // accesses, the highest importance, and the newer text/metadata.
633                existing.created_at = existing.created_at.min(incoming.created_at);
634                existing.last_access_at = existing.last_access_at.max(incoming.last_access_at);
635                existing.access_count = existing.access_count.saturating_add(incoming.access_count);
636                existing.item.importance = existing.item.importance.max(incoming.item.importance);
637                for (k, v) in incoming.item.metadata {
638                    existing.item.metadata.insert(k, v);
639                }
640                existing.item.text = incoming.item.text;
641            }
642        }
643        Ok(())
644    }
645
646    fn snapshot(&self, namespace: &str) -> Vec<StoredMemory> {
647        let inner = self.inner.lock().unwrap();
648        inner
649            .get(namespace)
650            .map(|m| m.values().cloned().collect())
651            .unwrap_or_default()
652    }
653
654    fn touch(&self, namespace: &str, keys: &[String]) {
655        let now = (self.clock)();
656        let mut inner = self.inner.lock().unwrap();
657        if let Some(map) = inner.get_mut(namespace) {
658            for key in keys {
659                if let Some(stored) = map.get_mut(key) {
660                    stored.touch(now);
661                }
662            }
663        }
664    }
665}
666
667impl Default for LongTermMemory {
668    fn default() -> Self {
669        Self::new()
670    }
671}
672
673#[async_trait]
674impl MemoryStore for LongTermMemory {
675    async fn put(&self, namespace: &str, item: MemoryItem) -> Result<(), MemoryError> {
676        validate(namespace, &item)?;
677        let now = (self.clock)();
678        self.upsert(
679            namespace,
680            StoredMemory::fresh(
681                MemoryItem {
682                    importance: item.importance.clamp(0.0, 1.0),
683                    ..item
684                },
685                now,
686            ),
687        )
688    }
689
690    async fn get(&self, namespace: &str, key: &str) -> Result<Option<MemoryItem>, MemoryError> {
691        let now = (self.clock)();
692        let mut inner = self
693            .inner
694            .lock()
695            .map_err(|e| MemoryError::LoadError(format!("long-term lock poisoned: {e}")))?;
696        Ok(inner
697            .get_mut(namespace)
698            .and_then(|m| m.get_mut(key))
699            .map(|stored| {
700                stored.touch(now);
701                stored.item.clone()
702            }))
703    }
704
705    async fn search(&self, query: &MemoryQuery<'_>) -> Result<Vec<MemoryHit>, MemoryError> {
706        if query.namespace.trim().is_empty() {
707            return Err(MemoryError::Other(
708                "memory namespace must not be empty".into(),
709            ));
710        }
711        let now = (self.clock)();
712        let entries = self.snapshot(query.namespace);
713        let mut scored: Vec<MemoryHit> = Vec::with_capacity(entries.len());
714        for stored in entries {
715            let sim = self.scorer.similarity(query.text, &stored.item.text).await;
716            let age = now
717                .duration_since(stored.last_access_at)
718                .unwrap_or(Duration::ZERO);
719            let score = self.weights.score(sim, stored.item.importance, age);
720            if score >= query.min_score {
721                scored.push(MemoryHit::from_stored(&stored, score, MemoryTier::Long));
722            }
723        }
724        scored.sort_by(|a, b| {
725            b.score
726                .partial_cmp(&a.score)
727                .unwrap_or(std::cmp::Ordering::Equal)
728        });
729        let hit_keys: Vec<String> = scored.iter().take(query.k).map(|h| h.key.clone()).collect();
730        scored.truncate(query.k);
731        self.touch(query.namespace, &hit_keys);
732        Ok(scored)
733    }
734
735    async fn forget(&self, namespace: &str, key: &str) -> Result<bool, MemoryError> {
736        let mut inner = self
737            .inner
738            .lock()
739            .map_err(|e| MemoryError::SaveError(format!("long-term lock poisoned: {e}")))?;
740        Ok(inner
741            .get_mut(namespace)
742            .map(|m| m.remove(key).is_some())
743            .unwrap_or(false))
744    }
745
746    async fn clear_namespace(&self, namespace: &str) -> Result<usize, MemoryError> {
747        let mut inner = self
748            .inner
749            .lock()
750            .map_err(|e| MemoryError::SaveError(format!("long-term lock poisoned: {e}")))?;
751        Ok(inner.remove(namespace).map(|m| m.len()).unwrap_or(0))
752    }
753
754    async fn len_namespace(&self, namespace: &str) -> Result<usize, MemoryError> {
755        let inner = self
756            .inner
757            .lock()
758            .map_err(|e| MemoryError::LoadError(format!("long-term lock poisoned: {e}")))?;
759        Ok(inner.get(namespace).map(|m| m.len()).unwrap_or(0))
760    }
761}
762
763// ─────────────────────── promotion / two tiers ──────────────────────
764
765/// Policy deciding which short-term entries consolidate into long-term memory.
766///
767/// An entry qualifies when **either** condition holds: importance is at least
768/// `min_importance`, or it has been accessed at least `min_access_count` times.
769#[derive(Debug, Clone, Copy, PartialEq)]
770pub struct PromotionPolicy {
771    /// Importance floor for immediate promotion.
772    pub min_importance: f64,
773    /// Re-access threshold for promotion of merely-useful memories.
774    pub min_access_count: u64,
775}
776
777impl Default for PromotionPolicy {
778    fn default() -> Self {
779        Self {
780            min_importance: 0.8,
781            min_access_count: 3,
782        }
783    }
784}
785
786impl PromotionPolicy {
787    /// Creates the default policy (importance ≥ 0.8 or ≥ 3 accesses).
788    pub fn new() -> Self {
789        Self::default()
790    }
791
792    /// Sets the importance floor.
793    pub fn with_min_importance(mut self, min_importance: f64) -> Self {
794        self.min_importance = min_importance.clamp(0.0, 1.0);
795        self
796    }
797
798    /// Sets the re-access threshold.
799    pub fn with_min_access_count(mut self, min_access_count: u64) -> Self {
800        self.min_access_count = min_access_count;
801        self
802    }
803
804    /// Pure predicate over a stored entry.
805    pub fn qualifies(&self, stored: &StoredMemory) -> bool {
806        stored.item.importance >= self.min_importance
807            || stored.access_count >= self.min_access_count
808    }
809}
810
811/// Two-tier memory: bounded short-term working store + decayed long-term store.
812///
813/// - Writes ([`put`](MemoryStore::put)) always land in the short tier.
814/// - [`get`](MemoryStore::get) checks the short tier first, then the long tier.
815/// - [`search`](MemoryStore::search) scores **both** tiers with the long-term weighted
816///   formula (so cross-tier ordering is on one scale), de-duplicating by key with the
817///   short tier preferred.
818/// - [`consolidate`](TwoTierMemory::consolidate) promotes qualifying short-term entries
819///   into long-term memory and removes them from the short tier.
820pub struct TwoTierMemory {
821    short: Arc<ShortTermMemory>,
822    long: Arc<LongTermMemory>,
823    policy: Mutex<PromotionPolicy>,
824    scorer: Arc<dyn SemanticScorer>,
825    weights: DecayWeights,
826    clock: Clock,
827}
828
829impl std::fmt::Debug for TwoTierMemory {
830    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
831        f.debug_struct("TwoTierMemory")
832            .field("policy", &self.policy)
833            .field("weights", &self.weights)
834            .finish_non_exhaustive()
835    }
836}
837
838impl TwoTierMemory {
839    /// Creates a two-tier store with default capacity/weights/scorer/policy.
840    pub fn new(short_capacity: usize) -> Self {
841        let scorer: Arc<dyn SemanticScorer> = Arc::new(LexicalScorer::new());
842        let weights = DecayWeights::default();
843        Self {
844            short: Arc::new(ShortTermMemory::with_scorer(short_capacity, scorer.clone())),
845            long: Arc::new(LongTermMemory::with_config(weights, scorer.clone())),
846            policy: Mutex::new(PromotionPolicy::default()),
847            scorer,
848            weights,
849            clock: real_clock(),
850        }
851    }
852
853    /// Replaces the promotion policy.
854    pub fn with_policy(self, policy: PromotionPolicy) -> Self {
855        *self.policy.lock().unwrap() = policy;
856        self
857    }
858
859    /// Overrides the wall clock on both tiers and the unified ranking.
860    ///
861    /// Test-only constructor: call **before** seeding data — the tiers are rebuilt, so
862    /// previously stored entries are lost.
863    #[cfg(test)]
864    pub(crate) fn with_clock(mut self, clock: Clock) -> Self {
865        let capacity = self.short.capacity();
866        self.short = Arc::new(
867            ShortTermMemory::with_scorer(capacity, self.scorer.clone()).with_clock(clock.clone()),
868        );
869        self.long = Arc::new(
870            LongTermMemory::with_config(self.weights, self.scorer.clone())
871                .with_clock(clock.clone()),
872        );
873        self.clock = clock;
874        self
875    }
876
877    /// The short-term tier (direct access for seeding/tests).
878    pub fn short_term(&self) -> Arc<ShortTermMemory> {
879        self.short.clone()
880    }
881
882    /// The long-term tier (direct access for seeding/tests).
883    pub fn long_term(&self) -> Arc<LongTermMemory> {
884        self.long.clone()
885    }
886
887    /// Replaces the promotion policy at runtime.
888    pub fn set_policy(&self, policy: PromotionPolicy) {
889        *self.policy.lock().unwrap() = policy;
890    }
891
892    /// Promotes qualifying short-term entries of one namespace into long-term memory,
893    /// removing promoted entries from the short tier. Returns the promoted keys.
894    pub async fn consolidate_namespace(&self, namespace: &str) -> Result<Vec<String>, MemoryError> {
895        let policy = *self
896            .policy
897            .lock()
898            .map_err(|e| MemoryError::Other(format!("promotion policy lock poisoned: {e}")))?;
899        let candidates = self.short.snapshot(namespace);
900        let mut promoted = Vec::new();
901        for stored in candidates {
902            if policy.qualifies(&stored) {
903                self.long.upsert(namespace, stored.clone())?;
904                self.short.forget(namespace, &stored.item.key).await?;
905                promoted.push(stored.item.key);
906            }
907        }
908        Ok(promoted)
909    }
910
911    /// Promotes qualifying entries across every short-term namespace.
912    pub async fn consolidate(&self) -> Result<Vec<String>, MemoryError> {
913        let namespaces: Vec<String> = self.short.inner.lock().unwrap().keys().cloned().collect();
914        let mut all = Vec::new();
915        for namespace in namespaces {
916            all.extend(self.consolidate_namespace(&namespace).await?);
917        }
918        Ok(all)
919    }
920
921    /// Unified weighted score for cross-tier ranking (same scale for every candidate).
922    async fn rank_one(&self, stored: &StoredMemory, query: &str, now: SystemTime) -> f64 {
923        let sim = self.scorer.similarity(query, &stored.item.text).await;
924        let age = now
925            .duration_since(stored.last_access_at)
926            .unwrap_or(Duration::ZERO);
927        self.weights.score(sim, stored.item.importance, age)
928    }
929}
930
931#[async_trait]
932impl MemoryStore for TwoTierMemory {
933    async fn put(&self, namespace: &str, item: MemoryItem) -> Result<(), MemoryError> {
934        self.short.put(namespace, item).await
935    }
936
937    async fn get(&self, namespace: &str, key: &str) -> Result<Option<MemoryItem>, MemoryError> {
938        if let Some(item) = self.short.get(namespace, key).await? {
939            return Ok(Some(item));
940        }
941        self.long.get(namespace, key).await
942    }
943
944    async fn search(&self, query: &MemoryQuery<'_>) -> Result<Vec<MemoryHit>, MemoryError> {
945        if query.namespace.trim().is_empty() {
946            return Err(MemoryError::Other(
947                "memory namespace must not be empty".into(),
948            ));
949        }
950        let now = (self.clock)();
951
952        // Snapshot both tiers, tag the tier, and score on one uniform scale.
953        let mut candidates: Vec<(StoredMemory, MemoryTier)> = self
954            .short
955            .snapshot(query.namespace)
956            .into_iter()
957            .map(|s| (s, MemoryTier::Short))
958            .collect();
959        candidates.extend(
960            self.long
961                .snapshot(query.namespace)
962                .into_iter()
963                .map(|s| (s, MemoryTier::Long)),
964        );
965
966        let mut hits: Vec<MemoryHit> = Vec::with_capacity(candidates.len());
967        for (stored, tier) in &candidates {
968            let score = self.rank_one(stored, query.text, now).await;
969            if score >= query.min_score {
970                hits.push(MemoryHit::from_stored(stored, score, *tier));
971            }
972        }
973
974        // De-duplicate by key: short tier wins (it holds the freshest write).
975        let mut seen = std::collections::HashSet::new();
976        hits.retain(|h| seen.insert(h.key.clone()));
977
978        hits.sort_by(|a, b| {
979            b.score
980                .partial_cmp(&a.score)
981                .unwrap_or(std::cmp::Ordering::Equal)
982        });
983        hits.truncate(query.k);
984
985        // Refresh access metadata in the tier each surviving hit came from.
986        let mut short_keys = Vec::new();
987        let mut long_keys = Vec::new();
988        for hit in &hits {
989            match hit.tier {
990                MemoryTier::Short => short_keys.push(hit.key.clone()),
991                MemoryTier::Long => long_keys.push(hit.key.clone()),
992            }
993        }
994        self.short.touch(query.namespace, &short_keys);
995        self.long.touch(query.namespace, &long_keys);
996        Ok(hits)
997    }
998
999    async fn forget(&self, namespace: &str, key: &str) -> Result<bool, MemoryError> {
1000        let in_short = self.short.forget(namespace, key).await?;
1001        let in_long = self.long.forget(namespace, key).await?;
1002        Ok(in_short || in_long)
1003    }
1004
1005    async fn clear_namespace(&self, namespace: &str) -> Result<usize, MemoryError> {
1006        let s = self.short.clear_namespace(namespace).await?;
1007        let l = self.long.clear_namespace(namespace).await?;
1008        Ok(s + l)
1009    }
1010
1011    async fn len_namespace(&self, namespace: &str) -> Result<usize, MemoryError> {
1012        Ok(self.short.len_namespace(namespace).await? + self.long.len_namespace(namespace).await?)
1013    }
1014}
1015
1016// ─────────────────────────── extraction ─────────────────────────────
1017
1018/// Turns a completed conversation turn into durable memories.
1019///
1020/// Implementations call an LLM, apply heuristics, or simply filter; returning an empty
1021/// vector means "nothing worth remembering from this turn". The agent executor runs
1022/// this on a detached task so a slow extractor never blocks the control loop.
1023#[async_trait]
1024pub trait MemoryExtractor: Send + Sync {
1025    /// Extracts zero or more memories from one user/assistant exchange.
1026    async fn extract(
1027        &self,
1028        namespace: &str,
1029        user_input: &str,
1030        assistant_output: &str,
1031    ) -> Result<Vec<MemoryItem>, MemoryError>;
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037
1038    fn clock_at(secs: u64) -> Clock {
1039        Arc::new(move || std::time::UNIX_EPOCH + Duration::from_secs(secs))
1040    }
1041
1042    fn item(key: &str, text: &str, importance: f64) -> MemoryItem {
1043        MemoryItem::new(key, text).with_importance(importance)
1044    }
1045
1046    #[tokio::test]
1047    async fn lexical_scorer_ranks_shared_terms_first() {
1048        assert!(LexicalScorer::score("rust memory decay", "rust memory decay") > 0.99);
1049        let exact =
1050            LexicalScorer::score("rust agent framework", "the rust agent framework is fast");
1051        let unrelated = LexicalScorer::score("rust agent framework", "banana bread recipe sunday");
1052        assert!(exact > unrelated);
1053        assert_eq!(LexicalScorer::score("", "anything"), 0.0);
1054    }
1055
1056    #[tokio::test]
1057    async fn namespace_isolation_covers_get_search_forget_and_clear() {
1058        let store = ShortTermMemory::new(10);
1059        store
1060            .put("a", item("k1", "shared secret alpha", 0.5))
1061            .await
1062            .unwrap();
1063        store
1064            .put("b", item("k1", "shared secret beta", 0.5))
1065            .await
1066            .unwrap();
1067
1068        // Exact KV is namespaced.
1069        assert_eq!(
1070            store.get("a", "k1").await.unwrap().unwrap().text,
1071            "shared secret alpha"
1072        );
1073        assert_eq!(store.len_namespace("a").await.unwrap(), 1);
1074        assert_eq!(store.len_namespace("b").await.unwrap(), 1);
1075        assert_eq!(store.len_namespace("c").await.unwrap(), 0);
1076
1077        // Semantic recall never crosses namespaces.
1078        let hits_a = store
1079            .search(&MemoryQuery::new("a", "secret alpha").k(5))
1080            .await
1081            .unwrap();
1082        assert_eq!(hits_a.len(), 1);
1083        assert_eq!(hits_a[0].text, "shared secret alpha");
1084        assert!(store
1085            .search(&MemoryQuery::new("c", "secret"))
1086            .await
1087            .unwrap()
1088            .is_empty());
1089
1090        // Forget / clear stay inside the namespace.
1091        assert!(store.forget("a", "k1").await.unwrap());
1092        assert!(!store.forget("a", "k1").await.unwrap());
1093        assert_eq!(store.len_namespace("b").await.unwrap(), 1);
1094        assert_eq!(store.clear_namespace("b").await.unwrap(), 1);
1095        assert_eq!(store.len_namespace("b").await.unwrap(), 0);
1096    }
1097
1098    #[tokio::test]
1099    async fn short_term_validates_inputs_and_evicts_fifo() {
1100        let store = ShortTermMemory::new(2);
1101        assert!(store.put("ns", MemoryItem::new("", "x")).await.is_err());
1102        assert!(store.put("ns", MemoryItem::new("k", " ")).await.is_err());
1103        assert!(store.put("", MemoryItem::new("k", "x")).await.is_err());
1104
1105        store
1106            .put("ns", item("first", "first entry text", 0.5))
1107            .await
1108            .unwrap();
1109        store
1110            .put("ns", item("second", "second entry text", 0.5))
1111            .await
1112            .unwrap();
1113        store
1114            .put("ns", item("third", "third entry text", 0.5))
1115            .await
1116            .unwrap();
1117        assert_eq!(store.len_namespace("ns").await.unwrap(), 2);
1118        assert!(store.get("ns", "first").await.unwrap().is_none());
1119        assert!(store.get("ns", "second").await.unwrap().is_some());
1120        assert!(store.get("ns", "third").await.unwrap().is_some());
1121
1122        // Re-putting an existing key does not consume an extra slot.
1123        store
1124            .put("ns", item("second", "second updated", 0.9))
1125            .await
1126            .unwrap();
1127        assert_eq!(store.len_namespace("ns").await.unwrap(), 2);
1128        assert_eq!(
1129            store.get("ns", "second").await.unwrap().unwrap().importance,
1130            0.9
1131        );
1132    }
1133
1134    #[tokio::test]
1135    async fn get_counts_as_access_for_promotion() {
1136        let store = ShortTermMemory::new(10);
1137        store
1138            .put("ns", item("k", "watched fact", 0.1))
1139            .await
1140            .unwrap();
1141        store.get("ns", "k").await.unwrap();
1142        store.get("ns", "k").await.unwrap();
1143        // put itself counts as access 1 → two more gets reach 3.
1144        let stored = store.snapshot("ns").pop().unwrap();
1145        assert_eq!(stored.access_count, 3);
1146    }
1147
1148    /// Mutable clock handle shared between the test and the store.
1149    fn moving_clock(secs: Arc<Mutex<u64>>) -> Clock {
1150        Arc::new(move || std::time::UNIX_EPOCH + Duration::from_secs(*secs.lock().unwrap()))
1151    }
1152
1153    #[tokio::test]
1154    async fn long_term_decay_rewards_recency_and_importance() {
1155        // Pure formula: identical similarity, recency and importance move the score.
1156        let w = DecayWeights::default().with_half_life(Duration::from_secs(10));
1157        let fresh = w.score(1.0, 0.5, Duration::from_secs(0));
1158        let stale = w.score(1.0, 0.5, Duration::from_secs(30));
1159        assert!(fresh > stale);
1160        let important_stale = w.score(1.0, 1.0, Duration::from_secs(30));
1161        assert!(important_stale > stale);
1162        // At one half-life the recency term contributes half its weight.
1163        let half = w.score(0.0, 0.0, Duration::from_secs(10));
1164        assert!((half - 0.15 * 0.5).abs() < 1e-9);
1165
1166        // End-to-end: same text (identical similarity) written at t=0 and t=100, queried
1167        // at t=100 — the newer entry must outrank the stale one.
1168        let t = Arc::new(Mutex::new(0u64));
1169        let long = LongTermMemory::with_config(
1170            DecayWeights::default().with_half_life(Duration::from_secs(10)),
1171            Arc::new(LexicalScorer::new()),
1172        )
1173        .with_clock(moving_clock(t.clone()));
1174        long.put("ns", item("old", "same fact wording", 0.5))
1175            .await
1176            .unwrap();
1177        *t.lock().unwrap() = 100;
1178        long.put("ns", item("new", "same fact wording", 0.5))
1179            .await
1180            .unwrap();
1181
1182        let hits = long
1183            .search(&MemoryQuery::new("ns", "same fact wording").k(5))
1184            .await
1185            .unwrap();
1186        assert_eq!(hits[0].key, "new");
1187        assert_eq!(hits[0].tier, MemoryTier::Long);
1188        assert!(hits[0].score > hits[1].score);
1189    }
1190
1191    #[tokio::test]
1192    async fn consolidation_promotes_by_importance_or_access_and_merges() {
1193        let mem = TwoTierMemory::new(10).with_clock(clock_at(0));
1194        mem.put("ns", item("hot", "important fact", 0.95))
1195            .await
1196            .unwrap();
1197        mem.put("ns", item("warm", "reaccessed fact", 0.2))
1198            .await
1199            .unwrap();
1200        mem.put("ns", item("cold", "ignored fact", 0.2))
1201            .await
1202            .unwrap();
1203        // Warm earns two re-accesses (put = 1, total 3 → threshold).
1204        mem.get("ns", "warm").await.unwrap();
1205        mem.get("ns", "warm").await.unwrap();
1206
1207        let promoted = mem.consolidate_namespace("ns").await.unwrap();
1208        assert!(promoted.contains(&"hot".to_string()));
1209        assert!(promoted.contains(&"warm".to_string()));
1210        assert!(!promoted.contains(&"cold".to_string()));
1211        assert_eq!(promoted.len(), 2);
1212
1213        // Promoted entries left the short tier and live in long-term.
1214        assert!(mem.short_term().get("ns", "hot").await.unwrap().is_none());
1215        assert_eq!(mem.long_term().len_namespace("ns").await.unwrap(), 2);
1216        assert_eq!(mem.short_term().len_namespace("ns").await.unwrap(), 1);
1217        // get() still resolves promoted entries through the long tier.
1218        assert!(mem.get("ns", "hot").await.unwrap().is_some());
1219
1220        // Re-promotion merges rather than duplicating: same key returns from short with
1221        // a lower importance and accumulated accesses.
1222        mem.put("ns", item("hot", "important fact refined", 0.3))
1223            .await
1224            .unwrap();
1225        mem.short_term().get("ns", "hot").await.unwrap();
1226        mem.short_term().get("ns", "hot").await.unwrap();
1227        let again = mem.consolidate_namespace("ns").await.unwrap();
1228        assert_eq!(again, vec!["hot".to_string()]);
1229        let merged = mem.long_term().get("ns", "hot").await.unwrap().unwrap();
1230        assert_eq!(merged.text, "important fact refined");
1231        assert_eq!(merged.importance, 0.95); // max kept
1232        assert_eq!(mem.long_term().len_namespace("ns").await.unwrap(), 2);
1233    }
1234
1235    #[tokio::test]
1236    async fn two_tier_search_merges_dedupes_and_ranks_on_one_scale() {
1237        let mem = TwoTierMemory::new(10)
1238            .with_policy(PromotionPolicy::default().with_min_importance(0.0))
1239            .with_clock(clock_at(0));
1240        mem.put("ns", item("short_only", "alpha distinctive tokens", 0.5))
1241            .await
1242            .unwrap();
1243        mem.put("ns", item("both", "gamma shared wording here", 0.5))
1244            .await
1245            .unwrap();
1246        mem.consolidate_namespace("ns").await.unwrap(); // both → long
1247                                                        // Re-write "both" into short so it exists in both tiers; add a long-only item.
1248        mem.put("ns", item("both", "gamma shared wording here fresher", 0.5))
1249            .await
1250            .unwrap();
1251        mem.long_term()
1252            .put("ns", item("long_only", "beta another memory", 0.5))
1253            .await
1254            .unwrap();
1255
1256        let hits = mem
1257            .search(
1258                &MemoryQuery::new("ns", "gamma shared wording")
1259                    .k(10)
1260                    .min_score(0.3),
1261            )
1262            .await
1263            .unwrap();
1264        let keys: Vec<&str> = hits.iter().map(|h| h.key.as_str()).collect();
1265        assert!(keys.contains(&"both"));
1266        // A zero-similarity memory (importance-only score 0.075) is filtered out.
1267        assert!(!keys.contains(&"short_only"));
1268        assert!(!keys.contains(&"long_only"));
1269        // No duplicate key from the two tiers.
1270        assert_eq!(keys.iter().filter(|k| **k == "both").count(), 1);
1271        // The freshest "both" wins over the stale long-only match.
1272        assert_eq!(hits[0].key, "both");
1273
1274        // forget clears whichever tier holds the key; "both" lives in both tiers and
1275        // is removed from both.
1276        assert!(mem.forget("ns", "long_only").await.unwrap());
1277        assert!(mem.forget("ns", "both").await.unwrap());
1278        // long still holds short_only; short is now empty.
1279        assert_eq!(mem.len_namespace("ns").await.unwrap(), 1);
1280    }
1281
1282    #[tokio::test]
1283    async fn empty_namespace_and_query_validation() {
1284        let mem = TwoTierMemory::new(4);
1285        assert!(mem.search(&MemoryQuery::new(" ", "x")).await.is_err());
1286        assert!(mem.put(" ", item("k", "v", 0.5)).await.is_err());
1287    }
1288}