Skip to main content

zeph_memory/graph/
implicit_conflict.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Implicit conflict detection for SYNAPSE recall (spec 004-17, STALE/CUPMem).
5//!
6//! [`ImplicitConflictDetector`] runs at write time to detect predicate pairs
7//! that are semantically similar but not identical, staging them in
8//! `implicit_conflict_candidates` for later resolution or annotation.
9//!
10//! SYNAPSE recall uses [`annotate_conflicts`] to mark retrieved [`ActivatedFact`]s
11//! that have pending conflict candidates.
12
13// SQLite limits bind parameters to 999. Each ID is bound twice (two IN clauses),
14// so process in chunks of at most 499.
15const MAX_IDS_PER_QUERY: usize = 499;
16
17use std::time::{SystemTime, UNIX_EPOCH};
18
19use zeph_config::ImplicitConflictConfig;
20use zeph_db::{DbTransaction, sql};
21
22use crate::error::MemoryError;
23use crate::graph::activation::ActivatedFact;
24use crate::graph::string_similarity::normalized_similarity;
25
26/// A candidate conflict pair detected at write time.
27#[derive(Debug, Clone)]
28pub struct ConflictCandidate {
29    /// ID of the newly inserted edge.
30    pub edge_a_id: i64,
31    /// ID of the existing edge with a similar predicate.
32    pub edge_b_id: i64,
33    /// Similarity score in `[0.0, 1.0]`.
34    pub similarity: f64,
35    /// The similarity method that produced this candidate.
36    pub method: String,
37}
38
39/// Write-time implicit conflict detector.
40///
41/// Compares a new edge's predicate against existing active edges on the same
42/// source entity using the configured similarity method and threshold.
43///
44/// # Examples
45///
46/// ```rust,no_run
47/// use zeph_config::ImplicitConflictConfig;
48/// use zeph_memory::graph::implicit_conflict::ImplicitConflictDetector;
49///
50/// let config = ImplicitConflictConfig { enabled: true, ..Default::default() };
51/// let detector = ImplicitConflictDetector::new(config);
52/// let candidates = detector.detect_candidates(42, "employ", &[(1, "employs")], false);
53/// assert!(!candidates.is_empty());
54/// ```
55pub struct ImplicitConflictDetector {
56    config: ImplicitConflictConfig,
57}
58
59impl ImplicitConflictDetector {
60    /// Create a new detector with the given configuration.
61    #[must_use]
62    pub fn new(config: ImplicitConflictConfig) -> Self {
63        Self { config }
64    }
65
66    /// Detect implicit conflict candidates for a new predicate against existing ones.
67    ///
68    /// Returns pairs where normalized Levenshtein similarity is
69    /// `>= conflict_similarity_threshold` **and** the predicates differ (identical
70    /// predicates are already handled by APEX-MEM explicit supersession).
71    ///
72    /// Returns an empty vec when `enabled = false` or when the cardinality flag
73    /// `is_cardinality_n` is set (FR-011).
74    ///
75    /// # Arguments
76    ///
77    /// * `new_edge_id` — database ID of the newly inserted edge
78    /// * `new_predicate` — canonical relation of the new edge
79    /// * `existing` — slice of `(edge_id, canonical_relation)` for all other active
80    ///   edges on the same source entity
81    /// * `is_cardinality_n` — set to `true` for multi-valued predicates; skips detection
82    #[must_use]
83    pub fn detect_candidates(
84        &self,
85        new_edge_id: i64,
86        new_predicate: &str,
87        existing: &[(i64, &str)],
88        is_cardinality_n: bool,
89    ) -> Vec<ConflictCandidate> {
90        let _span = tracing::info_span!(
91            "memory.graph.implicit_conflict.detect",
92            predicate = new_predicate,
93        )
94        .entered();
95
96        if !self.config.enabled || is_cardinality_n || existing.is_empty() {
97            return Vec::new();
98        }
99
100        let threshold = self.config.conflict_similarity_threshold;
101        let mut candidates = Vec::new();
102
103        for &(edge_id, predicate) in existing {
104            if predicate == new_predicate {
105                // Identical predicate: handled by APEX-MEM explicit supersession.
106                continue;
107            }
108            let sim = Self::normalized_levenshtein(new_predicate, predicate);
109            if sim >= threshold {
110                candidates.push(ConflictCandidate {
111                    edge_a_id: new_edge_id,
112                    edge_b_id: edge_id,
113                    similarity: sim,
114                    method: "levenshtein".to_owned(),
115                });
116            }
117        }
118
119        candidates
120    }
121
122    /// Persist conflict candidates into `implicit_conflict_candidates`.
123    ///
124    /// Each candidate is inserted with `status = 'pending'` and an expiry of
125    /// `now + ttl_days * 86400` seconds.
126    ///
127    /// # Errors
128    ///
129    /// Returns a [`MemoryError`] on database write failure.
130    #[tracing::instrument(skip_all, name = "memory.graph.implicit_conflict.stage", fields(count = candidates.len()))]
131    pub async fn stage_candidates(
132        &self,
133        candidates: &[ConflictCandidate],
134        tx: &mut DbTransaction<'_>,
135        ttl_days: u32,
136    ) -> Result<(), MemoryError> {
137        if candidates.is_empty() {
138            return Ok(());
139        }
140
141        #[allow(clippy::cast_possible_wrap)]
142        let now = SystemTime::now()
143            .duration_since(UNIX_EPOCH)
144            .unwrap_or_default()
145            .as_secs() as i64;
146        let ttl_secs = i64::from(ttl_days) * 86_400;
147        let expires_at = now + ttl_secs;
148
149        for c in candidates {
150            zeph_db::query(sql!(
151                "INSERT INTO implicit_conflict_candidates
152                 (edge_a_id, edge_b_id, similarity, method, status, created_at, expires_at)
153                 VALUES (?, ?, ?, ?, 'pending', ?, ?)"
154            ))
155            .bind(c.edge_a_id)
156            .bind(c.edge_b_id)
157            .bind(c.similarity)
158            .bind(&c.method)
159            .bind(now)
160            .bind(expires_at)
161            .execute(&mut **tx)
162            .await
163            .map_err(MemoryError::from)?;
164        }
165
166        Ok(())
167    }
168
169    /// Compute normalized Levenshtein similarity between two strings.
170    ///
171    /// Returns a value in `[0.0, 1.0]` where `1.0` means identical.
172    /// Returns `1.0` if both strings are empty, `0.0` if only one is empty.
173    #[must_use]
174    pub fn normalized_levenshtein(a: &str, b: &str) -> f64 {
175        normalized_similarity(a, b)
176    }
177
178    /// Returns `true` when detection is enabled.
179    #[must_use]
180    pub fn is_enabled(&self) -> bool {
181        self.config.enabled
182    }
183
184    /// Returns the configured TTL for conflict candidates, in days.
185    #[must_use]
186    pub fn candidate_ttl_days(&self) -> u32 {
187        self.config.candidate_ttl_days
188    }
189}
190
191/// Annotate retrieved [`ActivatedFact`]s with pending implicit conflict metadata.
192///
193/// Queries `implicit_conflict_candidates` for all edge IDs in `facts` and sets
194/// `is_implicit_conflict = true` and `conflict_candidate_id` on matches.
195///
196/// # Errors
197///
198/// Returns a [`MemoryError`] on database query failure.
199#[tracing::instrument(skip_all, name = "memory.graph.implicit_conflict.annotate", fields(facts = facts.len()))]
200pub async fn annotate_conflicts(
201    facts: &mut [ActivatedFact],
202    tx: &mut DbTransaction<'_>,
203) -> Result<(), MemoryError> {
204    if facts.is_empty() {
205        return Ok(());
206    }
207
208    let edge_ids: Vec<i64> = facts.iter().map(|f| f.edge.id).collect();
209
210    let mut edge_to_candidate: std::collections::HashMap<i64, i64> =
211        std::collections::HashMap::new();
212
213    for chunk in edge_ids.chunks(MAX_IDS_PER_QUERY) {
214        let placeholders: String = chunk.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
215        let query_str = zeph_db::rewrite_placeholders(&format!(
216            "SELECT id, edge_a_id, edge_b_id
217             FROM implicit_conflict_candidates
218             WHERE status = 'pending'
219               AND (edge_a_id IN ({placeholders}) OR edge_b_id IN ({placeholders}))",
220        ));
221
222        let mut q = sqlx::query(sqlx::AssertSqlSafe(query_str));
223        for id in chunk {
224            q = q.bind(id);
225        }
226        // Bind a second time for the second IN clause.
227        for id in chunk {
228            q = q.bind(id);
229        }
230
231        let rows = q.fetch_all(&mut **tx).await.map_err(MemoryError::from)?;
232
233        for row in rows {
234            use sqlx::Row as _;
235            let candidate_id: i64 = row.try_get("id").map_err(MemoryError::from)?;
236            let ea: i64 = row.try_get("edge_a_id").map_err(MemoryError::from)?;
237            let eb: i64 = row.try_get("edge_b_id").map_err(MemoryError::from)?;
238            edge_to_candidate.entry(ea).or_insert(candidate_id);
239            edge_to_candidate.entry(eb).or_insert(candidate_id);
240        }
241    }
242
243    for fact in facts.iter_mut() {
244        if let Some(&cid) = edge_to_candidate.get(&fact.edge.id) {
245            fact.is_implicit_conflict = true;
246            fact.conflict_candidate_id = Some(cid);
247        }
248    }
249
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use zeph_config::ImplicitConflictConfig;
257
258    fn detector(enabled: bool) -> ImplicitConflictDetector {
259        ImplicitConflictDetector::new(ImplicitConflictConfig {
260            enabled,
261            conflict_similarity_threshold: 0.80,
262            ..Default::default()
263        })
264    }
265
266    // Algorithm-level coverage (identical/empty/dissimilar strings) lives in
267    // `graph::string_similarity` tests; this wrapper only needs a delegation smoke test.
268    #[test]
269    fn normalized_levenshtein_delegates_to_shared_helper() {
270        assert!(
271            (ImplicitConflictDetector::normalized_levenshtein("uses", "uses") - 1.0).abs()
272                < f64::EPSILON
273        );
274    }
275
276    #[test]
277    fn detect_candidates_above_threshold_returns_candidate() {
278        let d = detector(true);
279        // "employ" vs "employs": distance = 1, max = 7, sim ≈ 0.857 — above 0.80
280        let candidates = d.detect_candidates(42, "employ", &[(7, "employs")], false);
281        assert_eq!(candidates.len(), 1, "expected one candidate");
282        assert_eq!(candidates[0].edge_a_id, 42);
283        assert_eq!(candidates[0].edge_b_id, 7);
284        assert!(candidates[0].similarity >= 0.80);
285    }
286
287    #[test]
288    fn detect_candidates_below_threshold_returns_empty() {
289        let d = detector(true);
290        let candidates = d.detect_candidates(1, "uses", &[(2, "xyz_unrelated")], false);
291        assert!(
292            candidates.is_empty(),
293            "expected no candidates below threshold"
294        );
295    }
296
297    #[test]
298    fn detect_candidates_identical_predicate_skipped() {
299        let d = detector(true);
300        // Identical predicates are handled by APEX-MEM; detector must skip them.
301        let candidates = d.detect_candidates(1, "uses", &[(2, "uses")], false);
302        assert!(
303            candidates.is_empty(),
304            "identical predicates must not create candidates"
305        );
306    }
307
308    #[test]
309    fn detect_candidates_disabled_returns_empty() {
310        let d = detector(false);
311        // Even with high-similarity predicates, disabled detector returns nothing.
312        let candidates = d.detect_candidates(1, "employ", &[(2, "employs")], false);
313        assert!(candidates.is_empty(), "disabled detector must return empty");
314    }
315
316    #[test]
317    fn detect_candidates_cardinality_n_skipped() {
318        let d = detector(true);
319        let candidates = d.detect_candidates(1, "employ", &[(2, "employs")], true);
320        assert!(
321            candidates.is_empty(),
322            "cardinality-n predicate must be skipped"
323        );
324    }
325
326    // ── annotate_conflicts DB tests ───────────────────────────────────────────
327
328    async fn setup_test_db() -> crate::store::SqliteStore {
329        crate::store::SqliteStore::new(":memory:").await.unwrap()
330    }
331
332    fn stub_fact(edge_id: i64) -> ActivatedFact {
333        use crate::graph::types::{Edge, EdgeType};
334        ActivatedFact {
335            edge: Edge {
336                id: edge_id,
337                source_entity_id: 1,
338                target_entity_id: 2,
339                relation: "test".to_owned(),
340                canonical_relation: "test".to_owned(),
341                fact: "test fact".to_owned(),
342                confidence: 1.0,
343                valid_from: "2026-01-01".to_owned(),
344                valid_to: None,
345                created_at: "2026-01-01".to_owned(),
346                expired_at: None,
347                source_message_id: None,
348                qdrant_point_id: None,
349                edge_type: EdgeType::Semantic,
350                retrieval_count: 0,
351                last_retrieved_at: None,
352                superseded_by: None,
353                supersedes: None,
354                weight: 1.0,
355                confidence_fast: 1.0,
356                confidence_slow: 1.0,
357                turn_index: None,
358            },
359            activation_score: 1.0,
360            is_implicit_conflict: false,
361            conflict_candidate_id: None,
362        }
363    }
364
365    #[tokio::test]
366    async fn annotate_conflicts_marks_flagged_edges() {
367        let db = setup_test_db().await;
368        let pool = db.pool();
369
370        // Insert a pending candidate pair (edge_a_id=1, edge_b_id=2).
371        // Use raw SQL since these are not real graph_edges (no FK enforcement with PRAGMA).
372        sqlx::query(
373            "PRAGMA foreign_keys = OFF;
374             INSERT INTO implicit_conflict_candidates
375             (edge_a_id, edge_b_id, similarity, method, status, created_at, expires_at)
376             VALUES (1, 2, 0.90, 'levenshtein', 'pending', 1000000, 9999999)",
377        )
378        .execute(pool)
379        .await
380        .unwrap();
381
382        let mut facts = vec![stub_fact(1), stub_fact(3)];
383
384        let mut tx = zeph_db::begin(pool).await.unwrap();
385        annotate_conflicts(&mut facts, &mut tx).await.unwrap();
386        tx.commit().await.unwrap();
387
388        assert!(facts[0].is_implicit_conflict, "edge 1 must be flagged");
389        assert!(facts[0].conflict_candidate_id.is_some());
390        assert!(!facts[1].is_implicit_conflict, "edge 3 must not be flagged");
391        assert!(facts[1].conflict_candidate_id.is_none());
392    }
393
394    #[tokio::test]
395    async fn annotate_conflicts_empty_candidates_no_annotation() {
396        let db = setup_test_db().await;
397        let pool = db.pool();
398
399        let mut facts = vec![stub_fact(10), stub_fact(20)];
400
401        let mut tx = zeph_db::begin(pool).await.unwrap();
402        annotate_conflicts(&mut facts, &mut tx).await.unwrap();
403        tx.commit().await.unwrap();
404
405        assert!(
406            !facts[0].is_implicit_conflict,
407            "no candidates → no annotation"
408        );
409        assert!(facts[1].conflict_candidate_id.is_none());
410    }
411
412    #[tokio::test]
413    async fn annotate_conflicts_edge_b_side_also_flagged() {
414        let db = setup_test_db().await;
415        let pool = db.pool();
416
417        // Insert candidate with edge_a=5, edge_b=7. Pass edge 7 in facts.
418        sqlx::query(
419            "PRAGMA foreign_keys = OFF;
420             INSERT INTO implicit_conflict_candidates
421             (edge_a_id, edge_b_id, similarity, method, status, created_at, expires_at)
422             VALUES (5, 7, 0.85, 'levenshtein', 'pending', 1000000, 9999999)",
423        )
424        .execute(pool)
425        .await
426        .unwrap();
427
428        let mut facts = vec![stub_fact(7)];
429
430        let mut tx = zeph_db::begin(pool).await.unwrap();
431        annotate_conflicts(&mut facts, &mut tx).await.unwrap();
432        tx.commit().await.unwrap();
433
434        assert!(
435            facts[0].is_implicit_conflict,
436            "edge on edge_b side must also be flagged"
437        );
438    }
439}