Skip to main content

mentedb_cognitive/
write_inference.rs

1use mentedb_core::MemoryNode;
2use mentedb_core::edge::EdgeType;
3use mentedb_core::memory::MemoryType;
4use mentedb_core::types::MemoryId;
5
6use crate::llm::{
7    CognitiveLlmService, ContradictionVerdict, InvalidationVerdict, LlmJudge, MemorySummary,
8};
9
10#[derive(Debug, Clone)]
11pub enum InferredAction {
12    FlagContradiction {
13        existing: MemoryId,
14        new: MemoryId,
15        reason: String,
16    },
17    MarkObsolete {
18        memory: MemoryId,
19        superseded_by: MemoryId,
20    },
21    /// Set valid_until on the old memory instead of deleting it.
22    InvalidateMemory {
23        memory: MemoryId,
24        superseded_by: MemoryId,
25        valid_until: u64,
26    },
27    /// A byte-identical re-save: invalidate the duplicate and link it to the
28    /// surviving copy with a `Derived` edge (deduplication lineage), NOT a
29    /// `Supersedes` edge. A Supersedes edge here reads as a memory superseding an
30    /// exact copy of itself, which only shows up as noise in the conflict view.
31    DeduplicateExact {
32        duplicate: MemoryId,
33        keeper: MemoryId,
34    },
35    /// Update the content of an existing memory with merged information.
36    UpdateContent {
37        memory: MemoryId,
38        new_content: String,
39        reason: String,
40    },
41    CreateEdge {
42        source: MemoryId,
43        target: MemoryId,
44        edge_type: EdgeType,
45        weight: f32,
46    },
47    UpdateConfidence {
48        memory: MemoryId,
49        new_confidence: f32,
50    },
51    /// Spread a change of `delta` in `root`'s confidence to the memories that
52    /// depend on it (over the graph's dependency edges, damped per hop). The
53    /// root's own confidence is NOT changed by this action: it is always
54    /// emitted right after the `UpdateConfidence` that sets the root, and
55    /// `delta` is that update's new confidence minus the old one.
56    PropagateBeliefChange { root: MemoryId, delta: f32 },
57}
58
59fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
60    if a.len() != b.len() || a.is_empty() {
61        return 0.0;
62    }
63    let mut dot = 0.0f32;
64    let mut norm_a = 0.0f32;
65    let mut norm_b = 0.0f32;
66    for i in 0..a.len() {
67        dot += a[i] * b[i];
68        norm_a += a[i] * a[i];
69        norm_b += b[i] * b[i];
70    }
71    let denom = norm_a.sqrt() * norm_b.sqrt();
72    if denom == 0.0 { 0.0 } else { dot / denom }
73}
74
75/// What write inference does when it finds that a new memory contradicts an
76/// older one (a value update on the same sentence frame, or an LLM verdict that
77/// one memory replaces the other).
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
79#[serde(rename_all = "snake_case")]
80pub enum ContradictionPolicy {
81    /// Close the losing memory: set its valid_until, record a Supersedes edge
82    /// and decay its confidence, so recall serves only the winner.
83    #[default]
84    Supersede,
85    /// Hint mode: keep both memories valid and record a Contradicts edge
86    /// between them, so the caller can surface the conflict and decide. Under
87    /// this policy recall keeps serving both sides of a Contradicts edge.
88    /// Memories stored with the explicit Correction type still supersede,
89    /// because the caller already made that decision.
90    Flag,
91}
92
93impl std::str::FromStr for ContradictionPolicy {
94    type Err = String;
95
96    fn from_str(s: &str) -> Result<Self, Self::Err> {
97        match s.trim().to_ascii_lowercase().as_str() {
98            "supersede" => Ok(Self::Supersede),
99            "flag" => Ok(Self::Flag),
100            other => Err(format!(
101                "unknown contradiction policy '{other}' (expected supersede or flag)"
102            )),
103        }
104    }
105}
106
107/// Configuration for write-time inference thresholds.
108#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
109#[serde(default)]
110pub struct WriteInferenceConfig {
111    /// Similarity above which two memories may contradict (default: 0.95).
112    pub contradiction_threshold: f32,
113    /// Similarity above which an older memory is marked obsolete (default: 0.85).
114    pub obsolete_threshold: f32,
115    /// Minimum similarity for creating a Related edge (default: 0.6).
116    pub related_min: f32,
117    /// Maximum similarity for creating a Related edge (default: 0.85).
118    pub related_max: f32,
119    /// Minimum similarity for a Correction to supersede (default: 0.5).
120    pub correction_threshold: f32,
121    /// Multiplier applied to original confidence on correction (default: 0.5).
122    pub confidence_decay_factor: f32,
123    /// Minimum confidence after decay (default: 0.1).
124    pub confidence_floor: f32,
125    /// Whether the value-update rule runs: a new memory that shares an older
126    /// same-owner memory's sentence frame (long common token prefix) but ends
127    /// in a different value supersedes it, so "favorite coffee is a cortado"
128    /// followed by "favorite coffee is a flat white" keeps only the correction.
129    /// This is the cheap, LLM-free slice of contradiction handling; unlike bare
130    /// cosine (which cannot tell paraphrase from contradiction and was removed
131    /// at ~0% precision), the shared-frame test only fires on the update shape.
132    pub value_update_enabled: bool,
133    /// Minimum embedding similarity for the value-update rule (default: 0.3).
134    ///
135    /// This is a loose topical floor, not the primary gate. A value correction
136    /// inherently drives cosine down (the changed value is most of a short
137    /// fact's meaning), so real corrections land well below a high cosine bar:
138    /// measured on Titan V2 at 256 dims, "favorite coffee is a cortado" vs
139    /// "... a flat white" is 0.61 and "phone is 1234" vs "... 4321" is 0.51;
140    /// on OpenAI text-embedding-3-small they are 0.80 and 0.79. A 0.88 gate
141    /// (the first default) was unreachable on either embedder, and even 0.5
142    /// sat inside the real-pair range and silently gated identical-frame
143    /// pairs in production (observed on Cohere 1024: "uses Next.js for the
144    /// front end" vs "uses React for the front end" never superseded). The
145    /// embedder-independent `is_value_update` shared frame test carries the
146    /// precision here; the floor only rejects genuinely off-topic pairs that
147    /// a shared token frame collided on, which sit near 0.2 and below.
148    pub value_update_min_similarity: f32,
149    /// Minimum fraction of the longer memory's tokens that must be shared
150    /// frame, the common prefix plus the common suffix around the changed
151    /// value (default: 0.6).
152    pub value_update_prefix_share: f32,
153    /// Maximum differing value tokens per side (default: 4). Larger middles
154    /// mean genuinely different statements, not a value change.
155    pub value_update_max_tail: usize,
156    /// What to do when a new memory contradicts an older one (default:
157    /// supersede the older one).
158    pub contradiction_policy: ContradictionPolicy,
159    /// Whether a confidence change made by write inference (a superseded or
160    /// corrected memory losing confidence) is also spread to the memories
161    /// that depend on it, as a `PropagateBeliefChange` action (default: true).
162    pub belief_propagation_enabled: bool,
163    /// Smallest absolute confidence change that triggers belief propagation
164    /// (default: 0.05). Smaller changes update only the memory itself.
165    pub belief_propagation_min_delta: f32,
166}
167
168impl Default for WriteInferenceConfig {
169    fn default() -> Self {
170        Self {
171            contradiction_threshold: 0.95,
172            obsolete_threshold: 0.85,
173            related_min: 0.6,
174            related_max: 0.85,
175            correction_threshold: 0.5,
176            confidence_decay_factor: 0.5,
177            confidence_floor: 0.1,
178            value_update_enabled: true,
179            value_update_min_similarity: 0.3,
180            value_update_prefix_share: 0.6,
181            value_update_max_tail: 4,
182            contradiction_policy: ContradictionPolicy::Supersede,
183            belief_propagation_enabled: true,
184            belief_propagation_min_delta: 0.05,
185        }
186    }
187}
188
189/// Lowercased alphanumeric tokens of a memory's content, the unit of the
190/// value-update frame comparison.
191fn frame_tokens(s: &str) -> Vec<String> {
192    s.split(|c: char| !c.is_alphanumeric())
193        .filter(|t| !t.is_empty())
194        .map(|t| t.to_lowercase())
195        .collect()
196}
197
198/// True when `new` and `old` share the same sentence frame with a short
199/// differing value somewhere inside it: a common token prefix and a common
200/// token suffix that together cover most of the sentence, with a small
201/// non-identical middle on each side. The value may sit at the end ("the
202/// user's favorite coffee order is a cortado" vs "... is a flat white") or
203/// mid sentence ("i use next.js for the front end" vs "i use react for the
204/// front end", where the value is wrapped by a shared tail). A subject swap
205/// at the head ("my dog is allergic to chicken" vs "my cat is allergic to
206/// chicken") shares at most one leading token, so the two token minimum
207/// prefix keeps it out: changing the subject is a different fact, not a
208/// value update. The original prefix-only test could not see shared tails,
209/// which made every mid sentence value change invisible and left
210/// contradicting facts coexisting.
211///
212/// Mid sentence middles that are purely numeric on both sides ("rule 3" vs
213/// "rule 4", "room 2" vs "room 5") are identifiers naming different things,
214/// not changed values, and such facts legitimately coexist; only the end
215/// position keeps numeric updates ("phone is 1234" vs "phone is 4321").
216/// Precision beats recall here on purpose: a wrong supersession silently
217/// deletes a true fact, while a missed one is caught by the LLM
218/// contradiction layer.
219fn is_value_update(new: &str, old: &str, frame_share: f32, max_value: usize) -> bool {
220    let nt = frame_tokens(new);
221    let ot = frame_tokens(old);
222    if nt.is_empty() || ot.is_empty() || nt == ot {
223        return false;
224    }
225    // Very short sentences cannot carry enough frame to tell an update from
226    // an enumeration: "directive number 1" and "directive number 2" are two
227    // items, not one changed value. Real value sentences name a relation and
228    // a value and run longer ("my phone number is 4321" is five tokens).
229    if nt.len().min(ot.len()) < 4 {
230        return false;
231    }
232    let prefix = nt.iter().zip(ot.iter()).take_while(|(a, b)| a == b).count();
233    if prefix < 2 {
234        return false;
235    }
236    let suffix = nt
237        .iter()
238        .rev()
239        .zip(ot.iter().rev())
240        .take_while(|(a, b)| a == b)
241        .count()
242        // The suffix may not reach back into the prefix on the shorter side.
243        .min(nt.len().min(ot.len()) - prefix);
244    let longest = nt.len().max(ot.len());
245    let value_new = nt.len() - prefix - suffix;
246    let value_old = ot.len() - prefix - suffix;
247    if suffix > 0 {
248        let all_numeric =
249            |tokens: &[String]| tokens.iter().all(|t| t.chars().all(|c| c.is_ascii_digit()));
250        if all_numeric(&nt[prefix..nt.len() - suffix])
251            && all_numeric(&ot[prefix..ot.len() - suffix])
252        {
253            return false;
254        }
255    }
256    (prefix + suffix) as f32 / longest as f32 >= frame_share
257        && value_new.max(value_old) > 0
258        && value_new <= max_value
259        && value_old <= max_value
260}
261
262/// Decide which of two value-frame-matching memories supersedes the other, and
263/// which is left stale. Order-independent: a `Correction` supersedes a plain
264/// fact whichever was stored first (the extractor types a correction explicitly,
265/// so it is the authoritative value); otherwise the later-created memory wins.
266/// Returns `(winner_id, loser_id, loser_confidence)`, or `None` when there is no
267/// correction signal and the timestamps are equal, in which case the two are
268/// kept as coexisting facts rather than guessing a direction.
269fn value_update_resolution(
270    new: &MemoryNode,
271    existing: &MemoryNode,
272) -> Option<(MemoryId, MemoryId, f32)> {
273    let new_correction = new.memory_type == MemoryType::Correction;
274    let old_correction = existing.memory_type == MemoryType::Correction;
275    if new_correction && !old_correction {
276        return Some((new.id, existing.id, existing.confidence));
277    }
278    if old_correction && !new_correction {
279        return Some((existing.id, new.id, new.confidence));
280    }
281    if new.created_at > existing.created_at {
282        return Some((new.id, existing.id, existing.confidence));
283    }
284    if existing.created_at > new.created_at {
285        return Some((existing.id, new.id, new.confidence));
286    }
287    None
288}
289
290pub struct WriteInferenceEngine {
291    config: WriteInferenceConfig,
292}
293
294impl WriteInferenceEngine {
295    pub fn new() -> Self {
296        Self {
297            config: WriteInferenceConfig::default(),
298        }
299    }
300
301    pub fn with_config(config: WriteInferenceConfig) -> Self {
302        Self { config }
303    }
304
305    /// Whether `new` and `old` look like a value update (same sentence frame,
306    /// changed value tail) under this engine's configured thresholds. A
307    /// deduplication pass that merges near-duplicates uses this as a guard so a
308    /// correction ("coffee is a cortado" then "coffee is a flat white") is
309    /// never collapsed into the fact it corrects; the two must stay distinct
310    /// for supersession to resolve them.
311    pub fn looks_like_value_update(&self, new: &str, old: &str) -> bool {
312        is_value_update(
313            new,
314            old,
315            self.config.value_update_prefix_share,
316            self.config.value_update_max_tail,
317        )
318    }
319
320    pub fn infer_on_write(
321        &self,
322        new_memory: &MemoryNode,
323        existing_memories: &[MemoryNode],
324        existing_edges: &[(MemoryId, MemoryId, EdgeType)],
325    ) -> Vec<InferredAction> {
326        let _ = existing_edges; // reserved for future graph-aware inference
327        let mut actions = Vec::new();
328        // Value-frame-matched pairs are owned by the value-update rule below; the
329        // broad correction handler skips them so a pair is never resolved twice.
330        let mut value_update_handled: Vec<MemoryId> = Vec::new();
331
332        for existing in existing_memories {
333            if existing.id == new_memory.id {
334                continue;
335            }
336
337            let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
338
339            // Cosine similarity alone cannot tell a reworded duplicate from a
340            // genuine contradiction: paraphrases ("uses Postgres" / "uses
341            // PostgreSQL") and opposites ("prefers tabs" / "prefers spaces")
342            // both land in the high-similarity band. So the cheap write-time
343            // heuristic makes only the two judgments similarity CAN support:
344            //   1. byte-identical content -> a true duplicate, supersede it
345            //   2. moderate similarity    -> a Related edge
346            // Every semantic decision (merge, paraphrase dedup, real
347            // contradiction, which-fact-wins supersession) is deferred to the
348            // LLM paths (`consolidate_memories`, `detect_conflicts_with_llm`),
349            // which read the actual text. Flagging contradictions from bare
350            // similarity here produced ~0% precision (every reworded re-save
351            // looked like a contradiction), so it was removed.
352            if existing.content == new_memory.content
353                && sim > self.config.obsolete_threshold
354                && new_memory.created_at > existing.created_at
355            {
356                actions.push(InferredAction::DeduplicateExact {
357                    duplicate: existing.id,
358                    keeper: new_memory.id,
359                });
360            } else if self.config.value_update_enabled
361                && existing.agent_id == new_memory.agent_id
362                && existing.user_id == new_memory.user_id
363                && is_value_update(
364                    &new_memory.content,
365                    &existing.content,
366                    self.config.value_update_prefix_share,
367                    self.config.value_update_max_tail,
368                )
369            {
370                if sim < self.config.value_update_min_similarity {
371                    // A frame matched pair below the floor is exactly the
372                    // signal that the floor sits wrong for the embedding
373                    // space in use; log it so production data keeps the
374                    // default honest.
375                    tracing::info!(
376                        similarity = sim,
377                        "value update frame matched below the cosine floor, skipped"
378                    );
379                    continue;
380                }
381                // Same sentence frame, different value: one supersedes the other.
382                // The value-update rule owns this pair (so the correction handler
383                // skips it), and the winner is decided deterministically rather
384                // than by arrival order: a correction beats a plain fact, else the
385                // later-created wins. Batch enrichment stores extracted facts in
386                // arbitrary order with near-equal timestamps, so an order-dependent
387                // rule fired only about half the time; this resolves the same way
388                // whether the correction arrives before or after the original.
389                value_update_handled.push(existing.id);
390                if let Some((winner, loser, loser_confidence)) =
391                    value_update_resolution(new_memory, existing)
392                {
393                    // Invalidate the loser, not just edge it: recall's
394                    // validity filter reads valid_until, so a Supersedes edge
395                    // alone left the stale value recallable and the agent
396                    // kept answering with it. InvalidateMemory sets
397                    // valid_until and creates the Supersedes edge.
398                    let now = std::time::SystemTime::now()
399                        .duration_since(std::time::UNIX_EPOCH)
400                        .unwrap_or_default()
401                        .as_micros() as u64;
402                    self.resolve_contradiction(
403                        &mut actions,
404                        existing.id,
405                        new_memory.id,
406                        loser,
407                        winner,
408                        loser_confidence,
409                        now,
410                        "value update: same sentence frame with a different value",
411                    );
412                }
413                // else: no correction signal and equal timestamps, so the two are
414                // kept as coexisting facts (a multi-valued attribute) rather than
415                // one silently superseding the other.
416            } else if sim > self.config.related_min && sim <= self.config.related_max {
417                actions.push(InferredAction::CreateEdge {
418                    source: new_memory.id,
419                    target: existing.id,
420                    edge_type: EdgeType::Related,
421                    weight: sim,
422                });
423            }
424        }
425
426        // Correction type: find the most similar existing memory and supersede
427        // it, skipping any pair the value-update rule already resolved above.
428        if new_memory.memory_type == MemoryType::Correction
429            && let Some(original) = existing_memories
430                .iter()
431                .filter(|m| m.id != new_memory.id && !value_update_handled.contains(&m.id))
432                .max_by(|a, b| {
433                    cosine_similarity(&new_memory.embedding, &a.embedding)
434                        .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
435                        .unwrap_or(std::cmp::Ordering::Equal)
436                })
437        {
438            let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
439            if sim > self.config.correction_threshold {
440                actions.push(InferredAction::CreateEdge {
441                    source: new_memory.id,
442                    target: original.id,
443                    edge_type: EdgeType::Supersedes,
444                    weight: 1.0,
445                });
446                self.push_confidence_change(
447                    &mut actions,
448                    original.id,
449                    original.confidence,
450                    self.decayed_confidence(original.confidence),
451                );
452            }
453        }
454
455        actions
456    }
457
458    /// LLM enhanced write inference. Uses the CognitiveLlmService when available
459    /// to make smarter invalidation and contradiction decisions. Falls back to
460    /// cosine similarity heuristics for moderate similarity (Related edges) and
461    /// correction handling.
462    pub async fn infer_on_write_with_llm<J: LlmJudge>(
463        &self,
464        new_memory: &MemoryNode,
465        existing_memories: &[MemoryNode],
466        existing_edges: &[(MemoryId, MemoryId, EdgeType)],
467        llm: &CognitiveLlmService<J>,
468    ) -> Vec<InferredAction> {
469        let _ = existing_edges;
470        let mut actions = Vec::new();
471
472        let new_summary = memory_to_summary(new_memory);
473
474        for existing in existing_memories {
475            if existing.id == new_memory.id {
476                continue;
477            }
478
479            let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
480
481            // Only consult LLM for memories with moderate+ similarity (> 0.5)
482            // to avoid burning tokens on completely unrelated pairs
483            if sim > 0.5 && existing.agent_id == new_memory.agent_id {
484                let old_summary = memory_to_summary(existing);
485
486                // LLM invalidation check
487                if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
488                    match verdict {
489                        InvalidationVerdict::Invalidate { reason } => {
490                            // InvalidateMemory sets valid_until and creates the
491                            // Supersedes edge; no separate MarkObsolete/CreateEdge.
492                            self.resolve_contradiction(
493                                &mut actions,
494                                existing.id,
495                                new_memory.id,
496                                existing.id,
497                                new_memory.id,
498                                existing.confidence,
499                                new_memory.created_at,
500                                &reason,
501                            );
502                            // Skip contradiction check since we already know the relationship
503                            continue;
504                        }
505                        InvalidationVerdict::Update {
506                            merged_content,
507                            reason,
508                        } => {
509                            actions.push(InferredAction::UpdateContent {
510                                memory: existing.id,
511                                new_content: merged_content,
512                                reason,
513                            });
514                            continue;
515                        }
516                        InvalidationVerdict::Keep { .. } => {
517                            // Fall through to contradiction check
518                        }
519                    }
520                }
521
522                // LLM contradiction check for high similarity pairs that weren't invalidated
523                if sim > 0.7
524                    && existing.content != new_memory.content
525                    && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
526                {
527                    match verdict {
528                        ContradictionVerdict::Contradicts { reason } => {
529                            actions.push(InferredAction::FlagContradiction {
530                                existing: existing.id,
531                                new: new_memory.id,
532                                reason,
533                            });
534                        }
535                        ContradictionVerdict::Supersedes { winner, reason } => {
536                            let winner_is_new = winner == new_memory.id.to_string();
537                            let (obsolete, superseder) = if winner_is_new {
538                                (existing.id, new_memory.id)
539                            } else {
540                                (new_memory.id, existing.id)
541                            };
542                            match self.config.contradiction_policy {
543                                ContradictionPolicy::Supersede => {
544                                    actions.push(InferredAction::InvalidateMemory {
545                                        memory: obsolete,
546                                        superseded_by: superseder,
547                                        valid_until: new_memory.created_at,
548                                    });
549                                }
550                                ContradictionPolicy::Flag => {
551                                    actions.push(InferredAction::FlagContradiction {
552                                        existing: existing.id,
553                                        new: new_memory.id,
554                                        reason,
555                                    });
556                                }
557                            }
558                        }
559                        ContradictionVerdict::Compatible { .. } => {}
560                    }
561                }
562            }
563
564            // Moderate similarity: create Related edge (heuristic, no LLM needed)
565            if sim > self.config.related_min && sim <= self.config.related_max {
566                actions.push(InferredAction::CreateEdge {
567                    source: new_memory.id,
568                    target: existing.id,
569                    edge_type: EdgeType::Related,
570                    weight: sim,
571                });
572            }
573        }
574
575        // Correction type still uses heuristic (find most similar and supersede)
576        if new_memory.memory_type == MemoryType::Correction
577            && let Some(original) = existing_memories
578                .iter()
579                .filter(|m| m.id != new_memory.id)
580                .max_by(|a, b| {
581                    cosine_similarity(&new_memory.embedding, &a.embedding)
582                        .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
583                        .unwrap_or(std::cmp::Ordering::Equal)
584                })
585        {
586            let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
587            if sim > self.config.correction_threshold {
588                actions.push(InferredAction::InvalidateMemory {
589                    memory: original.id,
590                    superseded_by: new_memory.id,
591                    valid_until: new_memory.created_at,
592                });
593                self.push_confidence_change(
594                    &mut actions,
595                    original.id,
596                    original.confidence,
597                    self.decayed_confidence(original.confidence),
598                );
599            }
600        }
601
602        actions
603    }
604}
605
606impl WriteInferenceEngine {
607    /// The confidence a superseded or corrected memory drops to.
608    fn decayed_confidence(&self, old: f32) -> f32 {
609        (old * self.config.confidence_decay_factor).max(self.config.confidence_floor)
610    }
611
612    /// Set `memory`'s confidence from `old` to `new`, and, when belief
613    /// propagation is on and the change is at least the configured minimum,
614    /// spread the same change to the memories that depend on it. The
615    /// `UpdateConfidence` alone sets the memory itself; the propagation that
616    /// follows moves only its dependents, so the memory ends at exactly `new`.
617    fn push_confidence_change(
618        &self,
619        actions: &mut Vec<InferredAction>,
620        memory: MemoryId,
621        old: f32,
622        new: f32,
623    ) {
624        actions.push(InferredAction::UpdateConfidence {
625            memory,
626            new_confidence: new,
627        });
628        let delta = new - old;
629        if self.config.belief_propagation_enabled
630            && delta.is_finite()
631            && delta != 0.0
632            && delta.abs() >= self.config.belief_propagation_min_delta
633        {
634            actions.push(InferredAction::PropagateBeliefChange {
635                root: memory,
636                delta,
637            });
638        }
639    }
640
641    /// Record a detected contradiction between `existing` and `new` according
642    /// to the configured policy: supersede the loser (close it and decay its
643    /// confidence) or flag the pair and keep both valid.
644    #[allow(clippy::too_many_arguments)]
645    fn resolve_contradiction(
646        &self,
647        actions: &mut Vec<InferredAction>,
648        existing: MemoryId,
649        new: MemoryId,
650        loser: MemoryId,
651        winner: MemoryId,
652        loser_confidence: f32,
653        valid_until: u64,
654        reason: &str,
655    ) {
656        match self.config.contradiction_policy {
657            ContradictionPolicy::Supersede => {
658                actions.push(InferredAction::InvalidateMemory {
659                    memory: loser,
660                    superseded_by: winner,
661                    valid_until,
662                });
663                self.push_confidence_change(
664                    actions,
665                    loser,
666                    loser_confidence,
667                    self.decayed_confidence(loser_confidence),
668                );
669            }
670            ContradictionPolicy::Flag => {
671                actions.push(InferredAction::FlagContradiction {
672                    existing,
673                    new,
674                    reason: reason.to_string(),
675                });
676            }
677        }
678    }
679}
680
681impl Default for WriteInferenceEngine {
682    fn default() -> Self {
683        Self::new()
684    }
685}
686
687fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
688    MemorySummary {
689        id: m.id,
690        content: m.content.clone(),
691        memory_type: m.memory_type,
692        confidence: m.confidence,
693        created_at: m.created_at,
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn confidence_drop_emits_propagation_after_the_update() {
703        let agent = AgentId::new();
704        let mut existing = make_memory(
705            "The user's favorite coffee order is a cortado",
706            vec![1.0, 0.0, 0.0],
707            MemoryType::Semantic,
708        );
709        existing.agent_id = agent;
710        existing.confidence = 0.8;
711        let mut new_mem = make_memory(
712            "The user's favorite coffee order is a flat white",
713            vec![0.98, 0.02, 0.0],
714            MemoryType::Semantic,
715        );
716        new_mem.agent_id = agent;
717        new_mem.created_at = 2000;
718
719        let actions =
720            WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
721        let update = actions.iter().position(|a| {
722            matches!(a, InferredAction::UpdateConfidence { memory, new_confidence }
723                if *memory == existing.id && (*new_confidence - 0.4).abs() < 1e-6)
724        });
725        let propagate = actions.iter().position(|a| {
726            matches!(a, InferredAction::PropagateBeliefChange { root, delta }
727                if *root == existing.id && (*delta + 0.4).abs() < 1e-6)
728        });
729        assert!(
730            matches!((update, propagate), (Some(u), Some(p)) if p == u + 1),
731            "the drop must be set then propagated, got: {actions:?}"
732        );
733
734        // Disabled, or below the minimum change: the update alone.
735        for cfg in [
736            WriteInferenceConfig {
737                belief_propagation_enabled: false,
738                ..WriteInferenceConfig::default()
739            },
740            WriteInferenceConfig {
741                belief_propagation_min_delta: 0.5,
742                ..WriteInferenceConfig::default()
743            },
744        ] {
745            let actions = WriteInferenceEngine::with_config(cfg).infer_on_write(
746                &new_mem,
747                &[existing.clone()],
748                &[],
749            );
750            assert!(
751                actions
752                    .iter()
753                    .any(|a| matches!(a, InferredAction::UpdateConfidence { .. }))
754            );
755            assert!(
756                !actions
757                    .iter()
758                    .any(|a| matches!(a, InferredAction::PropagateBeliefChange { .. })),
759                "got: {actions:?}"
760            );
761        }
762    }
763    use mentedb_core::memory::MemoryType;
764    use mentedb_core::types::AgentId;
765
766    use crate::llm::MockLlmJudge;
767
768    fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
769        let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
770        m.created_at = 1000;
771        m
772    }
773
774    #[test]
775    fn test_heuristic_does_not_flag_contradiction_from_similarity() {
776        // Two facts with high embedding similarity but different content look
777        // identical to cosine similarity whether they are paraphrases or
778        // opposites. The cheap heuristic must NOT guess "contradiction" here
779        // (that produced ~0% precision in production); real contradiction
780        // detection is the LLM path's job. High-sim different-content yields no
781        // action from the heuristic.
782        let agent = AgentId::new();
783        let mut existing =
784            make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
785        existing.agent_id = agent;
786
787        let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
788        new_mem.agent_id = agent;
789        new_mem.created_at = 2000;
790
791        let engine = WriteInferenceEngine::new();
792        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
793        assert!(
794            !actions
795                .iter()
796                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
797            "Heuristic must not flag contradictions from bare similarity, got: {:?}",
798            actions
799        );
800    }
801
802    #[test]
803    fn value_update_supersedes_the_old_fact() {
804        // The canonical correction shape: same sentence frame, new value at the
805        // tail. The newer memory must supersede the older one so recall serves
806        // only the corrected fact (live bug: both coffee facts coexisted).
807        let agent = AgentId::new();
808        let mut existing = make_memory(
809            "The user's favorite coffee order is a cortado",
810            vec![1.0, 0.0, 0.0],
811            MemoryType::Semantic,
812        );
813        existing.agent_id = agent;
814        let mut new_mem = make_memory(
815            "The user's favorite coffee order is a flat white",
816            vec![0.98, 0.02, 0.0],
817            MemoryType::Semantic,
818        );
819        new_mem.agent_id = agent;
820        new_mem.created_at = 2000;
821
822        let engine = WriteInferenceEngine::new();
823        let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
824        assert!(
825            actions.iter().any(|a| matches!(
826                a,
827                InferredAction::InvalidateMemory { memory, superseded_by, .. }
828                    if *superseded_by == new_mem.id && *memory == existing.id
829            )),
830            "value update must invalidate the stale fact, got: {actions:?}"
831        );
832        assert!(
833            actions
834                .iter()
835                .any(|a| matches!(a, InferredAction::UpdateConfidence { memory, .. } if *memory == existing.id)),
836            "superseded fact's confidence must decay"
837        );
838    }
839
840    #[test]
841    fn value_update_fires_at_realistic_embedder_cosine() {
842        // Regression for the production-dead rule: real embedders put a value
843        // correction far below a high cosine bar (measured: Titan V2 0.61,
844        // OpenAI 3-small 0.80 for cortado vs flat white; 0.51 / 0.79 for a phone
845        // number change). The old 0.88 default was unreachable on either, so the
846        // rule never fired in prod. A frame-matched correction at cosine 0.6 must
847        // now supersede; the structural shared-frame test, not the cosine, is
848        // what proves it is a correction.
849        let agent = AgentId::new();
850        let mut existing = make_memory(
851            "The user's favorite coffee order is a cortado",
852            vec![1.0, 0.0, 0.0],
853            MemoryType::Semantic,
854        );
855        existing.agent_id = agent;
856        // cosine([1,0,0], [0.6,0.8,0]) = 0.6: below the old 0.88 gate, above 0.5.
857        let mut new_mem = make_memory(
858            "The user's favorite coffee order is a flat white",
859            vec![0.6, 0.8, 0.0],
860            MemoryType::Semantic,
861        );
862        new_mem.agent_id = agent;
863        new_mem.created_at = 2000;
864
865        let actions =
866            WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
867        assert!(
868            actions.iter().any(|a| matches!(
869                a,
870                InferredAction::InvalidateMemory { memory, superseded_by, .. }
871                    if *superseded_by == new_mem.id && *memory == existing.id
872            )),
873            "a frame-matched correction at realistic cosine 0.6 must supersede, got: {actions:?}"
874        );
875    }
876
877    #[test]
878    fn value_update_fires_below_the_old_floor() {
879        // The production miss on Cohere 1024: an identical frame pair whose
880        // cosine sits in the 0.3 to 0.5 band was silently gated by the 0.5
881        // floor. cosine([1,0,0],[0.4,0.9165,0]) = 0.4 must fire now.
882        let agent = AgentId::new();
883        let mut existing = make_memory(
884            "The user uses Next.js for the front end",
885            vec![1.0, 0.0, 0.0],
886            MemoryType::Semantic,
887        );
888        existing.agent_id = agent;
889        let mut new_mem = make_memory(
890            "The user uses React for the front end",
891            vec![0.4, 0.9165, 0.0],
892            MemoryType::Semantic,
893        );
894        new_mem.agent_id = agent;
895        new_mem.created_at = 2000;
896
897        let actions =
898            WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
899        assert!(
900            actions.iter().any(|a| matches!(
901                a,
902                InferredAction::InvalidateMemory { memory, superseded_by, .. }
903                    if *superseded_by == new_mem.id && *memory == existing.id
904            )),
905            "an identical frame pair at cosine 0.4 must supersede, got: {actions:?}"
906        );
907    }
908
909    #[test]
910    fn value_update_below_cosine_floor_does_not_supersede() {
911        // The cosine floor still guards: a shared token frame that collided on
912        // genuinely off-topic content (cosine 0.2, below the 0.3 floor) must not
913        // supersede, even though the frame test alone would pass. Structure is
914        // primary, but the floor rejects pathological frame collisions.
915        let agent = AgentId::new();
916        let mut existing = make_memory(
917            "The user's favorite coffee order is a cortado",
918            vec![1.0, 0.0, 0.0],
919            MemoryType::Semantic,
920        );
921        existing.agent_id = agent;
922        // cosine([1,0,0], [0.2,0.9798,0]) = 0.2: below the 0.3 floor.
923        let mut new_mem = make_memory(
924            "The user's favorite coffee order is a flat white",
925            vec![0.2, 0.9798, 0.0],
926            MemoryType::Semantic,
927        );
928        new_mem.agent_id = agent;
929        new_mem.created_at = 2000;
930
931        let actions = WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing], &[]);
932        assert!(
933            !actions
934                .iter()
935                .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
936            "below the cosine floor a frame match must not supersede, got: {actions:?}"
937        );
938    }
939
940    #[test]
941    fn correction_supersedes_regardless_of_store_order() {
942        // Batch enrichment can store the correction BEFORE the original it
943        // corrects. The original arriving after the existing correction must
944        // still be the one marked stale: the correction wins either way. This is
945        // the case the old order-dependent rule missed (both facts coexisted).
946        let agent = AgentId::new();
947        let mut correction = make_memory(
948            "The user's favorite coffee is a flat white",
949            vec![1.0, 0.0, 0.0],
950            MemoryType::Correction,
951        );
952        correction.agent_id = agent;
953        correction.created_at = 5000;
954        let mut original = make_memory(
955            "The user's favorite coffee is a cortado",
956            vec![0.98, 0.02, 0.0],
957            MemoryType::Semantic,
958        );
959        original.agent_id = agent;
960        original.created_at = 5000; // same batch timestamp
961
962        // The original arrives as the new memory; the correction already exists.
963        let actions =
964            WriteInferenceEngine::new().infer_on_write(&original, &[correction.clone()], &[]);
965        assert!(
966            actions.iter().any(|a| matches!(
967                a,
968                InferredAction::InvalidateMemory { memory, superseded_by, .. }
969                    if *superseded_by == correction.id && *memory == original.id
970            )),
971            "the correction must invalidate the later-arriving original, got: {actions:?}"
972        );
973    }
974
975    #[test]
976    fn correction_supersedes_original_at_equal_timestamps() {
977        // The other store order: original first, correction second, same batch
978        // timestamp. The correction still wins.
979        let agent = AgentId::new();
980        let mut original = make_memory(
981            "The user's favorite coffee is a cortado",
982            vec![1.0, 0.0, 0.0],
983            MemoryType::Semantic,
984        );
985        original.agent_id = agent;
986        original.created_at = 5000;
987        let mut correction = make_memory(
988            "The user's favorite coffee is a flat white",
989            vec![0.98, 0.02, 0.0],
990            MemoryType::Correction,
991        );
992        correction.agent_id = agent;
993        correction.created_at = 5000;
994
995        let actions =
996            WriteInferenceEngine::new().infer_on_write(&correction, &[original.clone()], &[]);
997        assert!(
998            actions.iter().any(|a| matches!(
999                a,
1000                InferredAction::InvalidateMemory { memory, superseded_by, .. }
1001                    if *superseded_by == correction.id && *memory == original.id
1002            )),
1003            "the correction must invalidate the original at equal timestamps, got: {actions:?}"
1004        );
1005    }
1006
1007    #[test]
1008    fn multivalued_facts_at_equal_timestamps_are_both_kept() {
1009        // Two plain facts sharing a frame that are not a correction of each other
1010        // (a multi-valued attribute) must not supersede when timestamps are equal,
1011        // so making the rule order-independent does not start eating coexisting
1012        // facts. Only a correction signal or a clear time order triggers it.
1013        let agent = AgentId::new();
1014        let mut a = make_memory(
1015            "The user knows Rust",
1016            vec![1.0, 0.0, 0.0],
1017            MemoryType::Semantic,
1018        );
1019        a.agent_id = agent;
1020        a.created_at = 5000;
1021        let mut b = make_memory(
1022            "The user knows Python",
1023            vec![0.97, 0.03, 0.0],
1024            MemoryType::Semantic,
1025        );
1026        b.agent_id = agent;
1027        b.created_at = 5000;
1028
1029        let actions = WriteInferenceEngine::new().infer_on_write(&b, &[a], &[]);
1030        assert!(
1031            !actions.iter().any(|x| matches!(
1032                x,
1033                InferredAction::CreateEdge {
1034                    edge_type: EdgeType::Supersedes,
1035                    ..
1036                }
1037            )),
1038            "coexisting facts at equal timestamps must not supersede, got: {actions:?}"
1039        );
1040    }
1041
1042    #[test]
1043    fn subject_change_is_not_a_value_update() {
1044        // "my dog ..." vs "my cat ..." differ at the head: two facts about two
1045        // subjects, both must survive.
1046        let agent = AgentId::new();
1047        let mut existing = make_memory(
1048            "My dog is allergic to chicken",
1049            vec![1.0, 0.0, 0.0],
1050            MemoryType::Semantic,
1051        );
1052        existing.agent_id = agent;
1053        let mut new_mem = make_memory(
1054            "My cat is allergic to chicken",
1055            vec![0.99, 0.01, 0.0],
1056            MemoryType::Semantic,
1057        );
1058        new_mem.agent_id = agent;
1059        new_mem.created_at = 2000;
1060
1061        let engine = WriteInferenceEngine::new();
1062        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
1063        assert!(
1064            !actions.iter().any(|a| matches!(
1065                a,
1066                InferredAction::CreateEdge {
1067                    edge_type: EdgeType::Supersedes,
1068                    ..
1069                }
1070            )),
1071            "a subject change must not supersede, got: {actions:?}"
1072        );
1073    }
1074
1075    #[test]
1076    fn value_update_respects_owner_and_config() {
1077        // Cross-owner pairs never supersede, and the rule can be disabled.
1078        let mut existing = make_memory(
1079            "The user's favorite coffee order is a cortado",
1080            vec![1.0, 0.0, 0.0],
1081            MemoryType::Semantic,
1082        );
1083        existing.agent_id = AgentId::new();
1084        let mut new_mem = make_memory(
1085            "The user's favorite coffee order is a flat white",
1086            vec![0.98, 0.02, 0.0],
1087            MemoryType::Semantic,
1088        );
1089        new_mem.agent_id = AgentId::new(); // different owner
1090        new_mem.created_at = 2000;
1091
1092        let engine = WriteInferenceEngine::new();
1093        let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
1094        assert!(
1095            !actions.iter().any(|a| matches!(
1096                a,
1097                InferredAction::CreateEdge {
1098                    edge_type: EdgeType::Supersedes,
1099                    ..
1100                }
1101            )),
1102            "cross-owner memories must never supersede each other"
1103        );
1104
1105        // Same owner but rule disabled: no supersession either.
1106        let agent = AgentId::new();
1107        existing.agent_id = agent;
1108        new_mem.agent_id = agent;
1109        let engine_off = WriteInferenceEngine::with_config(WriteInferenceConfig {
1110            value_update_enabled: false,
1111            ..WriteInferenceConfig::default()
1112        });
1113        let actions = engine_off.infer_on_write(&new_mem, &[existing], &[]);
1114        assert!(
1115            !actions.iter().any(|a| matches!(
1116                a,
1117                InferredAction::CreateEdge {
1118                    edge_type: EdgeType::Supersedes,
1119                    ..
1120                }
1121            )),
1122            "disabled rule must not supersede"
1123        );
1124    }
1125
1126    #[test]
1127    fn is_value_update_frame_analysis() {
1128        // Positive: long shared prefix, short differing tail.
1129        assert!(is_value_update(
1130            "my phone number is 4321",
1131            "my phone number is 1234",
1132            0.6,
1133            4
1134        ));
1135        // Negative: head differs (different subject).
1136        assert!(!is_value_update(
1137            "my cat is allergic to chicken",
1138            "my dog is allergic to chicken",
1139            0.6,
1140            4
1141        ));
1142        // Negative: identical content is not an update.
1143        assert!(!is_value_update("same fact", "same fact", 0.6, 4));
1144        // Positive: the value sits mid sentence, wrapped by a shared tail.
1145        // This is the reported production miss: "i use next.js" then "now i
1146        // use react" coexisted forever because the old prefix-only test
1147        // could not see the shared "for the front end" tail.
1148        assert!(is_value_update(
1149            "The user uses React for the front end",
1150            "The user uses Next.js for the front end",
1151            0.6,
1152            4
1153        ));
1154        assert!(is_value_update(
1155            "i use react for the front end",
1156            "i use next.js for the front end",
1157            0.6,
1158            4
1159        ));
1160        // Negative: subject swap with a long shared tail must still not fire;
1161        // only a one token head is shared.
1162        assert!(!is_value_update(
1163            "the cat is allergic to chicken and needs the special food",
1164            "the dog is allergic to chicken and needs the special food",
1165            0.6,
1166            4
1167        ));
1168        // Negative: purely numeric mid sentence middles are identifiers
1169        // naming different things (rule 3 and rule 4 coexist), never a
1170        // changed value.
1171        assert!(!is_value_update(
1172            "API sibling rule 1 about payload naming",
1173            "API sibling rule 0 about payload naming",
1174            0.6,
1175            4
1176        ));
1177        // Positive is unaffected at the end position: a numeric tail is a
1178        // value ("phone is 1234" to "phone is 4321"), covered above.
1179        // Negative: three token enumerations are items, never updates.
1180        assert!(!is_value_update(
1181            "Directive number 1",
1182            "Directive number 0",
1183            0.6,
1184            4
1185        ));
1186        // Negative: tail too long (a genuinely different statement).
1187        assert!(!is_value_update(
1188            "the deploy runs every friday and sarah reviews it after standup",
1189            "the deploy runs every friday unless the release train is frozen for the quarter end audit",
1190            0.6,
1191            4
1192        ));
1193    }
1194
1195    #[test]
1196    fn test_byte_identical_duplicate_is_deduplicated() {
1197        // Identical text is deduplicated (invalidate + Derived lineage), not a
1198        // supersession, so it never surfaces as a conflict.
1199        let agent = AgentId::new();
1200        let mut existing = make_memory(
1201            "Ran command: ls -la",
1202            vec![1.0, 0.0, 0.0],
1203            MemoryType::Episodic,
1204        );
1205        existing.agent_id = agent;
1206
1207        let mut new_mem = make_memory(
1208            "Ran command: ls -la",
1209            vec![1.0, 0.0, 0.0],
1210            MemoryType::Episodic,
1211        );
1212        new_mem.agent_id = agent;
1213        new_mem.created_at = 2000;
1214
1215        let engine = WriteInferenceEngine::new();
1216        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
1217        assert!(
1218            actions
1219                .iter()
1220                .any(|a| matches!(a, InferredAction::DeduplicateExact { .. })),
1221            "Expected identical duplicate to be deduplicated, got: {:?}",
1222            actions
1223        );
1224    }
1225
1226    #[test]
1227    fn test_moderate_similarity_creates_edge() {
1228        let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
1229        // ~0.7 similarity
1230        let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
1231
1232        let engine = WriteInferenceEngine::new();
1233        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
1234        assert!(
1235            actions.iter().any(|a| matches!(
1236                a,
1237                InferredAction::CreateEdge {
1238                    edge_type: EdgeType::Related,
1239                    ..
1240                }
1241            )),
1242            "Expected CreateEdge Related, got: {:?}",
1243            actions
1244        );
1245    }
1246
1247    #[tokio::test]
1248    async fn test_llm_invalidation_emits_temporal_actions() {
1249        let agent = AgentId::new();
1250        let mut existing = make_memory(
1251            "Alice works at Acme",
1252            vec![0.8, 0.6, 0.0],
1253            MemoryType::Semantic,
1254        );
1255        existing.agent_id = agent;
1256
1257        let mut new_mem = make_memory(
1258            "Alice joined Google last week",
1259            vec![0.75, 0.65, 0.1],
1260            MemoryType::Semantic,
1261        );
1262        new_mem.agent_id = agent;
1263        new_mem.created_at = 2000;
1264
1265        let judge =
1266            MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
1267        let llm = CognitiveLlmService::new(judge);
1268        let engine = WriteInferenceEngine::new();
1269        let actions = engine
1270            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1271            .await;
1272
1273        assert!(
1274            actions
1275                .iter()
1276                .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
1277            "Expected InvalidateMemory from LLM verdict, got: {:?}",
1278            actions
1279        );
1280        // Exactly one invalidation action: InvalidateMemory both sets
1281        // valid_until and creates the Supersedes edge, so a MarkObsolete
1282        // alongside it would duplicate the edge.
1283        let invalidation_count = actions
1284            .iter()
1285            .filter(|a| {
1286                matches!(
1287                    a,
1288                    InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
1289                )
1290            })
1291            .count();
1292        assert_eq!(
1293            invalidation_count, 1,
1294            "Expected a single invalidation action, got: {:?}",
1295            actions
1296        );
1297    }
1298
1299    #[tokio::test]
1300    async fn test_llm_update_emits_update_content() {
1301        let agent = AgentId::new();
1302        let mut existing = make_memory(
1303            "Project uses React",
1304            vec![0.8, 0.6, 0.0],
1305            MemoryType::Semantic,
1306        );
1307        existing.agent_id = agent;
1308
1309        let mut new_mem = make_memory(
1310            "Project migrated from React to Vue",
1311            vec![0.75, 0.65, 0.1],
1312            MemoryType::Semantic,
1313        );
1314        new_mem.agent_id = agent;
1315        new_mem.created_at = 2000;
1316
1317        let judge = MockLlmJudge::new(
1318            r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
1319        );
1320        let llm = CognitiveLlmService::new(judge);
1321        let engine = WriteInferenceEngine::new();
1322        let actions = engine
1323            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1324            .await;
1325
1326        assert!(
1327            actions
1328                .iter()
1329                .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
1330            "Expected UpdateContent from LLM update verdict, got: {:?}",
1331            actions
1332        );
1333    }
1334
1335    #[tokio::test]
1336    async fn test_llm_keep_falls_through_to_contradiction_check() {
1337        let agent = AgentId::new();
1338        // High similarity pair where LLM says keep but content differs
1339        let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
1340        existing.agent_id = agent;
1341
1342        let mut new_mem = make_memory(
1343            "Prefers spaces",
1344            vec![0.88, 0.47, 0.0],
1345            MemoryType::Semantic,
1346        );
1347        new_mem.agent_id = agent;
1348        new_mem.created_at = 2000;
1349
1350        let judge = MockLlmJudge::new(
1351            r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
1352        );
1353        let llm = CognitiveLlmService::new(judge);
1354        let engine = WriteInferenceEngine::new();
1355        let actions = engine
1356            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1357            .await;
1358
1359        // MockLlmJudge returns the same response for all calls, so the keep
1360        // from invalidation falls through, then contradiction returns compatible
1361        assert!(
1362            !actions
1363                .iter()
1364                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
1365            "Should not flag contradiction when LLM says compatible",
1366        );
1367    }
1368}