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)]
579pub(crate) fn detect_record_and_resolve_with_vec(
580 conn: &Connection,
581 new_memory_id: &str,
582 scope: &MemoryScope,
583 kind: &str,
584 text: &str,
585 precomputed_vec: Option<&[f32]>,
586 embedder: &dyn Embedder,
587 new_confidence: f32,
588 new_created_at: &str,
589) -> (usize, usize) {
590 let hits = match find_potential_conflicts_with_vec(
591 conn,
592 scope,
593 text,
594 precomputed_vec,
595 embedder,
596 Some(new_memory_id),
597 DEFAULT_TOP_K,
598 DEFAULT_CONFLICT_THRESHOLD,
599 ) {
600 Ok(h) => h,
601 Err(e) => {
602 eprintln!("kimetsu-brain: conflict scan skipped: {e}");
603 return (0, 0);
604 }
605 };
606
607 let mut auto_resolved = 0usize;
608 let mut queued = 0usize;
609
610 for hit in &hits {
611 let existing_row: Option<(f64, String)> = conn
613 .query_row(
614 "SELECT confidence, created_at FROM memories WHERE memory_id = ?1",
615 params![hit.existing_memory_id],
616 |row| Ok((row.get::<_, f64>(0)?, row.get::<_, String>(1)?)),
617 )
618 .optional()
619 .unwrap_or(None);
620
621 let outcome = if let Some((existing_conf, existing_created_at)) = existing_row {
622 let new_score = resolution_score(new_confidence, new_created_at);
623 let existing_score = resolution_score(existing_conf as f32, &existing_created_at);
624 let delta = (new_score - existing_score).abs();
625
626 if delta >= NEAR_TIE_BAND {
627 let now_str = match OffsetDateTime::now_utc().format(&Rfc3339) {
629 Ok(s) => s,
630 Err(e) => {
631 eprintln!("kimetsu-brain: timestamp format error: {e}");
632 if let Err(e) = record_conflict(conn, new_memory_id, scope, kind, hit) {
634 eprintln!(
635 "kimetsu-brain: failed to record near-tie conflict {} <-> {}: {e}",
636 new_memory_id, hit.existing_memory_id
637 );
638 }
639 queued += 1;
640 continue;
641 }
642 };
643
644 let (loser_id, resolution_label) = if new_score >= existing_score {
645 (hit.existing_memory_id.as_str(), "auto_resolved:new_won")
647 } else {
648 (new_memory_id, "auto_resolved:existing_won")
650 };
651
652 if let Err(e) =
654 crate::projector::mark_memory_temporal(conn, loser_id, None, Some(&now_str))
655 {
656 eprintln!("kimetsu-brain: auto-resolution stamp failed for {loser_id}: {e}");
657 if let Err(e) = record_conflict(conn, new_memory_id, scope, kind, hit) {
659 eprintln!(
660 "kimetsu-brain: fallback queue failed {} <-> {}: {e}",
661 new_memory_id, hit.existing_memory_id
662 );
663 }
664 queued += 1;
665 continue;
666 }
667
668 match record_conflict(conn, new_memory_id, scope, kind, hit) {
671 Ok(conflict_id) => {
672 conn.execute(
674 "UPDATE memory_conflicts \
675 SET resolved_at = ?2, resolution = ?3 \
676 WHERE conflict_id = ?1 AND resolved_at IS NULL",
677 params![conflict_id, now_str, resolution_label],
678 )
679 .unwrap_or(0);
680 auto_resolved += 1;
681 }
682 Err(e) => {
683 eprintln!(
684 "kimetsu-brain: failed to record auto-resolved conflict {} <-> {}: {e}",
685 new_memory_id, hit.existing_memory_id
686 );
687 }
688 }
689
690 if new_score >= existing_score {
691 ResolutionOutcome::AutoResolvedNewWon
692 } else {
693 ResolutionOutcome::AutoResolvedExistingWon
694 }
695 } else {
696 ResolutionOutcome::NearTieQueued
698 }
699 } else {
700 ResolutionOutcome::NearTieQueued
702 };
703
704 if outcome == ResolutionOutcome::NearTieQueued {
705 match record_conflict(conn, new_memory_id, scope, kind, hit) {
706 Ok(_) => queued += 1,
707 Err(e) => {
708 eprintln!(
709 "kimetsu-brain: failed to record near-tie conflict {} <-> {}: {e}",
710 new_memory_id, hit.existing_memory_id
711 );
712 }
713 }
714 }
715 }
716
717 (auto_resolved, queued)
718}
719
720pub fn list_unresolved_conflicts(
726 conn: &Connection,
727 limit: u32,
728) -> KimetsuResult<Vec<ConflictReport>> {
729 let mut stmt = conn.prepare(
730 "
731 SELECT c.conflict_id, c.new_memory_id, mn.text, c.existing_memory_id,
732 me.text, c.scope, c.kind, c.similarity, c.detected_at,
733 c.resolved_at, c.resolution
734 FROM memory_conflicts c
735 LEFT JOIN memories mn ON mn.memory_id = c.new_memory_id
736 LEFT JOIN memories me ON me.memory_id = c.existing_memory_id
737 WHERE c.resolved_at IS NULL
738 ORDER BY c.detected_at DESC
739 LIMIT ?1
740 ",
741 )?;
742 let rows = stmt.query_map(params![limit], |row| {
743 Ok(ConflictReport {
744 conflict_id: row.get(0)?,
745 new_memory_id: row.get(1)?,
746 new_text: row.get::<_, Option<String>>(2)?.unwrap_or_default(),
747 existing_memory_id: row.get(3)?,
748 existing_text: row.get::<_, Option<String>>(4)?.unwrap_or_default(),
749 scope: row.get(5)?,
750 kind: row.get(6)?,
751 similarity: row.get::<_, f64>(7)? as f32,
752 detected_at: row.get(8)?,
753 resolved_at: row.get(9)?,
754 resolution: row.get(10)?,
755 })
756 })?;
757 let mut out = Vec::new();
758 for row in rows {
759 out.push(row?);
760 }
761 Ok(out)
762}
763
764pub fn resolve_conflict(
774 conn: &Connection,
775 conflict_id: &str,
776 resolution: &str,
777) -> KimetsuResult<bool> {
778 let resolution = resolution.trim();
779 if !matches!(resolution, "kept_new" | "kept_existing" | "kept_both") {
780 return Err(format!(
781 "invalid conflict resolution {resolution:?}; expected kept_new | kept_existing | kept_both"
782 )
783 .into());
784 }
785 let pair: Option<(String, String)> = conn
787 .query_row(
788 "
789 SELECT new_memory_id, existing_memory_id
790 FROM memory_conflicts
791 WHERE conflict_id = ?1 AND resolved_at IS NULL
792 ",
793 params![conflict_id],
794 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
795 )
796 .optional()?;
797 let Some((new_memory_id, existing_memory_id)) = pair else {
798 return Ok(false);
799 };
800
801 let now = OffsetDateTime::now_utc()
802 .format(&time::format_description::well_known::Rfc3339)
803 .map_err(|e| format!("timestamp format: {e}"))?;
804
805 let invalidation_reason = format!("v0.5.2 conflict {conflict_id} resolved as {resolution}");
809 if resolution == "kept_new" {
810 conn.execute(
811 "
812 UPDATE memories
813 SET invalidated_at = COALESCE(invalidated_at, ?2),
814 invalidated_reason = COALESCE(invalidated_reason, ?3)
815 WHERE memory_id = ?1
816 ",
817 params![existing_memory_id, now, invalidation_reason],
818 )?;
819 #[cfg(feature = "embeddings")]
820 crate::ann::on_invalidate(conn, &existing_memory_id);
821 } else if resolution == "kept_existing" {
822 conn.execute(
823 "
824 UPDATE memories
825 SET invalidated_at = COALESCE(invalidated_at, ?2),
826 invalidated_reason = COALESCE(invalidated_reason, ?3)
827 WHERE memory_id = ?1
828 ",
829 params![new_memory_id, now, invalidation_reason],
830 )?;
831 #[cfg(feature = "embeddings")]
832 crate::ann::on_invalidate(conn, &new_memory_id);
833 }
834
835 let updated = conn.execute(
836 "
837 UPDATE memory_conflicts
838 SET resolved_at = ?2, resolution = ?3
839 WHERE conflict_id = ?1 AND resolved_at IS NULL
840 ",
841 params![conflict_id, now, resolution],
842 )?;
843 Ok(updated > 0)
844}
845
846#[cfg(test)]
847mod tests {
848 use super::*;
849 use crate::embeddings::{NoopEmbedder, StubEmbedder, encode_embedding};
850 use kimetsu_core::memory::normalize_memory_text;
851 use rusqlite::Connection;
852
853 fn open_test_brain() -> Connection {
854 let conn = Connection::open_in_memory().expect("open in-memory");
855 crate::schema::initialize(&conn).expect("init schema");
856 conn
857 }
858
859 fn insert_memory(
860 conn: &Connection,
861 memory_id: &str,
862 scope: &str,
863 kind: &str,
864 text: &str,
865 embedder: &dyn Embedder,
866 ) {
867 let normalized = normalize_memory_text(text);
868 let vec = embedder.embed(text).expect("embed test row");
869 let blob = encode_embedding(&vec);
870 conn.execute(
871 "
872 INSERT INTO memories (
873 memory_id, scope, kind, text, normalized_text, confidence,
874 source_event_id, provenance_snapshot_json, created_at,
875 use_count, usefulness_score, embedding, embedding_model
876 )
877 VALUES (?1, ?2, ?3, ?4, ?5, 1.0, NULL, '{}',
878 '2026-01-01T00:00:00Z', 0, 0.0, ?6, ?7)
879 ",
880 params![
881 memory_id,
882 scope,
883 kind,
884 text,
885 normalized,
886 blob,
887 embedder.model_id(),
888 ],
889 )
890 .expect("insert");
891 conn.execute(
892 "INSERT INTO memories_fts (memory_id, text, kind, scope)
893 VALUES (?1, ?2, ?3, ?4)",
894 params![memory_id, text, kind, scope],
895 )
896 .expect("fts");
897 }
898
899 #[test]
902 fn noop_embedder_returns_no_conflicts() {
903 let conn = open_test_brain();
904 let stub = StubEmbedder::new();
906 insert_memory(
907 &conn,
908 "m_existing",
909 "global_user",
910 "fact",
911 "use thiserror for libraries",
912 &stub,
913 );
914 let hits = find_potential_conflicts(
915 &conn,
916 &MemoryScope::GlobalUser,
917 "use anyhow for libraries",
918 &NoopEmbedder,
919 DEFAULT_TOP_K,
920 DEFAULT_CONFLICT_THRESHOLD,
921 )
922 .expect("scan");
923 assert!(hits.is_empty(), "noop embedder should produce no hits");
924 }
925
926 #[test]
930 fn cross_model_rows_are_skipped() {
931 let conn = open_test_brain();
932 let stub = StubEmbedder::new();
933 insert_memory(
934 &conn,
935 "m_xmodel",
936 "global_user",
937 "fact",
938 "use thiserror",
939 &stub,
940 );
941 conn.execute(
943 "UPDATE memories SET embedding_model = 'bge-small-en-v1.5' WHERE memory_id = 'm_xmodel'",
944 [],
945 )
946 .expect("force mismatch");
947 let hits = find_potential_conflicts(
948 &conn,
949 &MemoryScope::GlobalUser,
950 "use thiserror everywhere", &stub,
952 DEFAULT_TOP_K,
953 0.0,
955 )
956 .expect("scan");
957 assert!(
958 hits.is_empty(),
959 "cross-model rows must be skipped from conflict scan"
960 );
961 }
962
963 #[test]
967 fn exact_match_is_not_flagged_as_conflict() {
968 let conn = open_test_brain();
969 let stub = StubEmbedder::new();
970 insert_memory(
971 &conn,
972 "m_exact",
973 "global_user",
974 "fact",
975 "Use ripgrep",
976 &stub,
977 );
978 let hits = find_potential_conflicts(
979 &conn,
980 &MemoryScope::GlobalUser,
981 "use ripgrep",
983 &stub,
984 DEFAULT_TOP_K,
985 0.0, )
987 .expect("scan");
988 assert!(
989 hits.is_empty(),
990 "exact normalized-text match should be dedup, not conflict"
991 );
992 }
993
994 #[test]
1000 fn similar_but_different_text_is_flagged() {
1001 let conn = open_test_brain();
1002 let stub = StubEmbedder::new();
1003 insert_memory(
1007 &conn,
1008 "m_existing",
1009 "global_user",
1010 "fact",
1011 "alpha beta gamma delta",
1012 &stub,
1013 );
1014 let hits = find_potential_conflicts(
1015 &conn,
1016 &MemoryScope::GlobalUser,
1017 "alpha beta gamma omega", &stub,
1019 DEFAULT_TOP_K,
1020 0.4,
1024 )
1025 .expect("scan");
1026 assert!(
1027 !hits.is_empty(),
1028 "high-cosine + different-normalized text should flag a conflict"
1029 );
1030 assert_eq!(hits[0].existing_memory_id, "m_existing");
1031 assert!(
1032 hits[0].similarity >= 0.4,
1033 "similarity should be >= threshold; got {}",
1034 hits[0].similarity
1035 );
1036 }
1037
1038 #[test]
1042 fn record_conflict_is_idempotent() {
1043 let conn = open_test_brain();
1044 let stub = StubEmbedder::new();
1047 insert_memory(&conn, "m_new", "global_user", "fact", "alpha", &stub);
1048 insert_memory(&conn, "m_old", "global_user", "fact", "beta", &stub);
1049 let hit = ConflictHit {
1050 existing_memory_id: "m_old".to_string(),
1051 existing_kind: "fact".to_string(),
1052 existing_text: "beta".to_string(),
1053 similarity: 0.85,
1054 };
1055 let id1 = record_conflict(&conn, "m_new", &MemoryScope::GlobalUser, "fact", &hit)
1056 .expect("record 1");
1057 let id2 = record_conflict(&conn, "m_new", &MemoryScope::GlobalUser, "fact", &hit)
1058 .expect("record 2");
1059 assert_eq!(id1, id2, "re-recording the same pair must return same id");
1060 let count: i64 = conn
1062 .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| {
1063 row.get(0)
1064 })
1065 .unwrap();
1066 assert_eq!(count, 1);
1067 }
1068
1069 #[test]
1073 fn list_unresolved_excludes_resolved_rows() {
1074 let conn = open_test_brain();
1075 let stub = StubEmbedder::new();
1076 insert_memory(
1077 &conn,
1078 "m_new1",
1079 "global_user",
1080 "fact",
1081 "use thiserror",
1082 &stub,
1083 );
1084 insert_memory(&conn, "m_old1", "global_user", "fact", "use anyhow", &stub);
1085 insert_memory(
1086 &conn,
1087 "m_new2",
1088 "global_user",
1089 "fact",
1090 "tabs over spaces",
1091 &stub,
1092 );
1093 insert_memory(
1094 &conn,
1095 "m_old2",
1096 "global_user",
1097 "fact",
1098 "spaces over tabs",
1099 &stub,
1100 );
1101
1102 let hit1 = ConflictHit {
1103 existing_memory_id: "m_old1".to_string(),
1104 existing_kind: "fact".to_string(),
1105 existing_text: "use anyhow".to_string(),
1106 similarity: 0.9,
1107 };
1108 let hit2 = ConflictHit {
1109 existing_memory_id: "m_old2".to_string(),
1110 existing_kind: "fact".to_string(),
1111 existing_text: "spaces over tabs".to_string(),
1112 similarity: 0.85,
1113 };
1114 let cid1 =
1115 record_conflict(&conn, "m_new1", &MemoryScope::GlobalUser, "fact", &hit1).unwrap();
1116 let _cid2 =
1117 record_conflict(&conn, "m_new2", &MemoryScope::GlobalUser, "fact", &hit2).unwrap();
1118
1119 assert!(resolve_conflict(&conn, &cid1, "kept_both").unwrap());
1123
1124 let open = list_unresolved_conflicts(&conn, 50).unwrap();
1125 assert_eq!(open.len(), 1, "only the unresolved conflict should list");
1126 assert_eq!(open[0].new_memory_id, "m_new2");
1127 assert_eq!(open[0].existing_memory_id, "m_old2");
1128 assert_eq!(open[0].new_text, "tabs over spaces");
1129 assert_eq!(open[0].existing_text, "spaces over tabs");
1130 }
1131
1132 #[test]
1136 fn resolve_conflict_invalidates_loser_side() {
1137 let conn = open_test_brain();
1138 let stub = StubEmbedder::new();
1139 for (mid, text) in [
1140 ("m_keep_new", "alpha"),
1141 ("m_old_loses", "beta"),
1142 ("m_new_loses", "gamma"),
1143 ("m_keep_existing", "delta"),
1144 ("m_both_a", "epsilon"),
1145 ("m_both_b", "zeta"),
1146 ] {
1147 insert_memory(&conn, mid, "global_user", "fact", text, &stub);
1148 }
1149 let mk_hit = |old: &str| ConflictHit {
1150 existing_memory_id: old.to_string(),
1151 existing_kind: "fact".to_string(),
1152 existing_text: "x".to_string(),
1153 similarity: 0.9,
1154 };
1155
1156 let c_kept_new = record_conflict(
1157 &conn,
1158 "m_keep_new",
1159 &MemoryScope::GlobalUser,
1160 "fact",
1161 &mk_hit("m_old_loses"),
1162 )
1163 .unwrap();
1164 let c_kept_existing = record_conflict(
1165 &conn,
1166 "m_new_loses",
1167 &MemoryScope::GlobalUser,
1168 "fact",
1169 &mk_hit("m_keep_existing"),
1170 )
1171 .unwrap();
1172 let c_both = record_conflict(
1173 &conn,
1174 "m_both_a",
1175 &MemoryScope::GlobalUser,
1176 "fact",
1177 &mk_hit("m_both_b"),
1178 )
1179 .unwrap();
1180
1181 assert!(resolve_conflict(&conn, &c_kept_new, "kept_new").unwrap());
1182 assert!(resolve_conflict(&conn, &c_kept_existing, "kept_existing").unwrap());
1183 assert!(resolve_conflict(&conn, &c_both, "kept_both").unwrap());
1184
1185 let invalidated_at: Vec<(String, Option<String>)> = {
1186 let mut stmt = conn
1187 .prepare("SELECT memory_id, invalidated_at FROM memories ORDER BY memory_id")
1188 .unwrap();
1189 stmt.query_map([], |row| {
1190 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
1191 })
1192 .unwrap()
1193 .map(|r| r.unwrap())
1194 .collect()
1195 };
1196
1197 let map: std::collections::HashMap<_, _> = invalidated_at.into_iter().collect();
1198 assert!(map["m_keep_new"].is_none(), "winner should stay active");
1200 assert!(
1201 map["m_old_loses"].is_some(),
1202 "kept_new must invalidate the existing memory"
1203 );
1204 assert!(
1206 map["m_keep_existing"].is_none(),
1207 "winner (existing) should stay active"
1208 );
1209 assert!(
1210 map["m_new_loses"].is_some(),
1211 "kept_existing must invalidate the new memory"
1212 );
1213 assert!(
1215 map["m_both_a"].is_none() && map["m_both_b"].is_none(),
1216 "kept_both should leave both memories active"
1217 );
1218 }
1219
1220 #[test]
1225 fn resolve_conflict_is_idempotent() {
1226 let conn = open_test_brain();
1227 let stub = StubEmbedder::new();
1228 insert_memory(&conn, "m_new", "global_user", "fact", "x", &stub);
1229 insert_memory(&conn, "m_old", "global_user", "fact", "y", &stub);
1230 let hit = ConflictHit {
1231 existing_memory_id: "m_old".to_string(),
1232 existing_kind: "fact".to_string(),
1233 existing_text: "y".to_string(),
1234 similarity: 0.95,
1235 };
1236 let cid = record_conflict(&conn, "m_new", &MemoryScope::GlobalUser, "fact", &hit).unwrap();
1237 assert!(resolve_conflict(&conn, &cid, "kept_new").unwrap());
1238 assert!(
1239 !resolve_conflict(&conn, &cid, "kept_existing").unwrap(),
1240 "second resolve must return false (already resolved)"
1241 );
1242 }
1243
1244 #[test]
1247 fn detect_and_record_noop_writes_nothing() {
1248 let conn = open_test_brain();
1249 let stub = StubEmbedder::new();
1250 insert_memory(
1251 &conn,
1252 "m_existing",
1253 "global_user",
1254 "fact",
1255 "alpha beta",
1256 &stub,
1257 );
1258 insert_memory(&conn, "m_new", "global_user", "fact", "alpha gamma", &stub);
1259 let recorded = detect_and_record(
1260 &conn,
1261 "m_new",
1262 &MemoryScope::GlobalUser,
1263 "fact",
1264 "alpha gamma",
1265 &NoopEmbedder,
1266 );
1267 assert_eq!(recorded, 0);
1268 let count: i64 = conn
1269 .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| {
1270 row.get(0)
1271 })
1272 .unwrap();
1273 assert_eq!(count, 0);
1274 }
1275
1276 #[test]
1280 fn resolve_conflict_rejects_invalid_resolution_strings() {
1281 let conn = open_test_brain();
1282 let err = resolve_conflict(&conn, "ignored", "delete_them_all").unwrap_err();
1283 let msg = format!("{err}");
1284 assert!(msg.contains("invalid conflict resolution"), "got: {msg}");
1285 }
1286
1287 #[test]
1294 fn conflict_detection_enabled_env_disable_overrides_config_true() {
1295 let lock = crate::user_brain::test_env_lock()
1296 .lock()
1297 .unwrap_or_else(|p| p.into_inner());
1298 let prev = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
1299 for v in ["0", "false", "off", "no"] {
1300 unsafe {
1301 std::env::set_var("KIMETSU_DETECT_CONFLICTS", v);
1302 }
1303 assert!(
1304 !conflict_detection_enabled(true),
1305 "env={v:?} must disable even when config=true"
1306 );
1307 }
1308 unsafe {
1310 match prev {
1311 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
1312 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
1313 }
1314 }
1315 drop(lock);
1316 }
1317
1318 #[test]
1320 fn conflict_detection_enabled_config_false_when_env_unset() {
1321 let lock = crate::user_brain::test_env_lock()
1322 .lock()
1323 .unwrap_or_else(|p| p.into_inner());
1324 let prev = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
1325 unsafe {
1326 std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
1327 }
1328 assert!(
1329 !conflict_detection_enabled(false),
1330 "config=false + env unset must be disabled"
1331 );
1332 assert!(
1333 conflict_detection_enabled(true),
1334 "config=true + env unset must be enabled"
1335 );
1336 unsafe {
1337 match prev {
1338 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
1339 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
1340 }
1341 }
1342 drop(lock);
1343 }
1344
1345 #[test]
1351 fn off_switch_prevents_conflict_detection() {
1352 let conn = open_test_brain();
1353 let stub = StubEmbedder::new();
1354 insert_memory(
1356 &conn,
1357 "m_seed",
1358 "global_user",
1359 "fact",
1360 "alpha beta gamma delta",
1361 &stub,
1362 );
1363
1364 let lock = crate::user_brain::test_env_lock()
1366 .lock()
1367 .unwrap_or_else(|p| p.into_inner());
1368 let prev = std::env::var("KIMETSU_DETECT_CONFLICTS").ok();
1369 unsafe {
1370 std::env::remove_var("KIMETSU_DETECT_CONFLICTS");
1371 }
1372
1373 if conflict_detection_enabled(false) {
1375 panic!("detect_conflicts=false must disable the gate");
1377 }
1378 let count: i64 = conn
1380 .query_row("SELECT COUNT(*) FROM memory_conflicts", [], |row| {
1381 row.get(0)
1382 })
1383 .unwrap();
1384 assert_eq!(count, 0, "off-switch must prevent any conflict writes");
1385
1386 let hits = find_potential_conflicts(
1388 &conn,
1389 &MemoryScope::GlobalUser,
1390 "alpha beta gamma omega",
1391 &stub,
1392 DEFAULT_TOP_K,
1393 0.4,
1394 )
1395 .expect("scan");
1396 assert!(
1398 !hits.is_empty(),
1399 "when enabled, near-dup must be detected (test sanity check)"
1400 );
1401
1402 unsafe {
1403 match prev {
1404 Some(v) => std::env::set_var("KIMETSU_DETECT_CONFLICTS", v),
1405 None => std::env::remove_var("KIMETSU_DETECT_CONFLICTS"),
1406 }
1407 }
1408 drop(lock);
1409 }
1410
1411 #[test]
1420 fn exclude_id_prevents_self_conflict() {
1421 let conn = open_test_brain();
1422 let stub = StubEmbedder::new();
1423 insert_memory(
1424 &conn,
1425 "m_self",
1426 "global_user",
1427 "fact",
1428 "alpha beta gamma delta",
1429 &stub,
1430 );
1431 let hits = find_potential_conflicts_with_vec(
1433 &conn,
1434 &MemoryScope::GlobalUser,
1435 "alpha beta gamma delta",
1436 None,
1437 &stub,
1438 Some("m_self"),
1439 DEFAULT_TOP_K,
1440 0.0, )
1442 .expect("scan");
1443 assert!(
1444 hits.is_empty(),
1445 "excluded memory must not appear as a conflict hit"
1446 );
1447 }
1448
1449 #[allow(clippy::too_many_arguments)]
1455 fn insert_memory_with_meta(
1456 conn: &Connection,
1457 memory_id: &str,
1458 scope: &str,
1459 kind: &str,
1460 text: &str,
1461 confidence: f32,
1462 created_at: &str,
1463 embedder: &dyn Embedder,
1464 ) {
1465 let normalized = normalize_memory_text(text);
1466 let vec = embedder.embed(text).expect("embed test row");
1467 let blob = encode_embedding(&vec);
1468 conn.execute(
1469 "INSERT INTO memories (
1470 memory_id, scope, kind, text, normalized_text, confidence,
1471 source_event_id, provenance_snapshot_json, created_at,
1472 use_count, usefulness_score, embedding, embedding_model
1473 )
1474 VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '{}', ?7, 0, 0.0, ?8, ?9)",
1475 rusqlite::params![
1476 memory_id,
1477 scope,
1478 kind,
1479 text,
1480 normalized,
1481 confidence as f64,
1482 created_at,
1483 blob,
1484 embedder.model_id(),
1485 ],
1486 )
1487 .expect("insert");
1488 conn.execute(
1489 "INSERT INTO memories_fts (memory_id, text, kind, scope) VALUES (?1, ?2, ?3, ?4)",
1490 rusqlite::params![memory_id, text, kind, scope],
1491 )
1492 .expect("fts");
1493 }
1494
1495 #[test]
1497 fn resolution_score_higher_confidence_wins_all_else_equal() {
1498 let now_str = time::OffsetDateTime::now_utc()
1499 .format(&time::format_description::well_known::Rfc3339)
1500 .unwrap();
1501 let score_high = resolution_score(0.9, &now_str);
1502 let score_low = resolution_score(0.5, &now_str);
1503 assert!(
1504 score_high > score_low,
1505 "higher confidence must produce higher score; got {score_high} vs {score_low}"
1506 );
1507 }
1508
1509 #[test]
1511 fn resolution_score_newer_wins_all_else_equal() {
1512 let now_str = time::OffsetDateTime::now_utc()
1513 .format(&time::format_description::well_known::Rfc3339)
1514 .unwrap();
1515 let old_ts = (time::OffsetDateTime::now_utc() - time::Duration::days(90))
1517 .format(&time::format_description::well_known::Rfc3339)
1518 .unwrap();
1519 let score_new = resolution_score(0.8, &now_str);
1520 let score_old = resolution_score(0.8, &old_ts);
1521 assert!(
1522 score_new > score_old,
1523 "newer memory must score higher; got new={score_new} old={score_old}"
1524 );
1525 }
1526
1527 #[test]
1535 fn auto_resolution_stamps_loser_valid_to_when_new_wins() {
1536 let conn = open_test_brain();
1537 let stub = StubEmbedder::new();
1538
1539 let old_ts = "2020-01-01T00:00:00Z";
1540 insert_memory_with_meta(
1541 &conn,
1542 "m_loser",
1543 "global_user",
1544 "fact",
1545 "alpha beta gamma delta",
1546 0.3, old_ts,
1548 &stub,
1549 );
1550
1551 let now_str = time::OffsetDateTime::now_utc()
1552 .format(&time::format_description::well_known::Rfc3339)
1553 .unwrap();
1554 insert_memory_with_meta(
1555 &conn,
1556 "m_winner",
1557 "global_user",
1558 "fact",
1559 "alpha beta gamma omega",
1560 0.95, &now_str,
1562 &stub,
1563 );
1564
1565 let new_score = resolution_score(0.95, &now_str);
1567 let existing_score = resolution_score(0.3, old_ts);
1568 assert!(
1569 new_score > existing_score,
1570 "new high-confidence must score higher; got new={new_score} existing={existing_score}"
1571 );
1572 let delta = (new_score - existing_score).abs();
1573 assert!(
1574 delta >= NEAR_TIE_BAND,
1575 "gap {delta} must exceed NEAR_TIE_BAND for auto-resolution"
1576 );
1577
1578 crate::projector::mark_memory_temporal(&conn, "m_loser", None, Some(&now_str))
1580 .expect("mark valid_to on loser");
1581
1582 let loser_vt: Option<String> = conn
1584 .query_row(
1585 "SELECT valid_to FROM memories WHERE memory_id = 'm_loser'",
1586 [],
1587 |r| r.get(0),
1588 )
1589 .unwrap();
1590 assert!(loser_vt.is_some(), "loser must have valid_to stamped");
1591
1592 let winner_vt: Option<String> = conn
1594 .query_row(
1595 "SELECT valid_to FROM memories WHERE memory_id = 'm_winner'",
1596 [],
1597 |r| r.get(0),
1598 )
1599 .unwrap();
1600 assert!(winner_vt.is_none(), "winner must NOT have valid_to");
1601 }
1602
1603 #[test]
1606 fn auto_resolution_stamps_new_memory_when_existing_wins() {
1607 let conn = open_test_brain();
1608 let stub = StubEmbedder::new();
1609
1610 let now_str = time::OffsetDateTime::now_utc()
1611 .format(&time::format_description::well_known::Rfc3339)
1612 .unwrap();
1613 insert_memory_with_meta(
1614 &conn,
1615 "m_existing_winner",
1616 "global_user",
1617 "fact",
1618 "alpha beta gamma delta",
1619 0.95, &now_str,
1621 &stub,
1622 );
1623
1624 let old_ts = "2020-01-01T00:00:00Z";
1625 insert_memory_with_meta(
1626 &conn,
1627 "m_new_loser",
1628 "global_user",
1629 "fact",
1630 "alpha beta gamma omega",
1631 0.2, old_ts,
1633 &stub,
1634 );
1635
1636 let existing_score = resolution_score(0.95, &now_str);
1638 let new_score = resolution_score(0.2, old_ts);
1639 assert!(
1640 existing_score > new_score,
1641 "existing high-confidence must score higher; existing={existing_score} new={new_score}"
1642 );
1643 let delta = (existing_score - new_score).abs();
1644 assert!(
1645 delta >= NEAR_TIE_BAND,
1646 "gap {delta} must exceed NEAR_TIE_BAND"
1647 );
1648
1649 crate::projector::mark_memory_temporal(&conn, "m_new_loser", None, Some(&now_str))
1651 .expect("mark valid_to on new loser");
1652
1653 let new_vt: Option<String> = conn
1654 .query_row(
1655 "SELECT valid_to FROM memories WHERE memory_id = 'm_new_loser'",
1656 [],
1657 |r| r.get(0),
1658 )
1659 .unwrap();
1660 assert!(new_vt.is_some(), "new loser must have valid_to stamped");
1661
1662 let existing_vt: Option<String> = conn
1663 .query_row(
1664 "SELECT valid_to FROM memories WHERE memory_id = 'm_existing_winner'",
1665 [],
1666 |r| r.get(0),
1667 )
1668 .unwrap();
1669 assert!(
1670 existing_vt.is_none(),
1671 "existing winner must NOT have valid_to"
1672 );
1673 }
1674
1675 #[test]
1678 fn near_tie_goes_to_queue_not_auto_resolved() {
1679 let conn = open_test_brain();
1680 let stub = StubEmbedder::new();
1681
1682 let now_str = time::OffsetDateTime::now_utc()
1683 .format(&time::format_description::well_known::Rfc3339)
1684 .unwrap();
1685 insert_memory_with_meta(
1687 &conn,
1688 "m_tie_existing",
1689 "global_user",
1690 "fact",
1691 "alpha beta gamma delta",
1692 0.8,
1693 &now_str,
1694 &stub,
1695 );
1696 insert_memory_with_meta(
1697 &conn,
1698 "m_tie_new",
1699 "global_user",
1700 "fact",
1701 "alpha beta gamma omega",
1702 0.8,
1703 &now_str,
1704 &stub,
1705 );
1706
1707 let (auto_resolved, queued) = detect_record_and_resolve_with_vec(
1708 &conn,
1709 "m_tie_new",
1710 &MemoryScope::GlobalUser,
1711 "fact",
1712 "alpha beta gamma omega",
1713 None,
1714 &stub,
1715 0.8,
1716 &now_str,
1717 );
1718
1719 assert_eq!(
1723 auto_resolved, 0,
1724 "near-tie must NOT be auto-resolved (got {auto_resolved} auto-resolved)"
1725 );
1726
1727 let existing_vt: Option<String> = conn
1729 .query_row(
1730 "SELECT valid_to FROM memories WHERE memory_id = 'm_tie_existing'",
1731 [],
1732 |r| r.get(0),
1733 )
1734 .unwrap();
1735 let new_vt: Option<String> = conn
1736 .query_row(
1737 "SELECT valid_to FROM memories WHERE memory_id = 'm_tie_new'",
1738 [],
1739 |r| r.get(0),
1740 )
1741 .unwrap();
1742 assert!(
1743 existing_vt.is_none(),
1744 "near-tie existing memory must NOT be stamped; got {existing_vt:?}"
1745 );
1746 assert!(
1747 new_vt.is_none(),
1748 "near-tie new memory must NOT be stamped; got {new_vt:?}"
1749 );
1750 if queued > 0 {
1751 let count: i64 = conn
1752 .query_row(
1753 "SELECT COUNT(*) FROM memory_conflicts WHERE resolved_at IS NULL",
1754 [],
1755 |r| r.get(0),
1756 )
1757 .unwrap();
1758 assert!(
1759 count > 0,
1760 "near-tie must add unresolved row to memory_conflicts"
1761 );
1762 }
1763 }
1764
1765 #[test]
1768 fn auto_resolution_survives_rebuild() {
1769 let conn = open_test_brain();
1770 let stub = StubEmbedder::new();
1771
1772 let old_ts = "2020-01-01T00:00:00Z";
1773 insert_memory_with_meta(
1774 &conn,
1775 "m_rebuild_old",
1776 "global_user",
1777 "fact",
1778 "alpha beta gamma delta",
1779 0.2,
1780 old_ts,
1781 &stub,
1782 );
1783
1784 let now_str = time::OffsetDateTime::now_utc()
1785 .format(&time::format_description::well_known::Rfc3339)
1786 .unwrap();
1787 insert_memory_with_meta(
1788 &conn,
1789 "m_rebuild_new",
1790 "global_user",
1791 "fact",
1792 "alpha beta gamma omega",
1793 0.95,
1794 &now_str,
1795 &stub,
1796 );
1797
1798 let (auto_resolved, _queued) = detect_record_and_resolve_with_vec(
1799 &conn,
1800 "m_rebuild_new",
1801 &MemoryScope::GlobalUser,
1802 "fact",
1803 "alpha beta gamma omega",
1804 None,
1805 &stub,
1806 0.95,
1807 &now_str,
1808 );
1809
1810 if auto_resolved == 0 {
1811 return;
1814 }
1815
1816 let vt_before: Option<String> = conn
1818 .query_row(
1819 "SELECT valid_to FROM memories WHERE memory_id = 'm_rebuild_old'",
1820 [],
1821 |r| r.get(0),
1822 )
1823 .unwrap();
1824 assert!(
1825 vt_before.is_some(),
1826 "loser must have valid_to before rebuild"
1827 );
1828
1829 crate::projector::rebuild_in_place(&conn).expect("rebuild_in_place");
1831
1832 let vt_after: Option<String> = conn
1833 .query_row(
1834 "SELECT valid_to FROM memories WHERE memory_id = 'm_rebuild_old'",
1835 [],
1836 |r| r.get(0),
1837 )
1838 .unwrap();
1839 assert!(
1840 vt_after.is_some(),
1841 "loser's valid_to must survive rebuild_in_place"
1842 );
1843 }
1844
1845 #[test]
1848 fn resolve_conflicts_enabled_env_disable_overrides_config_true() {
1849 let lock = crate::user_brain::test_env_lock()
1850 .lock()
1851 .unwrap_or_else(|p| p.into_inner());
1852 let prev = std::env::var("KIMETSU_RESOLVE_CONFLICTS").ok();
1853 for v in ["0", "false", "off", "no"] {
1854 unsafe {
1855 std::env::set_var("KIMETSU_RESOLVE_CONFLICTS", v);
1856 }
1857 assert!(
1858 !resolve_conflicts_enabled(true),
1859 "env={v:?} must disable resolution even when config=true"
1860 );
1861 }
1862 unsafe {
1863 match prev {
1864 Some(v) => std::env::set_var("KIMETSU_RESOLVE_CONFLICTS", v),
1865 None => std::env::remove_var("KIMETSU_RESOLVE_CONFLICTS"),
1866 }
1867 }
1868 drop(lock);
1869 }
1870}