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, Default)]
67pub struct ContextRequest {
68 pub stage: String,
69 pub query: String,
70 pub budget_tokens: u32,
71 pub tags: Vec<String>,
76 pub min_score: f32,
81 pub max_capsules: usize,
84 pub prefer_roles: Vec<String>,
88 pub kinds: Vec<String>,
95}
96
97#[derive(Debug, Clone)]
98pub struct ContextBundle {
99 pub stage: String,
100 pub budget_tokens: u32,
101 pub used_tokens: u32,
102 pub capsules: Vec<ContextCapsule>,
103 pub excluded: Vec<ContextCapsule>,
104 pub skipped: bool,
107 pub top_score: f32,
110}
111
112#[derive(Debug, Clone)]
113struct Candidate {
114 capsule: ContextCapsule,
115 raw_relevance: f32,
116}
117
118pub fn retrieve_context(
119 conn: &Connection,
120 repo_root: &str,
121 weights: &BrokerWeights,
122 request: ContextRequest,
123) -> KimetsuResult<ContextBundle> {
124 retrieve_context_multi(conn, repo_root, weights, request, &[])
125}
126
127pub fn retrieve_context_multi(
144 conn: &Connection,
145 repo_root: &str,
146 weights: &BrokerWeights,
147 request: ContextRequest,
148 extra_memory_conns: &[&Connection],
149) -> KimetsuResult<ContextBundle> {
150 let embedder = embeddings::open_default_embedder();
151 retrieve_context_with_embedder(
152 conn,
153 repo_root,
154 weights,
155 request,
156 extra_memory_conns,
157 embedder,
158 )
159}
160
161pub fn retrieve_context_with_embedder(
168 conn: &Connection,
169 repo_root: &str,
170 weights: &BrokerWeights,
171 request: ContextRequest,
172 extra_memory_conns: &[&Connection],
173 embedder: &dyn Embedder,
174) -> KimetsuResult<ContextBundle> {
175 let query_embedding = QueryEmbedding::from_embedder(embedder, &request.query);
176 let half_life_days = weights.decay_half_life_days;
177 let mut candidates = Vec::new();
178 candidates.extend(memory_candidates(
179 conn,
180 &request.query,
181 query_embedding.as_ref(),
182 half_life_days,
183 )?);
184 for extra in extra_memory_conns {
185 candidates.extend(memory_candidates(
186 extra,
187 &request.query,
188 query_embedding.as_ref(),
189 half_life_days,
190 )?);
191 }
192 candidates.extend(repo_file_candidates(conn, repo_root, &request.query, 30)?);
193 candidates.extend(manifest_candidates(conn, repo_root, &request.query)?);
194
195 if !request.kinds.is_empty() {
202 candidates.retain(|c| {
203 request
204 .kinds
205 .iter()
206 .any(|k| capsule_matches_kind(&c.capsule, k))
207 });
208 }
209
210 normalize_and_score(&mut candidates, weights_for_stage(weights, &request.stage));
211
212 if !request.tags.is_empty() || !request.prefer_roles.is_empty() {
216 let tags_lc: Vec<String> = request
217 .tags
218 .iter()
219 .map(|t| t.to_ascii_lowercase())
220 .collect();
221 for c in &mut candidates {
222 let summary_lc = c.capsule.summary.to_ascii_lowercase();
223 if !tags_lc.is_empty() && tags_lc.iter().any(|t| summary_lc.contains(t.as_str())) {
224 c.capsule.score *= 1.4;
225 }
226 if !request.prefer_roles.is_empty()
227 && request
228 .prefer_roles
229 .iter()
230 .any(|r| c.capsule.kind.contains(r.as_str()))
231 {
232 c.capsule.score *= 1.3;
233 }
234 }
235 }
236
237 let mut capsules = candidates
238 .into_iter()
239 .map(|candidate| candidate.capsule)
240 .collect::<Vec<_>>();
241
242 capsules.sort_by(|left, right| {
243 right
244 .score
245 .partial_cmp(&left.score)
246 .unwrap_or(Ordering::Equal)
247 .then_with(|| {
248 right
249 .freshness
250 .partial_cmp(&left.freshness)
251 .unwrap_or(Ordering::Equal)
252 })
253 .then_with(|| left.id.cmp(&right.id))
254 });
255
256 let top_score = capsules.first().map(|c| c.score).unwrap_or(0.0);
259 if request.min_score > 0.0 && top_score < request.min_score {
260 return Ok(ContextBundle {
261 stage: request.stage,
262 budget_tokens: request.budget_tokens,
263 used_tokens: 0,
264 capsules: Vec::new(),
265 excluded: capsules,
266 skipped: true,
267 top_score,
268 });
269 }
270
271 let capsules = apply_mmr_diversity(capsules, 0.7);
277
278 let capsule_budget = request.budget_tokens / 2;
279 let mut used_tokens = 0u32;
280 let mut included = Vec::new();
281 let mut excluded = Vec::new();
282
283 for capsule in capsules {
284 if request.max_capsules > 0 && included.len() >= request.max_capsules {
286 excluded.push(capsule);
287 continue;
288 }
289 if used_tokens.saturating_add(capsule.token_estimate) <= capsule_budget {
290 used_tokens += capsule.token_estimate;
291 included.push(capsule);
292 } else {
293 excluded.push(capsule);
294 }
295 }
296
297 Ok(ContextBundle {
298 stage: request.stage,
299 budget_tokens: request.budget_tokens,
300 used_tokens,
301 capsules: included,
302 excluded,
303 skipped: false,
304 top_score,
305 })
306}
307
308pub fn search_repo_files(
309 conn: &Connection,
310 repo_root: &str,
311 query: &str,
312 limit: u32,
313) -> KimetsuResult<Vec<ContextCapsule>> {
314 let candidates = repo_file_candidates(conn, repo_root, query, limit)?;
315 let mut capsules = candidates
316 .into_iter()
317 .map(|mut candidate| {
318 candidate.capsule.relevance = candidate.raw_relevance;
319 candidate.capsule.score = candidate.raw_relevance;
320 candidate.capsule
321 })
322 .collect::<Vec<_>>();
323 capsules.sort_by(|left, right| {
324 right
325 .score
326 .partial_cmp(&left.score)
327 .unwrap_or(Ordering::Equal)
328 .then_with(|| left.expansion_handle.cmp(&right.expansion_handle))
329 });
330 Ok(capsules)
331}
332
333fn memory_candidates(
334 conn: &Connection,
335 query: &str,
336 query_embedding: Option<&QueryEmbedding>,
337 half_life_days: f32,
338) -> KimetsuResult<Vec<Candidate>> {
339 let query_tokens = query_tokens(query);
340 if let Some(fts_query) = fts_query(query) {
341 let candidates = memory_fts_candidates(
342 conn,
343 &query_tokens,
344 &fts_query,
345 80,
346 query_embedding,
347 half_life_days,
348 )?;
349 if !candidates.is_empty() {
350 return Ok(candidates);
351 }
352 }
353
354 latest_memory_candidates(conn, &query_tokens, 200, query_embedding, half_life_days)
355}
356
357fn latest_memory_candidates(
358 conn: &Connection,
359 query_tokens: &[String],
360 limit: u32,
361 query_embedding: Option<&QueryEmbedding>,
362 half_life_days: f32,
363) -> KimetsuResult<Vec<Candidate>> {
364 let mut stmt = conn.prepare_cached(
375 "
376 SELECT memory_id, scope, kind, text, confidence, created_at,
377 use_count, usefulness_score, embedding, embedding_model,
378 last_useful_at
379 FROM memories
380 WHERE invalidated_at IS NULL
381 ORDER BY created_at DESC
382 LIMIT ?1
383 ",
384 )?;
385
386 let rows = stmt.query_map(params![limit], |row| {
387 Ok((
388 row.get::<_, String>(0)?,
389 row.get::<_, String>(1)?,
390 row.get::<_, String>(2)?,
391 row.get::<_, String>(3)?,
392 row.get::<_, f32>(4)?,
393 row.get::<_, String>(5)?,
394 row.get::<_, i64>(6)?,
395 row.get::<_, f64>(7)?,
396 row.get::<_, Option<Vec<u8>>>(8)?,
397 row.get::<_, Option<String>>(9)?,
398 row.get::<_, Option<String>>(10)?,
399 ))
400 })?;
401
402 let mut candidates = Vec::new();
403 for row in rows {
404 let (
405 memory_id,
406 scope,
407 kind,
408 text,
409 confidence,
410 created_at,
411 use_count,
412 usefulness_score,
413 embedding,
414 embedding_model,
415 last_useful_at,
416 ) = row?;
417 let cosine = compute_cosine(
418 query_embedding,
419 embedding.as_deref(),
420 embedding_model.as_deref(),
421 );
422 if let Some(candidate) = memory_row_to_candidate(
423 query_tokens,
424 memory_id,
425 scope,
426 kind,
427 text,
428 confidence,
429 created_at,
430 use_count,
431 usefulness_score,
432 last_useful_at,
433 half_life_days,
434 None,
435 cosine,
436 ) {
437 candidates.push(candidate);
438 }
439 }
440 Ok(candidates)
441}
442
443fn memory_fts_candidates(
444 conn: &Connection,
445 query_tokens: &[String],
446 fts_query: &str,
447 limit: u32,
448 query_embedding: Option<&QueryEmbedding>,
449 half_life_days: f32,
450) -> KimetsuResult<Vec<Candidate>> {
451 let mut stmt = conn.prepare_cached(
452 "
453 SELECT m.memory_id, m.scope, m.kind, m.text, m.confidence, m.created_at,
454 m.use_count, m.usefulness_score, bm25(memories_fts) AS rank,
455 m.embedding, m.embedding_model, m.last_useful_at
456 FROM memories_fts
457 JOIN memories m
458 ON m.memory_id = memories_fts.memory_id
459 WHERE m.invalidated_at IS NULL
460 AND memories_fts MATCH ?1
461 ORDER BY rank
462 LIMIT ?2
463 ",
464 )?;
465
466 let rows = stmt.query_map(params![fts_query, limit], |row| {
467 Ok((
468 row.get::<_, String>(0)?,
469 row.get::<_, String>(1)?,
470 row.get::<_, String>(2)?,
471 row.get::<_, String>(3)?,
472 row.get::<_, f32>(4)?,
473 row.get::<_, String>(5)?,
474 row.get::<_, i64>(6)?,
475 row.get::<_, f64>(7)?,
476 row.get::<_, f64>(8)?,
477 row.get::<_, Option<Vec<u8>>>(9)?,
478 row.get::<_, Option<String>>(10)?,
479 row.get::<_, Option<String>>(11)?,
480 ))
481 })?;
482
483 let mut candidates = Vec::new();
484 for row in rows {
485 let (
486 memory_id,
487 scope,
488 kind,
489 text,
490 confidence,
491 created_at,
492 use_count,
493 usefulness_score,
494 rank,
495 embedding,
496 embedding_model,
497 last_useful_at,
498 ) = row?;
499 let fts_relevance = (-rank as f32).max(0.0);
500 let cosine = compute_cosine(
501 query_embedding,
502 embedding.as_deref(),
503 embedding_model.as_deref(),
504 );
505 if let Some(candidate) = memory_row_to_candidate(
506 query_tokens,
507 memory_id,
508 scope,
509 kind,
510 text,
511 confidence,
512 created_at,
513 use_count,
514 usefulness_score,
515 last_useful_at,
516 half_life_days,
517 Some(fts_relevance),
518 cosine,
519 ) {
520 candidates.push(candidate);
521 }
522 }
523 Ok(candidates)
524}
525
526fn compute_cosine(
538 query_embedding: Option<&QueryEmbedding>,
539 row_bytes: Option<&[u8]>,
540 row_model: Option<&str>,
541) -> Option<f32> {
542 let q = query_embedding?;
543 let bytes = row_bytes?;
544 let model = row_model?;
545 if model != q.model_id {
546 return None;
547 }
548 let row_vec = match decode_embedding(bytes, Some(q.vector.len())) {
549 Ok(v) => v,
550 Err(_) => return None,
551 };
552 Some(cosine_similarity(&q.vector, &row_vec))
553}
554
555#[allow(clippy::too_many_arguments)]
556fn memory_row_to_candidate(
557 query_tokens: &[String],
558 memory_id: String,
559 scope: String,
560 kind: String,
561 text: String,
562 confidence: f32,
563 created_at: String,
564 use_count: i64,
565 usefulness_score: f64,
566 last_useful_at: Option<String>,
567 half_life_days: f32,
568 raw_relevance_override: Option<f32>,
569 cosine_score: Option<f32>,
570) -> Option<Candidate> {
571 let lexical = lexical_relevance(query_tokens, &format!("{kind} {text}"));
572 let lexical_term = raw_relevance_override.unwrap_or(lexical).max(lexical);
573
574 let raw_relevance = match cosine_score {
586 Some(c) => {
587 let normalized_cos = ((c + 1.0) * 0.5).clamp(0.0, 1.0);
588 (1.0 - DEFAULT_HYBRID_ALPHA) * lexical_term + DEFAULT_HYBRID_ALPHA * normalized_cos
589 }
590 None => lexical_term,
591 };
592
593 if raw_relevance <= 0.0 && !query_tokens.is_empty() {
599 return None;
600 }
601
602 let freshness = freshness(&created_at);
603 let scope_weight = scope_weight(&scope);
604 let raw_multiplier = usefulness_multiplier(usefulness_score as f32, use_count as u32);
610 let decay = usefulness_decay(last_useful_at.as_deref(), &created_at, half_life_days);
611 let multiplier = 1.0 + (raw_multiplier - 1.0) * decay;
612 let biased_relevance = raw_relevance * multiplier;
613 Some(Candidate {
614 raw_relevance: biased_relevance,
615 capsule: ContextCapsule {
616 id: new_id().to_string(),
617 kind: "memory".to_string(),
618 summary: format!("{scope}:{kind} - {text}"),
619 token_estimate: estimate_tokens(&text) + 8,
620 expansion_handle: format!("memory:{memory_id}"),
621 provenance: vec![ProvenanceRef {
622 source: "Memory".to_string(),
623 id: memory_id,
624 excerpt: Some(excerpt(&text)),
625 }],
626 confidence,
627 freshness,
628 relevance: 0.0,
629 scope_weight,
630 score: 0.0,
631 },
632 })
633}
634
635pub(crate) fn usefulness_decay(
659 last_useful_at: Option<&str>,
660 created_at: &str,
661 half_life_days: f32,
662) -> f32 {
663 if half_life_days <= 0.0 {
664 return 1.0;
665 }
666 let reference = last_useful_at.unwrap_or(created_at);
667 let Ok(reference_ts) =
668 OffsetDateTime::parse(reference, &time::format_description::well_known::Rfc3339)
669 else {
670 return 1.0;
671 };
672 let age = OffsetDateTime::now_utc() - reference_ts;
673 let age_days = (age.whole_seconds().max(0) as f32) / 86_400.0;
674 let exponent = -std::f32::consts::LN_2 * age_days / half_life_days;
675 exponent.exp().clamp(0.0, 1.0)
676}
677
678pub(crate) fn usefulness_multiplier(usefulness_score: f32, use_count: u32) -> f32 {
683 const FULL_CONFIDENCE_USES: u32 = 3;
691 const MULTIPLIER_MIN: f32 = 0.5;
692 const MULTIPLIER_MAX: f32 = 1.5;
693 if use_count == 0 {
694 return 1.0;
695 }
696 let ratio = usefulness_score / use_count as f32; let normalized = ((ratio + 1.0) / 2.0).clamp(0.0, 1.0); let full_multiplier = MULTIPLIER_MIN + normalized * (MULTIPLIER_MAX - MULTIPLIER_MIN);
699 let confidence = (use_count as f32 / FULL_CONFIDENCE_USES as f32).min(1.0);
700 1.0 * (1.0 - confidence) + full_multiplier * confidence
701}
702
703fn repo_file_candidates(
704 conn: &Connection,
705 repo_root: &str,
706 query: &str,
707 limit: u32,
708) -> KimetsuResult<Vec<Candidate>> {
709 let Some(fts_query) = fts_query(query) else {
710 return Ok(Vec::new());
711 };
712
713 let mut stmt = conn.prepare_cached(
714 "
715 SELECT path, snippet, language_guess, bm25(repo_files_fts) AS rank
716 FROM repo_files_fts
717 WHERE repo_root = ?1 AND repo_files_fts MATCH ?2
718 ORDER BY rank
719 LIMIT ?3
720 ",
721 )?;
722
723 let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
724 Ok((
725 row.get::<_, String>(0)?,
726 row.get::<_, String>(1)?,
727 row.get::<_, String>(2)?,
728 row.get::<_, f64>(3)?,
729 ))
730 })?;
731
732 let mut candidates = Vec::new();
733 for row in rows {
734 let (path, snippet, language, rank) = row?;
735 let raw_relevance = (-rank as f32).max(0.0);
736 let summary = format!("{path} ({language}) - {}", excerpt(&snippet));
737 let token_estimate = estimate_tokens(&summary) + 8;
738 candidates.push(Candidate {
739 raw_relevance,
740 capsule: ContextCapsule {
741 id: new_id().to_string(),
742 kind: "repo_file".to_string(),
743 summary,
744 token_estimate,
745 expansion_handle: format!("file:{path}"),
746 provenance: vec![ProvenanceRef {
747 source: "RepoFile".to_string(),
748 id: path.clone(),
749 excerpt: Some(excerpt(&snippet)),
750 }],
751 confidence: 0.9,
752 freshness: 1.0,
753 relevance: 0.0,
754 scope_weight: 0.9,
755 score: 0.0,
756 },
757 });
758 }
759 Ok(candidates)
760}
761
762fn manifest_candidates(
763 conn: &Connection,
764 repo_root: &str,
765 query: &str,
766) -> KimetsuResult<Vec<Candidate>> {
767 if let Some(fts_query) = fts_query(query) {
768 let candidates = manifest_fts_candidates(conn, repo_root, &fts_query, 30)?;
769 if !candidates.is_empty() {
770 return Ok(candidates);
771 }
772 }
773
774 let query_tokens = query_tokens(query);
775 let mut stmt = conn.prepare_cached(
776 "
777 SELECT manifest_path, manifest_kind, parsed_summary_json
778 FROM repo_manifests
779 WHERE repo_root = ?1
780 ORDER BY manifest_path
781 ",
782 )?;
783
784 let rows = stmt.query_map(params![repo_root], |row| {
785 Ok((
786 row.get::<_, String>(0)?,
787 row.get::<_, String>(1)?,
788 row.get::<_, String>(2)?,
789 ))
790 })?;
791
792 let mut candidates = Vec::new();
793 for row in rows {
794 let (path, kind, summary_json) = row?;
795 let raw_relevance =
796 lexical_relevance(&query_tokens, &format!("{path} {kind} {summary_json}"));
797 if raw_relevance <= 0.0 && !query_tokens.is_empty() {
798 continue;
799 }
800 let summary = format!("{path} manifest ({kind})");
801 let token_estimate = estimate_tokens(&summary) + 8;
802 candidates.push(Candidate {
803 raw_relevance,
804 capsule: ContextCapsule {
805 id: new_id().to_string(),
806 kind: "repo_manifest".to_string(),
807 summary,
808 token_estimate,
809 expansion_handle: format!("file:{path}"),
810 provenance: vec![ProvenanceRef {
811 source: "Manifest".to_string(),
812 id: path,
813 excerpt: Some(excerpt(&summary_json)),
814 }],
815 confidence: 0.95,
816 freshness: 1.0,
817 relevance: 0.0,
818 scope_weight: 0.9,
819 score: 0.0,
820 },
821 });
822 }
823 Ok(candidates)
824}
825
826fn manifest_fts_candidates(
827 conn: &Connection,
828 repo_root: &str,
829 fts_query: &str,
830 limit: u32,
831) -> KimetsuResult<Vec<Candidate>> {
832 let mut stmt = conn.prepare_cached(
833 "
834 SELECT manifest_path, manifest_kind, parsed_summary_json,
835 bm25(repo_manifests_fts) AS rank
836 FROM repo_manifests_fts
837 WHERE repo_root = ?1 AND repo_manifests_fts MATCH ?2
838 ORDER BY rank
839 LIMIT ?3
840 ",
841 )?;
842
843 let rows = stmt.query_map(params![repo_root, fts_query, limit], |row| {
844 Ok((
845 row.get::<_, String>(0)?,
846 row.get::<_, String>(1)?,
847 row.get::<_, String>(2)?,
848 row.get::<_, f64>(3)?,
849 ))
850 })?;
851
852 let mut candidates = Vec::new();
853 for row in rows {
854 let (path, kind, summary_json, rank) = row?;
855 let raw_relevance = (-rank as f32).max(0.0);
856 let summary = format!("{path} manifest ({kind})");
857 let token_estimate = estimate_tokens(&summary) + 8;
858 candidates.push(Candidate {
859 raw_relevance,
860 capsule: ContextCapsule {
861 id: new_id().to_string(),
862 kind: "repo_manifest".to_string(),
863 summary,
864 token_estimate,
865 expansion_handle: format!("file:{path}"),
866 provenance: vec![ProvenanceRef {
867 source: "Manifest".to_string(),
868 id: path,
869 excerpt: Some(excerpt(&summary_json)),
870 }],
871 confidence: 0.95,
872 freshness: 1.0,
873 relevance: 0.0,
874 scope_weight: 0.9,
875 score: 0.0,
876 },
877 });
878 }
879 Ok(candidates)
880}
881
882fn normalize_and_score(candidates: &mut [Candidate], weights: StageWeights) {
883 let mut max_by_kind = HashMap::<String, f32>::new();
884 for candidate in candidates.iter() {
885 max_by_kind
886 .entry(candidate.capsule.kind.clone())
887 .and_modify(|max| *max = (*max).max(candidate.raw_relevance))
888 .or_insert(candidate.raw_relevance);
889 }
890
891 for candidate in candidates {
892 let max = max_by_kind
893 .get(&candidate.capsule.kind)
894 .copied()
895 .unwrap_or(0.0);
896 let relevance = if max <= f32::EPSILON {
897 if candidate.raw_relevance > 0.0 {
898 1.0
899 } else {
900 0.0
901 }
902 } else {
903 (candidate.raw_relevance / max).clamp(0.0, 1.0)
904 };
905 candidate.capsule.relevance = relevance;
906 candidate.capsule.score = weights.relevance * relevance
907 + weights.confidence * candidate.capsule.confidence
908 + weights.freshness * candidate.capsule.freshness
909 + weights.scope * candidate.capsule.scope_weight;
910 }
911}
912
913fn weights_for_stage(weights: &BrokerWeights, stage: &str) -> StageWeights {
914 match stage {
915 "localization" => weights.localization.clone(),
916 "patch_plan" => weights.patch_plan.clone(),
917 "verification" => weights.verification.clone(),
918 "review" => weights.review.clone(),
919 _ => None,
920 }
921 .unwrap_or(StageWeights {
922 relevance: weights.relevance,
923 confidence: weights.confidence,
924 freshness: weights.freshness,
925 scope: weights.scope,
926 })
927}
928
929fn scope_weight(scope: &str) -> f32 {
930 match scope.parse::<MemoryScope>() {
931 Ok(MemoryScope::Run) => 1.0,
932 Ok(MemoryScope::Repo) => 0.9,
933 Ok(MemoryScope::Project) => 0.7,
934 Ok(MemoryScope::GlobalUser) => 0.5,
935 Err(_) => 0.3,
936 }
937}
938
939fn freshness(created_at: &str) -> f32 {
940 let Ok(created_at) =
941 OffsetDateTime::parse(created_at, &time::format_description::well_known::Rfc3339)
942 else {
943 return 0.5;
944 };
945 let age = OffsetDateTime::now_utc() - created_at;
946 let age_days = age.whole_seconds().max(0) as f32 / 86_400.0;
947 (-age_days / 30.0).exp().clamp(0.0, 1.0)
948}
949
950fn query_tokens(query: &str) -> Vec<String> {
951 let mut tokens: Vec<String> = query
952 .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
953 .map(str::trim)
954 .filter(|part| part.len() >= 2)
955 .map(str::to_ascii_lowercase)
956 .collect();
957 let lower = query.to_ascii_lowercase();
964 for (triggers, expansions) in CLASS_HINTS.iter() {
965 if triggers.iter().any(|t| lower.contains(t)) {
966 tokens.extend(expansions.iter().map(|e| e.to_string()));
967 }
968 }
969 tokens
970}
971
972const CLASS_HINTS: &[(&[&str], &[&str])] = &[
980 (
981 &[
982 "build",
983 "compile",
984 "make",
985 "cargo",
986 "cmake",
987 "configure",
988 "install",
989 "train",
990 "benchmark",
991 "test suite",
992 "ray trace",
993 "render",
994 ],
995 &[
996 "shell_background",
997 "shell_status",
998 "shell_output",
999 "shell_stop",
1000 "long_running",
1001 ],
1002 ),
1003 (
1004 &[
1005 "edit", "modify", "change", "fix", "update", "patch", "refactor", "rename",
1006 ],
1007 &["edit_file", "apply_patch", "old_string", "new_string"],
1008 ),
1009 (
1010 &[
1011 "read", "inspect", "review", "analyze", "examine", "view", "show",
1012 ],
1013 &["read_file", "offset", "limit", "multi_read"],
1014 ),
1015 (
1016 &["find", "locate", "search", "look up", "discover", "list"],
1017 &["glob", "search_files", "list_files"],
1018 ),
1019 (
1020 &["plan", "step", "checklist", "todo", "task list", "phase"],
1021 &["plan", "todos"],
1022 ),
1023 (
1024 &[
1025 "verify",
1026 "check",
1027 "ensure",
1028 "validate",
1029 "pass test",
1030 "verifier",
1031 ],
1032 &["finish", "verifier", "verification"],
1033 ),
1034 (
1035 &[
1036 "image",
1037 "png",
1038 "jpeg",
1039 "jpg",
1040 "pdf",
1041 "diagram",
1042 "screenshot",
1043 ],
1044 &["view_image", "base64", "sha256"],
1045 ),
1046 (&["delete", "remove", "rm "], &["delete_file", "recursive"]),
1047 (&["rename", "move file", "mv "], &["move_file"]),
1048];
1049
1050fn capsule_matches_kind(capsule: &ContextCapsule, wanted: &str) -> bool {
1055 if capsule.kind == wanted {
1056 return true;
1057 }
1058 if capsule.kind == "memory"
1059 && let Some((prefix, _)) = capsule.summary.split_once(" - ")
1060 && let Some((_scope, mkind)) = prefix.split_once(':')
1061 {
1062 return mkind == wanted;
1063 }
1064 false
1065}
1066
1067pub(crate) fn fts_query(query: &str) -> Option<String> {
1068 let tokens = query_tokens(query);
1069 if tokens.is_empty() {
1070 return None;
1071 }
1072 Some(
1073 tokens
1074 .into_iter()
1075 .take(12)
1076 .map(|token| format!("{token}*"))
1077 .collect::<Vec<_>>()
1078 .join(" OR "),
1079 )
1080}
1081
1082fn apply_mmr_diversity(mut sorted: Vec<ContextCapsule>, lambda: f32) -> Vec<ContextCapsule> {
1094 if sorted.len() <= 1 {
1095 return sorted;
1096 }
1097 let summaries: Vec<std::collections::HashSet<String>> = sorted
1099 .iter()
1100 .map(|c| summary_token_set(&c.summary))
1101 .collect();
1102 let mut picked_indices: Vec<usize> = Vec::with_capacity(sorted.len());
1103 let mut remaining: Vec<usize> = (0..sorted.len()).collect();
1104
1105 picked_indices.push(remaining.remove(0));
1107
1108 while !remaining.is_empty() {
1109 let mut best_idx_in_remaining = 0;
1110 let mut best_score = f32::MIN;
1111 for (i, &cand) in remaining.iter().enumerate() {
1112 let mut max_overlap = 0.0f32;
1113 for &p in &picked_indices {
1114 let raw = jaccard(&summaries[cand], &summaries[p]);
1115 let overlap = if sorted[cand].kind == sorted[p].kind {
1116 raw
1117 } else {
1118 raw * 0.5
1121 };
1122 if overlap > max_overlap {
1123 max_overlap = overlap;
1124 }
1125 }
1126 let mmr = lambda * sorted[cand].score - (1.0 - lambda) * max_overlap;
1127 if mmr > best_score {
1128 best_score = mmr;
1129 best_idx_in_remaining = i;
1130 }
1131 }
1132 picked_indices.push(remaining.remove(best_idx_in_remaining));
1133 }
1134 let mut out = Vec::with_capacity(sorted.len());
1136 let mut taken: Vec<Option<ContextCapsule>> = sorted.drain(..).map(Some).collect();
1138 for idx in picked_indices {
1139 if let Some(c) = taken[idx].take() {
1140 out.push(c);
1141 }
1142 }
1143 out
1144}
1145
1146fn summary_token_set(s: &str) -> std::collections::HashSet<String> {
1147 s.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_')
1148 .filter(|t| t.len() >= 3)
1149 .map(str::to_ascii_lowercase)
1150 .collect()
1151}
1152
1153fn jaccard(a: &std::collections::HashSet<String>, b: &std::collections::HashSet<String>) -> f32 {
1154 if a.is_empty() && b.is_empty() {
1155 return 0.0;
1156 }
1157 let intersection = a.intersection(b).count();
1158 let union = a.union(b).count();
1159 intersection as f32 / union.max(1) as f32
1160}
1161
1162fn lexical_relevance(tokens: &[String], haystack: &str) -> f32 {
1163 if tokens.is_empty() {
1164 return 0.0;
1165 }
1166 let haystack = haystack.to_ascii_lowercase();
1167 let matches = tokens
1168 .iter()
1169 .filter(|token| haystack.contains(token.as_str()))
1170 .count();
1171 matches as f32 / tokens.len() as f32
1172}
1173
1174fn estimate_tokens(text: &str) -> u32 {
1175 ((text.split_whitespace().count() as f32) * 1.33).ceil() as u32
1176}
1177
1178fn excerpt(text: &str) -> String {
1179 let value = one_line(text);
1180 value.chars().take(256).collect()
1181}
1182
1183fn one_line(text: &str) -> String {
1184 text.split_whitespace().collect::<Vec<_>>().join(" ")
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190
1191 fn capsule(kind: &str, summary: &str) -> ContextCapsule {
1192 ContextCapsule {
1193 id: "c".into(),
1194 kind: kind.into(),
1195 summary: summary.into(),
1196 token_estimate: 1,
1197 expansion_handle: "memory:x".into(),
1198 provenance: vec![],
1199 confidence: 1.0,
1200 freshness: 1.0,
1201 relevance: 1.0,
1202 scope_weight: 1.0,
1203 score: 1.0,
1204 }
1205 }
1206
1207 #[test]
1208 fn capsule_matches_kind_reads_memory_summary_prefix() {
1209 let mem = capsule("memory", "project:failure_pattern - linker not found");
1211 assert!(capsule_matches_kind(&mem, "failure_pattern"));
1212 assert!(!capsule_matches_kind(&mem, "command"));
1213 let repo = capsule("repo_file", "src/lib.rs:command - run build");
1215 assert!(capsule_matches_kind(&repo, "repo_file"));
1216 assert!(!capsule_matches_kind(&repo, "command"));
1217 }
1218
1219 #[test]
1222 fn usefulness_multiplier_neutral_at_zero_uses() {
1223 assert!((usefulness_multiplier(0.0, 0) - 1.0).abs() < f32::EPSILON);
1225 assert!((usefulness_multiplier(5.0, 0) - 1.0).abs() < f32::EPSILON);
1226 assert!((usefulness_multiplier(-5.0, 0) - 1.0).abs() < f32::EPSILON);
1227 }
1228
1229 #[test]
1233 fn usefulness_multiplier_blends_smoothly_in_transition() {
1234 let one_use = usefulness_multiplier(1.0, 1);
1237 assert!((one_use - 1.166_666_6).abs() < 1e-4, "got {one_use}");
1238 let two_uses = usefulness_multiplier(2.0, 2);
1241 assert!((two_uses - 1.333_333_4).abs() < 1e-4, "got {two_uses}");
1242 let two_uses_bad = usefulness_multiplier(-2.0, 2);
1244 assert!(
1246 (two_uses_bad - 0.666_666_7).abs() < 1e-4,
1247 "got {two_uses_bad}"
1248 );
1249 }
1250
1251 #[test]
1255 fn usefulness_multiplier_maps_ratio_onto_envelope() {
1256 assert!((usefulness_multiplier(5.0, 5) - 1.5).abs() < f32::EPSILON);
1258 assert!((usefulness_multiplier(-5.0, 5) - 0.5).abs() < f32::EPSILON);
1260 let mid = usefulness_multiplier(0.0, 6);
1262 assert!((mid - 1.0).abs() < f32::EPSILON, "got {mid}");
1263 let high = usefulness_multiplier(2.0, 4);
1265 assert!((high - 1.25).abs() < f32::EPSILON, "got {high}");
1266 let low = usefulness_multiplier(-2.0, 4);
1268 assert!((low - 0.75).abs() < f32::EPSILON, "got {low}");
1269 }
1270
1271 #[test]
1275 fn usefulness_multiplier_clamps_to_envelope() {
1276 assert!((usefulness_multiplier(100.0, 5) - 1.5).abs() < f32::EPSILON);
1278 assert!((usefulness_multiplier(-100.0, 5) - 0.5).abs() < f32::EPSILON);
1280 }
1281
1282 #[test]
1285 fn query_tokens_expands_build_class() {
1286 let toks = query_tokens("Build the project from source");
1287 assert!(toks.iter().any(|t| t == "build"));
1288 assert!(toks.iter().any(|t| t == "shell_background"));
1290 assert!(toks.iter().any(|t| t == "long_running"));
1291 }
1292
1293 #[test]
1294 fn query_tokens_expands_edit_class() {
1295 let toks = query_tokens("Modify the config to fix the bug");
1296 assert!(toks.iter().any(|t| t == "edit_file"));
1297 assert!(toks.iter().any(|t| t == "apply_patch"));
1298 }
1299
1300 #[test]
1301 fn query_tokens_expands_search_class() {
1302 let toks = query_tokens("Find all references to the symbol");
1303 assert!(toks.iter().any(|t| t == "glob"));
1304 assert!(toks.iter().any(|t| t == "search_files"));
1305 }
1306
1307 #[test]
1308 fn query_tokens_no_expansion_on_unrelated_query() {
1309 let toks = query_tokens("hello world testing nothing");
1310 assert!(toks.iter().any(|t| t == "hello"));
1312 assert!(toks.iter().any(|t| t == "world"));
1314 }
1315
1316 #[test]
1319 fn jaccard_is_zero_for_disjoint_sets() {
1320 let a: std::collections::HashSet<String> =
1321 ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1322 let b: std::collections::HashSet<String> =
1323 ["baz", "qux"].iter().map(|s| s.to_string()).collect();
1324 assert!((jaccard(&a, &b) - 0.0).abs() < f32::EPSILON);
1325 }
1326
1327 #[test]
1328 fn jaccard_is_one_for_identical_sets() {
1329 let a: std::collections::HashSet<String> =
1330 ["foo", "bar"].iter().map(|s| s.to_string()).collect();
1331 let b = a.clone();
1332 assert!((jaccard(&a, &b) - 1.0).abs() < f32::EPSILON);
1333 }
1334
1335 #[test]
1336 fn jaccard_partial_overlap() {
1337 let a: std::collections::HashSet<String> = ["foo", "bar", "baz"]
1338 .iter()
1339 .map(|s| s.to_string())
1340 .collect();
1341 let b: std::collections::HashSet<String> =
1342 ["bar", "qux"].iter().map(|s| s.to_string()).collect();
1343 assert!((jaccard(&a, &b) - 0.25).abs() < f32::EPSILON);
1345 }
1346
1347 #[test]
1348 fn summary_token_set_lowercases_and_filters_short() {
1349 let set = summary_token_set("Build the Foo-bar project");
1350 assert!(set.contains("build"));
1351 assert!(set.contains("foo"));
1352 assert!(set.contains("bar"));
1353 assert!(set.contains("project"));
1354 assert!(set.contains("the"));
1356 }
1357
1358 fn insert_memory_with_embedding(
1364 conn: &rusqlite::Connection,
1365 memory_id: &str,
1366 text: &str,
1367 embedder: &dyn embeddings::Embedder,
1368 ) {
1369 let normalized = kimetsu_core::memory::normalize_memory_text(text);
1370 conn.execute(
1371 "
1372 INSERT INTO memories (
1373 memory_id, scope, kind, text, normalized_text, confidence,
1374 source_event_id, provenance_snapshot_json, created_at,
1375 use_count, usefulness_score, embedding, embedding_model
1376 )
1377 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1378 '2026-05-01T00:00:00Z', 0, 0.0, ?4, ?5)
1379 ",
1380 rusqlite::params![
1381 memory_id,
1382 text,
1383 normalized,
1384 embeddings::encode_embedding(&embedder.embed(text).expect("embed test row")),
1385 embedder.model_id(),
1386 ],
1387 )
1388 .expect("insert memory");
1389 conn.execute(
1390 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, 'fact', 'global_user')",
1391 rusqlite::params![memory_id, text],
1392 )
1393 .expect("insert fts row");
1394 }
1395
1396 #[test]
1406 fn hybrid_retrieval_uses_cosine_score_to_rerank() {
1407 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1408 crate::schema::initialize(&conn).expect("init schema");
1409 let stub = embeddings::StubEmbedder::new();
1410
1411 insert_memory_with_embedding(&conn, "m_rg", "use ripgrep for code search", &stub);
1412 insert_memory_with_embedding(
1413 &conn,
1414 "m_unrelated",
1415 "cookie recipe with chocolate chips",
1416 &stub,
1417 );
1418
1419 let weights = kimetsu_core::config::BrokerWeights::default();
1422 let bundle = retrieve_context_with_embedder(
1423 &conn,
1424 "/fake-repo",
1425 &weights,
1426 ContextRequest {
1427 stage: "localization".to_string(),
1428 query: "ripgrep search".to_string(),
1429 budget_tokens: 4000,
1430 ..Default::default()
1431 },
1432 &[],
1433 &stub,
1434 )
1435 .expect("retrieve");
1436
1437 let memory_handles: Vec<_> = bundle
1438 .capsules
1439 .iter()
1440 .filter(|c| c.expansion_handle.starts_with("memory:"))
1441 .collect();
1442 assert!(
1443 !memory_handles.is_empty(),
1444 "at least one memory should surface"
1445 );
1446 assert_eq!(
1448 memory_handles[0].expansion_handle,
1449 "memory:m_rg",
1450 "ripgrep memory should outrank the cookie recipe; ranked: {:?}",
1451 memory_handles
1452 .iter()
1453 .map(|c| &c.expansion_handle)
1454 .collect::<Vec<_>>()
1455 );
1456 }
1457
1458 #[test]
1465 fn hybrid_retrieval_skips_cosine_on_model_id_mismatch() {
1466 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1467 crate::schema::initialize(&conn).expect("init schema");
1468 let stub = embeddings::StubEmbedder::new();
1469 insert_memory_with_embedding(&conn, "m_xref", "use ripgrep for code search", &stub);
1470
1471 conn.execute(
1476 "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xref'",
1477 [],
1478 )
1479 .expect("force model_id mismatch");
1480
1481 let weights = kimetsu_core::config::BrokerWeights::default();
1486 let bundle = retrieve_context_with_embedder(
1487 &conn,
1488 "/fake-repo",
1489 &weights,
1490 ContextRequest {
1491 stage: "localization".to_string(),
1492 query: "ripgrep search".to_string(),
1493 budget_tokens: 4000,
1494 ..Default::default()
1495 },
1496 &[],
1497 &stub,
1498 )
1499 .expect("retrieve");
1500
1501 assert!(
1502 bundle
1503 .capsules
1504 .iter()
1505 .any(|c| c.expansion_handle == "memory:m_xref"),
1506 "cross-model row should still match lexically (cosine skipped, FTS works)"
1507 );
1508 }
1509
1510 #[test]
1517 fn usefulness_decay_disabled_when_half_life_is_zero_or_negative() {
1518 let ancient = "2021-01-01T00:00:00Z";
1520 assert!((usefulness_decay(Some(ancient), ancient, 0.0) - 1.0).abs() < f32::EPSILON);
1521 assert!((usefulness_decay(Some(ancient), ancient, -1.0) - 1.0).abs() < f32::EPSILON);
1522 }
1523
1524 #[test]
1528 fn usefulness_decay_returns_one_on_unparseable_timestamps() {
1529 assert!(
1530 (usefulness_decay(Some("not-a-date"), "also-not", 30.0) - 1.0).abs() < f32::EPSILON
1531 );
1532 }
1533
1534 #[test]
1537 fn usefulness_decay_full_at_zero_age() {
1538 let future = "2099-01-01T00:00:00Z";
1540 let d = usefulness_decay(Some(future), future, 30.0);
1541 assert!((d - 1.0).abs() < f32::EPSILON, "got {d}");
1542 }
1543
1544 #[test]
1549 fn usefulness_decay_follows_half_life_curve() {
1550 let half_life = 10.0_f32;
1551 let now = OffsetDateTime::now_utc();
1552 let fmt = &time::format_description::well_known::Rfc3339;
1553
1554 let one_half_life_ago = (now - time::Duration::seconds((half_life * 86_400.0) as i64))
1556 .format(fmt)
1557 .expect("format");
1558 let d1 = usefulness_decay(Some(&one_half_life_ago), &one_half_life_ago, half_life);
1559 assert!(
1560 (d1 - 0.5).abs() < 0.01,
1561 "expected ~0.5 at one half-life, got {d1}"
1562 );
1563
1564 let two_half_lives_ago = (now
1566 - time::Duration::seconds((2.0 * half_life * 86_400.0) as i64))
1567 .format(fmt)
1568 .expect("format");
1569 let d2 = usefulness_decay(Some(&two_half_lives_ago), &two_half_lives_ago, half_life);
1570 assert!(
1571 (d2 - 0.25).abs() < 0.01,
1572 "expected ~0.25 at two half-lives, got {d2}"
1573 );
1574 }
1575
1576 #[test]
1580 fn usefulness_decay_falls_back_to_created_at_when_last_useful_is_none() {
1581 let now = OffsetDateTime::now_utc();
1582 let fmt = &time::format_description::well_known::Rfc3339;
1583 let one_day_ago = (now - time::Duration::seconds(86_400))
1584 .format(fmt)
1585 .expect("format");
1586 let d = usefulness_decay(None, &one_day_ago, 30.0);
1587 assert!(
1589 (d - 0.977).abs() < 0.01,
1590 "expected ~0.977 for 1-day-old created_at under 30d half-life, got {d}"
1591 );
1592 }
1593
1594 #[test]
1599 fn aged_cited_memory_ranks_below_recently_cited_memory() {
1600 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1601 crate::schema::initialize(&conn).expect("init schema");
1602
1603 let now = OffsetDateTime::now_utc();
1604 let fmt = &time::format_description::well_known::Rfc3339;
1605 let one_day_ago = (now - time::Duration::seconds(86_400))
1606 .format(fmt)
1607 .expect("format");
1608 let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
1609 .format(fmt)
1610 .expect("format");
1611
1612 for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] {
1616 let text = "use ripgrep for code search";
1617 let normalized = kimetsu_core::memory::normalize_memory_text(text);
1618 conn.execute(
1619 "
1620 INSERT INTO memories (
1621 memory_id, scope, kind, text, normalized_text, confidence,
1622 source_event_id, provenance_snapshot_json, created_at,
1623 use_count, usefulness_score, last_useful_at
1624 )
1625 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1626 '2024-01-01T00:00:00Z', 5, 5.0, ?4)
1627 ",
1628 rusqlite::params![mid, text, normalized, last_useful],
1629 )
1630 .expect("insert memory");
1631 conn.execute(
1632 "INSERT INTO memories_fts (memory_id, text, kind, scope)
1633 VALUES (?1, ?2, 'fact', 'global_user')",
1634 rusqlite::params![mid, text],
1635 )
1636 .expect("insert fts");
1637 }
1638
1639 let weights = kimetsu_core::config::BrokerWeights::default();
1641 let bundle = retrieve_context_with_embedder(
1642 &conn,
1643 "/fake-repo",
1644 &weights,
1645 ContextRequest {
1646 stage: "localization".to_string(),
1647 query: "ripgrep search".to_string(),
1648 budget_tokens: 4000,
1649 ..Default::default()
1650 },
1651 &[],
1652 &embeddings::NoopEmbedder,
1653 )
1654 .expect("retrieve");
1655
1656 let mem_order: Vec<&str> = bundle
1657 .capsules
1658 .iter()
1659 .filter_map(|c| c.expansion_handle.strip_prefix("memory:").map(|s| s))
1660 .collect();
1661 assert_eq!(
1662 mem_order.first().copied(),
1663 Some("m_recent"),
1664 "recently-cited memory must rank first under decay; got order {mem_order:?}"
1665 );
1666 }
1667
1668 #[test]
1673 fn aged_cited_memory_does_not_decay_when_half_life_is_zero() {
1674 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1675 crate::schema::initialize(&conn).expect("init schema");
1676
1677 let now = OffsetDateTime::now_utc();
1678 let fmt = &time::format_description::well_known::Rfc3339;
1679 let one_day_ago = (now - time::Duration::seconds(86_400))
1680 .format(fmt)
1681 .expect("format");
1682 let one_year_ago = (now - time::Duration::seconds(365 * 86_400))
1683 .format(fmt)
1684 .expect("format");
1685
1686 for (mid, last_useful) in [("m_recent", &one_day_ago), ("m_aged", &one_year_ago)] {
1687 let text = "use ripgrep for code search";
1688 let normalized = kimetsu_core::memory::normalize_memory_text(text);
1689 conn.execute(
1690 "
1691 INSERT INTO memories (
1692 memory_id, scope, kind, text, normalized_text, confidence,
1693 source_event_id, provenance_snapshot_json, created_at,
1694 use_count, usefulness_score, last_useful_at
1695 )
1696 VALUES (?1, 'global_user', 'fact', ?2, ?3, 1.0, NULL, '{}',
1697 '2024-01-01T00:00:00Z', 5, 5.0, ?4)
1698 ",
1699 rusqlite::params![mid, text, normalized, last_useful],
1700 )
1701 .expect("insert memory");
1702 conn.execute(
1703 "INSERT INTO memories_fts (memory_id, text, kind, scope)
1704 VALUES (?1, ?2, 'fact', 'global_user')",
1705 rusqlite::params![mid, text],
1706 )
1707 .expect("insert fts");
1708 }
1709
1710 let mut weights = kimetsu_core::config::BrokerWeights::default();
1712 weights.decay_half_life_days = 0.0;
1713
1714 let bundle = retrieve_context_with_embedder(
1715 &conn,
1716 "/fake-repo",
1717 &weights,
1718 ContextRequest {
1719 stage: "localization".to_string(),
1720 query: "ripgrep search".to_string(),
1721 budget_tokens: 4000,
1722 ..Default::default()
1723 },
1724 &[],
1725 &embeddings::NoopEmbedder,
1726 )
1727 .expect("retrieve");
1728
1729 let scores: Vec<(String, f32)> = bundle
1735 .capsules
1736 .iter()
1737 .filter_map(|c| {
1738 c.expansion_handle
1739 .strip_prefix("memory:")
1740 .map(|id| (id.to_string(), c.score))
1741 })
1742 .collect();
1743 assert_eq!(scores.len(), 2, "both memories should surface");
1744 let recent_score = scores
1745 .iter()
1746 .find(|(id, _)| id == "m_recent")
1747 .map(|(_, s)| *s)
1748 .expect("m_recent present");
1749 let aged_score = scores
1750 .iter()
1751 .find(|(id, _)| id == "m_aged")
1752 .map(|(_, s)| *s)
1753 .expect("m_aged present");
1754 assert!(
1756 (recent_score - aged_score).abs() < 1e-4,
1757 "with decay disabled the two memories should tie on score: recent={recent_score} aged={aged_score}"
1758 );
1759 }
1760
1761 #[test]
1766 fn hybrid_retrieval_with_noop_embedder_is_lexical_only() {
1767 let conn = rusqlite::Connection::open_in_memory().expect("open in-memory");
1768 crate::schema::initialize(&conn).expect("init schema");
1769 let stub = embeddings::StubEmbedder::new();
1770 insert_memory_with_embedding(&conn, "m_a", "use ripgrep", &stub);
1772 insert_memory_with_embedding(&conn, "m_b", "use ripgrep too", &stub);
1773
1774 let weights = kimetsu_core::config::BrokerWeights::default();
1777 let bundle = retrieve_context_with_embedder(
1778 &conn,
1779 "/fake-repo",
1780 &weights,
1781 ContextRequest {
1782 stage: "localization".to_string(),
1783 query: "ripgrep".to_string(),
1784 budget_tokens: 4000,
1785 ..Default::default()
1786 },
1787 &[],
1788 &embeddings::NoopEmbedder,
1789 )
1790 .expect("retrieve");
1791
1792 let count = bundle
1793 .capsules
1794 .iter()
1795 .filter(|c| c.expansion_handle.starts_with("memory:"))
1796 .count();
1797 assert_eq!(count, 2, "both memories should surface via FTS");
1798 }
1799}