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    PropagateBeliefChange {
52        root: MemoryId,
53        delta: f32,
54    },
55}
56
57fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
58    if a.len() != b.len() || a.is_empty() {
59        return 0.0;
60    }
61    let mut dot = 0.0f32;
62    let mut norm_a = 0.0f32;
63    let mut norm_b = 0.0f32;
64    for i in 0..a.len() {
65        dot += a[i] * b[i];
66        norm_a += a[i] * a[i];
67        norm_b += b[i] * b[i];
68    }
69    let denom = norm_a.sqrt() * norm_b.sqrt();
70    if denom == 0.0 { 0.0 } else { dot / denom }
71}
72
73/// Configuration for write-time inference thresholds.
74#[derive(Debug, Clone)]
75pub struct WriteInferenceConfig {
76    /// Similarity above which two memories may contradict (default: 0.95).
77    pub contradiction_threshold: f32,
78    /// Similarity above which an older memory is marked obsolete (default: 0.85).
79    pub obsolete_threshold: f32,
80    /// Minimum similarity for creating a Related edge (default: 0.6).
81    pub related_min: f32,
82    /// Maximum similarity for creating a Related edge (default: 0.85).
83    pub related_max: f32,
84    /// Minimum similarity for a Correction to supersede (default: 0.5).
85    pub correction_threshold: f32,
86    /// Multiplier applied to original confidence on correction (default: 0.5).
87    pub confidence_decay_factor: f32,
88    /// Minimum confidence after decay (default: 0.1).
89    pub confidence_floor: f32,
90    /// Whether the value-update rule runs: a new memory that shares an older
91    /// same-owner memory's sentence frame (long common token prefix) but ends
92    /// in a different value supersedes it, so "favorite coffee is a cortado"
93    /// followed by "favorite coffee is a flat white" keeps only the correction.
94    /// This is the cheap, LLM-free slice of contradiction handling; unlike bare
95    /// cosine (which cannot tell paraphrase from contradiction and was removed
96    /// at ~0% precision), the shared-frame test only fires on the update shape.
97    pub value_update_enabled: bool,
98    /// Minimum embedding similarity for the value-update rule (default: 0.5).
99    ///
100    /// This is a loose topical floor, not the primary gate. A value correction
101    /// inherently drives cosine down (the changed value is most of a short
102    /// fact's meaning), so real corrections land well below a high cosine bar:
103    /// measured on Titan V2 at 256 dims, "favorite coffee is a cortado" vs
104    /// "... a flat white" is 0.61 and "phone is 1234" vs "... 4321" is 0.51;
105    /// on OpenAI text-embedding-3-small they are 0.80 and 0.79. A 0.88 gate
106    /// (the old default) was unreachable on either embedder, so the rule never
107    /// fired in production. The embedder-independent `is_value_update` shared
108    /// frame test carries the precision here; the cosine floor only rejects
109    /// genuinely off-topic pairs that a shared token frame collided on.
110    pub value_update_min_similarity: f32,
111    /// Minimum fraction of the longer memory's tokens that must be a shared
112    /// prefix for the pair to count as the same sentence frame (default: 0.6).
113    pub value_update_prefix_share: f32,
114    /// Maximum differing tail tokens for a value update (default: 4). Larger
115    /// tails mean genuinely different statements, not a value change.
116    pub value_update_max_tail: usize,
117}
118
119impl Default for WriteInferenceConfig {
120    fn default() -> Self {
121        Self {
122            contradiction_threshold: 0.95,
123            obsolete_threshold: 0.85,
124            related_min: 0.6,
125            related_max: 0.85,
126            correction_threshold: 0.5,
127            confidence_decay_factor: 0.5,
128            confidence_floor: 0.1,
129            value_update_enabled: true,
130            value_update_min_similarity: 0.5,
131            value_update_prefix_share: 0.6,
132            value_update_max_tail: 4,
133        }
134    }
135}
136
137/// Lowercased alphanumeric tokens of a memory's content, the unit of the
138/// value-update frame comparison.
139fn frame_tokens(s: &str) -> Vec<String> {
140    s.split(|c: char| !c.is_alphanumeric())
141        .filter(|t| !t.is_empty())
142        .map(|t| t.to_lowercase())
143        .collect()
144}
145
146/// True when `new` and `old` share the same sentence frame but end in a
147/// different value: a long common token prefix with a short, non-identical
148/// tail. "the user's favorite coffee order is a cortado" vs "... is a flat
149/// white" fires; "my dog is allergic to chicken" vs "my cat is allergic to
150/// chicken" does not (the difference is at the head, a different subject).
151fn is_value_update(new: &str, old: &str, prefix_share: f32, max_tail: usize) -> bool {
152    let nt = frame_tokens(new);
153    let ot = frame_tokens(old);
154    if nt.is_empty() || ot.is_empty() || nt == ot {
155        return false;
156    }
157    let prefix = nt.iter().zip(ot.iter()).take_while(|(a, b)| a == b).count();
158    let longest = nt.len().max(ot.len());
159    let tail = longest - prefix;
160    prefix as f32 / longest as f32 >= prefix_share && tail > 0 && tail <= max_tail
161}
162
163/// Decide which of two value-frame-matching memories supersedes the other, and
164/// which is left stale. Order-independent: a `Correction` supersedes a plain
165/// fact whichever was stored first (the extractor types a correction explicitly,
166/// so it is the authoritative value); otherwise the later-created memory wins.
167/// Returns `(winner_id, loser_id, loser_confidence)`, or `None` when there is no
168/// correction signal and the timestamps are equal, in which case the two are
169/// kept as coexisting facts rather than guessing a direction.
170fn value_update_resolution(
171    new: &MemoryNode,
172    existing: &MemoryNode,
173) -> Option<(MemoryId, MemoryId, f32)> {
174    let new_correction = new.memory_type == MemoryType::Correction;
175    let old_correction = existing.memory_type == MemoryType::Correction;
176    if new_correction && !old_correction {
177        return Some((new.id, existing.id, existing.confidence));
178    }
179    if old_correction && !new_correction {
180        return Some((existing.id, new.id, new.confidence));
181    }
182    if new.created_at > existing.created_at {
183        return Some((new.id, existing.id, existing.confidence));
184    }
185    if existing.created_at > new.created_at {
186        return Some((existing.id, new.id, new.confidence));
187    }
188    None
189}
190
191pub struct WriteInferenceEngine {
192    config: WriteInferenceConfig,
193}
194
195impl WriteInferenceEngine {
196    pub fn new() -> Self {
197        Self {
198            config: WriteInferenceConfig::default(),
199        }
200    }
201
202    pub fn with_config(config: WriteInferenceConfig) -> Self {
203        Self { config }
204    }
205
206    /// Whether `new` and `old` look like a value update (same sentence frame,
207    /// changed value tail) under this engine's configured thresholds. A
208    /// deduplication pass that merges near-duplicates uses this as a guard so a
209    /// correction ("coffee is a cortado" then "coffee is a flat white") is
210    /// never collapsed into the fact it corrects; the two must stay distinct
211    /// for supersession to resolve them.
212    pub fn looks_like_value_update(&self, new: &str, old: &str) -> bool {
213        is_value_update(
214            new,
215            old,
216            self.config.value_update_prefix_share,
217            self.config.value_update_max_tail,
218        )
219    }
220
221    pub fn infer_on_write(
222        &self,
223        new_memory: &MemoryNode,
224        existing_memories: &[MemoryNode],
225        existing_edges: &[(MemoryId, MemoryId, EdgeType)],
226    ) -> Vec<InferredAction> {
227        let _ = existing_edges; // reserved for future graph-aware inference
228        let mut actions = Vec::new();
229        // Value-frame-matched pairs are owned by the value-update rule below; the
230        // broad correction handler skips them so a pair is never resolved twice.
231        let mut value_update_handled: Vec<MemoryId> = Vec::new();
232
233        for existing in existing_memories {
234            if existing.id == new_memory.id {
235                continue;
236            }
237
238            let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
239
240            // Cosine similarity alone cannot tell a reworded duplicate from a
241            // genuine contradiction: paraphrases ("uses Postgres" / "uses
242            // PostgreSQL") and opposites ("prefers tabs" / "prefers spaces")
243            // both land in the high-similarity band. So the cheap write-time
244            // heuristic makes only the two judgments similarity CAN support:
245            //   1. byte-identical content -> a true duplicate, supersede it
246            //   2. moderate similarity    -> a Related edge
247            // Every semantic decision (merge, paraphrase dedup, real
248            // contradiction, which-fact-wins supersession) is deferred to the
249            // LLM paths (`consolidate_memories`, `detect_conflicts_with_llm`),
250            // which read the actual text. Flagging contradictions from bare
251            // similarity here produced ~0% precision (every reworded re-save
252            // looked like a contradiction), so it was removed.
253            if existing.content == new_memory.content
254                && sim > self.config.obsolete_threshold
255                && new_memory.created_at > existing.created_at
256            {
257                actions.push(InferredAction::DeduplicateExact {
258                    duplicate: existing.id,
259                    keeper: new_memory.id,
260                });
261            } else if self.config.value_update_enabled
262                && sim >= self.config.value_update_min_similarity
263                && existing.agent_id == new_memory.agent_id
264                && existing.user_id == new_memory.user_id
265                && is_value_update(
266                    &new_memory.content,
267                    &existing.content,
268                    self.config.value_update_prefix_share,
269                    self.config.value_update_max_tail,
270                )
271            {
272                // Same sentence frame, different value: one supersedes the other.
273                // The value-update rule owns this pair (so the correction handler
274                // skips it), and the winner is decided deterministically rather
275                // than by arrival order: a correction beats a plain fact, else the
276                // later-created wins. Batch enrichment stores extracted facts in
277                // arbitrary order with near-equal timestamps, so an order-dependent
278                // rule fired only about half the time; this resolves the same way
279                // whether the correction arrives before or after the original.
280                value_update_handled.push(existing.id);
281                if let Some((winner, loser, loser_confidence)) =
282                    value_update_resolution(new_memory, existing)
283                {
284                    actions.push(InferredAction::CreateEdge {
285                        source: winner,
286                        target: loser,
287                        edge_type: EdgeType::Supersedes,
288                        weight: sim,
289                    });
290                    actions.push(InferredAction::UpdateConfidence {
291                        memory: loser,
292                        new_confidence: (loser_confidence * self.config.confidence_decay_factor)
293                            .max(self.config.confidence_floor),
294                    });
295                }
296                // else: no correction signal and equal timestamps, so the two are
297                // kept as coexisting facts (a multi-valued attribute) rather than
298                // one silently superseding the other.
299            } else if sim > self.config.related_min && sim <= self.config.related_max {
300                actions.push(InferredAction::CreateEdge {
301                    source: new_memory.id,
302                    target: existing.id,
303                    edge_type: EdgeType::Related,
304                    weight: sim,
305                });
306            }
307        }
308
309        // Correction type: find the most similar existing memory and supersede
310        // it, skipping any pair the value-update rule already resolved above.
311        if new_memory.memory_type == MemoryType::Correction
312            && let Some(original) = existing_memories
313                .iter()
314                .filter(|m| m.id != new_memory.id && !value_update_handled.contains(&m.id))
315                .max_by(|a, b| {
316                    cosine_similarity(&new_memory.embedding, &a.embedding)
317                        .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
318                        .unwrap_or(std::cmp::Ordering::Equal)
319                })
320        {
321            let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
322            if sim > self.config.correction_threshold {
323                actions.push(InferredAction::CreateEdge {
324                    source: new_memory.id,
325                    target: original.id,
326                    edge_type: EdgeType::Supersedes,
327                    weight: 1.0,
328                });
329                actions.push(InferredAction::UpdateConfidence {
330                    memory: original.id,
331                    new_confidence: (original.confidence * self.config.confidence_decay_factor)
332                        .max(self.config.confidence_floor),
333                });
334            }
335        }
336
337        actions
338    }
339
340    /// LLM enhanced write inference. Uses the CognitiveLlmService when available
341    /// to make smarter invalidation and contradiction decisions. Falls back to
342    /// cosine similarity heuristics for moderate similarity (Related edges) and
343    /// correction handling.
344    pub async fn infer_on_write_with_llm<J: LlmJudge>(
345        &self,
346        new_memory: &MemoryNode,
347        existing_memories: &[MemoryNode],
348        existing_edges: &[(MemoryId, MemoryId, EdgeType)],
349        llm: &CognitiveLlmService<J>,
350    ) -> Vec<InferredAction> {
351        let _ = existing_edges;
352        let mut actions = Vec::new();
353
354        let new_summary = memory_to_summary(new_memory);
355
356        for existing in existing_memories {
357            if existing.id == new_memory.id {
358                continue;
359            }
360
361            let sim = cosine_similarity(&new_memory.embedding, &existing.embedding);
362
363            // Only consult LLM for memories with moderate+ similarity (> 0.5)
364            // to avoid burning tokens on completely unrelated pairs
365            if sim > 0.5 && existing.agent_id == new_memory.agent_id {
366                let old_summary = memory_to_summary(existing);
367
368                // LLM invalidation check
369                if let Ok(verdict) = llm.judge_invalidation(&old_summary, &new_summary).await {
370                    match verdict {
371                        InvalidationVerdict::Invalidate { reason: _ } => {
372                            // InvalidateMemory sets valid_until and creates the
373                            // Supersedes edge; no separate MarkObsolete/CreateEdge.
374                            actions.push(InferredAction::InvalidateMemory {
375                                memory: existing.id,
376                                superseded_by: new_memory.id,
377                                valid_until: new_memory.created_at,
378                            });
379                            actions.push(InferredAction::UpdateConfidence {
380                                memory: existing.id,
381                                new_confidence: (existing.confidence
382                                    * self.config.confidence_decay_factor)
383                                    .max(self.config.confidence_floor),
384                            });
385                            // Skip contradiction check since we already know the relationship
386                            continue;
387                        }
388                        InvalidationVerdict::Update {
389                            merged_content,
390                            reason,
391                        } => {
392                            actions.push(InferredAction::UpdateContent {
393                                memory: existing.id,
394                                new_content: merged_content,
395                                reason,
396                            });
397                            continue;
398                        }
399                        InvalidationVerdict::Keep { .. } => {
400                            // Fall through to contradiction check
401                        }
402                    }
403                }
404
405                // LLM contradiction check for high similarity pairs that weren't invalidated
406                if sim > 0.7
407                    && existing.content != new_memory.content
408                    && let Ok(verdict) = llm.detect_contradiction(&old_summary, &new_summary).await
409                {
410                    match verdict {
411                        ContradictionVerdict::Contradicts { reason } => {
412                            actions.push(InferredAction::FlagContradiction {
413                                existing: existing.id,
414                                new: new_memory.id,
415                                reason,
416                            });
417                        }
418                        ContradictionVerdict::Supersedes { winner, reason: _ } => {
419                            let winner_is_new = winner == new_memory.id.to_string();
420                            let (obsolete, superseder) = if winner_is_new {
421                                (existing.id, new_memory.id)
422                            } else {
423                                (new_memory.id, existing.id)
424                            };
425                            actions.push(InferredAction::InvalidateMemory {
426                                memory: obsolete,
427                                superseded_by: superseder,
428                                valid_until: new_memory.created_at,
429                            });
430                        }
431                        ContradictionVerdict::Compatible { .. } => {}
432                    }
433                }
434            }
435
436            // Moderate similarity: create Related edge (heuristic, no LLM needed)
437            if sim > self.config.related_min && sim <= self.config.related_max {
438                actions.push(InferredAction::CreateEdge {
439                    source: new_memory.id,
440                    target: existing.id,
441                    edge_type: EdgeType::Related,
442                    weight: sim,
443                });
444            }
445        }
446
447        // Correction type still uses heuristic (find most similar and supersede)
448        if new_memory.memory_type == MemoryType::Correction
449            && let Some(original) = existing_memories
450                .iter()
451                .filter(|m| m.id != new_memory.id)
452                .max_by(|a, b| {
453                    cosine_similarity(&new_memory.embedding, &a.embedding)
454                        .partial_cmp(&cosine_similarity(&new_memory.embedding, &b.embedding))
455                        .unwrap_or(std::cmp::Ordering::Equal)
456                })
457        {
458            let sim = cosine_similarity(&new_memory.embedding, &original.embedding);
459            if sim > self.config.correction_threshold {
460                actions.push(InferredAction::InvalidateMemory {
461                    memory: original.id,
462                    superseded_by: new_memory.id,
463                    valid_until: new_memory.created_at,
464                });
465                actions.push(InferredAction::UpdateConfidence {
466                    memory: original.id,
467                    new_confidence: (original.confidence * self.config.confidence_decay_factor)
468                        .max(self.config.confidence_floor),
469                });
470            }
471        }
472
473        actions
474    }
475}
476
477impl Default for WriteInferenceEngine {
478    fn default() -> Self {
479        Self::new()
480    }
481}
482
483fn memory_to_summary(m: &MemoryNode) -> MemorySummary {
484    MemorySummary {
485        id: m.id,
486        content: m.content.clone(),
487        memory_type: m.memory_type,
488        confidence: m.confidence,
489        created_at: m.created_at,
490    }
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use mentedb_core::memory::MemoryType;
497    use mentedb_core::types::AgentId;
498
499    use crate::llm::MockLlmJudge;
500
501    fn make_memory(content: &str, embedding: Vec<f32>, mem_type: MemoryType) -> MemoryNode {
502        let mut m = MemoryNode::new(AgentId::new(), mem_type, content.to_string(), embedding);
503        m.created_at = 1000;
504        m
505    }
506
507    #[test]
508    fn test_heuristic_does_not_flag_contradiction_from_similarity() {
509        // Two facts with high embedding similarity but different content look
510        // identical to cosine similarity whether they are paraphrases or
511        // opposites. The cheap heuristic must NOT guess "contradiction" here
512        // (that produced ~0% precision in production); real contradiction
513        // detection is the LLM path's job. High-sim different-content yields no
514        // action from the heuristic.
515        let agent = AgentId::new();
516        let mut existing =
517            make_memory("uses PostgreSQL", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
518        existing.agent_id = agent;
519
520        let mut new_mem = make_memory("uses MySQL", vec![0.99, 0.01, 0.0], MemoryType::Semantic);
521        new_mem.agent_id = agent;
522        new_mem.created_at = 2000;
523
524        let engine = WriteInferenceEngine::new();
525        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
526        assert!(
527            !actions
528                .iter()
529                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
530            "Heuristic must not flag contradictions from bare similarity, got: {:?}",
531            actions
532        );
533    }
534
535    #[test]
536    fn value_update_supersedes_the_old_fact() {
537        // The canonical correction shape: same sentence frame, new value at the
538        // tail. The newer memory must supersede the older one so recall serves
539        // only the corrected fact (live bug: both coffee facts coexisted).
540        let agent = AgentId::new();
541        let mut existing = make_memory(
542            "The user's favorite coffee order is a cortado",
543            vec![1.0, 0.0, 0.0],
544            MemoryType::Semantic,
545        );
546        existing.agent_id = agent;
547        let mut new_mem = make_memory(
548            "The user's favorite coffee order is a flat white",
549            vec![0.98, 0.02, 0.0],
550            MemoryType::Semantic,
551        );
552        new_mem.agent_id = agent;
553        new_mem.created_at = 2000;
554
555        let engine = WriteInferenceEngine::new();
556        let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
557        assert!(
558            actions.iter().any(|a| matches!(
559                a,
560                InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
561                    if *source == new_mem.id && *target == existing.id
562            )),
563            "value update must create a Supersedes edge, got: {actions:?}"
564        );
565        assert!(
566            actions
567                .iter()
568                .any(|a| matches!(a, InferredAction::UpdateConfidence { memory, .. } if *memory == existing.id)),
569            "superseded fact's confidence must decay"
570        );
571    }
572
573    #[test]
574    fn value_update_fires_at_realistic_embedder_cosine() {
575        // Regression for the production-dead rule: real embedders put a value
576        // correction far below a high cosine bar (measured: Titan V2 0.61,
577        // OpenAI 3-small 0.80 for cortado vs flat white; 0.51 / 0.79 for a phone
578        // number change). The old 0.88 default was unreachable on either, so the
579        // rule never fired in prod. A frame-matched correction at cosine 0.6 must
580        // now supersede; the structural shared-frame test, not the cosine, is
581        // what proves it is a correction.
582        let agent = AgentId::new();
583        let mut existing = make_memory(
584            "The user's favorite coffee order is a cortado",
585            vec![1.0, 0.0, 0.0],
586            MemoryType::Semantic,
587        );
588        existing.agent_id = agent;
589        // cosine([1,0,0], [0.6,0.8,0]) = 0.6: below the old 0.88 gate, above 0.5.
590        let mut new_mem = make_memory(
591            "The user's favorite coffee order is a flat white",
592            vec![0.6, 0.8, 0.0],
593            MemoryType::Semantic,
594        );
595        new_mem.agent_id = agent;
596        new_mem.created_at = 2000;
597
598        let actions =
599            WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing.clone()], &[]);
600        assert!(
601            actions.iter().any(|a| matches!(
602                a,
603                InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
604                    if *source == new_mem.id && *target == existing.id
605            )),
606            "a frame-matched correction at realistic cosine 0.6 must supersede, got: {actions:?}"
607        );
608    }
609
610    #[test]
611    fn value_update_below_cosine_floor_does_not_supersede() {
612        // The cosine floor still guards: a shared token frame that collided on
613        // genuinely off-topic content (cosine 0.4, below the 0.5 floor) must not
614        // supersede, even though the frame test alone would pass. Structure is
615        // primary, but the floor rejects pathological frame collisions.
616        let agent = AgentId::new();
617        let mut existing = make_memory(
618            "The user's favorite coffee order is a cortado",
619            vec![1.0, 0.0, 0.0],
620            MemoryType::Semantic,
621        );
622        existing.agent_id = agent;
623        // cosine([1,0,0], [0.4,0.917,0]) = 0.4: below the 0.5 floor.
624        let mut new_mem = make_memory(
625            "The user's favorite coffee order is a flat white",
626            vec![0.4, 0.917, 0.0],
627            MemoryType::Semantic,
628        );
629        new_mem.agent_id = agent;
630        new_mem.created_at = 2000;
631
632        let actions = WriteInferenceEngine::new().infer_on_write(&new_mem, &[existing], &[]);
633        assert!(
634            !actions.iter().any(|a| matches!(
635                a,
636                InferredAction::CreateEdge {
637                    edge_type: EdgeType::Supersedes,
638                    ..
639                }
640            )),
641            "below the cosine floor a frame match must not supersede, got: {actions:?}"
642        );
643    }
644
645    #[test]
646    fn correction_supersedes_regardless_of_store_order() {
647        // Batch enrichment can store the correction BEFORE the original it
648        // corrects. The original arriving after the existing correction must
649        // still be the one marked stale: the correction wins either way. This is
650        // the case the old order-dependent rule missed (both facts coexisted).
651        let agent = AgentId::new();
652        let mut correction = make_memory(
653            "The user's favorite coffee is a flat white",
654            vec![1.0, 0.0, 0.0],
655            MemoryType::Correction,
656        );
657        correction.agent_id = agent;
658        correction.created_at = 5000;
659        let mut original = make_memory(
660            "The user's favorite coffee is a cortado",
661            vec![0.98, 0.02, 0.0],
662            MemoryType::Semantic,
663        );
664        original.agent_id = agent;
665        original.created_at = 5000; // same batch timestamp
666
667        // The original arrives as the new memory; the correction already exists.
668        let actions =
669            WriteInferenceEngine::new().infer_on_write(&original, &[correction.clone()], &[]);
670        assert!(
671            actions.iter().any(|a| matches!(
672                a,
673                InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
674                    if *source == correction.id && *target == original.id
675            )),
676            "the correction must supersede the later-arriving original, got: {actions:?}"
677        );
678    }
679
680    #[test]
681    fn correction_supersedes_original_at_equal_timestamps() {
682        // The other store order: original first, correction second, same batch
683        // timestamp. The correction still wins.
684        let agent = AgentId::new();
685        let mut original = make_memory(
686            "The user's favorite coffee is a cortado",
687            vec![1.0, 0.0, 0.0],
688            MemoryType::Semantic,
689        );
690        original.agent_id = agent;
691        original.created_at = 5000;
692        let mut correction = make_memory(
693            "The user's favorite coffee is a flat white",
694            vec![0.98, 0.02, 0.0],
695            MemoryType::Correction,
696        );
697        correction.agent_id = agent;
698        correction.created_at = 5000;
699
700        let actions =
701            WriteInferenceEngine::new().infer_on_write(&correction, &[original.clone()], &[]);
702        assert!(
703            actions.iter().any(|a| matches!(
704                a,
705                InferredAction::CreateEdge { source, target, edge_type: EdgeType::Supersedes, .. }
706                    if *source == correction.id && *target == original.id
707            )),
708            "the correction must supersede the original at equal timestamps, got: {actions:?}"
709        );
710    }
711
712    #[test]
713    fn multivalued_facts_at_equal_timestamps_are_both_kept() {
714        // Two plain facts sharing a frame that are not a correction of each other
715        // (a multi-valued attribute) must not supersede when timestamps are equal,
716        // so making the rule order-independent does not start eating coexisting
717        // facts. Only a correction signal or a clear time order triggers it.
718        let agent = AgentId::new();
719        let mut a = make_memory(
720            "The user knows Rust",
721            vec![1.0, 0.0, 0.0],
722            MemoryType::Semantic,
723        );
724        a.agent_id = agent;
725        a.created_at = 5000;
726        let mut b = make_memory(
727            "The user knows Python",
728            vec![0.97, 0.03, 0.0],
729            MemoryType::Semantic,
730        );
731        b.agent_id = agent;
732        b.created_at = 5000;
733
734        let actions = WriteInferenceEngine::new().infer_on_write(&b, &[a], &[]);
735        assert!(
736            !actions.iter().any(|x| matches!(
737                x,
738                InferredAction::CreateEdge {
739                    edge_type: EdgeType::Supersedes,
740                    ..
741                }
742            )),
743            "coexisting facts at equal timestamps must not supersede, got: {actions:?}"
744        );
745    }
746
747    #[test]
748    fn subject_change_is_not_a_value_update() {
749        // "my dog ..." vs "my cat ..." differ at the head: two facts about two
750        // subjects, both must survive.
751        let agent = AgentId::new();
752        let mut existing = make_memory(
753            "My dog is allergic to chicken",
754            vec![1.0, 0.0, 0.0],
755            MemoryType::Semantic,
756        );
757        existing.agent_id = agent;
758        let mut new_mem = make_memory(
759            "My cat is allergic to chicken",
760            vec![0.99, 0.01, 0.0],
761            MemoryType::Semantic,
762        );
763        new_mem.agent_id = agent;
764        new_mem.created_at = 2000;
765
766        let engine = WriteInferenceEngine::new();
767        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
768        assert!(
769            !actions.iter().any(|a| matches!(
770                a,
771                InferredAction::CreateEdge {
772                    edge_type: EdgeType::Supersedes,
773                    ..
774                }
775            )),
776            "a subject change must not supersede, got: {actions:?}"
777        );
778    }
779
780    #[test]
781    fn value_update_respects_owner_and_config() {
782        // Cross-owner pairs never supersede, and the rule can be disabled.
783        let mut existing = make_memory(
784            "The user's favorite coffee order is a cortado",
785            vec![1.0, 0.0, 0.0],
786            MemoryType::Semantic,
787        );
788        existing.agent_id = AgentId::new();
789        let mut new_mem = make_memory(
790            "The user's favorite coffee order is a flat white",
791            vec![0.98, 0.02, 0.0],
792            MemoryType::Semantic,
793        );
794        new_mem.agent_id = AgentId::new(); // different owner
795        new_mem.created_at = 2000;
796
797        let engine = WriteInferenceEngine::new();
798        let actions = engine.infer_on_write(&new_mem, &[existing.clone()], &[]);
799        assert!(
800            !actions.iter().any(|a| matches!(
801                a,
802                InferredAction::CreateEdge {
803                    edge_type: EdgeType::Supersedes,
804                    ..
805                }
806            )),
807            "cross-owner memories must never supersede each other"
808        );
809
810        // Same owner but rule disabled: no supersession either.
811        let agent = AgentId::new();
812        existing.agent_id = agent;
813        new_mem.agent_id = agent;
814        let engine_off = WriteInferenceEngine::with_config(WriteInferenceConfig {
815            value_update_enabled: false,
816            ..WriteInferenceConfig::default()
817        });
818        let actions = engine_off.infer_on_write(&new_mem, &[existing], &[]);
819        assert!(
820            !actions.iter().any(|a| matches!(
821                a,
822                InferredAction::CreateEdge {
823                    edge_type: EdgeType::Supersedes,
824                    ..
825                }
826            )),
827            "disabled rule must not supersede"
828        );
829    }
830
831    #[test]
832    fn is_value_update_frame_analysis() {
833        // Positive: long shared prefix, short differing tail.
834        assert!(is_value_update(
835            "my phone number is 4321",
836            "my phone number is 1234",
837            0.6,
838            4
839        ));
840        // Negative: head differs (different subject).
841        assert!(!is_value_update(
842            "my cat is allergic to chicken",
843            "my dog is allergic to chicken",
844            0.6,
845            4
846        ));
847        // Negative: identical content is not an update.
848        assert!(!is_value_update("same fact", "same fact", 0.6, 4));
849        // Negative: tail too long (a genuinely different statement).
850        assert!(!is_value_update(
851            "the deploy runs every friday and sarah reviews it after standup",
852            "the deploy runs every friday unless the release train is frozen for the quarter end audit",
853            0.6,
854            4
855        ));
856    }
857
858    #[test]
859    fn test_byte_identical_duplicate_is_deduplicated() {
860        // Identical text is deduplicated (invalidate + Derived lineage), not a
861        // supersession, so it never surfaces as a conflict.
862        let agent = AgentId::new();
863        let mut existing = make_memory(
864            "Ran command: ls -la",
865            vec![1.0, 0.0, 0.0],
866            MemoryType::Episodic,
867        );
868        existing.agent_id = agent;
869
870        let mut new_mem = make_memory(
871            "Ran command: ls -la",
872            vec![1.0, 0.0, 0.0],
873            MemoryType::Episodic,
874        );
875        new_mem.agent_id = agent;
876        new_mem.created_at = 2000;
877
878        let engine = WriteInferenceEngine::new();
879        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
880        assert!(
881            actions
882                .iter()
883                .any(|a| matches!(a, InferredAction::DeduplicateExact { .. })),
884            "Expected identical duplicate to be deduplicated, got: {:?}",
885            actions
886        );
887    }
888
889    #[test]
890    fn test_moderate_similarity_creates_edge() {
891        let existing = make_memory("topic A", vec![1.0, 0.0, 0.0], MemoryType::Semantic);
892        // ~0.7 similarity
893        let new_mem = make_memory("topic B", vec![0.7, 0.714, 0.0], MemoryType::Semantic);
894
895        let engine = WriteInferenceEngine::new();
896        let actions = engine.infer_on_write(&new_mem, &[existing], &[]);
897        assert!(
898            actions.iter().any(|a| matches!(
899                a,
900                InferredAction::CreateEdge {
901                    edge_type: EdgeType::Related,
902                    ..
903                }
904            )),
905            "Expected CreateEdge Related, got: {:?}",
906            actions
907        );
908    }
909
910    #[tokio::test]
911    async fn test_llm_invalidation_emits_temporal_actions() {
912        let agent = AgentId::new();
913        let mut existing = make_memory(
914            "Alice works at Acme",
915            vec![0.8, 0.6, 0.0],
916            MemoryType::Semantic,
917        );
918        existing.agent_id = agent;
919
920        let mut new_mem = make_memory(
921            "Alice joined Google last week",
922            vec![0.75, 0.65, 0.1],
923            MemoryType::Semantic,
924        );
925        new_mem.agent_id = agent;
926        new_mem.created_at = 2000;
927
928        let judge =
929            MockLlmJudge::new(r#"{"verdict": "invalidate", "reason": "Alice changed jobs"}"#);
930        let llm = CognitiveLlmService::new(judge);
931        let engine = WriteInferenceEngine::new();
932        let actions = engine
933            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
934            .await;
935
936        assert!(
937            actions
938                .iter()
939                .any(|a| matches!(a, InferredAction::InvalidateMemory { .. })),
940            "Expected InvalidateMemory from LLM verdict, got: {:?}",
941            actions
942        );
943        // Exactly one invalidation action: InvalidateMemory both sets
944        // valid_until and creates the Supersedes edge, so a MarkObsolete
945        // alongside it would duplicate the edge.
946        let invalidation_count = actions
947            .iter()
948            .filter(|a| {
949                matches!(
950                    a,
951                    InferredAction::InvalidateMemory { .. } | InferredAction::MarkObsolete { .. }
952                )
953            })
954            .count();
955        assert_eq!(
956            invalidation_count, 1,
957            "Expected a single invalidation action, got: {:?}",
958            actions
959        );
960    }
961
962    #[tokio::test]
963    async fn test_llm_update_emits_update_content() {
964        let agent = AgentId::new();
965        let mut existing = make_memory(
966            "Project uses React",
967            vec![0.8, 0.6, 0.0],
968            MemoryType::Semantic,
969        );
970        existing.agent_id = agent;
971
972        let mut new_mem = make_memory(
973            "Project migrated from React to Vue",
974            vec![0.75, 0.65, 0.1],
975            MemoryType::Semantic,
976        );
977        new_mem.agent_id = agent;
978        new_mem.created_at = 2000;
979
980        let judge = MockLlmJudge::new(
981            r#"{"verdict": "update", "merged_content": "Project migrated from React to Vue in Q2", "reason": "adds temporal context"}"#,
982        );
983        let llm = CognitiveLlmService::new(judge);
984        let engine = WriteInferenceEngine::new();
985        let actions = engine
986            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
987            .await;
988
989        assert!(
990            actions
991                .iter()
992                .any(|a| matches!(a, InferredAction::UpdateContent { .. })),
993            "Expected UpdateContent from LLM update verdict, got: {:?}",
994            actions
995        );
996    }
997
998    #[tokio::test]
999    async fn test_llm_keep_falls_through_to_contradiction_check() {
1000        let agent = AgentId::new();
1001        // High similarity pair where LLM says keep but content differs
1002        let mut existing = make_memory("Prefers tabs", vec![0.9, 0.44, 0.0], MemoryType::Semantic);
1003        existing.agent_id = agent;
1004
1005        let mut new_mem = make_memory(
1006            "Prefers spaces",
1007            vec![0.88, 0.47, 0.0],
1008            MemoryType::Semantic,
1009        );
1010        new_mem.agent_id = agent;
1011        new_mem.created_at = 2000;
1012
1013        let judge = MockLlmJudge::new(
1014            r#"{"verdict": "compatible", "reason": "different formatting preferences"}"#,
1015        );
1016        let llm = CognitiveLlmService::new(judge);
1017        let engine = WriteInferenceEngine::new();
1018        let actions = engine
1019            .infer_on_write_with_llm(&new_mem, &[existing.clone()], &[], &llm)
1020            .await;
1021
1022        // MockLlmJudge returns the same response for all calls, so the keep
1023        // from invalidation falls through, then contradiction returns compatible
1024        assert!(
1025            !actions
1026                .iter()
1027                .any(|a| matches!(a, InferredAction::FlagContradiction { .. })),
1028            "Should not flag contradiction when LLM says compatible",
1029        );
1030    }
1031}