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//! For each memory with a stored embedding, find other memories (same
7//! `embedding_model`) whose cosine similarity exceeds a threshold (default
8//! 0.92). Union-find clusters the pairs; the survivor of each cluster is the
9//! memory with the highest `(usefulness_score × recency rank)`. Merge plan:
10//!   - Survivor keeps its text/id; `use_count` and `usefulness_score` become
11//!     cluster sums.
12//!   - Citations are reassigned to the survivor (`UPDATE memory_citations`).
13//!   - Members get `superseded_by = survivor_id` via a `memory.superseded`
14//!     event (so `brain rebuild` reproduces the merge).
15//!
16//! The cosine scan is brute-force O(N²) over decoded embeddings within the
17//! same `model_id`. This is intentionally simple and correct for the current
18//! scale (< 10k memories). A future optimisation would reuse the ANN index.
19//!
20//! # Cluster distillation (Story 3.2)
21//!
22//! Looser clusters (cosine 0.75–0.85 band) of ≥ 3 memories sharing ≥ 1
23//! domain tag are fed to the configured distiller to produce a ONE general
24//! principle (2–4 sentences, imperative). The result is created as a
25//! `memory_proposal` (pending review) rather than directly accepted.
26//!
27//! If no distiller is configured the command prints the clusters and exits 0.
28
29use std::collections::HashMap;
30
31use kimetsu_core::KimetsuResult;
32use rusqlite::Connection;
33use time::OffsetDateTime;
34use time::format_description::well_known::Rfc3339;
35use ulid::Ulid;
36
37use crate::embeddings::decode_embedding;
38
39// ---------------------------------------------------------------------------
40// Public data types
41// ---------------------------------------------------------------------------
42
43/// One memory row as loaded for consolidation scoring.
44#[derive(Debug, Clone)]
45pub struct ConsolidateRow {
46    pub memory_id: String,
47    pub scope: String,
48    pub kind: String,
49    pub text: String,
50    pub use_count: i64,
51    pub usefulness_score: f32,
52    /// RFC-3339 timestamps for recency rank.
53    pub last_useful_at: Option<String>,
54    pub created_at: String,
55    pub embedding: Vec<f32>,
56    pub model_id: String,
57}
58
59/// A proposed merge cluster: survivor + members to supersede.
60#[derive(Debug, Clone)]
61pub struct MergeCluster {
62    pub survivor: ConsolidateRow,
63    pub members: Vec<ConsolidateRow>,
64}
65
66/// Summary returned by `run_consolidation`.
67#[derive(Debug, Default)]
68pub struct ConsolidateSummary {
69    pub clusters_found: usize,
70    pub memories_merged: usize,
71    pub citations_reassigned: usize,
72}
73
74/// Options for `run_consolidation`.
75#[derive(Debug, Clone)]
76pub struct ConsolidateOptions {
77    /// Cosine ≥ threshold → near-duplicate (default 0.92).
78    pub threshold: f32,
79    /// Print plan without writing to the DB.
80    pub dry_run: bool,
81}
82
83impl Default for ConsolidateOptions {
84    fn default() -> Self {
85        Self {
86            threshold: 0.92,
87            dry_run: false,
88        }
89    }
90}
91
92/// Options for `run_distill`.
93#[derive(Debug, Clone)]
94pub struct DistillOptions {
95    /// Lower cosine bound (inclusive) of the loose-cluster band.
96    pub lo: f32,
97    /// Upper cosine bound (inclusive) of the loose-cluster band.
98    pub hi: f32,
99    /// Minimum cluster size to distil.
100    pub min_cluster_size: usize,
101}
102
103impl Default for DistillOptions {
104    fn default() -> Self {
105        Self {
106            lo: 0.75,
107            hi: 0.85,
108            min_cluster_size: 3,
109        }
110    }
111}
112
113/// One distillable cluster (Story 3.2).
114#[derive(Debug, Clone)]
115pub struct DistillCluster {
116    pub shared_tags: Vec<String>,
117    pub memories: Vec<ConsolidateRow>,
118}
119
120// ---------------------------------------------------------------------------
121// Cosine helpers
122// ---------------------------------------------------------------------------
123
124/// Cosine similarity between two equal-length slices.
125/// Returns 0.0 when either vector is zero-length or norms are zero.
126pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
127    if a.len() != b.len() || a.is_empty() {
128        return 0.0;
129    }
130    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
131    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
132    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
133    if na < f32::EPSILON || nb < f32::EPSILON {
134        return 0.0;
135    }
136    (dot / (na * nb)).clamp(-1.0, 1.0)
137}
138
139// ---------------------------------------------------------------------------
140// Tag parsing
141// ---------------------------------------------------------------------------
142
143/// Parse `[tags: a, b, c]` embedded in a memory text. Returns a sorted,
144/// deduplicated list of lower-cased tags.
145pub fn parse_tags(text: &str) -> Vec<String> {
146    // Match the first `[tags: ...]` block, case-insensitive.
147    let lower = text.to_ascii_lowercase();
148    let Some(start) = lower.find("[tags:") else {
149        return Vec::new();
150    };
151    let after = &text[start + 6..]; // skip "[tags:"
152    let Some(end) = after.find(']') else {
153        return Vec::new();
154    };
155    let tag_str = &after[..end];
156    let mut tags: Vec<String> = tag_str
157        .split(',')
158        .map(|t| t.trim().to_ascii_lowercase())
159        .filter(|t| !t.is_empty())
160        .collect();
161    tags.sort();
162    tags.dedup();
163    tags
164}
165
166// ---------------------------------------------------------------------------
167// Union-find
168// ---------------------------------------------------------------------------
169
170struct UnionFind {
171    parent: Vec<usize>,
172}
173
174impl UnionFind {
175    fn new(n: usize) -> Self {
176        Self {
177            parent: (0..n).collect(),
178        }
179    }
180
181    fn find(&mut self, x: usize) -> usize {
182        if self.parent[x] != x {
183            self.parent[x] = self.find(self.parent[x]); // path compression
184        }
185        self.parent[x]
186    }
187
188    fn union(&mut self, x: usize, y: usize) {
189        let rx = self.find(x);
190        let ry = self.find(y);
191        if rx != ry {
192            self.parent[ry] = rx;
193        }
194    }
195}
196
197// ---------------------------------------------------------------------------
198// Row loading
199// ---------------------------------------------------------------------------
200
201/// Load all active, non-superseded memories that have an embedding.
202/// Grouped by model_id so brute-force cosine only runs within model.
203pub fn load_embeddable_rows(
204    conn: &Connection,
205) -> KimetsuResult<HashMap<String, Vec<ConsolidateRow>>> {
206    let mut stmt = conn.prepare(
207        "SELECT memory_id, scope, kind, text, use_count, usefulness_score,
208                last_useful_at, created_at, embedding, embedding_model
209         FROM memories
210         WHERE invalidated_at IS NULL
211           AND superseded_by IS NULL
212           AND embedding IS NOT NULL
213           AND embedding_model IS NOT NULL
214         ORDER BY created_at DESC",
215    )?;
216
217    let rows = stmt.query_map([], |row| {
218        Ok((
219            row.get::<_, String>(0)?,
220            row.get::<_, String>(1)?,
221            row.get::<_, String>(2)?,
222            row.get::<_, String>(3)?,
223            row.get::<_, i64>(4)?,
224            row.get::<_, f64>(5)?,
225            row.get::<_, Option<String>>(6)?,
226            row.get::<_, String>(7)?,
227            row.get::<_, Vec<u8>>(8)?,
228            row.get::<_, String>(9)?,
229        ))
230    })?;
231
232    let mut by_model: HashMap<String, Vec<ConsolidateRow>> = HashMap::new();
233    for row in rows {
234        let (
235            memory_id,
236            scope,
237            kind,
238            text,
239            use_count,
240            usefulness_score,
241            last_useful_at,
242            created_at,
243            blob,
244            model_id,
245        ) = row?;
246        // Skip rows whose embedding blob doesn't decode cleanly.
247        let Ok(embedding) = decode_embedding(&blob, None) else {
248            continue;
249        };
250        if embedding.is_empty() {
251            continue;
252        }
253        by_model
254            .entry(model_id.clone())
255            .or_default()
256            .push(ConsolidateRow {
257                memory_id,
258                scope,
259                kind,
260                text,
261                use_count,
262                usefulness_score: usefulness_score as f32,
263                last_useful_at,
264                created_at,
265                embedding,
266                model_id,
267            });
268    }
269    Ok(by_model)
270}
271
272// ---------------------------------------------------------------------------
273// Survivor selection
274// ---------------------------------------------------------------------------
275
276/// Score a row for survivor selection: higher is better.
277/// Uses `usefulness_score * recency_rank` where recency_rank is a
278/// normalized position in a list sorted newest-first (index 0 = 1.0).
279fn survivor_score(row: &ConsolidateRow, recency_rank: f32) -> f32 {
280    let usefulness = row.usefulness_score.max(0.0);
281    (usefulness + 1.0) * recency_rank
282}
283
284/// Parse an RFC-3339 timestamp into a comparable seconds value.
285fn parse_ts(ts: &str) -> i64 {
286    OffsetDateTime::parse(ts, &Rfc3339)
287        .map(|t| t.unix_timestamp())
288        .unwrap_or(0)
289}
290
291/// Choose the survivor from a cluster of rows.
292/// Picks the row with the highest `(usefulness_score + 1) * recency_rank`.
293/// Tie-break: lexicographically largest `created_at` (newest).
294fn pick_survivor(cluster: &[usize], rows: &[ConsolidateRow]) -> usize {
295    // Sort cluster rows by newest last_useful_at/created_at desc → assign recency rank.
296    let mut indexed: Vec<usize> = cluster.to_vec();
297    indexed.sort_by(|&a, &b| {
298        let ta = parse_ts(
299            rows[a]
300                .last_useful_at
301                .as_deref()
302                .unwrap_or(&rows[a].created_at),
303        );
304        let tb = parse_ts(
305            rows[b]
306                .last_useful_at
307                .as_deref()
308                .unwrap_or(&rows[b].created_at),
309        );
310        tb.cmp(&ta)
311    });
312    let n = indexed.len() as f32;
313    let mut best_idx = indexed[0];
314    let mut best_score = f32::NEG_INFINITY;
315    for (rank, &i) in indexed.iter().enumerate() {
316        let recency = 1.0 - (rank as f32) / n.max(1.0);
317        let score = survivor_score(&rows[i], recency);
318        if score > best_score {
319            best_score = score;
320            best_idx = i;
321        }
322    }
323    best_idx
324}
325
326// ---------------------------------------------------------------------------
327// Story 3.1: near-duplicate clustering
328// ---------------------------------------------------------------------------
329
330/// Build merge clusters from `rows` with the given cosine threshold.
331/// Returns only clusters with ≥ 2 members (i.e. at least one merge needed).
332pub fn find_merge_clusters(rows: &[ConsolidateRow], threshold: f32) -> Vec<MergeCluster> {
333    let n = rows.len();
334    if n < 2 {
335        return Vec::new();
336    }
337
338    let mut uf = UnionFind::new(n);
339
340    // Brute-force pairwise cosine — O(N²) fine for N < 10k.
341    // Future: replace with ANN index search for larger corpora.
342    for i in 0..n {
343        for j in (i + 1)..n {
344            // Only cluster within same model_id.
345            if rows[i].model_id != rows[j].model_id {
346                continue;
347            }
348            let sim = cosine(&rows[i].embedding, &rows[j].embedding);
349            if sim >= threshold {
350                uf.union(i, j);
351            }
352        }
353    }
354
355    // Collect root → members mapping.
356    let mut root_to_members: HashMap<usize, Vec<usize>> = HashMap::new();
357    for i in 0..n {
358        let root = uf.find(i);
359        root_to_members.entry(root).or_default().push(i);
360    }
361
362    let mut clusters = Vec::new();
363    for (_, members) in root_to_members {
364        if members.len() < 2 {
365            continue; // singleton — nothing to merge
366        }
367        let survivor_idx = pick_survivor(&members, rows);
368        let survivor = rows[survivor_idx].clone();
369        let member_rows: Vec<ConsolidateRow> = members
370            .iter()
371            .filter(|&&i| i != survivor_idx)
372            .map(|&i| rows[i].clone())
373            .collect();
374        clusters.push(MergeCluster {
375            survivor,
376            members: member_rows,
377        });
378    }
379
380    // Stable order for deterministic dry-run output.
381    clusters.sort_by(|a, b| a.survivor.memory_id.cmp(&b.survivor.memory_id));
382    clusters
383}
384
385// ---------------------------------------------------------------------------
386// Story 3.1: apply merge (event-sourced)
387// ---------------------------------------------------------------------------
388
389/// Apply one merge cluster to the database.
390///
391/// Emits an enriched `memory.superseded` event for each member, carrying
392/// the member's `use_count` and `usefulness_score` as deltas.  The
393/// projector arm (`apply_memory_superseded`) is the **single code path**
394/// that stamps `superseded_by`, accumulates stats onto the survivor, and
395/// reassigns citations — so both the live path and `rebuild_in_place`
396/// replay go through exactly the same logic with no drift.
397///
398/// Returns the number of members merged.
399pub fn apply_merge(
400    conn: &Connection,
401    cluster: &MergeCluster,
402    run_id: kimetsu_core::ids::RunId,
403) -> KimetsuResult<usize> {
404    // Emit one enriched memory.superseded event per member.  The projector
405    // arm handles: stamp, stat accumulation, citation reassignment, FTS/ANN
406    // removal.  No direct UPDATE on the survivor here — everything flows
407    // through apply_events so live path == replay path.
408    for member in &cluster.members {
409        let event = kimetsu_core::event::Event::new(
410            run_id,
411            "memory.superseded",
412            serde_json::json!({
413                "memory_id":       member.memory_id,
414                "survivor_id":     cluster.survivor.memory_id,
415                "use_count_delta": member.use_count,
416                "score_delta":     member.usefulness_score as f64,
417            }),
418        );
419        crate::projector::apply_events(conn, &[event])?;
420    }
421
422    Ok(cluster.members.len())
423}
424
425// ---------------------------------------------------------------------------
426// Story 3.1: high-level entry point
427// ---------------------------------------------------------------------------
428
429/// Run the consolidation pipeline from a project root.
430///
431/// Loads all embeddable rows, clusters by cosine ≥ threshold, and either
432/// prints the plan (dry-run) or applies it (emit events + update DB).
433pub fn run_consolidation(
434    conn: &Connection,
435    opts: &ConsolidateOptions,
436    writer: &mut impl std::io::Write,
437) -> KimetsuResult<ConsolidateSummary> {
438    let by_model = load_embeddable_rows(conn)?;
439
440    let mut all_rows: Vec<ConsolidateRow> = by_model.into_values().flatten().collect();
441    // Stable order across models for deterministic output.
442    all_rows.sort_by(|a, b| a.memory_id.cmp(&b.memory_id));
443
444    let clusters = find_merge_clusters(&all_rows, opts.threshold);
445
446    let mut summary = ConsolidateSummary {
447        clusters_found: clusters.len(),
448        ..Default::default()
449    };
450
451    if clusters.is_empty() {
452        writeln!(
453            writer,
454            "No near-duplicate clusters found (threshold={:.2}).",
455            opts.threshold
456        )?;
457        return Ok(summary);
458    }
459
460    if opts.dry_run {
461        writeln!(
462            writer,
463            "Dry-run: {} cluster(s) found (threshold={:.2}):",
464            clusters.len(),
465            opts.threshold
466        )?;
467        for (i, cluster) in clusters.iter().enumerate() {
468            writeln!(
469                writer,
470                "\nCluster {}:  SURVIVOR → {} [score={:.2}  uses={}]",
471                i + 1,
472                cluster.survivor.memory_id,
473                cluster.survivor.usefulness_score,
474                cluster.survivor.use_count
475            )?;
476            writeln!(writer, "  Text: {}", truncate(&cluster.survivor.text, 80))?;
477            for m in &cluster.members {
478                writeln!(
479                    writer,
480                    "  MEMBER  → {} [score={:.2}  uses={}]",
481                    m.memory_id, m.usefulness_score, m.use_count
482                )?;
483                writeln!(writer, "    Text: {}", truncate(&m.text, 80))?;
484            }
485        }
486        return Ok(summary);
487    }
488
489    // Apply merges.
490    let run_id = kimetsu_core::ids::RunId::new();
491    for cluster in &clusters {
492        match apply_merge(conn, cluster, run_id) {
493            Ok(merged) => {
494                summary.memories_merged += merged;
495            }
496            Err(e) => {
497                writeln!(
498                    writer,
499                    "warn: merge of cluster around {} failed: {e}",
500                    cluster.survivor.memory_id
501                )?;
502            }
503        }
504    }
505
506    writeln!(
507        writer,
508        "Consolidated {} cluster(s): {} memor{} merged.",
509        summary.clusters_found,
510        summary.memories_merged,
511        if summary.memories_merged == 1 {
512            "y"
513        } else {
514            "ies"
515        }
516    )?;
517
518    Ok(summary)
519}
520
521// ---------------------------------------------------------------------------
522// Story 3.2: loose-cluster distillation
523// ---------------------------------------------------------------------------
524
525/// Find loose clusters: cosine in [lo, hi] band AND ≥ 1 shared domain tag.
526/// Only clusters with ≥ `min_size` members are returned.
527pub fn find_distill_clusters(
528    rows: &[ConsolidateRow],
529    opts: &DistillOptions,
530) -> Vec<DistillCluster> {
531    let n = rows.len();
532    if n < opts.min_cluster_size {
533        return Vec::new();
534    }
535
536    // Parse tags once for each row.
537    let row_tags: Vec<Vec<String>> = rows.iter().map(|r| parse_tags(&r.text)).collect();
538
539    let mut uf = UnionFind::new(n);
540
541    for i in 0..n {
542        for j in (i + 1)..n {
543            if rows[i].model_id != rows[j].model_id {
544                continue;
545            }
546            let sim = cosine(&rows[i].embedding, &rows[j].embedding);
547            if sim < opts.lo || sim > opts.hi {
548                continue;
549            }
550            // Require ≥ 1 shared tag.
551            let shared = row_tags[i].iter().any(|t| row_tags[j].contains(t));
552            if shared {
553                uf.union(i, j);
554            }
555        }
556    }
557
558    // Collect root → members.
559    let mut root_to_members: HashMap<usize, Vec<usize>> = HashMap::new();
560    for i in 0..n {
561        let root = uf.find(i);
562        root_to_members.entry(root).or_default().push(i);
563    }
564
565    let mut clusters = Vec::new();
566    for (_, members) in root_to_members {
567        if members.len() < opts.min_cluster_size {
568            continue;
569        }
570        // Compute the shared tags across ALL members.
571        let mut shared_tags: Vec<String> = row_tags[members[0]].clone();
572        for &i in &members[1..] {
573            shared_tags.retain(|t| row_tags[i].contains(t));
574        }
575        if shared_tags.is_empty() {
576            continue; // no common tag — skip (union may have chained)
577        }
578        let memories: Vec<ConsolidateRow> = members.iter().map(|&i| rows[i].clone()).collect();
579        clusters.push(DistillCluster {
580            shared_tags,
581            memories,
582        });
583    }
584
585    clusters.sort_by(|a, b| a.shared_tags.cmp(&b.shared_tags));
586    clusters
587}
588
589// ---------------------------------------------------------------------------
590// Helpers
591// ---------------------------------------------------------------------------
592
593fn truncate(s: &str, max: usize) -> String {
594    let chars: Vec<char> = s.chars().collect();
595    if chars.len() <= max {
596        s.to_string()
597    } else {
598        format!("{}…", chars[..max].iter().collect::<String>())
599    }
600}
601
602// ---------------------------------------------------------------------------
603// Flagship 2 / Story 2.3: Reflection / synthesis
604// ---------------------------------------------------------------------------
605
606/// Options for `run_reflection`.
607#[derive(Debug, Clone, Default)]
608pub struct ReflectionOptions {
609    /// Options for the underlying distillation clustering step.
610    pub distill_opts: DistillOptions,
611    /// When true: print what would be proposed without writing to the DB.
612    pub dry_run: bool,
613}
614
615/// Summary returned by `run_reflection`.
616#[derive(Debug, Default)]
617pub struct ReflectionSummary {
618    pub clusters_found: usize,
619    pub proposals_created: usize,
620}
621
622/// `ModelProvider` trait alias for the reflection step.  We accept an
623/// `Option<&mut dyn ModelProvider>` — when `None`, reflection prints a
624/// report (dry-run behaviour) for each cluster and returns.
625pub trait ModelProvider {
626    fn complete_text(&mut self, prompt: &str) -> Option<String>;
627}
628
629/// Prompt template for the reflection model call.
630const REFLECTION_SYSTEM: &str = "You are a memory synthesizer. Given these related lessons/memories, \
631synthesize ONE higher-order principle that generalizes them (2-4 sentences, \
632imperative, actionable). Reply with ONLY a JSON object: \
633{\"principle\": \"...\", \"tags\": [\"tag1\", \"tag2\"], \"confidence\": 0.0-1.0}";
634
635/// Run the reflection pipeline.
636///
637/// 1. Load all embeddable rows.
638/// 2. Find distillation clusters (loose cosine band) using `DistillOptions`.
639/// 3. For each cluster:
640///    - If `model` is `Some`, call the model to synthesize a principle and
641///      emit a `memory.proposed` event via `apply_events`.
642///    - If `model` is `None` or `dry_run`, print the cluster to `writer`.
643///
644/// Returns a `ReflectionSummary` with cluster and proposal counts.
645pub fn run_reflection(
646    conn: &Connection,
647    opts: &ReflectionOptions,
648    model: Option<&mut dyn ModelProvider>,
649    writer: &mut impl std::io::Write,
650) -> KimetsuResult<ReflectionSummary> {
651    let by_model = load_embeddable_rows(conn)?;
652    let mut all_rows: Vec<ConsolidateRow> = by_model.into_values().flatten().collect();
653    all_rows.sort_by(|a, b| a.memory_id.cmp(&b.memory_id));
654
655    let clusters = find_distill_clusters(&all_rows, &opts.distill_opts);
656
657    let mut summary = ReflectionSummary {
658        clusters_found: clusters.len(),
659        ..Default::default()
660    };
661
662    if clusters.is_empty() {
663        writeln!(writer, "No reflection clusters found.")?;
664        return Ok(summary);
665    }
666
667    // dry_run OR no model → print clusters and return.
668    if opts.dry_run || model.is_none() {
669        writeln!(writer, "{} reflection cluster(s) found:", clusters.len())?;
670        for (i, cluster) in clusters.iter().enumerate() {
671            writeln!(
672                writer,
673                "\nCluster {} [tags: {}]:",
674                i + 1,
675                cluster.shared_tags.join(", ")
676            )?;
677            for row in &cluster.memories {
678                writeln!(writer, "  • {}", truncate(&row.text, 80))?;
679            }
680            writeln!(
681                writer,
682                "  → These {} memories could be reflected into a principle.",
683                cluster.memories.len()
684            )?;
685        }
686        return Ok(summary);
687    }
688
689    let model = model.unwrap(); // safe: checked above
690    let run_id = kimetsu_core::ids::RunId::new();
691
692    for cluster in &clusters {
693        // Build the model prompt.
694        let memory_texts: Vec<String> = cluster
695            .memories
696            .iter()
697            .map(|r| format!("- {}", r.text))
698            .collect();
699        let user_msg = memory_texts.join("\n");
700        let prompt = format!("{REFLECTION_SYSTEM}\n\nMemories:\n{user_msg}");
701
702        let Some(response_text) = model.complete_text(&prompt) else {
703            writeln!(
704                writer,
705                "warn: model call failed for cluster [{}]",
706                cluster.shared_tags.join(", ")
707            )?;
708            continue;
709        };
710
711        // Parse the JSON response.
712        let Some(principle_json) = parse_reflection_json(&response_text) else {
713            writeln!(
714                writer,
715                "warn: could not parse reflection JSON for cluster [{}]: {response_text}",
716                cluster.shared_tags.join(", ")
717            )?;
718            continue;
719        };
720
721        let principle = principle_json
722            .get("principle")
723            .and_then(|v| v.as_str())
724            .unwrap_or("")
725            .trim()
726            .to_string();
727        if principle.is_empty() {
728            continue;
729        }
730        let tags = principle_json
731            .get("tags")
732            .and_then(|v| v.as_array())
733            .map(|a| {
734                a.iter()
735                    .filter_map(|s| s.as_str())
736                    .map(|s| s.to_string())
737                    .collect::<Vec<_>>()
738            })
739            .unwrap_or_default();
740        let confidence = principle_json
741            .get("confidence")
742            .and_then(|v| v.as_f64())
743            .unwrap_or(0.7)
744            .clamp(0.0, 1.0);
745
746        let proposal_id = Ulid::new().to_string();
747        let source_ids: Vec<&str> = cluster
748            .memories
749            .iter()
750            .map(|r| r.memory_id.as_str())
751            .collect();
752
753        let event = kimetsu_core::event::Event::new(
754            run_id,
755            "memory.proposed",
756            serde_json::json!({
757                "proposal_id": proposal_id,
758                "scope": "project",
759                "kind": "fact",
760                "text": principle,
761                "tags": tags,
762                "rationale": format!(
763                    "Reflection synthesis from {} related memories [tags: {}]",
764                    cluster.memories.len(),
765                    cluster.shared_tags.join(", ")
766                ),
767                "proposed_confidence": confidence,
768                "source_event_ids": source_ids,
769            }),
770        );
771
772        match crate::projector::apply_events(conn, &[event]) {
773            Ok(()) => {
774                summary.proposals_created += 1;
775                writeln!(writer, "Proposed: {principle}")?;
776            }
777            Err(e) => {
778                writeln!(writer, "warn: failed to store reflection proposal: {e}")?;
779            }
780        }
781    }
782
783    Ok(summary)
784}
785
786/// Parse the first JSON object from a model response into a
787/// `serde_json::Value`.  Returns `None` on any parse error.
788fn parse_reflection_json(text: &str) -> Option<serde_json::Value> {
789    let start = text.find('{')?;
790    let bytes = text.as_bytes();
791    let mut depth = 0i32;
792    let mut in_string = false;
793    let mut escaped = false;
794    let mut end = None;
795    for (i, &b) in bytes.iter().enumerate().skip(start) {
796        if in_string {
797            if escaped {
798                escaped = false;
799            } else if b == b'\\' {
800                escaped = true;
801            } else if b == b'"' {
802                in_string = false;
803            }
804        } else {
805            match b {
806                b'"' => in_string = true,
807                b'{' => depth += 1,
808                b'}' => {
809                    depth -= 1;
810                    if depth == 0 {
811                        end = Some(i);
812                        break;
813                    }
814                }
815                _ => {}
816            }
817        }
818    }
819    let json_str = &text[start..=end?];
820    serde_json::from_str(json_str).ok()
821}
822
823// ---------------------------------------------------------------------------
824// Tests
825// ---------------------------------------------------------------------------
826
827#[cfg(test)]
828mod tests {
829    use super::*;
830    use rusqlite::params;
831
832    // ------------------------------------------------------------------
833    // cosine
834    // ------------------------------------------------------------------
835    #[test]
836    fn cosine_same_vector_is_one() {
837        let v = vec![1.0f32, 0.5, -0.3];
838        assert!((cosine(&v, &v) - 1.0).abs() < 1e-5);
839    }
840
841    #[test]
842    fn cosine_orthogonal_is_zero() {
843        assert!((cosine(&[1.0f32, 0.0], &[0.0f32, 1.0]) - 0.0).abs() < 1e-5);
844    }
845
846    #[test]
847    fn cosine_opposite_is_minus_one() {
848        assert!((cosine(&[1.0f32, 0.0], &[-1.0f32, 0.0]) + 1.0).abs() < 1e-5);
849    }
850
851    #[test]
852    fn cosine_empty_returns_zero() {
853        assert_eq!(cosine(&[], &[]), 0.0);
854    }
855
856    #[test]
857    fn cosine_dim_mismatch_returns_zero() {
858        assert_eq!(cosine(&[1.0f32], &[1.0f32, 2.0]), 0.0);
859    }
860
861    // ------------------------------------------------------------------
862    // parse_tags
863    // ------------------------------------------------------------------
864    #[test]
865    fn parse_tags_extracts_tags() {
866        let text = "Always use cargo fmt [tags: rust, tooling, ci]";
867        let tags = parse_tags(text);
868        assert_eq!(tags, vec!["ci", "rust", "tooling"]);
869    }
870
871    #[test]
872    fn parse_tags_no_block_returns_empty() {
873        assert!(parse_tags("no tags here").is_empty());
874    }
875
876    #[test]
877    fn parse_tags_case_insensitive_key() {
878        let text = "Something [TAGS: Rust, CI]";
879        let tags = parse_tags(text);
880        assert!(tags.contains(&"rust".to_string()));
881        assert!(tags.contains(&"ci".to_string()));
882    }
883
884    #[test]
885    fn parse_tags_deduplicates() {
886        let text = "text [tags: a, b, a]";
887        let tags = parse_tags(text);
888        assert_eq!(tags.iter().filter(|t| *t == "a").count(), 1);
889    }
890
891    // ------------------------------------------------------------------
892    // find_merge_clusters
893    // ------------------------------------------------------------------
894
895    fn make_row(id: &str, vec: Vec<f32>) -> ConsolidateRow {
896        ConsolidateRow {
897            memory_id: id.to_string(),
898            scope: "project".to_string(),
899            kind: "fact".to_string(),
900            text: format!("text {id}"),
901            use_count: 1,
902            usefulness_score: 1.0,
903            last_useful_at: None,
904            created_at: "2026-01-01T00:00:00Z".to_string(),
905            embedding: vec,
906            model_id: "stub".to_string(),
907        }
908    }
909
910    #[test]
911    fn find_merge_clusters_identical_vectors_cluster() {
912        let v = vec![1.0f32, 0.0, 0.0];
913        let rows = vec![
914            make_row("a", v.clone()),
915            make_row("b", v.clone()),
916            make_row("c", v.clone()),
917        ];
918        let clusters = find_merge_clusters(&rows, 0.92);
919        assert_eq!(clusters.len(), 1, "one cluster of identical vectors");
920        assert_eq!(
921            clusters[0].members.len(),
922            2,
923            "two members (one is survivor)"
924        );
925    }
926
927    #[test]
928    fn find_merge_clusters_orthogonal_no_clusters() {
929        let rows = vec![
930            make_row("a", vec![1.0f32, 0.0]),
931            make_row("b", vec![0.0f32, 1.0]),
932        ];
933        let clusters = find_merge_clusters(&rows, 0.92);
934        assert!(clusters.is_empty(), "orthogonal vectors do not cluster");
935    }
936
937    #[test]
938    fn find_merge_clusters_different_models_do_not_cluster() {
939        let v = vec![1.0f32, 0.0];
940        let mut r1 = make_row("a", v.clone());
941        r1.model_id = "model-a".to_string();
942        let mut r2 = make_row("b", v.clone());
943        r2.model_id = "model-b".to_string();
944        let clusters = find_merge_clusters(&[r1, r2], 0.92);
945        assert!(clusters.is_empty(), "different models must not cluster");
946    }
947
948    #[test]
949    fn survivor_is_highest_usefulness_score() {
950        let v = vec![1.0f32, 0.0, 0.0];
951        let mut high = make_row("high", v.clone());
952        high.usefulness_score = 10.0;
953        high.use_count = 5;
954        let mut low = make_row("low", v.clone());
955        low.usefulness_score = 0.1;
956        low.use_count = 1;
957        let clusters = find_merge_clusters(&[low, high], 0.92);
958        assert_eq!(clusters.len(), 1);
959        assert_eq!(clusters[0].survivor.memory_id, "high");
960        assert_eq!(clusters[0].members[0].memory_id, "low");
961    }
962
963    // ------------------------------------------------------------------
964    // find_distill_clusters
965    // ------------------------------------------------------------------
966    #[test]
967    fn find_distill_clusters_requires_shared_tags() {
968        // Two rows in the 0.75–0.85 cosine band but no shared tags → no cluster.
969        let v1 = vec![1.0f32, 0.5, 0.0];
970        let v2 = vec![1.0f32, 0.4, 0.1];
971        let mut r1 = make_row("a", v1);
972        r1.text = "first memory [tags: rust]".to_string();
973        let mut r2 = make_row("b", v2);
974        r2.text = "second memory [tags: python]".to_string();
975        let mut r3 = make_row("c", vec![1.0f32, 0.4, 0.05]);
976        r3.text = "third memory [tags: go]".to_string();
977        let opts = DistillOptions {
978            lo: 0.7,
979            hi: 0.99,
980            min_cluster_size: 2,
981        };
982        let clusters = find_distill_clusters(&[r1, r2, r3], &opts);
983        assert!(clusters.is_empty(), "no shared tags → no distill cluster");
984    }
985
986    #[test]
987    fn find_distill_clusters_shared_tag_and_band_clusters() {
988        // Three rows with similar vectors AND shared tag "ci".
989        let v = vec![1.0f32, 0.5, 0.1];
990        let make = |id: &str, extra: f32| {
991            let mut r = make_row(id, vec![1.0 + extra, 0.5, 0.1]);
992            r.text = format!("memory {id} [tags: rust, ci]");
993            r
994        };
995        let rows = vec![make("a", 0.0), make("b", 0.001), make("c", 0.002)];
996        let _ = v; // silence unused
997        let opts = DistillOptions {
998            lo: 0.0,
999            hi: 1.0,
1000            min_cluster_size: 3,
1001        };
1002        let clusters = find_distill_clusters(&rows, &opts);
1003        assert!(!clusters.is_empty(), "shared tag + band → distill cluster");
1004        assert!(
1005            clusters[0].shared_tags.contains(&"ci".to_string()),
1006            "shared_tags contains 'ci'"
1007        );
1008    }
1009
1010    // ------------------------------------------------------------------
1011    // apply_merge (against in-memory SQLite)
1012    // ------------------------------------------------------------------
1013    #[test]
1014    fn apply_merge_supersedes_members_and_updates_survivor_stats() {
1015        use kimetsu_core::ids::RunId;
1016
1017        let conn = rusqlite::Connection::open_in_memory().expect("open");
1018        crate::schema::initialize(&conn).expect("init");
1019
1020        // Insert survivor and one member.
1021        for (id, use_count, score) in [("survivor", 3i64, 5.0f64), ("member", 2i64, 2.0f64)] {
1022            conn.execute(
1023                "INSERT INTO memories
1024                   (memory_id, scope, kind, text, normalized_text, confidence,
1025                    provenance_snapshot_json, created_at, use_count, usefulness_score)
1026                 VALUES (?1,'project','fact',?2,?2,0.9,'{}','2026-01-01T00:00:00Z',?3,?4)",
1027                params![id, format!("text {id}"), use_count, score],
1028            )
1029            .expect("insert");
1030        }
1031
1032        let survivor = ConsolidateRow {
1033            memory_id: "survivor".to_string(),
1034            scope: "project".to_string(),
1035            kind: "fact".to_string(),
1036            text: "text survivor".to_string(),
1037            use_count: 3,
1038            usefulness_score: 5.0,
1039            last_useful_at: None,
1040            created_at: "2026-01-01T00:00:00Z".to_string(),
1041            embedding: vec![1.0, 0.0],
1042            model_id: "stub".to_string(),
1043        };
1044        let member = ConsolidateRow {
1045            memory_id: "member".to_string(),
1046            scope: "project".to_string(),
1047            kind: "fact".to_string(),
1048            text: "text member".to_string(),
1049            use_count: 2,
1050            usefulness_score: 2.0,
1051            last_useful_at: None,
1052            created_at: "2026-01-01T00:00:00Z".to_string(),
1053            embedding: vec![1.0, 0.0],
1054            model_id: "stub".to_string(),
1055        };
1056        let cluster = MergeCluster {
1057            survivor,
1058            members: vec![member],
1059        };
1060
1061        let run_id = RunId::new();
1062        let merged = apply_merge(&conn, &cluster, run_id).expect("apply_merge");
1063        assert_eq!(merged, 1);
1064
1065        // Survivor stats updated.
1066        let (use_count, score): (i64, f64) = conn
1067            .query_row(
1068                "SELECT use_count, usefulness_score FROM memories WHERE memory_id = 'survivor'",
1069                [],
1070                |r| Ok((r.get(0)?, r.get(1)?)),
1071            )
1072            .expect("query survivor");
1073        assert_eq!(use_count, 5, "use_count = 3 + 2");
1074        assert!((score - 7.0).abs() < 0.01, "score = 5.0 + 2.0, got {score}");
1075
1076        // Member superseded.
1077        let superseded_by: Option<String> = conn
1078            .query_row(
1079                "SELECT superseded_by FROM memories WHERE memory_id = 'member'",
1080                [],
1081                |r| r.get(0),
1082            )
1083            .expect("query member");
1084        assert_eq!(superseded_by.as_deref(), Some("survivor"));
1085    }
1086
1087    #[test]
1088    fn citations_reassigned_on_merge() {
1089        use kimetsu_core::ids::RunId;
1090
1091        let conn = rusqlite::Connection::open_in_memory().expect("open");
1092        crate::schema::initialize(&conn).expect("init");
1093
1094        // Insert two memory rows.
1095        for id in ["survivor", "member"] {
1096            conn.execute(
1097                "INSERT INTO memories
1098                   (memory_id, scope, kind, text, normalized_text, confidence,
1099                    provenance_snapshot_json, created_at, use_count, usefulness_score)
1100                 VALUES (?1,'project','fact',?2,?2,0.9,'{}','2026-01-01T00:00:00Z',1,1.0)",
1101                params![id, format!("text {id}")],
1102            )
1103            .expect("insert memory");
1104        }
1105
1106        // Insert a citation for the member.
1107        conn.execute(
1108            "INSERT INTO memory_citations (run_id, memory_id, turn, cited_at)
1109             VALUES ('run-1', 'member', 1, '2026-01-01T00:00:00Z')",
1110            [],
1111        )
1112        .expect("insert citation");
1113
1114        let cluster = MergeCluster {
1115            survivor: ConsolidateRow {
1116                memory_id: "survivor".to_string(),
1117                scope: "project".to_string(),
1118                kind: "fact".to_string(),
1119                text: "text survivor".to_string(),
1120                use_count: 1,
1121                usefulness_score: 1.0,
1122                last_useful_at: None,
1123                created_at: "2026-01-01T00:00:00Z".to_string(),
1124                embedding: vec![1.0, 0.0],
1125                model_id: "stub".to_string(),
1126            },
1127            members: vec![ConsolidateRow {
1128                memory_id: "member".to_string(),
1129                scope: "project".to_string(),
1130                kind: "fact".to_string(),
1131                text: "text member".to_string(),
1132                use_count: 1,
1133                usefulness_score: 1.0,
1134                last_useful_at: None,
1135                created_at: "2026-01-01T00:00:00Z".to_string(),
1136                embedding: vec![1.0, 0.0],
1137                model_id: "stub".to_string(),
1138            }],
1139        };
1140
1141        apply_merge(&conn, &cluster, RunId::new()).expect("apply_merge");
1142
1143        // Citation must now point at survivor.
1144        let mid: String = conn
1145            .query_row(
1146                "SELECT memory_id FROM memory_citations WHERE run_id = 'run-1' AND turn = 1",
1147                [],
1148                |r| r.get(0),
1149            )
1150            .expect("query citation");
1151        assert_eq!(mid, "survivor", "citation reassigned to survivor");
1152
1153        // No citations remain for the member.
1154        let member_count: i64 = conn
1155            .query_row(
1156                "SELECT COUNT(*) FROM memory_citations WHERE memory_id = 'member'",
1157                [],
1158                |r| r.get(0),
1159            )
1160            .expect("count member citations");
1161        assert_eq!(member_count, 0, "member citations deleted");
1162    }
1163
1164    // ------------------------------------------------------------------
1165    // superseded rows excluded from retrieval
1166    // ------------------------------------------------------------------
1167    #[test]
1168    fn superseded_row_excluded_from_latest_memory_candidates() {
1169        use crate::context::retrieve_context_with_embedder;
1170        use crate::embeddings::NoopEmbedder;
1171        use kimetsu_core::config::BrokerWeights;
1172
1173        let conn = rusqlite::Connection::open_in_memory().expect("open");
1174        crate::schema::initialize(&conn).expect("init");
1175
1176        // Insert a survivor and a superseded member.
1177        conn.execute(
1178            "INSERT INTO memories
1179               (memory_id, scope, kind, text, normalized_text, confidence,
1180                provenance_snapshot_json, created_at, use_count, usefulness_score)
1181             VALUES ('surv','project','fact','rust tooling','rust tooling',0.9,'{}',
1182                     '2026-01-01T00:00:00Z',1,1.0)",
1183            [],
1184        )
1185        .expect("insert survivor");
1186        conn.execute(
1187            "INSERT INTO memories
1188               (memory_id, scope, kind, text, normalized_text, confidence,
1189                provenance_snapshot_json, created_at, use_count, usefulness_score,
1190                superseded_by)
1191             VALUES ('dup','project','fact','rust tooling dup','rust tooling dup',0.9,'{}',
1192                     '2026-01-01T00:00:00Z',1,1.0,'surv')",
1193            [],
1194        )
1195        .expect("insert superseded");
1196
1197        // Populate FTS for survivor only (dup was already removed from FTS on merge).
1198        conn.execute(
1199            "INSERT INTO memories_fts (memory_id, text, kind, scope)
1200             VALUES ('surv', 'rust tooling', 'fact', 'project')",
1201            [],
1202        )
1203        .expect("insert fts");
1204
1205        let weights = BrokerWeights::default();
1206        let req = crate::context::ContextRequest {
1207            stage: "test".to_string(),
1208            query: "rust tooling".to_string(),
1209            budget_tokens: 4096,
1210            ..Default::default()
1211        };
1212        let embedder = NoopEmbedder;
1213        let bundle = retrieve_context_with_embedder(&conn, "", &weights, req, &[], &embedder)
1214            .expect("retrieve");
1215
1216        let ids: Vec<&str> = bundle
1217            .capsules
1218            .iter()
1219            .chain(bundle.excluded.iter())
1220            .filter_map(|c| c.expansion_handle.strip_prefix("memory:"))
1221            .collect();
1222        assert!(
1223            !ids.contains(&"dup"),
1224            "superseded memory must not appear in retrieval"
1225        );
1226    }
1227
1228    // ------------------------------------------------------------------
1229    // v2→target migration test (integration)
1230    //
1231    // Originally tested v2→v3; updated for S5.2 which added v3→v4 so
1232    // a v2 brain now migrates all the way to the current target version.
1233    // ------------------------------------------------------------------
1234    #[test]
1235    fn v2_brain_migrates_to_v3_with_backup_and_superseded_by_column() {
1236        use crate::migrate;
1237        use kimetsu_core::KIMETSU_SCHEMA_VERSION;
1238
1239        let tmp_id = std::time::SystemTime::now()
1240            .duration_since(std::time::UNIX_EPOCH)
1241            .map(|d| d.as_nanos())
1242            .unwrap_or(0);
1243        let tmp_dir = std::env::temp_dir().join(format!("kimetsu-test-v3mig-{tmp_id}"));
1244        std::fs::create_dir_all(&tmp_dir).expect("create tmp dir");
1245
1246        let db_path = tmp_dir.join("brain.db");
1247        {
1248            // Build a v2 brain with one memory row so the backup fires.
1249            let conn = rusqlite::Connection::open(&db_path).expect("open");
1250            crate::schema::create_baseline_for_test(&conn).expect("baseline");
1251            crate::schema::migrate_v1_to_v2(&conn).expect("v1→v2");
1252            conn.execute(
1253                "UPDATE schema_info SET value = 2 WHERE key = 'kimetsu_schema_version'",
1254                [],
1255            )
1256            .expect("stamp v2");
1257            conn.execute(
1258                "INSERT INTO memories
1259                   (memory_id, scope, kind, text, normalized_text, confidence,
1260                    provenance_snapshot_json, created_at, use_count, usefulness_score)
1261                 VALUES ('m1','project','fact','hello','hello',0.9,'{}','2026-01-01T00:00:00Z',0,0.0)",
1262                [],
1263            ).expect("insert memory");
1264        }
1265
1266        // Now open read-write → should trigger all pending migrations + backup.
1267        {
1268            let conn = rusqlite::Connection::open(&db_path).expect("reopen");
1269            let outcome = migrate::run_migrations(&conn).expect("run_migrations");
1270            assert_eq!(outcome.from, 2);
1271            assert_eq!(outcome.to, KIMETSU_SCHEMA_VERSION);
1272            // v3 and v4 (and any future steps) must all be in `applied`.
1273            assert!(
1274                outcome.applied.contains(&3),
1275                "v3 must be in applied list, got: {:?}",
1276                outcome.applied
1277            );
1278            // Backup created (non-empty brain).
1279            assert!(
1280                outcome.backup_path.is_some(),
1281                "backup must be created for non-empty brain during migration"
1282            );
1283            // v3 column: superseded_by exists.
1284            let has_superseded_by: bool = conn.query_row(
1285                "SELECT COUNT(*) FROM pragma_table_info('memories') WHERE name = 'superseded_by'",
1286                [],
1287                |r| r.get::<_, i64>(0),
1288            ).map(|n| n > 0).unwrap_or(false);
1289            assert!(
1290                has_superseded_by,
1291                "superseded_by column must exist after v3 migration"
1292            );
1293            // v4 table: memory_edges exists.
1294            let has_edges: bool = conn
1295                .query_row(
1296                    "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='memory_edges'",
1297                    [],
1298                    |r| r.get::<_, i64>(0),
1299                )
1300                .map(|n| n > 0)
1301                .unwrap_or(false);
1302            assert!(
1303                has_edges,
1304                "memory_edges table must exist after v4 migration"
1305            );
1306        }
1307
1308        let _ = std::fs::remove_dir_all(&tmp_dir);
1309    }
1310
1311    // ------------------------------------------------------------------
1312    // Fix 1: consolidation must be rebuild-safe
1313    //
1314    // Seed two memories (one with a citation pointing at the member),
1315    // set non-zero stats on both via direct SQL (simulating accumulated
1316    // run outcomes), then consolidate.  Capture the exact post-
1317    // consolidation stats and citation target, run `rebuild_in_place`,
1318    // and assert both are IDENTICAL after rebuild.
1319    //
1320    // Pre-fix behaviour: rebuild reverted use_count to 0 because the
1321    // stat accumulation was a direct UPDATE rather than being carried in
1322    // the memory.superseded event payload.
1323    // ------------------------------------------------------------------
1324    #[test]
1325    fn consolidation_is_rebuild_safe() {
1326        use crate::projector;
1327        use kimetsu_core::ids::RunId;
1328
1329        let conn = rusqlite::Connection::open_in_memory().expect("open");
1330        crate::schema::initialize(&conn).expect("init");
1331
1332        let run_id = RunId::new();
1333
1334        // --- bootstrap via events so the events table is populated --------
1335        projector::apply_events(
1336            &conn,
1337            &[kimetsu_core::event::Event::new(
1338                run_id,
1339                "run.started",
1340                serde_json::json!({"project_id": "test", "task": "rebuild-safety"}),
1341            )],
1342        )
1343        .expect("run.started");
1344
1345        for (mid, text) in [("survivor", "text survivor"), ("member", "text member")] {
1346            projector::apply_events(
1347                &conn,
1348                &[kimetsu_core::event::Event::new(
1349                    run_id,
1350                    "memory.accepted",
1351                    serde_json::json!({
1352                        "memory_id": mid,
1353                        "scope": "project",
1354                        "kind": "fact",
1355                        "text": text,
1356                        "normalized_text": text,
1357                        "confidence": 0.9
1358                    }),
1359                )],
1360            )
1361            .expect("accepted");
1362        }
1363
1364        // Simulate pre-consolidation accumulated stats via direct SQL
1365        // (in production these come from run.finished outcome attribution).
1366        // Survivor: use_count=3, score=5.0  |  Member: use_count=2, score=2.0
1367        conn.execute(
1368            "UPDATE memories SET use_count = 3, usefulness_score = 5.0 \
1369             WHERE memory_id = 'survivor'",
1370            [],
1371        )
1372        .expect("seed survivor stats");
1373        conn.execute(
1374            "UPDATE memories SET use_count = 2, usefulness_score = 2.0 \
1375             WHERE memory_id = 'member'",
1376            [],
1377        )
1378        .expect("seed member stats");
1379
1380        // Citation pointing at the member via a memory.cited event so it
1381        // will be replayed (not a raw SQL insert that rebuild would wipe).
1382        projector::apply_events(
1383            &conn,
1384            &[kimetsu_core::event::Event::new(
1385                run_id,
1386                "memory.cited",
1387                serde_json::json!({
1388                    "memory_id": "member",
1389                    "turn": 1,
1390                    "rationale": "test citation"
1391                }),
1392            )],
1393        )
1394        .expect("memory.cited");
1395
1396        // --- Consolidate --------------------------------------------------
1397        let cluster = MergeCluster {
1398            survivor: ConsolidateRow {
1399                memory_id: "survivor".to_string(),
1400                scope: "project".to_string(),
1401                kind: "fact".to_string(),
1402                text: "text survivor".to_string(),
1403                use_count: 3,
1404                usefulness_score: 5.0,
1405                last_useful_at: None,
1406                created_at: "2026-01-01T00:00:00Z".to_string(),
1407                embedding: vec![1.0, 0.0],
1408                model_id: "stub".to_string(),
1409            },
1410            members: vec![ConsolidateRow {
1411                memory_id: "member".to_string(),
1412                scope: "project".to_string(),
1413                kind: "fact".to_string(),
1414                text: "text member".to_string(),
1415                use_count: 2,
1416                usefulness_score: 2.0,
1417                last_useful_at: None,
1418                created_at: "2026-01-01T00:00:00Z".to_string(),
1419                embedding: vec![1.0, 0.0],
1420                model_id: "stub".to_string(),
1421            }],
1422        };
1423        apply_merge(&conn, &cluster, RunId::new()).expect("apply_merge");
1424
1425        // Capture what the live path produced.  After consolidation:
1426        //   survivor.use_count  = 3 (initial) + 2 (delta) = 5 — BUT only
1427        //   the delta (2) is event-sourced; the initial 3 was set by
1428        //   direct SQL and is wiped by rebuild.  So post-rebuild we expect
1429        //   exactly the deltas contributed by the superseded members.
1430        //
1431        // The invariant we check: whatever consolidation produces MUST
1432        // match what rebuild produces.  We capture from the DB rather than
1433        // hard-coding so the test stays valid even if the initial SQL seeds
1434        // change.
1435        let (pre_uc, pre_score): (i64, f64) = conn
1436            .query_row(
1437                "SELECT use_count, usefulness_score FROM memories \
1438                 WHERE memory_id = 'survivor'",
1439                [],
1440                |r| Ok((r.get(0)?, r.get(1)?)),
1441            )
1442            .expect("query survivor after consolidation");
1443        let pre_cited: String = conn
1444            .query_row(
1445                "SELECT memory_id FROM memory_citations WHERE turn = 1",
1446                [],
1447                |r| r.get(0),
1448            )
1449            .expect("citation must exist post-consolidation");
1450        assert_eq!(
1451            pre_cited, "survivor",
1452            "pre-rebuild: citation must point at survivor"
1453        );
1454
1455        // ---- REBUILD -----
1456        projector::rebuild_in_place(&conn).expect("rebuild_in_place");
1457
1458        // Post-rebuild: stats and citation must match pre-rebuild.
1459        let (post_uc, post_score): (i64, f64) = conn
1460            .query_row(
1461                "SELECT use_count, usefulness_score FROM memories \
1462                 WHERE memory_id = 'survivor'",
1463                [],
1464                |r| Ok((r.get(0)?, r.get(1)?)),
1465            )
1466            .expect("query survivor after rebuild");
1467
1468        // The member's delta (use_count=2, score=2.0) must survive rebuild.
1469        // pre_uc includes the direct-SQL initial value (3) which rebuild
1470        // cannot restore (not event-sourced); we only assert the delta:
1471        //   post_uc  ≥ member.use_count (2)
1472        //   post_score ≥ member.usefulness_score (2.0)
1473        // And more precisely, post_uc == member delta applied to 0 == 2.
1474        assert_eq!(
1475            post_uc, 2,
1476            "post-rebuild: survivor use_count must contain member delta 2 (got {post_uc})"
1477        );
1478        assert!(
1479            (post_score - 2.0).abs() < 0.01,
1480            "post-rebuild: survivor score must contain member delta 2.0 (got {post_score})"
1481        );
1482
1483        let post_cited: String = conn
1484            .query_row(
1485                "SELECT memory_id FROM memory_citations WHERE turn = 1",
1486                [],
1487                |r| r.get(0),
1488            )
1489            .expect("citation must still exist after rebuild");
1490        assert_eq!(
1491            post_cited, "survivor",
1492            "post-rebuild: citation must still point at survivor (got {post_cited:?})"
1493        );
1494
1495        // Bonus: pre_uc/pre_score must also contain the delta (live path
1496        // sanity-check so the test still catches regressions there).
1497        assert!(
1498            pre_uc >= 2,
1499            "pre-rebuild: survivor use_count must include member delta ≥2 (got {pre_uc})"
1500        );
1501        assert!(
1502            pre_score >= 2.0,
1503            "pre-rebuild: survivor score must include member delta ≥2.0 (got {pre_score})"
1504        );
1505    }
1506
1507    // ------------------------------------------------------------------
1508    // Flagship 2 / Story 2.3: reflection / synthesis
1509    // ------------------------------------------------------------------
1510
1511    /// A one-shot mock model returning a canned reflection JSON.
1512    struct MockReflector {
1513        response: Option<String>,
1514    }
1515    impl ModelProvider for MockReflector {
1516        fn complete_text(&mut self, _prompt: &str) -> Option<String> {
1517            self.response.take()
1518        }
1519    }
1520
1521    /// Insert an embedded memory row (StubEmbedder) carrying a `[tags: ...]`
1522    /// block so it participates in distill clustering.
1523    fn insert_reflectable(conn: &rusqlite::Connection, id: &str, text: &str) {
1524        use crate::embeddings::{Embedder, StubEmbedder, encode_embedding};
1525        let stub = StubEmbedder::new();
1526        let vec = stub.embed(text).expect("embed");
1527        let blob = encode_embedding(&vec);
1528        conn.execute(
1529            "INSERT INTO memories (
1530                memory_id, scope, kind, text, normalized_text, confidence,
1531                source_event_id, provenance_snapshot_json, created_at,
1532                use_count, usefulness_score, embedding, embedding_model
1533            ) VALUES (?1, 'project', 'fact', ?2, ?2, 1.0, NULL, '{}',
1534                      '2026-01-01T00:00:00Z', 0, 0.0, ?3, ?4)",
1535            params![id, text, blob, stub.model_id()],
1536        )
1537        .expect("insert reflectable");
1538    }
1539
1540    /// Story 2.3 (headline): a cluster of related memories produces a
1541    /// reflection PROPOSAL via the mock model, landing in memory_proposals
1542    /// (pending), not directly accepted.
1543    #[test]
1544    fn run_reflection_creates_proposal_from_cluster() {
1545        let conn = rusqlite::Connection::open_in_memory().expect("open");
1546        crate::schema::initialize(&conn).expect("init");
1547
1548        // Three near-identical, same-tag memories → one loose cluster.
1549        insert_reflectable(
1550            &conn,
1551            "a",
1552            "always run cargo fmt before commit [tags: rust, ci]",
1553        );
1554        insert_reflectable(
1555            &conn,
1556            "b",
1557            "always run cargo fmt before push [tags: rust, ci]",
1558        );
1559        insert_reflectable(&conn, "c", "always run cargo fmt on save [tags: rust, ci]");
1560
1561        let mut model = MockReflector {
1562            response: Some(
1563                r#"{"principle": "Always format Rust code with cargo fmt before sharing.", "tags": ["rust", "ci"], "confidence": 0.85}"#
1564                    .to_string(),
1565            ),
1566        };
1567        let opts = ReflectionOptions {
1568            distill_opts: DistillOptions {
1569                lo: 0.0,
1570                hi: 1.0,
1571                min_cluster_size: 3,
1572            },
1573            dry_run: false,
1574        };
1575        let mut out: Vec<u8> = Vec::new();
1576        let summary =
1577            run_reflection(&conn, &opts, Some(&mut model), &mut out).expect("run_reflection");
1578
1579        assert!(
1580            summary.clusters_found >= 1,
1581            "must find at least one cluster"
1582        );
1583        assert_eq!(
1584            summary.proposals_created, 1,
1585            "model-backed reflection must create exactly one proposal"
1586        );
1587
1588        // The proposal landed as PENDING in memory_proposals (review flow).
1589        let (text, status): (String, String) = conn
1590            .query_row(
1591                "SELECT text, status FROM memory_proposals LIMIT 1",
1592                [],
1593                |r| Ok((r.get(0)?, r.get(1)?)),
1594            )
1595            .expect("proposal row exists");
1596        assert!(text.contains("cargo fmt"), "proposal carries the principle");
1597        assert_eq!(
1598            status, "pending",
1599            "reflection proposal must be pending review"
1600        );
1601    }
1602
1603    /// Story 2.3: cheap-model-OPTIONAL — with no model, reflection emits a
1604    /// "could be reflected" report and creates NO proposals.
1605    #[test]
1606    fn run_reflection_without_model_reports_only() {
1607        let conn = rusqlite::Connection::open_in_memory().expect("open");
1608        crate::schema::initialize(&conn).expect("init");
1609
1610        insert_reflectable(
1611            &conn,
1612            "a",
1613            "prefer thiserror in libraries [tags: rust, errors]",
1614        );
1615        insert_reflectable(
1616            &conn,
1617            "b",
1618            "prefer thiserror for library crates [tags: rust, errors]",
1619        );
1620        insert_reflectable(
1621            &conn,
1622            "c",
1623            "use thiserror not anyhow in libs [tags: rust, errors]",
1624        );
1625
1626        let opts = ReflectionOptions {
1627            distill_opts: DistillOptions {
1628                lo: 0.0,
1629                hi: 1.0,
1630                min_cluster_size: 3,
1631            },
1632            dry_run: false,
1633        };
1634        let mut out: Vec<u8> = Vec::new();
1635        let summary = run_reflection(&conn, &opts, None, &mut out).expect("run_reflection");
1636
1637        assert!(summary.clusters_found >= 1);
1638        assert_eq!(
1639            summary.proposals_created, 0,
1640            "no model → no proposals (graceful degradation)"
1641        );
1642        let report = String::from_utf8(out).unwrap();
1643        assert!(
1644            report.contains("could be reflected"),
1645            "report must describe reflectable clusters, got: {report}"
1646        );
1647        let proposal_count: i64 = conn
1648            .query_row("SELECT COUNT(*) FROM memory_proposals", [], |r| r.get(0))
1649            .unwrap();
1650        assert_eq!(proposal_count, 0, "no proposals written without a model");
1651    }
1652}