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