1use 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
57pub 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 config_value
75 } else {
76 !matches!(v.as_str(), "0" | "false" | "off" | "no")
78 }
79 }
80 Err(_) => config_value,
82 }
83}
84
85pub const DEFAULT_CONFLICT_THRESHOLD: f32 = 0.8;
91
92pub const DEFAULT_TOP_K: u32 = 3;
97
98pub const NEAR_TIE_BAND: f32 = 0.15;
105
106pub 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#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ResolutionOutcome {
134 AutoResolvedNewWon,
137 AutoResolvedExistingWon,
140 NearTieQueued,
143}
144
145pub 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, };
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#[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#[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
198pub 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#[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 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 #[cfg_attr(not(feature = "embeddings"), allow(unused_variables))]
276 let pool_size = (top_k * 8).max(64) as i64;
277
278 #[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 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 if normalized == new_normalized {
327 continue;
328 }
329 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 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#[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
442pub fn record_conflict(
448 conn: &Connection,
449 new_memory_id: &str,
450 scope: &MemoryScope,
451 kind: &str,
452 hit: &ConflictHit,
453) -> KimetsuResult<String> {
454 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
494pub 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
518pub(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#[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 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
607pub 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
651pub 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
695pub(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 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 #[test]
806 fn noop_embedder_returns_no_conflicts() {
807 let conn = open_test_brain();
808 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 #[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 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", &stub,
856 DEFAULT_TOP_K,
857 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 #[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 "use ripgrep",
887 &stub,
888 DEFAULT_TOP_K,
889 0.0, )
891 .expect("scan");
892 assert!(
893 hits.is_empty(),
894 "exact normalized-text match should be dedup, not conflict"
895 );
896 }
897
898 #[test]
904 fn similar_but_different_text_is_flagged() {
905 let conn = open_test_brain();
906 let stub = StubEmbedder::new();
907 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", &stub,
923 DEFAULT_TOP_K,
924 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 #[test]
946 fn record_conflict_is_idempotent() {
947 let conn = open_test_brain();
948 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 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 #[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 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 #[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 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 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 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 #[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 #[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 #[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 #[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 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 #[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 #[test]
1255 fn off_switch_prevents_conflict_detection() {
1256 let conn = open_test_brain();
1257 let stub = StubEmbedder::new();
1258 insert_memory(
1260 &conn,
1261 "m_seed",
1262 "global_user",
1263 "fact",
1264 "alpha beta gamma delta",
1265 &stub,
1266 );
1267
1268 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 if conflict_detection_enabled(false) {
1279 panic!("detect_conflicts=false must disable the gate");
1281 }
1282 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 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 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 #[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 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, )
1346 .expect("scan");
1347 assert!(
1348 hits.is_empty(),
1349 "excluded memory must not appear as a conflict hit"
1350 );
1351 }
1352
1353 #[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 #[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 #[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 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 #[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, 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, &now_str,
1466 &stub,
1467 );
1468
1469 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 crate::projector::mark_memory_temporal(&conn, "m_loser", None, Some(&now_str))
1484 .expect("mark valid_to on loser");
1485
1486 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 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 #[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, &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, old_ts,
1537 &stub,
1538 );
1539
1540 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 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 #[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 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 assert_eq!(
1627 auto_resolved, 0,
1628 "near-tie must NOT be auto-resolved (got {auto_resolved} auto-resolved)"
1629 );
1630
1631 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 #[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 return;
1763 }
1764
1765 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 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 #[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}