Skip to main content

zeph_memory/graph/resolver/
mod.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::sync::Arc;
5
6use dashmap::DashMap;
7use futures::stream::{self, StreamExt as _};
8use schemars::JsonSchema;
9use serde::Deserialize;
10use tokio::sync::Mutex;
11use zeph_common::sanitize::strip_control_chars;
12use zeph_common::text::truncate_to_bytes_ref;
13use zeph_llm::any::AnyProvider;
14use zeph_llm::provider::{LlmProvider as _, Message, Role};
15
16use super::store::GraphStore;
17use super::types::{EntityType, GraphProvenance};
18use crate::embedding_store::EmbeddingStore;
19use crate::error::MemoryError;
20use crate::graph::extractor::ExtractedEntity;
21use crate::types::MessageId;
22use crate::vector_store::{FieldCondition, FieldValue, VectorFilter};
23
24/// Minimum byte length for entity names — rejects noise tokens like "go", "cd".
25const MIN_ENTITY_NAME_BYTES: usize = 3;
26/// Maximum byte length for entity names stored in the graph.
27const MAX_ENTITY_NAME_BYTES: usize = 512;
28/// Maximum byte length for relation strings.
29///
30/// `pub(crate)` so `semantic::graph` can share this cap instead of re-declaring it.
31pub(crate) const MAX_RELATION_BYTES: usize = 256;
32/// Maximum byte length for fact strings.
33///
34/// `pub(crate)` so `semantic::graph` can share this cap instead of re-declaring it.
35pub(crate) const MAX_FACT_BYTES: usize = 2048;
36
37/// Sanitize a relation string for use as an edge dedup key: strip control chars, lowercase,
38/// trim, then truncate to [`MAX_RELATION_BYTES`].
39pub(crate) fn sanitize_relation(relation: &str) -> String {
40    let cleaned = strip_control_chars(&relation.trim().to_lowercase());
41    truncate_to_bytes_ref(&cleaned, MAX_RELATION_BYTES).to_owned()
42}
43
44/// Sanitize a fact string: strip control chars, trim, then truncate to [`MAX_FACT_BYTES`].
45pub(crate) fn sanitize_fact(fact: &str) -> String {
46    let cleaned = strip_control_chars(fact.trim());
47    truncate_to_bytes_ref(&cleaned, MAX_FACT_BYTES).to_owned()
48}
49
50/// Qdrant collection for entity embeddings.
51const ENTITY_COLLECTION: &str = "zeph_graph_entities";
52
53/// Timeout for a single `embed()` call in seconds.
54const EMBED_TIMEOUT_SECS: u64 = 30;
55
56/// Outcome of an entity resolution attempt.
57#[derive(Debug, Clone, PartialEq)]
58#[non_exhaustive]
59pub enum ResolutionOutcome {
60    /// Exact name+type match in `SQLite`.
61    ExactMatch,
62    /// Cosine similarity >= merge threshold; score is the cosine similarity value.
63    EmbeddingMatch { score: f32 },
64    /// LLM confirmed merge in ambiguous similarity range.
65    LlmDisambiguated,
66    /// New entity was created.
67    Created,
68}
69
70/// LLM response for entity disambiguation.
71#[derive(Debug, Deserialize, JsonSchema)]
72struct DisambiguationResponse {
73    same_entity: bool,
74}
75
76/// Per-entity-name lock guard to prevent concurrent duplicate creation.
77///
78/// Keyed by normalized entity name. Entities with different names resolve concurrently;
79/// entities with the same name are serialized.
80///
81/// TODO(SEC-M33-02): This map grows unboundedly — one entry per unique normalized name.
82/// For a short-lived resolver this is acceptable. If the resolver becomes long-lived
83/// (stored in `SemanticMemory`), add eviction or use a fixed-size sharded lock array.
84type NameLockMap = Arc<DashMap<String, Arc<Mutex<()>>>>;
85
86pub struct EntityResolver<'a> {
87    store: &'a GraphStore,
88    embedding_store: Option<&'a Arc<EmbeddingStore>>,
89    provider: Option<&'a AnyProvider>,
90    similarity_threshold: f32,
91    ambiguous_threshold: f32,
92    name_locks: NameLockMap,
93    /// Counter for error-triggered fallbacks (embed/LLM failures). Tests can read this via Arc.
94    fallback_count: Arc<std::sync::atomic::AtomicU64>,
95    /// Ensures `ensure_named_collection()` is called at most once per resolver lifetime.
96    ///
97    /// Arc-wrapped so future clones or spawned tasks can share the same gate.
98    collection_ensured: Arc<tokio::sync::OnceCell<()>>,
99    /// Per-call timeout for every `embed()` invocation. Default: 5 s.
100    embed_timeout: std::time::Duration,
101    /// Per-call timeout for the LLM disambiguation `chat()` invocation. Default: 30 s.
102    ///
103    /// Kept separate from `embed_timeout` because chat completions are substantially
104    /// slower than embeds — see `GraphExtractionConfig::llm_timeout_secs`, which this
105    /// should be set from at construction time.
106    llm_timeout: std::time::Duration,
107}
108
109impl<'a> EntityResolver<'a> {
110    /// Returns a reference to the underlying graph store.
111    #[must_use]
112    pub fn graph_store(&self) -> &GraphStore {
113        self.store
114    }
115
116    #[must_use]
117    pub fn new(store: &'a GraphStore) -> Self {
118        Self {
119            store,
120            embedding_store: None,
121            provider: None,
122            similarity_threshold: 0.85,
123            ambiguous_threshold: 0.70,
124            name_locks: Arc::new(DashMap::new()),
125            fallback_count: Arc::new(std::sync::atomic::AtomicU64::new(0)),
126            collection_ensured: Arc::new(tokio::sync::OnceCell::new()),
127            embed_timeout: std::time::Duration::from_secs(5),
128            llm_timeout: std::time::Duration::from_secs(30),
129        }
130    }
131
132    /// Set the per-call timeout for every `embed()` invocation.
133    ///
134    /// Default: 5 s.
135    #[must_use]
136    pub fn with_embed_timeout(mut self, timeout_secs: u64) -> Self {
137        self.embed_timeout = std::time::Duration::from_secs(timeout_secs);
138        self
139    }
140
141    /// Set the per-call timeout for the LLM disambiguation `chat()` invocation.
142    ///
143    /// Default: 30 s. Callers should set this from `GraphExtractionConfig::llm_timeout_secs`
144    /// rather than reusing the (much shorter) embed timeout — chat completions are slower.
145    #[must_use]
146    pub fn with_llm_timeout(mut self, timeout_secs: u64) -> Self {
147        self.llm_timeout = std::time::Duration::from_secs(timeout_secs);
148        self
149    }
150
151    #[must_use]
152    pub fn with_embedding_store(mut self, store: &'a Arc<EmbeddingStore>) -> Self {
153        self.embedding_store = Some(store);
154        self
155    }
156
157    #[must_use]
158    pub fn with_provider(mut self, provider: &'a AnyProvider) -> Self {
159        self.provider = Some(provider);
160        self
161    }
162
163    #[must_use]
164    pub fn with_thresholds(mut self, similarity: f32, ambiguous: f32) -> Self {
165        self.similarity_threshold = similarity;
166        self.ambiguous_threshold = ambiguous;
167        self
168    }
169
170    /// Shared fallback counter — tests can clone this Arc to inspect the value.
171    #[must_use]
172    pub fn fallback_count(&self) -> Arc<std::sync::atomic::AtomicU64> {
173        Arc::clone(&self.fallback_count)
174    }
175
176    /// Normalize an entity name: trim, lowercase, strip control chars, truncate.
177    fn normalize_name(name: &str) -> String {
178        let lowered = name.trim().to_lowercase();
179        let cleaned = strip_control_chars(&lowered);
180        let normalized = truncate_to_bytes_ref(&cleaned, MAX_ENTITY_NAME_BYTES).to_owned();
181        if normalized.len() < cleaned.len() {
182            tracing::debug!(
183                "graph resolver: entity name truncated to {} bytes",
184                MAX_ENTITY_NAME_BYTES
185            );
186        }
187        normalized
188    }
189
190    /// Parse an entity type string, falling back to `Concept` on unknown values.
191    ///
192    /// `pub(crate)` so callers outside this module facing the same "parse a `Qdrant`-payload
193    /// `entity_type` string, default to `Concept`" need (e.g. note-linking's
194    /// `resolve_local_target_id` in `semantic/graph.rs`, #5801) share this logic instead of
195    /// duplicating it.
196    pub(crate) fn parse_entity_type(entity_type: &str) -> EntityType {
197        entity_type
198            .trim()
199            .to_lowercase()
200            .parse::<EntityType>()
201            .unwrap_or_else(|_| {
202                tracing::debug!(
203                    "graph resolver: unknown entity type {:?}, falling back to Concept",
204                    entity_type
205                );
206                EntityType::Concept
207            })
208    }
209
210    /// Acquire the per-name lock and return the guard. Keeps lock alive for the caller.
211    async fn lock_name(&self, normalized: &str) -> tokio::sync::OwnedMutexGuard<()> {
212        let lock = self
213            .name_locks
214            .entry(normalized.to_owned())
215            .or_insert_with(|| Arc::new(Mutex::new(())))
216            .clone();
217        lock.lock_owned().await
218    }
219
220    /// Resolve an extracted entity using the alias-first canonicalization pipeline.
221    ///
222    /// Pipeline:
223    /// 1. Normalize: trim, lowercase, strip control chars, truncate to 512 bytes.
224    /// 2. Parse entity type (fallback to Concept on unknown).
225    /// 3. Alias lookup: search `graph_entity_aliases` by normalized name + `entity_type`.
226    ///    If found, touch `last_seen_at` and return the existing entity id.
227    /// 4. Canonical name lookup: search `graph_entities` by `canonical_name` + `entity_type`.
228    ///    If found, touch `last_seen_at` and return the existing entity id.
229    /// 5. When `embedding_store` and `provider` are configured, performs embedding-based fuzzy
230    ///    matching: cosine similarity search (Qdrant), LLM disambiguation for ambiguous range,
231    ///    merge or create based on result. Failures degrade gracefully to step 6.
232    /// 6. Create: upsert new entity with `canonical_name` = normalized name.
233    /// 7. Register the normalized form (and original trimmed form if different) as aliases.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the entity name is empty after normalization, or if a DB operation fails.
238    pub async fn resolve(
239        &self,
240        name: &str,
241        entity_type: &str,
242        summary: Option<&str>,
243        provenance: Option<&GraphProvenance>,
244    ) -> Result<(i64, ResolutionOutcome), MemoryError> {
245        let normalized = Self::normalize_name(name);
246
247        if normalized.is_empty() {
248            return Err(MemoryError::GraphStore("empty entity name".into()));
249        }
250
251        if normalized.len() < MIN_ENTITY_NAME_BYTES {
252            return Err(MemoryError::GraphStore(format!(
253                "entity name too short: {normalized:?} ({} bytes, min {MIN_ENTITY_NAME_BYTES})",
254                normalized.len()
255            )));
256        }
257
258        let et = Self::parse_entity_type(entity_type);
259
260        // The surface form preserves the original casing for user-facing display.
261        let surface_name = name.trim().to_owned();
262
263        // Acquire per-name lock to prevent concurrent duplicate creation.
264        let _guard = self.lock_name(&normalized).await;
265
266        // Step 3: alias-first lookup (filters by entity_type to prevent cross-type collisions).
267        if let Some(entity) = self.store.find_entity_by_alias(&normalized, et).await? {
268            self.store
269                .upsert_entity(
270                    &surface_name,
271                    &entity.canonical_name,
272                    et,
273                    summary,
274                    provenance,
275                )
276                .await?;
277            return Ok((entity.id.0, ResolutionOutcome::ExactMatch));
278        }
279
280        // Step 4: canonical name lookup.
281        if let Some(entity) = self.store.find_entity(&normalized, et).await? {
282            self.store
283                .upsert_entity(
284                    &surface_name,
285                    &entity.canonical_name,
286                    et,
287                    summary,
288                    provenance,
289                )
290                .await?;
291            return Ok((entity.id.0, ResolutionOutcome::ExactMatch));
292        }
293
294        // Step 5: Embedding-based resolution (when configured).
295        if let Some(outcome) = self
296            .resolve_via_embedding(&normalized, name, &surface_name, et, summary, provenance)
297            .await?
298        {
299            return Ok(outcome);
300        }
301
302        // Step 6: Create new entity (no embedding store, or embedding failure).
303        let entity_id = self
304            .store
305            .upsert_entity(&surface_name, &normalized, et, summary, provenance)
306            .await?;
307
308        self.register_aliases(entity_id.0, &normalized, name)
309            .await?;
310
311        Ok((entity_id.0, ResolutionOutcome::Created))
312    }
313
314    /// Compute embedding for an entity, incrementing `fallback_count` on failure/timeout.
315    /// Returns `None` when embedding is unavailable (caller should skip vector operations).
316    async fn embed_entity_text(
317        &self,
318        provider: &AnyProvider,
319        normalized: &str,
320        summary: Option<&str>,
321    ) -> Option<Vec<f32>> {
322        let safe_summary = truncate_to_bytes_ref(summary.unwrap_or(""), MAX_FACT_BYTES);
323        let embed_text = format!("{normalized}: {safe_summary}");
324        let embed_result = tokio::time::timeout(
325            std::time::Duration::from_secs(EMBED_TIMEOUT_SECS),
326            provider.embed(&embed_text),
327        )
328        .await;
329        match embed_result {
330            Ok(Ok(v)) => Some(v),
331            Ok(Err(err)) => {
332                self.fallback_count
333                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
334                tracing::warn!(entity_name = %normalized, error = %err,
335                    "embed() failed; falling back to exact-match-only entity creation");
336                None
337            }
338            Err(_timeout) => {
339                self.fallback_count
340                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
341                tracing::warn!(entity_name = %normalized,
342                    "embed() timed out after {}s; falling back to create new entity",
343                    EMBED_TIMEOUT_SECS);
344                None
345            }
346        }
347    }
348
349    /// Handle a candidate in the ambiguous score range by running LLM disambiguation.
350    /// Returns `Ok(Some(...))` if the LLM confirms a match, `Ok(None)` to fall through to create.
351    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
352    async fn handle_ambiguous_candidate(
353        &self,
354        emb_store: &EmbeddingStore,
355        provider: &AnyProvider,
356        payload: &std::collections::HashMap<String, serde_json::Value>,
357        point_id: &str,
358        score: f32,
359        surface_name: &str,
360        normalized: &str,
361        et: EntityType,
362        summary: Option<&str>,
363        _provenance: Option<&GraphProvenance>,
364    ) -> Result<Option<(i64, ResolutionOutcome)>, MemoryError> {
365        let payload_entity_id = payload
366            .get("entity_id")
367            .and_then(serde_json::Value::as_i64)
368            .ok_or_else(|| MemoryError::GraphStore("missing entity_id in payload".into()))?;
369        let existing_name = payload
370            .get("name")
371            .and_then(|v| v.as_str())
372            .unwrap_or("")
373            .to_owned();
374        let existing_summary = payload
375            .get("summary")
376            .and_then(|v| v.as_str())
377            .unwrap_or("")
378            .to_owned();
379        // Use the existing entity's actual type from the payload (IC-S3)
380        let existing_type = payload
381            .get("entity_type")
382            .and_then(|v| v.as_str())
383            .unwrap_or(et.as_str())
384            .to_owned();
385        let existing_canonical = payload.get("canonical_name").and_then(|v| v.as_str());
386        let existing_summary_str = payload.get("summary").and_then(|v| v.as_str());
387        match self
388            .llm_disambiguate(
389                provider,
390                normalized,
391                et.as_str(),
392                summary.unwrap_or(""),
393                &existing_name,
394                &existing_type,
395                &existing_summary,
396                score,
397            )
398            .await
399        {
400            Some(true) => {
401                let resolved_entity_id = self
402                    .merge_entity(
403                        emb_store,
404                        provider,
405                        payload_entity_id,
406                        surface_name,
407                        normalized,
408                        et,
409                        summary,
410                        existing_canonical,
411                        existing_summary_str,
412                        Some(point_id),
413                    )
414                    .await?;
415                Ok(Some((
416                    resolved_entity_id,
417                    ResolutionOutcome::LlmDisambiguated,
418                )))
419            }
420            Some(false) => Ok(None),
421            None => {
422                // `fallback_count` was already bumped inside `llm_disambiguate` itself
423                // (mirrors `embed_entity_text`'s self-contained counter increment).
424                tracing::warn!(entity_name = %normalized,
425                    "LLM disambiguation failed; falling back to create new entity");
426                Ok(None)
427            }
428        }
429    }
430
431    /// Attempt embedding-based resolution. Returns `Ok(Some(...))` if resolved (early return),
432    /// `Ok(None)` if no match found (caller should fall through to create), or `Err` on DB error.
433    #[allow(clippy::too_many_lines)]
434    async fn resolve_via_embedding(
435        &self,
436        normalized: &str,
437        original_name: &str,
438        surface_name: &str,
439        et: EntityType,
440        summary: Option<&str>,
441        provenance: Option<&GraphProvenance>,
442    ) -> Result<Option<(i64, ResolutionOutcome)>, MemoryError> {
443        let (Some(emb_store), Some(provider)) = (self.embedding_store, self.provider) else {
444            return Ok(None);
445        };
446
447        let Some(query_vec) = self.embed_entity_text(provider, normalized, summary).await else {
448            return Ok(None);
449        };
450
451        let type_filter = VectorFilter {
452            must: vec![FieldCondition {
453                field: "entity_type".into(),
454                value: FieldValue::Text(et.as_str().to_owned()),
455            }],
456            must_not: vec![],
457        };
458        let candidates = match emb_store
459            .search_collection(ENTITY_COLLECTION, &query_vec, 5, Some(type_filter))
460            .await
461        {
462            Ok(c) => c,
463            Err(err) => {
464                self.fallback_count
465                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
466                tracing::warn!(entity_name = %normalized, error = %err,
467                    "Qdrant search failed; falling back to create new entity");
468                return self
469                    .create_with_embedding(
470                        emb_store,
471                        surface_name,
472                        normalized,
473                        original_name,
474                        et,
475                        summary,
476                        &query_vec,
477                        provenance,
478                    )
479                    .await
480                    .map(Some);
481            }
482        };
483
484        if let Some(best) = candidates.first() {
485            let score = best.score;
486            if score >= self.similarity_threshold {
487                let payload_entity_id = best
488                    .payload
489                    .get("entity_id")
490                    .and_then(serde_json::Value::as_i64)
491                    .ok_or_else(|| {
492                        MemoryError::GraphStore("missing entity_id in payload".into())
493                    })?;
494                let existing_canonical =
495                    best.payload.get("canonical_name").and_then(|v| v.as_str());
496                let existing_summary = best.payload.get("summary").and_then(|v| v.as_str());
497                let existing_pid = Some(best.id.as_str());
498                let resolved_entity_id = self
499                    .merge_entity(
500                        emb_store,
501                        provider,
502                        payload_entity_id,
503                        surface_name,
504                        normalized,
505                        et,
506                        summary,
507                        existing_canonical,
508                        existing_summary,
509                        existing_pid,
510                    )
511                    .await?;
512                return Ok(Some((
513                    resolved_entity_id,
514                    ResolutionOutcome::EmbeddingMatch { score },
515                )));
516            } else if score >= self.ambiguous_threshold
517                && let Some(result) = self
518                    .handle_ambiguous_candidate(
519                        emb_store,
520                        provider,
521                        &best.payload,
522                        &best.id,
523                        score,
524                        surface_name,
525                        normalized,
526                        et,
527                        summary,
528                        provenance,
529                    )
530                    .await?
531            {
532                return Ok(Some(result));
533            }
534            // score < ambiguous_threshold or LLM said different: fall through to create with embedding
535        }
536
537        // No suitable match — create new entity and store embedding.
538        self.create_with_embedding(
539            emb_store,
540            surface_name,
541            normalized,
542            original_name,
543            et,
544            summary,
545            &query_vec,
546            provenance,
547        )
548        .await
549        .map(Some)
550    }
551
552    /// Create a new entity, register aliases, and store its embedding in Qdrant.
553    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
554    async fn create_with_embedding(
555        &self,
556        emb_store: &EmbeddingStore,
557        surface_name: &str,
558        normalized: &str,
559        original_name: &str,
560        et: EntityType,
561        summary: Option<&str>,
562        query_vec: &[f32],
563        provenance: Option<&GraphProvenance>,
564    ) -> Result<(i64, ResolutionOutcome), MemoryError> {
565        let entity_id = self
566            .store
567            .upsert_entity(surface_name, normalized, et, summary, provenance)
568            .await?;
569        self.register_aliases(entity_id.0, normalized, original_name)
570            .await?;
571        self.store_entity_embedding(
572            emb_store,
573            entity_id.0,
574            None,
575            normalized,
576            et,
577            summary.unwrap_or(""),
578            query_vec,
579        )
580        .await;
581        Ok((entity_id.0, ResolutionOutcome::Created))
582    }
583
584    /// Register the normalized form and original trimmed form as aliases for an entity.
585    async fn register_aliases(
586        &self,
587        entity_id: i64,
588        normalized: &str,
589        original_name: &str,
590    ) -> Result<(), MemoryError> {
591        self.store.add_alias(entity_id, normalized).await?;
592
593        // Also register the original trimmed lowercased form if it differs from normalized
594        // (e.g. when control chars were stripped, leaving a shorter string).
595        let original_trimmed = original_name.trim().to_lowercase();
596        let original_clean_str = strip_control_chars(&original_trimmed);
597        let original_clean = truncate_to_bytes_ref(&original_clean_str, MAX_ENTITY_NAME_BYTES);
598        if original_clean != normalized {
599            self.store.add_alias(entity_id, original_clean).await?;
600        }
601
602        Ok(())
603    }
604
605    /// Merge an existing entity with new information: combine summaries, update Qdrant.
606    ///
607    /// `existing_canonical_name` and `existing_summary` are read from the Qdrant payload at
608    /// the call site (hot path, no `SQLite` roundtrip). Pass `None` for either when the point
609    /// predates the payload fields — a targeted `find_entity_by_id` read is then used as a
610    /// one-time fallback (legacy transition path, removed after all points are rewritten).
611    ///
612    /// `payload_entity_id` is the `entity_id` read off the Qdrant point payload — it may
613    /// reference a row in a *different* `SQLite` instance than the one this resolver is bound
614    /// to (e.g. `SQLite` was reset/restored independently of a shared Qdrant collection).
615    /// The returned `i64` is always the id of the row actually written by this call's
616    /// `upsert_entity` (keyed on `canonical_name` + `entity_type`, not on `payload_entity_id`),
617    /// so it is guaranteed to exist in the local `SQLite` database — callers MUST use the
618    /// returned id (not `payload_entity_id`) as the FK target for edge inserts (#5801).
619    #[allow(clippy::too_many_arguments)]
620    // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
621    #[allow(clippy::too_many_lines)] // cross-DB id correction (#5801) added a diagnostic branch
622    async fn merge_entity(
623        &self,
624        emb_store: &EmbeddingStore,
625        provider: &AnyProvider,
626        payload_entity_id: i64,
627        new_surface_name: &str,
628        new_canonical_name: &str,
629        entity_type: EntityType,
630        new_summary: Option<&str>,
631        existing_canonical_name: Option<&str>,
632        existing_summary_payload: Option<&str>,
633        existing_point_id: Option<&str>,
634    ) -> Result<i64, MemoryError> {
635        // Hot path: use values from the Qdrant payload (no SQLite roundtrip).
636        // Both canonical_name AND summary must be present; if either is absent the point
637        // predates the payload field — fall back to a targeted SQLite read to avoid
638        // silently dropping a summary that was written outside merge_entity.
639        let (existing_canonical, existing_summary, existing_point_id_owned) =
640            if existing_canonical_name.is_some() && existing_summary_payload.is_some() {
641                (
642                    existing_canonical_name
643                        .unwrap_or(new_canonical_name)
644                        .to_owned(),
645                    existing_summary_payload.unwrap_or("").to_owned(),
646                    existing_point_id.map(ToOwned::to_owned),
647                )
648            } else {
649                // Transition-period fallback. Legacy Qdrant points pre-date the payload
650                // fields; one targeted read is acceptable until the embedding is rewritten
651                // on the next merge. Also used when canonical_name is present but summary
652                // is absent — prevents overwriting a SQLite summary with empty string.
653                let existing = self.store.find_entity_by_id(payload_entity_id).await?;
654                let canonical = existing_canonical_name.map_or_else(
655                    || {
656                        existing.as_ref().map_or_else(
657                            || new_canonical_name.to_owned(),
658                            |e| e.canonical_name.clone(),
659                        )
660                    },
661                    ToOwned::to_owned,
662                );
663                let summary = existing
664                    .as_ref()
665                    .and_then(|e| e.summary.as_deref())
666                    .unwrap_or("")
667                    .to_owned();
668                let pid = existing_point_id.map(ToOwned::to_owned).or_else(|| {
669                    existing
670                        .as_ref()
671                        .and_then(|e| e.qdrant_point_id.as_deref())
672                        .map(ToOwned::to_owned)
673                });
674                (canonical, summary, pid)
675            };
676
677        let merged_summary = if let Some(new) = new_summary {
678            if !new.is_empty() && !existing_summary.is_empty() {
679                let combined = format!("{existing_summary}; {new}");
680                // TODO(S2): use LLM-based summary merge when summary exceeds 512 bytes
681                truncate_to_bytes_ref(&combined, MAX_FACT_BYTES).to_owned()
682            } else if !new.is_empty() {
683                new.to_owned()
684            } else {
685                existing_summary.clone()
686            }
687        } else {
688            existing_summary.clone()
689        };
690
691        let summary_opt = if merged_summary.is_empty() {
692            None
693        } else {
694            Some(merged_summary.as_str())
695        };
696
697        // Preserve the existing display name from the payload; fall back to the incoming surface
698        // name only for brand-new entities (where the payload had no "name" field).
699        //
700        // This upsert is keyed on (canonical_name, entity_type) and RETURNING id gives back
701        // the row actually present in the *local* SQLite database — authoritative regardless
702        // of whether `payload_entity_id` is stale (#5801).
703        let entity_id = self
704            .store
705            .upsert_entity(
706                new_surface_name,
707                &existing_canonical,
708                entity_type,
709                summary_opt,
710                None,
711            )
712            .await?
713            .0;
714
715        if entity_id != payload_entity_id {
716            // The Qdrant payload pointed at an id absent from (or divergent from) the local
717            // SQLite row for this canonical_name/entity_type — most commonly because SQLite
718            // was reset/restored/migrated independently of a shared Qdrant collection. Using
719            // `payload_entity_id` as an edge FK target here would fail with a foreign-key
720            // violation; the corrected local id is used and re-stamped into Qdrant below.
721            tracing::warn!(
722                payload_entity_id,
723                resolved_entity_id = entity_id,
724                canonical_name = %existing_canonical,
725                "graph: Qdrant entity_id payload did not match local SQLite row \
726                 (cross-DB or stale resolution) — corrected to the locally authoritative id"
727            );
728        }
729
730        // Re-embed merged text and upsert to Qdrant
731        let embed_text = format!("{new_surface_name}: {merged_summary}");
732        let embed_result = tokio::time::timeout(
733            std::time::Duration::from_secs(EMBED_TIMEOUT_SECS),
734            provider.embed(&embed_text),
735        )
736        .await;
737
738        match embed_result {
739            Ok(Ok(vec)) => {
740                self.store_entity_embedding(
741                    emb_store,
742                    entity_id,
743                    existing_point_id_owned.as_deref(),
744                    new_surface_name,
745                    entity_type,
746                    &merged_summary,
747                    &vec,
748                )
749                .await;
750            }
751            Ok(Err(err)) => {
752                tracing::warn!(
753                    entity_id,
754                    error = %err,
755                    "merge re-embed failed; Qdrant entry may be stale"
756                );
757            }
758            Err(_) => {
759                tracing::warn!(
760                    entity_id,
761                    "merge re-embed timed out; Qdrant entry may be stale"
762                );
763            }
764        }
765
766        Ok(entity_id)
767    }
768
769    /// Store an entity embedding in Qdrant and update `qdrant_point_id` in `SQLite`.
770    ///
771    /// When `existing_point_id` is `Some`, the existing Qdrant point is updated in-place
772    /// (upsert by ID) to avoid orphaned stale points. When `None`, a new point is created.
773    ///
774    /// Failures are logged at warn level but do not propagate — the entity is still
775    /// valid in `SQLite` even if Qdrant upsert fails.
776    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
777    async fn store_entity_embedding(
778        &self,
779        emb_store: &EmbeddingStore,
780        entity_id: i64,
781        existing_point_id: Option<&str>,
782        name: &str,
783        entity_type: EntityType,
784        summary: &str,
785        vector: &[f32],
786    ) {
787        // Ensure the Qdrant collection exists exactly once per resolver lifetime.
788        // All entity embeddings use the same model dimension, so the first call's
789        // vector dimension wins — subsequent entities share the same collection.
790        // On error the cell stays unset, so the next entity retries — transient
791        // network failures do not permanently disable embedding storage.
792        let collection_ensured = Arc::clone(&self.collection_ensured);
793        if let Err(err) = collection_ensured
794            .get_or_try_init(|| async {
795                emb_store
796                    .ensure_named_collection_for_vector(ENTITY_COLLECTION, vector)
797                    .await
798            })
799            .await
800        {
801            tracing::error!(
802                error = %err,
803                "failed to ensure entity embedding collection; skipping Qdrant upsert"
804            );
805            return;
806        }
807
808        let payload = serde_json::json!({
809            "entity_id": entity_id,
810            // String mirror of entity_id for scroll_all enumeration (scroll_all only surfaces
811            // StringValue payload fields; the i64 entity_id is preserved for existing search
812            // consumers which read it directly from ScoredVectorPoint.payload).
813            "entity_id_str": entity_id.to_string(),
814            "canonical_name": name,
815            "name": name,
816            "entity_type": entity_type.as_str(),
817            "summary": summary,
818        });
819
820        if let Some(point_id) = existing_point_id {
821            // Reuse existing point to avoid orphaned stale points (IC-S1)
822            if let Err(err) = emb_store
823                .upsert_to_collection(ENTITY_COLLECTION, point_id, payload, vector.to_vec())
824                .await
825            {
826                tracing::warn!(
827                    entity_id,
828                    error = %err,
829                    "Qdrant upsert (existing point) failed; Qdrant entry may be stale"
830                );
831            }
832        } else {
833            match emb_store
834                .store_to_collection(ENTITY_COLLECTION, payload, vector.to_vec())
835                .await
836            {
837                Ok(point_id) => {
838                    if let Err(err) = self
839                        .store
840                        .set_entity_qdrant_point_id(entity_id, &point_id)
841                        .await
842                    {
843                        tracing::warn!(
844                            entity_id,
845                            error = %err,
846                            "failed to store qdrant_point_id in SQLite"
847                        );
848                    }
849                }
850                Err(err) => {
851                    tracing::warn!(
852                        entity_id,
853                        error = %err,
854                        "Qdrant upsert failed; entity created in SQLite, qdrant_point_id remains NULL"
855                    );
856                }
857            }
858        }
859    }
860
861    /// Ask the LLM whether two entities are the same.
862    ///
863    /// Returns `Some(true)` for merge, `Some(false)` for separate, `None` on failure.
864    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
865    async fn llm_disambiguate(
866        &self,
867        provider: &AnyProvider,
868        new_name: &str,
869        new_type: &str,
870        new_summary: &str,
871        existing_name: &str,
872        existing_type: &str,
873        existing_summary: &str,
874        score: f32,
875    ) -> Option<bool> {
876        let prompt = format!(
877            "New entity:\n\
878             - Name: <external-data>{new_name}</external-data>\n\
879             - Type: <external-data>{new_type}</external-data>\n\
880             - Summary: <external-data>{new_summary}</external-data>\n\
881             \n\
882             Existing entity:\n\
883             - Name: <external-data>{existing_name}</external-data>\n\
884             - Type: <external-data>{existing_type}</external-data>\n\
885             - Summary: <external-data>{existing_summary}</external-data>\n\
886             \n\
887             Cosine similarity: {score:.2}\n\
888             \n\
889             Are these the same entity? Respond with JSON: {{\"same_entity\": true}} or {{\"same_entity\": false}}"
890        );
891
892        let messages = [
893            Message::from_legacy(
894                Role::System,
895                "You are an entity disambiguation assistant. Given a new entity mention and \
896                 an existing entity from the knowledge graph, determine if they refer to the same \
897                 real-world entity. Respond only with JSON.",
898            ),
899            Message::from_legacy(Role::User, prompt),
900        ];
901
902        let response = match tokio::time::timeout(self.llm_timeout, provider.chat(&messages)).await
903        {
904            Ok(Ok(r)) => r,
905            Ok(Err(err)) => {
906                self.fallback_count
907                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
908                tracing::warn!(error = %err, "LLM disambiguation chat failed");
909                return None;
910            }
911            Err(_timeout) => {
912                self.fallback_count
913                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
914                tracing::warn!(
915                    timeout = ?self.llm_timeout,
916                    "LLM disambiguation chat timed out"
917                );
918                return None;
919            }
920        };
921
922        // Parse JSON response, tolerating markdown code fences
923        let json_str = extract_json(&response);
924        match serde_json::from_str::<DisambiguationResponse>(json_str) {
925            Ok(parsed) => Some(parsed.same_entity),
926            Err(err) => {
927                self.fallback_count
928                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
929                tracing::warn!(error = %err, response = %response, "failed to parse LLM disambiguation response");
930                None
931            }
932        }
933    }
934
935    /// Resolve a batch of extracted entities concurrently.
936    ///
937    /// Returns a `Vec` of `(entity_id, ResolutionOutcome)` in the same order as input.
938    ///
939    /// # Errors
940    ///
941    /// Returns an error if any DB operation fails.
942    ///
943    /// # Panics
944    ///
945    /// Panics if an internal stream collection bug causes a result index to be missing.
946    /// This indicates a programming error and should never occur in correct usage.
947    pub async fn resolve_batch(
948        &self,
949        entities: &[ExtractedEntity],
950    ) -> Result<Vec<(i64, ResolutionOutcome)>, MemoryError> {
951        if entities.is_empty() {
952            return Ok(Vec::new());
953        }
954
955        // Process up to 4 embed+resolve operations concurrently (IC-S2/PERF-01).
956        let mut results: Vec<Option<(i64, ResolutionOutcome)>> = vec![None; entities.len()];
957
958        let mut stream = stream::iter(entities.iter().enumerate().map(|(i, e)| {
959            let name = e.name.clone();
960            let entity_type = e.entity_type.clone();
961            let summary = e.summary.clone();
962            async move {
963                let result = self
964                    .resolve(&name, &entity_type, summary.as_deref(), None)
965                    .await;
966                (i, result)
967            }
968        }))
969        .buffer_unordered(4);
970
971        while let Some((i, result)) = stream.next().await {
972            match result {
973                Ok(outcome) => results[i] = Some(outcome),
974                Err(err) => return Err(err),
975            }
976        }
977
978        Ok(results
979            .into_iter()
980            .enumerate()
981            .map(|(i, r)| {
982                r.unwrap_or_else(|| {
983                    tracing::warn!(
984                        index = i,
985                        "resolve_batch: missing result at index — bug in stream collection"
986                    );
987                    panic!("resolve_batch: missing result at index {i}")
988                })
989            })
990            .collect())
991    }
992
993    /// Resolve an extracted edge: deduplicate or supersede existing edges.
994    ///
995    /// - If an active edge with the same direction and relation exists with an identical fact,
996    ///   returns `None` (deduplicated).
997    /// - If an active edge with the same direction and relation exists with a different fact,
998    ///   invalidates the old edge and inserts the new one, returning `Some(new_id)`.
999    /// - If no matching edge exists, inserts a new edge and returns `Some(new_id)`.
1000    ///
1001    /// Relation and fact strings are sanitized (control chars stripped, length-capped).
1002    ///
1003    /// # Errors
1004    ///
1005    /// Returns an error if any database operation fails.
1006    pub async fn resolve_edge(
1007        &self,
1008        source_id: i64,
1009        target_id: i64,
1010        relation: &str,
1011        fact: &str,
1012        confidence: f32,
1013        episode_id: Option<MessageId>,
1014    ) -> Result<Option<i64>, MemoryError> {
1015        let normalized_relation = sanitize_relation(relation);
1016        let normalized_fact = sanitize_fact(fact);
1017
1018        // Fetch only exact-direction edges — no reverse edges to filter out
1019        let existing_edges = self.store.edges_exact(source_id, target_id).await?;
1020
1021        let matching = existing_edges
1022            .iter()
1023            .find(|e| e.relation == normalized_relation);
1024
1025        if let Some(old) = matching {
1026            if old.fact == normalized_fact {
1027                // Exact duplicate — skip
1028                return Ok(None);
1029            }
1030            // Same relation, different fact — supersede
1031            self.store.invalidate_edge(old.id).await?;
1032        }
1033
1034        let new_id = self
1035            .store
1036            .insert_edge(
1037                source_id,
1038                target_id,
1039                &normalized_relation,
1040                &normalized_fact,
1041                confidence,
1042                episode_id,
1043                None,
1044            )
1045            .await?;
1046        Ok(Some(new_id))
1047    }
1048
1049    /// Resolve a typed edge: deduplicate or supersede existing edges of the same type.
1050    ///
1051    /// Identical to [`Self::resolve_edge`] but includes `edge_type` in the matching key.
1052    /// An active edge with the same `(source, target, relation, edge_type)` and identical
1053    /// fact returns `None`; same relation+type with different fact is superseded.
1054    ///
1055    /// When `belief_revision` is `Some`, uses semantic contradiction detection to find edges
1056    /// to supersede across the same relation domain. The new fact embedding is pre-computed
1057    /// here (one embed call) to avoid N+1 embedding calls.
1058    ///
1059    /// This ensures that different MAGMA edge types for the same entity pair are stored
1060    /// independently (critic mitigation: dedup key includes `edge_type`).
1061    ///
1062    /// `turn_index` (#5784) is forwarded to [`GraphStore::insert_edge_typed`] so the default
1063    /// (non-APEX-MEM) extraction path also records turn-level provenance, matching the
1064    /// APEX-MEM path's `insert_or_supersede_with_turn_index_and_metrics`.
1065    ///
1066    /// # Errors
1067    ///
1068    /// Returns an error if any database operation fails.
1069    #[allow(clippy::too_many_arguments)] // function with many required inputs; a *Params struct would be more verbose without simplifying the call site
1070    pub async fn resolve_edge_typed(
1071        &self,
1072        source_id: i64,
1073        target_id: i64,
1074        relation: &str,
1075        fact: &str,
1076        confidence: f32,
1077        episode_id: Option<crate::types::MessageId>,
1078        edge_type: crate::graph::EdgeType,
1079        belief_revision: Option<&crate::graph::BeliefRevisionConfig>,
1080        turn_index: Option<u32>,
1081        provenance: Option<&GraphProvenance>,
1082    ) -> Result<Option<i64>, MemoryError> {
1083        let normalized_relation = sanitize_relation(relation);
1084        let normalized_fact = sanitize_fact(fact);
1085
1086        let existing_edges = self.store.edges_exact(source_id, target_id).await?;
1087
1088        // Exact dedup: same (relation, edge_type, fact) → skip.
1089        let matching = existing_edges
1090            .iter()
1091            .find(|e| e.relation == normalized_relation && e.edge_type == edge_type);
1092
1093        if matching.is_some_and(|old| old.fact == normalized_fact) {
1094            return Ok(None);
1095        }
1096
1097        // Determine which edges to supersede.
1098        let superseded_ids: Vec<i64> = if let (Some(cfg), Some(provider)) =
1099            (belief_revision, self.provider)
1100        {
1101            // Kumiho belief revision: embed new fact once, find semantically contradicted edges.
1102            match tokio::time::timeout(self.embed_timeout, provider.embed(&normalized_fact)).await {
1103                Ok(Ok(new_emb)) => {
1104                    match crate::graph::belief_revision::find_superseded_edges(
1105                        &existing_edges,
1106                        &new_emb,
1107                        &normalized_relation,
1108                        edge_type,
1109                        provider,
1110                        cfg,
1111                        self.embed_timeout,
1112                    )
1113                    .await
1114                    {
1115                        Ok(ids) => ids,
1116                        Err(err) => {
1117                            tracing::warn!(error = %err,
1118                                    "belief_revision: find_superseded_edges failed, falling back to exact match");
1119                            matching.map(|e| vec![e.id]).unwrap_or_default()
1120                        }
1121                    }
1122                }
1123                Ok(Err(err)) => {
1124                    tracing::warn!(error = %err,
1125                            "belief_revision: embed new fact failed, falling back to exact match");
1126                    matching.map(|e| vec![e.id]).unwrap_or_default()
1127                }
1128                Err(_) => {
1129                    tracing::warn!(
1130                        "belief_revision: embed new fact timed out, falling back to exact match"
1131                    );
1132                    matching.map(|e| vec![e.id]).unwrap_or_default()
1133                }
1134            }
1135        } else {
1136            // Legacy: exact (relation, edge_type) match with different fact.
1137            matching.map(|e| vec![e.id]).unwrap_or_default()
1138        };
1139
1140        let new_id = self
1141            .store
1142            .insert_edge_typed(
1143                source_id,
1144                target_id,
1145                &normalized_relation,
1146                &normalized_fact,
1147                confidence,
1148                episode_id,
1149                edge_type,
1150                turn_index,
1151                provenance,
1152            )
1153            .await?;
1154
1155        // Supersede old edges with audit trail (belief revision) or plain invalidation (legacy).
1156        for old_id in superseded_ids {
1157            if belief_revision.is_some() {
1158                self.store
1159                    .invalidate_edge_with_supersession(old_id, new_id)
1160                    .await?;
1161            } else {
1162                self.store.invalidate_edge(old_id).await?;
1163            }
1164        }
1165
1166        Ok(Some(new_id))
1167    }
1168}
1169
1170/// Extract a JSON object from a string that may contain markdown code fences.
1171fn extract_json(s: &str) -> &str {
1172    let trimmed = s.trim();
1173    // Strip ```json ... ``` or ``` ... ```
1174    if let Some(inner) = trimmed.strip_prefix("```json")
1175        && let Some(end) = inner.rfind("```")
1176    {
1177        return inner[..end].trim();
1178    }
1179    if let Some(inner) = trimmed.strip_prefix("```")
1180        && let Some(end) = inner.rfind("```")
1181    {
1182        return inner[..end].trim();
1183    }
1184    // Find first '{' to last '}'
1185    if let (Some(start), Some(end)) = (trimmed.find('{'), trimmed.rfind('}'))
1186        && start <= end
1187    {
1188        return &trimmed[start..=end];
1189    }
1190    trimmed
1191}
1192
1193#[cfg(test)]
1194mod tests;