use super::*;
fn both_ways(kb_lines: &[&str], queries: &[&str]) -> (Vec<QueryResult>, Vec<QueryResult>) {
let run = |on: bool| -> Vec<QueryResult> {
let kb = new_kb();
kb.set_materialization(on);
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
queries
.iter()
.map(|q| query_result(&kb, compile_surface(q)))
.collect()
};
(run(true), run(false))
}
const AUDIT_KB: &[&str] = &[
"person(Ara).",
"person(Bel).",
"person(Cyd).",
"teaches(Ara, Cyd).",
"teaches(Bel, Cyd).",
"judge(Ara, Bel).",
"capture(Ara, Bel).",
"all $a: all $x: judge($a, $x) & capture($a, $x) & ~deceive($a, $x) -> false($x).",
"all $t: all $s: teaches($t, $s) & ~false($t) -> reward($t).",
];
#[test]
fn naf_verdicts_are_unchanged_by_materialization() {
let queries = [
"reward(Ara).", "reward(Bel).", "false(Bel).", "false(Ara).", "reward(Cyd).", ];
let (on, off) = both_ways(AUDIT_KB, &queries);
for ((q, a), b) in queries.iter().zip(&on).zip(&off) {
if a == b {
continue;
}
assert!(
!b.is_definitive(),
"materialisation changed a DEFINITIVE verdict for {q}: {b:?} → {a:?}"
);
assert!(
a.is_definitive(),
"materialisation made {q} LESS definitive: {b:?} → {a:?}"
);
}
assert_eq!(on[0], QueryResult::True, "Ara is unvoided and teaches");
assert_eq!(on[1], QueryResult::False, "Bel is voided, so no reward");
assert_eq!(on[2], QueryResult::True, "Bel was audited");
assert_eq!(on[3], QueryResult::False, "Ara was not audited");
}
#[test]
fn naf_over_a_chain_past_the_depth_bound_becomes_definitive() {
let kb_lines = [
"dog(Rex).",
"all $x: dog($x) -> animal($x).",
"all $x: animal($x) -> alive($x).",
"all $x: alive($x) -> beautiful($x).",
"all $x: person($x) & ~beautiful($x) -> rotten($x).",
"person(Rex).",
];
let verdict = |on: bool| {
let kb = new_kb();
kb.set_materialization(on);
kb.set_max_chain_depth(1);
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
query_result(&kb, compile_surface("rotten(Rex)."))
};
let off = verdict(false);
assert!(
!off.is_definitive(),
"without materialisation a NAF past the depth bound must be non-definitive, got {off:?}"
);
let on = verdict(true);
assert!(
on.is_definitive(),
"with materialisation the saturated extension decides it regardless of the bound, got {on:?}"
);
assert_eq!(on, QueryResult::False);
}
#[test]
fn a_later_assertion_invalidates_the_saturation() {
let kb = new_kb();
for l in [
"person(Ara).",
"all $x: person($x) & ~rotten($x) -> fit($x).",
] {
assert_buf(&kb, compile_surface(l));
}
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::True,
"nothing makes Ara rotten yet"
);
assert_buf(&kb, compile_surface("rotten(Ara)."));
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::False,
"the new `rotten` fact must block the NAF — a stale saturation would still say TRUE"
);
}
#[test]
fn retraction_invalidates_the_saturation() {
let kb = new_kb();
assert_buf(&kb, compile_surface("person(Ara)."));
assert_buf(
&kb,
compile_surface("all $x: person($x) & ~rotten($x) -> fit($x)."),
);
let rotten = assert_id(&kb, compile_surface("rotten(Ara)."), "rotten");
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::False
);
kb.retract_fact_inner(rotten).unwrap();
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::True,
"retracting the blocker must re-open the NAF — a saturation surviving the rebuild would not"
);
}
#[test]
fn a_bare_rebuild_drops_the_saturation() {
let kb = new_kb();
assert_buf(&kb, compile_surface("person(Ara)."));
assert_buf(
&kb,
compile_surface("all $x: person($x) & ~rotten($x) -> fit($x)."),
);
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::True
);
assert!(
kb.inner.borrow().materialized.borrow().is_some(),
"the query should have built a saturation"
);
kb.rebuild().unwrap();
assert!(
kb.inner.borrow().materialized.borrow().is_none(),
"rebuild must drop the saturation itself, not rely on caller discipline"
);
}
#[test]
fn a_flavoured_relation_is_refused_and_still_answers_correctly() {
let kb_lines = [
"person(Ara).",
"past rotten(Ara).",
"all $x: person($x) & ~rotten($x) -> fit($x).",
];
let (on, off) = both_ways(&kb_lines, &["fit(Ara)."]);
assert_eq!(
on, off,
"a flavoured KB must fall back, giving byte-identical verdicts"
);
let kb = new_kb();
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
let _ = query_result(&kb, compile_surface("fit(Ara)."));
let (complete, _) = kb.materialization_report();
assert!(
!complete.iter().any(|r| r == "rotten"),
"a relation with a Past fact must not be reported complete: {complete:?}"
);
}
#[test]
fn equality_classes_refuse_the_whole_kb() {
let kb_lines = [
"person(Ara).",
"rotten(Bel).",
"Ara = Bel.",
"all $x: person($x) & ~rotten($x) -> fit($x).",
];
let (on, off) = both_ways(&kb_lines, &["fit(Ara)."]);
assert_eq!(
on, off,
"with `du` present the engine must fall back, not answer from a projection"
);
let kb = new_kb();
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
let _ = query_result(&kb, compile_surface("fit(Ara)."));
let (complete, _) = kb.materialization_report();
assert!(
complete.is_empty(),
"no relation may be saturated while equivalence classes exist: {complete:?}"
);
}
#[test]
fn the_report_names_what_was_saturated() {
let kb = new_kb();
for l in AUDIT_KB {
assert_buf(&kb, compile_surface(l));
}
let _ = query_result(&kb, compile_surface("reward(Ara)."));
let (complete, refused) = kb.materialization_report();
assert!(
complete.iter().any(|r| r == "false"),
"`false` is read under `~` and is projectable — expected it saturated: \
complete={complete:?} refused={refused:?}"
);
assert!(refused.iter().all(|(_, why)| !why.is_empty()));
}
#[test]
fn a_kb_without_negation_still_saturates_for_positive_lookups() {
let kb = new_kb();
for l in ["dog(Rex).", "all $x: dog($x) -> animal($x)."] {
assert_buf(&kb, compile_surface(l));
}
assert!(query(&kb, compile_surface("animal(Rex).")));
let (complete, refused) = kb.materialization_report();
assert!(
complete.iter().any(|r| r == "animal"),
"`animal` is rule-derived and projectable — expected it saturated: \
complete={complete:?} refused={refused:?}"
);
}
#[test]
fn positive_goal_past_the_depth_bound_becomes_definitive() {
let kb_lines = [
"dog(Rex).",
"all $x: dog($x) -> animal($x).",
"all $x: animal($x) -> alive($x).",
"all $x: alive($x) -> beautiful($x).",
];
let verdict = |on: bool| {
let kb = new_kb();
kb.set_materialization(on);
kb.set_max_chain_depth(1);
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
query_result(&kb, compile_surface("beautiful(Rex)."))
};
let off = verdict(false);
assert!(
!off.is_definitive(),
"a 3-hop chain under a depth-1 bound must be non-definitive without \
materialisation, got {off:?}"
);
assert_eq!(
verdict(true),
QueryResult::True,
"the saturated extension decides it regardless of the bound"
);
}
#[test]
fn a_traced_query_agrees_with_its_untraced_twin() {
let kb = new_kb();
for l in [
"dog(Rex).",
"all $x: dog($x) -> animal($x).",
"all $x: animal($x) -> alive($x).",
] {
assert_buf(&kb, compile_surface(l));
}
let untraced = query_result(&kb, compile_surface("alive(Rex)."));
let (traced, trace) = kb
.query_entailment_with_proof_inner(compile_surface("alive(Rex)."))
.unwrap();
assert_eq!(untraced, QueryResult::True);
assert_eq!(
traced, untraced,
"traced and untraced verdicts must not diverge"
);
assert!(
trace
.steps
.get(trace.root as usize)
.is_some_and(|s| s.holds),
"a TRUE verdict must carry a holding root step, not a not-found leaf"
);
}
#[test]
fn a_recursive_positive_relation_saturates_to_its_fixpoint() {
let kb_lines = [
"person(Ara).",
"parent(Ara, Bel).",
"parent(Bel, Cyd).",
"parent(Cyd, Dee).",
"all $x: all $y: parent($x, $y) -> judge($x, $y).",
"all $x: all $y: all $z: judge($x, $y) & parent($y, $z) -> judge($x, $z).",
"all $x: person($x) & ~judge($x, Dee) -> rotten($x).",
];
let (on, off) = both_ways(&kb_lines, &["rotten(Ara).", "judge(Ara, Dee)."]);
assert_eq!(on, off, "recursion must not change under materialisation");
assert_eq!(
on[1],
QueryResult::True,
"Ara reaches Dee in three hops of the transitive closure"
);
assert_eq!(
on[0],
QueryResult::False,
"so `~judge(Ara, Dee)` fails — a truncated fixpoint would wrongly say TRUE"
);
}
#[test]
fn toggling_materialization_off_drops_the_saturation() {
let kb = new_kb();
for l in AUDIT_KB {
assert_buf(&kb, compile_surface(l));
}
let _ = query_result(&kb, compile_surface("reward(Ara)."));
assert!(kb.inner.borrow().materialized.borrow().is_some());
kb.set_materialization(false);
assert!(
kb.inner.borrow().materialized.borrow().is_none(),
"turning the switch off must drop the extension now, not later"
);
assert!(!kb.is_materialization());
assert_eq!(
query_result(&kb, compile_surface("reward(Ara).")),
QueryResult::True
);
}
#[test]
fn the_materialization_mode_survives_reset() {
let kb = new_kb();
kb.set_materialization(false);
kb.reset();
assert!(
!kb.is_materialization(),
"the mode is session configuration, not KB content"
);
}
#[test]
fn a_relation_whose_negated_dependency_is_unseedable_is_refused_not_completed() {
let kb_lines = [
"person(Ara).",
"rotten(Ara).",
"past rotten(Ara).",
"all $x: person($x) & ~rotten($x) -> fit($x).",
];
let (on, off) = both_ways(&kb_lines, &["fit(Ara)."]);
assert_eq!(
on[0],
QueryResult::False,
"Ara IS rotten, so `~rotten(Ara)` fails and `fit` must not hold"
);
assert_eq!(on, off, "materialisation must not change this verdict");
let kb = new_kb();
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
let _ = query_result(&kb, compile_surface("fit(Ara)."));
let (complete, refused) = kb.materialization_report();
assert!(
!complete.iter().any(|r| r == "fit"),
"`fit` reads an unseedable relation under `~` — it must NOT be complete: \
complete={complete:?}"
);
assert!(
refused
.iter()
.any(|(rel, why)| rel == "fit" && why.contains("rotten")),
"the refusal must NAME the dependency that caused it: refused={refused:?}"
);
assert!(
refused
.iter()
.any(|(rel, why)| rel == "rotten" && why.contains("stored fact")),
"refused={refused:?}"
);
}
#[test]
fn an_entitlement_is_materialised_without_fabricating_the_actuality() {
let kb_lines = ["entitled(every person, event { eats() }).", "person(Adam)."];
let (on, off) = both_ways(
&kb_lines,
&[
"entitled(Adam, event { eats() }).",
"eats(Adam).",
"eats(some person).",
"entitled(Adam, event { choose() }).",
],
);
assert_eq!(on, off, "the projection must not change any verdict");
assert_eq!(on[0], QueryResult::True, "the entitlement must still MATCH");
assert_eq!(
on[1],
QueryResult::False,
"the actuality must still MISS — an entitlement does not feed anyone"
);
assert_eq!(on[2], QueryResult::False, "nor for anyone else");
assert_eq!(
on[3],
QueryResult::False,
"a different body is a different content hash — no marker collision"
);
let kb = new_kb();
for l in kb_lines {
assert_buf(&kb, compile_surface(l));
}
let _ = query_result(&kb, compile_surface("eats(Adam)."));
let (complete, refused) = kb.materialization_report();
assert!(
complete.iter().any(|r| r == "eats"),
"`eats` should be saturable now: complete={complete:?} refused={refused:?}"
);
assert!(
refused.iter().any(|(r, _)| r == "event"),
"the `event` typing anchor must be refused, not left to look like EDB: \
refused={refused:?}"
);
assert!(
refused.iter().any(|(r, _)| r.starts_with("__abs_")),
"the abstraction marker must be refused too: refused={refused:?}"
);
}
#[test]
fn a_materialised_relation_flips_across_an_assertion() {
let kb = new_kb();
for l in [
"person(Ara).",
"all $x: person($x) & ~rotten($x) -> fit($x).",
] {
assert_buf(&kb, compile_surface(l));
}
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::True
);
let (before, _) = kb.materialization_report();
assert!(
before.iter().any(|r| r == "fit"),
"`fit` must be materialised for this to test anything: {before:?}"
);
assert_buf(&kb, compile_surface("rotten(Ara)."));
assert_eq!(
query_result(&kb, compile_surface("fit(Ara).")),
QueryResult::False,
"the new fact must block the NAF — a surviving extension would still say TRUE"
);
let (after, _) = kb.materialization_report();
assert!(
after.iter().any(|r| r == "fit"),
"and it must be re-saturated, not silently demoted to fallback: {after:?}"
);
}
fn kb_from_corpus(src: &str) -> KnowledgeBase {
let kb = new_kb();
for raw in src.lines() {
let line = raw.trim();
if line.is_empty()
|| line.starts_with('#')
|| line.starts_with(':')
|| line.starts_with('?')
{
continue;
}
if let Ok(ast) = nibli_kr::parse_checked(line)
&& let Ok(mut buf) = nibli_semantics::compile_from_ast(ast)
{
transform_compute_nodes(&mut buf, &default_compute_predicates());
let _ = kb.assert_fact(buf, line.to_string());
}
}
kb
}
#[test]
fn strata_surface_projection_is_lossless() {
for (name, src) in [
("utopia", include_str!("../../../utopia.nibli")),
("gdpr", include_str!("../../../gdpr.nibli")),
(
"drug-interactions",
include_str!("../../../drug-interactions.nibli"),
),
(
"determinism",
include_str!("../../../determinism-corpus.nibli"),
),
] {
let kb = kb_from_corpus(src);
let inner = kb.inner.borrow();
let strata = crate::materialize::compute_strata(&inner.pred_dep_graph);
let mut by_surface: std::collections::BTreeMap<&str, std::collections::BTreeSet<usize>> =
std::collections::BTreeMap::new();
for (raw, lvl) in &strata {
by_surface
.entry(crate::materialize::surface_relation(raw))
.or_default()
.insert(*lvl);
}
for (surface, levels) in &by_surface {
assert_eq!(
levels.len(),
1,
"{name}: `{surface}` spans strata {levels:?} — the anchor and its role \
predicates disagree, so collapsing them loses information"
);
}
}
}
#[test]
fn stratification_report_is_stable_and_well_formed() {
let kb = kb_from_corpus(include_str!("../../../utopia.nibli"));
let rows = kb.stratification_report();
assert!(!rows.is_empty(), "utopia must produce a non-empty report");
let again = kb.stratification_report();
assert_eq!(rows, again, "two reports off one KB must be identical");
let names: Vec<&str> = rows.iter().map(|r| r.predicate.as_str()).collect();
let mut sorted = names.clone();
sorted.sort_unstable();
assert_eq!(names, sorted, "rows must be sorted by predicate");
for r in &rows {
let mut e = r.edges.clone();
e.sort();
e.dedup();
assert_eq!(
e, r.edges,
"{}: edges must be sorted and deduplicated",
r.predicate
);
assert_eq!(
crate::materialize::surface_relation(&r.predicate),
r.predicate,
"a role predicate leaked into the report"
);
}
let inner = kb.inner.borrow();
let derived: std::collections::BTreeSet<&str> = inner
.universal_rules
.keys()
.map(|k| crate::materialize::surface_relation(k))
.collect();
for r in &rows {
assert_eq!(
r.base,
!derived.contains(r.predicate.as_str()),
"{}: base/derived disagrees with the rule-head set",
r.predicate
);
}
assert!(
rows.iter().any(|r| r.edges.iter().any(|e| e.negative)),
"utopia has NAF rules; the report must mark at least one negative edge"
);
}
#[test]
fn a_negative_edge_raises_the_stratum_it_reads_from() {
let kb = new_kb();
for line in [
"person(Adam).",
"all $x: person($x) & ~home($x) -> prisoner($x).",
"all $x: prisoner($x) -> reward($x).",
] {
assert_buf(&kb, compile_surface(line));
}
let rows = kb.stratification_report();
let get = |p: &str| {
rows.iter()
.find(|r| r.predicate == p)
.unwrap_or_else(|| panic!("{p}"))
};
let home = get("home");
let prisoner = get("prisoner");
let watched = get("reward");
assert!(
prisoner.stratum > home.stratum,
"a NAF read must raise the reader's stratum: prisoner={} home={}",
prisoner.stratum,
home.stratum
);
assert_eq!(
watched.stratum, prisoner.stratum,
"a POSITIVE edge must not raise the stratum"
);
assert!(home.base, "`home` is concluded by no rule");
assert!(!prisoner.base, "`prisoner` is concluded by a rule");
assert!(
prisoner.edges.iter().any(|e| e.to == "home" && e.negative),
"the prisoner -> home edge must be marked negative: {:?}",
prisoner.edges
);
assert!(
watched
.edges
.iter()
.any(|e| e.to == "prisoner" && !e.negative),
"the watched -> prisoner edge must be marked positive: {:?}",
watched.edges
);
}