#![no_std]
#![warn(missing_docs)]
extern crate alloc;
#[cfg(feature = "std")]
extern crate std;
pub mod sat;
use alloc::collections::BTreeSet;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use core::fmt;
pub use elenchus_compiler::Diagnostics;
use elenchus_compiler::{
AtomId, AtomKey, Clause, Compiled, Fact, KIND_UNSAT, Lit, Origin, Rule, Value, kw, levenshtein,
};
pub use elenchus_compiler::{
CompileError, MemoryResolver, Resolver, UnusedImport, compile, compile_source,
};
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum V3 {
True,
False,
Unknown,
}
impl V3 {
fn not(self) -> V3 {
match self {
V3::True => V3::False,
V3::False => V3::True,
V3::Unknown => V3::Unknown,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Consistent,
Underdetermined,
Warning,
Conflict,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Conflict {
pub origin: Origin,
pub atoms: Vec<String>,
pub trace: Vec<TraceStep>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceStep {
pub atom: String,
pub value: Value,
pub reason: TraceReason,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TraceReason {
Asserted(Origin),
Derived {
origin: Origin,
from: Vec<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Warning {
pub origin: Origin,
pub blocked_by: Vec<String>,
pub hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Derived {
pub atom: String,
pub value: Value,
pub origin: Origin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Report {
pub status: Status,
pub conflicts: Vec<Conflict>,
pub warnings: Vec<Warning>,
pub derived: Vec<Derived>,
pub underdetermined: Option<String>,
pub unsat_core: Vec<CoreItem>,
pub retract: Vec<CoreItem>,
pub hints: Vec<SimilarAtoms>,
pub orphans: Vec<OrphanFact>,
pub unused_imports: Vec<UnusedImport>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SimilarAtoms {
pub a: String,
pub b: String,
pub reason: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrphanFact {
pub atom: String,
pub value: Value,
pub origin: Origin,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoreItem {
pub origin: Origin,
pub label: String,
}
impl Report {
pub fn exit_code(&self) -> i32 {
match self.status {
Status::Conflict => 2,
Status::Underdetermined | Status::Warning => 1,
Status::Consistent => 0,
}
}
pub fn to_json(&self) -> String {
use core::fmt::Write as _;
let mut s = String::new();
let _ = write!(s, "{{\"status\":");
json_str(status_name(self.status), &mut s);
let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
s.push_str(",\"conflicts\":[");
for (i, c) in self.conflicts.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_origin(&c.origin, &mut s);
s.push_str(",\"atoms\":");
json_array(&c.atoms, &mut s);
s.push_str(",\"trace\":[");
for (j, step) in c.trace.iter().enumerate() {
if j > 0 {
s.push(',');
}
json_trace_step(step, &mut s);
}
s.push_str("]}");
}
s.push_str("],\"warnings\":[");
for (i, w) in self.warnings.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_origin(&w.origin, &mut s);
s.push_str(",\"blocked_by\":");
json_array(&w.blocked_by, &mut s);
s.push_str(",\"hint\":");
match &w.hint {
Some(h) => json_str(h, &mut s),
None => s.push_str("null"),
}
s.push('}');
}
s.push_str("],\"derived\":[");
for (i, d) in self.derived.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push('{');
json_origin_fields(&d.origin, &mut s);
s.push_str(",\"atom\":");
json_str(&d.atom, &mut s);
let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
s.push('}');
}
s.push_str("],\"underdetermined\":");
match &self.underdetermined {
Some(atom) => json_str(atom, &mut s),
None => s.push_str("null"),
}
s.push_str(",\"unsat_core\":[");
for (i, it) in self.unsat_core.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_origin(&it.origin, &mut s);
s.push_str(",\"label\":");
json_str(&it.label, &mut s);
s.push('}');
}
s.push_str("],\"retract\":[");
for (i, it) in self.retract.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_origin(&it.origin, &mut s);
s.push_str(",\"label\":");
json_str(&it.label, &mut s);
s.push('}');
}
s.push_str("],\"hints\":[");
for (i, h) in self.hints.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str("{\"a\":");
json_str(&h.a, &mut s);
s.push_str(",\"b\":");
json_str(&h.b, &mut s);
s.push_str(",\"reason\":");
json_str(h.reason, &mut s);
s.push('}');
}
s.push_str("],\"orphans\":[");
for (i, o) in self.orphans.iter().enumerate() {
if i > 0 {
s.push(',');
}
json_origin(&o.origin, &mut s);
s.push_str(",\"atom\":");
json_str(&o.atom, &mut s);
let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
s.push('}');
}
s.push_str("],\"unused_imports\":[");
for (i, u) in self.unused_imports.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str("{\"file\":");
json_str(&u.file, &mut s);
s.push_str(",\"domain\":");
json_str(&u.domain, &mut s);
s.push_str(",\"alias\":");
match &u.alias {
Some(a) => json_str(a, &mut s),
None => s.push_str("null"),
}
let _ = write!(s, ",\"line\":{}", u.line);
s.push('}');
}
s.push_str("]}");
s
}
}
fn json_trace_step(step: &TraceStep, out: &mut String) {
use core::fmt::Write as _;
out.push_str("{\"atom\":");
json_str(&step.atom, out);
let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
match &step.reason {
TraceReason::Asserted(o) => {
out.push_str(",\"how\":\"asserted\",");
json_origin_fields(o, out);
out.push_str(",\"from\":[]");
}
TraceReason::Derived { origin, from } => {
out.push_str(",\"how\":\"derived\",");
json_origin_fields(origin, out);
out.push_str(",\"from\":");
json_array(from, out);
}
}
out.push('}');
}
fn status_name(s: Status) -> &'static str {
match s {
Status::Consistent => "CONSISTENT",
Status::Underdetermined => "UNDERDETERMINED",
Status::Warning => "WARNING",
Status::Conflict => "CONFLICT",
}
}
fn json_str(value: &str, out: &mut String) {
use core::fmt::Write as _;
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
}
fn json_array(items: &[String], out: &mut String) {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
json_str(item, out);
}
out.push(']');
}
fn json_origin_fields(o: &Origin, out: &mut String) {
use core::fmt::Write as _;
out.push_str("\"premise\":");
match &o.premise {
Some(name) => json_str(name, out),
None => out.push_str("null"),
}
out.push_str(",\"kind\":");
json_str(o.kind, out);
out.push_str(",\"source\":");
json_str(&o.source, out);
let _ = write!(out, ",\"line\":{}", o.line);
}
fn json_origin(o: &Origin, out: &mut String) {
out.push('{');
json_origin_fields(o, out);
}
fn label(c: &Compiled, a: AtomId) -> String {
let k = &c.atoms[a as usize];
match &k.object {
Some(o) => alloc::format!("{}.{} {} {}", k.domain, k.subject, k.predicate, o),
None => alloc::format!("{}.{} {}", k.domain, k.subject, k.predicate),
}
}
fn lit_value(model: &[V3], l: &Lit) -> V3 {
let v = model[l.atom as usize];
if l.negated { v.not() } else { v }
}
fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
let mut result = V3::True;
for l in lits {
match lit_value(model, l) {
V3::False => return V3::False,
V3::Unknown => result = V3::Unknown,
V3::True => {}
}
}
result
}
enum ClauseEval {
Violated,
Satisfied,
Blocked(Vec<AtomId>),
}
fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
let mut any_false = false;
let mut all_true = true;
let mut blocked = Vec::new();
for l in &clause.lits {
match lit_value(model, l) {
V3::False => {
any_false = true;
all_true = false;
}
V3::Unknown => {
all_true = false;
blocked.push(l.atom);
}
V3::True => {}
}
}
if all_true {
ClauseEval::Violated
} else if any_false {
ClauseEval::Satisfied
} else {
ClauseEval::Blocked(blocked)
}
}
#[derive(Clone)]
enum AtomReason {
Asserted(Origin),
Derived { origin: Origin, from: Vec<AtomId> },
}
struct RawConflict {
origin: Origin,
atoms: Vec<String>,
cause: Vec<AtomId>,
}
struct Eval<'a> {
c: &'a Compiled,
model: Vec<V3>,
reason: Vec<Option<AtomReason>>,
conflicts: Vec<RawConflict>,
warnings: Vec<Warning>,
derived: Vec<Derived>,
unsat_core: Vec<CoreItem>,
}
impl<'a> Eval<'a> {
fn new(c: &'a Compiled) -> Self {
Eval {
c,
model: vec![V3::Unknown; c.atoms.len()],
reason: vec![None; c.atoms.len()],
conflicts: Vec::new(),
warnings: Vec::new(),
derived: Vec::new(),
unsat_core: Vec::new(),
}
}
fn label(&self, a: AtomId) -> String {
label(self.c, a)
}
fn seed_facts(&mut self) {
let c = self.c;
let n = c.atoms.len();
let mut t: Vec<Option<Origin>> = vec![None; n];
let mut f: Vec<Option<Origin>> = vec![None; n];
for fact in &c.facts {
let slot = match fact.value {
Value::True => &mut t,
Value::False => &mut f,
};
if slot[fact.atom as usize].is_none() {
slot[fact.atom as usize] = Some(fact.origin.clone());
}
}
for a in 0..n {
match (&t[a], &f[a]) {
(Some(o), Some(_)) => {
self.model[a] = V3::True;
self.reason[a] = Some(AtomReason::Asserted(o.clone()));
self.conflicts.push(RawConflict {
origin: o.clone(),
atoms: vec![alloc::format!(
"{} (asserted both TRUE and FALSE)",
self.label(a as AtomId)
)],
cause: Vec::new(),
});
}
(Some(o), None) => {
self.model[a] = V3::True;
self.reason[a] = Some(AtomReason::Asserted(o.clone()));
}
(None, Some(o)) => {
self.model[a] = V3::False;
self.reason[a] = Some(AtomReason::Asserted(o.clone()));
}
(None, None) => {}
}
}
}
fn saturate_rules(&mut self) {
let c = self.c;
loop {
let mut changed = false;
for r in &c.rules {
if conjunction(&self.model, &r.antecedent) != V3::True {
continue; }
for cl in &r.consequent {
let target = if cl.negated { V3::False } else { V3::True };
match self.model[cl.atom as usize] {
V3::Unknown => {
self.model[cl.atom as usize] = target;
self.reason[cl.atom as usize] = Some(AtomReason::Derived {
origin: r.origin.clone(),
from: r.antecedent.iter().map(|l| l.atom).collect(),
});
changed = true;
self.derived.push(Derived {
atom: self.label(cl.atom),
value: if cl.negated {
Value::False
} else {
Value::True
},
origin: r.origin.clone(),
});
}
v if v == target => {}
_ => {
let mut cause: Vec<AtomId> =
r.antecedent.iter().map(|l| l.atom).collect();
cause.push(cl.atom);
self.conflicts.push(RawConflict {
origin: r.origin.clone(),
atoms: vec![alloc::format!(
"{} (derived value contradicts a known fact)",
self.label(cl.atom)
)],
cause,
});
}
}
}
}
if !changed {
break;
}
}
}
fn check_premises(&mut self) {
let c = self.c;
let derivable: BTreeSet<AtomId> = c
.rules
.iter()
.flat_map(|r| r.consequent.iter().map(|l| l.atom))
.collect();
for clause in &c.clauses {
match eval_clause(&self.model, clause) {
ClauseEval::Violated => self.conflicts.push(RawConflict {
origin: clause.origin.clone(),
atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
cause: clause.lits.iter().map(|l| l.atom).collect(),
}),
ClauseEval::Satisfied => {}
ClauseEval::Blocked(unknowns) if clause.origin.kind == kw::PREMISE => {
let hint = self.warning_hint(&unknowns, &derivable);
self.warnings.push(Warning {
origin: clause.origin.clone(),
blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
hint,
});
}
ClauseEval::Blocked(_) => {}
}
}
}
fn warning_hint(&self, unknowns: &[AtomId], derivable: &BTreeSet<AtomId>) -> Option<String> {
let free = unknowns.iter().find(|a| !derivable.contains(a));
match free {
Some(&a) => Some(alloc::format!(
"nothing determines `{}` — add `FACT {}` (or `NOT …`), or if a PREMISE's \
THEN is meant to establish it, make that PREMISE a RULE so it derives the value",
self.label(a),
self.label(a),
)),
None => unknowns.first().map(|&a| {
alloc::format!(
"`{}` is derived by a RULE that has not fired — assert that rule's antecedent",
self.label(a),
)
}),
}
}
fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
let mut visited = vec![false; self.c.atoms.len()];
let mut out = Vec::new();
for &a in causes {
self.trace_dfs(a, &mut visited, &mut out);
}
out
}
fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
if visited[a as usize] {
return;
}
visited[a as usize] = true;
let value = match v3_to_value(self.model[a as usize]) {
Some(v) => v,
None => return, };
let reason = match &self.reason[a as usize] {
Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
Some(AtomReason::Derived { origin, from }) => {
for &f in from {
self.trace_dfs(f, visited, out); }
TraceReason::Derived {
origin: origin.clone(),
from: from.iter().map(|&f| self.label(f)).collect(),
}
}
None => return,
};
out.push(TraceStep {
atom: self.label(a),
value,
reason,
});
}
fn backward_pass(&mut self) -> Option<String> {
if !self.c.checks.iter().any(|ch| ch.bidirectional) {
return None;
}
let (cnf, project) = build_cnf(self.c);
let found = sat::models(&cnf, &project, 2);
match found.len() {
0 if self.conflicts.is_empty() => {
self.unsat_core = minimal_unsat_core(self.c);
self.conflicts.push(RawConflict {
origin: Origin {
source: String::from("<system>"),
line: 0,
premise: None,
kind: KIND_UNSAT,
},
atoms: vec![String::from(
"the premises and facts are jointly unsatisfiable",
)],
cause: Vec::new(),
});
None
}
n if n >= 2 => {
let (m0, m1) = (&found[0], &found[1]);
project
.iter()
.find(|&&v| m0[v as usize] != m1[v as usize])
.map(|&v| self.label(v))
.or_else(|| Some(String::from("a free atom")))
}
_ => None,
}
}
fn finish(mut self) -> Report {
let underdetermined = self.backward_pass();
self.conflicts.sort_by_key(|c| key(&c.origin));
self.warnings.sort_by_key(|w| key(&w.origin));
let status = if !self.conflicts.is_empty() {
Status::Conflict
} else if underdetermined.is_some() {
Status::Underdetermined
} else if !self.warnings.is_empty() {
Status::Warning
} else {
Status::Consistent
};
let conflicts: Vec<Conflict> = self
.conflicts
.iter()
.map(|rc| Conflict {
origin: rc.origin.clone(),
atoms: rc.atoms.clone(),
trace: self.build_trace(&rc.cause),
})
.collect();
Report {
status,
conflicts,
warnings: self.warnings,
derived: self.derived,
underdetermined,
unsat_core: self.unsat_core,
retract: Vec::new(), hints: Vec::new(), orphans: Vec::new(), unused_imports: Vec::new(), }
}
}
pub fn solve(c: &Compiled) -> Report {
let mut e = Eval::new(c);
e.seed_facts();
e.saturate_rules();
e.check_premises();
let mut report = e.finish();
if report.status == Status::Conflict {
let retract = retract_assumptions(c);
if !retract.is_empty() {
report.unsat_core = Vec::new();
report.retract = retract;
}
}
report.hints = similar_atom_pairs(c);
report.orphans = orphan_facts(c);
report.unused_imports = c.unused_imports.clone();
report
}
fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
let mut referenced = vec![false; c.atoms.len()];
for clause in &c.clauses {
for l in &clause.lits {
referenced[l.atom as usize] = true;
}
}
for r in &c.rules {
for l in r.antecedent.iter().chain(r.consequent.iter()) {
referenced[l.atom as usize] = true;
}
}
for &a in &c.consumed {
referenced[a as usize] = true;
}
let mut out: Vec<OrphanFact> = c
.facts
.iter()
.filter(|f| !referenced[f.atom as usize])
.map(|f| OrphanFact {
atom: label(c, f.atom),
value: f.value,
origin: f.origin.clone(),
})
.collect();
out.sort_by_key(|o| key(&o.origin));
out
}
fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
if !c.facts.iter().any(|f| f.soft) {
return Vec::new();
}
let all = constructs(c);
let is_soft: Vec<bool> = (0..all.len())
.map(|i| i < c.facts.len() && c.facts[i].soft)
.collect();
let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
return Vec::new();
}
let mut active = vec![true; all.len()];
if subset_is_sat(c.atoms.len(), &all, &active) {
return Vec::new();
}
for i in 0..all.len() {
if active[i] && is_soft[i] {
active[i] = false;
if subset_is_sat(c.atoms.len(), &all, &active) {
active[i] = true; }
}
}
let mut core: Vec<CoreItem> = (0..all.len())
.filter(|&i| active[i] && is_soft[i])
.map(|i| {
let f = &c.facts[i];
let label = if matches!(f.value, Value::False) {
alloc::format!("NOT {}", label(c, f.atom))
} else {
label(c, f.atom)
};
CoreItem {
origin: f.origin.clone(),
label,
}
})
.collect();
core.sort_by_key(|it| key(&it.origin));
core
}
fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
let mut consumed = vec![false; c.atoms.len()];
for &a in &c.consumed {
consumed[a as usize] = true;
}
let mut out = Vec::new();
for i in 0..c.atoms.len() {
if consumed[i] {
continue;
}
for j in (i + 1)..c.atoms.len() {
if consumed[j] {
continue;
}
if let Some(reason) = atoms_look_similar(
&c.atoms[i],
&folded[i],
cased[i],
&c.atoms[j],
&folded[j],
cased[j],
) {
out.push(SimilarAtoms {
a: label(c, i as AtomId),
b: label(c, j as AtomId),
reason,
});
}
}
}
out
}
fn fold_atom(k: &AtomKey) -> Vec<char> {
let mut raw = String::new();
raw.push_str(&k.subject);
raw.push(' ');
raw.push_str(&k.predicate);
if let Some(o) = &k.object {
raw.push(' ');
raw.push_str(o);
}
let mut out: Vec<char> = Vec::new();
let mut prev_space = false;
for ch in raw.chars() {
if ch == '_' || ch.is_whitespace() {
if !prev_space && !out.is_empty() {
out.push(' ');
prev_space = true;
}
} else {
for lc in ch.to_lowercase() {
out.push(lc);
}
prev_space = false;
}
}
if out.last() == Some(&' ') {
out.pop();
}
out
}
fn is_cased_alphabetic(folded: &[char]) -> bool {
folded.iter().all(|&c| c == ' ' || c.is_lowercase())
}
fn atoms_look_similar(
ka: &AtomKey,
fa: &[char],
cased_a: bool,
kb: &AtomKey,
fb: &[char],
cased_b: bool,
) -> Option<&'static str> {
if fa == fb && ka.domain == kb.domain {
return Some("same name up to case, '_', or spaces");
}
if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
return None;
}
if fa.len().abs_diff(fb.len()) > 1 {
return None; }
let min_len = fa.len().min(fb.len());
if min_len >= 5 && levenshtein(fa, fb) == 1 {
return Some("looks like a one-character typo of each other");
}
None
}
fn clause_lit(l: &Lit) -> sat::SatLit {
sat::SatLit::new(l.atom, l.negated)
}
fn fact_lit(f: &Fact) -> sat::SatLit {
match f.value {
Value::True => sat::SatLit::positive(f.atom),
Value::False => sat::SatLit::negative(f.atom),
}
}
fn rule_consequent_clause(r: &Rule, cons: &Lit) -> Vec<sat::SatLit> {
let mut lits: Vec<sat::SatLit> = r.antecedent.iter().map(clause_lit).collect();
lits.push(sat::SatLit::new(cons.atom, !cons.negated));
lits
}
fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
let mut cnf = sat::Cnf::new(c.atoms.len());
let mut constrained = vec![false; c.atoms.len()];
let add_constraining =
|cnf: &mut sat::Cnf, constrained: &mut [bool], lits: Vec<sat::SatLit>| {
for l in &lits {
constrained[l.var() as usize] = true;
}
cnf.add_clause(lits);
};
for clause in &c.clauses {
add_constraining(
&mut cnf,
&mut constrained,
clause.lits.iter().map(clause_lit).collect(),
);
}
for r in &c.rules {
for cons in &r.consequent {
add_constraining(&mut cnf, &mut constrained, rule_consequent_clause(r, cons));
}
}
for f in &c.facts {
cnf.add_clause(vec![fact_lit(f)]);
}
let project = (0..c.atoms.len() as AtomId)
.filter(|&a| constrained[a as usize])
.collect();
(cnf, project)
}
fn v3_to_value(v: V3) -> Option<Value> {
match v {
V3::True => Some(Value::True),
V3::False => Some(Value::False),
V3::Unknown => None,
}
}
struct Construct {
origin: Origin,
label: String,
clauses: Vec<Vec<sat::SatLit>>,
}
fn same_origin(a: &Origin, b: &Origin) -> bool {
a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
}
fn constructs(c: &Compiled) -> Vec<Construct> {
let mut out: Vec<Construct> = Vec::new();
for f in &c.facts {
out.push(Construct {
origin: f.origin.clone(),
label: label(c, f.atom),
clauses: vec![vec![fact_lit(f)]],
});
}
let mut premises: Vec<Construct> = Vec::new();
for clause in &c.clauses {
let lits: Vec<sat::SatLit> = clause.lits.iter().map(clause_lit).collect();
match premises
.iter_mut()
.find(|k| same_origin(&k.origin, &clause.origin))
{
Some(k) => k.clauses.push(lits),
None => premises.push(Construct {
label: clause.origin.premise.clone().unwrap_or_default(),
origin: clause.origin.clone(),
clauses: vec![lits],
}),
}
}
out.extend(premises);
for r in &c.rules {
let clauses = r
.consequent
.iter()
.map(|cons| rule_consequent_clause(r, cons))
.collect();
out.push(Construct {
label: r.origin.premise.clone().unwrap_or_default(),
origin: r.origin.clone(),
clauses,
});
}
out
}
fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
let mut cnf = sat::Cnf::new(num_vars);
for (k, &keep) in all.iter().zip(active) {
if keep {
for cl in &k.clauses {
cnf.add_clause(cl.clone());
}
}
}
sat::solve(&cnf).is_some()
}
fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
let base = c.atoms.len();
let mut cnf = sat::Cnf::new(base + all.len());
let sel = |i: usize| (base + i) as sat::Var;
for (i, k) in all.iter().enumerate() {
let s_neg = sat::SatLit::negative(sel(i));
for cl in &k.clauses {
let mut lits = Vec::with_capacity(cl.len() + 1);
lits.push(s_neg);
lits.extend_from_slice(cl);
cnf.add_clause(lits);
}
}
let assumptions: Vec<sat::SatLit> = (0..all.len())
.map(|i| sat::SatLit::positive(sel(i)))
.collect();
let mut active = vec![false; all.len()];
match sat::solve_assuming(&cnf, &assumptions) {
sat::Solved::Unsat(core) => {
for lit in core {
let v = lit.var() as usize;
if v >= base {
active[v - base] = true;
}
}
}
sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
}
active
}
fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
let all = constructs(c);
let mut active = candidate_via_assumptions(c, &all);
for i in 0..all.len() {
if active[i] {
active[i] = false;
if subset_is_sat(c.atoms.len(), &all, &active) {
active[i] = true; }
}
}
let mut core: Vec<CoreItem> = all
.iter()
.zip(&active)
.filter(|&(_, &keep)| keep)
.map(|(k, _)| CoreItem {
origin: k.origin.clone(),
label: k.label.clone(),
})
.collect();
core.sort_by_key(|it| key(&it.origin));
core
}
fn key(o: &Origin) -> (String, u32) {
(o.source.clone(), o.line)
}
pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
Ok(solve(&compile_source(name, src)?))
}
pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
Ok(solve(&compile(root, resolver)?))
}
impl fmt::Display for Status {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(status_name(*self))
}
}
fn premise_tag(o: &Origin) -> String {
let name = o.premise.as_deref().unwrap_or("-");
alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
}
fn trace_line(step: &TraceStep) -> String {
let v = match step.value {
Value::True => "TRUE",
Value::False => "FALSE",
};
match &step.reason {
TraceReason::Asserted(o) => {
alloc::format!(
"{} = {} [{} {}:{}]",
step.atom,
v,
o.kind,
o.source,
o.line
)
}
TraceReason::Derived { origin, from } => alloc::format!(
"{} = {} from {} ({}) [{}:{}] <= {}",
step.atom,
v,
origin.premise.as_deref().unwrap_or("-"),
origin.kind,
origin.source,
origin.line,
from.join(", ")
),
}
}
mod indent {
pub const ROOT: usize = 0;
pub const SECTION: usize = 2;
pub const ITEM: usize = 6;
pub const NESTED: usize = 8;
}
struct ReportWriter<'a, 'b> {
f: &'a mut fmt::Formatter<'b>,
}
impl ReportWriter<'_, '_> {
fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
write!(self.f, "{:width$}{}", "", args, width = indent)?;
self.f.write_str("\n")
}
fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
write!(self.f, "{:width$}{}", "", args, width = indent)
}
}
macro_rules! emit {
($out:expr, $indent:expr, $($arg:tt)*) => {
$out.line($indent, format_args!($($arg)*))
};
}
impl fmt::Display for Report {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
use indent::{ITEM, NESTED, ROOT, SECTION};
let mut out = ReportWriter { f };
emit!(out, ROOT, "RESULT: {}", self.status)?;
if !self.retract.is_empty() {
emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
emit!(
out,
ITEM,
"But these ASSUME guesses cannot all be true together."
)?;
emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
for it in &self.retract {
emit!(
out,
ITEM,
"ASSUME {} [{}:{}]",
it.label,
it.origin.source,
it.origin.line
)?;
}
} else {
for c in &self.conflicts {
emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
for a in &c.atoms {
emit!(out, ITEM, "{}", a)?;
}
if !c.trace.is_empty() {
emit!(out, ITEM, "why:")?;
for step in &c.trace {
emit!(out, NESTED, "{}", trace_line(step))?;
}
}
}
if !self.unsat_core.is_empty() {
emit!(
out,
SECTION,
"CORE smallest jointly-unsatisfiable set ({}):",
self.unsat_core.len()
)?;
for it in &self.unsat_core {
let name = if it.label.is_empty() { "-" } else { &it.label };
emit!(
out,
NESTED,
"{} ({}) [{}:{}]",
name,
it.origin.kind,
it.origin.source,
it.origin.line
)?;
}
}
}
let mut shown_fixes: Vec<&str> = Vec::new();
for w in &self.warnings {
emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
if let Some(hint) = &w.hint
&& !shown_fixes.contains(&hint.as_str())
{
shown_fixes.push(hint);
emit!(out, ITEM, "fix: {hint}")?;
}
}
if let Some(atom) = &self.underdetermined {
emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
}
for d in &self.derived {
let v = match d.value {
Value::True => "TRUE",
Value::False => "FALSE",
};
emit!(
out,
SECTION,
"DERIVED {} = {} from {}",
d.atom,
v,
premise_tag(&d.origin)
)?;
}
for h in &self.hints {
emit!(
out,
SECTION,
"HINT possible typo — '{}' and '{}' look like the same atom ({})",
h.a,
h.b,
h.reason
)?;
}
for o in &self.orphans {
let surface = if o.origin.kind == kw::ASSUME && matches!(o.value, Value::False) {
alloc::format!("{} {} {}", kw::ASSUME, kw::NOT, o.atom)
} else {
alloc::format!("{} {}", o.origin.kind, o.atom)
};
emit!(
out,
SECTION,
"ORPHAN {} — not used by any premise or rule (no effect on the result)",
surface
)?;
}
for u in &self.unused_imports {
let via = match &u.alias {
Some(a) => alloc::format!("{} AS {}", u.domain, a),
None => u.domain.clone(),
};
emit!(
out,
SECTION,
"UNUSED IMPORT {} — imported in {}:{} but never referenced (no effect on the result)",
via,
u.file,
u.line
)?;
}
let underdetermined = usize::from(self.status == Status::Underdetermined);
emit!(
out,
ROOT,
"SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
self.conflicts.len(),
underdetermined,
self.warnings.len(),
self.derived.len()
)?;
out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn vs(src: &str) -> Result<Report, CompileError> {
verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
}
#[test]
fn clean_consistent() {
let r = vs("FACT x a\nCHECK x\n").unwrap();
assert_eq!(r.status, Status::Consistent);
assert!(r.conflicts.is_empty() && r.warnings.is_empty());
}
#[test]
fn fact_contradiction_is_conflict() {
let r = vs("FACT x a\nNOT x a\n").unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.conflicts.len(), 1);
}
#[test]
fn exclusive_violation_is_conflict() {
let src = include_str!("../../../docs/examples/conflict.vrf");
let r = verify_source("conflict.vrf", src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(
r.conflicts[0].origin.premise.as_deref(),
Some("fly_xor_swim")
);
assert_eq!(r.conflicts[0].atoms.len(), 2);
}
#[test]
fn exclusive_with_unknown_is_consistent_not_warning() {
let r = vs(r"
FACT A has flying
PREMISE e:
EXCLUSIVE
A has flying
A has swimming
")
.unwrap();
assert_eq!(r.status, Status::Consistent);
assert!(r.warnings.is_empty());
}
#[test]
fn implication_missing_consequent_is_warning() {
let r = vs(r#"
FACT A has flying
PREMISE w:
WHEN A has flying
THEN A has wing
"#)
.unwrap();
assert_eq!(r.status, Status::Warning);
assert_eq!(r.warnings.len(), 1);
assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
}
#[test]
fn warning_hint_points_at_rule_when_atom_is_a_free_input() {
let r = vs(r"
FACT A has flying
PREMISE w:
WHEN A has flying
THEN A has wing
")
.unwrap();
let hint = r.warnings[0].hint.as_deref().unwrap();
assert!(hint.contains("make that PREMISE a RULE"), "{hint}");
assert!(hint.contains("t.A has wing"), "{hint}");
}
#[test]
fn warning_hint_points_at_antecedent_when_a_rule_could_derive_it() {
let r = vs(r"
RULE d:
WHEN x trigger
THEN c ready
FACT go now
PREMISE p:
WHEN go now
THEN c ready
")
.unwrap();
assert_eq!(r.status, Status::Warning);
let hint = r.warnings[0].hint.as_deref().unwrap();
assert!(
hint.contains("derived by a RULE that has not fired"),
"{hint}"
);
}
#[test]
fn human_report_dedupes_repeated_fix_lines() {
let r = vs(r"
FACT a on
FACT b on
PREMISE p1:
WHEN a on
THEN gate one_ok
PREMISE p2:
WHEN b on
THEN gate one_ok
PREMISE p3:
WHEN a on
THEN gate two_ok
")
.unwrap();
assert_eq!(r.warnings.len(), 3);
assert!(r.warnings.iter().all(|w| w.hint.is_some()));
let distinct_hints: BTreeSet<&str> = r
.warnings
.iter()
.filter_map(|w| w.hint.as_deref())
.collect();
assert_eq!(distinct_hints.len(), 2);
let text = alloc::format!("{r}");
let shown = text
.lines()
.filter(|l| l.trim_start().starts_with("fix:"))
.count();
assert_eq!(shown, distinct_hints.len());
}
#[test]
fn implication_satisfied_is_consistent() {
let r = vs(r"
FACT A has flying
FACT A has wing
PREMISE w:
WHEN A has flying
THEN A has wing
")
.unwrap();
assert_eq!(r.status, Status::Consistent);
}
#[test]
fn implication_violated_is_conflict() {
let r = vs(r"
FACT A has flying
NOT A has wing
PREMISE w:
WHEN A has flying
THEN A has wing
")
.unwrap();
assert_eq!(r.status, Status::Conflict);
}
#[test]
fn rule_derives_fact() {
let r = vs(r#"
FACT A has flying
RULE o:
WHEN A has flying
THEN A needs oxygen
"#)
.unwrap();
assert_eq!(r.status, Status::Consistent);
assert_eq!(r.derived.len(), 1);
assert_eq!(r.derived[0].atom, "t.A needs oxygen");
}
#[test]
fn rule_derivation_contradiction_is_conflict() {
let r = vs(r"
FACT A has flying
NOT A needs oxygen
RULE o:
WHEN A has flying
THEN A needs oxygen
")
.unwrap();
assert_eq!(r.status, Status::Conflict);
}
#[test]
fn bidirectional_finds_alternative_model_underdetermined() {
let r = vs(r#"
PREMISE e:
EXCLUSIVE
x a
x b
CHECK x BIDIRECTIONAL
"#)
.unwrap();
assert_eq!(r.status, Status::Underdetermined);
}
#[test]
fn fact_pins_unique_model_consistent() {
let r = vs(r#"
FACT x a
PREMISE e:
EXCLUSIVE
x a
x b
CHECK x BIDIRECTIONAL
"#)
.unwrap();
assert_eq!(r.status, Status::Consistent);
}
#[test]
fn no_bidirectional_skips_backward_pass() {
let r = vs(r#"
PREMISE e:
EXCLUSIVE
x a
x b
CHECK x
"#)
.unwrap();
assert_eq!(r.status, Status::Consistent);
}
#[test]
fn creature_example_forward_pass() {
let src = include_str!("../../../docs/examples/creature.vrf");
let r = verify_source("creature.vrf", src).unwrap();
assert_eq!(r.status, Status::Warning);
assert!(r.conflicts.is_empty());
assert_eq!(r.warnings.len(), 2);
assert_eq!(r.derived.len(), 1);
assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
}
#[test]
fn roles_puzzle_is_uniquely_solved() {
let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
let r = verify_source("roles-puzzle.vrf", src).unwrap();
assert_eq!(r.status, Status::Consistent);
assert!(r.conflicts.is_empty());
assert!(r.underdetermined.is_none());
}
#[test]
fn roles_puzzle_underdetermined_without_a_clue() {
let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
.replace("\r\n", "\n")
.replace("NOT bob is qa\n", "");
let r = verify_source("roles-puzzle.vrf", &src).unwrap();
assert_eq!(r.status, Status::Underdetermined);
}
#[test]
fn socrates_chain_is_a_conflict() {
let src = include_str!("../../../docs/examples/socrates.vrf");
let r = verify_source("socrates.vrf", src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.conflicts.len(), 1);
assert_eq!(
r.conflicts[0].origin.premise.as_deref(),
Some("mortal_xor_immortal")
);
assert_eq!(r.derived.len(), 3); }
#[test]
fn forward_conflict_carries_a_trace_of_its_facts() {
let r = vs(r#"
FACT x a
FACT x b
PREMISE e:
EXCLUSIVE
x a
x b
CHECK x
"#)
.unwrap();
assert_eq!(r.status, Status::Conflict);
let t = &r.conflicts[0].trace;
assert_eq!(t.len(), 2);
assert_eq!(t[0].atom, "t.x a");
assert_eq!(t[0].value, Value::True);
assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
assert!(r.unsat_core.is_empty());
}
#[test]
fn derivation_chain_is_traced_back_to_the_root_fact() {
let src = include_str!("../../../docs/examples/socrates.vrf");
let r = verify_source("socrates.vrf", src).unwrap();
let t = &r.conflicts[0].trace;
assert_eq!(t.len(), 5);
assert_eq!(t[0].atom, "philosophy.socrates is human");
assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
let mortal = t
.iter()
.find(|s| s.atom == "philosophy.socrates is mortal")
.unwrap();
match &mortal.reason {
TraceReason::Derived { from, .. } => {
assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
}
_ => panic!("mortal should be derived, not asserted"),
}
}
#[test]
fn direct_fact_contradiction_has_no_trace() {
let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
assert_eq!(r.status, Status::Conflict);
assert!(r.conflicts[0].trace.is_empty());
}
#[test]
fn jointly_unsatisfiable_reports_a_minimal_core() {
let src = r#"
PREMISE one:
ONEOF
x a
x b
PREMISE ac:
WHEN x a
THEN x c
PREMISE bc:
WHEN x b
THEN x c
NOT x c
CHECK x BIDIRECTIONAL
"#;
let r = vs(src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
assert_eq!(r.unsat_core.len(), 4);
let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
assert!(labels.contains(&"one"));
assert!(labels.contains(&"t.x c")); }
#[test]
fn unsat_core_excludes_irrelevant_constructs() {
let src = r#"
PREMISE one:
ONEOF
x a
x b
PREMISE ac:
WHEN x a
THEN x c
PREMISE bc:
WHEN x b
THEN x c
NOT x c
FACT z here
PREMISE noise:
EXCLUSIVE
z here
z gone
CHECK x BIDIRECTIONAL
"#;
let r = vs(src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.unsat_core.len(), 4);
let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
assert!(!labels.contains(&"noise"));
assert!(!labels.iter().any(|l| l.contains("here")));
}
#[test]
fn unsat_core_blames_the_rules_that_form_it() {
let src = r#"
PREMISE one:
ONEOF
x a
x b
RULE ac:
WHEN x a
THEN x c
RULE bc:
WHEN x b
THEN x c
NOT x c
CHECK x BIDIRECTIONAL
"#;
let r = vs(src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
assert_eq!(r.unsat_core.len(), 4);
let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
assert!(labels.contains(&"one"));
assert!(labels.contains(&"ac"));
assert!(labels.contains(&"bc"));
assert!(labels.contains(&"t.x c")); }
#[test]
fn consistent_report_has_empty_core_and_no_trace() {
let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
assert_eq!(r.status, Status::Consistent);
assert!(r.unsat_core.is_empty());
assert!(r.conflicts.is_empty());
}
#[test]
fn compatible_assumptions_behave_like_facts() {
let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
assert_eq!(r.status, Status::Consistent);
assert!(r.retract.is_empty());
assert!(r.conflicts.is_empty());
}
#[test]
fn assume_drives_a_rule_like_a_fact() {
let r = vs(r"
ASSUME A has flying
RULE o:
WHEN A has flying
THEN A needs oxygen
CHECK A
")
.unwrap();
assert_eq!(r.status, Status::Consistent);
assert_eq!(r.derived.len(), 1);
assert_eq!(r.derived[0].atom, "t.A needs oxygen");
}
#[test]
fn clashing_assumptions_yield_conflict_with_a_retract_set() {
let src = r#"
FACT rel reviewed
PREMISE prod_needs_safety:
WHEN rel in_prod
THEN rel has_rollback
OR rel has_feature_flag
ASSUME rel in_prod
ASSUME NOT rel has_rollback
ASSUME NOT rel has_feature_flag
CHECK rel
"#;
let r = vs(src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.exit_code(), 2);
assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
assert!(labels.contains(&"t.rel in_prod"));
assert!(labels.contains(&"NOT t.rel has_rollback"));
assert!(labels.contains(&"NOT t.rel has_feature_flag"));
assert!(r.retract.iter().all(|it| it.origin.kind == kw::ASSUME));
let shown = alloc::format!("{r}");
assert!(shown.contains("RETRACT"), "{shown}");
assert!(!shown.contains("CONFLICT "), "{shown}");
}
#[test]
fn assume_vs_fact_retracts_only_the_assumption() {
let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.retract.len(), 1);
assert_eq!(r.retract[0].label, "NOT t.x a");
assert_eq!(r.retract[0].origin.kind, kw::ASSUME);
}
#[test]
fn hard_conflict_is_not_blamed_on_assumptions() {
let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
assert_eq!(r.status, Status::Conflict);
assert!(r.retract.is_empty(), "{:?}", r.retract);
}
#[test]
fn two_assumptions_directly_contradict() {
let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
}
#[test]
fn assume_retract_is_in_json() {
let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
let j = r.to_json();
assert!(j.contains("\"retract\":["), "{j}");
assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
assert!(j.contains("NOT t.x a"), "{j}");
}
#[test]
fn hint_flags_underscore_vs_space_and_is_advisory_only() {
let r = vs(r#"
FACT auth is rolled_back
NOT auth is_rolled_back
CHECK
"#)
.unwrap();
assert_eq!(
r.status,
Status::Consistent,
"hint must not change the verdict"
);
assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
}
#[test]
fn hint_flags_case_only_difference() {
let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn hint_flags_single_char_typo_same_subject() {
let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn no_hint_for_short_distinct_atoms() {
let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
assert!(r.hints.is_empty(), "{:?}", r.hints);
}
#[test]
fn no_hint_for_distinct_words() {
let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
assert!(r.hints.is_empty(), "{:?}", r.hints);
}
#[test]
fn russian_case_typo_is_flagged() {
let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn russian_single_char_typo_is_flagged() {
let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn cjk_one_char_difference_is_not_flagged() {
let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
assert!(r.hints.is_empty(), "{:?}", r.hints);
}
#[test]
fn cjk_underscore_vs_space_is_flagged() {
let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn orphan_fact_is_flagged_but_advisory_only() {
let r = vs("FACT x a\nCHECK\n").unwrap();
assert_eq!(
r.status,
Status::Consistent,
"orphan must not change verdict"
);
assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
assert_eq!(r.orphans[0].atom, "t.x a");
assert_eq!(r.orphans[0].origin.kind, kw::FACT);
}
#[test]
fn fact_used_by_a_premise_is_not_orphan() {
let r = vs(r"
FACT x a
PREMISE p:
EXCLUSIVE
x a
x b
CHECK
")
.unwrap();
assert!(r.orphans.is_empty(), "{:?}", r.orphans);
}
#[test]
fn fact_used_by_a_rule_antecedent_is_not_orphan() {
let r = vs(r"
FACT x a
RULE r:
WHEN x a
THEN x c
CHECK
")
.unwrap();
assert!(r.orphans.is_empty(), "{:?}", r.orphans);
}
#[test]
fn negation_and_assumption_orphans_keep_their_surface_polarity() {
let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
let text = alloc::format!("{r}");
assert!(text.contains("ORPHAN NOT t.x a"), "{text}");
assert!(text.contains("ORPHAN ASSUME NOT t.y b"), "{text}");
}
#[test]
fn orphan_is_in_json() {
let r = vs("FACT x a\nCHECK\n").unwrap();
let j = r.to_json();
assert!(j.contains("\"orphans\":["), "{j}");
assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
assert!(j.contains("\"kind\":\"FACT\""), "{j}");
}
#[test]
fn unused_import_is_advisory_only_in_the_report() {
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\nCHECK\n",
);
let rep = verify("main.vrf", &r).unwrap();
assert_eq!(rep.status, Status::Consistent);
assert_eq!(rep.exit_code(), 0);
assert_eq!(rep.unused_imports.len(), 1);
assert_eq!(rep.unused_imports[0].domain, "physics");
let text = alloc::format!("{rep}");
assert!(text.contains("UNUSED IMPORT physics"), "{text}");
assert!(
rep.to_json().contains("\"unused_imports\":[{"),
"{}",
rep.to_json()
);
}
#[test]
fn a_derived_atom_does_not_make_its_consumer_orphan() {
let r = vs(r"
FACT x a
RULE r:
WHEN x a
THEN x c
PREMISE p:
WHEN x c
THEN x d
CHECK
")
.unwrap();
assert!(r.orphans.is_empty(), "{:?}", r.orphans);
}
}