Skip to main content

kimetsu_brain/
context.rs

1#[path = "delivery.rs"]
2pub mod delivery;
3
4use std::cmp::Ordering;
5use std::collections::HashMap;
6
7use kimetsu_core::config::{BrokerWeights, StageWeights};
8use kimetsu_core::memory::MemoryScope;
9use kimetsu_core::{KimetsuResult, ids::new_id};
10use rusqlite::{Connection, OptionalExtension, params};
11use serde::{Deserialize, Serialize};
12
13// -----------------------------------------------------------------------
14// E3: task-kind classification + adaptive retrieval routing
15// -----------------------------------------------------------------------
16
17/// The inferred kind of the current coding task. Classified once at
18/// intake from the task description string — deterministic keyword
19/// scan, no model call, zero allocation-heavy work.
20///
21/// `Feature` is the NEUTRAL default: it does not change weights or
22/// prefer_roles at all, so every existing `..Default::default()`
23/// construction produces exactly the prior retrieval behaviour.
24///
25/// Precedence when multiple keyword sets match:
26///   Debug > Investigation > Refactor > Docs > Feature
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum TaskKind {
29    /// Neutral / catch-all (add, implement, build, create, support, …).
30    /// Must NOT alter weights or prefer_roles — keeps existing tests green.
31    #[default]
32    Feature,
33    /// fix, bug, error, fail, crash, panic, regression, broken, debug,
34    /// stack trace, exception — up freshness, prefer failure_pattern.
35    Debug,
36    /// refactor, rename, cleanup, restructure, simplify, extract,
37    /// deduplicate, reorganize — up scope, prefer convention.
38    Refactor,
39    /// document, readme, changelog, comment, docstring, docs, tutorial,
40    /// guide — near-neutral mild adjustments.
41    Docs,
42    /// investigate, analyze, understand, why, explore, find out,
43    /// root cause, audit, trace — up relevance, prefer fact + preference.
44    Investigation,
45}
46
47/// Classify a task description string into a [`TaskKind`] using a
48/// deterministic keyword scan over the lowercased text. No model call.
49///
50/// Precedence (highest wins when multiple sets match):
51///   Debug > Investigation > Refactor > Docs > Feature
52pub fn classify_task(task: &str) -> TaskKind {
53    let lower = task.to_ascii_lowercase();
54
55    // Debug keywords (highest priority)
56    const DEBUG_KW: &[&str] = &[
57        "fix",
58        "bug",
59        "error",
60        "fail",
61        "crash",
62        "panic",
63        "regression",
64        "broken",
65        "debug",
66        "stack trace",
67        "exception",
68    ];
69    if DEBUG_KW.iter().any(|kw| lower.contains(kw)) {
70        return TaskKind::Debug;
71    }
72
73    // Investigation keywords
74    const INVESTIGATE_KW: &[&str] = &[
75        "investigate",
76        "analyze",
77        "understand",
78        " why ",
79        "explore",
80        "find out",
81        "root cause",
82        "audit",
83        "trace",
84    ];
85    if INVESTIGATE_KW.iter().any(|kw| lower.contains(kw)) {
86        return TaskKind::Investigation;
87    }
88
89    // Refactor keywords
90    const REFACTOR_KW: &[&str] = &[
91        "refactor",
92        "rename",
93        "cleanup",
94        "clean up",
95        "restructure",
96        "simplify",
97        "extract",
98        "deduplicate",
99        "reorganize",
100    ];
101    if REFACTOR_KW.iter().any(|kw| lower.contains(kw)) {
102        return TaskKind::Refactor;
103    }
104
105    // Docs keywords
106    const DOCS_KW: &[&str] = &[
107        "document",
108        "readme",
109        "changelog",
110        "comment",
111        "docstring",
112        "docs",
113        "tutorial",
114        "guide",
115    ];
116    if DOCS_KW.iter().any(|kw| lower.contains(kw)) {
117        return TaskKind::Docs;
118    }
119
120    // Default: Feature (neutral)
121    TaskKind::Feature
122}
123
124/// Compose task-kind weight biases on top of the stage weights.
125///
126/// For `Feature`, returns `base` UNCHANGED — this is the neutrality
127/// guarantee that keeps all existing retrieval tests green.
128///
129/// For other kinds, one component is multiplied by a bias factor and
130/// the result is renormalized so the four weights still sum to the same
131/// total as `base`, preserving overall scoring magnitude (just the mix
132/// changes).
133///
134/// Bias factors (applied before renorm):
135/// - Debug       → freshness × 1.6  (recent failures matter most)
136/// - Refactor    → scope × 1.6      (project/repo conventions matter most)
137/// - Investigation → relevance × 1.4 (broad fact/preference recall)
138/// - Docs        → mild (confidence × 1.15, near-neutral)
139fn weights_for_task_kind(base: StageWeights, kind: TaskKind) -> StageWeights {
140    match kind {
141        TaskKind::Feature => base,
142        TaskKind::Debug => renorm(StageWeights {
143            freshness: base.freshness * 1.6,
144            ..base
145        }),
146        TaskKind::Refactor => renorm(StageWeights {
147            scope: base.scope * 1.6,
148            ..base
149        }),
150        TaskKind::Investigation => renorm(StageWeights {
151            relevance: base.relevance * 1.4,
152            ..base
153        }),
154        TaskKind::Docs => renorm(StageWeights {
155            confidence: base.confidence * 1.15,
156            ..base
157        }),
158    }
159}
160
161/// Renormalize `StageWeights` so the four components sum to the same
162/// total as before the bias was applied. This preserves scoring
163/// magnitude — only the mix changes.
164fn renorm(w: StageWeights) -> StageWeights {
165    let sum = w.relevance + w.confidence + w.freshness + w.scope;
166    if sum <= f32::EPSILON {
167        return w;
168    }
169    // The original sum (before any bias) isn't available here; instead
170    // we scale to 1.0 and then the absolute scores are comparable
171    // because normalize_and_score already places components in [0,1].
172    // NOTE: the stage weights themselves don't need to sum to 1.0 —
173    // the existing defaults (0.5+0.2+0.2+0.1=1.0) do, but the
174    // renormalization target should be the unbiased sum so we don't
175    // change the overall scale. Since we only modify ONE component by a
176    // small factor, we scale back to 1.0 (the natural target).
177    StageWeights {
178        relevance: w.relevance / sum,
179        confidence: w.confidence / sum,
180        freshness: w.freshness / sum,
181        scope: w.scope / sum,
182    }
183}
184
185/// Return the additional `prefer_roles` hints implied by `kind`.
186///
187/// These are MERGED with any caller-supplied `prefer_roles` (not
188/// clobbered), so the task-kind bias is additive.
189/// For `Feature`, returns an empty slice — zero effect on existing behaviour.
190fn task_kind_prefer_roles(kind: TaskKind) -> &'static [&'static str] {
191    match kind {
192        TaskKind::Feature => &[],
193        TaskKind::Debug => &["failure_pattern"],
194        TaskKind::Refactor => &["convention"],
195        TaskKind::Investigation => &["fact", "preference"],
196        TaskKind::Docs => &["convention"],
197    }
198}
199use time::OffsetDateTime;
200
201use crate::embeddings::{
202    self, DEFAULT_HYBRID_ALPHA, Embedder, cosine_similarity, decode_embedding,
203};
204
205/// v0.4.2: a pre-computed query embedding paired with the producing
206/// model's id. Threaded down into [`memory_candidates`] so each row
207/// can decide whether to contribute a cosine term (only when the
208/// row's `embedding_model` matches the active query's `model_id`).
209///
210/// S5.1: `pub(crate)` so `backend.rs` can name the type in the
211/// `RetrievalBackend` trait signature without exposing it outside the crate.
212#[derive(Debug, Clone)]
213pub(crate) struct QueryEmbedding {
214    pub(crate) vector: Vec<f32>,
215    pub(crate) model_id: String,
216}
217
218impl QueryEmbedding {
219    fn from_embedder(embedder: &dyn Embedder, query: &str) -> Option<Self> {
220        if embedder.is_noop() {
221            return None;
222        }
223        match embedder.embed(query) {
224            Ok(v) if v.len() == embedder.dim() => Some(Self {
225                vector: v,
226                model_id: embedder.model_id().to_string(),
227            }),
228            // NotImplemented / dim-mismatch / load failure → silently
229            // skip the cosine blend. v0.4.2 surfaces no warning here
230            // by design — the broker stays usable on best-effort
231            // semantic retrieval.
232            _ => None,
233        }
234    }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ContextCapsule {
239    pub id: String,
240    pub kind: String,
241    pub summary: String,
242    pub token_estimate: u32,
243    pub expansion_handle: String,
244    pub provenance: Vec<ProvenanceRef>,
245    pub confidence: f32,
246    pub freshness: f32,
247    pub relevance: f32,
248    pub scope_weight: f32,
249    pub score: f32,
250    /// v2.7: set by [`apply_supersession_penalty`] when a newer near-duplicate
251    /// sibling was also a candidate. Carried on the capsule so the penalty can
252    /// be REAPPLIED after cross-encoder reranking — the reranker scores pure
253    /// query relevance and would otherwise resurrect the older twin whenever
254    /// the query's wording happens to match it better (measured: resolution
255    /// 0.58 → 0.36 when reranking landed without this flag).
256    #[serde(default)]
257    pub superseded_hint: bool,
258    /// Legacy serialized usefulness sign; bounded when reranking old capsules.
259    #[serde(default)]
260    pub rerank_policy_tier: i8,
261    /// Claim identity read in the same SQLite snapshot as the hydrated text.
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub claim_revision: Option<String>,
264    /// Structured evidence read alongside this capsule text and revision.
265    #[serde(default, skip_serializing_if = "Vec::is_empty")]
266    pub facts: Vec<crate::fact_store::StoredFact>,
267    /// Decayed usefulness multiplier and provenance discount carried to reranking.
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub rerank_usefulness: Option<f32>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub rerank_trust: Option<f32>,
272}
273
274/// Revision bindings for the actual delivered capsules. Conflicting versions of
275/// one ID are omitted because the legacy event map can represent only one claim.
276pub fn memory_revision_bindings(
277    capsules: &[ContextCapsule],
278) -> std::collections::BTreeMap<String, String> {
279    let mut bindings = std::collections::BTreeMap::new();
280    let mut ambiguous = std::collections::HashSet::new();
281    for c in capsules {
282        if let (Some(id), Some(revision)) = (
283            c.expansion_handle.strip_prefix("memory:"),
284            c.claim_revision.as_ref(),
285        ) {
286            if bindings.get(id).is_some_and(|old| old != revision) {
287                ambiguous.insert(id.to_string());
288            }
289            bindings.insert(id.to_string(), revision.clone());
290        }
291    }
292    for id in ambiguous {
293        bindings.remove(&id);
294    }
295    bindings
296}
297
298impl ContextCapsule {
299    /// v1.0.0: build a render-only capsule from daemon wire data. Only the
300    /// fields the hook renders (`summary`, `kind`, `score`) are meaningful;
301    /// the rest are zeroed — this capsule is never re-scored or expanded.
302    pub fn wire_minimal(summary: String, kind: String, score: f32) -> Self {
303        Self {
304            id: String::new(),
305            kind,
306            summary,
307            token_estimate: 0,
308            expansion_handle: String::new(),
309            provenance: Vec::new(),
310            confidence: 0.0,
311            freshness: 0.0,
312            relevance: 0.0,
313            scope_weight: 0.0,
314            score,
315            superseded_hint: false,
316            rerank_policy_tier: 0,
317            claim_revision: None,
318            facts: vec![],
319            rerank_usefulness: None,
320            rerank_trust: None,
321        }
322    }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ProvenanceRef {
327    pub source: String,
328    pub id: String,
329    pub excerpt: Option<String>,
330}
331
332#[derive(Debug, Clone, Default)]
333pub struct ContextRequest {
334    /// Hydrate structured evidence only for consumers that explicitly request it.
335    pub include_fact_evidence: bool,
336    /// Defer intermediate budgeting to a final evidence-aware renderer; requires a bounded pool.
337    pub defer_fact_budget: bool,
338    pub stage: String,
339    pub query: String,
340    pub budget_tokens: u32,
341    /// v2.6: per-request override for how the lexical and semantic rankings
342    /// are merged (`"linear"` / `"rrf"`; see [`crate::fusion`]).
343    ///
344    /// Empty (the default) means "use `[broker] fusion`". This exists so
345    /// `kimetsu brain tune` can sweep the two rules against one corpus in one
346    /// process — the reason the shipped default is still `linear` is that
347    /// nothing had measured the alternative on a real brain.
348    pub fusion: String,
349    /// v2.6: per-request override for how `raw_relevance` is normalized into
350    /// the `relevance` term (`"per_kind"` / `"global"`; see
351    /// [`Normalization`]).
352    ///
353    /// Empty (the default) means "use `[broker] normalization`". Exists for
354    /// the same reason `fusion` does: the alternative had to be measurable on
355    /// one corpus in one process before it could be argued for.
356    pub normalization: String,
357    /// v0.6: domain-hint tags. Capsules whose text or kind contains any
358    /// of these strings receive a 1.4× score boost, pushing on-domain
359    /// capsules above the `min_score` threshold when they would otherwise
360    /// be filtered out.
361    pub tags: Vec<String>,
362    /// v0.6: minimum composite score for inclusion. When > 0.0 and the
363    /// top-scoring capsule falls below this threshold, `ContextBundle`
364    /// is returned with `skipped: true` and an empty capsule list —
365    /// zero tokens injected. 0.0 (default) disables the check.
366    pub min_score: f32,
367    /// v0.6: hard cap on returned capsules regardless of token budget.
368    /// 0 = no cap (budget-only limit, prior behaviour).
369    pub max_capsules: usize,
370    /// v0.6: role-preference boost. Capsules whose `kind` matches one
371    /// of these strings receive an additional 1.3× multiplier after the
372    /// tag boost (e.g. `["semantic_operator", "anti_pattern"]` for bench).
373    pub prefer_roles: Vec<String>,
374    /// v0.8: hard kind filter applied BEFORE scoring + capping. When
375    /// non-empty, only candidates whose capsule `kind` is in this list
376    /// survive — so a higher-ranked repo file or off-kind memory can't
377    /// consume a (often single) slot. Used by the proactive engine to
378    /// restrict recall to actionable kinds (failure_pattern, command,
379    /// convention). Empty (default) keeps all kinds, prior behaviour.
380    pub kinds: Vec<String>,
381    /// D1e: absolute cosine-similarity floor. On embeddings builds,
382    /// memory candidates whose cosine to the query is below this
383    /// threshold are dropped before budgeting. 0.0 (default) disables
384    /// the floor — matches pre-D1e behaviour. Repo-file and manifest
385    /// candidates are unaffected (they have no cosine score). Populated
386    /// from `BrokerSection.min_semantic_score` by the pipeline; callers
387    /// that don't set it get the prior behaviour automatically.
388    pub min_semantic_score: f32,
389    /// Explicit public override: None preserves legacy zero=inherited; Some(0)
390    /// disables, Some(-1) selects model auto, Some(positive) sets a floor.
391    pub min_semantic_score_override: Option<f32>,
392    /// v1.0.0: absolute *lexical* relevance floor for memory candidates,
393    /// as the fraction of the query's IDF-weighted discriminating power a
394    /// memory must cover. Unlike `min_semantic_score` this needs no query
395    /// embedding, so it protects the FTS-only hook path. When > 0.0, memory
396    /// candidates below the floor are dropped BEFORE scoring (so they don't
397    /// even set the per-kind normalization max). Repo-file/manifest
398    /// candidates are unaffected. 0.0 (default) disables it — every existing
399    /// `..Default::default()` construction is unchanged. Populated from
400    /// `BrokerSection.min_lexical_coverage` by the pipeline.
401    pub min_lexical_coverage: f32,
402    /// Some(0) explicitly disables the lexical floor; None inherits legacy behavior.
403    pub min_lexical_coverage_override: Option<f32>,
404    /// E3: inferred kind of the current task. Defaults to `Feature`
405    /// (the neutral kind) so every existing `..Default::default()`
406    /// construction is unchanged — Feature does NOT alter weights or
407    /// prefer_roles. Set by the pipeline via `classify_task` at intake.
408    pub task_kind: TaskKind,
409    /// v2.7: ABSOLUTE abstention floor. `min_score` above compares against the
410    /// normalized composite, whose top candidate always carries relevance 1.0
411    /// — it can rank candidates but cannot express "nothing here is genuinely
412    /// relevant", so it never abstains on a plausible-but-wrong corpus (the
413    /// workflow benchmark measured false-injection 1.00 at a 60-memory brain).
414    /// This floor gates on absolute evidence instead: the best raw cosine any
415    /// memory candidate achieved. When > 0.0 and no cosine-backed memory
416    /// candidate clears it — and the bundle would contain only memory capsules
417    /// — the retrieval returns `skipped: true` with zero tokens injected.
418    /// Lexical-only and cross-model candidates are unmeasured on this scale and
419    /// therefore exempt. Repo-file/manifest capsules also suppress the gate: an
420    /// FTS hit on real repo content is its own evidence the bundle is useful.
421    /// 0.0 (default) disables the gate. Populated from
422    /// `BrokerSection.abstain_min_score` by the pipeline.
423    pub abstain_evidence: f32,
424    /// Some(0) disables abstention; Some(-1) uses model auto; None inherits.
425    pub abstain_evidence_override: Option<f32>,
426}
427
428#[derive(Debug, Clone)]
429pub struct ContextBundle {
430    pub stage: String,
431    pub budget_tokens: u32,
432    pub used_tokens: u32,
433    pub capsules: Vec<ContextCapsule>,
434    pub excluded: Vec<ContextCapsule>,
435    /// v0.6: true when the top capsule score was below `min_score`.
436    /// All capsules are empty; no tokens were injected.
437    pub skipped: bool,
438    /// v0.6: best composite score observed before the skip check.
439    /// Useful for diagnostics ("why was the brain silent?").
440    pub top_score: f32,
441    /// v2.7: best ABSOLUTE cosine evidence any memory candidate achieved.
442    /// Unlike `top_score` this is not normalized, so "0.42" means the same
443    /// thing on a 3-memory brain and a 3000-memory brain. `-1.0` means no
444    /// comparable cosine was available (lean builds or cross-model rows), so
445    /// the cosine-calibrated gate has no verdict. Exposed for diagnostics and
446    /// threshold sweeps.
447    pub top_abs_evidence: f32,
448    /// v2.6: what fraction of the query's discriminating power the returned
449    /// capsules cover, *collectively*, in `[0, 1]`.
450    ///
451    /// Kimetsu already abstains at the bundle level — nothing above
452    /// `min_score` means an empty bundle and zero tokens. What it never did is
453    /// say anything about a bundle it *does* return, so a reader handed three
454    /// capsules that touch half the question has no way to tell that from
455    /// three that answer it, and confabulates the rest. That is what BEAM's
456    /// abstention track measures, and where Kimetsu scores worst (45% / 30%).
457    ///
458    /// IDF-weighted against the corpus, so a query term present in every
459    /// memory contributes nothing and a rare one dominates — the same weighting
460    /// the per-memory lexical floor uses, applied to the bundle as a whole.
461    /// 1.0 when there is no discriminating term to measure against.
462    pub evidence_coverage: f32,
463    /// v2.6: the discriminating query terms *no* returned capsule mentions.
464    ///
465    /// The actionable half of `evidence_coverage`: a reader can be told
466    /// precisely what memory does not know about, rather than being handed a
467    /// number. Empty when coverage is complete or unmeasurable.
468    pub uncovered_terms: Vec<String>,
469    /// v2.6: true when the query asked about order and the capsules were
470    /// re-rendered chronologically, oldest first, each carrying its date.
471    ///
472    /// The reader needs to be told, or a time-ordered bundle looks like a
473    /// relevance-ranked one whose ranking has gone wrong. See
474    /// [`crate::ordering`] for why ordering is rendered rather than retrieved.
475    pub chronological: bool,
476    /// Conflicts observed in eligible evidence before delivery trimming.
477    pub known_fact_conflicts: Vec<String>,
478}
479
480/// Discriminating weight per query token, for *bundle* coverage.
481///
482/// Deliberately not [`corpus_token_idf`]. That one zeroes a token the corpus
483/// has never seen (`df == 0`), because for the per-memory floor an
484/// out-of-corpus word would sink every candidate — the on-topic memory that
485/// matches the rare in-corpus word would be wrongly pruned.
486///
487/// For coverage the same fact means the opposite. A query term that appears in
488/// **no** memory is the strongest possible evidence that memory does not cover
489/// this question, which is precisely what the reader needs to be told. Zeroing
490/// it would make "how do I checkpoint the WAL during a Kubernetes rollout"
491/// report full coverage on the strength of the WAL half alone — the exact
492/// confabulation this is meant to prevent.
493///
494/// So `df == 0` gets the maximal weight, and only `df == N` (present in every
495/// memory, e.g. the project name) is zeroed.
496fn coverage_token_idf(conn: &Connection, tokens: &[String]) -> KimetsuResult<HashMap<String, f32>> {
497    token_idf(conn, tokens, false)
498}
499
500/// Render the partial-evidence warning for a bundle, if it needs one.
501///
502/// The point is to let a reader abstain on Kimetsu's advice rather than
503/// confabulate from partial evidence. Naming the missing terms is what makes
504/// that actionable — "memory does not cover X" is a fact the reader can act on,
505/// where a coverage number is not.
506///
507/// Returns `None` when coverage is adequate, so a complete bundle costs nothing.
508pub fn partial_evidence_notice(bundle: &ContextBundle) -> Option<String> {
509    if bundle.skipped || bundle.capsules.is_empty() {
510        return None; // an empty bundle already says everything it can
511    }
512    if bundle.evidence_coverage > PARTIAL_EVIDENCE_COVERAGE || bundle.uncovered_terms.is_empty() {
513        return None;
514    }
515    // Cap the list: naming twenty terms is noise, and the first few are the
516    // highest-IDF ones anyway (content_tokens preserves query order, and the
517    // uncovered list is filtered from it).
518    const MAX_NAMED: usize = 6;
519    let named: Vec<&str> = bundle
520        .uncovered_terms
521        .iter()
522        .take(MAX_NAMED)
523        .map(String::as_str)
524        .collect();
525    let more = bundle.uncovered_terms.len().saturating_sub(named.len());
526    let suffix = if more > 0 {
527        format!(" (and {more} more)")
528    } else {
529        String::new()
530    };
531    Some(format!(
532        "Partial memory: nothing above covers {}{}. Treat the rest as unknown \
533         rather than inferring it.",
534        named.join(", "),
535        suffix
536    ))
537}
538
539/// Coverage at or below which a bundle is worth flagging as partial.
540///
541/// Chosen to mirror `min_lexical_coverage`'s default (0.5): a bundle that
542/// collectively covers less of the query than a single memory would need to
543/// survive the per-memory floor is, by the system's own standard, thin.
544pub const PARTIAL_EVIDENCE_COVERAGE: f32 = 0.5;
545
546/// Measure how much of `query`'s discriminating power `capsules` collectively
547/// cover, and which terms none of them mention.
548///
549/// Deliberately computed over the *union* of the capsules rather than the best
550/// one: the question is whether the bundle answers the query, not whether any
551/// single memory does.
552pub(crate) fn evidence_coverage(
553    conn: &Connection,
554    query: &str,
555    capsules: &[ContextCapsule],
556) -> (f32, Vec<String>) {
557    let content = content_tokens(query);
558    if content.is_empty() {
559        return (1.0, Vec::new());
560    }
561    let Ok(idf) = coverage_token_idf(conn, &content) else {
562        return (1.0, Vec::new());
563    };
564    // Everything the bundle says, lowercased once.
565    let haystack = capsules
566        .iter()
567        .map(|c| c.summary.to_ascii_lowercase())
568        .collect::<Vec<_>>()
569        .join(" ");
570
571    let mut total = 0.0f32;
572    let mut hit = 0.0f32;
573    let mut uncovered = Vec::new();
574    for token in &content {
575        let weight = idf.get(token).copied().unwrap_or(0.0);
576        if weight <= 0.0 {
577            continue; // corpus-ubiquitous or out-of-corpus: no signal either way
578        }
579        total += weight;
580        if haystack.contains(token.as_str()) {
581            hit += weight;
582        } else {
583            uncovered.push(token.clone());
584        }
585    }
586    if total <= f32::EPSILON {
587        // No discriminating term to measure against — claiming a gap here
588        // would make every vague query look like a memory failure.
589        return (1.0, Vec::new());
590    }
591    (hit / total, uncovered)
592}
593
594/// S5.1: a single memory candidate produced by candidate generation and
595/// consumed by the broker (scoring, floors, rerank).
596///
597/// `pub(crate)` so `backend.rs` can name the type in the `RetrievalBackend`
598/// trait signature without exposing it outside the crate.
599#[derive(Debug, Clone)]
600pub(crate) struct Candidate {
601    pub(crate) capsule: ContextCapsule,
602    pub(crate) raw_relevance: f32,
603    /// D1e: the row's embedding vector, present when the row's
604    /// `embedding_model` matches the active query embedder's id.
605    /// `None` for repo-file/manifest candidates and for memory rows
606    /// whose model differs from the active embedder (cross-model
607    /// rows). Used by the candidate-stage embedding-MMR pass.
608    pub(crate) embedding: Option<Vec<f32>>,
609    /// D1e: raw cosine similarity between this candidate and the
610    /// query embedding. Present when `embedding` is `Some`. Used for
611    /// the absolute semantic relevance floor (min_semantic_score).
612    pub(crate) cosine: Option<f32>,
613    /// v2.6: the memory's RFC 3339 creation time, carried so the bundle can be
614    /// re-rendered in time order when the query asks about sequence (see
615    /// [`crate::ordering`]). `None` for repo files and manifests, which have no
616    /// position in the memory timeline.
617    pub(crate) created_at: Option<String>,
618}
619
620pub fn retrieve_context(
621    conn: &Connection,
622    repo_root: &str,
623    weights: &BrokerWeights,
624    request: ContextRequest,
625) -> KimetsuResult<ContextBundle> {
626    retrieve_context_multi(conn, repo_root, weights, request, &[])
627}
628
629/// v0.4.1: multi-conn variant. `extra_memory_conns` is searched for
630/// memory candidates only (repo files + manifests stay project-local).
631/// The candidate stream is concatenated BEFORE normalization so the
632/// blended set is normalized together — keeping a user-brain capsule
633/// and a project-brain capsule comparable on the same `raw_relevance`
634/// scale.
635///
636/// Today `extra_memory_conns` carries at most one entry (the user
637/// brain at `~/.kimetsu/brain.db`); the slice shape leaves room for
638/// future scope tiers (team brain, org brain) without breaking the
639/// signature.
640///
641/// v0.4.2: uses [`embeddings::open_default_embedder`] for the cosine
642/// term. Pre-v0.4.3 the default is `NoopEmbedder`, which short-
643/// circuits the cosine path so retrieval stays FTS-only — exact
644/// v0.4.1 behavior. v0.4.3 swaps the default to a real embedder.
645pub fn retrieve_context_multi(
646    conn: &Connection,
647    repo_root: &str,
648    weights: &BrokerWeights,
649    request: ContextRequest,
650    extra_memory_conns: &[&Connection],
651) -> KimetsuResult<ContextBundle> {
652    let embedder = embeddings::open_default_embedder();
653    retrieve_context_with_embedder(
654        conn,
655        repo_root,
656        weights,
657        request,
658        extra_memory_conns,
659        embedder,
660    )
661}
662
663/// v0.4.2: explicit-embedder variant. Lets tests inject `StubEmbedder`
664/// or any other [`Embedder`] without going through
665/// [`embeddings::open_default_embedder`]. v0.4.3 callers (chat REPL,
666/// MCP server) can also use this directly to hold one embedder
667/// instance for the lifetime of a session instead of paying the
668/// model-load cost on every retrieval.
669///
670/// S5.1: delegates to [`retrieve_context_with_embedder_and_backend`] with
671/// the default [`crate::backend::FlatBackend`]. All existing call sites
672/// (including the full test suite) are unchanged and continue to get exactly
673/// the pre-S5.1 FTS + ANN behaviour.
674pub fn retrieve_context_with_embedder(
675    conn: &Connection,
676    repo_root: &str,
677    weights: &BrokerWeights,
678    request: ContextRequest,
679    extra_memory_conns: &[&Connection],
680    embedder: &dyn Embedder,
681) -> KimetsuResult<ContextBundle> {
682    retrieve_context_with_embedder_and_backend(
683        conn,
684        repo_root,
685        weights,
686        request,
687        extra_memory_conns,
688        embedder,
689        &crate::backend::FlatBackend {
690            fusion: crate::fusion::Fusion::Linear,
691        },
692    )
693}
694
695/// S5.1: backend-aware variant of [`retrieve_context_with_embedder`].
696///
697/// Identical to `retrieve_context_with_embedder` except that the memory
698/// candidate step is delegated to `backend.memory_candidates()` instead of
699/// the hard-coded [`memory_candidates`] call. The broker (lexical/semantic
700/// floors, scoring, MMR, compression, budgeting) runs ABOVE the backend and
701/// is backend-agnostic.
702///
703/// [`BrainSession`] methods call this variant so the `[storage] backend`
704/// config field takes effect. Tests that call `retrieve_context_with_embedder`
705/// directly still use [`crate::backend::FlatBackend`] implicitly — zero
706/// behaviour change.
707pub(crate) fn retrieve_context_with_embedder_and_backend(
708    conn: &Connection,
709    repo_root: &str,
710    weights: &BrokerWeights,
711    request: ContextRequest,
712    extra_memory_conns: &[&Connection],
713    embedder: &dyn Embedder,
714    backend: &dyn crate::backend::RetrievalBackend,
715) -> KimetsuResult<ContextBundle> {
716    let query_embedding = QueryEmbedding::from_embedder(embedder, &request.query);
717    let half_life_days = weights.decay_half_life_days;
718    let mut candidates = Vec::new();
719    candidates.extend(backend.memory_candidates(
720        conn,
721        &request.query,
722        query_embedding.as_ref(),
723        half_life_days,
724        request.include_fact_evidence,
725    )?);
726    for extra in extra_memory_conns {
727        candidates.extend(backend.memory_candidates(
728            extra,
729            &request.query,
730            query_embedding.as_ref(),
731            half_life_days,
732            request.include_fact_evidence,
733        )?);
734    }
735    // v2.5.2 consolidation v1: bounded query-association routing boost —
736    // memories that repeatedly answered SIMILAR past queries (per the
737    // citation-derived query_routes table) gain up to ROUTING_BOOST_CAP
738    // relevance from a fixed per-retrieval budget. Applied to memory
739    // candidates only, before file/manifest candidates join the pool.
740    crate::reinforce::apply_query_routing(
741        conn,
742        &request.query,
743        query_embedding.as_ref(),
744        &mut candidates,
745    );
746
747    candidates.extend(repo_file_candidates(conn, repo_root, &request.query, 30)?);
748    candidates.extend(manifest_candidates(conn, repo_root, &request.query)?);
749
750    // v0.8: proactive kind filter — restrict to actionable kinds BEFORE
751    // scoring + capping so a higher-ranked repo file or off-kind memory
752    // can't take the proactive slot and get filtered out afterwards.
753    // Memory capsules carry the generic `kind: "memory"` and encode the
754    // real memory kind in the summary prefix ("scope:kind - text"), so
755    // match against that for memories.
756    if !request.kinds.is_empty() {
757        candidates.retain(|c| {
758            request
759                .kinds
760                .iter()
761                .any(|k| capsule_matches_kind(&c.capsule, k))
762        });
763    }
764
765    // v1.0.0: absolute LEXICAL relevance floor. The FTS-only hook path has
766    // no cosine, so the `min_semantic_score` floor below can't protect it —
767    // a broad conceptual query whose only matching tokens are corpus-
768    // ubiquitous (e.g. the project name) would otherwise surface unrelated
769    // memories, which per-kind normalization later promotes to relevance=1.0
770    // regardless of how weak the match is.
771    //
772    // We compute an IDF-weighted coverage in [0,1] over the query's CONTENT
773    // tokens (stopwords removed; ubiquitous tokens carry ~0 IDF so they don't
774    // drive coverage) and drop a memory candidate when its coverage is below
775    // the floor AND it has no semantic support. Applied BEFORE scoring so
776    // pruned rows don't even set the per-kind normalization max. Only memory
777    // candidates are floored — repo_file/manifest capsules pass through (an
778    // FTS match on file content is itself a relevance signal, and overview
779    // queries *want* the README). Inert when the floor is 0.0 or the query
780    // has no discriminating (non-ubiquitous) content token.
781    if request.min_lexical_coverage > 0.0 {
782        let content = content_tokens(&request.query);
783        if !content.is_empty() {
784            let idf = corpus_token_idf(conn, &content)?;
785            let total_idf: f32 = content
786                .iter()
787                .map(|t| idf.get(t).copied().unwrap_or(0.0))
788                .sum();
789            // Skip the floor when no content token is discriminating — every
790            // token is corpus-ubiquitous, so we have no signal to floor on.
791            if total_idf > f32::EPSILON {
792                candidates.retain(|c| {
793                    if c.capsule.kind != "memory" {
794                        return true; // repo_file / manifest pass through
795                    }
796                    // Semantic support keeps a lexically-thin but on-topic
797                    // memory on embeddings builds (cosine is None on the hook).
798                    if c.cosine.is_some_and(|cos| cos >= SEMANTIC_KEEP_COSINE) {
799                        return true;
800                    }
801                    weighted_coverage(&content, &idf, &c.capsule.summary)
802                        >= request.min_lexical_coverage
803                });
804            }
805        }
806    }
807
808    // E3: compose task-kind weight bias over stage weights, then renormalize.
809    // For Feature (default), weights_for_task_kind returns the base unchanged.
810    let stage_weights = weights_for_stage(weights, &request.stage);
811    let effective_weights = weights_for_task_kind(stage_weights, request.task_kind);
812    normalize_and_score(
813        &mut candidates,
814        effective_weights,
815        Normalization::from_config(&request.normalization),
816    );
817
818    // E3: merge task-kind prefer_role hints with caller-supplied prefer_roles.
819    // For Feature the hints are empty so this is a no-op (neutral).
820    let kind_role_hints = task_kind_prefer_roles(request.task_kind);
821    let mut effective_prefer_roles: Vec<String> = request.prefer_roles.clone();
822    for &hint in kind_role_hints {
823        let hint_s = hint.to_string();
824        if !effective_prefer_roles.contains(&hint_s) {
825            effective_prefer_roles.push(hint_s);
826        }
827    }
828
829    // v0.6: apply tag boost (1.4×) and role-preference boost (1.3×) after
830    // normalisation so the multipliers operate on the [0,1]-normalised score
831    // rather than the raw pre-normalisation values.
832    //
833    // E3: the role-preference check uses `capsule_matches_kind` so that
834    // memory capsules (whose outer `kind` is always `"memory"`) are matched
835    // against the real sub-kind embedded in their summary prefix
836    // (`"scope:kind - text"`). This makes task-kind prefer_role hints
837    // (e.g. "failure_pattern" for Debug) actually work for memory capsules.
838    // For non-memory capsules (repo_file, manifest) the outer kind is checked
839    // directly — same behaviour as before for caller-supplied prefer_roles.
840    if !request.tags.is_empty() || !effective_prefer_roles.is_empty() {
841        let tags_lc: Vec<String> = request
842            .tags
843            .iter()
844            .map(|t| t.to_ascii_lowercase())
845            .collect();
846        for c in &mut candidates {
847            let summary_lc = c.capsule.summary.to_ascii_lowercase();
848            if !tags_lc.is_empty() && tags_lc.iter().any(|t| summary_lc.contains(t.as_str())) {
849                c.capsule.score *= 1.4;
850            }
851            if !effective_prefer_roles.is_empty()
852                && effective_prefer_roles.iter().any(|r| {
853                    // For memory capsules: check the real sub-kind embedded in the
854                    // summary prefix ("scope:kind - text") via capsule_matches_kind.
855                    // This makes task-kind prefer_role hints work for memory capsules
856                    // whose outer `kind` field is always the generic "memory" string.
857                    // For non-memory capsules: fall back to the original substring
858                    // check on the outer `kind` field (preserves v0.6 behaviour for
859                    // caller-supplied prefer_roles like "semantic_operator").
860                    if c.capsule.kind == "memory" {
861                        capsule_matches_kind(&c.capsule, r.as_str())
862                    } else {
863                        c.capsule.kind.contains(r.as_str())
864                    }
865                })
866            {
867                c.capsule.score *= 1.3;
868            }
869        }
870    }
871
872    // v2.7: newer-wins supersession penalty. The workflow benchmark measured
873    // that a cited incumbent buries its own replacement: the usefulness boost
874    // feeds the relevance axis, freshness cannot distinguish memories written
875    // minutes apart (30-day half-life), and nothing at ranking time knows two
876    // candidates conflict. When two memory candidates are near-duplicates by
877    // embedding cosine, the OLDER one is penalized so the newer statement of
878    // the same topic wins the ordering regardless of its sibling's citations.
879    // Inert on lean builds (no embeddings) and for genuinely distinct memories.
880    apply_supersession_penalty(&mut candidates);
881
882    // D1e-2: absolute semantic relevance floor. On embeddings builds
883    // (query_embedding is Some), drop candidates whose cosine to the
884    // query is strictly below min_semantic_score. This ensures a
885    // genuinely-irrelevant corpus hits the zero-capsule skipped path
886    // rather than surfacing its "best of a bad lot". Inert on lean
887    // builds (query_embedding is None) or when floor is 0.0.
888    //
889    // Applied BEFORE the candidate→capsule conversion so irrelevant
890    // rows don't consume budget or affect normalization.
891    //
892    // Only applied to memory candidates (those with cosine populated);
893    // repo_file and manifest candidates have cosine=None and are
894    // always passed through — they're matched by FTS which is already
895    // a signal of relevance.
896    if query_embedding.is_some() && request.min_semantic_score > 0.0 {
897        candidates.retain(|c| {
898            // Keep non-memory candidates (no cosine) and memory
899            // candidates that cleared the floor.
900            match c.cosine {
901                Some(cos) => cos >= request.min_semantic_score,
902                None => true,
903            }
904        });
905    }
906
907    // D1e-1: candidate-stage embedding-MMR. On embeddings builds,
908    // apply MMR over the ranked Vec<Candidate> using cosine similarity
909    // between candidate embeddings as the redundancy measure. This
910    // collapses true semantic near-duplicates ("prefer rg over grep"
911    // and "use ripgrep") that Jaccard-of-tokens would miss.
912    //
913    // When EITHER candidate lacks an embedding (repo-file, manifest,
914    // or a cross-model memory row), falls back to Jaccard similarity
915    // of summary tokens — the same measure the existing capsule-stage
916    // MMR uses. This preserves lean parity exactly.
917    //
918    // Sort by score descending first so the greedy MMR seeds on the
919    // top-scoring candidate (same as the capsule-stage MMR).
920    candidates.sort_by(|a, b| {
921        b.capsule
922            .score
923            .partial_cmp(&a.capsule.score)
924            .unwrap_or(Ordering::Equal)
925            .then_with(|| {
926                b.capsule
927                    .freshness
928                    .partial_cmp(&a.capsule.freshness)
929                    .unwrap_or(Ordering::Equal)
930            })
931            // Deterministic tiebreak on the STABLE handle (memory:<id> /
932            // file:<path>) — capsule.id is a fresh random ULID per retrieval,
933            // so tiebreaking on it would make retrieval non-reproducible on
934            // score+freshness ties.
935            .then_with(|| a.capsule.expansion_handle.cmp(&b.capsule.expansion_handle))
936    });
937
938    // Run embedding-MMR on embeddings builds; lean builds skip directly
939    // to the capsule-stage Jaccard MMR below.
940    let embedding_mmr_ran = query_embedding.is_some() && !candidates.is_empty();
941    let candidates = if embedding_mmr_ran {
942        apply_candidate_mmr_diversity(candidates, 0.7)
943    } else {
944        candidates
945    };
946
947    // v2.6: keep each memory's creation time keyed by its stable handle before
948    // the candidates are consumed. Only built when the question is actually
949    // about order — on every other query it would be a map nobody reads.
950    let created_at_by_handle: std::collections::HashMap<String, String> =
951        if crate::ordering::is_ordering_query(&request.query) {
952            candidates
953                .iter()
954                .filter_map(|c| {
955                    c.created_at
956                        .clone()
957                        .map(|ts| (c.capsule.expansion_handle.clone(), ts))
958                })
959                .collect()
960        } else {
961            std::collections::HashMap::new()
962        };
963
964    // v2.7: best absolute evidence over the surviving memory candidates —
965    // COSINE ONLY. Lexical relevance lives on a different scale (token
966    // overlap, ~0.2-0.6 for good matches) and comparing it against a
967    // cosine-calibrated floor silently killed every lexical-only retrieval
968    // (lean builds, cross-model rows). When no candidate carries a cosine the
969    // gate has no verdict: -1.0 = "unmeasured", and both the hard gate below
970    // and the band arbitration treat it as exempt.
971    let top_abs_evidence = candidates
972        .iter()
973        .filter(|c| c.capsule.kind == "memory")
974        .filter_map(|c| c.cosine)
975        .fold(f32::NAN, f32::max);
976    let top_abs_evidence = if top_abs_evidence.is_nan() {
977        -1.0
978    } else {
979        top_abs_evidence
980    };
981    // Repo-file/manifest capsules suppress the evidence gate: an FTS hit on
982    // real repo content is its own evidence the bundle is useful.
983    let memory_only = candidates.iter().all(|c| c.capsule.kind == "memory");
984
985    let mut capsules = candidates
986        .into_iter()
987        .map(|candidate| candidate.capsule)
988        .collect::<Vec<_>>();
989
990    // After embedding-MMR the candidate list is already in MMR order.
991    // On lean builds (no embedding-MMR) we still need to sort by score.
992    if !embedding_mmr_ran {
993        capsules.sort_by(|left, right| {
994            right
995                .score
996                .partial_cmp(&left.score)
997                .unwrap_or(Ordering::Equal)
998                .then_with(|| {
999                    right
1000                        .freshness
1001                        .partial_cmp(&left.freshness)
1002                        .unwrap_or(Ordering::Equal)
1003                })
1004                // Stable handle tiebreak (capsule.id is random per retrieval).
1005                .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
1006        });
1007    }
1008
1009    // v0.6: confidence-aware skip — if the top score is below the caller's
1010    // threshold, return an empty bundle immediately. Zero tokens injected.
1011    //
1012    // v2.7: joined by the absolute abstention gate. `top_score` is normalized
1013    // (the best candidate always carries relevance 1.0), so `min_score` ranks
1014    // but cannot abstain; `abstain_evidence` compares the best RAW cosine /
1015    // relevance against an absolute floor, so a corpus with nothing genuinely
1016    // relevant stays silent instead of shipping its best-of-a-bad-lot.
1017    let top_score = capsules.first().map(|c| c.score).unwrap_or(0.0);
1018    let composite_skip = request.min_score > 0.0 && top_score < request.min_score;
1019    // The hard floor sits one band-width BELOW the configured threshold: the
1020    // [threshold - width, threshold) band is not decided here but by
1021    // [`rerank_and_arbitrate`] at the call sites that own a cross-encoder —
1022    // raw bi-encoder cosine under-scores paraphrased matches, and the band is
1023    // exactly where those live. Callers without a reranker fail the band
1024    // closed, which reproduces the plain hard-gate behavior.
1025    let evidence_skip = request.abstain_evidence > 0.0
1026        && memory_only
1027        && top_abs_evidence >= 0.0
1028        && top_abs_evidence < (request.abstain_evidence - abstain_band_width()).max(0.0);
1029    if composite_skip || evidence_skip {
1030        return Ok(ContextBundle {
1031            stage: request.stage,
1032            budget_tokens: request.budget_tokens,
1033            used_tokens: 0,
1034            capsules: Vec::new(),
1035            excluded: capsules,
1036            skipped: true,
1037            top_score,
1038            top_abs_evidence,
1039            // A skipped bundle covers nothing, by construction.
1040            evidence_coverage: 0.0,
1041            uncovered_terms: Vec::new(),
1042            // Nothing was rendered, so nothing was rendered in time order.
1043            chronological: false,
1044            known_fact_conflicts: vec![],
1045        });
1046    }
1047
1048    // MP-17 #13: capsule-stage Jaccard MMR — safety net / lean path.
1049    // On embeddings builds the candidate-stage embedding-MMR already
1050    // collapsed semantic near-duplicates; this pass is largely a no-op
1051    // (same-kind Jaccard score will be low for already-deduped summaries)
1052    // but provides a final guard against any remaining token-level
1053    // duplicates (e.g. repo files with heavily overlapping snippets).
1054    // On lean builds this is the sole diversity mechanism (unchanged).
1055    let capsules = apply_mmr_diversity(capsules, 0.7);
1056
1057    let capsule_budget = request.budget_tokens / 2;
1058    let mut used_tokens = 0u32;
1059    let mut included = Vec::new();
1060    let mut excluded = Vec::new();
1061
1062    for capsule in capsules {
1063        // v0.6: max_capsules cap (0 = disabled)
1064        if request.max_capsules > 0 && included.len() >= request.max_capsules {
1065            excluded.push(capsule);
1066            continue;
1067        }
1068        if (request.defer_fact_budget && request.max_capsules > 0)
1069            || used_tokens.saturating_add(capsule.token_estimate) <= capsule_budget
1070        {
1071            used_tokens = used_tokens.saturating_add(capsule.token_estimate);
1072            included.push(capsule);
1073        } else {
1074            excluded.push(capsule);
1075        }
1076    }
1077
1078    let (coverage, uncovered_terms) = evidence_coverage(conn, &request.query, &included);
1079
1080    // v2.6: presentation only, and last — the budget has already decided which
1081    // capsules ship, so re-rendering can neither admit one it rejected nor drop
1082    // one it chose. Coverage is measured before the date prefixes are added so
1083    // the score describes the memories, not their timestamps.
1084    //
1085    // `used_tokens` is recomputed because the prefixes are real tokens.
1086    let chronological = !created_at_by_handle.is_empty();
1087    let included = if chronological {
1088        let dated = crate::ordering::render_chronologically(included, &created_at_by_handle);
1089        used_tokens = dated.iter().map(|c| c.token_estimate).sum();
1090        dated
1091    } else {
1092        included
1093    };
1094
1095    Ok(ContextBundle {
1096        stage: request.stage,
1097        budget_tokens: request.budget_tokens,
1098        used_tokens,
1099        capsules: included,
1100        excluded,
1101        skipped: false,
1102        top_score,
1103        top_abs_evidence,
1104        evidence_coverage: coverage,
1105        uncovered_terms,
1106        chronological,
1107        known_fact_conflicts: vec![],
1108    })
1109}
1110
1111/// History/lineage path: search memories including expired ones (valid_to in the past).
1112///
1113/// This is the companion to `retrieve_context` for cases where you WANT to see
1114/// superseded, expired, or historically-valid memories — e.g. `kimetsu brain memory list`,
1115/// blame attribution, and lineage inspection.  The default retrieval path
1116/// (`retrieve_context` / `memory_candidates`) always excludes expired memories.
1117///
1118/// Returns the most recent `limit` active memories (invalidated_at IS NULL,
1119/// superseded_by IS NULL) including those whose `valid_to` has passed.
1120/// Superseded and invalidated rows are excluded (those are never valid for injection;
1121/// the `blame` path has its own direct SQL for those).
1122pub fn search_memories_including_expired(
1123    conn: &Connection,
1124    limit: u32,
1125) -> KimetsuResult<Vec<ContextCapsule>> {
1126    let mut stmt = conn.prepare_cached(
1127        "
1128        SELECT memory_id, scope, kind, text, confidence, created_at,
1129               use_count, usefulness_score, valid_from, valid_to
1130        FROM memories
1131        WHERE invalidated_at IS NULL
1132          AND superseded_by IS NULL
1133        ORDER BY created_at DESC
1134        LIMIT ?1
1135        ",
1136    )?;
1137    let rows = stmt.query_map(params![limit], |row| {
1138        Ok((
1139            row.get::<_, String>(0)?,
1140            row.get::<_, String>(1)?,
1141            row.get::<_, String>(2)?,
1142            row.get::<_, String>(3)?,
1143            row.get::<_, f32>(4)?,
1144            row.get::<_, String>(5)?,
1145            row.get::<_, i64>(6)?,
1146            row.get::<_, f64>(7)?,
1147            row.get::<_, Option<String>>(8)?,
1148            row.get::<_, Option<String>>(9)?,
1149        ))
1150    })?;
1151    let now_utc = OffsetDateTime::now_utc();
1152    let mut capsules = Vec::new();
1153    for row in rows {
1154        let (
1155            memory_id,
1156            scope,
1157            kind,
1158            text,
1159            confidence,
1160            created_at,
1161            _use_count,
1162            _usefulness,
1163            _valid_from,
1164            valid_to,
1165        ) = row?;
1166        let freshness = freshness(&created_at);
1167        let scope_weight = scope_weight(&scope);
1168        // Annotate expired memories so callers can identify them in history output.
1169        let suffix = if let Some(ref vt) = valid_to {
1170            if OffsetDateTime::parse(vt, &time::format_description::well_known::Rfc3339)
1171                .is_ok_and(|end| end <= now_utc)
1172            {
1173                format!(" [expired valid_to={vt}]")
1174            } else {
1175                format!(" [valid_to={vt}]")
1176            }
1177        } else {
1178            String::new()
1179        };
1180        let revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1181        let facts = crate::fact_store::load(conn, &memory_id, &revision)?;
1182        let claim_revision = Some(revision);
1183        capsules.push(ContextCapsule {
1184            id: new_id().to_string(),
1185            kind: "memory".to_string(),
1186            summary: format!("{scope}:{kind} - {text}{suffix}"),
1187            token_estimate: estimate_tokens(&text) + 8,
1188            expansion_handle: format!("memory:{memory_id}"),
1189            provenance: vec![ProvenanceRef {
1190                source: "Memory".to_string(),
1191                id: memory_id,
1192                excerpt: Some(excerpt(&text)),
1193            }],
1194            confidence,
1195            freshness,
1196            relevance: 0.0,
1197            scope_weight,
1198            score: 0.0,
1199            superseded_hint: false,
1200            rerank_policy_tier: 0,
1201            claim_revision,
1202            facts,
1203            rerank_usefulness: None,
1204            rerank_trust: None,
1205        });
1206    }
1207    Ok(capsules)
1208}
1209
1210pub fn search_repo_files(
1211    conn: &Connection,
1212    repo_root: &str,
1213    query: &str,
1214    limit: u32,
1215) -> KimetsuResult<Vec<ContextCapsule>> {
1216    let candidates = repo_file_candidates(conn, repo_root, query, limit)?;
1217    let mut capsules = candidates
1218        .into_iter()
1219        .map(|mut candidate| {
1220            candidate.capsule.relevance = candidate.raw_relevance;
1221            candidate.capsule.score = candidate.raw_relevance;
1222            candidate.capsule
1223        })
1224        .collect::<Vec<_>>();
1225    capsules.sort_by(|left, right| {
1226        right
1227            .score
1228            .partial_cmp(&left.score)
1229            .unwrap_or(Ordering::Equal)
1230            .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
1231    });
1232    Ok(capsules)
1233}
1234
1235// -----------------------------------------------------------------------
1236// ANN candidate generation via the usearch HNSW index — embeddings only.
1237// (The old brute-force `vec0` index code was removed in T3c; usearch now
1238// supersedes it entirely. See `crate::ann`.)
1239// -----------------------------------------------------------------------
1240
1241/// Top-K ANN candidates from the usearch HNSW index.
1242///
1243/// Returns memory rows fetched from `memories` (same columns as
1244/// `latest_memory_candidates`) built into `Candidate`s via
1245/// `memory_row_to_candidate`. Callers union this with the FTS set and dedup.
1246#[cfg(feature = "embeddings")]
1247fn memory_ann_candidates(
1248    conn: &Connection,
1249    qe: &QueryEmbedding,
1250    k: u32,
1251    query_tokens: &[String],
1252    half_life_days: f32,
1253    include_facts: bool,
1254) -> KimetsuResult<Vec<Candidate>> {
1255    // Tier-3: ANN candidate generation via the usearch HNSW index.
1256    let handle = crate::ann::handle_for_query(conn, qe.vector.len(), &qe.model_id)?;
1257    let hits = handle
1258        .read()
1259        .unwrap_or_else(|p| p.into_inner())
1260        .search(&qe.vector, k as usize)?;
1261    // Map rowids back to memory_ids (active-only is enforced by the index, but
1262    // we still join `memories` below for the full row + the embedding_model
1263    // residual filter, so collect rowids here).
1264    let knn_rowids: Vec<i64> = hits.into_iter().map(|(rowid, _dist)| rowid).collect();
1265    if knn_rowids.is_empty() {
1266        return Ok(Vec::new());
1267    }
1268
1269    // Fetch full memory rows for those rowids (same projection as latest_memory_candidates).
1270    let placeholders: String = knn_rowids
1271        .iter()
1272        .enumerate()
1273        .map(|(i, _)| format!("?{}", i + 1))
1274        .collect::<Vec<_>>()
1275        .join(", ");
1276    let sql = format!(
1277        "SELECT memory_id, scope, kind, text, confidence, created_at,
1278                use_count, usefulness_score, embedding, embedding_model,
1279                last_useful_at, provenance_snapshot_json
1280         FROM   memories
1281         WHERE  invalidated_at IS NULL
1282           AND  superseded_by IS NULL
1283           AND  (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
1284          AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
1285           AND  embedding_model = ?{model_param}
1286           AND  rowid IN ({placeholders})",
1287        model_param = knn_rowids.len() + 1
1288    );
1289    let mut stmt = conn.prepare(&sql)?;
1290    let mut params_vec: Vec<&dyn rusqlite::ToSql> = knn_rowids
1291        .iter()
1292        .map(|n| n as &dyn rusqlite::ToSql)
1293        .collect();
1294    params_vec.push(&qe.model_id);
1295    let rows_iter = stmt.query_map(params_vec.as_slice(), |row| {
1296        Ok((
1297            row.get::<_, String>(0)?,
1298            row.get::<_, String>(1)?,
1299            row.get::<_, String>(2)?,
1300            row.get::<_, String>(3)?,
1301            row.get::<_, f32>(4)?,
1302            row.get::<_, String>(5)?,
1303            row.get::<_, i64>(6)?,
1304            row.get::<_, f64>(7)?,
1305            row.get::<_, Option<Vec<u8>>>(8)?,
1306            row.get::<_, Option<String>>(9)?,
1307            row.get::<_, Option<String>>(10)?,
1308            row.get::<_, Option<String>>(11)?,
1309        ))
1310    })?;
1311
1312    let mut candidates = Vec::new();
1313    for row in rows_iter {
1314        let (
1315            memory_id,
1316            scope,
1317            kind,
1318            text,
1319            confidence,
1320            created_at,
1321            use_count,
1322            usefulness_score,
1323            embedding,
1324            embedding_model,
1325            last_useful_at,
1326            provenance_snapshot,
1327        ) = row?;
1328        let (cosine, row_vec) =
1329            compute_cosine_and_vec(Some(qe), embedding.as_deref(), embedding_model.as_deref());
1330        let claim_revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1331        if let Some(mut candidate) = memory_row_to_candidate(
1332            query_tokens,
1333            memory_id,
1334            scope,
1335            kind,
1336            text,
1337            confidence,
1338            created_at,
1339            use_count,
1340            usefulness_score,
1341            last_useful_at,
1342            provenance_snapshot,
1343            half_life_days,
1344            None, // no raw FTS relevance override — cosine drives ranking
1345            cosine,
1346            row_vec,
1347        ) {
1348            candidate.capsule.claim_revision = Some(claim_revision);
1349            hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
1350            candidates.push(candidate);
1351        }
1352    }
1353    Ok(candidates)
1354}
1355
1356/// Attach evidence only after row eligibility, while the text SELECT holds its snapshot.
1357pub(crate) fn hydrate_fact_evidence(
1358    conn: &Connection,
1359    candidate: &mut Candidate,
1360    include_facts: bool,
1361) -> KimetsuResult<()> {
1362    if include_facts {
1363        if let (Some(id), Some(revision)) = (
1364            candidate.capsule.expansion_handle.strip_prefix("memory:"),
1365            candidate.capsule.claim_revision.as_deref(),
1366        ) {
1367            candidate.capsule.facts = crate::fact_store::load(conn, id, revision)?;
1368        }
1369    }
1370    Ok(())
1371}
1372
1373/// S5.1: the flat memory candidate function exposed as `pub(crate)` so
1374/// [`crate::backend::FlatBackend`] can delegate to it without copying logic.
1375///
1376/// Runs the FTS + usearch-ANN (embeddings) or FTS + recency (lean) candidate
1377/// pipeline — identical behaviour to pre-S5.1.
1378pub(crate) fn memory_candidates_flat(
1379    conn: &Connection,
1380    query: &str,
1381    query_embedding: Option<&QueryEmbedding>,
1382    half_life_days: f32,
1383    fusion: crate::fusion::Fusion,
1384    include_facts: bool,
1385) -> KimetsuResult<Vec<Candidate>> {
1386    memory_candidates(
1387        conn,
1388        query,
1389        query_embedding,
1390        half_life_days,
1391        fusion,
1392        include_facts,
1393    )
1394}
1395
1396/// Build the flat candidate pool, merging the lexical and semantic rankings
1397/// with `fusion`.
1398///
1399/// The two sources — FTS5 and the ANN index — are independent rankings over the
1400/// same corpus, and how they are merged is a real ranking decision rather than
1401/// plumbing. See [`crate::fusion`].
1402fn memory_candidates(
1403    conn: &Connection,
1404    query: &str,
1405    query_embedding: Option<&QueryEmbedding>,
1406    half_life_days: f32,
1407    // Read only on the embeddings build: the lean path has a single ranking,
1408    // so there is nothing to fuse and any rule is the identity.
1409    #[cfg_attr(not(feature = "embeddings"), allow(unused_variables))] fusion: crate::fusion::Fusion,
1410    include_facts: bool,
1411) -> KimetsuResult<Vec<Candidate>> {
1412    let query_tokens = query_tokens(query);
1413
1414    // D1c: on embeddings builds with a real query vector, run BOTH FTS and
1415    // ANN and fuse the two rankings. This replaces the recency-bounded
1416    // latest_memory_candidates fallback as the semantic-recall source when
1417    // embeddings are active.
1418    #[cfg(feature = "embeddings")]
1419    if let Some(qe) = query_embedding {
1420        // FTS candidates (may be empty if no lexical matches).
1421        let fts_candidates = if let Some(fts_query) = fts_query(query) {
1422            memory_fts_candidates(
1423                conn,
1424                &query_tokens,
1425                &fts_query,
1426                80,
1427                Some(qe),
1428                half_life_days,
1429                include_facts,
1430            )?
1431        } else {
1432            Vec::new()
1433        };
1434
1435        // ANN candidates — top-80 nearest neighbours from the usearch index.
1436        let ann_candidates =
1437            memory_ann_candidates(conn, qe, 80, &query_tokens, half_life_days, include_facts)?;
1438
1439        // Both sources return best-first, which is what rank-based fusion needs.
1440        return Ok(crate::fusion::fuse(
1441            fusion,
1442            vec![fts_candidates, ann_candidates],
1443        ));
1444    }
1445
1446    // Lean (NoopEmbedder) path: unchanged — FTS then recency fallback.
1447    if let Some(fts_query) = fts_query(query) {
1448        let candidates = memory_fts_candidates(
1449            conn,
1450            &query_tokens,
1451            &fts_query,
1452            80,
1453            query_embedding,
1454            half_life_days,
1455            include_facts,
1456        )?;
1457        if !candidates.is_empty() {
1458            return Ok(candidates);
1459        }
1460    }
1461
1462    latest_memory_candidates(
1463        conn,
1464        &query_tokens,
1465        200,
1466        query_embedding,
1467        half_life_days,
1468        include_facts,
1469    )
1470}
1471
1472fn latest_memory_candidates(
1473    conn: &Connection,
1474    query_tokens: &[String],
1475    limit: u32,
1476    query_embedding: Option<&QueryEmbedding>,
1477    half_life_days: f32,
1478    include_facts: bool,
1479) -> KimetsuResult<Vec<Candidate>> {
1480    // MP-4d: exclude invalidated memories from retrieval. The row stays in
1481    // brain.db so `memory list` and replay can still see the history; only
1482    // the broker filters it out.
1483    //
1484    // v0.4.2: SELECT now also pulls the optional embedding + model id
1485    // so we can blend a cosine score with the lexical match.
1486    //
1487    // v0.5.1: SELECT also pulls `last_useful_at` so the broker can
1488    // apply the half-life decay term (memories that helped recently
1489    // outvote memories that haven't been confirmed useful in months).
1490    let mut stmt = conn.prepare_cached(
1491        "
1492        SELECT memory_id, scope, kind, text, confidence, created_at,
1493               use_count, usefulness_score, embedding, embedding_model,
1494               last_useful_at, provenance_snapshot_json
1495        FROM memories
1496        WHERE invalidated_at IS NULL
1497          AND superseded_by IS NULL
1498          AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
1499          AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
1500        ORDER BY created_at DESC
1501        LIMIT ?1
1502        ",
1503    )?;
1504
1505    let rows = stmt.query_map(params![limit], |row| {
1506        Ok((
1507            row.get::<_, String>(0)?,
1508            row.get::<_, String>(1)?,
1509            row.get::<_, String>(2)?,
1510            row.get::<_, String>(3)?,
1511            row.get::<_, f32>(4)?,
1512            row.get::<_, String>(5)?,
1513            row.get::<_, i64>(6)?,
1514            row.get::<_, f64>(7)?,
1515            row.get::<_, Option<Vec<u8>>>(8)?,
1516            row.get::<_, Option<String>>(9)?,
1517            row.get::<_, Option<String>>(10)?,
1518            row.get::<_, Option<String>>(11)?,
1519        ))
1520    })?;
1521
1522    let mut candidates = Vec::new();
1523    for row in rows {
1524        let (
1525            memory_id,
1526            scope,
1527            kind,
1528            text,
1529            confidence,
1530            created_at,
1531            use_count,
1532            usefulness_score,
1533            embedding,
1534            embedding_model,
1535            last_useful_at,
1536            provenance_snapshot,
1537        ) = row?;
1538        let (cosine, row_vec) = compute_cosine_and_vec(
1539            query_embedding,
1540            embedding.as_deref(),
1541            embedding_model.as_deref(),
1542        );
1543        let claim_revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1544        if let Some(mut candidate) = memory_row_to_candidate(
1545            query_tokens,
1546            memory_id,
1547            scope,
1548            kind,
1549            text,
1550            confidence,
1551            created_at,
1552            use_count,
1553            usefulness_score,
1554            last_useful_at,
1555            provenance_snapshot,
1556            half_life_days,
1557            None,
1558            cosine,
1559            row_vec,
1560        ) {
1561            candidate.capsule.claim_revision = Some(claim_revision);
1562            hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
1563            candidates.push(candidate);
1564        }
1565    }
1566    Ok(candidates)
1567}
1568
1569fn memory_fts_candidates(
1570    conn: &Connection,
1571    query_tokens: &[String],
1572    fts_query: &str,
1573    limit: u32,
1574    query_embedding: Option<&QueryEmbedding>,
1575    half_life_days: f32,
1576    include_facts: bool,
1577) -> KimetsuResult<Vec<Candidate>> {
1578    let mut stmt = conn.prepare_cached(
1579        "
1580        SELECT m.memory_id, m.scope, m.kind, m.text, m.confidence, m.created_at,
1581               m.use_count, m.usefulness_score, bm25(memories_fts) AS rank,
1582               m.embedding, m.embedding_model, m.last_useful_at,
1583               m.provenance_snapshot_json
1584        FROM memories_fts
1585        JOIN memories m
1586          ON m.memory_id = memories_fts.memory_id
1587        WHERE m.invalidated_at IS NULL
1588          AND m.superseded_by IS NULL
1589          AND (m.valid_from IS NULL OR julianday(m.valid_from) <= julianday('now'))
1590          AND (m.valid_to IS NULL OR julianday(m.valid_to) > julianday('now'))
1591          AND memories_fts MATCH ?1
1592        ORDER BY rank
1593        LIMIT ?2
1594        ",
1595    )?;
1596
1597    let rows = stmt.query_map(params![fts_query, limit], |row| {
1598        Ok((
1599            row.get::<_, String>(0)?,
1600            row.get::<_, String>(1)?,
1601            row.get::<_, String>(2)?,
1602            row.get::<_, String>(3)?,
1603            row.get::<_, f32>(4)?,
1604            row.get::<_, String>(5)?,
1605            row.get::<_, i64>(6)?,
1606            row.get::<_, f64>(7)?,
1607            row.get::<_, f64>(8)?,
1608            row.get::<_, Option<Vec<u8>>>(9)?,
1609            row.get::<_, Option<String>>(10)?,
1610            row.get::<_, Option<String>>(11)?,
1611            row.get::<_, Option<String>>(12)?,
1612        ))
1613    })?;
1614
1615    let mut candidates = Vec::new();
1616    for row in rows {
1617        let (
1618            memory_id,
1619            scope,
1620            kind,
1621            text,
1622            confidence,
1623            created_at,
1624            use_count,
1625            usefulness_score,
1626            rank,
1627            embedding,
1628            embedding_model,
1629            last_useful_at,
1630            provenance_snapshot,
1631        ) = row?;
1632        let fts_relevance = (-rank as f32).max(0.0);
1633        let (cosine, row_vec) = compute_cosine_and_vec(
1634            query_embedding,
1635            embedding.as_deref(),
1636            embedding_model.as_deref(),
1637        );
1638        let claim_revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1639        if let Some(mut candidate) = memory_row_to_candidate(
1640            query_tokens,
1641            memory_id,
1642            scope,
1643            kind,
1644            text,
1645            confidence,
1646            created_at,
1647            use_count,
1648            usefulness_score,
1649            last_useful_at,
1650            provenance_snapshot,
1651            half_life_days,
1652            Some(fts_relevance),
1653            cosine,
1654            row_vec,
1655        ) {
1656            candidate.capsule.claim_revision = Some(claim_revision);
1657            hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
1658            candidates.push(candidate);
1659        }
1660    }
1661    Ok(candidates)
1662}
1663
1664/// v0.4.2 / D1e: cosine helper — returns both the cosine score and the
1665/// decoded row embedding vector for a memory row. Used by all three
1666/// memory-candidate retrieval paths (FTS, ANN, latest-recency) to
1667/// populate `Candidate.cosine` and `Candidate.embedding` for the
1668/// candidate-stage embedding-MMR pass.
1669///
1670/// Returns `(None, None)` when:
1671///   * `query_embedding` is None (NoopEmbedder / lean build)
1672///   * The row has no embedding bytes
1673///   * The row's `embedding_model` doesn't match the active query's
1674///     model id (cross-model mismatch — vectors are incomparable)
1675///
1676/// Cross-model rows are intentionally NOT blended: a row embedded
1677/// with `stub-d8` and a query embedded with `bge-small-en-v1.5`
1678/// produce meaningless dot products. Falling back to FTS for those
1679/// rows keeps hybrid retrieval safe across schema upgrades and
1680/// `kimetsu brain reindex` migrations (v0.4.3).
1681///
1682/// D1e: variant that returns both the cosine score and the decoded row
1683/// embedding vector. Used by callsites that need to store the vector
1684/// on the `Candidate` for the candidate-stage embedding-MMR pass.
1685/// When the row is cross-model or has no embedding, both fields are
1686/// `None` — identical semantics to [`compute_cosine`].
1687fn compute_cosine_and_vec(
1688    query_embedding: Option<&QueryEmbedding>,
1689    row_bytes: Option<&[u8]>,
1690    row_model: Option<&str>,
1691) -> (Option<f32>, Option<Vec<f32>>) {
1692    let q = match query_embedding {
1693        Some(q) => q,
1694        None => return (None, None),
1695    };
1696    let bytes = match row_bytes {
1697        Some(b) => b,
1698        None => return (None, None),
1699    };
1700    let model = match row_model {
1701        Some(m) => m,
1702        None => return (None, None),
1703    };
1704    if model != q.model_id {
1705        return (None, None);
1706    }
1707    let row_vec = match decode_embedding(bytes, Some(q.vector.len())) {
1708        Ok(v) => v,
1709        Err(_) => return (None, None),
1710    };
1711    let score = cosine_similarity(&q.vector, &row_vec);
1712    (Some(score), Some(row_vec))
1713}
1714
1715#[allow(clippy::too_many_arguments)]
1716pub(crate) fn memory_row_to_candidate(
1717    query_tokens: &[String],
1718    memory_id: String,
1719    scope: String,
1720    kind: String,
1721    text: String,
1722    confidence: f32,
1723    created_at: String,
1724    use_count: i64,
1725    usefulness_score: f64,
1726    last_useful_at: Option<String>,
1727    // v2.6: the memory's stored provenance snapshot, classified into a trust
1728    // multiplier. See `crate::trust`.
1729    provenance_snapshot: Option<String>,
1730    half_life_days: f32,
1731    raw_relevance_override: Option<f32>,
1732    cosine_score: Option<f32>,
1733    // D1e: decoded embedding vector for this row (same model as the
1734    // active query embedder). None for cross-model rows, rows without
1735    // embeddings, or lean builds. Stored on Candidate for the
1736    // candidate-stage embedding-MMR pass.
1737    row_embedding: Option<Vec<f32>>,
1738) -> Option<Candidate> {
1739    let lexical = lexical_relevance(query_tokens, &format!("{kind} {text}"));
1740    let lexical_term = raw_relevance_override.unwrap_or(lexical).max(lexical);
1741
1742    // v0.4.2: hybrid blend.
1743    //   final = (1 - α) * lexical + α * normalized_cosine
1744    // where normalized_cosine maps [-1, 1] -> [0, 1] so it composes
1745    // with the lexical relevance scale.
1746    //
1747    // When cosine_score is None (NoopEmbedder, NULL row embedding,
1748    // cross-model mismatch), the cosine term drops out and the
1749    // candidate scores lexical-only — exact v0.4.1 behavior. The
1750    // caller's gate `raw_relevance <= 0.0 && !query_tokens.is_empty()`
1751    // still works because in the no-cosine path `raw_relevance ==
1752    // lexical_term`.
1753    let raw_relevance = match cosine_score {
1754        Some(c) => {
1755            let normalized_cos = ((c + 1.0) * 0.5).clamp(0.0, 1.0);
1756            (1.0 - DEFAULT_HYBRID_ALPHA) * lexical_term + DEFAULT_HYBRID_ALPHA * normalized_cos
1757        }
1758        None => lexical_term,
1759    };
1760
1761    // Drop the row when neither lexical nor cosine had any signal —
1762    // an empty query OR a candidate that didn't match any of the
1763    // search terms. The cosine-only path is still allowed through
1764    // (raw_relevance > 0) for semantic-only matches against rows
1765    // whose words don't textually overlap the query.
1766    if raw_relevance <= 0.0 && !query_tokens.is_empty() {
1767        return None;
1768    }
1769
1770    let freshness = freshness(&created_at);
1771    let scope_weight = scope_weight(&scope);
1772    // v0.5.1: usefulness multiplier with half-life decay applied to
1773    // the *deviation from neutral*. A 6-month-old memory that scored
1774    // 1.5 (max boost) decays toward 1.0 (neutral) — NOT toward 0,
1775    // because losing confidence in old signal shouldn't penalize a
1776    // memory below a brand-new memory with zero history.
1777    let raw_multiplier = usefulness_multiplier(usefulness_score as f32, use_count as u32);
1778    let decay = usefulness_decay(last_useful_at.as_deref(), &created_at, half_life_days);
1779    let multiplier = 1.0 + (raw_multiplier - 1.0) * decay;
1780    let biased_relevance = apply_usefulness_boost(raw_relevance, multiplier);
1781    // Retain the sign for older serialized consumers. New capsules carry the full multiplier.
1782    let rerank_policy_tier = if multiplier > 1.0 + f32::EPSILON {
1783        1
1784    } else if multiplier < 1.0 - f32::EPSILON {
1785        -1
1786    } else {
1787        0
1788    };
1789    // Reliance and outcome association do not verify a memory or erase origin.
1790    let provenance =
1791        crate::trust::Provenance::from_snapshot(provenance_snapshot.as_deref().unwrap_or("{}"));
1792    let trusted_relevance =
1793        biased_relevance * crate::trust::trust_multiplier(provenance, last_useful_at.is_some());
1794
1795    Some(Candidate {
1796        raw_relevance: trusted_relevance,
1797        embedding: row_embedding,
1798        cosine: cosine_score,
1799        created_at: Some(created_at),
1800        capsule: ContextCapsule {
1801            id: new_id().to_string(),
1802            kind: "memory".to_string(),
1803            summary: format!("{scope}:{kind} - {text}"),
1804            token_estimate: estimate_tokens(&text) + 8,
1805            expansion_handle: format!("memory:{memory_id}"),
1806            provenance: vec![ProvenanceRef {
1807                source: "Memory".to_string(),
1808                id: memory_id,
1809                excerpt: Some(excerpt(&text)),
1810            }],
1811            confidence,
1812            freshness,
1813            relevance: 0.0,
1814            scope_weight,
1815            score: 0.0,
1816            superseded_hint: false,
1817            rerank_policy_tier,
1818            claim_revision: None,
1819            facts: vec![],
1820            rerank_usefulness: Some(multiplier),
1821            rerank_trust: Some(crate::trust::trust_multiplier(
1822                provenance,
1823                last_useful_at.is_some(),
1824            )),
1825        },
1826    })
1827}
1828
1829/// v0.5.1: half-life decay factor applied to the *deviation from
1830/// neutral* of [`usefulness_multiplier`]. Returns a value in `[0.0,
1831/// 1.0]` where 1.0 = "use full envelope" (memory was confirmed useful
1832/// recently) and 0.0 = "treat as neutral" (memory's confirmation is
1833/// ancient).
1834///
1835/// Reference timestamp:
1836///   * `last_useful_at` (set by the projector when a cited memory's
1837///     run ended in run.finished) if present
1838///   * fallback to `created_at` so a brand-new memory that's never
1839///     been cited yet decays from its birthday — same shape, but
1840///     starts fresh.
1841///
1842/// Math:
1843///   decay = exp(-ln(2) * age_days / half_life_days)
1844/// so at age == half_life the contribution is halved, at 2*half_life
1845/// it's quartered, etc.
1846///
1847/// Safety rails:
1848///   * `half_life_days <= 0` disables decay (returns 1.0) so an
1849///     operator can opt out via project.toml.
1850///   * Unparseable RFC3339 timestamps return 1.0 — fail-open so a
1851///     corrupted row doesn't get silently demoted out of retrieval.
1852pub(crate) fn usefulness_decay(
1853    last_useful_at: Option<&str>,
1854    created_at: &str,
1855    half_life_days: f32,
1856) -> f32 {
1857    if half_life_days <= 0.0 {
1858        return 1.0;
1859    }
1860    let reference = last_useful_at.unwrap_or(created_at);
1861    let Ok(reference_ts) =
1862        OffsetDateTime::parse(reference, &time::format_description::well_known::Rfc3339)
1863    else {
1864        return 1.0;
1865    };
1866    let age = OffsetDateTime::now_utc() - reference_ts;
1867    let age_days = (age.whole_seconds().max(0) as f32) / 86_400.0;
1868    let exponent = -std::f32::consts::LN_2 * age_days / half_life_days;
1869    exponent.exp().clamp(0.0, 1.0)
1870}
1871
1872// v2.5.1: the boost cap and multiplier envelope live in crate::scoring (the
1873// one-page home of every learning-loop constant).
1874pub(crate) use crate::scoring::USEFULNESS_BOOST_CAP;
1875
1876/// Apply the usefulness multiplier to a relevance score with the boost gain
1877/// capped at [`USEFULNESS_BOOST_CAP`] (see there for why).
1878pub(crate) fn apply_usefulness_boost(raw_relevance: f32, multiplier: f32) -> f32 {
1879    if multiplier <= 1.0 {
1880        return raw_relevance * multiplier;
1881    }
1882    (raw_relevance * multiplier).min(raw_relevance + USEFULNESS_BOOST_CAP)
1883}
1884
1885/// MP-4b multiplier in [0.5, 1.5] derived from a memory's outcome history.
1886/// `use_count < 3` is treated as small-sample and yields 1.0 (neutral) so a
1887/// brand-new memory has a fair chance to demonstrate value before being
1888/// boosted or penalized.
1889pub(crate) fn usefulness_multiplier(usefulness_score: f32, use_count: u32) -> f32 {
1890    // MP-17e: soften the hard sample-size threshold via Bayesian smoothing.
1891    //
1892    // Old behaviour: hard cutoff at use_count < 3 returned neutral 1.0,
1893    // then full envelope kicked in. That meant a memory with 2 uses (both
1894    // helpful) was treated identically to a memory with 0 uses, which
1895    // wasted early signal. New behaviour: linearly blend toward the
1896    // full multiplier as use_count climbs to FULL_CONFIDENCE_USES.
1897    use crate::scoring::{FULL_CONFIDENCE_USES, MULTIPLIER_MAX, MULTIPLIER_MIN};
1898    if use_count == 0 {
1899        return 1.0;
1900    }
1901    let ratio = usefulness_score / use_count as f32; // in -1.0..1.0 typically
1902    let normalized = ((ratio + 1.0) / 2.0).clamp(0.0, 1.0); // map to 0..1
1903    let full_multiplier = MULTIPLIER_MIN + normalized * (MULTIPLIER_MAX - MULTIPLIER_MIN);
1904    let confidence = (use_count as f32 / FULL_CONFIDENCE_USES as f32).min(1.0);
1905    1.0 * (1.0 - confidence) + full_multiplier * confidence
1906}
1907
1908fn repo_file_candidates(
1909    conn: &Connection,
1910    repo_root: &str,
1911    query: &str,
1912    limit: u32,
1913) -> KimetsuResult<Vec<Candidate>> {
1914    let Some(fts_query) = fts_query(query) else {
1915        return Ok(Vec::new());
1916    };
1917
1918    let mut stmt = conn.prepare_cached(
1919        "
1920        SELECT path, snippet, language_guess, bm25(repo_files_fts) AS rank
1921        FROM repo_files_fts
1922        WHERE repo_root = ?1 AND repo_files_fts MATCH ?2
1923        ORDER BY rank
1924        LIMIT ?3
1925        ",
1926    )?;
1927
1928    let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
1929        Ok((
1930            row.get::<_, String>(0)?,
1931            row.get::<_, String>(1)?,
1932            row.get::<_, String>(2)?,
1933            row.get::<_, f64>(3)?,
1934        ))
1935    })?;
1936
1937    let mut candidates = Vec::new();
1938    for row in rows {
1939        let (path, snippet, language, rank) = row?;
1940        let raw_relevance = (-rank as f32).max(0.0);
1941        let summary = format!("{path} ({language}) - {}", excerpt(&snippet));
1942        let token_estimate = estimate_tokens(&summary) + 8;
1943        candidates.push(Candidate {
1944            raw_relevance,
1945            embedding: None,
1946            cosine: None,
1947            // A repo file has no position in the memory timeline.
1948            created_at: None,
1949            capsule: ContextCapsule {
1950                id: new_id().to_string(),
1951                kind: "repo_file".to_string(),
1952                summary,
1953                token_estimate,
1954                expansion_handle: format!("file:{path}"),
1955                provenance: vec![ProvenanceRef {
1956                    source: "RepoFile".to_string(),
1957                    id: path.clone(),
1958                    excerpt: Some(excerpt(&snippet)),
1959                }],
1960                confidence: 0.9,
1961                freshness: 1.0,
1962                relevance: 0.0,
1963                scope_weight: 0.9,
1964                score: 0.0,
1965                superseded_hint: false,
1966                rerank_policy_tier: 0,
1967                claim_revision: None,
1968                facts: vec![],
1969                rerank_usefulness: None,
1970                rerank_trust: None,
1971            },
1972        });
1973    }
1974    Ok(candidates)
1975}
1976
1977fn manifest_candidates(
1978    conn: &Connection,
1979    repo_root: &str,
1980    query: &str,
1981) -> KimetsuResult<Vec<Candidate>> {
1982    if let Some(fts_query) = fts_query(query) {
1983        let candidates = manifest_fts_candidates(conn, repo_root, &fts_query, 30)?;
1984        if !candidates.is_empty() {
1985            return Ok(candidates);
1986        }
1987    }
1988
1989    let query_tokens = query_tokens(query);
1990    let mut stmt = conn.prepare_cached(
1991        "
1992        SELECT manifest_path, manifest_kind, parsed_summary_json
1993        FROM repo_manifests
1994        WHERE repo_root = ?1
1995        ORDER BY manifest_path
1996        ",
1997    )?;
1998
1999    let rows = stmt.query_map(params![repo_root], |row| {
2000        Ok((
2001            row.get::<_, String>(0)?,
2002            row.get::<_, String>(1)?,
2003            row.get::<_, String>(2)?,
2004        ))
2005    })?;
2006
2007    let mut candidates = Vec::new();
2008    for row in rows {
2009        let (path, kind, summary_json) = row?;
2010        let raw_relevance =
2011            lexical_relevance(&query_tokens, &format!("{path} {kind} {summary_json}"));
2012        if raw_relevance <= 0.0 && !query_tokens.is_empty() {
2013            continue;
2014        }
2015        let summary = format!("{path} manifest ({kind})");
2016        let token_estimate = estimate_tokens(&summary) + 8;
2017        candidates.push(Candidate {
2018            raw_relevance,
2019            embedding: None,
2020            cosine: None,
2021            // A repo file has no position in the memory timeline.
2022            created_at: None,
2023            capsule: ContextCapsule {
2024                id: new_id().to_string(),
2025                kind: "repo_manifest".to_string(),
2026                summary,
2027                token_estimate,
2028                expansion_handle: format!("file:{path}"),
2029                provenance: vec![ProvenanceRef {
2030                    source: "Manifest".to_string(),
2031                    id: path,
2032                    excerpt: Some(excerpt(&summary_json)),
2033                }],
2034                confidence: 0.95,
2035                freshness: 1.0,
2036                relevance: 0.0,
2037                scope_weight: 0.9,
2038                score: 0.0,
2039                superseded_hint: false,
2040                rerank_policy_tier: 0,
2041                claim_revision: None,
2042                facts: vec![],
2043                rerank_usefulness: None,
2044                rerank_trust: None,
2045            },
2046        });
2047    }
2048    Ok(candidates)
2049}
2050
2051fn manifest_fts_candidates(
2052    conn: &Connection,
2053    repo_root: &str,
2054    fts_query: &str,
2055    limit: u32,
2056) -> KimetsuResult<Vec<Candidate>> {
2057    let mut stmt = conn.prepare_cached(
2058        "
2059        SELECT manifest_path, manifest_kind, parsed_summary_json,
2060               bm25(repo_manifests_fts) AS rank
2061        FROM repo_manifests_fts
2062        WHERE repo_root = ?1 AND repo_manifests_fts MATCH ?2
2063        ORDER BY rank
2064        LIMIT ?3
2065        ",
2066    )?;
2067
2068    let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
2069        Ok((
2070            row.get::<_, String>(0)?,
2071            row.get::<_, String>(1)?,
2072            row.get::<_, String>(2)?,
2073            row.get::<_, f64>(3)?,
2074        ))
2075    })?;
2076
2077    let mut candidates = Vec::new();
2078    for row in rows {
2079        let (path, kind, summary_json, rank) = row?;
2080        let raw_relevance = (-rank as f32).max(0.0);
2081        let summary = format!("{path} manifest ({kind})");
2082        let token_estimate = estimate_tokens(&summary) + 8;
2083        candidates.push(Candidate {
2084            raw_relevance,
2085            embedding: None,
2086            cosine: None,
2087            // A repo file has no position in the memory timeline.
2088            created_at: None,
2089            capsule: ContextCapsule {
2090                id: new_id().to_string(),
2091                kind: "repo_manifest".to_string(),
2092                summary,
2093                token_estimate,
2094                expansion_handle: format!("file:{path}"),
2095                provenance: vec![ProvenanceRef {
2096                    source: "Manifest".to_string(),
2097                    id: path,
2098                    excerpt: Some(excerpt(&summary_json)),
2099                }],
2100                confidence: 0.95,
2101                freshness: 1.0,
2102                relevance: 0.0,
2103                scope_weight: 0.9,
2104                score: 0.0,
2105                superseded_hint: false,
2106                rerank_policy_tier: 0,
2107                claim_revision: None,
2108                facts: vec![],
2109                rerank_usefulness: None,
2110                rerank_trust: None,
2111            },
2112        });
2113    }
2114    Ok(candidates)
2115}
2116
2117/// v2.6: how `raw_relevance` becomes the `relevance` term of the composite
2118/// score.
2119///
2120/// ## Per-kind (the rule through v2.5)
2121///
2122/// Each `kind` is normalized against the best `raw_relevance` *of that kind*.
2123/// The consequence is that the top memory and the top repo_file both score
2124/// `relevance = 1.0` no matter how good either actually is: on a query where
2125/// memory has the answer and no file is relevant, the best of the irrelevant
2126/// files is still promoted to a perfect relevance and competes for budget on
2127/// the strength of the other three score terms alone.
2128///
2129/// That is the distortion the lexical and semantic *floors* exist to
2130/// compensate for — they prune the weak candidate before normalization can
2131/// flatter it. A floor is a blunt instrument for this: it is a fixed
2132/// threshold standing in for a comparison the normalizer could just make.
2133///
2134/// ## Global
2135///
2136/// One max over all candidates, so `relevance` means the same thing across
2137/// kinds and a candidate that is merely the best of a bad kind keeps a low
2138/// relevance. Nothing else in the pipeline changes.
2139///
2140/// ## Which one runs
2141///
2142/// Selectable, defaulting to `per_kind`. Global normalization is the more
2143/// principled rule and it is *still* not the default here, for the same
2144/// reason RRF is not: the house rule is that a ranking change ships with a
2145/// measurement on a real corpus, and an argument from first principles is not
2146/// one. `[broker] normalization` and the per-request override are how a
2147/// corpus gets to settle it.
2148#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2149pub enum Normalization {
2150    /// Normalize within each capsule kind. Kimetsu's behaviour through v2.5.
2151    #[default]
2152    PerKind,
2153    /// Normalize against a single max over every candidate.
2154    Global,
2155}
2156
2157impl Normalization {
2158    /// Parse from config. Unknown values fall back to the default, matching
2159    /// how `[broker] fusion` treats an unrecognized rule — a typo in a config
2160    /// file must not silently change ranking.
2161    pub fn from_config(value: &str) -> Self {
2162        match value.trim().to_ascii_lowercase().as_str() {
2163            "global" => Self::Global,
2164            _ => Self::PerKind,
2165        }
2166    }
2167}
2168
2169fn normalize_and_score(
2170    candidates: &mut [Candidate],
2171    weights: StageWeights,
2172    normalization: Normalization,
2173) {
2174    // The per-kind rule keys on the capsule kind; the global rule uses one
2175    // bucket for everything. Sharing the map keeps a single scoring loop.
2176    let mut max_by_kind = HashMap::<String, f32>::new();
2177    let bucket = |candidate: &Candidate| match normalization {
2178        Normalization::PerKind => candidate.capsule.kind.clone(),
2179        Normalization::Global => String::new(),
2180    };
2181    for candidate in candidates.iter() {
2182        max_by_kind
2183            .entry(bucket(candidate))
2184            .and_modify(|max| *max = (*max).max(candidate.raw_relevance))
2185            .or_insert(candidate.raw_relevance);
2186    }
2187
2188    for candidate in candidates {
2189        let max = max_by_kind.get(&bucket(candidate)).copied().unwrap_or(0.0);
2190        let relevance = if max <= f32::EPSILON {
2191            if candidate.raw_relevance > 0.0 {
2192                1.0
2193            } else {
2194                0.0
2195            }
2196        } else {
2197            (candidate.raw_relevance / max).clamp(0.0, 1.0)
2198        };
2199        candidate.capsule.relevance = relevance;
2200        candidate.capsule.score = weights.relevance * relevance
2201            + weights.confidence * candidate.capsule.confidence
2202            + weights.freshness * candidate.capsule.freshness
2203            + weights.scope * candidate.capsule.scope_weight;
2204    }
2205}
2206
2207/// v2.7: two memory candidates whose embeddings agree at least this much are
2208/// treated as statements of the same topic for the newer-wins rule. High on
2209/// purpose — complementary-but-distinct lessons on one subject must not
2210/// penalize each other; only near-restatements qualify.
2211pub(crate) const SUPERSESSION_MIN_COSINE: f32 = 0.85;
2212
2213/// Explicit correction language supplies extra evidence that two moderately
2214/// similar memories are successive versions of one fact. The 0.82 floor admits
2215/// the measured "no longer uses replica-1" update pair (cosine 0.837) without
2216/// broadening the rule for ordinary memories, which still require 0.85.
2217pub(crate) const SUPERSESSION_CUE_MIN_COSINE: f32 = 0.82;
2218
2219/// A correction can also identify a rewritten update whose embedding moved
2220/// substantially because the replacement contains new implementation detail.
2221/// Require both several shared content tokens and dense overlap with the
2222/// smaller text; the cue alone is never sufficient.
2223pub(crate) const SUPERSESSION_CUE_MIN_SHARED_TOKENS: usize = 3;
2224pub(crate) const SUPERSESSION_CUE_MIN_LEXICAL_OVERLAP: f32 = 0.35;
2225
2226/// v2.7: score multiplier applied (once) to the older member of a
2227/// near-duplicate pair. Sized from the workflow benchmark's measured failure:
2228/// a cited incumbent's usefulness boost gives it a ~1.08× composite advantage
2229/// over its fresher replacement, so 0.80 flips the ordering with margin while
2230/// leaving genuinely-unrelated rankings untouched.
2231pub(crate) const SUPERSESSION_PENALTY: f32 = 0.80;
2232
2233/// v2.7: minimum age gap for the newer-wins rule. Supersession implies
2234/// temporal SUCCESSION — a near-duplicate written milliseconds apart is one
2235/// authoring event (a batch ingest, two variants of one lesson), not an
2236/// update, and penalizing the "older" of the pair broke the importance
2237/// dimension's cited-must-outrank assertion (92.1% → 81.6% on the
2238/// comprehensive A/B). One second cleanly separates same-batch co-writes
2239/// (Δ≈ms) from genuine cross-task updates (Δ≥ seconds); explicit correction
2240/// language provides the narrow exception for ordered batch imports.
2241pub(crate) const SUPERSESSION_MIN_AGE_GAP_SECS: f64 = 1.0;
2242
2243/// Text can establish succession even when storage timestamps cannot. This is
2244/// intentionally a narrow phrase list: it only applies to near-duplicate
2245/// memory pairs, and only when exactly one side carries an explicit correction
2246/// cue. Generic recency words such as "new" are excluded because they appear
2247/// in ordinary co-written lessons too often.
2248fn has_explicit_supersession_signal(text: &str) -> bool {
2249    // Normalize punctuation to word boundaries before matching. Correction
2250    // language commonly appears parenthesized ("(switched from tabs)"); a
2251    // literal-space matcher misses it even though the words are unambiguous.
2252    // Keeping outer spaces prevents the single-word cues from matching inside
2253    // unrelated words (for example `now` inside `known`).
2254    let words = text
2255        .to_ascii_lowercase()
2256        .chars()
2257        .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
2258        .collect::<String>();
2259    let text = format!(
2260        " {} ",
2261        words.split_whitespace().collect::<Vec<_>>().join(" ")
2262    );
2263    [
2264        " now ",
2265        " no longer ",
2266        " renamed from ",
2267        " switched from ",
2268        " as of ",
2269        " currently ",
2270        " after the ",
2271        " supersedes ",
2272        " replaced by ",
2273        " silently breaks ",
2274    ]
2275    .iter()
2276    .any(|signal| text.contains(signal))
2277}
2278
2279fn has_supersession_lexical_identity(a: &str, b: &str) -> bool {
2280    let a = content_tokens(a);
2281    let b = content_tokens(b);
2282    let smaller = a.len().min(b.len());
2283    if smaller == 0 {
2284        return false;
2285    }
2286    let shared = a.iter().filter(|token| b.contains(token)).count();
2287    shared >= SUPERSESSION_CUE_MIN_SHARED_TOKENS
2288        && shared as f32 / smaller as f32 >= SUPERSESSION_CUE_MIN_LEXICAL_OVERLAP
2289}
2290
2291/// v2.7: newer-wins supersession pass. Strong embedding near-duplicates
2292/// qualify directly; moderately similar or rewritten pairs must also carry
2293/// exactly one explicit correction cue and enough lexical identity. For
2294/// lexical-only rewrites, the replacement must be at least as relevant to the
2295/// current query so historical questions can still retrieve the old fact.
2296/// The OLDER candidate is multiplied by `SUPERSESSION_PENALTY` at most once,
2297/// however many newer siblings it has. Runs after normalization and boosts so
2298/// the penalty is the last word: a heavily-cited incumbent cannot out-boost
2299/// its own replacement.
2300///
2301/// Requires both embeddings and both parseable `created_at` timestamps; pairs
2302/// missing either are left alone (lean builds are unaffected end to end).
2303pub(crate) fn apply_supersession_penalty(candidates: &mut [Candidate]) {
2304    let parsed: Vec<Option<OffsetDateTime>> = candidates
2305        .iter()
2306        .map(|c| {
2307            if c.capsule.kind != "memory" || c.embedding.is_none() {
2308                return None;
2309            }
2310            c.created_at.as_deref().and_then(|ts| {
2311                OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339).ok()
2312            })
2313        })
2314        .collect();
2315
2316    let mut penalized = vec![false; candidates.len()];
2317    for i in 0..candidates.len() {
2318        let Some(ti) = parsed[i] else { continue };
2319        for j in (i + 1)..candidates.len() {
2320            let Some(tj) = parsed[j] else { continue };
2321            let (Some(ei), Some(ej)) = (&candidates[i].embedding, &candidates[j].embedding) else {
2322                continue;
2323            };
2324            let signals = (
2325                has_explicit_supersession_signal(&candidates[i].capsule.summary),
2326                has_explicit_supersession_signal(&candidates[j].capsule.summary),
2327            );
2328            let exactly_one_signal = signals.0 ^ signals.1;
2329            let similarity = crate::embeddings::cosine_similarity(ei, ej);
2330            let cue_related = exactly_one_signal
2331                && (similarity >= SUPERSESSION_CUE_MIN_COSINE
2332                    || has_supersession_lexical_identity(
2333                        &candidates[i].capsule.summary,
2334                        &candidates[j].capsule.summary,
2335                    ));
2336            if similarity < SUPERSESSION_MIN_COSINE && !cue_related {
2337                continue;
2338            }
2339            // Sub-second gaps (incl. identical timestamps) are normally one
2340            // authoring event, not a supersession. A single explicit
2341            // correction cue can still establish direction for batch imports;
2342            // if both or neither carry one, there is no defensible winner.
2343            let gap = (ti - tj).abs();
2344            let subsecond = (gap.whole_milliseconds().unsigned_abs() as f64)
2345                < SUPERSESSION_MIN_AGE_GAP_SECS * 1000.0;
2346            let older = if subsecond {
2347                match signals {
2348                    (true, false) => j,
2349                    (false, true) => i,
2350                    _ => continue,
2351                }
2352            } else {
2353                match ti.cmp(&tj) {
2354                    Ordering::Less => i,
2355                    Ordering::Greater => j,
2356                    Ordering::Equal => continue,
2357                }
2358            };
2359            if similarity < SUPERSESSION_CUE_MIN_COSINE {
2360                // Lexical-only rewrites below the cue-qualified cosine floor
2361                // can be related facts rather than mutually exclusive
2362                // restatements. Apply newer-wins only when the replacement is
2363                // at least as relevant to THIS query. This preserves historical
2364                // questions such as "which embedder reached MRR 1.0?" while
2365                // still resolving "what is preferred now?" to the newer
2366                // recommendation. Cue-marked pairs at 0.82+ are close enough
2367                // to use the ordinary newer-wins policy directly.
2368                let replacement = if older == i { j } else { i };
2369                let replacement_has_signal = if replacement == i {
2370                    signals.0
2371                } else {
2372                    signals.1
2373                };
2374                let query_favors_replacement =
2375                    match (candidates[replacement].cosine, candidates[older].cosine) {
2376                        (Some(replacement_cosine), Some(older_cosine)) => {
2377                            replacement_cosine >= older_cosine
2378                        }
2379                        _ => false,
2380                    };
2381                if !replacement_has_signal || !query_favors_replacement {
2382                    continue;
2383                }
2384            }
2385            if !penalized[older] {
2386                penalized[older] = true;
2387                candidates[older].capsule.score *= SUPERSESSION_PENALTY;
2388                // Mark it so post-retrieval reranking can reapply the penalty
2389                // on the cross-encoder's score domain.
2390                candidates[older].capsule.superseded_hint = true;
2391            }
2392        }
2393    }
2394}
2395
2396fn weights_for_stage(weights: &BrokerWeights, stage: &str) -> StageWeights {
2397    match stage {
2398        "localization" => weights.localization.clone(),
2399        "patch_plan" => weights.patch_plan.clone(),
2400        "verification" => weights.verification.clone(),
2401        "review" => weights.review.clone(),
2402        _ => None,
2403    }
2404    .unwrap_or(StageWeights {
2405        relevance: weights.relevance,
2406        confidence: weights.confidence,
2407        freshness: weights.freshness,
2408        scope: weights.scope,
2409    })
2410}
2411
2412fn scope_weight(scope: &str) -> f32 {
2413    match scope.parse::<MemoryScope>() {
2414        Ok(MemoryScope::Run) => 1.0,
2415        Ok(MemoryScope::Repo) => 0.9,
2416        Ok(MemoryScope::Project) => 0.7,
2417        Ok(MemoryScope::GlobalUser) => 0.5,
2418        Err(_) => 0.3,
2419    }
2420}
2421
2422fn freshness(created_at: &str) -> f32 {
2423    let Ok(created_at) =
2424        OffsetDateTime::parse(created_at, &time::format_description::well_known::Rfc3339)
2425    else {
2426        return 0.5;
2427    };
2428    let age = OffsetDateTime::now_utc() - created_at;
2429    let age_days = age.whole_seconds().max(0) as f32 / 86_400.0;
2430    (-std::f32::consts::LN_2 * age_days / 30.0)
2431        .exp()
2432        .clamp(0.0, 1.0)
2433}
2434
2435/// v1.0.0: a memory whose cosine to the query clears this bar is kept by
2436/// the lexical floor even when it shares few query words — a genuine
2437/// semantic match shouldn't be pruned for lexical thinness. Inert on the
2438/// FTS-only hook path (cosine is always `None` there).
2439const SEMANTIC_KEEP_COSINE: f32 = 0.20;
2440
2441/// v1.0.0: generic English function words carry no topical signal, so they
2442/// are stripped before the IDF-weighted lexical floor. Kept deliberately
2443/// small — only true stopwords. Content words like "repo" or "idea" are NOT
2444/// here; their commonness is handled by IDF, not a hand-maintained list.
2445const STOPWORDS: &[&str] = &[
2446    "the", "and", "for", "are", "but", "not", "you", "your", "with", "this", "that", "these",
2447    "those", "from", "into", "about", "what", "whats", "which", "who", "whom", "how", "why",
2448    "when", "where", "can", "could", "would", "should", "will", "shall", "does", "did", "was",
2449    "were", "been", "being", "have", "has", "had", "its", "it", "is", "as", "at", "by", "of", "to",
2450    "in", "on", "or", "an", "be", "do", "me", "my", "we", "us", "our", "im", "ive", "let", "lets",
2451    "please", "tell", "give", "show", "want", "need", "get", "got", "use", "using", "there",
2452    "their", "they", "them", "then", "than", "some", "any", "all", "more", "most", "such", "via",
2453    "per",
2454    // v2.6: the function words this list had always meant to cover. The
2455    // comment in `content_tokens` cited "during" as the reason stemming runs
2456    // after the stopword check, and "during" was not actually in the list —
2457    // harmless while these tokens only nudged a floor, but v2.6 shows uncovered
2458    // terms to the user by name, and "nothing above covers during" is noise.
2459    // These also reconcile this list with `graph::STOPWORDS`, which had a
2460    // different set; two disagreeing stopword lists in one codebase is its own
2461    // small defect.
2462    "during", "while", "until", "unless", "before", "after", "again", "against", "above", "below",
2463    "between", "through", "under", "over", "because", "also", "just", "only", "very", "much",
2464    "many", "each", "both", "same", "other", "another", "always", "never", "still", "even", "ever",
2465    "every", "first", "found", "thing", "things", "value", "default", "if", "so", "up", "out",
2466    "off", "down", "no", "yes",
2467];
2468
2469/// v1.0.0: tokenize a query into deduped CONTENT tokens — the same word
2470/// split as [`query_tokens`] but with stopwords removed and WITHOUT the
2471/// `CLASS_HINTS` tool-name expansions (those are a retrieval *boost*, not
2472/// part of the user's topical intent). Used only by the lexical floor.
2473fn content_tokens(query: &str) -> Vec<String> {
2474    let mut seen = std::collections::HashSet::new();
2475    query
2476        .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
2477        .map(str::trim)
2478        .filter(|part| part.len() >= 2)
2479        .map(str::to_ascii_lowercase)
2480        .filter(|t| !STOPWORDS.contains(&t.as_str()))
2481        // Stem AFTER the stopword check ("during" must not stem to "dur"
2482        // and dodge the list) so inflected variants share one IDF entry.
2483        .map(|t| light_stem(&t).to_string())
2484        .filter(|t| seen.insert(t.clone()))
2485        .collect()
2486}
2487
2488/// v1.0.0: discriminating weight for each content token over the
2489/// (non-invalidated) memory corpus, where `df` is the number of memories
2490/// whose text contains the token as a substring (matching
2491/// [`lexical_relevance`]'s substring semantics). Only tokens that actually
2492/// partition the corpus carry weight; the two useless extremes are zeroed:
2493///
2494///   * `df == N` — the token is in EVERY memory (the project name). `idf =
2495///     ln((N+1)/(N+1)) = 0` falls out of the formula naturally.
2496///   * `df == 0` — the token is in NO memory (an out-of-corpus word like a
2497///     generic English verb). It can't distinguish one memory from another,
2498///     so it's forced to 0. Leaving it at its (maximal) raw IDF would let a
2499///     single generic query word sink every candidate's coverage below the
2500///     floor — the on-topic memory that matches the *rare, in-corpus* word
2501///     would be wrongly pruned.
2502///
2503/// Everything in between gets `idf = ln((N+1)/(df+1))` — rarer ⇒ larger.
2504/// Best-effort: a query/count failure yields 0 for that token (fail-open).
2505fn corpus_token_idf(conn: &Connection, tokens: &[String]) -> KimetsuResult<HashMap<String, f32>> {
2506    token_idf(conn, tokens, true)
2507}
2508
2509/// Prefix MATCH uses the same FTS tokenizer as candidate retrieval. Each matched
2510/// memory counts once even when its text repeats a token. N and df both refer to
2511/// non-invalidated indexed documents, including historical/superseded documents.
2512/// One indexed posting-list lookup per unique token replaces per-term text scans.
2513fn token_idf(
2514    conn: &Connection,
2515    tokens: &[String],
2516    zero_absent: bool,
2517) -> KimetsuResult<HashMap<String, f32>> {
2518    let n: i64 = conn.query_row(
2519        "SELECT COUNT(*) FROM memories_fts JOIN memories m USING(memory_id) WHERE m.invalidated_at IS NULL",
2520        [], |r| r.get(0))?;
2521    let mut idf = HashMap::new();
2522    if n == 0 {
2523        return Ok(idf);
2524    }
2525    let mut stmt = conn.prepare_cached(
2526        "SELECT COUNT(DISTINCT m.memory_id) FROM memories_fts JOIN memories m USING(memory_id)
2527         WHERE memories_fts MATCH ?1 AND m.invalidated_at IS NULL",
2528    )?;
2529    for token in tokens {
2530        if idf.contains_key(token) {
2531            continue;
2532        }
2533        let pattern = format!("text : \"{}\"*", token.replace('"', "\"\""));
2534        let df: i64 = stmt.query_row(params![pattern], |r| r.get(0))?;
2535        let weight = if zero_absent && df == 0 {
2536            0.0
2537        } else {
2538            (((n + 1) as f32) / ((df + 1) as f32)).ln().max(0.0)
2539        };
2540        idf.insert(token.clone(), weight);
2541    }
2542    Ok(idf)
2543}
2544
2545/// v1.0.0: the IDF-weighted fraction of the query's discriminating power that
2546/// `summary` lexically covers, in `[0,1]`. Tokens present in the haystack
2547/// contribute their IDF weight to the numerator; all tokens contribute to the
2548/// denominator. A summary that matches only the query's low-IDF (common)
2549/// words scores near 0; one that matches the rare, topical words scores near
2550/// 1. Returns 0 when the total weight is ~0 (all tokens ubiquitous).
2551fn weighted_coverage(content: &[String], idf: &HashMap<String, f32>, summary: &str) -> f32 {
2552    let haystack = summary.to_ascii_lowercase();
2553    let mut total = 0.0f32;
2554    let mut hit = 0.0f32;
2555    for token in content {
2556        let weight = idf.get(token).copied().unwrap_or(0.0);
2557        total += weight;
2558        if weight > 0.0 && haystack.contains(token.as_str()) {
2559            hit += weight;
2560        }
2561    }
2562    if total <= f32::EPSILON {
2563        0.0
2564    } else {
2565        (hit / total).clamp(0.0, 1.0)
2566    }
2567}
2568
2569/// v1.0.0: light query-side stemming — strip the common English inflection
2570/// suffixes so "benchmarked"/"benchmarking" reduce to "benchmark". Because
2571/// downstream matching uses substring lexical hints and FTS-prefix document
2572/// frequencies (`fts_query` also appends `*`), the
2573/// stem matches every variant in the corpus while the inflected form matches
2574/// none of them — an unstemmed "benchmarked" gets df=0, loses all IDF
2575/// weight, and the relevance floor goes blind on the query's one
2576/// discriminating word. Haystacks stay raw; only query tokens are stemmed.
2577/// Conservative: a suffix is stripped only when ≥4 chars remain, and only
2578/// one suffix is stripped.
2579///
2580/// v2.6: plus the English y→ies rule, which the suffix list alone gets wrong in
2581/// both directions. `"retries"` strips `es` to `retri`; `"retry"` matches no
2582/// suffix and stays `retry`; neither is a prefix of the other, so a query
2583/// asking about `retry` treats a corpus that says `retries` as not mentioning
2584/// it at all. BrainBench's sycophancy track found this by flagging a gap on a
2585/// question the memories plainly answered — the same defect silently costs the
2586/// lexical floor its IDF weight on any `-y` word (`query`, `policy`, `memory`,
2587/// `binary`), which is a large share of the vocabulary this corpus is made of.
2588///
2589/// Stripping a trailing `y`/`i` after a consonant collapses both forms onto the
2590/// shared prefix (`retry`, `retries` → `retr`), which is what substring and
2591/// FTS-prefix matching need. Only after a consonant, so `day`/`key` keep their
2592/// vowel-`y`, and only with ≥4 chars remaining, so short words are left alone.
2593fn light_stem(token: &str) -> &str {
2594    let mut stem = token;
2595    for suffix in ["ing", "ed", "es", "s"] {
2596        if let Some(stripped) = token.strip_suffix(suffix)
2597            && stripped.len() >= 4
2598        {
2599            stem = stripped;
2600            break;
2601        }
2602    }
2603    if stem.len() >= 5
2604        && let Some(trimmed) = stem.strip_suffix('y').or_else(|| stem.strip_suffix('i'))
2605        && trimmed
2606            .chars()
2607            .next_back()
2608            .is_some_and(|c| !matches!(c, 'a' | 'e' | 'i' | 'o' | 'u'))
2609    {
2610        return trimmed;
2611    }
2612    stem
2613}
2614
2615fn query_tokens(query: &str) -> Vec<String> {
2616    let mut tokens: Vec<String> = query
2617        .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
2618        .map(str::trim)
2619        .filter(|part| part.len() >= 2)
2620        .map(str::to_ascii_lowercase)
2621        .map(|t| light_stem(&t).to_string())
2622        .collect();
2623    // MP-17 #11: task-class routing — augment the query with tool-aware
2624    // tokens so MP-17b's tool-proficiency capsules surface higher when
2625    // the task description matches a known class. Cheap keyword fan-out;
2626    // the underlying lexical_relevance counts substring matches so the
2627    // augmented tokens only matter when a capsule's text actually mentions
2628    // them (i.e. the new MP-17b capsules light up, not generic text).
2629    let lower = query.to_ascii_lowercase();
2630    for (triggers, expansions) in CLASS_HINTS.iter() {
2631        if triggers.iter().any(|t| lower.contains(t)) {
2632            tokens.extend(expansions.iter().map(|e| e.to_string()));
2633        }
2634    }
2635    tokens
2636}
2637
2638// MP-17 #11: (trigger keywords, expansion tokens) pairs.
2639//
2640// When the user task mentions a trigger, we add the expansions to the
2641// query token set. Capsules whose text mentions the same expansions
2642// then score higher on lexical_relevance. The expansions are kimetsu
2643// tool / concept names so MP-17b capsules (which document those tools)
2644// surface preferentially.
2645const CLASS_HINTS: &[(&[&str], &[&str])] = &[
2646    (
2647        &[
2648            "build",
2649            "compile",
2650            "make",
2651            "cargo",
2652            "cmake",
2653            "configure",
2654            "install",
2655            "train",
2656            "benchmark",
2657            "test suite",
2658            "ray trace",
2659            "render",
2660        ],
2661        &[
2662            "shell_background",
2663            "shell_status",
2664            "shell_output",
2665            "shell_stop",
2666            "long_running",
2667        ],
2668    ),
2669    (
2670        &[
2671            "edit", "modify", "change", "fix", "update", "patch", "refactor", "rename",
2672        ],
2673        &["edit_file", "apply_patch", "old_string", "new_string"],
2674    ),
2675    (
2676        &[
2677            "read", "inspect", "review", "analyze", "examine", "view", "show",
2678        ],
2679        &["read_file", "offset", "limit", "multi_read"],
2680    ),
2681    (
2682        &["find", "locate", "search", "look up", "discover", "list"],
2683        &["glob", "search_files", "list_files"],
2684    ),
2685    (
2686        &["plan", "step", "checklist", "todo", "task list", "phase"],
2687        &["plan", "todos"],
2688    ),
2689    (
2690        &[
2691            "verify",
2692            "check",
2693            "ensure",
2694            "validate",
2695            "pass test",
2696            "verifier",
2697        ],
2698        &["finish", "verifier", "verification"],
2699    ),
2700    (
2701        &[
2702            "image",
2703            "png",
2704            "jpeg",
2705            "jpg",
2706            "pdf",
2707            "diagram",
2708            "screenshot",
2709        ],
2710        &["view_image", "base64", "sha256"],
2711    ),
2712    (&["delete", "remove", "rm "], &["delete_file", "recursive"]),
2713    (&["rename", "move file", "mv "], &["move_file"]),
2714];
2715
2716/// v0.8: does a capsule satisfy a requested (memory) kind? Repo/manifest
2717/// capsules match only by their literal `kind`; memory capsules
2718/// (`kind == "memory"`) match against the real kind embedded in their
2719/// `"scope:kind - text"` summary prefix.
2720fn capsule_matches_kind(capsule: &ContextCapsule, wanted: &str) -> bool {
2721    if capsule.kind == wanted {
2722        return true;
2723    }
2724    if capsule.kind == "memory"
2725        && let Some((prefix, _)) = capsule.summary.split_once(" - ")
2726        && let Some((_scope, mkind)) = prefix.split_once(':')
2727    {
2728        return mkind == wanted;
2729    }
2730    false
2731}
2732
2733pub(crate) fn fts_query(query: &str) -> Option<String> {
2734    let tokens = query_tokens(query);
2735    if tokens.is_empty() {
2736        return None;
2737    }
2738    Some(
2739        tokens
2740            .into_iter()
2741            .take(12)
2742            .map(|token| format!("{token}*"))
2743            .collect::<Vec<_>>()
2744            .join(" OR "),
2745    )
2746}
2747
2748/// D1e: candidate-stage MMR using embedding cosine similarity as the
2749/// redundancy measure, with Jaccard-of-summary-tokens as the fallback
2750/// when either candidate lacks an embedding vector.
2751///
2752/// Called BEFORE the candidate→capsule conversion so the `Candidate`
2753/// embedding fields are still accessible. Input must already be sorted
2754/// by descending score (the pipeline sorts before calling this).
2755///
2756/// Redundancy measure:
2757///   * Both candidates have embeddings of the same model → cosine(a, b).
2758///     cosine ∈ [-1, 1]; we use it directly as the overlap penalty.
2759///     Two paraphrases ("prefer rg" / "use ripgrep") will typically
2760///     share high cosine (≥0.85) and collapse to one slot.
2761///   * Either candidate lacks an embedding → Jaccard of summary-token
2762///     sets, scaled by 0.5 for cross-kind pairs (mirrors the existing
2763///     capsule-stage logic).
2764///
2765/// Cross-kind pairs are penalized at half the same-kind rate for both
2766/// measures (consistent with the capsule-stage Jaccard MMR).
2767fn apply_candidate_mmr_diversity(mut sorted: Vec<Candidate>, lambda: f32) -> Vec<Candidate> {
2768    if sorted.len() <= 1 {
2769        return sorted;
2770    }
2771    // Pre-tokenize summaries for the Jaccard fallback.
2772    let summaries: Vec<std::collections::HashSet<String>> = sorted
2773        .iter()
2774        .map(|c| summary_token_set(&c.capsule.summary))
2775        .collect();
2776
2777    let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
2778    let mut remaining: Vec<usize> = (0..sorted.len()).collect();
2779
2780    // Seed with the highest-scoring candidate.
2781    picked_indices.push(remaining.remove(0));
2782
2783    while !remaining.is_empty() {
2784        let mut best_idx_in_remaining = 0;
2785        let mut best_score = f32::MIN;
2786
2787        for (i, &cand) in remaining.iter().enumerate() {
2788            let mut max_overlap = 0.0f32;
2789            for &p in &picked_indices {
2790                // Compute redundancy between candidate `cand` and
2791                // already-picked `p`.
2792                let same_kind = sorted[cand].capsule.kind == sorted[p].capsule.kind;
2793                let raw_overlap = candidate_pair_overlap(
2794                    &sorted[cand],
2795                    &sorted[p],
2796                    &summaries[cand],
2797                    &summaries[p],
2798                );
2799                let overlap = if same_kind {
2800                    raw_overlap
2801                } else {
2802                    raw_overlap * 0.5
2803                };
2804                if overlap > max_overlap {
2805                    max_overlap = overlap;
2806                }
2807            }
2808            let mmr = lambda * sorted[cand].capsule.score - (1.0 - lambda) * max_overlap;
2809            if mmr > best_score {
2810                best_score = mmr;
2811                best_idx_in_remaining = i;
2812            }
2813        }
2814        picked_indices.push(remaining.remove(best_idx_in_remaining));
2815    }
2816
2817    // Reconstruct in picked order.
2818    let mut taken: Vec<Option<Candidate>> = sorted.drain(..).map(Some).collect();
2819    let mut out = Vec::with_capacity(taken.len());
2820    for idx in picked_indices {
2821        if let Some(c) = taken[idx].take() {
2822            out.push(c);
2823        }
2824    }
2825    out
2826}
2827
2828/// D1e: overlap between two candidates for MMR.
2829///
2830/// * Both have embeddings → cosine similarity (clamped to [0,1] to
2831///   treat anti-correlated vectors as non-redundant, not negatively
2832///   redundant).
2833/// * Either lacks an embedding → Jaccard of summary-token sets.
2834fn candidate_pair_overlap(
2835    a: &Candidate,
2836    b: &Candidate,
2837    tokens_a: &std::collections::HashSet<String>,
2838    tokens_b: &std::collections::HashSet<String>,
2839) -> f32 {
2840    if let (Some(va), Some(vb)) = (a.embedding.as_deref(), b.embedding.as_deref()) {
2841        // Cosine in [-1,1]; clamp to [0,1] so negative correlation
2842        // (very different content) contributes 0 overlap rather than
2843        // a negative penalty (which would spuriously boost unrelated
2844        // content over moderately-related content).
2845        cosine_similarity(va, vb).max(0.0)
2846    } else {
2847        jaccard(tokens_a, tokens_b)
2848    }
2849}
2850
2851/// MP-17 #13: greedy MMR (Maximal Marginal Relevance) re-ranking.
2852///
2853/// Given capsules already sorted by relevance score, walk the list and
2854/// at each step pick the next capsule that maximizes
2855/// `lambda * score - (1 - lambda) * max_overlap_with_already_picked`.
2856///
2857/// Overlap = Jaccard similarity of the lowercased token sets of the
2858/// `summary` field. Capsules from different kinds (memory / repo_file /
2859/// manifest) get a 0.5 similarity floor so redundancy is only penalized
2860/// within-kind (a memory and a repo_file aren't really redundant even
2861/// if they share words).
2862fn apply_mmr_diversity(mut sorted: Vec<ContextCapsule>, lambda: f32) -> Vec<ContextCapsule> {
2863    if sorted.len() <= 1 {
2864        return sorted;
2865    }
2866    // Pre-tokenize summaries for cheap Jaccard.
2867    let summaries: Vec<std::collections::HashSet<String>> = sorted
2868        .iter()
2869        .map(|c| summary_token_set(&c.summary))
2870        .collect();
2871    let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
2872    let mut remaining: Vec<usize> = (0..sorted.len()).collect();
2873
2874    // Always seed with the top-scoring capsule.
2875    picked_indices.push(remaining.remove(0));
2876
2877    while !remaining.is_empty() {
2878        let mut best_idx_in_remaining = 0;
2879        let mut best_score = f32::MIN;
2880        for (i, &cand) in remaining.iter().enumerate() {
2881            let mut max_overlap = 0.0f32;
2882            for &p in &picked_indices {
2883                let raw = jaccard(&summaries[cand], &summaries[p]);
2884                let overlap = if sorted[cand].kind == sorted[p].kind {
2885                    raw
2886                } else {
2887                    // cross-kind: scale down so we don't over-penalize a memory
2888                    // that happens to share words with a repo file.
2889                    raw * 0.5
2890                };
2891                if overlap > max_overlap {
2892                    max_overlap = overlap;
2893                }
2894            }
2895            let mmr = lambda * sorted[cand].score - (1.0 - lambda) * max_overlap;
2896            if mmr > best_score {
2897                best_score = mmr;
2898                best_idx_in_remaining = i;
2899            }
2900        }
2901        picked_indices.push(remaining.remove(best_idx_in_remaining));
2902    }
2903    // Reorder `sorted` to match picked_indices.
2904    let mut out = Vec::with_capacity(sorted.len());
2905    // We need to drain in picked_indices order; do it by taking with mem::replace.
2906    let mut taken: Vec<Option<ContextCapsule>> = sorted.drain(..).map(Some).collect();
2907    for idx in picked_indices {
2908        if let Some(c) = taken[idx].take() {
2909            out.push(c);
2910        }
2911    }
2912    out
2913}
2914
2915fn summary_token_set(s: &str) -> std::collections::HashSet<String> {
2916    s.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
2917        .filter(|t| t.len() >= 3)
2918        .map(str::to_ascii_lowercase)
2919        .collect()
2920}
2921
2922fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f32 {
2923    if a.is_empty() && b.is_empty() {
2924        return 0.0;
2925    }
2926    let intersection = a.intersection(b).count();
2927    let union = a.union(b).count();
2928    intersection as f32 / union.max(1) as f32
2929}
2930
2931fn lexical_relevance(tokens: &[String], haystack: &str) -> f32 {
2932    if tokens.is_empty() {
2933        return 0.0;
2934    }
2935    let haystack = haystack.to_ascii_lowercase();
2936    let matches = tokens
2937        .iter()
2938        .filter(|token| haystack.contains(token.as_str()))
2939        .count();
2940    matches as f32 / tokens.len() as f32
2941}
2942
2943pub fn estimate_tokens(text: &str) -> u32 {
2944    ((text.split_whitespace().count() as f32) * 1.33).ceil() as u32
2945}
2946
2947// -----------------------------------------------------------------------
2948// v1.5 (Story 2.1): render-time capsule compression
2949// -----------------------------------------------------------------------
2950
2951/// Render-time compression: strips the `[tags: ...]` prefix and the trailing
2952/// `(context: ...)` suffix, then caps at the first `max_sentences` sentences.
2953///
2954/// **Architectural invariant**: this function is called ONLY at render time —
2955/// after retrieval and reranking. Ranking inputs, stored `summary` text, and
2956/// the eval/bench retrieval paths are never affected. The full text stays
2957/// available via `expansion_handle`.
2958///
2959/// Sentence splitting uses `". "` / `".\n"` boundaries (simple, reliable,
2960/// UTF-8-safe). Common abbreviation edge cases are deliberately NOT handled —
2961/// the savings far outweigh an occasional mid-abbreviation split.
2962///
2963/// The `scope:kind - ` prefix that memory summaries carry (e.g.
2964/// `"project:fact - Some lesson here."`) is preserved: compression applies
2965/// only to the text *after* the ` - ` separator.
2966///
2967/// Fallback: never returns an empty string — when trimming would leave nothing,
2968/// the original input is returned unchanged.
2969pub fn compress_for_render(summary: &str, max_sentences: usize) -> String {
2970    if max_sentences == 0 {
2971        return summary.to_string();
2972    }
2973
2974    // ── 1. Strip [tags: ...] prefix (if present) ─────────────────────────
2975    let text = if let Some(rest) = summary.strip_prefix('[') {
2976        // Find the closing ']' followed by optional whitespace
2977        if let Some(idx) = rest.find(']') {
2978            rest[idx + 1..].trim_start()
2979        } else {
2980            summary
2981        }
2982    } else {
2983        summary
2984    };
2985
2986    // ── 2. Strip (context: ...) suffix (if present) ──────────────────────
2987    let text = if let Some(idx) = text.rfind('(') {
2988        let candidate = text[..idx].trim_end();
2989        // Only strip if the parenthetical looks like a trailing annotation
2990        // (contains a ':' inside), to avoid stripping content parentheses.
2991        let inner = &text[idx + 1..];
2992        if inner.contains(':') && inner.trim_end().ends_with(')') {
2993            candidate
2994        } else {
2995            text
2996        }
2997    } else {
2998        text
2999    };
3000
3001    // ── 3. Detect and preserve "scope:kind - " prefix ────────────────────
3002    let (scope_prefix, body) = if let Some(dash_pos) = text.find(" - ") {
3003        let prefix_candidate = &text[..dash_pos];
3004        // Must look like "word:word" (no spaces in the prefix part)
3005        if !prefix_candidate.contains(' ') && prefix_candidate.contains(':') {
3006            let body_start = dash_pos + 3; // len(" - ")
3007            (&text[..body_start], &text[body_start..])
3008        } else {
3009            ("", text)
3010        }
3011    } else {
3012        ("", text)
3013    };
3014
3015    // ── 4. Cap at max_sentences on the body ──────────────────────────────
3016    let compressed_body = cap_sentences(body, max_sentences);
3017
3018    // ── 5. Reassemble; fallback to original if result would be empty ─────
3019    let result = if scope_prefix.is_empty() {
3020        compressed_body.to_string()
3021    } else {
3022        format!("{scope_prefix}{compressed_body}")
3023    };
3024
3025    if result.trim().is_empty() {
3026        summary.to_string()
3027    } else {
3028        result
3029    }
3030}
3031
3032/// Return the first `n` sentences from `text`, where sentences end at
3033/// `". "` or `".\n"` boundaries. The terminal period is included in the
3034/// returned slice. If fewer than `n` sentences exist the full text is returned.
3035fn cap_sentences(text: &str, n: usize) -> &str {
3036    let bytes = text.as_bytes();
3037    let len = bytes.len();
3038    let mut count = 0;
3039    let mut i = 0;
3040    while i < len {
3041        // Look for ". " or ".\n" — a period followed by whitespace.
3042        if bytes[i] == b'.' {
3043            let next = i + 1;
3044            if next < len && (bytes[next] == b' ' || bytes[next] == b'\n') {
3045                count += 1;
3046                if count >= n {
3047                    // Include the period, trim trailing whitespace on the slice.
3048                    return text[..=i].trim_end();
3049                }
3050            }
3051        }
3052        i += 1;
3053    }
3054    // Fewer than n sentences — return the whole text.
3055    text.trim_end()
3056}
3057
3058fn excerpt(text: &str) -> String {
3059    let value = one_line(text);
3060    value.chars().take(256).collect()
3061}
3062
3063fn one_line(text: &str) -> String {
3064    text.split_whitespace().collect::<Vec<_>>().join(" ")
3065}
3066
3067// -----------------------------------------------------------------------
3068// F2: capsule resolver — expand a headline handle to its full text.
3069// -----------------------------------------------------------------------
3070
3071/// Maximum bytes returned when resolving a `file:` handle. Keeps large
3072/// source files from flooding the context window on a single expand call.
3073const FILE_EXPAND_CAP_BYTES: usize = 2048;
3074
3075/// F2: resolve an expansion handle to its full text content.
3076///
3077/// Handles:
3078/// - `memory:<id>` → `SELECT text FROM memories WHERE memory_id = ?`
3079/// - `file:<path>` → read `repo_root/<path>`, capped at [`FILE_EXPAND_CAP_BYTES`]
3080/// - `run:<id>`    → deferred; returns a descriptive error
3081/// - anything else → returns a descriptive error
3082///
3083/// This is the resolver that the `expand_capsule` agent tool delegates to.
3084/// All errors are user-visible (returned to the agent as a tool-result
3085/// error string) and never crash the dispatch loop.
3086pub fn resolve_capsule(
3087    conn: &Connection,
3088    repo_root: &std::path::Path,
3089    handle: &str,
3090) -> kimetsu_core::KimetsuResult<String> {
3091    if let Some(memory_id) = handle.strip_prefix("memory:") {
3092        // SELECT the raw text from the memories table.
3093        let mut stmt = conn.prepare_cached(
3094            "SELECT text FROM memories WHERE memory_id = ? AND invalidated_at IS NULL",
3095        )?;
3096        let text: Option<String> = stmt
3097            .query_row(rusqlite::params![memory_id], |row| row.get(0))
3098            .optional()?;
3099        match text {
3100            Some(t) => Ok(t),
3101            None => {
3102                Err(format!("expand_capsule: no active memory found for handle `{handle}`").into())
3103            }
3104        }
3105    } else if let Some(rel_path) = handle.strip_prefix("file:") {
3106        // Sanitize: reject absolute paths (drive-letter or Unix-root) and
3107        // `..` traversal. On Windows, POSIX-style `/foo` paths are not
3108        // considered absolute by `is_absolute()` (no drive prefix), so we
3109        // also reject paths with a RootDir component.
3110        let path = std::path::Path::new(rel_path);
3111        if path.is_absolute() {
3112            return Err(format!(
3113                "expand_capsule: `{handle}` is an absolute path — only repo-relative paths are supported"
3114            )
3115            .into());
3116        }
3117        for component in path.components() {
3118            match component {
3119                std::path::Component::ParentDir => {
3120                    return Err(format!(
3121                        "expand_capsule: `{handle}` contains `..` traversal — rejected"
3122                    )
3123                    .into());
3124                }
3125                std::path::Component::RootDir | std::path::Component::Prefix(_) => {
3126                    return Err(format!(
3127                        "expand_capsule: `{handle}` is an absolute path — only repo-relative paths are supported"
3128                    )
3129                    .into());
3130                }
3131                _ => {}
3132            }
3133        }
3134        let full_path = repo_root.join(path);
3135        let bytes = std::fs::read(&full_path)
3136            .map_err(|e| format!("expand_capsule: could not read `{rel_path}`: {e}"))?;
3137        // Bound the returned slice so huge files don't blow the context window.
3138        let bounded = if bytes.len() > FILE_EXPAND_CAP_BYTES {
3139            let mut end = FILE_EXPAND_CAP_BYTES;
3140            // Snap back to a UTF-8 boundary so we don't slice mid-codepoint.
3141            while end > 0 && (bytes[end] & 0xC0) == 0x80 {
3142                end -= 1;
3143            }
3144            let s = String::from_utf8_lossy(&bytes[..end]);
3145            format!(
3146                "{s}\n[... truncated at {FILE_EXPAND_CAP_BYTES} bytes; call expand_capsule again with a line range if needed]"
3147            )
3148        } else {
3149            String::from_utf8_lossy(&bytes).into_owned()
3150        };
3151        Ok(bounded)
3152    } else if handle.starts_with("run:") {
3153        Err(format!(
3154            "expand_capsule: `run:` handle expansion is not yet supported (handle: `{handle}`)"
3155        )
3156        .into())
3157    } else {
3158        Err(format!(
3159            "expand_capsule: unrecognised handle format `{handle}`; \
3160             expected `memory:<id>`, `file:<path>`, or `run:<id>`"
3161        )
3162        .into())
3163    }
3164}
3165
3166// ── v1.0.0: cross-encoder reranking ──────────────────────────────────────
3167
3168/// v1.0.0: final-stage cross-encoder rerank over already-retrieved capsules.
3169/// Reranks by `summary`, overwrites `score` with the sigmoid-normalized
3170/// rerank score, sorts descending, drops capsules below `floor`, truncates
3171/// to `cap` (0 = no cap). Fail-open: on a rerank error the input ordering
3172/// is returned unchanged (truncated to `cap`) — a broken reranker must
3173/// never lose retrieval entirely.
3174/// v2.7: width of the evidence band below `abstain_evidence` in which the
3175/// cross-encoder arbitrates instead of the raw cosine. Below
3176/// `abstain_evidence - ABSTAIN_BAND_WIDTH` the retrieval hard-abstains.
3177pub const ABSTAIN_BAND_WIDTH: f32 = 0.10;
3178
3179fn abstain_band_width() -> f32 {
3180    std::env::var("KIMETSU_ABSTAIN_BAND_WIDTH")
3181        .ok()
3182        .and_then(|v| v.parse::<f32>().ok())
3183        .unwrap_or(ABSTAIN_BAND_WIDTH)
3184        .clamp(0.0, 1.0)
3185}
3186
3187/// v2.7: default cross-encoder score a band bundle must reach to be injected.
3188/// Swept on the workflow benchmark (ms-marco-tinybert, n=150/floor): recall
3189/// was FLAT from 0.3 through 0.95 (useful-hit 0.80 → 0.79) while
3190/// false-injection fell monotonically (0.32 → 0.24) — genuinely relevant band
3191/// bundles saturate the tinybert sigmoid, junk does not. 0.9 takes most of
3192/// that precision and leaves margin for cross-encoders whose scores don't
3193/// saturate as hard. Overridable via `KIMETSU_ABSTAIN_RERANK_FLOOR`.
3194pub const ABSTAIN_RERANK_FLOOR: f32 = 0.9;
3195
3196fn abstain_rerank_floor() -> f32 {
3197    std::env::var("KIMETSU_ABSTAIN_RERANK_FLOOR")
3198        .ok()
3199        .and_then(|v| v.parse::<f32>().ok())
3200        .unwrap_or(ABSTAIN_RERANK_FLOOR)
3201}
3202
3203/// v2.7: post-retrieval reranking + evidence-band arbitration, shared by every
3204/// call site that owns a cross-encoder (CLI `brain context`, MCP server, embed
3205/// daemon).
3206///
3207/// The retrieval pipeline hard-abstains below `abstain_evidence -`
3208/// [`ABSTAIN_BAND_WIDTH`] and returns bundles above it. This helper decides
3209/// the band in between: the cross-encoder rescoring recognizes paraphrased
3210/// matches that raw bi-encoder cosine under-scores, so a band bundle whose
3211/// best rerank score clears [`ABSTAIN_RERANK_FLOOR`] is injected and one that
3212/// doesn't is converted to a skipped (zero-token) bundle. Out-of-band bundles
3213/// are reranked for ordering but never converted.
3214///
3215/// With no reranker available the band FAILS CLOSED (skipped) — equivalent to
3216/// the plain hard gate at `abstain_evidence` — so a lean build is never
3217/// noisier than the gate promises. Bundles containing non-memory capsules are
3218/// never converted (repo evidence stands on its own), and `abstain_evidence
3219/// <= 0` disables arbitration entirely.
3220pub fn rerank_and_arbitrate(
3221    query: &str,
3222    mut bundle: ContextBundle,
3223    reranker: Option<&dyn crate::embeddings::Reranker>,
3224    abstain_evidence: f32,
3225    rerank_floor: f32,
3226    rerank_cap: usize,
3227) -> ContextBundle {
3228    if bundle.skipped || bundle.capsules.is_empty() {
3229        return bundle;
3230    }
3231    let memory_only = bundle.capsules.iter().all(|c| c.kind == "memory");
3232    // top_abs_evidence < 0.0 means "no cosine evidence exists" (lean builds,
3233    // cross-model rows) — the band is a cosine construct, so it is exempt.
3234    let in_band = abstain_evidence > 0.0
3235        && memory_only
3236        && bundle.top_abs_evidence >= 0.0
3237        && bundle.top_abs_evidence < abstain_evidence;
3238
3239    let to_skipped = |mut bundle: ContextBundle| -> ContextBundle {
3240        let rejected = std::mem::take(&mut bundle.capsules);
3241        bundle.excluded.extend(rejected);
3242        bundle.skipped = true;
3243        bundle.used_tokens = 0;
3244        bundle.evidence_coverage = 0.0;
3245        bundle.uncovered_terms = Vec::new();
3246        bundle.chronological = false;
3247        bundle
3248    };
3249
3250    match reranker {
3251        Some(rr) => {
3252            let reranked = rerank_capsules_with_diagnostics(
3253                query,
3254                std::mem::take(&mut bundle.capsules),
3255                rr,
3256                rerank_floor,
3257                rerank_cap,
3258            );
3259            // Admission is calibrated on the RAW cross-encoder score, before
3260            // ranking policy is applied. Otherwise usefulness or supersession
3261            // could silently alter abstention behavior.
3262            let best_raw_rerank = reranked.best_raw_score.unwrap_or(0.0);
3263            bundle.capsules = reranked.capsules;
3264            bundle.used_tokens = bundle.capsules.iter().map(|c| c.token_estimate).sum();
3265            if in_band && best_raw_rerank < abstain_rerank_floor() {
3266                to_skipped(bundle)
3267            } else {
3268                bundle
3269            }
3270        }
3271        // No arbiter: the band fails closed, matching the hard gate.
3272        None if in_band => to_skipped(bundle),
3273        None => bundle,
3274    }
3275}
3276
3277pub fn rerank_capsules(
3278    query: &str,
3279    capsules: Vec<ContextCapsule>,
3280    reranker: &dyn crate::embeddings::Reranker,
3281    floor: f32,
3282    cap: usize,
3283) -> Vec<ContextCapsule> {
3284    rerank_capsules_with_diagnostics(query, capsules, reranker, floor, cap).capsules
3285}
3286
3287struct RerankOutcome {
3288    capsules: Vec<ContextCapsule>,
3289    /// Best raw cross-encoder score among capsules that survived the ranking
3290    /// floor.
3291    /// `None` means the reranker did not produce a usable verdict.
3292    best_raw_score: Option<f32>,
3293}
3294
3295fn effective_rerank_policy_tier(capsule: &ContextCapsule) -> i8 {
3296    // A superseded memory must not retain a historic citation advantage over
3297    // its replacement. Its explicit post-rerank penalty carries that policy.
3298    if capsule.superseded_hint {
3299        0
3300    } else {
3301        capsule.rerank_policy_tier
3302    }
3303}
3304
3305fn rerank_capsules_with_diagnostics(
3306    query: &str,
3307    capsules: Vec<ContextCapsule>,
3308    reranker: &dyn crate::embeddings::Reranker,
3309    floor: f32,
3310    cap: usize,
3311) -> RerankOutcome {
3312    if capsules.is_empty() {
3313        return RerankOutcome {
3314            capsules,
3315            best_raw_score: None,
3316        };
3317    }
3318
3319    // Rerank on the FULL summary. Truncating to a snippet was tried for
3320    // latency and measurably cratered quality on the eval fixture
3321    // (recall@4 0.83 → 0.66, below even FTS) — the cross-encoder needs the
3322    // whole lesson to judge relevance. Reranking is therefore a
3323    // quality-over-latency opt-in, not part of the hook's 300ms budget.
3324    let docs: Vec<&str> = capsules.iter().map(|c| c.summary.as_str()).collect();
3325    let scores = match reranker.rerank(query, &docs) {
3326        // The trait contract is one score per doc in doc order; a custom
3327        // third-party reranker that returns a short vec would otherwise
3328        // silently drop the unscored tail via the zip below — treat a
3329        // length mismatch as an error and fail open instead.
3330        Ok(s) if s.len() == docs.len() => s,
3331        _ => {
3332            // Fail-open: preserve input order, just apply cap.
3333            let mut out = capsules;
3334            if cap > 0 && out.len() > cap {
3335                out.truncate(cap);
3336            }
3337            return RerankOutcome {
3338                capsules: out,
3339                best_raw_score: None,
3340            };
3341        }
3342    };
3343
3344    // Apply bounded usefulness, then trust and supersession; admission uses raw scores.
3345    let mut ranked: Vec<(ContextCapsule, f32)> = capsules
3346        .into_iter()
3347        .zip(scores)
3348        .map(|(mut c, s)| {
3349            let multiplier = if c.superseded_hint {
3350                1.0
3351            } else {
3352                c.rerank_usefulness
3353                    .unwrap_or(1.0 + 0.5 * effective_rerank_policy_tier(&c) as f32)
3354            };
3355            c.score = apply_usefulness_boost(s, multiplier.clamp(0.5, 1.5))
3356                * c.rerank_trust.unwrap_or(1.0).clamp(0.0, 1.0);
3357            if c.superseded_hint {
3358                c.score *= SUPERSESSION_PENALTY;
3359            }
3360            (c, s)
3361        })
3362        .collect();
3363
3364    ranked.sort_by(|a, b| b.0.score.total_cmp(&a.0.score));
3365
3366    // This is the ordinary per-capsule retention floor. The separate
3367    // evidence-band decision reads `best_raw_score` before the supersession
3368    // policy is reapplied, because its 0.9 threshold was calibrated on the
3369    // cross-encoder's sigmoid scale.
3370    ranked.retain(|(_, raw_score)| *raw_score >= floor);
3371
3372    // Admission evidence is independent of policy ordering and output cap.
3373    // A high-confidence neutral capsule displaced by a cited one still proves
3374    // that the in-band bundle has relevant evidence.
3375    let best_raw_score = ranked
3376        .iter()
3377        .map(|(_, raw_score)| *raw_score)
3378        .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3379
3380    if cap > 0 && ranked.len() > cap {
3381        ranked.truncate(cap);
3382    }
3383
3384    RerankOutcome {
3385        capsules: ranked.into_iter().map(|(c, _)| c).collect(),
3386        best_raw_score,
3387    }
3388}
3389
3390#[cfg(test)]
3391mod tests {
3392    use super::*;
3393
3394    fn capsule(kind: &str, summary: &str) -> ContextCapsule {
3395        ContextCapsule {
3396            id: "c".into(),
3397            kind: kind.into(),
3398            summary: summary.into(),
3399            token_estimate: 1,
3400            expansion_handle: "memory:x".into(),
3401            provenance: vec![],
3402            confidence: 1.0,
3403            freshness: 1.0,
3404            relevance: 1.0,
3405            scope_weight: 1.0,
3406            score: 1.0,
3407            superseded_hint: false,
3408            rerank_policy_tier: 0,
3409            claim_revision: None,
3410            facts: vec![],
3411            rerank_usefulness: None,
3412            rerank_trust: None,
3413        }
3414    }
3415
3416    /// Create a unique temp directory under the system temp path.
3417    /// Named by `tag` so test failures are diagnosable.
3418    fn make_test_dir(tag: &str) -> std::path::PathBuf {
3419        use std::time::{SystemTime, UNIX_EPOCH};
3420        let ts = SystemTime::now()
3421            .duration_since(UNIX_EPOCH)
3422            .map(|d| d.subsec_nanos())
3423            .unwrap_or(0);
3424        let dir = std::env::temp_dir().join(format!("kbrain_test_{tag}_{ts}"));
3425        std::fs::create_dir_all(&dir).expect("create test dir");
3426        dir
3427    }
3428
3429    #[test]
3430    fn capsule_matches_kind_reads_memory_summary_prefix() {
3431        // Memory capsule: real kind lives in the "scope:kind - text" prefix.
3432        let mem = capsule("memory", "project:failure_pattern - linker not found");
3433        assert!(capsule_matches_kind(&mem, "failure_pattern"));
3434        assert!(!capsule_matches_kind(&mem, "command"));
3435        // Non-memory capsules match only by literal kind, never via prefix.
3436        let repo = capsule("repo_file", "src/lib.rs:command - run build");
3437        assert!(capsule_matches_kind(&repo, "repo_file"));
3438        assert!(!capsule_matches_kind(&repo, "command"));
3439    }
3440
3441    /// MP-17e: zero-use rows are neutral (no data); use_count >= 1 starts
3442    /// blending toward the full multiplier (Bayesian smoothing).
3443    #[test]
3444    fn usefulness_multiplier_neutral_at_zero_uses() {
3445        // use_count = 0 is the only strictly-neutral case.
3446        assert!((usefulness_multiplier(0.0, 0) - 1.0).abs() < f32::EPSILON);
3447        assert!((usefulness_multiplier(5.0, 0) - 1.0).abs() < f32::EPSILON);
3448        assert!((usefulness_multiplier(-5.0, 0) - 1.0).abs() < f32::EPSILON);
3449    }
3450
3451    /// MP-17e: between use_count 1..3 the multiplier blends linearly from
3452    /// neutral (1.0) toward the full envelope. A use_count of 2 with a
3453    /// perfect ratio lands at 2/3 of the way to the max boost.
3454    #[test]
3455    fn usefulness_multiplier_blends_smoothly_in_transition() {
3456        // use_count = 1, ratio = 1.0 -> confidence 1/3, blend toward 1.5
3457        // expected = 1.0 * 2/3 + 1.5 * 1/3 = 1.1667
3458        let one_use = usefulness_multiplier(1.0, 1);
3459        assert!((one_use - 1.166_666_6).abs() < 1e-4, "got {one_use}");
3460        // use_count = 2, ratio = 1.0 -> confidence 2/3, blend toward 1.5
3461        // expected = 1.0 * 1/3 + 1.5 * 2/3 = 1.3333
3462        let two_uses = usefulness_multiplier(2.0, 2);
3463        assert!((two_uses - 1.333_333_4).abs() < 1e-4, "got {two_uses}");
3464        // use_count = 2 with ratio = -1.0 should pull toward the penalty side.
3465        let two_uses_bad = usefulness_multiplier(-2.0, 2);
3466        // expected = 1.0 * 1/3 + 0.5 * 2/3 = 0.6667
3467        assert!(
3468            (two_uses_bad - 0.666_666_7).abs() < 1e-4,
3469            "got {two_uses_bad}"
3470        );
3471    }
3472
3473    /// MP-4b: at use_count >= 3 the multiplier maps ratio in [-1, 1] linearly
3474    /// onto [MULTIPLIER_MIN, MULTIPLIER_MAX] = [0.5, 1.5]. A neutral memory
3475    /// (ratio = 0) gets a 1.0 multiplier.
3476    #[test]
3477    fn usefulness_multiplier_maps_ratio_onto_envelope() {
3478        // ratio = 1.0 -> 1.5 (max boost)
3479        assert!((usefulness_multiplier(5.0, 5) - 1.5).abs() < f32::EPSILON);
3480        // ratio = -1.0 -> 0.5 (max penalty)
3481        assert!((usefulness_multiplier(-5.0, 5) - 0.5).abs() < f32::EPSILON);
3482        // ratio = 0.0 -> 1.0 (neutral)
3483        let mid = usefulness_multiplier(0.0, 6);
3484        assert!((mid - 1.0).abs() < f32::EPSILON, "got {mid}");
3485        // ratio = 0.5 -> 1.25 (mid boost)
3486        let high = usefulness_multiplier(2.0, 4);
3487        assert!((high - 1.25).abs() < f32::EPSILON, "got {high}");
3488        // ratio = -0.5 -> 0.75 (mid penalty)
3489        let low = usefulness_multiplier(-2.0, 4);
3490        assert!((low - 0.75).abs() < f32::EPSILON, "got {low}");
3491    }
3492
3493    /// MP-4b: the multiplier is bounded so even a runaway score cannot
3494    /// dominate the budget; a single memory with usefulness_score >> use_count
3495    /// is clamped at the upper envelope.
3496    #[test]
3497    fn usefulness_multiplier_clamps_to_envelope() {
3498        // ratio > 1.0 is clamped to 1.0 -> 1.5
3499        assert!((usefulness_multiplier(100.0, 5) - 1.5).abs() < f32::EPSILON);
3500        // ratio < -1.0 is clamped to -1.0 -> 0.5
3501        assert!((usefulness_multiplier(-100.0, 5) - 0.5).abs() < f32::EPSILON);
3502    }
3503
3504    // ----- v2.5.1: citation-boost saturation (LoCoMo k=5 collapse fix) -----
3505
3506    /// The boost's absolute gain is capped: a max-boosted (1.5x) weakly
3507    /// relevant memory must NOT outrank a strongly relevant uncited one.
3508    /// This is the exact inversion observed in the LoCoMo learning run
3509    /// (junk at raw 0.39 x 1.5 = 0.58 beat a true match at 0.53).
3510    #[test]
3511    fn boost_gain_is_capped_so_cited_junk_cannot_beat_relevant_uncited() {
3512        let junk = apply_usefulness_boost(0.39, 1.5);
3513        let true_match = apply_usefulness_boost(0.53, 1.0);
3514        assert!(
3515            junk < true_match,
3516            "capped boost must preserve relevance order: junk {junk} vs match {true_match}"
3517        );
3518        // gain never exceeds the cap
3519        assert!(junk <= 0.39 + USEFULNESS_BOOST_CAP + f32::EPSILON);
3520    }
3521
3522    /// Within a relevance band the boost still reorders: a proven memory at
3523    /// slightly lower relevance may overtake a neutral near-equal. This is
3524    /// the behaviour that produced the +4.6 holdout gain and must survive.
3525    #[test]
3526    fn boost_still_reorders_within_a_relevance_band() {
3527        let proven = apply_usefulness_boost(0.85, 1.5);
3528        let neutral = apply_usefulness_boost(0.90, 1.0);
3529        assert!(
3530            proven > neutral,
3531            "capped boost must still reorder near-equals: proven {proven} vs neutral {neutral}"
3532        );
3533    }
3534
3535    /// Penalties stay multiplicative: suppressing net-negative memories
3536    /// below their raw relevance is desirable and unbounded-downward is safe
3537    /// (floor 0.5x from the multiplier envelope).
3538    #[test]
3539    fn penalty_side_remains_multiplicative() {
3540        let penalized = apply_usefulness_boost(0.8, 0.5);
3541        assert!((penalized - 0.4).abs() < 1e-6);
3542    }
3543
3544    // ----- MP-17 #11: task-class query expansion -----
3545
3546    #[test]
3547    fn query_tokens_expands_build_class() {
3548        let toks = query_tokens("Build the project from source");
3549        assert!(toks.iter().any(|t| t == "build"));
3550        // class-aware expansion adds tool tokens:
3551        assert!(toks.iter().any(|t| t == "shell_background"));
3552        assert!(toks.iter().any(|t| t == "long_running"));
3553    }
3554
3555    #[test]
3556    fn query_tokens_expands_edit_class() {
3557        let toks = query_tokens("Modify the config to fix the bug");
3558        assert!(toks.iter().any(|t| t == "edit_file"));
3559        assert!(toks.iter().any(|t| t == "apply_patch"));
3560    }
3561
3562    #[test]
3563    fn query_tokens_expands_search_class() {
3564        let toks = query_tokens("Find all references to the symbol");
3565        assert!(toks.iter().any(|t| t == "glob"));
3566        assert!(toks.iter().any(|t| t == "search_files"));
3567    }
3568
3569    #[test]
3570    fn query_tokens_no_expansion_on_unrelated_query() {
3571        let toks = query_tokens("hello world testing nothing");
3572        // Only the "test" trigger fires here -> verification expansion.
3573        assert!(toks.iter().any(|t| t == "hello"));
3574        // The base tokens are present regardless.
3575        assert!(toks.iter().any(|t| t == "world"));
3576    }
3577
3578    // ----- MP-17 #13: MMR diversity helpers -----
3579
3580    #[test]
3581    fn jaccard_is_zero_for_disjoint_sets() {
3582        let a: std::collections::HashSet<String> =
3583            ["foo", "bar"].iter().map(|s| s.to_string()).collect();
3584        let b: std::collections::HashSet<String> =
3585            ["baz", "qux"].iter().map(|s| s.to_string()).collect();
3586        assert!((jaccard(&a, &b) - 0.0).abs() < f32::EPSILON);
3587    }
3588
3589    #[test]
3590    fn jaccard_is_one_for_identical_sets() {
3591        let a: std::collections::HashSet<String> =
3592            ["foo", "bar"].iter().map(|s| s.to_string()).collect();
3593        let b = a.clone();
3594        assert!((jaccard(&a, &b) - 1.0).abs() < f32::EPSILON);
3595    }
3596
3597    #[test]
3598    fn jaccard_partial_overlap() {
3599        let a: std::collections::HashSet<String> = ["foo", "bar", "baz"]
3600            .iter()
3601            .map(|s| s.to_string())
3602            .collect();
3603        let b: std::collections::HashSet<String> =
3604            ["bar", "qux"].iter().map(|s| s.to_string()).collect();
3605        // intersection = {bar} = 1, union = {foo,bar,baz,qux} = 4
3606        assert!((jaccard(&a, &b) - 0.25).abs() < f32::EPSILON);
3607    }
3608
3609    #[test]
3610    fn summary_token_set_lowercases_and_filters_short() {
3611        let set = summary_token_set("Build the Foo-bar project");
3612        assert!(set.contains("build"));
3613        assert!(set.contains("foo"));
3614        assert!(set.contains("bar"));
3615        assert!(set.contains("project"));
3616        // "the" is len=3, included; "a" or "i" would be excluded.
3617        assert!(set.contains("the"));
3618    }
3619
3620    // ----- v0.4.2: hybrid retrieval end-to-end -----
3621
3622    /// Helper: open an in-memory brain.db, initialize schema, insert
3623    /// a memory row (post-projector shape) plus its embedding +
3624    /// embedding_model and the matching FTS entry.
3625    fn insert_memory_with_embedding(
3626        conn: &rusqlite::Connection,
3627        memory_id: &str,
3628        text: &str,
3629        embedder: &dyn embeddings::Embedder,
3630    ) {
3631        let normalized = kimetsu_core::memory::normalize_memory_text(text);
3632        conn.execute(
3633            "
3634            INSERT INTO memories (
3635                memory_id, scope, kind, text, normalized_text, confidence,
3636                source_event_id, provenance_snapshot_json, created_at,
3637                use_count, usefulness_score, embedding, embedding_model
3638            )
3639            VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
3640                    '2026-05-01T00:00:00Z', 0, 0.0, ?4, ?5)
3641            ",
3642            rusqlite::params![
3643                memory_id,
3644                text,
3645                normalized,
3646                embeddings::encode_embedding(&embedder.embed(text).expect("embed test row")),
3647                embedder.model_id(),
3648            ],
3649        )
3650        .expect("insert memory");
3651        conn.execute(
3652            "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
3653            rusqlite::params![memory_id, text],
3654        )
3655        .expect("insert fts row");
3656    }
3657
3658    /// v0.4.2: the cosine blend changes retrieval ranking when two
3659    /// memories tie lexically but differ semantically (via the stub
3660    /// embedder's hashed-bucket vectors).
3661    ///
3662    /// Setup: two memories, neither containing the query's literal
3663    /// words. With pure FTS, neither matches and we fall back to
3664    /// latest-memory ranking. With the stub embedder enabled, the
3665    /// memory that's "semantically closer" to the query (shares
3666    /// hash buckets) outranks the other.
3667    #[test]
3668    fn hybrid_retrieval_uses_cosine_score_to_rerank() {
3669        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3670        crate::schema::initialize(&conn).expect("init schema");
3671        let stub = embeddings::StubEmbedder::new();
3672
3673        insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
3674        insert_memory_with_embedding(
3675            &conn,
3676            "m_unrelated",
3677            "cookie recipe with chocolate chips",
3678            &stub,
3679        );
3680
3681        // Query shares words with m_rg but not m_unrelated. FTS will
3682        // already prefer m_rg here; we use that as the baseline.
3683        let weights = kimetsu_core::config::BrokerWeights::default();
3684        let bundle = retrieve_context_with_embedder(
3685            &conn,
3686            "/fake-repo",
3687            &weights,
3688            ContextRequest {
3689                stage: "localization".to_string(),
3690                query: "ripgrep search".to_string(),
3691                budget_tokens: 4000,
3692                ..Default::default()
3693            },
3694            &[],
3695            &stub,
3696        )
3697        .expect("retrieve");
3698
3699        let memory_handles: Vec<_> = bundle
3700            .capsules
3701            .iter()
3702            .filter(|c| c.expansion_handle.starts_with("memory:"))
3703            .collect();
3704        assert!(
3705            !memory_handles.is_empty(),
3706            "at least one memory should surface"
3707        );
3708        // The semantically-relevant memory must rank first.
3709        assert_eq!(
3710            memory_handles[0].expansion_handle,
3711            "memory:m_rg",
3712            "ripgrep memory should outrank the cookie recipe; ranked: {:?}",
3713            memory_handles
3714                .iter()
3715                .map(|c| &c.expansion_handle)
3716                .collect::<Vec<_>>()
3717        );
3718    }
3719
3720    /// v2.7: the absolute abstention gate. `top_abs_evidence` carries the best
3721    /// raw cosine; a floor above it skips the bundle entirely, a floor below
3722    /// it (or 0.0) lets the bundle through. Self-calibrating: the test reads
3723    /// the achieved evidence first rather than hard-coding a stub cosine.
3724    #[test]
3725    fn abstain_evidence_gate_skips_on_weak_absolute_evidence() {
3726        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3727        crate::schema::initialize(&conn).expect("init schema");
3728        let stub = embeddings::StubEmbedder::new();
3729        insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
3730
3731        let weights = kimetsu_core::config::BrokerWeights::default();
3732        let retrieve = |abstain: f32| {
3733            retrieve_context_with_embedder(
3734                &conn,
3735                "/fake-repo",
3736                &weights,
3737                ContextRequest {
3738                    stage: "localization".to_string(),
3739                    query: "ripgrep search".to_string(),
3740                    budget_tokens: 4000,
3741                    abstain_evidence: abstain,
3742                    ..Default::default()
3743                },
3744                &[],
3745                &stub,
3746            )
3747            .expect("retrieve")
3748        };
3749
3750        let open = retrieve(0.0);
3751        assert!(!open.skipped, "gate off must not skip");
3752        assert!(
3753            open.top_abs_evidence > 0.0,
3754            "a matching memory must report positive absolute evidence"
3755        );
3756
3757        // The retrieval-internal gate hard-abstains one band-width below the
3758        // configured floor (the band itself is decided by the caller's
3759        // cross-encoder via rerank_and_arbitrate).
3760        let above = retrieve(open.top_abs_evidence + ABSTAIN_BAND_WIDTH + 0.05);
3761        assert!(
3762            above.skipped,
3763            "a floor a full band above the best evidence must hard-abstain (evidence {})",
3764            open.top_abs_evidence
3765        );
3766        assert!(above.capsules.is_empty(), "skipped bundle injects nothing");
3767
3768        // In the band: retrieval itself does NOT skip — the arbiter decides.
3769        let in_band = retrieve(open.top_abs_evidence + 0.05);
3770        assert!(
3771            !in_band.skipped,
3772            "an in-band bundle passes through for arbitration"
3773        );
3774
3775        let below = retrieve((open.top_abs_evidence - 0.05).max(0.01));
3776        assert!(!below.skipped, "a floor below the best evidence passes");
3777        assert!(!below.capsules.is_empty());
3778    }
3779
3780    /// v0.4.2: when a row's stored `embedding_model` doesn't match
3781    /// the active query embedder's id, the row's cosine contribution
3782    /// is skipped — falling back to FTS-only for that row. Critical
3783    /// for safety across `kimetsu brain reindex` migrations (v0.4.3)
3784    /// where some rows might be embedded with the new model and some
3785    /// with the old.
3786    #[test]
3787    fn hybrid_retrieval_skips_cosine_on_model_id_mismatch() {
3788        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3789        crate::schema::initialize(&conn).expect("init schema");
3790        let stub = embeddings::StubEmbedder::new();
3791        insert_memory_with_embedding(&conn, "m_xref", "use ripgrep for code search", &stub);
3792
3793        // Stomp the row's embedding_model with a synthetic id that
3794        // doesn't match the active embedder. Simulates a `kimetsu
3795        // brain reindex` mid-migration where some rows are on the
3796        // new model and some on the old.
3797        conn.execute(
3798            "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xref'",
3799            [],
3800        )
3801        .expect("force model_id mismatch");
3802
3803        // Query through the stub embedder. Its model_id is "stub-d8";
3804        // the row's is "bge-small-en-v1.5". The cosine path MUST be
3805        // skipped for this row; FTS still surfaces it on the lexical
3806        // match because retrieval doesn't crash on cross-model rows.
3807        let weights = kimetsu_core::config::BrokerWeights::default();
3808        let bundle = retrieve_context_with_embedder(
3809            &conn,
3810            "/fake-repo",
3811            &weights,
3812            ContextRequest {
3813                stage: "localization".to_string(),
3814                query: "ripgrep search".to_string(),
3815                budget_tokens: 4000,
3816                ..Default::default()
3817            },
3818            &[],
3819            &stub,
3820        )
3821        .expect("retrieve");
3822
3823        assert!(
3824            bundle
3825                .capsules
3826                .iter()
3827                .any(|c| c.expansion_handle == "memory:m_xref"),
3828            "cross-model row should still match lexically (cosine skipped, FTS works)"
3829        );
3830    }
3831
3832    // ----- v0.5.1: usefulness decay -----
3833
3834    /// v0.5.1: `half_life_days <= 0` is the operator opt-out hatch.
3835    /// Decay must short-circuit to 1.0 so the usefulness multiplier
3836    /// is unmodified — exact pre-v0.5.1 behavior for projects that
3837    /// set `decay_half_life_days = 0` in project.toml.
3838    #[test]
3839    fn usefulness_decay_disabled_when_half_life_is_zero_or_negative() {
3840        // Even a 5-year-old reference returns 1.0 with decay disabled.
3841        let ancient = "2021-01-01T00:00:00Z";
3842        assert!((usefulness_decay(Some(ancient), ancient, 0.0) - 1.0).abs() < f32::EPSILON);
3843        assert!((usefulness_decay(Some(ancient), ancient, -1.0) - 1.0).abs() < f32::EPSILON);
3844    }
3845
3846    /// v0.5.1: unparseable timestamps return 1.0 (fail-open). A
3847    /// corrupted row shouldn't get silently dropped out of retrieval
3848    /// just because its `last_useful_at` got mangled.
3849    #[test]
3850    fn usefulness_decay_returns_one_on_unparseable_timestamps() {
3851        assert!(
3852            (usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON
3853        );
3854    }
3855
3856    /// v0.5.1: a memory whose reference timestamp is "now" (no age)
3857    /// decays by zero — full contribution.
3858    #[test]
3859    fn usefulness_decay_full_at_zero_age() {
3860        // Use a timestamp from the future so age clamps to 0.
3861        let future = "2099-01-01T00:00:00Z";
3862        let d = usefulness_decay(Some(future), future, 30.0);
3863        assert!((d - 1.0).abs() < f32::EPSILON, "got {d}");
3864    }
3865
3866    /// v0.5.1: at age == half_life, decay = 0.5; at age = 2 * half_life,
3867    /// decay = 0.25. Computed by setting `last_useful_at` to (now - days)
3868    /// using OffsetDateTime arithmetic — the only way to get a stable
3869    /// "now-relative" timestamp without freezing the clock.
3870    #[test]
3871    fn usefulness_decay_follows_half_life_curve() {
3872        let half_life = 10.0_f32;
3873        let now = OffsetDateTime::now_utc();
3874        let fmt = &time::format_description::well_known::Rfc3339;
3875
3876        // age = half_life -> decay ~= 0.5
3877        let one_half_life_ago = (now - time::Duration::seconds((half_life * 86_400.0) as i64))
3878            .format(fmt)
3879            .expect("format");
3880        let d1 = usefulness_decay(Some(&one_half_life_ago), &one_half_life_ago, half_life);
3881        assert!(
3882            (d1 - 0.5).abs() < 0.01,
3883            "expected ~0.5 at one half-life, got {d1}"
3884        );
3885
3886        // age = 2 * half_life -> decay ~= 0.25
3887        let two_half_lives_ago = (now
3888            - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64))
3889        .format(fmt)
3890        .expect("format");
3891        let d2 = usefulness_decay(Some(&two_half_lives_ago), &two_half_lives_ago, half_life);
3892        assert!(
3893            (d2 - 0.25).abs() < 0.01,
3894            "expected ~0.25 at two half-lives, got {d2}"
3895        );
3896    }
3897
3898    /// v0.5.1: when `last_useful_at` is None the function falls back to
3899    /// `created_at`. A 1-day-old never-cited memory should still get
3900    /// nearly-full decay (close to 1.0) for a 30-day half-life.
3901    #[test]
3902    fn usefulness_decay_falls_back_to_created_at_when_last_useful_is_none() {
3903        let now = OffsetDateTime::now_utc();
3904        let fmt = &time::format_description::well_known::Rfc3339;
3905        let one_day_ago = (now - time::Duration::seconds(86_400))
3906            .format(fmt)
3907            .expect("format");
3908        let d = usefulness_decay(None, &one_day_ago, 30.0);
3909        // exp(-ln(2) / 30) ≈ 0.977
3910        assert!(
3911            (d - 0.977).abs() < 0.01,
3912            "expected ~0.977 for 1-day-old created_at under 30d half-life, got {d}"
3913        );
3914    }
3915
3916    /// v0.5.1: end-to-end retrieval test. Two memories with identical
3917    /// lexical match, identical use_count, identical (max) usefulness
3918    /// score — one cited yesterday, one cited a year ago. Decay must
3919    /// rank the recent one first.
3920    #[test]
3921    fn aged_cited_memory_ranks_below_recently_cited_memory() {
3922        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3923        crate::schema::initialize(&conn).expect("init schema");
3924
3925        let now = OffsetDateTime::now_utc();
3926        let fmt = &time::format_description::well_known::Rfc3339;
3927        let one_day_ago = (now - time::Duration::seconds(86_400))
3928            .format(fmt)
3929            .expect("format");
3930        let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
3931            .format(fmt)
3932            .expect("format");
3933
3934        // Both memories say "use ripgrep for code search", both have
3935        // use_count = 5, usefulness_score = 5 (max boost → 1.5
3936        // multiplier). The only difference is `last_useful_at`.
3937        for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] {
3938            let text = "use ripgrep for code search";
3939            let normalized = kimetsu_core::memory::normalize_memory_text(text);
3940            conn.execute(
3941                "
3942                INSERT INTO memories (
3943                    memory_id, scope, kind, text, normalized_text, confidence,
3944                    source_event_id, provenance_snapshot_json, created_at,
3945                    use_count, usefulness_score, last_useful_at
3946                )
3947                VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
3948                        '2024-01-01T00:00:00Z', 5, 5.0, ?4)
3949                ",
3950                rusqlite::params![mid, text, normalized, last_useful],
3951            )
3952            .expect("insert memory");
3953            conn.execute(
3954                "INSERT INTO memories_fts (memory_id, text, kind, scope)
3955                 VALUES (?1, ?2, 'fact', 'global_user')",
3956                rusqlite::params![mid, text],
3957            )
3958            .expect("insert fts");
3959        }
3960
3961        // Default broker weights → 30-day half-life. 1 year ≈ 12 half-lives.
3962        let weights = kimetsu_core::config::BrokerWeights::default();
3963        let bundle = retrieve_context_with_embedder(
3964            &conn,
3965            "/fake-repo",
3966            &weights,
3967            ContextRequest {
3968                stage: "localization".to_string(),
3969                query: "ripgrep search".to_string(),
3970                budget_tokens: 4000,
3971                ..Default::default()
3972            },
3973            &[],
3974            &embeddings::NoopEmbedder,
3975        )
3976        .expect("retrieve");
3977
3978        let mem_order: Vec<&str> = bundle
3979            .capsules
3980            .iter()
3981            .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
3982            .collect();
3983        assert_eq!(
3984            mem_order.first().copied(),
3985            Some("m_recent"),
3986            "recently-cited memory must rank first under decay; got order {mem_order:?}"
3987        );
3988    }
3989
3990    /// v0.5.1: with decay disabled (half_life = 0) the aged + recent
3991    /// memories tie and the deterministic tiebreaker (id) decides —
3992    /// proves the ranking flip in the previous test is *caused* by
3993    /// decay, not by some unrelated side effect of the timestamp.
3994    #[test]
3995    fn aged_cited_memory_does_not_decay_when_half_life_is_zero() {
3996        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3997        crate::schema::initialize(&conn).expect("init schema");
3998
3999        let now = OffsetDateTime::now_utc();
4000        let fmt = &time::format_description::well_known::Rfc3339;
4001        let one_day_ago = (now - time::Duration::seconds(86_400))
4002            .format(fmt)
4003            .expect("format");
4004        let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
4005            .format(fmt)
4006            .expect("format");
4007
4008        for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] {
4009            let text = "use ripgrep for code search";
4010            let normalized = kimetsu_core::memory::normalize_memory_text(text);
4011            conn.execute(
4012                "
4013                INSERT INTO memories (
4014                    memory_id, scope, kind, text, normalized_text, confidence,
4015                    source_event_id, provenance_snapshot_json, created_at,
4016                    use_count, usefulness_score, last_useful_at
4017                )
4018                VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
4019                        '2024-01-01T00:00:00Z', 5, 5.0, ?4)
4020                ",
4021                rusqlite::params![mid, text, normalized, last_useful],
4022            )
4023            .expect("insert memory");
4024            conn.execute(
4025                "INSERT INTO memories_fts (memory_id, text, kind, scope)
4026                 VALUES (?1, ?2, 'fact', 'global_user')",
4027                rusqlite::params![mid, text],
4028            )
4029            .expect("insert fts");
4030        }
4031
4032        // Disable decay via broker config.
4033        let weights = kimetsu_core::config::BrokerWeights {
4034            decay_half_life_days: 0.0,
4035            ..Default::default()
4036        };
4037
4038        let bundle = retrieve_context_with_embedder(
4039            &conn,
4040            "/fake-repo",
4041            &weights,
4042            ContextRequest {
4043                stage: "localization".to_string(),
4044                query: "ripgrep search".to_string(),
4045                budget_tokens: 4000,
4046                ..Default::default()
4047            },
4048            &[],
4049            &embeddings::NoopEmbedder,
4050        )
4051        .expect("retrieve");
4052
4053        // Both memories should surface. With decay disabled, their
4054        // scores are identical (same multiplier, same lexical match,
4055        // same freshness band since both created_at are equal). The
4056        // sort tiebreaker falls back to id, so m_aged < m_recent
4057        // alphabetically.
4058        let scores: Vec<(String, f32)> = bundle
4059            .capsules
4060            .iter()
4061            .filter_map(|c| {
4062                c.expansion_handle
4063                    .strip_prefix("memory:")
4064                    .map(|id| (id.to_string(), c.score))
4065            })
4066            .collect();
4067        assert_eq!(scores.len(), 2, "both memories should surface");
4068        let recent_score = scores
4069            .iter()
4070            .find(|(id, _)| id == "m_recent")
4071            .map(|(_, s)| *s)
4072            .expect("m_recent present");
4073        let aged_score = scores
4074            .iter()
4075            .find(|(id, _)| id == "m_aged")
4076            .map(|(_, s)| *s)
4077            .expect("m_aged present");
4078        // With decay off, the two multipliers are equal → scores match.
4079        assert!(
4080            (recent_score - aged_score).abs() < 1e-4,
4081            "with decay disabled the two memories should tie on score: recent={recent_score} aged={aged_score}"
4082        );
4083    }
4084
4085    /// v0.4.2: with [`NoopEmbedder`] the retrieval path is identical
4086    /// to v0.4.1 — no cosine term contributes, stored embeddings (if
4087    /// any) are ignored. Regression guard so the default build
4088    /// behaves identically to pre-v0.4.2.
4089    #[test]
4090    fn hybrid_retrieval_with_noop_embedder_is_lexical_only() {
4091        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4092        crate::schema::initialize(&conn).expect("init schema");
4093        let stub = embeddings::StubEmbedder::new();
4094        // Two memories, both with non-null embeddings.
4095        insert_memory_with_embedding(&conn, "m_a", "use ripgrep", &stub);
4096        insert_memory_with_embedding(&conn, "m_b", "use ripgrep too", &stub);
4097
4098        // Query through the Noop default. QueryEmbedding will be
4099        // None → no cosine blend → exact FTS ranking.
4100        let weights = kimetsu_core::config::BrokerWeights::default();
4101        let bundle = retrieve_context_with_embedder(
4102            &conn,
4103            "/fake-repo",
4104            &weights,
4105            ContextRequest {
4106                stage: "localization".to_string(),
4107                query: "ripgrep".to_string(),
4108                budget_tokens: 4000,
4109                ..Default::default()
4110            },
4111            &[],
4112            &embeddings::NoopEmbedder,
4113        )
4114        .expect("retrieve");
4115
4116        let count = bundle
4117            .capsules
4118            .iter()
4119            .filter(|c| c.expansion_handle.starts_with("memory:"))
4120            .count();
4121        assert_eq!(count, 2, "both memories should surface via FTS");
4122    }
4123
4124    // ---------------------------------------------------------------
4125    // D1d tests: ANN index correctness, rebuild on model change, dedup
4126    // ---------------------------------------------------------------
4127
4128    /// D1d test 1: ANN finds a semantic match that FTS misses.
4129    ///
4130    /// Strategy: use a manually-crafted ("oracle") embedder that returns a
4131    /// FIXED known vector for any input, paired with a direct-SQL memory
4132    /// insertion that stores the SAME vector for the "semantic" memory and a
4133    /// DIFFERENT vector for the "lexical decoy". The query text and memory
4134    /// texts deliberately share NO words, so FTS returns nothing. ANN
4135    /// surfaces the semantically-near memory via the usearch index.
4136    ///
4137    /// Concretely:
4138    ///   - query text = "phosphorescent bioluminescent organism" (no overlap
4139    ///     with any memory text)
4140    ///   - m_semantic text = "cookie recipe chocolate" — completely different
4141    ///     words, but we MANUALLY store the same vector as the query embedding.
4142    ///   - m_decoy text = "git rebase squash commits" — different text,
4143    ///     orthogonal vector.
4144    ///
4145    /// The "oracle" embedder always returns [1,0,0,0,0,0,0,0] for any text.
4146    /// We store [1,0,0,0,0,0,0,0] for m_semantic and [0,1,0,0,0,0,0,0] for
4147    /// m_decoy. Cosine("oracle query", m_semantic) = 1.0; cosine(query,
4148    /// m_decoy) = 0.0. FTS finds nothing (no shared tokens). ANN finds
4149    /// m_semantic as the nearest neighbour.
4150    #[cfg(feature = "embeddings")]
4151    #[test]
4152    fn ann_finds_semantic_match_fts_misses() {
4153        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4154        crate::schema::initialize(&conn).expect("init schema");
4155
4156        // Oracle embedder: always returns the same unit vector regardless of text.
4157        // This lets us control cosine similarity independently of word overlap.
4158        struct OracleEmbedder;
4159        impl embeddings::Embedder for OracleEmbedder {
4160            fn embed(&self, _text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4161                // [1,0,0,0,0,0,0,0] — unit vector along dim-0
4162                Ok(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
4163            }
4164            fn model_id(&self) -> &str {
4165                "oracle-d8"
4166            }
4167            fn dim(&self) -> usize {
4168                8
4169            }
4170        }
4171
4172        let model_id = "oracle-d8";
4173
4174        // m_semantic: text shares NO tokens with the query, but stored
4175        // embedding is [1,0,...,0] — cosine with the oracle query vector = 1.0.
4176        let sem_vec = embeddings::encode_embedding(&[1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
4177        let sem_text = "cookie recipe chocolate";
4178        let sem_norm = kimetsu_core::memory::normalize_memory_text(sem_text);
4179        conn.execute(
4180            "INSERT INTO memories (
4181                 memory_id, scope, kind, text, normalized_text, confidence,
4182                 source_event_id, provenance_snapshot_json, created_at,
4183                 use_count, usefulness_score, embedding, embedding_model
4184             )
4185             VALUES ('m_semantic', 'global_user', 'fact', ?1, ?2, 1.0, NULL, '{}',
4186                     '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
4187            rusqlite::params![sem_text, sem_norm, sem_vec, model_id],
4188        )
4189        .expect("insert m_semantic");
4190        conn.execute(
4191            "INSERT INTO memories_fts (memory_id, text, kind, scope)
4192             VALUES ('m_semantic', ?1, 'fact', 'global_user')",
4193            rusqlite::params![sem_text],
4194        )
4195        .expect("insert m_semantic fts");
4196
4197        // m_decoy: different text, orthogonal vector [0,1,0,...,0].
4198        let decoy_vec = embeddings::encode_embedding(&[0.0f32, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
4199        let decoy_text = "git rebase squash commits";
4200        let decoy_norm = kimetsu_core::memory::normalize_memory_text(decoy_text);
4201        conn.execute(
4202            "INSERT INTO memories (
4203                 memory_id, scope, kind, text, normalized_text, confidence,
4204                 source_event_id, provenance_snapshot_json, created_at,
4205                 use_count, usefulness_score, embedding, embedding_model
4206             )
4207             VALUES ('m_decoy', 'global_user', 'fact', ?1, ?2, 1.0, NULL, '{}',
4208                     '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
4209            rusqlite::params![decoy_text, decoy_norm, decoy_vec, model_id],
4210        )
4211        .expect("insert m_decoy");
4212        conn.execute(
4213            "INSERT INTO memories_fts (memory_id, text, kind, scope)
4214             VALUES ('m_decoy', ?1, 'fact', 'global_user')",
4215            rusqlite::params![decoy_text],
4216        )
4217        .expect("insert m_decoy fts");
4218
4219        // Sanity: FTS must find nothing for the query tokens.
4220        let fts_hits: i64 = conn
4221            .query_row(
4222                "SELECT COUNT(*) FROM memories_fts \
4223                 WHERE memories_fts MATCH 'phosphorescent bioluminescent'",
4224                [],
4225                |r| r.get(0),
4226            )
4227            .unwrap_or(0);
4228        assert_eq!(
4229            fts_hits, 0,
4230            "sanity: query tokens must not appear in any memory text"
4231        );
4232
4233        // Retrieve via oracle embedder.
4234        // query = "phosphorescent bioluminescent organism" has no lexical
4235        // overlap with either memory. ANN must surface m_semantic (cosine=1).
4236        let weights = kimetsu_core::config::BrokerWeights::default();
4237        let bundle = retrieve_context_with_embedder(
4238            &conn,
4239            "/fake-repo",
4240            &weights,
4241            ContextRequest {
4242                stage: "localization".to_string(),
4243                query: "phosphorescent bioluminescent organism".to_string(),
4244                budget_tokens: 4000,
4245                ..Default::default()
4246            },
4247            &[],
4248            &OracleEmbedder,
4249        )
4250        .expect("retrieve");
4251
4252        let handles: Vec<&str> = bundle
4253            .capsules
4254            .iter()
4255            .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
4256            .collect();
4257
4258        assert!(
4259            handles.contains(&"m_semantic"),
4260            "ANN must surface m_semantic (cosine=1 with oracle query) even though \
4261             FTS found nothing; got handles: {handles:?}"
4262        );
4263    }
4264
4265    /// D1d test 3: a memory matched by both FTS and ANN appears exactly once.
4266    #[cfg(feature = "embeddings")]
4267    #[test]
4268    fn dedup_memory_matched_by_fts_and_ann_appears_once() {
4269        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4270        crate::schema::initialize(&conn).expect("init schema");
4271
4272        let stub = embeddings::StubEmbedder::new();
4273
4274        // This memory contains "ripgrep" (lexical) AND has a stub embedding
4275        // derived from its text, so the query "ripgrep" matches it both via
4276        // FTS and via ANN (same words → same stub bucket vector).
4277        insert_memory_with_embedding(&conn, "m_both", "use ripgrep for fast search", &stub);
4278
4279        let weights = kimetsu_core::config::BrokerWeights::default();
4280        let bundle = retrieve_context_with_embedder(
4281            &conn,
4282            "/fake-repo",
4283            &weights,
4284            ContextRequest {
4285                stage: "localization".to_string(),
4286                query: "ripgrep".to_string(),
4287                budget_tokens: 4000,
4288                ..Default::default()
4289            },
4290            &[],
4291            &stub,
4292        )
4293        .expect("retrieve");
4294
4295        let count = bundle
4296            .capsules
4297            .iter()
4298            .filter(|c| c.expansion_handle == "memory:m_both")
4299            .count();
4300        assert_eq!(
4301            count,
4302            1,
4303            "m_both (matched by both FTS and ANN) must appear exactly once; \
4304             bundle: {:?}",
4305            bundle
4306                .capsules
4307                .iter()
4308                .map(|c| &c.expansion_handle)
4309                .collect::<Vec<_>>()
4310        );
4311    }
4312
4313    // ---------------------------------------------------------------
4314    // D1e tests: embedding-MMR deduplication + semantic relevance floor
4315    // ---------------------------------------------------------------
4316
4317    /// D1e-a (embeddings-gated): two paraphrased memories that share an
4318    /// almost-identical embedding vector (cosine = 1.0, so embedding-MMR
4319    /// sees them as maximally redundant) but have LOW Jaccard overlap on
4320    /// their summary tokens (different words, so the Jaccard-only capsule-
4321    /// stage MMR would NOT penalize the second one and both survive the
4322    /// budget with max_capsules=2).
4323    ///
4324    /// Key mechanic: embedding-MMR assigns the second near-duplicate a very
4325    /// negative MMR score (lambda * score - (1-lambda) * 1.0 < 0 when score
4326    /// is small). It therefore ends up LAST in the reordered candidate list.
4327    /// When max_capsules=1 it is excluded. With Jaccard-only (NoopEmbedder),
4328    /// the second paraphrase has low Jaccard overlap → survives when
4329    /// max_capsules=2.
4330    ///
4331    /// Expected result:
4332    ///   * OracleEmbedder + max_capsules=1: ONE paraphrase (embedding-MMR
4333    ///     collapsed the redundant one).
4334    ///   * NoopEmbedder + max_capsules=2: BOTH paraphrases survive (Jaccard
4335    ///     does not see them as redundant — different tokens).
4336    #[cfg(feature = "embeddings")]
4337    #[test]
4338    fn embedding_mmr_collapses_paraphrases_but_jaccard_does_not() {
4339        // OracleEmbedder: always returns [1,0,0,…] (dim=8).
4340        // cosine(any two texts) = 1.0 → maximal redundancy in embedding space.
4341        struct OracleEmbedder;
4342        impl embeddings::Embedder for OracleEmbedder {
4343            fn embed(&self, _text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4344                let mut v = vec![0.0f32; 8];
4345                v[0] = 1.0;
4346                Ok(v)
4347            }
4348            fn model_id(&self) -> &str {
4349                "oracle-d8"
4350            }
4351            fn dim(&self) -> usize {
4352                8
4353            }
4354        }
4355
4356        // Setup: two memories with DIFFERENT words (low Jaccard) but
4357        // SAME oracle embedding (cosine = 1.0).
4358        let oracle = OracleEmbedder;
4359        let weights = kimetsu_core::config::BrokerWeights::default();
4360
4361        // "prefer ripgrep" vs "rg is the fastest" — entirely different tokens.
4362        // Summary token-set overlap ≈ 0 ⟹ Jaccard ≈ 0.
4363        let m_rg1_text = "prefer ripgrep for searching source code";
4364        let m_rg2_text = "rg is the fastest way to locate patterns";
4365
4366        // --- Embedding-MMR path (OracleEmbedder), max_capsules=1 ---
4367        // Under embedding-MMR: second paraphrase gets MMR score
4368        //   0.7 * score - 0.3 * 1.0  (overlap = cosine = 1.0)
4369        // For any small normalised score, this is negative → it is assigned
4370        // last in the MMR reordering. max_capsules=1 → only 1 included.
4371        let conn = rusqlite::Connection::open_in_memory().expect("in-memory");
4372        crate::schema::initialize(&conn).expect("init schema");
4373        insert_memory_with_embedding(&conn, "m_rg1", m_rg1_text, &oracle);
4374        insert_memory_with_embedding(&conn, "m_rg2", m_rg2_text, &oracle);
4375
4376        let bundle_embedding = retrieve_context_with_embedder(
4377            &conn,
4378            "/fake-repo",
4379            &weights,
4380            ContextRequest {
4381                stage: "localization".to_string(),
4382                // Query that matches both via FTS so they survive pre-MMR scoring.
4383                query: "search source patterns".to_string(),
4384                budget_tokens: 20_000,
4385                max_capsules: 1, // tight cap: only 1 slot available
4386                ..Default::default()
4387            },
4388            &[],
4389            &oracle,
4390        )
4391        .expect("retrieve with oracle embedder");
4392
4393        // Under embedding-MMR, the second paraphrase (cosine=1.0 with first)
4394        // is reranked last and excluded by max_capsules=1.
4395        let emb_in_capsules = bundle_embedding
4396            .capsules
4397            .iter()
4398            .filter(|c| {
4399                c.expansion_handle == "memory:m_rg1" || c.expansion_handle == "memory:m_rg2"
4400            })
4401            .count();
4402        assert_eq!(
4403            emb_in_capsules,
4404            1,
4405            "embedding-MMR must collapse cosine=1.0 paraphrases: with max_capsules=1 \
4406             only ONE should be included; capsule handles: {:?}; excluded: {:?}",
4407            bundle_embedding
4408                .capsules
4409                .iter()
4410                .map(|c| &c.expansion_handle)
4411                .collect::<Vec<_>>(),
4412            bundle_embedding
4413                .excluded
4414                .iter()
4415                .map(|c| &c.expansion_handle)
4416                .collect::<Vec<_>>()
4417        );
4418
4419        // At least one is in excluded (the redundant near-duplicate).
4420        let emb_in_excluded = bundle_embedding
4421            .excluded
4422            .iter()
4423            .filter(|c| {
4424                c.expansion_handle == "memory:m_rg1" || c.expansion_handle == "memory:m_rg2"
4425            })
4426            .count();
4427        assert_eq!(
4428            emb_in_excluded,
4429            1,
4430            "the second near-duplicate must be in excluded under embedding-MMR; \
4431             excluded handles: {:?}",
4432            bundle_embedding
4433                .excluded
4434                .iter()
4435                .map(|c| &c.expansion_handle)
4436                .collect::<Vec<_>>()
4437        );
4438
4439        // --- Lean/Jaccard-only path (NoopEmbedder), max_capsules=2 ---
4440        // With Jaccard-only: summary tokens of m_rg1 and m_rg2 have ≈0
4441        // overlap (different words) → low redundancy penalty → BOTH score
4442        // high under MMR → both survive with max_capsules=2.
4443        let conn2 = rusqlite::Connection::open_in_memory().expect("in-memory 2");
4444        crate::schema::initialize(&conn2).expect("init schema 2");
4445        insert_memory_with_embedding(&conn2, "m_rg1", m_rg1_text, &oracle);
4446        insert_memory_with_embedding(&conn2, "m_rg2", m_rg2_text, &oracle);
4447
4448        let bundle_lean = retrieve_context_with_embedder(
4449            &conn2,
4450            "/fake-repo",
4451            &weights,
4452            ContextRequest {
4453                stage: "localization".to_string(),
4454                query: "search source patterns".to_string(),
4455                budget_tokens: 20_000,
4456                max_capsules: 2, // room for both
4457                ..Default::default()
4458            },
4459            &[],
4460            &embeddings::NoopEmbedder,
4461        )
4462        .expect("retrieve with NoopEmbedder");
4463
4464        let lean_in_capsules = bundle_lean
4465            .capsules
4466            .iter()
4467            .filter(|c| {
4468                c.expansion_handle == "memory:m_rg1" || c.expansion_handle == "memory:m_rg2"
4469            })
4470            .count();
4471        assert_eq!(
4472            lean_in_capsules,
4473            2,
4474            "Jaccard-only path must NOT collapse the two paraphrases (different words, \
4475             low token overlap → both survive MMR with max_capsules=2); capsule handles: {:?}",
4476            bundle_lean
4477                .capsules
4478                .iter()
4479                .map(|c| &c.expansion_handle)
4480                .collect::<Vec<_>>()
4481        );
4482    }
4483
4484    // ── v1.0.0: lexical relevance floor (A+B+C) ──────────────────────────
4485
4486    #[test]
4487    fn content_tokens_strips_stopwords_keeps_topical_words() {
4488        let got = content_tokens("Tell me about kimetsu, what's the idea of the repo");
4489        // Stopwords (tell, me, about, what, the, of) dropped; "s" too short.
4490        // Topical words kept; deduped (no second "the").
4491        assert_eq!(got, vec!["kimetsu", "idea", "repo"]);
4492    }
4493
4494    #[test]
4495    fn light_stem_strips_one_inflection_suffix() {
4496        assert_eq!(light_stem("benchmarked"), "benchmark");
4497        assert_eq!(light_stem("benchmarking"), "benchmark");
4498        assert_eq!(light_stem("repos"), "repo");
4499        // Too short after stripping → untouched.
4500        assert_eq!(light_stem("does"), "does");
4501        assert_eq!(light_stem("toml"), "toml");
4502    }
4503
4504    /// Real-world regression: "Can you find out how kimetsu is benchmarked?"
4505    /// surfaced off-topic memories because the inflected "benchmarked"
4506    /// matched nothing (FTS prefix `benchmarked*` and IDF `%benchmarked%`
4507    /// both miss "benchmark"), zeroing the query's only discriminating
4508    /// token. With query-side stemming the benchmark memory surfaces and
4509    /// the off-topic ones stay below the floor.
4510    #[test]
4511    fn stemmed_query_matches_inflected_corpus_through_floor() {
4512        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4513        crate::schema::initialize(&conn).expect("init schema");
4514        let insert = |id: &str, text: &str| {
4515            let norm = kimetsu_core::memory::normalize_memory_text(text);
4516            conn.execute(
4517                "INSERT INTO memories (
4518                     memory_id, scope, kind, text, normalized_text, confidence,
4519                     source_event_id, provenance_snapshot_json, created_at,
4520                     use_count, usefulness_score, embedding, embedding_model
4521                 )
4522                 VALUES (?1, 'global_user', 'fact', ?2, ?3, 0.9, NULL, '{}',
4523                         '2026-06-01T00:00:00Z', 0, 0.0, NULL, NULL)",
4524                rusqlite::params![id, text, norm],
4525            )
4526            .expect("insert memory");
4527            conn.execute(
4528                "INSERT INTO memories_fts (memory_id, text, kind, scope)
4529                 VALUES (?1, ?2, 'fact', 'global_user')",
4530                rusqlite::params![id, text],
4531            )
4532            .expect("insert fts");
4533        };
4534        insert(
4535            "m_bench",
4536            "kimetsu benchmark runs go through the kbench binary and the Terminal-Bench driver",
4537        );
4538        insert(
4539            "m_doctor",
4540            "kimetsu doctor version-skew check parses process start times on Windows via CIM",
4541        );
4542        insert(
4543            "m_gc",
4544            "kimetsu runs auto-GC on run creation; keep the env guard at the trigger site",
4545        );
4546
4547        let bundle = retrieve_context_with_embedder(
4548            &conn,
4549            "/fake-repo",
4550            &kimetsu_core::config::BrokerWeights::default(),
4551            ContextRequest {
4552                stage: "localization".to_string(),
4553                query: "Can you find out how kimetsu is benchmarked?".to_string(),
4554                budget_tokens: 2000,
4555                max_capsules: 2,
4556                min_lexical_coverage: 0.5,
4557                ..Default::default()
4558            },
4559            &[],
4560            &embeddings::NoopEmbedder,
4561        )
4562        .expect("retrieve");
4563        let handles: Vec<_> = bundle
4564            .capsules
4565            .iter()
4566            .map(|c| c.expansion_handle.as_str())
4567            .collect();
4568        assert!(
4569            handles.contains(&"memory:m_bench"),
4570            "stemmed 'benchmarked' must surface the benchmark memory; got {handles:?}"
4571        );
4572        assert!(
4573            !handles.contains(&"memory:m_doctor") && !handles.contains(&"memory:m_gc"),
4574            "off-topic memories sharing only 'kimetsu' must stay below the floor; got {handles:?}"
4575        );
4576    }
4577
4578    #[test]
4579    fn weighted_coverage_ignores_zero_idf_tokens() {
4580        // "kimetsu" is corpus-ubiquitous (idf 0); "idea" is rare (high idf);
4581        // "repo" is mid. A summary that matches only the project name + a
4582        // mid-idf word covers a minority of the discriminating weight.
4583        let content = vec![
4584            "kimetsu".to_string(),
4585            "idea".to_string(),
4586            "repo".to_string(),
4587        ];
4588        let mut idf = HashMap::new();
4589        idf.insert("kimetsu".to_string(), 0.0);
4590        idf.insert("idea".to_string(), 1.386);
4591        idf.insert("repo".to_string(), 0.693);
4592
4593        // Matches kimetsu + repo, NOT idea → 0.693 / (1.386+0.693) ≈ 0.333.
4594        let cov = weighted_coverage(
4595            &content,
4596            &idf,
4597            "global:fact - the git repo and kimetsu brain",
4598        );
4599        assert!((cov - 0.333).abs() < 0.01, "got {cov}");
4600
4601        // Matches the rare topical word → high coverage.
4602        let cov_topical =
4603            weighted_coverage(&content, &idf, "global:fact - the core idea of kimetsu");
4604        assert!(cov_topical > 0.6, "got {cov_topical}");
4605    }
4606
4607    /// The reported regression, reproduced end-to-end on the FTS-only path:
4608    /// a corpus of unrelated debugging war-stories that all happen to contain
4609    /// the project name "kimetsu", queried with a broad conceptual prompt.
4610    ///
4611    /// * floor disabled (min_lexical_coverage = 0.0) → all the noise surfaces
4612    ///   (pre-fix behaviour: incidental "kimetsu" overlap is enough).
4613    /// * floor enabled (0.5) → the memories whose ONLY match is the corpus-
4614    ///   ubiquitous project name (m2, m3) are dropped. m1 also contains the
4615    ///   real word "repo", so it's a genuine (if weak) lexical match and
4616    ///   survives — eliminating that kind of keyword-overlap-but-off-topic
4617    ///   hit needs the semantic path, not lexical filtering. The win here is
4618    ///   killing the pure-project-name matches, which were the bulk of the
4619    ///   injected noise.
4620    #[test]
4621    fn lexical_floor_drops_offtopic_memories_sharing_project_name() {
4622        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4623        crate::schema::initialize(&conn).expect("init schema");
4624
4625        let insert = |id: &str, text: &str| {
4626            let norm = kimetsu_core::memory::normalize_memory_text(text);
4627            conn.execute(
4628                "INSERT INTO memories (
4629                     memory_id, scope, kind, text, normalized_text, confidence,
4630                     source_event_id, provenance_snapshot_json, created_at,
4631                     use_count, usefulness_score, embedding, embedding_model
4632                 )
4633                 VALUES (?1, 'global_user', 'fact', ?2, ?3, 0.9, NULL, '{}',
4634                         '2026-06-01T00:00:00Z', 0, 0.0, NULL, NULL)",
4635                rusqlite::params![id, text, norm],
4636            )
4637            .expect("insert memory");
4638            conn.execute(
4639                "INSERT INTO memories_fts (memory_id, text, kind, scope)
4640                 VALUES (?1, ?2, 'fact', 'global_user')",
4641                rusqlite::params![id, text],
4642            )
4643            .expect("insert fts");
4644        };
4645
4646        // All three contain "kimetsu"; none contain "idea". Only m1 contains
4647        // "repo" (as in "git repo") — mirrors the real war-stories.
4648        insert(
4649            "m1",
4650            "When implementing a setup command that calls init_project, tests must call \
4651             git_init_boundary before setup_cmd so ProjectPaths discover resolves to the temp \
4652             dir instead of climbing to the real parent git repo including the user brain at kimetsu",
4653        );
4654        insert(
4655            "m2",
4656            "A member crate with default embeddings silently turned embeddings on for the entire \
4657             cargo test workspace build graph because cargo unifies features; kimetsu-chat \
4658             retrieval tests failed",
4659        );
4660        insert(
4661            "m3",
4662            "In toml 0.9 use toml from_str to parse a TOML document into a Value not str parse; \
4663             implementing config get and set in kimetsu-cli",
4664        );
4665
4666        let query = "Tell me about kimetsu, what's the idea of the repo".to_string();
4667        let weights = kimetsu_core::config::BrokerWeights::default();
4668        let handles = |bundle: &ContextBundle| {
4669            bundle
4670                .capsules
4671                .iter()
4672                .map(|c| c.expansion_handle.clone())
4673                .collect::<Vec<_>>()
4674        };
4675
4676        // Floor disabled: every off-topic memory surfaces (pre-fix behaviour).
4677        let no_floor = retrieve_context_with_embedder(
4678            &conn,
4679            "/fake-repo",
4680            &weights,
4681            ContextRequest {
4682                stage: "localization".to_string(),
4683                query: query.clone(),
4684                budget_tokens: 2000,
4685                max_capsules: 8,
4686                min_lexical_coverage: 0.0,
4687                ..Default::default()
4688            },
4689            &[],
4690            &embeddings::NoopEmbedder,
4691        )
4692        .expect("retrieve without floor");
4693        let before = handles(&no_floor);
4694        assert!(
4695            before.contains(&"memory:m2".to_string()) && before.contains(&"memory:m3".to_string()),
4696            "sanity: without the floor the pure-project-name memories should surface; got {before:?}"
4697        );
4698
4699        // Floor enabled: the pure-project-name matches (m2, m3) are dropped.
4700        let floored = retrieve_context_with_embedder(
4701            &conn,
4702            "/fake-repo",
4703            &weights,
4704            ContextRequest {
4705                stage: "localization".to_string(),
4706                query,
4707                budget_tokens: 2000,
4708                max_capsules: 8,
4709                min_lexical_coverage: 0.5,
4710                ..Default::default()
4711            },
4712            &[],
4713            &embeddings::NoopEmbedder,
4714        )
4715        .expect("retrieve with floor");
4716        let after = handles(&floored);
4717        assert!(
4718            !after.contains(&"memory:m2".to_string()) && !after.contains(&"memory:m3".to_string()),
4719            "the lexical floor must drop memories whose only match is the corpus-ubiquitous \
4720             project name; surviving: {after:?}"
4721        );
4722    }
4723
4724    /// A genuinely on-topic query must NOT be over-pruned: a memory that
4725    /// covers the query's rare, discriminating word survives the floor.
4726    #[test]
4727    fn lexical_floor_keeps_ontopic_memory() {
4728        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4729        crate::schema::initialize(&conn).expect("init schema");
4730
4731        let insert = |id: &str, text: &str| {
4732            let norm = kimetsu_core::memory::normalize_memory_text(text);
4733            conn.execute(
4734                "INSERT INTO memories (
4735                     memory_id, scope, kind, text, normalized_text, confidence,
4736                     source_event_id, provenance_snapshot_json, created_at,
4737                     use_count, usefulness_score, embedding, embedding_model
4738                 )
4739                 VALUES (?1, 'global_user', 'fact', ?2, ?3, 0.9, NULL, '{}',
4740                         '2026-06-01T00:00:00Z', 0, 0.0, NULL, NULL)",
4741                rusqlite::params![id, text, norm],
4742            )
4743            .expect("insert memory");
4744            conn.execute(
4745                "INSERT INTO memories_fts (memory_id, text, kind, scope)
4746                 VALUES (?1, ?2, 'fact', 'global_user')",
4747                rusqlite::params![id, text],
4748            )
4749            .expect("insert fts");
4750        };
4751
4752        // Two memories so "distiller" is rare (df=1) → high idf.
4753        insert(
4754            "d1",
4755            "The distiller runs at session end and harvests durable lessons from the transcript",
4756        );
4757        insert(
4758            "n1",
4759            "Unrelated note about git rebase and squashing commits",
4760        );
4761
4762        let bundle = retrieve_context_with_embedder(
4763            &conn,
4764            "/fake-repo",
4765            &kimetsu_core::config::BrokerWeights::default(),
4766            ContextRequest {
4767                stage: "localization".to_string(),
4768                query: "how does the distiller work".to_string(),
4769                budget_tokens: 2000,
4770                min_lexical_coverage: 0.5,
4771                ..Default::default()
4772            },
4773            &[],
4774            &embeddings::NoopEmbedder,
4775        )
4776        .expect("retrieve");
4777
4778        assert!(
4779            bundle
4780                .capsules
4781                .iter()
4782                .any(|c| c.expansion_handle == "memory:d1"),
4783            "on-topic memory covering the rare query word must survive the floor; got: {:?}",
4784            bundle
4785                .capsules
4786                .iter()
4787                .map(|c| &c.expansion_handle)
4788                .collect::<Vec<_>>()
4789        );
4790    }
4791
4792    /// D1e-b: absolute semantic relevance floor (min_semantic_score).
4793    ///
4794    /// * With a positive floor and a query whose embedding is orthogonal
4795    ///   to every memory, the result must be `skipped: true` / 0 capsules.
4796    /// * With the same floor and a query that IS relevant, the memory
4797    ///   still surfaces (signal preserved).
4798    /// * With floor = 0.0 (default), the off-topic query still surfaces
4799    ///   the "best of a bad lot" (existing pre-D1e behaviour).
4800    #[cfg(feature = "embeddings")]
4801    #[test]
4802    fn min_semantic_score_floor_drops_off_topic_queries() {
4803        // DirectionalEmbedder: returns a specific unit vector based on
4804        // which "topic" the text is assigned to. Allows us to place the
4805        // query vector and memory vectors in known relative positions.
4806        //
4807        // dim=8. Topic A = [1,0,0,0,0,0,0,0]. Topic B = [0,1,0,0,0,0,0,0].
4808        // cosine(A, B) = 0.0 → perfectly orthogonal (unrelated).
4809        // cosine(A, A) = 1.0 → identical topic.
4810        //
4811        // We embed the query on topic A, the memory on topic B.
4812        // Cosine(query, memory) = 0.0 < any positive floor.
4813        struct DirectionalEmbedder {
4814            // Text containing "TOPIC_A" embeds as [1,0,…]; all others as [0,1,…].
4815            marker: &'static str,
4816        }
4817        impl embeddings::Embedder for DirectionalEmbedder {
4818            fn embed(&self, text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4819                let mut v = vec![0.0f32; 8];
4820                if text.contains(self.marker) {
4821                    v[0] = 1.0;
4822                } else {
4823                    v[1] = 1.0;
4824                }
4825                Ok(v)
4826            }
4827            fn model_id(&self) -> &str {
4828                "directional-d8"
4829            }
4830            fn dim(&self) -> usize {
4831                8
4832            }
4833        }
4834
4835        let emb = DirectionalEmbedder { marker: "TOPIC_A" };
4836
4837        let conn = rusqlite::Connection::open_in_memory().expect("in-memory");
4838        crate::schema::initialize(&conn).expect("init schema");
4839
4840        // Memory is on topic B (does NOT contain "TOPIC_A").
4841        insert_memory_with_embedding(&conn, "m_b", "cookie recipe chocolate baking TOPIC_B", &emb);
4842
4843        let weights = kimetsu_core::config::BrokerWeights::default();
4844
4845        // 1. Off-topic query (TOPIC_A) with a positive floor: must be skipped.
4846        let bundle_off = retrieve_context_with_embedder(
4847            &conn,
4848            "/fake-repo",
4849            &weights,
4850            ContextRequest {
4851                stage: "localization".to_string(),
4852                // Query is on TOPIC_A (cosine with memory = 0.0).
4853                query: "TOPIC_A unrelated phosphorescent".to_string(),
4854                budget_tokens: 4000,
4855                min_semantic_score: 0.1, // positive floor
4856                ..Default::default()
4857            },
4858            &[],
4859            &emb,
4860        )
4861        .expect("retrieve off-topic");
4862
4863        assert!(
4864            bundle_off.capsules.is_empty(),
4865            "off-topic query (cosine=0 < floor=0.1) must produce zero capsules; \
4866             got: {:?}",
4867            bundle_off
4868                .capsules
4869                .iter()
4870                .map(|c| &c.expansion_handle)
4871                .collect::<Vec<_>>()
4872        );
4873
4874        // 2. On-topic query (TOPIC_B): cosine = 1.0 ≥ floor → surfaces.
4875        // Insert a memory explicitly on topic B that FTS can also match.
4876        let conn2 = rusqlite::Connection::open_in_memory().expect("in-memory 2");
4877        crate::schema::initialize(&conn2).expect("init schema 2");
4878        insert_memory_with_embedding(
4879            &conn2,
4880            "m_b2",
4881            "cookie recipe chocolate TOPIC_B baking"
4882                .to_string()
4883                .as_str(),
4884            &emb,
4885        );
4886
4887        let bundle_on = retrieve_context_with_embedder(
4888            &conn2,
4889            "/fake-repo",
4890            &weights,
4891            ContextRequest {
4892                stage: "localization".to_string(),
4893                // Query is on TOPIC_B: cosine with m_b2 = 1.0 ≥ floor.
4894                query: "cookie chocolate TOPIC_B".to_string(),
4895                budget_tokens: 4000,
4896                min_semantic_score: 0.1,
4897                ..Default::default()
4898            },
4899            &[],
4900            &emb,
4901        )
4902        .expect("retrieve on-topic");
4903
4904        assert!(
4905            bundle_on
4906                .capsules
4907                .iter()
4908                .any(|c| c.expansion_handle == "memory:m_b2"),
4909            "on-topic query (cosine=1.0 ≥ floor) must surface m_b2; \
4910             got capsules: {:?}",
4911            bundle_on
4912                .capsules
4913                .iter()
4914                .map(|c| &c.expansion_handle)
4915                .collect::<Vec<_>>()
4916        );
4917
4918        // 3. Off-topic query with floor=0.0 (disabled): memory still surfaces
4919        //    (existing pre-D1e behaviour — floor is a no-op at 0.0).
4920        let conn3 = rusqlite::Connection::open_in_memory().expect("in-memory 3");
4921        crate::schema::initialize(&conn3).expect("init schema 3");
4922        insert_memory_with_embedding(
4923            &conn3,
4924            "m_b3",
4925            "cookie chocolate TOPIC_B recipe".to_string().as_str(),
4926            &emb,
4927        );
4928
4929        let bundle_noop_floor = retrieve_context_with_embedder(
4930            &conn3,
4931            "/fake-repo",
4932            &weights,
4933            ContextRequest {
4934                stage: "localization".to_string(),
4935                // FTS: "cookie chocolate" matches m_b3.
4936                query: "cookie chocolate TOPIC_A".to_string(),
4937                budget_tokens: 4000,
4938                min_semantic_score: 0.0, // disabled
4939                ..Default::default()
4940            },
4941            &[],
4942            &emb,
4943        )
4944        .expect("retrieve noop floor");
4945
4946        // With floor disabled, FTS match is enough — memory surfaces.
4947        assert!(
4948            bundle_noop_floor
4949                .capsules
4950                .iter()
4951                .any(|c| c.expansion_handle == "memory:m_b3"),
4952            "with floor=0.0 (disabled), off-topic-cosine memory must still surface via FTS; \
4953             got: {:?}",
4954            bundle_noop_floor
4955                .capsules
4956                .iter()
4957                .map(|c| &c.expansion_handle)
4958                .collect::<Vec<_>>()
4959        );
4960    }
4961
4962    // ---------------------------------------------------------------
4963    // D1f test: token-economy reduction proof
4964    // ---------------------------------------------------------------
4965
4966    /// D1f: Prove that embedding-MMR + semantic floor reduces token usage
4967    /// while preserving signal.
4968    ///
4969    /// Setup: a corpus of 6 memories:
4970    ///   * 3 near-duplicate paraphrases on topic A (same OracleA vector)
4971    ///   * 1 genuinely relevant memory on topic A (same OracleA vector,
4972    ///     different words)
4973    ///   * 2 completely unrelated memories on topic B (OracleB vector)
4974    ///
4975    /// Query: topic A.
4976    ///
4977    /// WITHOUT D1e (NoopEmbedder + floor=0.0): all 6 memories potentially
4978    /// surface (no semantic dedup, no floor). With the budget large enough
4979    /// all 6 fit → many capsules, many tokens.
4980    ///
4981    /// WITH D1e (OracleEmbedder + positive floor):
4982    ///   * Floor (min_semantic_score > 0) drops the 2 topic-B memories.
4983    ///   * Embedding-MMR collapses the 3 near-duplicate topic-A memories
4984    ///     to 1 slot.
4985    ///   * The genuinely-relevant memory survives (it is the "seed" of MMR
4986    ///     or at least one slot per topic-A cluster remains).
4987    ///
4988    /// Assertion: WITH D1e → strictly fewer capsules AND the genuinely-
4989    /// relevant memory is still present (signal preserved, noise cut).
4990    #[cfg(feature = "embeddings")]
4991    #[test]
4992    fn d1f_token_economy_fewer_capsules_signal_preserved() {
4993        // OracleEmbedder: topic-A text gets [1,0,…]; everything else [0,1,…].
4994        struct OracleTopicEmbedder;
4995        impl embeddings::Embedder for OracleTopicEmbedder {
4996            fn embed(&self, text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4997                let mut v = vec![0.0f32; 8];
4998                if text.contains("TOPIC_A") {
4999                    v[0] = 1.0; // topic A
5000                } else {
5001                    v[1] = 1.0; // topic B
5002                }
5003                Ok(v)
5004            }
5005            fn model_id(&self) -> &str {
5006                "oracle-topic-d8"
5007            }
5008            fn dim(&self) -> usize {
5009                8
5010            }
5011        }
5012
5013        let oracle = OracleTopicEmbedder;
5014
5015        // Helper: set up the corpus on a fresh connection.
5016        let setup = |conn: &rusqlite::Connection| {
5017            // 3 near-duplicate paraphrases on topic A (same oracle vector,
5018            // different FTS words so they match the query but Jaccard is low).
5019            for (mid, text) in [
5020                ("m_dup1", "TOPIC_A prefer ripgrep for searching"),
5021                ("m_dup2", "TOPIC_A rg is the fastest searcher"),
5022                ("m_dup3", "TOPIC_A use rg tool to find patterns"),
5023                // 1 genuinely-relevant memory on topic A (the one we must keep).
5024                (
5025                    "m_relevant",
5026                    "TOPIC_A critical lesson about search performance",
5027                ),
5028                // 2 off-topic memories on topic B.
5029                ("m_noise1", "chocolate cookie baking TOPIC_B recipe"),
5030                ("m_noise2", "gardening tulip planting TOPIC_B spring"),
5031            ] {
5032                insert_memory_with_embedding(conn, mid, text, &oracle);
5033            }
5034        };
5035
5036        let weights = kimetsu_core::config::BrokerWeights::default();
5037
5038        // --- WITHOUT D1e: NoopEmbedder, floor=0.0 ---
5039        // FTS: "TOPIC_A" appears in m_dup1/2/3 + m_relevant; "search"
5040        // appears in m_dup1 and m_relevant. All 4 topic-A memories match
5041        // FTS. The 2 topic-B memories also have "recipe" and "spring"
5042        // which don't match — they may or may not appear via recency
5043        // fallback. Use a large budget so all matching memories fit.
5044        let conn_lean = rusqlite::Connection::open_in_memory().expect("in-memory lean");
5045        crate::schema::initialize(&conn_lean).expect("init schema lean");
5046        setup(&conn_lean);
5047
5048        let bundle_lean = retrieve_context_with_embedder(
5049            &conn_lean,
5050            "/fake-repo",
5051            &weights,
5052            ContextRequest {
5053                stage: "localization".to_string(),
5054                query: "TOPIC_A search performance".to_string(),
5055                budget_tokens: 20_000,
5056                min_semantic_score: 0.0, // floor disabled
5057                ..Default::default()
5058            },
5059            &[],
5060            &embeddings::NoopEmbedder,
5061        )
5062        .expect("retrieve lean");
5063
5064        let lean_count = bundle_lean
5065            .capsules
5066            .iter()
5067            .filter(|c| c.expansion_handle.starts_with("memory:"))
5068            .count();
5069
5070        // --- WITH D1e: OracleEmbedder + positive floor ---
5071        let conn_emb = rusqlite::Connection::open_in_memory().expect("in-memory emb");
5072        crate::schema::initialize(&conn_emb).expect("init schema emb");
5073        setup(&conn_emb);
5074
5075        let bundle_emb = retrieve_context_with_embedder(
5076            &conn_emb,
5077            "/fake-repo",
5078            &weights,
5079            ContextRequest {
5080                stage: "localization".to_string(),
5081                query: "TOPIC_A search performance".to_string(),
5082                budget_tokens: 20_000,
5083                min_semantic_score: 0.5, // positive floor: drops topic-B (cosine=0.0)
5084                ..Default::default()
5085            },
5086            &[],
5087            &oracle,
5088        )
5089        .expect("retrieve with embeddings");
5090
5091        let emb_count = bundle_emb
5092            .capsules
5093            .iter()
5094            .filter(|c| c.expansion_handle.starts_with("memory:"))
5095            .count();
5096
5097        // Token reduction: embedding path must produce strictly fewer capsules.
5098        assert!(
5099            emb_count < lean_count,
5100            "D1e must reduce capsule count: embedding path {emb_count} must be \
5101             < lean path {lean_count}. Embedding capsules: {:?}",
5102            bundle_emb
5103                .capsules
5104                .iter()
5105                .map(|c| &c.expansion_handle)
5106                .collect::<Vec<_>>()
5107        );
5108
5109        // Signal preservation: the genuinely-relevant memory must survive.
5110        assert!(
5111            bundle_emb
5112                .capsules
5113                .iter()
5114                .any(|c| c.expansion_handle == "memory:m_relevant"),
5115            "m_relevant must survive D1e selection (signal preserved); \
5116             embedding capsules: {:?}",
5117            bundle_emb
5118                .capsules
5119                .iter()
5120                .map(|c| &c.expansion_handle)
5121                .collect::<Vec<_>>()
5122        );
5123
5124        // Token estimate: embedding path must use fewer or equal token budget.
5125        let lean_tokens: u32 = bundle_lean.capsules.iter().map(|c| c.token_estimate).sum();
5126        let emb_tokens: u32 = bundle_emb.capsules.iter().map(|c| c.token_estimate).sum();
5127        assert!(
5128            emb_tokens < lean_tokens,
5129            "D1e must reduce token usage: emb={emb_tokens} must be < lean={lean_tokens}"
5130        );
5131    }
5132
5133    /// D1d test 4: lean-unchanged guarantee.
5134    ///
5135    /// With NoopEmbedder (query_embedding == None), memory_candidates
5136    /// takes the FTS-then-recency path exactly as before D1c. No vec
5137    /// table is touched; no panic occurs.
5138    #[test]
5139    fn lean_noop_embedder_uses_fts_then_recency_unchanged() {
5140        // The NoopEmbedder logic path never touches the ANN index — it must
5141        // work purely via FTS + recency on both lean and embeddings builds.
5142        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5143        crate::schema::initialize(&conn).expect("init schema");
5144
5145        // Insert two plain memories (no embeddings).
5146        for (mid, text) in [
5147            ("m_x", "use git rebase to clean history"),
5148            ("m_y", "grep finds text quickly"),
5149        ] {
5150            let normalized = kimetsu_core::memory::normalize_memory_text(text);
5151            conn.execute(
5152                "INSERT INTO memories (
5153                     memory_id, scope, kind, text, normalized_text, confidence,
5154                     source_event_id, provenance_snapshot_json, created_at,
5155                     use_count, usefulness_score
5156                 )
5157                 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
5158                         '2026-01-01T00:00:00Z', 0, 0.0)",
5159                rusqlite::params![mid, text, normalized],
5160            )
5161            .expect("insert");
5162            conn.execute(
5163                "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
5164                rusqlite::params![mid, text],
5165            )
5166            .expect("insert fts");
5167        }
5168
5169        let weights = kimetsu_core::config::BrokerWeights::default();
5170        // NoopEmbedder → query_embedding = None → FTS + recency path.
5171        let bundle = retrieve_context_with_embedder(
5172            &conn,
5173            "/fake-repo",
5174            &weights,
5175            ContextRequest {
5176                stage: "localization".to_string(),
5177                query: "grep text".to_string(),
5178                budget_tokens: 4000,
5179                ..Default::default()
5180            },
5181            &[],
5182            &embeddings::NoopEmbedder,
5183        )
5184        .expect("retrieve with NoopEmbedder must not panic");
5185
5186        // m_y matches "grep text" lexically via FTS. m_x does not.
5187        let handles: Vec<&str> = bundle
5188            .capsules
5189            .iter()
5190            .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
5191            .collect();
5192        assert!(
5193            handles.contains(&"m_y"),
5194            "m_y must surface via FTS on lean path; got {handles:?}"
5195        );
5196        // Crucially: no panic, no ANN index access.
5197    }
5198
5199    // ---------------------------------------------------------------
5200    // E3 tests: task-kind classification + adaptive retrieval routing
5201    // ---------------------------------------------------------------
5202
5203    /// E3-1: classify_task is deterministic for each kind.
5204    #[test]
5205    fn classify_task_maps_each_kind_deterministically() {
5206        // Debug examples
5207        assert_eq!(
5208            classify_task("fix the panic in the parser"),
5209            TaskKind::Debug,
5210            "contains 'fix' and 'panic'"
5211        );
5212        assert_eq!(
5213            classify_task("there is a crash in auth when calling login"),
5214            TaskKind::Debug,
5215            "contains 'crash'"
5216        );
5217        assert_eq!(
5218            classify_task("debug the failing test"),
5219            TaskKind::Debug,
5220            "contains 'debug' and 'fail'"
5221        );
5222
5223        // Investigation examples
5224        assert_eq!(
5225            classify_task("investigate why retrieval is slow"),
5226            TaskKind::Investigation,
5227            "contains 'investigate' and 'why'"
5228        );
5229        assert_eq!(
5230            classify_task("analyze the root cause of the latency"),
5231            TaskKind::Investigation,
5232            "contains 'analyze' and 'root cause'"
5233        );
5234
5235        // Refactor examples
5236        assert_eq!(
5237            classify_task("refactor the auth module"),
5238            TaskKind::Refactor,
5239            "contains 'refactor'"
5240        );
5241        assert_eq!(
5242            classify_task("rename the config struct"),
5243            TaskKind::Refactor,
5244            "contains 'rename'"
5245        );
5246        assert_eq!(
5247            classify_task("simplify the retry handling logic"),
5248            TaskKind::Refactor,
5249            "contains 'simplify'"
5250        );
5251
5252        // Docs examples
5253        assert_eq!(
5254            classify_task("document the API endpoints"),
5255            TaskKind::Docs,
5256            "contains 'document'"
5257        );
5258        assert_eq!(
5259            classify_task("update the readme with new instructions"),
5260            TaskKind::Docs,
5261            "contains 'readme'"
5262        );
5263        assert_eq!(
5264            classify_task("add a docstring to the main function"),
5265            TaskKind::Docs,
5266            "contains 'docstring'"
5267        );
5268
5269        // Feature examples (default / fallback)
5270        assert_eq!(
5271            classify_task("add a dark mode toggle"),
5272            TaskKind::Feature,
5273            "no debug/refactor/docs/investigate keyword"
5274        );
5275        assert_eq!(
5276            classify_task("implement the new caching layer"),
5277            TaskKind::Feature,
5278            "no debug/refactor/docs/investigate keyword"
5279        );
5280        assert_eq!(
5281            classify_task("build the export pipeline"),
5282            TaskKind::Feature,
5283            "no debug/refactor/docs/investigate keyword"
5284        );
5285    }
5286
5287    /// E3-1b: precedence — Debug > Investigation > Refactor > Docs > Feature.
5288    #[test]
5289    fn classify_task_respects_precedence_order() {
5290        // "fix" (Debug) + "refactor" (Refactor) → Debug wins
5291        assert_eq!(
5292            classify_task("fix and refactor the login module"),
5293            TaskKind::Debug,
5294            "Debug > Refactor"
5295        );
5296        // "investigate" (Investigation) + "refactor" (Refactor) → Investigation wins
5297        assert_eq!(
5298            classify_task("investigate and refactor the cache layer"),
5299            TaskKind::Investigation,
5300            "Investigation > Refactor"
5301        );
5302        // "investigate" (Investigation) + "document" (Docs) → Investigation wins
5303        assert_eq!(
5304            classify_task("investigate the docs and document the API"),
5305            TaskKind::Investigation,
5306            "Investigation > Docs"
5307        );
5308        // "refactor" (Refactor) + "docs" (Docs) → Refactor wins
5309        assert_eq!(
5310            classify_task("refactor and add docs"),
5311            TaskKind::Refactor,
5312            "Refactor > Docs"
5313        );
5314        // "fix" (Debug) + "investigate" (Investigation) → Debug wins
5315        assert_eq!(
5316            classify_task("fix the bug and investigate the regression"),
5317            TaskKind::Debug,
5318            "Debug > Investigation"
5319        );
5320    }
5321
5322    /// E3-2: weight renormalization — weights_for_task_kind(w, Debug) sums
5323    /// to approximately the same total as the input weights.
5324    /// v2.6 (2b): build two candidates of different kinds where the memory is
5325    /// a strong match and the repo_file is a weak one.
5326    fn two_kinds_one_strong() -> Vec<Candidate> {
5327        let mk = |kind: &str, raw: f32| Candidate {
5328            capsule: ContextCapsule {
5329                id: format!("{kind}-1"),
5330                kind: kind.to_string(),
5331                summary: String::new(),
5332                token_estimate: 0,
5333                expansion_handle: String::new(),
5334                provenance: Vec::new(),
5335                confidence: 0.0,
5336                freshness: 0.0,
5337                relevance: 0.0,
5338                scope_weight: 0.0,
5339                score: 0.0,
5340                superseded_hint: false,
5341                rerank_policy_tier: 0,
5342                claim_revision: None,
5343                facts: vec![],
5344                rerank_usefulness: None,
5345                rerank_trust: None,
5346            },
5347            raw_relevance: raw,
5348            embedding: None,
5349            cosine: None,
5350            created_at: None,
5351        };
5352        vec![mk("memory", 0.9), mk("repo_file", 0.1)]
5353    }
5354
5355    /// The behaviour 2b exists to describe: per-kind normalization promotes
5356    /// the best of an irrelevant kind to a perfect relevance.
5357    #[test]
5358    fn per_kind_normalization_flatters_the_best_of_a_weak_kind() {
5359        let mut candidates = two_kinds_one_strong();
5360        let weights = StageWeights {
5361            relevance: 1.0,
5362            confidence: 0.0,
5363            freshness: 0.0,
5364            scope: 0.0,
5365        };
5366        normalize_and_score(&mut candidates, weights, Normalization::PerKind);
5367        assert!((candidates[0].capsule.relevance - 1.0).abs() < 1e-6);
5368        assert!(
5369            (candidates[1].capsule.relevance - 1.0).abs() < 1e-6,
5370            "per-kind gives the lone weak repo_file relevance 1.0, got {}",
5371            candidates[1].capsule.relevance
5372        );
5373    }
5374
5375    /// Global normalization keeps relevance comparable across kinds: the weak
5376    /// repo_file stays weak because it is measured against the same max.
5377    #[test]
5378    fn global_normalization_keeps_relevance_comparable_across_kinds() {
5379        let mut candidates = two_kinds_one_strong();
5380        let weights = StageWeights {
5381            relevance: 1.0,
5382            confidence: 0.0,
5383            freshness: 0.0,
5384            scope: 0.0,
5385        };
5386        normalize_and_score(&mut candidates, weights, Normalization::Global);
5387        assert!((candidates[0].capsule.relevance - 1.0).abs() < 1e-6);
5388        let weak = candidates[1].capsule.relevance;
5389        assert!(
5390            (weak - (0.1 / 0.9)).abs() < 1e-6,
5391            "global normalizes against the single max, got {weak}"
5392        );
5393        assert!(weak < candidates[0].capsule.relevance);
5394    }
5395
5396    /// v2.7: candidate builder for the supersession tests — a memory with an
5397    /// embedding, a creation time, and a pre-set score.
5398    fn superseding_candidate(
5399        id: &str,
5400        embedding: Vec<f32>,
5401        created_at: &str,
5402        score: f32,
5403    ) -> Candidate {
5404        Candidate {
5405            capsule: ContextCapsule {
5406                id: id.to_string(),
5407                kind: "memory".to_string(),
5408                summary: id.to_string(),
5409                token_estimate: 0,
5410                expansion_handle: format!("memory:{id}"),
5411                provenance: Vec::new(),
5412                confidence: 0.0,
5413                freshness: 0.0,
5414                relevance: 0.0,
5415                scope_weight: 0.0,
5416                score,
5417                superseded_hint: false,
5418                rerank_policy_tier: 0,
5419                claim_revision: None,
5420                facts: vec![],
5421                rerank_usefulness: None,
5422                rerank_trust: None,
5423            },
5424            raw_relevance: score,
5425            embedding: Some(embedding),
5426            // Query relevance for relaxed, cue-qualified supersession tests.
5427            cosine: Some(score),
5428            created_at: Some(created_at.to_string()),
5429        }
5430    }
5431
5432    /// v2.7: near-duplicate pair — the OLDER one is penalized so the newer
5433    /// statement of the topic outranks it even when citations boosted it.
5434    #[test]
5435    fn supersession_penalizes_the_older_near_duplicate() {
5436        let mut candidates = vec![
5437            // Old, cited, currently winning (score 0.94 > 0.87).
5438            superseding_candidate("old", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.94),
5439            superseding_candidate("new", vec![0.99, 0.14], "2026-08-01T10:10:00Z", 0.87),
5440        ];
5441        apply_supersession_penalty(&mut candidates);
5442        let old_score = candidates[0].capsule.score;
5443        let new_score = candidates[1].capsule.score;
5444        assert!(
5445            (old_score - 0.94 * SUPERSESSION_PENALTY).abs() < 1e-6,
5446            "older twin must carry the penalty, got {old_score}"
5447        );
5448        assert!((new_score - 0.87).abs() < 1e-6, "newer twin untouched");
5449        assert!(
5450            new_score > old_score,
5451            "the update must now outrank the incumbent"
5452        );
5453    }
5454
5455    /// Distinct memories (low mutual cosine) must not penalize each other,
5456    /// and the penalty applies at most once however many newer siblings exist.
5457    #[test]
5458    fn supersession_ignores_distinct_memories_and_applies_once() {
5459        let mut candidates = vec![
5460            superseding_candidate("old", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.90),
5461            // Orthogonal embedding — different topic entirely.
5462            superseding_candidate("other", vec![0.0, 1.0], "2026-08-02T10:00:00Z", 0.80),
5463            // Two newer near-duplicates of `old`.
5464            superseding_candidate("new1", vec![0.99, 0.14], "2026-08-03T10:00:00Z", 0.70),
5465            superseding_candidate("new2", vec![0.98, 0.19], "2026-08-04T10:00:00Z", 0.60),
5466        ];
5467        apply_supersession_penalty(&mut candidates);
5468        assert!(
5469            (candidates[0].capsule.score - 0.90 * SUPERSESSION_PENALTY).abs() < 1e-6,
5470            "penalty applies exactly once, got {}",
5471            candidates[0].capsule.score
5472        );
5473        assert!(
5474            (candidates[1].capsule.score - 0.80).abs() < 1e-6,
5475            "orthogonal memory untouched"
5476        );
5477        // new1 is itself older than new2 and near-duplicate of it.
5478        assert!(
5479            (candidates[2].capsule.score - 0.70 * SUPERSESSION_PENALTY).abs() < 1e-6,
5480            "a middle sibling is old relative to a newer one"
5481        );
5482        assert!(
5483            (candidates[3].capsule.score - 0.60).abs() < 1e-6,
5484            "newest untouched"
5485        );
5486    }
5487
5488    /// Lean builds (no embeddings) and unparseable timestamps are inert.
5489    #[test]
5490    fn supersession_is_inert_without_embeddings_or_timestamps() {
5491        let mut no_embedding = vec![
5492            Candidate {
5493                embedding: None,
5494                ..superseding_candidate("a", vec![], "2026-08-01T10:00:00Z", 0.9)
5495            },
5496            Candidate {
5497                embedding: None,
5498                ..superseding_candidate("b", vec![], "2026-08-02T10:00:00Z", 0.8)
5499            },
5500        ];
5501        apply_supersession_penalty(&mut no_embedding);
5502        assert!((no_embedding[0].capsule.score - 0.9).abs() < 1e-6);
5503
5504        let mut bad_ts = vec![
5505            superseding_candidate("a", vec![1.0, 0.0], "not-a-date", 0.9),
5506            superseding_candidate("b", vec![1.0, 0.0], "2026-08-02T10:00:00Z", 0.8),
5507        ];
5508        apply_supersession_penalty(&mut bad_ts);
5509        assert!(
5510            (bad_ts[0].capsule.score - 0.9).abs() < 1e-6,
5511            "unparseable ts skipped"
5512        );
5513        assert!((bad_ts[1].capsule.score - 0.8).abs() < 1e-6);
5514
5515        // Identical timestamps: no defensible "older", both untouched.
5516        let mut same_ts = vec![
5517            superseding_candidate("a", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.9),
5518            superseding_candidate("b", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.8),
5519        ];
5520        apply_supersession_penalty(&mut same_ts);
5521        assert!((same_ts[0].capsule.score - 0.9).abs() < 1e-6);
5522        assert!((same_ts[1].capsule.score - 0.8).abs() < 1e-6);
5523
5524        // Sub-second gap: one authoring event (same batch ingest), not a
5525        // supersession — both untouched even though one is nominally older.
5526        let mut batch = vec![
5527            superseding_candidate("a", vec![1.0, 0.0], "2026-08-01T10:00:00.100Z", 0.9),
5528            superseding_candidate("b", vec![1.0, 0.0], "2026-08-01T10:00:00.900Z", 0.8),
5529        ];
5530        apply_supersession_penalty(&mut batch);
5531        assert!(
5532            (batch[0].capsule.score - 0.9).abs() < 1e-6,
5533            "millisecond-apart co-writes must not be penalized"
5534        );
5535        assert!((batch[1].capsule.score - 0.8).abs() < 1e-6);
5536
5537        // A batch-imported correction can establish direction in its text.
5538        // Timestamp order is deliberately reversed here: the explicit cue,
5539        // not arbitrary JSONL order, identifies the replacement.
5540        let mut explicit_update = vec![
5541            superseding_candidate(
5542                "the project uses spaces (switched from tabs)",
5543                vec![1.0, 0.0],
5544                "2026-08-01T10:00:00.100Z",
5545                0.9,
5546            ),
5547            superseding_candidate(
5548                "the project uses tabs for indentation",
5549                // Cosine 0.83: below the ordinary 0.85 floor, above the
5550                // cue-qualified 0.82 floor.
5551                vec![0.83, 0.557_8],
5552                "2026-08-01T10:00:00.900Z",
5553                0.8,
5554            ),
5555        ];
5556        apply_supersession_penalty(&mut explicit_update);
5557        assert!((explicit_update[0].capsule.score - 0.9).abs() < 1e-6);
5558        assert!(
5559            (explicit_update[1].capsule.score - 0.8 * SUPERSESSION_PENALTY).abs() < 1e-6,
5560            "the unmarked incumbent must lose to the explicit correction"
5561        );
5562        assert!(explicit_update[1].capsule.superseded_hint);
5563
5564        // Rewritten updates can move far in embedding space while retaining a
5565        // dense lexical identity. Correction cue + overlap recovers direction.
5566        let mut rewritten_update = vec![
5567            superseding_candidate(
5568                "as of v2 the preferred kimetsu embedder is jina, replacing bge",
5569                vec![1.0, 0.0],
5570                "2026-08-01T10:00:00.100Z",
5571                0.9,
5572            ),
5573            superseding_candidate(
5574                "the recommended kimetsu embedder for retrieval is bge",
5575                vec![0.71, 0.704_2],
5576                "2026-08-01T10:00:00.900Z",
5577                0.8,
5578            ),
5579        ];
5580        apply_supersession_penalty(&mut rewritten_update);
5581        assert!((rewritten_update[0].capsule.score - 0.9).abs() < 1e-6);
5582        assert!((rewritten_update[1].capsule.score - 0.8 * SUPERSESSION_PENALTY).abs() < 1e-6);
5583
5584        // The same pair asked as a historical question favors the incumbent
5585        // by query cosine, so the relaxed relationship must not penalize it.
5586        let mut historical_query = vec![
5587            Candidate {
5588                cosine: Some(0.70),
5589                ..superseding_candidate(
5590                    "as of v2 the preferred kimetsu embedder is jina, replacing bge",
5591                    vec![1.0, 0.0],
5592                    "2026-08-01T10:00:00.100Z",
5593                    0.9,
5594                )
5595            },
5596            Candidate {
5597                cosine: Some(0.90),
5598                ..superseding_candidate(
5599                    "the recommended kimetsu embedder for retrieval is bge",
5600                    vec![0.71, 0.704_2],
5601                    "2026-08-01T10:00:00.900Z",
5602                    0.8,
5603                )
5604            },
5605        ];
5606        apply_supersession_penalty(&mut historical_query);
5607        assert!((historical_query[0].capsule.score - 0.9).abs() < 1e-6);
5608        assert!((historical_query[1].capsule.score - 0.8).abs() < 1e-6);
5609
5610        // A cue is evidence of direction, not permission to join unrelated
5611        // topics: below the cue-qualified similarity floor both remain intact.
5612        let mut too_distant = vec![
5613            superseding_candidate(
5614                "the project now uses spaces",
5615                vec![1.0, 0.0],
5616                "2026-08-01T10:00:00.100Z",
5617                0.9,
5618            ),
5619            superseding_candidate(
5620                "database backup retention is seven days",
5621                vec![0.81, 0.586_4],
5622                "2026-08-01T10:00:00.900Z",
5623                0.8,
5624            ),
5625        ];
5626        apply_supersession_penalty(&mut too_distant);
5627        assert!((too_distant[0].capsule.score - 0.9).abs() < 1e-6);
5628        assert!((too_distant[1].capsule.score - 0.8).abs() < 1e-6);
5629
5630        // Two update-like variants are ambiguous and remain untouched.
5631        let mut ambiguous = vec![
5632            superseding_candidate(
5633                "the setting is now cheap_model",
5634                vec![1.0, 0.0],
5635                "2026-08-01T10:00:00.100Z",
5636                0.9,
5637            ),
5638            superseding_candidate(
5639                "as of v2 the setting is cheap_model",
5640                vec![1.0, 0.0],
5641                "2026-08-01T10:00:00.900Z",
5642                0.8,
5643            ),
5644        ];
5645        apply_supersession_penalty(&mut ambiguous);
5646        assert!((ambiguous[0].capsule.score - 0.9).abs() < 1e-6);
5647        assert!((ambiguous[1].capsule.score - 0.8).abs() < 1e-6);
5648    }
5649
5650    /// An unknown or empty value must not silently change ranking — a typo in
5651    /// project.toml falls back to the shipped rule.
5652    #[test]
5653    fn unknown_normalization_falls_back_to_per_kind() {
5654        assert_eq!(Normalization::from_config(""), Normalization::PerKind);
5655        assert_eq!(
5656            Normalization::from_config("per_kind"),
5657            Normalization::PerKind
5658        );
5659        assert_eq!(
5660            Normalization::from_config("nonsense"),
5661            Normalization::PerKind
5662        );
5663        assert_eq!(Normalization::from_config("global"), Normalization::Global);
5664        assert_eq!(
5665            Normalization::from_config("  GLOBAL "),
5666            Normalization::Global
5667        );
5668    }
5669
5670    #[test]
5671    fn weights_for_task_kind_renormalizes_to_unit_sum() {
5672        let base = StageWeights {
5673            relevance: 0.50,
5674            confidence: 0.20,
5675            freshness: 0.20,
5676            scope: 0.10,
5677        };
5678        let original_sum = base.relevance + base.confidence + base.freshness + base.scope;
5679
5680        for kind in [
5681            TaskKind::Debug,
5682            TaskKind::Refactor,
5683            TaskKind::Investigation,
5684            TaskKind::Docs,
5685        ] {
5686            let w = weights_for_task_kind(base.clone(), kind);
5687            let new_sum = w.relevance + w.confidence + w.freshness + w.scope;
5688            // Renormalized to 1.0; the original_sum is also 1.0 for these weights.
5689            assert!(
5690                (new_sum - original_sum).abs() < 1e-4,
5691                "weights_for_task_kind({kind:?}) sum {new_sum} differs from {original_sum}"
5692            );
5693        }
5694    }
5695
5696    /// E3-2b: Feature is the neutral kind — weights unchanged.
5697    #[test]
5698    fn weights_for_task_kind_feature_is_unchanged() {
5699        let base = StageWeights {
5700            relevance: 0.40,
5701            confidence: 0.30,
5702            freshness: 0.20,
5703            scope: 0.10,
5704        };
5705        let w = weights_for_task_kind(base.clone(), TaskKind::Feature);
5706        assert!((w.relevance - base.relevance).abs() < f32::EPSILON);
5707        assert!((w.confidence - base.confidence).abs() < f32::EPSILON);
5708        assert!((w.freshness - base.freshness).abs() < f32::EPSILON);
5709        assert!((w.scope - base.scope).abs() < f32::EPSILON);
5710    }
5711
5712    /// E3-2c: Debug biases toward freshness; after renorm, freshness
5713    /// fraction must be strictly larger than in the base weights.
5714    #[test]
5715    fn weights_for_task_kind_debug_up_freshness_fraction() {
5716        let base = StageWeights {
5717            relevance: 0.50,
5718            confidence: 0.20,
5719            freshness: 0.20,
5720            scope: 0.10,
5721        };
5722        let debug_w = weights_for_task_kind(base.clone(), TaskKind::Debug);
5723        // Freshness fraction = freshness / sum = freshness (since sum=1 after renorm).
5724        assert!(
5725            debug_w.freshness > base.freshness,
5726            "Debug must increase freshness fraction: {debug_w:?}"
5727        );
5728    }
5729
5730    /// E3-2d: Refactor biases toward scope; after renorm, scope fraction
5731    /// must be strictly larger than in the base weights.
5732    #[test]
5733    fn weights_for_task_kind_refactor_up_scope_fraction() {
5734        let base = StageWeights {
5735            relevance: 0.50,
5736            confidence: 0.20,
5737            freshness: 0.20,
5738            scope: 0.10,
5739        };
5740        let refactor_w = weights_for_task_kind(base.clone(), TaskKind::Refactor);
5741        assert!(
5742            refactor_w.scope > base.scope,
5743            "Refactor must increase scope fraction: {refactor_w:?}"
5744        );
5745    }
5746
5747    /// E3-3: Feature is truly neutral — retrieval with task_kind=Feature
5748    /// returns the same capsule set as with the default ContextRequest.
5749    #[test]
5750    fn task_kind_feature_is_retrieval_neutral() {
5751        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5752        crate::schema::initialize(&conn).expect("init schema");
5753
5754        // Insert a few memories so retrieval has something to return.
5755        // DB columns: scope='project', kind=actual memory kind.
5756        // Broker formats summary as "{scope}:{kind} - {text}".
5757        for (mid, db_kind, text) in [
5758            ("m1", "failure_pattern", "linker not found error in build"),
5759            ("m2", "convention", "use snake_case for all identifiers"),
5760            ("m3", "fact", "the cache is invalidated on every deploy"),
5761        ] {
5762            let normalized = kimetsu_core::memory::normalize_memory_text(text);
5763            conn.execute(
5764                "INSERT INTO memories (
5765                     memory_id, scope, kind, text, normalized_text, confidence,
5766                     source_event_id, provenance_snapshot_json, created_at,
5767                     use_count, usefulness_score
5768                 )
5769                 VALUES (?1, 'project', ?2, ?3, ?4, 1.0, NULL, '{}',
5770                         '2026-01-01T00:00:00Z', 0, 0.0)",
5771                rusqlite::params![mid, db_kind, text, normalized],
5772            )
5773            .expect("insert memory");
5774            conn.execute(
5775                "INSERT INTO memories_fts (memory_id, text, kind, scope)
5776                 VALUES (?1, ?2, ?3, 'project')",
5777                rusqlite::params![mid, text, db_kind],
5778            )
5779            .expect("insert fts");
5780        }
5781
5782        let weights = kimetsu_core::config::BrokerWeights::default();
5783        let query = "cache convention failure".to_string();
5784
5785        // Baseline: no task_kind set (Default::default() → Feature)
5786        let baseline = retrieve_context_with_embedder(
5787            &conn,
5788            "/fake-repo",
5789            &weights,
5790            ContextRequest {
5791                stage: "localization".to_string(),
5792                query: query.clone(),
5793                budget_tokens: 4000,
5794                ..Default::default()
5795            },
5796            &[],
5797            &embeddings::NoopEmbedder,
5798        )
5799        .expect("baseline retrieve");
5800
5801        // Explicit Feature: must be identical to baseline
5802        let feature = retrieve_context_with_embedder(
5803            &conn,
5804            "/fake-repo",
5805            &weights,
5806            ContextRequest {
5807                stage: "localization".to_string(),
5808                query: query.clone(),
5809                budget_tokens: 4000,
5810                task_kind: TaskKind::Feature,
5811                ..Default::default()
5812            },
5813            &[],
5814            &embeddings::NoopEmbedder,
5815        )
5816        .expect("feature retrieve");
5817
5818        let baseline_ids: Vec<&str> = baseline
5819            .capsules
5820            .iter()
5821            .map(|c| c.expansion_handle.as_str())
5822            .collect();
5823        let feature_ids: Vec<&str> = feature
5824            .capsules
5825            .iter()
5826            .map(|c| c.expansion_handle.as_str())
5827            .collect();
5828        assert_eq!(
5829            baseline_ids, feature_ids,
5830            "task_kind=Feature must produce identical retrieval to default; \
5831             baseline={baseline_ids:?} feature={feature_ids:?}"
5832        );
5833
5834        let baseline_scores: Vec<f32> = baseline.capsules.iter().map(|c| c.score).collect();
5835        let feature_scores: Vec<f32> = feature.capsules.iter().map(|c| c.score).collect();
5836        for (b, f) in baseline_scores.iter().zip(feature_scores.iter()) {
5837            assert!(
5838                (b - f).abs() < 1e-5,
5839                "scores must be identical: baseline={b} feature={f}"
5840            );
5841        }
5842    }
5843
5844    /// E3-4: headline behavioral proof — Debug routes strictly more
5845    /// failure_pattern capsules than Docs over the same corpus + query.
5846    ///
5847    /// Setup: 4 failure_pattern memories + 4 convention/fact memories
5848    /// that all share a common topic keyword "auth". We cap at 4 capsules
5849    /// and compare how many are failure_pattern between Debug and Docs.
5850    ///
5851    /// Memory row layout: `scope='project'`, `kind='failure_pattern'` (or
5852    /// `'convention'`/`'fact'`). The broker formats the capsule summary as
5853    /// `"{scope}:{kind} - {text}"` so `capsule_matches_kind` can parse it.
5854    #[test]
5855    fn debug_surfaces_more_failure_pattern_than_docs() {
5856        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5857        crate::schema::initialize(&conn).expect("init schema");
5858
5859        // Insert 4 failure_pattern memories.
5860        // DB columns: scope='project', kind='failure_pattern'
5861        // Broker formats summary as "project:failure_pattern - <text>".
5862        for (i, text) in [
5863            "auth token expired causes login failure",
5864            "auth service crash on null pointer",
5865            "auth regression after upgrade breaks sessions",
5866            "auth error when certificate is invalid",
5867        ]
5868        .iter()
5869        .enumerate()
5870        {
5871            let mid = format!("mfp{i}");
5872            let normalized = kimetsu_core::memory::normalize_memory_text(text);
5873            conn.execute(
5874                "INSERT INTO memories (
5875                     memory_id, scope, kind, text, normalized_text, confidence,
5876                     source_event_id, provenance_snapshot_json, created_at,
5877                     use_count, usefulness_score
5878                 )
5879                 VALUES (?1, 'project', 'failure_pattern', ?2, ?3, 1.0, NULL, '{}',
5880                         '2026-01-01T00:00:00Z', 0, 0.0)",
5881                rusqlite::params![mid, text, normalized],
5882            )
5883            .expect("insert failure_pattern");
5884            conn.execute(
5885                "INSERT INTO memories_fts (memory_id, text, kind, scope)
5886                 VALUES (?1, ?2, 'failure_pattern', 'project')",
5887                rusqlite::params![mid, text],
5888            )
5889            .expect("insert fts");
5890        }
5891
5892        // Insert 4 convention/fact memories — also mention "auth".
5893        // DB columns: scope='project', kind='convention' or 'fact'.
5894        for (i, (db_kind, text)) in [
5895            ("convention", "auth module uses bearer tokens by convention"),
5896            ("convention", "auth scopes are documented in the API guide"),
5897            ("fact", "auth service runs on port 8443 in production"),
5898            ("fact", "auth uses JWT with RS256 signing for all tokens"),
5899        ]
5900        .iter()
5901        .enumerate()
5902        {
5903            let mid = format!("mconv{i}");
5904            let normalized = kimetsu_core::memory::normalize_memory_text(text);
5905            conn.execute(
5906                "INSERT INTO memories (
5907                     memory_id, scope, kind, text, normalized_text, confidence,
5908                     source_event_id, provenance_snapshot_json, created_at,
5909                     use_count, usefulness_score
5910                 )
5911                 VALUES (?1, 'project', ?2, ?3, ?4, 1.0, NULL, '{}',
5912                         '2026-01-01T00:00:00Z', 0, 0.0)",
5913                rusqlite::params![mid, db_kind, text, normalized],
5914            )
5915            .expect("insert convention/fact");
5916            conn.execute(
5917                "INSERT INTO memories_fts (memory_id, text, kind, scope)
5918                 VALUES (?1, ?2, ?3, 'project')",
5919                rusqlite::params![mid, text, db_kind],
5920            )
5921            .expect("insert fts");
5922        }
5923
5924        let weights = kimetsu_core::config::BrokerWeights::default();
5925        let query = "auth token failure".to_string();
5926
5927        // Retrieve with Debug task_kind
5928        let debug_bundle = retrieve_context_with_embedder(
5929            &conn,
5930            "/fake-repo",
5931            &weights,
5932            ContextRequest {
5933                stage: "localization".to_string(),
5934                query: query.clone(),
5935                budget_tokens: 4000,
5936                max_capsules: 4,
5937                task_kind: TaskKind::Debug,
5938                ..Default::default()
5939            },
5940            &[],
5941            &embeddings::NoopEmbedder,
5942        )
5943        .expect("debug retrieve");
5944
5945        // Retrieve with Docs task_kind
5946        let docs_bundle = retrieve_context_with_embedder(
5947            &conn,
5948            "/fake-repo",
5949            &weights,
5950            ContextRequest {
5951                stage: "localization".to_string(),
5952                query: query.clone(),
5953                budget_tokens: 4000,
5954                max_capsules: 4,
5955                task_kind: TaskKind::Docs,
5956                ..Default::default()
5957            },
5958            &[],
5959            &embeddings::NoopEmbedder,
5960        )
5961        .expect("docs retrieve");
5962
5963        // Count failure_pattern capsules in each result.
5964        // Memory capsules have kind="memory"; the real kind is in the summary prefix.
5965        let count_failure_pattern = |bundle: &ContextBundle| -> usize {
5966            bundle
5967                .capsules
5968                .iter()
5969                .filter(|c| capsule_matches_kind(c, "failure_pattern"))
5970                .count()
5971        };
5972
5973        let debug_fp = count_failure_pattern(&debug_bundle);
5974        let docs_fp = count_failure_pattern(&docs_bundle);
5975
5976        assert!(
5977            debug_fp > docs_fp,
5978            "Debug must surface strictly more failure_pattern capsules than Docs: \
5979             debug_fp={debug_fp} docs_fp={docs_fp}\n\
5980             Debug capsules: {:?}\n\
5981             Docs capsules: {:?}",
5982            debug_bundle
5983                .capsules
5984                .iter()
5985                .map(|c| format!("{}:{}", c.kind, &c.summary[..c.summary.len().min(60)]))
5986                .collect::<Vec<_>>(),
5987            docs_bundle
5988                .capsules
5989                .iter()
5990                .map(|c| format!("{}:{}", c.kind, &c.summary[..c.summary.len().min(60)]))
5991                .collect::<Vec<_>>(),
5992        );
5993    }
5994
5995    // ── F2: resolve_capsule unit tests ────────────────────────────────────
5996
5997    fn init_db_with_memory(memory_id: &str, text: &str) -> rusqlite::Connection {
5998        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5999        crate::schema::initialize(&conn).expect("init schema");
6000        let normalized = kimetsu_core::memory::normalize_memory_text(text);
6001        conn.execute(
6002            "INSERT INTO memories (
6003                 memory_id, scope, kind, text, normalized_text, confidence,
6004                 source_event_id, provenance_snapshot_json, created_at,
6005                 use_count, usefulness_score
6006             )
6007             VALUES (?1, 'project', 'fact', ?2, ?3, 1.0, NULL, '{}',
6008                     '2026-01-01T00:00:00Z', 0, 0.0)",
6009            rusqlite::params![memory_id, text, normalized],
6010        )
6011        .expect("insert memory");
6012        conn
6013    }
6014
6015    /// F2-1: memory:<id> resolves to the full memory text.
6016    #[test]
6017    fn resolve_capsule_memory_returns_full_text() {
6018        let conn = init_db_with_memory("test-mem-id", "Use rg over grep for speed");
6019        let repo_root = std::path::Path::new("/fake-repo");
6020        let result =
6021            resolve_capsule(&conn, repo_root, "memory:test-mem-id").expect("should resolve");
6022        assert_eq!(result, "Use rg over grep for speed");
6023    }
6024
6025    /// F2-2: memory:<id> for a non-existent id returns Err.
6026    #[test]
6027    fn resolve_capsule_memory_missing_id_returns_err() {
6028        let conn = init_db_with_memory("real-id", "some text");
6029        let repo_root = std::path::Path::new("/fake-repo");
6030        let err = resolve_capsule(&conn, repo_root, "memory:nonexistent-id")
6031            .expect_err("should error for missing memory");
6032        assert!(
6033            err.to_string().contains("no active memory"),
6034            "error message should mention missing: {err}"
6035        );
6036    }
6037
6038    /// F2-3: file:<path> returns a bounded slice of the file content.
6039    #[test]
6040    fn resolve_capsule_file_returns_bounded_content() {
6041        let dir = make_test_dir("f2_file_resolve");
6042        let content = "hello from the file\n";
6043        std::fs::write(dir.join("notes.txt"), content).expect("write");
6044        let result = resolve_capsule(
6045            // conn is unused for file: handles; pass an in-memory DB
6046            &rusqlite::Connection::open_in_memory().expect("open"),
6047            &dir,
6048            "file:notes.txt",
6049        )
6050        .expect("should resolve file");
6051        assert!(result.contains("hello from the file"));
6052        std::fs::remove_dir_all(&dir).ok();
6053    }
6054
6055    /// F2-4: file:<path> for a large file is capped at FILE_EXPAND_CAP_BYTES.
6056    #[test]
6057    fn resolve_capsule_file_caps_large_file() {
6058        let dir = make_test_dir("f2_file_cap");
6059        let big = "A".repeat(FILE_EXPAND_CAP_BYTES * 3);
6060        std::fs::write(dir.join("big.txt"), &big).expect("write");
6061        let result = resolve_capsule(
6062            &rusqlite::Connection::open_in_memory().expect("open"),
6063            &dir,
6064            "file:big.txt",
6065        )
6066        .expect("should resolve large file");
6067        assert!(
6068            result.len() <= FILE_EXPAND_CAP_BYTES + 200,
6069            "result should be bounded: got {} bytes",
6070            result.len()
6071        );
6072        assert!(
6073            result.contains("truncated"),
6074            "truncation marker should be present"
6075        );
6076        std::fs::remove_dir_all(&dir).ok();
6077    }
6078
6079    /// F2-5: unknown handle format returns Err.
6080    #[test]
6081    fn resolve_capsule_unknown_handle_returns_err() {
6082        let conn = rusqlite::Connection::open_in_memory().expect("open");
6083        let err = resolve_capsule(&conn, std::path::Path::new("/r"), "blob:abc123")
6084            .expect_err("should error");
6085        assert!(
6086            err.to_string().contains("unrecognised handle"),
6087            "got: {err}"
6088        );
6089    }
6090
6091    /// F2-6: malformed handle (no colon) returns Err.
6092    #[test]
6093    fn resolve_capsule_malformed_handle_returns_err() {
6094        let conn = rusqlite::Connection::open_in_memory().expect("open");
6095        let err = resolve_capsule(&conn, std::path::Path::new("/r"), "justnocolon")
6096            .expect_err("should error");
6097        assert!(
6098            err.to_string().contains("unrecognised handle"),
6099            "got: {err}"
6100        );
6101    }
6102
6103    /// F2-7: run:<id> returns the deferred-error message.
6104    #[test]
6105    fn resolve_capsule_run_handle_returns_deferred_err() {
6106        let conn = rusqlite::Connection::open_in_memory().expect("open");
6107        let err = resolve_capsule(&conn, std::path::Path::new("/r"), "run:some-run-id")
6108            .expect_err("run: should be deferred err");
6109        assert!(err.to_string().contains("not yet supported"), "got: {err}");
6110    }
6111
6112    /// F2-8: file:<path> with absolute path is rejected.
6113    #[test]
6114    fn resolve_capsule_file_rejects_absolute_path() {
6115        let conn = rusqlite::Connection::open_in_memory().expect("open");
6116        let err = resolve_capsule(&conn, std::path::Path::new("/r"), "file:/etc/passwd")
6117            .expect_err("should reject absolute path");
6118        assert!(err.to_string().contains("absolute path"), "got: {err}");
6119    }
6120
6121    // ── v1.0.0 rerank_capsules tests ─────────────────────────────────────────
6122
6123    fn make_capsule(summary: &str, score: f32) -> ContextCapsule {
6124        ContextCapsule {
6125            id: new_id().to_string(),
6126            kind: "memory".to_string(),
6127            summary: summary.to_string(),
6128            token_estimate: 10,
6129            expansion_handle: format!("memory:{}", new_id()),
6130            provenance: vec![],
6131            confidence: 1.0,
6132            freshness: 1.0,
6133            relevance: 1.0,
6134            scope_weight: 1.0,
6135            score,
6136            superseded_hint: false,
6137            rerank_policy_tier: 0,
6138            claim_revision: None,
6139            facts: vec![],
6140            rerank_usefulness: None,
6141            rerank_trust: None,
6142        }
6143    }
6144
6145    /// RR-1: capsule whose summary shares more query words ranks first and
6146    /// the score field is overwritten by the reranker's sigmoid-normalized score.
6147    #[test]
6148    fn rerank_capsules_reorders_by_query_overlap() {
6149        use crate::embeddings::StubReranker;
6150
6151        // Two capsules: "rust async tokio" shares 3/3 query tokens;
6152        // "python django" shares 0/3.
6153        let query = "rust async tokio";
6154        let high_overlap = make_capsule("rust async tokio runtime", 0.0);
6155        let low_overlap = make_capsule("python django framework", 0.0);
6156        // Input order: low-overlap first to verify it gets pushed down.
6157        let capsules = vec![low_overlap.clone(), high_overlap.clone()];
6158
6159        let ranked = rerank_capsules(query, capsules, &StubReranker, 0.0, 0);
6160
6161        assert_eq!(ranked.len(), 2, "both capsules should survive (floor=0)");
6162        // The high-overlap capsule must rank first.
6163        assert!(
6164            ranked[0].summary.contains("rust"),
6165            "rust capsule must be first, got: {:?}",
6166            ranked[0].summary
6167        );
6168        // Score must be overwritten (was 0.0, now > 0.05 for the high-overlap one).
6169        assert!(
6170            ranked[0].score > 0.05,
6171            "score must be overwritten by reranker: {}",
6172            ranked[0].score
6173        );
6174        // High-overlap must score above low-overlap.
6175        assert!(
6176            ranked[0].score > ranked[1].score,
6177            "high overlap must score higher: {} vs {}",
6178            ranked[0].score,
6179            ranked[1].score
6180        );
6181    }
6182
6183    /// RR-2: floor drops a zero-overlap capsule.
6184    /// StubReranker scores a zero-overlap doc at 0.05.
6185    /// A floor of 0.3 must drop it.
6186    #[test]
6187    fn rerank_capsules_floor_drops_zero_overlap() {
6188        use crate::embeddings::StubReranker;
6189
6190        let query = "rust async tokio";
6191        let high = make_capsule("rust async tokio runtime", 0.0);
6192        let zero = make_capsule("completely unrelated document xyz", 0.0); // 0-overlap → 0.05
6193
6194        let capsules = vec![high, zero];
6195        let ranked = rerank_capsules(query, capsules, &StubReranker, 0.3, 0);
6196
6197        // The zero-overlap capsule (score 0.05) must be dropped by floor=0.3.
6198        assert_eq!(ranked.len(), 1, "zero-overlap capsule must be dropped");
6199        assert!(
6200            ranked[0].summary.contains("rust"),
6201            "only rust capsule should survive"
6202        );
6203    }
6204
6205    /// RR-3: cap truncates the result.
6206    #[test]
6207    fn rerank_capsules_cap_truncates() {
6208        use crate::embeddings::StubReranker;
6209
6210        let query = "alpha beta gamma";
6211        let capsules = vec![
6212            make_capsule("alpha beta gamma delta", 0.0),
6213            make_capsule("alpha beta", 0.0),
6214            make_capsule("alpha", 0.0),
6215            make_capsule("unrelated xyz", 0.0),
6216        ];
6217
6218        let ranked = rerank_capsules(query, capsules, &StubReranker, 0.0, 2);
6219        assert_eq!(ranked.len(), 2, "cap=2 must truncate to 2 results");
6220        // The top-2 should be the higher-overlap ones.
6221        assert!(
6222            ranked[0].score >= ranked[1].score,
6223            "results must be sorted descending"
6224        );
6225    }
6226
6227    #[test]
6228    fn hardening_usefulness_cannot_dominate_relevance() {
6229        let neutral = make_capsule("neutral", 0.0);
6230        let mut useful = make_capsule("useful", 0.0);
6231        useful.rerank_policy_tier = 1;
6232        let out = rerank_capsules(
6233            "q",
6234            vec![neutral, useful],
6235            &TwoScoreReranker(0.99, 0.31),
6236            0.30,
6237            0,
6238        );
6239        assert_eq!(out[0].summary, "neutral");
6240        assert!(out[1].score <= 0.410001);
6241    }
6242
6243    #[test]
6244    fn hardening_rerank_preserves_decayed_usefulness_and_trust() {
6245        let neutral = make_capsule("neutral", 0.0);
6246        let mut stale_useful = make_capsule("stale", 0.0);
6247        stale_useful.rerank_policy_tier = 1;
6248        stale_useful.rerank_usefulness = Some(1.0001);
6249        let out = rerank_capsules(
6250            "q",
6251            vec![neutral.clone(), stale_useful],
6252            &TwoScoreReranker(0.9, 0.85),
6253            0.0,
6254            0,
6255        );
6256        assert_eq!(out[0].summary, "neutral");
6257        let mut imported = make_capsule("imported", 0.0);
6258        imported.rerank_usefulness = Some(1.5);
6259        imported.rerank_trust = Some(0.5);
6260        let out = rerank_capsules(
6261            "q",
6262            vec![neutral, imported],
6263            &TwoScoreReranker(0.8, 0.9),
6264            0.0,
6265            0,
6266        );
6267        assert_eq!(out[0].summary, "neutral");
6268        assert!((out[1].score - 0.5).abs() < 0.00001);
6269    }
6270
6271    #[test]
6272    fn hardening_freshness_has_thirty_day_half_life() {
6273        let past = (OffsetDateTime::now_utc() - time::Duration::days(30))
6274            .format(&time::format_description::well_known::Rfc3339)
6275            .unwrap();
6276        assert!((freshness(&past) - 0.5).abs() < 0.0001);
6277    }
6278
6279    #[test]
6280    fn rerank_reapplies_usefulness_but_not_to_superseded_capsules() {
6281        let neutral = make_capsule("neutral", 0.0);
6282        let mut useful = make_capsule("useful", 0.0);
6283        useful.rerank_policy_tier = 1;
6284
6285        let out = rerank_capsules(
6286            "q",
6287            vec![neutral.clone(), useful.clone()],
6288            &TwoScoreReranker(0.59, 0.50),
6289            0.0,
6290            0,
6291        );
6292        assert_eq!(out[0].summary, "useful", "usefulness survives reranking");
6293
6294        useful.superseded_hint = true;
6295        useful.rerank_policy_tier = 1;
6296        let out = rerank_capsules(
6297            "q",
6298            vec![neutral, useful],
6299            &TwoScoreReranker(0.59, 0.50),
6300            0.0,
6301            0,
6302        );
6303        assert_eq!(
6304            out[0].summary, "neutral",
6305            "superseded memories must not keep their historic usefulness boost"
6306        );
6307        assert!(
6308            (out[1].score - 0.40).abs() < 1e-6,
6309            "supersession must be reapplied to the raw rerank score"
6310        );
6311    }
6312
6313    /// RR-4: fail-open — a broken reranker returns Err; input order is preserved.
6314    #[test]
6315    fn rerank_capsules_fail_open_preserves_input_order() {
6316        struct FailingReranker;
6317        impl crate::embeddings::Reranker for FailingReranker {
6318            fn rerank(
6319                &self,
6320                _query: &str,
6321                _docs: &[&str],
6322            ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6323                Err(crate::embeddings::EmbedderError::EmbedFailed(
6324                    "simulated failure".into(),
6325                ))
6326            }
6327            fn model_id(&self) -> &str {
6328                "fail-reranker"
6329            }
6330        }
6331
6332        let query = "anything";
6333        let c1 = make_capsule("first capsule", 0.9);
6334        let c2 = make_capsule("second capsule", 0.5);
6335        let c3 = make_capsule("third capsule", 0.1);
6336        let capsules = vec![c1.clone(), c2.clone(), c3.clone()];
6337
6338        let out = rerank_capsules(query, capsules, &FailingReranker, 0.0, 0);
6339
6340        // On error: input order preserved, all 3 capsules returned.
6341        assert_eq!(out.len(), 3, "all capsules must be returned on error");
6342        assert_eq!(out[0].summary, c1.summary, "order must be preserved");
6343        assert_eq!(out[1].summary, c2.summary, "order must be preserved");
6344        assert_eq!(out[2].summary, c3.summary, "order must be preserved");
6345    }
6346
6347    /// RR-0: empty input → empty output.
6348    #[test]
6349    fn rerank_capsules_empty_input_returns_empty() {
6350        use crate::embeddings::StubReranker;
6351        let out = rerank_capsules("query", vec![], &StubReranker, 0.0, 0);
6352        assert!(out.is_empty());
6353    }
6354
6355    // ── v2.7: evidence-band arbitration ─────────────────────────────────────
6356
6357    /// Reranker that returns one fixed score per doc, for band tests.
6358    struct FixedReranker(f32);
6359    impl crate::embeddings::Reranker for FixedReranker {
6360        fn rerank(
6361            &self,
6362            _query: &str,
6363            docs: &[&str],
6364        ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6365            Ok(vec![self.0; docs.len()])
6366        }
6367        fn model_id(&self) -> &str {
6368            "fixed-reranker"
6369        }
6370    }
6371
6372    struct TwoScoreReranker(f32, f32);
6373    impl crate::embeddings::Reranker for TwoScoreReranker {
6374        fn rerank(
6375            &self,
6376            _query: &str,
6377            _docs: &[&str],
6378        ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6379            Ok(vec![self.0, self.1])
6380        }
6381        fn model_id(&self) -> &str {
6382            "two-score"
6383        }
6384    }
6385
6386    fn band_bundle(top_abs_evidence: f32) -> ContextBundle {
6387        let mut capsule = make_capsule("a memory lesson", 0.9);
6388        capsule.kind = "memory".to_string();
6389        capsule.token_estimate = 10;
6390        ContextBundle {
6391            stage: "localization".into(),
6392            budget_tokens: 4000,
6393            used_tokens: 10,
6394            capsules: vec![capsule],
6395            excluded: vec![],
6396            skipped: false,
6397            top_score: 0.9,
6398            top_abs_evidence,
6399            evidence_coverage: 1.0,
6400            uncovered_terms: vec![],
6401            chronological: false,
6402            known_fact_conflicts: vec![],
6403        }
6404    }
6405
6406    /// In-band + cross-encoder approves → injected (recall recovered).
6407    /// In-band + cross-encoder rejects → converted to a skipped bundle.
6408    #[test]
6409    fn band_arbitration_follows_the_cross_encoder() {
6410        let approve = FixedReranker(ABSTAIN_RERANK_FLOOR + 0.2);
6411        let out = rerank_and_arbitrate("q", band_bundle(0.50), Some(&approve), 0.55, 0.0, 0);
6412        assert!(!out.skipped, "approved band bundle must inject");
6413        assert_eq!(out.capsules.len(), 1);
6414
6415        let reject = FixedReranker(ABSTAIN_RERANK_FLOOR - 0.2);
6416        let out = rerank_and_arbitrate("q", band_bundle(0.50), Some(&reject), 0.55, 0.0, 0);
6417        assert!(out.skipped, "rejected band bundle must convert to skipped");
6418        assert!(out.capsules.is_empty());
6419        assert_eq!(out.used_tokens, 0);
6420        assert_eq!(out.excluded.len(), 1, "rejected capsules land in excluded");
6421    }
6422
6423    /// The evidence-band threshold is calibrated on raw cross-encoder scores,
6424    /// before the supersession policy is reapplied to ordering.
6425    #[test]
6426    fn band_arbitration_uses_raw_rerank_evidence() {
6427        let mut bundle = band_bundle(0.50);
6428        bundle.capsules[0].superseded_hint = true;
6429        bundle
6430            .capsules
6431            .push(make_capsule("irrelevant distractor", 1.0));
6432
6433        let reranker = TwoScoreReranker(0.95, 0.0);
6434        let out = rerank_and_arbitrate("q", bundle, Some(&reranker), 0.55, 0.0, 0);
6435        assert!(!out.skipped, "raw rerank evidence above 0.9 must admit");
6436        assert_eq!(out.capsules[0].summary, "a memory lesson");
6437        assert!(
6438            out.capsules[0].score < ABSTAIN_RERANK_FLOOR,
6439            "the regression requires post-policy score below the raw-score floor"
6440        );
6441    }
6442
6443    /// Admission evidence is computed before policy ordering and output cap.
6444    /// Bounded usefulness cannot displace clearly stronger raw evidence.
6445    #[test]
6446    fn band_arbitration_uses_raw_evidence_before_policy_cap() {
6447        let mut bundle = band_bundle(0.50);
6448        bundle.capsules[0].rerank_policy_tier = 1;
6449        bundle
6450            .capsules
6451            .push(make_capsule("high-confidence neutral", 1.0));
6452
6453        let reranker = TwoScoreReranker(0.50, 0.95);
6454        let out = rerank_and_arbitrate("q", bundle, Some(&reranker), 0.55, 0.0, 1);
6455        assert!(!out.skipped, "raw evidence outside the cap must admit");
6456        assert_eq!(out.capsules.len(), 1);
6457        assert_eq!(out.capsules[0].summary, "high-confidence neutral");
6458    }
6459
6460    /// Above the band the cross-encoder reorders but never converts, even
6461    /// when its scores are low.
6462    #[test]
6463    fn band_arbitration_never_converts_out_of_band_bundles() {
6464        let reject = FixedReranker(0.0);
6465        let out = rerank_and_arbitrate("q", band_bundle(0.70), Some(&reject), 0.55, 0.0, 0);
6466        assert!(
6467            !out.skipped,
6468            "evidence above the threshold is not arbitrated"
6469        );
6470        assert_eq!(out.capsules.len(), 1);
6471    }
6472
6473    /// No reranker: the band FAILS CLOSED — equivalent to the plain hard gate.
6474    /// Out-of-band bundles pass through untouched.
6475    #[test]
6476    fn band_fails_closed_without_a_reranker() {
6477        let out = rerank_and_arbitrate("q", band_bundle(0.50), None, 0.55, 0.0, 0);
6478        assert!(out.skipped, "band without an arbiter must abstain");
6479        let out = rerank_and_arbitrate("q", band_bundle(0.70), None, 0.55, 0.0, 0);
6480        assert!(!out.skipped);
6481        // Gate disabled: nothing converts.
6482        let out = rerank_and_arbitrate("q", band_bundle(0.10), None, 0.0, 0.0, 0);
6483        assert!(!out.skipped);
6484    }
6485
6486    /// Reranker that scores docs by position: first doc highest. Used to
6487    /// simulate the cross-encoder preferring the OLD twin's wording.
6488    struct PositionReranker;
6489    impl crate::embeddings::Reranker for PositionReranker {
6490        fn rerank(
6491            &self,
6492            _query: &str,
6493            docs: &[&str],
6494        ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6495            Ok((0..docs.len()).map(|i| 0.99 - 0.01 * i as f32).collect())
6496        }
6497        fn model_id(&self) -> &str {
6498            "position-reranker"
6499        }
6500    }
6501
6502    /// The supersession penalty must survive reranking: the cross-encoder
6503    /// judges pure query relevance, so without reapplying the penalty on its
6504    /// scores an older twin whose wording matches the query better would be
6505    /// resurrected above its replacement (measured: resolution 0.58 → 0.36).
6506    #[test]
6507    fn supersession_penalty_survives_reranking() {
6508        let mut old = make_capsule("deploy via make deploy-staging", 0.7);
6509        old.superseded_hint = true; // marked by apply_supersession_penalty
6510        let new = make_capsule("deploy via make deploy-preview since the migration", 0.9);
6511        let mut bundle = band_bundle(0.70); // out of band: no conversion risk
6512        bundle.capsules = vec![old, new];
6513
6514        // PositionReranker scores the OLD twin (first doc) highest: 0.99 vs
6515        // 0.98. The reapplied ×0.80 penalty must drop it below the new one.
6516        let out = rerank_and_arbitrate("q", bundle, Some(&PositionReranker), 0.55, 0.0, 0);
6517        assert!(!out.skipped);
6518        assert_eq!(out.capsules.len(), 2);
6519        assert!(
6520            out.capsules[0].summary.contains("deploy-preview"),
6521            "the replacement must outrank the penalized incumbent after reranking; got {:?}",
6522            out.capsules.iter().map(|c| &c.summary).collect::<Vec<_>>()
6523        );
6524        assert!(out.capsules[1].superseded_hint);
6525    }
6526
6527    /// A bundle with a non-memory capsule is never converted: repo evidence
6528    /// stands on its own.
6529    #[test]
6530    fn band_spares_bundles_with_repo_evidence() {
6531        let mut bundle = band_bundle(0.50);
6532        let mut repo = make_capsule("README excerpt", 0.4);
6533        repo.kind = "repo_file".to_string();
6534        bundle.capsules.push(repo);
6535        let reject = FixedReranker(0.0);
6536        let out = rerank_and_arbitrate("q", bundle, Some(&reject), 0.55, 0.0, 0);
6537        assert!(!out.skipped, "repo capsules suppress band conversion");
6538    }
6539
6540    // ── v1.5 Story 2.1: compress_for_render unit tests ──────────────────────
6541
6542    /// CFR-1: short text (< 3 sentences) is returned unchanged (no truncation).
6543    #[test]
6544    fn compress_for_render_short_text_unchanged() {
6545        let text = "project:fact - Use cargo fmt before committing.";
6546        let out = compress_for_render(text, 3);
6547        assert_eq!(out, text, "short text must not be altered");
6548    }
6549
6550    /// CFR-2: [tags: ...] prefix is stripped before capping.
6551    #[test]
6552    fn compress_for_render_strips_tags_prefix() {
6553        let text = "[tags: rust, cargo] Always run cargo clippy before submitting a PR.";
6554        let out = compress_for_render(text, 3);
6555        assert!(
6556            !out.starts_with('['),
6557            "tags prefix must be stripped, got: {out:?}"
6558        );
6559        assert!(
6560            out.contains("cargo clippy"),
6561            "body must remain, got: {out:?}"
6562        );
6563    }
6564
6565    /// CFR-3: (context: ...) trailing suffix is stripped.
6566    #[test]
6567    fn compress_for_render_strips_context_suffix() {
6568        let text =
6569            "project:fact - Use cargo fmt. Always clippy clean. (context: Kimetsu brain lesson)";
6570        let out = compress_for_render(text, 5);
6571        assert!(
6572            !out.contains("(context:"),
6573            "context suffix must be stripped, got: {out:?}"
6574        );
6575        assert!(out.contains("cargo fmt"), "body must remain, got: {out:?}");
6576    }
6577
6578    /// CFR-4: multi-sentence body is capped at max_sentences.
6579    #[test]
6580    fn compress_for_render_caps_sentences() {
6581        let text =
6582            "project:fact - First sentence. Second sentence. Third sentence. Fourth sentence.";
6583        let out = compress_for_render(text, 2);
6584        // Must contain "First" and "Second" but not "Third" or "Fourth".
6585        assert!(out.contains("First"), "first sentence must be present");
6586        assert!(out.contains("Second"), "second sentence must be present");
6587        assert!(
6588            !out.contains("Third"),
6589            "third sentence must be truncated, got: {out:?}"
6590        );
6591    }
6592
6593    /// CFR-5: scope:kind prefix is preserved after compression.
6594    #[test]
6595    fn compress_for_render_preserves_scope_prefix() {
6596        let text = "global_user:convention - First rule. Second rule. Third rule. Fourth rule.";
6597        let out = compress_for_render(text, 2);
6598        assert!(
6599            out.starts_with("global_user:convention - "),
6600            "scope prefix must be preserved, got: {out:?}"
6601        );
6602        assert!(out.contains("First"), "first sentence must remain");
6603        assert!(!out.contains("Third"), "third sentence must be truncated");
6604    }
6605
6606    /// CFR-6: empty string never panics and returns the original (empty) string.
6607    #[test]
6608    fn compress_for_render_empty_input_safe() {
6609        let out = compress_for_render("", 3);
6610        assert_eq!(out, "", "empty input must return empty string");
6611    }
6612
6613    /// CFR-7: max_sentences=0 returns the original text unchanged (opt-out).
6614    #[test]
6615    fn compress_for_render_zero_max_sentences_returns_original() {
6616        let text = "project:fact - Some lesson that is quite long. It keeps going. And going.";
6617        let out = compress_for_render(text, 0);
6618        assert_eq!(out, text);
6619    }
6620
6621    /// CFR-8: exotic UTF-8 (multi-byte characters) is handled safely.
6622    #[test]
6623    fn compress_for_render_utf8_safe() {
6624        let text = "project:fact - こんにちは世界. Hello world. Third sentence. Fourth sentence.";
6625        // Should not panic; body trimming is purely ASCII-safe (splitting on b'.')
6626        let out = compress_for_render(text, 2);
6627        assert!(!out.is_empty(), "UTF-8 text must not produce empty output");
6628        // The first Japanese sentence period is b'.', so cap at 2 means we cut after the 2nd.
6629        assert!(!out.contains("Third"), "third sentence must be truncated");
6630    }
6631
6632    /// CFR-9: long memory (>60 tokens) is compressed by >=25%.
6633    /// Acceptance test for the Story 2.1 token-reduction gate.
6634    #[test]
6635    fn compress_for_render_long_memory_reduces_tokens_by_25_percent() {
6636        // Representative long memory text (8 sentences, well over 60 tokens).
6637        let long_summary = "project:fact - When a SQLite WAL file exists from a crashed process, \
6638            opening the DB causes the WAL to be replayed. The replayed WAL may contain \
6639            partial writes that corrupt the DB. Always check for WAL files before opening. \
6640            Delete the WAL only after verifying the DB is consistent. Use PRAGMA integrity_check \
6641            to validate after opening. If integrity_check fails, restore from backup. Never \
6642            truncate the WAL without replaying it first. This pattern applies to any \
6643            crash-recovery scenario.";
6644
6645        let raw_tokens = estimate_tokens(long_summary);
6646        assert!(
6647            raw_tokens > 60,
6648            "test precondition: raw memory must be >60 tokens, got {raw_tokens}"
6649        );
6650
6651        let compressed = compress_for_render(long_summary, 3);
6652        let compressed_tokens = estimate_tokens(&compressed);
6653
6654        let reduction = 1.0 - (compressed_tokens as f64 / raw_tokens as f64);
6655        assert!(
6656            reduction >= 0.25,
6657            "compression must reduce tokens by >=25% on long memories; \
6658             raw={raw_tokens} compressed={compressed_tokens} reduction={reduction:.2}"
6659        );
6660    }
6661}
6662
6663#[cfg(test)]
6664mod evidence_tests {
6665    use super::*;
6666
6667    fn conn_with(texts: &[&str]) -> Connection {
6668        let conn = Connection::open_in_memory().expect("open");
6669        crate::schema::initialize(&conn).expect("schema");
6670        for (i, text) in texts.iter().enumerate() {
6671            conn.execute(
6672                "INSERT INTO memories
6673                 (memory_id, scope, kind, text, normalized_text, confidence,
6674                  provenance_snapshot_json, created_at)
6675                 VALUES (?1, 'project', 'fact', ?2, ?2, 0.9, '{}', '2026-01-01T00:00:00Z')",
6676                rusqlite::params![format!("m{i}"), text],
6677            )
6678            .expect("insert");
6679            conn.execute("INSERT INTO memories_fts(memory_id,text,kind,scope) VALUES (?1,?2,'fact','project')",
6680                params![format!("m{i}"),text]).unwrap();
6681        }
6682        conn
6683    }
6684
6685    fn capsule(summary: &str) -> ContextCapsule {
6686        ContextCapsule {
6687            id: String::new(),
6688            kind: "memory".to_string(),
6689            summary: summary.to_string(),
6690            token_estimate: 10,
6691            expansion_handle: format!("memory:{summary}"),
6692            provenance: Vec::new(),
6693            confidence: 0.9,
6694            freshness: 0.5,
6695            relevance: 0.0,
6696            scope_weight: 0.9,
6697            score: 0.5,
6698            superseded_hint: false,
6699            rerank_policy_tier: 0,
6700            claim_revision: None,
6701            facts: vec![],
6702            rerank_usefulness: None,
6703            rerank_trust: None,
6704        }
6705    }
6706
6707    fn bundle(capsules: Vec<ContextCapsule>, coverage: f32, uncovered: &[&str]) -> ContextBundle {
6708        ContextBundle {
6709            stage: "localization".to_string(),
6710            budget_tokens: 2000,
6711            used_tokens: 20,
6712            capsules,
6713            excluded: Vec::new(),
6714            skipped: false,
6715            top_score: 0.7,
6716            top_abs_evidence: 0.0,
6717            evidence_coverage: coverage,
6718            uncovered_terms: uncovered.iter().map(|s| s.to_string()).collect(),
6719            chronological: false,
6720            known_fact_conflicts: vec![],
6721        }
6722    }
6723
6724    /// A bundle that answers the question fully must report full coverage and
6725    /// name nothing — a complete answer should cost zero extra tokens.
6726    #[test]
6727    fn full_coverage_names_nothing() {
6728        let conn = conn_with(&[
6729            "checkpoint the wal before copying brain.db",
6730            "vacuum reclaims dead pages",
6731        ]);
6732        let (coverage, uncovered) = evidence_coverage(
6733            &conn,
6734            "checkpoint wal",
6735            &[capsule(
6736                "project:fact - checkpoint the wal before copying brain.db",
6737            )],
6738        );
6739        assert!(coverage > 0.99, "got {coverage}");
6740        assert!(uncovered.is_empty(), "got {uncovered:?}");
6741    }
6742
6743    /// The case that matters: capsules that touch part of the query. The
6744    /// reader must be told which part memory does not cover, rather than being
6745    /// left to infer it.
6746    #[test]
6747    fn partial_coverage_names_the_missing_terms() {
6748        let conn = conn_with(&[
6749            "checkpoint the wal before copying brain.db",
6750            "the migration runner snapshots before each step",
6751        ]);
6752        let (coverage, uncovered) = evidence_coverage(
6753            &conn,
6754            "checkpoint wal migration",
6755            &[capsule(
6756                "project:fact - checkpoint the wal before copying brain.db",
6757            )],
6758        );
6759        assert!(coverage < 1.0, "coverage should be partial: {coverage}");
6760        assert!(
6761            uncovered.iter().any(|t| t.starts_with("migrat")),
6762            "the uncovered term must be named: {uncovered:?}"
6763        );
6764    }
6765
6766    /// Coverage is measured over the union of the capsules, not the best one:
6767    /// the question is whether the bundle answers the query.
6768    #[test]
6769    fn coverage_is_collective_not_per_capsule() {
6770        let conn = conn_with(&[
6771            "checkpoint the wal before copying brain.db",
6772            "the migration runner snapshots before each step",
6773        ]);
6774        let (coverage, uncovered) = evidence_coverage(
6775            &conn,
6776            "checkpoint migration",
6777            &[
6778                capsule("project:fact - checkpoint the wal before copying"),
6779                capsule("project:fact - the migration runner snapshots first"),
6780            ],
6781        );
6782        assert!(
6783            coverage > 0.99,
6784            "neither capsule covers both terms, but together they do: {coverage}"
6785        );
6786        assert!(uncovered.is_empty(), "got {uncovered:?}");
6787    }
6788
6789    /// A query of nothing but stopwords has no content to measure. Claiming a
6790    /// gap there would make every vague question look like a memory failure.
6791    #[test]
6792    fn an_unmeasurable_query_does_not_claim_a_gap() {
6793        let conn = conn_with(&["checkpoint the wal"]);
6794        let (coverage, uncovered) =
6795            evidence_coverage(&conn, "the and of", &[capsule("project:fact - checkpoint")]);
6796        assert_eq!(coverage, 1.0);
6797        assert!(uncovered.is_empty());
6798    }
6799
6800    /// The case that made this need its own IDF. A query term the corpus has
6801    /// never seen is the *strongest* evidence memory does not cover the
6802    /// question — but the per-memory floor's IDF zeroes exactly those, because
6803    /// there an out-of-corpus word would sink every candidate. Reusing it here
6804    /// made "checkpoint the wal during a kubernetes rollout" report full
6805    /// coverage on the strength of the WAL half alone.
6806    #[test]
6807    fn a_term_the_corpus_has_never_seen_counts_as_a_gap() {
6808        let conn = conn_with(&[
6809            "checkpoint the wal before copying brain.db",
6810            "vacuum reclaims dead pages",
6811        ]);
6812        let (coverage, uncovered) = evidence_coverage(
6813            &conn,
6814            "checkpoint the wal during a kubernetes rollout",
6815            &[capsule(
6816                "project:fact - checkpoint the wal before copying brain.db",
6817            )],
6818        );
6819        assert!(
6820            coverage <= PARTIAL_EVIDENCE_COVERAGE,
6821            "an unknown half of the question must read as thin, not complete: {coverage}"
6822        );
6823        assert!(
6824            uncovered.iter().any(|t| t.starts_with("kubernet")),
6825            "the unknown term must be named: {uncovered:?}"
6826        );
6827    }
6828
6829    /// …and the mirror: a term in *every* memory (the project name) carries no
6830    /// signal either way and must not inflate coverage.
6831    #[test]
6832    fn a_ubiquitous_term_carries_no_weight() {
6833        let conn = conn_with(&["kimetsu checkpoint wal", "kimetsu vacuum pages"]);
6834        let (coverage, _) = evidence_coverage(
6835            &conn,
6836            "kimetsu vacuum",
6837            &[capsule("project:fact - kimetsu vacuum pages")],
6838        );
6839        assert!(coverage > 0.99, "got {coverage}");
6840    }
6841
6842    #[test]
6843    fn an_empty_query_does_not_claim_a_gap() {
6844        let conn = conn_with(&["checkpoint the wal"]);
6845        assert_eq!(evidence_coverage(&conn, "", &[]).0, 1.0);
6846    }
6847
6848    // ── The rendered notice ──────────────────────────────────────────────
6849
6850    #[test]
6851    fn a_complete_bundle_gets_no_notice() {
6852        assert!(partial_evidence_notice(&bundle(vec![capsule("a")], 1.0, &[])).is_none());
6853        assert!(
6854            partial_evidence_notice(&bundle(vec![capsule("a")], 0.9, &["x"])).is_none(),
6855            "above the threshold is not partial"
6856        );
6857    }
6858
6859    #[test]
6860    fn an_empty_or_skipped_bundle_gets_no_notice() {
6861        let mut skipped = bundle(Vec::new(), 0.0, &["x"]);
6862        skipped.skipped = true;
6863        assert!(
6864            partial_evidence_notice(&skipped).is_none(),
6865            "an empty bundle already says everything it can"
6866        );
6867        assert!(partial_evidence_notice(&bundle(Vec::new(), 0.0, &["x"])).is_none());
6868    }
6869
6870    #[test]
6871    fn a_partial_bundle_names_what_is_missing_and_tells_the_reader_what_to_do() {
6872        let notice =
6873            partial_evidence_notice(&bundle(vec![capsule("a")], 0.3, &["migration", "rollback"]))
6874                .expect("a thin bundle must be flagged");
6875        assert!(notice.contains("migration"), "got: {notice}");
6876        assert!(notice.contains("rollback"), "got: {notice}");
6877        assert!(
6878            notice.contains("unknown"),
6879            "the notice must tell the reader to abstain, not just report a gap: {notice}"
6880        );
6881    }
6882
6883    /// Naming twenty terms is noise.
6884    #[test]
6885    fn the_notice_caps_how_many_terms_it_names() {
6886        let terms: Vec<String> = (0..12).map(|i| format!("term{i}")).collect();
6887        let refs: Vec<&str> = terms.iter().map(String::as_str).collect();
6888        let notice =
6889            partial_evidence_notice(&bundle(vec![capsule("a")], 0.1, &refs)).expect("flagged");
6890        assert!(notice.contains("and 6 more"), "got: {notice}");
6891        assert!(!notice.contains("term9"), "got: {notice}");
6892    }
6893
6894    // ── v2.6: light stemming ─────────────────────────────────────────────
6895
6896    /// The defect BrainBench's sycophancy track surfaced: a query asking about
6897    /// `retry` treated a corpus saying `retries` as not mentioning it, because
6898    /// the two stemmed to `retry` and `retri` and neither prefixes the other.
6899    #[test]
6900    fn the_y_ies_pair_shares_a_stem() {
6901        for (a, b) in [
6902            ("retry", "retries"),
6903            ("query", "queries"),
6904            ("policy", "policies"),
6905            ("memory", "memories"),
6906            ("binary", "binaries"),
6907            ("registry", "registries"),
6908        ] {
6909            assert_eq!(
6910                light_stem(a),
6911                light_stem(b),
6912                "{a}/{b} stemmed to {:?}/{:?}",
6913                light_stem(a),
6914                light_stem(b)
6915            );
6916        }
6917    }
6918
6919    /// Vowel-`y` is part of the word, not an inflection: `day` is not `da`.
6920    #[test]
6921    fn a_vowel_y_is_not_stripped() {
6922        assert_eq!(light_stem("delay"), "delay");
6923        assert_eq!(light_stem("gateway"), "gateway");
6924        // "journeys" strips the s (7 chars remain), and the y survives because
6925        // a vowel precedes it.
6926        assert_eq!(light_stem("journeys"), "journey");
6927    }
6928
6929    /// Short words are left alone: over-stemming a four-letter token leaves a
6930    /// prefix that matches half the corpus.
6931    #[test]
6932    fn short_words_keep_their_ending() {
6933        assert_eq!(light_stem("body"), "body");
6934        assert_eq!(light_stem("copy"), "copy");
6935    }
6936
6937    /// The pre-existing behaviour must be unchanged — this rule is additive.
6938    #[test]
6939    fn the_original_suffix_rules_still_hold() {
6940        assert_eq!(light_stem("benchmarked"), "benchmark");
6941        assert_eq!(light_stem("benchmarking"), "benchmark");
6942        assert_eq!(light_stem("migrations"), "migration");
6943        assert_eq!(light_stem("run"), "run");
6944    }
6945
6946    /// End to end, which is the form the defect actually took: a bundle must
6947    /// not report a gap on a term the corpus inflects differently.
6948    #[test]
6949    fn an_inflected_corpus_term_counts_as_covered() {
6950        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
6951        crate::schema::initialize(&conn).expect("init schema");
6952        let text = "the ingest worker retries a failed batch three times before giving up";
6953        let normalized = kimetsu_core::memory::normalize_memory_text(text);
6954        conn.execute(
6955            "
6956            INSERT INTO memories (
6957                memory_id, scope, kind, text, normalized_text, confidence,
6958                source_event_id, provenance_snapshot_json, created_at
6959            )
6960            VALUES ('m_retry', 'project', 'fact', ?1, ?2, 1.0, NULL, '{}',
6961                    '2026-01-01T00:00:00Z')
6962            ",
6963            rusqlite::params![text, normalized],
6964        )
6965        .expect("insert memory");
6966        conn.execute(
6967            "INSERT INTO memories_fts (memory_id, text, kind, scope)
6968             VALUES ('m_retry', ?1, 'fact', 'project')",
6969            rusqlite::params![text],
6970        )
6971        .expect("insert fts");
6972
6973        let bundle = retrieve_context_with_embedder(
6974            &conn,
6975            "/fake-repo",
6976            &kimetsu_core::config::BrokerWeights::default(),
6977            ContextRequest {
6978                stage: "localization".to_string(),
6979                query: "how many times does the ingest worker retry a failed batch".to_string(),
6980                budget_tokens: 4000,
6981                ..Default::default()
6982            },
6983            &[],
6984            &embeddings::NoopEmbedder,
6985        )
6986        .expect("retrieve");
6987
6988        assert!(
6989            !bundle.uncovered_terms.iter().any(|t| t.starts_with("retr")),
6990            "`retry` must match a corpus that says `retries`; uncovered: {:?}",
6991            bundle.uncovered_terms
6992        );
6993    }
6994
6995    // ── v2.6: event ordering (crate::ordering) ──────────────────────────
6996
6997    /// Seed two memories on the same topic, written months apart, and retrieve
6998    /// them. The end-to-end proof that `crate::ordering` is actually reachable
6999    /// from the broker — the unit tests there operate on capsules the broker
7000    /// never handed them.
7001    fn ordering_conn() -> rusqlite::Connection {
7002        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
7003        crate::schema::initialize(&conn).expect("init schema");
7004        for (mid, created, text) in [
7005            (
7006                "m_late",
7007                "2026-06-01T09:00:00Z",
7008                "switched the error type to thiserror",
7009            ),
7010            (
7011                "m_early",
7012                "2026-01-15T10:00:00Z",
7013                "ran the thiserror schema migration",
7014            ),
7015        ] {
7016            let normalized = kimetsu_core::memory::normalize_memory_text(text);
7017            conn.execute(
7018                "
7019                INSERT INTO memories (
7020                    memory_id, scope, kind, text, normalized_text, confidence,
7021                    source_event_id, provenance_snapshot_json, created_at
7022                )
7023                VALUES (?1, 'project', 'fact', ?2, ?3, 1.0, NULL, '{}', ?4)
7024                ",
7025                rusqlite::params![mid, text, normalized, created],
7026            )
7027            .expect("insert memory");
7028            conn.execute(
7029                "INSERT INTO memories_fts (memory_id, text, kind, scope)
7030                 VALUES (?1, ?2, 'fact', 'project')",
7031                rusqlite::params![mid, text],
7032            )
7033            .expect("insert fts");
7034        }
7035        conn
7036    }
7037
7038    fn ordering_bundle(conn: &rusqlite::Connection, query: &str) -> ContextBundle {
7039        retrieve_context_with_embedder(
7040            conn,
7041            "/fake-repo",
7042            &kimetsu_core::config::BrokerWeights::default(),
7043            ContextRequest {
7044                stage: "localization".to_string(),
7045                query: query.to_string(),
7046                budget_tokens: 4000,
7047                ..Default::default()
7048            },
7049            &[],
7050            &embeddings::NoopEmbedder,
7051        )
7052        .expect("retrieve")
7053    }
7054
7055    /// The fix, end to end: asked which came first, the reader is handed the
7056    /// memories oldest-first with the dates it needs to answer.
7057    #[test]
7058    fn an_ordering_query_returns_a_dated_chronological_bundle() {
7059        let conn = ordering_conn();
7060        let bundle = ordering_bundle(&conn, "did we run the thiserror migration before or after");
7061
7062        assert!(bundle.chronological, "the query asked about order");
7063        let order: Vec<&str> = bundle
7064            .capsules
7065            .iter()
7066            .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
7067            .collect();
7068        assert_eq!(order, vec!["m_early", "m_late"], "oldest first");
7069        for (capsule, date) in bundle.capsules.iter().zip(["2026-01-15", "2026-06-01"]) {
7070            assert!(
7071                capsule.summary.contains(&format!("[{date}]")),
7072                "every capsule carries its date; got: {}",
7073                capsule.summary
7074            );
7075        }
7076    }
7077
7078    /// The narrow gate is the whole reason this is safe: an ordinary question
7079    /// keeps relevance order and spends no tokens on dates.
7080    #[test]
7081    fn an_ordinary_query_is_untouched() {
7082        let conn = ordering_conn();
7083        let bundle = ordering_bundle(&conn, "how do we handle thiserror errors");
7084
7085        assert!(!bundle.chronological);
7086        for capsule in &bundle.capsules {
7087            assert!(
7088                !capsule.summary.contains('['),
7089                "no dates on a non-ordering query; got: {}",
7090                capsule.summary
7091            );
7092        }
7093    }
7094
7095    /// Presentation, not selection: reordering runs after the budget loop, so
7096    /// the same capsules ship either way. If this ever diverges, ordering has
7097    /// started changing *what* the reader sees rather than how.
7098    #[test]
7099    fn ordering_changes_the_rendering_not_the_selection() {
7100        let conn = ordering_conn();
7101        let ordered = ordering_bundle(&conn, "did we run the thiserror migration before or after");
7102        let plain = ordering_bundle(&conn, "did we run the thiserror migration");
7103
7104        let mut got: Vec<&str> = ordered
7105            .capsules
7106            .iter()
7107            .map(|c| c.expansion_handle.as_str())
7108            .collect();
7109        let mut want: Vec<&str> = plain
7110            .capsules
7111            .iter()
7112            .map(|c| c.expansion_handle.as_str())
7113            .collect();
7114        got.sort_unstable();
7115        want.sort_unstable();
7116        assert_eq!(got, want, "same capsules, different order");
7117    }
7118
7119    /// The dates are real tokens and the bundle's accounting has to say so,
7120    /// or a budgeted caller under-counts what it just injected.
7121    #[test]
7122    fn the_dates_are_counted_against_the_budget() {
7123        let conn = ordering_conn();
7124        let ordered = ordering_bundle(&conn, "did we run the thiserror migration before or after");
7125        let plain = ordering_bundle(&conn, "did we run the thiserror migration");
7126        assert!(
7127            ordered.used_tokens > plain.used_tokens,
7128            "dated: {} vs plain: {}",
7129            ordered.used_tokens,
7130            plain.used_tokens
7131        );
7132        assert_eq!(
7133            ordered.used_tokens,
7134            ordered
7135                .capsules
7136                .iter()
7137                .map(|c| c.token_estimate)
7138                .sum::<u32>(),
7139            "used_tokens must match what was actually rendered"
7140        );
7141    }
7142}
7143
7144#[cfg(test)]
7145mod hardening_tests {
7146    use super::*;
7147
7148    fn corpus() -> Connection {
7149        let conn = Connection::open_in_memory().unwrap();
7150        crate::schema::initialize(&conn).unwrap();
7151        for (id, text) in [
7152            ("live", "routing routing routes"),
7153            ("future", "routing"),
7154            ("expired", "routing"),
7155            ("offset", "routing"),
7156            ("other", "rerouting unrelated"),
7157        ] {
7158            conn.execute("INSERT INTO memories (memory_id,scope,kind,text,normalized_text,confidence,created_at,provenance_snapshot_json)
7159                VALUES (?1,'project','fact',?2,?2,1,'2020-01-01T00:00:00Z','{}')", params![id,text]).unwrap();
7160            conn.execute("INSERT INTO memories_fts(memory_id,text,kind,scope) VALUES (?1,?2,'fact','project')",params![id,text]).unwrap();
7161        }
7162        conn
7163    }
7164
7165    #[test]
7166    fn hardening_live_lexical_and_recency_apply_both_time_bounds() {
7167        let conn = corpus();
7168        let now = OffsetDateTime::now_utc();
7169        let fmt = &time::format_description::well_known::Rfc3339;
7170        let future = (now + time::Duration::hours(1)).format(fmt).unwrap();
7171        let expired = (now - time::Duration::seconds(2)).format(fmt).unwrap();
7172        let offset = (now - time::Duration::seconds(2))
7173            .to_offset(time::UtcOffset::from_hms(12, 0, 0).unwrap())
7174            .format(fmt)
7175            .unwrap();
7176        conn.execute(
7177            "UPDATE memories SET valid_from=?1 WHERE memory_id='future'",
7178            params![future],
7179        )
7180        .unwrap();
7181        conn.execute(
7182            "UPDATE memories SET valid_to=?1 WHERE memory_id='expired'",
7183            params![expired],
7184        )
7185        .unwrap();
7186        conn.execute(
7187            "UPDATE memories SET valid_to=?1 WHERE memory_id='offset'",
7188            params![offset],
7189        )
7190        .unwrap();
7191        for candidates in [
7192            memory_fts_candidates(
7193                &conn,
7194                &["routing".into()],
7195                "routing*",
7196                80,
7197                None,
7198                30.0,
7199                false,
7200            )
7201            .unwrap(),
7202            latest_memory_candidates(&conn, &["routing".into()], 200, None, 30.0, false).unwrap(),
7203        ] {
7204            let ids: Vec<_> = candidates
7205                .iter()
7206                .map(|c| c.capsule.expansion_handle.as_str())
7207                .collect();
7208            assert!(ids.contains(&"memory:live"));
7209            for id in ["memory:future", "memory:expired", "memory:offset"] {
7210                assert!(!ids.contains(&id), "returned {id}");
7211            }
7212        }
7213    }
7214
7215    #[test]
7216    fn hardening_hydration_binds_text_revision_before_later_correction() {
7217        let conn = corpus();
7218        let candidates = memory_fts_candidates(
7219            &conn,
7220            &["routing".into()],
7221            "routing*",
7222            80,
7223            None,
7224            30.0,
7225            false,
7226        )
7227        .unwrap();
7228        let capsules: Vec<_> = candidates.into_iter().map(|c| c.capsule).collect();
7229        assert_eq!(memory_revision_bindings(&capsules)["live"], "baseline:live");
7230        conn.execute("INSERT INTO memory_revisions(memory_id,event_id,text,kind,known_at,effective_at,confidence,use_count,usefulness_score)
7231            VALUES ('live','corrected','changed claim','fact','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z',1,0,0)",[]).unwrap();
7232        conn.execute(
7233            "UPDATE memories SET text='changed claim' WHERE memory_id='live'",
7234            [],
7235        )
7236        .unwrap();
7237        assert_eq!(
7238            crate::projector::claim_revision_at(&conn, "live", None).unwrap(),
7239            "corrected"
7240        );
7241        assert_eq!(memory_revision_bindings(&capsules)["live"], "baseline:live");
7242        assert!(
7243            capsules
7244                .iter()
7245                .find(|c| c.expansion_handle == "memory:live")
7246                .unwrap()
7247                .summary
7248                .contains("routing routing")
7249        );
7250    }
7251
7252    #[cfg(feature = "embeddings")]
7253    #[test]
7254    fn hardening_ann_hydration_filters_time_bounds() {
7255        let conn = corpus();
7256        let blob = crate::embeddings::encode_embedding(&[1.0, 0.0]);
7257        conn.execute(
7258            "UPDATE memories SET embedding=?1,embedding_model='test'",
7259            params![blob],
7260        )
7261        .unwrap();
7262        conn.execute(
7263            "UPDATE memories SET valid_from='2099-01-01T00:00:00Z' WHERE memory_id='future'",
7264            [],
7265        )
7266        .unwrap();
7267        let expired = (OffsetDateTime::now_utc() - time::Duration::seconds(2))
7268            .to_offset(time::UtcOffset::from_hms(12, 0, 0).unwrap())
7269            .format(&time::format_description::well_known::Rfc3339)
7270            .unwrap();
7271        conn.execute(
7272            "UPDATE memories SET valid_to=?1 WHERE memory_id IN ('expired','offset')",
7273            params![expired],
7274        )
7275        .unwrap();
7276        let qe = QueryEmbedding {
7277            vector: vec![1.0, 0.0],
7278            model_id: "test".into(),
7279        };
7280        let out = memory_ann_candidates(&conn, &qe, 80, &["routing".into()], 30.0, false).unwrap();
7281        assert_eq!(out.len(), 2);
7282        for c in out {
7283            assert!(matches!(
7284                c.capsule.expansion_handle.as_str(),
7285                "memory:live" | "memory:other"
7286            ));
7287        }
7288    }
7289
7290    #[test]
7291    fn hardening_idf_counts_prefix_documents_not_occurrences_or_substrings() {
7292        let conn = corpus();
7293        let tokens = vec!["rout".into(), "absent".into()];
7294        let coverage = coverage_token_idf(&conn, &tokens).unwrap();
7295        // Four prefix-matching documents out of five; repeated terms count once.
7296        assert!((coverage["rout"] - (6.0_f32 / 5.0).ln()).abs() < 0.00001);
7297        assert!((coverage["absent"] - 6.0_f32.ln()).abs() < 0.00001);
7298        assert_eq!(corpus_token_idf(&conn, &tokens).unwrap()["absent"], 0.0);
7299    }
7300}
7301
7302#[cfg(test)]
7303mod structured_fact_hydration_tests {
7304    use super::*;
7305    use kimetsu_core::{event::Event, ids::RunId};
7306
7307    #[test]
7308    fn lexical_and_recency_capsules_keep_their_delivered_fact_revision() {
7309        let c = Connection::open_in_memory().unwrap();
7310        crate::schema::initialize(&c).unwrap();
7311        crate::projector::apply_events(&c,&[Event::new(RunId::new(),"memory.accepted",serde_json::json!({
7312            "memory_id":"m","scope":"project","kind":"fact","text":"Orchid staging gateway port is 7319."
7313        }))]).unwrap();
7314        let mut delivered = Vec::new();
7315        for candidates in [
7316            memory_fts_candidates(&c, &["orchid".into()], "orchid*", 80, None, 30.0, true).unwrap(),
7317            latest_memory_candidates(&c, &["orchid".into()], 200, None, 30.0, true).unwrap(),
7318        ] {
7319            let capsule = &candidates[0].capsule;
7320            assert_eq!(capsule.facts.len(), 1);
7321            assert_eq!(capsule.facts[0].claim.value, "7319");
7322            assert_eq!(
7323                capsule.claim_revision.as_deref(),
7324                Some(capsule.facts[0].claim_revision.as_str())
7325            );
7326            delivered.push(capsule.clone());
7327        }
7328        crate::projector::apply_events(
7329            &c,
7330            &[Event::new(
7331                RunId::new(),
7332                "memory.corrected",
7333                serde_json::json!({
7334                    "memory_id":"m","text":"Orchid staging gateway port is 8420."
7335                }),
7336            )],
7337        )
7338        .unwrap();
7339        for capsule in delivered {
7340            assert!(capsule.summary.contains("7319"));
7341            assert_eq!(capsule.facts[0].claim.value, "7319");
7342        }
7343        let latest =
7344            latest_memory_candidates(&c, &["orchid".into()], 200, None, 30.0, true).unwrap();
7345        assert_eq!(latest[0].capsule.facts[0].claim.value, "8420");
7346    }
7347    #[test]
7348    fn legacy_wire_capsules_default_to_empty_fact_evidence() {
7349        let c = ContextCapsule::wire_minimal("hello".into(), "memory".into(), 1.0);
7350        let json = serde_json::to_value(&c).unwrap();
7351        assert!(json.get("facts").is_none());
7352        assert!(
7353            serde_json::from_value::<ContextCapsule>(json)
7354                .unwrap()
7355                .facts
7356                .is_empty()
7357        );
7358    }
7359}
7360
7361#[cfg(test)]
7362mod disabled_fact_hydration_tests {
7363    use super::*;
7364    #[test]
7365    fn ordinary_retrieval_does_not_read_the_fact_projection() {
7366        let c = Connection::open_in_memory().unwrap();
7367        crate::schema::initialize(&c).unwrap();
7368        crate::projector::apply_events(
7369            &c,
7370            &[kimetsu_core::event::Event::new(
7371                kimetsu_core::ids::RunId::new(),
7372                "memory.accepted",
7373                serde_json::json!({"memory_id":"m","text":"Orchid gateway port is 7319."}),
7374            )],
7375        )
7376        .unwrap();
7377        c.execute_batch("DROP TABLE memory_facts").unwrap();
7378        for query in ["Orchid", ""] {
7379            let out =
7380                memory_candidates_flat(&c, query, None, 30.0, crate::fusion::Fusion::Linear, false)
7381                    .unwrap();
7382            assert_eq!(out.len(), 1);
7383            assert!(out[0].capsule.facts.is_empty());
7384        }
7385    }
7386}
7387
7388#[cfg(test)]
7389mod deferred_fact_budget_tests {
7390    use super::*;
7391    #[test]
7392    fn initial_retrieval_budget_must_not_hide_an_eligible_conflicting_fact() {
7393        let c = Connection::open_in_memory().unwrap();
7394        crate::schema::initialize(&c).unwrap();
7395        for (id, value) in [("a", "7319"), ("b", "7320")] {
7396            let text = format!(
7397                "Orchid gateway port is {value}. Stable operation. Recorded settings. {}",
7398                "Operational notes remain available. ".repeat(350)
7399            );
7400            crate::projector::apply_events(
7401                &c,
7402                &[kimetsu_core::event::Event::new(
7403                    kimetsu_core::ids::RunId::new(),
7404                    "memory.accepted",
7405                    serde_json::json!({"memory_id":id,"scope":"project","kind":"fact","text":text}),
7406                )],
7407            )
7408            .unwrap();
7409        }
7410        let query = "What is the Orchid gateway port?";
7411        let policy = crate::serving::ServingPolicy {
7412            budget: 6000,
7413            cap: 1,
7414            explicit_fact_guard: true,
7415            ..Default::default()
7416        };
7417        let request = ContextRequest {
7418            stage: "localization".into(),
7419            query: query.into(),
7420            budget_tokens: 6000,
7421            ..Default::default()
7422        };
7423        let weights = BrokerWeights::default();
7424        let mut ordinary = request.clone();
7425        ordinary.max_capsules = 6;
7426        let ordinary = retrieve_context_with_embedder(
7427            &c,
7428            "/fake-repo",
7429            &weights,
7430            ordinary,
7431            &[],
7432            &crate::embeddings::NoopEmbedder,
7433        )
7434        .unwrap();
7435        assert_eq!(ordinary.capsules.len(), 1);
7436        assert!(ordinary.used_tokens <= 3000);
7437        let selected = retrieve_context_with_embedder(
7438            &c,
7439            "/fake-repo",
7440            &weights,
7441            policy.prepare(request, false),
7442            &[],
7443            &crate::embeddings::NoopEmbedder,
7444        )
7445        .unwrap();
7446        assert_eq!(
7447            selected.capsules.len(),
7448            2,
7449            "both eligible claims must reach arbitration before delivery budgeting"
7450        );
7451        let selected = policy.arbitrate(query, selected, None, 0.0);
7452        let delivered =
7453            policy.render_for_query(query, selected, true, crate::serving::EVAL_EXPOSURE_ID);
7454        assert_eq!(delivered.capsules.len(), 1);
7455        assert_eq!(delivered.payload["answerability"]["status"], "conflicting");
7456        assert!(delivered.payload["used_tokens"].as_u64().unwrap() <= 6000);
7457    }
7458}