Skip to main content

heartbit_core/memory/
in_memory.rs

1//! In-memory `Memory` implementation backed by a sorted `Vec`.
2
3use std::collections::{HashMap, HashSet};
4use std::future::Future;
5use std::pin::Pin;
6
7use parking_lot::RwLock;
8
9use chrono::Utc;
10
11use crate::auth::TenantScope;
12use crate::error::Error;
13
14use super::bm25;
15use super::hybrid;
16use super::scoring::{STRENGTH_DECAY_RATE, ScoringWeights, composite_score, effective_strength};
17use super::{Memory, MemoryEntry, MemoryQuery};
18
19/// Default cap on the number of entries an `InMemoryStore` will hold.
20///
21/// SECURITY (F-MEM-3): without a cap, a hostile or buggy agent that spams
22/// `memory_store` can balloon the process memory linearly. Once the cap is
23/// reached, `store()` evicts the entry with the lowest effective strength
24/// (i.e., the weakest, oldest memory) before inserting the new one.
25pub const IN_MEMORY_STORE_DEFAULT_CAP: usize = 100_000;
26
27/// Pre-tokenised, lower-cased view of a `MemoryEntry`'s text fields,
28/// cached for the recall hot path so per-entry lowercase + tokenisation
29/// is paid **once at store time** instead of on every recall (P-MEM-2
30/// stepping stone in `tasks/perf-audit-memory.md`).
31#[derive(Debug, Clone, Default)]
32struct EntryTokens {
33    /// `entry.content.to_lowercase()` — used by the substring text filter
34    /// so semantics match the previous `lower_content.contains(token)`
35    /// pass.
36    lower_content: String,
37    /// `lower_content.split_whitespace().map(String::from).collect()` —
38    /// fed directly to `bm25_score_pre` so BM25 no longer pays its own
39    /// per-call lowercase + split.
40    content_words: Vec<String>,
41    /// `entry.keywords.iter().map(to_lowercase).collect()` — used by both
42    /// the filter (substring match) and BM25 (keyword bonus).
43    lower_keywords: Vec<String>,
44}
45
46fn build_entry_tokens(entry: &MemoryEntry) -> EntryTokens {
47    let lower_content = entry.content.to_lowercase();
48    let content_words: Vec<String> = lower_content.split_whitespace().map(String::from).collect();
49    let lower_keywords: Vec<String> = entry.keywords.iter().map(|k| k.to_lowercase()).collect();
50    EntryTokens {
51        lower_content,
52        content_words,
53        lower_keywords,
54    }
55}
56
57/// Insert `entry_id` into the inverted index under every distinct
58/// `(content_words ∪ lower_keywords)` token from `tokens`. Caller
59/// holds the inverted-index write lock.
60fn index_entry(
61    inverted: &mut HashMap<String, HashSet<String>>,
62    entry_id: &str,
63    tokens: &EntryTokens,
64) {
65    let mut seen: HashSet<&str> = HashSet::with_capacity(tokens.content_words.len());
66    for word in tokens
67        .content_words
68        .iter()
69        .chain(tokens.lower_keywords.iter())
70    {
71        if seen.insert(word.as_str()) {
72            inverted
73                .entry(word.clone())
74                .or_default()
75                .insert(entry_id.to_string());
76        }
77    }
78}
79
80/// Remove `entry_id` from every inverted-index bucket the entry's
81/// `tokens` previously populated. Empty buckets are dropped so the
82/// index doesn't grow unbounded across deletions. Caller holds the
83/// inverted-index write lock.
84fn deindex_entry(
85    inverted: &mut HashMap<String, HashSet<String>>,
86    entry_id: &str,
87    tokens: &EntryTokens,
88) {
89    let mut seen: HashSet<&str> = HashSet::with_capacity(tokens.content_words.len());
90    for word in tokens
91        .content_words
92        .iter()
93        .chain(tokens.lower_keywords.iter())
94    {
95        if !seen.insert(word.as_str()) {
96            continue;
97        }
98        if let Some(bucket) = inverted.get_mut(word) {
99            bucket.remove(entry_id);
100            if bucket.is_empty() {
101                inverted.remove(word);
102            }
103        }
104    }
105}
106
107/// Thread-safe in-memory store for agent memories.
108///
109/// Backed by `parking_lot::RwLock<HashMap>` (T2 — `tasks/performance-audit-
110/// heartbit-core-2026-05-06.md`). Suitable for tests and single-process use.
111/// Uses composite scoring (recency + importance + relevance) for recall
112/// ordering.
113///
114/// Maintains a sibling `tokens` cache of pre-lowercased / pre-tokenised
115/// content + keywords (P-MEM-2 stepping stone). The cache is updated in
116/// lock-step with `entries` on every `store` / `update` / `forget`; the
117/// recall hot path reads from the cache so it never pays the per-entry
118/// `to_lowercase()` + `split_whitespace()` cost again. Locks are always
119/// acquired in the order `entries` → `tokens` → `inverted`; all three
120/// are `parking_lot::RwLock` and never held across `.await`.
121///
122/// Phase 8: also maintains a sibling `inverted` index mapping each
123/// lowercased exact-word token (from content + keywords) to the set of
124/// entry ids that contain it. Used **only** when the caller opts in via
125/// `MemoryQuery::exact_words = true`; default recall behaviour is
126/// substring-based and ignores the index. See
127/// `tasks/perf-audit-v2-2026-05-07.md` for the BM25 design decision.
128pub struct InMemoryStore {
129    entries: RwLock<HashMap<String, MemoryEntry>>,
130    tokens: RwLock<HashMap<String, EntryTokens>>,
131    /// `lowercased_token → set<entry_id>`. Built from each entry's
132    /// `content_words ∪ lower_keywords` at store time. Reads are
133    /// O(1) per token and reject most of the corpus when callers
134    /// opt in via `MemoryQuery::exact_words`.
135    inverted: RwLock<HashMap<String, HashSet<String>>>,
136    scoring_weights: ScoringWeights,
137    max_entries: usize,
138}
139
140impl InMemoryStore {
141    /// Create an empty in-memory store with default scoring weights and cap.
142    pub fn new() -> Self {
143        Self {
144            entries: RwLock::new(HashMap::new()),
145            tokens: RwLock::new(HashMap::new()),
146            inverted: RwLock::new(HashMap::new()),
147            scoring_weights: ScoringWeights::default(),
148            max_entries: IN_MEMORY_STORE_DEFAULT_CAP,
149        }
150    }
151
152    /// Override the composite-score weights used by `recall`.
153    pub fn with_scoring_weights(mut self, weights: ScoringWeights) -> Self {
154        self.scoring_weights = weights;
155        self
156    }
157
158    /// Override the max-entries cap. Set to `usize::MAX` to disable.
159    pub fn with_max_entries(mut self, max_entries: usize) -> Self {
160        self.max_entries = max_entries;
161        self
162    }
163
164    /// Exact lookup of a stored entry by id (no recall scoring, no reinforcement).
165    pub fn get(&self, id: &str) -> Option<MemoryEntry> {
166        self.entries.read().get(id).cloned()
167    }
168}
169
170impl Default for InMemoryStore {
171    fn default() -> Self {
172        Self::new()
173    }
174}
175
176impl Memory for InMemoryStore {
177    fn store(
178        &self,
179        scope: &TenantScope,
180        mut entry: MemoryEntry,
181    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
182        // Stamp the entry with the calling scope's tenant/user identity.
183        entry.author_tenant_id = Some(scope.tenant_id.clone());
184        entry.author_user_id = scope.user_id.clone();
185        Box::pin(async move {
186            let mut entries = self.entries.write();
187            let mut tokens = self.tokens.write();
188            let mut inverted = self.inverted.write();
189            // SECURITY (F-MEM-3): when at capacity, evict the entry with the
190            // lowest effective strength (most-decayed, oldest weak memory)
191            // before inserting the new one. Without this cap, a hostile or
192            // buggy agent could balloon process memory by spamming
193            // `memory_store`. Eviction happens BEFORE insertion to avoid a
194            // transient over-cap state.
195            if !entries.contains_key(&entry.id) && entries.len() >= self.max_entries {
196                let now = Utc::now();
197                if let Some(victim_id) = entries
198                    .values()
199                    .min_by(|a, b| {
200                        let ea = effective_strength(
201                            a.strength,
202                            a.last_accessed,
203                            now,
204                            STRENGTH_DECAY_RATE,
205                        );
206                        let eb = effective_strength(
207                            b.strength,
208                            b.last_accessed,
209                            now,
210                            STRENGTH_DECAY_RATE,
211                        );
212                        ea.partial_cmp(&eb).unwrap_or(std::cmp::Ordering::Equal)
213                    })
214                    .map(|e| e.id.clone())
215                {
216                    entries.remove(&victim_id);
217                    if let Some(victim_tokens) = tokens.remove(&victim_id) {
218                        deindex_entry(&mut inverted, &victim_id, &victim_tokens);
219                    }
220                    tracing::warn!(
221                        evicted = %victim_id,
222                        cap = self.max_entries,
223                        "InMemoryStore at cap; evicted weakest entry (F-MEM-3)"
224                    );
225                }
226            }
227            // PERF (P-MEM-2 stepping stone): tokenise once at store time
228            // so the recall hot path never pays for it again.
229            let entry_tokens = build_entry_tokens(&entry);
230            let id = entry.id.clone();
231            // Phase 8: pre-deindex the previous version of this entry
232            // (if any) before re-indexing — `store()` doubles as upsert.
233            if let Some(old_tokens) = tokens.get(&id) {
234                deindex_entry(&mut inverted, &id, old_tokens);
235            }
236            index_entry(&mut inverted, &id, &entry_tokens);
237            entries.insert(id.clone(), entry);
238            tokens.insert(id, entry_tokens);
239            Ok(())
240        })
241    }
242
243    fn recall(
244        &self,
245        scope: &TenantScope,
246        query: MemoryQuery,
247    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryEntry>, Error>> + Send + '_>> {
248        let tenant_id = scope.tenant_id.clone();
249        Box::pin(async move {
250            // Single write lock for the entire operation. Recall updates
251            // access_count as a side effect, so we need write access anyway.
252            // Using one lock avoids a TOCTOU window where concurrent forget()
253            // or store() could interleave between filter and access-count update.
254            let mut entries = self.entries.write();
255            // P-MEM-2 stepping stone: read-lock the tokens cache for the
256            // duration of the scan. Lock order is `entries` → `tokens`,
257            // mirroring every writer (`store` / `update` / `forget` /
258            // `prune`), so no deadlock is possible.
259            let tokens_cache = self.tokens.read();
260            // Phase 8: read-lock the inverted index. Only consulted on
261            // the `exact_words` opt-in path; on the substring path it's
262            // immediately dropped after the early return below.
263            let inverted = self.inverted.read();
264
265            let now = Utc::now();
266            let query_tokens: Vec<String> = query
267                .text
268                .as_deref()
269                .map(|t| {
270                    let mut seen = std::collections::HashSet::new();
271                    t.to_lowercase()
272                        .split_whitespace()
273                        .filter(|tok| seen.insert(tok.to_string()))
274                        .map(String::from)
275                        .collect()
276                })
277                .unwrap_or_default();
278
279            // Phase 8 fast path: when `query.exact_words` is set and
280            // we have at least one query token, look up the candidate
281            // entry-id set in the inverted index and short-circuit the
282            // full-scan filter loop. Drops 10k entries × N substring
283            // checks → O(K) lookups + a per-candidate filter pass on
284            // typically a few hundred entries. The remaining filters
285            // (tenant, agent, category, ...) still run on this smaller
286            // set so semantics for non-text predicates are preserved.
287            //
288            // When the inverted lookup turns up nothing, the result set
289            // is correctly empty (no entry has any query token as an
290            // exact word). When `exact_words` is false (default) or the
291            // query has no text, fall through to the legacy substring
292            // path below.
293            let exact_word_candidate_ids: Option<Vec<String>> =
294                if query.exact_words && !query_tokens.is_empty() {
295                    let mut ids: HashSet<String> = HashSet::new();
296                    for token in &query_tokens {
297                        if let Some(bucket) = inverted.get(token.as_str()) {
298                            ids.extend(bucket.iter().cloned());
299                        }
300                    }
301                    Some(ids.into_iter().collect())
302                } else {
303                    None
304                };
305
306            // Each surviving candidate is paired with its cached tokens
307            // so the BM25 scoring loop below can call `bm25_score_pre`
308            // directly — zero per-entry lowercase + tokenise on the
309            // recall hot path.
310            //
311            // Filter ordering: cheap field comparisons first, then the
312            // text-substring scan last (using the cached `lower_content`
313            // and `lower_keywords`).
314            //
315            // The two paths share the same inner `filter_logic` closure
316            // so non-text predicates behave identically across modes.
317            let filter_logic = |e: &MemoryEntry| -> bool {
318                // Tenant isolation first — fastest reject.
319                if e.author_tenant_id.as_deref() != Some(tenant_id.as_str()) {
320                    return false;
321                }
322                if let Some(ref agent) = query.agent {
323                    if e.agent != *agent {
324                        return false;
325                    }
326                } else if let Some(ref prefix) = query.agent_prefix
327                    && !e.agent.starts_with(prefix.as_str())
328                {
329                    return false;
330                }
331                if let Some(ref cat) = query.category
332                    && e.category != *cat
333                {
334                    return false;
335                }
336                if !query.tags.is_empty() && !query.tags.iter().any(|t| e.tags.contains(t)) {
337                    return false;
338                }
339                if let Some(ref mt) = query.memory_type
340                    && e.memory_type != *mt
341                {
342                    return false;
343                }
344                if let Some(max_conf) = query.max_confidentiality
345                    && e.confidentiality > max_conf
346                {
347                    return false;
348                }
349                if let Some(min_s) = query.min_strength {
350                    let eff =
351                        effective_strength(e.strength, e.last_accessed, now, STRENGTH_DECAY_RATE);
352                    if eff < min_s {
353                        return false;
354                    }
355                }
356                true
357            };
358
359            let candidates: Vec<(&MemoryEntry, &EntryTokens)> = match exact_word_candidate_ids {
360                Some(ids) => ids
361                    .iter()
362                    .filter_map(|id| {
363                        let e = entries.get(id)?;
364                        if !filter_logic(e) {
365                            return None;
366                        }
367                        let tokens = tokens_cache.get(id)?;
368                        Some((e, tokens))
369                    })
370                    .collect(),
371                None => entries
372                    .values()
373                    .filter_map(|e| {
374                        if !filter_logic(e) {
375                            return None;
376                        }
377                        // Tokens cache must be in lock-step with `entries`;
378                        // any maintenance gap (shouldn't happen with the
379                        // current store/update/forget paths) just rejects
380                        // the entry rather than panicking.
381                        let tokens = tokens_cache.get(&e.id)?;
382                        // Expensive filter (substring scan) last — uses the
383                        // cached lower-cased forms.
384                        if !query_tokens.is_empty() {
385                            let has_match = query_tokens.iter().any(|token| {
386                                tokens.lower_content.contains(token.as_str())
387                                    || tokens
388                                        .lower_keywords
389                                        .iter()
390                                        .any(|k| k.contains(token.as_str()))
391                            });
392                            if !has_match {
393                                return None;
394                            }
395                        }
396                        Some((e, tokens))
397                    })
398                    .collect(),
399            };
400
401            // Compute average document length for BM25 normalisation.
402            // Uses the cached `content_words.len()` — zero new tokenisation.
403            let avgdl = if candidates.is_empty() {
404                1.0
405            } else {
406                let total_words: usize =
407                    candidates.iter().map(|(_, t)| t.content_words.len()).sum();
408                (total_words as f64 / candidates.len() as f64).max(1.0)
409            };
410
411            // Pre-compute BM25 scores. Keyed by `&str` slices into the
412            // entries map; lifetime is tied to the immutable borrow held
413            // by `candidates` (released before any `get_mut` below).
414            let bm25_map: HashMap<&str, f64> = candidates
415                .iter()
416                .map(|(e, t)| {
417                    let score = bm25::bm25_score_pre(
418                        &t.content_words,
419                        &t.lower_keywords,
420                        &query_tokens,
421                        avgdl,
422                        bm25::DEFAULT_K1,
423                        bm25::DEFAULT_B,
424                    );
425                    (e.id.as_str(), score)
426                })
427                .collect();
428
429            // Compute relevance scores: hybrid (BM25 + cosine via RRF) when
430            // query_embedding is available, otherwise pure BM25.
431            let relevance_map: HashMap<&str, f64> = if let Some(ref q_emb) = query.query_embedding {
432                // BM25 ranked list (descending by score)
433                let mut bm25_ranked: Vec<(&str, f64)> =
434                    bm25_map.iter().map(|(id, &s)| (*id, s)).collect();
435                bm25_ranked
436                    .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
437
438                // Vector ranked list (cosine similarity, descending)
439                let mut vector_ranked: Vec<(&str, f64)> = candidates
440                    .iter()
441                    .filter_map(|(e, _)| {
442                        e.embedding
443                            .as_ref()
444                            .map(|emb| (e.id.as_str(), hybrid::cosine_similarity(emb, q_emb)))
445                    })
446                    .collect();
447                vector_ranked
448                    .sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
449
450                if vector_ranked.is_empty() {
451                    // No embeddings stored — fall back to pure BM25
452                    let max_bm25 = bm25_map
453                        .values()
454                        .copied()
455                        .fold(f64::NEG_INFINITY, f64::max)
456                        .max(1.0);
457                    bm25_map
458                        .iter()
459                        .map(|(id, &s)| (*id, s / max_bm25))
460                        .collect()
461                } else {
462                    let fused = hybrid::rrf_fuse(&bm25_ranked, &vector_ranked, 50);
463                    let max_fused = fused
464                        .iter()
465                        .map(|(_, s)| *s)
466                        .fold(f64::NEG_INFINITY, f64::max)
467                        .max(f64::EPSILON);
468                    // `rrf_fuse` returns owned `String` keys; project them
469                    // back onto the original `&str` slices we already
470                    // hold via the bm25_map's keys to keep the lifetime
471                    // discipline consistent across both branches.
472                    let mut out: HashMap<&str, f64> = HashMap::with_capacity(fused.len());
473                    for (id_owned, score) in &fused {
474                        if let Some((k, _)) = bm25_map.get_key_value(id_owned.as_str()) {
475                            out.insert(k, score / max_fused);
476                        }
477                    }
478                    out
479                }
480            } else {
481                // Pure BM25 path
482                let max_bm25 = bm25_map
483                    .values()
484                    .copied()
485                    .fold(f64::NEG_INFINITY, f64::max)
486                    .max(1.0);
487                bm25_map
488                    .iter()
489                    .map(|(id, &s)| (*id, s / max_bm25))
490                    .collect()
491            };
492
493            // Pair refs with composite score (computed **once** per entry)
494            // and sort the pairs. The previous `sort_by` recomputed
495            // `effective_strength` on both elements of every comparison —
496            // at N=10k that's ~280k redundant exp() calls per recall.
497            let mut scored: Vec<(&MemoryEntry, f64)> = candidates
498                .iter()
499                .map(|(e, _)| {
500                    let relevance = relevance_map.get(e.id.as_str()).copied().unwrap_or(0.0);
501                    let eff =
502                        effective_strength(e.strength, e.last_accessed, now, STRENGTH_DECAY_RATE);
503                    let score = composite_score(
504                        &self.scoring_weights,
505                        e.created_at,
506                        now,
507                        e.importance,
508                        relevance,
509                        eff,
510                    );
511                    (*e, score)
512                })
513                .collect();
514            scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
515
516            if query.limit > 0 && scored.len() > query.limit {
517                scored.truncate(query.limit);
518            }
519
520            // Graph expansion: collect related_ids that aren't already in
521            // the top-K. Uses owned `String` ids to keep lifetimes simple.
522            let top_ids: std::collections::HashSet<String> =
523                scored.iter().map(|(e, _)| e.id.clone()).collect();
524            let mut to_expand = Vec::new();
525            let mut seen_expanded = std::collections::HashSet::new();
526            for (entry, _) in &scored {
527                for related_id in &entry.related_ids {
528                    if !top_ids.contains(related_id) && seen_expanded.insert(related_id.clone()) {
529                        to_expand.push(related_id.clone());
530                    }
531                }
532            }
533
534            // Compute BM25 + composite for related entries (still under
535            // the same immutable borrow on `entries`) and append to the
536            // scored Vec. Reuses the cached tokens for related entries
537            // too — graph-expansion never tokenises on the recall path.
538            // Hoist the normalisation scalar out of the loop.
539            let max_bm25 = bm25_map
540                .values()
541                .copied()
542                .fold(f64::NEG_INFINITY, f64::max)
543                .max(1.0);
544            let min_s = query.min_strength.unwrap_or(0.0);
545            let mut expanded_added = 0usize;
546            for related_id in &to_expand {
547                if let Some(related) = entries.get(related_id) {
548                    // SECURITY (M1): enforce the TENANT boundary on graph-expanded
549                    // entries — the first pass rejects cross-tenant entries, but
550                    // this loop previously checked only confidentiality + strength,
551                    // so a cross-tenant `related_ids` ref could leak an entry the
552                    // caller may not see. We re-check tenant ONLY here, NOT
553                    // agent/category/tags/memory_type: graph expansion
554                    // intentionally pulls related context ACROSS those dimensions
555                    // within the same tenant (just as it pulls across lexical
556                    // overlap). Confidentiality + strength stay, as before.
557                    if related.author_tenant_id.as_deref() != Some(tenant_id.as_str()) {
558                        continue;
559                    }
560                    if let Some(max_conf) = query.max_confidentiality
561                        && related.confidentiality > max_conf
562                    {
563                        continue;
564                    }
565                    let eff = effective_strength(
566                        related.strength,
567                        related.last_accessed,
568                        now,
569                        STRENGTH_DECAY_RATE,
570                    );
571                    if eff < min_s {
572                        continue;
573                    }
574                    // Score the related entry against the same query
575                    // using its cached tokens. If somehow the cache is
576                    // out of sync (shouldn't happen — every writer keeps
577                    // both maps locked together) we just skip the entry.
578                    let Some(related_tokens) = tokens_cache.get(related_id) else {
579                        continue;
580                    };
581                    let relevance = bm25::bm25_score_pre(
582                        &related_tokens.content_words,
583                        &related_tokens.lower_keywords,
584                        &query_tokens,
585                        avgdl,
586                        bm25::DEFAULT_K1,
587                        bm25::DEFAULT_B,
588                    );
589                    let normalised = relevance / max_bm25;
590                    let score = composite_score(
591                        &self.scoring_weights,
592                        related.created_at,
593                        now,
594                        related.importance,
595                        normalised,
596                        eff,
597                    );
598                    scored.push((related, score));
599                    expanded_added += 1;
600                }
601            }
602
603            if expanded_added > 0 {
604                scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
605                if query.limit > 0 && scored.len() > query.limit {
606                    scored.truncate(query.limit);
607                }
608            }
609
610            // Collect top-K ids (owned), drop the immutable borrows on
611            // both the entries and tokens-cache maps so we can call
612            // `get_mut` for the access-count updates and the *single*
613            // clone per surviving entry.
614            let top_ids_final: Vec<String> = scored.iter().map(|(e, _)| e.id.clone()).collect();
615            drop(scored);
616            drop(candidates);
617            drop(tokens_cache);
618            drop(inverted);
619
620            // PERF (P-MEM-5): clone only the top-K, after the limit has
621            // been applied. Previously the entire filtered set was cloned
622            // before sorting / truncation — at N=10k that's ~5 MB of
623            // allocation per recall.
624            let reinforce = query.reinforce;
625            let mut results: Vec<MemoryEntry> = Vec::with_capacity(top_ids_final.len());
626            for id in top_ids_final {
627                if let Some(e) = entries.get_mut(&id) {
628                    e.access_count += 1;
629                    e.last_accessed = now;
630                    if reinforce {
631                        // Ebbinghaus reinforcement: +0.2 per access, capped at 1.0.
632                        e.strength = (e.strength + 0.2).min(1.0);
633                    }
634                    results.push(e.clone());
635                }
636            }
637
638            Ok(results)
639        })
640    }
641
642    fn update(
643        &self,
644        scope: &TenantScope,
645        id: &str,
646        content: String,
647    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
648        let id = id.to_string();
649        let tenant_id = scope.tenant_id.clone();
650        Box::pin(async move {
651            let mut entries = self.entries.write();
652            let mut tokens = self.tokens.write();
653            let mut inverted = self.inverted.write();
654            match entries.get_mut(&id) {
655                Some(entry) if entry.author_tenant_id.as_deref() == Some(tenant_id.as_str()) => {
656                    entry.content = content;
657                    entry.last_accessed = Utc::now();
658                    // Refresh the side cache so the recall hot path
659                    // sees the new content tokenisation immediately.
660                    let new_tokens = build_entry_tokens(entry);
661                    if let Some(old_tokens) = tokens.get(&id) {
662                        deindex_entry(&mut inverted, &id, old_tokens);
663                    }
664                    index_entry(&mut inverted, &id, &new_tokens);
665                    tokens.insert(id.clone(), new_tokens);
666                    Ok(())
667                }
668                Some(_) => {
669                    // Entry exists but belongs to a different tenant — treat as not found.
670                    Err(Error::Memory(format!("memory not found: {id}")))
671                }
672                None => Err(Error::Memory(format!("memory not found: {id}"))),
673            }
674        })
675    }
676
677    fn forget(
678        &self,
679        scope: &TenantScope,
680        id: &str,
681    ) -> Pin<Box<dyn Future<Output = Result<bool, Error>> + Send + '_>> {
682        let id = id.to_string();
683        let tenant_id = scope.tenant_id.clone();
684        Box::pin(async move {
685            let mut entries = self.entries.write();
686            let mut tokens = self.tokens.write();
687            let mut inverted = self.inverted.write();
688            // Only remove if the entry belongs to this tenant.
689            // Return false for both "not found" and "wrong tenant" to avoid
690            // revealing cross-tenant id existence.
691            let belongs = entries
692                .get(&id)
693                .map(|e| e.author_tenant_id.as_deref() == Some(tenant_id.as_str()))
694                .unwrap_or(false);
695            if belongs {
696                let removed = entries.remove(&id).is_some();
697                if let Some(old_tokens) = tokens.remove(&id) {
698                    deindex_entry(&mut inverted, &id, &old_tokens);
699                }
700                Ok(removed)
701            } else {
702                Ok(false)
703            }
704        })
705    }
706
707    fn add_link(
708        &self,
709        scope: &TenantScope,
710        id: &str,
711        related_id: &str,
712    ) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + '_>> {
713        let id = id.to_string();
714        let related_id = related_id.to_string();
715        let tenant_id = scope.tenant_id.clone();
716        Box::pin(async move {
717            let mut entries = self.entries.write();
718
719            // Only link entries that belong to the same tenant.
720            let id_ok = entries
721                .get(&id)
722                .map(|e| e.author_tenant_id.as_deref() == Some(tenant_id.as_str()))
723                .unwrap_or(false);
724            let rel_ok = entries
725                .get(&related_id)
726                .map(|e| e.author_tenant_id.as_deref() == Some(tenant_id.as_str()))
727                .unwrap_or(false);
728
729            if id_ok
730                && let Some(entry) = entries.get_mut(&id)
731                && !entry.related_ids.contains(&related_id)
732            {
733                entry.related_ids.push(related_id.clone());
734            }
735            if rel_ok
736                && let Some(entry) = entries.get_mut(&related_id)
737                && !entry.related_ids.contains(&id)
738            {
739                entry.related_ids.push(id);
740            }
741            Ok(())
742        })
743    }
744
745    fn prune(
746        &self,
747        scope: &TenantScope,
748        min_strength: f64,
749        min_age: chrono::Duration,
750        agent_prefix: Option<&str>,
751    ) -> Pin<Box<dyn Future<Output = Result<usize, Error>> + Send + '_>> {
752        let owned_prefix = agent_prefix.map(String::from);
753        let tenant_id = scope.tenant_id.clone();
754        Box::pin(async move {
755            let mut entries = self.entries.write();
756
757            let now = Utc::now();
758            let to_remove: Vec<String> = entries
759                .values()
760                .filter(|e| {
761                    // Tenant isolation: only prune entries belonging to this scope.
762                    if e.author_tenant_id.as_deref() != Some(tenant_id.as_str()) {
763                        return false;
764                    }
765                    // SECURITY (F-MEM-1): match only on EXACT agent name or
766                    // proper `prefix:` separator. Plain `starts_with` lets
767                    // `user:alice` match `user:alice2` / `user:alice-staging`,
768                    // which would let a NamespacedMemory for one user prune
769                    // weak entries of a sibling user with an overlapping
770                    // prefix. The recall path uses exact `agent ==` matching;
771                    // align prune to the same semantics.
772                    if let Some(ref prefix) = owned_prefix {
773                        let p = prefix.as_str();
774                        let agent = e.agent.as_str();
775                        let separator_match = agent.len() > p.len()
776                            && agent.starts_with(p)
777                            && agent.as_bytes()[p.len()] == b':';
778                        if agent != p && !separator_match {
779                            return false;
780                        }
781                    }
782                    let eff =
783                        effective_strength(e.strength, e.last_accessed, now, STRENGTH_DECAY_RATE);
784                    eff < min_strength && now.signed_duration_since(e.created_at) > min_age
785                })
786                .map(|e| e.id.clone())
787                .collect();
788
789            let count = to_remove.len();
790            let mut tokens = self.tokens.write();
791            let mut inverted = self.inverted.write();
792            for id in to_remove {
793                entries.remove(&id);
794                if let Some(old_tokens) = tokens.remove(&id) {
795                    deindex_entry(&mut inverted, &id, &old_tokens);
796                }
797            }
798            Ok(count)
799        })
800    }
801}
802
803#[cfg(test)]
804mod tests {
805    use super::*;
806    use chrono::Utc;
807
808    use super::super::{Confidentiality, MemoryType};
809
810    fn test_scope() -> TenantScope {
811        TenantScope::default()
812    }
813
814    fn make_entry(id: &str, agent: &str, content: &str, category: &str) -> MemoryEntry {
815        MemoryEntry {
816            id: id.into(),
817            agent: agent.into(),
818            content: content.into(),
819            category: category.into(),
820            tags: vec![],
821            created_at: Utc::now(),
822            last_accessed: Utc::now(),
823            access_count: 0,
824            importance: 5,
825            memory_type: MemoryType::default(),
826            keywords: vec![],
827            summary: None,
828            strength: 1.0,
829            related_ids: vec![],
830            source_ids: vec![],
831            embedding: None,
832            confidentiality: Confidentiality::default(),
833            author_user_id: None,
834            author_tenant_id: None,
835        }
836    }
837
838    fn make_entry_with_tags(
839        id: &str,
840        agent: &str,
841        content: &str,
842        category: &str,
843        tags: Vec<String>,
844    ) -> MemoryEntry {
845        MemoryEntry {
846            id: id.into(),
847            agent: agent.into(),
848            content: content.into(),
849            category: category.into(),
850            tags,
851            created_at: Utc::now(),
852            last_accessed: Utc::now(),
853            access_count: 0,
854            importance: 5,
855            memory_type: MemoryType::default(),
856            keywords: vec![],
857            summary: None,
858            strength: 1.0,
859            related_ids: vec![],
860            source_ids: vec![],
861            embedding: None,
862            confidentiality: Confidentiality::default(),
863            author_user_id: None,
864            author_tenant_id: None,
865        }
866    }
867
868    #[tokio::test]
869    async fn store_and_recall() {
870        let store = InMemoryStore::new();
871        let entry = make_entry("m1", "agent1", "Rust is fast", "fact");
872        store.store(&test_scope(), entry).await.unwrap();
873
874        let results = store
875            .recall(
876                &test_scope(),
877                MemoryQuery {
878                    limit: 10,
879                    ..Default::default()
880                },
881            )
882            .await
883            .unwrap();
884        assert_eq!(results.len(), 1);
885        assert_eq!(results[0].content, "Rust is fast");
886    }
887
888    #[tokio::test]
889    async fn graph_expansion_does_not_leak_cross_tenant_related_entries() {
890        // M1: a recall scoped to tenant-1 must not surface a related entry owned
891        // by tenant-2 via graph expansion, even when a tenant-1 entry carries a
892        // hand-crafted cross-tenant `related_ids` ref. The first-pass filter
893        // rejects cross-tenant entries; the expansion path must apply the same
894        // filter (it previously re-checked only confidentiality + strength).
895        let store = InMemoryStore::new();
896        let t1 = TenantScope::new("tenant-1");
897        let t2 = TenantScope::new("tenant-2");
898
899        store
900            .store(
901                &t2,
902                make_entry("secret", "a", "tenant two private data", "fact"),
903            )
904            .await
905            .unwrap();
906
907        let mut bait = make_entry("bait", "a", "rust is fast", "fact");
908        bait.related_ids = vec!["secret".to_string()];
909        store.store(&t1, bait).await.unwrap();
910
911        let results = store
912            .recall(
913                &t1,
914                MemoryQuery {
915                    text: Some("rust".into()),
916                    limit: 10,
917                    ..Default::default()
918                },
919            )
920            .await
921            .unwrap();
922
923        assert!(
924            results.iter().any(|e| e.id == "bait"),
925            "own entry should be recalled"
926        );
927        assert!(
928            !results.iter().any(|e| e.id == "secret"),
929            "cross-tenant related entry leaked via graph expansion: {:?}",
930            results.iter().map(|e| &e.id).collect::<Vec<_>>()
931        );
932    }
933
934    #[tokio::test]
935    async fn graph_expansion_preserves_cross_category_within_tenant() {
936        // M1 must close the TENANT leak WITHOUT also dropping same-tenant related
937        // entries that differ in category/agent — graph expansion intentionally
938        // pulls related context across those dimensions. A category-filtered
939        // query whose bait matches must still surface its related entry even
940        // though that related entry is in a different category.
941        let store = InMemoryStore::new();
942        let scope = test_scope();
943
944        store
945            .store(
946                &scope,
947                make_entry("related", "a", "rust ownership notes", "design"),
948            )
949            .await
950            .unwrap();
951        let mut bait = make_entry("bait", "a", "rust borrow checker", "fact");
952        bait.related_ids = vec!["related".to_string()];
953        store.store(&scope, bait).await.unwrap();
954
955        let results = store
956            .recall(
957                &scope,
958                MemoryQuery {
959                    text: Some("rust".into()),
960                    category: Some("fact".into()),
961                    limit: 10,
962                    ..Default::default()
963                },
964            )
965            .await
966            .unwrap();
967
968        assert!(results.iter().any(|e| e.id == "bait"));
969        assert!(
970            results.iter().any(|e| e.id == "related"),
971            "same-tenant related entry in a different category must still be \
972             surfaced via graph expansion: {:?}",
973            results
974                .iter()
975                .map(|e| (&e.id, &e.category))
976                .collect::<Vec<_>>()
977        );
978    }
979
980    #[tokio::test]
981    async fn recall_by_text() {
982        let store = InMemoryStore::new();
983        store
984            .store(&test_scope(), make_entry("m1", "a", "Rust is fast", "fact"))
985            .await
986            .unwrap();
987        store
988            .store(
989                &test_scope(),
990                make_entry("m2", "a", "Python is slow", "fact"),
991            )
992            .await
993            .unwrap();
994
995        let results = store
996            .recall(
997                &test_scope(),
998                MemoryQuery {
999                    text: Some("rust".into()),
1000                    limit: 10,
1001                    ..Default::default()
1002                },
1003            )
1004            .await
1005            .unwrap();
1006        assert_eq!(results.len(), 1);
1007        assert_eq!(results[0].id, "m1");
1008    }
1009
1010    #[tokio::test]
1011    async fn recall_by_category() {
1012        let store = InMemoryStore::new();
1013        store
1014            .store(
1015                &test_scope(),
1016                make_entry("m1", "a", "remember this", "fact"),
1017            )
1018            .await
1019            .unwrap();
1020        store
1021            .store(
1022                &test_scope(),
1023                make_entry("m2", "a", "I saw something", "observation"),
1024            )
1025            .await
1026            .unwrap();
1027
1028        let results = store
1029            .recall(
1030                &test_scope(),
1031                MemoryQuery {
1032                    category: Some("observation".into()),
1033                    limit: 10,
1034                    ..Default::default()
1035                },
1036            )
1037            .await
1038            .unwrap();
1039        assert_eq!(results.len(), 1);
1040        assert_eq!(results[0].id, "m2");
1041    }
1042
1043    #[tokio::test]
1044    async fn recall_by_tags() {
1045        let store = InMemoryStore::new();
1046        store
1047            .store(
1048                &test_scope(),
1049                make_entry_with_tags(
1050                    "m1",
1051                    "a",
1052                    "Rust memory safety",
1053                    "fact",
1054                    vec!["rust".into(), "safety".into()],
1055                ),
1056            )
1057            .await
1058            .unwrap();
1059        store
1060            .store(
1061                &test_scope(),
1062                make_entry_with_tags(
1063                    "m2",
1064                    "a",
1065                    "Go is garbage collected",
1066                    "fact",
1067                    vec!["go".into()],
1068                ),
1069            )
1070            .await
1071            .unwrap();
1072
1073        let results = store
1074            .recall(
1075                &test_scope(),
1076                MemoryQuery {
1077                    tags: vec!["rust".into()],
1078                    limit: 10,
1079                    ..Default::default()
1080                },
1081            )
1082            .await
1083            .unwrap();
1084        assert_eq!(results.len(), 1);
1085        assert_eq!(results[0].id, "m1");
1086    }
1087
1088    #[tokio::test]
1089    async fn recall_by_agent() {
1090        let store = InMemoryStore::new();
1091        store
1092            .store(
1093                &test_scope(),
1094                make_entry("m1", "researcher", "data point", "fact"),
1095            )
1096            .await
1097            .unwrap();
1098        store
1099            .store(
1100                &test_scope(),
1101                make_entry("m2", "coder", "code snippet", "procedure"),
1102            )
1103            .await
1104            .unwrap();
1105
1106        let results = store
1107            .recall(
1108                &test_scope(),
1109                MemoryQuery {
1110                    agent: Some("researcher".into()),
1111                    limit: 10,
1112                    ..Default::default()
1113                },
1114            )
1115            .await
1116            .unwrap();
1117        assert_eq!(results.len(), 1);
1118        assert_eq!(results[0].id, "m1");
1119    }
1120
1121    #[tokio::test]
1122    async fn recall_limit() {
1123        let store = InMemoryStore::new();
1124        for i in 0..10 {
1125            store
1126                .store(
1127                    &test_scope(),
1128                    make_entry(&format!("m{i}"), "a", &format!("entry {i}"), "fact"),
1129                )
1130                .await
1131                .unwrap();
1132        }
1133
1134        let results = store
1135            .recall(
1136                &test_scope(),
1137                MemoryQuery {
1138                    limit: 3,
1139                    ..Default::default()
1140                },
1141            )
1142            .await
1143            .unwrap();
1144        assert_eq!(results.len(), 3);
1145    }
1146
1147    #[tokio::test]
1148    async fn update_existing() {
1149        let store = InMemoryStore::new();
1150        store
1151            .store(&test_scope(), make_entry("m1", "a", "original", "fact"))
1152            .await
1153            .unwrap();
1154
1155        store
1156            .update(&test_scope(), "m1", "updated content".into())
1157            .await
1158            .unwrap();
1159
1160        let results = store
1161            .recall(
1162                &test_scope(),
1163                MemoryQuery {
1164                    limit: 10,
1165                    ..Default::default()
1166                },
1167            )
1168            .await
1169            .unwrap();
1170        assert_eq!(results[0].content, "updated content");
1171    }
1172
1173    #[tokio::test]
1174    async fn update_nonexistent() {
1175        let store = InMemoryStore::new();
1176        let err = store
1177            .update(&test_scope(), "missing", "content".into())
1178            .await
1179            .unwrap_err();
1180        assert!(err.to_string().contains("not found"));
1181    }
1182
1183    #[tokio::test]
1184    async fn forget_existing() {
1185        let store = InMemoryStore::new();
1186        store
1187            .store(&test_scope(), make_entry("m1", "a", "to delete", "fact"))
1188            .await
1189            .unwrap();
1190
1191        assert!(store.forget(&test_scope(), "m1").await.unwrap());
1192
1193        let results = store
1194            .recall(
1195                &test_scope(),
1196                MemoryQuery {
1197                    limit: 10,
1198                    ..Default::default()
1199                },
1200            )
1201            .await
1202            .unwrap();
1203        assert!(results.is_empty());
1204    }
1205
1206    #[tokio::test]
1207    async fn forget_nonexistent() {
1208        let store = InMemoryStore::new();
1209        assert!(!store.forget(&test_scope(), "missing").await.unwrap());
1210    }
1211
1212    #[test]
1213    fn is_send_sync() {
1214        fn assert_send_sync<T: Send + Sync>() {}
1215        assert_send_sync::<InMemoryStore>();
1216    }
1217
1218    #[tokio::test]
1219    async fn get_returns_entry_by_id_or_none() {
1220        let store = InMemoryStore::new();
1221        let scope = test_scope();
1222        let entry = make_entry("e1", "test", "hello world", "fact");
1223        store.store(&scope, entry).await.unwrap();
1224
1225        let got = store.get("e1").expect("entry e1 should exist");
1226        assert_eq!(got.content, "hello world");
1227        assert!(store.get("missing").is_none());
1228    }
1229
1230    #[tokio::test]
1231    async fn recall_sorts_by_composite_score() {
1232        let store = InMemoryStore::new();
1233
1234        // Old entry with high importance (2 days old, importance=10)
1235        let mut high_imp = make_entry("m1", "a", "high importance", "fact");
1236        high_imp.importance = 10;
1237        high_imp.created_at = Utc::now() - chrono::Duration::hours(48);
1238        store.store(&test_scope(), high_imp).await.unwrap();
1239
1240        // Recent entry with low importance (now, importance=1)
1241        let mut low_imp = make_entry("m2", "a", "low importance", "fact");
1242        low_imp.importance = 1;
1243        low_imp.created_at = Utc::now();
1244        store.store(&test_scope(), low_imp).await.unwrap();
1245
1246        let results = store
1247            .recall(
1248                &test_scope(),
1249                MemoryQuery {
1250                    limit: 10,
1251                    ..Default::default()
1252                },
1253            )
1254            .await
1255            .unwrap();
1256
1257        assert_eq!(results.len(), 2);
1258        // With default weights (0.3 recency, 0.3 importance, 0.4 relevance=0):
1259        //   m1: 0.3*e^(-0.01*48) + 0.3*1.0 ≈ 0.3*0.619 + 0.3 ≈ 0.486
1260        //   m2: 0.3*1.0 + 0.3*0.0 ≈ 0.300
1261        // High-importance old entry beats recent low-importance entry
1262        assert_eq!(results[0].id, "m1");
1263        assert_eq!(results[1].id, "m2");
1264    }
1265
1266    #[tokio::test]
1267    async fn recall_recent_high_importance_first() {
1268        let store = InMemoryStore::new();
1269
1270        // Old, low importance
1271        let mut old_low = make_entry("m1", "a", "old low", "fact");
1272        old_low.importance = 1;
1273        old_low.created_at = Utc::now() - chrono::Duration::hours(1000);
1274        store.store(&test_scope(), old_low).await.unwrap();
1275
1276        // Recent, high importance — should definitely be first
1277        let mut recent_high = make_entry("m2", "a", "recent high", "fact");
1278        recent_high.importance = 10;
1279        recent_high.created_at = Utc::now();
1280        store.store(&test_scope(), recent_high).await.unwrap();
1281
1282        let results = store
1283            .recall(
1284                &test_scope(),
1285                MemoryQuery {
1286                    limit: 10,
1287                    ..Default::default()
1288                },
1289            )
1290            .await
1291            .unwrap();
1292
1293        assert_eq!(results[0].id, "m2");
1294    }
1295
1296    #[tokio::test]
1297    async fn recall_with_custom_weights() {
1298        // Pure importance sorting (alpha=0, beta=1, gamma=0, delta=0)
1299        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
1300            alpha: 0.0,
1301            beta: 1.0,
1302            gamma: 0.0,
1303            delta: 0.0,
1304            decay_rate: 0.01,
1305        });
1306
1307        let mut low = make_entry("m1", "a", "recent but low", "fact");
1308        low.importance = 1;
1309        low.created_at = Utc::now();
1310        store.store(&test_scope(), low).await.unwrap();
1311
1312        let mut high = make_entry("m2", "a", "old but high", "fact");
1313        high.importance = 10;
1314        high.created_at = Utc::now() - chrono::Duration::hours(1000);
1315        store.store(&test_scope(), high).await.unwrap();
1316
1317        let results = store
1318            .recall(
1319                &test_scope(),
1320                MemoryQuery {
1321                    limit: 10,
1322                    ..Default::default()
1323                },
1324            )
1325            .await
1326            .unwrap();
1327
1328        // Pure importance: high importance entry comes first regardless of age
1329        assert_eq!(results[0].id, "m2");
1330    }
1331
1332    #[tokio::test]
1333    async fn recall_text_query_affects_relevance() {
1334        // With gamma=1 (pure relevance), matching entries should score higher
1335        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
1336            alpha: 0.0,
1337            beta: 0.0,
1338            gamma: 1.0,
1339            delta: 0.0,
1340            decay_rate: 0.01,
1341        });
1342
1343        let mut e1 = make_entry("m1", "a", "Rust is fast", "fact");
1344        e1.importance = 5;
1345        store.store(&test_scope(), e1).await.unwrap();
1346
1347        // Text query means relevance=1.0 for matched entries
1348        let results = store
1349            .recall(
1350                &test_scope(),
1351                MemoryQuery {
1352                    text: Some("Rust".into()),
1353                    limit: 10,
1354                    ..Default::default()
1355                },
1356            )
1357            .await
1358            .unwrap();
1359
1360        assert_eq!(results.len(), 1);
1361        assert_eq!(results[0].id, "m1");
1362    }
1363
1364    #[tokio::test]
1365    async fn recall_limit_zero_returns_all() {
1366        let store = InMemoryStore::new();
1367        for i in 0..5 {
1368            store
1369                .store(
1370                    &test_scope(),
1371                    make_entry(&format!("m{i}"), "a", &format!("entry {i}"), "fact"),
1372                )
1373                .await
1374                .unwrap();
1375        }
1376
1377        // limit=0 means "no limit" — should return all entries
1378        let results = store
1379            .recall(
1380                &test_scope(),
1381                MemoryQuery {
1382                    limit: 0,
1383                    ..Default::default()
1384                },
1385            )
1386            .await
1387            .unwrap();
1388        assert_eq!(results.len(), 5);
1389    }
1390
1391    #[tokio::test]
1392    async fn recall_deduplicates_query_tokens() {
1393        // "rust rust rust" should behave identically to "rust" for scoring.
1394        // Before the fix, repeated tokens inflated the denominator, lowering scores.
1395        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
1396            alpha: 0.0,
1397            beta: 0.0,
1398            gamma: 1.0,
1399            delta: 0.0,
1400            decay_rate: 0.01,
1401        });
1402
1403        store
1404            .store(&test_scope(), make_entry("m1", "a", "Rust is fast", "fact"))
1405            .await
1406            .unwrap();
1407        store
1408            .store(
1409                &test_scope(),
1410                make_entry("m2", "a", "Python is slow", "fact"),
1411            )
1412            .await
1413            .unwrap();
1414
1415        // Query with duplicated token
1416        let results = store
1417            .recall(
1418                &test_scope(),
1419                MemoryQuery {
1420                    text: Some("rust rust rust".into()),
1421                    limit: 10,
1422                    ..Default::default()
1423                },
1424            )
1425            .await
1426            .unwrap();
1427
1428        // Only m1 should match (contains "rust")
1429        assert_eq!(results.len(), 1);
1430        assert_eq!(results[0].id, "m1");
1431    }
1432
1433    #[tokio::test]
1434    async fn relevance_score_differentiates_results() {
1435        // Two entries with same importance and similar timestamps.
1436        // "Rust is fast and safe" matches both "Rust" and "fast" from query "Rust fast",
1437        // while "Rust is popular" matches only "Rust". Higher relevance should rank first.
1438        let store = InMemoryStore::new();
1439
1440        let mut entry_partial =
1441            make_entry("m1", "agent1", "Rust is popular in the industry", "fact");
1442        entry_partial.importance = 5;
1443
1444        let mut entry_full =
1445            make_entry("m2", "agent1", "Rust is fast and safe for systems", "fact");
1446        entry_full.importance = 5;
1447
1448        // Store partial-match first so it would naturally sort first by insertion order
1449        store.store(&test_scope(), entry_partial).await.unwrap();
1450        store.store(&test_scope(), entry_full).await.unwrap();
1451
1452        let results = store
1453            .recall(
1454                &test_scope(),
1455                MemoryQuery {
1456                    text: Some("Rust fast".into()),
1457                    limit: 10,
1458                    ..Default::default()
1459                },
1460            )
1461            .await
1462            .unwrap();
1463
1464        // Both should match (both contain "rust")
1465        assert_eq!(results.len(), 2);
1466        // The entry matching both query tokens should rank higher
1467        assert_eq!(
1468            results[0].id, "m2",
1469            "entry matching more query tokens should rank first"
1470        );
1471    }
1472
1473    // --- New field tests ---
1474
1475    #[tokio::test]
1476    async fn recall_filters_by_memory_type() {
1477        let store = InMemoryStore::new();
1478
1479        let mut episodic = make_entry("m1", "a", "episodic fact", "fact");
1480        episodic.memory_type = MemoryType::Episodic;
1481        store.store(&test_scope(), episodic).await.unwrap();
1482
1483        let mut semantic = make_entry("m2", "a", "semantic knowledge", "fact");
1484        semantic.memory_type = MemoryType::Semantic;
1485        store.store(&test_scope(), semantic).await.unwrap();
1486
1487        let mut reflection = make_entry("m3", "a", "reflection insight", "fact");
1488        reflection.memory_type = MemoryType::Reflection;
1489        store.store(&test_scope(), reflection).await.unwrap();
1490
1491        // Filter by Semantic only
1492        let results = store
1493            .recall(
1494                &test_scope(),
1495                MemoryQuery {
1496                    memory_type: Some(MemoryType::Semantic),
1497                    limit: 10,
1498                    ..Default::default()
1499                },
1500            )
1501            .await
1502            .unwrap();
1503        assert_eq!(results.len(), 1);
1504        assert_eq!(results[0].id, "m2");
1505
1506        // Filter by Reflection
1507        let results = store
1508            .recall(
1509                &test_scope(),
1510                MemoryQuery {
1511                    memory_type: Some(MemoryType::Reflection),
1512                    limit: 10,
1513                    ..Default::default()
1514                },
1515            )
1516            .await
1517            .unwrap();
1518        assert_eq!(results.len(), 1);
1519        assert_eq!(results[0].id, "m3");
1520
1521        // No filter returns all
1522        let results = store
1523            .recall(
1524                &test_scope(),
1525                MemoryQuery {
1526                    limit: 10,
1527                    ..Default::default()
1528                },
1529            )
1530            .await
1531            .unwrap();
1532        assert_eq!(results.len(), 3);
1533    }
1534
1535    #[tokio::test]
1536    async fn recall_filters_by_min_strength() {
1537        let store = InMemoryStore::new();
1538
1539        let mut strong = make_entry("m1", "a", "strong memory", "fact");
1540        strong.strength = 0.9;
1541        store.store(&test_scope(), strong).await.unwrap();
1542
1543        let mut weak = make_entry("m2", "a", "weak memory", "fact");
1544        weak.strength = 0.05;
1545        store.store(&test_scope(), weak).await.unwrap();
1546
1547        // Only strong entries
1548        let results = store
1549            .recall(
1550                &test_scope(),
1551                MemoryQuery {
1552                    min_strength: Some(0.5),
1553                    limit: 10,
1554                    ..Default::default()
1555                },
1556            )
1557            .await
1558            .unwrap();
1559        assert_eq!(results.len(), 1);
1560        assert_eq!(results[0].id, "m1");
1561    }
1562
1563    #[tokio::test]
1564    async fn strength_reinforced_on_access() {
1565        let store = InMemoryStore::new();
1566
1567        let mut entry = make_entry("m1", "a", "test", "fact");
1568        entry.strength = 0.5;
1569        store.store(&test_scope(), entry).await.unwrap();
1570
1571        // Recall reinforces strength by +0.2
1572        let results = store
1573            .recall(
1574                &test_scope(),
1575                MemoryQuery {
1576                    limit: 10,
1577                    ..Default::default()
1578                },
1579            )
1580            .await
1581            .unwrap();
1582        assert!((results[0].strength - 0.7).abs() < f64::EPSILON);
1583
1584        // Second access: 0.7 + 0.2 = 0.9
1585        let results = store
1586            .recall(
1587                &test_scope(),
1588                MemoryQuery {
1589                    limit: 10,
1590                    ..Default::default()
1591                },
1592            )
1593            .await
1594            .unwrap();
1595        assert!((results[0].strength - 0.9).abs() < f64::EPSILON);
1596    }
1597
1598    #[tokio::test]
1599    async fn store_preserves_caller_supplied_strength() {
1600        // Issue #5: callers persisting an explicit `strength` (e.g. a freshly
1601        // weakened decay test fixture) must read the same value back on the
1602        // next pure recall.
1603        let store = InMemoryStore::new();
1604
1605        let mut weak = make_entry("m1", "a", "test", "fact");
1606        weak.strength = 0.05;
1607        store.store(&test_scope(), weak).await.unwrap();
1608
1609        let results = store
1610            .recall(
1611                &test_scope(),
1612                MemoryQuery {
1613                    limit: 10,
1614                    reinforce: false,
1615                    ..Default::default()
1616                },
1617            )
1618            .await
1619            .unwrap();
1620        assert!(
1621            (results[0].strength - 0.05).abs() < f64::EPSILON,
1622            "store must preserve caller strength; recall(reinforce=false) must not mutate it (got {})",
1623            results[0].strength
1624        );
1625    }
1626
1627    #[tokio::test]
1628    async fn recall_with_reinforce_false_is_idempotent_for_strength() {
1629        let store = InMemoryStore::new();
1630
1631        let mut entry = make_entry("m1", "a", "test", "fact");
1632        entry.strength = 0.4;
1633        store.store(&test_scope(), entry).await.unwrap();
1634
1635        for _ in 0..5 {
1636            let results = store
1637                .recall(
1638                    &test_scope(),
1639                    MemoryQuery {
1640                        limit: 10,
1641                        reinforce: false,
1642                        ..Default::default()
1643                    },
1644                )
1645                .await
1646                .unwrap();
1647            assert!((results[0].strength - 0.4).abs() < f64::EPSILON);
1648        }
1649
1650        // access_count still ticks even when strength is frozen.
1651        let results = store
1652            .recall(
1653                &test_scope(),
1654                MemoryQuery {
1655                    limit: 10,
1656                    reinforce: false,
1657                    ..Default::default()
1658                },
1659            )
1660            .await
1661            .unwrap();
1662        assert!(results[0].access_count >= 5);
1663    }
1664
1665    #[tokio::test]
1666    async fn strength_capped_at_one() {
1667        let store = InMemoryStore::new();
1668
1669        let mut entry = make_entry("m1", "a", "test", "fact");
1670        entry.strength = 0.95;
1671        store.store(&test_scope(), entry).await.unwrap();
1672
1673        // 0.95 + 0.2 should cap at 1.0
1674        let results = store
1675            .recall(
1676                &test_scope(),
1677                MemoryQuery {
1678                    limit: 10,
1679                    ..Default::default()
1680                },
1681            )
1682            .await
1683            .unwrap();
1684        assert!((results[0].strength - 1.0).abs() < f64::EPSILON);
1685    }
1686
1687    #[tokio::test]
1688    async fn keywords_searched_during_recall() {
1689        let store = InMemoryStore::new();
1690
1691        // Entry with "performance" only in keywords, not content
1692        let mut entry = make_entry("m1", "a", "Rust is great", "fact");
1693        entry.keywords = vec!["performance".into(), "speed".into()];
1694        store.store(&test_scope(), entry).await.unwrap();
1695
1696        let results = store
1697            .recall(
1698                &test_scope(),
1699                MemoryQuery {
1700                    text: Some("performance".into()),
1701                    limit: 10,
1702                    ..Default::default()
1703                },
1704            )
1705            .await
1706            .unwrap();
1707        assert_eq!(results.len(), 1);
1708        assert_eq!(results[0].id, "m1");
1709    }
1710
1711    #[tokio::test]
1712    async fn add_link_bidirectional() {
1713        let store = InMemoryStore::new();
1714        store
1715            .store(&test_scope(), make_entry("m1", "a", "first", "fact"))
1716            .await
1717            .unwrap();
1718        store
1719            .store(&test_scope(), make_entry("m2", "a", "second", "fact"))
1720            .await
1721            .unwrap();
1722
1723        store.add_link(&test_scope(), "m1", "m2").await.unwrap();
1724
1725        let results = store
1726            .recall(
1727                &test_scope(),
1728                MemoryQuery {
1729                    limit: 10,
1730                    ..Default::default()
1731                },
1732            )
1733            .await
1734            .unwrap();
1735        let m1 = results.iter().find(|e| e.id == "m1").unwrap();
1736        let m2 = results.iter().find(|e| e.id == "m2").unwrap();
1737
1738        assert!(m1.related_ids.contains(&"m2".to_string()));
1739        assert!(m2.related_ids.contains(&"m1".to_string()));
1740    }
1741
1742    #[tokio::test]
1743    async fn add_link_idempotent() {
1744        let store = InMemoryStore::new();
1745        store
1746            .store(&test_scope(), make_entry("m1", "a", "first", "fact"))
1747            .await
1748            .unwrap();
1749        store
1750            .store(&test_scope(), make_entry("m2", "a", "second", "fact"))
1751            .await
1752            .unwrap();
1753
1754        // Link twice — should not duplicate
1755        store.add_link(&test_scope(), "m1", "m2").await.unwrap();
1756        store.add_link(&test_scope(), "m1", "m2").await.unwrap();
1757
1758        let results = store
1759            .recall(
1760                &test_scope(),
1761                MemoryQuery {
1762                    limit: 10,
1763                    ..Default::default()
1764                },
1765            )
1766            .await
1767            .unwrap();
1768        let m1 = results.iter().find(|e| e.id == "m1").unwrap();
1769        assert_eq!(
1770            m1.related_ids.iter().filter(|id| *id == "m2").count(),
1771            1,
1772            "should not have duplicate links"
1773        );
1774    }
1775
1776    #[tokio::test]
1777    async fn prune_removes_below_threshold() {
1778        let store = InMemoryStore::new();
1779
1780        let mut strong = make_entry("m1", "a", "strong", "fact");
1781        strong.strength = 0.8;
1782        strong.created_at = Utc::now() - chrono::Duration::hours(48);
1783        store.store(&test_scope(), strong).await.unwrap();
1784
1785        let mut weak = make_entry("m2", "a", "weak", "fact");
1786        weak.strength = 0.05;
1787        weak.created_at = Utc::now() - chrono::Duration::hours(48);
1788        store.store(&test_scope(), weak).await.unwrap();
1789
1790        let pruned = store
1791            .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
1792            .await
1793            .unwrap();
1794        assert_eq!(pruned, 1);
1795
1796        let results = store
1797            .recall(
1798                &test_scope(),
1799                MemoryQuery {
1800                    limit: 10,
1801                    ..Default::default()
1802                },
1803            )
1804            .await
1805            .unwrap();
1806        assert_eq!(results.len(), 1);
1807        assert_eq!(results[0].id, "m1");
1808    }
1809
1810    #[tokio::test]
1811    async fn prune_respects_min_age() {
1812        let store = InMemoryStore::new();
1813
1814        // Weak but recent — should NOT be pruned
1815        let mut weak_recent = make_entry("m1", "a", "weak recent", "fact");
1816        weak_recent.strength = 0.01;
1817        weak_recent.created_at = Utc::now(); // just created
1818        store.store(&test_scope(), weak_recent).await.unwrap();
1819
1820        let pruned = store
1821            .prune(&test_scope(), 0.1, chrono::Duration::hours(24), None)
1822            .await
1823            .unwrap();
1824        assert_eq!(pruned, 0, "recent entry should not be pruned");
1825    }
1826
1827    #[tokio::test]
1828    async fn prune_uses_effective_strength_with_decay() {
1829        let store = InMemoryStore::new();
1830
1831        // Entry with moderate stored strength, but not accessed in a month.
1832        // effective_strength = 0.5 * e^(-0.005 * 720) ≈ 0.5 * 0.027 ≈ 0.014
1833        let mut old_accessed = make_entry("m1", "a", "old accessed", "fact");
1834        old_accessed.strength = 0.5;
1835        old_accessed.created_at = Utc::now() - chrono::Duration::hours(30 * 24);
1836        old_accessed.last_accessed = Utc::now() - chrono::Duration::hours(30 * 24);
1837        store.store(&test_scope(), old_accessed).await.unwrap();
1838
1839        // Same stored strength but recently accessed — effective ≈ 0.5
1840        let mut recently_accessed = make_entry("m2", "a", "recently accessed", "fact");
1841        recently_accessed.strength = 0.5;
1842        recently_accessed.created_at = Utc::now() - chrono::Duration::hours(30 * 24);
1843        recently_accessed.last_accessed = Utc::now();
1844        store.store(&test_scope(), recently_accessed).await.unwrap();
1845
1846        // Prune with min_strength=0.1, min_age=24h
1847        // m1: effective ≈ 0.014 < 0.1, age 30d > 24h → pruned
1848        // m2: effective ≈ 0.5 > 0.1 → kept
1849        let pruned = store
1850            .prune(&test_scope(), 0.1, chrono::Duration::hours(24), None)
1851            .await
1852            .unwrap();
1853        assert_eq!(pruned, 1, "old unaccessed entry should be pruned");
1854
1855        let results = store
1856            .recall(
1857                &test_scope(),
1858                MemoryQuery {
1859                    limit: 10,
1860                    ..Default::default()
1861                },
1862            )
1863            .await
1864            .unwrap();
1865        assert_eq!(results.len(), 1);
1866        assert_eq!(results[0].id, "m2");
1867    }
1868
1869    #[tokio::test]
1870    async fn prune_with_agent_prefix_only_removes_matching_agent() {
1871        let store = InMemoryStore::new();
1872
1873        // Weak + old entries from different agents
1874        let mut weak_a = make_entry("m1", "agent_a", "weak from A", "fact");
1875        weak_a.strength = 0.01;
1876        weak_a.created_at = Utc::now() - chrono::Duration::hours(48);
1877        weak_a.last_accessed = Utc::now() - chrono::Duration::hours(48);
1878        store.store(&test_scope(), weak_a).await.unwrap();
1879
1880        let mut weak_b = make_entry("m2", "agent_b", "weak from B", "fact");
1881        weak_b.strength = 0.01;
1882        weak_b.created_at = Utc::now() - chrono::Duration::hours(48);
1883        weak_b.last_accessed = Utc::now() - chrono::Duration::hours(48);
1884        store.store(&test_scope(), weak_b).await.unwrap();
1885
1886        // Prune with agent_prefix = "agent_a" — should only remove agent_a's entry
1887        let pruned = store
1888            .prune(
1889                &test_scope(),
1890                0.1,
1891                chrono::Duration::hours(1),
1892                Some("agent_a"),
1893            )
1894            .await
1895            .unwrap();
1896        assert_eq!(pruned, 1, "should only prune agent_a's entry");
1897
1898        let results = store
1899            .recall(
1900                &test_scope(),
1901                MemoryQuery {
1902                    limit: 10,
1903                    ..Default::default()
1904                },
1905            )
1906            .await
1907            .unwrap();
1908        assert_eq!(results.len(), 1);
1909        assert_eq!(results[0].id, "m2");
1910        assert_eq!(results[0].agent, "agent_b");
1911    }
1912
1913    /// SECURITY (F-MEM-1): a NamespacedMemory whose name is a prefix of
1914    /// another sibling's must NOT prune the sibling's entries. Before the fix,
1915    /// `e.agent.starts_with("user:alice")` matched `user:alice2`, letting
1916    /// alice's NamespacedMemory wipe weak entries belonging to alice2 (or
1917    /// alice-staging, alice_admin, etc.).
1918    #[tokio::test]
1919    async fn prune_does_not_match_overlapping_agent_prefix() {
1920        let store = InMemoryStore::new();
1921
1922        // alice — should be pruned
1923        let mut weak_alice = make_entry("ma", "user:alice", "weak alice", "fact");
1924        weak_alice.strength = 0.01;
1925        weak_alice.created_at = Utc::now() - chrono::Duration::hours(48);
1926        weak_alice.last_accessed = Utc::now() - chrono::Duration::hours(48);
1927        store.store(&test_scope(), weak_alice).await.unwrap();
1928
1929        // alice2 — overlapping prefix; must NOT be pruned by `user:alice` prune.
1930        let mut weak_alice2 = make_entry("m2", "user:alice2", "weak alice2", "fact");
1931        weak_alice2.strength = 0.01;
1932        weak_alice2.created_at = Utc::now() - chrono::Duration::hours(48);
1933        weak_alice2.last_accessed = Utc::now() - chrono::Duration::hours(48);
1934        store.store(&test_scope(), weak_alice2).await.unwrap();
1935
1936        // user:alice:tool — proper sub-namespace, MUST be pruned (separator match).
1937        let mut weak_subagent = make_entry("ms", "user:alice:tool", "weak sub", "fact");
1938        weak_subagent.strength = 0.01;
1939        weak_subagent.created_at = Utc::now() - chrono::Duration::hours(48);
1940        weak_subagent.last_accessed = Utc::now() - chrono::Duration::hours(48);
1941        store.store(&test_scope(), weak_subagent).await.unwrap();
1942
1943        let pruned = store
1944            .prune(
1945                &test_scope(),
1946                0.1,
1947                chrono::Duration::hours(1),
1948                Some("user:alice"),
1949            )
1950            .await
1951            .unwrap();
1952
1953        // Must prune alice + alice's sub-tool (separator match), NOT alice2.
1954        assert_eq!(pruned, 2, "must prune alice and user:alice:tool only");
1955
1956        let results = store
1957            .recall(
1958                &test_scope(),
1959                MemoryQuery {
1960                    limit: 10,
1961                    ..Default::default()
1962                },
1963            )
1964            .await
1965            .unwrap();
1966        let agents: std::collections::HashSet<&str> =
1967            results.iter().map(|e| e.agent.as_str()).collect();
1968        assert!(
1969            agents.contains("user:alice2"),
1970            "alice2 must survive (overlapping prefix bypass): got {agents:?}"
1971        );
1972    }
1973
1974    #[tokio::test]
1975    async fn prune_none_prefix_removes_all_matching() {
1976        let store = InMemoryStore::new();
1977
1978        let mut weak_a = make_entry("m1", "agent_a", "weak from A", "fact");
1979        weak_a.strength = 0.01;
1980        weak_a.created_at = Utc::now() - chrono::Duration::hours(48);
1981        weak_a.last_accessed = Utc::now() - chrono::Duration::hours(48);
1982        store.store(&test_scope(), weak_a).await.unwrap();
1983
1984        let mut weak_b = make_entry("m2", "agent_b", "weak from B", "fact");
1985        weak_b.strength = 0.01;
1986        weak_b.created_at = Utc::now() - chrono::Duration::hours(48);
1987        weak_b.last_accessed = Utc::now() - chrono::Duration::hours(48);
1988        store.store(&test_scope(), weak_b).await.unwrap();
1989
1990        // Prune with None prefix — removes all weak entries
1991        let pruned = store
1992            .prune(&test_scope(), 0.1, chrono::Duration::hours(1), None)
1993            .await
1994            .unwrap();
1995        assert_eq!(pruned, 2, "should prune all weak entries");
1996    }
1997
1998    #[tokio::test]
1999    async fn recall_bm25_ranks_better_than_naive_keyword() {
2000        // BM25 should rank an entry matching more query terms higher,
2001        // even when both entries have identical importance and recency.
2002        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2003            alpha: 0.0,
2004            beta: 0.0,
2005            gamma: 1.0,
2006            delta: 0.0,
2007            decay_rate: 0.01,
2008        });
2009
2010        // Entry with only one query term match
2011        let e1 = make_entry("m1", "a", "Rust is a programming language", "fact");
2012        store.store(&test_scope(), e1).await.unwrap();
2013
2014        // Entry matching both query terms
2015        let e2 = make_entry(
2016            "m2",
2017            "a",
2018            "Rust has excellent performance and speed",
2019            "fact",
2020        );
2021        store.store(&test_scope(), e2).await.unwrap();
2022
2023        let results = store
2024            .recall(
2025                &test_scope(),
2026                MemoryQuery {
2027                    text: Some("Rust performance".into()),
2028                    limit: 10,
2029                    ..Default::default()
2030                },
2031            )
2032            .await
2033            .unwrap();
2034
2035        assert_eq!(results.len(), 2);
2036        assert_eq!(
2037            results[0].id, "m2",
2038            "BM25 should rank entry matching more query terms first"
2039        );
2040    }
2041
2042    #[tokio::test]
2043    async fn recall_bm25_keyword_field_boosts_ranking() {
2044        // Entry with match in keywords should rank higher than content-only match
2045        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2046            alpha: 0.0,
2047            beta: 0.0,
2048            gamma: 1.0,
2049            delta: 0.0,
2050            decay_rate: 0.01,
2051        });
2052
2053        // Match in content only
2054        let e1 = make_entry("m1", "a", "optimization techniques for databases", "fact");
2055        store.store(&test_scope(), e1).await.unwrap();
2056
2057        // Match in both content and keywords (keyword boost)
2058        let mut e2 = make_entry("m2", "a", "optimization techniques for systems", "fact");
2059        e2.keywords = vec!["optimization".into(), "databases".into()];
2060        store.store(&test_scope(), e2).await.unwrap();
2061
2062        let results = store
2063            .recall(
2064                &test_scope(),
2065                MemoryQuery {
2066                    text: Some("optimization databases".into()),
2067                    limit: 10,
2068                    ..Default::default()
2069                },
2070            )
2071            .await
2072            .unwrap();
2073
2074        assert_eq!(results.len(), 2);
2075        assert_eq!(
2076            results[0].id, "m2",
2077            "entry with keyword match should rank higher"
2078        );
2079    }
2080
2081    #[tokio::test]
2082    async fn strength_affects_ranking() {
2083        // Use delta=1.0 (pure strength) to isolate the effect
2084        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2085            alpha: 0.0,
2086            beta: 0.0,
2087            gamma: 0.0,
2088            delta: 1.0,
2089            decay_rate: 0.01,
2090        });
2091
2092        let mut weak = make_entry("m1", "a", "weak entry", "fact");
2093        weak.strength = 0.2;
2094        store.store(&test_scope(), weak).await.unwrap();
2095
2096        let mut strong = make_entry("m2", "a", "strong entry", "fact");
2097        strong.strength = 0.9;
2098        store.store(&test_scope(), strong).await.unwrap();
2099
2100        let results = store
2101            .recall(
2102                &test_scope(),
2103                MemoryQuery {
2104                    limit: 10,
2105                    ..Default::default()
2106                },
2107            )
2108            .await
2109            .unwrap();
2110
2111        assert_eq!(results.len(), 2);
2112        assert_eq!(
2113            results[0].id, "m2",
2114            "stronger entry should rank first when delta=1.0"
2115        );
2116    }
2117
2118    #[tokio::test]
2119    async fn hybrid_recall_cosine_boosts_semantic_match() {
2120        // Pure relevance scoring: entry with high cosine similarity but no keyword
2121        // match should still surface via hybrid retrieval.
2122        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2123            alpha: 0.0,
2124            beta: 0.0,
2125            gamma: 1.0,
2126            delta: 0.0,
2127            decay_rate: 0.01,
2128        });
2129
2130        // e1: keyword match for "rust" but no embedding
2131        let e1 = make_entry("m1", "a", "Rust is fast", "fact");
2132        store.store(&test_scope(), e1).await.unwrap();
2133
2134        // e2: no keyword match for "rust" but has embedding very similar to query
2135        let mut e2 = make_entry(
2136            "m2",
2137            "a",
2138            "Systems programming language with safety",
2139            "fact",
2140        );
2141        e2.embedding = Some(vec![0.9, 0.1, 0.0]);
2142        store.store(&test_scope(), e2).await.unwrap();
2143
2144        // Query: "rust" with embedding close to e2's embedding
2145        let results = store
2146            .recall(
2147                &test_scope(),
2148                MemoryQuery {
2149                    text: Some("rust".into()),
2150                    query_embedding: Some(vec![0.9, 0.1, 0.0]),
2151                    limit: 10,
2152                    ..Default::default()
2153                },
2154            )
2155            .await
2156            .unwrap();
2157
2158        // Only m1 matches keyword filter, but m2 should not appear because
2159        // the keyword filter excludes it before scoring. Hybrid only affects
2160        // entries that pass the initial keyword filter.
2161        assert_eq!(results.len(), 1);
2162        assert_eq!(results[0].id, "m1");
2163    }
2164
2165    #[tokio::test]
2166    async fn hybrid_recall_fuses_bm25_and_vector() {
2167        // When entries pass keyword filter AND have embeddings, hybrid should
2168        // affect ranking via RRF fusion. We use 3 entries so that RRF
2169        // asymmetry from vector ranking can change the outcome.
2170        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2171            alpha: 0.0,
2172            beta: 0.0,
2173            gamma: 1.0,
2174            delta: 0.0,
2175            decay_rate: 0.01,
2176        });
2177
2178        // e1 matches "rust" and "fast" (2 terms) — strong BM25
2179        let mut e1 = make_entry("m1", "a", "Rust is fast and fast", "fact");
2180        e1.embedding = Some(vec![0.0, 0.0, 1.0]); // orthogonal to query embedding
2181        store.store(&test_scope(), e1).await.unwrap();
2182
2183        // e2 matches only "rust" (1 term) — weaker BM25
2184        let mut e2 = make_entry("m2", "a", "Rust has zero-cost abstractions", "fact");
2185        e2.embedding = Some(vec![0.95, 0.05, 0.0]); // very similar to query embedding
2186        store.store(&test_scope(), e2).await.unwrap();
2187
2188        // e3 matches only "rust" — weakest BM25, moderate vector
2189        let mut e3 = make_entry("m3", "a", "Rust is a programming language", "fact");
2190        e3.embedding = Some(vec![0.5, 0.5, 0.0]); // moderate similarity
2191        store.store(&test_scope(), e3).await.unwrap();
2192
2193        // Without hybrid: BM25 ranks m1 first (matches "rust"+"fast").
2194        // With hybrid: vector strongly boosts m2 (0.95 similarity vs m1's 0.0).
2195        // RRF fuses both signals — m2 should come out on top.
2196        let results = store
2197            .recall(
2198                &test_scope(),
2199                MemoryQuery {
2200                    text: Some("rust fast".into()),
2201                    query_embedding: Some(vec![0.95, 0.05, 0.0]),
2202                    limit: 10,
2203                    ..Default::default()
2204                },
2205            )
2206            .await
2207            .unwrap();
2208
2209        assert_eq!(results.len(), 3);
2210        // e2 is ranked #1 by vector (highest cosine) and #2 by BM25
2211        // e1 is ranked #1 by BM25 but #3 by vector (0.0 cosine = worst)
2212        // RRF: m2 = 1/52 + 1/51, m1 = 1/51 + 1/53, m3 = 1/53 + 1/52
2213        // m2 ≈ 0.03884, m1 ≈ 0.03850, m3 ≈ 0.03810
2214        assert_eq!(
2215            results[0].id, "m2",
2216            "entry with highest cosine similarity should rank first in hybrid mode"
2217        );
2218    }
2219
2220    #[tokio::test]
2221    async fn hybrid_recall_bm25_fallback_when_no_embeddings() {
2222        // When query_embedding is set but no entries have embeddings,
2223        // should fall back to pure BM25 ranking.
2224        let store = InMemoryStore::new().with_scoring_weights(ScoringWeights {
2225            alpha: 0.0,
2226            beta: 0.0,
2227            gamma: 1.0,
2228            delta: 0.0,
2229            decay_rate: 0.01,
2230        });
2231
2232        let e1 = make_entry("m1", "a", "Rust programming language", "fact");
2233        store.store(&test_scope(), e1).await.unwrap();
2234
2235        let e2 = make_entry("m2", "a", "Rust performance and speed", "fact");
2236        store.store(&test_scope(), e2).await.unwrap();
2237
2238        let results = store
2239            .recall(
2240                &test_scope(),
2241                MemoryQuery {
2242                    text: Some("Rust performance".into()),
2243                    query_embedding: Some(vec![0.5, 0.5, 0.0]),
2244                    limit: 10,
2245                    ..Default::default()
2246                },
2247            )
2248            .await
2249            .unwrap();
2250
2251        assert_eq!(results.len(), 2);
2252        // e2 matches both "rust" and "performance" → higher BM25 → ranks first
2253        assert_eq!(results[0].id, "m2");
2254    }
2255
2256    #[tokio::test]
2257    async fn recall_follows_related_ids_one_hop() {
2258        // m1 matches query "rust". m2 does NOT match "rust" but is linked to m1.
2259        // Graph expansion should surface m2 in results.
2260        let store = InMemoryStore::new();
2261
2262        let mut m1 = make_entry("m1", "a", "Rust is fast", "fact");
2263        m1.related_ids = vec!["m2".into()];
2264        store.store(&test_scope(), m1).await.unwrap();
2265
2266        let mut m2 = make_entry("m2", "a", "Memory safety guarantees", "fact");
2267        m2.related_ids = vec!["m1".into()];
2268        store.store(&test_scope(), m2).await.unwrap();
2269
2270        let results = store
2271            .recall(
2272                &test_scope(),
2273                MemoryQuery {
2274                    text: Some("rust".into()),
2275                    limit: 10,
2276                    ..Default::default()
2277                },
2278            )
2279            .await
2280            .unwrap();
2281
2282        // m1 matches directly, m2 should appear via graph expansion
2283        assert_eq!(results.len(), 2);
2284        let ids: Vec<&str> = results.iter().map(|e| e.id.as_str()).collect();
2285        assert!(ids.contains(&"m1"), "direct match should be in results");
2286        assert!(
2287            ids.contains(&"m2"),
2288            "linked entry should be surfaced via graph expansion"
2289        );
2290    }
2291
2292    #[tokio::test]
2293    async fn recall_graph_expansion_respects_strength_threshold() {
2294        let store = InMemoryStore::new();
2295
2296        let mut m1 = make_entry("m1", "a", "Rust is fast", "fact");
2297        m1.related_ids = vec!["m2".into()];
2298        store.store(&test_scope(), m1).await.unwrap();
2299
2300        // m2 has very low strength — should be excluded by min_strength
2301        let mut m2 = make_entry("m2", "a", "Weak linked memory", "fact");
2302        m2.related_ids = vec!["m1".into()];
2303        m2.strength = 0.01;
2304        m2.last_accessed = Utc::now() - chrono::Duration::hours(720); // very old
2305        store.store(&test_scope(), m2).await.unwrap();
2306
2307        let results = store
2308            .recall(
2309                &test_scope(),
2310                MemoryQuery {
2311                    text: Some("rust".into()),
2312                    min_strength: Some(0.1),
2313                    limit: 10,
2314                    ..Default::default()
2315                },
2316            )
2317            .await
2318            .unwrap();
2319
2320        // Only m1 should appear — m2's effective strength is below threshold
2321        assert_eq!(results.len(), 1);
2322        assert_eq!(results[0].id, "m1");
2323    }
2324
2325    #[tokio::test]
2326    async fn recall_graph_expansion_does_not_duplicate() {
2327        let store = InMemoryStore::new();
2328
2329        // Both m1 and m2 match "rust" directly AND are linked
2330        let mut m1 = make_entry("m1", "a", "Rust is fast", "fact");
2331        m1.related_ids = vec!["m2".into()];
2332        store.store(&test_scope(), m1).await.unwrap();
2333
2334        let mut m2 = make_entry("m2", "a", "Rust is safe", "fact");
2335        m2.related_ids = vec!["m1".into()];
2336        store.store(&test_scope(), m2).await.unwrap();
2337
2338        let results = store
2339            .recall(
2340                &test_scope(),
2341                MemoryQuery {
2342                    text: Some("rust".into()),
2343                    limit: 10,
2344                    ..Default::default()
2345                },
2346            )
2347            .await
2348            .unwrap();
2349
2350        // Both match directly — graph expansion should NOT add duplicates
2351        assert_eq!(results.len(), 2);
2352        let ids: Vec<&str> = results.iter().map(|e| e.id.as_str()).collect();
2353        assert_eq!(
2354            ids.iter().filter(|&&id| id == "m1").count(),
2355            1,
2356            "m1 should appear exactly once"
2357        );
2358        assert_eq!(
2359            ids.iter().filter(|&&id| id == "m2").count(),
2360            1,
2361            "m2 should appear exactly once"
2362        );
2363    }
2364
2365    #[tokio::test]
2366    async fn recall_agent_prefix_matches_sub_namespaces() {
2367        let store = InMemoryStore::new();
2368        // Sub-agent memories with compound namespace
2369        store
2370            .store(
2371                &test_scope(),
2372                make_entry("m1", "tg:123:assistant", "likes Rust", "fact"),
2373            )
2374            .await
2375            .unwrap();
2376        store
2377            .store(
2378                &test_scope(),
2379                make_entry("m2", "tg:123:researcher", "loves coffee", "fact"),
2380            )
2381            .await
2382            .unwrap();
2383        // Different user — should NOT match
2384        store
2385            .store(
2386                &test_scope(),
2387                make_entry("m3", "tg:456:assistant", "prefers Python", "fact"),
2388            )
2389            .await
2390            .unwrap();
2391
2392        let results = store
2393            .recall(
2394                &test_scope(),
2395                MemoryQuery {
2396                    agent_prefix: Some("tg:123".into()),
2397                    limit: 10,
2398                    ..Default::default()
2399                },
2400            )
2401            .await
2402            .unwrap();
2403
2404        assert_eq!(results.len(), 2);
2405        let ids: Vec<&str> = results.iter().map(|e| e.id.as_str()).collect();
2406        assert!(ids.contains(&"m1"));
2407        assert!(ids.contains(&"m2"));
2408    }
2409
2410    #[tokio::test]
2411    async fn recall_agent_exact_takes_precedence_over_prefix() {
2412        let store = InMemoryStore::new();
2413        store
2414            .store(
2415                &test_scope(),
2416                make_entry("m1", "tg:123:assistant", "from assistant", "fact"),
2417            )
2418            .await
2419            .unwrap();
2420        store
2421            .store(
2422                &test_scope(),
2423                make_entry("m2", "tg:123:researcher", "from researcher", "fact"),
2424            )
2425            .await
2426            .unwrap();
2427
2428        // Exact agent filter should only return the exact match
2429        let results = store
2430            .recall(
2431                &test_scope(),
2432                MemoryQuery {
2433                    agent: Some("tg:123:assistant".into()),
2434                    agent_prefix: Some("tg:123".into()), // ignored
2435                    limit: 10,
2436                    ..Default::default()
2437                },
2438            )
2439            .await
2440            .unwrap();
2441
2442        assert_eq!(results.len(), 1);
2443        assert_eq!(results[0].id, "m1");
2444    }
2445
2446    #[tokio::test]
2447    async fn recall_filters_by_max_confidentiality() {
2448        use super::super::Confidentiality;
2449        let store = InMemoryStore::new();
2450
2451        let mut public = make_entry("m1", "a", "public fact", "fact");
2452        public.confidentiality = Confidentiality::Public;
2453        store.store(&test_scope(), public).await.unwrap();
2454
2455        let mut internal = make_entry("m2", "a", "internal note", "fact");
2456        internal.confidentiality = Confidentiality::Internal;
2457        store.store(&test_scope(), internal).await.unwrap();
2458
2459        let mut confidential = make_entry("m3", "a", "private expense", "fact");
2460        confidential.confidentiality = Confidentiality::Confidential;
2461        store.store(&test_scope(), confidential).await.unwrap();
2462
2463        let mut restricted = make_entry("m4", "a", "api key", "fact");
2464        restricted.confidentiality = Confidentiality::Restricted;
2465        store.store(&test_scope(), restricted).await.unwrap();
2466
2467        // Cap at Public — only public entries returned
2468        let results = store
2469            .recall(
2470                &test_scope(),
2471                MemoryQuery {
2472                    max_confidentiality: Some(Confidentiality::Public),
2473                    ..Default::default()
2474                },
2475            )
2476            .await
2477            .unwrap();
2478        assert_eq!(results.len(), 1);
2479        assert_eq!(results[0].id, "m1");
2480
2481        // No cap — all entries returned
2482        let results = store
2483            .recall(
2484                &test_scope(),
2485                MemoryQuery {
2486                    max_confidentiality: None,
2487                    ..Default::default()
2488                },
2489            )
2490            .await
2491            .unwrap();
2492        assert_eq!(results.len(), 4);
2493
2494        // Cap at Confidential — excludes Restricted
2495        let results = store
2496            .recall(
2497                &test_scope(),
2498                MemoryQuery {
2499                    max_confidentiality: Some(Confidentiality::Confidential),
2500                    ..Default::default()
2501                },
2502            )
2503            .await
2504            .unwrap();
2505        assert_eq!(results.len(), 3);
2506        assert!(results.iter().all(|e| e.id != "m4"));
2507    }
2508
2509    #[tokio::test]
2510    async fn graph_expansion_respects_max_confidentiality() {
2511        use super::super::Confidentiality;
2512        let store = InMemoryStore::new();
2513
2514        // Public entry with a link to a Confidential entry
2515        let mut public = make_entry("m1", "a", "project update", "fact");
2516        public.confidentiality = Confidentiality::Public;
2517        public.related_ids = vec!["m2".into()];
2518        public.keywords = vec!["project".into()];
2519        store.store(&test_scope(), public).await.unwrap();
2520
2521        let mut confidential = make_entry("m2", "a", "private expense data", "fact");
2522        confidential.confidentiality = Confidentiality::Confidential;
2523        confidential.keywords = vec!["expense".into()];
2524        store.store(&test_scope(), confidential).await.unwrap();
2525
2526        // Query with Public cap and keyword that matches m1 — should NOT expand to m2
2527        let results = store
2528            .recall(
2529                &test_scope(),
2530                MemoryQuery {
2531                    text: Some("project".into()),
2532                    max_confidentiality: Some(Confidentiality::Public),
2533                    ..Default::default()
2534                },
2535            )
2536            .await
2537            .unwrap();
2538
2539        assert!(
2540            results.iter().all(|e| e.id != "m2"),
2541            "graph expansion should not include Confidential entries when capped at Public"
2542        );
2543        assert!(results.iter().any(|e| e.id == "m1"));
2544    }
2545
2546    // --- Tenant-scope isolation (B4): scope filter is independent of agent_prefix ---
2547
2548    #[tokio::test]
2549    async fn recall_does_not_leak_across_tenants() {
2550        let store = InMemoryStore::new();
2551        let acme = TenantScope::new("acme");
2552        let globex = TenantScope::new("globex");
2553
2554        store
2555            .store(&acme, make_entry("a1", "agent", "acme-secret", "fact"))
2556            .await
2557            .unwrap();
2558        store
2559            .store(&globex, make_entry("g1", "agent", "globex-secret", "fact"))
2560            .await
2561            .unwrap();
2562
2563        let acme_results = store
2564            .recall(
2565                &acme,
2566                MemoryQuery {
2567                    agent: Some("agent".into()),
2568                    ..Default::default()
2569                },
2570            )
2571            .await
2572            .unwrap();
2573        assert_eq!(acme_results.len(), 1);
2574        assert_eq!(acme_results[0].id, "a1");
2575
2576        let globex_results = store
2577            .recall(
2578                &globex,
2579                MemoryQuery {
2580                    agent: Some("agent".into()),
2581                    ..Default::default()
2582                },
2583            )
2584            .await
2585            .unwrap();
2586        assert_eq!(globex_results.len(), 1);
2587        assert_eq!(globex_results[0].id, "g1");
2588    }
2589
2590    #[tokio::test]
2591    async fn forget_does_not_delete_other_tenant() {
2592        let store = InMemoryStore::new();
2593        let acme = TenantScope::new("acme");
2594        let globex = TenantScope::new("globex");
2595
2596        store
2597            .store(&acme, make_entry("a1", "agent", "x", "fact"))
2598            .await
2599            .unwrap();
2600        store
2601            .store(&globex, make_entry("g1", "agent", "y", "fact"))
2602            .await
2603            .unwrap();
2604
2605        // Try to forget acme's id under globex's scope — should not delete acme's entry.
2606        let removed = store.forget(&globex, "a1").await.unwrap();
2607        assert!(!removed);
2608
2609        let acme_results = store
2610            .recall(
2611                &acme,
2612                MemoryQuery {
2613                    agent: Some("agent".into()),
2614                    ..Default::default()
2615                },
2616            )
2617            .await
2618            .unwrap();
2619        assert_eq!(acme_results.len(), 1);
2620    }
2621
2622    #[tokio::test]
2623    async fn update_does_not_modify_other_tenant() {
2624        let store = InMemoryStore::new();
2625        let acme = TenantScope::new("acme");
2626        let globex = TenantScope::new("globex");
2627
2628        store
2629            .store(&acme, make_entry("a1", "agent", "original", "fact"))
2630            .await
2631            .unwrap();
2632
2633        // Cross-tenant update should fail (entry exists, but in a different tenant).
2634        let err = store
2635            .update(&globex, "a1", "tampered".into())
2636            .await
2637            .unwrap_err();
2638        assert!(err.to_string().contains("memory not found"), "got: {err}");
2639
2640        // Original content survived.
2641        let results = store
2642            .recall(
2643                &acme,
2644                MemoryQuery {
2645                    agent: Some("agent".into()),
2646                    ..Default::default()
2647                },
2648            )
2649            .await
2650            .unwrap();
2651        assert_eq!(results.len(), 1);
2652        assert_eq!(results[0].content, "original");
2653    }
2654
2655    #[tokio::test]
2656    async fn store_under_scope_populates_author_tenant_id() {
2657        let store = InMemoryStore::new();
2658        let scope = TenantScope::new("acme").with_user("u-42");
2659        store
2660            .store(&scope, make_entry("s1", "agent", "x", "fact"))
2661            .await
2662            .unwrap();
2663
2664        let results = store
2665            .recall(
2666                &scope,
2667                MemoryQuery {
2668                    agent: Some("agent".into()),
2669                    ..Default::default()
2670                },
2671            )
2672            .await
2673            .unwrap();
2674        assert_eq!(results.len(), 1);
2675        assert_eq!(results[0].author_tenant_id.as_deref(), Some("acme"));
2676        assert_eq!(results[0].author_user_id.as_deref(), Some("u-42"));
2677    }
2678}