1use std::collections::HashMap;
23
24use kimetsu_core::KimetsuResult;
25use rusqlite::Connection;
26use time::OffsetDateTime;
27use time::format_description::well_known::Rfc3339;
28use ulid::Ulid;
29
30use crate::embeddings::decode_embedding;
31
32#[derive(Debug, Clone)]
38pub struct ConsolidateRow {
39 pub memory_id: String,
40 pub scope: String,
41 pub kind: String,
42 pub text: String,
43 pub use_count: i64,
44 pub usefulness_score: f32,
45 pub last_useful_at: Option<String>,
47 pub created_at: String,
48 pub embedding: Vec<f32>,
49 pub model_id: String,
50}
51
52#[derive(Debug, Clone)]
54pub struct MergeCluster {
55 pub survivor: ConsolidateRow,
56 pub members: Vec<ConsolidateRow>,
57}
58
59#[derive(Debug, Default)]
61pub struct ConsolidateSummary {
62 pub clusters_found: usize,
63 pub memories_merged: usize,
64 pub citations_reassigned: usize,
65}
66
67#[derive(Debug, Clone)]
69pub struct ConsolidateOptions {
70 pub threshold: f32,
72 pub dry_run: bool,
74}
75
76impl Default for ConsolidateOptions {
77 fn default() -> Self {
78 Self {
79 threshold: 0.92,
80 dry_run: false,
81 }
82 }
83}
84
85#[derive(Debug, Clone)]
87pub struct DistillOptions {
88 pub lo: f32,
90 pub hi: f32,
92 pub min_cluster_size: usize,
94}
95
96impl Default for DistillOptions {
97 fn default() -> Self {
98 Self {
99 lo: 0.75,
100 hi: 0.85,
101 min_cluster_size: 3,
102 }
103 }
104}
105
106#[derive(Debug, Clone)]
108pub struct DistillCluster {
109 pub shared_tags: Vec<String>,
110 pub memories: Vec<ConsolidateRow>,
111}
112
113pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
120 if a.len() != b.len() || a.is_empty() {
121 return 0.0;
122 }
123 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
124 let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
125 let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
126 if na < f32::EPSILON || nb < f32::EPSILON {
127 return 0.0;
128 }
129 (dot / (na * nb)).clamp(-1.0, 1.0)
130}
131
132pub fn parse_tags(text: &str) -> Vec<String> {
139 let lower = text.to_ascii_lowercase();
141 let Some(start) = lower.find("[tags:") else {
142 return Vec::new();
143 };
144 let after = &text[start + 6..]; let Some(end) = after.find(']') else {
146 return Vec::new();
147 };
148 let tag_str = &after[..end];
149 let mut tags: Vec<String> = tag_str
150 .split(',')
151 .map(|t| t.trim().to_ascii_lowercase())
152 .filter(|t| !t.is_empty())
153 .collect();
154 tags.sort();
155 tags.dedup();
156 tags
157}
158
159struct UnionFind {
164 parent: Vec<usize>,
165}
166
167impl UnionFind {
168 fn new(n: usize) -> Self {
169 Self {
170 parent: (0..n).collect(),
171 }
172 }
173
174 fn find(&mut self, x: usize) -> usize {
175 if self.parent[x] != x {
176 self.parent[x] = self.find(self.parent[x]); }
178 self.parent[x]
179 }
180
181 fn union(&mut self, x: usize, y: usize) {
182 let rx = self.find(x);
183 let ry = self.find(y);
184 if rx != ry {
185 self.parent[ry] = rx;
186 }
187 }
188}
189
190pub fn load_embeddable_rows(
197 conn: &Connection,
198) -> KimetsuResult<HashMap<String, Vec<ConsolidateRow>>> {
199 let mut stmt = conn.prepare(
200 "SELECT memory_id, scope, kind, text, use_count, usefulness_score,
201 last_useful_at, created_at, embedding, embedding_model
202 FROM memories
203 WHERE invalidated_at IS NULL
204 AND superseded_by IS NULL
205 AND valid_from IS NULL AND valid_to IS NULL
206 AND embedding IS NOT NULL
207 AND embedding_model IS NOT NULL
208 ORDER BY created_at DESC",
209 )?;
210
211 let rows = stmt.query_map([], |row| {
212 Ok((
213 row.get::<_, String>(0)?,
214 row.get::<_, String>(1)?,
215 row.get::<_, String>(2)?,
216 row.get::<_, String>(3)?,
217 row.get::<_, i64>(4)?,
218 row.get::<_, f64>(5)?,
219 row.get::<_, Option<String>>(6)?,
220 row.get::<_, String>(7)?,
221 row.get::<_, Vec<u8>>(8)?,
222 row.get::<_, String>(9)?,
223 ))
224 })?;
225
226 let mut by_model: HashMap<String, Vec<ConsolidateRow>> = HashMap::new();
227 for row in rows {
228 let (
229 memory_id,
230 scope,
231 kind,
232 text,
233 use_count,
234 usefulness_score,
235 last_useful_at,
236 created_at,
237 blob,
238 model_id,
239 ) = row?;
240 let Ok(embedding) = decode_embedding(&blob, None) else {
242 continue;
243 };
244 if embedding.is_empty() {
245 continue;
246 }
247 by_model
248 .entry(model_id.clone())
249 .or_default()
250 .push(ConsolidateRow {
251 memory_id,
252 scope,
253 kind,
254 text,
255 use_count,
256 usefulness_score: usefulness_score as f32,
257 last_useful_at,
258 created_at,
259 embedding,
260 model_id,
261 });
262 }
263 Ok(by_model)
264}
265
266fn survivor_score(row: &ConsolidateRow, recency_rank: f32) -> f32 {
274 let usefulness = row.usefulness_score.max(0.0);
275 (usefulness + 1.0) * recency_rank
276}
277
278fn parse_ts(ts: &str) -> i64 {
280 OffsetDateTime::parse(ts, &Rfc3339)
281 .map(|t| t.unix_timestamp())
282 .unwrap_or(0)
283}
284
285fn pick_survivor(cluster: &[usize], rows: &[ConsolidateRow]) -> usize {
289 let mut indexed: Vec<usize> = cluster.to_vec();
291 indexed.sort_by(|&a, &b| {
292 let ta = parse_ts(
293 rows[a]
294 .last_useful_at
295 .as_deref()
296 .unwrap_or(&rows[a].created_at),
297 );
298 let tb = parse_ts(
299 rows[b]
300 .last_useful_at
301 .as_deref()
302 .unwrap_or(&rows[b].created_at),
303 );
304 tb.cmp(&ta)
305 });
306 let n = indexed.len() as f32;
307 let mut best_idx = indexed[0];
308 let mut best_score = f32::NEG_INFINITY;
309 for (rank, &i) in indexed.iter().enumerate() {
310 let recency = 1.0 - (rank as f32) / n.max(1.0);
311 let score = survivor_score(&rows[i], recency);
312 if score > best_score {
313 best_score = score;
314 best_idx = i;
315 }
316 }
317 best_idx
318}
319
320pub fn find_merge_clusters(rows: &[ConsolidateRow], threshold: f32) -> Vec<MergeCluster> {
327 if rows.len() < 2 || !threshold.is_finite() || !(0.0..=1.0).contains(&threshold) {
328 return Vec::new();
329 }
330 let mut buckets = std::collections::BTreeMap::new();
335 for (i, row) in rows.iter().enumerate() {
336 if row.text.trim().is_empty() {
337 continue;
338 }
339 buckets
340 .entry((&row.scope, &row.kind, &row.model_id, row.text.trim()))
341 .or_insert_with(Vec::new)
342 .push(i);
343 }
344 let mut clusters = Vec::new();
345 let mut comparisons_left = 100_000usize;
347 for (_, mut pending) in buckets {
348 pending.sort_by(|&a, &b| rows[a].memory_id.cmp(&rows[b].memory_id));
349 while pending.len() > 1 && comparisons_left > 0 {
350 let survivor_idx = pick_survivor(&pending, rows);
351 let mut remaining = Vec::new();
352 let mut members = Vec::new();
353 for i in pending {
354 if i == survivor_idx {
355 continue;
356 }
357 if comparisons_left == 0 {
358 remaining.push(i);
359 continue;
360 }
361 comparisons_left -= 1;
362 if cosine(&rows[survivor_idx].embedding, &rows[i].embedding) >= threshold {
363 members.push(rows[i].clone());
364 } else {
365 remaining.push(i);
366 }
367 }
368 if !members.is_empty() {
369 clusters.push(MergeCluster {
370 survivor: rows[survivor_idx].clone(),
371 members,
372 });
373 }
374 pending = remaining;
375 }
376 }
377
378 clusters.sort_by(|a, b| a.survivor.memory_id.cmp(&b.survivor.memory_id));
380 clusters
381}
382
383pub fn apply_merge(
393 conn: &Connection,
394 cluster: &MergeCluster,
395 run_id: kimetsu_core::ids::RunId,
396) -> KimetsuResult<usize> {
397 let events: Vec<_> = cluster
398 .members
399 .iter()
400 .map(|member| {
401 kimetsu_core::event::Event::new(
402 run_id,
403 "memory.superseded",
404 serde_json::json!({
405 "memory_id": member.memory_id,
406 "survivor_id": cluster.survivor.memory_id,
407 "use_count_delta": 0,
408 "score_delta": 0.0,
409 }),
410 )
411 })
412 .collect();
413 crate::projector::apply_events_checked(conn, &events, |c| {
414 let mut ids = std::collections::HashSet::new();
415 for planned in std::iter::once(&cluster.survivor).chain(cluster.members.iter()) {
416 if !ids.insert(&planned.memory_id) {
417 return Err("merge plan repeats a memory ID".into());
418 }
419 let current: (String, String, String, bool) = c.query_row(
420 "SELECT scope, kind, text, invalidated_at IS NULL AND superseded_by IS NULL
421 AND valid_from IS NULL AND valid_to IS NULL
422 FROM memories WHERE memory_id = ?1",
423 [&planned.memory_id],
424 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
425 )?;
426 if !current.3
427 || current.0 != planned.scope
428 || current.1 != planned.kind
429 || current.2 != planned.text
430 || current.0 != cluster.survivor.scope
431 || current.1 != cluster.survivor.kind
432 || current.2.trim() != cluster.survivor.text.trim()
433 {
434 return Err("merge plan is stale or combines distinct claims".into());
435 }
436 }
437 Ok(())
438 })?;
439 Ok(cluster.members.len())
440}
441
442pub fn run_consolidation(
451 conn: &Connection,
452 opts: &ConsolidateOptions,
453 writer: &mut impl std::io::Write,
454) -> KimetsuResult<ConsolidateSummary> {
455 let by_model = load_embeddable_rows(conn)?;
456
457 let mut all_rows: Vec<ConsolidateRow> = by_model.into_values().flatten().collect();
458 all_rows.sort_by(|a, b| a.memory_id.cmp(&b.memory_id));
460
461 let clusters = find_merge_clusters(&all_rows, opts.threshold);
462
463 let mut summary = ConsolidateSummary {
464 clusters_found: clusters.len(),
465 ..Default::default()
466 };
467
468 if clusters.is_empty() {
469 writeln!(
470 writer,
471 "No near-duplicate clusters found (threshold={:.2}).",
472 opts.threshold
473 )?;
474 return Ok(summary);
475 }
476
477 if opts.dry_run {
478 writeln!(
479 writer,
480 "Dry-run: {} cluster(s) found (threshold={:.2}):",
481 clusters.len(),
482 opts.threshold
483 )?;
484 for (i, cluster) in clusters.iter().enumerate() {
485 writeln!(
486 writer,
487 "\nCluster {}: SURVIVOR → {} [score={:.2} uses={}]",
488 i + 1,
489 cluster.survivor.memory_id,
490 cluster.survivor.usefulness_score,
491 cluster.survivor.use_count
492 )?;
493 writeln!(writer, " Text: {}", truncate(&cluster.survivor.text, 80))?;
494 for m in &cluster.members {
495 writeln!(
496 writer,
497 " MEMBER → {} [score={:.2} uses={}]",
498 m.memory_id, m.usefulness_score, m.use_count
499 )?;
500 writeln!(writer, " Text: {}", truncate(&m.text, 80))?;
501 }
502 }
503 return Ok(summary);
504 }
505
506 let run_id = kimetsu_core::ids::RunId::new();
508 for cluster in &clusters {
509 match apply_merge(conn, cluster, run_id) {
510 Ok(merged) => {
511 summary.memories_merged += merged;
512 }
513 Err(e) => {
514 writeln!(
515 writer,
516 "warn: merge of cluster around {} failed: {e}",
517 cluster.survivor.memory_id
518 )?;
519 }
520 }
521 }
522
523 writeln!(
524 writer,
525 "Consolidated {} cluster(s): {} memor{} merged.",
526 summary.clusters_found,
527 summary.memories_merged,
528 if summary.memories_merged == 1 {
529 "y"
530 } else {
531 "ies"
532 }
533 )?;
534
535 Ok(summary)
536}
537
538pub fn find_distill_clusters(
545 rows: &[ConsolidateRow],
546 opts: &DistillOptions,
547) -> Vec<DistillCluster> {
548 let n = rows.len();
549 if n < opts.min_cluster_size {
550 return Vec::new();
551 }
552
553 let row_tags: Vec<Vec<String>> = rows.iter().map(|r| parse_tags(&r.text)).collect();
555
556 let mut uf = UnionFind::new(n);
557
558 for i in 0..n {
559 for j in (i + 1)..n {
560 if rows[i].model_id != rows[j].model_id {
561 continue;
562 }
563 let sim = cosine(&rows[i].embedding, &rows[j].embedding);
564 if sim < opts.lo || sim > opts.hi {
565 continue;
566 }
567 let shared = row_tags[i].iter().any(|t| row_tags[j].contains(t));
569 if shared {
570 uf.union(i, j);
571 }
572 }
573 }
574
575 let mut root_to_members: HashMap<usize, Vec<usize>> = HashMap::new();
577 for i in 0..n {
578 let root = uf.find(i);
579 root_to_members.entry(root).or_default().push(i);
580 }
581
582 let mut clusters = Vec::new();
583 for (_, members) in root_to_members {
584 if members.len() < opts.min_cluster_size {
585 continue;
586 }
587 let mut shared_tags: Vec<String> = row_tags[members[0]].clone();
589 for &i in &members[1..] {
590 shared_tags.retain(|t| row_tags[i].contains(t));
591 }
592 if shared_tags.is_empty() {
593 continue; }
595 let memories: Vec<ConsolidateRow> = members.iter().map(|&i| rows[i].clone()).collect();
596 clusters.push(DistillCluster {
597 shared_tags,
598 memories,
599 });
600 }
601
602 clusters.sort_by(|a, b| a.shared_tags.cmp(&b.shared_tags));
603 clusters
604}
605
606fn truncate(s: &str, max: usize) -> String {
611 let chars: Vec<char> = s.chars().collect();
612 if chars.len() <= max {
613 s.to_string()
614 } else {
615 format!("{}…", chars[..max].iter().collect::<String>())
616 }
617}
618
619#[derive(Debug, Clone, Default)]
625pub struct ReflectionOptions {
626 pub distill_opts: DistillOptions,
628 pub dry_run: bool,
630}
631
632#[derive(Debug, Default)]
634pub struct ReflectionSummary {
635 pub clusters_found: usize,
636 pub proposals_created: usize,
637}
638
639pub trait ModelProvider {
643 fn complete_text(&mut self, prompt: &str) -> Option<String>;
644}
645
646const REFLECTION_SYSTEM: &str = "You are a memory synthesizer. Given these related lessons/memories, \
648synthesize ONE higher-order principle that generalizes them (2-4 sentences, \
649imperative, actionable). Reply with ONLY a JSON object: \
650{\"principle\": \"...\", \"tags\": [\"tag1\", \"tag2\"], \"confidence\": 0.0-1.0}";
651
652pub fn run_reflection(
663 conn: &Connection,
664 opts: &ReflectionOptions,
665 model: Option<&mut dyn ModelProvider>,
666 writer: &mut impl std::io::Write,
667) -> KimetsuResult<ReflectionSummary> {
668 let by_model = load_embeddable_rows(conn)?;
669 let mut all_rows: Vec<ConsolidateRow> = by_model.into_values().flatten().collect();
670 all_rows.sort_by(|a, b| a.memory_id.cmp(&b.memory_id));
671
672 let clusters = find_distill_clusters(&all_rows, &opts.distill_opts);
673
674 let mut summary = ReflectionSummary {
675 clusters_found: clusters.len(),
676 ..Default::default()
677 };
678
679 if clusters.is_empty() {
680 writeln!(writer, "No reflection clusters found.")?;
681 return Ok(summary);
682 }
683
684 if opts.dry_run || model.is_none() {
686 writeln!(writer, "{} reflection cluster(s) found:", clusters.len())?;
687 for (i, cluster) in clusters.iter().enumerate() {
688 writeln!(
689 writer,
690 "\nCluster {} [tags: {}]:",
691 i + 1,
692 cluster.shared_tags.join(", ")
693 )?;
694 for row in &cluster.memories {
695 writeln!(writer, " • {}", truncate(&row.text, 80))?;
696 }
697 writeln!(
698 writer,
699 " → These {} memories could be reflected into a principle.",
700 cluster.memories.len()
701 )?;
702 }
703 return Ok(summary);
704 }
705
706 let model = model.unwrap(); let run_id = kimetsu_core::ids::RunId::new();
708
709 for cluster in &clusters {
710 let memory_texts: Vec<String> = cluster
712 .memories
713 .iter()
714 .map(|r| format!("- {}", r.text))
715 .collect();
716 let user_msg = memory_texts.join("\n");
717 let prompt = format!("{REFLECTION_SYSTEM}\n\nMemories:\n{user_msg}");
718
719 let Some(response_text) = model.complete_text(&prompt) else {
720 writeln!(
721 writer,
722 "warn: model call failed for cluster [{}]",
723 cluster.shared_tags.join(", ")
724 )?;
725 continue;
726 };
727
728 let Some(principle_json) = parse_reflection_json(&response_text) else {
730 writeln!(
731 writer,
732 "warn: could not parse reflection JSON for cluster [{}]: {response_text}",
733 cluster.shared_tags.join(", ")
734 )?;
735 continue;
736 };
737
738 let principle = principle_json
739 .get("principle")
740 .and_then(|v| v.as_str())
741 .unwrap_or("")
742 .trim()
743 .to_string();
744 if principle.is_empty() {
745 continue;
746 }
747 let tags = principle_json
748 .get("tags")
749 .and_then(|v| v.as_array())
750 .map(|a| {
751 a.iter()
752 .filter_map(|s| s.as_str())
753 .map(|s| s.to_string())
754 .collect::<Vec<_>>()
755 })
756 .unwrap_or_default();
757 let confidence = principle_json
758 .get("confidence")
759 .and_then(|v| v.as_f64())
760 .unwrap_or(0.7)
761 .clamp(0.0, 1.0);
762
763 let proposal_id = Ulid::new().to_string();
764 let source_ids: Vec<&str> = cluster
765 .memories
766 .iter()
767 .map(|r| r.memory_id.as_str())
768 .collect();
769
770 let event = kimetsu_core::event::Event::new(
771 run_id,
772 "memory.proposed",
773 serde_json::json!({
774 "proposal_id": proposal_id,
775 "scope": "project",
776 "kind": "fact",
777 "text": principle,
778 "tags": tags,
779 "rationale": format!(
780 "Reflection synthesis from {} related memories [tags: {}]",
781 cluster.memories.len(),
782 cluster.shared_tags.join(", ")
783 ),
784 "proposed_confidence": confidence,
785 "source_event_ids": source_ids,
786 }),
787 );
788
789 match crate::projector::apply_events(conn, &[event]) {
790 Ok(()) => {
791 summary.proposals_created += 1;
792 writeln!(writer, "Proposed: {principle}")?;
793 }
794 Err(e) => {
795 writeln!(writer, "warn: failed to store reflection proposal: {e}")?;
796 }
797 }
798 }
799
800 Ok(summary)
801}
802
803fn parse_reflection_json(text: &str) -> Option<serde_json::Value> {
806 let start = text.find('{')?;
807 let bytes = text.as_bytes();
808 let mut depth = 0i32;
809 let mut in_string = false;
810 let mut escaped = false;
811 let mut end = None;
812 for (i, &b) in bytes.iter().enumerate().skip(start) {
813 if in_string {
814 if escaped {
815 escaped = false;
816 } else if b == b'\\' {
817 escaped = true;
818 } else if b == b'"' {
819 in_string = false;
820 }
821 } else {
822 match b {
823 b'"' => in_string = true,
824 b'{' => depth += 1,
825 b'}' => {
826 depth -= 1;
827 if depth == 0 {
828 end = Some(i);
829 break;
830 }
831 }
832 _ => {}
833 }
834 }
835 }
836 let json_str = &text[start..=end?];
837 serde_json::from_str(json_str).ok()
838}
839
840#[cfg(test)]
845mod tests {
846 use super::*;
847 use rusqlite::params;
848
849 #[test]
853 fn cosine_same_vector_is_one() {
854 let v = vec![1.0f32, 0.5, -0.3];
855 assert!((cosine(&v, &v) - 1.0).abs() < 1e-5);
856 }
857
858 #[test]
859 fn cosine_orthogonal_is_zero() {
860 assert!((cosine(&[1.0f32, 0.0], &[0.0f32, 1.0]) - 0.0).abs() < 1e-5);
861 }
862
863 #[test]
864 fn cosine_opposite_is_minus_one() {
865 assert!((cosine(&[1.0f32, 0.0], &[-1.0f32, 0.0]) + 1.0).abs() < 1e-5);
866 }
867
868 #[test]
869 fn cosine_empty_returns_zero() {
870 assert_eq!(cosine(&[], &[]), 0.0);
871 }
872
873 #[test]
874 fn cosine_dim_mismatch_returns_zero() {
875 assert_eq!(cosine(&[1.0f32], &[1.0f32, 2.0]), 0.0);
876 }
877
878 #[test]
882 fn parse_tags_extracts_tags() {
883 let text = "Always use cargo fmt [tags: rust, tooling, ci]";
884 let tags = parse_tags(text);
885 assert_eq!(tags, vec!["ci", "rust", "tooling"]);
886 }
887
888 #[test]
889 fn parse_tags_no_block_returns_empty() {
890 assert!(parse_tags("no tags here").is_empty());
891 }
892
893 #[test]
894 fn parse_tags_case_insensitive_key() {
895 let text = "Something [TAGS: Rust, CI]";
896 let tags = parse_tags(text);
897 assert!(tags.contains(&"rust".to_string()));
898 assert!(tags.contains(&"ci".to_string()));
899 }
900
901 #[test]
902 fn parse_tags_deduplicates() {
903 let text = "text [tags: a, b, a]";
904 let tags = parse_tags(text);
905 assert_eq!(tags.iter().filter(|t| *t == "a").count(), 1);
906 }
907
908 fn make_row(id: &str, vec: Vec<f32>) -> ConsolidateRow {
913 ConsolidateRow {
914 memory_id: id.to_string(),
915 scope: "project".to_string(),
916 kind: "fact".to_string(),
917 text: "Identical stored claim".to_string(),
918 use_count: 1,
919 usefulness_score: 1.0,
920 last_useful_at: None,
921 created_at: "2026-01-01T00:00:00Z".to_string(),
922 embedding: vec,
923 model_id: "stub".to_string(),
924 }
925 }
926
927 #[test]
928 fn find_merge_clusters_identical_vectors_cluster() {
929 let v = vec![1.0f32, 0.0, 0.0];
930 let rows = vec![
931 make_row("a", v.clone()),
932 make_row("b", v.clone()),
933 make_row("c", v.clone()),
934 ];
935 let clusters = find_merge_clusters(&rows, 0.92);
936 assert_eq!(clusters.len(), 1, "one cluster of identical vectors");
937 assert_eq!(
938 clusters[0].members.len(),
939 2,
940 "two members (one is survivor)"
941 );
942 }
943
944 #[test]
945 fn merge_preserves_scope_kind_and_distinct_claims() {
946 let mut base = make_row("a", vec![1.0, 0.0]);
947 base.text = "The development port is 4317.".into();
948 for variant in 0..3 {
949 let mut other = base.clone();
950 other.memory_id = "b".into();
951 match variant {
952 0 => other.scope = "user".into(),
953 1 => other.kind = "constraint".into(),
954 _ => other.text = "The production port is 4317.".into(),
955 }
956 assert!(
957 find_merge_clusters(&[base.clone(), other], 0.92).is_empty(),
958 "similarity must not erase distinct applicability or a unique claim"
959 );
960 }
961 }
962
963 #[test]
964 fn merge_similarity_chain_cannot_bridge_distant_members() {
965 let mut rows = vec![
966 make_row("a", vec![1.0, 0.0]),
967 make_row("b", vec![0.9396926, 0.3420201]),
968 make_row("c", vec![0.7660444, 0.6427876]),
969 ];
970 for row in &mut rows {
971 row.text = "Identical claim with embedding drift".into();
972 }
973 let clusters = find_merge_clusters(&rows, 0.92);
974 assert!(clusters.iter().all(|c| {
975 c.members
976 .iter()
977 .all(|m| cosine(&c.survivor.embedding, &m.embedding) >= 0.92)
978 }));
979 }
980
981 #[test]
982 fn find_merge_clusters_orthogonal_no_clusters() {
983 let rows = vec![
984 make_row("a", vec![1.0f32, 0.0]),
985 make_row("b", vec![0.0f32, 1.0]),
986 ];
987 let clusters = find_merge_clusters(&rows, 0.92);
988 assert!(clusters.is_empty(), "orthogonal vectors do not cluster");
989 }
990
991 #[test]
992 fn find_merge_clusters_different_models_do_not_cluster() {
993 let v = vec![1.0f32, 0.0];
994 let mut r1 = make_row("a", v.clone());
995 r1.model_id = "model-a".to_string();
996 let mut r2 = make_row("b", v.clone());
997 r2.model_id = "model-b".to_string();
998 let clusters = find_merge_clusters(&[r1, r2], 0.92);
999 assert!(clusters.is_empty(), "different models must not cluster");
1000 }
1001
1002 #[test]
1003 fn survivor_is_highest_usefulness_score() {
1004 let v = vec![1.0f32, 0.0, 0.0];
1005 let mut high = make_row("high", v.clone());
1006 high.usefulness_score = 10.0;
1007 high.use_count = 5;
1008 let mut low = make_row("low", v.clone());
1009 low.usefulness_score = 0.1;
1010 low.use_count = 1;
1011 let clusters = find_merge_clusters(&[low, high], 0.92);
1012 assert_eq!(clusters.len(), 1);
1013 assert_eq!(clusters[0].survivor.memory_id, "high");
1014 assert_eq!(clusters[0].members[0].memory_id, "low");
1015 }
1016
1017 #[test]
1021 fn find_distill_clusters_requires_shared_tags() {
1022 let v1 = vec![1.0f32, 0.5, 0.0];
1024 let v2 = vec![1.0f32, 0.4, 0.1];
1025 let mut r1 = make_row("a", v1);
1026 r1.text = "first memory [tags: rust]".to_string();
1027 let mut r2 = make_row("b", v2);
1028 r2.text = "second memory [tags: python]".to_string();
1029 let mut r3 = make_row("c", vec![1.0f32, 0.4, 0.05]);
1030 r3.text = "third memory [tags: go]".to_string();
1031 let opts = DistillOptions {
1032 lo: 0.7,
1033 hi: 0.99,
1034 min_cluster_size: 2,
1035 };
1036 let clusters = find_distill_clusters(&[r1, r2, r3], &opts);
1037 assert!(clusters.is_empty(), "no shared tags → no distill cluster");
1038 }
1039
1040 #[test]
1041 fn find_distill_clusters_shared_tag_and_band_clusters() {
1042 let v = vec![1.0f32, 0.5, 0.1];
1044 let make = |id: &str, extra: f32| {
1045 let mut r = make_row(id, vec![1.0 + extra, 0.5, 0.1]);
1046 r.text = format!("memory {id} [tags: rust, ci]");
1047 r
1048 };
1049 let rows = vec![make("a", 0.0), make("b", 0.001), make("c", 0.002)];
1050 let _ = v; let opts = DistillOptions {
1052 lo: 0.0,
1053 hi: 1.0,
1054 min_cluster_size: 3,
1055 };
1056 let clusters = find_distill_clusters(&rows, &opts);
1057 assert!(!clusters.is_empty(), "shared tag + band → distill cluster");
1058 assert!(
1059 clusters[0].shared_tags.contains(&"ci".to_string()),
1060 "shared_tags contains 'ci'"
1061 );
1062 }
1063
1064 #[test]
1068 fn apply_merge_preserves_evidence_without_counting_copies_as_independent() {
1069 use kimetsu_core::ids::RunId;
1070
1071 let conn = rusqlite::Connection::open_in_memory().expect("open");
1072 crate::schema::initialize(&conn).expect("init");
1073
1074 for (id, use_count, score) in [("survivor", 3i64, 5.0f64), ("member", 2i64, 2.0f64)] {
1076 conn.execute(
1077 "INSERT INTO memories
1078 (memory_id, scope, kind, text, normalized_text, confidence,
1079 provenance_snapshot_json, created_at, use_count, usefulness_score)
1080 VALUES (?1,'project','fact',?2,?2,0.9,'{}','2026-01-01T00:00:00Z',?3,?4)",
1081 params![id, "Identical stored claim", use_count, score],
1082 )
1083 .expect("insert");
1084 }
1085
1086 let survivor = ConsolidateRow {
1087 memory_id: "survivor".to_string(),
1088 scope: "project".to_string(),
1089 kind: "fact".to_string(),
1090 text: "Identical stored claim".to_string(),
1091 use_count: 3,
1092 usefulness_score: 5.0,
1093 last_useful_at: None,
1094 created_at: "2026-01-01T00:00:00Z".to_string(),
1095 embedding: vec![1.0, 0.0],
1096 model_id: "stub".to_string(),
1097 };
1098 let member = ConsolidateRow {
1099 memory_id: "member".to_string(),
1100 scope: "project".to_string(),
1101 kind: "fact".to_string(),
1102 text: "Identical stored claim".to_string(),
1103 use_count: 2,
1104 usefulness_score: 2.0,
1105 last_useful_at: None,
1106 created_at: "2026-01-01T00:00:00Z".to_string(),
1107 embedding: vec![1.0, 0.0],
1108 model_id: "stub".to_string(),
1109 };
1110 let cluster = MergeCluster {
1111 survivor,
1112 members: vec![member],
1113 };
1114
1115 let run_id = RunId::new();
1116 let merged = apply_merge(&conn, &cluster, run_id).expect("apply_merge");
1117 assert_eq!(merged, 1);
1118
1119 let (use_count, score): (i64, f64) = conn
1121 .query_row(
1122 "SELECT use_count, usefulness_score FROM memories WHERE memory_id = 'survivor'",
1123 [],
1124 |r| Ok((r.get(0)?, r.get(1)?)),
1125 )
1126 .expect("query survivor");
1127 assert_eq!(
1128 use_count, 3,
1129 "copying a claim cannot create independent observations"
1130 );
1131 assert!(
1132 (score - 5.0).abs() < 0.01,
1133 "keep survivor's evidence, got {score}"
1134 );
1135
1136 let superseded_by: Option<String> = conn
1138 .query_row(
1139 "SELECT superseded_by FROM memories WHERE memory_id = 'member'",
1140 [],
1141 |r| r.get(0),
1142 )
1143 .expect("query member");
1144 assert_eq!(superseded_by.as_deref(), Some("survivor"));
1145 }
1146
1147 #[test]
1148 fn stale_merge_plan_does_not_retire_any_member() {
1149 let conn = rusqlite::Connection::open_in_memory().unwrap();
1150 crate::schema::initialize(&conn).unwrap();
1151 for id in ["a", "b", "c"] {
1152 conn.execute("INSERT INTO memories (memory_id,scope,kind,text,normalized_text,confidence,
1153 provenance_snapshot_json,created_at) VALUES (?1,'project','fact','Identical stored claim',
1154 'identical stored claim',0.5,'{}','2026-01-01T00:00:00Z')", params![id]).unwrap();
1155 }
1156 let cluster = MergeCluster {
1157 survivor: make_row("a", vec![1.0, 0.0]),
1158 members: vec![make_row("b", vec![1.0, 0.0]), make_row("c", vec![1.0, 0.0])],
1159 };
1160 conn.execute(
1161 "UPDATE memories SET text='A corrected distinct claim' WHERE memory_id='c'",
1162 [],
1163 )
1164 .unwrap();
1165 assert!(apply_merge(&conn, &cluster, kimetsu_core::ids::RunId::new()).is_err());
1166 let retired: i64 = conn
1167 .query_row(
1168 "SELECT COUNT(*) FROM memories WHERE superseded_by IS NOT NULL",
1169 [],
1170 |r| r.get(0),
1171 )
1172 .unwrap();
1173 assert_eq!(retired, 0, "the entire stale plan must roll back");
1174 conn.execute("UPDATE memories SET text='Identical stored claim', valid_from='2099-01-01T00:00:00Z' WHERE memory_id='c'", []).unwrap();
1175 assert!(
1176 apply_merge(&conn, &cluster, kimetsu_core::ids::RunId::new()).is_err(),
1177 "identical text with different applicability must not consolidate"
1178 );
1179 }
1180
1181 #[test]
1182 fn citations_reassigned_on_merge() {
1183 use kimetsu_core::ids::RunId;
1184
1185 let conn = rusqlite::Connection::open_in_memory().expect("open");
1186 crate::schema::initialize(&conn).expect("init");
1187
1188 for id in ["survivor", "member"] {
1190 conn.execute(
1191 "INSERT INTO memories
1192 (memory_id, scope, kind, text, normalized_text, confidence,
1193 provenance_snapshot_json, created_at, use_count, usefulness_score)
1194 VALUES (?1,'project','fact',?2,?2,0.9,'{}','2026-01-01T00:00:00Z',1,1.0)",
1195 params![id, "Identical stored claim"],
1196 )
1197 .expect("insert memory");
1198 }
1199
1200 conn.execute(
1202 "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
1203 VALUES ('run-1', 'member', 1, '2026-01-01T00:00:00Z')",
1204 [],
1205 )
1206 .expect("insert citation");
1207
1208 let cluster = MergeCluster {
1209 survivor: ConsolidateRow {
1210 memory_id: "survivor".to_string(),
1211 scope: "project".to_string(),
1212 kind: "fact".to_string(),
1213 text: "Identical stored claim".to_string(),
1214 use_count: 1,
1215 usefulness_score: 1.0,
1216 last_useful_at: None,
1217 created_at: "2026-01-01T00:00:00Z".to_string(),
1218 embedding: vec![1.0, 0.0],
1219 model_id: "stub".to_string(),
1220 },
1221 members: vec![ConsolidateRow {
1222 memory_id: "member".to_string(),
1223 scope: "project".to_string(),
1224 kind: "fact".to_string(),
1225 text: "Identical stored claim".to_string(),
1226 use_count: 1,
1227 usefulness_score: 1.0,
1228 last_useful_at: None,
1229 created_at: "2026-01-01T00:00:00Z".to_string(),
1230 embedding: vec![1.0, 0.0],
1231 model_id: "stub".to_string(),
1232 }],
1233 };
1234
1235 apply_merge(&conn, &cluster, RunId::new()).expect("apply_merge");
1236
1237 let mid: String = conn
1239 .query_row(
1240 "SELECT memory_id FROM memory_citations WHERE run_id = 'run-1' AND turn = 1",
1241 [],
1242 |r| r.get(0),
1243 )
1244 .expect("query citation");
1245 assert_eq!(mid, "survivor", "citation reassigned to survivor");
1246
1247 let member_count: i64 = conn
1249 .query_row(
1250 "SELECT COUNT(*) FROM memory_citations WHERE memory_id = 'member'",
1251 [],
1252 |r| r.get(0),
1253 )
1254 .expect("count member citations");
1255 assert_eq!(member_count, 0, "member citations deleted");
1256 }
1257
1258 #[test]
1262 fn superseded_row_excluded_from_latest_memory_candidates() {
1263 use crate::context::retrieve_context_with_embedder;
1264 use crate::embeddings::NoopEmbedder;
1265 use kimetsu_core::config::BrokerWeights;
1266
1267 let conn = rusqlite::Connection::open_in_memory().expect("open");
1268 crate::schema::initialize(&conn).expect("init");
1269
1270 conn.execute(
1272 "INSERT INTO memories
1273 (memory_id, scope, kind, text, normalized_text, confidence,
1274 provenance_snapshot_json, created_at, use_count, usefulness_score)
1275 VALUES ('surv','project','fact','rust tooling','rust tooling',0.9,'{}',
1276 '2026-01-01T00:00:00Z',1,1.0)",
1277 [],
1278 )
1279 .expect("insert survivor");
1280 conn.execute(
1281 "INSERT INTO memories
1282 (memory_id, scope, kind, text, normalized_text, confidence,
1283 provenance_snapshot_json, created_at, use_count, usefulness_score,
1284 superseded_by)
1285 VALUES ('dup','project','fact','rust tooling dup','rust tooling dup',0.9,'{}',
1286 '2026-01-01T00:00:00Z',1,1.0,'surv')",
1287 [],
1288 )
1289 .expect("insert superseded");
1290
1291 conn.execute(
1293 "INSERT INTO memories_fts (memory_id, text, kind, scope)
1294 VALUES ('surv', 'rust tooling', 'fact', 'project')",
1295 [],
1296 )
1297 .expect("insert fts");
1298
1299 let weights = BrokerWeights::default();
1300 let req = crate::context::ContextRequest {
1301 stage: "test".to_string(),
1302 query: "rust tooling".to_string(),
1303 budget_tokens: 4096,
1304 ..Default::default()
1305 };
1306 let embedder = NoopEmbedder;
1307 let bundle = retrieve_context_with_embedder(&conn, "", &weights, req, &[], &embedder)
1308 .expect("retrieve");
1309
1310 let ids: Vec<&str> = bundle
1311 .capsules
1312 .iter()
1313 .chain(bundle.excluded.iter())
1314 .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
1315 .collect();
1316 assert!(
1317 !ids.contains(&"dup"),
1318 "superseded memory must not appear in retrieval"
1319 );
1320 }
1321
1322 #[test]
1329 fn v2_brain_migrates_to_v3_with_backup_and_superseded_by_column() {
1330 use crate::migrate;
1331 use kimetsu_core::KIMETSU_SCHEMA_VERSION;
1332
1333 let tmp_id = std::time::SystemTime::now()
1334 .duration_since(std::time::UNIX_EPOCH)
1335 .map(|d| d.as_nanos())
1336 .unwrap_or(0);
1337 let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-v3mig-{tmp_id}"));
1338 std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1339
1340 let db_path = tmp_dir.join("brain.db");
1341 {
1342 let conn = rusqlite::Connection::open(&db_path).expect("open");
1344 crate::schema::create_baseline_for_test(&conn).expect("baseline");
1345 crate::schema::migrate_v1_to_v2(&conn).expect("v1→v2");
1346 conn.execute(
1347 "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
1348 [],
1349 )
1350 .expect("stamp v2");
1351 conn.execute(
1352 "INSERT INTO memories
1353 (memory_id, scope, kind, text, normalized_text, confidence,
1354 provenance_snapshot_json, created_at, use_count, usefulness_score)
1355 VALUES ('m1','project','fact','hello','hello',0.9,'{}','2026-01-01T00:00:00Z',0,0.0)",
1356 [],
1357 ).expect("insert memory");
1358 }
1359
1360 {
1362 let conn = rusqlite::Connection::open(&db_path).expect("reopen");
1363 let outcome = migrate::run_migrations(&conn).expect("run_migrations");
1364 assert_eq!(outcome.from, 2);
1365 assert_eq!(outcome.to, KIMETSU_SCHEMA_VERSION);
1366 assert!(
1368 outcome.applied.contains(&3),
1369 "v3 must be in applied list, got: {:?}",
1370 outcome.applied
1371 );
1372 assert!(
1374 outcome.backup_path.is_some(),
1375 "backup must be created for non-empty brain during migration"
1376 );
1377 let has_superseded_by: bool = conn.query_row(
1379 "SELECT COUNT(*) FROM pragma_table_info('memories') WHERE name = 'superseded_by'",
1380 [],
1381 |r| r.get::<_, i64>(0),
1382 ).map(|n| n > 0).unwrap_or(false);
1383 assert!(
1384 has_superseded_by,
1385 "superseded_by column must exist after v3 migration"
1386 );
1387 let has_edges: bool = conn
1389 .query_row(
1390 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_edges'",
1391 [],
1392 |r| r.get::<_, i64>(0),
1393 )
1394 .map(|n| n > 0)
1395 .unwrap_or(false);
1396 assert!(
1397 has_edges,
1398 "memory_edges table must exist after v4 migration"
1399 );
1400 }
1401
1402 let _ = std::fs::remove_dir_all(&tmp_dir);
1403 }
1404
1405 #[test]
1419 fn consolidation_is_rebuild_safe() {
1420 use crate::projector;
1421 use kimetsu_core::ids::RunId;
1422
1423 let conn = rusqlite::Connection::open_in_memory().expect("open");
1424 crate::schema::initialize(&conn).expect("init");
1425
1426 let run_id = RunId::new();
1427
1428 projector::apply_events(
1430 &conn,
1431 &[kimetsu_core::event::Event::new(
1432 run_id,
1433 "run.started",
1434 serde_json::json!({"project_id": "test", "task": "rebuild-safety"}),
1435 )],
1436 )
1437 .expect("run.started");
1438
1439 for (mid, text) in [
1440 ("survivor", "Identical stored claim"),
1441 ("member", "Identical stored claim"),
1442 ] {
1443 projector::apply_events(
1444 &conn,
1445 &[kimetsu_core::event::Event::new(
1446 run_id,
1447 "memory.accepted",
1448 serde_json::json!({
1449 "memory_id": mid,
1450 "scope": "project",
1451 "kind": "fact",
1452 "text": text,
1453 "normalized_text": text,
1454 "confidence": 0.9
1455 }),
1456 )],
1457 )
1458 .expect("accepted");
1459 }
1460
1461 conn.execute(
1465 "UPDATE memories SET use_count = 3, usefulness_score = 5.0 \
1466 WHERE memory_id = 'survivor'",
1467 [],
1468 )
1469 .expect("seed survivor stats");
1470 conn.execute(
1471 "UPDATE memories SET use_count = 2, usefulness_score = 2.0 \
1472 WHERE memory_id = 'member'",
1473 [],
1474 )
1475 .expect("seed member stats");
1476
1477 projector::apply_events(
1480 &conn,
1481 &[kimetsu_core::event::Event::new(
1482 run_id,
1483 "memory.cited",
1484 serde_json::json!({
1485 "memory_id": "member",
1486 "turn": 1,
1487 "rationale": "test citation"
1488 }),
1489 )],
1490 )
1491 .expect("memory.cited");
1492
1493 let cluster = MergeCluster {
1495 survivor: ConsolidateRow {
1496 memory_id: "survivor".to_string(),
1497 scope: "project".to_string(),
1498 kind: "fact".to_string(),
1499 text: "Identical stored claim".to_string(),
1500 use_count: 3,
1501 usefulness_score: 5.0,
1502 last_useful_at: None,
1503 created_at: "2026-01-01T00:00:00Z".to_string(),
1504 embedding: vec![1.0, 0.0],
1505 model_id: "stub".to_string(),
1506 },
1507 members: vec![ConsolidateRow {
1508 memory_id: "member".to_string(),
1509 scope: "project".to_string(),
1510 kind: "fact".to_string(),
1511 text: "Identical stored claim".to_string(),
1512 use_count: 2,
1513 usefulness_score: 2.0,
1514 last_useful_at: None,
1515 created_at: "2026-01-01T00:00:00Z".to_string(),
1516 embedding: vec![1.0, 0.0],
1517 model_id: "stub".to_string(),
1518 }],
1519 };
1520 apply_merge(&conn, &cluster, RunId::new()).expect("apply_merge");
1521
1522 let (pre_uc, pre_score): (i64, f64) = conn
1525 .query_row(
1526 "SELECT use_count, usefulness_score FROM memories \
1527 WHERE memory_id = 'survivor'",
1528 [],
1529 |r| Ok((r.get(0)?, r.get(1)?)),
1530 )
1531 .expect("query survivor after consolidation");
1532 let pre_cited: String = conn
1533 .query_row(
1534 "SELECT memory_id FROM memory_citations WHERE turn = 1",
1535 [],
1536 |r| r.get(0),
1537 )
1538 .expect("citation must exist post-consolidation");
1539 assert_eq!(
1540 pre_cited, "survivor",
1541 "pre-rebuild: citation must point at survivor"
1542 );
1543
1544 projector::rebuild_in_place(&conn).expect("rebuild_in_place");
1546
1547 let (post_uc, post_score): (i64, f64) = conn
1549 .query_row(
1550 "SELECT use_count, usefulness_score FROM memories \
1551 WHERE memory_id = 'survivor'",
1552 [],
1553 |r| Ok((r.get(0)?, r.get(1)?)),
1554 )
1555 .expect("query survivor after rebuild");
1556
1557 assert_eq!(post_uc, 0, "copies cannot manufacture replay evidence");
1558 assert_eq!(post_score, 0.0);
1559
1560 let post_cited: String = conn
1561 .query_row(
1562 "SELECT memory_id FROM memory_citations WHERE turn = 1",
1563 [],
1564 |r| r.get(0),
1565 )
1566 .expect("citation must still exist after rebuild");
1567 assert_eq!(
1568 post_cited, "survivor",
1569 "post-rebuild: citation must still point at survivor (got {post_cited:?})"
1570 );
1571
1572 assert_eq!(pre_uc, 3);
1573 assert_eq!(pre_score, 5.0);
1574 }
1575
1576 struct MockReflector {
1582 response: Option<String>,
1583 }
1584 impl ModelProvider for MockReflector {
1585 fn complete_text(&mut self, _prompt: &str) -> Option<String> {
1586 self.response.take()
1587 }
1588 }
1589
1590 fn insert_reflectable(conn: &rusqlite::Connection, id: &str, text: &str) {
1593 use crate::embeddings::{Embedder, StubEmbedder, encode_embedding};
1594 let stub = StubEmbedder::new();
1595 let vec = stub.embed(text).expect("embed");
1596 let blob = encode_embedding(&vec);
1597 conn.execute(
1598 "INSERT INTO memories (
1599 memory_id, scope, kind, text, normalized_text, confidence,
1600 source_event_id, provenance_snapshot_json, created_at,
1601 use_count, usefulness_score, embedding, embedding_model
1602 ) VALUES (?1, 'project', 'fact', ?2, ?2, 1.0, NULL, '{}',
1603 '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
1604 params![id, text, blob, stub.model_id()],
1605 )
1606 .expect("insert reflectable");
1607 }
1608
1609 #[test]
1613 fn run_reflection_creates_proposal_from_cluster() {
1614 let conn = rusqlite::Connection::open_in_memory().expect("open");
1615 crate::schema::initialize(&conn).expect("init");
1616
1617 insert_reflectable(
1619 &conn,
1620 "a",
1621 "always run cargo fmt before commit [tags: rust, ci]",
1622 );
1623 insert_reflectable(
1624 &conn,
1625 "b",
1626 "always run cargo fmt before push [tags: rust, ci]",
1627 );
1628 insert_reflectable(&conn, "c", "always run cargo fmt on save [tags: rust, ci]");
1629
1630 let mut model = MockReflector {
1631 response: Some(
1632 r#"{"principle": "Always format Rust code with cargo fmt before sharing.", "tags": ["rust", "ci"], "confidence": 0.85}"#
1633 .to_string(),
1634 ),
1635 };
1636 let opts = ReflectionOptions {
1637 distill_opts: DistillOptions {
1638 lo: 0.0,
1639 hi: 1.0,
1640 min_cluster_size: 3,
1641 },
1642 dry_run: false,
1643 };
1644 let mut out: Vec<u8> = Vec::new();
1645 let summary =
1646 run_reflection(&conn, &opts, Some(&mut model), &mut out).expect("run_reflection");
1647
1648 assert!(
1649 summary.clusters_found >= 1,
1650 "must find at least one cluster"
1651 );
1652 assert_eq!(
1653 summary.proposals_created, 1,
1654 "model-backed reflection must create exactly one proposal"
1655 );
1656
1657 let (text, status): (String, String) = conn
1659 .query_row(
1660 "SELECT text, status FROM memory_proposals LIMIT 1",
1661 [],
1662 |r| Ok((r.get(0)?, r.get(1)?)),
1663 )
1664 .expect("proposal row exists");
1665 assert!(text.contains("cargo fmt"), "proposal carries the principle");
1666 assert_eq!(
1667 status, "pending",
1668 "reflection proposal must be pending review"
1669 );
1670 }
1671
1672 #[test]
1675 fn run_reflection_without_model_reports_only() {
1676 let conn = rusqlite::Connection::open_in_memory().expect("open");
1677 crate::schema::initialize(&conn).expect("init");
1678
1679 insert_reflectable(
1680 &conn,
1681 "a",
1682 "prefer thiserror in libraries [tags: rust, errors]",
1683 );
1684 insert_reflectable(
1685 &conn,
1686 "b",
1687 "prefer thiserror for library crates [tags: rust, errors]",
1688 );
1689 insert_reflectable(
1690 &conn,
1691 "c",
1692 "use thiserror not anyhow in libs [tags: rust, errors]",
1693 );
1694
1695 let opts = ReflectionOptions {
1696 distill_opts: DistillOptions {
1697 lo: 0.0,
1698 hi: 1.0,
1699 min_cluster_size: 3,
1700 },
1701 dry_run: false,
1702 };
1703 let mut out: Vec<u8> = Vec::new();
1704 let summary = run_reflection(&conn, &opts, None, &mut out).expect("run_reflection");
1705
1706 assert!(summary.clusters_found >= 1);
1707 assert_eq!(
1708 summary.proposals_created, 0,
1709 "no model → no proposals (graceful degradation)"
1710 );
1711 let report = String::from_utf8(out).unwrap();
1712 assert!(
1713 report.contains("could be reflected"),
1714 "report must describe reflectable clusters, got: {report}"
1715 );
1716 let proposal_count: i64 = conn
1717 .query_row("SELECT COUNT(*) FROM memory_proposals", [], |r| r.get(0))
1718 .unwrap();
1719 assert_eq!(proposal_count, 0, "no proposals written without a model");
1720 }
1721}