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}