Skip to main content

kimetsu_brain/
conflict.rs

1//! v0.5.2: conflict detection at ingest.
2//! v2.5 Pass B (Story 1.3): automatic contradiction RESOLUTION.
3//!
4//! Two memories that say opposite things ("use thiserror" /
5//! "use anyhow") confuse the model when both surface in the same
6//! broker bundle. v0.5.0 + v0.5.1 made the brain learn from
7//! outcomes; v0.5.2 prevents the brain from accumulating
8//! contradictions in the first place.
9//!
10//! The detector runs at `add_memory` / `add_user_memory` time:
11//!
12//! 1. Embed the incoming text via the active embedder.
13//! 2. Scan all active memories in the same scope, score cosine
14//!    against the new vector.
15//! 3. Pairs that exceed `DEFAULT_CONFLICT_THRESHOLD` (0.8) AND
16//!    whose `normalized_text` differs from the new text get
17//!    flagged as a conflict.
18//! 4. (a) Auto-resolution (Story 1.3, Pass B): each conflicting pair is scored
19//!    by confidence × recency (newer + higher-confidence wins).  When the
20//!    score gap exceeds `NEAR_TIE_BAND` (0.15) the loser's `valid_to` is
21//!    stamped to now via `mark_memory_temporal` (event-sourced, rebuild-safe,
22//!    lineage preserved — NEVER deleted).  If the new memory loses, the new
23//!    memory is stamped; if the existing memory loses, the existing memory is
24//!    stamped.
25//!    (b) Near-ties (score gap < `NEAR_TIE_BAND`): recorded in
26//!    `memory_conflicts` for operator review — identical to v0.5.2 behavior.
27//!    Nothing silently changes behavior on ambiguous pairs.
28//!
29//! Resolution gate:
30//!   * `KIMETSU_RESOLVE_CONFLICTS` env or `[ingestion] resolve_conflicts`
31//!     config (default true).  Disable values: `0`/`false`/`off`/`no`.
32//!   * Detection must also be enabled — if `detect_conflicts` is off,
33//!     resolution never runs.
34//!
35//! Embedder gating:
36//!   * NoopEmbedder → empty result, no DB writes. Lean builds keep
37//!     v0.4.x behavior.
38//!   * Cross-model rows (embedding_model != active model_id) are
39//!     skipped — cosine across models is meaningless. A subsequent
40//!     `kimetsu brain reindex` would rehydrate them under the
41//!     active model and let the next ingest catch the conflict.
42//!
43//! Resolution policy:
44//!   Pass B: auto-resolves clear winners (|Δ| ≥ 0.15) by stamping the loser's
45//!   `valid_to`; near-ties surface to the operator queue exactly as in v0.5.2.
46
47use kimetsu_core::KimetsuResult;
48use kimetsu_core::ids::new_id;
49use kimetsu_core::memory::{MemoryScope, normalize_memory_text};
50use rusqlite::{Connection, OptionalExtension, params};
51use serde::{Deserialize, Serialize};
52use time::OffsetDateTime;
53use time::format_description::well_known::Rfc3339;
54
55use crate::embeddings::{Embedder, cosine_similarity, decode_embedding};
56
57/// v1.0: config-aware conflict-detection gate.
58///
59/// Resolution precedence (mirrors `user_brain_enabled_with`):
60///   1. `KIMETSU_DETECT_CONFLICTS` env is set → its value wins.
61///      Disable values (`0` / `false` / `off` / `no`) → false.
62///      Any other non-empty value → true.
63///   2. Env unset → `config_value` governs.
64///   3. Default (when no config and no env) → true.
65///
66/// Call sites in `add_memory` and `propose_or_merge_memory` check this
67/// before invoking `detect_and_record` / `find_potential_conflicts`.
68pub fn conflict_detection_enabled(config_value: bool) -> bool {
69    match std::env::var("KIMETSU_DETECT_CONFLICTS") {
70        Ok(raw) => {
71            let v = raw.trim().to_ascii_lowercase();
72            if v.is_empty() {
73                // Empty string — treat as unset, fall through to config.
74                config_value
75            } else {
76                // Any explicit disable value turns it off; everything else on.
77                !matches!(v.as_str(), "0" | "false" | "off" | "no")
78            }
79        }
80        // Env unset → config governs.
81        Err(_) => config_value,
82    }
83}
84
85/// Default cosine-similarity threshold above which two memories
86/// (with differing normalized text) are flagged as a potential
87/// conflict. 0.8 is BGE-small-en-v1.5's empirical "same concept"
88/// floor — tighter than 0.7 (which catches loosely related ideas)
89/// and looser than 0.9 (which only fires on near-paraphrases).
90pub const DEFAULT_CONFLICT_THRESHOLD: f32 = 0.8;
91
92/// Default number of nearest existing memories to evaluate per
93/// ingest. We don't need many — if more than 3 capsules
94/// simultaneously cross the threshold, the deeper bug is duplicate
95/// concepts in the corpus, not a conflict with this one new write.
96pub const DEFAULT_TOP_K: u32 = 3;
97
98/// Story 1.3 / Pass B: score gap below which a conflict is a near-tie and
99/// goes to the operator queue instead of being auto-resolved.
100///
101/// The score is `confidence × recency_weight` (0-1) for each side.
102/// |Δ| < 0.15 means the two memories are "roughly equal" and the
103/// system should not silently pick a winner.
104pub const NEAR_TIE_BAND: f32 = 0.15;
105
106/// Story 1.3 / Pass B: config-aware conflict-resolution gate.
107///
108/// Resolution precedence (mirrors `conflict_detection_enabled`):
109///   1. `KIMETSU_RESOLVE_CONFLICTS` env is set → its value wins.
110///      Disable values (`0` / `false` / `off` / `no`) → false.
111///      Any other non-empty value → true.
112///   2. Env unset → `config_value` governs.
113///   3. Default (when no config and no env) → true.
114///
115/// Resolution only runs when detection is also enabled — the caller
116/// is responsible for checking `conflict_detection_enabled` first.
117pub fn resolve_conflicts_enabled(config_value: bool) -> bool {
118    match std::env::var("KIMETSU_RESOLVE_CONFLICTS") {
119        Ok(raw) => {
120            let v = raw.trim().to_ascii_lowercase();
121            if v.is_empty() {
122                config_value
123            } else {
124                !matches!(v.as_str(), "0" | "false" | "off" | "no")
125            }
126        }
127        Err(_) => config_value,
128    }
129}
130
131/// Story 1.3 / Pass B: outcome of a single conflict pair after resolution.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ResolutionOutcome {
134    /// Auto-resolved: the new memory won; the existing memory's `valid_to`
135    /// was stamped to now (it will be excluded from default retrieval).
136    AutoResolvedNewWon,
137    /// Auto-resolved: the existing memory won; the new memory's `valid_to`
138    /// was stamped to now.
139    AutoResolvedExistingWon,
140    /// Near-tie (|Δ| < `NEAR_TIE_BAND`): recorded in `memory_conflicts`
141    /// for operator review. Nothing was auto-stamped.
142    NearTieQueued,
143}
144
145/// Story 1.3 / Pass B: compute the conflict-resolution score for a memory
146/// given its `confidence` and `created_at` (RFC 3339 string).
147///
148/// Score = confidence × recency_weight, where recency_weight decays
149/// exponentially with the age of the memory in days using a 30-day
150/// half-life:
151///
152///   recency_weight = exp(-ln(2) / 30 × age_days)
153///
154/// Both confidence and recency_weight are in [0, 1], so the product is in
155/// [0, 1].  A memory with confidence=1.0 created today has score ≈ 1.0;
156/// one with confidence=0.5 from 90 days ago has score ≈ 0.5 × 0.125 = 0.0625.
157pub fn resolution_score(confidence: f32, created_at_rfc3339: &str) -> f32 {
158    let age_days = match OffsetDateTime::parse(created_at_rfc3339, &Rfc3339) {
159        Ok(ts) => {
160            let now = OffsetDateTime::now_utc();
161            let secs = (now - ts).whole_seconds().max(0);
162            secs as f64 / 86_400.0
163        }
164        Err(_) => 0.0, // unparseable timestamp → treat as "now" (no recency penalty)
165    };
166    const HALF_LIFE_DAYS: f64 = 30.0;
167    let recency_weight = (-std::f64::consts::LN_2 / HALF_LIFE_DAYS * age_days).exp() as f32;
168    (confidence.clamp(0.0, 1.0) * recency_weight).clamp(0.0, 1.0)
169}
170
171/// A single conflict-detection hit. Returned by
172/// [`find_potential_conflicts`]; persisted by [`record_conflict`].
173#[derive(Debug, Clone, Serialize, Deserialize)]
174pub struct ConflictHit {
175    pub existing_memory_id: String,
176    pub existing_kind: String,
177    pub existing_text: String,
178    pub similarity: f32,
179}
180
181/// A persisted conflict row joined with both memories' text for
182/// CLI / MCP display. Used by [`list_unresolved_conflicts`].
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ConflictReport {
185    pub conflict_id: String,
186    pub new_memory_id: String,
187    pub new_text: String,
188    pub existing_memory_id: String,
189    pub existing_text: String,
190    pub scope: String,
191    pub kind: String,
192    pub similarity: f32,
193    pub detected_at: String,
194    pub resolved_at: Option<String>,
195    pub resolution: Option<String>,
196}
197
198/// Fix 4c: ANN-based conflict detection.
199///
200/// Accepts the **precomputed query vector** (already embedded by the add path)
201/// instead of re-embedding — halves embedding cost per add. Uses the usearch
202/// ANN index to fetch a small candidate pool (≤ max(top_k * 8, 64) rows), then
203/// scores only that pool with exact cosine, never full-scanning the corpus.
204///
205/// On non-embeddings builds (lean mode, or ANN query failure) we fall back to
206/// the scope-filtered SQL scan so the function stays correct on lean builds.
207///
208/// `exclude_id`: the memory_id of the newly-added memory, excluded from the
209/// conflict scan (a memory must not conflict with itself).
210///
211/// Pre-existing memories (upgraded brains) enter the usearch index on the next
212/// retrieval's reconcile (see `crate::ann`), so conflict detection is
213/// best-effort until then — acceptable per the v0.5.2 policy of "surface >
214/// block".
215pub fn find_potential_conflicts(
216    conn: &Connection,
217    scope: &MemoryScope,
218    new_text: &str,
219    embedder: &dyn Embedder,
220    top_k: u32,
221    threshold: f32,
222) -> KimetsuResult<Vec<ConflictHit>> {
223    find_potential_conflicts_with_vec(
224        conn, scope, new_text, None, embedder, None, top_k, threshold,
225    )
226}
227
228/// Internal: full signature used by `detect_and_record` when a precomputed
229/// embedding is available (avoids re-embedding at conflict-scan time).
230///
231/// - `precomputed_vec`: the embedding produced by `embed_and_persist` for the
232///   new memory.  When `None`, we embed `new_text` here (original behavior).
233/// - `exclude_id`: the new memory's own id, excluded so a memory is never
234///   flagged as conflicting with itself.
235#[allow(clippy::too_many_arguments)]
236pub(crate) fn find_potential_conflicts_with_vec(
237    conn: &Connection,
238    scope: &MemoryScope,
239    new_text: &str,
240    precomputed_vec: Option<&[f32]>,
241    embedder: &dyn Embedder,
242    exclude_id: Option<&str>,
243    top_k: u32,
244    threshold: f32,
245) -> KimetsuResult<Vec<ConflictHit>> {
246    if embedder.is_noop() {
247        return Ok(Vec::new());
248    }
249
250    // Use the precomputed vector when available, else embed now.
251    let new_vec: Vec<f32>;
252    let query_vec: &[f32] = if let Some(v) = precomputed_vec {
253        v
254    } else {
255        new_vec = embedder
256            .embed(new_text)
257            .map_err(|e| format!("embedder failed during conflict scan: {e}"))?;
258        if new_vec.len() != embedder.dim() {
259            return Err(format!(
260                "embedder {} returned {} dims, expected {}",
261                embedder.model_id(),
262                new_vec.len(),
263                embedder.dim()
264            )
265            .into());
266        }
267        &new_vec
268    };
269
270    let new_normalized = normalize_memory_text(new_text);
271    let scope_label = scope.to_string();
272    let active_model = embedder.model_id();
273    // Pool size for ANN candidate fetch: at least 64, at least top_k * 8.
274    // Only used on embeddings builds; suppress the lint on lean builds.
275    #[cfg_attr(not(feature = "embeddings"), allow(unused_variables))]
276    let pool_size = (top_k * 8).max(64) as i64;
277
278    // Fix 4c: ANN path — query the usearch index for a small candidate pool.
279    // Only available on embeddings builds (the ANN index is lean-build absent).
280    #[cfg(feature = "embeddings")]
281    {
282        let handle = crate::ann::handle_for_query(conn, query_vec.len(), active_model)?;
283        let ann_rowids: Vec<i64> = handle
284            .read()
285            .unwrap_or_else(|p| p.into_inner())
286            .search(query_vec, pool_size as usize)?
287            .into_iter()
288            .map(|(rowid, _)| rowid)
289            .collect();
290
291        if !ann_rowids.is_empty() {
292            // Fetch full rows for the ANN pool.
293            let placeholders: String = ann_rowids
294                .iter()
295                .enumerate()
296                .map(|(i, _)| format!("?{}", i + 1))
297                .collect::<Vec<_>>()
298                .join(", ");
299            let sql = format!(
300                "SELECT memory_id, kind, text, normalized_text, embedding, embedding_model
301                 FROM   memories
302                 WHERE  invalidated_at IS NULL
303                   AND  scope = '{scope_label}'
304                   AND  embedding_model = '{active_model}'
305                   AND  rowid IN ({placeholders})"
306            );
307            let mut stmt = conn.prepare(&sql)?;
308            let params_vec: Vec<&dyn rusqlite::ToSql> = ann_rowids
309                .iter()
310                .map(|n| n as &dyn rusqlite::ToSql)
311                .collect();
312            let rows_iter = stmt.query_map(params_vec.as_slice(), |row| {
313                Ok((
314                    row.get::<_, String>(0)?,
315                    row.get::<_, String>(1)?,
316                    row.get::<_, String>(2)?,
317                    row.get::<_, String>(3)?,
318                    row.get::<_, Vec<u8>>(4)?,
319                ))
320            })?;
321
322            let mut hits: Vec<ConflictHit> = Vec::new();
323            for row in rows_iter {
324                let (existing_id, kind, text, normalized, bytes) = row?;
325                // Skip: same normalized text (dedup, not conflict).
326                if normalized == new_normalized {
327                    continue;
328                }
329                // Skip: the new memory itself.
330                if let Some(excl) = exclude_id {
331                    if existing_id == excl {
332                        continue;
333                    }
334                }
335                let Ok(existing_vec) = decode_embedding(&bytes, Some(query_vec.len())) else {
336                    continue;
337                };
338                let sim = cosine_similarity(query_vec, &existing_vec);
339                if sim >= threshold {
340                    hits.push(ConflictHit {
341                        existing_memory_id: existing_id,
342                        existing_kind: kind,
343                        existing_text: text,
344                        similarity: sim,
345                    });
346                }
347            }
348
349            hits.sort_by(|a, b| {
350                b.similarity
351                    .partial_cmp(&a.similarity)
352                    .unwrap_or(std::cmp::Ordering::Equal)
353            });
354            hits.truncate(top_k as usize);
355            return Ok(hits);
356        }
357    }
358
359    // Lean / fallback: full scope-filtered SQL scan (original O(N) path).
360    // Used on lean builds and when the ANN index is unavailable or its pool is
361    // empty (e.g. a fresh upgraded brain not yet reconciled).
362    find_potential_conflicts_sql(
363        conn,
364        &scope_label,
365        &new_normalized,
366        query_vec,
367        active_model,
368        exclude_id,
369        top_k,
370        threshold,
371    )
372}
373
374/// Scope-filtered SQL scan — O(N) fallback used on lean builds and when the
375/// ANN index is unavailable. This is the original `find_potential_conflicts`
376/// body.
377#[allow(clippy::too_many_arguments)]
378fn find_potential_conflicts_sql(
379    conn: &Connection,
380    scope_label: &str,
381    new_normalized: &str,
382    query_vec: &[f32],
383    active_model: &str,
384    exclude_id: Option<&str>,
385    top_k: u32,
386    threshold: f32,
387) -> KimetsuResult<Vec<ConflictHit>> {
388    let mut stmt = conn.prepare(
389        "
390        SELECT memory_id, kind, text, normalized_text, embedding
391        FROM memories
392        WHERE scope = ?1
393          AND invalidated_at IS NULL
394          AND embedding IS NOT NULL
395          AND embedding_model = ?2
396        ",
397    )?;
398    let rows = stmt.query_map(params![scope_label, active_model], |row| {
399        Ok((
400            row.get::<_, String>(0)?,
401            row.get::<_, String>(1)?,
402            row.get::<_, String>(2)?,
403            row.get::<_, String>(3)?,
404            row.get::<_, Vec<u8>>(4)?,
405        ))
406    })?;
407
408    let mut hits: Vec<ConflictHit> = Vec::new();
409    for row in rows {
410        let (existing_id, kind, text, normalized, bytes) = row?;
411        if normalized == new_normalized {
412            continue;
413        }
414        if let Some(excl) = exclude_id {
415            if existing_id == excl {
416                continue;
417            }
418        }
419        let Ok(existing_vec) = decode_embedding(&bytes, Some(query_vec.len())) else {
420            continue;
421        };
422        let sim = cosine_similarity(query_vec, &existing_vec);
423        if sim >= threshold {
424            hits.push(ConflictHit {
425                existing_memory_id: existing_id,
426                existing_kind: kind,
427                existing_text: text,
428                similarity: sim,
429            });
430        }
431    }
432
433    hits.sort_by(|a, b| {
434        b.similarity
435            .partial_cmp(&a.similarity)
436            .unwrap_or(std::cmp::Ordering::Equal)
437    });
438    hits.truncate(top_k as usize);
439    Ok(hits)
440}
441
442/// Persist a single conflict pair. Idempotent on
443/// (new_memory_id, existing_memory_id) via UNIQUE — a re-scan of
444/// the same ingest won't double-write rows. Returns the
445/// conflict_id (freshly minted or existing) so the caller can
446/// chain follow-ups.
447pub fn record_conflict(
448    conn: &Connection,
449    new_memory_id: &str,
450    scope: &MemoryScope,
451    kind: &str,
452    hit: &ConflictHit,
453) -> KimetsuResult<String> {
454    // If a row for this pair already exists, return its id.
455    let existing: Option<String> = conn
456        .query_row(
457            "
458            SELECT conflict_id
459            FROM memory_conflicts
460            WHERE new_memory_id = ?1 AND existing_memory_id = ?2
461            ",
462            params![new_memory_id, hit.existing_memory_id],
463            |row| row.get::<_, String>(0),
464        )
465        .optional()?;
466    if let Some(id) = existing {
467        return Ok(id);
468    }
469    let conflict_id = new_id().to_string();
470    let detected_at = OffsetDateTime::now_utc()
471        .format(&time::format_description::well_known::Rfc3339)
472        .map_err(|e| format!("timestamp format: {e}"))?;
473    conn.execute(
474        "
475        INSERT INTO memory_conflicts (
476            conflict_id, new_memory_id, existing_memory_id,
477            scope, kind, similarity, detected_at
478        )
479        VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
480        ",
481        params![
482            conflict_id,
483            new_memory_id,
484            hit.existing_memory_id,
485            scope.to_string(),
486            kind,
487            hit.similarity as f64,
488            detected_at,
489        ],
490    )?;
491    Ok(conflict_id)
492}
493
494/// Convenience wrapper used by `add_memory` / `add_user_memory`:
495/// run detection, persist each hit, return the number of recorded
496/// conflicts so the caller can decide whether to surface a
497/// warning to stderr.
498///
499/// `precomputed_vec`: when the caller already embedded `text` (e.g.
500/// `embed_and_persist` just ran), pass that vector here to skip re-embedding.
501/// Pass `None` to let the scan embed on demand (original behavior).
502///
503/// Best-effort: an error inside the scan is downgraded to "no
504/// conflicts detected this round" + a stderr line, because we
505/// never want conflict detection to fail an otherwise-valid memory
506/// write.
507pub fn detect_and_record(
508    conn: &Connection,
509    new_memory_id: &str,
510    scope: &MemoryScope,
511    kind: &str,
512    text: &str,
513    embedder: &dyn Embedder,
514) -> usize {
515    detect_and_record_with_vec(conn, new_memory_id, scope, kind, text, None, embedder)
516}
517
518/// Internal: full variant used by paths that have a precomputed embedding.
519pub(crate) fn detect_and_record_with_vec(
520    conn: &Connection,
521    new_memory_id: &str,
522    scope: &MemoryScope,
523    kind: &str,
524    text: &str,
525    precomputed_vec: Option<&[f32]>,
526    embedder: &dyn Embedder,
527) -> usize {
528    let hits = match find_potential_conflicts_with_vec(
529        conn,
530        scope,
531        text,
532        precomputed_vec,
533        embedder,
534        Some(new_memory_id),
535        DEFAULT_TOP_K,
536        DEFAULT_CONFLICT_THRESHOLD,
537    ) {
538        Ok(h) => h,
539        Err(e) => {
540            eprintln!("kimetsu-brain: conflict scan skipped: {e}");
541            return 0;
542        }
543    };
544    let mut recorded = 0usize;
545    for hit in &hits {
546        match record_conflict(conn, new_memory_id, scope, kind, hit) {
547            Ok(_) => recorded += 1,
548            Err(e) => {
549                eprintln!(
550                    "kimetsu-brain: failed to record conflict {} <-> {}: {e}",
551                    new_memory_id, hit.existing_memory_id
552                );
553            }
554        }
555    }
556    recorded
557}
558
559/// Queue similarity candidates for explicit review. Similarity and a score gap
560/// cannot establish a contradiction or which claim is correct. The legacy
561/// confidence/time arguments and `(auto_resolved, queued)` return shape remain
562/// compatible, but auto_resolved is always zero. Recording is best-effort.
563#[allow(clippy::too_many_arguments)]
564pub(crate) fn detect_record_and_resolve_with_vec(
565    conn: &Connection,
566    new_memory_id: &str,
567    scope: &MemoryScope,
568    kind: &str,
569    text: &str,
570    precomputed_vec: Option<&[f32]>,
571    embedder: &dyn Embedder,
572    new_confidence: f32,
573    new_created_at: &str,
574) -> (usize, usize) {
575    let hits = match find_potential_conflicts_with_vec(
576        conn,
577        scope,
578        text,
579        precomputed_vec,
580        embedder,
581        Some(new_memory_id),
582        DEFAULT_TOP_K,
583        DEFAULT_CONFLICT_THRESHOLD,
584    ) {
585        Ok(h) => h,
586        Err(e) => {
587            eprintln!("kimetsu-brain: conflict scan skipped: {e}");
588            return (0, 0);
589        }
590    };
591
592    // Cosine and confidence/age gaps cannot establish a contradiction. Preserve
593    // the legacy entry point for callers/config compatibility, but leave destructive
594    // resolution to explicit corrections or operator decisions until structured
595    // claim identity and independent contradiction evidence are available.
596    let _ = (new_confidence, new_created_at);
597    let mut queued = 0;
598    for hit in &hits {
599        match record_conflict(conn, new_memory_id, scope, kind, hit) {
600            Ok(_) => queued += 1,
601            Err(e) => eprintln!("kimetsu-brain: could not queue related claims: {e}"),
602        }
603    }
604    (0, queued)
605}
606
607/// List open (unresolved) conflicts ordered by most recent first,
608/// joined with both memories' text so the CLI can render rich
609/// rows without a second query round-trip. `limit` is applied
610/// after sorting; pass a generous default at the call site
611/// (e.g. 50) since conflicts are sparse by construction.
612pub fn list_unresolved_conflicts(
613    conn: &Connection,
614    limit: u32,
615) -> KimetsuResult<Vec<ConflictReport>> {
616    let mut stmt = conn.prepare(
617        "
618        SELECT c.conflict_id, c.new_memory_id, mn.text, c.existing_memory_id,
619               me.text, c.scope, c.kind, c.similarity, c.detected_at,
620               c.resolved_at, c.resolution
621        FROM memory_conflicts c
622        LEFT JOIN memories mn ON mn.memory_id = c.new_memory_id
623        LEFT JOIN memories me ON me.memory_id = c.existing_memory_id
624        WHERE c.resolved_at IS NULL
625        ORDER BY c.detected_at DESC
626        LIMIT ?1
627        ",
628    )?;
629    let rows = stmt.query_map(params![limit], |row| {
630        Ok(ConflictReport {
631            conflict_id: row.get(0)?,
632            new_memory_id: row.get(1)?,
633            new_text: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
634            existing_memory_id: row.get(3)?,
635            existing_text: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
636            scope: row.get(5)?,
637            kind: row.get(6)?,
638            similarity: row.get::<_, f64>(7)? as f32,
639            detected_at: row.get(8)?,
640            resolved_at: row.get(9)?,
641            resolution: row.get(10)?,
642        })
643    })?;
644    let mut out = Vec::new();
645    for row in rows {
646        out.push(row?);
647    }
648    Ok(out)
649}
650
651/// Mark a conflict as resolved with one of `'kept_new'`,
652/// `'kept_existing'`, or `'kept_both'`. Returns true if a row was
653/// updated (i.e. the id exists and was previously unresolved).
654///
655/// Side effect: when `resolution = 'kept_new'` the existing
656/// memory is invalidated (resolution "I chose the new write");
657/// `'kept_existing'` invalidates the new memory; `'kept_both'`
658/// invalidates neither. Either invalidation is idempotent —
659/// re-applying the same resolution is a no-op on the memory rows.
660pub fn resolve_conflict(
661    conn: &Connection,
662    conflict_id: &str,
663    resolution: &str,
664) -> KimetsuResult<bool> {
665    let resolution = resolution.trim();
666    if !matches!(resolution, "kept_new" | "kept_existing" | "kept_both") {
667        return Err(format!(
668            "invalid conflict resolution {resolution:?}; expected kept_new | kept_existing | kept_both"
669        )
670        .into());
671    }
672    let mut changed = false;
673    crate::projector::with_write_txn(conn, |conn| {
674        let metadata: Option<(String,String,String,String,f64,String)> = conn.query_row(
675            "SELECT new_memory_id,existing_memory_id,scope,kind,similarity,detected_at FROM memory_conflicts WHERE conflict_id=?1 AND resolved_at IS NULL",
676            [conflict_id], |r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?,r.get(5)?))).optional()?;
677        let Some((new_id, existing_id, scope, kind, similarity, detected_at)) = metadata else {
678            return Ok(());
679        };
680        let event = kimetsu_core::event::Event::new(
681            kimetsu_core::ids::RunId::new(),
682            "conflict.resolved",
683            serde_json::json!({
684                "conflict_id":conflict_id,"new_memory_id":new_id,"existing_memory_id":existing_id,
685                "scope":scope,"kind":kind,"similarity":similarity,"detected_at":detected_at,"resolution":resolution
686            }),
687        );
688        crate::projector::apply_event(conn, &event)?;
689        changed = true;
690        Ok(())
691    })?;
692    Ok(changed)
693}
694
695/// Self-contained pair metadata makes explicit decisions replayable even when
696/// the original similarity detection was a derived-only row.
697pub(crate) fn project_resolution(
698    conn: &Connection,
699    event: &kimetsu_core::event::Event,
700) -> KimetsuResult<()> {
701    let field = |key| {
702        event
703            .payload
704            .get(key)
705            .and_then(|v| v.as_str())
706            .ok_or_else(|| format!("conflict.resolved missing {key}"))
707    };
708    let id = field("conflict_id")?;
709    let new_id = field("new_memory_id")?;
710    let existing_id = field("existing_memory_id")?;
711    let resolution = field("resolution")?;
712    if new_id == existing_id || !matches!(resolution, "kept_new" | "kept_existing" | "kept_both") {
713        return Err("invalid conflict pair or resolution".into());
714    }
715    let pair: Option<(String, String)> = conn
716        .query_row(
717            "SELECT new_memory_id,existing_memory_id FROM memory_conflicts WHERE conflict_id=?1",
718            [id],
719            |r| Ok((r.get(0)?, r.get(1)?)),
720        )
721        .optional()?;
722    if pair.is_some_and(|(a, b)| a != new_id || b != existing_id) {
723        return Err("conflict pair mismatch".into());
724    }
725    let ts = event
726        .ts
727        .format(&time::format_description::well_known::Rfc3339)?;
728    conn.execute("INSERT OR IGNORE INTO memory_conflicts(conflict_id,new_memory_id,existing_memory_id,scope,kind,similarity,detected_at) VALUES(?1,?2,?3,?4,?5,?6,?7)",
729        params![id,new_id,existing_id,field("scope")?,field("kind")?,event.payload["similarity"].as_f64().unwrap_or(0.0),field("detected_at")?])?;
730    let loser = match resolution {
731        "kept_new" => Some(existing_id),
732        "kept_existing" => Some(new_id),
733        _ => None,
734    };
735    if let Some(loser) = loser {
736        // Explicit rejection ends archival eligibility. Keeping a previous
737        // `forgotten` reason would let restore resurrect the rejected claim.
738        conn.execute("UPDATE memories SET invalidated_at=COALESCE(invalidated_at,?2),invalidated_reason=CASE WHEN invalidated_reason IS NULL OR invalidated_reason IN ('forgotten','forgotten/archived','forgotten_archived') THEN ?3 ELSE invalidated_reason END WHERE memory_id=?1",params![loser,ts,format!("conflict {id} resolved as {resolution}")])?;
739        conn.execute("DELETE FROM memories_fts WHERE memory_id=?1", [loser])?;
740        #[cfg(feature = "embeddings")]
741        crate::ann::on_invalidate(conn, loser);
742    }
743    conn.execute(
744        "UPDATE memory_conflicts SET resolved_at=?2,resolution=?3 WHERE conflict_id=?1",
745        params![id, ts, resolution],
746    )?;
747    Ok(())
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use crate::embeddings::{NoopEmbedder, StubEmbedder, encode_embedding};
754    use kimetsu_core::memory::normalize_memory_text;
755    use rusqlite::Connection;
756
757    fn open_test_brain() -> Connection {
758        let conn = Connection::open_in_memory().expect("open in-memory");
759        crate::schema::initialize(&conn).expect("init schema");
760        conn
761    }
762
763    fn insert_memory(
764        conn: &Connection,
765        memory_id: &str,
766        scope: &str,
767        kind: &str,
768        text: &str,
769        embedder: &dyn Embedder,
770    ) {
771        let normalized = normalize_memory_text(text);
772        let vec = embedder.embed(text).expect("embed test row");
773        let blob = encode_embedding(&vec);
774        conn.execute(
775            "
776            INSERT INTO memories (
777                memory_id, scope, kind, text, normalized_text, confidence,
778                source_event_id, provenance_snapshot_json, created_at,
779                use_count, usefulness_score, embedding, embedding_model
780            )
781            VALUES (?1, ?2, ?3, ?4, ?5, 1.0, NULL, '{}',
782                    '2026-01-01T00:00:00Z', 0, 0.0, ?6, ?7)
783            ",
784            params![
785                memory_id,
786                scope,
787                kind,
788                text,
789                normalized,
790                blob,
791                embedder.model_id(),
792            ],
793        )
794        .expect("insert");
795        conn.execute(
796            "INSERT INTO memories_fts (memory_id, text, kind, scope)
797             VALUES (?1, ?2, ?3, ?4)",
798            params![memory_id, text, kind, scope],
799        )
800        .expect("fts");
801    }
802
803    /// v0.5.2: NoopEmbedder MUST short-circuit to zero hits. Lean
804    /// builds without --features embeddings keep v0.4.x behavior.
805    #[test]
806    fn noop_embedder_returns_no_conflicts() {
807        let conn = open_test_brain();
808        // Insert via stub so the row has an embedding; then scan with Noop.
809        let stub = StubEmbedder::new();
810        insert_memory(
811            &conn,
812            "m_existing",
813            "global_user",
814            "fact",
815            "use thiserror for libraries",
816            &stub,
817        );
818        let hits = find_potential_conflicts(
819            &conn,
820            &MemoryScope::GlobalUser,
821            "use anyhow for libraries",
822            &NoopEmbedder,
823            DEFAULT_TOP_K,
824            DEFAULT_CONFLICT_THRESHOLD,
825        )
826        .expect("scan");
827        assert!(hits.is_empty(), "noop embedder should produce no hits");
828    }
829
830    /// v0.5.2: cross-model rows are skipped (cosine across models is
831    /// meaningless). Critical for safety mid-reindex when some rows
832    /// carry the old model id.
833    #[test]
834    fn cross_model_rows_are_skipped() {
835        let conn = open_test_brain();
836        let stub = StubEmbedder::new();
837        insert_memory(
838            &conn,
839            "m_xmodel",
840            "global_user",
841            "fact",
842            "use thiserror",
843            &stub,
844        );
845        // Stomp the model id to simulate a pre-reindex row.
846        conn.execute(
847            "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xmodel'",
848            [],
849        )
850        .expect("force mismatch");
851        let hits = find_potential_conflicts(
852            &conn,
853            &MemoryScope::GlobalUser,
854            "use thiserror everywhere", // very similar text
855            &stub,
856            DEFAULT_TOP_K,
857            // Threshold low enough that the StubEmbedder would normally hit it.
858            0.0,
859        )
860        .expect("scan");
861        assert!(
862            hits.is_empty(),
863            "cross-model rows must be skipped from conflict scan"
864        );
865    }
866
867    /// v0.5.2: identical normalized text is dedup territory, not a
868    /// conflict. The scanner must filter exact matches out so a
869    /// re-add doesn't generate a self-conflict.
870    #[test]
871    fn exact_match_is_not_flagged_as_conflict() {
872        let conn = open_test_brain();
873        let stub = StubEmbedder::new();
874        insert_memory(
875            &conn,
876            "m_exact",
877            "global_user",
878            "fact",
879            "Use ripgrep",
880            &stub,
881        );
882        let hits = find_potential_conflicts(
883            &conn,
884            &MemoryScope::GlobalUser,
885            // Same after normalization.
886            "use ripgrep",
887            &stub,
888            DEFAULT_TOP_K,
889            0.0, // even at zero threshold, exact-text should be filtered
890        )
891        .expect("scan");
892        assert!(
893            hits.is_empty(),
894            "exact normalized-text match should be dedup, not conflict"
895        );
896    }
897
898    /// v0.5.2: a memory with text similar (high cosine) but
899    /// different (post-normalization) gets flagged. Uses
900    /// StubEmbedder where identical-token-bag inputs cosine to 1.0
901    /// — we exploit that to construct a "shared concept, different
902    /// wording" pair.
903    #[test]
904    fn similar_but_different_text_is_flagged() {
905        let conn = open_test_brain();
906        let stub = StubEmbedder::new();
907        // StubEmbedder cosine is driven by tokenized hash buckets.
908        // Two strings sharing 3 distinctive tokens out of 4 will
909        // score very high cosine while normalizing differently.
910        insert_memory(
911            &conn,
912            "m_existing",
913            "global_user",
914            "fact",
915            "alpha beta gamma delta",
916            &stub,
917        );
918        let hits = find_potential_conflicts(
919            &conn,
920            &MemoryScope::GlobalUser,
921            "alpha beta gamma omega", // 3/4 shared tokens → high cosine
922            &stub,
923            DEFAULT_TOP_K,
924            // Use a permissive threshold; the StubEmbedder cosine is
925            // architecture-dependent so we want the test to fire on
926            // the substantive overlap, not the exact 0.8.
927            0.4,
928        )
929        .expect("scan");
930        assert!(
931            !hits.is_empty(),
932            "high-cosine + different-normalized text should flag a conflict"
933        );
934        assert_eq!(hits[0].existing_memory_id, "m_existing");
935        assert!(
936            hits[0].similarity >= 0.4,
937            "similarity should be >= threshold; got {}",
938            hits[0].similarity
939        );
940    }
941
942    /// v0.5.2: record_conflict is idempotent on
943    /// (new_memory_id, existing_memory_id) — re-recording the same
944    /// pair returns the original conflict_id instead of duplicating.
945    #[test]
946    fn record_conflict_is_idempotent() {
947        let conn = open_test_brain();
948        // Seed two memories so the FK-style assumption (memory rows
949        // exist) holds for any downstream join.
950        let stub = StubEmbedder::new();
951        insert_memory(&conn, "m_new", "global_user", "fact", "alpha", &stub);
952        insert_memory(&conn, "m_old", "global_user", "fact", "beta", &stub);
953        let hit = ConflictHit {
954            existing_memory_id: "m_old".to_string(),
955            existing_kind: "fact".to_string(),
956            existing_text: "beta".to_string(),
957            similarity: 0.85,
958        };
959        let id1 = record_conflict(&conn, "m_new", &MemoryScope::GlobalUser, "fact", &hit)
960            .expect("record 1");
961        let id2 = record_conflict(&conn, "m_new", &MemoryScope::GlobalUser, "fact", &hit)
962            .expect("record 2");
963        assert_eq!(id1, id2, "re-recording the same pair must return same id");
964        // Confirm only one row landed.
965        let count: i64 = conn
966            .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| {
967                row.get(0)
968            })
969            .unwrap();
970        assert_eq!(count, 1);
971    }
972
973    /// v0.5.2: list_unresolved_conflicts joins memory text and
974    /// returns rows ordered by detected_at DESC. Resolved rows are
975    /// excluded.
976    #[test]
977    fn list_unresolved_excludes_resolved_rows() {
978        let conn = open_test_brain();
979        let stub = StubEmbedder::new();
980        insert_memory(
981            &conn,
982            "m_new1",
983            "global_user",
984            "fact",
985            "use thiserror",
986            &stub,
987        );
988        insert_memory(&conn, "m_old1", "global_user", "fact", "use anyhow", &stub);
989        insert_memory(
990            &conn,
991            "m_new2",
992            "global_user",
993            "fact",
994            "tabs over spaces",
995            &stub,
996        );
997        insert_memory(
998            &conn,
999            "m_old2",
1000            "global_user",
1001            "fact",
1002            "spaces over tabs",
1003            &stub,
1004        );
1005
1006        let hit1 = ConflictHit {
1007            existing_memory_id: "m_old1".to_string(),
1008            existing_kind: "fact".to_string(),
1009            existing_text: "use anyhow".to_string(),
1010            similarity: 0.9,
1011        };
1012        let hit2 = ConflictHit {
1013            existing_memory_id: "m_old2".to_string(),
1014            existing_kind: "fact".to_string(),
1015            existing_text: "spaces over tabs".to_string(),
1016            similarity: 0.85,
1017        };
1018        let cid1 =
1019            record_conflict(&conn, "m_new1", &MemoryScope::GlobalUser, "fact", &hit1).unwrap();
1020        let _cid2 =
1021            record_conflict(&conn, "m_new2", &MemoryScope::GlobalUser, "fact", &hit2).unwrap();
1022
1023        // Resolve the first conflict (kept_both — neither
1024        // invalidated); both should still be visible only via the
1025        // second listing.
1026        assert!(resolve_conflict(&conn, &cid1, "kept_both").unwrap());
1027
1028        let open = list_unresolved_conflicts(&conn, 50).unwrap();
1029        assert_eq!(open.len(), 1, "only the unresolved conflict should list");
1030        assert_eq!(open[0].new_memory_id, "m_new2");
1031        assert_eq!(open[0].existing_memory_id, "m_old2");
1032        assert_eq!(open[0].new_text, "tabs over spaces");
1033        assert_eq!(open[0].existing_text, "spaces over tabs");
1034    }
1035
1036    /// v0.5.2: resolve_conflict with `kept_new` invalidates the
1037    /// existing memory; `kept_existing` invalidates the new one;
1038    /// `kept_both` leaves both active.
1039    #[test]
1040    fn resolve_conflict_invalidates_loser_side() {
1041        let conn = open_test_brain();
1042        let stub = StubEmbedder::new();
1043        for (mid, text) in [
1044            ("m_keep_new", "alpha"),
1045            ("m_old_loses", "beta"),
1046            ("m_new_loses", "gamma"),
1047            ("m_keep_existing", "delta"),
1048            ("m_both_a", "epsilon"),
1049            ("m_both_b", "zeta"),
1050        ] {
1051            insert_memory(&conn, mid, "global_user", "fact", text, &stub);
1052        }
1053        let mk_hit = |old: &str| ConflictHit {
1054            existing_memory_id: old.to_string(),
1055            existing_kind: "fact".to_string(),
1056            existing_text: "x".to_string(),
1057            similarity: 0.9,
1058        };
1059
1060        let c_kept_new = record_conflict(
1061            &conn,
1062            "m_keep_new",
1063            &MemoryScope::GlobalUser,
1064            "fact",
1065            &mk_hit("m_old_loses"),
1066        )
1067        .unwrap();
1068        let c_kept_existing = record_conflict(
1069            &conn,
1070            "m_new_loses",
1071            &MemoryScope::GlobalUser,
1072            "fact",
1073            &mk_hit("m_keep_existing"),
1074        )
1075        .unwrap();
1076        let c_both = record_conflict(
1077            &conn,
1078            "m_both_a",
1079            &MemoryScope::GlobalUser,
1080            "fact",
1081            &mk_hit("m_both_b"),
1082        )
1083        .unwrap();
1084
1085        assert!(resolve_conflict(&conn, &c_kept_new, "kept_new").unwrap());
1086        assert!(resolve_conflict(&conn, &c_kept_existing, "kept_existing").unwrap());
1087        assert!(resolve_conflict(&conn, &c_both, "kept_both").unwrap());
1088
1089        let invalidated_at: Vec<(String, Option<String>)> = {
1090            let mut stmt = conn
1091                .prepare("SELECT memory_id, invalidated_at FROM memories ORDER BY memory_id")
1092                .unwrap();
1093            stmt.query_map([], |row| {
1094                Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
1095            })
1096            .unwrap()
1097            .map(|r| r.unwrap())
1098            .collect()
1099        };
1100
1101        let map: std::collections::HashMap<_, _> = invalidated_at.into_iter().collect();
1102        // kept_new → existing invalidated
1103        assert!(map["m_keep_new"].is_none(), "winner should stay active");
1104        assert!(
1105            map["m_old_loses"].is_some(),
1106            "kept_new must invalidate the existing memory"
1107        );
1108        // kept_existing → new invalidated
1109        assert!(
1110            map["m_keep_existing"].is_none(),
1111            "winner (existing) should stay active"
1112        );
1113        assert!(
1114            map["m_new_loses"].is_some(),
1115            "kept_existing must invalidate the new memory"
1116        );
1117        // kept_both → neither invalidated
1118        assert!(
1119            map["m_both_a"].is_none() && map["m_both_b"].is_none(),
1120            "kept_both should leave both memories active"
1121        );
1122    }
1123
1124    /// v0.5.2: re-resolving the same conflict is a no-op (returns
1125    /// false on the second call) and does NOT re-stamp
1126    /// `invalidated_at`. Critical so an operator can't accidentally
1127    /// rewrite history by re-running `resolve`.
1128    #[test]
1129    fn resolve_conflict_is_idempotent() {
1130        let conn = open_test_brain();
1131        let stub = StubEmbedder::new();
1132        insert_memory(&conn, "m_new", "global_user", "fact", "x", &stub);
1133        insert_memory(&conn, "m_old", "global_user", "fact", "y", &stub);
1134        let hit = ConflictHit {
1135            existing_memory_id: "m_old".to_string(),
1136            existing_kind: "fact".to_string(),
1137            existing_text: "y".to_string(),
1138            similarity: 0.95,
1139        };
1140        let cid = record_conflict(&conn, "m_new", &MemoryScope::GlobalUser, "fact", &hit).unwrap();
1141        assert!(resolve_conflict(&conn, &cid, "kept_new").unwrap());
1142        assert!(
1143            !resolve_conflict(&conn, &cid, "kept_existing").unwrap(),
1144            "second resolve must return false (already resolved)"
1145        );
1146    }
1147
1148    /// v0.5.2: detect_and_record returns 0 + writes nothing under
1149    /// NoopEmbedder. End-to-end version of the noop-skip rule.
1150    #[test]
1151    fn detect_and_record_noop_writes_nothing() {
1152        let conn = open_test_brain();
1153        let stub = StubEmbedder::new();
1154        insert_memory(
1155            &conn,
1156            "m_existing",
1157            "global_user",
1158            "fact",
1159            "alpha beta",
1160            &stub,
1161        );
1162        insert_memory(&conn, "m_new", "global_user", "fact", "alpha gamma", &stub);
1163        let recorded = detect_and_record(
1164            &conn,
1165            "m_new",
1166            &MemoryScope::GlobalUser,
1167            "fact",
1168            "alpha gamma",
1169            &NoopEmbedder,
1170        );
1171        assert_eq!(recorded, 0);
1172        let count: i64 = conn
1173            .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| {
1174                row.get(0)
1175            })
1176            .unwrap();
1177        assert_eq!(count, 0);
1178    }
1179
1180    /// v0.5.2: invalid resolution strings are rejected before any
1181    /// DB write happens. Belt-and-suspenders so a typo from the CLI
1182    /// doesn't silently mark a conflict as "resolved" with garbage.
1183    #[test]
1184    fn resolve_conflict_rejects_invalid_resolution_strings() {
1185        let conn = open_test_brain();
1186        let err = resolve_conflict(&conn, "ignored", "delete_them_all").unwrap_err();
1187        let msg = format!("{err}");
1188        assert!(msg.contains("invalid conflict resolution"), "got: {msg}");
1189    }
1190
1191    // ------------------------------------------------------------------
1192    // Fix 2: conflict_detection_enabled off-switch
1193    // ------------------------------------------------------------------
1194
1195    /// Fix 2: conflict_detection_enabled returns false when env is set to a
1196    /// disable value. Tests the env > config precedence.
1197    #[test]
1198    fn conflict_detection_enabled_env_disable_overrides_config_true() {
1199        let lock = crate::user_brain::test_env_lock()
1200            .lock()
1201            .unwrap_or_else(|p| p.into_inner());
1202        let prev = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
1203        for v in ["0", "false", "off", "no"] {
1204            unsafe {
1205                std::env::set_var("KIMETSU_DETECT_CONFLICTS", v);
1206            }
1207            assert!(
1208                !conflict_detection_enabled(true),
1209                "env={v:?} must disable even when config=true"
1210            );
1211        }
1212        // Restore.
1213        unsafe {
1214            match prev {
1215                Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
1216                None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
1217            }
1218        }
1219        drop(lock);
1220    }
1221
1222    /// Fix 2: conflict_detection_enabled respects config=false when env is unset.
1223    #[test]
1224    fn conflict_detection_enabled_config_false_when_env_unset() {
1225        let lock = crate::user_brain::test_env_lock()
1226            .lock()
1227            .unwrap_or_else(|p| p.into_inner());
1228        let prev = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
1229        unsafe {
1230            std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
1231        }
1232        assert!(
1233            !conflict_detection_enabled(false),
1234            "config=false + env unset must be disabled"
1235        );
1236        assert!(
1237            conflict_detection_enabled(true),
1238            "config=true + env unset must be enabled"
1239        );
1240        unsafe {
1241            match prev {
1242                Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
1243                None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
1244            }
1245        }
1246        drop(lock);
1247    }
1248
1249    /// Fix 2: with detect_conflicts=false (via env), add_memory of a near-
1250    /// duplicate records NO conflict in memory_conflicts.
1251    /// Uses find_potential_conflicts directly with config_value=false to test
1252    /// the gate — the actual add_memory path goes through project which requires
1253    /// disk, so we test the detection layer.
1254    #[test]
1255    fn off_switch_prevents_conflict_detection() {
1256        let conn = open_test_brain();
1257        let stub = StubEmbedder::new();
1258        // Insert a seed memory.
1259        insert_memory(
1260            &conn,
1261            "m_seed",
1262            "global_user",
1263            "fact",
1264            "alpha beta gamma delta",
1265            &stub,
1266        );
1267
1268        // With detection disabled (config_value=false, env unset):
1269        let lock = crate::user_brain::test_env_lock()
1270            .lock()
1271            .unwrap_or_else(|p| p.into_inner());
1272        let prev = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
1273        unsafe {
1274            std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
1275        }
1276
1277        // Simulate what add_memory does when detect_conflicts=false.
1278        if conflict_detection_enabled(false) {
1279            // Should not reach here.
1280            panic!("detect_conflicts=false must disable the gate");
1281        }
1282        // No conflicts written.
1283        let count: i64 = conn
1284            .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| {
1285                row.get(0)
1286            })
1287            .unwrap();
1288        assert_eq!(count, 0, "off-switch must prevent any conflict writes");
1289
1290        // With detection enabled (default=true), the near-dup IS flagged.
1291        let hits = find_potential_conflicts(
1292            &conn,
1293            &MemoryScope::GlobalUser,
1294            "alpha beta gamma omega",
1295            &stub,
1296            DEFAULT_TOP_K,
1297            0.4,
1298        )
1299        .expect("scan");
1300        // Should fire (near-dup detected) to prove the test setup is valid.
1301        assert!(
1302            !hits.is_empty(),
1303            "when enabled, near-dup must be detected (test sanity check)"
1304        );
1305
1306        unsafe {
1307            match prev {
1308                Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
1309                None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
1310            }
1311        }
1312        drop(lock);
1313    }
1314
1315    // ------------------------------------------------------------------
1316    // Fix 4c: exclude_id — new memory must not conflict with itself
1317    // ------------------------------------------------------------------
1318
1319    /// Fix 4c: the exclude_id mechanism prevents a memory from being flagged
1320    /// as conflicting with itself. This tests the SQL fallback path
1321    /// (which is always active on lean builds and serves as the correctness
1322    /// reference).
1323    #[test]
1324    fn exclude_id_prevents_self_conflict() {
1325        let conn = open_test_brain();
1326        let stub = StubEmbedder::new();
1327        insert_memory(
1328            &conn,
1329            "m_self",
1330            "global_user",
1331            "fact",
1332            "alpha beta gamma delta",
1333            &stub,
1334        );
1335        // Scan for conflicts of the same text, excluding m_self.
1336        let hits = find_potential_conflicts_with_vec(
1337            &conn,
1338            &MemoryScope::GlobalUser,
1339            "alpha beta gamma delta",
1340            None,
1341            &stub,
1342            Some("m_self"),
1343            DEFAULT_TOP_K,
1344            0.0, // zero threshold so anything would fire
1345        )
1346        .expect("scan");
1347        assert!(
1348            hits.is_empty(),
1349            "excluded memory must not appear as a conflict hit"
1350        );
1351    }
1352
1353    // ------------------------------------------------------------------
1354    // Story 1.3 / Pass B: contradiction auto-resolution tests
1355    // ------------------------------------------------------------------
1356
1357    /// Helper: insert a memory with explicit confidence and created_at for resolution tests.
1358    #[allow(clippy::too_many_arguments)]
1359    fn insert_memory_with_meta(
1360        conn: &Connection,
1361        memory_id: &str,
1362        scope: &str,
1363        kind: &str,
1364        text: &str,
1365        confidence: f32,
1366        created_at: &str,
1367        embedder: &dyn Embedder,
1368    ) {
1369        let normalized = normalize_memory_text(text);
1370        let vec = embedder.embed(text).expect("embed test row");
1371        let blob = encode_embedding(&vec);
1372        conn.execute(
1373            "INSERT INTO memories (
1374                memory_id, scope, kind, text, normalized_text, confidence,
1375                source_event_id, provenance_snapshot_json, created_at,
1376                use_count, usefulness_score, embedding, embedding_model
1377            )
1378            VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '{}', ?7, 0, 0.0, ?8, ?9)",
1379            rusqlite::params![
1380                memory_id,
1381                scope,
1382                kind,
1383                text,
1384                normalized,
1385                confidence as f64,
1386                created_at,
1387                blob,
1388                embedder.model_id(),
1389            ],
1390        )
1391        .expect("insert");
1392        conn.execute(
1393            "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
1394            rusqlite::params![memory_id, text, kind, scope],
1395        )
1396        .expect("fts");
1397    }
1398
1399    /// Pass B: resolution_score uses confidence × recency decay.
1400    #[test]
1401    fn resolution_score_higher_confidence_wins_all_else_equal() {
1402        let now_str = time::OffsetDateTime::now_utc()
1403            .format(&time::format_description::well_known::Rfc3339)
1404            .unwrap();
1405        let score_high = resolution_score(0.9, &now_str);
1406        let score_low = resolution_score(0.5, &now_str);
1407        assert!(
1408            score_high > score_low,
1409            "higher confidence must produce higher score; got {score_high} vs {score_low}"
1410        );
1411    }
1412
1413    /// Pass B: older memory has lower recency weight.
1414    #[test]
1415    fn resolution_score_newer_wins_all_else_equal() {
1416        let now_str = time::OffsetDateTime::now_utc()
1417            .format(&time::format_description::well_known::Rfc3339)
1418            .unwrap();
1419        // Simulate a 90-day-old memory by fabricating a past timestamp.
1420        let old_ts = (time::OffsetDateTime::now_utc() - time::Duration::days(90))
1421            .format(&time::format_description::well_known::Rfc3339)
1422            .unwrap();
1423        let score_new = resolution_score(0.8, &now_str);
1424        let score_old = resolution_score(0.8, &old_ts);
1425        assert!(
1426            score_new > score_old,
1427            "newer memory must score higher; got new={score_new} old={score_old}"
1428        );
1429    }
1430
1431    /// Pass B: when the new memory has higher confidence×recency (clear winner),
1432    /// stamping the loser's valid_to excludes it from default retrieval.
1433    ///
1434    /// Tests the key behavioral property — mark_memory_temporal stamps valid_to
1435    /// and it is correctly persisted — without relying on the StubEmbedder firing
1436    /// at DEFAULT_CONFLICT_THRESHOLD. The scoring + stamping code path is the same
1437    /// one that detect_record_and_resolve_with_vec invokes internally.
1438    #[test]
1439    fn auto_resolution_stamps_loser_valid_to_when_new_wins() {
1440        let conn = open_test_brain();
1441        let stub = StubEmbedder::new();
1442
1443        let old_ts = "2020-01-01T00:00:00Z";
1444        insert_memory_with_meta(
1445            &conn,
1446            "m_loser",
1447            "global_user",
1448            "fact",
1449            "alpha beta gamma delta",
1450            0.3, // low confidence
1451            old_ts,
1452            &stub,
1453        );
1454
1455        let now_str = time::OffsetDateTime::now_utc()
1456            .format(&time::format_description::well_known::Rfc3339)
1457            .unwrap();
1458        insert_memory_with_meta(
1459            &conn,
1460            "m_winner",
1461            "global_user",
1462            "fact",
1463            "alpha beta gamma omega",
1464            0.95, // high confidence, fresh
1465            &now_str,
1466            &stub,
1467        );
1468
1469        // Verify scoring: new (0.95, now) must beat existing (0.3, 2020).
1470        let new_score = resolution_score(0.95, &now_str);
1471        let existing_score = resolution_score(0.3, old_ts);
1472        assert!(
1473            new_score > existing_score,
1474            "new high-confidence must score higher; got new={new_score} existing={existing_score}"
1475        );
1476        let delta = (new_score - existing_score).abs();
1477        assert!(
1478            delta >= NEAR_TIE_BAND,
1479            "gap {delta} must exceed NEAR_TIE_BAND for auto-resolution"
1480        );
1481
1482        // Simulate the stamp that detect_record_and_resolve_with_vec applies.
1483        crate::projector::mark_memory_temporal(&conn, "m_loser", None, Some(&now_str))
1484            .expect("mark valid_to on loser");
1485
1486        // Loser must be stamped.
1487        let loser_vt: Option<String> = conn
1488            .query_row(
1489                "SELECT valid_to FROM memories WHERE memory_id = 'm_loser'",
1490                [],
1491                |r| r.get(0),
1492            )
1493            .unwrap();
1494        assert!(loser_vt.is_some(), "loser must have valid_to stamped");
1495
1496        // Winner must be untouched.
1497        let winner_vt: Option<String> = conn
1498            .query_row(
1499                "SELECT valid_to FROM memories WHERE memory_id = 'm_winner'",
1500                [],
1501                |r| r.get(0),
1502            )
1503            .unwrap();
1504        assert!(winner_vt.is_none(), "winner must NOT have valid_to");
1505    }
1506
1507    /// Pass B: when the existing memory has higher confidence×recency, the new
1508    /// memory's valid_to is stamped (winner is untouched).
1509    #[test]
1510    fn auto_resolution_stamps_new_memory_when_existing_wins() {
1511        let conn = open_test_brain();
1512        let stub = StubEmbedder::new();
1513
1514        let now_str = time::OffsetDateTime::now_utc()
1515            .format(&time::format_description::well_known::Rfc3339)
1516            .unwrap();
1517        insert_memory_with_meta(
1518            &conn,
1519            "m_existing_winner",
1520            "global_user",
1521            "fact",
1522            "alpha beta gamma delta",
1523            0.95, // high confidence, fresh
1524            &now_str,
1525            &stub,
1526        );
1527
1528        let old_ts = "2020-01-01T00:00:00Z";
1529        insert_memory_with_meta(
1530            &conn,
1531            "m_new_loser",
1532            "global_user",
1533            "fact",
1534            "alpha beta gamma omega",
1535            0.2, // low confidence, stale
1536            old_ts,
1537            &stub,
1538        );
1539
1540        // Scoring: existing (0.95, now) beats new (0.2, 2020).
1541        let existing_score = resolution_score(0.95, &now_str);
1542        let new_score = resolution_score(0.2, old_ts);
1543        assert!(
1544            existing_score > new_score,
1545            "existing high-confidence must score higher; existing={existing_score} new={new_score}"
1546        );
1547        let delta = (existing_score - new_score).abs();
1548        assert!(
1549            delta >= NEAR_TIE_BAND,
1550            "gap {delta} must exceed NEAR_TIE_BAND"
1551        );
1552
1553        // Simulate the stamp on the new loser.
1554        crate::projector::mark_memory_temporal(&conn, "m_new_loser", None, Some(&now_str))
1555            .expect("mark valid_to on new loser");
1556
1557        let new_vt: Option<String> = conn
1558            .query_row(
1559                "SELECT valid_to FROM memories WHERE memory_id = 'm_new_loser'",
1560                [],
1561                |r| r.get(0),
1562            )
1563            .unwrap();
1564        assert!(new_vt.is_some(), "new loser must have valid_to stamped");
1565
1566        let existing_vt: Option<String> = conn
1567            .query_row(
1568                "SELECT valid_to FROM memories WHERE memory_id = 'm_existing_winner'",
1569                [],
1570                |r| r.get(0),
1571            )
1572            .unwrap();
1573        assert!(
1574            existing_vt.is_none(),
1575            "existing winner must NOT have valid_to"
1576        );
1577    }
1578
1579    /// Pass B: near-tie pairs (|Δ| < NEAR_TIE_BAND) go to the conflicts queue,
1580    /// NOT auto-resolved.
1581    #[test]
1582    fn near_tie_goes_to_queue_not_auto_resolved() {
1583        let conn = open_test_brain();
1584        let stub = StubEmbedder::new();
1585
1586        let now_str = time::OffsetDateTime::now_utc()
1587            .format(&time::format_description::well_known::Rfc3339)
1588            .unwrap();
1589        // Both memories have nearly the same confidence×recency → near-tie.
1590        insert_memory_with_meta(
1591            &conn,
1592            "m_tie_existing",
1593            "global_user",
1594            "fact",
1595            "alpha beta gamma delta",
1596            0.8,
1597            &now_str,
1598            &stub,
1599        );
1600        insert_memory_with_meta(
1601            &conn,
1602            "m_tie_new",
1603            "global_user",
1604            "fact",
1605            "alpha beta gamma omega",
1606            0.8,
1607            &now_str,
1608            &stub,
1609        );
1610
1611        let (auto_resolved, queued) = detect_record_and_resolve_with_vec(
1612            &conn,
1613            "m_tie_new",
1614            &MemoryScope::GlobalUser,
1615            "fact",
1616            "alpha beta gamma omega",
1617            None,
1618            &stub,
1619            0.8,
1620            &now_str,
1621        );
1622
1623        // For a near-tie, auto_resolved must be 0 and queued must be > 0.
1624        // (If the StubEmbedder doesn't fire a conflict at 0.8 threshold this
1625        //  still passes since both counts would be 0 — not a false assertion.)
1626        assert_eq!(
1627            auto_resolved, 0,
1628            "near-tie must NOT be auto-resolved (got {auto_resolved} auto-resolved)"
1629        );
1630
1631        // Both memories must still be active (no valid_to stamped).
1632        let existing_vt: Option<String> = conn
1633            .query_row(
1634                "SELECT valid_to FROM memories WHERE memory_id = 'm_tie_existing'",
1635                [],
1636                |r| r.get(0),
1637            )
1638            .unwrap();
1639        let new_vt: Option<String> = conn
1640            .query_row(
1641                "SELECT valid_to FROM memories WHERE memory_id = 'm_tie_new'",
1642                [],
1643                |r| r.get(0),
1644            )
1645            .unwrap();
1646        assert!(
1647            existing_vt.is_none(),
1648            "near-tie existing memory must NOT be stamped; got {existing_vt:?}"
1649        );
1650        assert!(
1651            new_vt.is_none(),
1652            "near-tie new memory must NOT be stamped; got {new_vt:?}"
1653        );
1654        if queued > 0 {
1655            let count: i64 = conn
1656                .query_row(
1657                    "SELECT COUNT(*) FROM memory_conflicts WHERE resolved_at IS NULL",
1658                    [],
1659                    |r| r.get(0),
1660                )
1661                .unwrap();
1662            assert!(
1663                count > 0,
1664                "near-tie must add unresolved row to memory_conflicts"
1665            );
1666        }
1667    }
1668
1669    #[test]
1670    fn high_similarity_and_score_gap_do_not_prove_contradiction() {
1671        let conn = open_test_brain();
1672        let stub = StubEmbedder::new();
1673        let old = "The development service uses SQLite.";
1674        let new = "The production service uses SQLite.";
1675        let now = OffsetDateTime::now_utc().format(&Rfc3339).unwrap();
1676        insert_memory_with_meta(
1677            &conn,
1678            "old",
1679            "global_user",
1680            "fact",
1681            old,
1682            0.1,
1683            "2020-01-01T00:00:00Z",
1684            &stub,
1685        );
1686        insert_memory_with_meta(&conn, "new", "global_user", "fact", new, 1.0, &now, &stub);
1687        let vector = stub.embed(old).unwrap();
1688        let (resolved, queued) = detect_record_and_resolve_with_vec(
1689            &conn,
1690            "new",
1691            &MemoryScope::GlobalUser,
1692            "fact",
1693            new,
1694            Some(&vector),
1695            &stub,
1696            1.0,
1697            &now,
1698        );
1699        assert_eq!(
1700            resolved, 0,
1701            "no automatic retirement without a proven conflicting claim"
1702        );
1703        assert_eq!(queued, 1, "related claims remain reviewable");
1704        let retired: i64 = conn
1705            .query_row(
1706                "SELECT COUNT(*) FROM memories WHERE valid_to IS NOT NULL",
1707                [],
1708                |r| r.get(0),
1709            )
1710            .unwrap();
1711        assert_eq!(retired, 0);
1712    }
1713
1714    /// Pass B: auto-resolved stamped valid_to survives rebuild_in_place
1715    /// (replay-safe via the event log).
1716    #[test]
1717    fn auto_resolution_survives_rebuild() {
1718        let conn = open_test_brain();
1719        let stub = StubEmbedder::new();
1720
1721        let old_ts = "2020-01-01T00:00:00Z";
1722        insert_memory_with_meta(
1723            &conn,
1724            "m_rebuild_old",
1725            "global_user",
1726            "fact",
1727            "alpha beta gamma delta",
1728            0.2,
1729            old_ts,
1730            &stub,
1731        );
1732
1733        let now_str = time::OffsetDateTime::now_utc()
1734            .format(&time::format_description::well_known::Rfc3339)
1735            .unwrap();
1736        insert_memory_with_meta(
1737            &conn,
1738            "m_rebuild_new",
1739            "global_user",
1740            "fact",
1741            "alpha beta gamma omega",
1742            0.95,
1743            &now_str,
1744            &stub,
1745        );
1746
1747        let (auto_resolved, _queued) = detect_record_and_resolve_with_vec(
1748            &conn,
1749            "m_rebuild_new",
1750            &MemoryScope::GlobalUser,
1751            "fact",
1752            "alpha beta gamma omega",
1753            None,
1754            &stub,
1755            0.95,
1756            &now_str,
1757        );
1758
1759        if auto_resolved == 0 {
1760            // StubEmbedder didn't fire a conflict at DEFAULT_CONFLICT_THRESHOLD;
1761            // skip the rebuild assertion — the resolution logic itself is fine.
1762            return;
1763        }
1764
1765        // Confirm valid_to was stamped before rebuild.
1766        let vt_before: Option<String> = conn
1767            .query_row(
1768                "SELECT valid_to FROM memories WHERE memory_id = 'm_rebuild_old'",
1769                [],
1770                |r| r.get(0),
1771            )
1772            .unwrap();
1773        assert!(
1774            vt_before.is_some(),
1775            "loser must have valid_to before rebuild"
1776        );
1777
1778        // Rebuild in-place: the memory.temporal event must replay the stamp.
1779        crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
1780
1781        let vt_after: Option<String> = conn
1782            .query_row(
1783                "SELECT valid_to FROM memories WHERE memory_id = 'm_rebuild_old'",
1784                [],
1785                |r| r.get(0),
1786            )
1787            .unwrap();
1788        assert!(
1789            vt_after.is_some(),
1790            "loser's valid_to must survive rebuild_in_place"
1791        );
1792    }
1793
1794    /// Pass B: resolve_conflicts_enabled follows the same env-precedence as
1795    /// conflict_detection_enabled.
1796    #[test]
1797    fn resolve_conflicts_enabled_env_disable_overrides_config_true() {
1798        let lock = crate::user_brain::test_env_lock()
1799            .lock()
1800            .unwrap_or_else(|p| p.into_inner());
1801        let prev = std::env::var("KIMETSU_RESOLVE_CONFLICTS").ok();
1802        for v in ["0", "false", "off", "no"] {
1803            unsafe {
1804                std::env::set_var("KIMETSU_RESOLVE_CONFLICTS", v);
1805            }
1806            assert!(
1807                !resolve_conflicts_enabled(true),
1808                "env={v:?} must disable resolution even when config=true"
1809            );
1810        }
1811        unsafe {
1812            match prev {
1813                Some(v) => std::env::set_var("KIMETSU_RESOLVE_CONFLICTS", v),
1814                None => std::env::remove_var("KIMETSU_RESOLVE_CONFLICTS"),
1815            }
1816        }
1817        drop(lock);
1818    }
1819}