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}
58
59impl Default for InjectionConfig {
60    fn default() -> Self {
61        Self {
62            candidate_pool: 48,
63            mmr_lambda: 0.7,
64            knee_gap_ratio: 2.0,
65            demotion_shown_min: 5,
66            demotion_factor: 0.5,
67            used_similarity: 0.6,
68            use_reinforcement: 0.05,
69        }
70    }
71}
72
73/// A request for injection-ready context.
74pub struct InjectionQuery<'a> {
75    /// Embedding of the prompt (plus any conversational blend).
76    pub embedding: &'a [f32],
77    /// Raw query text for the lexical half of hybrid recall.
78    pub query_text: Option<&'a str>,
79    /// The session issuing the query. Memories tagged `session:<id>` with
80    /// this ID are excluded: they are already in that session's context.
81    pub session_id: Option<&'a str>,
82    /// Memory IDs already delivered within the current context lifetime.
83    pub exclude_ids: &'a [MemoryId],
84    /// Maximum items returned beyond pinned memories.
85    pub max_items: usize,
86    /// Maximum verbatim episodic items within `max_items`.
87    pub max_episodic: usize,
88    /// Restrict recall to this agent's memories plus shared (nil owned)
89    /// knowledge. None recalls globally.
90    pub agent_id: Option<AgentId>,
91    /// Restrict recall to this user's memories plus shared (nil owned)
92    /// knowledge, orthogonal to `agent_id`. None recalls globally on the user
93    /// axis. A memory is injectable only when visible on BOTH axes.
94    pub user_id: Option<UserId>,
95}
96
97/// Why an item was selected, for introspection and client display.
98#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SelectionReason {
100    /// User-pinned scope:always memory: bypasses every quality gate.
101    Pinned,
102    /// Survived the relevance knee, MMR, and quotas.
103    Relevant,
104}
105
106/// One injection-ready memory with its selection metadata.
107pub struct InjectionCandidate {
108    pub node: MemoryNode,
109    /// Retrieval score after demotion adjustment (0.0 for pinned items,
110    /// which are not score-ranked).
111    pub score: f32,
112    pub reason: SelectionReason,
113}
114
115fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
116    if a.len() != b.len() || a.is_empty() {
117        return 0.0;
118    }
119    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
120    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
121    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
122    if na == 0.0 || nb == 0.0 {
123        return 0.0;
124    }
125    dot / (na * nb)
126}
127
128fn attr_count(node: &MemoryNode, key: &str) -> i64 {
129    match node.attributes.get(key) {
130        Some(AttributeValue::Integer(n)) => *n,
131        _ => 0,
132    }
133}
134
135fn bump_attr(node: &mut MemoryNode, key: &str) {
136    let next = attr_count(node, key) + 1;
137    node.attributes
138        .insert(key.to_string(), AttributeValue::Integer(next));
139}
140
141fn has_tag(node: &MemoryNode, tag: &str) -> bool {
142    node.tags.iter().any(|t| t == tag)
143}
144
145fn now_us() -> u64 {
146    std::time::SystemTime::now()
147        .duration_since(std::time::UNIX_EPOCH)
148        .unwrap_or_default()
149        .as_micros() as u64
150}
151
152/// Cut a descending score list at its largest relative gap (the relevance
153/// knee), when that gap is decisive. Returns how many items to keep.
154fn knee_cutoff(scores: &[f32], gap_ratio: f32) -> usize {
155    if scores.len() <= 1 {
156        return scores.len();
157    }
158    let mut best_ratio = 0.0f32;
159    let mut cut = scores.len();
160    for i in 0..scores.len() - 1 {
161        let hi = scores[i];
162        let lo = scores[i + 1].max(f32::EPSILON);
163        let ratio = hi / lo;
164        if ratio > best_ratio {
165            best_ratio = ratio;
166            cut = i + 1;
167        }
168    }
169    if best_ratio >= gap_ratio {
170        cut
171    } else {
172        scores.len()
173    }
174}
175
176impl MenteDb {
177    /// Injection-ready context selection. See the module docs for the
178    /// policy; this is the API client hooks should prefer over raw recall.
179    pub fn recall_for_injection(
180        &self,
181        query: &InjectionQuery<'_>,
182    ) -> MenteResult<Vec<InjectionCandidate>> {
183        let cfg = self.cognitive_config.injection_config.clone();
184        let excluded: HashSet<MemoryId> = query.exclude_ids.iter().copied().collect();
185        let session_tag = query.session_id.map(|s| format!("session:{s}"));
186
187        // Candidate pool from hybrid recall.
188        let hits = self
189            .recall_hybrid_scoped_at_mode(
190                query.embedding,
191                query.query_text,
192                cfg.candidate_pool,
193                now_us(),
194                None,
195                false,
196                None,
197                query.agent_id,
198                query.user_id,
199            )
200            .unwrap_or_default();
201
202        let mut scored: Vec<(MemoryNode, f32)> = Vec::new();
203        for (id, score) in hits {
204            if excluded.contains(&id) {
205                continue;
206            }
207            let Ok(node) = self.get_memory(id) else {
208                continue;
209            };
210            if has_tag(&node, "action")
211                || has_tag(&node, "scope:always")
212                || has_tag(&node, "ghost-memory")
213            {
214                // Actions never inject; pinned items are handled separately.
215                // Ghost memories are speculative working material for the
216                // trajectory tracker, not confirmed knowledge; injecting
217                // "Unconfirmed: ..." as if it were a fact misleads.
218                continue;
219            }
220            if let Some(ref st) = session_tag
221                && has_tag(&node, st)
222            {
223                continue;
224            }
225            // Chronically ignored memories fall back in the ranking until
226            // usage or decay resolves them.
227            let shown = attr_count(&node, ATTR_INJECTION_SHOWN);
228            let used = attr_count(&node, ATTR_INJECTION_USED);
229            let adjusted = if shown >= cfg.demotion_shown_min && used == 0 {
230                score * cfg.demotion_factor
231            } else {
232                score
233            };
234            scored.push((node, adjusted));
235        }
236
237        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
238        let scores: Vec<f32> = scored.iter().map(|(_, s)| *s).collect();
239        scored.truncate(knee_cutoff(&scores, cfg.knee_gap_ratio));
240
241        // MMR selection with type quotas.
242        let top_score = scored
243            .first()
244            .map(|(_, s)| *s)
245            .unwrap_or(1.0)
246            .max(f32::EPSILON);
247        let mut remaining: Vec<(MemoryNode, f32)> = scored;
248        let mut selected: Vec<InjectionCandidate> = Vec::new();
249        let mut episodic_count = 0usize;
250
251        while selected.len() < query.max_items && !remaining.is_empty() {
252            let mut best_idx: Option<usize> = None;
253            let mut best_value = f32::NEG_INFINITY;
254            for (idx, (node, score)) in remaining.iter().enumerate() {
255                if node.memory_type == MemoryType::Episodic && episodic_count >= query.max_episodic
256                {
257                    continue;
258                }
259                let redundancy = selected
260                    .iter()
261                    .map(|s| cosine_similarity(&node.embedding, &s.node.embedding))
262                    .fold(0.0f32, f32::max);
263                let value =
264                    cfg.mmr_lambda * (score / top_score) - (1.0 - cfg.mmr_lambda) * redundancy;
265                if value > best_value {
266                    best_value = value;
267                    best_idx = Some(idx);
268                }
269            }
270            let Some(idx) = best_idx else {
271                break;
272            };
273            let (node, score) = remaining.remove(idx);
274            if node.memory_type == MemoryType::Episodic {
275                episodic_count += 1;
276            }
277            selected.push(InjectionCandidate {
278                node,
279                score,
280                reason: SelectionReason::Relevant,
281            });
282        }
283
284        // Pinned memories bypass every gate except the ledger, and lead the
285        // result. The client's ledger reset (at context loss) governs
286        // re-delivery.
287        let mut result: Vec<InjectionCandidate> = Vec::new();
288        let page_ids: Vec<_> = {
289            let pm = self.page_map.read();
290            pm.values().copied().collect()
291        };
292        for pid in page_ids {
293            if let Ok(node) = self.storage.load_memory(pid)
294                && has_tag(&node, "scope:always")
295                && !excluded.contains(&node.id)
296                && crate::agent_visible(node.agent_id, query.agent_id)
297                && crate::user_visible(node.user_id, query.user_id)
298            {
299                result.push(InjectionCandidate {
300                    node,
301                    score: 0.0,
302                    reason: SelectionReason::Pinned,
303                });
304            }
305        }
306        result.extend(selected);
307        Ok(result)
308    }
309
310    /// Record the outcome of an injection: every shown memory's exposure
311    /// count rises, and memories the reply actually drew on (embedding
312    /// similarity against the reply) are counted as used and reinforced.
313    /// Returns (shown_updated, used_count).
314    pub fn record_injection_outcome(
315        &self,
316        shown: &[MemoryId],
317        reply_embedding: Option<&[f32]>,
318    ) -> MenteResult<(usize, usize)> {
319        let cfg = self.cognitive_config.injection_config.clone();
320        let mut updated = 0usize;
321        let mut used_total = 0usize;
322        let now = now_us();
323
324        for id in shown {
325            let pid = {
326                let pm = self.page_map.read();
327                match pm.get(id) {
328                    Some(p) => *p,
329                    None => continue,
330                }
331            };
332            let Ok(mut node) = self.storage.load_memory(pid) else {
333                continue;
334            };
335            bump_attr(&mut node, ATTR_INJECTION_SHOWN);
336
337            let used = reply_embedding
338                .map(|re| cosine_similarity(&node.embedding, re) >= cfg.used_similarity)
339                .unwrap_or(false);
340            if used {
341                bump_attr(&mut node, ATTR_INJECTION_USED);
342                node.access_count = node.access_count.saturating_add(1);
343                node.accessed_at = now;
344                node.salience = (node.salience + cfg.use_reinforcement).min(1.0);
345                used_total += 1;
346            }
347
348            // Counter updates must not re-run write inference; write the
349            // node in place.
350            self.storage.update_memory(pid, &node)?;
351            updated += 1;
352        }
353        Ok((updated, used_total))
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use mentedb_core::types::AgentId;
361    use uuid::Uuid;
362
363    #[test]
364    fn knee_cuts_at_largest_gap() {
365        let ratio = InjectionConfig::default().knee_gap_ratio;
366        assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.1, 0.09], ratio), 3);
367        // No decisive gap: keep everything.
368        assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.7], ratio), 4);
369        assert_eq!(knee_cutoff(&[1.0], ratio), 1);
370        assert_eq!(knee_cutoff(&[], ratio), 0);
371    }
372
373    #[test]
374    fn attr_counters_roundtrip() {
375        let mut node = MemoryNode::new(
376            AgentId(Uuid::nil()),
377            MemoryType::Semantic,
378            "x".into(),
379            vec![0.0; 4],
380        );
381        assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 0);
382        bump_attr(&mut node, ATTR_INJECTION_SHOWN);
383        bump_attr(&mut node, ATTR_INJECTION_SHOWN);
384        assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 2);
385    }
386}