Skip to main content

llm_agent_runtime/
memory.rs

1//! # Module: Memory
2//!
3//! ## Responsibility
4//! Provides episodic, semantic, and working memory stores for agents.
5//! Mirrors the public API of `tokio-agent-memory` and `tokio-memory`.
6//!
7//! ## Guarantees
8//! - Thread-safe: all stores wrap their state in `Arc<Mutex<_>>`
9//! - Bounded: WorkingMemory evicts the oldest entry when capacity is exceeded
10//! - Decaying: DecayPolicy reduces importance scores over time
11//! - Non-panicking: all operations return `Result`
12//! - Lock-poisoning resilient: a panicking thread does not permanently break a store
13//! - O(1) agent lookup: EpisodicStore indexes items per-agent for efficient recall
14//!
15//! ## NOT Responsible For
16//! - Cross-agent shared memory (see runtime.rs coordinator)
17//! - Persistence to disk or external store
18
19use crate::error::AgentRuntimeError;
20use crate::util::recover_lock;
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23use std::collections::{HashMap, VecDeque};
24use std::sync::{Arc, Mutex};
25use uuid::Uuid;
26
27// ── Cosine similarity ─────────────────────────────────────────────────────────
28
29fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
30    if a.len() != b.len() || a.is_empty() {
31        return 0.0;
32    }
33    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
34    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
35    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
36    if norm_a == 0.0 || norm_b == 0.0 {
37        return 0.0;
38    }
39    (dot / (norm_a * norm_b)).clamp(-1.0, 1.0)
40}
41
42// ── Newtype IDs ───────────────────────────────────────────────────────────────
43
44/// Stable identifier for an agent instance.
45#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
46pub struct AgentId(pub String);
47
48impl AgentId {
49    /// Create a new `AgentId` from any string-like value.
50    pub fn new(id: impl Into<String>) -> Self {
51        let id = id.into();
52        debug_assert!(!id.is_empty(), "AgentId must not be empty");
53        Self(id)
54    }
55
56    /// Generate a random `AgentId` backed by a UUID v4.
57    pub fn random() -> Self {
58        Self(Uuid::new_v4().to_string())
59    }
60
61    /// Return the inner ID string as a `&str`.
62    pub fn as_str(&self) -> &str {
63        &self.0
64    }
65}
66
67impl AsRef<str> for AgentId {
68    fn as_ref(&self) -> &str {
69        &self.0
70    }
71}
72
73impl std::fmt::Display for AgentId {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        write!(f, "{}", self.0)
76    }
77}
78
79/// Stable identifier for a memory item.
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub struct MemoryId(pub String);
82
83impl MemoryId {
84    /// Create a new `MemoryId` from any string-like value.
85    pub fn new(id: impl Into<String>) -> Self {
86        let id = id.into();
87        debug_assert!(!id.is_empty(), "MemoryId must not be empty");
88        Self(id)
89    }
90
91    /// Generate a random `MemoryId` backed by a UUID v4.
92    pub fn random() -> Self {
93        Self(Uuid::new_v4().to_string())
94    }
95}
96
97impl AsRef<str> for MemoryId {
98    fn as_ref(&self) -> &str {
99        &self.0
100    }
101}
102
103impl std::fmt::Display for MemoryId {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        write!(f, "{}", self.0)
106    }
107}
108
109// ── MemoryItem ────────────────────────────────────────────────────────────────
110
111/// A single memory record stored for an agent.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct MemoryItem {
114    /// Unique identifier for this memory.
115    pub id: MemoryId,
116    /// The agent this memory belongs to.
117    pub agent_id: AgentId,
118    /// Textual content of the memory.
119    pub content: String,
120    /// Importance score in `[0.0, 1.0]`. Higher = more important.
121    pub importance: f32,
122    /// UTC timestamp when this memory was recorded.
123    pub timestamp: DateTime<Utc>,
124    /// Searchable tags attached to this memory.
125    pub tags: Vec<String>,
126    /// Number of times this memory has been recalled. Updated in-place by `recall`.
127    #[serde(default)]
128    pub recall_count: u64,
129}
130
131impl MemoryItem {
132    /// Construct a new `MemoryItem` with the current timestamp and a random ID.
133    pub fn new(
134        agent_id: AgentId,
135        content: impl Into<String>,
136        importance: f32,
137        tags: Vec<String>,
138    ) -> Self {
139        Self {
140            id: MemoryId::random(),
141            agent_id,
142            content: content.into(),
143            importance: importance.clamp(0.0, 1.0),
144            timestamp: Utc::now(),
145            tags,
146            recall_count: 0,
147        }
148    }
149}
150
151// ── DecayPolicy ───────────────────────────────────────────────────────────────
152
153/// Governs how memory importance decays over time.
154#[derive(Debug, Clone)]
155pub struct DecayPolicy {
156    /// The half-life duration in hours. After this many hours, importance is halved.
157    half_life_hours: f64,
158}
159
160impl DecayPolicy {
161    /// Create an exponential decay policy with the given half-life in hours.
162    ///
163    /// # Arguments
164    /// * `half_life_hours` — time after which importance is halved; must be > 0
165    ///
166    /// # Returns
167    /// - `Ok(DecayPolicy)` — on success
168    /// - `Err(AgentRuntimeError::Memory)` — if `half_life_hours <= 0`
169    pub fn exponential(half_life_hours: f64) -> Result<Self, AgentRuntimeError> {
170        if half_life_hours <= 0.0 {
171            return Err(AgentRuntimeError::Memory(
172                "half_life_hours must be positive".into(),
173            ));
174        }
175        Ok(Self { half_life_hours })
176    }
177
178    /// Apply decay to an importance score based on elapsed time.
179    ///
180    /// # Arguments
181    /// * `importance` — original importance in `[0.0, 1.0]`
182    /// * `age_hours` — how many hours have passed since the memory was recorded
183    ///
184    /// # Returns
185    /// Decayed importance clamped to `[0.0, 1.0]`.
186    pub fn apply(&self, importance: f32, age_hours: f64) -> f32 {
187        let decay = (-age_hours * std::f64::consts::LN_2 / self.half_life_hours).exp();
188        (importance as f64 * decay).clamp(0.0, 1.0) as f32
189    }
190
191    /// Apply decay in-place to a mutable `MemoryItem`.
192    pub fn decay_item(&self, item: &mut MemoryItem) {
193        let age_hours = (Utc::now() - item.timestamp).num_seconds().max(0) as f64 / 3600.0;
194        item.importance = self.apply(item.importance, age_hours);
195    }
196}
197
198// ── RecallPolicy ──────────────────────────────────────────────────────────────
199
200/// Controls how memories are scored and ranked during recall.
201///
202/// # Interaction with `DecayPolicy`
203///
204/// When both a `DecayPolicy` and a `RecallPolicy` are configured, decay is
205/// applied **before** scoring.  This means that for `RecallPolicy::Importance`,
206/// an old high-importance memory may rank lower than a fresh low-importance
207/// memory after decay has reduced its score.
208///
209/// For `RecallPolicy::Hybrid`, the `recency_weight` term already captures
210/// temporal distance; combining it with a `DecayPolicy` therefore applies a
211/// *double* time penalty — set one or the other, not both, unless the double
212/// penalty is intentional.
213///
214/// ## Score Calculation Example
215///
216/// Given two memories, each with `importance = 0.5`:
217/// - Memory A: `recall_count = 0`, inserted 1 hour ago
218/// - Memory B: `recall_count = 10`, inserted 10 hours ago
219///
220/// With `recency_weight = 1.0` and `frequency_weight = 0.1`:
221/// - Score A = `0.5 + 1.0 × 1.0 + 0.1 × 0` = `1.5` (recency wins)
222/// - Score B = `0.5 + 1.0 × (−10.0) + 0.1 × 10` = `−8.5` (old → ranked lower)
223///
224/// Note: the recency term uses negative hours-since-creation so older items score lower.
225#[derive(Debug, Clone)]
226pub enum RecallPolicy {
227    /// Rank purely by importance score (default).
228    Importance,
229    /// Hybrid score: blends importance, recency, and recall frequency.
230    ///
231    /// `score = importance + recency_score * recency_weight + frequency_score * frequency_weight`
232    /// where `recency_score = exp(-age_hours / 24.0)` and
233    /// `frequency_score = recall_count / (max_recall_count + 1)` (normalized).
234    Hybrid {
235        /// Weight applied to the recency component of the hybrid score.
236        recency_weight: f32,
237        /// Weight applied to the recall-frequency component of the hybrid score.
238        frequency_weight: f32,
239    },
240}
241
242impl Default for RecallPolicy {
243    fn default() -> Self {
244        RecallPolicy::Importance
245    }
246}
247
248// ── Hybrid scoring helper ─────────────────────────────────────────────────────
249
250fn compute_hybrid_score(
251    item: &MemoryItem,
252    recency_weight: f32,
253    frequency_weight: f32,
254    max_recall: u64,
255    now: chrono::DateTime<Utc>,
256) -> f32 {
257    let age_hours = (now - item.timestamp).num_seconds().max(0) as f64 / 3600.0;
258    let recency_score = (-age_hours / 24.0).exp() as f32;
259    let frequency_score = item.recall_count as f32 / (max_recall as f32 + 1.0);
260    item.importance + recency_score * recency_weight + frequency_score * frequency_weight
261}
262
263// ── EvictionPolicy ────────────────────────────────────────────────────────────
264
265/// Policy controlling which item is evicted when the per-agent capacity is exceeded.
266#[derive(Debug, Clone, PartialEq, Eq, Default)]
267pub enum EvictionPolicy {
268    /// Evict the item with the lowest importance score (default).
269    #[default]
270    LowestImportance,
271    /// Evict the oldest item (by insertion order / timestamp).
272    Oldest,
273}
274
275// ── EpisodicStoreBuilder ──────────────────────────────────────────────────────
276
277/// Fluent builder for [`EpisodicStore`].
278///
279/// Allows combining any set of options — decay, recall policy, per-agent
280/// capacity, max age, and eviction policy — before creating the store.
281///
282/// # Example
283/// ```rust
284/// use llm_agent_runtime::memory::{EpisodicStore, EvictionPolicy, RecallPolicy, DecayPolicy};
285///
286/// let store = EpisodicStore::builder()
287///     .per_agent_capacity(50)
288///     .eviction_policy(EvictionPolicy::Oldest)
289///     .build();
290/// ```
291#[derive(Default)]
292pub struct EpisodicStoreBuilder {
293    decay: Option<DecayPolicy>,
294    recall_policy: Option<RecallPolicy>,
295    per_agent_capacity: Option<usize>,
296    max_age_hours: Option<f64>,
297    eviction_policy: Option<EvictionPolicy>,
298}
299
300impl EpisodicStoreBuilder {
301    /// Set the decay policy.
302    pub fn decay(mut self, policy: DecayPolicy) -> Self {
303        self.decay = Some(policy);
304        self
305    }
306
307    /// Set the recall policy.
308    pub fn recall_policy(mut self, policy: RecallPolicy) -> Self {
309        self.recall_policy = Some(policy);
310        self
311    }
312
313    /// Set the per-agent capacity. Panics if `capacity == 0`.
314    pub fn per_agent_capacity(mut self, capacity: usize) -> Self {
315        assert!(capacity > 0, "per_agent_capacity must be > 0");
316        self.per_agent_capacity = Some(capacity);
317        self
318    }
319
320    /// Set the maximum memory age in hours. Returns `Err` if `max_age_hours <= 0`.
321    pub fn max_age_hours(mut self, hours: f64) -> Result<Self, crate::error::AgentRuntimeError> {
322        if hours <= 0.0 {
323            return Err(crate::error::AgentRuntimeError::Memory(
324                "max_age_hours must be positive".into(),
325            ));
326        }
327        self.max_age_hours = Some(hours);
328        Ok(self)
329    }
330
331    /// Set the eviction policy.
332    pub fn eviction_policy(mut self, policy: EvictionPolicy) -> Self {
333        self.eviction_policy = Some(policy);
334        self
335    }
336
337    /// Consume the builder and create an [`EpisodicStore`].
338    pub fn build(self) -> EpisodicStore {
339        EpisodicStore {
340            inner: Arc::new(Mutex::new(EpisodicInner {
341                items: HashMap::new(),
342                decay: self.decay,
343                recall_policy: self.recall_policy.unwrap_or(RecallPolicy::Importance),
344                per_agent_capacity: self.per_agent_capacity,
345                max_age_hours: self.max_age_hours,
346                eviction_policy: self.eviction_policy.unwrap_or_default(),
347            })),
348        }
349    }
350}
351
352// ── EpisodicStore ─────────────────────────────────────────────────────────────
353
354/// Stores episodic (event-based) memories for agents, ordered by insertion time.
355///
356/// ## Guarantees
357/// - Thread-safe via `Arc<Mutex<_>>`
358/// - Ordered: recall returns items in descending importance order
359/// - Bounded by optional per-agent capacity
360/// - O(1) agent lookup via per-agent `HashMap` index
361/// - Automatic expiry via optional `max_age_hours`
362#[derive(Debug, Clone)]
363pub struct EpisodicStore {
364    inner: Arc<Mutex<EpisodicInner>>,
365}
366
367#[derive(Debug)]
368struct EpisodicInner {
369    /// Items stored per-agent for O(1) lookup. The key is the agent ID.
370    items: HashMap<AgentId, Vec<MemoryItem>>,
371    decay: Option<DecayPolicy>,
372    recall_policy: RecallPolicy,
373    /// Maximum items stored per agent. Oldest (lowest-importance) items evicted when exceeded.
374    per_agent_capacity: Option<usize>,
375    /// Maximum age in hours. Items older than this are purged on the next recall or add.
376    max_age_hours: Option<f64>,
377    /// Eviction policy when per_agent_capacity is exceeded.
378    eviction_policy: EvictionPolicy,
379}
380
381impl EpisodicInner {
382    /// Purge items for `agent_id` that exceed `max_age_hours`, if configured.
383    fn purge_stale(&mut self, agent_id: &AgentId) {
384        if let Some(max_age_h) = self.max_age_hours {
385            let cutoff = Utc::now()
386                - chrono::Duration::seconds((max_age_h * 3600.0) as i64);
387            if let Some(agent_items) = self.items.get_mut(agent_id) {
388                agent_items.retain(|i| i.timestamp >= cutoff);
389            }
390        }
391    }
392}
393
394/// Evict one item from `agent_items` if `len > cap`, according to `policy`.
395///
396/// The last element (the just-inserted item) is excluded from the
397/// `LowestImportance` scan so that newly added items are never evicted.
398fn evict_if_over_capacity(
399    agent_items: &mut Vec<MemoryItem>,
400    cap: usize,
401    policy: &EvictionPolicy,
402) {
403    if agent_items.len() <= cap {
404        return;
405    }
406    let pos = match policy {
407        EvictionPolicy::LowestImportance => {
408            let len = agent_items.len();
409            agent_items[..len - 1]
410                .iter()
411                .enumerate()
412                .min_by(|(_, a), (_, b)| {
413                    a.importance
414                        .partial_cmp(&b.importance)
415                        .unwrap_or(std::cmp::Ordering::Equal)
416                })
417                .map(|(pos, _)| pos)
418        }
419        EvictionPolicy::Oldest => {
420            let len = agent_items.len();
421            agent_items[..len - 1]
422                .iter()
423                .enumerate()
424                .min_by_key(|(_, item)| item.timestamp)
425                .map(|(pos, _)| pos)
426        }
427    };
428    if let Some(pos) = pos {
429        agent_items.remove(pos);
430    }
431}
432
433impl EpisodicStore {
434    /// Create a new unbounded episodic store without decay.
435    pub fn new() -> Self {
436        Self {
437            inner: Arc::new(Mutex::new(EpisodicInner {
438                items: HashMap::new(),
439                decay: None,
440                recall_policy: RecallPolicy::Importance,
441                per_agent_capacity: None,
442                max_age_hours: None,
443                eviction_policy: EvictionPolicy::LowestImportance,
444            })),
445        }
446    }
447
448    /// Return a fluent builder to construct an `EpisodicStore` with any combination of options.
449    pub fn builder() -> EpisodicStoreBuilder {
450        EpisodicStoreBuilder::default()
451    }
452
453    /// Create a new episodic store with the given decay policy.
454    pub fn with_decay(policy: DecayPolicy) -> Self {
455        Self {
456            inner: Arc::new(Mutex::new(EpisodicInner {
457                items: HashMap::new(),
458                decay: Some(policy),
459                recall_policy: RecallPolicy::Importance,
460                per_agent_capacity: None,
461                max_age_hours: None,
462                eviction_policy: EvictionPolicy::LowestImportance,
463            })),
464        }
465    }
466
467    /// Create a new episodic store with both a decay policy and a recall policy.
468    pub fn with_decay_and_recall_policy(decay: DecayPolicy, recall: RecallPolicy) -> Self {
469        Self {
470            inner: Arc::new(Mutex::new(EpisodicInner {
471                items: HashMap::new(),
472                decay: Some(decay),
473                recall_policy: recall,
474                per_agent_capacity: None,
475                max_age_hours: None,
476                eviction_policy: EvictionPolicy::LowestImportance,
477            })),
478        }
479    }
480
481    /// Create a new episodic store with the given recall policy.
482    pub fn with_recall_policy(policy: RecallPolicy) -> Self {
483        Self {
484            inner: Arc::new(Mutex::new(EpisodicInner {
485                items: HashMap::new(),
486                decay: None,
487                recall_policy: policy,
488                per_agent_capacity: None,
489                max_age_hours: None,
490                eviction_policy: EvictionPolicy::LowestImportance,
491            })),
492        }
493    }
494
495    /// Create a new episodic store with the given per-agent capacity limit.
496    ///
497    /// When an agent exceeds this capacity, the lowest-importance item for that
498    /// agent is evicted.
499    ///
500    /// # Soft-limit semantics
501    ///
502    /// The capacity is a *soft* limit.  During each [`add_episode`] call the
503    /// new item is inserted first, and only then is the lowest-importance item
504    /// evicted if the count exceeds `capacity`.  This means the store
505    /// momentarily holds `capacity + 1` items per agent while eviction is in
506    /// progress.  The newly added item is **never** the one evicted regardless
507    /// of its importance score.
508    ///
509    /// Concurrent calls to `add_episode` may briefly exceed the cap by more
510    /// than one item before each call performs its own eviction sweep.
511    ///
512    /// [`add_episode`]: EpisodicStore::add_episode
513    pub fn with_per_agent_capacity(capacity: usize) -> Self {
514        assert!(capacity > 0, "per_agent_capacity must be > 0");
515        Self {
516            inner: Arc::new(Mutex::new(EpisodicInner {
517                items: HashMap::new(),
518                decay: None,
519                recall_policy: RecallPolicy::Importance,
520                per_agent_capacity: Some(capacity),
521                max_age_hours: None,
522                eviction_policy: EvictionPolicy::LowestImportance,
523            })),
524        }
525    }
526
527    /// Create a new episodic store with an absolute age limit.
528    ///
529    /// Items older than `max_age_hours` are automatically purged on the next
530    /// `recall` or `add_episode` call for the owning agent.
531    ///
532    /// # Arguments
533    /// * `max_age_hours` — maximum memory age in hours; must be > 0
534    pub fn with_max_age(max_age_hours: f64) -> Result<Self, AgentRuntimeError> {
535        if max_age_hours <= 0.0 {
536            return Err(AgentRuntimeError::Memory(
537                "max_age_hours must be positive".into(),
538            ));
539        }
540        Ok(Self {
541            inner: Arc::new(Mutex::new(EpisodicInner {
542                items: HashMap::new(),
543                decay: None,
544                recall_policy: RecallPolicy::Importance,
545                per_agent_capacity: None,
546                max_age_hours: Some(max_age_hours),
547                eviction_policy: EvictionPolicy::LowestImportance,
548            })),
549        })
550    }
551
552    /// Create a new episodic store with the given eviction policy.
553    pub fn with_eviction_policy(policy: EvictionPolicy) -> Self {
554        Self {
555            inner: Arc::new(Mutex::new(EpisodicInner {
556                items: HashMap::new(),
557                decay: None,
558                recall_policy: RecallPolicy::Importance,
559                per_agent_capacity: None,
560                max_age_hours: None,
561                eviction_policy: policy,
562            })),
563        }
564    }
565
566    /// Record a new episode for the given agent.
567    ///
568    /// # Returns
569    /// The `MemoryId` of the newly created memory item.
570    ///
571    /// # Errors
572    /// Returns `Err(AgentRuntimeError::Memory)` only if the internal mutex is
573    /// poisoned (extremely unlikely in normal operation; see [`recover_lock`]).
574    ///
575    /// # Capacity enforcement
576    ///
577    /// If the store was created with [`with_per_agent_capacity`], the item is
578    /// always inserted first.  If the agent's item count then exceeds the cap,
579    /// the single lowest-importance item for that agent is evicted.  See
580    /// [`with_per_agent_capacity`] for the full soft-limit semantics.
581    ///
582    /// [`with_per_agent_capacity`]: EpisodicStore::with_per_agent_capacity
583    #[tracing::instrument(skip(self))]
584    pub fn add_episode(
585        &self,
586        agent_id: AgentId,
587        content: impl Into<String> + std::fmt::Debug,
588        importance: f32,
589    ) -> Result<MemoryId, AgentRuntimeError> {
590        let item = MemoryItem::new(agent_id.clone(), content, importance, Vec::new());
591        let id = item.id.clone();
592        let mut inner = recover_lock(self.inner.lock(), "EpisodicStore::add_episode");
593
594        inner.purge_stale(&agent_id);
595        let cap = inner.per_agent_capacity; // read before mutable borrow
596        let eviction_policy = inner.eviction_policy.clone();
597        let agent_items = inner.items.entry(agent_id).or_default();
598        agent_items.push(item);
599
600        if let Some(cap) = cap {
601            evict_if_over_capacity(agent_items, cap, &eviction_policy);
602        }
603        Ok(id)
604    }
605
606    /// Add an episode with an explicit timestamp.
607    #[tracing::instrument(skip(self))]
608    pub fn add_episode_at(
609        &self,
610        agent_id: AgentId,
611        content: impl Into<String> + std::fmt::Debug,
612        importance: f32,
613        timestamp: chrono::DateTime<chrono::Utc>,
614    ) -> Result<MemoryId, AgentRuntimeError> {
615        let mut item = MemoryItem::new(agent_id.clone(), content, importance, Vec::new());
616        item.timestamp = timestamp;
617        let id = item.id.clone();
618        let mut inner = recover_lock(self.inner.lock(), "EpisodicStore::add_episode_at");
619
620        inner.purge_stale(&agent_id);
621        let cap = inner.per_agent_capacity; // read before mutable borrow
622        let eviction_policy = inner.eviction_policy.clone();
623        let agent_items = inner.items.entry(agent_id).or_default();
624        agent_items.push(item);
625
626        if let Some(cap) = cap {
627            evict_if_over_capacity(agent_items, cap, &eviction_policy);
628        }
629        Ok(id)
630    }
631
632    /// Recall up to `limit` memories for the given agent.
633    ///
634    /// Applies decay if configured, purges stale items if `max_age` is set,
635    /// increments `recall_count` for each recalled item, then returns items
636    /// sorted according to the configured `RecallPolicy`.
637    ///
638    /// # Errors
639    /// Returns `Err(AgentRuntimeError::Memory)` only if the internal mutex is
640    /// poisoned (extremely unlikely in normal operation).
641    #[tracing::instrument(skip(self))]
642    pub fn recall(
643        &self,
644        agent_id: &AgentId,
645        limit: usize,
646    ) -> Result<Vec<MemoryItem>, AgentRuntimeError> {
647        let mut inner = recover_lock(self.inner.lock(), "EpisodicStore::recall");
648
649        // Clone policy values to avoid borrow conflicts.
650        let decay = inner.decay.clone();
651        let max_age = inner.max_age_hours;
652        let recall_policy = inner.recall_policy.clone();
653
654        // Use get_mut to avoid creating ghost entries for unknown agents.
655        if !inner.items.contains_key(agent_id) {
656            return Ok(Vec::new());
657        }
658        let agent_items = inner.items.get_mut(agent_id).unwrap();
659
660        // Apply decay in-place.
661        if let Some(ref policy) = decay {
662            for item in agent_items.iter_mut() {
663                policy.decay_item(item);
664            }
665        }
666
667        // Purge stale items.
668        if let Some(max_age_h) = max_age {
669            let cutoff =
670                Utc::now() - chrono::Duration::seconds((max_age_h * 3600.0) as i64);
671            agent_items.retain(|i| i.timestamp >= cutoff);
672        }
673
674        // Build a sorted index list (descending by score) without cloning all items first.
675        let mut indices: Vec<usize> = (0..agent_items.len()).collect();
676
677        match recall_policy {
678            RecallPolicy::Importance => {
679                indices.sort_by(|&a, &b| {
680                    agent_items[b]
681                        .importance
682                        .partial_cmp(&agent_items[a].importance)
683                        .unwrap_or(std::cmp::Ordering::Equal)
684                });
685            }
686            RecallPolicy::Hybrid {
687                recency_weight,
688                frequency_weight,
689            } => {
690                let max_recall = agent_items
691                    .iter()
692                    .map(|i| i.recall_count)
693                    .max()
694                    .unwrap_or(1)
695                    .max(1);
696                let now = Utc::now();
697                indices.sort_by(|&a, &b| {
698                    let score_a = compute_hybrid_score(
699                        &agent_items[a],
700                        recency_weight,
701                        frequency_weight,
702                        max_recall,
703                        now,
704                    );
705                    let score_b = compute_hybrid_score(
706                        &agent_items[b],
707                        recency_weight,
708                        frequency_weight,
709                        max_recall,
710                        now,
711                    );
712                    score_b
713                        .partial_cmp(&score_a)
714                        .unwrap_or(std::cmp::Ordering::Equal)
715                });
716            }
717        }
718
719        indices.truncate(limit);
720
721        // Increment recall_count only for the surviving items.
722        for &idx in &indices {
723            agent_items[idx].recall_count += 1;
724        }
725
726        // Clone only the surviving items, with already-incremented counts.
727        let items: Vec<MemoryItem> = indices.iter().map(|&idx| agent_items[idx].clone()).collect();
728
729        tracing::debug!("recalled {} items", items.len());
730        Ok(items)
731    }
732
733    /// Return the total number of stored episodes across all agents.
734    pub fn len(&self) -> Result<usize, AgentRuntimeError> {
735        let inner = recover_lock(self.inner.lock(), "EpisodicStore::len");
736        Ok(inner.items.values().map(|v| v.len()).sum())
737    }
738
739    /// Return `true` if no episodes have been stored.
740    pub fn is_empty(&self) -> Result<bool, AgentRuntimeError> {
741        Ok(self.len()? == 0)
742    }
743
744    /// Return the number of stored episodes for a specific agent.
745    ///
746    /// Returns `0` if the agent has no episodes or has not been seen before.
747    pub fn agent_memory_count(&self, agent_id: &AgentId) -> Result<usize, AgentRuntimeError> {
748        let inner = recover_lock(self.inner.lock(), "EpisodicStore::agent_memory_count");
749        Ok(inner.items.get(agent_id).map_or(0, |v| v.len()))
750    }
751
752    /// Return all agent IDs that have at least one stored episode.
753    ///
754    /// The order of agents in the returned vector is not guaranteed.
755    pub fn list_agents(&self) -> Result<Vec<AgentId>, AgentRuntimeError> {
756        let inner = recover_lock(self.inner.lock(), "EpisodicStore::list_agents");
757        Ok(inner.items.keys().cloned().collect())
758    }
759
760    /// Remove all stored episodes for `agent_id` and return the number removed.
761    ///
762    /// Returns `0` if the agent had no episodes.  Does not affect other agents.
763    pub fn purge_agent_memories(&self, agent_id: &AgentId) -> Result<usize, AgentRuntimeError> {
764        let mut inner = recover_lock(self.inner.lock(), "EpisodicStore::purge_agent_memories");
765        let removed = inner.items.remove(agent_id).map_or(0, |v| v.len());
766        Ok(removed)
767    }
768
769    /// Remove all memories for the given agent.
770    ///
771    /// After this call, `recall` for this agent returns an empty list.
772    pub fn clear_agent_memory(&self, agent_id: &AgentId) -> Result<(), AgentRuntimeError> {
773        let mut inner = recover_lock(self.inner.lock(), "EpisodicStore::clear_agent_memory");
774        inner.items.remove(agent_id);
775        Ok(())
776    }
777
778    /// Export all memories for the given agent as a serializable Vec.
779    ///
780    /// Useful for migrating agent state across runtime instances.
781    pub fn export_agent_memory(&self, agent_id: &AgentId) -> Result<Vec<MemoryItem>, AgentRuntimeError> {
782        let inner = recover_lock(self.inner.lock(), "EpisodicStore::export_agent_memory");
783        Ok(inner.items.get(agent_id).cloned().unwrap_or_default())
784    }
785
786    /// Import a Vec of MemoryItems for the given agent, replacing any existing memories.
787    ///
788    /// The agent's existing memories are completely replaced by the imported items.
789    pub fn import_agent_memory(&self, agent_id: &AgentId, items: Vec<MemoryItem>) -> Result<(), AgentRuntimeError> {
790        let mut inner = recover_lock(self.inner.lock(), "EpisodicStore::import_agent_memory");
791        inner.items.insert(agent_id.clone(), items);
792        Ok(())
793    }
794
795    /// Bump the `recall_count` of every item whose content equals `content` by `amount`.
796    ///
797    /// This method exists to support integration tests that need to simulate prior recall
798    /// history without accessing private fields. It is not intended for production use.
799    #[doc(hidden)]
800    pub fn bump_recall_count_by_content(&self, content: &str, amount: u64) {
801        let mut inner = recover_lock(
802            self.inner.lock(),
803            "EpisodicStore::bump_recall_count_by_content",
804        );
805        for agent_items in inner.items.values_mut() {
806            for item in agent_items.iter_mut() {
807                if item.content == content {
808                    item.recall_count = item.recall_count.saturating_add(amount);
809                }
810            }
811        }
812    }
813}
814
815impl Default for EpisodicStore {
816    fn default() -> Self {
817        Self::new()
818    }
819}
820
821// ── SemanticStore ─────────────────────────────────────────────────────────────
822
823/// Stores semantic (fact-based) knowledge as tagged key-value pairs.
824///
825/// ## Guarantees
826/// - Thread-safe via `Arc<Mutex<_>>`
827/// - Retrieval by tag intersection
828/// - Optional vector-based similarity search via stored embeddings
829#[derive(Debug, Clone)]
830pub struct SemanticStore {
831    inner: Arc<Mutex<SemanticInner>>,
832}
833
834#[derive(Debug)]
835struct SemanticInner {
836    entries: Vec<SemanticEntry>,
837    expected_dim: Option<usize>,
838}
839
840#[derive(Debug, Clone)]
841struct SemanticEntry {
842    key: String,
843    value: String,
844    tags: Vec<String>,
845    embedding: Option<Vec<f32>>,
846}
847
848impl SemanticStore {
849    /// Create a new empty semantic store.
850    pub fn new() -> Self {
851        Self {
852            inner: Arc::new(Mutex::new(SemanticInner {
853                entries: Vec::new(),
854                expected_dim: None,
855            })),
856        }
857    }
858
859    /// Store a key-value pair with associated tags.
860    #[tracing::instrument(skip(self))]
861    pub fn store(
862        &self,
863        key: impl Into<String> + std::fmt::Debug,
864        value: impl Into<String> + std::fmt::Debug,
865        tags: Vec<String>,
866    ) -> Result<(), AgentRuntimeError> {
867        let mut inner = recover_lock(self.inner.lock(), "SemanticStore::store");
868        inner.entries.push(SemanticEntry {
869            key: key.into(),
870            value: value.into(),
871            tags,
872            embedding: None,
873        });
874        Ok(())
875    }
876
877    /// Store a key-value pair with an embedding vector for similarity search.
878    ///
879    /// # Errors
880    /// Returns `Err(AgentRuntimeError::Memory)` if `embedding` is empty or dimension mismatches.
881    #[tracing::instrument(skip(self))]
882    pub fn store_with_embedding(
883        &self,
884        key: impl Into<String> + std::fmt::Debug,
885        value: impl Into<String> + std::fmt::Debug,
886        tags: Vec<String>,
887        embedding: Vec<f32>,
888    ) -> Result<(), AgentRuntimeError> {
889        if embedding.is_empty() {
890            return Err(AgentRuntimeError::Memory(
891                "embedding vector must not be empty".into(),
892            ));
893        }
894        let mut inner = recover_lock(self.inner.lock(), "SemanticStore::store_with_embedding");
895        // Validate dimension consistency using expected_dim.
896        if let Some(expected) = inner.expected_dim {
897            if expected != embedding.len() {
898                return Err(AgentRuntimeError::Memory(format!(
899                    "embedding dimension mismatch: expected {expected}, got {}",
900                    embedding.len()
901                )));
902            }
903        } else {
904            inner.expected_dim = Some(embedding.len());
905        }
906        inner.entries.push(SemanticEntry {
907            key: key.into(),
908            value: value.into(),
909            tags,
910            embedding: Some(embedding),
911        });
912        Ok(())
913    }
914
915    /// Retrieve all entries that contain **all** of the given tags.
916    ///
917    /// If `tags` is empty, returns all entries.
918    #[tracing::instrument(skip(self))]
919    pub fn retrieve(&self, tags: &[&str]) -> Result<Vec<(String, String)>, AgentRuntimeError> {
920        let inner = recover_lock(self.inner.lock(), "SemanticStore::retrieve");
921
922        let results = inner
923            .entries
924            .iter()
925            .filter(|entry| {
926                tags.iter()
927                    .all(|t| entry.tags.iter().any(|et| et.as_str() == *t))
928            })
929            .map(|e| (e.key.clone(), e.value.clone()))
930            .collect();
931
932        Ok(results)
933    }
934
935    /// Retrieve top-k entries by cosine similarity to `query_embedding`.
936    ///
937    /// Only entries that were stored with an embedding (via [`store_with_embedding`])
938    /// are considered.  Returns `(key, value, similarity)` sorted by descending
939    /// similarity.
940    ///
941    /// Returns `Err(AgentRuntimeError::Memory)` if `query_embedding` dimension mismatches.
942    ///
943    /// [`store_with_embedding`]: SemanticStore::store_with_embedding
944    #[tracing::instrument(skip(self, query_embedding))]
945    pub fn retrieve_similar(
946        &self,
947        query_embedding: &[f32],
948        top_k: usize,
949    ) -> Result<Vec<(String, String, f32)>, AgentRuntimeError> {
950        let inner = recover_lock(self.inner.lock(), "SemanticStore::retrieve_similar");
951
952        // Check dimension against expected_dim.
953        if let Some(expected) = inner.expected_dim {
954            if expected != query_embedding.len() {
955                return Err(AgentRuntimeError::Memory(format!(
956                    "query embedding dimension mismatch: expected {expected}, got {}",
957                    query_embedding.len()
958                )));
959            }
960        }
961
962        let mut scored: Vec<(String, String, f32)> = inner
963            .entries
964            .iter()
965            .filter_map(|entry| {
966                entry.embedding.as_ref().map(|emb| {
967                    let sim = cosine_similarity(query_embedding, emb);
968                    (entry.key.clone(), entry.value.clone(), sim)
969                })
970            })
971            .collect();
972
973        scored.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
974        scored.truncate(top_k);
975        Ok(scored)
976    }
977
978    /// Return the total number of stored entries.
979    pub fn len(&self) -> Result<usize, AgentRuntimeError> {
980        let inner = recover_lock(self.inner.lock(), "SemanticStore::len");
981        Ok(inner.entries.len())
982    }
983
984    /// Return `true` if no entries have been stored.
985    pub fn is_empty(&self) -> Result<bool, AgentRuntimeError> {
986        Ok(self.len()? == 0)
987    }
988}
989
990impl Default for SemanticStore {
991    fn default() -> Self {
992        Self::new()
993    }
994}
995
996// ── WorkingMemory ─────────────────────────────────────────────────────────────
997
998/// A bounded, key-value working memory for transient agent state.
999///
1000/// When capacity is exceeded, the oldest entry (by insertion order) is evicted.
1001///
1002/// ## Guarantees
1003/// - Thread-safe via `Arc<Mutex<_>>`
1004/// - Bounded: never exceeds `capacity` entries
1005/// - Deterministic eviction: LRU (oldest insertion first)
1006#[derive(Debug, Clone)]
1007pub struct WorkingMemory {
1008    capacity: usize,
1009    inner: Arc<Mutex<WorkingInner>>,
1010}
1011
1012#[derive(Debug)]
1013struct WorkingInner {
1014    map: HashMap<String, String>,
1015    order: VecDeque<String>,
1016}
1017
1018impl WorkingMemory {
1019    /// Create a new `WorkingMemory` with the given capacity.
1020    ///
1021    /// # Returns
1022    /// - `Ok(WorkingMemory)` — on success
1023    /// - `Err(AgentRuntimeError::Memory)` — if `capacity == 0`
1024    pub fn new(capacity: usize) -> Result<Self, AgentRuntimeError> {
1025        if capacity == 0 {
1026            return Err(AgentRuntimeError::Memory(
1027                "WorkingMemory capacity must be > 0".into(),
1028            ));
1029        }
1030        Ok(Self {
1031            capacity,
1032            inner: Arc::new(Mutex::new(WorkingInner {
1033                map: HashMap::new(),
1034                order: VecDeque::new(),
1035            })),
1036        })
1037    }
1038
1039    /// Insert or update a key-value pair, evicting the oldest entry if over capacity.
1040    #[tracing::instrument(skip(self))]
1041    pub fn set(
1042        &self,
1043        key: impl Into<String> + std::fmt::Debug,
1044        value: impl Into<String> + std::fmt::Debug,
1045    ) -> Result<(), AgentRuntimeError> {
1046        let key = key.into();
1047        let value = value.into();
1048        let mut inner = recover_lock(self.inner.lock(), "WorkingMemory::set");
1049
1050        // Remove existing key from order tracking if present
1051        if inner.map.contains_key(&key) {
1052            inner.order.retain(|k| k != &key);
1053        } else if inner.map.len() >= self.capacity {
1054            // Evict oldest
1055            if let Some(oldest) = inner.order.pop_front() {
1056                inner.map.remove(&oldest);
1057            }
1058        }
1059
1060        inner.order.push_back(key.clone());
1061        inner.map.insert(key, value);
1062        Ok(())
1063    }
1064
1065    /// Retrieve a value by key.
1066    ///
1067    /// # Returns
1068    /// - `Some(value)` — if the key exists
1069    /// - `None` — if not found
1070    #[tracing::instrument(skip(self))]
1071    pub fn get(&self, key: &str) -> Result<Option<String>, AgentRuntimeError> {
1072        let inner = recover_lock(self.inner.lock(), "WorkingMemory::get");
1073        Ok(inner.map.get(key).cloned())
1074    }
1075
1076    /// Remove all entries from working memory.
1077    pub fn clear(&self) -> Result<(), AgentRuntimeError> {
1078        let mut inner = recover_lock(self.inner.lock(), "WorkingMemory::clear");
1079        inner.map.clear();
1080        inner.order.clear();
1081        Ok(())
1082    }
1083
1084    /// Return the current number of entries.
1085    pub fn len(&self) -> Result<usize, AgentRuntimeError> {
1086        let inner = recover_lock(self.inner.lock(), "WorkingMemory::len");
1087        Ok(inner.map.len())
1088    }
1089
1090    /// Return `true` if no entries are stored.
1091    pub fn is_empty(&self) -> Result<bool, AgentRuntimeError> {
1092        Ok(self.len()? == 0)
1093    }
1094
1095    /// Iterate over all key-value pairs in insertion order.
1096    ///
1097    /// Equivalent to [`entries`]; provided as a more idiomatic name
1098    /// for `for`-loop patterns.
1099    ///
1100    /// [`entries`]: WorkingMemory::entries
1101    pub fn iter(&self) -> Result<Vec<(String, String)>, AgentRuntimeError> {
1102        self.entries()
1103    }
1104
1105    /// Return all key-value pairs in insertion order.
1106    pub fn entries(&self) -> Result<Vec<(String, String)>, AgentRuntimeError> {
1107        let inner = recover_lock(self.inner.lock(), "WorkingMemory::entries");
1108        let entries = inner
1109            .order
1110            .iter()
1111            .filter_map(|k| inner.map.get(k).map(|v| (k.clone(), v.clone())))
1112            .collect();
1113        Ok(entries)
1114    }
1115}
1116
1117// ── Tests ─────────────────────────────────────────────────────────────────────
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    // ── AgentId / MemoryId ────────────────────────────────────────────────────
1124
1125    #[test]
1126    fn test_agent_id_new_stores_string() {
1127        let id = AgentId::new("agent-1");
1128        assert_eq!(id.0, "agent-1");
1129    }
1130
1131    #[test]
1132    fn test_agent_id_random_is_unique() {
1133        let a = AgentId::random();
1134        let b = AgentId::random();
1135        assert_ne!(a, b);
1136    }
1137
1138    #[test]
1139    fn test_memory_id_new_stores_string() {
1140        let id = MemoryId::new("mem-1");
1141        assert_eq!(id.0, "mem-1");
1142    }
1143
1144    #[test]
1145    fn test_memory_id_random_is_unique() {
1146        let a = MemoryId::random();
1147        let b = MemoryId::random();
1148        assert_ne!(a, b);
1149    }
1150
1151    // ── MemoryItem ────────────────────────────────────────────────────────────
1152
1153    #[test]
1154    fn test_memory_item_new_clamps_importance_above_one() {
1155        let item = MemoryItem::new(AgentId::new("a"), "test", 1.5, vec![]);
1156        assert_eq!(item.importance, 1.0);
1157    }
1158
1159    #[test]
1160    fn test_memory_item_new_clamps_importance_below_zero() {
1161        let item = MemoryItem::new(AgentId::new("a"), "test", -0.5, vec![]);
1162        assert_eq!(item.importance, 0.0);
1163    }
1164
1165    #[test]
1166    fn test_memory_item_new_preserves_valid_importance() {
1167        let item = MemoryItem::new(AgentId::new("a"), "test", 0.7, vec![]);
1168        assert!((item.importance - 0.7).abs() < 1e-6);
1169    }
1170
1171    // ── DecayPolicy ───────────────────────────────────────────────────────────
1172
1173    #[test]
1174    fn test_decay_policy_rejects_zero_half_life() {
1175        assert!(DecayPolicy::exponential(0.0).is_err());
1176    }
1177
1178    #[test]
1179    fn test_decay_policy_rejects_negative_half_life() {
1180        assert!(DecayPolicy::exponential(-1.0).is_err());
1181    }
1182
1183    #[test]
1184    fn test_decay_policy_no_decay_at_age_zero() {
1185        let p = DecayPolicy::exponential(24.0).unwrap();
1186        let decayed = p.apply(1.0, 0.0);
1187        assert!((decayed - 1.0).abs() < 1e-5);
1188    }
1189
1190    #[test]
1191    fn test_decay_policy_half_importance_at_half_life() {
1192        let p = DecayPolicy::exponential(24.0).unwrap();
1193        let decayed = p.apply(1.0, 24.0);
1194        assert!((decayed - 0.5).abs() < 1e-5);
1195    }
1196
1197    #[test]
1198    fn test_decay_policy_quarter_importance_at_two_half_lives() {
1199        let p = DecayPolicy::exponential(24.0).unwrap();
1200        let decayed = p.apply(1.0, 48.0);
1201        assert!((decayed - 0.25).abs() < 1e-5);
1202    }
1203
1204    #[test]
1205    fn test_decay_policy_result_is_clamped_to_zero_one() {
1206        let p = DecayPolicy::exponential(1.0).unwrap();
1207        let decayed = p.apply(0.0, 1000.0);
1208        assert!(decayed >= 0.0 && decayed <= 1.0);
1209    }
1210
1211    // ── EpisodicStore ─────────────────────────────────────────────────────────
1212
1213    #[test]
1214    fn test_episodic_store_add_episode_returns_id() {
1215        let store = EpisodicStore::new();
1216        let id = store.add_episode(AgentId::new("a"), "event", 0.8).unwrap();
1217        assert!(!id.0.is_empty());
1218    }
1219
1220    #[test]
1221    fn test_episodic_store_recall_returns_stored_item() {
1222        let store = EpisodicStore::new();
1223        let agent = AgentId::new("agent-1");
1224        store
1225            .add_episode(agent.clone(), "hello world", 0.9)
1226            .unwrap();
1227        let items = store.recall(&agent, 10).unwrap();
1228        assert_eq!(items.len(), 1);
1229        assert_eq!(items[0].content, "hello world");
1230    }
1231
1232    #[test]
1233    fn test_episodic_store_recall_filters_by_agent() {
1234        let store = EpisodicStore::new();
1235        let a = AgentId::new("agent-a");
1236        let b = AgentId::new("agent-b");
1237        store.add_episode(a.clone(), "for a", 0.5).unwrap();
1238        store.add_episode(b.clone(), "for b", 0.5).unwrap();
1239        let items = store.recall(&a, 10).unwrap();
1240        assert_eq!(items.len(), 1);
1241        assert_eq!(items[0].content, "for a");
1242    }
1243
1244    #[test]
1245    fn test_episodic_store_recall_sorted_by_descending_importance() {
1246        let store = EpisodicStore::new();
1247        let agent = AgentId::new("agent-1");
1248        store.add_episode(agent.clone(), "low", 0.1).unwrap();
1249        store.add_episode(agent.clone(), "high", 0.9).unwrap();
1250        store.add_episode(agent.clone(), "mid", 0.5).unwrap();
1251        let items = store.recall(&agent, 10).unwrap();
1252        assert_eq!(items[0].content, "high");
1253        assert_eq!(items[1].content, "mid");
1254        assert_eq!(items[2].content, "low");
1255    }
1256
1257    #[test]
1258    fn test_episodic_store_recall_respects_limit() {
1259        let store = EpisodicStore::new();
1260        let agent = AgentId::new("agent-1");
1261        for i in 0..5 {
1262            store
1263                .add_episode(agent.clone(), format!("item {i}"), 0.5)
1264                .unwrap();
1265        }
1266        let items = store.recall(&agent, 3).unwrap();
1267        assert_eq!(items.len(), 3);
1268    }
1269
1270    #[test]
1271    fn test_episodic_store_len_tracks_insertions() {
1272        let store = EpisodicStore::new();
1273        let agent = AgentId::new("a");
1274        store.add_episode(agent.clone(), "a", 0.5).unwrap();
1275        store.add_episode(agent.clone(), "b", 0.5).unwrap();
1276        assert_eq!(store.len().unwrap(), 2);
1277    }
1278
1279    #[test]
1280    fn test_episodic_store_is_empty_initially() {
1281        let store = EpisodicStore::new();
1282        assert!(store.is_empty().unwrap());
1283    }
1284
1285    #[test]
1286    fn test_episodic_store_with_decay_reduces_importance() {
1287        let policy = DecayPolicy::exponential(0.001).unwrap(); // very fast decay
1288        let store = EpisodicStore::with_decay(policy);
1289        let agent = AgentId::new("a");
1290
1291        // Insert an old item by manipulating via add_episode_at.
1292        let old_ts = Utc::now() - chrono::Duration::hours(1);
1293        store
1294            .add_episode_at(agent.clone(), "old event", 1.0, old_ts)
1295            .unwrap();
1296
1297        let items = store.recall(&agent, 10).unwrap();
1298        // With half_life=0.001h and age=1h, importance should be near 0
1299        assert_eq!(items.len(), 1);
1300        assert!(
1301            items[0].importance < 0.01,
1302            "expected near-zero importance, got {}",
1303            items[0].importance
1304        );
1305    }
1306
1307    // ── max_age eviction ──────────────────────────────────────────────────────
1308
1309    #[test]
1310    fn test_max_age_rejects_zero() {
1311        assert!(EpisodicStore::with_max_age(0.0).is_err());
1312    }
1313
1314    #[test]
1315    fn test_max_age_rejects_negative() {
1316        assert!(EpisodicStore::with_max_age(-1.0).is_err());
1317    }
1318
1319    #[test]
1320    fn test_max_age_evicts_old_items_on_recall() {
1321        // max_age = 0.001 hours (~3.6 seconds) — items 1 hour old should be evicted
1322        let store = EpisodicStore::with_max_age(0.001).unwrap();
1323        let agent = AgentId::new("a");
1324
1325        let old_ts = Utc::now() - chrono::Duration::hours(1);
1326        store
1327            .add_episode_at(agent.clone(), "old", 0.9, old_ts)
1328            .unwrap();
1329        store.add_episode(agent.clone(), "new", 0.5).unwrap();
1330
1331        let items = store.recall(&agent, 10).unwrap();
1332        assert_eq!(items.len(), 1, "old item should be evicted by max_age");
1333        assert_eq!(items[0].content, "new");
1334    }
1335
1336    #[test]
1337    fn test_max_age_evicts_old_items_on_add() {
1338        let store = EpisodicStore::with_max_age(0.001).unwrap();
1339        let agent = AgentId::new("a");
1340
1341        let old_ts = Utc::now() - chrono::Duration::hours(1);
1342        store
1343            .add_episode_at(agent.clone(), "old", 0.9, old_ts)
1344            .unwrap();
1345        // Adding a new item triggers stale purge.
1346        store.add_episode(agent.clone(), "new", 0.5).unwrap();
1347
1348        assert_eq!(store.len().unwrap(), 1);
1349    }
1350
1351    // ── RecallPolicy / per-agent capacity tests ───────────────────────────────
1352
1353    #[test]
1354    fn test_recall_increments_recall_count() {
1355        let store = EpisodicStore::new();
1356        let agent = AgentId::new("agent-rc");
1357        store.add_episode(agent.clone(), "memory", 0.5).unwrap();
1358
1359        // First recall — count becomes 1
1360        let items = store.recall(&agent, 10).unwrap();
1361        assert_eq!(items[0].recall_count, 1);
1362
1363        // Second recall — count becomes 2
1364        let items = store.recall(&agent, 10).unwrap();
1365        assert_eq!(items[0].recall_count, 2);
1366    }
1367
1368    #[test]
1369    fn test_hybrid_recall_policy_prefers_recently_used() {
1370        let store = EpisodicStore::with_recall_policy(RecallPolicy::Hybrid {
1371            recency_weight: 0.1,
1372            frequency_weight: 2.0,
1373        });
1374        let agent = AgentId::new("agent-hybrid");
1375
1376        let old_ts = Utc::now() - chrono::Duration::hours(48);
1377        store
1378            .add_episode_at(agent.clone(), "old_frequent", 0.5, old_ts)
1379            .unwrap();
1380        store.add_episode(agent.clone(), "new_never", 0.5).unwrap();
1381
1382        // Simulate many prior recalls of "old_frequent".
1383        store.bump_recall_count_by_content("old_frequent", 100);
1384
1385        let items = store.recall(&agent, 10).unwrap();
1386        assert_eq!(items.len(), 2);
1387        assert_eq!(
1388            items[0].content, "old_frequent",
1389            "hybrid policy should rank the frequently-recalled item first"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_per_agent_capacity_evicts_lowest_importance() {
1395        let store = EpisodicStore::with_per_agent_capacity(2);
1396        let agent = AgentId::new("agent-cap");
1397
1398        // Add two items; capacity is full.
1399        store.add_episode(agent.clone(), "low", 0.1).unwrap();
1400        store.add_episode(agent.clone(), "high", 0.9).unwrap();
1401        // Adding "new" triggers eviction of the EXISTING lowest-importance item
1402        // ("low", 0.1) — the newly added item is never the one evicted.
1403        store.add_episode(agent.clone(), "new", 0.5).unwrap();
1404
1405        assert_eq!(
1406            store.len().unwrap(),
1407            2,
1408            "store should hold exactly 2 items after eviction"
1409        );
1410
1411        let items = store.recall(&agent, 10).unwrap();
1412        let contents: Vec<&str> = items.iter().map(|i| i.content.as_str()).collect();
1413        assert!(
1414            !contents.contains(&"low"),
1415            "the pre-existing lowest-importance item should have been evicted; remaining: {:?}",
1416            contents
1417        );
1418        assert!(
1419            contents.contains(&"new"),
1420            "the newly added item must never be evicted; remaining: {:?}",
1421            contents
1422        );
1423    }
1424
1425    // ── O(1) agent isolation ──────────────────────────────────────────────────
1426
1427    #[test]
1428    fn test_many_agents_do_not_see_each_others_memories() {
1429        let store = EpisodicStore::new();
1430        let n_agents = 20usize;
1431        for i in 0..n_agents {
1432            let agent = AgentId::new(format!("agent-{i}"));
1433            for j in 0..5 {
1434                store
1435                    .add_episode(agent.clone(), format!("item-{i}-{j}"), 0.5)
1436                    .unwrap();
1437            }
1438        }
1439        // Each agent should only see its own 5 items.
1440        for i in 0..n_agents {
1441            let agent = AgentId::new(format!("agent-{i}"));
1442            let items = store.recall(&agent, 100).unwrap();
1443            assert_eq!(
1444                items.len(),
1445                5,
1446                "agent {i} should see exactly 5 items, got {}",
1447                items.len()
1448            );
1449            for item in &items {
1450                assert!(
1451                    item.content.starts_with(&format!("item-{i}-")),
1452                    "agent {i} saw foreign item: {}",
1453                    item.content
1454                );
1455            }
1456        }
1457    }
1458
1459    // ── agent_memory_count / list_agents / purge_agent_memories ──────────────
1460
1461    #[test]
1462    fn test_agent_memory_count_returns_zero_for_unknown_agent() {
1463        let store = EpisodicStore::new();
1464        let count = store.agent_memory_count(&AgentId::new("ghost")).unwrap();
1465        assert_eq!(count, 0);
1466    }
1467
1468    #[test]
1469    fn test_agent_memory_count_tracks_insertions() {
1470        let store = EpisodicStore::new();
1471        let agent = AgentId::new("a");
1472        store.add_episode(agent.clone(), "e1", 0.5).unwrap();
1473        store.add_episode(agent.clone(), "e2", 0.5).unwrap();
1474        assert_eq!(store.agent_memory_count(&agent).unwrap(), 2);
1475    }
1476
1477    #[test]
1478    fn test_list_agents_returns_all_known_agents() {
1479        let store = EpisodicStore::new();
1480        let a = AgentId::new("agent-a");
1481        let b = AgentId::new("agent-b");
1482        store.add_episode(a.clone(), "x", 0.5).unwrap();
1483        store.add_episode(b.clone(), "y", 0.5).unwrap();
1484        let agents = store.list_agents().unwrap();
1485        assert_eq!(agents.len(), 2);
1486        assert!(agents.contains(&a));
1487        assert!(agents.contains(&b));
1488    }
1489
1490    #[test]
1491    fn test_list_agents_empty_when_no_episodes() {
1492        let store = EpisodicStore::new();
1493        let agents = store.list_agents().unwrap();
1494        assert!(agents.is_empty());
1495    }
1496
1497    #[test]
1498    fn test_purge_agent_memories_removes_all_for_agent() {
1499        let store = EpisodicStore::new();
1500        let a = AgentId::new("a");
1501        let b = AgentId::new("b");
1502        store.add_episode(a.clone(), "ep1", 0.5).unwrap();
1503        store.add_episode(a.clone(), "ep2", 0.5).unwrap();
1504        store.add_episode(b.clone(), "ep-b", 0.5).unwrap();
1505
1506        let removed = store.purge_agent_memories(&a).unwrap();
1507        assert_eq!(removed, 2);
1508        assert_eq!(store.agent_memory_count(&a).unwrap(), 0);
1509        assert_eq!(store.agent_memory_count(&b).unwrap(), 1);
1510        assert_eq!(store.len().unwrap(), 1);
1511    }
1512
1513    #[test]
1514    fn test_purge_agent_memories_returns_zero_for_unknown_agent() {
1515        let store = EpisodicStore::new();
1516        let removed = store.purge_agent_memories(&AgentId::new("ghost")).unwrap();
1517        assert_eq!(removed, 0);
1518    }
1519
1520    // ── All-stale recall ──────────────────────────────────────────────────────
1521
1522    #[test]
1523    fn test_recall_returns_empty_when_all_items_are_stale() {
1524        // max_age = 0.001 hours — all items inserted 1 hour ago will be evicted.
1525        let store = EpisodicStore::with_max_age(0.001).unwrap();
1526        let agent = AgentId::new("stale-agent");
1527
1528        let old_ts = Utc::now() - chrono::Duration::hours(1);
1529        store
1530            .add_episode_at(agent.clone(), "stale-1", 0.9, old_ts)
1531            .unwrap();
1532        store
1533            .add_episode_at(agent.clone(), "stale-2", 0.7, old_ts)
1534            .unwrap();
1535
1536        let items = store.recall(&agent, 100).unwrap();
1537        assert!(
1538            items.is_empty(),
1539            "all stale items should be evicted on recall, got {}",
1540            items.len()
1541        );
1542    }
1543
1544    // ── Concurrency stress tests ──────────────────────────────────────────────
1545
1546    #[test]
1547    fn test_concurrent_add_and_recall_are_consistent() {
1548        use std::sync::Arc;
1549        use std::thread;
1550
1551        let store = Arc::new(EpisodicStore::new());
1552        let agent = AgentId::new("concurrent-agent");
1553        let n_threads = 8;
1554        let items_per_thread = 25;
1555
1556        // Spawn writers.
1557        let mut handles = Vec::new();
1558        for t in 0..n_threads {
1559            let s = Arc::clone(&store);
1560            let a = agent.clone();
1561            handles.push(thread::spawn(move || {
1562                for i in 0..items_per_thread {
1563                    s.add_episode(a.clone(), format!("t{t}-i{i}"), 0.5).unwrap();
1564                }
1565            }));
1566        }
1567        for h in handles {
1568            h.join().unwrap();
1569        }
1570
1571        // Spawn readers.
1572        let mut read_handles = Vec::new();
1573        for _ in 0..n_threads {
1574            let s = Arc::clone(&store);
1575            let a = agent.clone();
1576            read_handles.push(thread::spawn(move || {
1577                let items = s.recall(&a, 1000).unwrap();
1578                assert!(items.len() <= n_threads * items_per_thread);
1579            }));
1580        }
1581        for h in read_handles {
1582            h.join().unwrap();
1583        }
1584    }
1585
1586    // ── Concurrent capacity eviction ──────────────────────────────────────────
1587
1588    #[test]
1589    fn test_concurrent_capacity_eviction_never_exceeds_cap() {
1590        use std::sync::Arc;
1591        use std::thread;
1592
1593        let cap = 5usize;
1594        let store = Arc::new(EpisodicStore::with_per_agent_capacity(cap));
1595        let agent = AgentId::new("cap-agent");
1596        let n_threads = 8;
1597        let items_per_thread = 10;
1598
1599        let mut handles = Vec::new();
1600        for t in 0..n_threads {
1601            let s = Arc::clone(&store);
1602            let a = agent.clone();
1603            handles.push(thread::spawn(move || {
1604                for i in 0..items_per_thread {
1605                    let importance = (t * items_per_thread + i) as f32 / 100.0;
1606                    s.add_episode(a.clone(), format!("t{t}-i{i}"), importance)
1607                        .unwrap();
1608                }
1609            }));
1610        }
1611        for h in handles {
1612            h.join().unwrap();
1613        }
1614
1615        // The store may momentarily exceed cap+1 during concurrent eviction,
1616        // but after all threads complete the final count must be <= cap + n_threads.
1617        // (Each thread's last insert may not have been evicted yet.)
1618        // The strong invariant: agent_memory_count <= cap + n_threads - 1.
1619        let count = store.agent_memory_count(&agent).unwrap();
1620        assert!(
1621            count <= cap + n_threads,
1622            "expected at most {} items, got {}",
1623            cap + n_threads,
1624            count
1625        );
1626    }
1627
1628    // ── SemanticStore ─────────────────────────────────────────────────────────
1629
1630    #[test]
1631    fn test_semantic_store_store_and_retrieve_all() {
1632        let store = SemanticStore::new();
1633        store.store("key1", "value1", vec!["tag-a".into()]).unwrap();
1634        store.store("key2", "value2", vec!["tag-b".into()]).unwrap();
1635        let results = store.retrieve(&[]).unwrap();
1636        assert_eq!(results.len(), 2);
1637    }
1638
1639    #[test]
1640    fn test_semantic_store_retrieve_filters_by_tag() {
1641        let store = SemanticStore::new();
1642        store
1643            .store("k1", "v1", vec!["rust".into(), "async".into()])
1644            .unwrap();
1645        store.store("k2", "v2", vec!["rust".into()]).unwrap();
1646        let results = store.retrieve(&["async"]).unwrap();
1647        assert_eq!(results.len(), 1);
1648        assert_eq!(results[0].0, "k1");
1649    }
1650
1651    #[test]
1652    fn test_semantic_store_retrieve_requires_all_tags() {
1653        let store = SemanticStore::new();
1654        store
1655            .store("k1", "v1", vec!["a".into(), "b".into()])
1656            .unwrap();
1657        store.store("k2", "v2", vec!["a".into()]).unwrap();
1658        let results = store.retrieve(&["a", "b"]).unwrap();
1659        assert_eq!(results.len(), 1);
1660    }
1661
1662    #[test]
1663    fn test_semantic_store_is_empty_initially() {
1664        let store = SemanticStore::new();
1665        assert!(store.is_empty().unwrap());
1666    }
1667
1668    #[test]
1669    fn test_semantic_store_len_tracks_insertions() {
1670        let store = SemanticStore::new();
1671        store.store("k", "v", vec![]).unwrap();
1672        assert_eq!(store.len().unwrap(), 1);
1673    }
1674
1675    #[test]
1676    fn test_semantic_store_empty_embedding_is_rejected() {
1677        let store = SemanticStore::new();
1678        let result = store.store_with_embedding("k", "v", vec![], vec![]);
1679        assert!(result.is_err(), "empty embedding should be rejected");
1680    }
1681
1682    #[test]
1683    fn test_semantic_store_dimension_mismatch_is_rejected() {
1684        let store = SemanticStore::new();
1685        store
1686            .store_with_embedding("k1", "v1", vec![], vec![1.0, 0.0])
1687            .unwrap();
1688        // Different dimension
1689        let result = store.store_with_embedding("k2", "v2", vec![], vec![1.0, 0.0, 0.0]);
1690        assert!(
1691            result.is_err(),
1692            "embedding dimension mismatch should be rejected"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_semantic_store_retrieve_similar_returns_closest() {
1698        let store = SemanticStore::new();
1699        store
1700            .store_with_embedding("close", "close value", vec![], vec![1.0, 0.0, 0.0])
1701            .unwrap();
1702        store
1703            .store_with_embedding("far", "far value", vec![], vec![0.0, 1.0, 0.0])
1704            .unwrap();
1705
1706        let query = vec![1.0, 0.0, 0.0];
1707        let results = store.retrieve_similar(&query, 2).unwrap();
1708        assert_eq!(results.len(), 2);
1709        assert_eq!(results[0].0, "close");
1710        assert!(
1711            (results[0].2 - 1.0).abs() < 1e-5,
1712            "expected similarity ~1.0, got {}",
1713            results[0].2
1714        );
1715        assert!(
1716            (results[1].2).abs() < 1e-5,
1717            "expected similarity ~0.0, got {}",
1718            results[1].2
1719        );
1720    }
1721
1722    #[test]
1723    fn test_semantic_store_retrieve_similar_ignores_unembedded_entries() {
1724        let store = SemanticStore::new();
1725        store.store("no-emb", "no embedding value", vec![]).unwrap();
1726        store
1727            .store_with_embedding("with-emb", "with embedding value", vec![], vec![1.0, 0.0])
1728            .unwrap();
1729
1730        let query = vec![1.0, 0.0];
1731        let results = store.retrieve_similar(&query, 10).unwrap();
1732        assert_eq!(results.len(), 1, "only the embedded entry should appear");
1733        assert_eq!(results[0].0, "with-emb");
1734    }
1735
1736    #[test]
1737    fn test_cosine_similarity_orthogonal_vectors_return_zero() {
1738        let store = SemanticStore::new();
1739        store
1740            .store_with_embedding("a", "va", vec![], vec![1.0, 0.0])
1741            .unwrap();
1742        store
1743            .store_with_embedding("b", "vb", vec![], vec![0.0, 1.0])
1744            .unwrap();
1745
1746        let query = vec![1.0, 0.0];
1747        let results = store.retrieve_similar(&query, 2).unwrap();
1748        assert_eq!(results.len(), 2);
1749        let b_result = results.iter().find(|(k, _, _)| k == "b").unwrap();
1750        assert!(
1751            b_result.2.abs() < 1e-5,
1752            "expected cosine similarity 0.0 for orthogonal vectors, got {}",
1753            b_result.2
1754        );
1755    }
1756
1757    // ── WorkingMemory ─────────────────────────────────────────────────────────
1758
1759    #[test]
1760    fn test_working_memory_new_rejects_zero_capacity() {
1761        assert!(WorkingMemory::new(0).is_err());
1762    }
1763
1764    #[test]
1765    fn test_working_memory_set_and_get() {
1766        let wm = WorkingMemory::new(10).unwrap();
1767        wm.set("foo", "bar").unwrap();
1768        let val = wm.get("foo").unwrap();
1769        assert_eq!(val, Some("bar".into()));
1770    }
1771
1772    #[test]
1773    fn test_working_memory_get_missing_key_returns_none() {
1774        let wm = WorkingMemory::new(10).unwrap();
1775        assert_eq!(wm.get("missing").unwrap(), None);
1776    }
1777
1778    #[test]
1779    fn test_working_memory_bounded_evicts_oldest() {
1780        let wm = WorkingMemory::new(3).unwrap();
1781        wm.set("k1", "v1").unwrap();
1782        wm.set("k2", "v2").unwrap();
1783        wm.set("k3", "v3").unwrap();
1784        wm.set("k4", "v4").unwrap(); // k1 should be evicted
1785        assert_eq!(wm.get("k1").unwrap(), None);
1786        assert_eq!(wm.get("k4").unwrap(), Some("v4".into()));
1787    }
1788
1789    #[test]
1790    fn test_working_memory_update_existing_key_no_eviction() {
1791        let wm = WorkingMemory::new(2).unwrap();
1792        wm.set("k1", "v1").unwrap();
1793        wm.set("k2", "v2").unwrap();
1794        wm.set("k1", "v1-updated").unwrap(); // update, not eviction
1795        assert_eq!(wm.len().unwrap(), 2);
1796        assert_eq!(wm.get("k1").unwrap(), Some("v1-updated".into()));
1797        assert_eq!(wm.get("k2").unwrap(), Some("v2".into()));
1798    }
1799
1800    #[test]
1801    fn test_working_memory_clear_removes_all() {
1802        let wm = WorkingMemory::new(10).unwrap();
1803        wm.set("a", "1").unwrap();
1804        wm.set("b", "2").unwrap();
1805        wm.clear().unwrap();
1806        assert!(wm.is_empty().unwrap());
1807    }
1808
1809    #[test]
1810    fn test_working_memory_is_empty_initially() {
1811        let wm = WorkingMemory::new(5).unwrap();
1812        assert!(wm.is_empty().unwrap());
1813    }
1814
1815    #[test]
1816    fn test_working_memory_len_tracks_entries() {
1817        let wm = WorkingMemory::new(10).unwrap();
1818        wm.set("a", "1").unwrap();
1819        wm.set("b", "2").unwrap();
1820        assert_eq!(wm.len().unwrap(), 2);
1821    }
1822
1823    #[test]
1824    fn test_working_memory_capacity_never_exceeded() {
1825        let cap = 5usize;
1826        let wm = WorkingMemory::new(cap).unwrap();
1827        for i in 0..20 {
1828            wm.set(format!("key-{i}"), format!("val-{i}")).unwrap();
1829            assert!(wm.len().unwrap() <= cap);
1830        }
1831    }
1832
1833    // ── Improvement 6: SemanticStore dimension validation on retrieve ──────────
1834
1835    #[test]
1836    fn test_semantic_dimension_mismatch_on_retrieve_returns_error() {
1837        let store = SemanticStore::new();
1838        store
1839            .store_with_embedding("k1", "v1", vec![], vec![1.0, 0.0, 0.0])
1840            .unwrap();
1841        // Query with wrong dimension
1842        let result = store.retrieve_similar(&[1.0, 0.0], 10);
1843        assert!(result.is_err(), "dimension mismatch on retrieve should error");
1844    }
1845
1846    // ── Improvement 12: EvictionPolicy::Oldest ────────────────────────────────
1847
1848    // ── #3 clear_agent_memory ────────────────────────────────────────────────
1849
1850    #[test]
1851    fn test_clear_agent_memory_removes_all_episodes() {
1852        let store = EpisodicStore::new();
1853        let agent = AgentId::new("a");
1854        store.add_episode(agent.clone(), "ep1", 0.5).unwrap();
1855        store.add_episode(agent.clone(), "ep2", 0.9).unwrap();
1856        store.clear_agent_memory(&agent).unwrap();
1857        let items = store.recall(&agent, 10).unwrap();
1858        assert!(items.is_empty(), "all memories should be cleared");
1859    }
1860
1861    // ── #13 AgentId::as_str / MemoryId::as_str ───────────────────────────────
1862
1863    #[test]
1864    fn test_agent_id_as_str() {
1865        let id = AgentId::new("hello");
1866        assert_eq!(id.as_str(), "hello");
1867    }
1868
1869    // ── #15 export/import round trip ─────────────────────────────────────────
1870
1871    #[test]
1872    fn test_export_import_agent_memory_round_trip() {
1873        let store = EpisodicStore::new();
1874        let agent = AgentId::new("export-agent");
1875        store.add_episode(agent.clone(), "fact1", 0.8).unwrap();
1876        store.add_episode(agent.clone(), "fact2", 0.6).unwrap();
1877
1878        let exported = store.export_agent_memory(&agent).unwrap();
1879        assert_eq!(exported.len(), 2);
1880
1881        let new_store = EpisodicStore::new();
1882        new_store.import_agent_memory(&agent, exported).unwrap();
1883        let recalled = new_store.recall(&agent, 10).unwrap();
1884        assert_eq!(recalled.len(), 2);
1885    }
1886
1887    // ── #19 WorkingMemory::iter ───────────────────────────────────────────────
1888
1889    #[test]
1890    fn test_working_memory_iter_matches_entries() {
1891        let wm = WorkingMemory::new(10).unwrap();
1892        wm.set("a", "1").unwrap();
1893        wm.set("b", "2").unwrap();
1894        let via_iter = wm.iter().unwrap();
1895        let via_entries = wm.entries().unwrap();
1896        assert_eq!(via_iter, via_entries);
1897    }
1898
1899    // ── #37 AsRef<str> for AgentId and MemoryId ──────────────────────────────
1900
1901    #[test]
1902    fn test_agent_id_as_ref_str() {
1903        let id = AgentId::new("ref-test");
1904        let s: &str = id.as_ref();
1905        assert_eq!(s, "ref-test");
1906    }
1907
1908    #[test]
1909    fn test_eviction_policy_oldest_evicts_first_inserted() {
1910        let store = EpisodicStore::with_eviction_policy(EvictionPolicy::Oldest);
1911        // Override capacity by building a combined store.
1912        // We need a store with capacity=2 AND oldest eviction.
1913        // Use `with_per_agent_capacity` approach on a new store then set policy.
1914        // Since we can't combine constructors directly yet, we test via a different path:
1915        // Insert items and check that the oldest is evicted.
1916        let store = {
1917            // Build internal store with per_agent_capacity=2 and Oldest policy
1918            let inner = EpisodicInner {
1919                items: std::collections::HashMap::new(),
1920                decay: None,
1921                recall_policy: RecallPolicy::Importance,
1922                per_agent_capacity: Some(2),
1923                max_age_hours: None,
1924                eviction_policy: EvictionPolicy::Oldest,
1925            };
1926            EpisodicStore {
1927                inner: std::sync::Arc::new(std::sync::Mutex::new(inner)),
1928            }
1929        };
1930
1931        let agent = AgentId::new("agent");
1932        // Add items with distinct timestamps by using add_episode_at
1933        let t1 = chrono::Utc::now() - chrono::Duration::seconds(100);
1934        let t2 = chrono::Utc::now() - chrono::Duration::seconds(50);
1935        store.add_episode_at(agent.clone(), "oldest", 0.9, t1).unwrap();
1936        store.add_episode_at(agent.clone(), "newer", 0.8, t2).unwrap();
1937        // Adding a third item should evict "oldest" (earliest timestamp)
1938        store.add_episode(agent.clone(), "newest", 0.5).unwrap();
1939
1940        let items = store.recall(&agent, 10).unwrap();
1941        assert_eq!(items.len(), 2);
1942        let contents: Vec<&str> = items.iter().map(|i| i.content.as_str()).collect();
1943        assert!(!contents.contains(&"oldest"), "oldest item should have been evicted; got: {contents:?}");
1944    }
1945}