use crate::digest::sanitize;
use crate::memory::identity::{
identity_props_after_forgetting_name, ALIASES_FIELD, ALIAS_KEYS_FIELD,
};
use crate::memory_schema::NAME_FIELD;
use crate::{GraphDb, PredicateSummary, RuleDef};
use core_rules::engine::DEFAULT_MAX_EDGES;
use core_rules::{evaluate, NodeView};
use core_storage::fs::Fs;
use core_storage::{namespace_of_value, Direction, GraphError, Result, NS_PROP};
use std::collections::BTreeSet;
pub const FORGET_NOTE_LIST: usize = 10;
pub const FORGET_SHAPE: &str = "pass exactly one of: key (forget a node), key and prop \
(forget one property), or fact {subject, predicate, object} (retract one edge)";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ForgetTarget {
Node { key: String },
Prop { key: String, prop: String },
Fact {
subject: String,
predicate: String,
object: String,
},
}
impl ForgetTarget {
#[must_use]
pub fn from_parts(
key: Option<String>,
prop: Option<String>,
fact: Option<(String, String, String)>,
) -> Option<Self> {
match (key, prop, fact) {
(Some(key), None, None) => Some(Self::Node { key }),
(Some(key), Some(prop), None) => Some(Self::Prop { key, prop }),
(None, None, Some((subject, predicate, object))) => Some(Self::Fact {
subject,
predicate,
object,
}),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct ForgetReport {
pub mode: &'static str,
pub target: String,
pub changed: bool,
pub manual_edges: u64,
pub derived_edges: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub prop: Option<String>,
pub notes: Vec<String>,
pub notes_total: usize,
pub history_floor: u64,
pub aliases_rewritten: bool,
pub alias_keys_remain: bool,
}
#[must_use]
pub fn predicate_fields(p: &PredicateSummary) -> Vec<String> {
let mut out: BTreeSet<String> = p.fields.iter().cloned().collect();
for part in p.parts.iter().flatten() {
out.extend(predicate_fields(part));
}
out.into_iter().collect()
}
fn notes_about<F: Fs>(db: &GraphDb<F>, keys: &[&str]) -> Vec<String> {
let mut common: Option<BTreeSet<String>> = None;
for key in keys {
let into: BTreeSet<String> = db
.neighbors(key, "ABOUT", Direction::In)
.unwrap_or_default()
.into_iter()
.filter(|k| db.node_ref(k).is_some_and(|n| n.label() == "Note"))
.collect();
common = Some(match common {
None => into,
Some(c) => c.intersection(&into).cloned().collect(),
});
}
common.unwrap_or_default().into_iter().collect()
}
type EdgeTriple = (String, String, String);
fn derived_edges_on<F: Fs>(db: &GraphDb<F>, key: &str) -> Result<BTreeSet<EdgeTriple>> {
Ok(db
.node_edges(key)?
.into_iter()
.filter(|e| e.derived)
.map(|e| (e.edge_type, e.src_key, e.dst_key))
.collect())
}
pub fn forget<F: Fs>(db: &mut GraphDb<F>, target: &ForgetTarget) -> Result<ForgetReport> {
match target {
ForgetTarget::Node { key } => {
if !db.has_node(key) {
return Err(GraphError::KeyNotFound { key: key.clone() });
}
let notes = notes_about(db, &[key.as_str()]);
let label = db
.node_ref(key)
.map(|n| n.label().to_string())
.unwrap_or_default();
let deleted = db.delete_node(key)?;
Ok(ForgetReport {
mode: "node",
target: format!("{key} ({label})"),
changed: true,
manual_edges: deleted.manual_edges,
derived_edges: deleted.derived_edges,
prop: None,
notes_total: notes.len(),
notes: notes.into_iter().take(FORGET_NOTE_LIST).collect(),
history_floor: db.stats().history_floor,
aliases_rewritten: false,
alias_keys_remain: false,
})
}
ForgetTarget::Prop { key, prop } => {
let before = derived_edges_on(db, key)?;
let rewrite = if prop == NAME_FIELD && db.get_prop(key, prop).is_some() {
identity_props_after_forgetting_name(db, key).unwrap_or_default()
} else {
Vec::new()
};
let changed = if rewrite.is_empty() {
db.remove_prop(key, prop)?
} else {
let mut batch = db.batch();
batch.remove_prop(key, prop);
for (field, value) in &rewrite {
batch.set_prop(key, field, value.clone());
}
batch.commit().map(|_| true)?
};
let aliases_rewritten = changed && !rewrite.is_empty();
let alias_keys_remain =
changed && prop == ALIASES_FIELD && db.get_prop(key, ALIAS_KEYS_FIELD).is_some();
let retracted = if changed {
let after = derived_edges_on(db, key).unwrap_or_default();
before.difference(&after).count() as u64
} else {
0
};
Ok(ForgetReport {
mode: "prop",
target: format!("{key}.{prop}"),
changed,
manual_edges: 0,
derived_edges: retracted,
prop: Some(prop.clone()),
notes: Vec::new(),
notes_total: 0,
history_floor: db.stats().history_floor,
aliases_rewritten,
alias_keys_remain,
})
}
ForgetTarget::Fact {
subject,
predicate,
object,
} => {
let changed = match db.delete_edge(predicate, subject, object) {
Ok(c) => c,
Err(GraphError::RuleOwned { .. }) if !has_edge(db, predicate, subject, object) => {
false
}
Err(GraphError::RuleOwned { detail }) => {
return Err(GraphError::RuleOwned {
detail: rule_owned_refusal(db, predicate, subject, object, &detail),
})
}
Err(e) => return Err(e),
};
let notes = if changed {
notes_about(db, &[subject.as_str(), object.as_str()])
} else {
Vec::new()
};
Ok(ForgetReport {
mode: "fact",
target: format!("{predicate} {subject} → {object}"),
changed,
manual_edges: 0,
derived_edges: 0,
prop: None,
notes_total: notes.len(),
notes: notes.into_iter().take(FORGET_NOTE_LIST).collect(),
history_floor: db.stats().history_floor,
aliases_rewritten: false,
alias_keys_remain: false,
})
}
}
}
fn has_edge<F: Fs>(db: &GraphDb<F>, predicate: &str, subject: &str, object: &str) -> bool {
db.neighbors(subject, predicate, Direction::Out)
.is_ok_and(|keys| keys.iter().any(|k| k == object))
}
pub fn rule_owned_refusal<F: Fs>(
db: &GraphDb<F>,
predicate: &str,
subject: &str,
object: &str,
detail: &str,
) -> String {
let by_provenance: BTreeSet<String> = db
.explain(subject, object)
.unwrap_or_default()
.into_iter()
.filter(|e| e.edge_type == predicate && e.src_key == subject && e.dst_key == object)
.map(|e| e.rule)
.collect();
let rules = db.rules();
let derived_by: Vec<&RuleDef> = rules
.iter()
.filter(|r| by_provenance.contains(&r.name))
.collect();
if !derived_by.is_empty() {
let (names, fields) = names_and_fields(&derived_by);
return format!(
"refused: {} {} → {} is derived by rule {names}. It changes only when the fields \
that rule reads change ({fields}), or when the rule is deleted. Nothing was written.",
sanitize(predicate),
sanitize(subject),
sanitize(object),
);
}
let matching: Vec<&RuleDef> = rules
.iter()
.filter(|r| guard_matches(db, r, predicate, subject, object))
.collect();
if matching.is_empty() {
return sanitize(detail);
}
let (names, fields) = names_and_fields(&matching);
let claim = if matching
.iter()
.all(|r| would_derive(db, r, subject, object))
{
"would derive it again"
} else {
"matches these two nodes' properties and may derive it again"
};
format!(
"refused: {} {} → {} was written by hand and no rule derived it, but rule {names} \
{claim}, so the delete is refused. It can be deleted once the fields that rule reads \
({fields}) no longer match, or once the rule is deleted. Nothing was written.",
sanitize(predicate),
sanitize(subject),
sanitize(object),
)
}
fn names_and_fields(rules: &[&RuleDef]) -> (String, String) {
let names: Vec<String> = rules.iter().map(|r| sanitize(&r.name)).collect();
let mut fields: BTreeSet<String> = BTreeSet::new();
for r in rules {
fields.extend(predicate_fields(&PredicateSummary::from(&r.predicate)));
}
let fields: Vec<String> = fields.iter().map(|f| sanitize(f)).collect();
(names.join(", "), fields.join(", "))
}
fn predicate_holds<F: Fs>(db: &GraphDb<F>, rule: &RuleDef, a: &str, b: &str) -> bool {
let a_props = |field: &str| db.get_prop(a, field);
let b_props = |field: &str| db.get_prop(b, field);
evaluate(
&rule.predicate,
&NodeView {
key: a,
props: &a_props,
},
&NodeView {
key: b,
props: &b_props,
},
)
.is_some()
}
fn guard_matches<F: Fs>(
db: &GraphDb<F>,
rule: &RuleDef,
predicate: &str,
subject: &str,
object: &str,
) -> bool {
if subject == object || rule.edge_type != predicate {
return false;
}
let label_is = |key: &str, label: &str| db.node_ref(key).is_some_and(|n| n.label() == label);
label_is(subject, &rule.src_label)
&& label_is(object, &rule.dst_label)
&& predicate_holds(db, rule, subject, object)
}
fn would_derive<F: Fs>(db: &GraphDb<F>, rule: &RuleDef, subject: &str, object: &str) -> bool {
let sees = |key: &str| match rule.namespace.as_deref() {
None => true,
Some(ns) => namespace_of_value(db.get_prop(key, NS_PROP).as_ref()) == ns,
};
if !sees(subject) || !sees(object) {
return false;
}
let sources = db.nodes_with_label(&rule.src_label).len() as u64;
let targets = db.nodes_with_label(&rule.dst_label).len() as u64;
let cap_cannot_bind = match rule.max_edges {
Some(k) => targets <= k,
None => sources.saturating_mul(targets) <= DEFAULT_MAX_EDGES,
};
if !cap_cannot_bind {
return false;
}
let (Some(via_label), Some(via_edge)) = (rule.via_label.as_deref(), rule.via_edge.as_deref())
else {
return true;
};
db.neighbors(subject, via_edge, rule.via_dir.unwrap_or(Direction::Out))
.unwrap_or_default()
.iter()
.any(|via| {
db.node_ref(via).is_some_and(|n| n.label() == via_label)
&& sees(via)
&& predicate_holds(db, rule, via, object)
})
}