Skip to main content

recall_echo/graph/
correct.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//! Human correction — telling memory that something it learned is wrong.
6//!
7//! # Why this is evidence, not a delete
8//!
9//! Everything else in the graph moves confidence by *observation*: an edge is
10//! corroborated or contradicted, the Beta counts move, the posterior mean
11//! follows. A human saying "that's wrong" is the highest-authority observation
12//! the system can receive, so it enters the same way — as contradicting
13//! evidence at [`Provenance::User`] weight — rather than as a silent write of
14//! a lower number or a quiet delete.
15//!
16//! That buys three things a delete cannot. The correction accumulates (saying
17//! it twice is twice the evidence). It stays visible (`graph decay-report`
18//! shows the counts that moved). And it degrades gracefully: an edge that was
19//! believed for good reason survives one contradiction with reduced
20//! confidence, which is the correct posture when a human and thirty
21//! observations disagree.
22//!
23//! # What `--wrong` on an *entity* means
24//!
25//! An entity has no truth value. "Rust" is not true or false; what can be
26//! wrong is a claim *about* Rust, and claims live on edges. So contradicting
27//! an entity means contradicting the claims it takes part in.
28//!
29//! Which claims is exactly the ambiguity, so this module refuses to guess: one
30//! live edge is unambiguous and is contradicted, several are reported back for
31//! the human to choose between, and contradicting all of them is available but
32//! must be asked for. Nothing is damaged on a guess.
33//!
34//! # Removal
35//!
36//! [`Correction::Forget`] is the escape hatch for memory that should not exist
37//! at all rather than be believed less. It is destructive, so the plan and the
38//! act are two separate requests: an unconfirmed forget only reports what would
39//! go, and nothing is removed until a caller sends `confirmed`.
40
41use serde::{Deserialize, Serialize};
42use surrealdb::Surreal;
43
44use super::confidence::{Provenance, ProvenanceWeights};
45use super::edge_view::{self, EdgeView, NameCache};
46use super::error::GraphError;
47use super::store::Db;
48use super::types::Relationship;
49
50/// Near-misses shown when a name does not resolve.
51const MAX_CANDIDATES: usize = 5;
52
53/// Below this similarity a name is not a near-miss, it is a different name.
54const CANDIDATE_FLOOR: f64 = 0.34;
55
56/// What a correction is aimed at.
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(tag = "kind", rename_all = "snake_case")]
59pub enum CorrectTarget {
60    /// Everything the graph claims about one entity.
61    Entity { name: String },
62    /// One specific claim.
63    Edge {
64        from: String,
65        rel_type: String,
66        to: String,
67    },
68}
69
70/// What to do to the target.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(tag = "kind", rename_all = "snake_case")]
73pub enum Correction {
74    /// Record contradicting evidence at user authority.
75    Wrong {
76        /// Contradict every live edge of an entity instead of refusing to
77        /// choose between them.
78        #[serde(default)]
79        all_edges: bool,
80    },
81    /// Remove outright. Nothing is deleted unless `confirmed`.
82    Forget {
83        #[serde(default)]
84        confirmed: bool,
85    },
86}
87
88/// An entity, named well enough for a person to recognise it.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct EntityMatch {
91    pub id: String,
92    pub name: String,
93    pub entity_type: String,
94}
95
96/// One edge after a contradiction, next to where it stood before.
97#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
98pub struct EdgeCorrection {
99    /// The edge as it now stands.
100    pub edge: EdgeView,
101    /// Posterior mean before the contradiction was recorded.
102    pub confidence_before: f64,
103    /// Evidence weight before the contradiction was recorded.
104    pub evidence_before: f64,
105}
106
107/// What a forget would take, or did.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct Removal {
110    /// The entity going away. Absent when only an edge was targeted.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub entity: Option<EntityMatch>,
113    /// Every relationship going away with it, superseded ones included.
114    pub edges: Vec<EdgeView>,
115}
116
117/// The result of one correction request.
118///
119/// Every outcome that changed nothing says why, and says it with enough detail
120/// for the caller to make a better request — because the alternative to
121/// refusing is damaging the wrong memory.
122#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123#[serde(tag = "outcome", rename_all = "snake_case")]
124pub enum CorrectionReport {
125    /// The name matched no entity. `candidates` are the closest stored names.
126    UnknownEntity {
127        query: String,
128        candidates: Vec<EntityMatch>,
129    },
130    /// Both entities exist but no live edge of that type joins them.
131    /// `existing` is what does join them.
132    NoSuchEdge {
133        from: String,
134        to: String,
135        rel_type: String,
136        existing: Vec<EdgeView>,
137    },
138    /// The entity takes part in several claims; which one is wrong is the
139    /// caller's to say.
140    Ambiguous {
141        entity: String,
142        edges: Vec<EdgeView>,
143    },
144    /// The entity exists and is not connected to anything, so there is no
145    /// claim to contradict.
146    NothingToCorrect { entity: String },
147    /// Contradicting evidence was recorded.
148    Contradicted { edges: Vec<EdgeCorrection> },
149    /// What an unconfirmed forget would remove. Nothing was removed.
150    Planned { removal: Removal },
151    /// What a confirmed forget removed.
152    Removed { removal: Removal },
153}
154
155impl CorrectionReport {
156    /// Whether the store was changed.
157    #[must_use]
158    pub fn applied(&self) -> bool {
159        matches!(
160            self,
161            Self::Contradicted { .. } | Self::Removed { .. } | Self::Planned { .. }
162        )
163    }
164}
165
166/// Apply a correction, or report why it was refused.
167pub async fn correct(
168    db: &Surreal<Db>,
169    weights: &ProvenanceWeights,
170    target: &CorrectTarget,
171    correction: Correction,
172) -> Result<CorrectionReport, GraphError> {
173    match target {
174        CorrectTarget::Entity { name } => correct_entity(db, weights, name, correction).await,
175        CorrectTarget::Edge { from, rel_type, to } => {
176            correct_edge(db, weights, from, rel_type, to, correction).await
177        }
178    }
179}
180
181// ── Entity target ────────────────────────────────────────────────────────
182
183async fn correct_entity(
184    db: &Surreal<Db>,
185    weights: &ProvenanceWeights,
186    name: &str,
187    correction: Correction,
188) -> Result<CorrectionReport, GraphError> {
189    let entity = match resolve(db, name).await? {
190        Resolution::Found(entity) => entity,
191        Resolution::Unresolved(report) => return Ok(report),
192    };
193
194    match correction {
195        Correction::Wrong { all_edges } => contradict_entity(db, weights, &entity, all_edges).await,
196        Correction::Forget { confirmed } => forget_entity(db, &entity, confirmed).await,
197    }
198}
199
200/// Contradict what the graph claims about one entity.
201///
202/// One live edge is what the human meant. Several are not, and guessing which
203/// would damage a memory the human never mentioned — so the choice goes back
204/// to them, unless they asked for all of it.
205async fn contradict_entity(
206    db: &Surreal<Db>,
207    weights: &ProvenanceWeights,
208    entity: &EntityMatch,
209    all_edges: bool,
210) -> Result<CorrectionReport, GraphError> {
211    let edges = edge_view::live_edges_of(db, &entity.id).await?;
212
213    if edges.is_empty() {
214        return Ok(CorrectionReport::NothingToCorrect {
215            entity: entity.name.clone(),
216        });
217    }
218    if edges.len() > 1 && !all_edges {
219        let mut cache = NameCache::new();
220        return Ok(CorrectionReport::Ambiguous {
221            entity: entity.name.clone(),
222            edges: edge_view::views(db, &mut cache, &edges).await?,
223        });
224    }
225
226    contradict(db, weights, &edges).await
227}
228
229async fn forget_entity(
230    db: &Surreal<Db>,
231    entity: &EntityMatch,
232    confirmed: bool,
233) -> Result<CorrectionReport, GraphError> {
234    let edges = edge_view::all_edges_of(db, &entity.id).await?;
235    let mut cache = NameCache::new();
236    let removal = Removal {
237        entity: Some(entity.clone()),
238        edges: edge_view::views(db, &mut cache, &edges).await?,
239    };
240
241    if !confirmed {
242        return Ok(CorrectionReport::Planned { removal });
243    }
244    super::crud::delete_entity(db, &entity.id).await?;
245    Ok(CorrectionReport::Removed { removal })
246}
247
248// ── Edge target ──────────────────────────────────────────────────────────
249
250async fn correct_edge(
251    db: &Surreal<Db>,
252    weights: &ProvenanceWeights,
253    from: &str,
254    rel_type: &str,
255    to: &str,
256    correction: Correction,
257) -> Result<CorrectionReport, GraphError> {
258    let source = match resolve(db, from).await? {
259        Resolution::Found(entity) => entity,
260        Resolution::Unresolved(report) => return Ok(report),
261    };
262    let target = match resolve(db, to).await? {
263        Resolution::Found(entity) => entity,
264        Resolution::Unresolved(report) => return Ok(report),
265    };
266
267    let between = edge_view::live_edges_between(db, &source.id, &target.id).await?;
268    let matched: Vec<Relationship> = between
269        .iter()
270        .filter(|edge| edge.rel_type.eq_ignore_ascii_case(rel_type))
271        .cloned()
272        .collect();
273
274    if matched.is_empty() {
275        let mut cache = NameCache::new();
276        return Ok(CorrectionReport::NoSuchEdge {
277            from: source.name,
278            to: target.name,
279            rel_type: rel_type.to_string(),
280            existing: edge_view::views(db, &mut cache, &between).await?,
281        });
282    }
283
284    match correction {
285        Correction::Wrong { .. } => contradict(db, weights, &matched).await,
286        Correction::Forget { confirmed } => forget_edges(db, &matched, confirmed).await,
287    }
288}
289
290async fn forget_edges(
291    db: &Surreal<Db>,
292    edges: &[Relationship],
293    confirmed: bool,
294) -> Result<CorrectionReport, GraphError> {
295    let mut cache = NameCache::new();
296    let removal = Removal {
297        entity: None,
298        edges: edge_view::views(db, &mut cache, edges).await?,
299    };
300
301    if !confirmed {
302        return Ok(CorrectionReport::Planned { removal });
303    }
304    for edge in edges {
305        super::crud::delete_relationship(db, &edge.id_string()).await?;
306    }
307    Ok(CorrectionReport::Removed { removal })
308}
309
310// ── Contradiction ────────────────────────────────────────────────────────
311
312/// Record one contradicting observation, at user authority, on every edge.
313async fn contradict(
314    db: &Surreal<Db>,
315    weights: &ProvenanceWeights,
316    edges: &[Relationship],
317) -> Result<CorrectionReport, GraphError> {
318    let mut cache = NameCache::new();
319    let mut corrections = Vec::with_capacity(edges.len());
320
321    for edge in edges {
322        let mut evidence = edge.edge_evidence();
323        evidence.contradict(Provenance::User, weights);
324        super::crud::contradict_relationship(db, &edge.id_string(), evidence).await?;
325
326        let counts = evidence.evidence();
327        let from = cache
328            .name_of(db, &edge_view::record_id(&edge.from_id))
329            .await?;
330        let to = cache
331            .name_of(db, &edge_view::record_id(&edge.to_id))
332            .await?;
333        let mut view = EdgeView::of(edge, from, to);
334        let confidence_before = view.confidence;
335        let evidence_before = view.evidence;
336        view.confidence = counts.mean();
337        view.evidence = counts.concentration();
338
339        corrections.push(EdgeCorrection {
340            edge: view,
341            confidence_before,
342            evidence_before,
343        });
344    }
345
346    Ok(CorrectionReport::Contradicted { edges: corrections })
347}
348
349// ── Name resolution ──────────────────────────────────────────────────────
350
351/// Either the one entity a name means, or the report saying why it means no
352/// single entity.
353enum Resolution {
354    Found(EntityMatch),
355    Unresolved(CorrectionReport),
356}
357
358/// Resolve a typed name to exactly one stored entity.
359///
360/// Only an exact name — or a unique case-insensitive one — resolves. Anything
361/// else comes back as candidates for the human to choose from: a fuzzy match
362/// picked by the machine is a correction applied to a memory nobody named.
363async fn resolve(db: &Surreal<Db>, name: &str) -> Result<Resolution, GraphError> {
364    let index = entity_index(db).await?;
365    let query = name.trim();
366
367    if let Some(entity) = index.iter().find(|entity| entity.name == query) {
368        return Ok(Resolution::Found(entity.clone()));
369    }
370
371    let folded: Vec<&EntityMatch> = index
372        .iter()
373        .filter(|entity| entity.name.eq_ignore_ascii_case(query))
374        .collect();
375    if folded.len() == 1 {
376        return Ok(Resolution::Found(folded[0].clone()));
377    }
378    if folded.len() > 1 {
379        return Ok(Resolution::Unresolved(CorrectionReport::UnknownEntity {
380            query: query.to_string(),
381            candidates: folded.into_iter().cloned().collect(),
382        }));
383    }
384
385    Ok(Resolution::Unresolved(CorrectionReport::UnknownEntity {
386        query: query.to_string(),
387        candidates: nearest(query, &index),
388    }))
389}
390
391/// Every entity's identity, without the 384 floats attached to it.
392async fn entity_index(db: &Surreal<Db>) -> Result<Vec<EntityMatch>, GraphError> {
393    #[derive(serde::Deserialize)]
394    struct Row {
395        id: serde_json::Value,
396        name: String,
397        entity_type: String,
398    }
399
400    let mut response = db
401        .query("SELECT id, name, entity_type FROM entity ORDER BY name")
402        .await?;
403    let rows: Vec<Row> = super::deserialize_take(&mut response, 0)?;
404    Ok(rows
405        .into_iter()
406        .map(|row| EntityMatch {
407            id: edge_view::record_id(&row.id),
408            name: row.name,
409            entity_type: row.entity_type,
410        })
411        .collect())
412}
413
414/// The stored names closest to what was typed, best first.
415fn nearest(query: &str, index: &[EntityMatch]) -> Vec<EntityMatch> {
416    let query = query.to_lowercase();
417    let mut scored: Vec<(f64, &EntityMatch)> = index
418        .iter()
419        .map(|entity| (similarity(&query, &entity.name.to_lowercase()), entity))
420        .filter(|(score, _)| *score >= CANDIDATE_FLOOR)
421        .collect();
422
423    scored.sort_by(|left, right| {
424        right
425            .0
426            .partial_cmp(&left.0)
427            .unwrap_or(std::cmp::Ordering::Equal)
428            .then_with(|| left.1.name.cmp(&right.1.name))
429    });
430    scored
431        .into_iter()
432        .take(MAX_CANDIDATES)
433        .map(|(_, entity)| entity.clone())
434        .collect()
435}
436
437/// How alike two lowercased names are, in `[0, 1]`.
438///
439/// Sørensen–Dice over character bigrams, which catches typos and word-order
440/// changes without a dependency. Containment scores high on its own: "recall"
441/// should surface "recall-echo" however few bigrams they share.
442fn similarity(query: &str, name: &str) -> f64 {
443    if query.is_empty() || name.is_empty() {
444        return 0.0;
445    }
446    if query == name {
447        return 1.0;
448    }
449
450    let dice = dice_coefficient(query, name);
451    if name.contains(query) || query.contains(name) {
452        return dice.max(0.9);
453    }
454    dice
455}
456
457fn dice_coefficient(left: &str, right: &str) -> f64 {
458    let left: Vec<[char; 2]> = bigrams(left);
459    let right: Vec<[char; 2]> = bigrams(right);
460    if left.is_empty() || right.is_empty() {
461        return 0.0;
462    }
463
464    let mut remaining = right.clone();
465    let mut shared = 0usize;
466    for bigram in &left {
467        if let Some(position) = remaining.iter().position(|other| other == bigram) {
468            remaining.swap_remove(position);
469            shared += 1;
470        }
471    }
472    (2.0 * shared as f64) / (left.len() + right.len()) as f64
473}
474
475fn bigrams(text: &str) -> Vec<[char; 2]> {
476    let chars: Vec<char> = text.chars().collect();
477    chars.windows(2).map(|pair| [pair[0], pair[1]]).collect()
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    fn index() -> Vec<EntityMatch> {
485        ["recall-echo", "pulse-null", "Rust", "rust", "Cargo"]
486            .into_iter()
487            .map(|name| EntityMatch {
488                id: format!("entity:{}", name.to_lowercase()),
489                name: name.to_string(),
490                entity_type: "tool".to_string(),
491            })
492            .collect()
493    }
494
495    #[test]
496    fn a_name_is_most_like_itself() {
497        assert_eq!(similarity("rust", "rust"), 1.0);
498        assert!(similarity("rust", "cargo") < CANDIDATE_FLOOR);
499    }
500
501    #[test]
502    fn a_typo_stays_a_near_miss() {
503        assert!(
504            similarity("recall-eco", "recall-echo") > CANDIDATE_FLOOR,
505            "{}",
506            similarity("recall-eco", "recall-echo")
507        );
508    }
509
510    #[test]
511    fn a_prefix_surfaces_the_whole_name() {
512        assert!(similarity("recall", "recall-echo") >= 0.9);
513    }
514
515    #[test]
516    fn candidates_are_the_closest_names_and_no_others() {
517        let candidates = nearest("recall", &index());
518        assert_eq!(
519            candidates.first().map(|entity| entity.name.as_str()),
520            Some("recall-echo")
521        );
522        assert!(
523            candidates.len() <= MAX_CANDIDATES,
524            "a wall of names is not a choice"
525        );
526        assert!(
527            !candidates.iter().any(|entity| entity.name == "Cargo"),
528            "unrelated names are not near-misses: {candidates:?}"
529        );
530    }
531
532    #[test]
533    fn nothing_is_close_to_nonsense() {
534        assert!(nearest("zzzzqqqq", &index()).is_empty());
535    }
536
537    #[test]
538    fn a_report_that_changed_nothing_says_so() {
539        assert!(!CorrectionReport::NothingToCorrect {
540            entity: "Rust".into()
541        }
542        .applied());
543        assert!(!CorrectionReport::UnknownEntity {
544            query: "Rust".into(),
545            candidates: Vec::new(),
546        }
547        .applied());
548        assert!(CorrectionReport::Contradicted { edges: Vec::new() }.applied());
549    }
550
551    #[test]
552    fn corrections_survive_the_daemon_wire_format() {
553        let target = CorrectTarget::Edge {
554            from: "D".into(),
555            rel_type: "USES".into(),
556            to: "Vim".into(),
557        };
558        let line = serde_json::to_string(&target).unwrap();
559        assert_eq!(
560            serde_json::from_str::<CorrectTarget>(&line).unwrap(),
561            target
562        );
563
564        // Optional modifiers may be omitted by an older client.
565        assert_eq!(
566            serde_json::from_str::<Correction>(r#"{"kind":"wrong"}"#).unwrap(),
567            Correction::Wrong { all_edges: false }
568        );
569        assert_eq!(
570            serde_json::from_str::<Correction>(r#"{"kind":"forget"}"#).unwrap(),
571            Correction::Forget { confirmed: false }
572        );
573    }
574}