1use std::collections::HashMap;
30
31use kimetsu_core::KimetsuResult;
32use rusqlite::Connection;
33use time::OffsetDateTime;
34use time::format_description::well_known::Rfc3339;
35use ulid::Ulid;
36
37use crate::embeddings::decode_embedding;
38
39#[derive(Debug, Clone)]
45pub struct ConsolidateRow {
46 pub memory_id: String,
47 pub scope: String,
48 pub kind: String,
49 pub text: String,
50 pub use_count: i64,
51 pub usefulness_score: f32,
52 pub last_useful_at: Option<String>,
54 pub created_at: String,
55 pub embedding: Vec<f32>,
56 pub model_id: String,
57}
58
59#[derive(Debug, Clone)]
61pub struct MergeCluster {
62 pub survivor: ConsolidateRow,
63 pub members: Vec<ConsolidateRow>,
64}
65
66#[derive(Debug, Default)]
68pub struct ConsolidateSummary {
69 pub clusters_found: usize,
70 pub memories_merged: usize,
71 pub citations_reassigned: usize,
72}
73
74#[derive(Debug, Clone)]
76pub struct ConsolidateOptions {
77 pub threshold: f32,
79 pub dry_run: bool,
81}
82
83impl Default for ConsolidateOptions {
84 fn default() -> Self {
85 Self {
86 threshold: 0.92,
87 dry_run: false,
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct DistillOptions {
95 pub lo: f32,
97 pub hi: f32,
99 pub min_cluster_size: usize,
101}
102
103impl Default for DistillOptions {
104 fn default() -> Self {
105 Self {
106 lo: 0.75,
107 hi: 0.85,
108 min_cluster_size: 3,
109 }
110 }
111}
112
113#[derive(Debug, Clone)]
115pub struct DistillCluster {
116 pub shared_tags: Vec<String>,
117 pub memories: Vec<ConsolidateRow>,
118}
119
120pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
127 if a.len() != b.len() || a.is_empty() {
128 return 0.0;
129 }
130 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
131 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
132 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
133 if na < f32::EPSILON || nb < f32::EPSILON {
134 return 0.0;
135 }
136 (dot / (na * nb)).clamp(-1.0, 1.0)
137}
138
139pub fn parse_tags(text: &str) -> Vec<String> {
146 let lower = text.to_ascii_lowercase();
148 let Some(start) = lower.find("[tags:") else {
149 return Vec::new();
150 };
151 let after = &text[start + 6..]; let Some(end) = after.find(']') else {
153 return Vec::new();
154 };
155 let tag_str = &after[..end];
156 let mut tags: Vec<String> = tag_str
157 .split(',')
158 .map(|t| t.trim().to_ascii_lowercase())
159 .filter(|t| !t.is_empty())
160 .collect();
161 tags.sort();
162 tags.dedup();
163 tags
164}
165
166struct UnionFind {
171 parent: Vec<usize>,
172}
173
174impl UnionFind {
175 fn new(n: usize) -> Self {
176 Self {
177 parent: (0..n).collect(),
178 }
179 }
180
181 fn find(&mut self, x: usize) -> usize {
182 if self.parent[x] != x {
183 self.parent[x] = self.find(self.parent[x]); }
185 self.parent[x]
186 }
187
188 fn union(&mut self, x: usize, y: usize) {
189 let rx = self.find(x);
190 let ry = self.find(y);
191 if rx != ry {
192 self.parent[ry] = rx;
193 }
194 }
195}
196
197pub fn load_embeddable_rows(
204 conn: &Connection,
205) -> KimetsuResult<HashMap<String, Vec<ConsolidateRow>>> {
206 let mut stmt = conn.prepare(
207 "SELECT memory_id, scope, kind, text, use_count, usefulness_score,
208 last_useful_at, created_at, embedding, embedding_model
209 FROM memories
210 WHERE invalidated_at IS NULL
211 AND superseded_by IS NULL
212 AND embedding IS NOT NULL
213 AND embedding_model IS NOT NULL
214 ORDER BY created_at DESC",
215 )?;
216
217 let rows = stmt.query_map([], |row| {
218 Ok((
219 row.get::<_, String>(0)?,
220 row.get::<_, String>(1)?,
221 row.get::<_, String>(2)?,
222 row.get::<_, String>(3)?,
223 row.get::<_, i64>(4)?,
224 row.get::<_, f64>(5)?,
225 row.get::<_, Option<String>>(6)?,
226 row.get::<_, String>(7)?,
227 row.get::<_, Vec<u8>>(8)?,
228 row.get::<_, String>(9)?,
229 ))
230 })?;
231
232 let mut by_model: HashMap<String, Vec<ConsolidateRow>> = HashMap::new();
233 for row in rows {
234 let (
235 memory_id,
236 scope,
237 kind,
238 text,
239 use_count,
240 usefulness_score,
241 last_useful_at,
242 created_at,
243 blob,
244 model_id,
245 ) = row?;
246 let Ok(embedding) = decode_embedding(&blob, None) else {
248 continue;
249 };
250 if embedding.is_empty() {
251 continue;
252 }
253 by_model
254 .entry(model_id.clone())
255 .or_default()
256 .push(ConsolidateRow {
257 memory_id,
258 scope,
259 kind,
260 text,
261 use_count,
262 usefulness_score: usefulness_score as f32,
263 last_useful_at,
264 created_at,
265 embedding,
266 model_id,
267 });
268 }
269 Ok(by_model)
270}
271
272fn survivor_score(row: &ConsolidateRow, recency_rank: f32) -> f32 {
280 let usefulness = row.usefulness_score.max(0.0);
281 (usefulness + 1.0) * recency_rank
282}
283
284fn parse_ts(ts: &str) -> i64 {
286 OffsetDateTime::parse(ts, &Rfc3339)
287 .map(|t| t.unix_timestamp())
288 .unwrap_or(0)
289}
290
291fn pick_survivor(cluster: &[usize], rows: &[ConsolidateRow]) -> usize {
295 let mut indexed: Vec<usize> = cluster.to_vec();
297 indexed.sort_by(|&a, &b| {
298 let ta = parse_ts(
299 rows[a]
300 .last_useful_at
301 .as_deref()
302 .unwrap_or(&rows[a].created_at),
303 );
304 let tb = parse_ts(
305 rows[b]
306 .last_useful_at
307 .as_deref()
308 .unwrap_or(&rows[b].created_at),
309 );
310 tb.cmp(&ta)
311 });
312 let n = indexed.len() as f32;
313 let mut best_idx = indexed[0];
314 let mut best_score = f32::NEG_INFINITY;
315 for (rank, &i) in indexed.iter().enumerate() {
316 let recency = 1.0 - (rank as f32) / n.max(1.0);
317 let score = survivor_score(&rows[i], recency);
318 if score > best_score {
319 best_score = score;
320 best_idx = i;
321 }
322 }
323 best_idx
324}
325
326pub fn find_merge_clusters(rows: &[ConsolidateRow], threshold: f32) -> Vec<MergeCluster> {
333 let n = rows.len();
334 if n < 2 {
335 return Vec::new();
336 }
337
338 let mut uf = UnionFind::new(n);
339
340 for i in 0..n {
343 for j in (i + 1)..n {
344 if rows[i].model_id != rows[j].model_id {
346 continue;
347 }
348 let sim = cosine(&rows[i].embedding, &rows[j].embedding);
349 if sim >= threshold {
350 uf.union(i, j);
351 }
352 }
353 }
354
355 let mut root_to_members: HashMap<usize, Vec<usize>> = HashMap::new();
357 for i in 0..n {
358 let root = uf.find(i);
359 root_to_members.entry(root).or_default().push(i);
360 }
361
362 let mut clusters = Vec::new();
363 for (_, members) in root_to_members {
364 if members.len() < 2 {
365 continue; }
367 let survivor_idx = pick_survivor(&members, rows);
368 let survivor = rows[survivor_idx].clone();
369 let member_rows: Vec<ConsolidateRow> = members
370 .iter()
371 .filter(|&&i| i != survivor_idx)
372 .map(|&i| rows[i].clone())
373 .collect();
374 clusters.push(MergeCluster {
375 survivor,
376 members: member_rows,
377 });
378 }
379
380 clusters.sort_by(|a, b| a.survivor.memory_id.cmp(&b.survivor.memory_id));
382 clusters
383}
384
385pub fn apply_merge(
400 conn: &Connection,
401 cluster: &MergeCluster,
402 run_id: kimetsu_core::ids::RunId,
403) -> KimetsuResult<usize> {
404 for member in &cluster.members {
409 let event = kimetsu_core::event::Event::new(
410 run_id,
411 "memory.superseded",
412 serde_json::json!({
413 "memory_id": member.memory_id,
414 "survivor_id": cluster.survivor.memory_id,
415 "use_count_delta": member.use_count,
416 "score_delta": member.usefulness_score as f64,
417 }),
418 );
419 crate::projector::apply_events(conn, &[event])?;
420 }
421
422 Ok(cluster.members.len())
423}
424
425pub fn run_consolidation(
434 conn: &Connection,
435 opts: &ConsolidateOptions,
436 writer: &mut impl std::io::Write,
437) -> KimetsuResult<ConsolidateSummary> {
438 let by_model = load_embeddable_rows(conn)?;
439
440 let mut all_rows: Vec<ConsolidateRow> = by_model.into_values().flatten().collect();
441 all_rows.sort_by(|a, b| a.memory_id.cmp(&b.memory_id));
443
444 let clusters = find_merge_clusters(&all_rows, opts.threshold);
445
446 let mut summary = ConsolidateSummary {
447 clusters_found: clusters.len(),
448 ..Default::default()
449 };
450
451 if clusters.is_empty() {
452 writeln!(
453 writer,
454 "No near-duplicate clusters found (threshold={:.2}).",
455 opts.threshold
456 )?;
457 return Ok(summary);
458 }
459
460 if opts.dry_run {
461 writeln!(
462 writer,
463 "Dry-run: {} cluster(s) found (threshold={:.2}):",
464 clusters.len(),
465 opts.threshold
466 )?;
467 for (i, cluster) in clusters.iter().enumerate() {
468 writeln!(
469 writer,
470 "\nCluster {}: SURVIVOR → {} [score={:.2} uses={}]",
471 i + 1,
472 cluster.survivor.memory_id,
473 cluster.survivor.usefulness_score,
474 cluster.survivor.use_count
475 )?;
476 writeln!(writer, " Text: {}", truncate(&cluster.survivor.text, 80))?;
477 for m in &cluster.members {
478 writeln!(
479 writer,
480 " MEMBER → {} [score={:.2} uses={}]",
481 m.memory_id, m.usefulness_score, m.use_count
482 )?;
483 writeln!(writer, " Text: {}", truncate(&m.text, 80))?;
484 }
485 }
486 return Ok(summary);
487 }
488
489 let run_id = kimetsu_core::ids::RunId::new();
491 for cluster in &clusters {
492 match apply_merge(conn, cluster, run_id) {
493 Ok(merged) => {
494 summary.memories_merged += merged;
495 }
496 Err(e) => {
497 writeln!(
498 writer,
499 "warn: merge of cluster around {} failed: {e}",
500 cluster.survivor.memory_id
501 )?;
502 }
503 }
504 }
505
506 writeln!(
507 writer,
508 "Consolidated {} cluster(s): {} memor{} merged.",
509 summary.clusters_found,
510 summary.memories_merged,
511 if summary.memories_merged == 1 {
512 "y"
513 } else {
514 "ies"
515 }
516 )?;
517
518 Ok(summary)
519}
520
521pub fn find_distill_clusters(
528 rows: &[ConsolidateRow],
529 opts: &DistillOptions,
530) -> Vec<DistillCluster> {
531 let n = rows.len();
532 if n < opts.min_cluster_size {
533 return Vec::new();
534 }
535
536 let row_tags: Vec<Vec<String>> = rows.iter().map(|r| parse_tags(&r.text)).collect();
538
539 let mut uf = UnionFind::new(n);
540
541 for i in 0..n {
542 for j in (i + 1)..n {
543 if rows[i].model_id != rows[j].model_id {
544 continue;
545 }
546 let sim = cosine(&rows[i].embedding, &rows[j].embedding);
547 if sim < opts.lo || sim > opts.hi {
548 continue;
549 }
550 let shared = row_tags[i].iter().any(|t| row_tags[j].contains(t));
552 if shared {
553 uf.union(i, j);
554 }
555 }
556 }
557
558 let mut root_to_members: HashMap<usize, Vec<usize>> = HashMap::new();
560 for i in 0..n {
561 let root = uf.find(i);
562 root_to_members.entry(root).or_default().push(i);
563 }
564
565 let mut clusters = Vec::new();
566 for (_, members) in root_to_members {
567 if members.len() < opts.min_cluster_size {
568 continue;
569 }
570 let mut shared_tags: Vec<String> = row_tags[members[0]].clone();
572 for &i in &members[1..] {
573 shared_tags.retain(|t| row_tags[i].contains(t));
574 }
575 if shared_tags.is_empty() {
576 continue; }
578 let memories: Vec<ConsolidateRow> = members.iter().map(|&i| rows[i].clone()).collect();
579 clusters.push(DistillCluster {
580 shared_tags,
581 memories,
582 });
583 }
584
585 clusters.sort_by(|a, b| a.shared_tags.cmp(&b.shared_tags));
586 clusters
587}
588
589fn truncate(s: &str, max: usize) -> String {
594 let chars: Vec<char> = s.chars().collect();
595 if chars.len() <= max {
596 s.to_string()
597 } else {
598 format!("{}…", chars[..max].iter().collect::<String>())
599 }
600}
601
602#[derive(Debug, Clone, Default)]
608pub struct ReflectionOptions {
609 pub distill_opts: DistillOptions,
611 pub dry_run: bool,
613}
614
615#[derive(Debug, Default)]
617pub struct ReflectionSummary {
618 pub clusters_found: usize,
619 pub proposals_created: usize,
620}
621
622pub trait ModelProvider {
626 fn complete_text(&mut self, prompt: &str) -> Option<String>;
627}
628
629const REFLECTION_SYSTEM: &str = "You are a memory synthesizer. Given these related lessons/memories, \
631synthesize ONE higher-order principle that generalizes them (2-4 sentences, \
632imperative, actionable). Reply with ONLY a JSON object: \
633{\"principle\": \"...\", \"tags\": [\"tag1\", \"tag2\"], \"confidence\": 0.0-1.0}";
634
635pub fn run_reflection(
646 conn: &Connection,
647 opts: &ReflectionOptions,
648 model: Option<&mut dyn ModelProvider>,
649 writer: &mut impl std::io::Write,
650) -> KimetsuResult<ReflectionSummary> {
651 let by_model = load_embeddable_rows(conn)?;
652 let mut all_rows: Vec<ConsolidateRow> = by_model.into_values().flatten().collect();
653 all_rows.sort_by(|a, b| a.memory_id.cmp(&b.memory_id));
654
655 let clusters = find_distill_clusters(&all_rows, &opts.distill_opts);
656
657 let mut summary = ReflectionSummary {
658 clusters_found: clusters.len(),
659 ..Default::default()
660 };
661
662 if clusters.is_empty() {
663 writeln!(writer, "No reflection clusters found.")?;
664 return Ok(summary);
665 }
666
667 if opts.dry_run || model.is_none() {
669 writeln!(writer, "{} reflection cluster(s) found:", clusters.len())?;
670 for (i, cluster) in clusters.iter().enumerate() {
671 writeln!(
672 writer,
673 "\nCluster {} [tags: {}]:",
674 i + 1,
675 cluster.shared_tags.join(", ")
676 )?;
677 for row in &cluster.memories {
678 writeln!(writer, " • {}", truncate(&row.text, 80))?;
679 }
680 writeln!(
681 writer,
682 " → These {} memories could be reflected into a principle.",
683 cluster.memories.len()
684 )?;
685 }
686 return Ok(summary);
687 }
688
689 let model = model.unwrap(); let run_id = kimetsu_core::ids::RunId::new();
691
692 for cluster in &clusters {
693 let memory_texts: Vec<String> = cluster
695 .memories
696 .iter()
697 .map(|r| format!("- {}", r.text))
698 .collect();
699 let user_msg = memory_texts.join("\n");
700 let prompt = format!("{REFLECTION_SYSTEM}\n\nMemories:\n{user_msg}");
701
702 let Some(response_text) = model.complete_text(&prompt) else {
703 writeln!(
704 writer,
705 "warn: model call failed for cluster [{}]",
706 cluster.shared_tags.join(", ")
707 )?;
708 continue;
709 };
710
711 let Some(principle_json) = parse_reflection_json(&response_text) else {
713 writeln!(
714 writer,
715 "warn: could not parse reflection JSON for cluster [{}]: {response_text}",
716 cluster.shared_tags.join(", ")
717 )?;
718 continue;
719 };
720
721 let principle = principle_json
722 .get("principle")
723 .and_then(|v| v.as_str())
724 .unwrap_or("")
725 .trim()
726 .to_string();
727 if principle.is_empty() {
728 continue;
729 }
730 let tags = principle_json
731 .get("tags")
732 .and_then(|v| v.as_array())
733 .map(|a| {
734 a.iter()
735 .filter_map(|s| s.as_str())
736 .map(|s| s.to_string())
737 .collect::<Vec<_>>()
738 })
739 .unwrap_or_default();
740 let confidence = principle_json
741 .get("confidence")
742 .and_then(|v| v.as_f64())
743 .unwrap_or(0.7)
744 .clamp(0.0, 1.0);
745
746 let proposal_id = Ulid::new().to_string();
747 let source_ids: Vec<&str> = cluster
748 .memories
749 .iter()
750 .map(|r| r.memory_id.as_str())
751 .collect();
752
753 let event = kimetsu_core::event::Event::new(
754 run_id,
755 "memory.proposed",
756 serde_json::json!({
757 "proposal_id": proposal_id,
758 "scope": "project",
759 "kind": "fact",
760 "text": principle,
761 "tags": tags,
762 "rationale": format!(
763 "Reflection synthesis from {} related memories [tags: {}]",
764 cluster.memories.len(),
765 cluster.shared_tags.join(", ")
766 ),
767 "proposed_confidence": confidence,
768 "source_event_ids": source_ids,
769 }),
770 );
771
772 match crate::projector::apply_events(conn, &[event]) {
773 Ok(()) => {
774 summary.proposals_created += 1;
775 writeln!(writer, "Proposed: {principle}")?;
776 }
777 Err(e) => {
778 writeln!(writer, "warn: failed to store reflection proposal: {e}")?;
779 }
780 }
781 }
782
783 Ok(summary)
784}
785
786fn parse_reflection_json(text: &str) -> Option<serde_json::Value> {
789 let start = text.find('{')?;
790 let bytes = text.as_bytes();
791 let mut depth = 0i32;
792 let mut in_string = false;
793 let mut escaped = false;
794 let mut end = None;
795 for (i, &b) in bytes.iter().enumerate().skip(start) {
796 if in_string {
797 if escaped {
798 escaped = false;
799 } else if b == b'\\' {
800 escaped = true;
801 } else if b == b'"' {
802 in_string = false;
803 }
804 } else {
805 match b {
806 b'"' => in_string = true,
807 b'{' => depth += 1,
808 b'}' => {
809 depth -= 1;
810 if depth == 0 {
811 end = Some(i);
812 break;
813 }
814 }
815 _ => {}
816 }
817 }
818 }
819 let json_str = &text[start..=end?];
820 serde_json::from_str(json_str).ok()
821}
822
823#[cfg(test)]
828mod tests {
829 use super::*;
830 use rusqlite::params;
831
832 #[test]
836 fn cosine_same_vector_is_one() {
837 let v = vec![1.0f32, 0.5, -0.3];
838 assert!((cosine(&v, &v) - 1.0).abs() < 1e-5);
839 }
840
841 #[test]
842 fn cosine_orthogonal_is_zero() {
843 assert!((cosine(&[1.0f32, 0.0], &[0.0f32, 1.0]) - 0.0).abs() < 1e-5);
844 }
845
846 #[test]
847 fn cosine_opposite_is_minus_one() {
848 assert!((cosine(&[1.0f32, 0.0], &[-1.0f32, 0.0]) + 1.0).abs() < 1e-5);
849 }
850
851 #[test]
852 fn cosine_empty_returns_zero() {
853 assert_eq!(cosine(&[], &[]), 0.0);
854 }
855
856 #[test]
857 fn cosine_dim_mismatch_returns_zero() {
858 assert_eq!(cosine(&[1.0f32], &[1.0f32, 2.0]), 0.0);
859 }
860
861 #[test]
865 fn parse_tags_extracts_tags() {
866 let text = "Always use cargo fmt [tags: rust, tooling, ci]";
867 let tags = parse_tags(text);
868 assert_eq!(tags, vec!["ci", "rust", "tooling"]);
869 }
870
871 #[test]
872 fn parse_tags_no_block_returns_empty() {
873 assert!(parse_tags("no tags here").is_empty());
874 }
875
876 #[test]
877 fn parse_tags_case_insensitive_key() {
878 let text = "Something [TAGS: Rust, CI]";
879 let tags = parse_tags(text);
880 assert!(tags.contains(&"rust".to_string()));
881 assert!(tags.contains(&"ci".to_string()));
882 }
883
884 #[test]
885 fn parse_tags_deduplicates() {
886 let text = "text [tags: a, b, a]";
887 let tags = parse_tags(text);
888 assert_eq!(tags.iter().filter(|t| *t == "a").count(), 1);
889 }
890
891 fn make_row(id: &str, vec: Vec<f32>) -> ConsolidateRow {
896 ConsolidateRow {
897 memory_id: id.to_string(),
898 scope: "project".to_string(),
899 kind: "fact".to_string(),
900 text: format!("text {id}"),
901 use_count: 1,
902 usefulness_score: 1.0,
903 last_useful_at: None,
904 created_at: "2026-01-01T00:00:00Z".to_string(),
905 embedding: vec,
906 model_id: "stub".to_string(),
907 }
908 }
909
910 #[test]
911 fn find_merge_clusters_identical_vectors_cluster() {
912 let v = vec![1.0f32, 0.0, 0.0];
913 let rows = vec![
914 make_row("a", v.clone()),
915 make_row("b", v.clone()),
916 make_row("c", v.clone()),
917 ];
918 let clusters = find_merge_clusters(&rows, 0.92);
919 assert_eq!(clusters.len(), 1, "one cluster of identical vectors");
920 assert_eq!(
921 clusters[0].members.len(),
922 2,
923 "two members (one is survivor)"
924 );
925 }
926
927 #[test]
928 fn find_merge_clusters_orthogonal_no_clusters() {
929 let rows = vec![
930 make_row("a", vec![1.0f32, 0.0]),
931 make_row("b", vec![0.0f32, 1.0]),
932 ];
933 let clusters = find_merge_clusters(&rows, 0.92);
934 assert!(clusters.is_empty(), "orthogonal vectors do not cluster");
935 }
936
937 #[test]
938 fn find_merge_clusters_different_models_do_not_cluster() {
939 let v = vec![1.0f32, 0.0];
940 let mut r1 = make_row("a", v.clone());
941 r1.model_id = "model-a".to_string();
942 let mut r2 = make_row("b", v.clone());
943 r2.model_id = "model-b".to_string();
944 let clusters = find_merge_clusters(&[r1, r2], 0.92);
945 assert!(clusters.is_empty(), "different models must not cluster");
946 }
947
948 #[test]
949 fn survivor_is_highest_usefulness_score() {
950 let v = vec![1.0f32, 0.0, 0.0];
951 let mut high = make_row("high", v.clone());
952 high.usefulness_score = 10.0;
953 high.use_count = 5;
954 let mut low = make_row("low", v.clone());
955 low.usefulness_score = 0.1;
956 low.use_count = 1;
957 let clusters = find_merge_clusters(&[low, high], 0.92);
958 assert_eq!(clusters.len(), 1);
959 assert_eq!(clusters[0].survivor.memory_id, "high");
960 assert_eq!(clusters[0].members[0].memory_id, "low");
961 }
962
963 #[test]
967 fn find_distill_clusters_requires_shared_tags() {
968 let v1 = vec![1.0f32, 0.5, 0.0];
970 let v2 = vec![1.0f32, 0.4, 0.1];
971 let mut r1 = make_row("a", v1);
972 r1.text = "first memory [tags: rust]".to_string();
973 let mut r2 = make_row("b", v2);
974 r2.text = "second memory [tags: python]".to_string();
975 let mut r3 = make_row("c", vec![1.0f32, 0.4, 0.05]);
976 r3.text = "third memory [tags: go]".to_string();
977 let opts = DistillOptions {
978 lo: 0.7,
979 hi: 0.99,
980 min_cluster_size: 2,
981 };
982 let clusters = find_distill_clusters(&[r1, r2, r3], &opts);
983 assert!(clusters.is_empty(), "no shared tags → no distill cluster");
984 }
985
986 #[test]
987 fn find_distill_clusters_shared_tag_and_band_clusters() {
988 let v = vec![1.0f32, 0.5, 0.1];
990 let make = |id: &str, extra: f32| {
991 let mut r = make_row(id, vec![1.0 + extra, 0.5, 0.1]);
992 r.text = format!("memory {id} [tags: rust, ci]");
993 r
994 };
995 let rows = vec![make("a", 0.0), make("b", 0.001), make("c", 0.002)];
996 let _ = v; let opts = DistillOptions {
998 lo: 0.0,
999 hi: 1.0,
1000 min_cluster_size: 3,
1001 };
1002 let clusters = find_distill_clusters(&rows, &opts);
1003 assert!(!clusters.is_empty(), "shared tag + band → distill cluster");
1004 assert!(
1005 clusters[0].shared_tags.contains(&"ci".to_string()),
1006 "shared_tags contains 'ci'"
1007 );
1008 }
1009
1010 #[test]
1014 fn apply_merge_supersedes_members_and_updates_survivor_stats() {
1015 use kimetsu_core::ids::RunId;
1016
1017 let conn = rusqlite::Connection::open_in_memory().expect("open");
1018 crate::schema::initialize(&conn).expect("init");
1019
1020 for (id, use_count, score) in [("survivor", 3i64, 5.0f64), ("member", 2i64, 2.0f64)] {
1022 conn.execute(
1023 "INSERT INTO memories
1024 (memory_id, scope, kind, text, normalized_text, confidence,
1025 provenance_snapshot_json, created_at, use_count, usefulness_score)
1026 VALUES (?1,'project','fact',?2,?2,0.9,'{}','2026-01-01T00:00:00Z',?3,?4)",
1027 params![id, format!("text {id}"), use_count, score],
1028 )
1029 .expect("insert");
1030 }
1031
1032 let survivor = ConsolidateRow {
1033 memory_id: "survivor".to_string(),
1034 scope: "project".to_string(),
1035 kind: "fact".to_string(),
1036 text: "text survivor".to_string(),
1037 use_count: 3,
1038 usefulness_score: 5.0,
1039 last_useful_at: None,
1040 created_at: "2026-01-01T00:00:00Z".to_string(),
1041 embedding: vec![1.0, 0.0],
1042 model_id: "stub".to_string(),
1043 };
1044 let member = ConsolidateRow {
1045 memory_id: "member".to_string(),
1046 scope: "project".to_string(),
1047 kind: "fact".to_string(),
1048 text: "text member".to_string(),
1049 use_count: 2,
1050 usefulness_score: 2.0,
1051 last_useful_at: None,
1052 created_at: "2026-01-01T00:00:00Z".to_string(),
1053 embedding: vec![1.0, 0.0],
1054 model_id: "stub".to_string(),
1055 };
1056 let cluster = MergeCluster {
1057 survivor,
1058 members: vec![member],
1059 };
1060
1061 let run_id = RunId::new();
1062 let merged = apply_merge(&conn, &cluster, run_id).expect("apply_merge");
1063 assert_eq!(merged, 1);
1064
1065 let (use_count, score): (i64, f64) = conn
1067 .query_row(
1068 "SELECT use_count, usefulness_score FROM memories WHERE memory_id = 'survivor'",
1069 [],
1070 |r| Ok((r.get(0)?, r.get(1)?)),
1071 )
1072 .expect("query survivor");
1073 assert_eq!(use_count, 5, "use_count = 3 + 2");
1074 assert!((score - 7.0).abs() < 0.01, "score = 5.0 + 2.0, got {score}");
1075
1076 let superseded_by: Option<String> = conn
1078 .query_row(
1079 "SELECT superseded_by FROM memories WHERE memory_id = 'member'",
1080 [],
1081 |r| r.get(0),
1082 )
1083 .expect("query member");
1084 assert_eq!(superseded_by.as_deref(), Some("survivor"));
1085 }
1086
1087 #[test]
1088 fn citations_reassigned_on_merge() {
1089 use kimetsu_core::ids::RunId;
1090
1091 let conn = rusqlite::Connection::open_in_memory().expect("open");
1092 crate::schema::initialize(&conn).expect("init");
1093
1094 for id in ["survivor", "member"] {
1096 conn.execute(
1097 "INSERT INTO memories
1098 (memory_id, scope, kind, text, normalized_text, confidence,
1099 provenance_snapshot_json, created_at, use_count, usefulness_score)
1100 VALUES (?1,'project','fact',?2,?2,0.9,'{}','2026-01-01T00:00:00Z',1,1.0)",
1101 params![id, format!("text {id}")],
1102 )
1103 .expect("insert memory");
1104 }
1105
1106 conn.execute(
1108 "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
1109 VALUES ('run-1', 'member', 1, '2026-01-01T00:00:00Z')",
1110 [],
1111 )
1112 .expect("insert citation");
1113
1114 let cluster = MergeCluster {
1115 survivor: ConsolidateRow {
1116 memory_id: "survivor".to_string(),
1117 scope: "project".to_string(),
1118 kind: "fact".to_string(),
1119 text: "text survivor".to_string(),
1120 use_count: 1,
1121 usefulness_score: 1.0,
1122 last_useful_at: None,
1123 created_at: "2026-01-01T00:00:00Z".to_string(),
1124 embedding: vec![1.0, 0.0],
1125 model_id: "stub".to_string(),
1126 },
1127 members: vec![ConsolidateRow {
1128 memory_id: "member".to_string(),
1129 scope: "project".to_string(),
1130 kind: "fact".to_string(),
1131 text: "text member".to_string(),
1132 use_count: 1,
1133 usefulness_score: 1.0,
1134 last_useful_at: None,
1135 created_at: "2026-01-01T00:00:00Z".to_string(),
1136 embedding: vec![1.0, 0.0],
1137 model_id: "stub".to_string(),
1138 }],
1139 };
1140
1141 apply_merge(&conn, &cluster, RunId::new()).expect("apply_merge");
1142
1143 let mid: String = conn
1145 .query_row(
1146 "SELECT memory_id FROM memory_citations WHERE run_id = 'run-1' AND turn = 1",
1147 [],
1148 |r| r.get(0),
1149 )
1150 .expect("query citation");
1151 assert_eq!(mid, "survivor", "citation reassigned to survivor");
1152
1153 let member_count: i64 = conn
1155 .query_row(
1156 "SELECT COUNT(*) FROM memory_citations WHERE memory_id = 'member'",
1157 [],
1158 |r| r.get(0),
1159 )
1160 .expect("count member citations");
1161 assert_eq!(member_count, 0, "member citations deleted");
1162 }
1163
1164 #[test]
1168 fn superseded_row_excluded_from_latest_memory_candidates() {
1169 use crate::context::retrieve_context_with_embedder;
1170 use crate::embeddings::NoopEmbedder;
1171 use kimetsu_core::config::BrokerWeights;
1172
1173 let conn = rusqlite::Connection::open_in_memory().expect("open");
1174 crate::schema::initialize(&conn).expect("init");
1175
1176 conn.execute(
1178 "INSERT INTO memories
1179 (memory_id, scope, kind, text, normalized_text, confidence,
1180 provenance_snapshot_json, created_at, use_count, usefulness_score)
1181 VALUES ('surv','project','fact','rust tooling','rust tooling',0.9,'{}',
1182 '2026-01-01T00:00:00Z',1,1.0)",
1183 [],
1184 )
1185 .expect("insert survivor");
1186 conn.execute(
1187 "INSERT INTO memories
1188 (memory_id, scope, kind, text, normalized_text, confidence,
1189 provenance_snapshot_json, created_at, use_count, usefulness_score,
1190 superseded_by)
1191 VALUES ('dup','project','fact','rust tooling dup','rust tooling dup',0.9,'{}',
1192 '2026-01-01T00:00:00Z',1,1.0,'surv')",
1193 [],
1194 )
1195 .expect("insert superseded");
1196
1197 conn.execute(
1199 "INSERT INTO memories_fts (memory_id, text, kind, scope)
1200 VALUES ('surv', 'rust tooling', 'fact', 'project')",
1201 [],
1202 )
1203 .expect("insert fts");
1204
1205 let weights = BrokerWeights::default();
1206 let req = crate::context::ContextRequest {
1207 stage: "test".to_string(),
1208 query: "rust tooling".to_string(),
1209 budget_tokens: 4096,
1210 ..Default::default()
1211 };
1212 let embedder = NoopEmbedder;
1213 let bundle = retrieve_context_with_embedder(&conn, "", &weights, req, &[], &embedder)
1214 .expect("retrieve");
1215
1216 let ids: Vec<&str> = bundle
1217 .capsules
1218 .iter()
1219 .chain(bundle.excluded.iter())
1220 .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
1221 .collect();
1222 assert!(
1223 !ids.contains(&"dup"),
1224 "superseded memory must not appear in retrieval"
1225 );
1226 }
1227
1228 #[test]
1235 fn v2_brain_migrates_to_v3_with_backup_and_superseded_by_column() {
1236 use crate::migrate;
1237 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
1238
1239 let tmp_id = std::time::SystemTime::now()
1240 .duration_since(std::time::UNIX_EPOCH)
1241 .map(|d| d.as_nanos())
1242 .unwrap_or(0);
1243 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-v3mig-{tmp_id}"));
1244 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1245
1246 let db_path = tmp_dir.join("brain.db");
1247 {
1248 let conn = rusqlite::Connection::open(&db_path).expect("open");
1250 crate::schema::create_baseline_for_test(&conn).expect("baseline");
1251 crate::schema::migrate_v1_to_v2(&conn).expect("v1→v2");
1252 conn.execute(
1253 "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
1254 [],
1255 )
1256 .expect("stamp v2");
1257 conn.execute(
1258 "INSERT INTO memories
1259 (memory_id, scope, kind, text, normalized_text, confidence,
1260 provenance_snapshot_json, created_at, use_count, usefulness_score)
1261 VALUES ('m1','project','fact','hello','hello',0.9,'{}','2026-01-01T00:00:00Z',0,0.0)",
1262 [],
1263 ).expect("insert memory");
1264 }
1265
1266 {
1268 let conn = rusqlite::Connection::open(&db_path).expect("reopen");
1269 let outcome = migrate::run_migrations(&conn).expect("run_migrations");
1270 assert_eq!(outcome.from, 2);
1271 assert_eq!(outcome.to, KIMETSU_SCHEMA_VERSION);
1272 assert!(
1274 outcome.applied.contains(&3),
1275 "v3 must be in applied list, got: {:?}",
1276 outcome.applied
1277 );
1278 assert!(
1280 outcome.backup_path.is_some(),
1281 "backup must be created for non-empty brain during migration"
1282 );
1283 let has_superseded_by: bool = conn.query_row(
1285 "SELECT COUNT(*) FROM pragma_table_info('memories') WHERE name = 'superseded_by'",
1286 [],
1287 |r| r.get::<_, i64>(0),
1288 ).map(|n| n > 0).unwrap_or(false);
1289 assert!(
1290 has_superseded_by,
1291 "superseded_by column must exist after v3 migration"
1292 );
1293 let has_edges: bool = conn
1295 .query_row(
1296 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_edges'",
1297 [],
1298 |r| r.get::<_, i64>(0),
1299 )
1300 .map(|n| n > 0)
1301 .unwrap_or(false);
1302 assert!(
1303 has_edges,
1304 "memory_edges table must exist after v4 migration"
1305 );
1306 }
1307
1308 let _ = std::fs::remove_dir_all(&tmp_dir);
1309 }
1310
1311 #[test]
1325 fn consolidation_is_rebuild_safe() {
1326 use crate::projector;
1327 use kimetsu_core::ids::RunId;
1328
1329 let conn = rusqlite::Connection::open_in_memory().expect("open");
1330 crate::schema::initialize(&conn).expect("init");
1331
1332 let run_id = RunId::new();
1333
1334 projector::apply_events(
1336 &conn,
1337 &[kimetsu_core::event::Event::new(
1338 run_id,
1339 "run.started",
1340 serde_json::json!({"project_id": "test", "task": "rebuild-safety"}),
1341 )],
1342 )
1343 .expect("run.started");
1344
1345 for (mid, text) in [("survivor", "text survivor"), ("member", "text member")] {
1346 projector::apply_events(
1347 &conn,
1348 &[kimetsu_core::event::Event::new(
1349 run_id,
1350 "memory.accepted",
1351 serde_json::json!({
1352 "memory_id": mid,
1353 "scope": "project",
1354 "kind": "fact",
1355 "text": text,
1356 "normalized_text": text,
1357 "confidence": 0.9
1358 }),
1359 )],
1360 )
1361 .expect("accepted");
1362 }
1363
1364 conn.execute(
1368 "UPDATE memories SET use_count = 3, usefulness_score = 5.0 \
1369 WHERE memory_id = 'survivor'",
1370 [],
1371 )
1372 .expect("seed survivor stats");
1373 conn.execute(
1374 "UPDATE memories SET use_count = 2, usefulness_score = 2.0 \
1375 WHERE memory_id = 'member'",
1376 [],
1377 )
1378 .expect("seed member stats");
1379
1380 projector::apply_events(
1383 &conn,
1384 &[kimetsu_core::event::Event::new(
1385 run_id,
1386 "memory.cited",
1387 serde_json::json!({
1388 "memory_id": "member",
1389 "turn": 1,
1390 "rationale": "test citation"
1391 }),
1392 )],
1393 )
1394 .expect("memory.cited");
1395
1396 let cluster = MergeCluster {
1398 survivor: ConsolidateRow {
1399 memory_id: "survivor".to_string(),
1400 scope: "project".to_string(),
1401 kind: "fact".to_string(),
1402 text: "text survivor".to_string(),
1403 use_count: 3,
1404 usefulness_score: 5.0,
1405 last_useful_at: None,
1406 created_at: "2026-01-01T00:00:00Z".to_string(),
1407 embedding: vec![1.0, 0.0],
1408 model_id: "stub".to_string(),
1409 },
1410 members: vec![ConsolidateRow {
1411 memory_id: "member".to_string(),
1412 scope: "project".to_string(),
1413 kind: "fact".to_string(),
1414 text: "text member".to_string(),
1415 use_count: 2,
1416 usefulness_score: 2.0,
1417 last_useful_at: None,
1418 created_at: "2026-01-01T00:00:00Z".to_string(),
1419 embedding: vec![1.0, 0.0],
1420 model_id: "stub".to_string(),
1421 }],
1422 };
1423 apply_merge(&conn, &cluster, RunId::new()).expect("apply_merge");
1424
1425 let (pre_uc, pre_score): (i64, f64) = conn
1436 .query_row(
1437 "SELECT use_count, usefulness_score FROM memories \
1438 WHERE memory_id = 'survivor'",
1439 [],
1440 |r| Ok((r.get(0)?, r.get(1)?)),
1441 )
1442 .expect("query survivor after consolidation");
1443 let pre_cited: String = conn
1444 .query_row(
1445 "SELECT memory_id FROM memory_citations WHERE turn = 1",
1446 [],
1447 |r| r.get(0),
1448 )
1449 .expect("citation must exist post-consolidation");
1450 assert_eq!(
1451 pre_cited, "survivor",
1452 "pre-rebuild: citation must point at survivor"
1453 );
1454
1455 projector::rebuild_in_place(&conn).expect("rebuild_in_place");
1457
1458 let (post_uc, post_score): (i64, f64) = conn
1460 .query_row(
1461 "SELECT use_count, usefulness_score FROM memories \
1462 WHERE memory_id = 'survivor'",
1463 [],
1464 |r| Ok((r.get(0)?, r.get(1)?)),
1465 )
1466 .expect("query survivor after rebuild");
1467
1468 assert_eq!(
1475 post_uc, 2,
1476 "post-rebuild: survivor use_count must contain member delta 2 (got {post_uc})"
1477 );
1478 assert!(
1479 (post_score - 2.0).abs() < 0.01,
1480 "post-rebuild: survivor score must contain member delta 2.0 (got {post_score})"
1481 );
1482
1483 let post_cited: String = conn
1484 .query_row(
1485 "SELECT memory_id FROM memory_citations WHERE turn = 1",
1486 [],
1487 |r| r.get(0),
1488 )
1489 .expect("citation must still exist after rebuild");
1490 assert_eq!(
1491 post_cited, "survivor",
1492 "post-rebuild: citation must still point at survivor (got {post_cited:?})"
1493 );
1494
1495 assert!(
1498 pre_uc >= 2,
1499 "pre-rebuild: survivor use_count must include member delta ≥2 (got {pre_uc})"
1500 );
1501 assert!(
1502 pre_score >= 2.0,
1503 "pre-rebuild: survivor score must include member delta ≥2.0 (got {pre_score})"
1504 );
1505 }
1506
1507 struct MockReflector {
1513 response: Option<String>,
1514 }
1515 impl ModelProvider for MockReflector {
1516 fn complete_text(&mut self, _prompt: &str) -> Option<String> {
1517 self.response.take()
1518 }
1519 }
1520
1521 fn insert_reflectable(conn: &rusqlite::Connection, id: &str, text: &str) {
1524 use crate::embeddings::{Embedder, StubEmbedder, encode_embedding};
1525 let stub = StubEmbedder::new();
1526 let vec = stub.embed(text).expect("embed");
1527 let blob = encode_embedding(&vec);
1528 conn.execute(
1529 "INSERT INTO memories (
1530 memory_id, scope, kind, text, normalized_text, confidence,
1531 source_event_id, provenance_snapshot_json, created_at,
1532 use_count, usefulness_score, embedding, embedding_model
1533 ) VALUES (?1, 'project', 'fact', ?2, ?2, 1.0, NULL, '{}',
1534 '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
1535 params![id, text, blob, stub.model_id()],
1536 )
1537 .expect("insert reflectable");
1538 }
1539
1540 #[test]
1544 fn run_reflection_creates_proposal_from_cluster() {
1545 let conn = rusqlite::Connection::open_in_memory().expect("open");
1546 crate::schema::initialize(&conn).expect("init");
1547
1548 insert_reflectable(
1550 &conn,
1551 "a",
1552 "always run cargo fmt before commit [tags: rust, ci]",
1553 );
1554 insert_reflectable(
1555 &conn,
1556 "b",
1557 "always run cargo fmt before push [tags: rust, ci]",
1558 );
1559 insert_reflectable(&conn, "c", "always run cargo fmt on save [tags: rust, ci]");
1560
1561 let mut model = MockReflector {
1562 response: Some(
1563 r#"{"principle": "Always format Rust code with cargo fmt before sharing.", "tags": ["rust", "ci"], "confidence": 0.85}"#
1564 .to_string(),
1565 ),
1566 };
1567 let opts = ReflectionOptions {
1568 distill_opts: DistillOptions {
1569 lo: 0.0,
1570 hi: 1.0,
1571 min_cluster_size: 3,
1572 },
1573 dry_run: false,
1574 };
1575 let mut out: Vec<u8> = Vec::new();
1576 let summary =
1577 run_reflection(&conn, &opts, Some(&mut model), &mut out).expect("run_reflection");
1578
1579 assert!(
1580 summary.clusters_found >= 1,
1581 "must find at least one cluster"
1582 );
1583 assert_eq!(
1584 summary.proposals_created, 1,
1585 "model-backed reflection must create exactly one proposal"
1586 );
1587
1588 let (text, status): (String, String) = conn
1590 .query_row(
1591 "SELECT text, status FROM memory_proposals LIMIT 1",
1592 [],
1593 |r| Ok((r.get(0)?, r.get(1)?)),
1594 )
1595 .expect("proposal row exists");
1596 assert!(text.contains("cargo fmt"), "proposal carries the principle");
1597 assert_eq!(
1598 status, "pending",
1599 "reflection proposal must be pending review"
1600 );
1601 }
1602
1603 #[test]
1606 fn run_reflection_without_model_reports_only() {
1607 let conn = rusqlite::Connection::open_in_memory().expect("open");
1608 crate::schema::initialize(&conn).expect("init");
1609
1610 insert_reflectable(
1611 &conn,
1612 "a",
1613 "prefer thiserror in libraries [tags: rust, errors]",
1614 );
1615 insert_reflectable(
1616 &conn,
1617 "b",
1618 "prefer thiserror for library crates [tags: rust, errors]",
1619 );
1620 insert_reflectable(
1621 &conn,
1622 "c",
1623 "use thiserror not anyhow in libs [tags: rust, errors]",
1624 );
1625
1626 let opts = ReflectionOptions {
1627 distill_opts: DistillOptions {
1628 lo: 0.0,
1629 hi: 1.0,
1630 min_cluster_size: 3,
1631 },
1632 dry_run: false,
1633 };
1634 let mut out: Vec<u8> = Vec::new();
1635 let summary = run_reflection(&conn, &opts, None, &mut out).expect("run_reflection");
1636
1637 assert!(summary.clusters_found >= 1);
1638 assert_eq!(
1639 summary.proposals_created, 0,
1640 "no model → no proposals (graceful degradation)"
1641 );
1642 let report = String::from_utf8(out).unwrap();
1643 assert!(
1644 report.contains("could be reflected"),
1645 "report must describe reflectable clusters, got: {report}"
1646 );
1647 let proposal_count: i64 = conn
1648 .query_row("SELECT COUNT(*) FROM memory_proposals", [], |r| r.get(0))
1649 .unwrap();
1650 assert_eq!(proposal_count, 0, "no proposals written without a model");
1651 }
1652}