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