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::{HashMap, 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 /// Cluster completion: when one section tag dominates the selected
46 /// head (at least this fraction of it, minimum two items), the query
47 /// lives inside that cluster, and the lowest ranked non cluster picks
48 /// are swapped for the section's best remaining siblings. A dense
49 /// cluster of sibling rules then arrives together instead of the top
50 /// sibling crowding the rest out. 0 disables. Off by default until the
51 /// offline sweep settles interactions with MMR and the knee and tail
52 /// gates; the mechanism is pinned by tests at 0.34.
53 pub cluster_dominant_fraction: f32,
54 /// Cap on siblings swapped in by cluster completion.
55 pub cluster_fill_max: usize,
56 /// Sections larger than this never count as clusters: a catch all
57 /// section holding half the file dominates any head trivially and its
58 /// siblings are arbitrary, not coherent.
59 pub cluster_max_span: usize,
60 /// Whether injection's hybrid recall includes the lexical (BM25) leg.
61 /// On rule stores the lexical leg can drown the vector signal: generic
62 /// task words match everywhere and rank fusion buries the semantically
63 /// right rule. Off sends vector only retrieval into the same pipeline.
64 pub use_lexical_leg: bool,
65 /// Score multiplier for memories owned by the querying agent. On an
66 /// agent scoped query the agent's own knowledge (its ingested rules,
67 /// its facts) must not be crowded out of the candidate ranking by the
68 /// account's shared nil owned memories; measured on a busy account,
69 /// shared memories otherwise flood the relevant slots. 1.0 disables.
70 pub own_agent_boost: f32,
71 /// Score multiplier for a memory tagged `escalated:violation`: a standing
72 /// rule the agent was shown and then broke. Escalation exists so a rule
73 /// that was violated once surfaces at the FRONT the next time its context
74 /// recurs, not buried mid-list where it was ignored. Without this the
75 /// escalation only bumps salience, which injection does not rank on, so the
76 /// correction would not stick. 1.0 disables.
77 pub escalation_boost: f32,
78 /// The boost applies only to an escalated rule whose cosine is at least
79 /// this fraction of the query's best cosine, so escalation reorders rules
80 /// that are genuinely on topic and never drags an unrelated escalated rule
81 /// into the context on the strength of the boost alone.
82 pub escalation_min_relevance: f32,
83 /// MMR relevance weight; the remainder weighs redundancy.
84 pub mmr_lambda: f32,
85 /// Consecutive score ratio treated as the relevance knee.
86 pub knee_gap_ratio: f32,
87 /// Shown at least this often with zero uses = demoted at selection.
88 pub demotion_shown_min: i64,
89 /// Score multiplier applied to chronically ignored memories.
90 pub demotion_factor: f32,
91 /// Shown at least this often with zero uses = excluded from injection
92 /// entirely (still reachable through ordinary recall). Demotion halves a
93 /// score; this is the terminal stage for memories the model never draws on.
94 pub demotion_drop_shown: i64,
95 /// Head size exempt from the semantic tail gate. The top RRF ranks are
96 /// trusted as-is because a pure keyword hit (exact identifier, phone
97 /// number) can carry a low cosine yet be exactly right.
98 pub tail_min_keep: usize,
99 /// Beyond the head, a candidate must reach this fraction of the best
100 /// query cosine among candidates or it is dropped. Relative to the query's
101 /// own score distribution, never an absolute cosine, so it holds across
102 /// embedders. This is what keeps a weak tail silent instead of letting
103 /// selection fill its quota with plausible-but-irrelevant memories.
104 pub tail_floor_fraction: f32,
105 /// Whether episodic memories tagged to another project may inject when a
106 /// current project is set. Episodic turns are working context of the
107 /// project they happened in; injecting them elsewhere is the classic
108 /// cross-context contamination users disable memory over.
109 pub cross_project_episodic: bool,
110 /// A non-episodic memory tagged to another project must reach this
111 /// fraction of the best query cosine to inject cross-project. Facts and
112 /// preferences may travel, but only when they are among the most relevant
113 /// candidates for the query, not as tail filler.
114 pub cross_project_floor_fraction: f32,
115 /// Reply similarity above which a shown memory counts as used.
116 pub used_similarity: f32,
117 /// Salience reinforcement applied when a memory is actually used.
118 pub use_reinforcement: f32,
119 /// Associative recall: how many 1-hop graph neighbors of the top vector hits
120 /// to fold into the candidate pool, so a memory linked to a strong hit can
121 /// surface even when it is not itself vector-similar to the query. 0 (the
122 /// default) disables expansion, leaving pure vector/hybrid recall unchanged.
123 pub graph_expansion_max: usize,
124 /// Score a neighbor inherits from the hit it was reached through, as a
125 /// fraction of that hit's score times the edge weight. Keeps neighbors below
126 /// direct hits so they only win a slot when there is room after them.
127 pub graph_expansion_decay: f32,
128 /// Mode rules: standing directives bound to an activity (working on
129 /// code, composing a reply) carry `trigger:` tags, and a trigger is
130 /// active for a turn when the turn's embedding lands within this cosine
131 /// of one of that trigger's own rules. Self defining and cognitive,
132 /// the rules are their own anchors, no pattern matching anywhere.
133 /// Embedder coupled like every absolute gate. 0 disables the channel.
134 pub mode_trigger_gate: f32,
135 /// Maximum mode rules injected per turn across all active triggers.
136 pub mode_rules_max: usize,
137}
138
139impl Default for InjectionConfig {
140 fn default() -> Self {
141 Self {
142 candidate_pool: 48,
143 cluster_dominant_fraction: 0.0,
144 cluster_fill_max: 4,
145 cluster_max_span: 12,
146 use_lexical_leg: true,
147 own_agent_boost: 1.25,
148 escalation_boost: 2.5,
149 escalation_min_relevance: 0.5,
150 mmr_lambda: 0.7,
151 knee_gap_ratio: 2.0,
152 demotion_shown_min: 5,
153 demotion_factor: 0.5,
154 demotion_drop_shown: 12,
155 tail_min_keep: 3,
156 tail_floor_fraction: 0.55,
157 cross_project_episodic: false,
158 cross_project_floor_fraction: 0.8,
159 used_similarity: 0.6,
160 use_reinforcement: 0.05,
161 graph_expansion_max: 0,
162 graph_expansion_decay: 0.5,
163 mode_trigger_gate: 0.40,
164 mode_rules_max: 4,
165 }
166 }
167}
168
169/// A request for injection-ready context.
170pub struct InjectionQuery<'a> {
171 /// Embedding of the prompt (plus any conversational blend).
172 pub embedding: &'a [f32],
173 /// Raw query text for the lexical half of hybrid recall.
174 pub query_text: Option<&'a str>,
175 /// The session issuing the query. Memories tagged `session:<id>` with
176 /// this ID are excluded: they are already in that session's context.
177 pub session_id: Option<&'a str>,
178 /// Memory IDs already delivered within the current context lifetime.
179 pub exclude_ids: &'a [MemoryId],
180 /// Maximum items returned beyond pinned memories.
181 pub max_items: usize,
182 /// Maximum verbatim episodic items within `max_items`.
183 pub max_episodic: usize,
184 /// Restrict recall to this agent's memories plus shared (nil owned)
185 /// knowledge. None recalls globally.
186 pub agent_id: Option<AgentId>,
187 /// Restrict recall to this user's memories plus shared (nil owned)
188 /// knowledge, orthogonal to `agent_id`. None recalls globally on the user
189 /// axis. A memory is injectable only when visible on BOTH axes.
190 pub user_id: Option<UserId>,
191 /// Current project (a `scope:project:<name>` value without the prefix). When
192 /// set, memories from other projects are weighted down in recall so injection
193 /// favors the active project instead of dumping cross-project noise. None
194 /// recalls across all projects.
195 pub current_project: Option<&'a str>,
196}
197
198/// Why an item was selected, for introspection and client display.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum SelectionReason {
201 /// User-pinned scope:always memory: bypasses every quality gate.
202 Pinned,
203 /// Survived the relevance knee, MMR, and quotas.
204 Relevant,
205 /// A standing directive whose activity mode is active for this turn:
206 /// the turn's embedding landed within the mode gate of the rule.
207 ModeRule,
208}
209
210/// One injection-ready memory with its selection metadata.
211pub struct InjectionCandidate {
212 pub node: MemoryNode,
213 /// Retrieval score after demotion adjustment (0.0 for pinned items,
214 /// which are not score-ranked).
215 pub score: f32,
216 pub reason: SelectionReason,
217}
218
219fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
220 if a.len() != b.len() || a.is_empty() {
221 return 0.0;
222 }
223 let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
224 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
225 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
226 if na == 0.0 || nb == 0.0 {
227 return 0.0;
228 }
229 dot / (na * nb)
230}
231
232fn attr_count(node: &MemoryNode, key: &str) -> i64 {
233 match node.attributes.get(key) {
234 Some(AttributeValue::Integer(n)) => *n,
235 _ => 0,
236 }
237}
238
239fn bump_attr(node: &mut MemoryNode, key: &str) {
240 let next = attr_count(node, key) + 1;
241 node.attributes
242 .insert(key.to_string(), AttributeValue::Integer(next));
243}
244
245fn has_tag(node: &MemoryNode, tag: &str) -> bool {
246 node.tags.iter().any(|t| t == tag)
247}
248
249fn now_us() -> u64 {
250 std::time::SystemTime::now()
251 .duration_since(std::time::UNIX_EPOCH)
252 .unwrap_or_default()
253 .as_micros() as u64
254}
255
256/// Cut a descending score list at its largest relative gap (the relevance
257/// knee), when that gap is decisive. Returns how many items to keep.
258fn knee_cutoff(scores: &[f32], gap_ratio: f32) -> usize {
259 if scores.len() <= 1 {
260 return scores.len();
261 }
262 let mut best_ratio = 0.0f32;
263 let mut cut = scores.len();
264 for i in 0..scores.len() - 1 {
265 let hi = scores[i];
266 let lo = scores[i + 1].max(f32::EPSILON);
267 let ratio = hi / lo;
268 if ratio > best_ratio {
269 best_ratio = ratio;
270 cut = i + 1;
271 }
272 }
273 if best_ratio >= gap_ratio {
274 cut
275 } else {
276 scores.len()
277 }
278}
279
280/// The project a memory is scoped to, from its `scope:project:<name>` tag.
281fn project_of(node: &MemoryNode) -> Option<&str> {
282 node.tags
283 .iter()
284 .find_map(|t| t.strip_prefix("scope:project:"))
285}
286
287/// Cross-project policy for a candidate when a current project is set.
288/// Untagged (global) memories and same-project memories always pass. Episodic
289/// memories from another project pass only if configured to. Other types from
290/// another project must be among the most semantically relevant candidates
291/// (cosine relative to the query's best), never tail filler.
292fn cross_project_allowed(
293 node: &MemoryNode,
294 cos: f32,
295 best_cos: f32,
296 current_project: Option<&str>,
297 cfg: &InjectionConfig,
298) -> bool {
299 let Some(current) = current_project else {
300 return true;
301 };
302 let Some(mem_project) = project_of(node) else {
303 return true;
304 };
305 if mem_project == current {
306 return true;
307 }
308 if node.memory_type == MemoryType::Episodic {
309 return cfg.cross_project_episodic;
310 }
311 cos >= best_cos * cfg.cross_project_floor_fraction
312}
313
314/// Semantic tail gate over a descending-ranked candidate list: the head
315/// (`tail_min_keep`) is trusted as ranked, the tail must reach
316/// `tail_floor_fraction` of the best query cosine to stay. Operates on
317/// (index, cosine) so RRF rank compression does not blunt it. Degenerate
318/// inputs (best cosine 0, e.g. mid-migration dimension mismatch) pass
319/// everything rather than silencing injection entirely.
320fn passes_tail_gate(idx: usize, cos: f32, best_cos: f32, cfg: &InjectionConfig) -> bool {
321 if idx < cfg.tail_min_keep {
322 return true;
323 }
324 cos >= best_cos * cfg.tail_floor_fraction
325}
326
327impl MenteDb {
328 /// Injection-ready context selection. See the module docs for the
329 /// policy; this is the API client hooks should prefer over raw recall.
330 pub fn recall_for_injection(
331 &self,
332 query: &InjectionQuery<'_>,
333 ) -> MenteResult<Vec<InjectionCandidate>> {
334 let cfg = self.cognitive_config.injection_config.clone();
335 let excluded: HashSet<MemoryId> = query.exclude_ids.iter().copied().collect();
336 let session_tag = query.session_id.map(|s| format!("session:{s}"));
337
338 // Phase timings, logged when a call is slow, so a slow recall in
339 // production names the phase and how many memories it loaded.
340 let t_start = std::time::Instant::now();
341 // Candidate pool from hybrid recall.
342 let hits = self
343 .recall_hybrid_scoped_at_mode(
344 query.embedding,
345 query.query_text.filter(|_| cfg.use_lexical_leg),
346 cfg.candidate_pool,
347 now_us(),
348 None,
349 false,
350 None,
351 query.agent_id,
352 query.user_id,
353 query.current_project,
354 )
355 .unwrap_or_default();
356
357 let t_hybrid = t_start.elapsed();
358 let hybrid_hits = hits.len();
359 let mut candidates: Vec<(MemoryNode, f32, f32)> = Vec::new();
360 for (id, score) in hits {
361 if excluded.contains(&id) {
362 continue;
363 }
364 let Ok(node) = self.get_memory(id) else {
365 continue;
366 };
367 if has_tag(&node, "action")
368 || has_tag(&node, "scope:always")
369 || has_tag(&node, "ghost-memory")
370 || node.tags.iter().any(|t| t.starts_with("mode-exemplar:"))
371 {
372 // Actions never inject; pinned items are handled separately.
373 // Ghost memories are speculative working material for the
374 // trajectory tracker, not confirmed knowledge; injecting
375 // "Unconfirmed: ..." as if it were a fact misleads.
376 continue;
377 }
378 if let Some(ref st) = session_tag
379 && has_tag(&node, st)
380 {
381 continue;
382 }
383 // Chronically ignored memories fall back in the ranking until
384 // usage or decay resolves them; past the drop threshold they are
385 // excluded from injection outright (ordinary recall still finds
386 // them). Exposure without a single use is the strongest negative
387 // signal the system has.
388 let shown = attr_count(&node, ATTR_INJECTION_SHOWN);
389 let used = attr_count(&node, ATTR_INJECTION_USED);
390 if shown >= cfg.demotion_drop_shown && used == 0 {
391 continue;
392 }
393 let mut adjusted = if shown >= cfg.demotion_shown_min && used == 0 {
394 score * cfg.demotion_factor
395 } else {
396 score
397 };
398 if let Some(agent) = query.agent_id
399 && node.agent_id == agent
400 && !agent.0.is_nil()
401 {
402 adjusted *= cfg.own_agent_boost;
403 }
404 let cos = cosine_similarity(query.embedding, &node.embedding);
405 candidates.push((node, adjusted, cos));
406 }
407
408 // Pre gate snapshot of section tagged candidates: cluster completion
409 // may need siblings the knee and tail gates are about to drop.
410 let cluster_pool: Vec<(MemoryNode, f32)> = if cfg.cluster_dominant_fraction > 0.0 {
411 candidates
412 .iter()
413 .filter(|(node, _, _)| node.tags.iter().any(|t| t.starts_with("section:")))
414 .map(|(node, _, cos)| (node.clone(), *cos))
415 .collect()
416 } else {
417 Vec::new()
418 };
419
420 // Gates are relative to the query's own best cosine, never an absolute
421 // threshold, so they hold across embedders (RRF scores are rank
422 // compressed and useless for this; the raw cosine is not).
423 let best_cos = candidates.iter().map(|(_, _, c)| *c).fold(0.0f32, f32::max);
424 candidates.retain(|(node, _, cos)| {
425 cross_project_allowed(node, *cos, best_cos, query.current_project, &cfg)
426 });
427
428 candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
429 let scores: Vec<f32> = candidates.iter().map(|(_, s, _)| *s).collect();
430 candidates.truncate(knee_cutoff(&scores, cfg.knee_gap_ratio));
431
432 // Semantic tail gate: beyond the trusted head, weak-cosine candidates
433 // vanish instead of being carried to MMR, which would otherwise fill
434 // max_items however irrelevant the tail. Silence beats plausible noise.
435 let mut idx = 0usize;
436 candidates.retain(|(_, _, cos)| {
437 let keep = passes_tail_gate(idx, *cos, best_cos, &cfg);
438 idx += 1;
439 keep
440 });
441
442 // Escalation re-ranks AMONG the relevance survivors: a rule the agent
443 // was shown and then broke leads the next time its context recurs. It
444 // runs here, after the cosine gates, so the boost can never drag an
445 // irrelevant escalated rule into the context, only reorder rules that
446 // already earned their place.
447 if cfg.escalation_boost != 1.0
448 && candidates
449 .iter()
450 .any(|(n, _, _)| has_tag(n, "escalated:violation"))
451 {
452 let relevance_floor = best_cos * cfg.escalation_min_relevance;
453 for (node, adjusted, cos) in candidates.iter_mut() {
454 if has_tag(node, "escalated:violation") && *cos >= relevance_floor {
455 *adjusted *= cfg.escalation_boost;
456 }
457 }
458 candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
459 }
460
461 let mut scored: Vec<(MemoryNode, f32)> =
462 candidates.into_iter().map(|(n, s, _)| (n, s)).collect();
463
464 // Associative recall: after the vector-tail knee, fold in 1-hop graph
465 // neighbors of the surviving hits, so a memory linked to a relevant hit
466 // can surface even when it is not itself vector-similar to the query.
467 // Runs after the knee (which is for weak vector tails, a different signal
468 // than an explicit edge) so a neighbor's decayed score is not mistaken
469 // for a tail and cut. Disabled by default (graph_expansion_max == 0),
470 // leaving pure vector/hybrid recall intact. Neighbors inherit a decayed
471 // share of the hit's score so they never outrank direct hits, pass the
472 // same gates, and are bounded by graph_expansion_max; MMR then picks the
473 // final set within max_items.
474 if cfg.graph_expansion_max > 0 && !scored.is_empty() {
475 let now = now_us();
476 let mut present: HashSet<MemoryId> = scored.iter().map(|(n, _)| n.id).collect();
477 present.extend(excluded.iter().copied());
478 let seeds: Vec<(MemoryId, f32)> = scored.iter().map(|(n, s)| (n.id, *s)).collect();
479
480 let g = self.graph.graph();
481 let mut added = 0usize;
482 'seeds: for (seed_id, seed_score) in seeds {
483 let mut edges = g.outgoing(seed_id);
484 edges.extend(g.incoming(seed_id));
485 for (nbr_id, edge) in edges {
486 if added >= cfg.graph_expansion_max {
487 break 'seeds;
488 }
489 if present.contains(&nbr_id) {
490 continue;
491 }
492 let Ok(node) = self.get_memory(nbr_id) else {
493 continue;
494 };
495 if has_tag(&node, "action")
496 || has_tag(&node, "scope:always")
497 || has_tag(&node, "ghost-memory")
498 || !node.is_valid_at(now)
499 || !crate::agent_visible(node.agent_id, query.agent_id)
500 || !crate::user_visible(node.user_id, query.user_id)
501 {
502 continue;
503 }
504 if let Some(ref st) = session_tag
505 && has_tag(&node, st)
506 {
507 continue;
508 }
509 let score = seed_score * edge.weight * cfg.graph_expansion_decay;
510 present.insert(nbr_id);
511 scored.push((node, score));
512 added += 1;
513 }
514 }
515 }
516
517 // Cluster dominance is read off the score ranking BEFORE MMR: MMR
518 // deliberately de clusters the head, hiding the very signal cluster
519 // completion needs.
520 let dominant_section: Option<String> = if cfg.cluster_dominant_fraction > 0.0 {
521 let head = scored.len().min(query.max_items.max(3));
522 let mut counts: HashMap<String, usize> = HashMap::new();
523 for (node, _) in &scored[..head] {
524 for tag in &node.tags {
525 if let Some(sec) = tag.strip_prefix("section:") {
526 *counts.entry(sec.to_string()).or_insert(0) += 1;
527 }
528 }
529 }
530 // A section wider than cluster_max_span across the whole pool is
531 // a catch all, not a coherent cluster; it dominates any head
532 // trivially and its siblings are arbitrary.
533 let mut span: HashMap<&str, usize> = HashMap::new();
534 for (node, _) in &cluster_pool {
535 for tag in &node.tags {
536 if let Some(sec) = tag.strip_prefix("section:") {
537 *span.entry(sec).or_insert(0) += 1;
538 }
539 }
540 }
541 let need = ((cfg.cluster_dominant_fraction * head as f32).ceil() as usize).max(2);
542 counts
543 .into_iter()
544 .filter(|(sec, n)| {
545 *n >= need
546 && span.get(sec.as_str()).copied().unwrap_or(0) <= cfg.cluster_max_span
547 })
548 .max_by_key(|(_, n)| *n)
549 .map(|(sec, _)| sec)
550 } else {
551 None
552 };
553
554 // MMR selection with type quotas.
555 let top_score = scored
556 .first()
557 .map(|(_, s)| *s)
558 .unwrap_or(1.0)
559 .max(f32::EPSILON);
560 let mut remaining: Vec<(MemoryNode, f32)> = scored;
561 let mut selected: Vec<InjectionCandidate> = Vec::new();
562 let mut episodic_count = 0usize;
563
564 while selected.len() < query.max_items && !remaining.is_empty() {
565 let mut best_idx: Option<usize> = None;
566 let mut best_value = f32::NEG_INFINITY;
567 for (idx, (node, score)) in remaining.iter().enumerate() {
568 if node.memory_type == MemoryType::Episodic && episodic_count >= query.max_episodic
569 {
570 continue;
571 }
572 let redundancy = selected
573 .iter()
574 .map(|s| cosine_similarity(&node.embedding, &s.node.embedding))
575 .fold(0.0f32, f32::max);
576 let value =
577 cfg.mmr_lambda * (score / top_score) - (1.0 - cfg.mmr_lambda) * redundancy;
578 if value > best_value {
579 best_value = value;
580 best_idx = Some(idx);
581 }
582 }
583 let Some(idx) = best_idx else {
584 break;
585 };
586 let (node, score) = remaining.remove(idx);
587 if node.memory_type == MemoryType::Episodic {
588 episodic_count += 1;
589 }
590 selected.push(InjectionCandidate {
591 node,
592 score,
593 reason: SelectionReason::Relevant,
594 });
595 }
596
597 // Cluster completion: when one section dominates the selected head,
598 // the turn lives inside that cluster of sibling rules, and embedding
599 // rank underestimates the rest of the cluster (the harness measured
600 // exactly this on a real 22KB agent file: two governing rules ranked
601 // below k=24 behind their own section siblings). Swap the weakest
602 // non cluster tail picks for the section's best remaining siblings,
603 // gates overridden deliberately, bounded by cluster_fill_max.
604 if selected.len() >= 3
605 && let Some(sec) = dominant_section
606 {
607 {
608 let sec_tag = format!("section:{sec}");
609 let mut siblings: Vec<(MemoryNode, f32)> = cluster_pool
610 .into_iter()
611 .filter(|(node, _)| {
612 node.tags.iter().any(|t| t == &sec_tag)
613 && !selected.iter().any(|c| c.node.id == node.id)
614 })
615 .collect();
616 siblings.sort_by(|a, b| b.1.total_cmp(&a.1));
617 siblings.truncate(cfg.cluster_fill_max);
618 // Evict the weakest non cluster picks, never the top pick.
619 // The span cap above keeps this to coherent clusters, where
620 // the cluster hypothesis says sibling relevance is
621 // underestimated by embedding rank.
622 let mut evict: Vec<usize> = (1..selected.len())
623 .filter(|i| !selected[*i].node.tags.iter().any(|t| t == &sec_tag))
624 .collect();
625 evict.sort_by(|a, b| {
626 selected[*a]
627 .score
628 .partial_cmp(&selected[*b].score)
629 .unwrap_or(std::cmp::Ordering::Equal)
630 });
631 for (pos, (node, cos)) in evict.into_iter().zip(siblings) {
632 selected[pos] = InjectionCandidate {
633 node,
634 score: cos,
635 reason: SelectionReason::Relevant,
636 };
637 }
638 }
639 }
640
641 // Pinned memories bypass every gate except the ledger, and lead the
642 // result. The client's ledger reset (at context loss) governs
643 // re-delivery. Fetched from the tag index (scope:always is capped
644 // near-zero) so this loads only the handful of matches; the old path
645 // scanned every page on every injection call, which thrashed the
646 // buffer pool against disk on large accounts.
647 let t_selected = t_start.elapsed();
648 let mut pinned_loads = 0usize;
649 let mut result: Vec<InjectionCandidate> = Vec::new();
650 for mid in self.index.bitmap.query_tag("scope:always") {
651 if excluded.contains(&mid) {
652 continue;
653 }
654 pinned_loads += 1;
655 if let Ok(node) = self.get_memory(mid)
656 && crate::agent_visible(node.agent_id, query.agent_id)
657 && crate::user_visible(node.user_id, query.user_id)
658 {
659 result.push(InjectionCandidate {
660 node,
661 score: 0.0,
662 reason: SelectionReason::Pinned,
663 });
664 }
665 }
666 // Mode rules: standing directives bound to an activity rather than a
667 // topic or a tool moment. "Longterm fixes only" must fire for "fix
668 // the login timeout" even though they share no words; the turn's
669 // embedding landing near the rule's own stored embedding is the
670 // activation, cognitive and self defining, dot products only.
671 let t_pinned = t_start.elapsed();
672 let mut mode_tags = 0usize;
673 let mut mode_loads = 0usize;
674 if cfg.mode_trigger_gate > 0.0 && !query.embedding.is_empty() {
675 let now = now_us();
676 let mut mode_rules: Vec<(f32, MemoryNode)> = Vec::new();
677 let mut mode_candidates: Vec<(f32, MemoryId, String)> = Vec::new();
678 for tag in self.index.bitmap.tags_with_prefix("trigger:") {
679 mode_tags += 1;
680 let trigger = tag.trim_start_matches("trigger:");
681 // Activation signal: the trigger's exemplar turns when it
682 // has any (model emitted examples of requests that enter
683 // this mode; a turn matching a turn is the strong signal,
684 // measured), otherwise the rules' own embeddings.
685 let exemplar_tag = format!("mode-exemplar:{trigger}");
686 let mut activation = f32::MIN;
687 let exemplar_ids = self.index.bitmap.query_tag(&exemplar_tag);
688 if exemplar_ids.is_empty() {
689 for mid in self.index.bitmap.query_tag(&tag) {
690 if let Some(probe) = self.mode_probe(mid, &mut mode_loads)
691 && probe.is_valid_at(now)
692 && !probe.embedding.is_empty()
693 && crate::agent_visible(probe.agent_id, query.agent_id)
694 && crate::user_visible(probe.user_id, query.user_id)
695 {
696 let sim = cosine_similarity(query.embedding, &probe.embedding);
697 activation = activation.max(sim);
698 }
699 }
700 } else {
701 for mid in exemplar_ids {
702 if let Some(probe) = self.mode_probe(mid, &mut mode_loads)
703 && probe.is_valid_at(now)
704 && !probe.embedding.is_empty()
705 && crate::agent_visible(probe.agent_id, query.agent_id)
706 && crate::user_visible(probe.user_id, query.user_id)
707 {
708 let sim = cosine_similarity(query.embedding, &probe.embedding);
709 activation = activation.max(sim);
710 }
711 }
712 }
713 if activation < cfg.mode_trigger_gate {
714 continue;
715 }
716 // The mode is active: its RULES (never exemplars) become
717 // candidates. Validity and owner come from the cached probes;
718 // full memories are loaded below only for the few kept.
719 for mid in self.index.bitmap.query_tag(&tag) {
720 if excluded.contains(&mid)
721 || result.iter().any(|c| c.node.id == mid)
722 || selected.iter().any(|c| c.node.id == mid)
723 || mode_candidates.iter().any(|(_, id, _)| *id == mid)
724 {
725 continue;
726 }
727 let Some(probe) = self.mode_probe(mid, &mut mode_loads) else {
728 continue;
729 };
730 if !probe.is_valid_at(now)
731 || !crate::agent_visible(probe.agent_id, query.agent_id)
732 || !crate::user_visible(probe.user_id, query.user_id)
733 {
734 continue;
735 }
736 mode_candidates.push((activation, mid, tag.clone()));
737 }
738 }
739 // Strongest mode first (stable, so rules keep their index order
740 // within a mode), then load only until the cap is filled. A rule
741 // whose stored tags no longer carry the trigger is skipped, as
742 // before.
743 mode_candidates.sort_by(|a, b| b.0.total_cmp(&a.0));
744 for (activation, mid, tag) in mode_candidates {
745 if mode_rules.len() >= cfg.mode_rules_max {
746 break;
747 }
748 mode_loads += 1;
749 let Ok(node) = self.get_memory(mid) else {
750 continue;
751 };
752 if node.tags.iter().any(|t| t == &tag) {
753 mode_rules.push((activation, node));
754 }
755 }
756 for (sim, node) in mode_rules {
757 result.push(InjectionCandidate {
758 node,
759 score: sim,
760 reason: SelectionReason::ModeRule,
761 });
762 }
763 }
764
765 result.extend(selected);
766 let total = t_start.elapsed();
767 if total >= std::time::Duration::from_millis(self.cognitive_config.slow_recall_log_ms) {
768 tracing::info!(
769 total_ms = total.as_millis() as u64,
770 hybrid_ms = t_hybrid.as_millis() as u64,
771 select_ms = (t_selected - t_hybrid).as_millis() as u64,
772 pinned_ms = (t_pinned - t_selected).as_millis() as u64,
773 mode_ms = (total - t_pinned).as_millis() as u64,
774 hybrid_hits,
775 pinned_loads,
776 mode_tags,
777 mode_loads,
778 "slow injection recall"
779 );
780 }
781 Ok(result)
782 }
783
784 /// Record the outcome of an injection: every shown memory's exposure
785 /// count rises, and memories the reply actually drew on (embedding
786 /// similarity against the reply) are counted as used and reinforced.
787 /// Returns (shown_updated, used_count).
788 pub fn record_injection_outcome(
789 &self,
790 shown: &[MemoryId],
791 reply_embedding: Option<&[f32]>,
792 ) -> MenteResult<(usize, usize)> {
793 let cfg = self.cognitive_config.injection_config.clone();
794 let mut updated = 0usize;
795 let mut used_total = 0usize;
796 let now = now_us();
797
798 for id in shown {
799 let pid = {
800 let pm = self.page_map.read();
801 match pm.get(id) {
802 Some(p) => *p,
803 None => continue,
804 }
805 };
806 let Ok(mut node) = self.storage.load_memory(pid) else {
807 continue;
808 };
809 bump_attr(&mut node, ATTR_INJECTION_SHOWN);
810
811 // Retrieval reinforcement: being surfaced at all is an access. Refresh
812 // the decay clock and bump the access count for every shown memory, not
813 // only the ones the reply echoed, so anything that keeps getting
814 // recalled stays alive instead of decaying to the forget threshold while
815 // it is actively in use. Memories that are never recalled still get no
816 // touch here, so they decay and are forgotten as intended; only what
817 // retrieval keeps surfacing survives.
818 node.access_count = node.access_count.saturating_add(1);
819 node.accessed_at = now;
820
821 let used = reply_embedding
822 .map(|re| cosine_similarity(&node.embedding, re) >= cfg.used_similarity)
823 .unwrap_or(false);
824 if used {
825 // The reply actually drew on it: a stronger signal than mere
826 // exposure, so add a salience boost on top of the access refresh.
827 bump_attr(&mut node, ATTR_INJECTION_USED);
828 node.salience = (node.salience + cfg.use_reinforcement).min(1.0);
829 used_total += 1;
830 }
831
832 // Counter updates must not re-run write inference; write the
833 // node in place.
834 self.update_node(pid, &node)?;
835 updated += 1;
836 }
837 Ok((updated, used_total))
838 }
839}
840
841/// What mode activation needs from a `trigger:` rule or `mode-exemplar:` turn:
842/// its embedding, validity window and owner. Cached per memory on the
843/// database so activation never loads the full memory from storage.
844pub(crate) struct ModeProbe {
845 pub(crate) embedding: Vec<f32>,
846 pub(crate) agent_id: AgentId,
847 pub(crate) user_id: UserId,
848 valid_from: Option<u64>,
849 valid_until: Option<u64>,
850}
851
852impl ModeProbe {
853 fn from_node(node: &MemoryNode) -> Self {
854 Self {
855 embedding: node.embedding.clone(),
856 agent_id: node.agent_id,
857 user_id: node.user_id,
858 valid_from: node.valid_from,
859 valid_until: node.valid_until,
860 }
861 }
862
863 /// Same rule as `MemoryNode::is_valid_at`.
864 pub(crate) fn is_valid_at(&self, at: u64) -> bool {
865 let from = self.valid_from.unwrap_or(0);
866 match self.valid_until {
867 Some(until) => at >= from && at < until,
868 None => at >= from,
869 }
870 }
871}
872
873impl MenteDb {
874 /// The cached mode probe for `id`, loading the memory once on a miss
875 /// (counted in `loads`). The slot is marked pending while loading; a write
876 /// to this memory removes the slot, so a copy read before that write is
877 /// returned but never cached. Writes to other memories do not interfere.
878 pub(crate) fn mode_probe(
879 &self,
880 id: MemoryId,
881 loads: &mut usize,
882 ) -> Option<std::sync::Arc<ModeProbe>> {
883 let token = {
884 let mut cache = self.mode_probes.write();
885 match cache.get(&id) {
886 Some(ProbeSlot::Ready(p)) => return Some(std::sync::Arc::clone(p)),
887 Some(ProbeSlot::Loading(_)) => None,
888 None => {
889 let token = NEXT_PROBE_TOKEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
890 cache.insert(id, ProbeSlot::Loading(token));
891 Some(token)
892 }
893 }
894 };
895 *loads += 1;
896 let loaded = self
897 .get_memory(id)
898 .ok()
899 .map(|n| std::sync::Arc::new(ModeProbe::from_node(&n)));
900 if let Some(token) = token {
901 let mut cache = self.mode_probes.write();
902 if matches!(cache.get(&id), Some(ProbeSlot::Loading(t)) if *t == token) {
903 match &loaded {
904 Some(p) => {
905 cache.insert(id, ProbeSlot::Ready(std::sync::Arc::clone(p)));
906 }
907 None => {
908 cache.remove(&id);
909 }
910 }
911 }
912 }
913 loaded
914 }
915}
916
917/// A mode probe cache slot: being loaded (by the holder of this token) or ready.
918pub(crate) enum ProbeSlot {
919 Loading(u64),
920 Ready(std::sync::Arc<ModeProbe>),
921}
922
923static NEXT_PROBE_TOKEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
924
925#[cfg(test)]
926mod tests {
927 use super::*;
928 use mentedb_core::types::AgentId;
929 use uuid::Uuid;
930
931 #[test]
932 fn knee_cuts_at_largest_gap() {
933 let ratio = InjectionConfig::default().knee_gap_ratio;
934 assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.1, 0.09], ratio), 3);
935 // No decisive gap: keep everything.
936 assert_eq!(knee_cutoff(&[1.0, 0.9, 0.8, 0.7], ratio), 4);
937 assert_eq!(knee_cutoff(&[1.0], ratio), 1);
938 assert_eq!(knee_cutoff(&[], ratio), 0);
939 }
940
941 #[test]
942 fn attr_counters_roundtrip() {
943 let mut node = MemoryNode::new(
944 AgentId(Uuid::nil()),
945 MemoryType::Semantic,
946 "x".into(),
947 vec![0.0; 4],
948 );
949 assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 0);
950 bump_attr(&mut node, ATTR_INJECTION_SHOWN);
951 bump_attr(&mut node, ATTR_INJECTION_SHOWN);
952 assert_eq!(attr_count(&node, ATTR_INJECTION_SHOWN), 2);
953 }
954
955 fn node_with_tags(memory_type: MemoryType, tags: &[&str]) -> MemoryNode {
956 let mut node = MemoryNode::new(AgentId(Uuid::nil()), memory_type, "x".into(), vec![0.0; 4]);
957 node.tags = tags.iter().map(|t| t.to_string()).collect();
958 node
959 }
960
961 #[test]
962 fn tail_gate_trusts_head_and_prunes_weak_tail() {
963 let cfg = InjectionConfig::default();
964 let best = 0.8;
965 // Head positions pass regardless of cosine (keyword hits).
966 assert!(passes_tail_gate(0, 0.05, best, &cfg));
967 assert!(passes_tail_gate(2, 0.05, best, &cfg));
968 // Tail passes only near the query's own best cosine.
969 assert!(passes_tail_gate(3, 0.6, best, &cfg));
970 assert!(!passes_tail_gate(3, 0.3, best, &cfg));
971 // Degenerate best cosine (mid-migration): everything passes.
972 assert!(passes_tail_gate(5, 0.0, 0.0, &cfg));
973 }
974
975 #[test]
976 fn cross_project_policy() {
977 let cfg = InjectionConfig::default();
978 let best = 0.8;
979 let foreign_epi = node_with_tags(MemoryType::Episodic, &["scope:project:other"]);
980 let foreign_sem = node_with_tags(MemoryType::Semantic, &["scope:project:other"]);
981 let same_epi = node_with_tags(MemoryType::Episodic, &["scope:project:mine"]);
982 let global_sem = node_with_tags(MemoryType::Semantic, &[]);
983
984 // No current project: everything passes.
985 assert!(cross_project_allowed(&foreign_epi, 0.1, best, None, &cfg));
986 // Same project and untagged always pass.
987 assert!(cross_project_allowed(
988 &same_epi,
989 0.1,
990 best,
991 Some("mine"),
992 &cfg
993 ));
994 assert!(cross_project_allowed(
995 &global_sem,
996 0.1,
997 best,
998 Some("mine"),
999 &cfg
1000 ));
1001 // Foreign episodic never passes by default.
1002 assert!(!cross_project_allowed(
1003 &foreign_epi,
1004 0.79,
1005 best,
1006 Some("mine"),
1007 &cfg
1008 ));
1009 // Foreign semantic passes only when among the most relevant.
1010 assert!(cross_project_allowed(
1011 &foreign_sem,
1012 0.7,
1013 best,
1014 Some("mine"),
1015 &cfg
1016 ));
1017 assert!(!cross_project_allowed(
1018 &foreign_sem,
1019 0.5,
1020 best,
1021 Some("mine"),
1022 &cfg
1023 ));
1024 }
1025
1026 #[test]
1027 fn project_of_reads_scope_tag() {
1028 let node = node_with_tags(MemoryType::Semantic, &["session:s1", "scope:project:apex"]);
1029 assert_eq!(project_of(&node), Some("apex"));
1030 let untagged = node_with_tags(MemoryType::Semantic, &["session:s1"]);
1031 assert_eq!(project_of(&untagged), None);
1032 }
1033}
1034
1035#[cfg(test)]
1036mod mode_probe_tests {
1037 use super::*;
1038 use mentedb_embedding::hash_provider::HashEmbeddingProvider;
1039
1040 #[test]
1041 fn probes_load_once_and_reload_after_a_write() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let db = MenteDb::open_with_embedder(dir.path(), Box::new(HashEmbeddingProvider::new(16)))
1044 .unwrap();
1045 let emb = db.embed_text("rule").unwrap().unwrap();
1046 let mut node = MemoryNode::new(AgentId::nil(), MemoryType::Procedural, "rule".into(), emb);
1047 node.tags = vec!["trigger:x".into()];
1048 let id = node.id;
1049 db.store(node).unwrap();
1050
1051 let mut loads = 0;
1052 assert!(db.mode_probe(id, &mut loads).is_some());
1053 assert!(db.mode_probe(id, &mut loads).is_some());
1054 assert_eq!(loads, 1, "second lookup must come from the cache");
1055
1056 // Writes to other memories leave this probe cached.
1057 for _ in 0..10 {
1058 db.note_write(MemoryId::new());
1059 }
1060 assert!(db.mode_probe(id, &mut loads).is_some());
1061 assert_eq!(loads, 1, "writes to other memories must not evict it");
1062
1063 db.note_write(id);
1064 assert!(db.mode_probe(id, &mut loads).is_some());
1065 assert_eq!(loads, 2, "a write must force a reload");
1066 assert!(db.mode_probe(id, &mut loads).is_some());
1067 assert_eq!(loads, 2, "and the reload is cached again");
1068 }
1069
1070 /// The production failure: an account writing constantly (a sync backlog
1071 /// draining) while recall fills the cache. Writes to other memories during
1072 /// a load must not stop that probe from being cached.
1073 #[test]
1074 fn probes_cache_while_other_memories_are_written() {
1075 let dir = tempfile::tempdir().unwrap();
1076 let db = std::sync::Arc::new(
1077 MenteDb::open_with_embedder(dir.path(), Box::new(HashEmbeddingProvider::new(16)))
1078 .unwrap(),
1079 );
1080 let mut ids = Vec::new();
1081 for i in 0..200 {
1082 let text = format!("rule {i}");
1083 let emb = db.embed_text(&text).unwrap().unwrap();
1084 let mut node = MemoryNode::new(AgentId::nil(), MemoryType::Procedural, text, emb);
1085 node.tags = vec!["trigger:x".into()];
1086 ids.push(node.id);
1087 db.store(node).unwrap();
1088 }
1089 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
1090 let writer = {
1091 let db = std::sync::Arc::clone(&db);
1092 let stop = std::sync::Arc::clone(&stop);
1093 std::thread::spawn(move || {
1094 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
1095 db.note_write(MemoryId::new());
1096 }
1097 })
1098 };
1099 let mut first = 0;
1100 for id in &ids {
1101 db.mode_probe(*id, &mut first);
1102 }
1103 stop.store(true, std::sync::atomic::Ordering::Relaxed);
1104 writer.join().unwrap();
1105 assert_eq!(first, ids.len());
1106 let mut second = 0;
1107 for id in &ids {
1108 db.mode_probe(*id, &mut second);
1109 }
1110 assert_eq!(second, 0, "every probe should be cached after one pass");
1111 }
1112}