Skip to main content

recall_echo/graph/
dedup.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Entity deduplication — skip, create, or merge decisions.
6//!
7//! Most candidates are decided locally: an existing entity with the same name
8//! and type is the same entity, a nearest neighbour far below the review
9//! threshold is a new one. Only the band in between is worth a model call, and
10//! only a bounded number of neighbours are ever shown to it — see
11//! [`crate::config::GraphDedupConfig`].
12
13use std::fmt::Write as _;
14
15use super::error::GraphError;
16use super::llm::LlmProvider;
17use super::types::*;
18use super::GraphMemory;
19use crate::config::DedupBand;
20
21const DEDUP_SYSTEM_PROMPT: &str = r#"You are a deduplication system for a knowledge graph. Given a candidate entity and existing similar entities, decide:
22
231. "skip" — The candidate is a duplicate. It adds no new information.
242. "create" — The candidate is genuinely new despite surface similarity.
253. "merge" — The candidate adds new information to an existing entity. Specify which one.
26
27Return EXACTLY this JSON (no markdown fencing, no explanation):
28
29{
30  "decision": "skip" | "create" | "merge",
31  "target": "Name of existing entity to merge into (only if merge)",
32  "reason": "Brief explanation"
33}
34
35Rules:
36- Same entity with minor name variations (e.g., "ElevenLabs" vs "Eleven Labs"): merge
37- Same concept but genuinely different instances: create
38- Candidate adds meaningful new detail to an existing entity: merge
39- Candidate is less detailed than existing: skip
40- When in doubt between create and merge: prefer create (avoid data loss)"#;
41
42/// Resolved entity after dedup — either newly created or existing (merged/skipped).
43pub enum ResolvedEntity {
44    Created(Entity),
45    Merged(Entity),
46    Skipped,
47}
48
49/// Which gate decided a candidate — the cost record of one resolution.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ResolutionPath {
52    /// An existing entity carried the same name and type.
53    NameMatch,
54    /// The nearest neighbour was similar enough to be the same entity.
55    SameEntityBand,
56    /// No neighbour was similar enough to be worth comparing.
57    NewEntityBand,
58    /// The ambiguous band — a model call decided it.
59    LlmDecision,
60}
61
62impl ResolutionPath {
63    /// Whether this resolution paid for a model call.
64    #[must_use]
65    pub fn used_llm(self) -> bool {
66        matches!(self, Self::LlmDecision)
67    }
68}
69
70/// What dedup did with a candidate, and what it cost.
71pub struct Resolution {
72    pub entity: ResolvedEntity,
73    pub path: ResolutionPath,
74}
75
76impl Resolution {
77    fn new(entity: ResolvedEntity, path: ResolutionPath) -> Self {
78        Self { entity, path }
79    }
80}
81
82/// Run the dedup pipeline for one extracted entity.
83///
84/// Three bands of raw cosine similarity, only one of which costs a model call:
85///
86/// 1. An existing entity of the same name and type — the same entity, resolved
87///    on an index lookup alone.
88/// 2. Nearest neighbour at or above `certain_similarity` — the same entity.
89/// 3. Nearest neighbour below `review_similarity` — a new entity, CREATEd.
90/// 4. Anything left is genuinely ambiguous: the model sees at most
91///    `max_candidates` neighbours and returns skip / create / merge.
92///
93/// Merging into an immutable type falls back to CREATE on every path.
94pub async fn resolve_entity(
95    gm: &GraphMemory,
96    llm: &dyn LlmProvider,
97    candidate: &ExtractedEntity,
98    session_id: &str,
99) -> Result<Resolution, GraphError> {
100    let config = gm.dedup_config();
101
102    if let Some(existing) = same_named_entity(gm, candidate).await? {
103        let resolved = absorb(gm, &existing, candidate, session_id).await?;
104        return Ok(Resolution::new(resolved, ResolutionPath::NameMatch));
105    }
106
107    // Ordered by distance ascending — nearest first. The limit is the cap:
108    // neither the prompt nor the comparison count can grow with the store.
109    let nearest = gm
110        .search(&candidate.abstract_text, config.candidate_limit())
111        .await?;
112
113    let Some(closest) = nearest.first() else {
114        return Ok(Resolution::new(
115            create(gm, candidate, session_id).await?,
116            ResolutionPath::NewEntityBand,
117        ));
118    };
119
120    match config.band(closest.similarity()) {
121        // Same meaning and same kind of thing: nothing for a model to weigh.
122        DedupBand::SameEntity if closest.entity.entity_type == candidate.entity_type => {
123            let resolved = absorb(gm, &closest.entity, candidate, session_id).await?;
124            Ok(Resolution::new(resolved, ResolutionPath::SameEntityBand))
125        }
126        DedupBand::NewEntity => Ok(Resolution::new(
127            create(gm, candidate, session_id).await?,
128            ResolutionPath::NewEntityBand,
129        )),
130        // Ambiguous, or near-identical text under a different type — the one
131        // case worth paying for.
132        _ => {
133            let resolved = resolve_with_llm(gm, llm, candidate, session_id, &nearest).await?;
134            Ok(Resolution::new(resolved, ResolutionPath::LlmDecision))
135        }
136    }
137}
138
139/// Ask the model to decide between the candidate and its comparable neighbours.
140async fn resolve_with_llm(
141    gm: &GraphMemory,
142    llm: &dyn LlmProvider,
143    candidate: &ExtractedEntity,
144    session_id: &str,
145    nearest: &[SearchResult],
146) -> Result<ResolvedEntity, GraphError> {
147    let config = gm.dedup_config();
148    let comparable: Vec<&SearchResult> = nearest
149        .iter()
150        .filter(|r| config.band(r.similarity()) != DedupBand::NewEntity)
151        .collect();
152
153    let user_message = build_dedup_message(candidate, &comparable);
154    let response = llm
155        .complete(DEDUP_SYSTEM_PROMPT, &user_message, 300)
156        .await?;
157
158    match parse_dedup_response(&response)? {
159        DedupDecision::Skip => Ok(ResolvedEntity::Skipped),
160
161        DedupDecision::Create => create(gm, candidate, session_id).await,
162
163        DedupDecision::Merge { target } => match gm.get_entity(&target).await? {
164            Some(target_entity) => absorb(gm, &target_entity, candidate, session_id).await,
165            // Hallucinated target — create rather than lose the candidate.
166            None => create(gm, candidate, session_id).await,
167        },
168    }
169}
170
171/// An existing entity with the same name and type as the candidate.
172///
173/// Exact match on the indexed `name` field: an index lookup, not a scan, so it
174/// stays flat as the store grows. Case and spacing variants ("ElevenLabs" /
175/// "Eleven Labs") are left to the similarity bands — SurrealDB has no
176/// case-insensitive index here, and normalising in the query would turn every
177/// dedup into a full table scan. A same-name entity of a *different* type is
178/// not the same thing (the event "Release" is not the project "Release"), so it
179/// falls through to the bands too.
180async fn same_named_entity(
181    gm: &GraphMemory,
182    candidate: &ExtractedEntity,
183) -> Result<Option<Entity>, GraphError> {
184    let Some(existing) = gm.get_entity(candidate.name.trim()).await? else {
185        return Ok(None);
186    };
187    Ok((existing.entity_type == candidate.entity_type).then_some(existing))
188}
189
190/// Fold a candidate into an entity already known to be the same thing.
191///
192/// Immutable types cannot absorb — a decision or an event records one moment —
193/// so the candidate becomes its own entity, exactly as a model-issued merge
194/// onto an immutable target already did. A candidate that carries nothing new
195/// is skipped rather than appended: re-reading the same archive must not grow
196/// the entity it describes.
197async fn absorb(
198    gm: &GraphMemory,
199    target: &Entity,
200    candidate: &ExtractedEntity,
201    session_id: &str,
202) -> Result<ResolvedEntity, GraphError> {
203    if !target.mutable {
204        return create(gm, candidate, session_id).await;
205    }
206    if !adds_information(target, candidate) {
207        return Ok(ResolvedEntity::Skipped);
208    }
209    Ok(ResolvedEntity::Merged(
210        merge_entity(gm, target, candidate).await?,
211    ))
212}
213
214/// Whether the candidate carries anything the target does not already hold —
215/// the deterministic half of the prompt's "candidate is less detailed: skip".
216fn adds_information(target: &Entity, candidate: &ExtractedEntity) -> bool {
217    let longer_abstract = candidate.abstract_text.len() > target.abstract_text.len();
218    let new_overview = candidate
219        .overview
220        .as_ref()
221        .is_some_and(|overview| !target.overview.contains(overview.as_str()));
222    let new_content = candidate.content.as_ref().is_some_and(|content| {
223        target
224            .content
225            .as_ref()
226            .is_none_or(|held| !held.contains(content.as_str()))
227    });
228    let new_attributes = candidate
229        .attributes
230        .as_ref()
231        .is_some_and(|attrs| target.attributes.as_ref() != Some(attrs));
232
233    longer_abstract || new_overview || new_content || new_attributes
234}
235
236async fn create(
237    gm: &GraphMemory,
238    candidate: &ExtractedEntity,
239    session_id: &str,
240) -> Result<ResolvedEntity, GraphError> {
241    let entity = gm.add_entity(candidate.to_new_entity(session_id)).await?;
242    Ok(ResolvedEntity::Created(entity))
243}
244
245/// Merge candidate data into an existing entity.
246///
247/// Rules:
248/// - Abstract: use longer/more detailed version
249/// - Overview: concatenate if both exist
250/// - Content: append candidate content
251/// - Attributes: deep-merge (candidate wins on conflict)
252async fn merge_entity(
253    gm: &GraphMemory,
254    target: &Entity,
255    candidate: &ExtractedEntity,
256) -> Result<Entity, GraphError> {
257    let new_abstract = if candidate.abstract_text.len() > target.abstract_text.len() {
258        Some(candidate.abstract_text.clone())
259    } else {
260        None
261    };
262
263    let new_overview = candidate.overview.as_ref().map(|co| {
264        if target.overview.is_empty() {
265            co.clone()
266        } else {
267            format!("{}\n\n{}", target.overview, co)
268        }
269    });
270
271    let new_content = candidate.content.as_ref().map(|cc| match &target.content {
272        Some(tc) => format!("{tc}\n\n{cc}"),
273        None => cc.clone(),
274    });
275
276    let new_attributes = candidate
277        .attributes
278        .as_ref()
279        .map(|ca| match &target.attributes {
280            Some(ta) => merge_json_objects(ta, ca),
281            None => ca.clone(),
282        });
283
284    let updates = EntityUpdate {
285        abstract_text: new_abstract,
286        overview: new_overview,
287        content: new_content,
288        attributes: new_attributes,
289    };
290
291    gm.update_entity(&target.id_string(), updates).await
292}
293
294fn build_dedup_message(candidate: &ExtractedEntity, similar: &[&SearchResult]) -> String {
295    let mut msg = format!(
296        "CANDIDATE:\n  Name: {}\n  Type: {}\n  Abstract: {}\n\nEXISTING SIMILAR ENTITIES:\n",
297        candidate.name, candidate.entity_type, candidate.abstract_text
298    );
299    for (i, r) in similar.iter().enumerate() {
300        // Similarity, not the blended retrieval score: how popular an entity is
301        // says nothing about whether it is this one.
302        let _ = write!(
303            msg,
304            "\n{}. Name: {} (similarity: {:.3})\n   Type: {}\n   Abstract: {}\n",
305            i + 1,
306            r.entity.name,
307            r.similarity(),
308            r.entity.entity_type,
309            r.entity.abstract_text
310        );
311    }
312    msg
313}
314
315/// Parse the LLM's dedup decision from JSON.
316pub fn parse_dedup_response(text: &str) -> Result<DedupDecision, GraphError> {
317    let cleaned = strip_markdown_fencing(text);
318
319    let v: serde_json::Value = serde_json::from_str(&cleaned).map_err(|e| {
320        // Try extracting JSON from surrounding text
321        if let Some(json_str) = extract_json_object(&cleaned) {
322            if let Ok(v) = serde_json::from_str::<serde_json::Value>(json_str) {
323                return parse_decision_value(&v)
324                    .err()
325                    .unwrap_or_else(|| GraphError::Parse(e.to_string()));
326            }
327        }
328        GraphError::Parse(format!("dedup response not valid JSON: {e}"))
329    })?;
330
331    parse_decision_value(&v)
332}
333
334fn parse_decision_value(v: &serde_json::Value) -> Result<DedupDecision, GraphError> {
335    let decision = v
336        .get("decision")
337        .and_then(|d| d.as_str())
338        .ok_or_else(|| GraphError::Parse("missing 'decision' field".into()))?;
339
340    match decision {
341        "skip" => Ok(DedupDecision::Skip),
342        "create" => Ok(DedupDecision::Create),
343        "merge" => {
344            let target = v
345                .get("target")
346                .and_then(|t| t.as_str())
347                .ok_or_else(|| GraphError::Parse("merge decision missing 'target' field".into()))?;
348            Ok(DedupDecision::Merge {
349                target: target.to_string(),
350            })
351        }
352        other => Err(GraphError::Parse(format!("unknown decision: {other}"))),
353    }
354}
355
356use super::util::{extract_json_object, merge_json_objects, strip_markdown_fencing};
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn parse_skip_decision() {
364        let json = r#"{"decision": "skip", "reason": "duplicate"}"#;
365        let decision = parse_dedup_response(json).unwrap();
366        assert_eq!(decision, DedupDecision::Skip);
367    }
368
369    #[test]
370    fn parse_create_decision() {
371        let json = r#"{"decision": "create", "reason": "genuinely new"}"#;
372        let decision = parse_dedup_response(json).unwrap();
373        assert_eq!(decision, DedupDecision::Create);
374    }
375
376    #[test]
377    fn parse_merge_decision() {
378        let json = r#"{"decision": "merge", "target": "Rust", "reason": "same entity"}"#;
379        let decision = parse_dedup_response(json).unwrap();
380        assert_eq!(
381            decision,
382            DedupDecision::Merge {
383                target: "Rust".into()
384            }
385        );
386    }
387
388    #[test]
389    fn parse_with_fencing() {
390        let json = "```json\n{\"decision\": \"skip\", \"reason\": \"dup\"}\n```";
391        let decision = parse_dedup_response(json).unwrap();
392        assert_eq!(decision, DedupDecision::Skip);
393    }
394
395    fn stored(abstract_text: &str, overview: &str, content: Option<&str>) -> Entity {
396        Entity {
397            id: serde_json::json!("entity:one"),
398            name: "Biscuit".into(),
399            entity_type: EntityType::Person,
400            abstract_text: abstract_text.into(),
401            overview: overview.into(),
402            content: content.map(String::from),
403            attributes: None,
404            embedding: None,
405            mutable: true,
406            access_count: 0,
407            utility_score: 0.5,
408            utility_updates: 0,
409            created_at: serde_json::json!("2026-01-01T00:00:00Z"),
410            updated_at: serde_json::json!("2026-01-01T00:00:00Z"),
411            source: None,
412        }
413    }
414
415    fn extracted(abstract_text: &str, overview: Option<&str>) -> ExtractedEntity {
416        ExtractedEntity {
417            name: "Biscuit".into(),
418            entity_type: EntityType::Person,
419            abstract_text: abstract_text.into(),
420            overview: overview.map(String::from),
421            content: None,
422            attributes: None,
423        }
424    }
425
426    /// Re-reading the same archive must not append the same text again.
427    #[test]
428    fn a_candidate_repeating_what_is_stored_adds_nothing() {
429        let target = stored("A golden retriever", "Adopted in May 2023", None);
430        let candidate = extracted("A golden retriever", Some("Adopted in May 2023"));
431        assert!(!adds_information(&target, &candidate));
432    }
433
434    #[test]
435    fn a_longer_abstract_is_information() {
436        let target = stored("A dog", "", None);
437        let candidate = extracted("A golden retriever named Biscuit", None);
438        assert!(adds_information(&target, &candidate));
439    }
440
441    #[test]
442    fn an_unseen_overview_is_information() {
443        let target = stored("A golden retriever", "Adopted in May 2023", None);
444        let candidate = extracted("A golden retriever", Some("Finished puppy class"));
445        assert!(adds_information(&target, &candidate));
446    }
447
448    #[test]
449    fn content_the_target_already_holds_is_not_information() {
450        let target = stored("A golden retriever", "", Some("Session 1\n\nSession 2"));
451        let mut candidate = extracted("A golden retriever", None);
452        candidate.content = Some("Session 2".into());
453        assert!(!adds_information(&target, &candidate));
454
455        candidate.content = Some("Session 3".into());
456        assert!(adds_information(&target, &candidate));
457    }
458
459    #[test]
460    fn only_the_llm_path_counts_as_a_model_call() {
461        assert!(ResolutionPath::LlmDecision.used_llm());
462        assert!(!ResolutionPath::NameMatch.used_llm());
463        assert!(!ResolutionPath::SameEntityBand.used_llm());
464        assert!(!ResolutionPath::NewEntityBand.used_llm());
465    }
466
467    /// The model must weigh meaning, not popularity: the prompt carries raw
468    /// similarity so a hot entity does not read as a likelier duplicate.
469    #[test]
470    fn the_dedup_prompt_shows_similarity_not_the_blended_score() {
471        let result = SearchResult {
472            entity: stored("A golden retriever", "", None),
473            score: 0.71,
474            distance: 0.2,
475        };
476        let msg = build_dedup_message(&extracted("A dog", None), &[&result]);
477        assert!(msg.contains("similarity: 0.800"), "{msg}");
478        assert!(!msg.contains("0.710"), "{msg}");
479    }
480
481    #[test]
482    fn merge_json_objects_test() {
483        let base = serde_json::json!({"a": 1, "b": 2});
484        let overlay = serde_json::json!({"b": 3, "c": 4});
485        let merged = merge_json_objects(&base, &overlay);
486        assert_eq!(merged, serde_json::json!({"a": 1, "b": 3, "c": 4}));
487    }
488}