use std::collections::{HashMap, HashSet};
use crate::kb::{
GroundTerm, KnowledgeBaseInner, NegatedExistsGroup, StoredFact, UniversalRuleRecord,
};
pub(super) type Strata = HashMap<String, usize>;
pub(super) fn compute_strata(graph: &HashMap<String, Vec<(String, bool)>>) -> Strata {
let sccs = crate::rules::compute_sccs(graph);
let mut comp_of: HashMap<&str, usize> = HashMap::new();
for (i, scc) in sccs.iter().enumerate() {
for node in scc {
comp_of.insert(node.as_str(), i);
}
}
let mut cond_edges: Vec<Vec<(usize, bool)>> = vec![Vec::new(); sccs.len()];
for (head, deps) in graph {
let Some(&h) = comp_of.get(head.as_str()) else {
continue;
};
for (dep, is_neg) in deps {
let Some(&d) = comp_of.get(dep.as_str()) else {
continue;
};
if d != h {
cond_edges[h].push((d, *is_neg));
}
}
}
let mut level: Vec<usize> = vec![0; sccs.len()];
for _ in 0..=sccs.len() {
let mut changed = false;
for h in 0..sccs.len() {
for &(d, is_neg) in &cond_edges[h] {
let want = level[d] + usize::from(is_neg);
if want > level[h] {
level[h] = want;
changed = true;
}
}
}
if !changed {
break;
}
}
let mut out = Strata::new();
for (i, scc) in sccs.iter().enumerate() {
for node in scc {
out.insert(node.clone(), level[i]);
}
}
out
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Ineligible {
SkolemHead(String),
NotProjectable(String),
NotRangeRestricted(String),
ComputeCondition(String),
Flavoured(String),
FlavouredFact,
Equality,
NegatedGroup(String),
DependsOn(String),
RoleGap,
ArityClash,
AbstractionTyping,
}
impl Ineligible {
pub fn reason(&self) -> String {
match self {
Ineligible::SkolemHead(r) => {
format!("rule '{r}' has a Skolem function in its head after projection")
}
Ineligible::NotProjectable(r) => {
format!("rule '{r}' conditions do not partition into per-atom role groups")
}
Ineligible::NotRangeRestricted(r) => {
format!("rule '{r}' is not range-restricted (a head variable is unbound)")
}
Ineligible::ComputeCondition(r) => {
format!("rule '{r}' has a compute-backend condition (domain not enumerable)")
}
Ineligible::Flavoured(r) => {
format!("rule '{r}' carries a tense/deontic flavour (not reproduced in v1)")
}
Ineligible::FlavouredFact => {
"a stored fact of it carries a tense/deontic flavour (not reproduced in v1)"
.to_string()
}
Ineligible::Equality => {
"the KB has `=` equivalence classes (lookup is modulo union-find)".to_string()
}
Ineligible::NegatedGroup(r) => {
format!("rule '{r}' has a `~` restrictor group that does not project cleanly")
}
Ineligible::DependsOn(d) => format!("depends on '{d}', which is not materialisable"),
Ineligible::RoleGap => {
"its stored facts skip a role place — no whole surface atom to project".to_string()
}
Ineligible::ArityClash => {
"its stored facts disagree on arity — no single surface shape to probe".to_string()
}
Ineligible::AbstractionTyping => {
"an abstraction typing marker — the projection eliminates its referent".to_string()
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Atom {
pub(super) relation: String,
pub(super) values: Vec<GroundTerm>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) enum ProjectErr {
NotRoleShaped,
NoAnchor,
GappedRoles,
EventEscapes,
AmbiguousAnchor,
Flavoured,
SkolemInValue,
}
pub(super) fn surface_relation(name: &str) -> &str {
split_role(name).map(|(b, _)| b).unwrap_or(name)
}
fn split_role(name: &str) -> Option<(&str, usize)> {
let (base, idx) = name.rsplit_once("_x")?;
if base.is_empty() {
return None;
}
let place: usize = idx.parse().ok()?;
if place == 0 {
None
} else {
Some((base, place))
}
}
fn is_function_term(t: &GroundTerm) -> bool {
matches!(t, GroundTerm::SkolemFn(_, _) | GroundTerm::DepPair(_, _))
}
#[allow(clippy::type_complexity)]
fn project_atoms(atoms: &[StoredFact]) -> Result<(Vec<Atom>, Vec<StoredFact>), ProjectErr> {
project_atoms_inner(atoms).map(|(atoms, flat, _)| (atoms, flat))
}
fn project_atoms_inner(
atoms: &[StoredFact],
) -> Result<(Vec<Atom>, Vec<StoredFact>, Vec<String>), ProjectErr> {
if atoms.iter().any(|a| !matches!(a, StoredFact::Bare(_))) {
return Err(ProjectErr::Flavoured);
}
let mut abs_const: HashMap<&GroundTerm, String> = HashMap::new();
let mut suppressed: Vec<String> = Vec::new();
for a in atoms {
let gf = a.inner();
if gf.args.len() == 1
&& gf
.relation
.starts_with(crate::kb::ABSTRACTION_MARKER_PREFIX)
{
abs_const.insert(&gf.args[0], gf.relation.clone());
suppressed.push(gf.relation.clone());
}
}
if !abs_const.is_empty() {
for (referent, _) in abs_const.iter() {
let uses = atoms
.iter()
.filter(|a| {
let gf = a.inner();
gf.args.len() == 2 && &gf.args[1] == *referent
})
.count();
if uses > 1 {
return Err(ProjectErr::EventEscapes);
}
}
for a in atoms {
let gf = a.inner();
if gf.args.len() == 1
&& abs_const.contains_key(&gf.args[0])
&& !gf
.relation
.starts_with(crate::kb::ABSTRACTION_MARKER_PREFIX)
{
suppressed.push(gf.relation.clone());
}
}
}
let referent_const = |t: &GroundTerm| -> Option<GroundTerm> {
abs_const.get(t).map(|m| GroundTerm::Constant(m.clone()))
};
let mut anchor_of: HashMap<&GroundTerm, &str> = HashMap::new();
let mut roles_of: HashMap<&GroundTerm, Vec<(usize, &GroundTerm, &str)>> = HashMap::new();
let mut order: Vec<&GroundTerm> = Vec::new();
let mut flat: Vec<StoredFact> = Vec::new();
for a in atoms {
let gf = a.inner();
if gf.args.len() == 1 && abs_const.contains_key(&gf.args[0]) {
continue;
}
match gf.args.len() {
1 => {
let ev = &gf.args[0];
if anchor_of.insert(ev, gf.relation.as_str()).is_some() {
return Err(ProjectErr::AmbiguousAnchor);
}
if !roles_of.contains_key(ev) {
order.push(ev);
roles_of.entry(ev).or_default();
}
}
2 => match split_role(&gf.relation) {
Some((_, place)) => {
let ev = &gf.args[0];
if !roles_of.contains_key(ev) {
order.push(ev);
}
roles_of.entry(ev).or_default().push((
place,
&gf.args[1],
gf.relation.as_str(),
));
}
None => flat.push(a.clone()),
},
_ => flat.push(a.clone()),
}
}
let mut out = Vec::with_capacity(order.len());
for ev in order {
let Some(&base) = anchor_of.get(ev) else {
return Err(ProjectErr::NoAnchor);
};
let mut roles = roles_of.remove(ev).unwrap_or_default();
for (_, _, rel) in &roles {
match split_role(rel) {
Some((b, _)) if b == base => {}
_ => return Err(ProjectErr::NotRoleShaped),
}
}
roles.sort_by_key(|(place, _, _)| *place);
for (i, (place, _, _)) in roles.iter().enumerate() {
if *place != i + 1 {
return Err(ProjectErr::GappedRoles);
}
}
let mut values = Vec::with_capacity(roles.len());
for (_, v, _) in roles {
if v == ev {
return Err(ProjectErr::EventEscapes);
}
if let Some(c) = referent_const(v) {
values.push(c);
continue;
}
if is_function_term(v) {
return Err(ProjectErr::SkolemInValue);
}
values.push(v.clone());
}
out.push(Atom {
relation: base.to_string(),
values,
});
}
suppressed.sort();
suppressed.dedup();
Ok((out, flat, suppressed))
}
fn project_negated_group(group: &NegatedExistsGroup) -> Result<Atom, ProjectErr> {
let (mut atoms, flat) = project_atoms(&group.conditions)?;
if atoms.len() != 1 || !flat.is_empty() {
return Err(ProjectErr::NotRoleShaped);
}
Ok(atoms.remove(0))
}
pub(super) struct ProjectedRule {
pub(super) label: String,
pub(super) positive: Vec<Atom>,
pub(super) negative: Vec<Atom>,
pub(super) builtins: Vec<(StoredFact, bool)>,
pub(super) head: Vec<Atom>,
pub(super) suppressed: Vec<String>,
}
const BUILTIN_RELATIONS: &[&str] = &[nibli_types::relations::IDENTITY];
fn is_builtin(rel: &str) -> bool {
BUILTIN_RELATIONS.contains(&rel)
}
pub(super) fn project_rule(rule: &UniversalRuleRecord) -> Result<ProjectedRule, Ineligible> {
let label = rule.label.clone();
let flav = |e: ProjectErr| -> Ineligible {
match e {
ProjectErr::Flavoured => Ineligible::Flavoured(label.clone()),
ProjectErr::SkolemInValue => Ineligible::SkolemHead(label.clone()),
_ => Ineligible::NotProjectable(label.clone()),
}
};
let mut pos_conds: Vec<StoredFact> = Vec::new();
let mut neg_conds: Vec<StoredFact> = Vec::new();
for (i, c) in rule.typed_conditions.iter().enumerate() {
if rule.negated_condition_indices.contains(&i) {
neg_conds.push(c.clone());
} else {
pos_conds.push(c.clone());
}
}
let (positive, pos_flat) = project_atoms(&pos_conds).map_err(&flav)?;
let (neg_flat_atoms, neg_flat) = project_atoms(&neg_conds).map_err(&flav)?;
let mut builtins: Vec<(StoredFact, bool)> = Vec::new();
for (f, negated) in pos_flat
.into_iter()
.map(|f| (f, false))
.chain(neg_flat.into_iter().map(|f| (f, true)))
{
if !is_builtin(f.relation()) {
return Err(Ineligible::ComputeCondition(label.clone()));
}
builtins.push((f, negated));
}
let mut negative = neg_flat_atoms;
for g in &rule.negated_exists_groups {
match project_negated_group(g) {
Ok(a) => negative.push(a),
Err(ProjectErr::Flavoured) => return Err(Ineligible::Flavoured(label)),
Err(_) => return Err(Ineligible::NegatedGroup(label)),
}
}
let (head, head_flat, suppressed) =
project_atoms_inner(&rule.typed_conclusions).map_err(&flav)?;
if !head_flat.is_empty() || head.is_empty() {
return Err(Ineligible::NotProjectable(label));
}
if head.iter().any(|a| is_builtin(&a.relation)) {
return Err(Ineligible::NotProjectable(label));
}
let mut bound: HashSet<&str> = HashSet::new();
for a in &positive {
for v in &a.values {
if let GroundTerm::PatternVar(n) = v {
bound.insert(n.as_str());
}
}
}
let unbound = |vals: &[GroundTerm]| -> bool {
vals.iter()
.any(|v| matches!(v, GroundTerm::PatternVar(n) if !bound.contains(n.as_str())))
};
if head.iter().any(|a| unbound(&a.values))
|| negative.iter().any(|a| unbound(&a.values))
|| builtins
.iter()
.any(|(f, _)| unbound(f.inner().args.as_slice()))
{
return Err(Ineligible::NotRangeRestricted(label));
}
Ok(ProjectedRule {
label,
positive,
negative,
builtins,
head,
suppressed,
})
}
pub(super) fn distinct_rules(
inner: &KnowledgeBaseInner,
) -> Vec<&std::sync::Arc<UniversalRuleRecord>> {
let mut seen: HashSet<*const UniversalRuleRecord> = HashSet::new();
let mut keys: Vec<&String> = inner.universal_rules.keys().collect();
keys.sort();
let mut out = Vec::new();
for k in keys {
for r in &inner.universal_rules[k] {
if seen.insert(std::sync::Arc::as_ptr(r)) {
out.push(r);
}
}
}
out
}
pub(super) struct Eligibility {
pub(super) eligible: HashSet<String>,
pub(super) refused: HashMap<String, Ineligible>,
pub(super) rules: HashMap<String, Vec<std::sync::Arc<ProjectedRule>>>,
}
pub(super) fn eligible_relations(inner: &KnowledgeBaseInner) -> Eligibility {
let mut refused: HashMap<String, Ineligible> = HashMap::new();
let mut rules: HashMap<String, Vec<std::sync::Arc<ProjectedRule>>> = HashMap::new();
if !inner.equivalence_parent.is_empty() {
for r in distinct_rules(inner) {
for c in &r.typed_conclusions {
refused.insert(c.relation().to_string(), Ineligible::Equality);
}
}
return Eligibility {
eligible: HashSet::new(),
refused,
rules,
};
}
for r in distinct_rules(inner) {
match project_rule(r) {
Ok(pr) => {
for rel in &pr.suppressed {
refused
.entry(rel.clone())
.or_insert(Ineligible::AbstractionTyping);
}
let pr = std::sync::Arc::new(pr);
for h in &pr.head {
rules
.entry(h.relation.clone())
.or_default()
.push(pr.clone());
}
}
Err(why) => {
for c in &r.typed_conclusions {
let rel = split_role(c.relation())
.map(|(b, _)| b.to_string())
.unwrap_or_else(|| c.relation().to_string());
refused.entry(rel).or_insert_with(|| why.clone());
}
}
}
}
let mut eligible: HashSet<String> = rules
.keys()
.filter(|r| !refused.contains_key(*r))
.cloned()
.collect();
fn is_edb(
rel: &str,
rules: &HashMap<String, Vec<std::sync::Arc<ProjectedRule>>>,
refused: &HashMap<String, Ineligible>,
) -> bool {
!rules.contains_key(rel) && !refused.contains_key(rel)
}
loop {
let mut drop_rel: Option<(String, String)> = None;
'outer: for rel in &eligible {
for pr in rules.get(rel).into_iter().flatten() {
for dep in pr.positive.iter().chain(pr.negative.iter()) {
if eligible.contains(&dep.relation) || is_edb(&dep.relation, &rules, &refused) {
continue;
}
drop_rel = Some((rel.clone(), dep.relation.clone()));
break 'outer;
}
}
}
match drop_rel {
Some((rel, dep)) => {
eligible.remove(&rel);
refused.entry(rel).or_insert(Ineligible::DependsOn(dep));
}
None => break,
}
}
Eligibility {
eligible,
refused,
rules,
}
}
pub(super) type Extensions = HashMap<String, HashSet<Vec<GroundTerm>>>;
const MAX_MATERIALIZED_TUPLES: usize = 2_000_000;
pub(super) struct Materialized {
pub(super) ext: Extensions,
pub(super) complete: HashSet<String>,
pub(super) refused: HashMap<String, Ineligible>,
pub(super) arity: HashMap<String, usize>,
}
impl Materialized {
pub(super) fn empty() -> Self {
Materialized {
ext: Extensions::new(),
complete: HashSet::new(),
refused: HashMap::new(),
arity: HashMap::new(),
}
}
pub(super) fn is_complete_for(&self, relation: &str, arity: usize) -> bool {
self.complete.contains(relation) && self.arity.get(relation).is_none_or(|&a| a == arity)
}
pub(super) fn contains(&self, relation: &str, tuple: &[GroundTerm]) -> bool {
self.ext
.get(relation)
.is_some_and(|set| set.contains(tuple))
}
}
fn seed_edb(inner: &KnowledgeBaseInner) -> (Extensions, HashMap<String, Ineligible>) {
let mut anchors: HashSet<(String, GroundTerm)> = HashSet::new();
let mut roles: HashMap<(String, GroundTerm), Vec<(usize, GroundTerm)>> = HashMap::new();
let mut unseedable: HashMap<String, Ineligible> = HashMap::new();
for f in inner.fact_store.all_facts() {
let gf = f.inner();
let bare = matches!(f, StoredFact::Bare(_));
match gf.args.len() {
1 => {
if !bare {
unseedable.insert(gf.relation.clone(), Ineligible::FlavouredFact);
continue;
}
anchors.insert((gf.relation.clone(), gf.args[0].clone()));
}
2 => {
if let Some((base, place)) = split_role(&gf.relation) {
if !bare {
unseedable.insert(base.to_string(), Ineligible::FlavouredFact);
continue;
}
roles
.entry((base.to_string(), gf.args[0].clone()))
.or_default()
.push((place, gf.args[1].clone()));
}
}
_ => {}
}
}
let mut ext = Extensions::new();
for (rel, ev) in anchors {
if unseedable.contains_key(&rel) {
continue;
}
let mut rs = roles.remove(&(rel.clone(), ev)).unwrap_or_default();
rs.sort_by_key(|(p, _)| *p);
if rs.iter().enumerate().any(|(i, (p, _))| *p != i + 1) {
unseedable.insert(rel, Ineligible::RoleGap);
continue;
}
let tuple: Vec<GroundTerm> = rs.into_iter().map(|(_, v)| v).collect();
ext.entry(rel).or_default().insert(tuple);
}
for (rel, tuples) in &ext {
let mut widths = tuples.iter().map(Vec::len);
let first = widths.next().unwrap_or(0);
if widths.any(|w| w != first) {
unseedable.insert(rel.clone(), Ineligible::ArityClash);
}
}
for rel in unseedable.keys() {
ext.remove(rel);
}
(ext, unseedable)
}
fn bind_tuple(
template: &[GroundTerm],
tuple: &[GroundTerm],
bindings: &HashMap<String, GroundTerm>,
) -> Option<HashMap<String, GroundTerm>> {
if template.len() != tuple.len() {
return None;
}
let mut out = bindings.clone();
for (t, v) in template.iter().zip(tuple.iter()) {
match t {
GroundTerm::PatternVar(n) => match out.get(n) {
Some(prev) if prev != v => return None,
Some(_) => {}
None => {
out.insert(n.clone(), v.clone());
}
},
other if other == v => {}
_ => return None,
}
}
Some(out)
}
fn ground_values(
template: &[GroundTerm],
bindings: &HashMap<String, GroundTerm>,
) -> Option<Vec<GroundTerm>> {
template
.iter()
.map(|t| match t {
GroundTerm::PatternVar(n) => bindings.get(n).cloned(),
other => Some(other.clone()),
})
.collect()
}
fn builtin_holds(fact: &StoredFact, bindings: &HashMap<String, GroundTerm>) -> Option<bool> {
let gf = fact.inner();
if gf.relation != nibli_types::relations::IDENTITY {
return None;
}
let args = ground_values(&gf.args, bindings)?;
if args.len() != 2 {
return None;
}
Some(args[0] == args[1])
}
fn eval_rule(
pr: &ProjectedRule,
ext: &Extensions,
delta: &Extensions,
delta_pos: Option<usize>,
out: &mut Vec<(String, Vec<GroundTerm>)>,
) {
fn walk(
pr: &ProjectedRule,
ext: &Extensions,
delta: &Extensions,
delta_pos: Option<usize>,
i: usize,
bindings: HashMap<String, GroundTerm>,
out: &mut Vec<(String, Vec<GroundTerm>)>,
) {
if i == pr.positive.len() {
for (f, negated) in &pr.builtins {
match builtin_holds(f, &bindings) {
Some(holds) if holds != *negated => {}
_ => return,
}
}
for n in &pr.negative {
let Some(t) = ground_values(&n.values, &bindings) else {
return;
};
if ext.get(&n.relation).is_some_and(|s| s.contains(&t)) {
return;
}
}
for h in &pr.head {
if let Some(t) = ground_values(&h.values, &bindings) {
out.push((h.relation.clone(), t));
}
}
return;
}
let atom = &pr.positive[i];
let source = if delta_pos == Some(i) { delta } else { ext };
let Some(tuples) = source.get(&atom.relation) else {
return;
};
for tuple in tuples {
if let Some(b) = bind_tuple(&atom.values, tuple, &bindings) {
walk(pr, ext, delta, delta_pos, i + 1, b, out);
}
}
}
walk(pr, ext, delta, delta_pos, 0, HashMap::new(), out);
}
pub(super) fn saturate(
inner: &KnowledgeBaseInner,
elig: &Eligibility,
strata: &Strata,
targets: &HashSet<String>,
) -> Materialized {
if !inner.equivalence_parent.is_empty() {
let mut refused = elig.refused.clone();
for rel in targets {
refused.entry(rel.clone()).or_insert(Ineligible::Equality);
}
return Materialized {
ext: Extensions::new(),
complete: HashSet::new(),
refused,
arity: HashMap::new(),
};
}
let (mut ext, unseedable) = seed_edb(inner);
let mut refused = elig.refused.clone();
for (rel, why) in &unseedable {
refused.entry(rel.clone()).or_insert_with(|| why.clone());
}
let mut wanted: HashSet<String> = HashSet::new();
let mut stack: Vec<String> = targets.iter().cloned().collect();
stack.sort();
while let Some(rel) = stack.pop() {
if unseedable.contains_key(&rel) || !wanted.insert(rel.clone()) {
continue;
}
for pr in elig.rules.get(&rel).into_iter().flatten() {
for dep in pr.positive.iter().chain(pr.negative.iter()) {
stack.push(dep.relation.clone());
}
}
}
let saturable: HashSet<&String> = wanted
.iter()
.filter(|r| elig.eligible.contains(*r))
.collect();
let mut by_stratum: Vec<(usize, String)> = wanted
.iter()
.map(|r| (strata.get(r).copied().unwrap_or(0), r.clone()))
.collect();
by_stratum.sort();
let mut complete: HashSet<String> = HashSet::new();
let mut budget = MAX_MATERIALIZED_TUPLES;
let mut idx = 0usize;
while idx < by_stratum.len() {
let level = by_stratum[idx].0;
let mut rels: Vec<&String> = Vec::new();
while idx < by_stratum.len() && by_stratum[idx].0 == level {
rels.push(&by_stratum[idx].1);
idx += 1;
}
for rel in &rels {
if unseedable.contains_key(*rel) || saturable.contains(*rel) {
continue;
}
if !elig.rules.contains_key(*rel) && !refused.contains_key(*rel) {
complete.insert((*rel).clone());
}
}
let mut derived_here: Vec<&String> = rels
.iter()
.filter(|rel| !unseedable.contains_key(**rel) && saturable.contains(**rel))
.copied()
.collect();
loop {
let mut drop_idx: Option<(usize, String)> = None;
'scan: for (i, rel) in derived_here.iter().enumerate() {
for pr in elig.rules.get(*rel).into_iter().flatten() {
for dep in pr.positive.iter().chain(pr.negative.iter()) {
if complete.contains(&dep.relation)
|| derived_here.iter().any(|r| **r == dep.relation)
{
continue;
}
drop_idx = Some((i, dep.relation.clone()));
break 'scan;
}
}
}
match drop_idx {
Some((i, dep)) => {
let rel = derived_here.remove(i);
refused
.entry(rel.clone())
.or_insert(Ineligible::DependsOn(dep));
}
None => break,
}
}
let mut stratum_rules: Vec<&std::sync::Arc<ProjectedRule>> = Vec::new();
let mut seen: HashSet<*const ProjectedRule> = HashSet::new();
for rel in &derived_here {
for pr in elig.rules.get(*rel).into_iter().flatten() {
if seen.insert(std::sync::Arc::as_ptr(pr)) {
stratum_rules.push(pr);
}
}
}
if derived_here.is_empty() {
continue;
}
let mut delta: Extensions = Extensions::new();
let mut round = 0usize;
let mut overflowed = false;
loop {
let mut produced: Vec<(String, Vec<GroundTerm>)> = Vec::new();
for pr in &stratum_rules {
if round == 0 {
eval_rule(pr, &ext, &delta, None, &mut produced);
} else {
for pos in 0..pr.positive.len() {
if delta
.get(&pr.positive[pos].relation)
.is_none_or(HashSet::is_empty)
{
continue;
}
eval_rule(pr, &ext, &delta, Some(pos), &mut produced);
}
}
}
let mut next: Extensions = Extensions::new();
for (rel, tuple) in produced {
if ext.get(&rel).is_some_and(|s| s.contains(&tuple)) {
continue;
}
if budget == 0 {
overflowed = true;
break;
}
if next.entry(rel).or_default().insert(tuple) {
budget -= 1;
}
}
if overflowed {
break;
}
let grew = next.values().any(|s| !s.is_empty());
for (rel, set) in &next {
ext.entry(rel.clone())
.or_default()
.extend(set.iter().cloned());
}
delta = next;
if !grew {
break;
}
round += 1;
}
if overflowed {
for rel in derived_here {
refused
.entry(rel.clone())
.or_insert_with(|| Ineligible::DependsOn("the materialisation budget".into()));
}
break;
}
for rel in derived_here {
complete.insert(rel.clone());
}
}
for rel in &wanted {
if !complete.contains(rel) {
refused
.entry(rel.clone())
.or_insert_with(|| Ineligible::DependsOn("an unsaturated dependency".into()));
}
}
let mut arity: HashMap<String, usize> = HashMap::new();
let mut clashing: HashSet<String> = HashSet::new();
for (rel, tuples) in &ext {
let mut widths = tuples.iter().map(Vec::len);
let Some(first) = widths.next() else { continue };
if widths.all(|w| w == first) {
arity.insert(rel.clone(), first);
} else {
clashing.insert(rel.clone());
}
}
let complete: HashSet<String> = complete
.into_iter()
.filter(|rel| !clashing.contains(rel))
.collect();
for rel in &clashing {
refused
.entry(rel.clone())
.or_insert_with(|| Ineligible::Flavoured(format!("mixed arities for '{rel}'")));
}
Materialized {
ext,
complete,
refused,
arity,
}
}
pub(super) fn collect_negated_relations(
buffer: &nibli_types::logic::LogicBuffer,
out: &mut HashSet<String>,
) {
use nibli_types::logic::LogicNode;
fn walk(
buffer: &nibli_types::logic::LogicBuffer,
id: u32,
under_not: bool,
out: &mut HashSet<String>,
seen: &mut HashSet<u32>,
) {
if !seen.insert(id) {
return;
}
let Some(node) = buffer.nodes.get(id as usize) else {
return;
};
match node {
LogicNode::Predicate((rel, _)) | LogicNode::ComputeNode((rel, _)) => {
if under_not {
out.insert(surface_relation(rel).to_string());
}
}
LogicNode::NotNode(inner) => walk(buffer, *inner, true, out, seen),
LogicNode::AndNode((l, r)) | LogicNode::OrNode((l, r)) => {
walk(buffer, *l, under_not, out, seen);
walk(buffer, *r, under_not, out, seen);
}
LogicNode::ExistsNode((_, body))
| LogicNode::ForAllNode((_, body))
| LogicNode::CountNode((_, _, body)) => walk(buffer, *body, under_not, out, seen),
LogicNode::PastNode(b)
| LogicNode::PresentNode(b)
| LogicNode::FutureNode(b)
| LogicNode::ObligatoryNode(b)
| LogicNode::PermittedNode(b) => walk(buffer, *b, under_not, out, seen),
}
}
for &root in &buffer.roots {
walk(buffer, root, false, out, &mut HashSet::new());
}
}
pub(super) fn collect_query_relations(
buffer: &nibli_types::logic::LogicBuffer,
out: &mut HashSet<String>,
) {
use nibli_types::logic::LogicNode;
for node in &buffer.nodes {
if let LogicNode::Predicate((rel, _)) = node {
out.insert(surface_relation(rel).to_string());
}
}
}
pub(super) fn probe_negated_group(
group: &NegatedExistsGroup,
bindings: &HashMap<String, GroundTerm>,
) -> Option<(String, Vec<GroundTerm>)> {
let atom = project_negated_group(group).ok()?;
let tuple = ground_values(&atom.values, bindings)?;
if tuple.iter().any(|t| matches!(t, GroundTerm::PatternVar(_))) {
return None;
}
Some((atom.relation, tuple))
}
pub(super) fn probe_positive_group(
buffer: &nibli_types::logic::LogicBuffer,
body_id: u32,
exists_var: &str,
subs: &HashMap<String, GroundTerm>,
) -> Option<(String, Vec<GroundTerm>)> {
use nibli_types::logic::LogicNode;
let mut conjuncts: Vec<u32> = Vec::new();
let mut stack = vec![body_id];
while let Some(id) = stack.pop() {
match buffer.nodes.get(id as usize)? {
LogicNode::AndNode((l, r)) => {
stack.push(*l);
stack.push(*r);
}
LogicNode::Predicate(_) => conjuncts.push(id),
_ => return None,
}
}
if conjuncts.is_empty() {
return None;
}
if subs.contains_key(exists_var) {
return None;
}
let mut ev_subs = subs.clone();
ev_subs.insert(
exists_var.to_string(),
GroundTerm::PatternVar(exists_var.to_string()),
);
let mut atoms: Vec<StoredFact> = Vec::with_capacity(conjuncts.len());
for id in conjuncts {
atoms.push(crate::rules::build_stored_fact_from_node(
buffer, id, &ev_subs, None,
)?);
}
let (mut projected, flat) = project_atoms(&atoms).ok()?;
if projected.len() != 1 || !flat.is_empty() {
return None;
}
let atom = projected.remove(0);
let tuple = ground_values(&atom.values, subs)?;
if tuple.iter().any(|t| matches!(t, GroundTerm::PatternVar(_))) {
return None;
}
Some((atom.relation, tuple))
}
#[cfg(test)]
mod strata_tests {
use super::*;
fn g(edges: &[(&str, &str, bool)]) -> HashMap<String, Vec<(String, bool)>> {
let mut m: HashMap<String, Vec<(String, bool)>> = HashMap::new();
for (h, d, n) in edges {
m.entry(h.to_string())
.or_default()
.push((d.to_string(), *n));
}
m
}
#[test]
fn edb_only_graph_is_all_stratum_zero() {
let s = compute_strata(&g(&[("b", "a", false), ("c", "b", false)]));
assert_eq!(s.get("a"), Some(&0));
assert_eq!(s.get("b"), Some(&0));
assert_eq!(s.get("c"), Some(&0));
}
#[test]
fn a_negative_edge_raises_the_reader_one_stratum() {
let s = compute_strata(&g(&[
("reward", "false", true),
("false", "capture", false),
]));
assert_eq!(s.get("capture"), Some(&0));
assert_eq!(s.get("false"), Some(&0));
assert_eq!(s.get("reward"), Some(&1));
}
#[test]
fn negative_edges_stack_along_a_chain() {
let s = compute_strata(&g(&[("c", "b", true), ("b", "a", true)]));
assert_eq!(s.get("a"), Some(&0));
assert_eq!(s.get("b"), Some(&1));
assert_eq!(s.get("c"), Some(&2));
}
#[test]
fn the_longest_negative_path_wins_not_the_first_found() {
let s = compute_strata(&g(&[
("d", "c", false),
("d", "a", true),
("c", "b", true),
("b", "a", true),
]));
assert_eq!(s.get("a"), Some(&0));
assert_eq!(s.get("d"), Some(&2));
}
#[test]
fn a_positive_cycle_shares_one_stratum() {
let s = compute_strata(&g(&[
("p", "q", false),
("q", "p", false),
("r", "p", true),
]));
assert_eq!(s.get("p"), s.get("q"));
assert_eq!(s.get("r"), Some(&(s["p"] + 1)));
}
#[test]
fn condition_only_leaf_predicates_are_labelled() {
let s = compute_strata(&g(&[("head", "leaf", false)]));
assert_eq!(s.get("leaf"), Some(&0));
}
#[test]
fn a_negative_self_loop_terminates_rather_than_diverging() {
let s = compute_strata(&g(&[("p", "p", true)]));
assert_eq!(s.get("p"), Some(&0));
}
#[test]
fn labels_are_stable_across_repeated_computation() {
let graph = g(&[("c", "b", true), ("b", "a", true), ("z", "a", false)]);
let first = compute_strata(&graph);
for _ in 0..8 {
assert_eq!(compute_strata(&graph), first);
}
let mut order: Vec<(usize, &str)> = first.iter().map(|(k, v)| (*v, k.as_str())).collect();
order.sort();
assert_eq!(order, vec![(0, "a"), (0, "z"), (1, "b"), (2, "c")]);
}
}