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    /// Current project (a `scope:project:<name>` value without the prefix). When
107    /// set, memories from other projects are weighted down in recall so injection
108    /// favors the active project instead of dumping cross-project noise. None
109    /// recalls across all projects.
110    pub current_project: Option<&'a str>,
111}
112
113/// Why an item was selected, for introspection and client display.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum SelectionReason {
116    /// User-pinned scope:always memory: bypasses every quality gate.
117    Pinned,
118    /// Survived the relevance knee, MMR, and quotas.
119    Relevant,
120}
121
122/// One injection-ready memory with its selection metadata.
123pub struct InjectionCandidate {
124    pub node: MemoryNode,
125    /// Retrieval score after demotion adjustment (0.0 for pinned items,
126    /// which are not score-ranked).
127    pub score: f32,
128    pub reason: SelectionReason,
129}
130
131fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
132    if a.len() != b.len() || a.is_empty() {
133        return 0.0;
134    }
135    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
136    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
137    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
138    if na == 0.0 || nb == 0.0 {
139        return 0.0;
140    }
141    dot / (na * nb)
142}
143
144fn attr_count(node: &MemoryNode, key: &str) -> i64 {
145    match node.attributes.get(key) {
146        Some(AttributeValue::Integer(n)) => *n,
147        _ => 0,
148    }
149}
150
151fn bump_attr(node: &mut MemoryNode, key: &str) {
152    let next = attr_count(node, key) + 1;
153    node.attributes
154        .insert(key.to_string(), AttributeValue::Integer(next));
155}
156
157fn has_tag(node: &MemoryNode, tag: &str) -> bool {
158    node.tags.iter().any(|t| t == tag)
159}
160
161fn now_us() -> u64 {
162    std::time::SystemTime::now()
163        .duration_since(std::time::UNIX_EPOCH)
164        .unwrap_or_default()
165        .as_micros() as u64
166}
167
168/// Cut a descending score list at its largest relative gap (the relevance
169/// knee), when that gap is decisive. Returns how many items to keep.
170fn knee_cutoff(scores: &[f32], gap_ratio: f32) -> usize {
171    if scores.len() <= 1 {
172        return scores.len();
173    }
174    let mut best_ratio = 0.0f32;
175    let mut cut = scores.len();
176    for i in 0..scores.len() - 1 {
177        let hi = scores[i];
178        let lo = scores[i + 1].max(f32::EPSILON);
179        let ratio = hi / lo;
180        if ratio > best_ratio {
181            best_ratio = ratio;
182            cut = i + 1;
183        }
184    }
185    if best_ratio >= gap_ratio {
186        cut
187    } else {
188        scores.len()
189    }
190}
191
192impl MenteDb {
193    /// Injection-ready context selection. See the module docs for the
194    /// policy; this is the API client hooks should prefer over raw recall.
195    pub fn recall_for_injection(
196        &self,
197        query: &InjectionQuery<'_>,
198    ) -> MenteResult<Vec<InjectionCandidate>> {
199        let cfg = self.cognitive_config.injection_config.clone();
200        let excluded: HashSet<MemoryId> = query.exclude_ids.iter().copied().collect();
201        let session_tag = query.session_id.map(|s| format!("session:{s}"));
202
203        // Candidate pool from hybrid recall.
204        let hits = self
205            .recall_hybrid_scoped_at_mode(
206                query.embedding,
207                query.query_text,
208                cfg.candidate_pool,
209                now_us(),
210                None,
211                false,
212                None,
213                query.agent_id,
214                query.user_id,
215                query.current_project,
216            )
217            .unwrap_or_default();
218
219        let mut scored: Vec<(MemoryNode, f32)> = Vec::new();
220        for (id, score) in hits {
221            if excluded.contains(&id) {
222                continue;
223            }
224            let Ok(node) = self.get_memory(id) else {
225                continue;
226            };
227            if has_tag(&node, "action")
228                || has_tag(&node, "scope:always")
229                || has_tag(&node, "ghost-memory")
230            {
231                // Actions never inject; pinned items are handled separately.
232                // Ghost memories are speculative working material for the
233                // trajectory tracker, not confirmed knowledge; injecting
234                // "Unconfirmed: ..." as if it were a fact misleads.
235                continue;
236            }
237            if let Some(ref st) = session_tag
238                && has_tag(&node, st)
239            {
240                continue;
241            }
242            // Chronically ignored memories fall back in the ranking until
243            // usage or decay resolves them.
244            let shown = attr_count(&node, ATTR_INJECTION_SHOWN);
245            let used = attr_count(&node, ATTR_INJECTION_USED);
246            let adjusted = if shown >= cfg.demotion_shown_min && used == 0 {
247                score * cfg.demotion_factor
248            } else {
249                score
250            };
251            scored.push((node, adjusted));
252        }
253
254        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
255        let scores: Vec<f32> = scored.iter().map(|(_, s)| *s).collect();
256        scored.truncate(knee_cutoff(&scores, cfg.knee_gap_ratio));
257
258        // Associative recall: after the vector-tail knee, fold in 1-hop graph
259        // neighbors of the surviving hits, so a memory linked to a relevant hit
260        // can surface even when it is not itself vector-similar to the query.
261        // Runs after the knee (which is for weak vector tails, a different signal
262        // than an explicit edge) so a neighbor's decayed score is not mistaken
263        // for a tail and cut. Disabled by default (graph_expansion_max == 0),
264        // leaving pure vector/hybrid recall intact. Neighbors inherit a decayed
265        // share of the hit's score so they never outrank direct hits, pass the
266        // same gates, and are bounded by graph_expansion_max; MMR then picks the
267        // final set within max_items.
268        if cfg.graph_expansion_max > 0 && !scored.is_empty() {
269            let now = now_us();
270            let mut present: HashSet<MemoryId> = scored.iter().map(|(n, _)| n.id).collect();
271            present.extend(excluded.iter().copied());
272            let seeds: Vec<(MemoryId, f32)> = scored.iter().map(|(n, s)| (n.id, *s)).collect();
273
274            let g = self.graph.graph();
275            let mut added = 0usize;
276            'seeds: for (seed_id, seed_score) in seeds {
277                let mut edges = g.outgoing(seed_id);
278                edges.extend(g.incoming(seed_id));
279                for (nbr_id, edge) in edges {
280                    if added >= cfg.graph_expansion_max {
281                        break 'seeds;
282                    }
283                    if present.contains(&nbr_id) {
284                        continue;
285                    }
286                    let Ok(node) = self.get_memory(nbr_id) else {
287                        continue;
288                    };
289                    if has_tag(&node, "action")
290                        || has_tag(&node, "scope:always")
291                        || has_tag(&node, "ghost-memory")
292                        || !node.is_valid_at(now)
293                        || !crate::agent_visible(node.agent_id, query.agent_id)
294                        || !crate::user_visible(node.user_id, query.user_id)
295                    {
296                        continue;
297                    }
298                    if let Some(ref st) = session_tag
299                        && has_tag(&node, st)
300                    {
301                        continue;
302                    }
303                    let score = seed_score * edge.weight * cfg.graph_expansion_decay;
304                    present.insert(nbr_id);
305                    scored.push((node, score));
306                    added += 1;
307                }
308            }
309        }
310
311        // MMR selection with type quotas.
312        let top_score = scored
313            .first()
314            .map(|(_, s)| *s)
315            .unwrap_or(1.0)
316            .max(f32::EPSILON);
317        let mut remaining: Vec<(MemoryNode, f32)> = scored;
318        let mut selected: Vec<InjectionCandidate> = Vec::new();
319        let mut episodic_count = 0usize;
320
321        while selected.len() < query.max_items && !remaining.is_empty() {
322            let mut best_idx: Option<usize> = None;
323            let mut best_value = f32::NEG_INFINITY;
324            for (idx, (node, score)) in remaining.iter().enumerate() {
325                if node.memory_type == MemoryType::Episodic && episodic_count >= query.max_episodic
326                {
327                    continue;
328                }
329                let redundancy = selected
330                    .iter()
331                    .map(|s| cosine_similarity(&node.embedding, &s.node.embedding))
332                    .fold(0.0f32, f32::max);
333                let value =
334                    cfg.mmr_lambda * (score / top_score) - (1.0 - cfg.mmr_lambda) * redundancy;
335                if value > best_value {
336                    best_value = value;
337                    best_idx = Some(idx);
338                }
339            }
340            let Some(idx) = best_idx else {
341                break;
342            };
343            let (node, score) = remaining.remove(idx);
344            if node.memory_type == MemoryType::Episodic {
345                episodic_count += 1;
346            }
347            selected.push(InjectionCandidate {
348                node,
349                score,
350                reason: SelectionReason::Relevant,
351            });
352        }
353
354        // Pinned memories bypass every gate except the ledger, and lead the
355        // result. The client's ledger reset (at context loss) governs
356        // re-delivery.
357        let mut result: Vec<InjectionCandidate> = Vec::new();
358        let page_ids: Vec<_> = {
359            let pm = self.page_map.read();
360            pm.values().copied().collect()
361        };
362        for pid in page_ids {
363            if let Ok(node) = self.storage.load_memory(pid)
364                && has_tag(&node, "scope:always")
365                && !excluded.contains(&node.id)
366                && crate::agent_visible(node.agent_id, query.agent_id)
367                && crate::user_visible(node.user_id, query.user_id)
368            {
369                result.push(InjectionCandidate {
370                    node,
371                    score: 0.0,
372                    reason: SelectionReason::Pinned,
373                });
374            }
375        }
376        result.extend(selected);
377        Ok(result)
378    }
379
380    /// Record the outcome of an injection: every shown memory's exposure
381    /// count rises, and memories the reply actually drew on (embedding
382    /// similarity against the reply) are counted as used and reinforced.
383    /// Returns (shown_updated, used_count).
384    pub fn record_injection_outcome(
385        &self,
386        shown: &[MemoryId],
387        reply_embedding: Option<&[f32]>,
388    ) -> MenteResult<(usize, usize)> {
389        let cfg = self.cognitive_config.injection_config.clone();
390        let mut updated = 0usize;
391        let mut used_total = 0usize;
392        let now = now_us();
393
394        for id in shown {
395            let pid = {
396                let pm = self.page_map.read();
397                match pm.get(id) {
398                    Some(p) => *p,
399                    None => continue,
400                }
401            };
402            let Ok(mut node) = self.storage.load_memory(pid) else {
403                continue;
404            };
405            bump_attr(&mut node, ATTR_INJECTION_SHOWN);
406
407            // Retrieval reinforcement: being surfaced at all is an access. Refresh
408            // the decay clock and bump the access count for every shown memory, not
409            // only the ones the reply echoed, so anything that keeps getting
410            // recalled stays alive instead of decaying to the forget threshold while
411            // it is actively in use. Memories that are never recalled still get no
412            // touch here, so they decay and are forgotten as intended; only what
413            // retrieval keeps surfacing survives.
414            node.access_count = node.access_count.saturating_add(1);
415            node.accessed_at = now;
416
417            let used = reply_embedding
418                .map(|re| cosine_similarity(&node.embedding, re) >= cfg.used_similarity)
419                .unwrap_or(false);
420            if used {
421                // The reply actually drew on it: a stronger signal than mere
422                // exposure, so add a salience boost on top of the access refresh.
423                bump_attr(&mut node, ATTR_INJECTION_USED);
424                node.salience = (node.salience + cfg.use_reinforcement).min(1.0);
425                used_total += 1;
426            }
427
428            // Counter updates must not re-run write inference; write the
429            // node in place.
430            self.storage.update_memory(pid, &node)?;
431            updated += 1;
432        }
433        Ok((updated, used_total))
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use mentedb_core::types::AgentId;
441    use uuid::Uuid;
442
443    #[test]
444    fn knee_cuts_at_largest_gap() {
445        let ratio = InjectionConfig::default().knee_gap_ratio;
446        assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.1, 0.09], ratio), 3);
447        // No decisive gap: keep everything.
448        assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.7], ratio), 4);
449        assert_eq!(knee_cutoff(&[1.0], ratio), 1);
450        assert_eq!(knee_cutoff(&[], ratio), 0);
451    }
452
453    #[test]
454    fn attr_counters_roundtrip() {
455        let mut node = MemoryNode::new(
456            AgentId(Uuid::nil()),
457            MemoryType::Semantic,
458            "x".into(),
459            vec![0.0; 4],
460        );
461        assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 0);
462        bump_attr(&mut node, ATTR_INJECTION_SHOWN);
463        bump_attr(&mut node, ATTR_INJECTION_SHOWN);
464        assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 2);
465    }
466}