Skip to main content

kimetsu_brain/
consolidate.rs

1//! Memory consolidation: near-duplicate merge (Story 3.1) and cluster
2//! distillation (Story 3.2).
3//!
4//! # Near-duplicate merge (Story 3.1)
5//!
6//! Only identical text within the same scope, kind and embedding model can
7//! consolidate. Each member must meet the cosine threshold against its survivor;
8//! similarity chains cannot bridge unrelated vectors. Each pass performs at most
9//! 100,000 cosine comparisons. Survivor evidence counts stay unchanged: duplicate
10//! storage is not independent support. An atomic, revalidated event batch retires
11//! members and reassigns citations, preserving the operation on rebuild.
12//!
13//! # Cluster distillation (Story 3.2)
14//!
15//! Looser clusters (cosine 0.75–0.85 band) of ≥ 3 memories sharing ≥ 1
16//! domain tag are fed to the configured distiller to produce a ONE general
17//! principle (2–4 sentences, imperative). The result is created as a
18//! `memory_proposal` (pending review) rather than directly accepted.
19//!
20//! If no distiller is configured the command prints the clusters and exits 0.
21
22use 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// ---------------------------------------------------------------------------
33// Public data types
34// ---------------------------------------------------------------------------
35
36/// One memory row as loaded for consolidation scoring.
37#[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    /// RFC-3339 timestamps for recency rank.
46    pub last_useful_at: Option<String>,
47    pub created_at: String,
48    pub embedding: Vec<f32>,
49    pub model_id: String,
50}
51
52/// A proposed merge cluster: survivor + members to supersede.
53#[derive(Debug, Clone)]
54pub struct MergeCluster {
55    pub survivor: ConsolidateRow,
56    pub members: Vec<ConsolidateRow>,
57}
58
59/// Summary returned by `run_consolidation`.
60#[derive(Debug, Default)]
61pub struct ConsolidateSummary {
62    pub clusters_found: usize,
63    pub memories_merged: usize,
64    pub citations_reassigned: usize,
65}
66
67/// Options for `run_consolidation`.
68#[derive(Debug, Clone)]
69pub struct ConsolidateOptions {
70    /// Cosine ≥ threshold → near-duplicate (default 0.92).
71    pub threshold: f32,
72    /// Print plan without writing to the DB.
73    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/// Options for `run_distill`.
86#[derive(Debug, Clone)]
87pub struct DistillOptions {
88    /// Lower cosine bound (inclusive) of the loose-cluster band.
89    pub lo: f32,
90    /// Upper cosine bound (inclusive) of the loose-cluster band.
91    pub hi: f32,
92    /// Minimum cluster size to distil.
93    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/// One distillable cluster (Story 3.2).
107#[derive(Debug, Clone)]
108pub struct DistillCluster {
109    pub shared_tags: Vec<String>,
110    pub memories: Vec<ConsolidateRow>,
111}
112
113// ---------------------------------------------------------------------------
114// Cosine helpers
115// ---------------------------------------------------------------------------
116
117/// Cosine similarity between two equal-length slices.
118/// Returns 0.0 when either vector is zero-length or norms are zero.
119pub 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
132// ---------------------------------------------------------------------------
133// Tag parsing
134// ---------------------------------------------------------------------------
135
136/// Parse `[tags: a, b, c]` embedded in a memory text. Returns a sorted,
137/// deduplicated list of lower-cased tags.
138pub fn parse_tags(text: &str) -> Vec<String> {
139    // Match the first `[tags: ...]` block, case-insensitive.
140    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..]; // skip "[tags:"
145    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
159// ---------------------------------------------------------------------------
160// Union-find
161// ---------------------------------------------------------------------------
162
163struct 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]); // path compression
177        }
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
190// ---------------------------------------------------------------------------
191// Row loading
192// ---------------------------------------------------------------------------
193
194/// Load all active, non-superseded memories that have an embedding.
195/// Grouped by model_id so brute-force cosine only runs within model.
196pub 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        // Skip rows whose embedding blob doesn't decode cleanly.
241        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
266// ---------------------------------------------------------------------------
267// Survivor selection
268// ---------------------------------------------------------------------------
269
270/// Score a row for survivor selection: higher is better.
271/// Uses `usefulness_score * recency_rank` where recency_rank is a
272/// normalized position in a list sorted newest-first (index 0 = 1.0).
273fn 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
278/// Parse an RFC-3339 timestamp into a comparable seconds value.
279fn parse_ts(ts: &str) -> i64 {
280    OffsetDateTime::parse(ts, &Rfc3339)
281        .map(|t| t.unix_timestamp())
282        .unwrap_or(0)
283}
284
285/// Choose the survivor from a cluster of rows.
286/// Picks the row with the highest `(usefulness_score + 1) * recency_rank`.
287/// Tie-break: lexicographically largest `created_at` (newest).
288fn pick_survivor(cluster: &[usize], rows: &[ConsolidateRow]) -> usize {
289    // Sort cluster rows by newest last_useful_at/created_at desc → assign recency rank.
290    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
320// ---------------------------------------------------------------------------
321// Story 3.1: near-duplicate clustering
322// ---------------------------------------------------------------------------
323
324/// Build merge clusters from `rows` with the given cosine threshold.
325/// Returns only clusters with ≥ 2 members (i.e. at least one merge needed).
326pub 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    // Similarity proposes related claims, not interchangeable text. Until claim
331    // identity is explicit, only identical text with identical applicability may
332    // be destructively consolidated. Do not normalize case/inner whitespace: code
333    // identifiers and quoted values may distinguish claims.
334    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    // Bound pathological embedding-drift buckets. Unexamined rows remain intact.
346    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    // Stable order for deterministic dry-run output.
379    clusters.sort_by(|a, b| a.survivor.memory_id.cmp(&b.survivor.memory_id));
380    clusters
381}
382
383// ---------------------------------------------------------------------------
384// Story 3.1: apply merge (event-sourced)
385// ---------------------------------------------------------------------------
386
387/// Apply one merge cluster to the database.
388///
389/// Consolidate an unchanged identical-claim plan atomically. Copies do not
390/// constitute independent evidence, so survivor counters are not increased.
391/// Durable events preserve supersession and citation reassignment on rebuild.
392pub 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
442// ---------------------------------------------------------------------------
443// Story 3.1: high-level entry point
444// ---------------------------------------------------------------------------
445
446/// Run the consolidation pipeline from a project root.
447///
448/// Loads all embeddable rows, clusters by cosine ≥ threshold, and either
449/// prints the plan (dry-run) or applies it (emit events + update DB).
450pub 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    // Stable order across models for deterministic output.
459    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    // Apply merges.
507    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
538// ---------------------------------------------------------------------------
539// Story 3.2: loose-cluster distillation
540// ---------------------------------------------------------------------------
541
542/// Find loose clusters: cosine in [lo, hi] band AND ≥ 1 shared domain tag.
543/// Only clusters with ≥ `min_size` members are returned.
544pub 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    // Parse tags once for each row.
554    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            // Require ≥ 1 shared tag.
568            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    // Collect root → members.
576    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        // Compute the shared tags across ALL members.
588        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; // no common tag — skip (union may have chained)
594        }
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
606// ---------------------------------------------------------------------------
607// Helpers
608// ---------------------------------------------------------------------------
609
610fn 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// ---------------------------------------------------------------------------
620// Flagship 2 / Story 2.3: Reflection / synthesis
621// ---------------------------------------------------------------------------
622
623/// Options for `run_reflection`.
624#[derive(Debug, Clone, Default)]
625pub struct ReflectionOptions {
626    /// Options for the underlying distillation clustering step.
627    pub distill_opts: DistillOptions,
628    /// When true: print what would be proposed without writing to the DB.
629    pub dry_run: bool,
630}
631
632/// Summary returned by `run_reflection`.
633#[derive(Debug, Default)]
634pub struct ReflectionSummary {
635    pub clusters_found: usize,
636    pub proposals_created: usize,
637}
638
639/// `ModelProvider` trait alias for the reflection step.  We accept an
640/// `Option<&mut dyn ModelProvider>` — when `None`, reflection prints a
641/// report (dry-run behaviour) for each cluster and returns.
642pub trait ModelProvider {
643    fn complete_text(&mut self, prompt: &str) -> Option<String>;
644}
645
646/// Prompt template for the reflection model call.
647const 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
652/// Run the reflection pipeline.
653///
654/// 1. Load all embeddable rows.
655/// 2. Find distillation clusters (loose cosine band) using `DistillOptions`.
656/// 3. For each cluster:
657///    - If `model` is `Some`, call the model to synthesize a principle and
658///      emit a `memory.proposed` event via `apply_events`.
659///    - If `model` is `None` or `dry_run`, print the cluster to `writer`.
660///
661/// Returns a `ReflectionSummary` with cluster and proposal counts.
662pub 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    // dry_run OR no model → print clusters and return.
685    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(); // safe: checked above
707    let run_id = kimetsu_core::ids::RunId::new();
708
709    for cluster in &clusters {
710        // Build the model prompt.
711        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        // Parse the JSON response.
729        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
803/// Parse the first JSON object from a model response into a
804/// `serde_json::Value`.  Returns `None` on any parse error.
805fn 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// ---------------------------------------------------------------------------
841// Tests
842// ---------------------------------------------------------------------------
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use rusqlite::params;
848
849    // ------------------------------------------------------------------
850    // cosine
851    // ------------------------------------------------------------------
852    #[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    // ------------------------------------------------------------------
879    // parse_tags
880    // ------------------------------------------------------------------
881    #[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    // ------------------------------------------------------------------
909    // find_merge_clusters
910    // ------------------------------------------------------------------
911
912    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    // ------------------------------------------------------------------
1018    // find_distill_clusters
1019    // ------------------------------------------------------------------
1020    #[test]
1021    fn find_distill_clusters_requires_shared_tags() {
1022        // Two rows in the 0.75–0.85 cosine band but no shared tags → no cluster.
1023        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        // Three rows with similar vectors AND shared tag "ci".
1043        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; // silence unused
1051        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    // ------------------------------------------------------------------
1065    // apply_merge (against in-memory SQLite)
1066    // ------------------------------------------------------------------
1067    #[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        // Insert survivor and one member.
1075        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        // Survivor stats updated.
1120        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        // Member superseded.
1137        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        // Insert two memory rows.
1189        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        // Insert a citation for the member.
1201        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        // Citation must now point at survivor.
1238        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        // No citations remain for the member.
1248        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    // ------------------------------------------------------------------
1259    // superseded rows excluded from retrieval
1260    // ------------------------------------------------------------------
1261    #[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        // Insert a survivor and a superseded member.
1271        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        // Populate FTS for survivor only (dup was already removed from FTS on merge).
1292        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    // ------------------------------------------------------------------
1323    // v2→target migration test (integration)
1324    //
1325    // Originally tested v2→v3; updated for S5.2 which added v3→v4 so
1326    // a v2 brain now migrates all the way to the current target version.
1327    // ------------------------------------------------------------------
1328    #[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            // Build a v2 brain with one memory row so the backup fires.
1343            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        // Now open read-write → should trigger all pending migrations + backup.
1361        {
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            // v3 and v4 (and any future steps) must all be in `applied`.
1367            assert!(
1368                outcome.applied.contains(&3),
1369                "v3 must be in applied list, got: {:?}",
1370                outcome.applied
1371            );
1372            // Backup created (non-empty brain).
1373            assert!(
1374                outcome.backup_path.is_some(),
1375                "backup must be created for non-empty brain during migration"
1376            );
1377            // v3 column: superseded_by exists.
1378            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            // v4 table: memory_edges exists.
1388            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    // ------------------------------------------------------------------
1406    // Fix 1: consolidation must be rebuild-safe
1407    //
1408    // Seed two memories (one with a citation pointing at the member),
1409    // set non-zero stats on both via direct SQL (simulating accumulated
1410    // run outcomes), then consolidate.  Capture the exact post-
1411    // consolidation stats and citation target, run `rebuild_in_place`,
1412    // and assert both are IDENTICAL after rebuild.
1413    //
1414    // Pre-fix behaviour: rebuild reverted use_count to 0 because the
1415    // stat accumulation was a direct UPDATE rather than being carried in
1416    // the memory.superseded event payload.
1417    // ------------------------------------------------------------------
1418    #[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        // --- bootstrap via events so the events table is populated --------
1429        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        // Simulate pre-consolidation accumulated stats via direct SQL
1462        // (in production these come from run.finished outcome attribution).
1463        // Survivor: use_count=3, score=5.0  |  Member: use_count=2, score=2.0
1464        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        // Citation pointing at the member via a memory.cited event so it
1478        // will be replayed (not a raw SQL insert that rebuild would wipe).
1479        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        // --- Consolidate --------------------------------------------------
1494        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        // SQL-seeded counters are intentionally not durable. Consolidation
1523        // must neither pool those counters nor manufacture replay evidence.
1524        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        // ---- REBUILD -----
1545        projector::rebuild_in_place(&conn).expect("rebuild_in_place");
1546
1547        // Post-rebuild: stats and citation must match pre-rebuild.
1548        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    // ------------------------------------------------------------------
1577    // Flagship 2 / Story 2.3: reflection / synthesis
1578    // ------------------------------------------------------------------
1579
1580    /// A one-shot mock model returning a canned reflection JSON.
1581    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    /// Insert an embedded memory row (StubEmbedder) carrying a `[tags: ...]`
1591    /// block so it participates in distill clustering.
1592    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    /// Story 2.3 (headline): a cluster of related memories produces a
1610    /// reflection PROPOSAL via the mock model, landing in memory_proposals
1611    /// (pending), not directly accepted.
1612    #[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        // Three near-identical, same-tag memories → one loose cluster.
1618        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        // The proposal landed as PENDING in memory_proposals (review flow).
1658        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    /// Story 2.3: cheap-model-OPTIONAL — with no model, reflection emits a
1673    /// "could be reflected" report and creates NO proposals.
1674    #[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}