Skip to main content

kimetsu_brain/
context.rs

1use std::cmp::Ordering;
2use std::collections::HashMap;
3
4use kimetsu_core::config::{BrokerWeights, StageWeights};
5use kimetsu_core::memory::MemoryScope;
6use kimetsu_core::{KimetsuResult, ids::new_id};
7use rusqlite::{Connection, params};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11use crate::embeddings::{
12    self, DEFAULT_HYBRID_ALPHA, Embedder, cosine_similarity, decode_embedding,
13};
14
15/// v0.4.2: a pre-computed query embedding paired with the producing
16/// model's id. Threaded down into [`memory_candidates`] so each row
17/// can decide whether to contribute a cosine term (only when the
18/// row's `embedding_model` matches the active query's `model_id`).
19#[derive(Debug, Clone)]
20struct QueryEmbedding {
21    vector: Vec<f32>,
22    model_id: String,
23}
24
25impl QueryEmbedding {
26    fn from_embedder(embedder: &dyn Embedder, query: &str) -> Option<Self> {
27        if embedder.is_noop() {
28            return None;
29        }
30        match embedder.embed(query) {
31            Ok(v) if v.len() == embedder.dim() => Some(Self {
32                vector: v,
33                model_id: embedder.model_id().to_string(),
34            }),
35            // NotImplemented / dim-mismatch / load failure → silently
36            // skip the cosine blend. v0.4.2 surfaces no warning here
37            // by design — the broker stays usable on best-effort
38            // semantic retrieval.
39            _ => None,
40        }
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ContextCapsule {
46    pub id: String,
47    pub kind: String,
48    pub summary: String,
49    pub token_estimate: u32,
50    pub expansion_handle: String,
51    pub provenance: Vec<ProvenanceRef>,
52    pub confidence: f32,
53    pub freshness: f32,
54    pub relevance: f32,
55    pub scope_weight: f32,
56    pub score: f32,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ProvenanceRef {
61    pub source: String,
62    pub id: String,
63    pub excerpt: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub struct ContextRequest {
68    pub stage: String,
69    pub query: String,
70    pub budget_tokens: u32,
71}
72
73#[derive(Debug, Clone)]
74pub struct ContextBundle {
75    pub stage: String,
76    pub budget_tokens: u32,
77    pub used_tokens: u32,
78    pub capsules: Vec<ContextCapsule>,
79    pub excluded: Vec<ContextCapsule>,
80}
81
82#[derive(Debug, Clone)]
83struct Candidate {
84    capsule: ContextCapsule,
85    raw_relevance: f32,
86}
87
88pub fn retrieve_context(
89    conn: &Connection,
90    repo_root: &str,
91    weights: &BrokerWeights,
92    request: ContextRequest,
93) -> KimetsuResult<ContextBundle> {
94    retrieve_context_multi(conn, repo_root, weights, request, &[])
95}
96
97/// v0.4.1: multi-conn variant. `extra_memory_conns` is searched for
98/// memory candidates only (repo files + manifests stay project-local).
99/// The candidate stream is concatenated BEFORE normalization so the
100/// blended set is normalized together — keeping a user-brain capsule
101/// and a project-brain capsule comparable on the same `raw_relevance`
102/// scale.
103///
104/// Today `extra_memory_conns` carries at most one entry (the user
105/// brain at `~/.kimetsu/brain.db`); the slice shape leaves room for
106/// future scope tiers (team brain, org brain) without breaking the
107/// signature.
108///
109/// v0.4.2: uses [`embeddings::open_default_embedder`] for the cosine
110/// term. Pre-v0.4.3 the default is `NoopEmbedder`, which short-
111/// circuits the cosine path so retrieval stays FTS-only — exact
112/// v0.4.1 behavior. v0.4.3 swaps the default to a real embedder.
113pub fn retrieve_context_multi(
114    conn: &Connection,
115    repo_root: &str,
116    weights: &BrokerWeights,
117    request: ContextRequest,
118    extra_memory_conns: &[&Connection],
119) -> KimetsuResult<ContextBundle> {
120    let embedder = embeddings::open_default_embedder();
121    retrieve_context_with_embedder(
122        conn,
123        repo_root,
124        weights,
125        request,
126        extra_memory_conns,
127        embedder,
128    )
129}
130
131/// v0.4.2: explicit-embedder variant. Lets tests inject `StubEmbedder`
132/// or any other [`Embedder`] without going through
133/// [`embeddings::open_default_embedder`]. v0.4.3 callers (chat REPL,
134/// MCP server) can also use this directly to hold one embedder
135/// instance for the lifetime of a session instead of paying the
136/// model-load cost on every retrieval.
137pub fn retrieve_context_with_embedder(
138    conn: &Connection,
139    repo_root: &str,
140    weights: &BrokerWeights,
141    request: ContextRequest,
142    extra_memory_conns: &[&Connection],
143    embedder: &dyn Embedder,
144) -> KimetsuResult<ContextBundle> {
145    let query_embedding = QueryEmbedding::from_embedder(embedder, &request.query);
146    let half_life_days = weights.decay_half_life_days;
147    let mut candidates = Vec::new();
148    candidates.extend(memory_candidates(
149        conn,
150        &request.query,
151        query_embedding.as_ref(),
152        half_life_days,
153    )?);
154    for extra in extra_memory_conns {
155        candidates.extend(memory_candidates(
156            extra,
157            &request.query,
158            query_embedding.as_ref(),
159            half_life_days,
160        )?);
161    }
162    candidates.extend(repo_file_candidates(conn, repo_root, &request.query, 30)?);
163    candidates.extend(manifest_candidates(conn, repo_root, &request.query)?);
164
165    normalize_and_score(&mut candidates, weights_for_stage(weights, &request.stage));
166
167    let mut capsules = candidates
168        .into_iter()
169        .map(|candidate| candidate.capsule)
170        .collect::<Vec<_>>();
171
172    capsules.sort_by(|left, right| {
173        right
174            .score
175            .partial_cmp(&left.score)
176            .unwrap_or(Ordering::Equal)
177            .then_with(|| {
178                right
179                    .freshness
180                    .partial_cmp(&left.freshness)
181                    .unwrap_or(Ordering::Equal)
182            })
183            .then_with(|| left.id.cmp(&right.id))
184    });
185
186    // MP-17 #13: Maximal-Marginal-Relevance (MMR) re-ranking — when two
187    // capsules look very similar (same tokens in the summary), keep the
188    // higher-scoring one but push the redundant ones down so the budget
189    // covers more distinct ground. Lambda=0.7 keeps the original ordering
190    // strongly while penalizing >0.5-Jaccard overlaps.
191    let capsules = apply_mmr_diversity(capsules, 0.7);
192
193    let capsule_budget = request.budget_tokens / 2;
194    let mut used_tokens = 0u32;
195    let mut included = Vec::new();
196    let mut excluded = Vec::new();
197
198    for capsule in capsules {
199        if used_tokens.saturating_add(capsule.token_estimate) <= capsule_budget {
200            used_tokens += capsule.token_estimate;
201            included.push(capsule);
202        } else {
203            excluded.push(capsule);
204        }
205    }
206
207    Ok(ContextBundle {
208        stage: request.stage,
209        budget_tokens: request.budget_tokens,
210        used_tokens,
211        capsules: included,
212        excluded,
213    })
214}
215
216pub fn search_repo_files(
217    conn: &Connection,
218    repo_root: &str,
219    query: &str,
220    limit: u32,
221) -> KimetsuResult<Vec<ContextCapsule>> {
222    let candidates = repo_file_candidates(conn, repo_root, query, limit)?;
223    let mut capsules = candidates
224        .into_iter()
225        .map(|mut candidate| {
226            candidate.capsule.relevance = candidate.raw_relevance;
227            candidate.capsule.score = candidate.raw_relevance;
228            candidate.capsule
229        })
230        .collect::<Vec<_>>();
231    capsules.sort_by(|left, right| {
232        right
233            .score
234            .partial_cmp(&left.score)
235            .unwrap_or(Ordering::Equal)
236            .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
237    });
238    Ok(capsules)
239}
240
241fn memory_candidates(
242    conn: &Connection,
243    query: &str,
244    query_embedding: Option<&QueryEmbedding>,
245    half_life_days: f32,
246) -> KimetsuResult<Vec<Candidate>> {
247    let query_tokens = query_tokens(query);
248    if let Some(fts_query) = fts_query(query) {
249        let candidates = memory_fts_candidates(
250            conn,
251            &query_tokens,
252            &fts_query,
253            80,
254            query_embedding,
255            half_life_days,
256        )?;
257        if !candidates.is_empty() {
258            return Ok(candidates);
259        }
260    }
261
262    latest_memory_candidates(conn, &query_tokens, 200, query_embedding, half_life_days)
263}
264
265fn latest_memory_candidates(
266    conn: &Connection,
267    query_tokens: &[String],
268    limit: u32,
269    query_embedding: Option<&QueryEmbedding>,
270    half_life_days: f32,
271) -> KimetsuResult<Vec<Candidate>> {
272    // MP-4d: exclude invalidated memories from retrieval. The row stays in
273    // brain.db so `memory list` and replay can still see the history; only
274    // the broker filters it out.
275    //
276    // v0.4.2: SELECT now also pulls the optional embedding + model id
277    // so we can blend a cosine score with the lexical match.
278    //
279    // v0.5.1: SELECT also pulls `last_useful_at` so the broker can
280    // apply the half-life decay term (memories that helped recently
281    // outvote memories that haven't been confirmed useful in months).
282    let mut stmt = conn.prepare_cached(
283        "
284        SELECT memory_id, scope, kind, text, confidence, created_at,
285               use_count, usefulness_score, embedding, embedding_model,
286               last_useful_at
287        FROM memories
288        WHERE invalidated_at IS NULL
289        ORDER BY created_at DESC
290        LIMIT ?1
291        ",
292    )?;
293
294    let rows = stmt.query_map(params![limit], |row| {
295        Ok((
296            row.get::<_, String>(0)?,
297            row.get::<_, String>(1)?,
298            row.get::<_, String>(2)?,
299            row.get::<_, String>(3)?,
300            row.get::<_, f32>(4)?,
301            row.get::<_, String>(5)?,
302            row.get::<_, i64>(6)?,
303            row.get::<_, f64>(7)?,
304            row.get::<_, Option<Vec<u8>>>(8)?,
305            row.get::<_, Option<String>>(9)?,
306            row.get::<_, Option<String>>(10)?,
307        ))
308    })?;
309
310    let mut candidates = Vec::new();
311    for row in rows {
312        let (
313            memory_id,
314            scope,
315            kind,
316            text,
317            confidence,
318            created_at,
319            use_count,
320            usefulness_score,
321            embedding,
322            embedding_model,
323            last_useful_at,
324        ) = row?;
325        let cosine = compute_cosine(query_embedding, embedding.as_deref(), embedding_model.as_deref());
326        if let Some(candidate) = memory_row_to_candidate(
327            query_tokens,
328            memory_id,
329            scope,
330            kind,
331            text,
332            confidence,
333            created_at,
334            use_count,
335            usefulness_score,
336            last_useful_at,
337            half_life_days,
338            None,
339            cosine,
340        ) {
341            candidates.push(candidate);
342        }
343    }
344    Ok(candidates)
345}
346
347fn memory_fts_candidates(
348    conn: &Connection,
349    query_tokens: &[String],
350    fts_query: &str,
351    limit: u32,
352    query_embedding: Option<&QueryEmbedding>,
353    half_life_days: f32,
354) -> KimetsuResult<Vec<Candidate>> {
355    let mut stmt = conn.prepare_cached(
356        "
357        SELECT m.memory_id, m.scope, m.kind, m.text, m.confidence, m.created_at,
358               m.use_count, m.usefulness_score, bm25(memories_fts) AS rank,
359               m.embedding, m.embedding_model, m.last_useful_at
360        FROM memories_fts
361        JOIN memories m
362          ON m.memory_id = memories_fts.memory_id
363        WHERE m.invalidated_at IS NULL
364          AND memories_fts MATCH ?1
365        ORDER BY rank
366        LIMIT ?2
367        ",
368    )?;
369
370    let rows = stmt.query_map(params![fts_query, limit], |row| {
371        Ok((
372            row.get::<_, String>(0)?,
373            row.get::<_, String>(1)?,
374            row.get::<_, String>(2)?,
375            row.get::<_, String>(3)?,
376            row.get::<_, f32>(4)?,
377            row.get::<_, String>(5)?,
378            row.get::<_, i64>(6)?,
379            row.get::<_, f64>(7)?,
380            row.get::<_, f64>(8)?,
381            row.get::<_, Option<Vec<u8>>>(9)?,
382            row.get::<_, Option<String>>(10)?,
383            row.get::<_, Option<String>>(11)?,
384        ))
385    })?;
386
387    let mut candidates = Vec::new();
388    for row in rows {
389        let (
390            memory_id,
391            scope,
392            kind,
393            text,
394            confidence,
395            created_at,
396            use_count,
397            usefulness_score,
398            rank,
399            embedding,
400            embedding_model,
401            last_useful_at,
402        ) = row?;
403        let fts_relevance = (-rank as f32).max(0.0);
404        let cosine = compute_cosine(query_embedding, embedding.as_deref(), embedding_model.as_deref());
405        if let Some(candidate) = memory_row_to_candidate(
406            query_tokens,
407            memory_id,
408            scope,
409            kind,
410            text,
411            confidence,
412            created_at,
413            use_count,
414            usefulness_score,
415            last_useful_at,
416            half_life_days,
417            Some(fts_relevance),
418            cosine,
419        ) {
420            candidates.push(candidate);
421        }
422    }
423    Ok(candidates)
424}
425
426/// v0.4.2: cosine helper used by both the FTS and latest-memory
427/// retrieval branches. Returns `Some(score in [-1, 1])` when a
428/// non-null embedding is present AND its `embedding_model` matches
429/// the active `query_embedding`'s model id. Otherwise None — the
430/// caller treats None as "lexical only".
431///
432/// Cross-model rows are intentionally NOT blended: a row embedded
433/// with `stub-d8` and a query embedded with `bge-small-en-v1.5`
434/// produce meaningless dot products. Falling back to FTS for those
435/// rows keeps hybrid retrieval safe across schema upgrades and
436/// `kimetsu brain reindex` migrations (v0.4.3).
437fn compute_cosine(
438    query_embedding: Option<&QueryEmbedding>,
439    row_bytes: Option<&[u8]>,
440    row_model: Option<&str>,
441) -> Option<f32> {
442    let q = query_embedding?;
443    let bytes = row_bytes?;
444    let model = row_model?;
445    if model != q.model_id {
446        return None;
447    }
448    let row_vec = match decode_embedding(bytes, Some(q.vector.len())) {
449        Ok(v) => v,
450        Err(_) => return None,
451    };
452    Some(cosine_similarity(&q.vector, &row_vec))
453}
454
455#[allow(clippy::too_many_arguments)]
456fn memory_row_to_candidate(
457    query_tokens: &[String],
458    memory_id: String,
459    scope: String,
460    kind: String,
461    text: String,
462    confidence: f32,
463    created_at: String,
464    use_count: i64,
465    usefulness_score: f64,
466    last_useful_at: Option<String>,
467    half_life_days: f32,
468    raw_relevance_override: Option<f32>,
469    cosine_score: Option<f32>,
470) -> Option<Candidate> {
471    let lexical = lexical_relevance(query_tokens, &format!("{kind} {text}"));
472    let lexical_term = raw_relevance_override.unwrap_or(lexical).max(lexical);
473
474    // v0.4.2: hybrid blend.
475    //   final = (1 - α) * lexical + α * normalized_cosine
476    // where normalized_cosine maps [-1, 1] -> [0, 1] so it composes
477    // with the lexical relevance scale.
478    //
479    // When cosine_score is None (NoopEmbedder, NULL row embedding,
480    // cross-model mismatch), the cosine term drops out and the
481    // candidate scores lexical-only — exact v0.4.1 behavior. The
482    // caller's gate `raw_relevance <= 0.0 && !query_tokens.is_empty()`
483    // still works because in the no-cosine path `raw_relevance ==
484    // lexical_term`.
485    let raw_relevance = match cosine_score {
486        Some(c) => {
487            let normalized_cos = ((c + 1.0) * 0.5).clamp(0.0, 1.0);
488            (1.0 - DEFAULT_HYBRID_ALPHA) * lexical_term + DEFAULT_HYBRID_ALPHA * normalized_cos
489        }
490        None => lexical_term,
491    };
492
493    // Drop the row when neither lexical nor cosine had any signal —
494    // an empty query OR a candidate that didn't match any of the
495    // search terms. The cosine-only path is still allowed through
496    // (raw_relevance > 0) for semantic-only matches against rows
497    // whose words don't textually overlap the query.
498    if raw_relevance <= 0.0 && !query_tokens.is_empty() {
499        return None;
500    }
501
502    let freshness = freshness(&created_at);
503    let scope_weight = scope_weight(&scope);
504    // v0.5.1: usefulness multiplier with half-life decay applied to
505    // the *deviation from neutral*. A 6-month-old memory that scored
506    // 1.5 (max boost) decays toward 1.0 (neutral) — NOT toward 0,
507    // because losing confidence in old signal shouldn't penalize a
508    // memory below a brand-new memory with zero history.
509    let raw_multiplier = usefulness_multiplier(usefulness_score as f32, use_count as u32);
510    let decay = usefulness_decay(last_useful_at.as_deref(), &created_at, half_life_days);
511    let multiplier = 1.0 + (raw_multiplier - 1.0) * decay;
512    let biased_relevance = raw_relevance * multiplier;
513    Some(Candidate {
514        raw_relevance: biased_relevance,
515        capsule: ContextCapsule {
516            id: new_id().to_string(),
517            kind: "memory".to_string(),
518            summary: format!("{scope}:{kind} - {text}"),
519            token_estimate: estimate_tokens(&text) + 8,
520            expansion_handle: format!("memory:{memory_id}"),
521            provenance: vec![ProvenanceRef {
522                source: "Memory".to_string(),
523                id: memory_id,
524                excerpt: Some(excerpt(&text)),
525            }],
526            confidence,
527            freshness,
528            relevance: 0.0,
529            scope_weight,
530            score: 0.0,
531        },
532    })
533}
534
535/// v0.5.1: half-life decay factor applied to the *deviation from
536/// neutral* of [`usefulness_multiplier`]. Returns a value in `[0.0,
537/// 1.0]` where 1.0 = "use full envelope" (memory was confirmed useful
538/// recently) and 0.0 = "treat as neutral" (memory's confirmation is
539/// ancient).
540///
541/// Reference timestamp:
542///   * `last_useful_at` (set by the projector when a cited memory's
543///     run ended in run.finished) if present
544///   * fallback to `created_at` so a brand-new memory that's never
545///     been cited yet decays from its birthday — same shape, but
546///     starts fresh.
547///
548/// Math:
549///   decay = exp(-ln(2) * age_days / half_life_days)
550/// so at age == half_life the contribution is halved, at 2*half_life
551/// it's quartered, etc.
552///
553/// Safety rails:
554///   * `half_life_days <= 0` disables decay (returns 1.0) so an
555///     operator can opt out via project.toml.
556///   * Unparseable RFC3339 timestamps return 1.0 — fail-open so a
557///     corrupted row doesn't get silently demoted out of retrieval.
558pub(crate) fn usefulness_decay(
559    last_useful_at: Option<&str>,
560    created_at: &str,
561    half_life_days: f32,
562) -> f32 {
563    if half_life_days <= 0.0 {
564        return 1.0;
565    }
566    let reference = last_useful_at.unwrap_or(created_at);
567    let Ok(reference_ts) =
568        OffsetDateTime::parse(reference, &time::format_description::well_known::Rfc3339)
569    else {
570        return 1.0;
571    };
572    let age = OffsetDateTime::now_utc() - reference_ts;
573    let age_days = (age.whole_seconds().max(0) as f32) / 86_400.0;
574    let exponent = -std::f32::consts::LN_2 * age_days / half_life_days;
575    exponent.exp().clamp(0.0, 1.0)
576}
577
578/// MP-4b multiplier in [0.5, 1.5] derived from a memory's outcome history.
579/// `use_count < 3` is treated as small-sample and yields 1.0 (neutral) so a
580/// brand-new memory has a fair chance to demonstrate value before being
581/// boosted or penalized.
582pub(crate) fn usefulness_multiplier(usefulness_score: f32, use_count: u32) -> f32 {
583    // MP-17e: soften the hard sample-size threshold via Bayesian smoothing.
584    //
585    // Old behaviour: hard cutoff at use_count < 3 returned neutral 1.0,
586    // then full envelope kicked in. That meant a memory with 2 uses (both
587    // helpful) was treated identically to a memory with 0 uses, which
588    // wasted early signal. New behaviour: linearly blend toward the
589    // full multiplier as use_count climbs to FULL_CONFIDENCE_USES.
590    const FULL_CONFIDENCE_USES: u32 = 3;
591    const MULTIPLIER_MIN: f32 = 0.5;
592    const MULTIPLIER_MAX: f32 = 1.5;
593    if use_count == 0 {
594        return 1.0;
595    }
596    let ratio = usefulness_score / use_count as f32; // in -1.0..1.0 typically
597    let normalized = ((ratio + 1.0) / 2.0).clamp(0.0, 1.0); // map to 0..1
598    let full_multiplier = MULTIPLIER_MIN + normalized * (MULTIPLIER_MAX - MULTIPLIER_MIN);
599    let confidence = (use_count as f32 / FULL_CONFIDENCE_USES as f32).min(1.0);
600    1.0 * (1.0 - confidence) + full_multiplier * confidence
601}
602
603fn repo_file_candidates(
604    conn: &Connection,
605    repo_root: &str,
606    query: &str,
607    limit: u32,
608) -> KimetsuResult<Vec<Candidate>> {
609    let Some(fts_query) = fts_query(query) else {
610        return Ok(Vec::new());
611    };
612
613    let mut stmt = conn.prepare_cached(
614        "
615        SELECT path, snippet, language_guess, bm25(repo_files_fts) AS rank
616        FROM repo_files_fts
617        WHERE repo_root = ?1 AND repo_files_fts MATCH ?2
618        ORDER BY rank
619        LIMIT ?3
620        ",
621    )?;
622
623    let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
624        Ok((
625            row.get::<_, String>(0)?,
626            row.get::<_, String>(1)?,
627            row.get::<_, String>(2)?,
628            row.get::<_, f64>(3)?,
629        ))
630    })?;
631
632    let mut candidates = Vec::new();
633    for row in rows {
634        let (path, snippet, language, rank) = row?;
635        let raw_relevance = (-rank as f32).max(0.0);
636        let summary = format!("{path} ({language}) - {}", excerpt(&snippet));
637        let token_estimate = estimate_tokens(&summary) + 8;
638        candidates.push(Candidate {
639            raw_relevance,
640            capsule: ContextCapsule {
641                id: new_id().to_string(),
642                kind: "repo_file".to_string(),
643                summary,
644                token_estimate,
645                expansion_handle: format!("file:{path}"),
646                provenance: vec![ProvenanceRef {
647                    source: "RepoFile".to_string(),
648                    id: path.clone(),
649                    excerpt: Some(excerpt(&snippet)),
650                }],
651                confidence: 0.9,
652                freshness: 1.0,
653                relevance: 0.0,
654                scope_weight: 0.9,
655                score: 0.0,
656            },
657        });
658    }
659    Ok(candidates)
660}
661
662fn manifest_candidates(
663    conn: &Connection,
664    repo_root: &str,
665    query: &str,
666) -> KimetsuResult<Vec<Candidate>> {
667    if let Some(fts_query) = fts_query(query) {
668        let candidates = manifest_fts_candidates(conn, repo_root, &fts_query, 30)?;
669        if !candidates.is_empty() {
670            return Ok(candidates);
671        }
672    }
673
674    let query_tokens = query_tokens(query);
675    let mut stmt = conn.prepare_cached(
676        "
677        SELECT manifest_path, manifest_kind, parsed_summary_json
678        FROM repo_manifests
679        WHERE repo_root = ?1
680        ORDER BY manifest_path
681        ",
682    )?;
683
684    let rows = stmt.query_map(params![repo_root], |row| {
685        Ok((
686            row.get::<_, String>(0)?,
687            row.get::<_, String>(1)?,
688            row.get::<_, String>(2)?,
689        ))
690    })?;
691
692    let mut candidates = Vec::new();
693    for row in rows {
694        let (path, kind, summary_json) = row?;
695        let raw_relevance =
696            lexical_relevance(&query_tokens, &format!("{path} {kind} {summary_json}"));
697        if raw_relevance <= 0.0 && !query_tokens.is_empty() {
698            continue;
699        }
700        let summary = format!("{path} manifest ({kind})");
701        let token_estimate = estimate_tokens(&summary) + 8;
702        candidates.push(Candidate {
703            raw_relevance,
704            capsule: ContextCapsule {
705                id: new_id().to_string(),
706                kind: "repo_manifest".to_string(),
707                summary,
708                token_estimate,
709                expansion_handle: format!("file:{path}"),
710                provenance: vec![ProvenanceRef {
711                    source: "Manifest".to_string(),
712                    id: path,
713                    excerpt: Some(excerpt(&summary_json)),
714                }],
715                confidence: 0.95,
716                freshness: 1.0,
717                relevance: 0.0,
718                scope_weight: 0.9,
719                score: 0.0,
720            },
721        });
722    }
723    Ok(candidates)
724}
725
726fn manifest_fts_candidates(
727    conn: &Connection,
728    repo_root: &str,
729    fts_query: &str,
730    limit: u32,
731) -> KimetsuResult<Vec<Candidate>> {
732    let mut stmt = conn.prepare_cached(
733        "
734        SELECT manifest_path, manifest_kind, parsed_summary_json,
735               bm25(repo_manifests_fts) AS rank
736        FROM repo_manifests_fts
737        WHERE repo_root = ?1 AND repo_manifests_fts MATCH ?2
738        ORDER BY rank
739        LIMIT ?3
740        ",
741    )?;
742
743    let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
744        Ok((
745            row.get::<_, String>(0)?,
746            row.get::<_, String>(1)?,
747            row.get::<_, String>(2)?,
748            row.get::<_, f64>(3)?,
749        ))
750    })?;
751
752    let mut candidates = Vec::new();
753    for row in rows {
754        let (path, kind, summary_json, rank) = row?;
755        let raw_relevance = (-rank as f32).max(0.0);
756        let summary = format!("{path} manifest ({kind})");
757        let token_estimate = estimate_tokens(&summary) + 8;
758        candidates.push(Candidate {
759            raw_relevance,
760            capsule: ContextCapsule {
761                id: new_id().to_string(),
762                kind: "repo_manifest".to_string(),
763                summary,
764                token_estimate,
765                expansion_handle: format!("file:{path}"),
766                provenance: vec![ProvenanceRef {
767                    source: "Manifest".to_string(),
768                    id: path,
769                    excerpt: Some(excerpt(&summary_json)),
770                }],
771                confidence: 0.95,
772                freshness: 1.0,
773                relevance: 0.0,
774                scope_weight: 0.9,
775                score: 0.0,
776            },
777        });
778    }
779    Ok(candidates)
780}
781
782fn normalize_and_score(candidates: &mut [Candidate], weights: StageWeights) {
783    let mut max_by_kind = HashMap::<String, f32>::new();
784    for candidate in candidates.iter() {
785        max_by_kind
786            .entry(candidate.capsule.kind.clone())
787            .and_modify(|max| *max = (*max).max(candidate.raw_relevance))
788            .or_insert(candidate.raw_relevance);
789    }
790
791    for candidate in candidates {
792        let max = max_by_kind
793            .get(&candidate.capsule.kind)
794            .copied()
795            .unwrap_or(0.0);
796        let relevance = if max <= f32::EPSILON {
797            if candidate.raw_relevance > 0.0 {
798                1.0
799            } else {
800                0.0
801            }
802        } else {
803            (candidate.raw_relevance / max).clamp(0.0, 1.0)
804        };
805        candidate.capsule.relevance = relevance;
806        candidate.capsule.score = weights.relevance * relevance
807            + weights.confidence * candidate.capsule.confidence
808            + weights.freshness * candidate.capsule.freshness
809            + weights.scope * candidate.capsule.scope_weight;
810    }
811}
812
813fn weights_for_stage(weights: &BrokerWeights, stage: &str) -> StageWeights {
814    match stage {
815        "localization" => weights.localization.clone(),
816        "patch_plan" => weights.patch_plan.clone(),
817        "verification" => weights.verification.clone(),
818        "review" => weights.review.clone(),
819        _ => None,
820    }
821    .unwrap_or(StageWeights {
822        relevance: weights.relevance,
823        confidence: weights.confidence,
824        freshness: weights.freshness,
825        scope: weights.scope,
826    })
827}
828
829fn scope_weight(scope: &str) -> f32 {
830    match scope.parse::<MemoryScope>() {
831        Ok(MemoryScope::Run) => 1.0,
832        Ok(MemoryScope::Repo) => 0.9,
833        Ok(MemoryScope::Project) => 0.7,
834        Ok(MemoryScope::GlobalUser) => 0.5,
835        Err(_) => 0.3,
836    }
837}
838
839fn freshness(created_at: &str) -> f32 {
840    let Ok(created_at) =
841        OffsetDateTime::parse(created_at, &time::format_description::well_known::Rfc3339)
842    else {
843        return 0.5;
844    };
845    let age = OffsetDateTime::now_utc() - created_at;
846    let age_days = age.whole_seconds().max(0) as f32 / 86_400.0;
847    (-age_days / 30.0).exp().clamp(0.0, 1.0)
848}
849
850fn query_tokens(query: &str) -> Vec<String> {
851    let mut tokens: Vec<String> = query
852        .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
853        .map(str::trim)
854        .filter(|part| part.len() >= 2)
855        .map(str::to_ascii_lowercase)
856        .collect();
857    // MP-17 #11: task-class routing — augment the query with tool-aware
858    // tokens so MP-17b's tool-proficiency capsules surface higher when
859    // the task description matches a known class. Cheap keyword fan-out;
860    // the underlying lexical_relevance counts substring matches so the
861    // augmented tokens only matter when a capsule's text actually mentions
862    // them (i.e. the new MP-17b capsules light up, not generic text).
863    let lower = query.to_ascii_lowercase();
864    for (triggers, expansions) in CLASS_HINTS.iter() {
865        if triggers.iter().any(|t| lower.contains(t)) {
866            tokens.extend(expansions.iter().map(|e| e.to_string()));
867        }
868    }
869    tokens
870}
871
872// MP-17 #11: (trigger keywords, expansion tokens) pairs.
873//
874// When the user task mentions a trigger, we add the expansions to the
875// query token set. Capsules whose text mentions the same expansions
876// then score higher on lexical_relevance. The expansions are kimetsu
877// tool / concept names so MP-17b capsules (which document those tools)
878// surface preferentially.
879const CLASS_HINTS: &[(&[&str], &[&str])] = &[
880    (
881        &[
882            "build",
883            "compile",
884            "make",
885            "cargo",
886            "cmake",
887            "configure",
888            "install",
889            "train",
890            "benchmark",
891            "test suite",
892            "ray trace",
893            "render",
894        ],
895        &[
896            "shell_background",
897            "shell_status",
898            "shell_output",
899            "shell_stop",
900            "long_running",
901        ],
902    ),
903    (
904        &[
905            "edit", "modify", "change", "fix", "update", "patch", "refactor", "rename",
906        ],
907        &["edit_file", "apply_patch", "old_string", "new_string"],
908    ),
909    (
910        &[
911            "read", "inspect", "review", "analyze", "examine", "view", "show",
912        ],
913        &["read_file", "offset", "limit", "multi_read"],
914    ),
915    (
916        &["find", "locate", "search", "look up", "discover", "list"],
917        &["glob", "search_files", "list_files"],
918    ),
919    (
920        &["plan", "step", "checklist", "todo", "task list", "phase"],
921        &["plan", "todos"],
922    ),
923    (
924        &[
925            "verify",
926            "check",
927            "ensure",
928            "validate",
929            "pass test",
930            "verifier",
931        ],
932        &["finish", "verifier", "verification"],
933    ),
934    (
935        &[
936            "image",
937            "png",
938            "jpeg",
939            "jpg",
940            "pdf",
941            "diagram",
942            "screenshot",
943        ],
944        &["view_image", "base64", "sha256"],
945    ),
946    (&["delete", "remove", "rm "], &["delete_file", "recursive"]),
947    (&["rename", "move file", "mv "], &["move_file"]),
948];
949
950fn fts_query(query: &str) -> Option<String> {
951    let tokens = query_tokens(query);
952    if tokens.is_empty() {
953        return None;
954    }
955    Some(
956        tokens
957            .into_iter()
958            .take(12)
959            .map(|token| format!("{token}*"))
960            .collect::<Vec<_>>()
961            .join(" OR "),
962    )
963}
964
965/// MP-17 #13: greedy MMR (Maximal Marginal Relevance) re-ranking.
966///
967/// Given capsules already sorted by relevance score, walk the list and
968/// at each step pick the next capsule that maximizes
969/// `lambda * score - (1 - lambda) * max_overlap_with_already_picked`.
970///
971/// Overlap = Jaccard similarity of the lowercased token sets of the
972/// `summary` field. Capsules from different kinds (memory / repo_file /
973/// manifest) get a 0.5 similarity floor so redundancy is only penalized
974/// within-kind (a memory and a repo_file aren't really redundant even
975/// if they share words).
976fn apply_mmr_diversity(mut sorted: Vec<ContextCapsule>, lambda: f32) -> Vec<ContextCapsule> {
977    if sorted.len() <= 1 {
978        return sorted;
979    }
980    // Pre-tokenize summaries for cheap Jaccard.
981    let summaries: Vec<std::collections::HashSet<String>> = sorted
982        .iter()
983        .map(|c| summary_token_set(&c.summary))
984        .collect();
985    let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
986    let mut remaining: Vec<usize> = (0..sorted.len()).collect();
987
988    // Always seed with the top-scoring capsule.
989    picked_indices.push(remaining.remove(0));
990
991    while !remaining.is_empty() {
992        let mut best_idx_in_remaining = 0;
993        let mut best_score = f32::MIN;
994        for (i, &cand) in remaining.iter().enumerate() {
995            let mut max_overlap = 0.0f32;
996            for &p in &picked_indices {
997                let raw = jaccard(&summaries[cand], &summaries[p]);
998                let overlap = if sorted[cand].kind == sorted[p].kind {
999                    raw
1000                } else {
1001                    // cross-kind: scale down so we don't over-penalize a memory
1002                    // that happens to share words with a repo file.
1003                    raw * 0.5
1004                };
1005                if overlap > max_overlap {
1006                    max_overlap = overlap;
1007                }
1008            }
1009            let mmr = lambda * sorted[cand].score - (1.0 - lambda) * max_overlap;
1010            if mmr > best_score {
1011                best_score = mmr;
1012                best_idx_in_remaining = i;
1013            }
1014        }
1015        picked_indices.push(remaining.remove(best_idx_in_remaining));
1016    }
1017    // Reorder `sorted` to match picked_indices.
1018    let mut out = Vec::with_capacity(sorted.len());
1019    // We need to drain in picked_indices order; do it by taking with mem::replace.
1020    let mut taken: Vec<Option<ContextCapsule>> = sorted.drain(..).map(Some).collect();
1021    for idx in picked_indices {
1022        if let Some(c) = taken[idx].take() {
1023            out.push(c);
1024        }
1025    }
1026    out
1027}
1028
1029fn summary_token_set(s: &str) -> std::collections::HashSet<String> {
1030    s.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
1031        .filter(|t| t.len() >= 3)
1032        .map(str::to_ascii_lowercase)
1033        .collect()
1034}
1035
1036fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f32 {
1037    if a.is_empty() && b.is_empty() {
1038        return 0.0;
1039    }
1040    let intersection = a.intersection(b).count();
1041    let union = a.union(b).count();
1042    intersection as f32 / union.max(1) as f32
1043}
1044
1045fn lexical_relevance(tokens: &[String], haystack: &str) -> f32 {
1046    if tokens.is_empty() {
1047        return 0.0;
1048    }
1049    let haystack = haystack.to_ascii_lowercase();
1050    let matches = tokens
1051        .iter()
1052        .filter(|token| haystack.contains(token.as_str()))
1053        .count();
1054    matches as f32 / tokens.len() as f32
1055}
1056
1057fn estimate_tokens(text: &str) -> u32 {
1058    ((text.split_whitespace().count() as f32) * 1.33).ceil() as u32
1059}
1060
1061fn excerpt(text: &str) -> String {
1062    let value = one_line(text);
1063    value.chars().take(256).collect()
1064}
1065
1066fn one_line(text: &str) -> String {
1067    text.split_whitespace().collect::<Vec<_>>().join(" ")
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072    use super::*;
1073
1074    /// MP-17e: zero-use rows are neutral (no data); use_count >= 1 starts
1075    /// blending toward the full multiplier (Bayesian smoothing).
1076    #[test]
1077    fn usefulness_multiplier_neutral_at_zero_uses() {
1078        // use_count = 0 is the only strictly-neutral case.
1079        assert!((usefulness_multiplier(0.0, 0) - 1.0).abs() < f32::EPSILON);
1080        assert!((usefulness_multiplier(5.0, 0) - 1.0).abs() < f32::EPSILON);
1081        assert!((usefulness_multiplier(-5.0, 0) - 1.0).abs() < f32::EPSILON);
1082    }
1083
1084    /// MP-17e: between use_count 1..3 the multiplier blends linearly from
1085    /// neutral (1.0) toward the full envelope. A use_count of 2 with a
1086    /// perfect ratio lands at 2/3 of the way to the max boost.
1087    #[test]
1088    fn usefulness_multiplier_blends_smoothly_in_transition() {
1089        // use_count = 1, ratio = 1.0 -> confidence 1/3, blend toward 1.5
1090        // expected = 1.0 * 2/3 + 1.5 * 1/3 = 1.1667
1091        let one_use = usefulness_multiplier(1.0, 1);
1092        assert!((one_use - 1.166_666_6).abs() < 1e-4, "got {one_use}");
1093        // use_count = 2, ratio = 1.0 -> confidence 2/3, blend toward 1.5
1094        // expected = 1.0 * 1/3 + 1.5 * 2/3 = 1.3333
1095        let two_uses = usefulness_multiplier(2.0, 2);
1096        assert!((two_uses - 1.333_333_4).abs() < 1e-4, "got {two_uses}");
1097        // use_count = 2 with ratio = -1.0 should pull toward the penalty side.
1098        let two_uses_bad = usefulness_multiplier(-2.0, 2);
1099        // expected = 1.0 * 1/3 + 0.5 * 2/3 = 0.6667
1100        assert!(
1101            (two_uses_bad - 0.666_666_7).abs() < 1e-4,
1102            "got {two_uses_bad}"
1103        );
1104    }
1105
1106    /// MP-4b: at use_count >= 3 the multiplier maps ratio in [-1, 1] linearly
1107    /// onto [MULTIPLIER_MIN, MULTIPLIER_MAX] = [0.5, 1.5]. A neutral memory
1108    /// (ratio = 0) gets a 1.0 multiplier.
1109    #[test]
1110    fn usefulness_multiplier_maps_ratio_onto_envelope() {
1111        // ratio = 1.0 -> 1.5 (max boost)
1112        assert!((usefulness_multiplier(5.0, 5) - 1.5).abs() < f32::EPSILON);
1113        // ratio = -1.0 -> 0.5 (max penalty)
1114        assert!((usefulness_multiplier(-5.0, 5) - 0.5).abs() < f32::EPSILON);
1115        // ratio = 0.0 -> 1.0 (neutral)
1116        let mid = usefulness_multiplier(0.0, 6);
1117        assert!((mid - 1.0).abs() < f32::EPSILON, "got {mid}");
1118        // ratio = 0.5 -> 1.25 (mid boost)
1119        let high = usefulness_multiplier(2.0, 4);
1120        assert!((high - 1.25).abs() < f32::EPSILON, "got {high}");
1121        // ratio = -0.5 -> 0.75 (mid penalty)
1122        let low = usefulness_multiplier(-2.0, 4);
1123        assert!((low - 0.75).abs() < f32::EPSILON, "got {low}");
1124    }
1125
1126    /// MP-4b: the multiplier is bounded so even a runaway score cannot
1127    /// dominate the budget; a single memory with usefulness_score >> use_count
1128    /// is clamped at the upper envelope.
1129    #[test]
1130    fn usefulness_multiplier_clamps_to_envelope() {
1131        // ratio > 1.0 is clamped to 1.0 -> 1.5
1132        assert!((usefulness_multiplier(100.0, 5) - 1.5).abs() < f32::EPSILON);
1133        // ratio < -1.0 is clamped to -1.0 -> 0.5
1134        assert!((usefulness_multiplier(-100.0, 5) - 0.5).abs() < f32::EPSILON);
1135    }
1136
1137    // ----- MP-17 #11: task-class query expansion -----
1138
1139    #[test]
1140    fn query_tokens_expands_build_class() {
1141        let toks = query_tokens("Build the project from source");
1142        assert!(toks.iter().any(|t| t == "build"));
1143        // class-aware expansion adds tool tokens:
1144        assert!(toks.iter().any(|t| t == "shell_background"));
1145        assert!(toks.iter().any(|t| t == "long_running"));
1146    }
1147
1148    #[test]
1149    fn query_tokens_expands_edit_class() {
1150        let toks = query_tokens("Modify the config to fix the bug");
1151        assert!(toks.iter().any(|t| t == "edit_file"));
1152        assert!(toks.iter().any(|t| t == "apply_patch"));
1153    }
1154
1155    #[test]
1156    fn query_tokens_expands_search_class() {
1157        let toks = query_tokens("Find all references to the symbol");
1158        assert!(toks.iter().any(|t| t == "glob"));
1159        assert!(toks.iter().any(|t| t == "search_files"));
1160    }
1161
1162    #[test]
1163    fn query_tokens_no_expansion_on_unrelated_query() {
1164        let toks = query_tokens("hello world testing nothing");
1165        // Only the "test" trigger fires here -> verification expansion.
1166        assert!(toks.iter().any(|t| t == "hello"));
1167        // The base tokens are present regardless.
1168        assert!(toks.iter().any(|t| t == "world"));
1169    }
1170
1171    // ----- MP-17 #13: MMR diversity helpers -----
1172
1173    #[test]
1174    fn jaccard_is_zero_for_disjoint_sets() {
1175        let a: std::collections::HashSet<String> =
1176            ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1177        let b: std::collections::HashSet<String> =
1178            ["baz", "qux"].iter().map(|s| s.to_string()).collect();
1179        assert!((jaccard(&a, &b) - 0.0).abs() < f32::EPSILON);
1180    }
1181
1182    #[test]
1183    fn jaccard_is_one_for_identical_sets() {
1184        let a: std::collections::HashSet<String> =
1185            ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1186        let b = a.clone();
1187        assert!((jaccard(&a, &b) - 1.0).abs() < f32::EPSILON);
1188    }
1189
1190    #[test]
1191    fn jaccard_partial_overlap() {
1192        let a: std::collections::HashSet<String> = ["foo", "bar", "baz"]
1193            .iter()
1194            .map(|s| s.to_string())
1195            .collect();
1196        let b: std::collections::HashSet<String> =
1197            ["bar", "qux"].iter().map(|s| s.to_string()).collect();
1198        // intersection = {bar} = 1, union = {foo,bar,baz,qux} = 4
1199        assert!((jaccard(&a, &b) - 0.25).abs() < f32::EPSILON);
1200    }
1201
1202    #[test]
1203    fn summary_token_set_lowercases_and_filters_short() {
1204        let set = summary_token_set("Build the Foo-bar project");
1205        assert!(set.contains("build"));
1206        assert!(set.contains("foo"));
1207        assert!(set.contains("bar"));
1208        assert!(set.contains("project"));
1209        // "the" is len=3, included; "a" or "i" would be excluded.
1210        assert!(set.contains("the"));
1211    }
1212
1213    // ----- v0.4.2: hybrid retrieval end-to-end -----
1214
1215    /// Helper: open an in-memory brain.db, initialize schema, insert
1216    /// a memory row (post-projector shape) plus its embedding +
1217    /// embedding_model and the matching FTS entry.
1218    fn insert_memory_with_embedding(
1219        conn: &rusqlite::Connection,
1220        memory_id: &str,
1221        text: &str,
1222        embedder: &dyn embeddings::Embedder,
1223    ) {
1224        let normalized = kimetsu_core::memory::normalize_memory_text(text);
1225        conn.execute(
1226            "
1227            INSERT INTO memories (
1228                memory_id, scope, kind, text, normalized_text, confidence,
1229                source_event_id, provenance_snapshot_json, created_at,
1230                use_count, usefulness_score, embedding, embedding_model
1231            )
1232            VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1233                    '2026-05-01T00:00:00Z', 0, 0.0, ?4, ?5)
1234            ",
1235            rusqlite::params![
1236                memory_id,
1237                text,
1238                normalized,
1239                embeddings::encode_embedding(&embedder.embed(text).expect("embed test row")),
1240                embedder.model_id(),
1241            ],
1242        )
1243        .expect("insert memory");
1244        conn.execute(
1245            "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
1246            rusqlite::params![memory_id, text],
1247        )
1248        .expect("insert fts row");
1249    }
1250
1251    /// v0.4.2: the cosine blend changes retrieval ranking when two
1252    /// memories tie lexically but differ semantically (via the stub
1253    /// embedder's hashed-bucket vectors).
1254    ///
1255    /// Setup: two memories, neither containing the query's literal
1256    /// words. With pure FTS, neither matches and we fall back to
1257    /// latest-memory ranking. With the stub embedder enabled, the
1258    /// memory that's "semantically closer" to the query (shares
1259    /// hash buckets) outranks the other.
1260    #[test]
1261    fn hybrid_retrieval_uses_cosine_score_to_rerank() {
1262        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1263        crate::schema::initialize(&conn).expect("init schema");
1264        let stub = embeddings::StubEmbedder::new();
1265
1266        insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
1267        insert_memory_with_embedding(
1268            &conn,
1269            "m_unrelated",
1270            "cookie recipe with chocolate chips",
1271            &stub,
1272        );
1273
1274        // Query shares words with m_rg but not m_unrelated. FTS will
1275        // already prefer m_rg here; we use that as the baseline.
1276        let weights = kimetsu_core::config::BrokerWeights::default();
1277        let bundle = retrieve_context_with_embedder(
1278            &conn,
1279            "/fake-repo",
1280            &weights,
1281            ContextRequest {
1282                stage: "localization".to_string(),
1283                query: "ripgrep search".to_string(),
1284                budget_tokens: 4000,
1285            },
1286            &[],
1287            &stub,
1288        )
1289        .expect("retrieve");
1290
1291        let memory_handles: Vec<_> = bundle
1292            .capsules
1293            .iter()
1294            .filter(|c| c.expansion_handle.starts_with("memory:"))
1295            .collect();
1296        assert!(
1297            !memory_handles.is_empty(),
1298            "at least one memory should surface"
1299        );
1300        // The semantically-relevant memory must rank first.
1301        assert_eq!(
1302            memory_handles[0].expansion_handle,
1303            "memory:m_rg",
1304            "ripgrep memory should outrank the cookie recipe; ranked: {:?}",
1305            memory_handles
1306                .iter()
1307                .map(|c| &c.expansion_handle)
1308                .collect::<Vec<_>>()
1309        );
1310    }
1311
1312    /// v0.4.2: when a row's stored `embedding_model` doesn't match
1313    /// the active query embedder's id, the row's cosine contribution
1314    /// is skipped — falling back to FTS-only for that row. Critical
1315    /// for safety across `kimetsu brain reindex` migrations (v0.4.3)
1316    /// where some rows might be embedded with the new model and some
1317    /// with the old.
1318    #[test]
1319    fn hybrid_retrieval_skips_cosine_on_model_id_mismatch() {
1320        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1321        crate::schema::initialize(&conn).expect("init schema");
1322        let stub = embeddings::StubEmbedder::new();
1323        insert_memory_with_embedding(&conn, "m_xref", "use ripgrep for code search", &stub);
1324
1325        // Stomp the row's embedding_model with a synthetic id that
1326        // doesn't match the active embedder. Simulates a `kimetsu
1327        // brain reindex` mid-migration where some rows are on the
1328        // new model and some on the old.
1329        conn.execute(
1330            "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xref'",
1331            [],
1332        )
1333        .expect("force model_id mismatch");
1334
1335        // Query through the stub embedder. Its model_id is "stub-d8";
1336        // the row's is "bge-small-en-v1.5". The cosine path MUST be
1337        // skipped for this row; FTS still surfaces it on the lexical
1338        // match because retrieval doesn't crash on cross-model rows.
1339        let weights = kimetsu_core::config::BrokerWeights::default();
1340        let bundle = retrieve_context_with_embedder(
1341            &conn,
1342            "/fake-repo",
1343            &weights,
1344            ContextRequest {
1345                stage: "localization".to_string(),
1346                query: "ripgrep search".to_string(),
1347                budget_tokens: 4000,
1348            },
1349            &[],
1350            &stub,
1351        )
1352        .expect("retrieve");
1353
1354        assert!(
1355            bundle
1356                .capsules
1357                .iter()
1358                .any(|c| c.expansion_handle == "memory:m_xref"),
1359            "cross-model row should still match lexically (cosine skipped, FTS works)"
1360        );
1361    }
1362
1363    // ----- v0.5.1: usefulness decay -----
1364
1365    /// v0.5.1: `half_life_days <= 0` is the operator opt-out hatch.
1366    /// Decay must short-circuit to 1.0 so the usefulness multiplier
1367    /// is unmodified — exact pre-v0.5.1 behavior for projects that
1368    /// set `decay_half_life_days = 0` in project.toml.
1369    #[test]
1370    fn usefulness_decay_disabled_when_half_life_is_zero_or_negative() {
1371        // Even a 5-year-old reference returns 1.0 with decay disabled.
1372        let ancient = "2021-01-01T00:00:00Z";
1373        assert!((usefulness_decay(Some(ancient), ancient, 0.0) - 1.0).abs() < f32::EPSILON);
1374        assert!((usefulness_decay(Some(ancient), ancient, -1.0) - 1.0).abs() < f32::EPSILON);
1375    }
1376
1377    /// v0.5.1: unparseable timestamps return 1.0 (fail-open). A
1378    /// corrupted row shouldn't get silently dropped out of retrieval
1379    /// just because its `last_useful_at` got mangled.
1380    #[test]
1381    fn usefulness_decay_returns_one_on_unparseable_timestamps() {
1382        assert!((usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON);
1383    }
1384
1385    /// v0.5.1: a memory whose reference timestamp is "now" (no age)
1386    /// decays by zero — full contribution.
1387    #[test]
1388    fn usefulness_decay_full_at_zero_age() {
1389        // Use a timestamp from the future so age clamps to 0.
1390        let future = "2099-01-01T00:00:00Z";
1391        let d = usefulness_decay(Some(future), future, 30.0);
1392        assert!((d - 1.0).abs() < f32::EPSILON, "got {d}");
1393    }
1394
1395    /// v0.5.1: at age == half_life, decay = 0.5; at age = 2 * half_life,
1396    /// decay = 0.25. Computed by setting `last_useful_at` to (now - days)
1397    /// using OffsetDateTime arithmetic — the only way to get a stable
1398    /// "now-relative" timestamp without freezing the clock.
1399    #[test]
1400    fn usefulness_decay_follows_half_life_curve() {
1401        let half_life = 10.0_f32;
1402        let now = OffsetDateTime::now_utc();
1403        let fmt = &time::format_description::well_known::Rfc3339;
1404
1405        // age = half_life -> decay ~= 0.5
1406        let one_half_life_ago =
1407            (now - time::Duration::seconds((half_life * 86_400.0) as i64))
1408                .format(fmt)
1409                .expect("format");
1410        let d1 = usefulness_decay(Some(&one_half_life_ago), &one_half_life_ago, half_life);
1411        assert!(
1412            (d1 - 0.5).abs() < 0.01,
1413            "expected ~0.5 at one half-life, got {d1}"
1414        );
1415
1416        // age = 2 * half_life -> decay ~= 0.25
1417        let two_half_lives_ago =
1418            (now - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64))
1419                .format(fmt)
1420                .expect("format");
1421        let d2 = usefulness_decay(
1422            Some(&two_half_lives_ago),
1423            &two_half_lives_ago,
1424            half_life,
1425        );
1426        assert!(
1427            (d2 - 0.25).abs() < 0.01,
1428            "expected ~0.25 at two half-lives, got {d2}"
1429        );
1430    }
1431
1432    /// v0.5.1: when `last_useful_at` is None the function falls back to
1433    /// `created_at`. A 1-day-old never-cited memory should still get
1434    /// nearly-full decay (close to 1.0) for a 30-day half-life.
1435    #[test]
1436    fn usefulness_decay_falls_back_to_created_at_when_last_useful_is_none() {
1437        let now = OffsetDateTime::now_utc();
1438        let fmt = &time::format_description::well_known::Rfc3339;
1439        let one_day_ago = (now - time::Duration::seconds(86_400))
1440            .format(fmt)
1441            .expect("format");
1442        let d = usefulness_decay(None, &one_day_ago, 30.0);
1443        // exp(-ln(2) / 30) ≈ 0.977
1444        assert!(
1445            (d - 0.977).abs() < 0.01,
1446            "expected ~0.977 for 1-day-old created_at under 30d half-life, got {d}"
1447        );
1448    }
1449
1450    /// v0.5.1: end-to-end retrieval test. Two memories with identical
1451    /// lexical match, identical use_count, identical (max) usefulness
1452    /// score — one cited yesterday, one cited a year ago. Decay must
1453    /// rank the recent one first.
1454    #[test]
1455    fn aged_cited_memory_ranks_below_recently_cited_memory() {
1456        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1457        crate::schema::initialize(&conn).expect("init schema");
1458
1459        let now = OffsetDateTime::now_utc();
1460        let fmt = &time::format_description::well_known::Rfc3339;
1461        let one_day_ago = (now - time::Duration::seconds(86_400))
1462            .format(fmt)
1463            .expect("format");
1464        let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
1465            .format(fmt)
1466            .expect("format");
1467
1468        // Both memories say "use ripgrep for code search", both have
1469        // use_count = 5, usefulness_score = 5 (max boost → 1.5
1470        // multiplier). The only difference is `last_useful_at`.
1471        for (mid, last_useful) in
1472            [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)]
1473        {
1474            let text = "use ripgrep for code search";
1475            let normalized = kimetsu_core::memory::normalize_memory_text(text);
1476            conn.execute(
1477                "
1478                INSERT INTO memories (
1479                    memory_id, scope, kind, text, normalized_text, confidence,
1480                    source_event_id, provenance_snapshot_json, created_at,
1481                    use_count, usefulness_score, last_useful_at
1482                )
1483                VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1484                        '2024-01-01T00:00:00Z', 5, 5.0, ?4)
1485                ",
1486                rusqlite::params![mid, text, normalized, last_useful],
1487            )
1488            .expect("insert memory");
1489            conn.execute(
1490                "INSERT INTO memories_fts (memory_id, text, kind, scope)
1491                 VALUES (?1, ?2, 'fact', 'global_user')",
1492                rusqlite::params![mid, text],
1493            )
1494            .expect("insert fts");
1495        }
1496
1497        // Default broker weights → 30-day half-life. 1 year ≈ 12 half-lives.
1498        let weights = kimetsu_core::config::BrokerWeights::default();
1499        let bundle = retrieve_context_with_embedder(
1500            &conn,
1501            "/fake-repo",
1502            &weights,
1503            ContextRequest {
1504                stage: "localization".to_string(),
1505                query: "ripgrep search".to_string(),
1506                budget_tokens: 4000,
1507            },
1508            &[],
1509            &embeddings::NoopEmbedder,
1510        )
1511        .expect("retrieve");
1512
1513        let mem_order: Vec<&str> = bundle
1514            .capsules
1515            .iter()
1516            .filter_map(|c| {
1517                c.expansion_handle
1518                    .strip_prefix("memory:")
1519                    .map(|s| s)
1520            })
1521            .collect();
1522        assert_eq!(
1523            mem_order.first().copied(),
1524            Some("m_recent"),
1525            "recently-cited memory must rank first under decay; got order {mem_order:?}"
1526        );
1527    }
1528
1529    /// v0.5.1: with decay disabled (half_life = 0) the aged + recent
1530    /// memories tie and the deterministic tiebreaker (id) decides —
1531    /// proves the ranking flip in the previous test is *caused* by
1532    /// decay, not by some unrelated side effect of the timestamp.
1533    #[test]
1534    fn aged_cited_memory_does_not_decay_when_half_life_is_zero() {
1535        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1536        crate::schema::initialize(&conn).expect("init schema");
1537
1538        let now = OffsetDateTime::now_utc();
1539        let fmt = &time::format_description::well_known::Rfc3339;
1540        let one_day_ago = (now - time::Duration::seconds(86_400))
1541            .format(fmt)
1542            .expect("format");
1543        let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
1544            .format(fmt)
1545            .expect("format");
1546
1547        for (mid, last_useful) in
1548            [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)]
1549        {
1550            let text = "use ripgrep for code search";
1551            let normalized = kimetsu_core::memory::normalize_memory_text(text);
1552            conn.execute(
1553                "
1554                INSERT INTO memories (
1555                    memory_id, scope, kind, text, normalized_text, confidence,
1556                    source_event_id, provenance_snapshot_json, created_at,
1557                    use_count, usefulness_score, last_useful_at
1558                )
1559                VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1560                        '2024-01-01T00:00:00Z', 5, 5.0, ?4)
1561                ",
1562                rusqlite::params![mid, text, normalized, last_useful],
1563            )
1564            .expect("insert memory");
1565            conn.execute(
1566                "INSERT INTO memories_fts (memory_id, text, kind, scope)
1567                 VALUES (?1, ?2, 'fact', 'global_user')",
1568                rusqlite::params![mid, text],
1569            )
1570            .expect("insert fts");
1571        }
1572
1573        // Disable decay via broker config.
1574        let mut weights = kimetsu_core::config::BrokerWeights::default();
1575        weights.decay_half_life_days = 0.0;
1576
1577        let bundle = retrieve_context_with_embedder(
1578            &conn,
1579            "/fake-repo",
1580            &weights,
1581            ContextRequest {
1582                stage: "localization".to_string(),
1583                query: "ripgrep search".to_string(),
1584                budget_tokens: 4000,
1585            },
1586            &[],
1587            &embeddings::NoopEmbedder,
1588        )
1589        .expect("retrieve");
1590
1591        // Both memories should surface. With decay disabled, their
1592        // scores are identical (same multiplier, same lexical match,
1593        // same freshness band since both created_at are equal). The
1594        // sort tiebreaker falls back to id, so m_aged < m_recent
1595        // alphabetically.
1596        let scores: Vec<(String, f32)> = bundle
1597            .capsules
1598            .iter()
1599            .filter_map(|c| {
1600                c.expansion_handle
1601                    .strip_prefix("memory:")
1602                    .map(|id| (id.to_string(), c.score))
1603            })
1604            .collect();
1605        assert_eq!(scores.len(), 2, "both memories should surface");
1606        let recent_score = scores
1607            .iter()
1608            .find(|(id, _)| id == "m_recent")
1609            .map(|(_, s)| *s)
1610            .expect("m_recent present");
1611        let aged_score = scores
1612            .iter()
1613            .find(|(id, _)| id == "m_aged")
1614            .map(|(_, s)| *s)
1615            .expect("m_aged present");
1616        // With decay off, the two multipliers are equal → scores match.
1617        assert!(
1618            (recent_score - aged_score).abs() < 1e-4,
1619            "with decay disabled the two memories should tie on score: recent={recent_score} aged={aged_score}"
1620        );
1621    }
1622
1623    /// v0.4.2: with [`NoopEmbedder`] the retrieval path is identical
1624    /// to v0.4.1 — no cosine term contributes, stored embeddings (if
1625    /// any) are ignored. Regression guard so the default build
1626    /// behaves identically to pre-v0.4.2.
1627    #[test]
1628    fn hybrid_retrieval_with_noop_embedder_is_lexical_only() {
1629        let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1630        crate::schema::initialize(&conn).expect("init schema");
1631        let stub = embeddings::StubEmbedder::new();
1632        // Two memories, both with non-null embeddings.
1633        insert_memory_with_embedding(&conn, "m_a", "use ripgrep", &stub);
1634        insert_memory_with_embedding(&conn, "m_b", "use ripgrep too", &stub);
1635
1636        // Query through the Noop default. QueryEmbedding will be
1637        // None → no cosine blend → exact FTS ranking.
1638        let weights = kimetsu_core::config::BrokerWeights::default();
1639        let bundle = retrieve_context_with_embedder(
1640            &conn,
1641            "/fake-repo",
1642            &weights,
1643            ContextRequest {
1644                stage: "localization".to_string(),
1645                query: "ripgrep".to_string(),
1646                budget_tokens: 4000,
1647            },
1648            &[],
1649            &embeddings::NoopEmbedder,
1650        )
1651        .expect("retrieve");
1652
1653        let count = bundle
1654            .capsules
1655            .iter()
1656            .filter(|c| c.expansion_handle.starts_with("memory:"))
1657            .count();
1658        assert_eq!(count, 2, "both memories should surface via FTS");
1659    }
1660}