1#[path = "delivery.rs"]
2pub mod delivery;
3
4use std::cmp::Ordering;
5use std::collections::HashMap;
6
7use kimetsu_core::config::{BrokerWeights, StageWeights};
8use kimetsu_core::memory::MemoryScope;
9use kimetsu_core::{KimetsuResult, ids::new_id};
10use rusqlite::{Connection, OptionalExtension, params};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
28pub enum TaskKind {
29 #[default]
32 Feature,
33 Debug,
36 Refactor,
39 Docs,
42 Investigation,
45}
46
47pub fn classify_task(task: &str) -> TaskKind {
53 let lower = task.to_ascii_lowercase();
54
55 const DEBUG_KW: &[&str] = &[
57 "fix",
58 "bug",
59 "error",
60 "fail",
61 "crash",
62 "panic",
63 "regression",
64 "broken",
65 "debug",
66 "stack trace",
67 "exception",
68 ];
69 if DEBUG_KW.iter().any(|kw| lower.contains(kw)) {
70 return TaskKind::Debug;
71 }
72
73 const INVESTIGATE_KW: &[&str] = &[
75 "investigate",
76 "analyze",
77 "understand",
78 " why ",
79 "explore",
80 "find out",
81 "root cause",
82 "audit",
83 "trace",
84 ];
85 if INVESTIGATE_KW.iter().any(|kw| lower.contains(kw)) {
86 return TaskKind::Investigation;
87 }
88
89 const REFACTOR_KW: &[&str] = &[
91 "refactor",
92 "rename",
93 "cleanup",
94 "clean up",
95 "restructure",
96 "simplify",
97 "extract",
98 "deduplicate",
99 "reorganize",
100 ];
101 if REFACTOR_KW.iter().any(|kw| lower.contains(kw)) {
102 return TaskKind::Refactor;
103 }
104
105 const DOCS_KW: &[&str] = &[
107 "document",
108 "readme",
109 "changelog",
110 "comment",
111 "docstring",
112 "docs",
113 "tutorial",
114 "guide",
115 ];
116 if DOCS_KW.iter().any(|kw| lower.contains(kw)) {
117 return TaskKind::Docs;
118 }
119
120 TaskKind::Feature
122}
123
124fn weights_for_task_kind(base: StageWeights, kind: TaskKind) -> StageWeights {
140 match kind {
141 TaskKind::Feature => base,
142 TaskKind::Debug => renorm(StageWeights {
143 freshness: base.freshness * 1.6,
144 ..base
145 }),
146 TaskKind::Refactor => renorm(StageWeights {
147 scope: base.scope * 1.6,
148 ..base
149 }),
150 TaskKind::Investigation => renorm(StageWeights {
151 relevance: base.relevance * 1.4,
152 ..base
153 }),
154 TaskKind::Docs => renorm(StageWeights {
155 confidence: base.confidence * 1.15,
156 ..base
157 }),
158 }
159}
160
161fn renorm(w: StageWeights) -> StageWeights {
165 let sum = w.relevance + w.confidence + w.freshness + w.scope;
166 if sum <= f32::EPSILON {
167 return w;
168 }
169 StageWeights {
178 relevance: w.relevance / sum,
179 confidence: w.confidence / sum,
180 freshness: w.freshness / sum,
181 scope: w.scope / sum,
182 }
183}
184
185fn task_kind_prefer_roles(kind: TaskKind) -> &'static [&'static str] {
191 match kind {
192 TaskKind::Feature => &[],
193 TaskKind::Debug => &["failure_pattern"],
194 TaskKind::Refactor => &["convention"],
195 TaskKind::Investigation => &["fact", "preference"],
196 TaskKind::Docs => &["convention"],
197 }
198}
199use time::OffsetDateTime;
200
201use crate::embeddings::{
202 self, DEFAULT_HYBRID_ALPHA, Embedder, cosine_similarity, decode_embedding,
203};
204
205#[derive(Debug, Clone)]
213pub(crate) struct QueryEmbedding {
214 pub(crate) vector: Vec<f32>,
215 pub(crate) model_id: String,
216}
217
218impl QueryEmbedding {
219 fn from_embedder(embedder: &dyn Embedder, query: &str) -> Option<Self> {
220 if embedder.is_noop() {
221 return None;
222 }
223 match embedder.embed(query) {
224 Ok(v) if v.len() == embedder.dim() => Some(Self {
225 vector: v,
226 model_id: embedder.model_id().to_string(),
227 }),
228 _ => None,
233 }
234 }
235}
236
237#[derive(Debug, Clone, Serialize, Deserialize)]
238pub struct ContextCapsule {
239 pub id: String,
240 pub kind: String,
241 pub summary: String,
242 pub token_estimate: u32,
243 pub expansion_handle: String,
244 pub provenance: Vec<ProvenanceRef>,
245 pub confidence: f32,
246 pub freshness: f32,
247 pub relevance: f32,
248 pub scope_weight: f32,
249 pub score: f32,
250 #[serde(default)]
257 pub superseded_hint: bool,
258 #[serde(default)]
260 pub rerank_policy_tier: i8,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub claim_revision: Option<String>,
264 #[serde(default, skip_serializing_if = "Vec::is_empty")]
266 pub facts: Vec<crate::fact_store::StoredFact>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub rerank_usefulness: Option<f32>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
271 pub rerank_trust: Option<f32>,
272}
273
274pub fn memory_revision_bindings(
277 capsules: &[ContextCapsule],
278) -> std::collections::BTreeMap<String, String> {
279 let mut bindings = std::collections::BTreeMap::new();
280 let mut ambiguous = std::collections::HashSet::new();
281 for c in capsules {
282 if let (Some(id), Some(revision)) = (
283 c.expansion_handle.strip_prefix("memory:"),
284 c.claim_revision.as_ref(),
285 ) {
286 if bindings.get(id).is_some_and(|old| old != revision) {
287 ambiguous.insert(id.to_string());
288 }
289 bindings.insert(id.to_string(), revision.clone());
290 }
291 }
292 for id in ambiguous {
293 bindings.remove(&id);
294 }
295 bindings
296}
297
298impl ContextCapsule {
299 pub fn wire_minimal(summary: String, kind: String, score: f32) -> Self {
303 Self {
304 id: String::new(),
305 kind,
306 summary,
307 token_estimate: 0,
308 expansion_handle: String::new(),
309 provenance: Vec::new(),
310 confidence: 0.0,
311 freshness: 0.0,
312 relevance: 0.0,
313 scope_weight: 0.0,
314 score,
315 superseded_hint: false,
316 rerank_policy_tier: 0,
317 claim_revision: None,
318 facts: vec![],
319 rerank_usefulness: None,
320 rerank_trust: None,
321 }
322 }
323}
324
325#[derive(Debug, Clone, Serialize, Deserialize)]
326pub struct ProvenanceRef {
327 pub source: String,
328 pub id: String,
329 pub excerpt: Option<String>,
330}
331
332#[derive(Debug, Clone, Default)]
333pub struct ContextRequest {
334 pub include_fact_evidence: bool,
336 pub defer_fact_budget: bool,
338 pub stage: String,
339 pub query: String,
340 pub budget_tokens: u32,
341 pub fusion: String,
349 pub normalization: String,
357 pub tags: Vec<String>,
362 pub min_score: f32,
367 pub max_capsules: usize,
370 pub prefer_roles: Vec<String>,
374 pub kinds: Vec<String>,
381 pub min_semantic_score: f32,
389 pub min_semantic_score_override: Option<f32>,
392 pub min_lexical_coverage: f32,
402 pub min_lexical_coverage_override: Option<f32>,
404 pub task_kind: TaskKind,
409 pub abstain_evidence: f32,
424 pub abstain_evidence_override: Option<f32>,
426}
427
428#[derive(Debug, Clone)]
429pub struct ContextBundle {
430 pub stage: String,
431 pub budget_tokens: u32,
432 pub used_tokens: u32,
433 pub capsules: Vec<ContextCapsule>,
434 pub excluded: Vec<ContextCapsule>,
435 pub skipped: bool,
438 pub top_score: f32,
441 pub top_abs_evidence: f32,
448 pub evidence_coverage: f32,
463 pub uncovered_terms: Vec<String>,
469 pub chronological: bool,
476 pub known_fact_conflicts: Vec<String>,
478}
479
480fn coverage_token_idf(conn: &Connection, tokens: &[String]) -> KimetsuResult<HashMap<String, f32>> {
497 token_idf(conn, tokens, false)
498}
499
500pub fn partial_evidence_notice(bundle: &ContextBundle) -> Option<String> {
509 if bundle.skipped || bundle.capsules.is_empty() {
510 return None; }
512 if bundle.evidence_coverage > PARTIAL_EVIDENCE_COVERAGE || bundle.uncovered_terms.is_empty() {
513 return None;
514 }
515 const MAX_NAMED: usize = 6;
519 let named: Vec<&str> = bundle
520 .uncovered_terms
521 .iter()
522 .take(MAX_NAMED)
523 .map(String::as_str)
524 .collect();
525 let more = bundle.uncovered_terms.len().saturating_sub(named.len());
526 let suffix = if more > 0 {
527 format!(" (and {more} more)")
528 } else {
529 String::new()
530 };
531 Some(format!(
532 "Partial memory: nothing above covers {}{}. Treat the rest as unknown \
533 rather than inferring it.",
534 named.join(", "),
535 suffix
536 ))
537}
538
539pub const PARTIAL_EVIDENCE_COVERAGE: f32 = 0.5;
545
546pub(crate) fn evidence_coverage(
553 conn: &Connection,
554 query: &str,
555 capsules: &[ContextCapsule],
556) -> (f32, Vec<String>) {
557 let content = content_tokens(query);
558 if content.is_empty() {
559 return (1.0, Vec::new());
560 }
561 let Ok(idf) = coverage_token_idf(conn, &content) else {
562 return (1.0, Vec::new());
563 };
564 let haystack = capsules
566 .iter()
567 .map(|c| c.summary.to_ascii_lowercase())
568 .collect::<Vec<_>>()
569 .join(" ");
570
571 let mut total = 0.0f32;
572 let mut hit = 0.0f32;
573 let mut uncovered = Vec::new();
574 for token in &content {
575 let weight = idf.get(token).copied().unwrap_or(0.0);
576 if weight <= 0.0 {
577 continue; }
579 total += weight;
580 if haystack.contains(token.as_str()) {
581 hit += weight;
582 } else {
583 uncovered.push(token.clone());
584 }
585 }
586 if total <= f32::EPSILON {
587 return (1.0, Vec::new());
590 }
591 (hit / total, uncovered)
592}
593
594#[derive(Debug, Clone)]
600pub(crate) struct Candidate {
601 pub(crate) capsule: ContextCapsule,
602 pub(crate) raw_relevance: f32,
603 pub(crate) embedding: Option<Vec<f32>>,
609 pub(crate) cosine: Option<f32>,
613 pub(crate) created_at: Option<String>,
618}
619
620pub fn retrieve_context(
621 conn: &Connection,
622 repo_root: &str,
623 weights: &BrokerWeights,
624 request: ContextRequest,
625) -> KimetsuResult<ContextBundle> {
626 retrieve_context_multi(conn, repo_root, weights, request, &[])
627}
628
629pub fn retrieve_context_multi(
646 conn: &Connection,
647 repo_root: &str,
648 weights: &BrokerWeights,
649 request: ContextRequest,
650 extra_memory_conns: &[&Connection],
651) -> KimetsuResult<ContextBundle> {
652 let embedder = embeddings::open_default_embedder();
653 retrieve_context_with_embedder(
654 conn,
655 repo_root,
656 weights,
657 request,
658 extra_memory_conns,
659 embedder,
660 )
661}
662
663pub fn retrieve_context_with_embedder(
675 conn: &Connection,
676 repo_root: &str,
677 weights: &BrokerWeights,
678 request: ContextRequest,
679 extra_memory_conns: &[&Connection],
680 embedder: &dyn Embedder,
681) -> KimetsuResult<ContextBundle> {
682 retrieve_context_with_embedder_and_backend(
683 conn,
684 repo_root,
685 weights,
686 request,
687 extra_memory_conns,
688 embedder,
689 &crate::backend::FlatBackend {
690 fusion: crate::fusion::Fusion::Linear,
691 },
692 )
693}
694
695pub(crate) fn retrieve_context_with_embedder_and_backend(
708 conn: &Connection,
709 repo_root: &str,
710 weights: &BrokerWeights,
711 request: ContextRequest,
712 extra_memory_conns: &[&Connection],
713 embedder: &dyn Embedder,
714 backend: &dyn crate::backend::RetrievalBackend,
715) -> KimetsuResult<ContextBundle> {
716 let query_embedding = QueryEmbedding::from_embedder(embedder, &request.query);
717 let half_life_days = weights.decay_half_life_days;
718 let mut candidates = Vec::new();
719 candidates.extend(backend.memory_candidates(
720 conn,
721 &request.query,
722 query_embedding.as_ref(),
723 half_life_days,
724 request.include_fact_evidence,
725 )?);
726 for extra in extra_memory_conns {
727 candidates.extend(backend.memory_candidates(
728 extra,
729 &request.query,
730 query_embedding.as_ref(),
731 half_life_days,
732 request.include_fact_evidence,
733 )?);
734 }
735 crate::reinforce::apply_query_routing(
741 conn,
742 &request.query,
743 query_embedding.as_ref(),
744 &mut candidates,
745 );
746
747 candidates.extend(repo_file_candidates(conn, repo_root, &request.query, 30)?);
748 candidates.extend(manifest_candidates(conn, repo_root, &request.query)?);
749
750 if !request.kinds.is_empty() {
757 candidates.retain(|c| {
758 request
759 .kinds
760 .iter()
761 .any(|k| capsule_matches_kind(&c.capsule, k))
762 });
763 }
764
765 if request.min_lexical_coverage > 0.0 {
782 let content = content_tokens(&request.query);
783 if !content.is_empty() {
784 let idf = corpus_token_idf(conn, &content)?;
785 let total_idf: f32 = content
786 .iter()
787 .map(|t| idf.get(t).copied().unwrap_or(0.0))
788 .sum();
789 if total_idf > f32::EPSILON {
792 candidates.retain(|c| {
793 if c.capsule.kind != "memory" {
794 return true; }
796 if c.cosine.is_some_and(|cos| cos >= SEMANTIC_KEEP_COSINE) {
799 return true;
800 }
801 weighted_coverage(&content, &idf, &c.capsule.summary)
802 >= request.min_lexical_coverage
803 });
804 }
805 }
806 }
807
808 let stage_weights = weights_for_stage(weights, &request.stage);
811 let effective_weights = weights_for_task_kind(stage_weights, request.task_kind);
812 normalize_and_score(
813 &mut candidates,
814 effective_weights,
815 Normalization::from_config(&request.normalization),
816 );
817
818 let kind_role_hints = task_kind_prefer_roles(request.task_kind);
821 let mut effective_prefer_roles: Vec<String> = request.prefer_roles.clone();
822 for &hint in kind_role_hints {
823 let hint_s = hint.to_string();
824 if !effective_prefer_roles.contains(&hint_s) {
825 effective_prefer_roles.push(hint_s);
826 }
827 }
828
829 if !request.tags.is_empty() || !effective_prefer_roles.is_empty() {
841 let tags_lc: Vec<String> = request
842 .tags
843 .iter()
844 .map(|t| t.to_ascii_lowercase())
845 .collect();
846 for c in &mut candidates {
847 let summary_lc = c.capsule.summary.to_ascii_lowercase();
848 if !tags_lc.is_empty() && tags_lc.iter().any(|t| summary_lc.contains(t.as_str())) {
849 c.capsule.score *= 1.4;
850 }
851 if !effective_prefer_roles.is_empty()
852 && effective_prefer_roles.iter().any(|r| {
853 if c.capsule.kind == "memory" {
861 capsule_matches_kind(&c.capsule, r.as_str())
862 } else {
863 c.capsule.kind.contains(r.as_str())
864 }
865 })
866 {
867 c.capsule.score *= 1.3;
868 }
869 }
870 }
871
872 apply_supersession_penalty(&mut candidates);
881
882 if query_embedding.is_some() && request.min_semantic_score > 0.0 {
897 candidates.retain(|c| {
898 match c.cosine {
901 Some(cos) => cos >= request.min_semantic_score,
902 None => true,
903 }
904 });
905 }
906
907 candidates.sort_by(|a, b| {
921 b.capsule
922 .score
923 .partial_cmp(&a.capsule.score)
924 .unwrap_or(Ordering::Equal)
925 .then_with(|| {
926 b.capsule
927 .freshness
928 .partial_cmp(&a.capsule.freshness)
929 .unwrap_or(Ordering::Equal)
930 })
931 .then_with(|| a.capsule.expansion_handle.cmp(&b.capsule.expansion_handle))
936 });
937
938 let embedding_mmr_ran = query_embedding.is_some() && !candidates.is_empty();
941 let candidates = if embedding_mmr_ran {
942 apply_candidate_mmr_diversity(candidates, 0.7)
943 } else {
944 candidates
945 };
946
947 let created_at_by_handle: std::collections::HashMap<String, String> =
951 if crate::ordering::is_ordering_query(&request.query) {
952 candidates
953 .iter()
954 .filter_map(|c| {
955 c.created_at
956 .clone()
957 .map(|ts| (c.capsule.expansion_handle.clone(), ts))
958 })
959 .collect()
960 } else {
961 std::collections::HashMap::new()
962 };
963
964 let top_abs_evidence = candidates
972 .iter()
973 .filter(|c| c.capsule.kind == "memory")
974 .filter_map(|c| c.cosine)
975 .fold(f32::NAN, f32::max);
976 let top_abs_evidence = if top_abs_evidence.is_nan() {
977 -1.0
978 } else {
979 top_abs_evidence
980 };
981 let memory_only = candidates.iter().all(|c| c.capsule.kind == "memory");
984
985 let mut capsules = candidates
986 .into_iter()
987 .map(|candidate| candidate.capsule)
988 .collect::<Vec<_>>();
989
990 if !embedding_mmr_ran {
993 capsules.sort_by(|left, right| {
994 right
995 .score
996 .partial_cmp(&left.score)
997 .unwrap_or(Ordering::Equal)
998 .then_with(|| {
999 right
1000 .freshness
1001 .partial_cmp(&left.freshness)
1002 .unwrap_or(Ordering::Equal)
1003 })
1004 .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
1006 });
1007 }
1008
1009 let top_score = capsules.first().map(|c| c.score).unwrap_or(0.0);
1018 let composite_skip = request.min_score > 0.0 && top_score < request.min_score;
1019 let evidence_skip = request.abstain_evidence > 0.0
1026 && memory_only
1027 && top_abs_evidence >= 0.0
1028 && top_abs_evidence < (request.abstain_evidence - abstain_band_width()).max(0.0);
1029 if composite_skip || evidence_skip {
1030 return Ok(ContextBundle {
1031 stage: request.stage,
1032 budget_tokens: request.budget_tokens,
1033 used_tokens: 0,
1034 capsules: Vec::new(),
1035 excluded: capsules,
1036 skipped: true,
1037 top_score,
1038 top_abs_evidence,
1039 evidence_coverage: 0.0,
1041 uncovered_terms: Vec::new(),
1042 chronological: false,
1044 known_fact_conflicts: vec![],
1045 });
1046 }
1047
1048 let capsules = apply_mmr_diversity(capsules, 0.7);
1056
1057 let capsule_budget = request.budget_tokens / 2;
1058 let mut used_tokens = 0u32;
1059 let mut included = Vec::new();
1060 let mut excluded = Vec::new();
1061
1062 for capsule in capsules {
1063 if request.max_capsules > 0 && included.len() >= request.max_capsules {
1065 excluded.push(capsule);
1066 continue;
1067 }
1068 if (request.defer_fact_budget && request.max_capsules > 0)
1069 || used_tokens.saturating_add(capsule.token_estimate) <= capsule_budget
1070 {
1071 used_tokens = used_tokens.saturating_add(capsule.token_estimate);
1072 included.push(capsule);
1073 } else {
1074 excluded.push(capsule);
1075 }
1076 }
1077
1078 let (coverage, uncovered_terms) = evidence_coverage(conn, &request.query, &included);
1079
1080 let chronological = !created_at_by_handle.is_empty();
1087 let included = if chronological {
1088 let dated = crate::ordering::render_chronologically(included, &created_at_by_handle);
1089 used_tokens = dated.iter().map(|c| c.token_estimate).sum();
1090 dated
1091 } else {
1092 included
1093 };
1094
1095 Ok(ContextBundle {
1096 stage: request.stage,
1097 budget_tokens: request.budget_tokens,
1098 used_tokens,
1099 capsules: included,
1100 excluded,
1101 skipped: false,
1102 top_score,
1103 top_abs_evidence,
1104 evidence_coverage: coverage,
1105 uncovered_terms,
1106 chronological,
1107 known_fact_conflicts: vec![],
1108 })
1109}
1110
1111pub fn search_memories_including_expired(
1123 conn: &Connection,
1124 limit: u32,
1125) -> KimetsuResult<Vec<ContextCapsule>> {
1126 let mut stmt = conn.prepare_cached(
1127 "
1128 SELECT memory_id, scope, kind, text, confidence, created_at,
1129 use_count, usefulness_score, valid_from, valid_to
1130 FROM memories
1131 WHERE invalidated_at IS NULL
1132 AND superseded_by IS NULL
1133 ORDER BY created_at DESC
1134 LIMIT ?1
1135 ",
1136 )?;
1137 let rows = stmt.query_map(params![limit], |row| {
1138 Ok((
1139 row.get::<_, String>(0)?,
1140 row.get::<_, String>(1)?,
1141 row.get::<_, String>(2)?,
1142 row.get::<_, String>(3)?,
1143 row.get::<_, f32>(4)?,
1144 row.get::<_, String>(5)?,
1145 row.get::<_, i64>(6)?,
1146 row.get::<_, f64>(7)?,
1147 row.get::<_, Option<String>>(8)?,
1148 row.get::<_, Option<String>>(9)?,
1149 ))
1150 })?;
1151 let now_utc = OffsetDateTime::now_utc();
1152 let mut capsules = Vec::new();
1153 for row in rows {
1154 let (
1155 memory_id,
1156 scope,
1157 kind,
1158 text,
1159 confidence,
1160 created_at,
1161 _use_count,
1162 _usefulness,
1163 _valid_from,
1164 valid_to,
1165 ) = row?;
1166 let freshness = freshness(&created_at);
1167 let scope_weight = scope_weight(&scope);
1168 let suffix = if let Some(ref vt) = valid_to {
1170 if OffsetDateTime::parse(vt, &time::format_description::well_known::Rfc3339)
1171 .is_ok_and(|end| end <= now_utc)
1172 {
1173 format!(" [expired valid_to={vt}]")
1174 } else {
1175 format!(" [valid_to={vt}]")
1176 }
1177 } else {
1178 String::new()
1179 };
1180 let revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1181 let facts = crate::fact_store::load(conn, &memory_id, &revision)?;
1182 let claim_revision = Some(revision);
1183 capsules.push(ContextCapsule {
1184 id: new_id().to_string(),
1185 kind: "memory".to_string(),
1186 summary: format!("{scope}:{kind} - {text}{suffix}"),
1187 token_estimate: estimate_tokens(&text) + 8,
1188 expansion_handle: format!("memory:{memory_id}"),
1189 provenance: vec![ProvenanceRef {
1190 source: "Memory".to_string(),
1191 id: memory_id,
1192 excerpt: Some(excerpt(&text)),
1193 }],
1194 confidence,
1195 freshness,
1196 relevance: 0.0,
1197 scope_weight,
1198 score: 0.0,
1199 superseded_hint: false,
1200 rerank_policy_tier: 0,
1201 claim_revision,
1202 facts,
1203 rerank_usefulness: None,
1204 rerank_trust: None,
1205 });
1206 }
1207 Ok(capsules)
1208}
1209
1210pub fn search_repo_files(
1211 conn: &Connection,
1212 repo_root: &str,
1213 query: &str,
1214 limit: u32,
1215) -> KimetsuResult<Vec<ContextCapsule>> {
1216 let candidates = repo_file_candidates(conn, repo_root, query, limit)?;
1217 let mut capsules = candidates
1218 .into_iter()
1219 .map(|mut candidate| {
1220 candidate.capsule.relevance = candidate.raw_relevance;
1221 candidate.capsule.score = candidate.raw_relevance;
1222 candidate.capsule
1223 })
1224 .collect::<Vec<_>>();
1225 capsules.sort_by(|left, right| {
1226 right
1227 .score
1228 .partial_cmp(&left.score)
1229 .unwrap_or(Ordering::Equal)
1230 .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
1231 });
1232 Ok(capsules)
1233}
1234
1235#[cfg(feature = "embeddings")]
1247fn memory_ann_candidates(
1248 conn: &Connection,
1249 qe: &QueryEmbedding,
1250 k: u32,
1251 query_tokens: &[String],
1252 half_life_days: f32,
1253 include_facts: bool,
1254) -> KimetsuResult<Vec<Candidate>> {
1255 let handle = crate::ann::handle_for_query(conn, qe.vector.len(), &qe.model_id)?;
1257 let hits = handle
1258 .read()
1259 .unwrap_or_else(|p| p.into_inner())
1260 .search(&qe.vector, k as usize)?;
1261 let knn_rowids: Vec<i64> = hits.into_iter().map(|(rowid, _dist)| rowid).collect();
1265 if knn_rowids.is_empty() {
1266 return Ok(Vec::new());
1267 }
1268
1269 let placeholders: String = knn_rowids
1271 .iter()
1272 .enumerate()
1273 .map(|(i, _)| format!("?{}", i + 1))
1274 .collect::<Vec<_>>()
1275 .join(", ");
1276 let sql = format!(
1277 "SELECT memory_id, scope, kind, text, confidence, created_at,
1278 use_count, usefulness_score, embedding, embedding_model,
1279 last_useful_at, provenance_snapshot_json
1280 FROM memories
1281 WHERE invalidated_at IS NULL
1282 AND superseded_by IS NULL
1283 AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
1284 AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
1285 AND embedding_model = ?{model_param}
1286 AND rowid IN ({placeholders})",
1287 model_param = knn_rowids.len() + 1
1288 );
1289 let mut stmt = conn.prepare(&sql)?;
1290 let mut params_vec: Vec<&dyn rusqlite::ToSql> = knn_rowids
1291 .iter()
1292 .map(|n| n as &dyn rusqlite::ToSql)
1293 .collect();
1294 params_vec.push(&qe.model_id);
1295 let rows_iter = stmt.query_map(params_vec.as_slice(), |row| {
1296 Ok((
1297 row.get::<_, String>(0)?,
1298 row.get::<_, String>(1)?,
1299 row.get::<_, String>(2)?,
1300 row.get::<_, String>(3)?,
1301 row.get::<_, f32>(4)?,
1302 row.get::<_, String>(5)?,
1303 row.get::<_, i64>(6)?,
1304 row.get::<_, f64>(7)?,
1305 row.get::<_, Option<Vec<u8>>>(8)?,
1306 row.get::<_, Option<String>>(9)?,
1307 row.get::<_, Option<String>>(10)?,
1308 row.get::<_, Option<String>>(11)?,
1309 ))
1310 })?;
1311
1312 let mut candidates = Vec::new();
1313 for row in rows_iter {
1314 let (
1315 memory_id,
1316 scope,
1317 kind,
1318 text,
1319 confidence,
1320 created_at,
1321 use_count,
1322 usefulness_score,
1323 embedding,
1324 embedding_model,
1325 last_useful_at,
1326 provenance_snapshot,
1327 ) = row?;
1328 let (cosine, row_vec) =
1329 compute_cosine_and_vec(Some(qe), embedding.as_deref(), embedding_model.as_deref());
1330 let claim_revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1331 if let Some(mut candidate) = memory_row_to_candidate(
1332 query_tokens,
1333 memory_id,
1334 scope,
1335 kind,
1336 text,
1337 confidence,
1338 created_at,
1339 use_count,
1340 usefulness_score,
1341 last_useful_at,
1342 provenance_snapshot,
1343 half_life_days,
1344 None, cosine,
1346 row_vec,
1347 ) {
1348 candidate.capsule.claim_revision = Some(claim_revision);
1349 hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
1350 candidates.push(candidate);
1351 }
1352 }
1353 Ok(candidates)
1354}
1355
1356pub(crate) fn hydrate_fact_evidence(
1358 conn: &Connection,
1359 candidate: &mut Candidate,
1360 include_facts: bool,
1361) -> KimetsuResult<()> {
1362 if include_facts {
1363 if let (Some(id), Some(revision)) = (
1364 candidate.capsule.expansion_handle.strip_prefix("memory:"),
1365 candidate.capsule.claim_revision.as_deref(),
1366 ) {
1367 candidate.capsule.facts = crate::fact_store::load(conn, id, revision)?;
1368 }
1369 }
1370 Ok(())
1371}
1372
1373pub(crate) fn memory_candidates_flat(
1379 conn: &Connection,
1380 query: &str,
1381 query_embedding: Option<&QueryEmbedding>,
1382 half_life_days: f32,
1383 fusion: crate::fusion::Fusion,
1384 include_facts: bool,
1385) -> KimetsuResult<Vec<Candidate>> {
1386 memory_candidates(
1387 conn,
1388 query,
1389 query_embedding,
1390 half_life_days,
1391 fusion,
1392 include_facts,
1393 )
1394}
1395
1396fn memory_candidates(
1403 conn: &Connection,
1404 query: &str,
1405 query_embedding: Option<&QueryEmbedding>,
1406 half_life_days: f32,
1407 #[cfg_attr(not(feature = "embeddings"), allow(unused_variables))] fusion: crate::fusion::Fusion,
1410 include_facts: bool,
1411) -> KimetsuResult<Vec<Candidate>> {
1412 let query_tokens = query_tokens(query);
1413
1414 #[cfg(feature = "embeddings")]
1419 if let Some(qe) = query_embedding {
1420 let fts_candidates = if let Some(fts_query) = fts_query(query) {
1422 memory_fts_candidates(
1423 conn,
1424 &query_tokens,
1425 &fts_query,
1426 80,
1427 Some(qe),
1428 half_life_days,
1429 include_facts,
1430 )?
1431 } else {
1432 Vec::new()
1433 };
1434
1435 let ann_candidates =
1437 memory_ann_candidates(conn, qe, 80, &query_tokens, half_life_days, include_facts)?;
1438
1439 return Ok(crate::fusion::fuse(
1441 fusion,
1442 vec![fts_candidates, ann_candidates],
1443 ));
1444 }
1445
1446 if let Some(fts_query) = fts_query(query) {
1448 let candidates = memory_fts_candidates(
1449 conn,
1450 &query_tokens,
1451 &fts_query,
1452 80,
1453 query_embedding,
1454 half_life_days,
1455 include_facts,
1456 )?;
1457 if !candidates.is_empty() {
1458 return Ok(candidates);
1459 }
1460 }
1461
1462 latest_memory_candidates(
1463 conn,
1464 &query_tokens,
1465 200,
1466 query_embedding,
1467 half_life_days,
1468 include_facts,
1469 )
1470}
1471
1472fn latest_memory_candidates(
1473 conn: &Connection,
1474 query_tokens: &[String],
1475 limit: u32,
1476 query_embedding: Option<&QueryEmbedding>,
1477 half_life_days: f32,
1478 include_facts: bool,
1479) -> KimetsuResult<Vec<Candidate>> {
1480 let mut stmt = conn.prepare_cached(
1491 "
1492 SELECT memory_id, scope, kind, text, confidence, created_at,
1493 use_count, usefulness_score, embedding, embedding_model,
1494 last_useful_at, provenance_snapshot_json
1495 FROM memories
1496 WHERE invalidated_at IS NULL
1497 AND superseded_by IS NULL
1498 AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
1499 AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
1500 ORDER BY created_at DESC
1501 LIMIT ?1
1502 ",
1503 )?;
1504
1505 let rows = stmt.query_map(params![limit], |row| {
1506 Ok((
1507 row.get::<_, String>(0)?,
1508 row.get::<_, String>(1)?,
1509 row.get::<_, String>(2)?,
1510 row.get::<_, String>(3)?,
1511 row.get::<_, f32>(4)?,
1512 row.get::<_, String>(5)?,
1513 row.get::<_, i64>(6)?,
1514 row.get::<_, f64>(7)?,
1515 row.get::<_, Option<Vec<u8>>>(8)?,
1516 row.get::<_, Option<String>>(9)?,
1517 row.get::<_, Option<String>>(10)?,
1518 row.get::<_, Option<String>>(11)?,
1519 ))
1520 })?;
1521
1522 let mut candidates = Vec::new();
1523 for row in rows {
1524 let (
1525 memory_id,
1526 scope,
1527 kind,
1528 text,
1529 confidence,
1530 created_at,
1531 use_count,
1532 usefulness_score,
1533 embedding,
1534 embedding_model,
1535 last_useful_at,
1536 provenance_snapshot,
1537 ) = row?;
1538 let (cosine, row_vec) = compute_cosine_and_vec(
1539 query_embedding,
1540 embedding.as_deref(),
1541 embedding_model.as_deref(),
1542 );
1543 let claim_revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1544 if let Some(mut candidate) = memory_row_to_candidate(
1545 query_tokens,
1546 memory_id,
1547 scope,
1548 kind,
1549 text,
1550 confidence,
1551 created_at,
1552 use_count,
1553 usefulness_score,
1554 last_useful_at,
1555 provenance_snapshot,
1556 half_life_days,
1557 None,
1558 cosine,
1559 row_vec,
1560 ) {
1561 candidate.capsule.claim_revision = Some(claim_revision);
1562 hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
1563 candidates.push(candidate);
1564 }
1565 }
1566 Ok(candidates)
1567}
1568
1569fn memory_fts_candidates(
1570 conn: &Connection,
1571 query_tokens: &[String],
1572 fts_query: &str,
1573 limit: u32,
1574 query_embedding: Option<&QueryEmbedding>,
1575 half_life_days: f32,
1576 include_facts: bool,
1577) -> KimetsuResult<Vec<Candidate>> {
1578 let mut stmt = conn.prepare_cached(
1579 "
1580 SELECT m.memory_id, m.scope, m.kind, m.text, m.confidence, m.created_at,
1581 m.use_count, m.usefulness_score, bm25(memories_fts) AS rank,
1582 m.embedding, m.embedding_model, m.last_useful_at,
1583 m.provenance_snapshot_json
1584 FROM memories_fts
1585 JOIN memories m
1586 ON m.memory_id = memories_fts.memory_id
1587 WHERE m.invalidated_at IS NULL
1588 AND m.superseded_by IS NULL
1589 AND (m.valid_from IS NULL OR julianday(m.valid_from) <= julianday('now'))
1590 AND (m.valid_to IS NULL OR julianday(m.valid_to) > julianday('now'))
1591 AND memories_fts MATCH ?1
1592 ORDER BY rank
1593 LIMIT ?2
1594 ",
1595 )?;
1596
1597 let rows = stmt.query_map(params![fts_query, limit], |row| {
1598 Ok((
1599 row.get::<_, String>(0)?,
1600 row.get::<_, String>(1)?,
1601 row.get::<_, String>(2)?,
1602 row.get::<_, String>(3)?,
1603 row.get::<_, f32>(4)?,
1604 row.get::<_, String>(5)?,
1605 row.get::<_, i64>(6)?,
1606 row.get::<_, f64>(7)?,
1607 row.get::<_, f64>(8)?,
1608 row.get::<_, Option<Vec<u8>>>(9)?,
1609 row.get::<_, Option<String>>(10)?,
1610 row.get::<_, Option<String>>(11)?,
1611 row.get::<_, Option<String>>(12)?,
1612 ))
1613 })?;
1614
1615 let mut candidates = Vec::new();
1616 for row in rows {
1617 let (
1618 memory_id,
1619 scope,
1620 kind,
1621 text,
1622 confidence,
1623 created_at,
1624 use_count,
1625 usefulness_score,
1626 rank,
1627 embedding,
1628 embedding_model,
1629 last_useful_at,
1630 provenance_snapshot,
1631 ) = row?;
1632 let fts_relevance = (-rank as f32).max(0.0);
1633 let (cosine, row_vec) = compute_cosine_and_vec(
1634 query_embedding,
1635 embedding.as_deref(),
1636 embedding_model.as_deref(),
1637 );
1638 let claim_revision = crate::projector::claim_revision_at(conn, &memory_id, None)?;
1639 if let Some(mut candidate) = memory_row_to_candidate(
1640 query_tokens,
1641 memory_id,
1642 scope,
1643 kind,
1644 text,
1645 confidence,
1646 created_at,
1647 use_count,
1648 usefulness_score,
1649 last_useful_at,
1650 provenance_snapshot,
1651 half_life_days,
1652 Some(fts_relevance),
1653 cosine,
1654 row_vec,
1655 ) {
1656 candidate.capsule.claim_revision = Some(claim_revision);
1657 hydrate_fact_evidence(conn, &mut candidate, include_facts)?;
1658 candidates.push(candidate);
1659 }
1660 }
1661 Ok(candidates)
1662}
1663
1664fn compute_cosine_and_vec(
1688 query_embedding: Option<&QueryEmbedding>,
1689 row_bytes: Option<&[u8]>,
1690 row_model: Option<&str>,
1691) -> (Option<f32>, Option<Vec<f32>>) {
1692 let q = match query_embedding {
1693 Some(q) => q,
1694 None => return (None, None),
1695 };
1696 let bytes = match row_bytes {
1697 Some(b) => b,
1698 None => return (None, None),
1699 };
1700 let model = match row_model {
1701 Some(m) => m,
1702 None => return (None, None),
1703 };
1704 if model != q.model_id {
1705 return (None, None);
1706 }
1707 let row_vec = match decode_embedding(bytes, Some(q.vector.len())) {
1708 Ok(v) => v,
1709 Err(_) => return (None, None),
1710 };
1711 let score = cosine_similarity(&q.vector, &row_vec);
1712 (Some(score), Some(row_vec))
1713}
1714
1715#[allow(clippy::too_many_arguments)]
1716pub(crate) fn memory_row_to_candidate(
1717 query_tokens: &[String],
1718 memory_id: String,
1719 scope: String,
1720 kind: String,
1721 text: String,
1722 confidence: f32,
1723 created_at: String,
1724 use_count: i64,
1725 usefulness_score: f64,
1726 last_useful_at: Option<String>,
1727 provenance_snapshot: Option<String>,
1730 half_life_days: f32,
1731 raw_relevance_override: Option<f32>,
1732 cosine_score: Option<f32>,
1733 row_embedding: Option<Vec<f32>>,
1738) -> Option<Candidate> {
1739 let lexical = lexical_relevance(query_tokens, &format!("{kind} {text}"));
1740 let lexical_term = raw_relevance_override.unwrap_or(lexical).max(lexical);
1741
1742 let raw_relevance = match cosine_score {
1754 Some(c) => {
1755 let normalized_cos = ((c + 1.0) * 0.5).clamp(0.0, 1.0);
1756 (1.0 - DEFAULT_HYBRID_ALPHA) * lexical_term + DEFAULT_HYBRID_ALPHA * normalized_cos
1757 }
1758 None => lexical_term,
1759 };
1760
1761 if raw_relevance <= 0.0 && !query_tokens.is_empty() {
1767 return None;
1768 }
1769
1770 let freshness = freshness(&created_at);
1771 let scope_weight = scope_weight(&scope);
1772 let raw_multiplier = usefulness_multiplier(usefulness_score as f32, use_count as u32);
1778 let decay = usefulness_decay(last_useful_at.as_deref(), &created_at, half_life_days);
1779 let multiplier = 1.0 + (raw_multiplier - 1.0) * decay;
1780 let biased_relevance = apply_usefulness_boost(raw_relevance, multiplier);
1781 let rerank_policy_tier = if multiplier > 1.0 + f32::EPSILON {
1783 1
1784 } else if multiplier < 1.0 - f32::EPSILON {
1785 -1
1786 } else {
1787 0
1788 };
1789 let provenance =
1791 crate::trust::Provenance::from_snapshot(provenance_snapshot.as_deref().unwrap_or("{}"));
1792 let trusted_relevance =
1793 biased_relevance * crate::trust::trust_multiplier(provenance, last_useful_at.is_some());
1794
1795 Some(Candidate {
1796 raw_relevance: trusted_relevance,
1797 embedding: row_embedding,
1798 cosine: cosine_score,
1799 created_at: Some(created_at),
1800 capsule: ContextCapsule {
1801 id: new_id().to_string(),
1802 kind: "memory".to_string(),
1803 summary: format!("{scope}:{kind} - {text}"),
1804 token_estimate: estimate_tokens(&text) + 8,
1805 expansion_handle: format!("memory:{memory_id}"),
1806 provenance: vec![ProvenanceRef {
1807 source: "Memory".to_string(),
1808 id: memory_id,
1809 excerpt: Some(excerpt(&text)),
1810 }],
1811 confidence,
1812 freshness,
1813 relevance: 0.0,
1814 scope_weight,
1815 score: 0.0,
1816 superseded_hint: false,
1817 rerank_policy_tier,
1818 claim_revision: None,
1819 facts: vec![],
1820 rerank_usefulness: Some(multiplier),
1821 rerank_trust: Some(crate::trust::trust_multiplier(
1822 provenance,
1823 last_useful_at.is_some(),
1824 )),
1825 },
1826 })
1827}
1828
1829pub(crate) fn usefulness_decay(
1853 last_useful_at: Option<&str>,
1854 created_at: &str,
1855 half_life_days: f32,
1856) -> f32 {
1857 if half_life_days <= 0.0 {
1858 return 1.0;
1859 }
1860 let reference = last_useful_at.unwrap_or(created_at);
1861 let Ok(reference_ts) =
1862 OffsetDateTime::parse(reference, &time::format_description::well_known::Rfc3339)
1863 else {
1864 return 1.0;
1865 };
1866 let age = OffsetDateTime::now_utc() - reference_ts;
1867 let age_days = (age.whole_seconds().max(0) as f32) / 86_400.0;
1868 let exponent = -std::f32::consts::LN_2 * age_days / half_life_days;
1869 exponent.exp().clamp(0.0, 1.0)
1870}
1871
1872pub(crate) use crate::scoring::USEFULNESS_BOOST_CAP;
1875
1876pub(crate) fn apply_usefulness_boost(raw_relevance: f32, multiplier: f32) -> f32 {
1879 if multiplier <= 1.0 {
1880 return raw_relevance * multiplier;
1881 }
1882 (raw_relevance * multiplier).min(raw_relevance + USEFULNESS_BOOST_CAP)
1883}
1884
1885pub(crate) fn usefulness_multiplier(usefulness_score: f32, use_count: u32) -> f32 {
1890 use crate::scoring::{FULL_CONFIDENCE_USES, MULTIPLIER_MAX, MULTIPLIER_MIN};
1898 if use_count == 0 {
1899 return 1.0;
1900 }
1901 let ratio = usefulness_score / use_count as f32; let normalized = ((ratio + 1.0) / 2.0).clamp(0.0, 1.0); let full_multiplier = MULTIPLIER_MIN + normalized * (MULTIPLIER_MAX - MULTIPLIER_MIN);
1904 let confidence = (use_count as f32 / FULL_CONFIDENCE_USES as f32).min(1.0);
1905 1.0 * (1.0 - confidence) + full_multiplier * confidence
1906}
1907
1908fn repo_file_candidates(
1909 conn: &Connection,
1910 repo_root: &str,
1911 query: &str,
1912 limit: u32,
1913) -> KimetsuResult<Vec<Candidate>> {
1914 let Some(fts_query) = fts_query(query) else {
1915 return Ok(Vec::new());
1916 };
1917
1918 let mut stmt = conn.prepare_cached(
1919 "
1920 SELECT path, snippet, language_guess, bm25(repo_files_fts) AS rank
1921 FROM repo_files_fts
1922 WHERE repo_root = ?1 AND repo_files_fts MATCH ?2
1923 ORDER BY rank
1924 LIMIT ?3
1925 ",
1926 )?;
1927
1928 let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
1929 Ok((
1930 row.get::<_, String>(0)?,
1931 row.get::<_, String>(1)?,
1932 row.get::<_, String>(2)?,
1933 row.get::<_, f64>(3)?,
1934 ))
1935 })?;
1936
1937 let mut candidates = Vec::new();
1938 for row in rows {
1939 let (path, snippet, language, rank) = row?;
1940 let raw_relevance = (-rank as f32).max(0.0);
1941 let summary = format!("{path} ({language}) - {}", excerpt(&snippet));
1942 let token_estimate = estimate_tokens(&summary) + 8;
1943 candidates.push(Candidate {
1944 raw_relevance,
1945 embedding: None,
1946 cosine: None,
1947 created_at: None,
1949 capsule: ContextCapsule {
1950 id: new_id().to_string(),
1951 kind: "repo_file".to_string(),
1952 summary,
1953 token_estimate,
1954 expansion_handle: format!("file:{path}"),
1955 provenance: vec![ProvenanceRef {
1956 source: "RepoFile".to_string(),
1957 id: path.clone(),
1958 excerpt: Some(excerpt(&snippet)),
1959 }],
1960 confidence: 0.9,
1961 freshness: 1.0,
1962 relevance: 0.0,
1963 scope_weight: 0.9,
1964 score: 0.0,
1965 superseded_hint: false,
1966 rerank_policy_tier: 0,
1967 claim_revision: None,
1968 facts: vec![],
1969 rerank_usefulness: None,
1970 rerank_trust: None,
1971 },
1972 });
1973 }
1974 Ok(candidates)
1975}
1976
1977fn manifest_candidates(
1978 conn: &Connection,
1979 repo_root: &str,
1980 query: &str,
1981) -> KimetsuResult<Vec<Candidate>> {
1982 if let Some(fts_query) = fts_query(query) {
1983 let candidates = manifest_fts_candidates(conn, repo_root, &fts_query, 30)?;
1984 if !candidates.is_empty() {
1985 return Ok(candidates);
1986 }
1987 }
1988
1989 let query_tokens = query_tokens(query);
1990 let mut stmt = conn.prepare_cached(
1991 "
1992 SELECT manifest_path, manifest_kind, parsed_summary_json
1993 FROM repo_manifests
1994 WHERE repo_root = ?1
1995 ORDER BY manifest_path
1996 ",
1997 )?;
1998
1999 let rows = stmt.query_map(params![repo_root], |row| {
2000 Ok((
2001 row.get::<_, String>(0)?,
2002 row.get::<_, String>(1)?,
2003 row.get::<_, String>(2)?,
2004 ))
2005 })?;
2006
2007 let mut candidates = Vec::new();
2008 for row in rows {
2009 let (path, kind, summary_json) = row?;
2010 let raw_relevance =
2011 lexical_relevance(&query_tokens, &format!("{path} {kind} {summary_json}"));
2012 if raw_relevance <= 0.0 && !query_tokens.is_empty() {
2013 continue;
2014 }
2015 let summary = format!("{path} manifest ({kind})");
2016 let token_estimate = estimate_tokens(&summary) + 8;
2017 candidates.push(Candidate {
2018 raw_relevance,
2019 embedding: None,
2020 cosine: None,
2021 created_at: None,
2023 capsule: ContextCapsule {
2024 id: new_id().to_string(),
2025 kind: "repo_manifest".to_string(),
2026 summary,
2027 token_estimate,
2028 expansion_handle: format!("file:{path}"),
2029 provenance: vec![ProvenanceRef {
2030 source: "Manifest".to_string(),
2031 id: path,
2032 excerpt: Some(excerpt(&summary_json)),
2033 }],
2034 confidence: 0.95,
2035 freshness: 1.0,
2036 relevance: 0.0,
2037 scope_weight: 0.9,
2038 score: 0.0,
2039 superseded_hint: false,
2040 rerank_policy_tier: 0,
2041 claim_revision: None,
2042 facts: vec![],
2043 rerank_usefulness: None,
2044 rerank_trust: None,
2045 },
2046 });
2047 }
2048 Ok(candidates)
2049}
2050
2051fn manifest_fts_candidates(
2052 conn: &Connection,
2053 repo_root: &str,
2054 fts_query: &str,
2055 limit: u32,
2056) -> KimetsuResult<Vec<Candidate>> {
2057 let mut stmt = conn.prepare_cached(
2058 "
2059 SELECT manifest_path, manifest_kind, parsed_summary_json,
2060 bm25(repo_manifests_fts) AS rank
2061 FROM repo_manifests_fts
2062 WHERE repo_root = ?1 AND repo_manifests_fts MATCH ?2
2063 ORDER BY rank
2064 LIMIT ?3
2065 ",
2066 )?;
2067
2068 let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
2069 Ok((
2070 row.get::<_, String>(0)?,
2071 row.get::<_, String>(1)?,
2072 row.get::<_, String>(2)?,
2073 row.get::<_, f64>(3)?,
2074 ))
2075 })?;
2076
2077 let mut candidates = Vec::new();
2078 for row in rows {
2079 let (path, kind, summary_json, rank) = row?;
2080 let raw_relevance = (-rank as f32).max(0.0);
2081 let summary = format!("{path} manifest ({kind})");
2082 let token_estimate = estimate_tokens(&summary) + 8;
2083 candidates.push(Candidate {
2084 raw_relevance,
2085 embedding: None,
2086 cosine: None,
2087 created_at: None,
2089 capsule: ContextCapsule {
2090 id: new_id().to_string(),
2091 kind: "repo_manifest".to_string(),
2092 summary,
2093 token_estimate,
2094 expansion_handle: format!("file:{path}"),
2095 provenance: vec![ProvenanceRef {
2096 source: "Manifest".to_string(),
2097 id: path,
2098 excerpt: Some(excerpt(&summary_json)),
2099 }],
2100 confidence: 0.95,
2101 freshness: 1.0,
2102 relevance: 0.0,
2103 scope_weight: 0.9,
2104 score: 0.0,
2105 superseded_hint: false,
2106 rerank_policy_tier: 0,
2107 claim_revision: None,
2108 facts: vec![],
2109 rerank_usefulness: None,
2110 rerank_trust: None,
2111 },
2112 });
2113 }
2114 Ok(candidates)
2115}
2116
2117#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2149pub enum Normalization {
2150 #[default]
2152 PerKind,
2153 Global,
2155}
2156
2157impl Normalization {
2158 pub fn from_config(value: &str) -> Self {
2162 match value.trim().to_ascii_lowercase().as_str() {
2163 "global" => Self::Global,
2164 _ => Self::PerKind,
2165 }
2166 }
2167}
2168
2169fn normalize_and_score(
2170 candidates: &mut [Candidate],
2171 weights: StageWeights,
2172 normalization: Normalization,
2173) {
2174 let mut max_by_kind = HashMap::<String, f32>::new();
2177 let bucket = |candidate: &Candidate| match normalization {
2178 Normalization::PerKind => candidate.capsule.kind.clone(),
2179 Normalization::Global => String::new(),
2180 };
2181 for candidate in candidates.iter() {
2182 max_by_kind
2183 .entry(bucket(candidate))
2184 .and_modify(|max| *max = (*max).max(candidate.raw_relevance))
2185 .or_insert(candidate.raw_relevance);
2186 }
2187
2188 for candidate in candidates {
2189 let max = max_by_kind.get(&bucket(candidate)).copied().unwrap_or(0.0);
2190 let relevance = if max <= f32::EPSILON {
2191 if candidate.raw_relevance > 0.0 {
2192 1.0
2193 } else {
2194 0.0
2195 }
2196 } else {
2197 (candidate.raw_relevance / max).clamp(0.0, 1.0)
2198 };
2199 candidate.capsule.relevance = relevance;
2200 candidate.capsule.score = weights.relevance * relevance
2201 + weights.confidence * candidate.capsule.confidence
2202 + weights.freshness * candidate.capsule.freshness
2203 + weights.scope * candidate.capsule.scope_weight;
2204 }
2205}
2206
2207pub(crate) const SUPERSESSION_MIN_COSINE: f32 = 0.85;
2212
2213pub(crate) const SUPERSESSION_CUE_MIN_COSINE: f32 = 0.82;
2218
2219pub(crate) const SUPERSESSION_CUE_MIN_SHARED_TOKENS: usize = 3;
2224pub(crate) const SUPERSESSION_CUE_MIN_LEXICAL_OVERLAP: f32 = 0.35;
2225
2226pub(crate) const SUPERSESSION_PENALTY: f32 = 0.80;
2232
2233pub(crate) const SUPERSESSION_MIN_AGE_GAP_SECS: f64 = 1.0;
2242
2243fn has_explicit_supersession_signal(text: &str) -> bool {
2249 let words = text
2255 .to_ascii_lowercase()
2256 .chars()
2257 .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
2258 .collect::<String>();
2259 let text = format!(
2260 " {} ",
2261 words.split_whitespace().collect::<Vec<_>>().join(" ")
2262 );
2263 [
2264 " now ",
2265 " no longer ",
2266 " renamed from ",
2267 " switched from ",
2268 " as of ",
2269 " currently ",
2270 " after the ",
2271 " supersedes ",
2272 " replaced by ",
2273 " silently breaks ",
2274 ]
2275 .iter()
2276 .any(|signal| text.contains(signal))
2277}
2278
2279fn has_supersession_lexical_identity(a: &str, b: &str) -> bool {
2280 let a = content_tokens(a);
2281 let b = content_tokens(b);
2282 let smaller = a.len().min(b.len());
2283 if smaller == 0 {
2284 return false;
2285 }
2286 let shared = a.iter().filter(|token| b.contains(token)).count();
2287 shared >= SUPERSESSION_CUE_MIN_SHARED_TOKENS
2288 && shared as f32 / smaller as f32 >= SUPERSESSION_CUE_MIN_LEXICAL_OVERLAP
2289}
2290
2291pub(crate) fn apply_supersession_penalty(candidates: &mut [Candidate]) {
2304 let parsed: Vec<Option<OffsetDateTime>> = candidates
2305 .iter()
2306 .map(|c| {
2307 if c.capsule.kind != "memory" || c.embedding.is_none() {
2308 return None;
2309 }
2310 c.created_at.as_deref().and_then(|ts| {
2311 OffsetDateTime::parse(ts, &time::format_description::well_known::Rfc3339).ok()
2312 })
2313 })
2314 .collect();
2315
2316 let mut penalized = vec![false; candidates.len()];
2317 for i in 0..candidates.len() {
2318 let Some(ti) = parsed[i] else { continue };
2319 for j in (i + 1)..candidates.len() {
2320 let Some(tj) = parsed[j] else { continue };
2321 let (Some(ei), Some(ej)) = (&candidates[i].embedding, &candidates[j].embedding) else {
2322 continue;
2323 };
2324 let signals = (
2325 has_explicit_supersession_signal(&candidates[i].capsule.summary),
2326 has_explicit_supersession_signal(&candidates[j].capsule.summary),
2327 );
2328 let exactly_one_signal = signals.0 ^ signals.1;
2329 let similarity = crate::embeddings::cosine_similarity(ei, ej);
2330 let cue_related = exactly_one_signal
2331 && (similarity >= SUPERSESSION_CUE_MIN_COSINE
2332 || has_supersession_lexical_identity(
2333 &candidates[i].capsule.summary,
2334 &candidates[j].capsule.summary,
2335 ));
2336 if similarity < SUPERSESSION_MIN_COSINE && !cue_related {
2337 continue;
2338 }
2339 let gap = (ti - tj).abs();
2344 let subsecond = (gap.whole_milliseconds().unsigned_abs() as f64)
2345 < SUPERSESSION_MIN_AGE_GAP_SECS * 1000.0;
2346 let older = if subsecond {
2347 match signals {
2348 (true, false) => j,
2349 (false, true) => i,
2350 _ => continue,
2351 }
2352 } else {
2353 match ti.cmp(&tj) {
2354 Ordering::Less => i,
2355 Ordering::Greater => j,
2356 Ordering::Equal => continue,
2357 }
2358 };
2359 if similarity < SUPERSESSION_CUE_MIN_COSINE {
2360 let replacement = if older == i { j } else { i };
2369 let replacement_has_signal = if replacement == i {
2370 signals.0
2371 } else {
2372 signals.1
2373 };
2374 let query_favors_replacement =
2375 match (candidates[replacement].cosine, candidates[older].cosine) {
2376 (Some(replacement_cosine), Some(older_cosine)) => {
2377 replacement_cosine >= older_cosine
2378 }
2379 _ => false,
2380 };
2381 if !replacement_has_signal || !query_favors_replacement {
2382 continue;
2383 }
2384 }
2385 if !penalized[older] {
2386 penalized[older] = true;
2387 candidates[older].capsule.score *= SUPERSESSION_PENALTY;
2388 candidates[older].capsule.superseded_hint = true;
2391 }
2392 }
2393 }
2394}
2395
2396fn weights_for_stage(weights: &BrokerWeights, stage: &str) -> StageWeights {
2397 match stage {
2398 "localization" => weights.localization.clone(),
2399 "patch_plan" => weights.patch_plan.clone(),
2400 "verification" => weights.verification.clone(),
2401 "review" => weights.review.clone(),
2402 _ => None,
2403 }
2404 .unwrap_or(StageWeights {
2405 relevance: weights.relevance,
2406 confidence: weights.confidence,
2407 freshness: weights.freshness,
2408 scope: weights.scope,
2409 })
2410}
2411
2412fn scope_weight(scope: &str) -> f32 {
2413 match scope.parse::<MemoryScope>() {
2414 Ok(MemoryScope::Run) => 1.0,
2415 Ok(MemoryScope::Repo) => 0.9,
2416 Ok(MemoryScope::Project) => 0.7,
2417 Ok(MemoryScope::GlobalUser) => 0.5,
2418 Err(_) => 0.3,
2419 }
2420}
2421
2422fn freshness(created_at: &str) -> f32 {
2423 let Ok(created_at) =
2424 OffsetDateTime::parse(created_at, &time::format_description::well_known::Rfc3339)
2425 else {
2426 return 0.5;
2427 };
2428 let age = OffsetDateTime::now_utc() - created_at;
2429 let age_days = age.whole_seconds().max(0) as f32 / 86_400.0;
2430 (-std::f32::consts::LN_2 * age_days / 30.0)
2431 .exp()
2432 .clamp(0.0, 1.0)
2433}
2434
2435const SEMANTIC_KEEP_COSINE: f32 = 0.20;
2440
2441const STOPWORDS: &[&str] = &[
2446 "the", "and", "for", "are", "but", "not", "you", "your", "with", "this", "that", "these",
2447 "those", "from", "into", "about", "what", "whats", "which", "who", "whom", "how", "why",
2448 "when", "where", "can", "could", "would", "should", "will", "shall", "does", "did", "was",
2449 "were", "been", "being", "have", "has", "had", "its", "it", "is", "as", "at", "by", "of", "to",
2450 "in", "on", "or", "an", "be", "do", "me", "my", "we", "us", "our", "im", "ive", "let", "lets",
2451 "please", "tell", "give", "show", "want", "need", "get", "got", "use", "using", "there",
2452 "their", "they", "them", "then", "than", "some", "any", "all", "more", "most", "such", "via",
2453 "per",
2454 "during", "while", "until", "unless", "before", "after", "again", "against", "above", "below",
2463 "between", "through", "under", "over", "because", "also", "just", "only", "very", "much",
2464 "many", "each", "both", "same", "other", "another", "always", "never", "still", "even", "ever",
2465 "every", "first", "found", "thing", "things", "value", "default", "if", "so", "up", "out",
2466 "off", "down", "no", "yes",
2467];
2468
2469fn content_tokens(query: &str) -> Vec<String> {
2474 let mut seen = std::collections::HashSet::new();
2475 query
2476 .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
2477 .map(str::trim)
2478 .filter(|part| part.len() >= 2)
2479 .map(str::to_ascii_lowercase)
2480 .filter(|t| !STOPWORDS.contains(&t.as_str()))
2481 .map(|t| light_stem(&t).to_string())
2484 .filter(|t| seen.insert(t.clone()))
2485 .collect()
2486}
2487
2488fn corpus_token_idf(conn: &Connection, tokens: &[String]) -> KimetsuResult<HashMap<String, f32>> {
2506 token_idf(conn, tokens, true)
2507}
2508
2509fn token_idf(
2514 conn: &Connection,
2515 tokens: &[String],
2516 zero_absent: bool,
2517) -> KimetsuResult<HashMap<String, f32>> {
2518 let n: i64 = conn.query_row(
2519 "SELECT COUNT(*) FROM memories_fts JOIN memories m USING(memory_id) WHERE m.invalidated_at IS NULL",
2520 [], |r| r.get(0))?;
2521 let mut idf = HashMap::new();
2522 if n == 0 {
2523 return Ok(idf);
2524 }
2525 let mut stmt = conn.prepare_cached(
2526 "SELECT COUNT(DISTINCT m.memory_id) FROM memories_fts JOIN memories m USING(memory_id)
2527 WHERE memories_fts MATCH ?1 AND m.invalidated_at IS NULL",
2528 )?;
2529 for token in tokens {
2530 if idf.contains_key(token) {
2531 continue;
2532 }
2533 let pattern = format!("text : \"{}\"*", token.replace('"', "\"\""));
2534 let df: i64 = stmt.query_row(params![pattern], |r| r.get(0))?;
2535 let weight = if zero_absent && df == 0 {
2536 0.0
2537 } else {
2538 (((n + 1) as f32) / ((df + 1) as f32)).ln().max(0.0)
2539 };
2540 idf.insert(token.clone(), weight);
2541 }
2542 Ok(idf)
2543}
2544
2545fn weighted_coverage(content: &[String], idf: &HashMap<String, f32>, summary: &str) -> f32 {
2552 let haystack = summary.to_ascii_lowercase();
2553 let mut total = 0.0f32;
2554 let mut hit = 0.0f32;
2555 for token in content {
2556 let weight = idf.get(token).copied().unwrap_or(0.0);
2557 total += weight;
2558 if weight > 0.0 && haystack.contains(token.as_str()) {
2559 hit += weight;
2560 }
2561 }
2562 if total <= f32::EPSILON {
2563 0.0
2564 } else {
2565 (hit / total).clamp(0.0, 1.0)
2566 }
2567}
2568
2569fn light_stem(token: &str) -> &str {
2594 let mut stem = token;
2595 for suffix in ["ing", "ed", "es", "s"] {
2596 if let Some(stripped) = token.strip_suffix(suffix)
2597 && stripped.len() >= 4
2598 {
2599 stem = stripped;
2600 break;
2601 }
2602 }
2603 if stem.len() >= 5
2604 && let Some(trimmed) = stem.strip_suffix('y').or_else(|| stem.strip_suffix('i'))
2605 && trimmed
2606 .chars()
2607 .next_back()
2608 .is_some_and(|c| !matches!(c, 'a' | 'e' | 'i' | 'o' | 'u'))
2609 {
2610 return trimmed;
2611 }
2612 stem
2613}
2614
2615fn query_tokens(query: &str) -> Vec<String> {
2616 let mut tokens: Vec<String> = query
2617 .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
2618 .map(str::trim)
2619 .filter(|part| part.len() >= 2)
2620 .map(str::to_ascii_lowercase)
2621 .map(|t| light_stem(&t).to_string())
2622 .collect();
2623 let lower = query.to_ascii_lowercase();
2630 for (triggers, expansions) in CLASS_HINTS.iter() {
2631 if triggers.iter().any(|t| lower.contains(t)) {
2632 tokens.extend(expansions.iter().map(|e| e.to_string()));
2633 }
2634 }
2635 tokens
2636}
2637
2638const CLASS_HINTS: &[(&[&str], &[&str])] = &[
2646 (
2647 &[
2648 "build",
2649 "compile",
2650 "make",
2651 "cargo",
2652 "cmake",
2653 "configure",
2654 "install",
2655 "train",
2656 "benchmark",
2657 "test suite",
2658 "ray trace",
2659 "render",
2660 ],
2661 &[
2662 "shell_background",
2663 "shell_status",
2664 "shell_output",
2665 "shell_stop",
2666 "long_running",
2667 ],
2668 ),
2669 (
2670 &[
2671 "edit", "modify", "change", "fix", "update", "patch", "refactor", "rename",
2672 ],
2673 &["edit_file", "apply_patch", "old_string", "new_string"],
2674 ),
2675 (
2676 &[
2677 "read", "inspect", "review", "analyze", "examine", "view", "show",
2678 ],
2679 &["read_file", "offset", "limit", "multi_read"],
2680 ),
2681 (
2682 &["find", "locate", "search", "look up", "discover", "list"],
2683 &["glob", "search_files", "list_files"],
2684 ),
2685 (
2686 &["plan", "step", "checklist", "todo", "task list", "phase"],
2687 &["plan", "todos"],
2688 ),
2689 (
2690 &[
2691 "verify",
2692 "check",
2693 "ensure",
2694 "validate",
2695 "pass test",
2696 "verifier",
2697 ],
2698 &["finish", "verifier", "verification"],
2699 ),
2700 (
2701 &[
2702 "image",
2703 "png",
2704 "jpeg",
2705 "jpg",
2706 "pdf",
2707 "diagram",
2708 "screenshot",
2709 ],
2710 &["view_image", "base64", "sha256"],
2711 ),
2712 (&["delete", "remove", "rm "], &["delete_file", "recursive"]),
2713 (&["rename", "move file", "mv "], &["move_file"]),
2714];
2715
2716fn capsule_matches_kind(capsule: &ContextCapsule, wanted: &str) -> bool {
2721 if capsule.kind == wanted {
2722 return true;
2723 }
2724 if capsule.kind == "memory"
2725 && let Some((prefix, _)) = capsule.summary.split_once(" - ")
2726 && let Some((_scope, mkind)) = prefix.split_once(':')
2727 {
2728 return mkind == wanted;
2729 }
2730 false
2731}
2732
2733pub(crate) fn fts_query(query: &str) -> Option<String> {
2734 let tokens = query_tokens(query);
2735 if tokens.is_empty() {
2736 return None;
2737 }
2738 Some(
2739 tokens
2740 .into_iter()
2741 .take(12)
2742 .map(|token| format!("{token}*"))
2743 .collect::<Vec<_>>()
2744 .join(" OR "),
2745 )
2746}
2747
2748fn apply_candidate_mmr_diversity(mut sorted: Vec<Candidate>, lambda: f32) -> Vec<Candidate> {
2768 if sorted.len() <= 1 {
2769 return sorted;
2770 }
2771 let summaries: Vec<std::collections::HashSet<String>> = sorted
2773 .iter()
2774 .map(|c| summary_token_set(&c.capsule.summary))
2775 .collect();
2776
2777 let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
2778 let mut remaining: Vec<usize> = (0..sorted.len()).collect();
2779
2780 picked_indices.push(remaining.remove(0));
2782
2783 while !remaining.is_empty() {
2784 let mut best_idx_in_remaining = 0;
2785 let mut best_score = f32::MIN;
2786
2787 for (i, &cand) in remaining.iter().enumerate() {
2788 let mut max_overlap = 0.0f32;
2789 for &p in &picked_indices {
2790 let same_kind = sorted[cand].capsule.kind == sorted[p].capsule.kind;
2793 let raw_overlap = candidate_pair_overlap(
2794 &sorted[cand],
2795 &sorted[p],
2796 &summaries[cand],
2797 &summaries[p],
2798 );
2799 let overlap = if same_kind {
2800 raw_overlap
2801 } else {
2802 raw_overlap * 0.5
2803 };
2804 if overlap > max_overlap {
2805 max_overlap = overlap;
2806 }
2807 }
2808 let mmr = lambda * sorted[cand].capsule.score - (1.0 - lambda) * max_overlap;
2809 if mmr > best_score {
2810 best_score = mmr;
2811 best_idx_in_remaining = i;
2812 }
2813 }
2814 picked_indices.push(remaining.remove(best_idx_in_remaining));
2815 }
2816
2817 let mut taken: Vec<Option<Candidate>> = sorted.drain(..).map(Some).collect();
2819 let mut out = Vec::with_capacity(taken.len());
2820 for idx in picked_indices {
2821 if let Some(c) = taken[idx].take() {
2822 out.push(c);
2823 }
2824 }
2825 out
2826}
2827
2828fn candidate_pair_overlap(
2835 a: &Candidate,
2836 b: &Candidate,
2837 tokens_a: &std::collections::HashSet<String>,
2838 tokens_b: &std::collections::HashSet<String>,
2839) -> f32 {
2840 if let (Some(va), Some(vb)) = (a.embedding.as_deref(), b.embedding.as_deref()) {
2841 cosine_similarity(va, vb).max(0.0)
2846 } else {
2847 jaccard(tokens_a, tokens_b)
2848 }
2849}
2850
2851fn apply_mmr_diversity(mut sorted: Vec<ContextCapsule>, lambda: f32) -> Vec<ContextCapsule> {
2863 if sorted.len() <= 1 {
2864 return sorted;
2865 }
2866 let summaries: Vec<std::collections::HashSet<String>> = sorted
2868 .iter()
2869 .map(|c| summary_token_set(&c.summary))
2870 .collect();
2871 let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
2872 let mut remaining: Vec<usize> = (0..sorted.len()).collect();
2873
2874 picked_indices.push(remaining.remove(0));
2876
2877 while !remaining.is_empty() {
2878 let mut best_idx_in_remaining = 0;
2879 let mut best_score = f32::MIN;
2880 for (i, &cand) in remaining.iter().enumerate() {
2881 let mut max_overlap = 0.0f32;
2882 for &p in &picked_indices {
2883 let raw = jaccard(&summaries[cand], &summaries[p]);
2884 let overlap = if sorted[cand].kind == sorted[p].kind {
2885 raw
2886 } else {
2887 raw * 0.5
2890 };
2891 if overlap > max_overlap {
2892 max_overlap = overlap;
2893 }
2894 }
2895 let mmr = lambda * sorted[cand].score - (1.0 - lambda) * max_overlap;
2896 if mmr > best_score {
2897 best_score = mmr;
2898 best_idx_in_remaining = i;
2899 }
2900 }
2901 picked_indices.push(remaining.remove(best_idx_in_remaining));
2902 }
2903 let mut out = Vec::with_capacity(sorted.len());
2905 let mut taken: Vec<Option<ContextCapsule>> = sorted.drain(..).map(Some).collect();
2907 for idx in picked_indices {
2908 if let Some(c) = taken[idx].take() {
2909 out.push(c);
2910 }
2911 }
2912 out
2913}
2914
2915fn summary_token_set(s: &str) -> std::collections::HashSet<String> {
2916 s.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
2917 .filter(|t| t.len() >= 3)
2918 .map(str::to_ascii_lowercase)
2919 .collect()
2920}
2921
2922fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f32 {
2923 if a.is_empty() && b.is_empty() {
2924 return 0.0;
2925 }
2926 let intersection = a.intersection(b).count();
2927 let union = a.union(b).count();
2928 intersection as f32 / union.max(1) as f32
2929}
2930
2931fn lexical_relevance(tokens: &[String], haystack: &str) -> f32 {
2932 if tokens.is_empty() {
2933 return 0.0;
2934 }
2935 let haystack = haystack.to_ascii_lowercase();
2936 let matches = tokens
2937 .iter()
2938 .filter(|token| haystack.contains(token.as_str()))
2939 .count();
2940 matches as f32 / tokens.len() as f32
2941}
2942
2943pub fn estimate_tokens(text: &str) -> u32 {
2944 ((text.split_whitespace().count() as f32) * 1.33).ceil() as u32
2945}
2946
2947pub fn compress_for_render(summary: &str, max_sentences: usize) -> String {
2970 if max_sentences == 0 {
2971 return summary.to_string();
2972 }
2973
2974 let text = if let Some(rest) = summary.strip_prefix('[') {
2976 if let Some(idx) = rest.find(']') {
2978 rest[idx + 1..].trim_start()
2979 } else {
2980 summary
2981 }
2982 } else {
2983 summary
2984 };
2985
2986 let text = if let Some(idx) = text.rfind('(') {
2988 let candidate = text[..idx].trim_end();
2989 let inner = &text[idx + 1..];
2992 if inner.contains(':') && inner.trim_end().ends_with(')') {
2993 candidate
2994 } else {
2995 text
2996 }
2997 } else {
2998 text
2999 };
3000
3001 let (scope_prefix, body) = if let Some(dash_pos) = text.find(" - ") {
3003 let prefix_candidate = &text[..dash_pos];
3004 if !prefix_candidate.contains(' ') && prefix_candidate.contains(':') {
3006 let body_start = dash_pos + 3; (&text[..body_start], &text[body_start..])
3008 } else {
3009 ("", text)
3010 }
3011 } else {
3012 ("", text)
3013 };
3014
3015 let compressed_body = cap_sentences(body, max_sentences);
3017
3018 let result = if scope_prefix.is_empty() {
3020 compressed_body.to_string()
3021 } else {
3022 format!("{scope_prefix}{compressed_body}")
3023 };
3024
3025 if result.trim().is_empty() {
3026 summary.to_string()
3027 } else {
3028 result
3029 }
3030}
3031
3032fn cap_sentences(text: &str, n: usize) -> &str {
3036 let bytes = text.as_bytes();
3037 let len = bytes.len();
3038 let mut count = 0;
3039 let mut i = 0;
3040 while i < len {
3041 if bytes[i] == b'.' {
3043 let next = i + 1;
3044 if next < len && (bytes[next] == b' ' || bytes[next] == b'\n') {
3045 count += 1;
3046 if count >= n {
3047 return text[..=i].trim_end();
3049 }
3050 }
3051 }
3052 i += 1;
3053 }
3054 text.trim_end()
3056}
3057
3058fn excerpt(text: &str) -> String {
3059 let value = one_line(text);
3060 value.chars().take(256).collect()
3061}
3062
3063fn one_line(text: &str) -> String {
3064 text.split_whitespace().collect::<Vec<_>>().join(" ")
3065}
3066
3067const FILE_EXPAND_CAP_BYTES: usize = 2048;
3074
3075pub fn resolve_capsule(
3087 conn: &Connection,
3088 repo_root: &std::path::Path,
3089 handle: &str,
3090) -> kimetsu_core::KimetsuResult<String> {
3091 if let Some(memory_id) = handle.strip_prefix("memory:") {
3092 let mut stmt = conn.prepare_cached(
3094 "SELECT text FROM memories WHERE memory_id = ? AND invalidated_at IS NULL",
3095 )?;
3096 let text: Option<String> = stmt
3097 .query_row(rusqlite::params![memory_id], |row| row.get(0))
3098 .optional()?;
3099 match text {
3100 Some(t) => Ok(t),
3101 None => {
3102 Err(format!("expand_capsule: no active memory found for handle `{handle}`").into())
3103 }
3104 }
3105 } else if let Some(rel_path) = handle.strip_prefix("file:") {
3106 let path = std::path::Path::new(rel_path);
3111 if path.is_absolute() {
3112 return Err(format!(
3113 "expand_capsule: `{handle}` is an absolute path — only repo-relative paths are supported"
3114 )
3115 .into());
3116 }
3117 for component in path.components() {
3118 match component {
3119 std::path::Component::ParentDir => {
3120 return Err(format!(
3121 "expand_capsule: `{handle}` contains `..` traversal — rejected"
3122 )
3123 .into());
3124 }
3125 std::path::Component::RootDir | std::path::Component::Prefix(_) => {
3126 return Err(format!(
3127 "expand_capsule: `{handle}` is an absolute path — only repo-relative paths are supported"
3128 )
3129 .into());
3130 }
3131 _ => {}
3132 }
3133 }
3134 let full_path = repo_root.join(path);
3135 let bytes = std::fs::read(&full_path)
3136 .map_err(|e| format!("expand_capsule: could not read `{rel_path}`: {e}"))?;
3137 let bounded = if bytes.len() > FILE_EXPAND_CAP_BYTES {
3139 let mut end = FILE_EXPAND_CAP_BYTES;
3140 while end > 0 && (bytes[end] & 0xC0) == 0x80 {
3142 end -= 1;
3143 }
3144 let s = String::from_utf8_lossy(&bytes[..end]);
3145 format!(
3146 "{s}\n[... truncated at {FILE_EXPAND_CAP_BYTES} bytes; call expand_capsule again with a line range if needed]"
3147 )
3148 } else {
3149 String::from_utf8_lossy(&bytes).into_owned()
3150 };
3151 Ok(bounded)
3152 } else if handle.starts_with("run:") {
3153 Err(format!(
3154 "expand_capsule: `run:` handle expansion is not yet supported (handle: `{handle}`)"
3155 )
3156 .into())
3157 } else {
3158 Err(format!(
3159 "expand_capsule: unrecognised handle format `{handle}`; \
3160 expected `memory:<id>`, `file:<path>`, or `run:<id>`"
3161 )
3162 .into())
3163 }
3164}
3165
3166pub const ABSTAIN_BAND_WIDTH: f32 = 0.10;
3178
3179fn abstain_band_width() -> f32 {
3180 std::env::var("KIMETSU_ABSTAIN_BAND_WIDTH")
3181 .ok()
3182 .and_then(|v| v.parse::<f32>().ok())
3183 .unwrap_or(ABSTAIN_BAND_WIDTH)
3184 .clamp(0.0, 1.0)
3185}
3186
3187pub const ABSTAIN_RERANK_FLOOR: f32 = 0.9;
3195
3196fn abstain_rerank_floor() -> f32 {
3197 std::env::var("KIMETSU_ABSTAIN_RERANK_FLOOR")
3198 .ok()
3199 .and_then(|v| v.parse::<f32>().ok())
3200 .unwrap_or(ABSTAIN_RERANK_FLOOR)
3201}
3202
3203pub fn rerank_and_arbitrate(
3221 query: &str,
3222 mut bundle: ContextBundle,
3223 reranker: Option<&dyn crate::embeddings::Reranker>,
3224 abstain_evidence: f32,
3225 rerank_floor: f32,
3226 rerank_cap: usize,
3227) -> ContextBundle {
3228 if bundle.skipped || bundle.capsules.is_empty() {
3229 return bundle;
3230 }
3231 let memory_only = bundle.capsules.iter().all(|c| c.kind == "memory");
3232 let in_band = abstain_evidence > 0.0
3235 && memory_only
3236 && bundle.top_abs_evidence >= 0.0
3237 && bundle.top_abs_evidence < abstain_evidence;
3238
3239 let to_skipped = |mut bundle: ContextBundle| -> ContextBundle {
3240 let rejected = std::mem::take(&mut bundle.capsules);
3241 bundle.excluded.extend(rejected);
3242 bundle.skipped = true;
3243 bundle.used_tokens = 0;
3244 bundle.evidence_coverage = 0.0;
3245 bundle.uncovered_terms = Vec::new();
3246 bundle.chronological = false;
3247 bundle
3248 };
3249
3250 match reranker {
3251 Some(rr) => {
3252 let reranked = rerank_capsules_with_diagnostics(
3253 query,
3254 std::mem::take(&mut bundle.capsules),
3255 rr,
3256 rerank_floor,
3257 rerank_cap,
3258 );
3259 let best_raw_rerank = reranked.best_raw_score.unwrap_or(0.0);
3263 bundle.capsules = reranked.capsules;
3264 bundle.used_tokens = bundle.capsules.iter().map(|c| c.token_estimate).sum();
3265 if in_band && best_raw_rerank < abstain_rerank_floor() {
3266 to_skipped(bundle)
3267 } else {
3268 bundle
3269 }
3270 }
3271 None if in_band => to_skipped(bundle),
3273 None => bundle,
3274 }
3275}
3276
3277pub fn rerank_capsules(
3278 query: &str,
3279 capsules: Vec<ContextCapsule>,
3280 reranker: &dyn crate::embeddings::Reranker,
3281 floor: f32,
3282 cap: usize,
3283) -> Vec<ContextCapsule> {
3284 rerank_capsules_with_diagnostics(query, capsules, reranker, floor, cap).capsules
3285}
3286
3287struct RerankOutcome {
3288 capsules: Vec<ContextCapsule>,
3289 best_raw_score: Option<f32>,
3293}
3294
3295fn effective_rerank_policy_tier(capsule: &ContextCapsule) -> i8 {
3296 if capsule.superseded_hint {
3299 0
3300 } else {
3301 capsule.rerank_policy_tier
3302 }
3303}
3304
3305fn rerank_capsules_with_diagnostics(
3306 query: &str,
3307 capsules: Vec<ContextCapsule>,
3308 reranker: &dyn crate::embeddings::Reranker,
3309 floor: f32,
3310 cap: usize,
3311) -> RerankOutcome {
3312 if capsules.is_empty() {
3313 return RerankOutcome {
3314 capsules,
3315 best_raw_score: None,
3316 };
3317 }
3318
3319 let docs: Vec<&str> = capsules.iter().map(|c| c.summary.as_str()).collect();
3325 let scores = match reranker.rerank(query, &docs) {
3326 Ok(s) if s.len() == docs.len() => s,
3331 _ => {
3332 let mut out = capsules;
3334 if cap > 0 && out.len() > cap {
3335 out.truncate(cap);
3336 }
3337 return RerankOutcome {
3338 capsules: out,
3339 best_raw_score: None,
3340 };
3341 }
3342 };
3343
3344 let mut ranked: Vec<(ContextCapsule, f32)> = capsules
3346 .into_iter()
3347 .zip(scores)
3348 .map(|(mut c, s)| {
3349 let multiplier = if c.superseded_hint {
3350 1.0
3351 } else {
3352 c.rerank_usefulness
3353 .unwrap_or(1.0 + 0.5 * effective_rerank_policy_tier(&c) as f32)
3354 };
3355 c.score = apply_usefulness_boost(s, multiplier.clamp(0.5, 1.5))
3356 * c.rerank_trust.unwrap_or(1.0).clamp(0.0, 1.0);
3357 if c.superseded_hint {
3358 c.score *= SUPERSESSION_PENALTY;
3359 }
3360 (c, s)
3361 })
3362 .collect();
3363
3364 ranked.sort_by(|a, b| b.0.score.total_cmp(&a.0.score));
3365
3366 ranked.retain(|(_, raw_score)| *raw_score >= floor);
3371
3372 let best_raw_score = ranked
3376 .iter()
3377 .map(|(_, raw_score)| *raw_score)
3378 .max_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
3379
3380 if cap > 0 && ranked.len() > cap {
3381 ranked.truncate(cap);
3382 }
3383
3384 RerankOutcome {
3385 capsules: ranked.into_iter().map(|(c, _)| c).collect(),
3386 best_raw_score,
3387 }
3388}
3389
3390#[cfg(test)]
3391mod tests {
3392 use super::*;
3393
3394 fn capsule(kind: &str, summary: &str) -> ContextCapsule {
3395 ContextCapsule {
3396 id: "c".into(),
3397 kind: kind.into(),
3398 summary: summary.into(),
3399 token_estimate: 1,
3400 expansion_handle: "memory:x".into(),
3401 provenance: vec![],
3402 confidence: 1.0,
3403 freshness: 1.0,
3404 relevance: 1.0,
3405 scope_weight: 1.0,
3406 score: 1.0,
3407 superseded_hint: false,
3408 rerank_policy_tier: 0,
3409 claim_revision: None,
3410 facts: vec![],
3411 rerank_usefulness: None,
3412 rerank_trust: None,
3413 }
3414 }
3415
3416 fn make_test_dir(tag: &str) -> std::path::PathBuf {
3419 use std::time::{SystemTime, UNIX_EPOCH};
3420 let ts = SystemTime::now()
3421 .duration_since(UNIX_EPOCH)
3422 .map(|d| d.subsec_nanos())
3423 .unwrap_or(0);
3424 let dir = std::env::temp_dir().join(format!("kbrain_test_{tag}_{ts}"));
3425 std::fs::create_dir_all(&dir).expect("create test dir");
3426 dir
3427 }
3428
3429 #[test]
3430 fn capsule_matches_kind_reads_memory_summary_prefix() {
3431 let mem = capsule("memory", "project:failure_pattern - linker not found");
3433 assert!(capsule_matches_kind(&mem, "failure_pattern"));
3434 assert!(!capsule_matches_kind(&mem, "command"));
3435 let repo = capsule("repo_file", "src/lib.rs:command - run build");
3437 assert!(capsule_matches_kind(&repo, "repo_file"));
3438 assert!(!capsule_matches_kind(&repo, "command"));
3439 }
3440
3441 #[test]
3444 fn usefulness_multiplier_neutral_at_zero_uses() {
3445 assert!((usefulness_multiplier(0.0, 0) - 1.0).abs() < f32::EPSILON);
3447 assert!((usefulness_multiplier(5.0, 0) - 1.0).abs() < f32::EPSILON);
3448 assert!((usefulness_multiplier(-5.0, 0) - 1.0).abs() < f32::EPSILON);
3449 }
3450
3451 #[test]
3455 fn usefulness_multiplier_blends_smoothly_in_transition() {
3456 let one_use = usefulness_multiplier(1.0, 1);
3459 assert!((one_use - 1.166_666_6).abs() < 1e-4, "got {one_use}");
3460 let two_uses = usefulness_multiplier(2.0, 2);
3463 assert!((two_uses - 1.333_333_4).abs() < 1e-4, "got {two_uses}");
3464 let two_uses_bad = usefulness_multiplier(-2.0, 2);
3466 assert!(
3468 (two_uses_bad - 0.666_666_7).abs() < 1e-4,
3469 "got {two_uses_bad}"
3470 );
3471 }
3472
3473 #[test]
3477 fn usefulness_multiplier_maps_ratio_onto_envelope() {
3478 assert!((usefulness_multiplier(5.0, 5) - 1.5).abs() < f32::EPSILON);
3480 assert!((usefulness_multiplier(-5.0, 5) - 0.5).abs() < f32::EPSILON);
3482 let mid = usefulness_multiplier(0.0, 6);
3484 assert!((mid - 1.0).abs() < f32::EPSILON, "got {mid}");
3485 let high = usefulness_multiplier(2.0, 4);
3487 assert!((high - 1.25).abs() < f32::EPSILON, "got {high}");
3488 let low = usefulness_multiplier(-2.0, 4);
3490 assert!((low - 0.75).abs() < f32::EPSILON, "got {low}");
3491 }
3492
3493 #[test]
3497 fn usefulness_multiplier_clamps_to_envelope() {
3498 assert!((usefulness_multiplier(100.0, 5) - 1.5).abs() < f32::EPSILON);
3500 assert!((usefulness_multiplier(-100.0, 5) - 0.5).abs() < f32::EPSILON);
3502 }
3503
3504 #[test]
3511 fn boost_gain_is_capped_so_cited_junk_cannot_beat_relevant_uncited() {
3512 let junk = apply_usefulness_boost(0.39, 1.5);
3513 let true_match = apply_usefulness_boost(0.53, 1.0);
3514 assert!(
3515 junk < true_match,
3516 "capped boost must preserve relevance order: junk {junk} vs match {true_match}"
3517 );
3518 assert!(junk <= 0.39 + USEFULNESS_BOOST_CAP + f32::EPSILON);
3520 }
3521
3522 #[test]
3526 fn boost_still_reorders_within_a_relevance_band() {
3527 let proven = apply_usefulness_boost(0.85, 1.5);
3528 let neutral = apply_usefulness_boost(0.90, 1.0);
3529 assert!(
3530 proven > neutral,
3531 "capped boost must still reorder near-equals: proven {proven} vs neutral {neutral}"
3532 );
3533 }
3534
3535 #[test]
3539 fn penalty_side_remains_multiplicative() {
3540 let penalized = apply_usefulness_boost(0.8, 0.5);
3541 assert!((penalized - 0.4).abs() < 1e-6);
3542 }
3543
3544 #[test]
3547 fn query_tokens_expands_build_class() {
3548 let toks = query_tokens("Build the project from source");
3549 assert!(toks.iter().any(|t| t == "build"));
3550 assert!(toks.iter().any(|t| t == "shell_background"));
3552 assert!(toks.iter().any(|t| t == "long_running"));
3553 }
3554
3555 #[test]
3556 fn query_tokens_expands_edit_class() {
3557 let toks = query_tokens("Modify the config to fix the bug");
3558 assert!(toks.iter().any(|t| t == "edit_file"));
3559 assert!(toks.iter().any(|t| t == "apply_patch"));
3560 }
3561
3562 #[test]
3563 fn query_tokens_expands_search_class() {
3564 let toks = query_tokens("Find all references to the symbol");
3565 assert!(toks.iter().any(|t| t == "glob"));
3566 assert!(toks.iter().any(|t| t == "search_files"));
3567 }
3568
3569 #[test]
3570 fn query_tokens_no_expansion_on_unrelated_query() {
3571 let toks = query_tokens("hello world testing nothing");
3572 assert!(toks.iter().any(|t| t == "hello"));
3574 assert!(toks.iter().any(|t| t == "world"));
3576 }
3577
3578 #[test]
3581 fn jaccard_is_zero_for_disjoint_sets() {
3582 let a: std::collections::HashSet<String> =
3583 ["foo", "bar"].iter().map(|s| s.to_string()).collect();
3584 let b: std::collections::HashSet<String> =
3585 ["baz", "qux"].iter().map(|s| s.to_string()).collect();
3586 assert!((jaccard(&a, &b) - 0.0).abs() < f32::EPSILON);
3587 }
3588
3589 #[test]
3590 fn jaccard_is_one_for_identical_sets() {
3591 let a: std::collections::HashSet<String> =
3592 ["foo", "bar"].iter().map(|s| s.to_string()).collect();
3593 let b = a.clone();
3594 assert!((jaccard(&a, &b) - 1.0).abs() < f32::EPSILON);
3595 }
3596
3597 #[test]
3598 fn jaccard_partial_overlap() {
3599 let a: std::collections::HashSet<String> = ["foo", "bar", "baz"]
3600 .iter()
3601 .map(|s| s.to_string())
3602 .collect();
3603 let b: std::collections::HashSet<String> =
3604 ["bar", "qux"].iter().map(|s| s.to_string()).collect();
3605 assert!((jaccard(&a, &b) - 0.25).abs() < f32::EPSILON);
3607 }
3608
3609 #[test]
3610 fn summary_token_set_lowercases_and_filters_short() {
3611 let set = summary_token_set("Build the Foo-bar project");
3612 assert!(set.contains("build"));
3613 assert!(set.contains("foo"));
3614 assert!(set.contains("bar"));
3615 assert!(set.contains("project"));
3616 assert!(set.contains("the"));
3618 }
3619
3620 fn insert_memory_with_embedding(
3626 conn: &rusqlite::Connection,
3627 memory_id: &str,
3628 text: &str,
3629 embedder: &dyn embeddings::Embedder,
3630 ) {
3631 let normalized = kimetsu_core::memory::normalize_memory_text(text);
3632 conn.execute(
3633 "
3634 INSERT INTO memories (
3635 memory_id, scope, kind, text, normalized_text, confidence,
3636 source_event_id, provenance_snapshot_json, created_at,
3637 use_count, usefulness_score, embedding, embedding_model
3638 )
3639 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
3640 '2026-05-01T00:00:00Z', 0, 0.0, ?4, ?5)
3641 ",
3642 rusqlite::params![
3643 memory_id,
3644 text,
3645 normalized,
3646 embeddings::encode_embedding(&embedder.embed(text).expect("embed test row")),
3647 embedder.model_id(),
3648 ],
3649 )
3650 .expect("insert memory");
3651 conn.execute(
3652 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
3653 rusqlite::params![memory_id, text],
3654 )
3655 .expect("insert fts row");
3656 }
3657
3658 #[test]
3668 fn hybrid_retrieval_uses_cosine_score_to_rerank() {
3669 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3670 crate::schema::initialize(&conn).expect("init schema");
3671 let stub = embeddings::StubEmbedder::new();
3672
3673 insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
3674 insert_memory_with_embedding(
3675 &conn,
3676 "m_unrelated",
3677 "cookie recipe with chocolate chips",
3678 &stub,
3679 );
3680
3681 let weights = kimetsu_core::config::BrokerWeights::default();
3684 let bundle = retrieve_context_with_embedder(
3685 &conn,
3686 "/fake-repo",
3687 &weights,
3688 ContextRequest {
3689 stage: "localization".to_string(),
3690 query: "ripgrep search".to_string(),
3691 budget_tokens: 4000,
3692 ..Default::default()
3693 },
3694 &[],
3695 &stub,
3696 )
3697 .expect("retrieve");
3698
3699 let memory_handles: Vec<_> = bundle
3700 .capsules
3701 .iter()
3702 .filter(|c| c.expansion_handle.starts_with("memory:"))
3703 .collect();
3704 assert!(
3705 !memory_handles.is_empty(),
3706 "at least one memory should surface"
3707 );
3708 assert_eq!(
3710 memory_handles[0].expansion_handle,
3711 "memory:m_rg",
3712 "ripgrep memory should outrank the cookie recipe; ranked: {:?}",
3713 memory_handles
3714 .iter()
3715 .map(|c| &c.expansion_handle)
3716 .collect::<Vec<_>>()
3717 );
3718 }
3719
3720 #[test]
3725 fn abstain_evidence_gate_skips_on_weak_absolute_evidence() {
3726 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3727 crate::schema::initialize(&conn).expect("init schema");
3728 let stub = embeddings::StubEmbedder::new();
3729 insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
3730
3731 let weights = kimetsu_core::config::BrokerWeights::default();
3732 let retrieve = |abstain: f32| {
3733 retrieve_context_with_embedder(
3734 &conn,
3735 "/fake-repo",
3736 &weights,
3737 ContextRequest {
3738 stage: "localization".to_string(),
3739 query: "ripgrep search".to_string(),
3740 budget_tokens: 4000,
3741 abstain_evidence: abstain,
3742 ..Default::default()
3743 },
3744 &[],
3745 &stub,
3746 )
3747 .expect("retrieve")
3748 };
3749
3750 let open = retrieve(0.0);
3751 assert!(!open.skipped, "gate off must not skip");
3752 assert!(
3753 open.top_abs_evidence > 0.0,
3754 "a matching memory must report positive absolute evidence"
3755 );
3756
3757 let above = retrieve(open.top_abs_evidence + ABSTAIN_BAND_WIDTH + 0.05);
3761 assert!(
3762 above.skipped,
3763 "a floor a full band above the best evidence must hard-abstain (evidence {})",
3764 open.top_abs_evidence
3765 );
3766 assert!(above.capsules.is_empty(), "skipped bundle injects nothing");
3767
3768 let in_band = retrieve(open.top_abs_evidence + 0.05);
3770 assert!(
3771 !in_band.skipped,
3772 "an in-band bundle passes through for arbitration"
3773 );
3774
3775 let below = retrieve((open.top_abs_evidence - 0.05).max(0.01));
3776 assert!(!below.skipped, "a floor below the best evidence passes");
3777 assert!(!below.capsules.is_empty());
3778 }
3779
3780 #[test]
3787 fn hybrid_retrieval_skips_cosine_on_model_id_mismatch() {
3788 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3789 crate::schema::initialize(&conn).expect("init schema");
3790 let stub = embeddings::StubEmbedder::new();
3791 insert_memory_with_embedding(&conn, "m_xref", "use ripgrep for code search", &stub);
3792
3793 conn.execute(
3798 "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xref'",
3799 [],
3800 )
3801 .expect("force model_id mismatch");
3802
3803 let weights = kimetsu_core::config::BrokerWeights::default();
3808 let bundle = retrieve_context_with_embedder(
3809 &conn,
3810 "/fake-repo",
3811 &weights,
3812 ContextRequest {
3813 stage: "localization".to_string(),
3814 query: "ripgrep search".to_string(),
3815 budget_tokens: 4000,
3816 ..Default::default()
3817 },
3818 &[],
3819 &stub,
3820 )
3821 .expect("retrieve");
3822
3823 assert!(
3824 bundle
3825 .capsules
3826 .iter()
3827 .any(|c| c.expansion_handle == "memory:m_xref"),
3828 "cross-model row should still match lexically (cosine skipped, FTS works)"
3829 );
3830 }
3831
3832 #[test]
3839 fn usefulness_decay_disabled_when_half_life_is_zero_or_negative() {
3840 let ancient = "2021-01-01T00:00:00Z";
3842 assert!((usefulness_decay(Some(ancient), ancient, 0.0) - 1.0).abs() < f32::EPSILON);
3843 assert!((usefulness_decay(Some(ancient), ancient, -1.0) - 1.0).abs() < f32::EPSILON);
3844 }
3845
3846 #[test]
3850 fn usefulness_decay_returns_one_on_unparseable_timestamps() {
3851 assert!(
3852 (usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON
3853 );
3854 }
3855
3856 #[test]
3859 fn usefulness_decay_full_at_zero_age() {
3860 let future = "2099-01-01T00:00:00Z";
3862 let d = usefulness_decay(Some(future), future, 30.0);
3863 assert!((d - 1.0).abs() < f32::EPSILON, "got {d}");
3864 }
3865
3866 #[test]
3871 fn usefulness_decay_follows_half_life_curve() {
3872 let half_life = 10.0_f32;
3873 let now = OffsetDateTime::now_utc();
3874 let fmt = &time::format_description::well_known::Rfc3339;
3875
3876 let one_half_life_ago = (now - time::Duration::seconds((half_life * 86_400.0) as i64))
3878 .format(fmt)
3879 .expect("format");
3880 let d1 = usefulness_decay(Some(&one_half_life_ago), &one_half_life_ago, half_life);
3881 assert!(
3882 (d1 - 0.5).abs() < 0.01,
3883 "expected ~0.5 at one half-life, got {d1}"
3884 );
3885
3886 let two_half_lives_ago = (now
3888 - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64))
3889 .format(fmt)
3890 .expect("format");
3891 let d2 = usefulness_decay(Some(&two_half_lives_ago), &two_half_lives_ago, half_life);
3892 assert!(
3893 (d2 - 0.25).abs() < 0.01,
3894 "expected ~0.25 at two half-lives, got {d2}"
3895 );
3896 }
3897
3898 #[test]
3902 fn usefulness_decay_falls_back_to_created_at_when_last_useful_is_none() {
3903 let now = OffsetDateTime::now_utc();
3904 let fmt = &time::format_description::well_known::Rfc3339;
3905 let one_day_ago = (now - time::Duration::seconds(86_400))
3906 .format(fmt)
3907 .expect("format");
3908 let d = usefulness_decay(None, &one_day_ago, 30.0);
3909 assert!(
3911 (d - 0.977).abs() < 0.01,
3912 "expected ~0.977 for 1-day-old created_at under 30d half-life, got {d}"
3913 );
3914 }
3915
3916 #[test]
3921 fn aged_cited_memory_ranks_below_recently_cited_memory() {
3922 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3923 crate::schema::initialize(&conn).expect("init schema");
3924
3925 let now = OffsetDateTime::now_utc();
3926 let fmt = &time::format_description::well_known::Rfc3339;
3927 let one_day_ago = (now - time::Duration::seconds(86_400))
3928 .format(fmt)
3929 .expect("format");
3930 let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
3931 .format(fmt)
3932 .expect("format");
3933
3934 for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] {
3938 let text = "use ripgrep for code search";
3939 let normalized = kimetsu_core::memory::normalize_memory_text(text);
3940 conn.execute(
3941 "
3942 INSERT INTO memories (
3943 memory_id, scope, kind, text, normalized_text, confidence,
3944 source_event_id, provenance_snapshot_json, created_at,
3945 use_count, usefulness_score, last_useful_at
3946 )
3947 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
3948 '2024-01-01T00:00:00Z', 5, 5.0, ?4)
3949 ",
3950 rusqlite::params![mid, text, normalized, last_useful],
3951 )
3952 .expect("insert memory");
3953 conn.execute(
3954 "INSERT INTO memories_fts (memory_id, text, kind, scope)
3955 VALUES (?1, ?2, 'fact', 'global_user')",
3956 rusqlite::params![mid, text],
3957 )
3958 .expect("insert fts");
3959 }
3960
3961 let weights = kimetsu_core::config::BrokerWeights::default();
3963 let bundle = retrieve_context_with_embedder(
3964 &conn,
3965 "/fake-repo",
3966 &weights,
3967 ContextRequest {
3968 stage: "localization".to_string(),
3969 query: "ripgrep search".to_string(),
3970 budget_tokens: 4000,
3971 ..Default::default()
3972 },
3973 &[],
3974 &embeddings::NoopEmbedder,
3975 )
3976 .expect("retrieve");
3977
3978 let mem_order: Vec<&str> = bundle
3979 .capsules
3980 .iter()
3981 .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
3982 .collect();
3983 assert_eq!(
3984 mem_order.first().copied(),
3985 Some("m_recent"),
3986 "recently-cited memory must rank first under decay; got order {mem_order:?}"
3987 );
3988 }
3989
3990 #[test]
3995 fn aged_cited_memory_does_not_decay_when_half_life_is_zero() {
3996 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
3997 crate::schema::initialize(&conn).expect("init schema");
3998
3999 let now = OffsetDateTime::now_utc();
4000 let fmt = &time::format_description::well_known::Rfc3339;
4001 let one_day_ago = (now - time::Duration::seconds(86_400))
4002 .format(fmt)
4003 .expect("format");
4004 let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
4005 .format(fmt)
4006 .expect("format");
4007
4008 for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] {
4009 let text = "use ripgrep for code search";
4010 let normalized = kimetsu_core::memory::normalize_memory_text(text);
4011 conn.execute(
4012 "
4013 INSERT INTO memories (
4014 memory_id, scope, kind, text, normalized_text, confidence,
4015 source_event_id, provenance_snapshot_json, created_at,
4016 use_count, usefulness_score, last_useful_at
4017 )
4018 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
4019 '2024-01-01T00:00:00Z', 5, 5.0, ?4)
4020 ",
4021 rusqlite::params![mid, text, normalized, last_useful],
4022 )
4023 .expect("insert memory");
4024 conn.execute(
4025 "INSERT INTO memories_fts (memory_id, text, kind, scope)
4026 VALUES (?1, ?2, 'fact', 'global_user')",
4027 rusqlite::params![mid, text],
4028 )
4029 .expect("insert fts");
4030 }
4031
4032 let weights = kimetsu_core::config::BrokerWeights {
4034 decay_half_life_days: 0.0,
4035 ..Default::default()
4036 };
4037
4038 let bundle = retrieve_context_with_embedder(
4039 &conn,
4040 "/fake-repo",
4041 &weights,
4042 ContextRequest {
4043 stage: "localization".to_string(),
4044 query: "ripgrep search".to_string(),
4045 budget_tokens: 4000,
4046 ..Default::default()
4047 },
4048 &[],
4049 &embeddings::NoopEmbedder,
4050 )
4051 .expect("retrieve");
4052
4053 let scores: Vec<(String, f32)> = bundle
4059 .capsules
4060 .iter()
4061 .filter_map(|c| {
4062 c.expansion_handle
4063 .strip_prefix("memory:")
4064 .map(|id| (id.to_string(), c.score))
4065 })
4066 .collect();
4067 assert_eq!(scores.len(), 2, "both memories should surface");
4068 let recent_score = scores
4069 .iter()
4070 .find(|(id, _)| id == "m_recent")
4071 .map(|(_, s)| *s)
4072 .expect("m_recent present");
4073 let aged_score = scores
4074 .iter()
4075 .find(|(id, _)| id == "m_aged")
4076 .map(|(_, s)| *s)
4077 .expect("m_aged present");
4078 assert!(
4080 (recent_score - aged_score).abs() < 1e-4,
4081 "with decay disabled the two memories should tie on score: recent={recent_score} aged={aged_score}"
4082 );
4083 }
4084
4085 #[test]
4090 fn hybrid_retrieval_with_noop_embedder_is_lexical_only() {
4091 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4092 crate::schema::initialize(&conn).expect("init schema");
4093 let stub = embeddings::StubEmbedder::new();
4094 insert_memory_with_embedding(&conn, "m_a", "use ripgrep", &stub);
4096 insert_memory_with_embedding(&conn, "m_b", "use ripgrep too", &stub);
4097
4098 let weights = kimetsu_core::config::BrokerWeights::default();
4101 let bundle = retrieve_context_with_embedder(
4102 &conn,
4103 "/fake-repo",
4104 &weights,
4105 ContextRequest {
4106 stage: "localization".to_string(),
4107 query: "ripgrep".to_string(),
4108 budget_tokens: 4000,
4109 ..Default::default()
4110 },
4111 &[],
4112 &embeddings::NoopEmbedder,
4113 )
4114 .expect("retrieve");
4115
4116 let count = bundle
4117 .capsules
4118 .iter()
4119 .filter(|c| c.expansion_handle.starts_with("memory:"))
4120 .count();
4121 assert_eq!(count, 2, "both memories should surface via FTS");
4122 }
4123
4124 #[cfg(feature = "embeddings")]
4151 #[test]
4152 fn ann_finds_semantic_match_fts_misses() {
4153 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4154 crate::schema::initialize(&conn).expect("init schema");
4155
4156 struct OracleEmbedder;
4159 impl embeddings::Embedder for OracleEmbedder {
4160 fn embed(&self, _text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4161 Ok(vec![1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
4163 }
4164 fn model_id(&self) -> &str {
4165 "oracle-d8"
4166 }
4167 fn dim(&self) -> usize {
4168 8
4169 }
4170 }
4171
4172 let model_id = "oracle-d8";
4173
4174 let sem_vec = embeddings::encode_embedding(&[1.0f32, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
4177 let sem_text = "cookie recipe chocolate";
4178 let sem_norm = kimetsu_core::memory::normalize_memory_text(sem_text);
4179 conn.execute(
4180 "INSERT INTO memories (
4181 memory_id, scope, kind, text, normalized_text, confidence,
4182 source_event_id, provenance_snapshot_json, created_at,
4183 use_count, usefulness_score, embedding, embedding_model
4184 )
4185 VALUES ('m_semantic', 'global_user', 'fact', ?1, ?2, 1.0, NULL, '{}',
4186 '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
4187 rusqlite::params![sem_text, sem_norm, sem_vec, model_id],
4188 )
4189 .expect("insert m_semantic");
4190 conn.execute(
4191 "INSERT INTO memories_fts (memory_id, text, kind, scope)
4192 VALUES ('m_semantic', ?1, 'fact', 'global_user')",
4193 rusqlite::params![sem_text],
4194 )
4195 .expect("insert m_semantic fts");
4196
4197 let decoy_vec = embeddings::encode_embedding(&[0.0f32, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]);
4199 let decoy_text = "git rebase squash commits";
4200 let decoy_norm = kimetsu_core::memory::normalize_memory_text(decoy_text);
4201 conn.execute(
4202 "INSERT INTO memories (
4203 memory_id, scope, kind, text, normalized_text, confidence,
4204 source_event_id, provenance_snapshot_json, created_at,
4205 use_count, usefulness_score, embedding, embedding_model
4206 )
4207 VALUES ('m_decoy', 'global_user', 'fact', ?1, ?2, 1.0, NULL, '{}',
4208 '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
4209 rusqlite::params![decoy_text, decoy_norm, decoy_vec, model_id],
4210 )
4211 .expect("insert m_decoy");
4212 conn.execute(
4213 "INSERT INTO memories_fts (memory_id, text, kind, scope)
4214 VALUES ('m_decoy', ?1, 'fact', 'global_user')",
4215 rusqlite::params![decoy_text],
4216 )
4217 .expect("insert m_decoy fts");
4218
4219 let fts_hits: i64 = conn
4221 .query_row(
4222 "SELECT COUNT(*) FROM memories_fts \
4223 WHERE memories_fts MATCH 'phosphorescent bioluminescent'",
4224 [],
4225 |r| r.get(0),
4226 )
4227 .unwrap_or(0);
4228 assert_eq!(
4229 fts_hits, 0,
4230 "sanity: query tokens must not appear in any memory text"
4231 );
4232
4233 let weights = kimetsu_core::config::BrokerWeights::default();
4237 let bundle = retrieve_context_with_embedder(
4238 &conn,
4239 "/fake-repo",
4240 &weights,
4241 ContextRequest {
4242 stage: "localization".to_string(),
4243 query: "phosphorescent bioluminescent organism".to_string(),
4244 budget_tokens: 4000,
4245 ..Default::default()
4246 },
4247 &[],
4248 &OracleEmbedder,
4249 )
4250 .expect("retrieve");
4251
4252 let handles: Vec<&str> = bundle
4253 .capsules
4254 .iter()
4255 .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
4256 .collect();
4257
4258 assert!(
4259 handles.contains(&"m_semantic"),
4260 "ANN must surface m_semantic (cosine=1 with oracle query) even though \
4261 FTS found nothing; got handles: {handles:?}"
4262 );
4263 }
4264
4265 #[cfg(feature = "embeddings")]
4267 #[test]
4268 fn dedup_memory_matched_by_fts_and_ann_appears_once() {
4269 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4270 crate::schema::initialize(&conn).expect("init schema");
4271
4272 let stub = embeddings::StubEmbedder::new();
4273
4274 insert_memory_with_embedding(&conn, "m_both", "use ripgrep for fast search", &stub);
4278
4279 let weights = kimetsu_core::config::BrokerWeights::default();
4280 let bundle = retrieve_context_with_embedder(
4281 &conn,
4282 "/fake-repo",
4283 &weights,
4284 ContextRequest {
4285 stage: "localization".to_string(),
4286 query: "ripgrep".to_string(),
4287 budget_tokens: 4000,
4288 ..Default::default()
4289 },
4290 &[],
4291 &stub,
4292 )
4293 .expect("retrieve");
4294
4295 let count = bundle
4296 .capsules
4297 .iter()
4298 .filter(|c| c.expansion_handle == "memory:m_both")
4299 .count();
4300 assert_eq!(
4301 count,
4302 1,
4303 "m_both (matched by both FTS and ANN) must appear exactly once; \
4304 bundle: {:?}",
4305 bundle
4306 .capsules
4307 .iter()
4308 .map(|c| &c.expansion_handle)
4309 .collect::<Vec<_>>()
4310 );
4311 }
4312
4313 #[cfg(feature = "embeddings")]
4337 #[test]
4338 fn embedding_mmr_collapses_paraphrases_but_jaccard_does_not() {
4339 struct OracleEmbedder;
4342 impl embeddings::Embedder for OracleEmbedder {
4343 fn embed(&self, _text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4344 let mut v = vec![0.0f32; 8];
4345 v[0] = 1.0;
4346 Ok(v)
4347 }
4348 fn model_id(&self) -> &str {
4349 "oracle-d8"
4350 }
4351 fn dim(&self) -> usize {
4352 8
4353 }
4354 }
4355
4356 let oracle = OracleEmbedder;
4359 let weights = kimetsu_core::config::BrokerWeights::default();
4360
4361 let m_rg1_text = "prefer ripgrep for searching source code";
4364 let m_rg2_text = "rg is the fastest way to locate patterns";
4365
4366 let conn = rusqlite::Connection::open_in_memory().expect("in-memory");
4372 crate::schema::initialize(&conn).expect("init schema");
4373 insert_memory_with_embedding(&conn, "m_rg1", m_rg1_text, &oracle);
4374 insert_memory_with_embedding(&conn, "m_rg2", m_rg2_text, &oracle);
4375
4376 let bundle_embedding = retrieve_context_with_embedder(
4377 &conn,
4378 "/fake-repo",
4379 &weights,
4380 ContextRequest {
4381 stage: "localization".to_string(),
4382 query: "search source patterns".to_string(),
4384 budget_tokens: 20_000,
4385 max_capsules: 1, ..Default::default()
4387 },
4388 &[],
4389 &oracle,
4390 )
4391 .expect("retrieve with oracle embedder");
4392
4393 let emb_in_capsules = bundle_embedding
4396 .capsules
4397 .iter()
4398 .filter(|c| {
4399 c.expansion_handle == "memory:m_rg1" || c.expansion_handle == "memory:m_rg2"
4400 })
4401 .count();
4402 assert_eq!(
4403 emb_in_capsules,
4404 1,
4405 "embedding-MMR must collapse cosine=1.0 paraphrases: with max_capsules=1 \
4406 only ONE should be included; capsule handles: {:?}; excluded: {:?}",
4407 bundle_embedding
4408 .capsules
4409 .iter()
4410 .map(|c| &c.expansion_handle)
4411 .collect::<Vec<_>>(),
4412 bundle_embedding
4413 .excluded
4414 .iter()
4415 .map(|c| &c.expansion_handle)
4416 .collect::<Vec<_>>()
4417 );
4418
4419 let emb_in_excluded = bundle_embedding
4421 .excluded
4422 .iter()
4423 .filter(|c| {
4424 c.expansion_handle == "memory:m_rg1" || c.expansion_handle == "memory:m_rg2"
4425 })
4426 .count();
4427 assert_eq!(
4428 emb_in_excluded,
4429 1,
4430 "the second near-duplicate must be in excluded under embedding-MMR; \
4431 excluded handles: {:?}",
4432 bundle_embedding
4433 .excluded
4434 .iter()
4435 .map(|c| &c.expansion_handle)
4436 .collect::<Vec<_>>()
4437 );
4438
4439 let conn2 = rusqlite::Connection::open_in_memory().expect("in-memory 2");
4444 crate::schema::initialize(&conn2).expect("init schema 2");
4445 insert_memory_with_embedding(&conn2, "m_rg1", m_rg1_text, &oracle);
4446 insert_memory_with_embedding(&conn2, "m_rg2", m_rg2_text, &oracle);
4447
4448 let bundle_lean = retrieve_context_with_embedder(
4449 &conn2,
4450 "/fake-repo",
4451 &weights,
4452 ContextRequest {
4453 stage: "localization".to_string(),
4454 query: "search source patterns".to_string(),
4455 budget_tokens: 20_000,
4456 max_capsules: 2, ..Default::default()
4458 },
4459 &[],
4460 &embeddings::NoopEmbedder,
4461 )
4462 .expect("retrieve with NoopEmbedder");
4463
4464 let lean_in_capsules = bundle_lean
4465 .capsules
4466 .iter()
4467 .filter(|c| {
4468 c.expansion_handle == "memory:m_rg1" || c.expansion_handle == "memory:m_rg2"
4469 })
4470 .count();
4471 assert_eq!(
4472 lean_in_capsules,
4473 2,
4474 "Jaccard-only path must NOT collapse the two paraphrases (different words, \
4475 low token overlap → both survive MMR with max_capsules=2); capsule handles: {:?}",
4476 bundle_lean
4477 .capsules
4478 .iter()
4479 .map(|c| &c.expansion_handle)
4480 .collect::<Vec<_>>()
4481 );
4482 }
4483
4484 #[test]
4487 fn content_tokens_strips_stopwords_keeps_topical_words() {
4488 let got = content_tokens("Tell me about kimetsu, what's the idea of the repo");
4489 assert_eq!(got, vec!["kimetsu", "idea", "repo"]);
4492 }
4493
4494 #[test]
4495 fn light_stem_strips_one_inflection_suffix() {
4496 assert_eq!(light_stem("benchmarked"), "benchmark");
4497 assert_eq!(light_stem("benchmarking"), "benchmark");
4498 assert_eq!(light_stem("repos"), "repo");
4499 assert_eq!(light_stem("does"), "does");
4501 assert_eq!(light_stem("toml"), "toml");
4502 }
4503
4504 #[test]
4511 fn stemmed_query_matches_inflected_corpus_through_floor() {
4512 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4513 crate::schema::initialize(&conn).expect("init schema");
4514 let insert = |id: &str, text: &str| {
4515 let norm = kimetsu_core::memory::normalize_memory_text(text);
4516 conn.execute(
4517 "INSERT INTO memories (
4518 memory_id, scope, kind, text, normalized_text, confidence,
4519 source_event_id, provenance_snapshot_json, created_at,
4520 use_count, usefulness_score, embedding, embedding_model
4521 )
4522 VALUES (?1, 'global_user', 'fact', ?2, ?3, 0.9, NULL, '{}',
4523 '2026-06-01T00:00:00Z', 0, 0.0, NULL, NULL)",
4524 rusqlite::params![id, text, norm],
4525 )
4526 .expect("insert memory");
4527 conn.execute(
4528 "INSERT INTO memories_fts (memory_id, text, kind, scope)
4529 VALUES (?1, ?2, 'fact', 'global_user')",
4530 rusqlite::params![id, text],
4531 )
4532 .expect("insert fts");
4533 };
4534 insert(
4535 "m_bench",
4536 "kimetsu benchmark runs go through the kbench binary and the Terminal-Bench driver",
4537 );
4538 insert(
4539 "m_doctor",
4540 "kimetsu doctor version-skew check parses process start times on Windows via CIM",
4541 );
4542 insert(
4543 "m_gc",
4544 "kimetsu runs auto-GC on run creation; keep the env guard at the trigger site",
4545 );
4546
4547 let bundle = retrieve_context_with_embedder(
4548 &conn,
4549 "/fake-repo",
4550 &kimetsu_core::config::BrokerWeights::default(),
4551 ContextRequest {
4552 stage: "localization".to_string(),
4553 query: "Can you find out how kimetsu is benchmarked?".to_string(),
4554 budget_tokens: 2000,
4555 max_capsules: 2,
4556 min_lexical_coverage: 0.5,
4557 ..Default::default()
4558 },
4559 &[],
4560 &embeddings::NoopEmbedder,
4561 )
4562 .expect("retrieve");
4563 let handles: Vec<_> = bundle
4564 .capsules
4565 .iter()
4566 .map(|c| c.expansion_handle.as_str())
4567 .collect();
4568 assert!(
4569 handles.contains(&"memory:m_bench"),
4570 "stemmed 'benchmarked' must surface the benchmark memory; got {handles:?}"
4571 );
4572 assert!(
4573 !handles.contains(&"memory:m_doctor") && !handles.contains(&"memory:m_gc"),
4574 "off-topic memories sharing only 'kimetsu' must stay below the floor; got {handles:?}"
4575 );
4576 }
4577
4578 #[test]
4579 fn weighted_coverage_ignores_zero_idf_tokens() {
4580 let content = vec![
4584 "kimetsu".to_string(),
4585 "idea".to_string(),
4586 "repo".to_string(),
4587 ];
4588 let mut idf = HashMap::new();
4589 idf.insert("kimetsu".to_string(), 0.0);
4590 idf.insert("idea".to_string(), 1.386);
4591 idf.insert("repo".to_string(), 0.693);
4592
4593 let cov = weighted_coverage(
4595 &content,
4596 &idf,
4597 "global:fact - the git repo and kimetsu brain",
4598 );
4599 assert!((cov - 0.333).abs() < 0.01, "got {cov}");
4600
4601 let cov_topical =
4603 weighted_coverage(&content, &idf, "global:fact - the core idea of kimetsu");
4604 assert!(cov_topical > 0.6, "got {cov_topical}");
4605 }
4606
4607 #[test]
4621 fn lexical_floor_drops_offtopic_memories_sharing_project_name() {
4622 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4623 crate::schema::initialize(&conn).expect("init schema");
4624
4625 let insert = |id: &str, text: &str| {
4626 let norm = kimetsu_core::memory::normalize_memory_text(text);
4627 conn.execute(
4628 "INSERT INTO memories (
4629 memory_id, scope, kind, text, normalized_text, confidence,
4630 source_event_id, provenance_snapshot_json, created_at,
4631 use_count, usefulness_score, embedding, embedding_model
4632 )
4633 VALUES (?1, 'global_user', 'fact', ?2, ?3, 0.9, NULL, '{}',
4634 '2026-06-01T00:00:00Z', 0, 0.0, NULL, NULL)",
4635 rusqlite::params![id, text, norm],
4636 )
4637 .expect("insert memory");
4638 conn.execute(
4639 "INSERT INTO memories_fts (memory_id, text, kind, scope)
4640 VALUES (?1, ?2, 'fact', 'global_user')",
4641 rusqlite::params![id, text],
4642 )
4643 .expect("insert fts");
4644 };
4645
4646 insert(
4649 "m1",
4650 "When implementing a setup command that calls init_project, tests must call \
4651 git_init_boundary before setup_cmd so ProjectPaths discover resolves to the temp \
4652 dir instead of climbing to the real parent git repo including the user brain at kimetsu",
4653 );
4654 insert(
4655 "m2",
4656 "A member crate with default embeddings silently turned embeddings on for the entire \
4657 cargo test workspace build graph because cargo unifies features; kimetsu-chat \
4658 retrieval tests failed",
4659 );
4660 insert(
4661 "m3",
4662 "In toml 0.9 use toml from_str to parse a TOML document into a Value not str parse; \
4663 implementing config get and set in kimetsu-cli",
4664 );
4665
4666 let query = "Tell me about kimetsu, what's the idea of the repo".to_string();
4667 let weights = kimetsu_core::config::BrokerWeights::default();
4668 let handles = |bundle: &ContextBundle| {
4669 bundle
4670 .capsules
4671 .iter()
4672 .map(|c| c.expansion_handle.clone())
4673 .collect::<Vec<_>>()
4674 };
4675
4676 let no_floor = retrieve_context_with_embedder(
4678 &conn,
4679 "/fake-repo",
4680 &weights,
4681 ContextRequest {
4682 stage: "localization".to_string(),
4683 query: query.clone(),
4684 budget_tokens: 2000,
4685 max_capsules: 8,
4686 min_lexical_coverage: 0.0,
4687 ..Default::default()
4688 },
4689 &[],
4690 &embeddings::NoopEmbedder,
4691 )
4692 .expect("retrieve without floor");
4693 let before = handles(&no_floor);
4694 assert!(
4695 before.contains(&"memory:m2".to_string()) && before.contains(&"memory:m3".to_string()),
4696 "sanity: without the floor the pure-project-name memories should surface; got {before:?}"
4697 );
4698
4699 let floored = retrieve_context_with_embedder(
4701 &conn,
4702 "/fake-repo",
4703 &weights,
4704 ContextRequest {
4705 stage: "localization".to_string(),
4706 query,
4707 budget_tokens: 2000,
4708 max_capsules: 8,
4709 min_lexical_coverage: 0.5,
4710 ..Default::default()
4711 },
4712 &[],
4713 &embeddings::NoopEmbedder,
4714 )
4715 .expect("retrieve with floor");
4716 let after = handles(&floored);
4717 assert!(
4718 !after.contains(&"memory:m2".to_string()) && !after.contains(&"memory:m3".to_string()),
4719 "the lexical floor must drop memories whose only match is the corpus-ubiquitous \
4720 project name; surviving: {after:?}"
4721 );
4722 }
4723
4724 #[test]
4727 fn lexical_floor_keeps_ontopic_memory() {
4728 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
4729 crate::schema::initialize(&conn).expect("init schema");
4730
4731 let insert = |id: &str, text: &str| {
4732 let norm = kimetsu_core::memory::normalize_memory_text(text);
4733 conn.execute(
4734 "INSERT INTO memories (
4735 memory_id, scope, kind, text, normalized_text, confidence,
4736 source_event_id, provenance_snapshot_json, created_at,
4737 use_count, usefulness_score, embedding, embedding_model
4738 )
4739 VALUES (?1, 'global_user', 'fact', ?2, ?3, 0.9, NULL, '{}',
4740 '2026-06-01T00:00:00Z', 0, 0.0, NULL, NULL)",
4741 rusqlite::params![id, text, norm],
4742 )
4743 .expect("insert memory");
4744 conn.execute(
4745 "INSERT INTO memories_fts (memory_id, text, kind, scope)
4746 VALUES (?1, ?2, 'fact', 'global_user')",
4747 rusqlite::params![id, text],
4748 )
4749 .expect("insert fts");
4750 };
4751
4752 insert(
4754 "d1",
4755 "The distiller runs at session end and harvests durable lessons from the transcript",
4756 );
4757 insert(
4758 "n1",
4759 "Unrelated note about git rebase and squashing commits",
4760 );
4761
4762 let bundle = retrieve_context_with_embedder(
4763 &conn,
4764 "/fake-repo",
4765 &kimetsu_core::config::BrokerWeights::default(),
4766 ContextRequest {
4767 stage: "localization".to_string(),
4768 query: "how does the distiller work".to_string(),
4769 budget_tokens: 2000,
4770 min_lexical_coverage: 0.5,
4771 ..Default::default()
4772 },
4773 &[],
4774 &embeddings::NoopEmbedder,
4775 )
4776 .expect("retrieve");
4777
4778 assert!(
4779 bundle
4780 .capsules
4781 .iter()
4782 .any(|c| c.expansion_handle == "memory:d1"),
4783 "on-topic memory covering the rare query word must survive the floor; got: {:?}",
4784 bundle
4785 .capsules
4786 .iter()
4787 .map(|c| &c.expansion_handle)
4788 .collect::<Vec<_>>()
4789 );
4790 }
4791
4792 #[cfg(feature = "embeddings")]
4801 #[test]
4802 fn min_semantic_score_floor_drops_off_topic_queries() {
4803 struct DirectionalEmbedder {
4814 marker: &'static str,
4816 }
4817 impl embeddings::Embedder for DirectionalEmbedder {
4818 fn embed(&self, text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4819 let mut v = vec![0.0f32; 8];
4820 if text.contains(self.marker) {
4821 v[0] = 1.0;
4822 } else {
4823 v[1] = 1.0;
4824 }
4825 Ok(v)
4826 }
4827 fn model_id(&self) -> &str {
4828 "directional-d8"
4829 }
4830 fn dim(&self) -> usize {
4831 8
4832 }
4833 }
4834
4835 let emb = DirectionalEmbedder { marker: "TOPIC_A" };
4836
4837 let conn = rusqlite::Connection::open_in_memory().expect("in-memory");
4838 crate::schema::initialize(&conn).expect("init schema");
4839
4840 insert_memory_with_embedding(&conn, "m_b", "cookie recipe chocolate baking TOPIC_B", &emb);
4842
4843 let weights = kimetsu_core::config::BrokerWeights::default();
4844
4845 let bundle_off = retrieve_context_with_embedder(
4847 &conn,
4848 "/fake-repo",
4849 &weights,
4850 ContextRequest {
4851 stage: "localization".to_string(),
4852 query: "TOPIC_A unrelated phosphorescent".to_string(),
4854 budget_tokens: 4000,
4855 min_semantic_score: 0.1, ..Default::default()
4857 },
4858 &[],
4859 &emb,
4860 )
4861 .expect("retrieve off-topic");
4862
4863 assert!(
4864 bundle_off.capsules.is_empty(),
4865 "off-topic query (cosine=0 < floor=0.1) must produce zero capsules; \
4866 got: {:?}",
4867 bundle_off
4868 .capsules
4869 .iter()
4870 .map(|c| &c.expansion_handle)
4871 .collect::<Vec<_>>()
4872 );
4873
4874 let conn2 = rusqlite::Connection::open_in_memory().expect("in-memory 2");
4877 crate::schema::initialize(&conn2).expect("init schema 2");
4878 insert_memory_with_embedding(
4879 &conn2,
4880 "m_b2",
4881 "cookie recipe chocolate TOPIC_B baking"
4882 .to_string()
4883 .as_str(),
4884 &emb,
4885 );
4886
4887 let bundle_on = retrieve_context_with_embedder(
4888 &conn2,
4889 "/fake-repo",
4890 &weights,
4891 ContextRequest {
4892 stage: "localization".to_string(),
4893 query: "cookie chocolate TOPIC_B".to_string(),
4895 budget_tokens: 4000,
4896 min_semantic_score: 0.1,
4897 ..Default::default()
4898 },
4899 &[],
4900 &emb,
4901 )
4902 .expect("retrieve on-topic");
4903
4904 assert!(
4905 bundle_on
4906 .capsules
4907 .iter()
4908 .any(|c| c.expansion_handle == "memory:m_b2"),
4909 "on-topic query (cosine=1.0 ≥ floor) must surface m_b2; \
4910 got capsules: {:?}",
4911 bundle_on
4912 .capsules
4913 .iter()
4914 .map(|c| &c.expansion_handle)
4915 .collect::<Vec<_>>()
4916 );
4917
4918 let conn3 = rusqlite::Connection::open_in_memory().expect("in-memory 3");
4921 crate::schema::initialize(&conn3).expect("init schema 3");
4922 insert_memory_with_embedding(
4923 &conn3,
4924 "m_b3",
4925 "cookie chocolate TOPIC_B recipe".to_string().as_str(),
4926 &emb,
4927 );
4928
4929 let bundle_noop_floor = retrieve_context_with_embedder(
4930 &conn3,
4931 "/fake-repo",
4932 &weights,
4933 ContextRequest {
4934 stage: "localization".to_string(),
4935 query: "cookie chocolate TOPIC_A".to_string(),
4937 budget_tokens: 4000,
4938 min_semantic_score: 0.0, ..Default::default()
4940 },
4941 &[],
4942 &emb,
4943 )
4944 .expect("retrieve noop floor");
4945
4946 assert!(
4948 bundle_noop_floor
4949 .capsules
4950 .iter()
4951 .any(|c| c.expansion_handle == "memory:m_b3"),
4952 "with floor=0.0 (disabled), off-topic-cosine memory must still surface via FTS; \
4953 got: {:?}",
4954 bundle_noop_floor
4955 .capsules
4956 .iter()
4957 .map(|c| &c.expansion_handle)
4958 .collect::<Vec<_>>()
4959 );
4960 }
4961
4962 #[cfg(feature = "embeddings")]
4991 #[test]
4992 fn d1f_token_economy_fewer_capsules_signal_preserved() {
4993 struct OracleTopicEmbedder;
4995 impl embeddings::Embedder for OracleTopicEmbedder {
4996 fn embed(&self, text: &str) -> Result<Vec<f32>, embeddings::EmbedderError> {
4997 let mut v = vec![0.0f32; 8];
4998 if text.contains("TOPIC_A") {
4999 v[0] = 1.0; } else {
5001 v[1] = 1.0; }
5003 Ok(v)
5004 }
5005 fn model_id(&self) -> &str {
5006 "oracle-topic-d8"
5007 }
5008 fn dim(&self) -> usize {
5009 8
5010 }
5011 }
5012
5013 let oracle = OracleTopicEmbedder;
5014
5015 let setup = |conn: &rusqlite::Connection| {
5017 for (mid, text) in [
5020 ("m_dup1", "TOPIC_A prefer ripgrep for searching"),
5021 ("m_dup2", "TOPIC_A rg is the fastest searcher"),
5022 ("m_dup3", "TOPIC_A use rg tool to find patterns"),
5023 (
5025 "m_relevant",
5026 "TOPIC_A critical lesson about search performance",
5027 ),
5028 ("m_noise1", "chocolate cookie baking TOPIC_B recipe"),
5030 ("m_noise2", "gardening tulip planting TOPIC_B spring"),
5031 ] {
5032 insert_memory_with_embedding(conn, mid, text, &oracle);
5033 }
5034 };
5035
5036 let weights = kimetsu_core::config::BrokerWeights::default();
5037
5038 let conn_lean = rusqlite::Connection::open_in_memory().expect("in-memory lean");
5045 crate::schema::initialize(&conn_lean).expect("init schema lean");
5046 setup(&conn_lean);
5047
5048 let bundle_lean = retrieve_context_with_embedder(
5049 &conn_lean,
5050 "/fake-repo",
5051 &weights,
5052 ContextRequest {
5053 stage: "localization".to_string(),
5054 query: "TOPIC_A search performance".to_string(),
5055 budget_tokens: 20_000,
5056 min_semantic_score: 0.0, ..Default::default()
5058 },
5059 &[],
5060 &embeddings::NoopEmbedder,
5061 )
5062 .expect("retrieve lean");
5063
5064 let lean_count = bundle_lean
5065 .capsules
5066 .iter()
5067 .filter(|c| c.expansion_handle.starts_with("memory:"))
5068 .count();
5069
5070 let conn_emb = rusqlite::Connection::open_in_memory().expect("in-memory emb");
5072 crate::schema::initialize(&conn_emb).expect("init schema emb");
5073 setup(&conn_emb);
5074
5075 let bundle_emb = retrieve_context_with_embedder(
5076 &conn_emb,
5077 "/fake-repo",
5078 &weights,
5079 ContextRequest {
5080 stage: "localization".to_string(),
5081 query: "TOPIC_A search performance".to_string(),
5082 budget_tokens: 20_000,
5083 min_semantic_score: 0.5, ..Default::default()
5085 },
5086 &[],
5087 &oracle,
5088 )
5089 .expect("retrieve with embeddings");
5090
5091 let emb_count = bundle_emb
5092 .capsules
5093 .iter()
5094 .filter(|c| c.expansion_handle.starts_with("memory:"))
5095 .count();
5096
5097 assert!(
5099 emb_count < lean_count,
5100 "D1e must reduce capsule count: embedding path {emb_count} must be \
5101 < lean path {lean_count}. Embedding capsules: {:?}",
5102 bundle_emb
5103 .capsules
5104 .iter()
5105 .map(|c| &c.expansion_handle)
5106 .collect::<Vec<_>>()
5107 );
5108
5109 assert!(
5111 bundle_emb
5112 .capsules
5113 .iter()
5114 .any(|c| c.expansion_handle == "memory:m_relevant"),
5115 "m_relevant must survive D1e selection (signal preserved); \
5116 embedding capsules: {:?}",
5117 bundle_emb
5118 .capsules
5119 .iter()
5120 .map(|c| &c.expansion_handle)
5121 .collect::<Vec<_>>()
5122 );
5123
5124 let lean_tokens: u32 = bundle_lean.capsules.iter().map(|c| c.token_estimate).sum();
5126 let emb_tokens: u32 = bundle_emb.capsules.iter().map(|c| c.token_estimate).sum();
5127 assert!(
5128 emb_tokens < lean_tokens,
5129 "D1e must reduce token usage: emb={emb_tokens} must be < lean={lean_tokens}"
5130 );
5131 }
5132
5133 #[test]
5139 fn lean_noop_embedder_uses_fts_then_recency_unchanged() {
5140 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5143 crate::schema::initialize(&conn).expect("init schema");
5144
5145 for (mid, text) in [
5147 ("m_x", "use git rebase to clean history"),
5148 ("m_y", "grep finds text quickly"),
5149 ] {
5150 let normalized = kimetsu_core::memory::normalize_memory_text(text);
5151 conn.execute(
5152 "INSERT INTO memories (
5153 memory_id, scope, kind, text, normalized_text, confidence,
5154 source_event_id, provenance_snapshot_json, created_at,
5155 use_count, usefulness_score
5156 )
5157 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
5158 '2026-01-01T00:00:00Z', 0, 0.0)",
5159 rusqlite::params![mid, text, normalized],
5160 )
5161 .expect("insert");
5162 conn.execute(
5163 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
5164 rusqlite::params![mid, text],
5165 )
5166 .expect("insert fts");
5167 }
5168
5169 let weights = kimetsu_core::config::BrokerWeights::default();
5170 let bundle = retrieve_context_with_embedder(
5172 &conn,
5173 "/fake-repo",
5174 &weights,
5175 ContextRequest {
5176 stage: "localization".to_string(),
5177 query: "grep text".to_string(),
5178 budget_tokens: 4000,
5179 ..Default::default()
5180 },
5181 &[],
5182 &embeddings::NoopEmbedder,
5183 )
5184 .expect("retrieve with NoopEmbedder must not panic");
5185
5186 let handles: Vec<&str> = bundle
5188 .capsules
5189 .iter()
5190 .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
5191 .collect();
5192 assert!(
5193 handles.contains(&"m_y"),
5194 "m_y must surface via FTS on lean path; got {handles:?}"
5195 );
5196 }
5198
5199 #[test]
5205 fn classify_task_maps_each_kind_deterministically() {
5206 assert_eq!(
5208 classify_task("fix the panic in the parser"),
5209 TaskKind::Debug,
5210 "contains 'fix' and 'panic'"
5211 );
5212 assert_eq!(
5213 classify_task("there is a crash in auth when calling login"),
5214 TaskKind::Debug,
5215 "contains 'crash'"
5216 );
5217 assert_eq!(
5218 classify_task("debug the failing test"),
5219 TaskKind::Debug,
5220 "contains 'debug' and 'fail'"
5221 );
5222
5223 assert_eq!(
5225 classify_task("investigate why retrieval is slow"),
5226 TaskKind::Investigation,
5227 "contains 'investigate' and 'why'"
5228 );
5229 assert_eq!(
5230 classify_task("analyze the root cause of the latency"),
5231 TaskKind::Investigation,
5232 "contains 'analyze' and 'root cause'"
5233 );
5234
5235 assert_eq!(
5237 classify_task("refactor the auth module"),
5238 TaskKind::Refactor,
5239 "contains 'refactor'"
5240 );
5241 assert_eq!(
5242 classify_task("rename the config struct"),
5243 TaskKind::Refactor,
5244 "contains 'rename'"
5245 );
5246 assert_eq!(
5247 classify_task("simplify the retry handling logic"),
5248 TaskKind::Refactor,
5249 "contains 'simplify'"
5250 );
5251
5252 assert_eq!(
5254 classify_task("document the API endpoints"),
5255 TaskKind::Docs,
5256 "contains 'document'"
5257 );
5258 assert_eq!(
5259 classify_task("update the readme with new instructions"),
5260 TaskKind::Docs,
5261 "contains 'readme'"
5262 );
5263 assert_eq!(
5264 classify_task("add a docstring to the main function"),
5265 TaskKind::Docs,
5266 "contains 'docstring'"
5267 );
5268
5269 assert_eq!(
5271 classify_task("add a dark mode toggle"),
5272 TaskKind::Feature,
5273 "no debug/refactor/docs/investigate keyword"
5274 );
5275 assert_eq!(
5276 classify_task("implement the new caching layer"),
5277 TaskKind::Feature,
5278 "no debug/refactor/docs/investigate keyword"
5279 );
5280 assert_eq!(
5281 classify_task("build the export pipeline"),
5282 TaskKind::Feature,
5283 "no debug/refactor/docs/investigate keyword"
5284 );
5285 }
5286
5287 #[test]
5289 fn classify_task_respects_precedence_order() {
5290 assert_eq!(
5292 classify_task("fix and refactor the login module"),
5293 TaskKind::Debug,
5294 "Debug > Refactor"
5295 );
5296 assert_eq!(
5298 classify_task("investigate and refactor the cache layer"),
5299 TaskKind::Investigation,
5300 "Investigation > Refactor"
5301 );
5302 assert_eq!(
5304 classify_task("investigate the docs and document the API"),
5305 TaskKind::Investigation,
5306 "Investigation > Docs"
5307 );
5308 assert_eq!(
5310 classify_task("refactor and add docs"),
5311 TaskKind::Refactor,
5312 "Refactor > Docs"
5313 );
5314 assert_eq!(
5316 classify_task("fix the bug and investigate the regression"),
5317 TaskKind::Debug,
5318 "Debug > Investigation"
5319 );
5320 }
5321
5322 fn two_kinds_one_strong() -> Vec<Candidate> {
5327 let mk = |kind: &str, raw: f32| Candidate {
5328 capsule: ContextCapsule {
5329 id: format!("{kind}-1"),
5330 kind: kind.to_string(),
5331 summary: String::new(),
5332 token_estimate: 0,
5333 expansion_handle: String::new(),
5334 provenance: Vec::new(),
5335 confidence: 0.0,
5336 freshness: 0.0,
5337 relevance: 0.0,
5338 scope_weight: 0.0,
5339 score: 0.0,
5340 superseded_hint: false,
5341 rerank_policy_tier: 0,
5342 claim_revision: None,
5343 facts: vec![],
5344 rerank_usefulness: None,
5345 rerank_trust: None,
5346 },
5347 raw_relevance: raw,
5348 embedding: None,
5349 cosine: None,
5350 created_at: None,
5351 };
5352 vec![mk("memory", 0.9), mk("repo_file", 0.1)]
5353 }
5354
5355 #[test]
5358 fn per_kind_normalization_flatters_the_best_of_a_weak_kind() {
5359 let mut candidates = two_kinds_one_strong();
5360 let weights = StageWeights {
5361 relevance: 1.0,
5362 confidence: 0.0,
5363 freshness: 0.0,
5364 scope: 0.0,
5365 };
5366 normalize_and_score(&mut candidates, weights, Normalization::PerKind);
5367 assert!((candidates[0].capsule.relevance - 1.0).abs() < 1e-6);
5368 assert!(
5369 (candidates[1].capsule.relevance - 1.0).abs() < 1e-6,
5370 "per-kind gives the lone weak repo_file relevance 1.0, got {}",
5371 candidates[1].capsule.relevance
5372 );
5373 }
5374
5375 #[test]
5378 fn global_normalization_keeps_relevance_comparable_across_kinds() {
5379 let mut candidates = two_kinds_one_strong();
5380 let weights = StageWeights {
5381 relevance: 1.0,
5382 confidence: 0.0,
5383 freshness: 0.0,
5384 scope: 0.0,
5385 };
5386 normalize_and_score(&mut candidates, weights, Normalization::Global);
5387 assert!((candidates[0].capsule.relevance - 1.0).abs() < 1e-6);
5388 let weak = candidates[1].capsule.relevance;
5389 assert!(
5390 (weak - (0.1 / 0.9)).abs() < 1e-6,
5391 "global normalizes against the single max, got {weak}"
5392 );
5393 assert!(weak < candidates[0].capsule.relevance);
5394 }
5395
5396 fn superseding_candidate(
5399 id: &str,
5400 embedding: Vec<f32>,
5401 created_at: &str,
5402 score: f32,
5403 ) -> Candidate {
5404 Candidate {
5405 capsule: ContextCapsule {
5406 id: id.to_string(),
5407 kind: "memory".to_string(),
5408 summary: id.to_string(),
5409 token_estimate: 0,
5410 expansion_handle: format!("memory:{id}"),
5411 provenance: Vec::new(),
5412 confidence: 0.0,
5413 freshness: 0.0,
5414 relevance: 0.0,
5415 scope_weight: 0.0,
5416 score,
5417 superseded_hint: false,
5418 rerank_policy_tier: 0,
5419 claim_revision: None,
5420 facts: vec![],
5421 rerank_usefulness: None,
5422 rerank_trust: None,
5423 },
5424 raw_relevance: score,
5425 embedding: Some(embedding),
5426 cosine: Some(score),
5428 created_at: Some(created_at.to_string()),
5429 }
5430 }
5431
5432 #[test]
5435 fn supersession_penalizes_the_older_near_duplicate() {
5436 let mut candidates = vec![
5437 superseding_candidate("old", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.94),
5439 superseding_candidate("new", vec![0.99, 0.14], "2026-08-01T10:10:00Z", 0.87),
5440 ];
5441 apply_supersession_penalty(&mut candidates);
5442 let old_score = candidates[0].capsule.score;
5443 let new_score = candidates[1].capsule.score;
5444 assert!(
5445 (old_score - 0.94 * SUPERSESSION_PENALTY).abs() < 1e-6,
5446 "older twin must carry the penalty, got {old_score}"
5447 );
5448 assert!((new_score - 0.87).abs() < 1e-6, "newer twin untouched");
5449 assert!(
5450 new_score > old_score,
5451 "the update must now outrank the incumbent"
5452 );
5453 }
5454
5455 #[test]
5458 fn supersession_ignores_distinct_memories_and_applies_once() {
5459 let mut candidates = vec![
5460 superseding_candidate("old", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.90),
5461 superseding_candidate("other", vec![0.0, 1.0], "2026-08-02T10:00:00Z", 0.80),
5463 superseding_candidate("new1", vec![0.99, 0.14], "2026-08-03T10:00:00Z", 0.70),
5465 superseding_candidate("new2", vec![0.98, 0.19], "2026-08-04T10:00:00Z", 0.60),
5466 ];
5467 apply_supersession_penalty(&mut candidates);
5468 assert!(
5469 (candidates[0].capsule.score - 0.90 * SUPERSESSION_PENALTY).abs() < 1e-6,
5470 "penalty applies exactly once, got {}",
5471 candidates[0].capsule.score
5472 );
5473 assert!(
5474 (candidates[1].capsule.score - 0.80).abs() < 1e-6,
5475 "orthogonal memory untouched"
5476 );
5477 assert!(
5479 (candidates[2].capsule.score - 0.70 * SUPERSESSION_PENALTY).abs() < 1e-6,
5480 "a middle sibling is old relative to a newer one"
5481 );
5482 assert!(
5483 (candidates[3].capsule.score - 0.60).abs() < 1e-6,
5484 "newest untouched"
5485 );
5486 }
5487
5488 #[test]
5490 fn supersession_is_inert_without_embeddings_or_timestamps() {
5491 let mut no_embedding = vec![
5492 Candidate {
5493 embedding: None,
5494 ..superseding_candidate("a", vec![], "2026-08-01T10:00:00Z", 0.9)
5495 },
5496 Candidate {
5497 embedding: None,
5498 ..superseding_candidate("b", vec![], "2026-08-02T10:00:00Z", 0.8)
5499 },
5500 ];
5501 apply_supersession_penalty(&mut no_embedding);
5502 assert!((no_embedding[0].capsule.score - 0.9).abs() < 1e-6);
5503
5504 let mut bad_ts = vec![
5505 superseding_candidate("a", vec![1.0, 0.0], "not-a-date", 0.9),
5506 superseding_candidate("b", vec![1.0, 0.0], "2026-08-02T10:00:00Z", 0.8),
5507 ];
5508 apply_supersession_penalty(&mut bad_ts);
5509 assert!(
5510 (bad_ts[0].capsule.score - 0.9).abs() < 1e-6,
5511 "unparseable ts skipped"
5512 );
5513 assert!((bad_ts[1].capsule.score - 0.8).abs() < 1e-6);
5514
5515 let mut same_ts = vec![
5517 superseding_candidate("a", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.9),
5518 superseding_candidate("b", vec![1.0, 0.0], "2026-08-01T10:00:00Z", 0.8),
5519 ];
5520 apply_supersession_penalty(&mut same_ts);
5521 assert!((same_ts[0].capsule.score - 0.9).abs() < 1e-6);
5522 assert!((same_ts[1].capsule.score - 0.8).abs() < 1e-6);
5523
5524 let mut batch = vec![
5527 superseding_candidate("a", vec![1.0, 0.0], "2026-08-01T10:00:00.100Z", 0.9),
5528 superseding_candidate("b", vec![1.0, 0.0], "2026-08-01T10:00:00.900Z", 0.8),
5529 ];
5530 apply_supersession_penalty(&mut batch);
5531 assert!(
5532 (batch[0].capsule.score - 0.9).abs() < 1e-6,
5533 "millisecond-apart co-writes must not be penalized"
5534 );
5535 assert!((batch[1].capsule.score - 0.8).abs() < 1e-6);
5536
5537 let mut explicit_update = vec![
5541 superseding_candidate(
5542 "the project uses spaces (switched from tabs)",
5543 vec![1.0, 0.0],
5544 "2026-08-01T10:00:00.100Z",
5545 0.9,
5546 ),
5547 superseding_candidate(
5548 "the project uses tabs for indentation",
5549 vec![0.83, 0.557_8],
5552 "2026-08-01T10:00:00.900Z",
5553 0.8,
5554 ),
5555 ];
5556 apply_supersession_penalty(&mut explicit_update);
5557 assert!((explicit_update[0].capsule.score - 0.9).abs() < 1e-6);
5558 assert!(
5559 (explicit_update[1].capsule.score - 0.8 * SUPERSESSION_PENALTY).abs() < 1e-6,
5560 "the unmarked incumbent must lose to the explicit correction"
5561 );
5562 assert!(explicit_update[1].capsule.superseded_hint);
5563
5564 let mut rewritten_update = vec![
5567 superseding_candidate(
5568 "as of v2 the preferred kimetsu embedder is jina, replacing bge",
5569 vec![1.0, 0.0],
5570 "2026-08-01T10:00:00.100Z",
5571 0.9,
5572 ),
5573 superseding_candidate(
5574 "the recommended kimetsu embedder for retrieval is bge",
5575 vec![0.71, 0.704_2],
5576 "2026-08-01T10:00:00.900Z",
5577 0.8,
5578 ),
5579 ];
5580 apply_supersession_penalty(&mut rewritten_update);
5581 assert!((rewritten_update[0].capsule.score - 0.9).abs() < 1e-6);
5582 assert!((rewritten_update[1].capsule.score - 0.8 * SUPERSESSION_PENALTY).abs() < 1e-6);
5583
5584 let mut historical_query = vec![
5587 Candidate {
5588 cosine: Some(0.70),
5589 ..superseding_candidate(
5590 "as of v2 the preferred kimetsu embedder is jina, replacing bge",
5591 vec![1.0, 0.0],
5592 "2026-08-01T10:00:00.100Z",
5593 0.9,
5594 )
5595 },
5596 Candidate {
5597 cosine: Some(0.90),
5598 ..superseding_candidate(
5599 "the recommended kimetsu embedder for retrieval is bge",
5600 vec![0.71, 0.704_2],
5601 "2026-08-01T10:00:00.900Z",
5602 0.8,
5603 )
5604 },
5605 ];
5606 apply_supersession_penalty(&mut historical_query);
5607 assert!((historical_query[0].capsule.score - 0.9).abs() < 1e-6);
5608 assert!((historical_query[1].capsule.score - 0.8).abs() < 1e-6);
5609
5610 let mut too_distant = vec![
5613 superseding_candidate(
5614 "the project now uses spaces",
5615 vec![1.0, 0.0],
5616 "2026-08-01T10:00:00.100Z",
5617 0.9,
5618 ),
5619 superseding_candidate(
5620 "database backup retention is seven days",
5621 vec![0.81, 0.586_4],
5622 "2026-08-01T10:00:00.900Z",
5623 0.8,
5624 ),
5625 ];
5626 apply_supersession_penalty(&mut too_distant);
5627 assert!((too_distant[0].capsule.score - 0.9).abs() < 1e-6);
5628 assert!((too_distant[1].capsule.score - 0.8).abs() < 1e-6);
5629
5630 let mut ambiguous = vec![
5632 superseding_candidate(
5633 "the setting is now cheap_model",
5634 vec![1.0, 0.0],
5635 "2026-08-01T10:00:00.100Z",
5636 0.9,
5637 ),
5638 superseding_candidate(
5639 "as of v2 the setting is cheap_model",
5640 vec![1.0, 0.0],
5641 "2026-08-01T10:00:00.900Z",
5642 0.8,
5643 ),
5644 ];
5645 apply_supersession_penalty(&mut ambiguous);
5646 assert!((ambiguous[0].capsule.score - 0.9).abs() < 1e-6);
5647 assert!((ambiguous[1].capsule.score - 0.8).abs() < 1e-6);
5648 }
5649
5650 #[test]
5653 fn unknown_normalization_falls_back_to_per_kind() {
5654 assert_eq!(Normalization::from_config(""), Normalization::PerKind);
5655 assert_eq!(
5656 Normalization::from_config("per_kind"),
5657 Normalization::PerKind
5658 );
5659 assert_eq!(
5660 Normalization::from_config("nonsense"),
5661 Normalization::PerKind
5662 );
5663 assert_eq!(Normalization::from_config("global"), Normalization::Global);
5664 assert_eq!(
5665 Normalization::from_config(" GLOBAL "),
5666 Normalization::Global
5667 );
5668 }
5669
5670 #[test]
5671 fn weights_for_task_kind_renormalizes_to_unit_sum() {
5672 let base = StageWeights {
5673 relevance: 0.50,
5674 confidence: 0.20,
5675 freshness: 0.20,
5676 scope: 0.10,
5677 };
5678 let original_sum = base.relevance + base.confidence + base.freshness + base.scope;
5679
5680 for kind in [
5681 TaskKind::Debug,
5682 TaskKind::Refactor,
5683 TaskKind::Investigation,
5684 TaskKind::Docs,
5685 ] {
5686 let w = weights_for_task_kind(base.clone(), kind);
5687 let new_sum = w.relevance + w.confidence + w.freshness + w.scope;
5688 assert!(
5690 (new_sum - original_sum).abs() < 1e-4,
5691 "weights_for_task_kind({kind:?}) sum {new_sum} differs from {original_sum}"
5692 );
5693 }
5694 }
5695
5696 #[test]
5698 fn weights_for_task_kind_feature_is_unchanged() {
5699 let base = StageWeights {
5700 relevance: 0.40,
5701 confidence: 0.30,
5702 freshness: 0.20,
5703 scope: 0.10,
5704 };
5705 let w = weights_for_task_kind(base.clone(), TaskKind::Feature);
5706 assert!((w.relevance - base.relevance).abs() < f32::EPSILON);
5707 assert!((w.confidence - base.confidence).abs() < f32::EPSILON);
5708 assert!((w.freshness - base.freshness).abs() < f32::EPSILON);
5709 assert!((w.scope - base.scope).abs() < f32::EPSILON);
5710 }
5711
5712 #[test]
5715 fn weights_for_task_kind_debug_up_freshness_fraction() {
5716 let base = StageWeights {
5717 relevance: 0.50,
5718 confidence: 0.20,
5719 freshness: 0.20,
5720 scope: 0.10,
5721 };
5722 let debug_w = weights_for_task_kind(base.clone(), TaskKind::Debug);
5723 assert!(
5725 debug_w.freshness > base.freshness,
5726 "Debug must increase freshness fraction: {debug_w:?}"
5727 );
5728 }
5729
5730 #[test]
5733 fn weights_for_task_kind_refactor_up_scope_fraction() {
5734 let base = StageWeights {
5735 relevance: 0.50,
5736 confidence: 0.20,
5737 freshness: 0.20,
5738 scope: 0.10,
5739 };
5740 let refactor_w = weights_for_task_kind(base.clone(), TaskKind::Refactor);
5741 assert!(
5742 refactor_w.scope > base.scope,
5743 "Refactor must increase scope fraction: {refactor_w:?}"
5744 );
5745 }
5746
5747 #[test]
5750 fn task_kind_feature_is_retrieval_neutral() {
5751 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5752 crate::schema::initialize(&conn).expect("init schema");
5753
5754 for (mid, db_kind, text) in [
5758 ("m1", "failure_pattern", "linker not found error in build"),
5759 ("m2", "convention", "use snake_case for all identifiers"),
5760 ("m3", "fact", "the cache is invalidated on every deploy"),
5761 ] {
5762 let normalized = kimetsu_core::memory::normalize_memory_text(text);
5763 conn.execute(
5764 "INSERT INTO memories (
5765 memory_id, scope, kind, text, normalized_text, confidence,
5766 source_event_id, provenance_snapshot_json, created_at,
5767 use_count, usefulness_score
5768 )
5769 VALUES (?1, 'project', ?2, ?3, ?4, 1.0, NULL, '{}',
5770 '2026-01-01T00:00:00Z', 0, 0.0)",
5771 rusqlite::params![mid, db_kind, text, normalized],
5772 )
5773 .expect("insert memory");
5774 conn.execute(
5775 "INSERT INTO memories_fts (memory_id, text, kind, scope)
5776 VALUES (?1, ?2, ?3, 'project')",
5777 rusqlite::params![mid, text, db_kind],
5778 )
5779 .expect("insert fts");
5780 }
5781
5782 let weights = kimetsu_core::config::BrokerWeights::default();
5783 let query = "cache convention failure".to_string();
5784
5785 let baseline = retrieve_context_with_embedder(
5787 &conn,
5788 "/fake-repo",
5789 &weights,
5790 ContextRequest {
5791 stage: "localization".to_string(),
5792 query: query.clone(),
5793 budget_tokens: 4000,
5794 ..Default::default()
5795 },
5796 &[],
5797 &embeddings::NoopEmbedder,
5798 )
5799 .expect("baseline retrieve");
5800
5801 let feature = retrieve_context_with_embedder(
5803 &conn,
5804 "/fake-repo",
5805 &weights,
5806 ContextRequest {
5807 stage: "localization".to_string(),
5808 query: query.clone(),
5809 budget_tokens: 4000,
5810 task_kind: TaskKind::Feature,
5811 ..Default::default()
5812 },
5813 &[],
5814 &embeddings::NoopEmbedder,
5815 )
5816 .expect("feature retrieve");
5817
5818 let baseline_ids: Vec<&str> = baseline
5819 .capsules
5820 .iter()
5821 .map(|c| c.expansion_handle.as_str())
5822 .collect();
5823 let feature_ids: Vec<&str> = feature
5824 .capsules
5825 .iter()
5826 .map(|c| c.expansion_handle.as_str())
5827 .collect();
5828 assert_eq!(
5829 baseline_ids, feature_ids,
5830 "task_kind=Feature must produce identical retrieval to default; \
5831 baseline={baseline_ids:?} feature={feature_ids:?}"
5832 );
5833
5834 let baseline_scores: Vec<f32> = baseline.capsules.iter().map(|c| c.score).collect();
5835 let feature_scores: Vec<f32> = feature.capsules.iter().map(|c| c.score).collect();
5836 for (b, f) in baseline_scores.iter().zip(feature_scores.iter()) {
5837 assert!(
5838 (b - f).abs() < 1e-5,
5839 "scores must be identical: baseline={b} feature={f}"
5840 );
5841 }
5842 }
5843
5844 #[test]
5855 fn debug_surfaces_more_failure_pattern_than_docs() {
5856 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5857 crate::schema::initialize(&conn).expect("init schema");
5858
5859 for (i, text) in [
5863 "auth token expired causes login failure",
5864 "auth service crash on null pointer",
5865 "auth regression after upgrade breaks sessions",
5866 "auth error when certificate is invalid",
5867 ]
5868 .iter()
5869 .enumerate()
5870 {
5871 let mid = format!("mfp{i}");
5872 let normalized = kimetsu_core::memory::normalize_memory_text(text);
5873 conn.execute(
5874 "INSERT INTO memories (
5875 memory_id, scope, kind, text, normalized_text, confidence,
5876 source_event_id, provenance_snapshot_json, created_at,
5877 use_count, usefulness_score
5878 )
5879 VALUES (?1, 'project', 'failure_pattern', ?2, ?3, 1.0, NULL, '{}',
5880 '2026-01-01T00:00:00Z', 0, 0.0)",
5881 rusqlite::params![mid, text, normalized],
5882 )
5883 .expect("insert failure_pattern");
5884 conn.execute(
5885 "INSERT INTO memories_fts (memory_id, text, kind, scope)
5886 VALUES (?1, ?2, 'failure_pattern', 'project')",
5887 rusqlite::params![mid, text],
5888 )
5889 .expect("insert fts");
5890 }
5891
5892 for (i, (db_kind, text)) in [
5895 ("convention", "auth module uses bearer tokens by convention"),
5896 ("convention", "auth scopes are documented in the API guide"),
5897 ("fact", "auth service runs on port 8443 in production"),
5898 ("fact", "auth uses JWT with RS256 signing for all tokens"),
5899 ]
5900 .iter()
5901 .enumerate()
5902 {
5903 let mid = format!("mconv{i}");
5904 let normalized = kimetsu_core::memory::normalize_memory_text(text);
5905 conn.execute(
5906 "INSERT INTO memories (
5907 memory_id, scope, kind, text, normalized_text, confidence,
5908 source_event_id, provenance_snapshot_json, created_at,
5909 use_count, usefulness_score
5910 )
5911 VALUES (?1, 'project', ?2, ?3, ?4, 1.0, NULL, '{}',
5912 '2026-01-01T00:00:00Z', 0, 0.0)",
5913 rusqlite::params![mid, db_kind, text, normalized],
5914 )
5915 .expect("insert convention/fact");
5916 conn.execute(
5917 "INSERT INTO memories_fts (memory_id, text, kind, scope)
5918 VALUES (?1, ?2, ?3, 'project')",
5919 rusqlite::params![mid, text, db_kind],
5920 )
5921 .expect("insert fts");
5922 }
5923
5924 let weights = kimetsu_core::config::BrokerWeights::default();
5925 let query = "auth token failure".to_string();
5926
5927 let debug_bundle = retrieve_context_with_embedder(
5929 &conn,
5930 "/fake-repo",
5931 &weights,
5932 ContextRequest {
5933 stage: "localization".to_string(),
5934 query: query.clone(),
5935 budget_tokens: 4000,
5936 max_capsules: 4,
5937 task_kind: TaskKind::Debug,
5938 ..Default::default()
5939 },
5940 &[],
5941 &embeddings::NoopEmbedder,
5942 )
5943 .expect("debug retrieve");
5944
5945 let docs_bundle = retrieve_context_with_embedder(
5947 &conn,
5948 "/fake-repo",
5949 &weights,
5950 ContextRequest {
5951 stage: "localization".to_string(),
5952 query: query.clone(),
5953 budget_tokens: 4000,
5954 max_capsules: 4,
5955 task_kind: TaskKind::Docs,
5956 ..Default::default()
5957 },
5958 &[],
5959 &embeddings::NoopEmbedder,
5960 )
5961 .expect("docs retrieve");
5962
5963 let count_failure_pattern = |bundle: &ContextBundle| -> usize {
5966 bundle
5967 .capsules
5968 .iter()
5969 .filter(|c| capsule_matches_kind(c, "failure_pattern"))
5970 .count()
5971 };
5972
5973 let debug_fp = count_failure_pattern(&debug_bundle);
5974 let docs_fp = count_failure_pattern(&docs_bundle);
5975
5976 assert!(
5977 debug_fp > docs_fp,
5978 "Debug must surface strictly more failure_pattern capsules than Docs: \
5979 debug_fp={debug_fp} docs_fp={docs_fp}\n\
5980 Debug capsules: {:?}\n\
5981 Docs capsules: {:?}",
5982 debug_bundle
5983 .capsules
5984 .iter()
5985 .map(|c| format!("{}:{}", c.kind, &c.summary[..c.summary.len().min(60)]))
5986 .collect::<Vec<_>>(),
5987 docs_bundle
5988 .capsules
5989 .iter()
5990 .map(|c| format!("{}:{}", c.kind, &c.summary[..c.summary.len().min(60)]))
5991 .collect::<Vec<_>>(),
5992 );
5993 }
5994
5995 fn init_db_with_memory(memory_id: &str, text: &str) -> rusqlite::Connection {
5998 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
5999 crate::schema::initialize(&conn).expect("init schema");
6000 let normalized = kimetsu_core::memory::normalize_memory_text(text);
6001 conn.execute(
6002 "INSERT INTO memories (
6003 memory_id, scope, kind, text, normalized_text, confidence,
6004 source_event_id, provenance_snapshot_json, created_at,
6005 use_count, usefulness_score
6006 )
6007 VALUES (?1, 'project', 'fact', ?2, ?3, 1.0, NULL, '{}',
6008 '2026-01-01T00:00:00Z', 0, 0.0)",
6009 rusqlite::params![memory_id, text, normalized],
6010 )
6011 .expect("insert memory");
6012 conn
6013 }
6014
6015 #[test]
6017 fn resolve_capsule_memory_returns_full_text() {
6018 let conn = init_db_with_memory("test-mem-id", "Use rg over grep for speed");
6019 let repo_root = std::path::Path::new("/fake-repo");
6020 let result =
6021 resolve_capsule(&conn, repo_root, "memory:test-mem-id").expect("should resolve");
6022 assert_eq!(result, "Use rg over grep for speed");
6023 }
6024
6025 #[test]
6027 fn resolve_capsule_memory_missing_id_returns_err() {
6028 let conn = init_db_with_memory("real-id", "some text");
6029 let repo_root = std::path::Path::new("/fake-repo");
6030 let err = resolve_capsule(&conn, repo_root, "memory:nonexistent-id")
6031 .expect_err("should error for missing memory");
6032 assert!(
6033 err.to_string().contains("no active memory"),
6034 "error message should mention missing: {err}"
6035 );
6036 }
6037
6038 #[test]
6040 fn resolve_capsule_file_returns_bounded_content() {
6041 let dir = make_test_dir("f2_file_resolve");
6042 let content = "hello from the file\n";
6043 std::fs::write(dir.join("notes.txt"), content).expect("write");
6044 let result = resolve_capsule(
6045 &rusqlite::Connection::open_in_memory().expect("open"),
6047 &dir,
6048 "file:notes.txt",
6049 )
6050 .expect("should resolve file");
6051 assert!(result.contains("hello from the file"));
6052 std::fs::remove_dir_all(&dir).ok();
6053 }
6054
6055 #[test]
6057 fn resolve_capsule_file_caps_large_file() {
6058 let dir = make_test_dir("f2_file_cap");
6059 let big = "A".repeat(FILE_EXPAND_CAP_BYTES * 3);
6060 std::fs::write(dir.join("big.txt"), &big).expect("write");
6061 let result = resolve_capsule(
6062 &rusqlite::Connection::open_in_memory().expect("open"),
6063 &dir,
6064 "file:big.txt",
6065 )
6066 .expect("should resolve large file");
6067 assert!(
6068 result.len() <= FILE_EXPAND_CAP_BYTES + 200,
6069 "result should be bounded: got {} bytes",
6070 result.len()
6071 );
6072 assert!(
6073 result.contains("truncated"),
6074 "truncation marker should be present"
6075 );
6076 std::fs::remove_dir_all(&dir).ok();
6077 }
6078
6079 #[test]
6081 fn resolve_capsule_unknown_handle_returns_err() {
6082 let conn = rusqlite::Connection::open_in_memory().expect("open");
6083 let err = resolve_capsule(&conn, std::path::Path::new("/r"), "blob:abc123")
6084 .expect_err("should error");
6085 assert!(
6086 err.to_string().contains("unrecognised handle"),
6087 "got: {err}"
6088 );
6089 }
6090
6091 #[test]
6093 fn resolve_capsule_malformed_handle_returns_err() {
6094 let conn = rusqlite::Connection::open_in_memory().expect("open");
6095 let err = resolve_capsule(&conn, std::path::Path::new("/r"), "justnocolon")
6096 .expect_err("should error");
6097 assert!(
6098 err.to_string().contains("unrecognised handle"),
6099 "got: {err}"
6100 );
6101 }
6102
6103 #[test]
6105 fn resolve_capsule_run_handle_returns_deferred_err() {
6106 let conn = rusqlite::Connection::open_in_memory().expect("open");
6107 let err = resolve_capsule(&conn, std::path::Path::new("/r"), "run:some-run-id")
6108 .expect_err("run: should be deferred err");
6109 assert!(err.to_string().contains("not yet supported"), "got: {err}");
6110 }
6111
6112 #[test]
6114 fn resolve_capsule_file_rejects_absolute_path() {
6115 let conn = rusqlite::Connection::open_in_memory().expect("open");
6116 let err = resolve_capsule(&conn, std::path::Path::new("/r"), "file:/etc/passwd")
6117 .expect_err("should reject absolute path");
6118 assert!(err.to_string().contains("absolute path"), "got: {err}");
6119 }
6120
6121 fn make_capsule(summary: &str, score: f32) -> ContextCapsule {
6124 ContextCapsule {
6125 id: new_id().to_string(),
6126 kind: "memory".to_string(),
6127 summary: summary.to_string(),
6128 token_estimate: 10,
6129 expansion_handle: format!("memory:{}", new_id()),
6130 provenance: vec![],
6131 confidence: 1.0,
6132 freshness: 1.0,
6133 relevance: 1.0,
6134 scope_weight: 1.0,
6135 score,
6136 superseded_hint: false,
6137 rerank_policy_tier: 0,
6138 claim_revision: None,
6139 facts: vec![],
6140 rerank_usefulness: None,
6141 rerank_trust: None,
6142 }
6143 }
6144
6145 #[test]
6148 fn rerank_capsules_reorders_by_query_overlap() {
6149 use crate::embeddings::StubReranker;
6150
6151 let query = "rust async tokio";
6154 let high_overlap = make_capsule("rust async tokio runtime", 0.0);
6155 let low_overlap = make_capsule("python django framework", 0.0);
6156 let capsules = vec![low_overlap.clone(), high_overlap.clone()];
6158
6159 let ranked = rerank_capsules(query, capsules, &StubReranker, 0.0, 0);
6160
6161 assert_eq!(ranked.len(), 2, "both capsules should survive (floor=0)");
6162 assert!(
6164 ranked[0].summary.contains("rust"),
6165 "rust capsule must be first, got: {:?}",
6166 ranked[0].summary
6167 );
6168 assert!(
6170 ranked[0].score > 0.05,
6171 "score must be overwritten by reranker: {}",
6172 ranked[0].score
6173 );
6174 assert!(
6176 ranked[0].score > ranked[1].score,
6177 "high overlap must score higher: {} vs {}",
6178 ranked[0].score,
6179 ranked[1].score
6180 );
6181 }
6182
6183 #[test]
6187 fn rerank_capsules_floor_drops_zero_overlap() {
6188 use crate::embeddings::StubReranker;
6189
6190 let query = "rust async tokio";
6191 let high = make_capsule("rust async tokio runtime", 0.0);
6192 let zero = make_capsule("completely unrelated document xyz", 0.0); let capsules = vec![high, zero];
6195 let ranked = rerank_capsules(query, capsules, &StubReranker, 0.3, 0);
6196
6197 assert_eq!(ranked.len(), 1, "zero-overlap capsule must be dropped");
6199 assert!(
6200 ranked[0].summary.contains("rust"),
6201 "only rust capsule should survive"
6202 );
6203 }
6204
6205 #[test]
6207 fn rerank_capsules_cap_truncates() {
6208 use crate::embeddings::StubReranker;
6209
6210 let query = "alpha beta gamma";
6211 let capsules = vec![
6212 make_capsule("alpha beta gamma delta", 0.0),
6213 make_capsule("alpha beta", 0.0),
6214 make_capsule("alpha", 0.0),
6215 make_capsule("unrelated xyz", 0.0),
6216 ];
6217
6218 let ranked = rerank_capsules(query, capsules, &StubReranker, 0.0, 2);
6219 assert_eq!(ranked.len(), 2, "cap=2 must truncate to 2 results");
6220 assert!(
6222 ranked[0].score >= ranked[1].score,
6223 "results must be sorted descending"
6224 );
6225 }
6226
6227 #[test]
6228 fn hardening_usefulness_cannot_dominate_relevance() {
6229 let neutral = make_capsule("neutral", 0.0);
6230 let mut useful = make_capsule("useful", 0.0);
6231 useful.rerank_policy_tier = 1;
6232 let out = rerank_capsules(
6233 "q",
6234 vec![neutral, useful],
6235 &TwoScoreReranker(0.99, 0.31),
6236 0.30,
6237 0,
6238 );
6239 assert_eq!(out[0].summary, "neutral");
6240 assert!(out[1].score <= 0.410001);
6241 }
6242
6243 #[test]
6244 fn hardening_rerank_preserves_decayed_usefulness_and_trust() {
6245 let neutral = make_capsule("neutral", 0.0);
6246 let mut stale_useful = make_capsule("stale", 0.0);
6247 stale_useful.rerank_policy_tier = 1;
6248 stale_useful.rerank_usefulness = Some(1.0001);
6249 let out = rerank_capsules(
6250 "q",
6251 vec![neutral.clone(), stale_useful],
6252 &TwoScoreReranker(0.9, 0.85),
6253 0.0,
6254 0,
6255 );
6256 assert_eq!(out[0].summary, "neutral");
6257 let mut imported = make_capsule("imported", 0.0);
6258 imported.rerank_usefulness = Some(1.5);
6259 imported.rerank_trust = Some(0.5);
6260 let out = rerank_capsules(
6261 "q",
6262 vec![neutral, imported],
6263 &TwoScoreReranker(0.8, 0.9),
6264 0.0,
6265 0,
6266 );
6267 assert_eq!(out[0].summary, "neutral");
6268 assert!((out[1].score - 0.5).abs() < 0.00001);
6269 }
6270
6271 #[test]
6272 fn hardening_freshness_has_thirty_day_half_life() {
6273 let past = (OffsetDateTime::now_utc() - time::Duration::days(30))
6274 .format(&time::format_description::well_known::Rfc3339)
6275 .unwrap();
6276 assert!((freshness(&past) - 0.5).abs() < 0.0001);
6277 }
6278
6279 #[test]
6280 fn rerank_reapplies_usefulness_but_not_to_superseded_capsules() {
6281 let neutral = make_capsule("neutral", 0.0);
6282 let mut useful = make_capsule("useful", 0.0);
6283 useful.rerank_policy_tier = 1;
6284
6285 let out = rerank_capsules(
6286 "q",
6287 vec![neutral.clone(), useful.clone()],
6288 &TwoScoreReranker(0.59, 0.50),
6289 0.0,
6290 0,
6291 );
6292 assert_eq!(out[0].summary, "useful", "usefulness survives reranking");
6293
6294 useful.superseded_hint = true;
6295 useful.rerank_policy_tier = 1;
6296 let out = rerank_capsules(
6297 "q",
6298 vec![neutral, useful],
6299 &TwoScoreReranker(0.59, 0.50),
6300 0.0,
6301 0,
6302 );
6303 assert_eq!(
6304 out[0].summary, "neutral",
6305 "superseded memories must not keep their historic usefulness boost"
6306 );
6307 assert!(
6308 (out[1].score - 0.40).abs() < 1e-6,
6309 "supersession must be reapplied to the raw rerank score"
6310 );
6311 }
6312
6313 #[test]
6315 fn rerank_capsules_fail_open_preserves_input_order() {
6316 struct FailingReranker;
6317 impl crate::embeddings::Reranker for FailingReranker {
6318 fn rerank(
6319 &self,
6320 _query: &str,
6321 _docs: &[&str],
6322 ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6323 Err(crate::embeddings::EmbedderError::EmbedFailed(
6324 "simulated failure".into(),
6325 ))
6326 }
6327 fn model_id(&self) -> &str {
6328 "fail-reranker"
6329 }
6330 }
6331
6332 let query = "anything";
6333 let c1 = make_capsule("first capsule", 0.9);
6334 let c2 = make_capsule("second capsule", 0.5);
6335 let c3 = make_capsule("third capsule", 0.1);
6336 let capsules = vec![c1.clone(), c2.clone(), c3.clone()];
6337
6338 let out = rerank_capsules(query, capsules, &FailingReranker, 0.0, 0);
6339
6340 assert_eq!(out.len(), 3, "all capsules must be returned on error");
6342 assert_eq!(out[0].summary, c1.summary, "order must be preserved");
6343 assert_eq!(out[1].summary, c2.summary, "order must be preserved");
6344 assert_eq!(out[2].summary, c3.summary, "order must be preserved");
6345 }
6346
6347 #[test]
6349 fn rerank_capsules_empty_input_returns_empty() {
6350 use crate::embeddings::StubReranker;
6351 let out = rerank_capsules("query", vec![], &StubReranker, 0.0, 0);
6352 assert!(out.is_empty());
6353 }
6354
6355 struct FixedReranker(f32);
6359 impl crate::embeddings::Reranker for FixedReranker {
6360 fn rerank(
6361 &self,
6362 _query: &str,
6363 docs: &[&str],
6364 ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6365 Ok(vec![self.0; docs.len()])
6366 }
6367 fn model_id(&self) -> &str {
6368 "fixed-reranker"
6369 }
6370 }
6371
6372 struct TwoScoreReranker(f32, f32);
6373 impl crate::embeddings::Reranker for TwoScoreReranker {
6374 fn rerank(
6375 &self,
6376 _query: &str,
6377 _docs: &[&str],
6378 ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6379 Ok(vec![self.0, self.1])
6380 }
6381 fn model_id(&self) -> &str {
6382 "two-score"
6383 }
6384 }
6385
6386 fn band_bundle(top_abs_evidence: f32) -> ContextBundle {
6387 let mut capsule = make_capsule("a memory lesson", 0.9);
6388 capsule.kind = "memory".to_string();
6389 capsule.token_estimate = 10;
6390 ContextBundle {
6391 stage: "localization".into(),
6392 budget_tokens: 4000,
6393 used_tokens: 10,
6394 capsules: vec![capsule],
6395 excluded: vec![],
6396 skipped: false,
6397 top_score: 0.9,
6398 top_abs_evidence,
6399 evidence_coverage: 1.0,
6400 uncovered_terms: vec![],
6401 chronological: false,
6402 known_fact_conflicts: vec![],
6403 }
6404 }
6405
6406 #[test]
6409 fn band_arbitration_follows_the_cross_encoder() {
6410 let approve = FixedReranker(ABSTAIN_RERANK_FLOOR + 0.2);
6411 let out = rerank_and_arbitrate("q", band_bundle(0.50), Some(&approve), 0.55, 0.0, 0);
6412 assert!(!out.skipped, "approved band bundle must inject");
6413 assert_eq!(out.capsules.len(), 1);
6414
6415 let reject = FixedReranker(ABSTAIN_RERANK_FLOOR - 0.2);
6416 let out = rerank_and_arbitrate("q", band_bundle(0.50), Some(&reject), 0.55, 0.0, 0);
6417 assert!(out.skipped, "rejected band bundle must convert to skipped");
6418 assert!(out.capsules.is_empty());
6419 assert_eq!(out.used_tokens, 0);
6420 assert_eq!(out.excluded.len(), 1, "rejected capsules land in excluded");
6421 }
6422
6423 #[test]
6426 fn band_arbitration_uses_raw_rerank_evidence() {
6427 let mut bundle = band_bundle(0.50);
6428 bundle.capsules[0].superseded_hint = true;
6429 bundle
6430 .capsules
6431 .push(make_capsule("irrelevant distractor", 1.0));
6432
6433 let reranker = TwoScoreReranker(0.95, 0.0);
6434 let out = rerank_and_arbitrate("q", bundle, Some(&reranker), 0.55, 0.0, 0);
6435 assert!(!out.skipped, "raw rerank evidence above 0.9 must admit");
6436 assert_eq!(out.capsules[0].summary, "a memory lesson");
6437 assert!(
6438 out.capsules[0].score < ABSTAIN_RERANK_FLOOR,
6439 "the regression requires post-policy score below the raw-score floor"
6440 );
6441 }
6442
6443 #[test]
6446 fn band_arbitration_uses_raw_evidence_before_policy_cap() {
6447 let mut bundle = band_bundle(0.50);
6448 bundle.capsules[0].rerank_policy_tier = 1;
6449 bundle
6450 .capsules
6451 .push(make_capsule("high-confidence neutral", 1.0));
6452
6453 let reranker = TwoScoreReranker(0.50, 0.95);
6454 let out = rerank_and_arbitrate("q", bundle, Some(&reranker), 0.55, 0.0, 1);
6455 assert!(!out.skipped, "raw evidence outside the cap must admit");
6456 assert_eq!(out.capsules.len(), 1);
6457 assert_eq!(out.capsules[0].summary, "high-confidence neutral");
6458 }
6459
6460 #[test]
6463 fn band_arbitration_never_converts_out_of_band_bundles() {
6464 let reject = FixedReranker(0.0);
6465 let out = rerank_and_arbitrate("q", band_bundle(0.70), Some(&reject), 0.55, 0.0, 0);
6466 assert!(
6467 !out.skipped,
6468 "evidence above the threshold is not arbitrated"
6469 );
6470 assert_eq!(out.capsules.len(), 1);
6471 }
6472
6473 #[test]
6476 fn band_fails_closed_without_a_reranker() {
6477 let out = rerank_and_arbitrate("q", band_bundle(0.50), None, 0.55, 0.0, 0);
6478 assert!(out.skipped, "band without an arbiter must abstain");
6479 let out = rerank_and_arbitrate("q", band_bundle(0.70), None, 0.55, 0.0, 0);
6480 assert!(!out.skipped);
6481 let out = rerank_and_arbitrate("q", band_bundle(0.10), None, 0.0, 0.0, 0);
6483 assert!(!out.skipped);
6484 }
6485
6486 struct PositionReranker;
6489 impl crate::embeddings::Reranker for PositionReranker {
6490 fn rerank(
6491 &self,
6492 _query: &str,
6493 docs: &[&str],
6494 ) -> Result<Vec<f32>, crate::embeddings::EmbedderError> {
6495 Ok((0..docs.len()).map(|i| 0.99 - 0.01 * i as f32).collect())
6496 }
6497 fn model_id(&self) -> &str {
6498 "position-reranker"
6499 }
6500 }
6501
6502 #[test]
6507 fn supersession_penalty_survives_reranking() {
6508 let mut old = make_capsule("deploy via make deploy-staging", 0.7);
6509 old.superseded_hint = true; let new = make_capsule("deploy via make deploy-preview since the migration", 0.9);
6511 let mut bundle = band_bundle(0.70); bundle.capsules = vec![old, new];
6513
6514 let out = rerank_and_arbitrate("q", bundle, Some(&PositionReranker), 0.55, 0.0, 0);
6517 assert!(!out.skipped);
6518 assert_eq!(out.capsules.len(), 2);
6519 assert!(
6520 out.capsules[0].summary.contains("deploy-preview"),
6521 "the replacement must outrank the penalized incumbent after reranking; got {:?}",
6522 out.capsules.iter().map(|c| &c.summary).collect::<Vec<_>>()
6523 );
6524 assert!(out.capsules[1].superseded_hint);
6525 }
6526
6527 #[test]
6530 fn band_spares_bundles_with_repo_evidence() {
6531 let mut bundle = band_bundle(0.50);
6532 let mut repo = make_capsule("README excerpt", 0.4);
6533 repo.kind = "repo_file".to_string();
6534 bundle.capsules.push(repo);
6535 let reject = FixedReranker(0.0);
6536 let out = rerank_and_arbitrate("q", bundle, Some(&reject), 0.55, 0.0, 0);
6537 assert!(!out.skipped, "repo capsules suppress band conversion");
6538 }
6539
6540 #[test]
6544 fn compress_for_render_short_text_unchanged() {
6545 let text = "project:fact - Use cargo fmt before committing.";
6546 let out = compress_for_render(text, 3);
6547 assert_eq!(out, text, "short text must not be altered");
6548 }
6549
6550 #[test]
6552 fn compress_for_render_strips_tags_prefix() {
6553 let text = "[tags: rust, cargo] Always run cargo clippy before submitting a PR.";
6554 let out = compress_for_render(text, 3);
6555 assert!(
6556 !out.starts_with('['),
6557 "tags prefix must be stripped, got: {out:?}"
6558 );
6559 assert!(
6560 out.contains("cargo clippy"),
6561 "body must remain, got: {out:?}"
6562 );
6563 }
6564
6565 #[test]
6567 fn compress_for_render_strips_context_suffix() {
6568 let text =
6569 "project:fact - Use cargo fmt. Always clippy clean. (context: Kimetsu brain lesson)";
6570 let out = compress_for_render(text, 5);
6571 assert!(
6572 !out.contains("(context:"),
6573 "context suffix must be stripped, got: {out:?}"
6574 );
6575 assert!(out.contains("cargo fmt"), "body must remain, got: {out:?}");
6576 }
6577
6578 #[test]
6580 fn compress_for_render_caps_sentences() {
6581 let text =
6582 "project:fact - First sentence. Second sentence. Third sentence. Fourth sentence.";
6583 let out = compress_for_render(text, 2);
6584 assert!(out.contains("First"), "first sentence must be present");
6586 assert!(out.contains("Second"), "second sentence must be present");
6587 assert!(
6588 !out.contains("Third"),
6589 "third sentence must be truncated, got: {out:?}"
6590 );
6591 }
6592
6593 #[test]
6595 fn compress_for_render_preserves_scope_prefix() {
6596 let text = "global_user:convention - First rule. Second rule. Third rule. Fourth rule.";
6597 let out = compress_for_render(text, 2);
6598 assert!(
6599 out.starts_with("global_user:convention - "),
6600 "scope prefix must be preserved, got: {out:?}"
6601 );
6602 assert!(out.contains("First"), "first sentence must remain");
6603 assert!(!out.contains("Third"), "third sentence must be truncated");
6604 }
6605
6606 #[test]
6608 fn compress_for_render_empty_input_safe() {
6609 let out = compress_for_render("", 3);
6610 assert_eq!(out, "", "empty input must return empty string");
6611 }
6612
6613 #[test]
6615 fn compress_for_render_zero_max_sentences_returns_original() {
6616 let text = "project:fact - Some lesson that is quite long. It keeps going. And going.";
6617 let out = compress_for_render(text, 0);
6618 assert_eq!(out, text);
6619 }
6620
6621 #[test]
6623 fn compress_for_render_utf8_safe() {
6624 let text = "project:fact - こんにちは世界. Hello world. Third sentence. Fourth sentence.";
6625 let out = compress_for_render(text, 2);
6627 assert!(!out.is_empty(), "UTF-8 text must not produce empty output");
6628 assert!(!out.contains("Third"), "third sentence must be truncated");
6630 }
6631
6632 #[test]
6635 fn compress_for_render_long_memory_reduces_tokens_by_25_percent() {
6636 let long_summary = "project:fact - When a SQLite WAL file exists from a crashed process, \
6638 opening the DB causes the WAL to be replayed. The replayed WAL may contain \
6639 partial writes that corrupt the DB. Always check for WAL files before opening. \
6640 Delete the WAL only after verifying the DB is consistent. Use PRAGMA integrity_check \
6641 to validate after opening. If integrity_check fails, restore from backup. Never \
6642 truncate the WAL without replaying it first. This pattern applies to any \
6643 crash-recovery scenario.";
6644
6645 let raw_tokens = estimate_tokens(long_summary);
6646 assert!(
6647 raw_tokens > 60,
6648 "test precondition: raw memory must be >60 tokens, got {raw_tokens}"
6649 );
6650
6651 let compressed = compress_for_render(long_summary, 3);
6652 let compressed_tokens = estimate_tokens(&compressed);
6653
6654 let reduction = 1.0 - (compressed_tokens as f64 / raw_tokens as f64);
6655 assert!(
6656 reduction >= 0.25,
6657 "compression must reduce tokens by >=25% on long memories; \
6658 raw={raw_tokens} compressed={compressed_tokens} reduction={reduction:.2}"
6659 );
6660 }
6661}
6662
6663#[cfg(test)]
6664mod evidence_tests {
6665 use super::*;
6666
6667 fn conn_with(texts: &[&str]) -> Connection {
6668 let conn = Connection::open_in_memory().expect("open");
6669 crate::schema::initialize(&conn).expect("schema");
6670 for (i, text) in texts.iter().enumerate() {
6671 conn.execute(
6672 "INSERT INTO memories
6673 (memory_id, scope, kind, text, normalized_text, confidence,
6674 provenance_snapshot_json, created_at)
6675 VALUES (?1, 'project', 'fact', ?2, ?2, 0.9, '{}', '2026-01-01T00:00:00Z')",
6676 rusqlite::params![format!("m{i}"), text],
6677 )
6678 .expect("insert");
6679 conn.execute("INSERT INTO memories_fts(memory_id,text,kind,scope) VALUES (?1,?2,'fact','project')",
6680 params![format!("m{i}"),text]).unwrap();
6681 }
6682 conn
6683 }
6684
6685 fn capsule(summary: &str) -> ContextCapsule {
6686 ContextCapsule {
6687 id: String::new(),
6688 kind: "memory".to_string(),
6689 summary: summary.to_string(),
6690 token_estimate: 10,
6691 expansion_handle: format!("memory:{summary}"),
6692 provenance: Vec::new(),
6693 confidence: 0.9,
6694 freshness: 0.5,
6695 relevance: 0.0,
6696 scope_weight: 0.9,
6697 score: 0.5,
6698 superseded_hint: false,
6699 rerank_policy_tier: 0,
6700 claim_revision: None,
6701 facts: vec![],
6702 rerank_usefulness: None,
6703 rerank_trust: None,
6704 }
6705 }
6706
6707 fn bundle(capsules: Vec<ContextCapsule>, coverage: f32, uncovered: &[&str]) -> ContextBundle {
6708 ContextBundle {
6709 stage: "localization".to_string(),
6710 budget_tokens: 2000,
6711 used_tokens: 20,
6712 capsules,
6713 excluded: Vec::new(),
6714 skipped: false,
6715 top_score: 0.7,
6716 top_abs_evidence: 0.0,
6717 evidence_coverage: coverage,
6718 uncovered_terms: uncovered.iter().map(|s| s.to_string()).collect(),
6719 chronological: false,
6720 known_fact_conflicts: vec![],
6721 }
6722 }
6723
6724 #[test]
6727 fn full_coverage_names_nothing() {
6728 let conn = conn_with(&[
6729 "checkpoint the wal before copying brain.db",
6730 "vacuum reclaims dead pages",
6731 ]);
6732 let (coverage, uncovered) = evidence_coverage(
6733 &conn,
6734 "checkpoint wal",
6735 &[capsule(
6736 "project:fact - checkpoint the wal before copying brain.db",
6737 )],
6738 );
6739 assert!(coverage > 0.99, "got {coverage}");
6740 assert!(uncovered.is_empty(), "got {uncovered:?}");
6741 }
6742
6743 #[test]
6747 fn partial_coverage_names_the_missing_terms() {
6748 let conn = conn_with(&[
6749 "checkpoint the wal before copying brain.db",
6750 "the migration runner snapshots before each step",
6751 ]);
6752 let (coverage, uncovered) = evidence_coverage(
6753 &conn,
6754 "checkpoint wal migration",
6755 &[capsule(
6756 "project:fact - checkpoint the wal before copying brain.db",
6757 )],
6758 );
6759 assert!(coverage < 1.0, "coverage should be partial: {coverage}");
6760 assert!(
6761 uncovered.iter().any(|t| t.starts_with("migrat")),
6762 "the uncovered term must be named: {uncovered:?}"
6763 );
6764 }
6765
6766 #[test]
6769 fn coverage_is_collective_not_per_capsule() {
6770 let conn = conn_with(&[
6771 "checkpoint the wal before copying brain.db",
6772 "the migration runner snapshots before each step",
6773 ]);
6774 let (coverage, uncovered) = evidence_coverage(
6775 &conn,
6776 "checkpoint migration",
6777 &[
6778 capsule("project:fact - checkpoint the wal before copying"),
6779 capsule("project:fact - the migration runner snapshots first"),
6780 ],
6781 );
6782 assert!(
6783 coverage > 0.99,
6784 "neither capsule covers both terms, but together they do: {coverage}"
6785 );
6786 assert!(uncovered.is_empty(), "got {uncovered:?}");
6787 }
6788
6789 #[test]
6792 fn an_unmeasurable_query_does_not_claim_a_gap() {
6793 let conn = conn_with(&["checkpoint the wal"]);
6794 let (coverage, uncovered) =
6795 evidence_coverage(&conn, "the and of", &[capsule("project:fact - checkpoint")]);
6796 assert_eq!(coverage, 1.0);
6797 assert!(uncovered.is_empty());
6798 }
6799
6800 #[test]
6807 fn a_term_the_corpus_has_never_seen_counts_as_a_gap() {
6808 let conn = conn_with(&[
6809 "checkpoint the wal before copying brain.db",
6810 "vacuum reclaims dead pages",
6811 ]);
6812 let (coverage, uncovered) = evidence_coverage(
6813 &conn,
6814 "checkpoint the wal during a kubernetes rollout",
6815 &[capsule(
6816 "project:fact - checkpoint the wal before copying brain.db",
6817 )],
6818 );
6819 assert!(
6820 coverage <= PARTIAL_EVIDENCE_COVERAGE,
6821 "an unknown half of the question must read as thin, not complete: {coverage}"
6822 );
6823 assert!(
6824 uncovered.iter().any(|t| t.starts_with("kubernet")),
6825 "the unknown term must be named: {uncovered:?}"
6826 );
6827 }
6828
6829 #[test]
6832 fn a_ubiquitous_term_carries_no_weight() {
6833 let conn = conn_with(&["kimetsu checkpoint wal", "kimetsu vacuum pages"]);
6834 let (coverage, _) = evidence_coverage(
6835 &conn,
6836 "kimetsu vacuum",
6837 &[capsule("project:fact - kimetsu vacuum pages")],
6838 );
6839 assert!(coverage > 0.99, "got {coverage}");
6840 }
6841
6842 #[test]
6843 fn an_empty_query_does_not_claim_a_gap() {
6844 let conn = conn_with(&["checkpoint the wal"]);
6845 assert_eq!(evidence_coverage(&conn, "", &[]).0, 1.0);
6846 }
6847
6848 #[test]
6851 fn a_complete_bundle_gets_no_notice() {
6852 assert!(partial_evidence_notice(&bundle(vec![capsule("a")], 1.0, &[])).is_none());
6853 assert!(
6854 partial_evidence_notice(&bundle(vec![capsule("a")], 0.9, &["x"])).is_none(),
6855 "above the threshold is not partial"
6856 );
6857 }
6858
6859 #[test]
6860 fn an_empty_or_skipped_bundle_gets_no_notice() {
6861 let mut skipped = bundle(Vec::new(), 0.0, &["x"]);
6862 skipped.skipped = true;
6863 assert!(
6864 partial_evidence_notice(&skipped).is_none(),
6865 "an empty bundle already says everything it can"
6866 );
6867 assert!(partial_evidence_notice(&bundle(Vec::new(), 0.0, &["x"])).is_none());
6868 }
6869
6870 #[test]
6871 fn a_partial_bundle_names_what_is_missing_and_tells_the_reader_what_to_do() {
6872 let notice =
6873 partial_evidence_notice(&bundle(vec![capsule("a")], 0.3, &["migration", "rollback"]))
6874 .expect("a thin bundle must be flagged");
6875 assert!(notice.contains("migration"), "got: {notice}");
6876 assert!(notice.contains("rollback"), "got: {notice}");
6877 assert!(
6878 notice.contains("unknown"),
6879 "the notice must tell the reader to abstain, not just report a gap: {notice}"
6880 );
6881 }
6882
6883 #[test]
6885 fn the_notice_caps_how_many_terms_it_names() {
6886 let terms: Vec<String> = (0..12).map(|i| format!("term{i}")).collect();
6887 let refs: Vec<&str> = terms.iter().map(String::as_str).collect();
6888 let notice =
6889 partial_evidence_notice(&bundle(vec![capsule("a")], 0.1, &refs)).expect("flagged");
6890 assert!(notice.contains("and 6 more"), "got: {notice}");
6891 assert!(!notice.contains("term9"), "got: {notice}");
6892 }
6893
6894 #[test]
6900 fn the_y_ies_pair_shares_a_stem() {
6901 for (a, b) in [
6902 ("retry", "retries"),
6903 ("query", "queries"),
6904 ("policy", "policies"),
6905 ("memory", "memories"),
6906 ("binary", "binaries"),
6907 ("registry", "registries"),
6908 ] {
6909 assert_eq!(
6910 light_stem(a),
6911 light_stem(b),
6912 "{a}/{b} stemmed to {:?}/{:?}",
6913 light_stem(a),
6914 light_stem(b)
6915 );
6916 }
6917 }
6918
6919 #[test]
6921 fn a_vowel_y_is_not_stripped() {
6922 assert_eq!(light_stem("delay"), "delay");
6923 assert_eq!(light_stem("gateway"), "gateway");
6924 assert_eq!(light_stem("journeys"), "journey");
6927 }
6928
6929 #[test]
6932 fn short_words_keep_their_ending() {
6933 assert_eq!(light_stem("body"), "body");
6934 assert_eq!(light_stem("copy"), "copy");
6935 }
6936
6937 #[test]
6939 fn the_original_suffix_rules_still_hold() {
6940 assert_eq!(light_stem("benchmarked"), "benchmark");
6941 assert_eq!(light_stem("benchmarking"), "benchmark");
6942 assert_eq!(light_stem("migrations"), "migration");
6943 assert_eq!(light_stem("run"), "run");
6944 }
6945
6946 #[test]
6949 fn an_inflected_corpus_term_counts_as_covered() {
6950 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
6951 crate::schema::initialize(&conn).expect("init schema");
6952 let text = "the ingest worker retries a failed batch three times before giving up";
6953 let normalized = kimetsu_core::memory::normalize_memory_text(text);
6954 conn.execute(
6955 "
6956 INSERT INTO memories (
6957 memory_id, scope, kind, text, normalized_text, confidence,
6958 source_event_id, provenance_snapshot_json, created_at
6959 )
6960 VALUES ('m_retry', 'project', 'fact', ?1, ?2, 1.0, NULL, '{}',
6961 '2026-01-01T00:00:00Z')
6962 ",
6963 rusqlite::params![text, normalized],
6964 )
6965 .expect("insert memory");
6966 conn.execute(
6967 "INSERT INTO memories_fts (memory_id, text, kind, scope)
6968 VALUES ('m_retry', ?1, 'fact', 'project')",
6969 rusqlite::params![text],
6970 )
6971 .expect("insert fts");
6972
6973 let bundle = retrieve_context_with_embedder(
6974 &conn,
6975 "/fake-repo",
6976 &kimetsu_core::config::BrokerWeights::default(),
6977 ContextRequest {
6978 stage: "localization".to_string(),
6979 query: "how many times does the ingest worker retry a failed batch".to_string(),
6980 budget_tokens: 4000,
6981 ..Default::default()
6982 },
6983 &[],
6984 &embeddings::NoopEmbedder,
6985 )
6986 .expect("retrieve");
6987
6988 assert!(
6989 !bundle.uncovered_terms.iter().any(|t| t.starts_with("retr")),
6990 "`retry` must match a corpus that says `retries`; uncovered: {:?}",
6991 bundle.uncovered_terms
6992 );
6993 }
6994
6995 fn ordering_conn() -> rusqlite::Connection {
7002 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
7003 crate::schema::initialize(&conn).expect("init schema");
7004 for (mid, created, text) in [
7005 (
7006 "m_late",
7007 "2026-06-01T09:00:00Z",
7008 "switched the error type to thiserror",
7009 ),
7010 (
7011 "m_early",
7012 "2026-01-15T10:00:00Z",
7013 "ran the thiserror schema migration",
7014 ),
7015 ] {
7016 let normalized = kimetsu_core::memory::normalize_memory_text(text);
7017 conn.execute(
7018 "
7019 INSERT INTO memories (
7020 memory_id, scope, kind, text, normalized_text, confidence,
7021 source_event_id, provenance_snapshot_json, created_at
7022 )
7023 VALUES (?1, 'project', 'fact', ?2, ?3, 1.0, NULL, '{}', ?4)
7024 ",
7025 rusqlite::params![mid, text, normalized, created],
7026 )
7027 .expect("insert memory");
7028 conn.execute(
7029 "INSERT INTO memories_fts (memory_id, text, kind, scope)
7030 VALUES (?1, ?2, 'fact', 'project')",
7031 rusqlite::params![mid, text],
7032 )
7033 .expect("insert fts");
7034 }
7035 conn
7036 }
7037
7038 fn ordering_bundle(conn: &rusqlite::Connection, query: &str) -> ContextBundle {
7039 retrieve_context_with_embedder(
7040 conn,
7041 "/fake-repo",
7042 &kimetsu_core::config::BrokerWeights::default(),
7043 ContextRequest {
7044 stage: "localization".to_string(),
7045 query: query.to_string(),
7046 budget_tokens: 4000,
7047 ..Default::default()
7048 },
7049 &[],
7050 &embeddings::NoopEmbedder,
7051 )
7052 .expect("retrieve")
7053 }
7054
7055 #[test]
7058 fn an_ordering_query_returns_a_dated_chronological_bundle() {
7059 let conn = ordering_conn();
7060 let bundle = ordering_bundle(&conn, "did we run the thiserror migration before or after");
7061
7062 assert!(bundle.chronological, "the query asked about order");
7063 let order: Vec<&str> = bundle
7064 .capsules
7065 .iter()
7066 .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
7067 .collect();
7068 assert_eq!(order, vec!["m_early", "m_late"], "oldest first");
7069 for (capsule, date) in bundle.capsules.iter().zip(["2026-01-15", "2026-06-01"]) {
7070 assert!(
7071 capsule.summary.contains(&format!("[{date}]")),
7072 "every capsule carries its date; got: {}",
7073 capsule.summary
7074 );
7075 }
7076 }
7077
7078 #[test]
7081 fn an_ordinary_query_is_untouched() {
7082 let conn = ordering_conn();
7083 let bundle = ordering_bundle(&conn, "how do we handle thiserror errors");
7084
7085 assert!(!bundle.chronological);
7086 for capsule in &bundle.capsules {
7087 assert!(
7088 !capsule.summary.contains('['),
7089 "no dates on a non-ordering query; got: {}",
7090 capsule.summary
7091 );
7092 }
7093 }
7094
7095 #[test]
7099 fn ordering_changes_the_rendering_not_the_selection() {
7100 let conn = ordering_conn();
7101 let ordered = ordering_bundle(&conn, "did we run the thiserror migration before or after");
7102 let plain = ordering_bundle(&conn, "did we run the thiserror migration");
7103
7104 let mut got: Vec<&str> = ordered
7105 .capsules
7106 .iter()
7107 .map(|c| c.expansion_handle.as_str())
7108 .collect();
7109 let mut want: Vec<&str> = plain
7110 .capsules
7111 .iter()
7112 .map(|c| c.expansion_handle.as_str())
7113 .collect();
7114 got.sort_unstable();
7115 want.sort_unstable();
7116 assert_eq!(got, want, "same capsules, different order");
7117 }
7118
7119 #[test]
7122 fn the_dates_are_counted_against_the_budget() {
7123 let conn = ordering_conn();
7124 let ordered = ordering_bundle(&conn, "did we run the thiserror migration before or after");
7125 let plain = ordering_bundle(&conn, "did we run the thiserror migration");
7126 assert!(
7127 ordered.used_tokens > plain.used_tokens,
7128 "dated: {} vs plain: {}",
7129 ordered.used_tokens,
7130 plain.used_tokens
7131 );
7132 assert_eq!(
7133 ordered.used_tokens,
7134 ordered
7135 .capsules
7136 .iter()
7137 .map(|c| c.token_estimate)
7138 .sum::<u32>(),
7139 "used_tokens must match what was actually rendered"
7140 );
7141 }
7142}
7143
7144#[cfg(test)]
7145mod hardening_tests {
7146 use super::*;
7147
7148 fn corpus() -> Connection {
7149 let conn = Connection::open_in_memory().unwrap();
7150 crate::schema::initialize(&conn).unwrap();
7151 for (id, text) in [
7152 ("live", "routing routing routes"),
7153 ("future", "routing"),
7154 ("expired", "routing"),
7155 ("offset", "routing"),
7156 ("other", "rerouting unrelated"),
7157 ] {
7158 conn.execute("INSERT INTO memories (memory_id,scope,kind,text,normalized_text,confidence,created_at,provenance_snapshot_json)
7159 VALUES (?1,'project','fact',?2,?2,1,'2020-01-01T00:00:00Z','{}')", params![id,text]).unwrap();
7160 conn.execute("INSERT INTO memories_fts(memory_id,text,kind,scope) VALUES (?1,?2,'fact','project')",params![id,text]).unwrap();
7161 }
7162 conn
7163 }
7164
7165 #[test]
7166 fn hardening_live_lexical_and_recency_apply_both_time_bounds() {
7167 let conn = corpus();
7168 let now = OffsetDateTime::now_utc();
7169 let fmt = &time::format_description::well_known::Rfc3339;
7170 let future = (now + time::Duration::hours(1)).format(fmt).unwrap();
7171 let expired = (now - time::Duration::seconds(2)).format(fmt).unwrap();
7172 let offset = (now - time::Duration::seconds(2))
7173 .to_offset(time::UtcOffset::from_hms(12, 0, 0).unwrap())
7174 .format(fmt)
7175 .unwrap();
7176 conn.execute(
7177 "UPDATE memories SET valid_from=?1 WHERE memory_id='future'",
7178 params![future],
7179 )
7180 .unwrap();
7181 conn.execute(
7182 "UPDATE memories SET valid_to=?1 WHERE memory_id='expired'",
7183 params![expired],
7184 )
7185 .unwrap();
7186 conn.execute(
7187 "UPDATE memories SET valid_to=?1 WHERE memory_id='offset'",
7188 params![offset],
7189 )
7190 .unwrap();
7191 for candidates in [
7192 memory_fts_candidates(
7193 &conn,
7194 &["routing".into()],
7195 "routing*",
7196 80,
7197 None,
7198 30.0,
7199 false,
7200 )
7201 .unwrap(),
7202 latest_memory_candidates(&conn, &["routing".into()], 200, None, 30.0, false).unwrap(),
7203 ] {
7204 let ids: Vec<_> = candidates
7205 .iter()
7206 .map(|c| c.capsule.expansion_handle.as_str())
7207 .collect();
7208 assert!(ids.contains(&"memory:live"));
7209 for id in ["memory:future", "memory:expired", "memory:offset"] {
7210 assert!(!ids.contains(&id), "returned {id}");
7211 }
7212 }
7213 }
7214
7215 #[test]
7216 fn hardening_hydration_binds_text_revision_before_later_correction() {
7217 let conn = corpus();
7218 let candidates = memory_fts_candidates(
7219 &conn,
7220 &["routing".into()],
7221 "routing*",
7222 80,
7223 None,
7224 30.0,
7225 false,
7226 )
7227 .unwrap();
7228 let capsules: Vec<_> = candidates.into_iter().map(|c| c.capsule).collect();
7229 assert_eq!(memory_revision_bindings(&capsules)["live"], "baseline:live");
7230 conn.execute("INSERT INTO memory_revisions(memory_id,event_id,text,kind,known_at,effective_at,confidence,use_count,usefulness_score)
7231 VALUES ('live','corrected','changed claim','fact','2026-01-01T00:00:00Z','2026-01-01T00:00:00Z',1,0,0)",[]).unwrap();
7232 conn.execute(
7233 "UPDATE memories SET text='changed claim' WHERE memory_id='live'",
7234 [],
7235 )
7236 .unwrap();
7237 assert_eq!(
7238 crate::projector::claim_revision_at(&conn, "live", None).unwrap(),
7239 "corrected"
7240 );
7241 assert_eq!(memory_revision_bindings(&capsules)["live"], "baseline:live");
7242 assert!(
7243 capsules
7244 .iter()
7245 .find(|c| c.expansion_handle == "memory:live")
7246 .unwrap()
7247 .summary
7248 .contains("routing routing")
7249 );
7250 }
7251
7252 #[cfg(feature = "embeddings")]
7253 #[test]
7254 fn hardening_ann_hydration_filters_time_bounds() {
7255 let conn = corpus();
7256 let blob = crate::embeddings::encode_embedding(&[1.0, 0.0]);
7257 conn.execute(
7258 "UPDATE memories SET embedding=?1,embedding_model='test'",
7259 params![blob],
7260 )
7261 .unwrap();
7262 conn.execute(
7263 "UPDATE memories SET valid_from='2099-01-01T00:00:00Z' WHERE memory_id='future'",
7264 [],
7265 )
7266 .unwrap();
7267 let expired = (OffsetDateTime::now_utc() - time::Duration::seconds(2))
7268 .to_offset(time::UtcOffset::from_hms(12, 0, 0).unwrap())
7269 .format(&time::format_description::well_known::Rfc3339)
7270 .unwrap();
7271 conn.execute(
7272 "UPDATE memories SET valid_to=?1 WHERE memory_id IN ('expired','offset')",
7273 params![expired],
7274 )
7275 .unwrap();
7276 let qe = QueryEmbedding {
7277 vector: vec![1.0, 0.0],
7278 model_id: "test".into(),
7279 };
7280 let out = memory_ann_candidates(&conn, &qe, 80, &["routing".into()], 30.0, false).unwrap();
7281 assert_eq!(out.len(), 2);
7282 for c in out {
7283 assert!(matches!(
7284 c.capsule.expansion_handle.as_str(),
7285 "memory:live" | "memory:other"
7286 ));
7287 }
7288 }
7289
7290 #[test]
7291 fn hardening_idf_counts_prefix_documents_not_occurrences_or_substrings() {
7292 let conn = corpus();
7293 let tokens = vec!["rout".into(), "absent".into()];
7294 let coverage = coverage_token_idf(&conn, &tokens).unwrap();
7295 assert!((coverage["rout"] - (6.0_f32 / 5.0).ln()).abs() < 0.00001);
7297 assert!((coverage["absent"] - 6.0_f32.ln()).abs() < 0.00001);
7298 assert_eq!(corpus_token_idf(&conn, &tokens).unwrap()["absent"], 0.0);
7299 }
7300}
7301
7302#[cfg(test)]
7303mod structured_fact_hydration_tests {
7304 use super::*;
7305 use kimetsu_core::{event::Event, ids::RunId};
7306
7307 #[test]
7308 fn lexical_and_recency_capsules_keep_their_delivered_fact_revision() {
7309 let c = Connection::open_in_memory().unwrap();
7310 crate::schema::initialize(&c).unwrap();
7311 crate::projector::apply_events(&c,&[Event::new(RunId::new(),"memory.accepted",serde_json::json!({
7312 "memory_id":"m","scope":"project","kind":"fact","text":"Orchid staging gateway port is 7319."
7313 }))]).unwrap();
7314 let mut delivered = Vec::new();
7315 for candidates in [
7316 memory_fts_candidates(&c, &["orchid".into()], "orchid*", 80, None, 30.0, true).unwrap(),
7317 latest_memory_candidates(&c, &["orchid".into()], 200, None, 30.0, true).unwrap(),
7318 ] {
7319 let capsule = &candidates[0].capsule;
7320 assert_eq!(capsule.facts.len(), 1);
7321 assert_eq!(capsule.facts[0].claim.value, "7319");
7322 assert_eq!(
7323 capsule.claim_revision.as_deref(),
7324 Some(capsule.facts[0].claim_revision.as_str())
7325 );
7326 delivered.push(capsule.clone());
7327 }
7328 crate::projector::apply_events(
7329 &c,
7330 &[Event::new(
7331 RunId::new(),
7332 "memory.corrected",
7333 serde_json::json!({
7334 "memory_id":"m","text":"Orchid staging gateway port is 8420."
7335 }),
7336 )],
7337 )
7338 .unwrap();
7339 for capsule in delivered {
7340 assert!(capsule.summary.contains("7319"));
7341 assert_eq!(capsule.facts[0].claim.value, "7319");
7342 }
7343 let latest =
7344 latest_memory_candidates(&c, &["orchid".into()], 200, None, 30.0, true).unwrap();
7345 assert_eq!(latest[0].capsule.facts[0].claim.value, "8420");
7346 }
7347 #[test]
7348 fn legacy_wire_capsules_default_to_empty_fact_evidence() {
7349 let c = ContextCapsule::wire_minimal("hello".into(), "memory".into(), 1.0);
7350 let json = serde_json::to_value(&c).unwrap();
7351 assert!(json.get("facts").is_none());
7352 assert!(
7353 serde_json::from_value::<ContextCapsule>(json)
7354 .unwrap()
7355 .facts
7356 .is_empty()
7357 );
7358 }
7359}
7360
7361#[cfg(test)]
7362mod disabled_fact_hydration_tests {
7363 use super::*;
7364 #[test]
7365 fn ordinary_retrieval_does_not_read_the_fact_projection() {
7366 let c = Connection::open_in_memory().unwrap();
7367 crate::schema::initialize(&c).unwrap();
7368 crate::projector::apply_events(
7369 &c,
7370 &[kimetsu_core::event::Event::new(
7371 kimetsu_core::ids::RunId::new(),
7372 "memory.accepted",
7373 serde_json::json!({"memory_id":"m","text":"Orchid gateway port is 7319."}),
7374 )],
7375 )
7376 .unwrap();
7377 c.execute_batch("DROP TABLE memory_facts").unwrap();
7378 for query in ["Orchid", ""] {
7379 let out =
7380 memory_candidates_flat(&c, query, None, 30.0, crate::fusion::Fusion::Linear, false)
7381 .unwrap();
7382 assert_eq!(out.len(), 1);
7383 assert!(out[0].capsule.facts.is_empty());
7384 }
7385 }
7386}
7387
7388#[cfg(test)]
7389mod deferred_fact_budget_tests {
7390 use super::*;
7391 #[test]
7392 fn initial_retrieval_budget_must_not_hide_an_eligible_conflicting_fact() {
7393 let c = Connection::open_in_memory().unwrap();
7394 crate::schema::initialize(&c).unwrap();
7395 for (id, value) in [("a", "7319"), ("b", "7320")] {
7396 let text = format!(
7397 "Orchid gateway port is {value}. Stable operation. Recorded settings. {}",
7398 "Operational notes remain available. ".repeat(350)
7399 );
7400 crate::projector::apply_events(
7401 &c,
7402 &[kimetsu_core::event::Event::new(
7403 kimetsu_core::ids::RunId::new(),
7404 "memory.accepted",
7405 serde_json::json!({"memory_id":id,"scope":"project","kind":"fact","text":text}),
7406 )],
7407 )
7408 .unwrap();
7409 }
7410 let query = "What is the Orchid gateway port?";
7411 let policy = crate::serving::ServingPolicy {
7412 budget: 6000,
7413 cap: 1,
7414 explicit_fact_guard: true,
7415 ..Default::default()
7416 };
7417 let request = ContextRequest {
7418 stage: "localization".into(),
7419 query: query.into(),
7420 budget_tokens: 6000,
7421 ..Default::default()
7422 };
7423 let weights = BrokerWeights::default();
7424 let mut ordinary = request.clone();
7425 ordinary.max_capsules = 6;
7426 let ordinary = retrieve_context_with_embedder(
7427 &c,
7428 "/fake-repo",
7429 &weights,
7430 ordinary,
7431 &[],
7432 &crate::embeddings::NoopEmbedder,
7433 )
7434 .unwrap();
7435 assert_eq!(ordinary.capsules.len(), 1);
7436 assert!(ordinary.used_tokens <= 3000);
7437 let selected = retrieve_context_with_embedder(
7438 &c,
7439 "/fake-repo",
7440 &weights,
7441 policy.prepare(request, false),
7442 &[],
7443 &crate::embeddings::NoopEmbedder,
7444 )
7445 .unwrap();
7446 assert_eq!(
7447 selected.capsules.len(),
7448 2,
7449 "both eligible claims must reach arbitration before delivery budgeting"
7450 );
7451 let selected = policy.arbitrate(query, selected, None, 0.0);
7452 let delivered =
7453 policy.render_for_query(query, selected, true, crate::serving::EVAL_EXPOSURE_ID);
7454 assert_eq!(delivered.capsules.len(), 1);
7455 assert_eq!(delivered.payload["answerability"]["status"], "conflicting");
7456 assert!(delivered.payload["used_tokens"].as_u64().unwrap() <= 6000);
7457 }
7458}