1use std::cmp::Ordering;
2use std::collections::HashMap;
3
4use kimetsu_core::config::{BrokerWeights, StageWeights};
5use kimetsu_core::memory::MemoryScope;
6use kimetsu_core::{KimetsuResult, ids::new_id};
7use rusqlite::{Connection, params};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10
11use crate::embeddings::{
12 self, DEFAULT_HYBRID_ALPHA, Embedder, cosine_similarity, decode_embedding,
13};
14
15#[derive(Debug, Clone)]
20struct QueryEmbedding {
21 vector: Vec<f32>,
22 model_id: String,
23}
24
25impl QueryEmbedding {
26 fn from_embedder(embedder: &dyn Embedder, query: &str) -> Option<Self> {
27 if embedder.is_noop() {
28 return None;
29 }
30 match embedder.embed(query) {
31 Ok(v) if v.len() == embedder.dim() => Some(Self {
32 vector: v,
33 model_id: embedder.model_id().to_string(),
34 }),
35 _ => None,
40 }
41 }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct ContextCapsule {
46 pub id: String,
47 pub kind: String,
48 pub summary: String,
49 pub token_estimate: u32,
50 pub expansion_handle: String,
51 pub provenance: Vec<ProvenanceRef>,
52 pub confidence: f32,
53 pub freshness: f32,
54 pub relevance: f32,
55 pub scope_weight: f32,
56 pub score: f32,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ProvenanceRef {
61 pub source: String,
62 pub id: String,
63 pub excerpt: Option<String>,
64}
65
66#[derive(Debug, Clone)]
67pub struct ContextRequest {
68 pub stage: String,
69 pub query: String,
70 pub budget_tokens: u32,
71}
72
73#[derive(Debug, Clone)]
74pub struct ContextBundle {
75 pub stage: String,
76 pub budget_tokens: u32,
77 pub used_tokens: u32,
78 pub capsules: Vec<ContextCapsule>,
79 pub excluded: Vec<ContextCapsule>,
80}
81
82#[derive(Debug, Clone)]
83struct Candidate {
84 capsule: ContextCapsule,
85 raw_relevance: f32,
86}
87
88pub fn retrieve_context(
89 conn: &Connection,
90 repo_root: &str,
91 weights: &BrokerWeights,
92 request: ContextRequest,
93) -> KimetsuResult<ContextBundle> {
94 retrieve_context_multi(conn, repo_root, weights, request, &[])
95}
96
97pub fn retrieve_context_multi(
114 conn: &Connection,
115 repo_root: &str,
116 weights: &BrokerWeights,
117 request: ContextRequest,
118 extra_memory_conns: &[&Connection],
119) -> KimetsuResult<ContextBundle> {
120 let embedder = embeddings::open_default_embedder();
121 retrieve_context_with_embedder(
122 conn,
123 repo_root,
124 weights,
125 request,
126 extra_memory_conns,
127 embedder,
128 )
129}
130
131pub fn retrieve_context_with_embedder(
138 conn: &Connection,
139 repo_root: &str,
140 weights: &BrokerWeights,
141 request: ContextRequest,
142 extra_memory_conns: &[&Connection],
143 embedder: &dyn Embedder,
144) -> KimetsuResult<ContextBundle> {
145 let query_embedding = QueryEmbedding::from_embedder(embedder, &request.query);
146 let half_life_days = weights.decay_half_life_days;
147 let mut candidates = Vec::new();
148 candidates.extend(memory_candidates(
149 conn,
150 &request.query,
151 query_embedding.as_ref(),
152 half_life_days,
153 )?);
154 for extra in extra_memory_conns {
155 candidates.extend(memory_candidates(
156 extra,
157 &request.query,
158 query_embedding.as_ref(),
159 half_life_days,
160 )?);
161 }
162 candidates.extend(repo_file_candidates(conn, repo_root, &request.query, 30)?);
163 candidates.extend(manifest_candidates(conn, repo_root, &request.query)?);
164
165 normalize_and_score(&mut candidates, weights_for_stage(weights, &request.stage));
166
167 let mut capsules = candidates
168 .into_iter()
169 .map(|candidate| candidate.capsule)
170 .collect::<Vec<_>>();
171
172 capsules.sort_by(|left, right| {
173 right
174 .score
175 .partial_cmp(&left.score)
176 .unwrap_or(Ordering::Equal)
177 .then_with(|| {
178 right
179 .freshness
180 .partial_cmp(&left.freshness)
181 .unwrap_or(Ordering::Equal)
182 })
183 .then_with(|| left.id.cmp(&right.id))
184 });
185
186 let capsules = apply_mmr_diversity(capsules, 0.7);
192
193 let capsule_budget = request.budget_tokens / 2;
194 let mut used_tokens = 0u32;
195 let mut included = Vec::new();
196 let mut excluded = Vec::new();
197
198 for capsule in capsules {
199 if used_tokens.saturating_add(capsule.token_estimate) <= capsule_budget {
200 used_tokens += capsule.token_estimate;
201 included.push(capsule);
202 } else {
203 excluded.push(capsule);
204 }
205 }
206
207 Ok(ContextBundle {
208 stage: request.stage,
209 budget_tokens: request.budget_tokens,
210 used_tokens,
211 capsules: included,
212 excluded,
213 })
214}
215
216pub fn search_repo_files(
217 conn: &Connection,
218 repo_root: &str,
219 query: &str,
220 limit: u32,
221) -> KimetsuResult<Vec<ContextCapsule>> {
222 let candidates = repo_file_candidates(conn, repo_root, query, limit)?;
223 let mut capsules = candidates
224 .into_iter()
225 .map(|mut candidate| {
226 candidate.capsule.relevance = candidate.raw_relevance;
227 candidate.capsule.score = candidate.raw_relevance;
228 candidate.capsule
229 })
230 .collect::<Vec<_>>();
231 capsules.sort_by(|left, right| {
232 right
233 .score
234 .partial_cmp(&left.score)
235 .unwrap_or(Ordering::Equal)
236 .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
237 });
238 Ok(capsules)
239}
240
241fn memory_candidates(
242 conn: &Connection,
243 query: &str,
244 query_embedding: Option<&QueryEmbedding>,
245 half_life_days: f32,
246) -> KimetsuResult<Vec<Candidate>> {
247 let query_tokens = query_tokens(query);
248 if let Some(fts_query) = fts_query(query) {
249 let candidates = memory_fts_candidates(
250 conn,
251 &query_tokens,
252 &fts_query,
253 80,
254 query_embedding,
255 half_life_days,
256 )?;
257 if !candidates.is_empty() {
258 return Ok(candidates);
259 }
260 }
261
262 latest_memory_candidates(conn, &query_tokens, 200, query_embedding, half_life_days)
263}
264
265fn latest_memory_candidates(
266 conn: &Connection,
267 query_tokens: &[String],
268 limit: u32,
269 query_embedding: Option<&QueryEmbedding>,
270 half_life_days: f32,
271) -> KimetsuResult<Vec<Candidate>> {
272 let mut stmt = conn.prepare_cached(
283 "
284 SELECT memory_id, scope, kind, text, confidence, created_at,
285 use_count, usefulness_score, embedding, embedding_model,
286 last_useful_at
287 FROM memories
288 WHERE invalidated_at IS NULL
289 ORDER BY created_at DESC
290 LIMIT ?1
291 ",
292 )?;
293
294 let rows = stmt.query_map(params![limit], |row| {
295 Ok((
296 row.get::<_, String>(0)?,
297 row.get::<_, String>(1)?,
298 row.get::<_, String>(2)?,
299 row.get::<_, String>(3)?,
300 row.get::<_, f32>(4)?,
301 row.get::<_, String>(5)?,
302 row.get::<_, i64>(6)?,
303 row.get::<_, f64>(7)?,
304 row.get::<_, Option<Vec<u8>>>(8)?,
305 row.get::<_, Option<String>>(9)?,
306 row.get::<_, Option<String>>(10)?,
307 ))
308 })?;
309
310 let mut candidates = Vec::new();
311 for row in rows {
312 let (
313 memory_id,
314 scope,
315 kind,
316 text,
317 confidence,
318 created_at,
319 use_count,
320 usefulness_score,
321 embedding,
322 embedding_model,
323 last_useful_at,
324 ) = row?;
325 let cosine = compute_cosine(query_embedding, embedding.as_deref(), embedding_model.as_deref());
326 if let Some(candidate) = memory_row_to_candidate(
327 query_tokens,
328 memory_id,
329 scope,
330 kind,
331 text,
332 confidence,
333 created_at,
334 use_count,
335 usefulness_score,
336 last_useful_at,
337 half_life_days,
338 None,
339 cosine,
340 ) {
341 candidates.push(candidate);
342 }
343 }
344 Ok(candidates)
345}
346
347fn memory_fts_candidates(
348 conn: &Connection,
349 query_tokens: &[String],
350 fts_query: &str,
351 limit: u32,
352 query_embedding: Option<&QueryEmbedding>,
353 half_life_days: f32,
354) -> KimetsuResult<Vec<Candidate>> {
355 let mut stmt = conn.prepare_cached(
356 "
357 SELECT m.memory_id, m.scope, m.kind, m.text, m.confidence, m.created_at,
358 m.use_count, m.usefulness_score, bm25(memories_fts) AS rank,
359 m.embedding, m.embedding_model, m.last_useful_at
360 FROM memories_fts
361 JOIN memories m
362 ON m.memory_id = memories_fts.memory_id
363 WHERE m.invalidated_at IS NULL
364 AND memories_fts MATCH ?1
365 ORDER BY rank
366 LIMIT ?2
367 ",
368 )?;
369
370 let rows = stmt.query_map(params![fts_query, limit], |row| {
371 Ok((
372 row.get::<_, String>(0)?,
373 row.get::<_, String>(1)?,
374 row.get::<_, String>(2)?,
375 row.get::<_, String>(3)?,
376 row.get::<_, f32>(4)?,
377 row.get::<_, String>(5)?,
378 row.get::<_, i64>(6)?,
379 row.get::<_, f64>(7)?,
380 row.get::<_, f64>(8)?,
381 row.get::<_, Option<Vec<u8>>>(9)?,
382 row.get::<_, Option<String>>(10)?,
383 row.get::<_, Option<String>>(11)?,
384 ))
385 })?;
386
387 let mut candidates = Vec::new();
388 for row in rows {
389 let (
390 memory_id,
391 scope,
392 kind,
393 text,
394 confidence,
395 created_at,
396 use_count,
397 usefulness_score,
398 rank,
399 embedding,
400 embedding_model,
401 last_useful_at,
402 ) = row?;
403 let fts_relevance = (-rank as f32).max(0.0);
404 let cosine = compute_cosine(query_embedding, embedding.as_deref(), embedding_model.as_deref());
405 if let Some(candidate) = memory_row_to_candidate(
406 query_tokens,
407 memory_id,
408 scope,
409 kind,
410 text,
411 confidence,
412 created_at,
413 use_count,
414 usefulness_score,
415 last_useful_at,
416 half_life_days,
417 Some(fts_relevance),
418 cosine,
419 ) {
420 candidates.push(candidate);
421 }
422 }
423 Ok(candidates)
424}
425
426fn compute_cosine(
438 query_embedding: Option<&QueryEmbedding>,
439 row_bytes: Option<&[u8]>,
440 row_model: Option<&str>,
441) -> Option<f32> {
442 let q = query_embedding?;
443 let bytes = row_bytes?;
444 let model = row_model?;
445 if model != q.model_id {
446 return None;
447 }
448 let row_vec = match decode_embedding(bytes, Some(q.vector.len())) {
449 Ok(v) => v,
450 Err(_) => return None,
451 };
452 Some(cosine_similarity(&q.vector, &row_vec))
453}
454
455#[allow(clippy::too_many_arguments)]
456fn memory_row_to_candidate(
457 query_tokens: &[String],
458 memory_id: String,
459 scope: String,
460 kind: String,
461 text: String,
462 confidence: f32,
463 created_at: String,
464 use_count: i64,
465 usefulness_score: f64,
466 last_useful_at: Option<String>,
467 half_life_days: f32,
468 raw_relevance_override: Option<f32>,
469 cosine_score: Option<f32>,
470) -> Option<Candidate> {
471 let lexical = lexical_relevance(query_tokens, &format!("{kind} {text}"));
472 let lexical_term = raw_relevance_override.unwrap_or(lexical).max(lexical);
473
474 let raw_relevance = match cosine_score {
486 Some(c) => {
487 let normalized_cos = ((c + 1.0) * 0.5).clamp(0.0, 1.0);
488 (1.0 - DEFAULT_HYBRID_ALPHA) * lexical_term + DEFAULT_HYBRID_ALPHA * normalized_cos
489 }
490 None => lexical_term,
491 };
492
493 if raw_relevance <= 0.0 && !query_tokens.is_empty() {
499 return None;
500 }
501
502 let freshness = freshness(&created_at);
503 let scope_weight = scope_weight(&scope);
504 let raw_multiplier = usefulness_multiplier(usefulness_score as f32, use_count as u32);
510 let decay = usefulness_decay(last_useful_at.as_deref(), &created_at, half_life_days);
511 let multiplier = 1.0 + (raw_multiplier - 1.0) * decay;
512 let biased_relevance = raw_relevance * multiplier;
513 Some(Candidate {
514 raw_relevance: biased_relevance,
515 capsule: ContextCapsule {
516 id: new_id().to_string(),
517 kind: "memory".to_string(),
518 summary: format!("{scope}:{kind} - {text}"),
519 token_estimate: estimate_tokens(&text) + 8,
520 expansion_handle: format!("memory:{memory_id}"),
521 provenance: vec![ProvenanceRef {
522 source: "Memory".to_string(),
523 id: memory_id,
524 excerpt: Some(excerpt(&text)),
525 }],
526 confidence,
527 freshness,
528 relevance: 0.0,
529 scope_weight,
530 score: 0.0,
531 },
532 })
533}
534
535pub(crate) fn usefulness_decay(
559 last_useful_at: Option<&str>,
560 created_at: &str,
561 half_life_days: f32,
562) -> f32 {
563 if half_life_days <= 0.0 {
564 return 1.0;
565 }
566 let reference = last_useful_at.unwrap_or(created_at);
567 let Ok(reference_ts) =
568 OffsetDateTime::parse(reference, &time::format_description::well_known::Rfc3339)
569 else {
570 return 1.0;
571 };
572 let age = OffsetDateTime::now_utc() - reference_ts;
573 let age_days = (age.whole_seconds().max(0) as f32) / 86_400.0;
574 let exponent = -std::f32::consts::LN_2 * age_days / half_life_days;
575 exponent.exp().clamp(0.0, 1.0)
576}
577
578pub(crate) fn usefulness_multiplier(usefulness_score: f32, use_count: u32) -> f32 {
583 const FULL_CONFIDENCE_USES: u32 = 3;
591 const MULTIPLIER_MIN: f32 = 0.5;
592 const MULTIPLIER_MAX: f32 = 1.5;
593 if use_count == 0 {
594 return 1.0;
595 }
596 let ratio = usefulness_score / use_count as f32; let normalized = ((ratio + 1.0) / 2.0).clamp(0.0, 1.0); let full_multiplier = MULTIPLIER_MIN + normalized * (MULTIPLIER_MAX - MULTIPLIER_MIN);
599 let confidence = (use_count as f32 / FULL_CONFIDENCE_USES as f32).min(1.0);
600 1.0 * (1.0 - confidence) + full_multiplier * confidence
601}
602
603fn repo_file_candidates(
604 conn: &Connection,
605 repo_root: &str,
606 query: &str,
607 limit: u32,
608) -> KimetsuResult<Vec<Candidate>> {
609 let Some(fts_query) = fts_query(query) else {
610 return Ok(Vec::new());
611 };
612
613 let mut stmt = conn.prepare_cached(
614 "
615 SELECT path, snippet, language_guess, bm25(repo_files_fts) AS rank
616 FROM repo_files_fts
617 WHERE repo_root = ?1 AND repo_files_fts MATCH ?2
618 ORDER BY rank
619 LIMIT ?3
620 ",
621 )?;
622
623 let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
624 Ok((
625 row.get::<_, String>(0)?,
626 row.get::<_, String>(1)?,
627 row.get::<_, String>(2)?,
628 row.get::<_, f64>(3)?,
629 ))
630 })?;
631
632 let mut candidates = Vec::new();
633 for row in rows {
634 let (path, snippet, language, rank) = row?;
635 let raw_relevance = (-rank as f32).max(0.0);
636 let summary = format!("{path} ({language}) - {}", excerpt(&snippet));
637 let token_estimate = estimate_tokens(&summary) + 8;
638 candidates.push(Candidate {
639 raw_relevance,
640 capsule: ContextCapsule {
641 id: new_id().to_string(),
642 kind: "repo_file".to_string(),
643 summary,
644 token_estimate,
645 expansion_handle: format!("file:{path}"),
646 provenance: vec![ProvenanceRef {
647 source: "RepoFile".to_string(),
648 id: path.clone(),
649 excerpt: Some(excerpt(&snippet)),
650 }],
651 confidence: 0.9,
652 freshness: 1.0,
653 relevance: 0.0,
654 scope_weight: 0.9,
655 score: 0.0,
656 },
657 });
658 }
659 Ok(candidates)
660}
661
662fn manifest_candidates(
663 conn: &Connection,
664 repo_root: &str,
665 query: &str,
666) -> KimetsuResult<Vec<Candidate>> {
667 if let Some(fts_query) = fts_query(query) {
668 let candidates = manifest_fts_candidates(conn, repo_root, &fts_query, 30)?;
669 if !candidates.is_empty() {
670 return Ok(candidates);
671 }
672 }
673
674 let query_tokens = query_tokens(query);
675 let mut stmt = conn.prepare_cached(
676 "
677 SELECT manifest_path, manifest_kind, parsed_summary_json
678 FROM repo_manifests
679 WHERE repo_root = ?1
680 ORDER BY manifest_path
681 ",
682 )?;
683
684 let rows = stmt.query_map(params![repo_root], |row| {
685 Ok((
686 row.get::<_, String>(0)?,
687 row.get::<_, String>(1)?,
688 row.get::<_, String>(2)?,
689 ))
690 })?;
691
692 let mut candidates = Vec::new();
693 for row in rows {
694 let (path, kind, summary_json) = row?;
695 let raw_relevance =
696 lexical_relevance(&query_tokens, &format!("{path} {kind} {summary_json}"));
697 if raw_relevance <= 0.0 && !query_tokens.is_empty() {
698 continue;
699 }
700 let summary = format!("{path} manifest ({kind})");
701 let token_estimate = estimate_tokens(&summary) + 8;
702 candidates.push(Candidate {
703 raw_relevance,
704 capsule: ContextCapsule {
705 id: new_id().to_string(),
706 kind: "repo_manifest".to_string(),
707 summary,
708 token_estimate,
709 expansion_handle: format!("file:{path}"),
710 provenance: vec![ProvenanceRef {
711 source: "Manifest".to_string(),
712 id: path,
713 excerpt: Some(excerpt(&summary_json)),
714 }],
715 confidence: 0.95,
716 freshness: 1.0,
717 relevance: 0.0,
718 scope_weight: 0.9,
719 score: 0.0,
720 },
721 });
722 }
723 Ok(candidates)
724}
725
726fn manifest_fts_candidates(
727 conn: &Connection,
728 repo_root: &str,
729 fts_query: &str,
730 limit: u32,
731) -> KimetsuResult<Vec<Candidate>> {
732 let mut stmt = conn.prepare_cached(
733 "
734 SELECT manifest_path, manifest_kind, parsed_summary_json,
735 bm25(repo_manifests_fts) AS rank
736 FROM repo_manifests_fts
737 WHERE repo_root = ?1 AND repo_manifests_fts MATCH ?2
738 ORDER BY rank
739 LIMIT ?3
740 ",
741 )?;
742
743 let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
744 Ok((
745 row.get::<_, String>(0)?,
746 row.get::<_, String>(1)?,
747 row.get::<_, String>(2)?,
748 row.get::<_, f64>(3)?,
749 ))
750 })?;
751
752 let mut candidates = Vec::new();
753 for row in rows {
754 let (path, kind, summary_json, rank) = row?;
755 let raw_relevance = (-rank as f32).max(0.0);
756 let summary = format!("{path} manifest ({kind})");
757 let token_estimate = estimate_tokens(&summary) + 8;
758 candidates.push(Candidate {
759 raw_relevance,
760 capsule: ContextCapsule {
761 id: new_id().to_string(),
762 kind: "repo_manifest".to_string(),
763 summary,
764 token_estimate,
765 expansion_handle: format!("file:{path}"),
766 provenance: vec![ProvenanceRef {
767 source: "Manifest".to_string(),
768 id: path,
769 excerpt: Some(excerpt(&summary_json)),
770 }],
771 confidence: 0.95,
772 freshness: 1.0,
773 relevance: 0.0,
774 scope_weight: 0.9,
775 score: 0.0,
776 },
777 });
778 }
779 Ok(candidates)
780}
781
782fn normalize_and_score(candidates: &mut [Candidate], weights: StageWeights) {
783 let mut max_by_kind = HashMap::<String, f32>::new();
784 for candidate in candidates.iter() {
785 max_by_kind
786 .entry(candidate.capsule.kind.clone())
787 .and_modify(|max| *max = (*max).max(candidate.raw_relevance))
788 .or_insert(candidate.raw_relevance);
789 }
790
791 for candidate in candidates {
792 let max = max_by_kind
793 .get(&candidate.capsule.kind)
794 .copied()
795 .unwrap_or(0.0);
796 let relevance = if max <= f32::EPSILON {
797 if candidate.raw_relevance > 0.0 {
798 1.0
799 } else {
800 0.0
801 }
802 } else {
803 (candidate.raw_relevance / max).clamp(0.0, 1.0)
804 };
805 candidate.capsule.relevance = relevance;
806 candidate.capsule.score = weights.relevance * relevance
807 + weights.confidence * candidate.capsule.confidence
808 + weights.freshness * candidate.capsule.freshness
809 + weights.scope * candidate.capsule.scope_weight;
810 }
811}
812
813fn weights_for_stage(weights: &BrokerWeights, stage: &str) -> StageWeights {
814 match stage {
815 "localization" => weights.localization.clone(),
816 "patch_plan" => weights.patch_plan.clone(),
817 "verification" => weights.verification.clone(),
818 "review" => weights.review.clone(),
819 _ => None,
820 }
821 .unwrap_or(StageWeights {
822 relevance: weights.relevance,
823 confidence: weights.confidence,
824 freshness: weights.freshness,
825 scope: weights.scope,
826 })
827}
828
829fn scope_weight(scope: &str) -> f32 {
830 match scope.parse::<MemoryScope>() {
831 Ok(MemoryScope::Run) => 1.0,
832 Ok(MemoryScope::Repo) => 0.9,
833 Ok(MemoryScope::Project) => 0.7,
834 Ok(MemoryScope::GlobalUser) => 0.5,
835 Err(_) => 0.3,
836 }
837}
838
839fn freshness(created_at: &str) -> f32 {
840 let Ok(created_at) =
841 OffsetDateTime::parse(created_at, &time::format_description::well_known::Rfc3339)
842 else {
843 return 0.5;
844 };
845 let age = OffsetDateTime::now_utc() - created_at;
846 let age_days = age.whole_seconds().max(0) as f32 / 86_400.0;
847 (-age_days / 30.0).exp().clamp(0.0, 1.0)
848}
849
850fn query_tokens(query: &str) -> Vec<String> {
851 let mut tokens: Vec<String> = query
852 .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
853 .map(str::trim)
854 .filter(|part| part.len() >= 2)
855 .map(str::to_ascii_lowercase)
856 .collect();
857 let lower = query.to_ascii_lowercase();
864 for (triggers, expansions) in CLASS_HINTS.iter() {
865 if triggers.iter().any(|t| lower.contains(t)) {
866 tokens.extend(expansions.iter().map(|e| e.to_string()));
867 }
868 }
869 tokens
870}
871
872const CLASS_HINTS: &[(&[&str], &[&str])] = &[
880 (
881 &[
882 "build",
883 "compile",
884 "make",
885 "cargo",
886 "cmake",
887 "configure",
888 "install",
889 "train",
890 "benchmark",
891 "test suite",
892 "ray trace",
893 "render",
894 ],
895 &[
896 "shell_background",
897 "shell_status",
898 "shell_output",
899 "shell_stop",
900 "long_running",
901 ],
902 ),
903 (
904 &[
905 "edit", "modify", "change", "fix", "update", "patch", "refactor", "rename",
906 ],
907 &["edit_file", "apply_patch", "old_string", "new_string"],
908 ),
909 (
910 &[
911 "read", "inspect", "review", "analyze", "examine", "view", "show",
912 ],
913 &["read_file", "offset", "limit", "multi_read"],
914 ),
915 (
916 &["find", "locate", "search", "look up", "discover", "list"],
917 &["glob", "search_files", "list_files"],
918 ),
919 (
920 &["plan", "step", "checklist", "todo", "task list", "phase"],
921 &["plan", "todos"],
922 ),
923 (
924 &[
925 "verify",
926 "check",
927 "ensure",
928 "validate",
929 "pass test",
930 "verifier",
931 ],
932 &["finish", "verifier", "verification"],
933 ),
934 (
935 &[
936 "image",
937 "png",
938 "jpeg",
939 "jpg",
940 "pdf",
941 "diagram",
942 "screenshot",
943 ],
944 &["view_image", "base64", "sha256"],
945 ),
946 (&["delete", "remove", "rm "], &["delete_file", "recursive"]),
947 (&["rename", "move file", "mv "], &["move_file"]),
948];
949
950fn fts_query(query: &str) -> Option<String> {
951 let tokens = query_tokens(query);
952 if tokens.is_empty() {
953 return None;
954 }
955 Some(
956 tokens
957 .into_iter()
958 .take(12)
959 .map(|token| format!("{token}*"))
960 .collect::<Vec<_>>()
961 .join(" OR "),
962 )
963}
964
965fn apply_mmr_diversity(mut sorted: Vec<ContextCapsule>, lambda: f32) -> Vec<ContextCapsule> {
977 if sorted.len() <= 1 {
978 return sorted;
979 }
980 let summaries: Vec<std::collections::HashSet<String>> = sorted
982 .iter()
983 .map(|c| summary_token_set(&c.summary))
984 .collect();
985 let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
986 let mut remaining: Vec<usize> = (0..sorted.len()).collect();
987
988 picked_indices.push(remaining.remove(0));
990
991 while !remaining.is_empty() {
992 let mut best_idx_in_remaining = 0;
993 let mut best_score = f32::MIN;
994 for (i, &cand) in remaining.iter().enumerate() {
995 let mut max_overlap = 0.0f32;
996 for &p in &picked_indices {
997 let raw = jaccard(&summaries[cand], &summaries[p]);
998 let overlap = if sorted[cand].kind == sorted[p].kind {
999 raw
1000 } else {
1001 raw * 0.5
1004 };
1005 if overlap > max_overlap {
1006 max_overlap = overlap;
1007 }
1008 }
1009 let mmr = lambda * sorted[cand].score - (1.0 - lambda) * max_overlap;
1010 if mmr > best_score {
1011 best_score = mmr;
1012 best_idx_in_remaining = i;
1013 }
1014 }
1015 picked_indices.push(remaining.remove(best_idx_in_remaining));
1016 }
1017 let mut out = Vec::with_capacity(sorted.len());
1019 let mut taken: Vec<Option<ContextCapsule>> = sorted.drain(..).map(Some).collect();
1021 for idx in picked_indices {
1022 if let Some(c) = taken[idx].take() {
1023 out.push(c);
1024 }
1025 }
1026 out
1027}
1028
1029fn summary_token_set(s: &str) -> std::collections::HashSet<String> {
1030 s.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
1031 .filter(|t| t.len() >= 3)
1032 .map(str::to_ascii_lowercase)
1033 .collect()
1034}
1035
1036fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f32 {
1037 if a.is_empty() && b.is_empty() {
1038 return 0.0;
1039 }
1040 let intersection = a.intersection(b).count();
1041 let union = a.union(b).count();
1042 intersection as f32 / union.max(1) as f32
1043}
1044
1045fn lexical_relevance(tokens: &[String], haystack: &str) -> f32 {
1046 if tokens.is_empty() {
1047 return 0.0;
1048 }
1049 let haystack = haystack.to_ascii_lowercase();
1050 let matches = tokens
1051 .iter()
1052 .filter(|token| haystack.contains(token.as_str()))
1053 .count();
1054 matches as f32 / tokens.len() as f32
1055}
1056
1057fn estimate_tokens(text: &str) -> u32 {
1058 ((text.split_whitespace().count() as f32) * 1.33).ceil() as u32
1059}
1060
1061fn excerpt(text: &str) -> String {
1062 let value = one_line(text);
1063 value.chars().take(256).collect()
1064}
1065
1066fn one_line(text: &str) -> String {
1067 text.split_whitespace().collect::<Vec<_>>().join(" ")
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073
1074 #[test]
1077 fn usefulness_multiplier_neutral_at_zero_uses() {
1078 assert!((usefulness_multiplier(0.0, 0) - 1.0).abs() < f32::EPSILON);
1080 assert!((usefulness_multiplier(5.0, 0) - 1.0).abs() < f32::EPSILON);
1081 assert!((usefulness_multiplier(-5.0, 0) - 1.0).abs() < f32::EPSILON);
1082 }
1083
1084 #[test]
1088 fn usefulness_multiplier_blends_smoothly_in_transition() {
1089 let one_use = usefulness_multiplier(1.0, 1);
1092 assert!((one_use - 1.166_666_6).abs() < 1e-4, "got {one_use}");
1093 let two_uses = usefulness_multiplier(2.0, 2);
1096 assert!((two_uses - 1.333_333_4).abs() < 1e-4, "got {two_uses}");
1097 let two_uses_bad = usefulness_multiplier(-2.0, 2);
1099 assert!(
1101 (two_uses_bad - 0.666_666_7).abs() < 1e-4,
1102 "got {two_uses_bad}"
1103 );
1104 }
1105
1106 #[test]
1110 fn usefulness_multiplier_maps_ratio_onto_envelope() {
1111 assert!((usefulness_multiplier(5.0, 5) - 1.5).abs() < f32::EPSILON);
1113 assert!((usefulness_multiplier(-5.0, 5) - 0.5).abs() < f32::EPSILON);
1115 let mid = usefulness_multiplier(0.0, 6);
1117 assert!((mid - 1.0).abs() < f32::EPSILON, "got {mid}");
1118 let high = usefulness_multiplier(2.0, 4);
1120 assert!((high - 1.25).abs() < f32::EPSILON, "got {high}");
1121 let low = usefulness_multiplier(-2.0, 4);
1123 assert!((low - 0.75).abs() < f32::EPSILON, "got {low}");
1124 }
1125
1126 #[test]
1130 fn usefulness_multiplier_clamps_to_envelope() {
1131 assert!((usefulness_multiplier(100.0, 5) - 1.5).abs() < f32::EPSILON);
1133 assert!((usefulness_multiplier(-100.0, 5) - 0.5).abs() < f32::EPSILON);
1135 }
1136
1137 #[test]
1140 fn query_tokens_expands_build_class() {
1141 let toks = query_tokens("Build the project from source");
1142 assert!(toks.iter().any(|t| t == "build"));
1143 assert!(toks.iter().any(|t| t == "shell_background"));
1145 assert!(toks.iter().any(|t| t == "long_running"));
1146 }
1147
1148 #[test]
1149 fn query_tokens_expands_edit_class() {
1150 let toks = query_tokens("Modify the config to fix the bug");
1151 assert!(toks.iter().any(|t| t == "edit_file"));
1152 assert!(toks.iter().any(|t| t == "apply_patch"));
1153 }
1154
1155 #[test]
1156 fn query_tokens_expands_search_class() {
1157 let toks = query_tokens("Find all references to the symbol");
1158 assert!(toks.iter().any(|t| t == "glob"));
1159 assert!(toks.iter().any(|t| t == "search_files"));
1160 }
1161
1162 #[test]
1163 fn query_tokens_no_expansion_on_unrelated_query() {
1164 let toks = query_tokens("hello world testing nothing");
1165 assert!(toks.iter().any(|t| t == "hello"));
1167 assert!(toks.iter().any(|t| t == "world"));
1169 }
1170
1171 #[test]
1174 fn jaccard_is_zero_for_disjoint_sets() {
1175 let a: std::collections::HashSet<String> =
1176 ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1177 let b: std::collections::HashSet<String> =
1178 ["baz", "qux"].iter().map(|s| s.to_string()).collect();
1179 assert!((jaccard(&a, &b) - 0.0).abs() < f32::EPSILON);
1180 }
1181
1182 #[test]
1183 fn jaccard_is_one_for_identical_sets() {
1184 let a: std::collections::HashSet<String> =
1185 ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1186 let b = a.clone();
1187 assert!((jaccard(&a, &b) - 1.0).abs() < f32::EPSILON);
1188 }
1189
1190 #[test]
1191 fn jaccard_partial_overlap() {
1192 let a: std::collections::HashSet<String> = ["foo", "bar", "baz"]
1193 .iter()
1194 .map(|s| s.to_string())
1195 .collect();
1196 let b: std::collections::HashSet<String> =
1197 ["bar", "qux"].iter().map(|s| s.to_string()).collect();
1198 assert!((jaccard(&a, &b) - 0.25).abs() < f32::EPSILON);
1200 }
1201
1202 #[test]
1203 fn summary_token_set_lowercases_and_filters_short() {
1204 let set = summary_token_set("Build the Foo-bar project");
1205 assert!(set.contains("build"));
1206 assert!(set.contains("foo"));
1207 assert!(set.contains("bar"));
1208 assert!(set.contains("project"));
1209 assert!(set.contains("the"));
1211 }
1212
1213 fn insert_memory_with_embedding(
1219 conn: &rusqlite::Connection,
1220 memory_id: &str,
1221 text: &str,
1222 embedder: &dyn embeddings::Embedder,
1223 ) {
1224 let normalized = kimetsu_core::memory::normalize_memory_text(text);
1225 conn.execute(
1226 "
1227 INSERT INTO memories (
1228 memory_id, scope, kind, text, normalized_text, confidence,
1229 source_event_id, provenance_snapshot_json, created_at,
1230 use_count, usefulness_score, embedding, embedding_model
1231 )
1232 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1233 '2026-05-01T00:00:00Z', 0, 0.0, ?4, ?5)
1234 ",
1235 rusqlite::params![
1236 memory_id,
1237 text,
1238 normalized,
1239 embeddings::encode_embedding(&embedder.embed(text).expect("embed test row")),
1240 embedder.model_id(),
1241 ],
1242 )
1243 .expect("insert memory");
1244 conn.execute(
1245 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
1246 rusqlite::params![memory_id, text],
1247 )
1248 .expect("insert fts row");
1249 }
1250
1251 #[test]
1261 fn hybrid_retrieval_uses_cosine_score_to_rerank() {
1262 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1263 crate::schema::initialize(&conn).expect("init schema");
1264 let stub = embeddings::StubEmbedder::new();
1265
1266 insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
1267 insert_memory_with_embedding(
1268 &conn,
1269 "m_unrelated",
1270 "cookie recipe with chocolate chips",
1271 &stub,
1272 );
1273
1274 let weights = kimetsu_core::config::BrokerWeights::default();
1277 let bundle = retrieve_context_with_embedder(
1278 &conn,
1279 "/fake-repo",
1280 &weights,
1281 ContextRequest {
1282 stage: "localization".to_string(),
1283 query: "ripgrep search".to_string(),
1284 budget_tokens: 4000,
1285 },
1286 &[],
1287 &stub,
1288 )
1289 .expect("retrieve");
1290
1291 let memory_handles: Vec<_> = bundle
1292 .capsules
1293 .iter()
1294 .filter(|c| c.expansion_handle.starts_with("memory:"))
1295 .collect();
1296 assert!(
1297 !memory_handles.is_empty(),
1298 "at least one memory should surface"
1299 );
1300 assert_eq!(
1302 memory_handles[0].expansion_handle,
1303 "memory:m_rg",
1304 "ripgrep memory should outrank the cookie recipe; ranked: {:?}",
1305 memory_handles
1306 .iter()
1307 .map(|c| &c.expansion_handle)
1308 .collect::<Vec<_>>()
1309 );
1310 }
1311
1312 #[test]
1319 fn hybrid_retrieval_skips_cosine_on_model_id_mismatch() {
1320 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1321 crate::schema::initialize(&conn).expect("init schema");
1322 let stub = embeddings::StubEmbedder::new();
1323 insert_memory_with_embedding(&conn, "m_xref", "use ripgrep for code search", &stub);
1324
1325 conn.execute(
1330 "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xref'",
1331 [],
1332 )
1333 .expect("force model_id mismatch");
1334
1335 let weights = kimetsu_core::config::BrokerWeights::default();
1340 let bundle = retrieve_context_with_embedder(
1341 &conn,
1342 "/fake-repo",
1343 &weights,
1344 ContextRequest {
1345 stage: "localization".to_string(),
1346 query: "ripgrep search".to_string(),
1347 budget_tokens: 4000,
1348 },
1349 &[],
1350 &stub,
1351 )
1352 .expect("retrieve");
1353
1354 assert!(
1355 bundle
1356 .capsules
1357 .iter()
1358 .any(|c| c.expansion_handle == "memory:m_xref"),
1359 "cross-model row should still match lexically (cosine skipped, FTS works)"
1360 );
1361 }
1362
1363 #[test]
1370 fn usefulness_decay_disabled_when_half_life_is_zero_or_negative() {
1371 let ancient = "2021-01-01T00:00:00Z";
1373 assert!((usefulness_decay(Some(ancient), ancient, 0.0) - 1.0).abs() < f32::EPSILON);
1374 assert!((usefulness_decay(Some(ancient), ancient, -1.0) - 1.0).abs() < f32::EPSILON);
1375 }
1376
1377 #[test]
1381 fn usefulness_decay_returns_one_on_unparseable_timestamps() {
1382 assert!((usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON);
1383 }
1384
1385 #[test]
1388 fn usefulness_decay_full_at_zero_age() {
1389 let future = "2099-01-01T00:00:00Z";
1391 let d = usefulness_decay(Some(future), future, 30.0);
1392 assert!((d - 1.0).abs() < f32::EPSILON, "got {d}");
1393 }
1394
1395 #[test]
1400 fn usefulness_decay_follows_half_life_curve() {
1401 let half_life = 10.0_f32;
1402 let now = OffsetDateTime::now_utc();
1403 let fmt = &time::format_description::well_known::Rfc3339;
1404
1405 let one_half_life_ago =
1407 (now - time::Duration::seconds((half_life * 86_400.0) as i64))
1408 .format(fmt)
1409 .expect("format");
1410 let d1 = usefulness_decay(Some(&one_half_life_ago), &one_half_life_ago, half_life);
1411 assert!(
1412 (d1 - 0.5).abs() < 0.01,
1413 "expected ~0.5 at one half-life, got {d1}"
1414 );
1415
1416 let two_half_lives_ago =
1418 (now - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64))
1419 .format(fmt)
1420 .expect("format");
1421 let d2 = usefulness_decay(
1422 Some(&two_half_lives_ago),
1423 &two_half_lives_ago,
1424 half_life,
1425 );
1426 assert!(
1427 (d2 - 0.25).abs() < 0.01,
1428 "expected ~0.25 at two half-lives, got {d2}"
1429 );
1430 }
1431
1432 #[test]
1436 fn usefulness_decay_falls_back_to_created_at_when_last_useful_is_none() {
1437 let now = OffsetDateTime::now_utc();
1438 let fmt = &time::format_description::well_known::Rfc3339;
1439 let one_day_ago = (now - time::Duration::seconds(86_400))
1440 .format(fmt)
1441 .expect("format");
1442 let d = usefulness_decay(None, &one_day_ago, 30.0);
1443 assert!(
1445 (d - 0.977).abs() < 0.01,
1446 "expected ~0.977 for 1-day-old created_at under 30d half-life, got {d}"
1447 );
1448 }
1449
1450 #[test]
1455 fn aged_cited_memory_ranks_below_recently_cited_memory() {
1456 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1457 crate::schema::initialize(&conn).expect("init schema");
1458
1459 let now = OffsetDateTime::now_utc();
1460 let fmt = &time::format_description::well_known::Rfc3339;
1461 let one_day_ago = (now - time::Duration::seconds(86_400))
1462 .format(fmt)
1463 .expect("format");
1464 let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
1465 .format(fmt)
1466 .expect("format");
1467
1468 for (mid, last_useful) in
1472 [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)]
1473 {
1474 let text = "use ripgrep for code search";
1475 let normalized = kimetsu_core::memory::normalize_memory_text(text);
1476 conn.execute(
1477 "
1478 INSERT INTO memories (
1479 memory_id, scope, kind, text, normalized_text, confidence,
1480 source_event_id, provenance_snapshot_json, created_at,
1481 use_count, usefulness_score, last_useful_at
1482 )
1483 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1484 '2024-01-01T00:00:00Z', 5, 5.0, ?4)
1485 ",
1486 rusqlite::params![mid, text, normalized, last_useful],
1487 )
1488 .expect("insert memory");
1489 conn.execute(
1490 "INSERT INTO memories_fts (memory_id, text, kind, scope)
1491 VALUES (?1, ?2, 'fact', 'global_user')",
1492 rusqlite::params![mid, text],
1493 )
1494 .expect("insert fts");
1495 }
1496
1497 let weights = kimetsu_core::config::BrokerWeights::default();
1499 let bundle = retrieve_context_with_embedder(
1500 &conn,
1501 "/fake-repo",
1502 &weights,
1503 ContextRequest {
1504 stage: "localization".to_string(),
1505 query: "ripgrep search".to_string(),
1506 budget_tokens: 4000,
1507 },
1508 &[],
1509 &embeddings::NoopEmbedder,
1510 )
1511 .expect("retrieve");
1512
1513 let mem_order: Vec<&str> = bundle
1514 .capsules
1515 .iter()
1516 .filter_map(|c| {
1517 c.expansion_handle
1518 .strip_prefix("memory:")
1519 .map(|s| s)
1520 })
1521 .collect();
1522 assert_eq!(
1523 mem_order.first().copied(),
1524 Some("m_recent"),
1525 "recently-cited memory must rank first under decay; got order {mem_order:?}"
1526 );
1527 }
1528
1529 #[test]
1534 fn aged_cited_memory_does_not_decay_when_half_life_is_zero() {
1535 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1536 crate::schema::initialize(&conn).expect("init schema");
1537
1538 let now = OffsetDateTime::now_utc();
1539 let fmt = &time::format_description::well_known::Rfc3339;
1540 let one_day_ago = (now - time::Duration::seconds(86_400))
1541 .format(fmt)
1542 .expect("format");
1543 let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
1544 .format(fmt)
1545 .expect("format");
1546
1547 for (mid, last_useful) in
1548 [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)]
1549 {
1550 let text = "use ripgrep for code search";
1551 let normalized = kimetsu_core::memory::normalize_memory_text(text);
1552 conn.execute(
1553 "
1554 INSERT INTO memories (
1555 memory_id, scope, kind, text, normalized_text, confidence,
1556 source_event_id, provenance_snapshot_json, created_at,
1557 use_count, usefulness_score, last_useful_at
1558 )
1559 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1560 '2024-01-01T00:00:00Z', 5, 5.0, ?4)
1561 ",
1562 rusqlite::params![mid, text, normalized, last_useful],
1563 )
1564 .expect("insert memory");
1565 conn.execute(
1566 "INSERT INTO memories_fts (memory_id, text, kind, scope)
1567 VALUES (?1, ?2, 'fact', 'global_user')",
1568 rusqlite::params![mid, text],
1569 )
1570 .expect("insert fts");
1571 }
1572
1573 let mut weights = kimetsu_core::config::BrokerWeights::default();
1575 weights.decay_half_life_days = 0.0;
1576
1577 let bundle = retrieve_context_with_embedder(
1578 &conn,
1579 "/fake-repo",
1580 &weights,
1581 ContextRequest {
1582 stage: "localization".to_string(),
1583 query: "ripgrep search".to_string(),
1584 budget_tokens: 4000,
1585 },
1586 &[],
1587 &embeddings::NoopEmbedder,
1588 )
1589 .expect("retrieve");
1590
1591 let scores: Vec<(String, f32)> = bundle
1597 .capsules
1598 .iter()
1599 .filter_map(|c| {
1600 c.expansion_handle
1601 .strip_prefix("memory:")
1602 .map(|id| (id.to_string(), c.score))
1603 })
1604 .collect();
1605 assert_eq!(scores.len(), 2, "both memories should surface");
1606 let recent_score = scores
1607 .iter()
1608 .find(|(id, _)| id == "m_recent")
1609 .map(|(_, s)| *s)
1610 .expect("m_recent present");
1611 let aged_score = scores
1612 .iter()
1613 .find(|(id, _)| id == "m_aged")
1614 .map(|(_, s)| *s)
1615 .expect("m_aged present");
1616 assert!(
1618 (recent_score - aged_score).abs() < 1e-4,
1619 "with decay disabled the two memories should tie on score: recent={recent_score} aged={aged_score}"
1620 );
1621 }
1622
1623 #[test]
1628 fn hybrid_retrieval_with_noop_embedder_is_lexical_only() {
1629 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1630 crate::schema::initialize(&conn).expect("init schema");
1631 let stub = embeddings::StubEmbedder::new();
1632 insert_memory_with_embedding(&conn, "m_a", "use ripgrep", &stub);
1634 insert_memory_with_embedding(&conn, "m_b", "use ripgrep too", &stub);
1635
1636 let weights = kimetsu_core::config::BrokerWeights::default();
1639 let bundle = retrieve_context_with_embedder(
1640 &conn,
1641 "/fake-repo",
1642 &weights,
1643 ContextRequest {
1644 stage: "localization".to_string(),
1645 query: "ripgrep".to_string(),
1646 budget_tokens: 4000,
1647 },
1648 &[],
1649 &embeddings::NoopEmbedder,
1650 )
1651 .expect("retrieve");
1652
1653 let count = bundle
1654 .capsules
1655 .iter()
1656 .filter(|c| c.expansion_handle.starts_with("memory:"))
1657 .count();
1658 assert_eq!(count, 2, "both memories should surface via FTS");
1659 }
1660}