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::{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}