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