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