Skip to main content

mentedb/
injection.rs

1//! Engine-native injection attention: retrieval shaped for context
2//! injection rather than raw search.
3//!
4//! Plain top-k recall is the wrong contract for injecting memories into a
5//! model's context: the most similar memories to a conversation are echoes
6//! of that conversation, top-k always returns k items however weak the
7//! tail, and near-duplicates (a distilled fact and the turn it came from)
8//! co-rank. This module owns the selection policy that client hooks
9//! previously approximated with heuristics:
10//!
11//! - Session provenance: memories originating from the querying session are
12//!   never returned, they are in that context by construction.
13//! - A working-memory ledger (`exclude_ids`) supplied by the client, holding
14//!   what was already delivered within the current context lifetime.
15//! - A relevance knee instead of a fixed floor: the candidate list is cut at
16//!   the largest score gap, so weak tails vanish without a magic constant.
17//! - Maximal Marginal Relevance over embeddings, so each selected item adds
18//!   information the previous ones did not.
19//! - Type quotas: distilled knowledge first, verbatim episodic capped,
20//!   action notes never (they exist for distillation and session resume).
21//! - User-pinned `scope:always` memories bypass every quality gate.
22//! - Outcome learning: `record_injection_outcome` tracks shown vs used per
23//!   memory; chronically ignored memories are demoted at selection time and
24//!   used ones are reinforced.
25
26use std::collections::HashSet;
27
28use mentedb_core::error::MenteResult;
29use mentedb_core::memory::{AttributeValue, MemoryNode, MemoryType};
30use mentedb_core::types::{AgentId, MemoryId, UserId};
31
32use crate::MenteDb;
33
34/// Attribute key: how many times this memory was injected into a context.
35pub const ATTR_INJECTION_SHOWN: &str = "injection_shown";
36/// Attribute key: how many times an injected memory was drawn on by the
37/// reply that followed.
38pub const ATTR_INJECTION_USED: &str = "injection_used";
39
40/// Tunables for injection attention selection.
41#[derive(Debug, Clone)]
42pub struct InjectionConfig {
43    /// Retrieval pool fanned out before selection.
44    pub candidate_pool: usize,
45    /// MMR relevance weight; the remainder weighs redundancy.
46    pub mmr_lambda: f32,
47    /// Consecutive score ratio treated as the relevance knee.
48    pub knee_gap_ratio: f32,
49    /// Shown at least this often with zero uses = demoted at selection.
50    pub demotion_shown_min: i64,
51    /// Score multiplier applied to chronically ignored memories.
52    pub demotion_factor: f32,
53    /// Reply similarity above which a shown memory counts as used.
54    pub used_similarity: f32,
55    /// Salience reinforcement applied when a memory is actually used.
56    pub use_reinforcement: f32,
57    /// Associative recall: how many 1-hop graph neighbors of the top vector hits
58    /// to fold into the candidate pool, so a memory linked to a strong hit can
59    /// surface even when it is not itself vector-similar to the query. 0 (the
60    /// default) disables expansion, leaving pure vector/hybrid recall unchanged.
61    pub graph_expansion_max: usize,
62    /// Score a neighbor inherits from the hit it was reached through, as a
63    /// fraction of that hit's score times the edge weight. Keeps neighbors below
64    /// direct hits so they only win a slot when there is room after them.
65    pub graph_expansion_decay: f32,
66}
67
68impl Default for InjectionConfig {
69    fn default() -> Self {
70        Self {
71            candidate_pool: 48,
72            mmr_lambda: 0.7,
73            knee_gap_ratio: 2.0,
74            demotion_shown_min: 5,
75            demotion_factor: 0.5,
76            used_similarity: 0.6,
77            use_reinforcement: 0.05,
78            graph_expansion_max: 0,
79            graph_expansion_decay: 0.5,
80        }
81    }
82}
83
84/// A request for injection-ready context.
85pub struct InjectionQuery<'a> {
86    /// Embedding of the prompt (plus any conversational blend).
87    pub embedding: &'a [f32],
88    /// Raw query text for the lexical half of hybrid recall.
89    pub query_text: Option<&'a str>,
90    /// The session issuing the query. Memories tagged `session:<id>` with
91    /// this ID are excluded: they are already in that session's context.
92    pub session_id: Option<&'a str>,
93    /// Memory IDs already delivered within the current context lifetime.
94    pub exclude_ids: &'a [MemoryId],
95    /// Maximum items returned beyond pinned memories.
96    pub max_items: usize,
97    /// Maximum verbatim episodic items within `max_items`.
98    pub max_episodic: usize,
99    /// Restrict recall to this agent's memories plus shared (nil owned)
100    /// knowledge. None recalls globally.
101    pub agent_id: Option<AgentId>,
102    /// Restrict recall to this user's memories plus shared (nil owned)
103    /// knowledge, orthogonal to `agent_id`. None recalls globally on the user
104    /// axis. A memory is injectable only when visible on BOTH axes.
105    pub user_id: Option<UserId>,
106}
107
108/// Why an item was selected, for introspection and client display.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub enum SelectionReason {
111    /// User-pinned scope:always memory: bypasses every quality gate.
112    Pinned,
113    /// Survived the relevance knee, MMR, and quotas.
114    Relevant,
115}
116
117/// One injection-ready memory with its selection metadata.
118pub struct InjectionCandidate {
119    pub node: MemoryNode,
120    /// Retrieval score after demotion adjustment (0.0 for pinned items,
121    /// which are not score-ranked).
122    pub score: f32,
123    pub reason: SelectionReason,
124}
125
126fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
127    if a.len() != b.len() || a.is_empty() {
128        return 0.0;
129    }
130    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
131    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
132    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
133    if na == 0.0 || nb == 0.0 {
134        return 0.0;
135    }
136    dot / (na * nb)
137}
138
139fn attr_count(node: &MemoryNode, key: &str) -> i64 {
140    match node.attributes.get(key) {
141        Some(AttributeValue::Integer(n)) => *n,
142        _ => 0,
143    }
144}
145
146fn bump_attr(node: &mut MemoryNode, key: &str) {
147    let next = attr_count(node, key) + 1;
148    node.attributes
149        .insert(key.to_string(), AttributeValue::Integer(next));
150}
151
152fn has_tag(node: &MemoryNode, tag: &str) -> bool {
153    node.tags.iter().any(|t| t == tag)
154}
155
156fn now_us() -> u64 {
157    std::time::SystemTime::now()
158        .duration_since(std::time::UNIX_EPOCH)
159        .unwrap_or_default()
160        .as_micros() as u64
161}
162
163/// Cut a descending score list at its largest relative gap (the relevance
164/// knee), when that gap is decisive. Returns how many items to keep.
165fn knee_cutoff(scores: &[f32], gap_ratio: f32) -> usize {
166    if scores.len() <= 1 {
167        return scores.len();
168    }
169    let mut best_ratio = 0.0f32;
170    let mut cut = scores.len();
171    for i in 0..scores.len() - 1 {
172        let hi = scores[i];
173        let lo = scores[i + 1].max(f32::EPSILON);
174        let ratio = hi / lo;
175        if ratio > best_ratio {
176            best_ratio = ratio;
177            cut = i + 1;
178        }
179    }
180    if best_ratio >= gap_ratio {
181        cut
182    } else {
183        scores.len()
184    }
185}
186
187impl MenteDb {
188    /// Injection-ready context selection. See the module docs for the
189    /// policy; this is the API client hooks should prefer over raw recall.
190    pub fn recall_for_injection(
191        &self,
192        query: &InjectionQuery<'_>,
193    ) -> MenteResult<Vec<InjectionCandidate>> {
194        let cfg = self.cognitive_config.injection_config.clone();
195        let excluded: HashSet<MemoryId> = query.exclude_ids.iter().copied().collect();
196        let session_tag = query.session_id.map(|s| format!("session:{s}"));
197
198        // Candidate pool from hybrid recall.
199        let hits = self
200            .recall_hybrid_scoped_at_mode(
201                query.embedding,
202                query.query_text,
203                cfg.candidate_pool,
204                now_us(),
205                None,
206                false,
207                None,
208                query.agent_id,
209                query.user_id,
210            )
211            .unwrap_or_default();
212
213        let mut scored: Vec<(MemoryNode, f32)> = Vec::new();
214        for (id, score) in hits {
215            if excluded.contains(&id) {
216                continue;
217            }
218            let Ok(node) = self.get_memory(id) else {
219                continue;
220            };
221            if has_tag(&node, "action")
222                || has_tag(&node, "scope:always")
223                || has_tag(&node, "ghost-memory")
224            {
225                // Actions never inject; pinned items are handled separately.
226                // Ghost memories are speculative working material for the
227                // trajectory tracker, not confirmed knowledge; injecting
228                // "Unconfirmed: ..." as if it were a fact misleads.
229                continue;
230            }
231            if let Some(ref st) = session_tag
232                && has_tag(&node, st)
233            {
234                continue;
235            }
236            // Chronically ignored memories fall back in the ranking until
237            // usage or decay resolves them.
238            let shown = attr_count(&node, ATTR_INJECTION_SHOWN);
239            let used = attr_count(&node, ATTR_INJECTION_USED);
240            let adjusted = if shown >= cfg.demotion_shown_min && used == 0 {
241                score * cfg.demotion_factor
242            } else {
243                score
244            };
245            scored.push((node, adjusted));
246        }
247
248        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
249        let scores: Vec<f32> = scored.iter().map(|(_, s)| *s).collect();
250        scored.truncate(knee_cutoff(&scores, cfg.knee_gap_ratio));
251
252        // Associative recall: after the vector-tail knee, fold in 1-hop graph
253        // neighbors of the surviving hits, so a memory linked to a relevant hit
254        // can surface even when it is not itself vector-similar to the query.
255        // Runs after the knee (which is for weak vector tails, a different signal
256        // than an explicit edge) so a neighbor's decayed score is not mistaken
257        // for a tail and cut. Disabled by default (graph_expansion_max == 0),
258        // leaving pure vector/hybrid recall intact. Neighbors inherit a decayed
259        // share of the hit's score so they never outrank direct hits, pass the
260        // same gates, and are bounded by graph_expansion_max; MMR then picks the
261        // final set within max_items.
262        if cfg.graph_expansion_max > 0 && !scored.is_empty() {
263            let now = now_us();
264            let mut present: HashSet<MemoryId> = scored.iter().map(|(n, _)| n.id).collect();
265            present.extend(excluded.iter().copied());
266            let seeds: Vec<(MemoryId, f32)> = scored.iter().map(|(n, s)| (n.id, *s)).collect();
267
268            let g = self.graph.graph();
269            let mut added = 0usize;
270            'seeds: for (seed_id, seed_score) in seeds {
271                let mut edges = g.outgoing(seed_id);
272                edges.extend(g.incoming(seed_id));
273                for (nbr_id, edge) in edges {
274                    if added >= cfg.graph_expansion_max {
275                        break 'seeds;
276                    }
277                    if present.contains(&nbr_id) {
278                        continue;
279                    }
280                    let Ok(node) = self.get_memory(nbr_id) else {
281                        continue;
282                    };
283                    if has_tag(&node, "action")
284                        || has_tag(&node, "scope:always")
285                        || has_tag(&node, "ghost-memory")
286                        || !node.is_valid_at(now)
287                        || !crate::agent_visible(node.agent_id, query.agent_id)
288                        || !crate::user_visible(node.user_id, query.user_id)
289                    {
290                        continue;
291                    }
292                    if let Some(ref st) = session_tag
293                        && has_tag(&node, st)
294                    {
295                        continue;
296                    }
297                    let score = seed_score * edge.weight * cfg.graph_expansion_decay;
298                    present.insert(nbr_id);
299                    scored.push((node, score));
300                    added += 1;
301                }
302            }
303        }
304
305        // MMR selection with type quotas.
306        let top_score = scored
307            .first()
308            .map(|(_, s)| *s)
309            .unwrap_or(1.0)
310            .max(f32::EPSILON);
311        let mut remaining: Vec<(MemoryNode, f32)> = scored;
312        let mut selected: Vec<InjectionCandidate> = Vec::new();
313        let mut episodic_count = 0usize;
314
315        while selected.len() < query.max_items && !remaining.is_empty() {
316            let mut best_idx: Option<usize> = None;
317            let mut best_value = f32::NEG_INFINITY;
318            for (idx, (node, score)) in remaining.iter().enumerate() {
319                if node.memory_type == MemoryType::Episodic && episodic_count >= query.max_episodic
320                {
321                    continue;
322                }
323                let redundancy = selected
324                    .iter()
325                    .map(|s| cosine_similarity(&node.embedding, &s.node.embedding))
326                    .fold(0.0f32, f32::max);
327                let value =
328                    cfg.mmr_lambda * (score / top_score) - (1.0 - cfg.mmr_lambda) * redundancy;
329                if value > best_value {
330                    best_value = value;
331                    best_idx = Some(idx);
332                }
333            }
334            let Some(idx) = best_idx else {
335                break;
336            };
337            let (node, score) = remaining.remove(idx);
338            if node.memory_type == MemoryType::Episodic {
339                episodic_count += 1;
340            }
341            selected.push(InjectionCandidate {
342                node,
343                score,
344                reason: SelectionReason::Relevant,
345            });
346        }
347
348        // Pinned memories bypass every gate except the ledger, and lead the
349        // result. The client's ledger reset (at context loss) governs
350        // re-delivery.
351        let mut result: Vec<InjectionCandidate> = Vec::new();
352        let page_ids: Vec<_> = {
353            let pm = self.page_map.read();
354            pm.values().copied().collect()
355        };
356        for pid in page_ids {
357            if let Ok(node) = self.storage.load_memory(pid)
358                && has_tag(&node, "scope:always")
359                && !excluded.contains(&node.id)
360                && crate::agent_visible(node.agent_id, query.agent_id)
361                && crate::user_visible(node.user_id, query.user_id)
362            {
363                result.push(InjectionCandidate {
364                    node,
365                    score: 0.0,
366                    reason: SelectionReason::Pinned,
367                });
368            }
369        }
370        result.extend(selected);
371        Ok(result)
372    }
373
374    /// Record the outcome of an injection: every shown memory's exposure
375    /// count rises, and memories the reply actually drew on (embedding
376    /// similarity against the reply) are counted as used and reinforced.
377    /// Returns (shown_updated, used_count).
378    pub fn record_injection_outcome(
379        &self,
380        shown: &[MemoryId],
381        reply_embedding: Option<&[f32]>,
382    ) -> MenteResult<(usize, usize)> {
383        let cfg = self.cognitive_config.injection_config.clone();
384        let mut updated = 0usize;
385        let mut used_total = 0usize;
386        let now = now_us();
387
388        for id in shown {
389            let pid = {
390                let pm = self.page_map.read();
391                match pm.get(id) {
392                    Some(p) => *p,
393                    None => continue,
394                }
395            };
396            let Ok(mut node) = self.storage.load_memory(pid) else {
397                continue;
398            };
399            bump_attr(&mut node, ATTR_INJECTION_SHOWN);
400
401            // Retrieval reinforcement: being surfaced at all is an access. Refresh
402            // the decay clock and bump the access count for every shown memory, not
403            // only the ones the reply echoed, so anything that keeps getting
404            // recalled stays alive instead of decaying to the forget threshold while
405            // it is actively in use. Memories that are never recalled still get no
406            // touch here, so they decay and are forgotten as intended; only what
407            // retrieval keeps surfacing survives.
408            node.access_count = node.access_count.saturating_add(1);
409            node.accessed_at = now;
410
411            let used = reply_embedding
412                .map(|re| cosine_similarity(&node.embedding, re) >= cfg.used_similarity)
413                .unwrap_or(false);
414            if used {
415                // The reply actually drew on it: a stronger signal than mere
416                // exposure, so add a salience boost on top of the access refresh.
417                bump_attr(&mut node, ATTR_INJECTION_USED);
418                node.salience = (node.salience + cfg.use_reinforcement).min(1.0);
419                used_total += 1;
420            }
421
422            // Counter updates must not re-run write inference; write the
423            // node in place.
424            self.storage.update_memory(pid, &node)?;
425            updated += 1;
426        }
427        Ok((updated, used_total))
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use mentedb_core::types::AgentId;
435    use uuid::Uuid;
436
437    #[test]
438    fn knee_cuts_at_largest_gap() {
439        let ratio = InjectionConfig::default().knee_gap_ratio;
440        assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.1, 0.09], ratio), 3);
441        // No decisive gap: keep everything.
442        assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.7], ratio), 4);
443        assert_eq!(knee_cutoff(&[1.0], ratio), 1);
444        assert_eq!(knee_cutoff(&[], ratio), 0);
445    }
446
447    #[test]
448    fn attr_counters_roundtrip() {
449        let mut node = MemoryNode::new(
450            AgentId(Uuid::nil()),
451            MemoryType::Semantic,
452            "x".into(),
453            vec![0.0; 4],
454        );
455        assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 0);
456        bump_attr(&mut node, ATTR_INJECTION_SHOWN);
457        bump_attr(&mut node, ATTR_INJECTION_SHOWN);
458        assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 2);
459    }
460}