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}