#![no_std]
#![warn(missing_docs)]
extern crate alloc;
#[cfg(feature = "std")]
extern crate std;
pub mod sat;
use alloc::string::String;
use alloc::vec;
use alloc::vec::Vec;
use core::fmt;
use elenchus_compiler::{AtomId, AtomKey, Clause, Compiled, Lit, Origin, Value};
pub use elenchus_compiler::{CompileError, MemoryResolver, Resolver, compile, compile_source};
#[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>,
}
#[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>,
}
#[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 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('}');
}
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("]}");
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.subject, k.predicate, o),
None => alloc::format!("{} {}", 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;
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 == "PREMISE" => {
self.warnings.push(Warning {
origin: clause.origin.clone(),
blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
});
}
ClauseEval::Blocked(_) => {}
}
}
}
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: "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(), }
}
}
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
}
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 out = Vec::new();
for i in 0..c.atoms.len() {
for j in (i + 1)..c.atoms.len() {
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 {
return Some("same name up to case, '_', or spaces");
}
if !cased_a || !cased_b || 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 levenshtein(a: &[char], b: &[char]) -> usize {
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
core::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
use sat::SatLit;
let mut cnf = sat::Cnf::new(c.atoms.len());
let mut constrained = vec![false; c.atoms.len()];
let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
for clause in &c.clauses {
let lits = clause
.lits
.iter()
.map(|l| {
mark(l.atom, &mut constrained);
SatLit::new(l.atom, l.negated)
})
.collect();
cnf.add_clause(lits);
}
for r in &c.rules {
for cons in &r.consequent {
let mut lits: Vec<SatLit> = r
.antecedent
.iter()
.map(|a| {
mark(a.atom, &mut constrained);
SatLit::new(a.atom, a.negated)
})
.collect();
mark(cons.atom, &mut constrained);
lits.push(SatLit::new(cons.atom, !cons.negated));
cnf.add_clause(lits);
}
}
for f in &c.facts {
let lit = match f.value {
Value::True => SatLit::positive(f.atom),
Value::False => SatLit::negative(f.atom),
};
cnf.add_clause(vec![lit]);
}
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> {
use sat::SatLit;
let mut out: Vec<Construct> = Vec::new();
for f in &c.facts {
let lit = match f.value {
Value::True => SatLit::positive(f.atom),
Value::False => SatLit::negative(f.atom),
};
out.push(Construct {
origin: f.origin.clone(),
label: label(c, f.atom),
clauses: vec![vec![lit]],
});
}
let mut premises: Vec<Construct> = Vec::new();
for clause in &c.clauses {
let lits: Vec<SatLit> = clause
.lits
.iter()
.map(|l| SatLit::new(l.atom, l.negated))
.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| {
let mut lits: Vec<SatLit> = r
.antecedent
.iter()
.map(|a| SatLit::new(a.atom, a.negated))
.collect();
lits.push(SatLit::new(cons.atom, !cons.negated));
lits
})
.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(match self {
Status::Consistent => "CONSISTENT",
Status::Underdetermined => "UNDERDETERMINED",
Status::Warning => "WARNING",
Status::Conflict => "CONFLICT",
})
}
}
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
)?;
}
}
}
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(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
)?;
}
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::*;
#[test]
fn clean_consistent() {
let r = verify_source("<t>", "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 = verify_source("<t>", "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 = verify_source("<t>", "FACT A has flying\nPREMISE e:\n EXCLUSIVE\n A has flying\n A has swimming\n").unwrap();
assert_eq!(r.status, Status::Consistent);
assert!(r.warnings.is_empty());
}
#[test]
fn implication_missing_consequent_is_warning() {
let r = verify_source(
"<t>",
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("A has wing")]);
}
#[test]
fn implication_satisfied_is_consistent() {
let r = verify_source("<t>", "FACT A has flying\nFACT A has wing\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n").unwrap();
assert_eq!(r.status, Status::Consistent);
}
#[test]
fn implication_violated_is_conflict() {
let r = verify_source("<t>", "FACT A has flying\nNOT A has wing\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n").unwrap();
assert_eq!(r.status, Status::Conflict);
}
#[test]
fn rule_derives_fact() {
let r = verify_source(
"<t>",
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, "A needs oxygen");
}
#[test]
fn rule_derivation_contradiction_is_conflict() {
let r = verify_source("<t>", "FACT A has flying\nNOT A needs oxygen\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\n").unwrap();
assert_eq!(r.status, Status::Conflict);
}
#[test]
fn bidirectional_finds_alternative_model_underdetermined() {
let r = verify_source(
"<t>",
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 = verify_source(
"<t>",
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 = verify_source(
"<t>",
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, "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 = verify_source(
"<t>",
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, "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, "socrates is human");
assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
let mortal = t.iter().find(|s| s.atom == "socrates is mortal").unwrap();
match &mortal.reason {
TraceReason::Derived { from, .. } => {
assert_eq!(from, &vec![String::from("socrates is living")]);
}
_ => panic!("mortal should be derived, not asserted"),
}
}
#[test]
fn direct_fact_contradiction_has_no_trace() {
let r = verify_source("<t>", "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 = verify_source("<t>", src).unwrap();
assert_eq!(r.status, Status::Conflict);
assert_eq!(r.conflicts[0].origin.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(&"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 = verify_source("<t>", 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 consistent_report_has_empty_core_and_no_trace() {
let r = verify_source("<t>", "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 = verify_source("<t>", "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 = verify_source(
"<t>",
"ASSUME A has flying\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\nCHECK A\n",
)
.unwrap();
assert_eq!(r.status, Status::Consistent);
assert_eq!(r.derived.len(), 1);
assert_eq!(r.derived[0].atom, "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 = verify_source("<t>", 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(&"rel in_prod"));
assert!(labels.contains(&"NOT rel has_rollback"));
assert!(labels.contains(&"NOT rel has_feature_flag"));
assert!(r.retract.iter().all(|it| it.origin.kind == "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 = verify_source("<t>", "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 x a");
assert_eq!(r.retract[0].origin.kind, "ASSUME");
}
#[test]
fn hard_conflict_is_not_blamed_on_assumptions() {
let r = verify_source("<t>", "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 = verify_source("<t>", "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 = verify_source("<t>", "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 x a"), "{j}");
}
#[test]
fn hint_flags_underscore_vs_space_and_is_advisory_only() {
let r = verify_source(
"<t>",
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 = verify_source("<t>", "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 = verify_source("<t>", "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 = verify_source("<t>", "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 = verify_source("<t>", "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 = verify_source("<t>", "FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn russian_single_char_typo_is_flagged() {
let r = verify_source("<t>", "FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
#[test]
fn cjk_one_char_difference_is_not_flagged() {
let r = verify_source("<t>", "FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
assert!(r.hints.is_empty(), "{:?}", r.hints);
}
#[test]
fn cjk_underscore_vs_space_is_flagged() {
let r = verify_source("<t>", "FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
}
}