Skip to main content

mentedb/
llm_consolidation.rs

1//! LLM-driven memory consolidation.
2//!
3//! Folds newly stored memories into existing near-duplicates the extractor
4//! could not catch (semantic dedup, e.g. "uses Rust" and "prefers Rust for
5//! systems work" become one memory). The LLM judgment is provided through the
6//! [`LlmJudge`] trait, so the engine stays LLM-optional: callers with no judge
7//! simply do not invoke this, and the rule-based [`MenteDb::consolidate_cluster`]
8//! remains available.
9//!
10//! All applies are non-destructive: sources are invalidated (recall-hidden via
11//! `valid_until`, retained on disk) and linked to the surviving memory by a
12//! `Derived` edge, mirroring [`MenteDb::consolidate_cluster`].
13
14use 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/// Tunables for LLM consolidation. Defaults mirror the contradiction sweep's
26/// dedup band and a conservative information-loss guard.
27#[derive(Debug, Clone)]
28pub struct ConsolidationParams {
29    /// Only existing memories at least this cosine-similar are candidates.
30    pub similarity_floor: f32,
31    /// Nearest existing memories fetched per new memory.
32    pub top_k: usize,
33    /// Reject a merge covering less than this fraction of any source's
34    /// significant words (guards against dropped information).
35    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/// Tunables for LLM conflict detection (contradictions and supersessions).
49#[derive(Debug, Clone)]
50pub struct ConflictDetectionParams {
51    /// Minimum cosine similarity for two memories to be worth an LLM check.
52    /// Below this they are unlikely to concern the same subject, so judging
53    /// them would only burn tokens.
54    pub check_floor: f32,
55    /// At or above this similarity the pair is a near-identical duplicate and is
56    /// left to `consolidate_memories` (merge/dedup) rather than flagged as a
57    /// conflict. Genuine contradictions live in the band between the two.
58    pub near_identical: f32,
59    /// Nearest neighbors fetched per candidate memory.
60    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
95/// The scope tag that gates whether two memories may merge: a global fact and a
96/// project fact are different assertions and must never fold together. Returns
97/// the distinguishing scope tag, or None when the memory carries no scope tag.
98fn 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
112/// Guard against information loss: every source's significant words must be
113/// mostly present in the surviving text. Cheap defense in depth on top of the
114/// consolidation prompt's "preserve all important information" constraint.
115fn 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
127/// Union of every source's tags, so a merged memory stays visible in every
128/// scope its inputs were visible in.
129fn 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    /// Consolidate newly stored memories against existing near-duplicates using
150    /// an LLM judge.
151    ///
152    /// For each new memory, gathers same-agent same-scope neighbors above the
153    /// similarity floor, asks the judge how to combine the cluster, and applies
154    /// the MERGE/DEDUPLICATE decision non-destructively: sources are invalidated
155    /// (recall-hidden via `valid_until`, retained on disk) and linked to the
156    /// surviving memory by a `Derived` edge. Returns the number of clusters
157    /// consolidated.
158    ///
159    /// Fails soft: a judge or parse error on one cluster skips that cluster, not
160    /// the rest. The engine stays LLM-optional, pass any [`LlmJudge`]; with none,
161    /// do not call this and consolidation simply does not run.
162    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            // Gather same-agent, same-scope neighbors above the floor, plus the
182            // new memory itself, into one cluster for the judge.
183            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; // fail soft on judge/parse error
221            };
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    /// Apply one consolidation decision non-destructively against the cluster it
236    /// was made for. Only ids the judge actually saw (present in `cluster`) can
237    /// be touched. Returns true if anything changed.
238    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                // Resolve + revalidate sources: in the cluster, same agent AND
266                // same user, still valid. Closes the window if a concurrent
267                // write invalidated one.
268                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                // Dedup must not drop information the kept memory lacks.
330                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    /// Invalidate `source` (recall-hidden, retained on disk) and record that
343    /// `survivor` was derived from it. Best effort: edge/invalidation failures
344    /// do not abort the consolidation.
345    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    /// Detect genuine contradictions and supersessions between semantically
360    /// similar memories using an LLM judge, recording them as typed edges.
361    ///
362    /// This is the correct path for conflict detection. Cosine similarity cannot
363    /// tell a reworded duplicate from a real contradiction (both land in the
364    /// high-similarity band), so the write-time heuristic no longer guesses; it
365    /// produced ~0% precision. Here, for each candidate the engine gathers
366    /// same-agent, same-scope neighbors in the "similar but not identical" band
367    /// and asks the judge to read both texts:
368    ///   - `Contradicts` becomes a `Contradicts` edge (both kept, flagged).
369    ///   - `Supersedes` invalidates the loser and records a `Supersedes` edge.
370    ///   - `Compatible` (or any judge/parse error) does nothing.
371    ///
372    /// Near-identical duplicates are skipped and left to `consolidate_memories`.
373    ///
374    /// Bounds work to `max_checks` LLM calls, fails soft per pair, and never
375    /// re-judges a pair that already carries a conflict edge. Returns the number
376    /// of conflict edges recorded.
377    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        // Phase 1: gather unordered candidate pairs. Read-only; no lock is held
389        // across the later awaits because get_memory/recall_similar/graph each
390        // take and release their own lock.
391        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                // Same agent + same user + same scope + not already a duplicate
417                // string, and not already judged (a conflict edge exists).
418                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        // Phase 2: ask the judge to read each pair (no locks held).
438        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                _ => {} // Compatible, or judge/parse error: nothing.
473            }
474        }
475
476        // Phase 3: apply edges and invalidations.
477        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    /// One-time cleanup: remove the conflict edges the old write-time heuristic
516    /// created from bare cosine similarity (which flagged reworded duplicates as
517    /// contradictions at ~0% precision). Removes every `Contradicts` and
518    /// `Supersedes` edge, compacts, and persists a fresh graph snapshot so the
519    /// edge log cannot re-add them on reopen.
520    ///
521    /// Does NOT alter memory validity: near-duplicate copies the heuristic hid
522    /// stay hidden, and `detect_conflicts_with_llm` re-adds only genuine
523    /// conflicts going forward. Returns `(contradicts_removed, supersedes_removed)`.
524    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    /// True if a Contradicts or Supersedes edge already links `a` and `b` in
533    /// either direction, so the conflict sweep never re-judges the same pair.
534    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    // Hash embeddings are not semantic, so real near-duplicates fall below any
601    // meaningful production floor. A zero floor lets the end-to-end tests
602    // exercise the full collect -> judge -> apply path deterministically; the
603    // floor itself is a tunable, covered separately by the scope filter test.
604    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        // Sources retained with original content, invalidated.
641        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        // Merged memory recallable (identical text is cosine 1.0), sources not.
646        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        // Near-identical text so the hash embedder clusters them above the floor.
750        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        // Same text but different scope: a global fact and a project fact must
783        // never cluster, so there is nothing for the judge to merge.
784        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        // Loose bands so the hash-embedding neighbor qualifies as a candidate
825        // (hash similarity is not semantic). The judge says they contradict.
826        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, &params, 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        // Idempotent: the pair already carries a conflict edge, so a second sweep
847        // records nothing.
848        let judge2 = MockLlmJudge::new(r#"{"verdict":"contradicts","reason":"x"}"#);
849        let again = db
850            .detect_conflicts_with_llm(&[b], judge2, &params, 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}