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