1const 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#[derive(Debug, Clone)]
28pub struct ConflictCandidate {
29 pub edge_a_id: i64,
31 pub edge_b_id: i64,
33 pub similarity: f64,
35 pub method: String,
37}
38
39pub struct ImplicitConflictDetector {
56 config: ImplicitConflictConfig,
57}
58
59impl ImplicitConflictDetector {
60 #[must_use]
62 pub fn new(config: ImplicitConflictConfig) -> Self {
63 Self { config }
64 }
65
66 #[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 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 #[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 #[must_use]
174 pub fn normalized_levenshtein(a: &str, b: &str) -> f64 {
175 normalized_similarity(a, b)
176 }
177
178 #[must_use]
180 pub fn is_enabled(&self) -> bool {
181 self.config.enabled
182 }
183
184 #[must_use]
186 pub fn candidate_ttl_days(&self) -> u32 {
187 self.config.candidate_ttl_days
188 }
189}
190
191#[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 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 #[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 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 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 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 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 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 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}