#![no_std]
#![warn(missing_docs)]
extern crate alloc;
#[cfg(feature = "std")]
extern crate std;
use alloc::collections::{BTreeMap, BTreeSet};
use alloc::string::{String, ToString};
use alloc::vec;
use alloc::vec::Vec;
use core::fmt::Write as _;
use elenchus_parser::{Atom, Body, Conn, ListOp, Literal, Statement};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub fn hash_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let out = hasher.finalize();
let mut s = String::with_capacity(out.len() * 2);
for b in out {
let _ = write!(s, "{:02x}", b);
}
s
}
pub type AtomId = u32;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct AtomKey {
pub subject: String,
pub predicate: String,
pub object: Option<String>,
}
impl AtomKey {
fn from_atom(a: &Atom) -> Self {
AtomKey {
subject: a.subject.to_string(),
predicate: a.predicate.to_string(),
object: a.object.map(|o| o.to_string()),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Lit {
pub atom: AtomId,
pub negated: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Value {
True,
False,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Origin {
pub source: String,
pub line: u32,
pub premise: Option<String>,
pub kind: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fact {
pub atom: AtomId,
pub value: Value,
pub origin: Origin,
pub soft: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Clause {
pub lits: Vec<Lit>,
pub origin: Origin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Rule {
pub antecedent: Vec<Lit>,
pub consequent: Vec<Lit>,
pub origin: Origin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Check {
pub subject: Option<String>,
pub bidirectional: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Compiled {
pub atoms: Vec<AtomKey>,
pub facts: Vec<Fact>,
pub clauses: Vec<Clause>,
pub rules: Vec<Rule>,
pub checks: Vec<Check>,
pub pending_imports: Vec<String>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CompileError {
#[error("parse error in {file}: {message}")]
Parse {
file: String,
message: String,
},
#[error("'{name}' redefined with a different body")]
PremiseRedefinition {
name: String,
},
#[error("import not found: {0}")]
ImportNotFound(String),
#[error("circular import: {0}")]
CircularImport(String),
#[error("rule '{name}' cannot derive a disjunction (OR in THEN); use a PREMISE instead")]
RuleDisjunctiveConsequent {
name: String,
},
}
#[derive(Clone)]
struct RawLit {
key: AtomKey,
negated: bool,
}
struct RawFact {
key: AtomKey,
value: Value,
origin: Origin,
soft: bool,
}
struct RawClause {
lits: Vec<RawLit>,
origin: Origin,
}
struct RawRule {
antecedent: Vec<RawLit>,
consequent: Vec<RawLit>,
origin: Origin,
}
#[derive(Default)]
pub struct Compiler {
keys: BTreeSet<AtomKey>,
facts: Vec<RawFact>,
clauses: Vec<RawClause>,
rules: Vec<RawRule>,
checks: Vec<Check>,
pending_imports: Vec<String>,
defined: BTreeMap<(String, String), String>,
clause_sigs: BTreeSet<String>,
fact_sigs: BTreeSet<String>,
}
impl Compiler {
pub fn new() -> Self {
Self::default()
}
pub fn add_source(&mut self, source: &str, src: &str) -> Result<(), CompileError> {
let program = elenchus_parser::parse(src).map_err(|e| CompileError::Parse {
file: source.to_string(),
message: e.message,
})?;
for stmt in &program.statements {
self.add_statement(source, stmt)?;
}
Ok(())
}
fn load_recursive<R: Resolver>(
&mut self,
path: &str,
resolver: &R,
visited: &mut BTreeSet<String>,
stack: &mut Vec<String>,
) -> Result<(), CompileError> {
let content = resolver.load(path)?;
let hash = hash_hex(content.as_bytes());
if visited.contains(&hash) {
return Ok(()); }
if stack.contains(&hash) {
return Err(CompileError::CircularImport(path.to_string()));
}
stack.push(hash.clone());
let program = elenchus_parser::parse(&content).map_err(|e| CompileError::Parse {
file: path.to_string(),
message: e.message,
})?;
for stmt in &program.statements {
if let Statement::Import(p) = stmt {
let resolved = resolver.resolve(path, p.data);
self.load_recursive(&resolved, resolver, visited, stack)?;
} else {
self.add_statement(path, stmt)?;
}
}
stack.pop();
visited.insert(hash);
Ok(())
}
fn add_statement(&mut self, source: &str, stmt: &Statement) -> Result<(), CompileError> {
match stmt {
Statement::Import(path) => {
self.pending_imports.push(path.data.to_string());
}
Statement::Fact(a) => self.add_fact(source, a, Value::True, "FACT", false),
Statement::Negation(a) => self.add_fact(source, a, Value::False, "NOT", false),
Statement::Assume(l) => {
let value = if l.data.negated {
Value::False
} else {
Value::True
};
let located = elenchus_parser::Located {
data: l.data.atom.clone(),
span: l.span,
};
self.add_fact(source, &located, value, "ASSUME", true);
}
Statement::Check {
subject,
bidirectional,
} => self.checks.push(Check {
subject: subject.as_ref().map(|s| s.data.to_string()),
bidirectional: *bidirectional,
}),
Statement::Premise { name, body } => {
let line = name.span.location_line();
self.add_named(source, name.data, line, body, false)?;
}
Statement::Rule { name, body } => {
let line = name.span.location_line();
self.add_named(source, name.data, line, body, true)?;
}
}
Ok(())
}
fn intern(&mut self, key: &AtomKey) {
if !self.keys.contains(key) {
self.keys.insert(key.clone());
}
}
fn add_fact(
&mut self,
source: &str,
a: &elenchus_parser::Located<Atom>,
value: Value,
kind: &'static str,
soft: bool,
) {
let key = AtomKey::from_atom(&a.data);
self.intern(&key);
let sig = alloc::format!(
"{}|{}|{}|{}",
key_sig(&key),
matches!(value, Value::True) as u8,
kind,
"" );
if !self.fact_sigs.insert(sig) {
return; }
self.facts.push(RawFact {
key,
value,
origin: Origin {
source: source.to_string(),
line: a.span.location_line(),
premise: None,
kind,
},
soft,
});
}
fn add_named(
&mut self,
source: &str,
name: &str,
line: u32,
body: &Body,
is_rule: bool,
) -> Result<(), CompileError> {
let body_hash = hash_hex(canonical_body(name, body, is_rule).as_bytes());
let key = (source.to_string(), name.to_string());
match self.defined.get(&key) {
Some(prev) if *prev == body_hash => return Ok(()), Some(_) => {
return Err(CompileError::PremiseRedefinition {
name: name.to_string(),
});
}
None => {
self.defined.insert(key, body_hash);
}
}
if is_rule {
if let Body::Impl {
antecedent,
ante_conn,
consequent,
cons_conn,
} = body
{
if *cons_conn == Conn::Or {
return Err(CompileError::RuleDisjunctiveConsequent {
name: name.to_string(),
});
}
let (ante, cons) = (raw_lits(antecedent), raw_lits(consequent));
for l in ante.iter().chain(cons.iter()) {
self.intern(&l.key);
}
let origin = self.origin(source, line, Some(name), "RULE");
match ante_conn {
Conn::And => self.rules.push(RawRule {
antecedent: ante,
consequent: cons,
origin,
}),
Conn::Or => {
for a in &ante {
self.rules.push(RawRule {
antecedent: vec![a.clone()],
consequent: cons.clone(),
origin: origin.clone(),
});
}
}
}
}
return Ok(());
}
match body {
Body::List { op, atoms } => {
let keys: Vec<AtomKey> =
atoms.iter().map(|a| AtomKey::from_atom(&a.data)).collect();
for k in &keys {
self.intern(k);
}
let kind = list_kind(*op);
let origin = self.origin(source, line, Some(name), kind);
match op {
ListOp::Exclusive | ListOp::Forbids => {
self.emit_pairwise(&keys, &origin);
}
ListOp::OneOf => {
self.emit_pairwise(&keys, &origin);
self.emit_at_least_one(&keys, &origin);
}
ListOp::AtLeast => {
self.emit_at_least_one(&keys, &origin);
}
}
}
Body::Impl {
antecedent,
ante_conn,
consequent,
cons_conn,
} => {
let ante = raw_lits(antecedent);
let cons = raw_lits(consequent);
for l in ante.iter().chain(cons.iter()) {
self.intern(&l.key);
}
let origin = self.origin(source, line, Some(name), "PREMISE");
let ante_groups: Vec<Vec<RawLit>> = match ante_conn {
Conn::And => vec![ante.clone()],
Conn::Or => ante.iter().map(|l| vec![l.clone()]).collect(),
};
let cons_groups: Vec<Vec<RawLit>> = match cons_conn {
Conn::And => cons.iter().map(|l| vec![l.clone()]).collect(),
Conn::Or => vec![cons.clone()],
};
for ag in &ante_groups {
for cg in &cons_groups {
let mut lits = ag.clone();
for c in cg {
lits.push(RawLit {
key: c.key.clone(),
negated: !c.negated,
});
}
self.push_clause(lits, origin.clone());
}
}
}
}
Ok(())
}
fn emit_pairwise(&mut self, keys: &[AtomKey], origin: &Origin) {
for i in 0..keys.len() {
for j in (i + 1)..keys.len() {
let lits = vec![
RawLit {
key: keys[i].clone(),
negated: false,
},
RawLit {
key: keys[j].clone(),
negated: false,
},
];
self.push_clause(lits, origin.clone());
}
}
}
fn emit_at_least_one(&mut self, keys: &[AtomKey], origin: &Origin) {
let lits = keys
.iter()
.map(|k| RawLit {
key: k.clone(),
negated: true,
})
.collect();
self.push_clause(lits, origin.clone());
}
fn push_clause(&mut self, lits: Vec<RawLit>, origin: Origin) {
let sig = clause_sig(&lits);
if self.clause_sigs.insert(sig) {
self.clauses.push(RawClause { lits, origin });
}
}
fn origin(&self, source: &str, line: u32, premise: Option<&str>, kind: &'static str) -> Origin {
Origin {
source: source.to_string(),
line,
premise: premise.map(|s| s.to_string()),
kind,
}
}
pub fn finalize(self) -> Compiled {
let atoms: Vec<AtomKey> = self.keys.into_iter().collect(); let mut id_of: BTreeMap<AtomKey, AtomId> = BTreeMap::new();
for (i, k) in atoms.iter().enumerate() {
id_of.insert(k.clone(), i as AtomId);
}
let lower = |l: &RawLit| Lit {
atom: id_of[&l.key],
negated: l.negated,
};
let facts = self
.facts
.into_iter()
.map(|f| Fact {
atom: id_of[&f.key],
value: f.value,
origin: f.origin,
soft: f.soft,
})
.collect();
let clauses = self
.clauses
.into_iter()
.map(|c| Clause {
lits: c.lits.iter().map(lower).collect(),
origin: c.origin,
})
.collect();
let rules = self
.rules
.into_iter()
.map(|r| Rule {
antecedent: r.antecedent.iter().map(lower).collect(),
consequent: r.consequent.iter().map(lower).collect(),
origin: r.origin,
})
.collect();
Compiled {
atoms,
facts,
clauses,
rules,
checks: self.checks,
pending_imports: self.pending_imports,
}
}
}
pub fn compile_source(source: &str, src: &str) -> Result<Compiled, CompileError> {
let mut c = Compiler::new();
c.add_source(source, src)?;
Ok(c.finalize())
}
pub trait Resolver {
fn load(&self, path: &str) -> Result<String, CompileError>;
fn resolve(&self, _base: &str, relative: &str) -> String {
relative.to_string()
}
}
#[derive(Default)]
pub struct MemoryResolver {
sources: BTreeMap<String, String>,
}
impl MemoryResolver {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, path: &str, content: &str) -> &mut Self {
self.sources.insert(path.to_string(), content.to_string());
self
}
}
impl Resolver for MemoryResolver {
fn load(&self, path: &str) -> Result<String, CompileError> {
self.sources
.get(path)
.cloned()
.ok_or_else(|| CompileError::ImportNotFound(path.to_string()))
}
}
#[cfg(feature = "std")]
pub struct FileResolver;
#[cfg(feature = "std")]
impl Resolver for FileResolver {
fn load(&self, path: &str) -> Result<String, CompileError> {
std::fs::read_to_string(path)
.map_err(|e| CompileError::ImportNotFound(alloc::format!("{}: {}", path, e)))
}
fn resolve(&self, base: &str, relative: &str) -> String {
use std::path::{Component, Path, PathBuf};
let parent = Path::new(base).parent().unwrap_or_else(|| Path::new("."));
let joined = parent.join(relative);
let mut out = PathBuf::new();
for component in joined.components() {
match component {
Component::ParentDir => {
out.pop();
}
Component::CurDir => {}
c => out.push(c),
}
}
out.to_string_lossy().replace('\\', "/")
}
}
pub fn compile<R: Resolver>(root: &str, resolver: &R) -> Result<Compiled, CompileError> {
let mut c = Compiler::new();
let mut visited = BTreeSet::new();
let mut stack = Vec::new();
c.load_recursive(root, resolver, &mut visited, &mut stack)?;
Ok(c.finalize())
}
fn raw_lits(lits: &[elenchus_parser::Located<Literal>]) -> Vec<RawLit> {
lits.iter()
.map(|l| RawLit {
key: AtomKey::from_atom(&l.data.atom),
negated: l.data.negated,
})
.collect()
}
fn list_kind(op: ListOp) -> &'static str {
match op {
ListOp::Exclusive => "EXCLUSIVE",
ListOp::Forbids => "FORBIDS",
ListOp::OneOf => "ONEOF",
ListOp::AtLeast => "ATLEAST",
}
}
fn key_sig(k: &AtomKey) -> String {
alloc::format!(
"{}|{}|{}",
k.subject,
k.predicate,
k.object.as_deref().unwrap_or("")
)
}
fn clause_sig(lits: &[RawLit]) -> String {
let mut parts: Vec<String> = lits
.iter()
.map(|l| alloc::format!("{}|{}", key_sig(&l.key), l.negated as u8))
.collect();
parts.sort();
parts.dedup();
parts.join(";")
}
fn canonical_body(name: &str, body: &Body, is_rule: bool) -> String {
let mut s = String::new();
let _ = write!(s, "{}|{}|", if is_rule { "RULE" } else { "PREMISE" }, name);
match body {
Body::List { op, atoms } => {
let _ = write!(s, "LIST|{}|", list_kind(*op));
let mut keys: Vec<String> = atoms
.iter()
.map(|a| key_sig(&AtomKey::from_atom(&a.data)))
.collect();
keys.sort();
s.push_str(&keys.join(";"));
}
Body::Impl {
antecedent,
ante_conn,
consequent,
cons_conn,
} => {
let conn = |c: &Conn| if *c == Conn::Or { "OR" } else { "AND" };
s.push_str("IMPL|ANTE|");
s.push_str(conn(ante_conn));
s.push('|');
s.push_str(&lit_sigs(antecedent));
s.push_str("|CONS|");
s.push_str(conn(cons_conn));
s.push('|');
s.push_str(&lit_sigs(consequent));
}
}
s
}
fn lit_sigs(lits: &[elenchus_parser::Located<Literal>]) -> String {
let mut parts: Vec<String> = lits
.iter()
.map(|l| {
alloc::format!(
"{}|{}",
key_sig(&AtomKey::from_atom(&l.data.atom)),
l.data.negated as u8
)
})
.collect();
parts.sort();
parts.join(";")
}
#[cfg(test)]
mod tests {
use super::*;
fn key(subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
AtomKey {
subject: subject.to_string(),
predicate: predicate.to_string(),
object: object.map(|o| o.to_string()),
}
}
fn id(c: &Compiled, k: &AtomKey) -> AtomId {
c.atoms.iter().position(|a| a == k).unwrap() as AtomId
}
#[test]
fn exclusive_unfolds_pairwise() {
let src = r#"
PREMISE e:
EXCLUSIVE
x a
x b
x c
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 3);
for cl in &c.clauses {
assert_eq!(cl.lits.len(), 2);
assert!(cl.lits.iter().all(|l| !l.negated));
}
}
#[test]
fn implication_negates_consequent() {
let src = r#"
PREMISE r:
WHEN x a
THEN x b
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 1);
let cl = &c.clauses[0];
assert_eq!(cl.lits.len(), 2);
let a = id(&c, &key("x", "a", None));
let b = id(&c, &key("x", "b", None));
assert!(cl.lits.contains(&Lit {
atom: a,
negated: false
}));
assert!(cl.lits.contains(&Lit {
atom: b,
negated: true
}));
}
#[test]
fn negated_consequent_flips_to_positive() {
let src = r#"
PREMISE r:
WHEN x a
THEN NOT x b
"#;
let c = compile_source("<t>", src).unwrap();
let b = id(&c, &key("x", "b", None));
assert!(c.clauses[0].lits.contains(&Lit {
atom: b,
negated: false
}));
}
#[test]
fn consequent_or_is_one_clause_with_all_negated() {
let src = r#"
PREMISE r:
WHEN x p
THEN x a
OR x b
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 1);
let cl = &c.clauses[0];
assert_eq!(cl.lits.len(), 3);
let p = id(&c, &key("x", "p", None));
let a = id(&c, &key("x", "a", None));
let b = id(&c, &key("x", "b", None));
assert!(cl.lits.contains(&Lit {
atom: p,
negated: false
}));
assert!(cl.lits.contains(&Lit {
atom: a,
negated: true
}));
assert!(cl.lits.contains(&Lit {
atom: b,
negated: true
}));
}
#[test]
fn antecedent_or_is_one_clause_per_disjunct() {
let src = r#"
PREMISE r:
WHEN x a
OR x b
THEN x c
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 2);
let a = id(&c, &key("x", "a", None));
let b = id(&c, &key("x", "b", None));
let cc = id(&c, &key("x", "c", None));
for cl in &c.clauses {
assert_eq!(cl.lits.len(), 2);
assert!(cl.lits.contains(&Lit {
atom: cc,
negated: true
}));
}
let has = |atom| {
c.clauses.iter().any(|cl| {
cl.lits.contains(&Lit {
atom,
negated: false,
})
})
};
assert!(has(a) && has(b));
}
#[test]
fn antecedent_or_with_consequent_or_distributes() {
let src = r#"
PREMISE r:
WHEN x a
OR x b
THEN x c
OR x d
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 2);
for cl in &c.clauses {
assert_eq!(cl.lits.len(), 3);
}
}
#[test]
fn rule_with_or_antecedent_splits_into_two_rules() {
let src = r#"
RULE r:
WHEN x a
OR x b
THEN x c
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.rules.len(), 2);
assert!(
c.rules
.iter()
.all(|r| r.antecedent.len() == 1 && r.consequent.len() == 1)
);
}
#[test]
fn rule_with_or_consequent_is_rejected() {
let src = r#"
RULE r:
WHEN x a
THEN x b
OR x c
"#;
let err = compile_source("<t>", src).unwrap_err();
assert!(matches!(
err,
CompileError::RuleDisjunctiveConsequent { .. }
));
}
#[test]
fn oneof_is_pairwise_plus_at_least_one() {
let src = r#"
PREMISE o:
ONEOF
x a
x b
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 2);
assert!(c.clauses.iter().any(|cl| cl.lits.iter().all(|l| l.negated)));
}
#[test]
fn atleast_is_one_negated_clause() {
let src = r#"
PREMISE a:
ATLEAST
x a
x b
x c
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 1);
assert_eq!(c.clauses[0].lits.len(), 3);
assert!(c.clauses[0].lits.iter().all(|l| l.negated));
}
#[test]
fn rules_are_separate_from_clauses() {
let src = r#"
RULE needs:
WHEN x a
THEN x b
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 0);
assert_eq!(c.rules.len(), 1);
assert_eq!(c.rules[0].antecedent.len(), 1);
assert_eq!(c.rules[0].consequent.len(), 1);
}
#[test]
fn atoms_are_canonically_sorted() {
let src = r#"
FACT z z
FACT a a
FACT m m
"#;
let c = compile_source("<t>", src).unwrap();
let mut sorted = c.atoms.clone();
sorted.sort();
assert_eq!(c.atoms, sorted);
}
#[test]
fn duplicate_premise_is_idempotent() {
let src = r#"
PREMISE e:
EXCLUSIVE
x a
x b
PREMISE e:
EXCLUSIVE
x a
x b
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 1);
}
#[test]
fn redefinition_with_different_body_errors() {
let src = r#"
PREMISE e:
EXCLUSIVE
x a
x b
PREMISE e:
EXCLUSIVE
x a
x c
"#;
let err = compile_source("<t>", src).unwrap_err();
assert_eq!(
err,
CompileError::PremiseRedefinition {
name: "e".to_string()
}
);
}
#[test]
fn duplicate_fact_is_idempotent() {
let c = compile_source("<t>", "FACT x a\nFACT x a\n").unwrap();
assert_eq!(c.facts.len(), 1);
}
#[test]
fn conflicting_facts_are_both_kept() {
let c = compile_source("<t>", "FACT x a\nNOT x a\n").unwrap();
assert_eq!(c.facts.len(), 2);
}
#[test]
fn import_is_recorded_pending() {
let c = compile_source("<t>", "IMPORT \"physics.vrf\"\nFACT x a\n").unwrap();
assert_eq!(c.pending_imports, vec!["physics.vrf".to_string()]);
}
#[test]
fn import_flat_merges_and_atoms_unify() {
let mut r = MemoryResolver::new();
r.add(
"lib.vrf",
r#"
PREMISE needs_fuel:
WHEN Engine.X has engine
THEN Engine.X has fuel
"#,
);
r.add(
"main.vrf",
r#"
IMPORT "lib.vrf"
FACT Engine.X has engine
FACT Engine.X has fuel
"#,
);
let c = compile("main.vrf", &r).unwrap();
assert!(c.pending_imports.is_empty());
assert_eq!(c.clauses.len(), 1); assert_eq!(c.facts.len(), 2);
let fuel = key("Engine.X", "has", Some("fuel"));
let fuel_id = id(&c, &fuel);
assert!(c.facts.iter().any(|f| f.atom == fuel_id));
assert!(c.clauses[0].lits.iter().any(|l| l.atom == fuel_id));
}
#[test]
fn diamond_import_is_deduped() {
let mut r = MemoryResolver::new();
r.add(
"base.vrf",
r#"
PREMISE b:
EXCLUSIVE
x a
x b
"#,
);
r.add("a.vrf", "IMPORT \"base.vrf\"\n");
r.add("c.vrf", "IMPORT \"base.vrf\"\n");
r.add("main.vrf", "IMPORT \"a.vrf\"\nIMPORT \"c.vrf\"\n");
let c = compile("main.vrf", &r).unwrap();
assert_eq!(c.clauses.len(), 1); }
#[test]
fn circular_import_errors() {
let mut r = MemoryResolver::new();
r.add("a.vrf", "IMPORT \"b.vrf\"\n");
r.add("b.vrf", "IMPORT \"a.vrf\"\n");
let err = compile("a.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::CircularImport(_)));
}
#[test]
fn missing_import_errors() {
let mut r = MemoryResolver::new();
r.add("main.vrf", "IMPORT \"ghost.vrf\"\n");
let err = compile("main.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::ImportNotFound(_)));
}
#[test]
fn same_name_across_domains_coexists() {
let mut r = MemoryResolver::new();
r.add(
"physics.vrf",
r#"
PREMISE safety:
EXCLUSIVE
x a
x b
"#,
);
r.add(
"main.vrf",
r#"
IMPORT "physics.vrf"
PREMISE safety:
EXCLUSIVE
x a
x c
"#,
);
let c = compile("main.vrf", &r).unwrap();
assert_eq!(c.clauses.len(), 2); assert!(c.clauses.iter().any(|cl| cl.origin.source == "physics.vrf"));
assert!(c.clauses.iter().any(|cl| cl.origin.source == "main.vrf"));
}
#[test]
fn two_libs_same_name_into_one_consumer() {
let mut r = MemoryResolver::new();
r.add(
"A.vrf",
r#"
PREMISE x:
EXCLUSIVE
S has a
S has b
"#,
);
r.add(
"B.vrf",
r#"
PREMISE x:
EXCLUSIVE
S has a
S has c
"#,
);
r.add("C.vrf", "IMPORT \"A.vrf\"\nIMPORT \"B.vrf\"\n");
let c = compile("C.vrf", &r).unwrap();
assert_eq!(c.clauses.len(), 2);
assert!(
c.clauses
.iter()
.any(|cl| cl.origin.source == "A.vrf" && cl.origin.premise.as_deref() == Some("x"))
);
assert!(
c.clauses
.iter()
.any(|cl| cl.origin.source == "B.vrf" && cl.origin.premise.as_deref() == Some("x"))
);
let s_a = id(&c, &key("S", "has", Some("a")));
assert!(
c.clauses
.iter()
.filter(|cl| cl.lits.iter().any(|l| l.atom == s_a))
.count()
== 2,
"both clauses must reference the same unified `S has a` atom"
);
}
#[test]
fn redefinition_within_one_source_still_errors() {
let src = r#"
PREMISE e:
EXCLUSIVE
x a
x b
PREMISE e:
EXCLUSIVE
x a
x c
"#;
let err = compile_source("main.vrf", src).unwrap_err();
assert_eq!(
err,
CompileError::PremiseRedefinition {
name: "e".to_string()
}
);
}
#[test]
fn import_demo_examples_resolve() {
let mut r = MemoryResolver::new();
r.add(
"physics.vrf",
include_str!("../../../docs/examples/physics.vrf"),
);
r.add(
"import-demo.vrf",
include_str!("../../../docs/examples/import-demo.vrf"),
);
let c = compile("import-demo.vrf", &r).unwrap();
assert!(c.pending_imports.is_empty());
assert_eq!(c.clauses.len(), 2);
let over_100 = id(&c, &key("Motor", "over_100", None));
assert!(c.facts.iter().any(|f| f.atom == over_100));
assert!(
c.clauses
.iter()
.any(|cl| cl.lits.iter().any(|l| l.atom == over_100))
);
}
#[test]
fn creature_example_compiles() {
let src = include_str!("../../../docs/examples/creature.vrf");
let c = compile_source("creature.vrf", src).unwrap();
assert_eq!(c.facts.len(), 2); assert_eq!(c.rules.len(), 1); assert_eq!(c.checks.len(), 1);
assert_eq!(c.clauses.len(), 4);
assert_eq!(c.atoms.len(), 7);
}
#[test]
fn forbids_unfolds_pairwise() {
let src = r#"
PREMISE f:
FORBIDS
x a
x b
x c
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 3); assert!(
c.clauses
.iter()
.all(|cl| cl.lits.len() == 2 && cl.lits.iter().all(|l| !l.negated))
);
}
#[test]
fn rule_with_multiple_consequents() {
let src = r#"
RULE r:
WHEN x a
THEN x b
AND x c
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.rules.len(), 1);
assert_eq!(c.rules[0].consequent.len(), 2);
}
#[test]
fn negated_antecedent_literal_keeps_polarity() {
let src = r#"
PREMISE a:
WHEN NOT x a
THEN x b
"#;
let c = compile_source("<t>", src).unwrap();
let xa = id(&c, &key("x", "a", None));
assert!(c.clauses[0].lits.contains(&Lit {
atom: xa,
negated: true
}));
}
#[test]
fn rule_keeps_consequent_negation() {
let src = r#"
RULE r:
WHEN x a
THEN NOT x b
"#;
let c = compile_source("<t>", src).unwrap();
assert!(c.rules[0].consequent[0].negated);
}
#[test]
fn compilation_is_deterministic() {
let src = r#"
PREMISE e:
EXCLUSIVE
z z
a a
m m
FACT q q
"#;
assert_eq!(
compile_source("<t>", src).unwrap(),
compile_source("<t>", src).unwrap()
);
}
#[test]
fn empty_program_compiles_to_empty_ir() {
let c = compile_source("<t>", "// nothing here\n").unwrap();
assert!(c.atoms.is_empty() && c.clauses.is_empty() && c.facts.is_empty());
}
#[test]
fn same_clause_from_two_named_premises_is_deduped() {
let src = r#"
PREMISE e1:
EXCLUSIVE
x a
x b
PREMISE e2:
EXCLUSIVE
x a
x b
"#;
let c = compile_source("<t>", src).unwrap();
assert_eq!(c.clauses.len(), 1);
}
#[test]
fn object_distinguishes_atom_identity() {
let c = compile_source("<t>", "FACT x p a\nFACT x p b\n").unwrap();
assert_eq!(c.atoms.len(), 2);
}
}