Skip to main content

core_api/memory/
forget.rs

1//! `forget` — tombstone a node, remove one property, or retract one fact, and
2//! say what is left behind.
3//!
4//! The decision and the report live here so that every surface gives the
5//! same answer: the MCP `forget` tool renders [`ForgetReport`] as prose, and
6//! the Python binding returns it as a dict. Before 0.7's last plan this was
7//! private to the MCP server.
8//!
9//! A tombstone is not a redaction. History still holds what was forgotten
10//! until the log is pruned, and a note that stated a forgotten fact still
11//! says it: the report lists those notes and deletes none of them.
12
13use crate::digest::sanitize;
14use crate::memory::identity::{
15    identity_props_after_forgetting_name, ALIASES_FIELD, ALIAS_KEYS_FIELD,
16};
17use crate::memory_schema::NAME_FIELD;
18use crate::{GraphDb, PredicateSummary, RuleDef};
19use core_rules::engine::DEFAULT_MAX_EDGES;
20use core_rules::{evaluate, NodeView};
21use core_storage::fs::Fs;
22use core_storage::{namespace_of_value, Direction, GraphError, Result, NS_PROP};
23use std::collections::BTreeSet;
24
25/// Notes a report names when what it forgot was written about; the rest are
26/// counted in [`ForgetReport::notes_total`].
27pub const FORGET_NOTE_LIST: usize = 10;
28
29/// The refusal for a call that is not exactly one of the three shapes.
30pub const FORGET_SHAPE: &str = "pass exactly one of: key (forget a node), key and prop \
31     (forget one property), or fact {subject, predicate, object} (retract one edge)";
32
33/// What to forget: exactly one of a node, one property, or one fact.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum ForgetTarget {
36    /// Tombstone the node and every edge on it.
37    Node { key: String },
38    /// Remove one property from a node.
39    Prop { key: String, prop: String },
40    /// Retract one hand-written edge, `(predicate, subject, object)`.
41    Fact {
42        subject: String,
43        predicate: String,
44        object: String,
45    },
46}
47
48impl ForgetTarget {
49    /// The target three optional arguments name, or `None` when they are not
50    /// exactly one of the three shapes — the caller answers [`FORGET_SHAPE`].
51    #[must_use]
52    pub fn from_parts(
53        key: Option<String>,
54        prop: Option<String>,
55        fact: Option<(String, String, String)>,
56    ) -> Option<Self> {
57        match (key, prop, fact) {
58            (Some(key), None, None) => Some(Self::Node { key }),
59            (Some(key), Some(prop), None) => Some(Self::Prop { key, prop }),
60            (None, None, Some((subject, predicate, object))) => Some(Self::Fact {
61                subject,
62                predicate,
63                object,
64            }),
65            _ => None,
66        }
67    }
68}
69
70/// What one `forget` call did.
71#[derive(Debug, Clone, PartialEq, serde::Serialize)]
72pub struct ForgetReport {
73    /// `node`, `prop` or `fact`.
74    pub mode: &'static str,
75    /// The node, property or edge named, as the caller named it.
76    pub target: String,
77    /// False when there was nothing to forget, and nothing was written.
78    pub changed: bool,
79    /// Edges removed with a node: written by hand, and derived by rules. For a
80    /// property, `derived_edges` counts the rule-derived edges on the node that
81    /// the removal retracted, because a rule read that property.
82    pub manual_edges: u64,
83    pub derived_edges: u64,
84    /// The property removed, in `prop` mode.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub prop: Option<String>,
87    /// Notes with an `ABOUT` edge to the node, or to both ends of the fact.
88    /// Listed, never deleted: their text still says what was forgotten.
89    pub notes: Vec<String>,
90    pub notes_total: usize,
91    /// The first commit history still answers from.
92    pub history_floor: u64,
93    /// `prop` mode removed `name` from a node that carries `aliases`, and
94    /// rewrote that list in the same write: the name's words left it.
95    pub aliases_rewritten: bool,
96    /// `prop` mode removed `aliases` from a node that still carries
97    /// `alias_keys`, the aliases it declared, which still link stubs.
98    pub alias_keys_remain: bool,
99}
100
101/// Every field a predicate reads, its parts' included, sorted and deduped.
102#[must_use]
103pub fn predicate_fields(p: &PredicateSummary) -> Vec<String> {
104    let mut out: BTreeSet<String> = p.fields.iter().cloned().collect();
105    for part in p.parts.iter().flatten() {
106        out.extend(predicate_fields(part));
107    }
108    out.into_iter().collect()
109}
110
111/// Notes with an `ABOUT` edge to every one of `keys`, sorted.
112fn notes_about<F: Fs>(db: &GraphDb<F>, keys: &[&str]) -> Vec<String> {
113    let mut common: Option<BTreeSet<String>> = None;
114    for key in keys {
115        let into: BTreeSet<String> = db
116            .neighbors(key, "ABOUT", Direction::In)
117            .unwrap_or_default()
118            .into_iter()
119            .filter(|k| db.node_ref(k).is_some_and(|n| n.label() == "Note"))
120            .collect();
121        common = Some(match common {
122            None => into,
123            Some(c) => c.intersection(&into).cloned().collect(),
124        });
125    }
126    common.unwrap_or_default().into_iter().collect()
127}
128
129/// An edge as `(edge_type, src_key, dst_key)`.
130type EdgeTriple = (String, String, String);
131
132/// The rule-derived edges incident on `key`, both directions, each once.
133fn derived_edges_on<F: Fs>(db: &GraphDb<F>, key: &str) -> Result<BTreeSet<EdgeTriple>> {
134    Ok(db
135        .node_edges(key)?
136        .into_iter()
137        .filter(|e| e.derived)
138        .map(|e| (e.edge_type, e.src_key, e.dst_key))
139        .collect())
140}
141
142/// Forget `target`, in one call on the one handle, so nothing can change
143/// between what the report says and what was done.
144///
145/// # Errors
146/// - [`GraphError::KeyNotFound`] for a node or a fact endpoint that does not
147///   exist. Nothing is written.
148/// - [`GraphError::RuleOwned`] for a fact that is in the store and that the
149///   engine will not delete: a rule derived it, or it was written by hand
150///   and a rule matches the pair. `detail` is the whole refusal
151///   ([`rule_owned_refusal`]): which of the two, the rule and the fields it
152///   reads, or, when no rule can be named, the engine's own detail,
153///   sanitized. Nothing is written. A fact that is not in the store is never
154///   this error: it is `changed: false`, whatever a rule says of the pair.
155/// - Any other engine refusal, as it came.
156pub fn forget<F: Fs>(db: &mut GraphDb<F>, target: &ForgetTarget) -> Result<ForgetReport> {
157    match target {
158        ForgetTarget::Node { key } => {
159            if !db.has_node(key) {
160                return Err(GraphError::KeyNotFound { key: key.clone() });
161            }
162            let notes = notes_about(db, &[key.as_str()]);
163            let label = db
164                .node_ref(key)
165                .map(|n| n.label().to_string())
166                .unwrap_or_default();
167            let deleted = db.delete_node(key)?;
168            Ok(ForgetReport {
169                mode: "node",
170                target: format!("{key} ({label})"),
171                changed: true,
172                manual_edges: deleted.manual_edges,
173                derived_edges: deleted.derived_edges,
174                prop: None,
175                notes_total: notes.len(),
176                notes: notes.into_iter().take(FORGET_NOTE_LIST).collect(),
177                history_floor: db.stats().history_floor,
178                aliases_rewritten: false,
179                alias_keys_remain: false,
180            })
181        }
182        ForgetTarget::Prop { key, prop } => {
183            // Removing a property re-runs rule retraction, so a rule that read
184            // it drops the edges it derived. Compare the node's derived edges
185            // either side of the write to say how many went.
186            let before = derived_edges_on(db, key)?;
187            // A name's words live in `aliases`. When the name goes, the list is
188            // rewritten from the key alone in the same commit, so the words
189            // leave with it. A node with no `aliases` list, or one holding a
190            // value the store cannot read as a list, is left as it is.
191            let rewrite = if prop == NAME_FIELD && db.get_prop(key, prop).is_some() {
192                identity_props_after_forgetting_name(db, key).unwrap_or_default()
193            } else {
194                Vec::new()
195            };
196            let changed = if rewrite.is_empty() {
197                db.remove_prop(key, prop)?
198            } else {
199                let mut batch = db.batch();
200                batch.remove_prop(key, prop);
201                for (field, value) in &rewrite {
202                    batch.set_prop(key, field, value.clone());
203                }
204                batch.commit().map(|_| true)?
205            };
206            let aliases_rewritten = changed && !rewrite.is_empty();
207            let alias_keys_remain =
208                changed && prop == ALIASES_FIELD && db.get_prop(key, ALIAS_KEYS_FIELD).is_some();
209            let retracted = if changed {
210                let after = derived_edges_on(db, key).unwrap_or_default();
211                before.difference(&after).count() as u64
212            } else {
213                0
214            };
215            Ok(ForgetReport {
216                mode: "prop",
217                target: format!("{key}.{prop}"),
218                changed,
219                manual_edges: 0,
220                derived_edges: retracted,
221                prop: Some(prop.clone()),
222                notes: Vec::new(),
223                notes_total: 0,
224                history_floor: db.stats().history_floor,
225                aliases_rewritten,
226                alias_keys_remain,
227            })
228        }
229        ForgetTarget::Fact {
230            subject,
231            predicate,
232            object,
233        } => {
234            let changed = match db.delete_edge(predicate, subject, object) {
235                Ok(c) => c,
236                // The engine's guard refuses before it looks for the edge
237                // (ledger row 67), so a fact nobody stated can come back
238                // `RuleOwned`. An edge that is not there is nothing to
239                // retract, whatever a rule says about the pair.
240                Err(GraphError::RuleOwned { .. }) if !has_edge(db, predicate, subject, object) => {
241                    false
242                }
243                Err(GraphError::RuleOwned { detail }) => {
244                    return Err(GraphError::RuleOwned {
245                        detail: rule_owned_refusal(db, predicate, subject, object, &detail),
246                    })
247                }
248                Err(e) => return Err(e),
249            };
250            let notes = if changed {
251                notes_about(db, &[subject.as_str(), object.as_str()])
252            } else {
253                Vec::new()
254            };
255            Ok(ForgetReport {
256                mode: "fact",
257                target: format!("{predicate} {subject} → {object}"),
258                changed,
259                manual_edges: 0,
260                derived_edges: 0,
261                prop: None,
262                notes_total: notes.len(),
263                notes: notes.into_iter().take(FORGET_NOTE_LIST).collect(),
264                history_floor: db.stats().history_floor,
265                aliases_rewritten: false,
266                alias_keys_remain: false,
267            })
268        }
269    }
270}
271
272/// Whether the edge `(predicate, subject, object)` is in the store.
273fn has_edge<F: Fs>(db: &GraphDb<F>, predicate: &str, subject: &str, object: &str) -> bool {
274    db.neighbors(subject, predicate, Direction::Out)
275        .is_ok_and(|keys| keys.iter().any(|k| k == object))
276}
277
278/// Why a fact edge that exists cannot be retracted, in a sentence that is
279/// true of this edge.
280///
281/// The engine refuses the delete in two cases, and they are not the same
282/// fact:
283///
284/// - **A rule derived the edge.** It is in provenance, and the refusal names
285///   exactly the rules [`GraphDb::explain`] attributes it to.
286/// - **The edge was written by hand and a rule matches the pair.** It is in
287///   no provenance, so no rule is said to have derived it. The refusal names
288///   the rules the engine's guard refused for — `guard_matches` below, the
289///   same test — and not every rule that shares the edge type and the
290///   endpoint labels. It says those rules *would derive it again* only when
291///   every one of them would (`would_derive`); the guard asks less than
292///   that, so otherwise it says they match the two nodes' properties and may.
293///
294/// When neither names a rule, the engine's own `detail` is returned,
295/// sanitized.
296///
297/// [`forget`] calls this only for an edge that is in the store: an absent one
298/// is nothing to retract, and "was written by hand" would be false of it.
299pub fn rule_owned_refusal<F: Fs>(
300    db: &GraphDb<F>,
301    predicate: &str,
302    subject: &str,
303    object: &str,
304    detail: &str,
305) -> String {
306    // Which rule derived this edge is something the engine knows: `explain`
307    // answers from provenance, one entry per derived edge between the two
308    // keys. Matching on edge type and labels alone names every look-alike —
309    // every rule of the identity preset derives `SAME_AS`.
310    let by_provenance: BTreeSet<String> = db
311        .explain(subject, object)
312        .unwrap_or_default()
313        .into_iter()
314        .filter(|e| e.edge_type == predicate && e.src_key == subject && e.dst_key == object)
315        .map(|e| e.rule)
316        .collect();
317    let rules = db.rules();
318    let derived_by: Vec<&RuleDef> = rules
319        .iter()
320        .filter(|r| by_provenance.contains(&r.name))
321        .collect();
322    if !derived_by.is_empty() {
323        let (names, fields) = names_and_fields(&derived_by);
324        return format!(
325            "refused: {} {} → {} is derived by rule {names}. It changes only when the fields \
326             that rule reads change ({fields}), or when the rule is deleted. Nothing was written.",
327            sanitize(predicate),
328            sanitize(subject),
329            sanitize(object),
330        );
331    }
332    let matching: Vec<&RuleDef> = rules
333        .iter()
334        .filter(|r| guard_matches(db, r, predicate, subject, object))
335        .collect();
336    if matching.is_empty() {
337        return sanitize(detail);
338    }
339    let (names, fields) = names_and_fields(&matching);
340    // One claim for the whole list, so it has to hold for every rule in it.
341    let claim = if matching
342        .iter()
343        .all(|r| would_derive(db, r, subject, object))
344    {
345        "would derive it again"
346    } else {
347        "matches these two nodes' properties and may derive it again"
348    };
349    format!(
350        "refused: {} {} → {} was written by hand and no rule derived it, but rule {names} \
351         {claim}, so the delete is refused. It can be deleted once the fields that rule reads \
352         ({fields}) no longer match, or once the rule is deleted. Nothing was written.",
353        sanitize(predicate),
354        sanitize(subject),
355        sanitize(object),
356    )
357}
358
359/// The rules' names, and every field their predicates read, each sanitized
360/// and comma-joined; the fields sorted and deduped.
361fn names_and_fields(rules: &[&RuleDef]) -> (String, String) {
362    let names: Vec<String> = rules.iter().map(|r| sanitize(&r.name)).collect();
363    let mut fields: BTreeSet<String> = BTreeSet::new();
364    for r in rules {
365        fields.extend(predicate_fields(&PredicateSummary::from(&r.predicate)));
366    }
367    let fields: Vec<String> = fields.iter().map(|f| sanitize(f)).collect();
368    (names.join(", "), fields.join(", "))
369}
370
371/// Whether `rule`'s predicate holds between the nodes `a` and `b`.
372fn predicate_holds<F: Fs>(db: &GraphDb<F>, rule: &RuleDef, a: &str, b: &str) -> bool {
373    let a_props = |field: &str| db.get_prop(a, field);
374    let b_props = |field: &str| db.get_prop(b, field);
375    evaluate(
376        &rule.predicate,
377        &NodeView {
378            key: a,
379            props: &a_props,
380        },
381        &NodeView {
382            key: b,
383            props: &b_props,
384        },
385    )
386    .is_some()
387}
388
389/// Whether the engine's delete guard refuses `(predicate, subject, object)`
390/// on account of `rule`.
391///
392/// A mirror of the private `would_derive` in `db.rs`, which is why a
393/// hand-written edge comes back `RuleOwned`: the rule's edge type and
394/// endpoint labels, then its predicate evaluated on the pair. It is all the
395/// guard asks. It does not look at the rule's via hop, its namespace or its
396/// per-source cap (ledger row 67), so a rule can match here and still not be
397/// one that would derive the edge — see [`would_derive`].
398fn guard_matches<F: Fs>(
399    db: &GraphDb<F>,
400    rule: &RuleDef,
401    predicate: &str,
402    subject: &str,
403    object: &str,
404) -> bool {
405    if subject == object || rule.edge_type != predicate {
406        return false;
407    }
408    let label_is = |key: &str, label: &str| db.node_ref(key).is_some_and(|n| n.label() == label);
409    label_is(subject, &rule.src_label)
410        && label_is(object, &rule.dst_label)
411        && predicate_holds(db, rule, subject, object)
412}
413
414/// Whether `rule`, which [`guard_matches`] for this pair, would in fact
415/// derive `subject → object` from what the store holds now.
416///
417/// What the guard leaves out, as the rule engine applies it:
418///
419/// - **Namespace.** A scoped rule sees a node only in its own namespace, and
420///   that goes for the via node as well as the two ends.
421/// - **The via hop.** A via-hop rule evaluates its predicate between a via
422///   node and the destination, not between the two ends: some node of
423///   `via_label`, one `via_edge` hop from the source in `via_dir`, has to
424///   satisfy it.
425/// - **The cap.** A rule with `max_edges: Some(k)` keeps its best `k`
426///   targets per source; without one, the rule stops at a global budget.
427///   Ranking the candidates is the engine's business, so this answers yes
428///   only when the cap cannot bind: no more candidates than the cap admits.
429///   Past that the answer is no, which errs toward the weaker claim.
430fn would_derive<F: Fs>(db: &GraphDb<F>, rule: &RuleDef, subject: &str, object: &str) -> bool {
431    let sees = |key: &str| match rule.namespace.as_deref() {
432        None => true,
433        Some(ns) => namespace_of_value(db.get_prop(key, NS_PROP).as_ref()) == ns,
434    };
435    if !sees(subject) || !sees(object) {
436        return false;
437    }
438    let sources = db.nodes_with_label(&rule.src_label).len() as u64;
439    let targets = db.nodes_with_label(&rule.dst_label).len() as u64;
440    let cap_cannot_bind = match rule.max_edges {
441        Some(k) => targets <= k,
442        None => sources.saturating_mul(targets) <= DEFAULT_MAX_EDGES,
443    };
444    if !cap_cannot_bind {
445        return false;
446    }
447    let (Some(via_label), Some(via_edge)) = (rule.via_label.as_deref(), rule.via_edge.as_deref())
448    else {
449        // A plain rule: the guard already evaluated its predicate on the pair.
450        return true;
451    };
452    db.neighbors(subject, via_edge, rule.via_dir.unwrap_or(Direction::Out))
453        .unwrap_or_default()
454        .iter()
455        .any(|via| {
456            db.node_ref(via).is_some_and(|n| n.label() == via_label)
457                && sees(via)
458                && predicate_holds(db, rule, via, object)
459        })
460}