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