Skip to main content

elenchus_solver/
lib.rs

1//! elenchus-solver — the reasoning interpreter (forward pass).
2//!
3//! Consumes the [`Compiled`] IR from `elenchus-compiler` and evaluates it under
4//! three-valued Kleene logic (TRUE / FALSE / UNKNOWN, where UNKNOWN ≠ FALSE):
5//!
6//! 1. seed a model from confident `FACT`/`NOT` facts (and report `FACT X` + `NOT X`);
7//! 2. forward-chain `RULE`s to a fixpoint, deriving facts (a derived value that
8//!    contradicts an existing one is a CONFLICT);
9//! 3. evaluate every `Impossible` clause (the desugared `PREMISE`s):
10//!    - all literals forced TRUE → **CONFLICT** (the constraint is violated);
11//!    - some literal FALSE → satisfied → CONSISTENT;
12//!    - otherwise (no FALSE, an UNKNOWN remains): for implication premises this is a
13//!      **WARNING** (blocked by missing data), for list premises (EXCLUSIVE/FORBIDS/
14//!      ONEOF/ATLEAST) it is CONSISTENT (UNKNOWN means "no conflict yet").
15//!
16//! On `CHECK ... BIDIRECTIONAL` a **backward pass** also runs: the premises, rules
17//! and confident facts are encoded as CNF and handed to a small in-crate CDCL SAT
18//! core ([`sat`], replicating varisat's algorithm) to count models — 0 means the
19//! system is jointly unsatisfiable (a CONFLICT the forward pass may miss), ≥2
20//! means an alternative model exists (`UNDERDETERMINED`).
21#![no_std]
22// Every public item is documented; CI (`clippy -D warnings`) keeps it that way.
23#![warn(missing_docs)]
24
25extern crate alloc;
26
27#[cfg(feature = "std")]
28extern crate std;
29
30pub mod sat;
31
32use alloc::string::String;
33use alloc::vec;
34use alloc::vec::Vec;
35use core::fmt;
36
37use elenchus_compiler::{AtomId, Clause, Compiled, Lit, Origin, Value};
38pub use elenchus_compiler::{CompileError, MemoryResolver, Resolver, compile, compile_source};
39
40/// Three-valued truth (Kleene). UNKNOWN is a first-class value, not hidden FALSE.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum V3 {
43    /// Known true.
44    True,
45    /// Known false.
46    False,
47    /// Not asserted and not derivable — the absence of information, not falsity.
48    Unknown,
49}
50
51impl V3 {
52    /// Kleene negation: TRUE↔FALSE, and UNKNOWN stays UNKNOWN.
53    fn not(self) -> V3 {
54        match self {
55            V3::True => V3::False,
56            V3::False => V3::True,
57            V3::Unknown => V3::Unknown,
58        }
59    }
60}
61
62/// Overall verdict for the system.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Status {
65    /// No contradictions, and (when checked) the model is pinned down.
66    Consistent,
67    /// The constraints are satisfiable but do not pin a unique assignment — an
68    /// alternative model exists (found by the backward pass on `BIDIRECTIONAL`).
69    Underdetermined,
70    /// A premise could not be checked because a needed atom is UNKNOWN.
71    Warning,
72    /// A premise is violated, or the premises + facts are jointly unsatisfiable.
73    Conflict,
74}
75
76/// A violated constraint (or a fact-level contradiction).
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Conflict {
79    /// Provenance of the violated constraint (source, line, premise name, kind).
80    pub origin: Origin,
81    /// Human labels of the atoms participating in the contradiction.
82    pub atoms: Vec<String>,
83}
84
85/// A constraint that could not be checked because a needed atom is UNKNOWN.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct Warning {
88    /// Provenance of the constraint that could not be checked.
89    pub origin: Origin,
90    /// Human labels of the UNKNOWN atoms blocking the check.
91    pub blocked_by: Vec<String>,
92}
93
94/// A fact produced by a `RULE` during forward chaining.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct Derived {
97    /// Human label of the atom whose value was derived.
98    pub atom: String,
99    /// The value the rule assigned (TRUE, or FALSE for a `THEN NOT …`).
100    pub value: Value,
101    /// Provenance of the `RULE` that produced it.
102    pub origin: Origin,
103}
104
105/// The result of solving, self-contained (atom ids already resolved to labels).
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct Report {
108    /// The overall verdict.
109    pub status: Status,
110    /// Every violated constraint / fact contradiction (sorted by source+line).
111    pub conflicts: Vec<Conflict>,
112    /// Every premise blocked by an UNKNOWN atom (sorted by source+line).
113    pub warnings: Vec<Warning>,
114    /// Facts produced by forward-chaining `RULE`s.
115    pub derived: Vec<Derived>,
116    /// When `UNDERDETERMINED`, the label of an atom left free by the constraints
117    /// (asserting it would pin the model down).
118    pub underdetermined: Option<String>,
119}
120
121impl Report {
122    /// CLI-style exit code: 0 = consistent, 1 = underdetermined/warnings, 2 = conflicts.
123    pub fn exit_code(&self) -> i32 {
124        match self.status {
125            Status::Conflict => 2,
126            Status::Underdetermined | Status::Warning => 1,
127            Status::Consistent => 0,
128        }
129    }
130
131    /// Render the report as a single-line JSON object (for tooling / MCP).
132    /// Hand-written so the crate stays dependency-free and `no_std`.
133    pub fn to_json(&self) -> String {
134        use core::fmt::Write as _;
135        let mut s = String::new();
136        let _ = write!(s, "{{\"status\":");
137        json_str(status_name(self.status), &mut s);
138        let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
139
140        s.push_str(",\"conflicts\":[");
141        for (i, c) in self.conflicts.iter().enumerate() {
142            if i > 0 {
143                s.push(',');
144            }
145            json_origin(&c.origin, &mut s);
146            s.push_str(",\"atoms\":");
147            json_array(&c.atoms, &mut s);
148            s.push('}');
149        }
150        s.push_str("],\"warnings\":[");
151        for (i, w) in self.warnings.iter().enumerate() {
152            if i > 0 {
153                s.push(',');
154            }
155            json_origin(&w.origin, &mut s);
156            s.push_str(",\"blocked_by\":");
157            json_array(&w.blocked_by, &mut s);
158            s.push('}');
159        }
160        s.push_str("],\"derived\":[");
161        for (i, d) in self.derived.iter().enumerate() {
162            if i > 0 {
163                s.push(',');
164            }
165            s.push('{');
166            json_origin_fields(&d.origin, &mut s);
167            s.push_str(",\"atom\":");
168            json_str(&d.atom, &mut s);
169            let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
170            s.push('}');
171        }
172        s.push_str("],\"underdetermined\":");
173        match &self.underdetermined {
174            Some(atom) => json_str(atom, &mut s),
175            None => s.push_str("null"),
176        }
177        s.push('}');
178        s
179    }
180}
181
182fn status_name(s: Status) -> &'static str {
183    match s {
184        Status::Consistent => "CONSISTENT",
185        Status::Underdetermined => "UNDERDETERMINED",
186        Status::Warning => "WARNING",
187        Status::Conflict => "CONFLICT",
188    }
189}
190
191/// Push a JSON-escaped string literal (including the surrounding quotes).
192fn json_str(value: &str, out: &mut String) {
193    use core::fmt::Write as _;
194    out.push('"');
195    for ch in value.chars() {
196        match ch {
197            '"' => out.push_str("\\\""),
198            '\\' => out.push_str("\\\\"),
199            '\n' => out.push_str("\\n"),
200            '\r' => out.push_str("\\r"),
201            '\t' => out.push_str("\\t"),
202            c if (c as u32) < 0x20 => {
203                let _ = write!(out, "\\u{:04x}", c as u32);
204            }
205            c => out.push(c),
206        }
207    }
208    out.push('"');
209}
210
211/// Push a JSON array of escaped strings.
212fn json_array(items: &[String], out: &mut String) {
213    out.push('[');
214    for (i, item) in items.iter().enumerate() {
215        if i > 0 {
216            out.push(',');
217        }
218        json_str(item, out);
219    }
220    out.push(']');
221}
222
223/// `"premise":..,"kind":..,"source":..,"line":..` (no braces).
224fn json_origin_fields(o: &Origin, out: &mut String) {
225    use core::fmt::Write as _;
226    out.push_str("\"premise\":");
227    match &o.premise {
228        Some(name) => json_str(name, out),
229        None => out.push_str("null"),
230    }
231    out.push_str(",\"kind\":");
232    json_str(o.kind, out);
233    out.push_str(",\"source\":");
234    json_str(&o.source, out);
235    let _ = write!(out, ",\"line\":{}", o.line);
236}
237
238/// Open an object `{` and write the origin fields.
239fn json_origin(o: &Origin, out: &mut String) {
240    out.push('{');
241    json_origin_fields(o, out);
242}
243
244/// Render atom `a` as the human string `subject predicate [object]`.
245fn label(c: &Compiled, a: AtomId) -> String {
246    let k = &c.atoms[a as usize];
247    match &k.object {
248        Some(o) => alloc::format!("{} {} {}", k.subject, k.predicate, o),
249        None => alloc::format!("{} {}", k.subject, k.predicate),
250    }
251}
252
253/// The three-valued value of a literal: the atom's value, flipped if negated.
254fn lit_value(model: &[V3], l: &Lit) -> V3 {
255    let v = model[l.atom as usize];
256    if l.negated { v.not() } else { v }
257}
258
259/// Kleene AND over a conjunction of literals (a rule antecedent / clause prefix).
260fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
261    let mut result = V3::True;
262    for l in lits {
263        match lit_value(model, l) {
264            V3::False => return V3::False,
265            V3::Unknown => result = V3::Unknown,
266            V3::True => {}
267        }
268    }
269    result
270}
271
272/// The status of one `Impossible` clause under the current model.
273enum ClauseEval {
274    /// Every literal is forced TRUE → the constraint is violated.
275    Violated,
276    /// Some literal is FALSE → the literals cannot all hold → satisfied.
277    Satisfied,
278    /// No FALSE literal, but an UNKNOWN remains: the check is blocked on these atoms.
279    Blocked(Vec<AtomId>),
280}
281
282/// Classify one `Impossible` clause under the model: all-true is a violation,
283/// any-false satisfies it, otherwise it is blocked on the remaining UNKNOWNs.
284fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
285    let mut any_false = false;
286    let mut all_true = true;
287    let mut blocked = Vec::new();
288    for l in &clause.lits {
289        match lit_value(model, l) {
290            V3::False => {
291                any_false = true;
292                all_true = false;
293            }
294            V3::Unknown => {
295                all_true = false;
296                blocked.push(l.atom);
297            }
298            V3::True => {}
299        }
300    }
301    if all_true {
302        ClauseEval::Violated
303    } else if any_false {
304        ClauseEval::Satisfied
305    } else {
306        ClauseEval::Blocked(blocked)
307    }
308}
309
310/// Working state of the forward + backward evaluation, evaluated as a pipeline.
311struct Eval<'a> {
312    c: &'a Compiled,
313    model: Vec<V3>,
314    conflicts: Vec<Conflict>,
315    warnings: Vec<Warning>,
316    derived: Vec<Derived>,
317}
318
319impl<'a> Eval<'a> {
320    fn new(c: &'a Compiled) -> Self {
321        Eval {
322            c,
323            model: vec![V3::Unknown; c.atoms.len()],
324            conflicts: Vec::new(),
325            warnings: Vec::new(),
326            derived: Vec::new(),
327        }
328    }
329
330    fn label(&self, a: AtomId) -> String {
331        label(self.c, a)
332    }
333
334    /// 1. Seed the model from confident facts; `FACT X` + `NOT X` is a CONFLICT.
335    fn seed_facts(&mut self) {
336        let c = self.c;
337        let n = c.atoms.len();
338        let mut t: Vec<Option<Origin>> = vec![None; n];
339        let mut f: Vec<Option<Origin>> = vec![None; n];
340        for fact in &c.facts {
341            let slot = match fact.value {
342                Value::True => &mut t,
343                Value::False => &mut f,
344            };
345            if slot[fact.atom as usize].is_none() {
346                slot[fact.atom as usize] = Some(fact.origin.clone());
347            }
348        }
349        for a in 0..n {
350            match (&t[a], &f[a]) {
351                (Some(o), Some(_)) => {
352                    self.model[a] = V3::True;
353                    self.conflicts.push(Conflict {
354                        origin: o.clone(),
355                        atoms: vec![alloc::format!(
356                            "{} (asserted both TRUE and FALSE)",
357                            self.label(a as AtomId)
358                        )],
359                    });
360                }
361                (Some(_), None) => self.model[a] = V3::True,
362                (None, Some(_)) => self.model[a] = V3::False,
363                (None, None) => {}
364            }
365        }
366    }
367
368    /// 2. Forward-chain RULEs to a fixpoint, deriving facts (Kleene antecedent).
369    fn saturate_rules(&mut self) {
370        let c = self.c;
371        loop {
372            let mut changed = false;
373            for r in &c.rules {
374                if conjunction(&self.model, &r.antecedent) != V3::True {
375                    continue; // rule does not fire (FALSE, or blocked by UNKNOWN)
376                }
377                for cl in &r.consequent {
378                    let target = if cl.negated { V3::False } else { V3::True };
379                    match self.model[cl.atom as usize] {
380                        V3::Unknown => {
381                            self.model[cl.atom as usize] = target;
382                            changed = true;
383                            self.derived.push(Derived {
384                                atom: self.label(cl.atom),
385                                value: if cl.negated {
386                                    Value::False
387                                } else {
388                                    Value::True
389                                },
390                                origin: r.origin.clone(),
391                            });
392                        }
393                        v if v == target => {}
394                        _ => self.conflicts.push(Conflict {
395                            origin: r.origin.clone(),
396                            atoms: vec![alloc::format!(
397                                "{} (derived value contradicts a known fact)",
398                                self.label(cl.atom)
399                            )],
400                        }),
401                    }
402                }
403            }
404            if !changed {
405                break;
406            }
407        }
408    }
409
410    /// 3. Evaluate every `Impossible` clause against the model.
411    fn check_premises(&mut self) {
412        let c = self.c;
413        for clause in &c.clauses {
414            match eval_clause(&self.model, clause) {
415                ClauseEval::Violated => self.conflicts.push(Conflict {
416                    origin: clause.origin.clone(),
417                    atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
418                }),
419                ClauseEval::Satisfied => {}
420                // Implication premises warn on missing data; list premises treat
421                // UNKNOWN as "no conflict yet" and stay consistent.
422                ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
423                    self.warnings.push(Warning {
424                        origin: clause.origin.clone(),
425                        blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
426                    });
427                }
428                ClauseEval::Blocked(_) => {}
429            }
430        }
431    }
432
433    /// Backward pass (model finding), run only when a CHECK requests BIDIRECTIONAL.
434    /// Encodes premises + rules + facts as CNF and asks the SAT core for models.
435    /// No model means the system is jointly unsatisfiable (a CONFLICT the forward
436    /// pass may have missed). Two or more models means an alternative exists; we
437    /// return the UNDERDETERMINED witness — the first constrained atom the two
438    /// models disagree on.
439    fn backward_pass(&mut self) -> Option<String> {
440        if !self.c.checks.iter().any(|ch| ch.bidirectional) {
441            return None;
442        }
443        let (cnf, project) = build_cnf(self.c);
444        let found = sat::models(&cnf, &project, 2);
445        match found.len() {
446            0 if self.conflicts.is_empty() => {
447                self.conflicts.push(Conflict {
448                    origin: Origin {
449                        source: String::from("<system>"),
450                        line: 0,
451                        premise: None,
452                        kind: "UNSAT",
453                    },
454                    atoms: vec![String::from(
455                        "the premises and facts are jointly unsatisfiable",
456                    )],
457                });
458                None
459            }
460            n if n >= 2 => {
461                let (m0, m1) = (&found[0], &found[1]);
462                project
463                    .iter()
464                    .find(|&&v| m0[v as usize] != m1[v as usize])
465                    .map(|&v| self.label(v))
466                    .or_else(|| Some(String::from("a free atom")))
467            }
468            _ => None,
469        }
470    }
471
472    /// Run the backward pass, sort deterministically, and assemble the report.
473    fn finish(mut self) -> Report {
474        let underdetermined = self.backward_pass();
475        self.conflicts.sort_by_key(|c| key(&c.origin));
476        self.warnings.sort_by_key(|w| key(&w.origin));
477        let status = if !self.conflicts.is_empty() {
478            Status::Conflict
479        } else if underdetermined.is_some() {
480            Status::Underdetermined
481        } else if !self.warnings.is_empty() {
482            Status::Warning
483        } else {
484            Status::Consistent
485        };
486        Report {
487            status,
488            conflicts: self.conflicts,
489            warnings: self.warnings,
490            derived: self.derived,
491            underdetermined,
492        }
493    }
494}
495
496/// Evaluate a compiled program: the three-valued forward pass, then the backward
497/// pass on `BIDIRECTIONAL`.
498pub fn solve(c: &Compiled) -> Report {
499    let mut e = Eval::new(c);
500    e.seed_facts();
501    e.saturate_rules();
502    e.check_premises();
503    e.finish()
504}
505
506/// Encode the premises (`Impossible` clauses), rules (as implications), and
507/// confident facts (as unit clauses) into CNF for the backward pass. Also
508/// returns the constrained atoms (those appearing in a clause or rule) to
509/// project model counting onto.
510fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
511    use sat::SatLit;
512    let mut cnf = sat::Cnf::new(c.atoms.len());
513    let mut constrained = vec![false; c.atoms.len()];
514    let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
515
516    // Impossible([L1..Ln]) == (¬L1 ∨ … ∨ ¬Ln); ¬L of (atom, negated) is (atom, negated).
517    for clause in &c.clauses {
518        let lits = clause
519            .lits
520            .iter()
521            .map(|l| {
522                mark(l.atom, &mut constrained);
523                SatLit::new(l.atom, l.negated)
524            })
525            .collect();
526        cnf.add_clause(lits);
527    }
528    // RULE WHEN A.. THEN C.. == for each C: (¬A1 ∨ … ∨ C).
529    for r in &c.rules {
530        for cons in &r.consequent {
531            let mut lits: Vec<SatLit> = r
532                .antecedent
533                .iter()
534                .map(|a| {
535                    mark(a.atom, &mut constrained);
536                    SatLit::new(a.atom, a.negated)
537                })
538                .collect();
539            mark(cons.atom, &mut constrained);
540            lits.push(SatLit::new(cons.atom, !cons.negated));
541            cnf.add_clause(lits);
542        }
543    }
544    // Confident facts as unit clauses.
545    for f in &c.facts {
546        let lit = match f.value {
547            Value::True => SatLit::positive(f.atom),
548            Value::False => SatLit::negative(f.atom),
549        };
550        cnf.add_clause(vec![lit]);
551    }
552
553    let project = (0..c.atoms.len() as AtomId)
554        .filter(|&a| constrained[a as usize])
555        .collect();
556    (cnf, project)
557}
558
559/// Sort key giving conflicts/warnings a stable, source-then-line order.
560fn key(o: &Origin) -> (String, u32) {
561    (o.source.clone(), o.line)
562}
563
564/// Parse → compile → solve a single source.
565pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
566    Ok(solve(&compile_source(name, src)?))
567}
568
569/// Parse → compile (resolving imports) → solve, given a [`Resolver`].
570pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
571    Ok(solve(&compile(root, resolver)?))
572}
573
574// --- human-readable report -------------------------------------------------
575
576impl fmt::Display for Status {
577    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
578        f.write_str(match self {
579            Status::Consistent => "CONSISTENT",
580            Status::Underdetermined => "UNDERDETERMINED",
581            Status::Warning => "WARNING",
582            Status::Conflict => "CONFLICT",
583        })
584    }
585}
586
587/// Format provenance as `name (KIND)  [source:line]` for the human report.
588fn premise_tag(o: &Origin) -> String {
589    let name = o.premise.as_deref().unwrap_or("-");
590    alloc::format!("{} ({})  [{}:{}]", name, o.kind, o.source, o.line)
591}
592
593impl fmt::Display for Report {
594    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
595        writeln!(f, "RESULT: {}", self.status)?;
596        for c in &self.conflicts {
597            writeln!(f, "  CONFLICT  {}", premise_tag(&c.origin))?;
598            for a in &c.atoms {
599                writeln!(f, "      {}", a)?;
600            }
601        }
602        for w in &self.warnings {
603            writeln!(f, "  WARNING   {}", premise_tag(&w.origin))?;
604            writeln!(f, "      blocked by: {}", w.blocked_by.join(", "))?;
605        }
606        if let Some(atom) = &self.underdetermined {
607            writeln!(f, "  UNDERDETERMINED  an alternative model exists")?;
608            writeln!(f, "      pin it down: add  FACT {atom}  or  NOT {atom}")?;
609        }
610        for d in &self.derived {
611            let v = match d.value {
612                Value::True => "TRUE",
613                Value::False => "FALSE",
614            };
615            writeln!(
616                f,
617                "  DERIVED   {} = {}   from {}",
618                d.atom,
619                v,
620                premise_tag(&d.origin)
621            )?;
622        }
623        let underdetermined = usize::from(self.status == Status::Underdetermined);
624        writeln!(
625            f,
626            "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
627            self.conflicts.len(),
628            underdetermined,
629            self.warnings.len(),
630            self.derived.len()
631        )?;
632        write!(f, "EXIT_CODE: {}", self.exit_code())
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use super::*;
639
640    #[test]
641    fn clean_consistent() {
642        let r = verify_source("<t>", "FACT x a\nCHECK x\n").unwrap();
643        assert_eq!(r.status, Status::Consistent);
644        assert!(r.conflicts.is_empty() && r.warnings.is_empty());
645    }
646
647    #[test]
648    fn fact_contradiction_is_conflict() {
649        let r = verify_source("<t>", "FACT x a\nNOT x a\n").unwrap();
650        assert_eq!(r.status, Status::Conflict);
651        assert_eq!(r.conflicts.len(), 1);
652    }
653
654    #[test]
655    fn exclusive_violation_is_conflict() {
656        let src = include_str!("../../../docs/examples/conflict.vrf");
657        let r = verify_source("conflict.vrf", src).unwrap();
658        assert_eq!(r.status, Status::Conflict);
659        assert_eq!(
660            r.conflicts[0].origin.premise.as_deref(),
661            Some("fly_xor_swim")
662        );
663        assert_eq!(r.conflicts[0].atoms.len(), 2);
664    }
665
666    #[test]
667    fn exclusive_with_unknown_is_consistent_not_warning() {
668        // flying TRUE, swimming UNKNOWN — at most one can hold, no conflict, no warning.
669        let r = verify_source("<t>", "FACT A has flying\nPREMISE e:\n    EXCLUSIVE\n        A has flying\n        A has swimming\n").unwrap();
670        assert_eq!(r.status, Status::Consistent);
671        assert!(r.warnings.is_empty());
672    }
673
674    #[test]
675    fn implication_missing_consequent_is_warning() {
676        // WHEN flying THEN wing: flying TRUE, wing UNKNOWN → blocked → WARNING.
677        let r = verify_source(
678            "<t>",
679            "FACT A has flying\nPREMISE w:\n    WHEN A has flying\n    THEN A has wing\n",
680        )
681        .unwrap();
682        assert_eq!(r.status, Status::Warning);
683        assert_eq!(r.warnings.len(), 1);
684        assert_eq!(r.warnings[0].blocked_by, vec![String::from("A has wing")]);
685    }
686
687    #[test]
688    fn implication_satisfied_is_consistent() {
689        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();
690        assert_eq!(r.status, Status::Consistent);
691    }
692
693    #[test]
694    fn implication_violated_is_conflict() {
695        // antecedent TRUE, consequent FALSE → CONFLICT.
696        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();
697        assert_eq!(r.status, Status::Conflict);
698    }
699
700    #[test]
701    fn rule_derives_fact() {
702        let r = verify_source(
703            "<t>",
704            "FACT A has flying\nRULE o:\n    WHEN A has flying\n    THEN A needs oxygen\n",
705        )
706        .unwrap();
707        assert_eq!(r.status, Status::Consistent);
708        assert_eq!(r.derived.len(), 1);
709        assert_eq!(r.derived[0].atom, "A needs oxygen");
710    }
711
712    #[test]
713    fn rule_derivation_contradiction_is_conflict() {
714        // rule derives `A needs oxygen` TRUE, but it's asserted FALSE.
715        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();
716        assert_eq!(r.status, Status::Conflict);
717    }
718
719    #[test]
720    fn bidirectional_finds_alternative_model_underdetermined() {
721        // EXCLUSIVE(a,b) with no facts: {FF, TF, FT} all satisfy → not unique.
722        let r = verify_source(
723            "<t>",
724            "PREMISE e:\n    EXCLUSIVE\n        x a\n        x b\nCHECK x BIDIRECTIONAL\n",
725        )
726        .unwrap();
727        assert_eq!(r.status, Status::Underdetermined);
728    }
729
730    #[test]
731    fn fact_pins_unique_model_consistent() {
732        // Same premise, but FACT x a forces b false → the only model → CONSISTENT.
733        let r = verify_source(
734            "<t>",
735            "FACT x a\nPREMISE e:\n    EXCLUSIVE\n        x a\n        x b\nCHECK x BIDIRECTIONAL\n",
736        )
737        .unwrap();
738        assert_eq!(r.status, Status::Consistent);
739    }
740
741    #[test]
742    fn no_bidirectional_skips_backward_pass() {
743        // Plain CHECK: alternatives are not searched → stays CONSISTENT, not UNDERDETERMINED.
744        let r = verify_source(
745            "<t>",
746            "PREMISE e:\n    EXCLUSIVE\n        x a\n        x b\nCHECK x\n",
747        )
748        .unwrap();
749        assert_eq!(r.status, Status::Consistent);
750    }
751
752    #[test]
753    fn creature_example_forward_pass() {
754        let src = include_str!("../../../docs/examples/creature.vrf");
755        let r = verify_source("creature.vrf", src).unwrap();
756        // fly_xor_swim & no_dual_temp consistent; wings_need_bone → 2 warnings
757        // (wing, bone); needs_oxygen derived; no conflicts.
758        assert_eq!(r.status, Status::Warning);
759        assert!(r.conflicts.is_empty());
760        assert_eq!(r.warnings.len(), 2);
761        assert_eq!(r.derived.len(), 1);
762        assert_eq!(r.derived[0].atom, "Creature.A needs oxygen");
763    }
764
765    #[test]
766    fn roles_puzzle_is_uniquely_solved() {
767        // 3 people × 3 roles, ONEOF per person and per role, two clues. The
768        // backward (SAT) pass must find the assignment satisfiable AND unique —
769        // i.e. CONSISTENT, not UNDERDETERMINED.
770        let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
771        let r = verify_source("roles-puzzle.vrf", src).unwrap();
772        assert_eq!(r.status, Status::Consistent);
773        assert!(r.conflicts.is_empty());
774        assert!(r.underdetermined.is_none());
775    }
776
777    #[test]
778    fn roles_puzzle_underdetermined_without_a_clue() {
779        // Drop the `NOT bob is qa` clue and the solution is no longer unique
780        // (bob/carol can swap dev/qa) — the SAT pass reports UNDERDETERMINED.
781        // Normalize CRLF first: on a Windows checkout include_str! embeds the file
782        // with \r\n, so a literal "...\n" match would otherwise miss the line.
783        let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
784            .replace("\r\n", "\n")
785            .replace("NOT  bob is qa\n", "");
786        let r = verify_source("roles-puzzle.vrf", &src).unwrap();
787        assert_eq!(r.status, Status::Underdetermined);
788    }
789
790    #[test]
791    fn socrates_chain_is_a_conflict() {
792        // human → animal → living → mortal (3 derivations), then mortal EXCLUSIVE
793        // immortal with `immortal` asserted → CONFLICT on the exclusivity premise.
794        let src = include_str!("../../../docs/examples/socrates.vrf");
795        let r = verify_source("socrates.vrf", src).unwrap();
796        assert_eq!(r.status, Status::Conflict);
797        assert_eq!(r.conflicts.len(), 1);
798        assert_eq!(
799            r.conflicts[0].origin.premise.as_deref(),
800            Some("mortal_xor_immortal")
801        );
802        assert_eq!(r.derived.len(), 3); // animal, living, mortal
803    }
804}