1use crate::MenteDb;
15use mentedb_cognitive::llm::{
16 ClusterMember, CognitiveLlmService, ConsolidationDecision, ContradictionVerdict, LlmJudge,
17 MemorySummary,
18};
19use mentedb_core::edge::EdgeType;
20use mentedb_core::error::MenteResult;
21use mentedb_core::memory::MemoryType;
22use mentedb_core::types::{AgentId, MemoryId, UserId};
23use mentedb_core::{MemoryEdge, MemoryNode};
24
25#[derive(Debug, Clone)]
28pub struct ConsolidationParams {
29 pub similarity_floor: f32,
31 pub top_k: usize,
33 pub coverage_min: f32,
36}
37
38impl Default for ConsolidationParams {
39 fn default() -> Self {
40 Self {
41 similarity_floor: 0.80,
42 top_k: 6,
43 coverage_min: 0.6,
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
50pub struct ConflictDetectionParams {
51 pub check_floor: f32,
55 pub near_identical: f32,
59 pub top_k: usize,
61}
62
63impl Default for ConflictDetectionParams {
64 fn default() -> Self {
65 Self {
66 check_floor: 0.72,
67 near_identical: 0.995,
68 top_k: 6,
69 }
70 }
71}
72
73fn mtype_name(mt: MemoryType) -> &'static str {
74 match mt {
75 MemoryType::Episodic => "Episodic",
76 MemoryType::Semantic => "Semantic",
77 MemoryType::Procedural => "Procedural",
78 MemoryType::AntiPattern => "AntiPattern",
79 MemoryType::Reasoning => "Reasoning",
80 MemoryType::Correction => "Correction",
81 }
82}
83
84fn mtype_from(s: &str) -> MemoryType {
85 match s.trim().to_ascii_lowercase().as_str() {
86 "episodic" => MemoryType::Episodic,
87 "procedural" => MemoryType::Procedural,
88 "antipattern" | "anti_pattern" => MemoryType::AntiPattern,
89 "reasoning" => MemoryType::Reasoning,
90 "correction" => MemoryType::Correction,
91 _ => MemoryType::Semantic,
92 }
93}
94
95fn scope_key(tags: &[String]) -> Option<&str> {
99 tags.iter()
100 .map(|t| t.as_str())
101 .find(|t| *t == "scope:global" || t.starts_with("scope:project:"))
102}
103
104fn significant_words(s: &str) -> std::collections::HashSet<String> {
105 s.to_lowercase()
106 .split(|c: char| !c.is_alphanumeric())
107 .filter(|w| w.len() > 3)
108 .map(|w| w.to_string())
109 .collect()
110}
111
112fn entailment_ok(surviving: &str, sources: &[&str], coverage_min: f32) -> bool {
116 let sw_merged = significant_words(surviving);
117 sources.iter().all(|src| {
118 let sw = significant_words(src);
119 if sw.is_empty() {
120 return true;
121 }
122 let covered = sw.iter().filter(|w| sw_merged.contains(*w)).count();
123 (covered as f32) >= (sw.len() as f32) * coverage_min
124 })
125}
126
127fn merged_tags(sources: &[&MemoryNode]) -> Vec<String> {
130 let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
131 for s in sources {
132 set.extend(s.tags.iter().cloned());
133 }
134 set.into_iter().collect()
135}
136
137fn now_micros() -> u64 {
138 std::time::SystemTime::now()
139 .duration_since(std::time::UNIX_EPOCH)
140 .unwrap_or_default()
141 .as_micros() as u64
142}
143
144fn parse_id(s: &str) -> Option<MemoryId> {
145 s.parse::<uuid::Uuid>().ok().map(MemoryId)
146}
147
148impl MenteDb {
149 pub async fn consolidate_memories<J: LlmJudge>(
163 &self,
164 new_ids: &[MemoryId],
165 judge: J,
166 params: &ConsolidationParams,
167 ) -> MenteResult<usize> {
168 let svc = CognitiveLlmService::new(judge);
169 let new_set: std::collections::HashSet<MemoryId> = new_ids.iter().copied().collect();
170 let mut consolidated = 0usize;
171
172 for &new_id in new_ids {
173 let Ok(new_node) = self.get_memory(new_id) else {
174 continue;
175 };
176 if new_node.is_invalidated() || new_node.embedding.is_empty() {
177 continue;
178 }
179 let new_scope = scope_key(&new_node.tags).map(|s| s.to_string());
180
181 let hits = self
184 .recall_similar(&new_node.embedding, params.top_k)
185 .unwrap_or_default();
186 let mut cluster: Vec<MemoryNode> = vec![new_node.clone()];
187 let mut seen: std::collections::HashSet<MemoryId> = std::iter::once(new_id).collect();
188 for (cid, sim) in hits {
189 if sim < params.similarity_floor || new_set.contains(&cid) || !seen.insert(cid) {
190 continue;
191 }
192 let Ok(cand) = self.get_memory(cid) else {
193 continue;
194 };
195 if cand.agent_id != new_node.agent_id
196 || cand.user_id != new_node.user_id
197 || cand.is_invalidated()
198 || scope_key(&cand.tags).map(|s| s.to_string()) != new_scope
199 {
200 continue;
201 }
202 cluster.push(cand);
203 }
204 if cluster.len() < 2 {
205 continue;
206 }
207
208 let members: Vec<ClusterMember> = cluster
209 .iter()
210 .map(|n| ClusterMember {
211 id: n.id.to_string(),
212 content: n.content.clone(),
213 memory_type: mtype_name(n.memory_type).to_string(),
214 confidence: 1.0,
215 created_at: n.created_at,
216 })
217 .collect();
218
219 let Ok(decision) = svc.consolidate(&members).await else {
220 continue; };
222 if self.apply_consolidation(
223 &decision,
224 &cluster,
225 new_node.agent_id,
226 new_node.user_id,
227 params,
228 ) {
229 consolidated += 1;
230 }
231 }
232 Ok(consolidated)
233 }
234
235 fn apply_consolidation(
239 &self,
240 decision: &ConsolidationDecision,
241 cluster: &[MemoryNode],
242 agent: AgentId,
243 user: UserId,
244 params: &ConsolidationParams,
245 ) -> bool {
246 let in_cluster = |id: &str| -> Option<&MemoryNode> {
247 let mid = parse_id(id)?;
248 cluster.iter().find(|n| n.id == mid)
249 };
250 let now = now_micros();
251
252 match decision {
253 ConsolidationDecision::KeepAll { .. } => false,
254
255 ConsolidationDecision::Merge {
256 merged_content,
257 merged_type,
258 remove_ids,
259 ..
260 } => {
261 let merged_content = merged_content.trim();
262 if merged_content.is_empty() {
263 return false;
264 }
265 let sources: Vec<&MemoryNode> = remove_ids
269 .iter()
270 .filter_map(|id| in_cluster(id))
271 .filter(|n| n.agent_id == agent && n.user_id == user && !n.is_invalidated())
272 .collect();
273 if sources.len() < 2 {
274 return false;
275 }
276 let src_texts: Vec<&str> = sources.iter().map(|n| n.content.as_str()).collect();
277 if !entailment_ok(merged_content, &src_texts, params.coverage_min) {
278 return false;
279 }
280
281 let Ok(Some(embedding)) = self.embed_text(merged_content) else {
282 return false;
283 };
284 if embedding.is_empty() {
285 return false;
286 }
287 let mut merged = MemoryNode::new(
288 agent,
289 mtype_from(merged_type),
290 merged_content.to_string(),
291 embedding,
292 )
293 .with_user_id(user);
294 merged.tags = merged_tags(&sources);
295 let merged_id = merged.id;
296 if self.store(merged).is_err() {
297 return false;
298 }
299 for s in &sources {
300 self.hide_into(merged_id, s.id, now);
301 }
302 true
303 }
304
305 ConsolidationDecision::Deduplicate {
306 keep_id,
307 remove_ids,
308 ..
309 } => {
310 let Some(keep) = in_cluster(keep_id) else {
311 return false;
312 };
313 if keep.agent_id != agent || keep.user_id != user || keep.is_invalidated() {
314 return false;
315 }
316 let removes: Vec<&MemoryNode> = remove_ids
317 .iter()
318 .filter_map(|id| in_cluster(id))
319 .filter(|n| {
320 n.agent_id == agent
321 && n.user_id == user
322 && !n.is_invalidated()
323 && n.id != keep.id
324 })
325 .collect();
326 if removes.is_empty() {
327 return false;
328 }
329 let removed_texts: Vec<&str> = removes.iter().map(|n| n.content.as_str()).collect();
331 if !entailment_ok(&keep.content, &removed_texts, params.coverage_min) {
332 return false;
333 }
334 for r in &removes {
335 self.hide_into(keep.id, r.id, now);
336 }
337 true
338 }
339 }
340 }
341
342 fn hide_into(&self, survivor: MemoryId, source: MemoryId, now: u64) {
346 let _ = self.invalidate_memory(source, now);
347 let _ = self.relate(MemoryEdge {
348 source: survivor,
349 target: source,
350 edge_type: EdgeType::Derived,
351 weight: 1.0,
352 created_at: now,
353 valid_from: None,
354 valid_until: None,
355 label: None,
356 });
357 }
358
359 pub async fn detect_conflicts_with_llm<J: LlmJudge>(
378 &self,
379 candidate_ids: &[MemoryId],
380 judge: J,
381 params: &ConflictDetectionParams,
382 max_checks: usize,
383 ) -> MenteResult<usize> {
384 if max_checks == 0 {
385 return Ok(0);
386 }
387
388 let mut pairs: Vec<(MemoryNode, MemoryNode)> = Vec::new();
392 let mut seen: std::collections::HashSet<(MemoryId, MemoryId)> =
393 std::collections::HashSet::new();
394 'outer: for &cid in candidate_ids {
395 let Ok(node) = self.get_memory(cid) else {
396 continue;
397 };
398 if node.is_invalidated() || node.embedding.is_empty() {
399 continue;
400 }
401 let scope = scope_key(&node.tags).map(|s| s.to_string());
402 let hits = self
403 .recall_similar(&node.embedding, params.top_k)
404 .unwrap_or_default();
405 for (nid, sim) in hits {
406 if nid == cid || sim < params.check_floor || sim >= params.near_identical {
407 continue;
408 }
409 let key = if cid < nid { (cid, nid) } else { (nid, cid) };
410 if !seen.insert(key) {
411 continue;
412 }
413 let Ok(other) = self.get_memory(nid) else {
414 continue;
415 };
416 if other.is_invalidated()
419 || other.agent_id != node.agent_id
420 || other.user_id != node.user_id
421 || other.content == node.content
422 || scope_key(&other.tags).map(|s| s.to_string()) != scope
423 || self.has_conflict_edge(cid, nid)
424 {
425 continue;
426 }
427 pairs.push((node.clone(), other));
428 if pairs.len() >= max_checks {
429 break 'outer;
430 }
431 }
432 }
433 if pairs.is_empty() {
434 return Ok(0);
435 }
436
437 let svc = CognitiveLlmService::new(judge);
439 enum Act {
440 Contradict {
441 a: MemoryId,
442 b: MemoryId,
443 reason: String,
444 },
445 Supersede {
446 winner: MemoryId,
447 loser: MemoryId,
448 },
449 }
450 let mut acts: Vec<Act> = Vec::new();
451 for (a, b) in &pairs {
452 match svc
453 .detect_contradiction(&conflict_summary(a), &conflict_summary(b))
454 .await
455 {
456 Ok(ContradictionVerdict::Contradicts { reason }) => acts.push(Act::Contradict {
457 a: a.id,
458 b: b.id,
459 reason,
460 }),
461 Ok(ContradictionVerdict::Supersedes { winner, .. }) => {
462 let (w, l) = if winner == a.id.to_string() {
463 (a.id, b.id)
464 } else {
465 (b.id, a.id)
466 };
467 acts.push(Act::Supersede {
468 winner: w,
469 loser: l,
470 });
471 }
472 _ => {} }
474 }
475
476 let now = now_micros();
478 let mut recorded = 0usize;
479 for act in acts {
480 let ok = match act {
481 Act::Contradict { a, b, reason } => self
482 .relate(MemoryEdge {
483 source: a,
484 target: b,
485 edge_type: EdgeType::Contradicts,
486 weight: 1.0,
487 created_at: now,
488 valid_from: None,
489 valid_until: None,
490 label: Some(reason),
491 })
492 .is_ok(),
493 Act::Supersede { winner, loser } => {
494 let _ = self.invalidate_memory(loser, now);
495 self.relate(MemoryEdge {
496 source: winner,
497 target: loser,
498 edge_type: EdgeType::Supersedes,
499 weight: 1.0,
500 created_at: now,
501 valid_from: None,
502 valid_until: None,
503 label: None,
504 })
505 .is_ok()
506 }
507 };
508 if ok {
509 recorded += 1;
510 }
511 }
512 Ok(recorded)
513 }
514
515 pub fn purge_inferred_conflicts(&self) -> MenteResult<(usize, usize)> {
525 let contradicts = self.graph().remove_edges_of_types(&[EdgeType::Contradicts]);
526 let supersedes = self.graph().remove_edges_of_types(&[EdgeType::Supersedes]);
527 self.graph().compact();
528 self.flush_full()?;
529 Ok((contradicts, supersedes))
530 }
531
532 fn has_conflict_edge(&self, a: MemoryId, b: MemoryId) -> bool {
535 let gm = self.graph();
536 let csr = gm.graph();
537 let is_conflict = |et: EdgeType| matches!(et, EdgeType::Contradicts | EdgeType::Supersedes);
538 if csr.contains_node(a) {
539 for (t, e) in csr.outgoing(a) {
540 if t == b && is_conflict(e.edge_type) {
541 return true;
542 }
543 }
544 }
545 if csr.contains_node(b) {
546 for (t, e) in csr.outgoing(b) {
547 if t == a && is_conflict(e.edge_type) {
548 return true;
549 }
550 }
551 }
552 false
553 }
554}
555
556fn conflict_summary(n: &MemoryNode) -> MemorySummary {
557 MemorySummary {
558 id: n.id,
559 content: n.content.clone(),
560 memory_type: n.memory_type,
561 confidence: n.confidence,
562 created_at: n.created_at,
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use mentedb_cognitive::llm::MockLlmJudge;
570 use mentedb_embedding::hash_provider::HashEmbeddingProvider;
571
572 fn open_db(tag: &str) -> (MenteDb, std::path::PathBuf) {
573 static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
574 let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
575 let path = std::env::temp_dir().join(format!(
576 "mentedb_llmcons_{}_{}_{}",
577 tag,
578 std::process::id(),
579 n
580 ));
581 let _ = std::fs::remove_dir_all(&path);
582 let db = MenteDb::open_with_embedder(&path, Box::new(HashEmbeddingProvider::new(256)))
583 .expect("open db");
584 (db, path)
585 }
586
587 fn store(db: &MenteDb, agent: AgentId, content: &str, tags: &[&str]) -> MemoryId {
588 let emb = db.embed_text(content).unwrap().unwrap();
589 let mut node = MemoryNode::new(agent, MemoryType::Semantic, content.to_string(), emb);
590 node.tags = tags.iter().map(|t| t.to_string()).collect();
591 let id = node.id;
592 db.store(node).unwrap();
593 id
594 }
595
596 fn cluster_of(db: &MenteDb, ids: &[MemoryId]) -> Vec<MemoryNode> {
597 ids.iter().map(|id| db.get_memory(*id).unwrap()).collect()
598 }
599
600 fn loose_floor() -> ConsolidationParams {
605 ConsolidationParams {
606 similarity_floor: 0.0,
607 top_k: 6,
608 coverage_min: 0.6,
609 }
610 }
611
612 #[test]
613 fn apply_merge_is_non_destructive() {
614 let (db, path) = open_db("merge");
615 let agent = AgentId(uuid::Uuid::new_v4());
616 let e = store(&db, agent, "uses Rust", &["scope:global"]);
617 let n = store(
618 &db,
619 agent,
620 "prefers Rust for systems work",
621 &["scope:global"],
622 );
623 let merged_text = "Uses Rust and prefers Rust for systems work";
624 let decision = ConsolidationDecision::Merge {
625 merged_content: merged_text.to_string(),
626 merged_type: "Semantic".to_string(),
627 keep_ids: vec![],
628 remove_ids: vec![e.to_string(), n.to_string()],
629 reason: "same preference".to_string(),
630 };
631 let cluster = cluster_of(&db, &[e, n]);
632 assert!(db.apply_consolidation(
633 &decision,
634 &cluster,
635 agent,
636 UserId::nil(),
637 &ConsolidationParams::default()
638 ));
639
640 assert_eq!(db.get_memory(e).unwrap().content, "uses Rust");
642 assert!(db.get_memory(e).unwrap().is_invalidated());
643 assert!(db.get_memory(n).unwrap().is_invalidated());
644
645 let q = db.embed_text(merged_text).unwrap().unwrap();
647 let hits = db.recall_similar(&q, 10).unwrap();
648 assert!(!hits.is_empty());
649 assert_eq!(db.get_memory(hits[0].0).unwrap().content, merged_text);
650 assert!(hits.iter().all(|(id, _)| *id != e && *id != n));
651
652 drop(db);
653 let _ = std::fs::remove_dir_all(&path);
654 }
655
656 #[test]
657 fn apply_dedup_keeps_one_hides_rest() {
658 let (db, path) = open_db("dedup");
659 let agent = AgentId(uuid::Uuid::new_v4());
660 let keep = store(
661 &db,
662 agent,
663 "the user deploys with Terraform to AWS",
664 &["scope:global"],
665 );
666 let dupe = store(&db, agent, "deploys Terraform AWS", &["scope:global"]);
667 let decision = ConsolidationDecision::Deduplicate {
668 keep_id: keep.to_string(),
669 remove_ids: vec![dupe.to_string()],
670 reason: "redundant".to_string(),
671 };
672 let cluster = cluster_of(&db, &[keep, dupe]);
673 assert!(db.apply_consolidation(
674 &decision,
675 &cluster,
676 agent,
677 UserId::nil(),
678 &ConsolidationParams::default()
679 ));
680 assert!(!db.get_memory(keep).unwrap().is_invalidated());
681 assert!(db.get_memory(dupe).unwrap().is_invalidated());
682 drop(db);
683 let _ = std::fs::remove_dir_all(&path);
684 }
685
686 #[test]
687 fn apply_keep_all_is_noop() {
688 let (db, path) = open_db("keepall");
689 let agent = AgentId(uuid::Uuid::new_v4());
690 let a = store(&db, agent, "likes tea", &["scope:global"]);
691 let b = store(&db, agent, "uses Rust", &["scope:global"]);
692 let decision = ConsolidationDecision::KeepAll {
693 reason: "distinct".to_string(),
694 };
695 let cluster = cluster_of(&db, &[a, b]);
696 assert!(!db.apply_consolidation(
697 &decision,
698 &cluster,
699 agent,
700 UserId::nil(),
701 &ConsolidationParams::default()
702 ));
703 assert!(!db.get_memory(a).unwrap().is_invalidated());
704 assert!(!db.get_memory(b).unwrap().is_invalidated());
705 drop(db);
706 let _ = std::fs::remove_dir_all(&path);
707 }
708
709 #[test]
710 fn apply_merge_rejects_information_loss() {
711 let (db, path) = open_db("entail");
712 let agent = AgentId(uuid::Uuid::new_v4());
713 let e = store(
714 &db,
715 agent,
716 "deploys with Terraform to AWS",
717 &["scope:global"],
718 );
719 let n = store(
720 &db,
721 agent,
722 "uses Terraform for infrastructure",
723 &["scope:global"],
724 );
725 let decision = ConsolidationDecision::Merge {
726 merged_content: "does some deployment things".to_string(),
727 merged_type: "Semantic".to_string(),
728 keep_ids: vec![],
729 remove_ids: vec![e.to_string(), n.to_string()],
730 reason: "x".to_string(),
731 };
732 let cluster = cluster_of(&db, &[e, n]);
733 assert!(!db.apply_consolidation(
734 &decision,
735 &cluster,
736 agent,
737 UserId::nil(),
738 &ConsolidationParams::default()
739 ));
740 assert!(!db.get_memory(e).unwrap().is_invalidated());
741 drop(db);
742 let _ = std::fs::remove_dir_all(&path);
743 }
744
745 #[tokio::test]
746 async fn consolidate_memories_end_to_end_with_mock_judge() {
747 let (db, path) = open_db("e2e");
748 let agent = AgentId(uuid::Uuid::new_v4());
749 let e = store(
751 &db,
752 agent,
753 "deploys services to AWS using Terraform",
754 &["scope:global"],
755 );
756 let n = store(
757 &db,
758 agent,
759 "deploys services to AWS using Terraform daily",
760 &["scope:global"],
761 );
762 let merged = "Deploys services to AWS using Terraform daily";
763 let response = format!(
764 r#"{{"action":"merge","merged_content":"{merged}","merged_type":"Semantic","keep_ids":[],"remove_ids":["{e}","{n}"],"reason":"same"}}"#
765 );
766 let judge = MockLlmJudge::new(response);
767 let count = db
768 .consolidate_memories(&[n], judge, &loose_floor())
769 .await
770 .unwrap();
771 assert_eq!(count, 1, "the cluster should consolidate");
772 assert!(db.get_memory(e).unwrap().is_invalidated());
773 assert!(db.get_memory(n).unwrap().is_invalidated());
774 drop(db);
775 let _ = std::fs::remove_dir_all(&path);
776 }
777
778 #[tokio::test]
779 async fn consolidate_memories_refuses_cross_scope() {
780 let (db, path) = open_db("scope");
781 let agent = AgentId(uuid::Uuid::new_v4());
782 store(
785 &db,
786 agent,
787 "deploys services to AWS using Terraform",
788 &["scope:global"],
789 );
790 let n = store(
791 &db,
792 agent,
793 "deploys services to AWS using Terraform",
794 &["scope:project:apex"],
795 );
796 let judge = MockLlmJudge::new(r#"{"action":"keep_all","reason":"n/a"}"#);
797 let count = db
798 .consolidate_memories(&[n], judge, &loose_floor())
799 .await
800 .unwrap();
801 assert_eq!(count, 0);
802 assert!(!db.get_memory(n).unwrap().is_invalidated());
803 drop(db);
804 let _ = std::fs::remove_dir_all(&path);
805 }
806
807 #[tokio::test]
808 async fn detect_conflicts_flags_contradiction_via_judge() {
809 let (db, path) = open_db("conflict");
810 let agent = AgentId(uuid::Uuid::new_v4());
811 let a = store(
812 &db,
813 agent,
814 "User prefers tabs for indentation",
815 &["scope:global"],
816 );
817 let b = store(
818 &db,
819 agent,
820 "User prefers spaces for indentation",
821 &["scope:global"],
822 );
823
824 let params = ConflictDetectionParams {
827 check_floor: 0.0,
828 near_identical: 1.0,
829 top_k: 6,
830 };
831 let judge = MockLlmJudge::new(r#"{"verdict":"contradicts","reason":"tabs vs spaces"}"#);
832 let recorded = db
833 .detect_conflicts_with_llm(&[b], judge, ¶ms, 8)
834 .await
835 .unwrap();
836 assert_eq!(recorded, 1, "one contradiction edge expected");
837
838 let has_edge = {
839 let g = db.graph().read_graph();
840 g.outgoing(b)
841 .iter()
842 .any(|(t, e)| *t == a && e.edge_type == EdgeType::Contradicts)
843 };
844 assert!(has_edge, "expected a Contradicts edge b -> a");
845
846 let judge2 = MockLlmJudge::new(r#"{"verdict":"contradicts","reason":"x"}"#);
849 let again = db
850 .detect_conflicts_with_llm(&[b], judge2, ¶ms, 8)
851 .await
852 .unwrap();
853 assert_eq!(again, 0, "existing conflict edge must not be re-judged");
854
855 drop(db);
856 let _ = std::fs::remove_dir_all(&path);
857 }
858
859 #[test]
860 fn purge_removes_conflict_edges_keeps_related() {
861 let (db, path) = open_db("purge");
862 let agent = AgentId(uuid::Uuid::new_v4());
863 let a = store(&db, agent, "fact A", &["scope:global"]);
864 let b = store(&db, agent, "fact B", &["scope:global"]);
865
866 let now = now_micros();
867 let edge = |et: EdgeType| MemoryEdge {
868 source: a,
869 target: b,
870 edge_type: et,
871 weight: 1.0,
872 created_at: now,
873 valid_from: None,
874 valid_until: None,
875 label: None,
876 };
877 db.relate(edge(EdgeType::Contradicts)).unwrap();
878 db.relate(edge(EdgeType::Related)).unwrap();
879 db.relate(edge(EdgeType::Supersedes)).unwrap();
880
881 let (c, s) = db.purge_inferred_conflicts().unwrap();
882 assert_eq!((c, s), (1, 1), "one contradicts + one supersedes removed");
883
884 let g = db.graph().read_graph();
885 let out = g.outgoing(a);
886 assert!(
887 out.iter().all(|(_, e)| e.edge_type != EdgeType::Contradicts
888 && e.edge_type != EdgeType::Supersedes),
889 "conflict edges must be gone"
890 );
891 assert!(
892 out.iter()
893 .any(|(t, e)| *t == b && e.edge_type == EdgeType::Related),
894 "the Related sibling must survive the purge"
895 );
896 drop(g);
897 drop(db);
898 let _ = std::fs::remove_dir_all(&path);
899 }
900}