#![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 _;
pub use elenchus_parser::Diagnostics;
use elenchus_parser::{Atom, Body, Conn, ListOp, Literal, Statement, kw};
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 domain: String,
pub subject: String,
pub predicate: String,
pub object: Option<String>,
}
struct DomainCtx {
current: String,
aliases: BTreeMap<String, String>,
}
impl DomainCtx {
fn resolve(&self, prefix: Option<&str>) -> Result<String, CompileError> {
match prefix {
None => Ok(self.current.clone()),
Some(p) => self
.aliases
.get(p)
.cloned()
.ok_or_else(|| CompileError::UnknownDomain {
domain: p.to_string(),
}),
}
}
fn key(&self, a: &Atom) -> Result<AtomKey, CompileError> {
Ok(AtomKey {
domain: self.resolve(a.domain)?,
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>,
pub unused_imports: Vec<UnusedImport>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct UnusedImport {
pub file: String,
pub domain: String,
pub alias: Option<String>,
pub line: u32,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum CompileError {
#[error("{0}")]
Parse(elenchus_parser::Diagnostics),
#[error("'{name}' redefined with a different body")]
PremiseRedefinition {
name: String,
},
#[error("{file}: missing a DOMAIN declaration (every file must start with `DOMAIN <name>`)")]
MissingDomain {
file: String,
},
#[error("{file}: more than one DOMAIN declaration (a file has exactly one domain)")]
DuplicateDomain {
file: String,
},
#[error("unknown domain '{domain}' — declare it with DOMAIN, or IMPORT it in this file")]
UnknownDomain {
domain: String,
},
#[error("domain name '{alias}' is bound to two different imports (disambiguate with AS)")]
DomainAliasClash {
alias: 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(|mut diag| {
diag.set_file(source);
CompileError::Parse(diag)
})?;
let domain = extract_domain(&program, source)?;
let mut aliases = BTreeMap::new();
aliases.insert(domain.clone(), domain.clone());
let ctx = DomainCtx {
current: domain,
aliases,
};
for stmt in &program.statements {
match stmt {
Statement::Domain(_) => {}
Statement::Import { path, .. } => {
self.pending_imports.push(path.data.to_string());
}
other => self.add_statement(source, other, &ctx)?,
}
}
Ok(())
}
fn add_resolved(&mut self, file: &ResolvedFile) -> Result<(), CompileError> {
let program = elenchus_parser::parse(&file.content).map_err(|mut diag| {
diag.set_file(&file.path);
CompileError::Parse(diag)
})?;
for stmt in &program.statements {
match stmt {
Statement::Import { .. } | Statement::Domain(_) => {}
other => self.add_statement(&file.path, other, &file.ctx)?,
}
}
Ok(())
}
fn add_statement(
&mut self,
source: &str,
stmt: &Statement,
ctx: &DomainCtx,
) -> Result<(), CompileError> {
match stmt {
Statement::Import { .. } | Statement::Domain(_) => {}
Statement::Fact(a) => self.add_fact(source, a, Value::True, kw::FACT, false, ctx)?,
Statement::Negation(a) => {
self.add_fact(source, a, Value::False, kw::NOT, false, ctx)?
}
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, kw::ASSUME, true, ctx)?;
}
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, ctx)?;
}
Statement::Rule { name, body } => {
let line = name.span.location_line();
self.add_named(source, name.data, line, body, true, ctx)?;
}
}
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,
ctx: &DomainCtx,
) -> Result<(), CompileError> {
let key = ctx.key(&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 Ok(()); }
self.facts.push(RawFact {
key,
value,
origin: Origin {
source: source.to_string(),
line: a.span.location_line(),
premise: None,
kind,
},
soft,
});
Ok(())
}
fn add_named(
&mut self,
source: &str,
name: &str,
line: u32,
body: &Body,
is_rule: bool,
ctx: &DomainCtx,
) -> Result<(), CompileError> {
let body_hash = hash_hex(canonical_body(name, body, is_rule, ctx)?.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, ctx)?, raw_lits(consequent, ctx)?);
for l in ante.iter().chain(cons.iter()) {
self.intern(&l.key);
}
let origin = self.origin(source, line, Some(name), kw::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| ctx.key(&a.data))
.collect::<Result<_, _>>()?;
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, ctx)?;
let cons = raw_lits(consequent, ctx)?;
for l in ante.iter().chain(cons.iter()) {
self.intern(&l.key);
}
let origin = self.origin(source, line, Some(name), kw::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,
unused_imports: Vec::new(), }
}
}
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('\\', "/")
}
}
struct ResolvedFile {
path: String,
content: String,
ctx: DomainCtx,
}
pub fn compile<R: Resolver>(root: &str, resolver: &R) -> Result<Compiled, CompileError> {
let (files, unused_imports) = resolve_graph(root, resolver)?;
let mut c = Compiler::new();
for file in &files {
c.add_resolved(file)?;
}
let mut compiled = c.finalize();
compiled.unused_imports = unused_imports;
Ok(compiled)
}
struct ImportEdge {
alias: Option<String>,
child_path: String,
line: u32,
}
struct DiscoveredFile {
path: String,
content: String,
domain: String,
edges: Vec<ImportEdge>,
used_prefixes: BTreeSet<Option<String>>,
}
fn resolve_graph<R: Resolver>(
root: &str,
resolver: &R,
) -> Result<(Vec<ResolvedFile>, Vec<UnusedImport>), CompileError> {
enum Step {
Enter(String),
Exit(String),
}
let mut discovered: BTreeMap<String, DiscoveredFile> = BTreeMap::new(); let mut path_hash: BTreeMap<String, String> = BTreeMap::new(); let mut order: Vec<String> = Vec::new(); let mut active: BTreeSet<String> = BTreeSet::new(); let mut work: Vec<Step> = vec![Step::Enter(root.to_string())];
while let Some(step) = work.pop() {
match step {
Step::Exit(hash) => {
active.remove(&hash);
order.push(hash);
}
Step::Enter(path) => {
let content = resolver.load(&path)?;
let hash = hash_hex(content.as_bytes());
path_hash.insert(path.clone(), hash.clone());
if active.contains(&hash) {
return Err(CompileError::CircularImport(path)); }
if discovered.contains_key(&hash) {
continue; }
let program = elenchus_parser::parse(&content).map_err(|mut diag| {
diag.set_file(&path);
CompileError::Parse(diag)
})?;
let domain = extract_domain(&program, &path)?;
let mut edges = Vec::new();
let mut used_prefixes = BTreeSet::new();
for stmt in &program.statements {
if let Statement::Import { path: p, alias } = stmt {
edges.push(ImportEdge {
alias: alias.as_ref().map(|a| a.data.to_string()),
child_path: resolver.resolve(&path, p.data),
line: p.span.location_line(),
});
} else {
collect_prefixes(stmt, &mut used_prefixes);
}
}
drop(program); active.insert(hash.clone());
work.push(Step::Exit(hash.clone()));
for e in edges.iter().rev() {
work.push(Step::Enter(e.child_path.clone()));
}
discovered.insert(
hash,
DiscoveredFile {
path,
content,
domain,
edges,
used_prefixes,
},
);
}
}
}
let domain_of: BTreeMap<&str, &str> = discovered
.iter()
.map(|(h, f)| (h.as_str(), f.domain.as_str()))
.collect();
let mut out = Vec::with_capacity(order.len());
let mut unused: Vec<UnusedImport> = Vec::new();
for hash in &order {
let file = &discovered[hash];
let mut aliases = BTreeMap::new();
aliases.insert(file.domain.clone(), file.domain.clone());
for edge in &file.edges {
let child_domain = domain_of[path_hash[&edge.child_path].as_str()];
let bind = edge
.alias
.clone()
.unwrap_or_else(|| child_domain.to_string());
match aliases.get(&bind) {
Some(existing) if existing != child_domain => {
return Err(CompileError::DomainAliasClash { alias: bind });
}
_ => {
aliases.insert(bind, child_domain.to_string());
}
}
}
let referenced: BTreeSet<&str> = file
.used_prefixes
.iter()
.filter_map(|p| match p {
None => Some(file.domain.as_str()),
Some(name) => aliases.get(name).map(|d| d.as_str()),
})
.collect();
for edge in &file.edges {
let child_domain = domain_of[path_hash[&edge.child_path].as_str()];
if !referenced.contains(child_domain) {
unused.push(UnusedImport {
file: file.path.clone(),
domain: child_domain.to_string(),
alias: edge.alias.clone(),
line: edge.line,
});
}
}
let ctx = DomainCtx {
current: file.domain.clone(),
aliases,
};
out.push((hash.clone(), ctx));
}
unused.sort();
let files = out
.into_iter()
.map(|(hash, ctx)| {
let file = discovered.remove(&hash).expect("hash was discovered");
ResolvedFile {
path: file.path,
content: file.content,
ctx,
}
})
.collect();
Ok((files, unused))
}
fn collect_prefixes(stmt: &Statement, out: &mut BTreeSet<Option<String>>) {
let mut add = |a: &Atom| {
out.insert(a.domain.map(|d| d.to_string()));
};
match stmt {
Statement::Fact(a) | Statement::Negation(a) => add(&a.data),
Statement::Assume(l) => add(&l.data.atom),
Statement::Premise { body, .. } | Statement::Rule { body, .. } => match body {
Body::List { atoms, .. } => atoms.iter().for_each(|a| add(&a.data)),
Body::Impl {
antecedent,
consequent,
..
} => antecedent
.iter()
.chain(consequent)
.for_each(|l| add(&l.data.atom)),
},
Statement::Domain(_) | Statement::Import { .. } | Statement::Check { .. } => {}
}
}
fn extract_domain(
program: &elenchus_parser::Program,
source: &str,
) -> Result<String, CompileError> {
let mut found: Option<String> = None;
for stmt in &program.statements {
if let Statement::Domain(name) = stmt {
if found.is_some() {
return Err(CompileError::DuplicateDomain {
file: source.to_string(),
});
}
found = Some(name.data.to_string());
}
}
found.ok_or_else(|| CompileError::MissingDomain {
file: source.to_string(),
})
}
fn raw_lits(
lits: &[elenchus_parser::Located<Literal>],
ctx: &DomainCtx,
) -> Result<Vec<RawLit>, CompileError> {
lits.iter()
.map(|l| {
Ok(RawLit {
key: ctx.key(&l.data.atom)?,
negated: l.data.negated,
})
})
.collect()
}
fn list_kind(op: ListOp) -> &'static str {
match op {
ListOp::Exclusive => kw::EXCLUSIVE,
ListOp::Forbids => kw::FORBIDS,
ListOp::OneOf => kw::ONEOF,
ListOp::AtLeast => kw::ATLEAST,
}
}
fn key_sig(k: &AtomKey) -> String {
alloc::format!(
"{}|{}|{}|{}",
k.domain,
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,
ctx: &DomainCtx,
) -> Result<String, CompileError> {
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| Ok(key_sig(&ctx.key(&a.data)?)))
.collect::<Result<_, CompileError>>()?;
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, ctx)?);
s.push_str("|CONS|");
s.push_str(conn(cons_conn));
s.push('|');
s.push_str(&lit_sigs(consequent, ctx)?);
}
}
Ok(s)
}
fn lit_sigs(
lits: &[elenchus_parser::Located<Literal>],
ctx: &DomainCtx,
) -> Result<String, CompileError> {
let mut parts: Vec<String> = lits
.iter()
.map(|l| {
Ok(alloc::format!(
"{}|{}",
key_sig(&ctx.key(&l.data.atom)?),
l.data.negated as u8
))
})
.collect::<Result<_, CompileError>>()?;
parts.sort();
Ok(parts.join(";"))
}
#[cfg(test)]
mod tests {
use super::*;
fn cs(src: &str) -> Result<Compiled, CompileError> {
compile_source("<t>", &alloc::format!("DOMAIN t\n{src}"))
}
fn key(subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
key_in("t", subject, predicate, object)
}
fn key_in(domain: &str, subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
AtomKey {
domain: domain.to_string(),
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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(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 = cs(src).unwrap_err();
assert_eq!(
err,
CompileError::PremiseRedefinition {
name: "e".to_string()
}
);
}
#[test]
fn duplicate_fact_is_idempotent() {
let c = cs("FACT x a\nFACT x a\n").unwrap();
assert_eq!(c.facts.len(), 1);
}
#[test]
fn conflicting_facts_are_both_kept() {
let c = cs("FACT x a\nNOT x a\n").unwrap();
assert_eq!(c.facts.len(), 2);
}
#[test]
fn import_is_recorded_pending() {
let c = cs("IMPORT \"physics.vrf\"\nFACT x a\n").unwrap();
assert_eq!(c.pending_imports, vec!["physics.vrf".to_string()]);
}
#[test]
fn qualified_fact_lands_in_the_imported_domain() {
let mut r = MemoryResolver::new();
r.add(
"lib.vrf",
r#"
DOMAIN physics
PREMISE needs_fuel:
WHEN Engine_X has engine
THEN Engine_X has fuel
"#,
);
r.add(
"main.vrf",
r#"
DOMAIN main
IMPORT "lib.vrf"
FACT physics.Engine_X has engine
FACT physics.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_in("physics", "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 same_triple_in_different_domains_does_not_unify() {
let mut r = MemoryResolver::new();
r.add("lib.vrf", "DOMAIN physics\nFACT Engine_X has fuel\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"lib.vrf\"\nFACT Engine_X has fuel\n",
);
let c = compile("main.vrf", &r).unwrap();
assert!(c.atoms.iter().any(|a| a.domain == "physics"));
assert!(c.atoms.iter().any(|a| a.domain == "main"));
assert_eq!(
c.atoms
.iter()
.filter(|a| a.subject == "Engine_X" && a.predicate == "has")
.count(),
2
);
}
#[test]
fn import_alias_binds_a_local_domain_name() {
let mut r = MemoryResolver::new();
r.add("lib.vrf", "DOMAIN physics\nFACT Motor over_200\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"lib.vrf\" AS phys\nFACT phys.Motor over_100\n",
);
let c = compile("main.vrf", &r).unwrap();
assert_eq!(c.atoms.iter().filter(|a| a.domain == "physics").count(), 2);
}
#[test]
fn unknown_domain_reference_errors() {
let err = cs("FACT ghost.x a\n").unwrap_err();
assert!(matches!(err, CompileError::UnknownDomain { .. }));
}
#[test]
fn imports_are_not_transitive_for_naming() {
let mut r = MemoryResolver::new();
r.add("math.vrf", "DOMAIN math\nFACT foo bar\n");
r.add(
"physics.vrf",
"DOMAIN physics\nIMPORT \"math.vrf\"\nFACT Motor over_100\n",
);
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"physics.vrf\"\nFACT math.foo bar\n",
);
let err = compile("main.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::UnknownDomain { .. }));
}
#[test]
fn transitive_dependency_clauses_still_load() {
let mut r = MemoryResolver::new();
r.add(
"math.vrf",
"DOMAIN math\nPREMISE e:\n EXCLUSIVE\n x a\n x b\n",
);
r.add("physics.vrf", "DOMAIN physics\nIMPORT \"math.vrf\"\n");
r.add("main.vrf", "DOMAIN main\nIMPORT \"physics.vrf\"\n");
let c = compile("main.vrf", &r).unwrap();
assert_eq!(c.clauses.len(), 1); assert!(c.clauses.iter().all(|cl| cl.origin.source == "math.vrf"));
}
#[test]
fn missing_domain_errors() {
let err = compile_source("nodomain.vrf", "FACT x a\n").unwrap_err();
assert!(matches!(err, CompileError::MissingDomain { .. }));
}
#[test]
fn duplicate_domain_errors() {
let err = compile_source("dup.vrf", "DOMAIN a\nDOMAIN b\nFACT x a\n").unwrap_err();
assert!(matches!(err, CompileError::DuplicateDomain { .. }));
}
#[test]
fn alias_clash_when_one_local_name_binds_two_domains() {
let mut r = MemoryResolver::new();
r.add("a.vrf", "DOMAIN physics\nFACT Motor over_100\n");
r.add("b.vrf", "DOMAIN chemistry\nFACT atom reacts\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"a.vrf\" AS x\nIMPORT \"b.vrf\" AS x\n",
);
let err = compile("main.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::DomainAliasClash { .. }));
}
#[test]
fn two_files_with_the_same_domain_name_merge() {
let mut r = MemoryResolver::new();
r.add("a.vrf", "DOMAIN physics\nFACT Motor over_100\n");
r.add(
"main.vrf",
"DOMAIN physics\nIMPORT \"a.vrf\"\nFACT Motor over_200\n",
);
let c = compile("main.vrf", &r).unwrap();
assert!(c.atoms.iter().all(|a| a.domain == "physics"));
assert_eq!(c.atoms.len(), 2);
}
#[test]
fn diamond_import_is_deduped() {
let mut r = MemoryResolver::new();
r.add(
"base.vrf",
r#"
DOMAIN base
PREMISE b:
EXCLUSIVE
x a
x b
"#,
);
r.add("a.vrf", "DOMAIN a\nIMPORT \"base.vrf\"\n");
r.add("c.vrf", "DOMAIN c\nIMPORT \"base.vrf\"\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"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", "DOMAIN a\nIMPORT \"b.vrf\"\n");
r.add("b.vrf", "DOMAIN b\nIMPORT \"a.vrf\"\n");
let err = compile("a.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::CircularImport(_)));
}
#[test]
fn three_node_cycle_errors() {
let mut r = MemoryResolver::new();
r.add("a.vrf", "DOMAIN a\nIMPORT \"b.vrf\"\n");
r.add("b.vrf", "DOMAIN b\nIMPORT \"c.vrf\"\n");
r.add("c.vrf", "DOMAIN c\nIMPORT \"a.vrf\"\n");
let err = compile("a.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::CircularImport(_)));
}
#[test]
fn shared_grandchild_diamond_loads_once() {
let mut r = MemoryResolver::new();
r.add(
"b.vrf",
"DOMAIN b\nPREMISE e:\n EXCLUSIVE\n x a\n x b\n",
);
r.add("c.vrf", "DOMAIN c\nIMPORT \"b.vrf\"\n");
r.add("a.vrf", "DOMAIN a\nIMPORT \"b.vrf\"\nIMPORT \"c.vrf\"\n");
let c = compile("a.vrf", &r).unwrap();
assert_eq!(
c.clauses.len(),
1,
"b.vrf's clause must appear exactly once"
);
}
#[test]
fn exponential_fan_out_is_memoized_not_blown_up() {
let mut r = MemoryResolver::new();
r.add("f0.vrf", "DOMAIN d0\nFACT x a\n");
let n = 40;
for k in 1..=n {
r.add(
&alloc::format!("f{k}.vrf"),
&alloc::format!(
"DOMAIN d{k}\nIMPORT \"f{}.vrf\"\nIMPORT \"f{}.vrf\"\n",
k - 1,
k - 1
),
);
}
let c = compile(&alloc::format!("f{n}.vrf"), &r).unwrap();
assert_eq!(c.facts.len(), 1); }
#[test]
fn very_deep_linear_chain_does_not_overflow() {
let mut r = MemoryResolver::new();
r.add("f0.vrf", "DOMAIN d0\nFACT x a\n");
let n = 10_000;
for k in 1..=n {
r.add(
&alloc::format!("f{k}.vrf"),
&alloc::format!("DOMAIN d{k}\nIMPORT \"f{}.vrf\"\n", k - 1),
);
}
let c = compile(&alloc::format!("f{n}.vrf"), &r).unwrap();
assert_eq!(c.facts.len(), 1);
}
#[test]
fn missing_import_errors() {
let mut r = MemoryResolver::new();
r.add("main.vrf", "DOMAIN main\nIMPORT \"ghost.vrf\"\n");
let err = compile("main.vrf", &r).unwrap_err();
assert!(matches!(err, CompileError::ImportNotFound(_)));
}
#[test]
fn unused_import_is_flagged() {
let mut r = MemoryResolver::new();
r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\n",
);
let c = compile("main.vrf", &r).unwrap();
assert_eq!(c.unused_imports.len(), 1);
assert_eq!(c.unused_imports[0].domain, "physics");
assert_eq!(c.unused_imports[0].file, "main.vrf");
assert_eq!(c.unused_imports[0].alias, None);
}
#[test]
fn referenced_import_is_not_unused() {
let mut r = MemoryResolver::new();
r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"physics.vrf\"\nFACT physics.Motor over_200\n",
);
let c = compile("main.vrf", &r).unwrap();
assert!(c.unused_imports.is_empty(), "{:?}", c.unused_imports);
}
#[test]
fn unused_import_records_its_alias() {
let mut r = MemoryResolver::new();
r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"physics.vrf\" AS phys\nFACT x a\n",
);
let c = compile("main.vrf", &r).unwrap();
assert_eq!(c.unused_imports.len(), 1);
assert_eq!(c.unused_imports[0].alias.as_deref(), Some("phys"));
}
#[test]
fn import_referenced_only_inside_a_premise_is_used() {
let mut r = MemoryResolver::new();
r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
r.add(
"main.vrf",
"DOMAIN main\nIMPORT \"physics.vrf\"\nPREMISE p:\n WHEN physics.Motor over_100\n THEN x ok\n",
);
let c = compile("main.vrf", &r).unwrap();
assert!(c.unused_imports.is_empty(), "{:?}", c.unused_imports);
}
#[test]
fn same_premise_name_across_files_coexists() {
let mut r = MemoryResolver::new();
r.add(
"physics.vrf",
r#"
DOMAIN physics
PREMISE safety:
EXCLUSIVE
x a
x b
"#,
);
r.add(
"main.vrf",
r#"
DOMAIN main
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 redefinition_within_one_source_still_errors() {
let src = r#"
DOMAIN m
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_in("physics", "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 = cs(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 = cs(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 = cs(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 = cs(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!(cs(src).unwrap(), cs(src).unwrap());
}
#[test]
fn empty_program_compiles_to_empty_ir() {
let c = cs("// 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 = cs(src).unwrap();
assert_eq!(c.clauses.len(), 1);
}
#[test]
fn object_distinguishes_atom_identity() {
let c = cs("FACT x p a\nFACT x p b\n").unwrap();
assert_eq!(c.atoms.len(), 2);
}
}