Skip to main content

elenchus_solver/
lib.rs

1//! elenchus-solver — the inference 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, AtomKey, 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    /// The derivation chain that forced the participating atoms to the values
84    /// which made the constraint fire — supporting facts first, then each rule
85    /// built on them, ending at the conflict. This is the answer to "CONFLICT,
86    /// but *why*?". Empty for a direct `FACT X` + `NOT X` contradiction and for
87    /// the `<system>` joint-unsatisfiability conflict (neither has a chain).
88    pub trace: Vec<TraceStep>,
89}
90
91/// One link in a [`Conflict`]'s derivation chain: an atom, the value it was
92/// forced to, and why it holds that value.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct TraceStep {
95    /// Human label of the atom (`subject predicate [object]`).
96    pub atom: String,
97    /// The confident value the atom was forced to (TRUE or FALSE).
98    pub value: Value,
99    /// Why the atom holds that value.
100    pub reason: TraceReason,
101}
102
103/// Why a [`TraceStep`] atom holds its value.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum TraceReason {
106    /// Asserted directly by a `FACT` / `NOT`.
107    Asserted(Origin),
108    /// Derived by a `RULE` whose antecedent atoms all held.
109    Derived {
110        /// Provenance of the firing rule.
111        origin: Origin,
112        /// Human labels of the antecedent atoms that supported the derivation.
113        from: Vec<String>,
114    },
115}
116
117/// A constraint that could not be checked because a needed atom is UNKNOWN.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Warning {
120    /// Provenance of the constraint that could not be checked.
121    pub origin: Origin,
122    /// Human labels of the UNKNOWN atoms blocking the check.
123    pub blocked_by: Vec<String>,
124}
125
126/// A fact produced by a `RULE` during forward chaining.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Derived {
129    /// Human label of the atom whose value was derived.
130    pub atom: String,
131    /// The value the rule assigned (TRUE, or FALSE for a `THEN NOT …`).
132    pub value: Value,
133    /// Provenance of the `RULE` that produced it.
134    pub origin: Origin,
135}
136
137/// The result of solving, self-contained (atom ids already resolved to labels).
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct Report {
140    /// The overall verdict.
141    pub status: Status,
142    /// Every violated constraint / fact contradiction (sorted by source+line).
143    pub conflicts: Vec<Conflict>,
144    /// Every premise blocked by an UNKNOWN atom (sorted by source+line).
145    pub warnings: Vec<Warning>,
146    /// Facts produced by forward-chaining `RULE`s.
147    pub derived: Vec<Derived>,
148    /// When `UNDERDETERMINED`, the label of an atom left free by the constraints
149    /// (asserting it would pin the model down).
150    pub underdetermined: Option<String>,
151    /// When the system is jointly unsatisfiable but the forward pass found no
152    /// single violated constraint, the minimal set of constructs (facts /
153    /// premises / rules) whose removal restores satisfiability — i.e. the
154    /// smallest group jointly to blame. Empty in every other case.
155    pub unsat_core: Vec<CoreItem>,
156    /// Advisory near-duplicate atom-name hints (possible typos). Never affects
157    /// [`Report::status`] or [`Report::exit_code`] — purely informational.
158    pub hints: Vec<SimilarAtoms>,
159}
160
161/// An advisory hint that two atom names look like the same atom typed two
162/// different ways (e.g. `is_rolled_back` vs `is rolled_back`). **Purely a
163/// suggestion** — it never changes the verdict, the warning pool, or the exit
164/// code. It exists to catch the silent-typo trap where a misspelling creates a
165/// new UNKNOWN atom that quietly never links to the rest of the program.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct SimilarAtoms {
168    /// One atom's human label (`subject predicate [object]`).
169    pub a: String,
170    /// The other atom's human label.
171    pub b: String,
172    /// Why the pair was flagged (a short, fixed explanation).
173    pub reason: &'static str,
174}
175
176/// One construct named in an [`Report::unsat_core`].
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct CoreItem {
179    /// Provenance of the construct (source, line, kind, premise name if any).
180    pub origin: Origin,
181    /// A human label: the premise/rule name, or the atom for a bare `FACT`/`NOT`.
182    pub label: String,
183}
184
185impl Report {
186    /// CLI-style exit code: 0 = consistent, 1 = underdetermined/warnings, 2 = conflicts.
187    pub fn exit_code(&self) -> i32 {
188        match self.status {
189            Status::Conflict => 2,
190            Status::Underdetermined | Status::Warning => 1,
191            Status::Consistent => 0,
192        }
193    }
194
195    /// Render the report as a single-line JSON object (for tooling / MCP).
196    /// Hand-written so the crate stays dependency-free and `no_std`.
197    pub fn to_json(&self) -> String {
198        use core::fmt::Write as _;
199        let mut s = String::new();
200        let _ = write!(s, "{{\"status\":");
201        json_str(status_name(self.status), &mut s);
202        let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
203
204        s.push_str(",\"conflicts\":[");
205        for (i, c) in self.conflicts.iter().enumerate() {
206            if i > 0 {
207                s.push(',');
208            }
209            json_origin(&c.origin, &mut s);
210            s.push_str(",\"atoms\":");
211            json_array(&c.atoms, &mut s);
212            s.push_str(",\"trace\":[");
213            for (j, step) in c.trace.iter().enumerate() {
214                if j > 0 {
215                    s.push(',');
216                }
217                json_trace_step(step, &mut s);
218            }
219            s.push_str("]}");
220        }
221        s.push_str("],\"warnings\":[");
222        for (i, w) in self.warnings.iter().enumerate() {
223            if i > 0 {
224                s.push(',');
225            }
226            json_origin(&w.origin, &mut s);
227            s.push_str(",\"blocked_by\":");
228            json_array(&w.blocked_by, &mut s);
229            s.push('}');
230        }
231        s.push_str("],\"derived\":[");
232        for (i, d) in self.derived.iter().enumerate() {
233            if i > 0 {
234                s.push(',');
235            }
236            s.push('{');
237            json_origin_fields(&d.origin, &mut s);
238            s.push_str(",\"atom\":");
239            json_str(&d.atom, &mut s);
240            let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
241            s.push('}');
242        }
243        s.push_str("],\"underdetermined\":");
244        match &self.underdetermined {
245            Some(atom) => json_str(atom, &mut s),
246            None => s.push_str("null"),
247        }
248        s.push_str(",\"unsat_core\":[");
249        for (i, it) in self.unsat_core.iter().enumerate() {
250            if i > 0 {
251                s.push(',');
252            }
253            json_origin(&it.origin, &mut s);
254            s.push_str(",\"label\":");
255            json_str(&it.label, &mut s);
256            s.push('}');
257        }
258        s.push_str("],\"hints\":[");
259        for (i, h) in self.hints.iter().enumerate() {
260            if i > 0 {
261                s.push(',');
262            }
263            s.push_str("{\"a\":");
264            json_str(&h.a, &mut s);
265            s.push_str(",\"b\":");
266            json_str(&h.b, &mut s);
267            s.push_str(",\"reason\":");
268            json_str(h.reason, &mut s);
269            s.push('}');
270        }
271        s.push_str("]}");
272        s
273    }
274}
275
276/// Push one derivation-trace step as a JSON object.
277fn json_trace_step(step: &TraceStep, out: &mut String) {
278    use core::fmt::Write as _;
279    out.push_str("{\"atom\":");
280    json_str(&step.atom, out);
281    let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
282    match &step.reason {
283        TraceReason::Asserted(o) => {
284            out.push_str(",\"how\":\"asserted\",");
285            json_origin_fields(o, out);
286            out.push_str(",\"from\":[]");
287        }
288        TraceReason::Derived { origin, from } => {
289            out.push_str(",\"how\":\"derived\",");
290            json_origin_fields(origin, out);
291            out.push_str(",\"from\":");
292            json_array(from, out);
293        }
294    }
295    out.push('}');
296}
297
298fn status_name(s: Status) -> &'static str {
299    match s {
300        Status::Consistent => "CONSISTENT",
301        Status::Underdetermined => "UNDERDETERMINED",
302        Status::Warning => "WARNING",
303        Status::Conflict => "CONFLICT",
304    }
305}
306
307/// Push a JSON-escaped string literal (including the surrounding quotes).
308fn json_str(value: &str, out: &mut String) {
309    use core::fmt::Write as _;
310    out.push('"');
311    for ch in value.chars() {
312        match ch {
313            '"' => out.push_str("\\\""),
314            '\\' => out.push_str("\\\\"),
315            '\n' => out.push_str("\\n"),
316            '\r' => out.push_str("\\r"),
317            '\t' => out.push_str("\\t"),
318            c if (c as u32) < 0x20 => {
319                let _ = write!(out, "\\u{:04x}", c as u32);
320            }
321            c => out.push(c),
322        }
323    }
324    out.push('"');
325}
326
327/// Push a JSON array of escaped strings.
328fn json_array(items: &[String], out: &mut String) {
329    out.push('[');
330    for (i, item) in items.iter().enumerate() {
331        if i > 0 {
332            out.push(',');
333        }
334        json_str(item, out);
335    }
336    out.push(']');
337}
338
339/// `"premise":..,"kind":..,"source":..,"line":..` (no braces).
340fn json_origin_fields(o: &Origin, out: &mut String) {
341    use core::fmt::Write as _;
342    out.push_str("\"premise\":");
343    match &o.premise {
344        Some(name) => json_str(name, out),
345        None => out.push_str("null"),
346    }
347    out.push_str(",\"kind\":");
348    json_str(o.kind, out);
349    out.push_str(",\"source\":");
350    json_str(&o.source, out);
351    let _ = write!(out, ",\"line\":{}", o.line);
352}
353
354/// Open an object `{` and write the origin fields.
355fn json_origin(o: &Origin, out: &mut String) {
356    out.push('{');
357    json_origin_fields(o, out);
358}
359
360/// Render atom `a` as the human string `subject predicate [object]`.
361fn label(c: &Compiled, a: AtomId) -> String {
362    let k = &c.atoms[a as usize];
363    match &k.object {
364        Some(o) => alloc::format!("{} {} {}", k.subject, k.predicate, o),
365        None => alloc::format!("{} {}", k.subject, k.predicate),
366    }
367}
368
369/// The three-valued value of a literal: the atom's value, flipped if negated.
370fn lit_value(model: &[V3], l: &Lit) -> V3 {
371    let v = model[l.atom as usize];
372    if l.negated { v.not() } else { v }
373}
374
375/// Kleene AND over a conjunction of literals (a rule antecedent / clause prefix).
376fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
377    let mut result = V3::True;
378    for l in lits {
379        match lit_value(model, l) {
380            V3::False => return V3::False,
381            V3::Unknown => result = V3::Unknown,
382            V3::True => {}
383        }
384    }
385    result
386}
387
388/// The status of one `Impossible` clause under the current model.
389enum ClauseEval {
390    /// Every literal is forced TRUE → the constraint is violated.
391    Violated,
392    /// Some literal is FALSE → the literals cannot all hold → satisfied.
393    Satisfied,
394    /// No FALSE literal, but an UNKNOWN remains: the check is blocked on these atoms.
395    Blocked(Vec<AtomId>),
396}
397
398/// Classify one `Impossible` clause under the model: all-true is a violation,
399/// any-false satisfies it, otherwise it is blocked on the remaining UNKNOWNs.
400fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
401    let mut any_false = false;
402    let mut all_true = true;
403    let mut blocked = Vec::new();
404    for l in &clause.lits {
405        match lit_value(model, l) {
406            V3::False => {
407                any_false = true;
408                all_true = false;
409            }
410            V3::Unknown => {
411                all_true = false;
412                blocked.push(l.atom);
413            }
414            V3::True => {}
415        }
416    }
417    if all_true {
418        ClauseEval::Violated
419    } else if any_false {
420        ClauseEval::Satisfied
421    } else {
422        ClauseEval::Blocked(blocked)
423    }
424}
425
426/// Why an atom holds its confident value — the forward pass records this so a
427/// conflict can be traced back to the facts and rules that forced it.
428#[derive(Clone)]
429enum AtomReason {
430    /// Set directly by a `FACT` / `NOT`.
431    Asserted(Origin),
432    /// Derived by a firing `RULE` from the listed antecedent atoms.
433    Derived { origin: Origin, from: Vec<AtomId> },
434}
435
436/// An internal conflict before labels and trace are materialized. `atoms` are the
437/// exact display strings; `cause` are the atoms whose forcing chains explain it
438/// (empty when there is no chain to show, e.g. a direct fact contradiction).
439struct RawConflict {
440    origin: Origin,
441    atoms: Vec<String>,
442    cause: Vec<AtomId>,
443}
444
445/// Working state of the forward + backward evaluation, evaluated as a pipeline.
446struct Eval<'a> {
447    c: &'a Compiled,
448    model: Vec<V3>,
449    /// Per-atom provenance, filled by `seed_facts` and `saturate_rules`; read by
450    /// `build_trace` once the model is final.
451    reason: Vec<Option<AtomReason>>,
452    conflicts: Vec<RawConflict>,
453    warnings: Vec<Warning>,
454    derived: Vec<Derived>,
455    /// Minimal set of constructs to blame when the backward pass finds UNSAT.
456    unsat_core: Vec<CoreItem>,
457}
458
459impl<'a> Eval<'a> {
460    fn new(c: &'a Compiled) -> Self {
461        Eval {
462            c,
463            model: vec![V3::Unknown; c.atoms.len()],
464            reason: vec![None; c.atoms.len()],
465            conflicts: Vec::new(),
466            warnings: Vec::new(),
467            derived: Vec::new(),
468            unsat_core: Vec::new(),
469        }
470    }
471
472    fn label(&self, a: AtomId) -> String {
473        label(self.c, a)
474    }
475
476    /// 1. Seed the model from confident facts; `FACT X` + `NOT X` is a CONFLICT.
477    fn seed_facts(&mut self) {
478        let c = self.c;
479        let n = c.atoms.len();
480        let mut t: Vec<Option<Origin>> = vec![None; n];
481        let mut f: Vec<Option<Origin>> = vec![None; n];
482        for fact in &c.facts {
483            let slot = match fact.value {
484                Value::True => &mut t,
485                Value::False => &mut f,
486            };
487            if slot[fact.atom as usize].is_none() {
488                slot[fact.atom as usize] = Some(fact.origin.clone());
489            }
490        }
491        for a in 0..n {
492            match (&t[a], &f[a]) {
493                (Some(o), Some(_)) => {
494                    self.model[a] = V3::True;
495                    self.reason[a] = Some(AtomReason::Asserted(o.clone()));
496                    self.conflicts.push(RawConflict {
497                        origin: o.clone(),
498                        atoms: vec![alloc::format!(
499                            "{} (asserted both TRUE and FALSE)",
500                            self.label(a as AtomId)
501                        )],
502                        cause: Vec::new(),
503                    });
504                }
505                (Some(o), None) => {
506                    self.model[a] = V3::True;
507                    self.reason[a] = Some(AtomReason::Asserted(o.clone()));
508                }
509                (None, Some(o)) => {
510                    self.model[a] = V3::False;
511                    self.reason[a] = Some(AtomReason::Asserted(o.clone()));
512                }
513                (None, None) => {}
514            }
515        }
516    }
517
518    /// 2. Forward-chain RULEs to a fixpoint, deriving facts (Kleene antecedent).
519    fn saturate_rules(&mut self) {
520        let c = self.c;
521        loop {
522            let mut changed = false;
523            for r in &c.rules {
524                if conjunction(&self.model, &r.antecedent) != V3::True {
525                    continue; // rule does not fire (FALSE, or blocked by UNKNOWN)
526                }
527                for cl in &r.consequent {
528                    let target = if cl.negated { V3::False } else { V3::True };
529                    match self.model[cl.atom as usize] {
530                        V3::Unknown => {
531                            self.model[cl.atom as usize] = target;
532                            self.reason[cl.atom as usize] = Some(AtomReason::Derived {
533                                origin: r.origin.clone(),
534                                from: r.antecedent.iter().map(|l| l.atom).collect(),
535                            });
536                            changed = true;
537                            self.derived.push(Derived {
538                                atom: self.label(cl.atom),
539                                value: if cl.negated {
540                                    Value::False
541                                } else {
542                                    Value::True
543                                },
544                                origin: r.origin.clone(),
545                            });
546                        }
547                        v if v == target => {}
548                        _ => {
549                            // The rule wants the opposite of a value the atom already
550                            // holds. Trace both sides: why the antecedent fired (its
551                            // atoms) and how the atom got its existing value.
552                            let mut cause: Vec<AtomId> =
553                                r.antecedent.iter().map(|l| l.atom).collect();
554                            cause.push(cl.atom);
555                            self.conflicts.push(RawConflict {
556                                origin: r.origin.clone(),
557                                atoms: vec![alloc::format!(
558                                    "{} (derived value contradicts a known fact)",
559                                    self.label(cl.atom)
560                                )],
561                                cause,
562                            });
563                        }
564                    }
565                }
566            }
567            if !changed {
568                break;
569            }
570        }
571    }
572
573    /// 3. Evaluate every `Impossible` clause against the model.
574    fn check_premises(&mut self) {
575        let c = self.c;
576        for clause in &c.clauses {
577            match eval_clause(&self.model, clause) {
578                ClauseEval::Violated => self.conflicts.push(RawConflict {
579                    origin: clause.origin.clone(),
580                    atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
581                    cause: clause.lits.iter().map(|l| l.atom).collect(),
582                }),
583                ClauseEval::Satisfied => {}
584                // Implication premises warn on missing data; list premises treat
585                // UNKNOWN as "no conflict yet" and stay consistent.
586                ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
587                    self.warnings.push(Warning {
588                        origin: clause.origin.clone(),
589                        blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
590                    });
591                }
592                ClauseEval::Blocked(_) => {}
593            }
594        }
595    }
596
597    /// Build the derivation chain that explains why the `causes` atoms hold their
598    /// current values: a post-order walk of the reason graph so each atom's
599    /// supports appear before it (facts first, the conflict atoms last), with
600    /// every atom emitted once. Atoms with no recorded reason (UNKNOWN) are
601    /// skipped — a forced atom always has one.
602    fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
603        let mut visited = vec![false; self.c.atoms.len()];
604        let mut out = Vec::new();
605        for &a in causes {
606            self.trace_dfs(a, &mut visited, &mut out);
607        }
608        out
609    }
610
611    fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
612        if visited[a as usize] {
613            return;
614        }
615        visited[a as usize] = true;
616        let value = match v3_to_value(self.model[a as usize]) {
617            Some(v) => v,
618            None => return, // UNKNOWN: nothing forced it, nothing to explain
619        };
620        let reason = match &self.reason[a as usize] {
621            Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
622            Some(AtomReason::Derived { origin, from }) => {
623                for &f in from {
624                    self.trace_dfs(f, visited, out); // supports first
625                }
626                TraceReason::Derived {
627                    origin: origin.clone(),
628                    from: from.iter().map(|&f| self.label(f)).collect(),
629                }
630            }
631            None => return,
632        };
633        out.push(TraceStep {
634            atom: self.label(a),
635            value,
636            reason,
637        });
638    }
639
640    /// Backward pass (model finding), run only when a CHECK requests BIDIRECTIONAL.
641    /// Encodes premises + rules + facts as CNF and asks the SAT core for models.
642    /// No model means the system is jointly unsatisfiable (a CONFLICT the forward
643    /// pass may have missed). Two or more models means an alternative exists; we
644    /// return the UNDERDETERMINED witness — the first constrained atom the two
645    /// models disagree on.
646    fn backward_pass(&mut self) -> Option<String> {
647        if !self.c.checks.iter().any(|ch| ch.bidirectional) {
648            return None;
649        }
650        let (cnf, project) = build_cnf(self.c);
651        let found = sat::models(&cnf, &project, 2);
652        match found.len() {
653            0 if self.conflicts.is_empty() => {
654                self.unsat_core = minimal_unsat_core(self.c);
655                self.conflicts.push(RawConflict {
656                    origin: Origin {
657                        source: String::from("<system>"),
658                        line: 0,
659                        premise: None,
660                        kind: "UNSAT",
661                    },
662                    atoms: vec![String::from(
663                        "the premises and facts are jointly unsatisfiable",
664                    )],
665                    cause: Vec::new(),
666                });
667                None
668            }
669            n if n >= 2 => {
670                let (m0, m1) = (&found[0], &found[1]);
671                project
672                    .iter()
673                    .find(|&&v| m0[v as usize] != m1[v as usize])
674                    .map(|&v| self.label(v))
675                    .or_else(|| Some(String::from("a free atom")))
676            }
677            _ => None,
678        }
679    }
680
681    /// Run the backward pass, sort deterministically, and assemble the report.
682    fn finish(mut self) -> Report {
683        let underdetermined = self.backward_pass();
684        self.conflicts.sort_by_key(|c| key(&c.origin));
685        self.warnings.sort_by_key(|w| key(&w.origin));
686        let status = if !self.conflicts.is_empty() {
687            Status::Conflict
688        } else if underdetermined.is_some() {
689            Status::Underdetermined
690        } else if !self.warnings.is_empty() {
691            Status::Warning
692        } else {
693            Status::Consistent
694        };
695        // Materialize each raw conflict into its public form, attaching the
696        // derivation chain (reasons are final once the forward pass is done).
697        let conflicts: Vec<Conflict> = self
698            .conflicts
699            .iter()
700            .map(|rc| Conflict {
701                origin: rc.origin.clone(),
702                atoms: rc.atoms.clone(),
703                trace: self.build_trace(&rc.cause),
704            })
705            .collect();
706        Report {
707            status,
708            conflicts,
709            warnings: self.warnings,
710            derived: self.derived,
711            underdetermined,
712            unsat_core: self.unsat_core,
713            hints: Vec::new(), // filled by `solve` (advisory, post-verdict)
714        }
715    }
716}
717
718/// Evaluate a compiled program: the three-valued forward pass, then the backward
719/// pass on `BIDIRECTIONAL`.
720pub fn solve(c: &Compiled) -> Report {
721    let mut e = Eval::new(c);
722    e.seed_facts();
723    e.saturate_rules();
724    e.check_premises();
725    let mut report = e.finish();
726    // Advisory only: surface likely atom-name typos. Computed after the verdict
727    // so it can never influence status/exit code.
728    report.hints = similar_atom_pairs(c);
729    report
730}
731
732// --- near-duplicate atom detection (advisory typo hints) -------------------
733
734/// Detect pairs of distinct atoms whose names look like the same atom typed two
735/// ways. Two deliberately conservative signals (keep false positives minimal):
736///
737/// - **A — fold-equal:** identical after lowercasing and treating `_`/whitespace
738///   as one separator (`Has_fuel`/`has_fuel`, `is_rolled_back`/`is rolled_back`).
739///   Distinct atoms that fold to the same string are almost always one typo.
740/// - **B — near edit:** *same subject*, an *alphabetic (cased)* script, and a
741///   Levenshtein distance of exactly 1 over the folded form (names ≥ 5 chars).
742///   Distance 1 only — distance 2 flags real antonyms (mortal/immortal) far too
743///   often. Edit distance is a typo signal only where a word spans many
744///   characters; in caseless scripts (CJK / kana / hangul) one character is a
745///   whole word, so a one-character change is normally a *different* word — those
746///   are skipped by the "cased letters only" test (no hard-coded Unicode ranges).
747///
748/// Signal A is fully script-agnostic; signal B is the script-sensitive one.
749/// `O(n²)` over the (typically small) atom set, with a length-difference quick
750/// reject. Deterministic: atoms are already canonically sorted in `Compiled`.
751fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
752    let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
753    let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
754    let mut out = Vec::new();
755    for i in 0..c.atoms.len() {
756        for j in (i + 1)..c.atoms.len() {
757            if let Some(reason) = atoms_look_similar(
758                &c.atoms[i],
759                &folded[i],
760                cased[i],
761                &c.atoms[j],
762                &folded[j],
763                cased[j],
764            ) {
765                out.push(SimilarAtoms {
766                    a: label(c, i as AtomId),
767                    b: label(c, j as AtomId),
768                    reason,
769                });
770            }
771        }
772    }
773    out
774}
775
776/// Fold an atom to its comparison form: `subject predicate [object]` lowercased,
777/// every `_`/whitespace run collapsed to a single space. So `_` vs space vs case
778/// can never distinguish two names.
779fn fold_atom(k: &AtomKey) -> Vec<char> {
780    let mut raw = String::new();
781    raw.push_str(&k.subject);
782    raw.push(' ');
783    raw.push_str(&k.predicate);
784    if let Some(o) = &k.object {
785        raw.push(' ');
786        raw.push_str(o);
787    }
788    let mut out: Vec<char> = Vec::new();
789    let mut prev_space = false;
790    for ch in raw.chars() {
791        if ch == '_' || ch.is_whitespace() {
792            if !prev_space && !out.is_empty() {
793                out.push(' ');
794                prev_space = true;
795            }
796        } else {
797            for lc in ch.to_lowercase() {
798                out.push(lc);
799            }
800            prev_space = false;
801        }
802    }
803    if out.last() == Some(&' ') {
804        out.pop();
805    }
806    out
807}
808
809/// Whether every character of a folded name is a space or a *cased* letter — the
810/// script-agnostic gate for edit-distance (signal B). Cased scripts (Latin,
811/// Cyrillic, Greek, …) span many characters per word, so a one-character edit is
812/// a plausible typo. Caseless scripts (CJK / kana / hangul, where one character
813/// is a whole word) and digits report `is_lowercase() == false` after folding, so
814/// they fall out here without enumerating any Unicode ranges.
815fn is_cased_alphabetic(folded: &[char]) -> bool {
816    folded.iter().all(|&c| c == ' ' || c.is_lowercase())
817}
818
819/// The two-signal similarity test (see [`similar_atom_pairs`]). Returns the
820/// reason string when the pair looks like a typo, else `None`.
821fn atoms_look_similar(
822    ka: &AtomKey,
823    fa: &[char],
824    cased_a: bool,
825    kb: &AtomKey,
826    fb: &[char],
827    cased_b: bool,
828) -> Option<&'static str> {
829    // A — same folded form (the AtomKeys differ, so the raw spelling differs).
830    if fa == fb {
831        return Some("same name up to case, '_', or spaces");
832    }
833    // B — same subject, an alphabetic (cased) script, a single-character slip.
834    // Only distance 1: distance 2 flags real antonyms (mortal/immortal) and word
835    // pairs far too often — genuine typos are almost always a one-character edit,
836    // and the underscore/case case is already covered by signal A.
837    if !cased_a || !cased_b || ka.subject != kb.subject {
838        return None;
839    }
840    if fa.len().abs_diff(fb.len()) > 1 {
841        return None; // edit distance >= length difference, so it can't be 1
842    }
843    let min_len = fa.len().min(fb.len());
844    if min_len >= 5 && levenshtein(fa, fb) == 1 {
845        return Some("looks like a one-character typo of each other");
846    }
847    None
848}
849
850/// Plain Levenshtein edit distance over char slices (rolling two-row DP).
851fn levenshtein(a: &[char], b: &[char]) -> usize {
852    let mut prev: Vec<usize> = (0..=b.len()).collect();
853    let mut cur = vec![0usize; b.len() + 1];
854    for (i, &ca) in a.iter().enumerate() {
855        cur[0] = i + 1;
856        for (j, &cb) in b.iter().enumerate() {
857            let cost = usize::from(ca != cb);
858            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
859        }
860        core::mem::swap(&mut prev, &mut cur);
861    }
862    prev[b.len()]
863}
864
865/// Encode the premises (`Impossible` clauses), rules (as implications), and
866/// confident facts (as unit clauses) into CNF for the backward pass. Also
867/// returns the constrained atoms (those appearing in a clause or rule) to
868/// project model counting onto.
869fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
870    use sat::SatLit;
871    let mut cnf = sat::Cnf::new(c.atoms.len());
872    let mut constrained = vec![false; c.atoms.len()];
873    let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
874
875    // Impossible([L1..Ln]) == (¬L1 ∨ … ∨ ¬Ln); ¬L of (atom, negated) is (atom, negated).
876    for clause in &c.clauses {
877        let lits = clause
878            .lits
879            .iter()
880            .map(|l| {
881                mark(l.atom, &mut constrained);
882                SatLit::new(l.atom, l.negated)
883            })
884            .collect();
885        cnf.add_clause(lits);
886    }
887    // RULE WHEN A.. THEN C.. == for each C: (¬A1 ∨ … ∨ C).
888    for r in &c.rules {
889        for cons in &r.consequent {
890            let mut lits: Vec<SatLit> = r
891                .antecedent
892                .iter()
893                .map(|a| {
894                    mark(a.atom, &mut constrained);
895                    SatLit::new(a.atom, a.negated)
896                })
897                .collect();
898            mark(cons.atom, &mut constrained);
899            lits.push(SatLit::new(cons.atom, !cons.negated));
900            cnf.add_clause(lits);
901        }
902    }
903    // Confident facts as unit clauses.
904    for f in &c.facts {
905        let lit = match f.value {
906            Value::True => SatLit::positive(f.atom),
907            Value::False => SatLit::negative(f.atom),
908        };
909        cnf.add_clause(vec![lit]);
910    }
911
912    let project = (0..c.atoms.len() as AtomId)
913        .filter(|&a| constrained[a as usize])
914        .collect();
915    (cnf, project)
916}
917
918/// Convert a three-valued model entry to a confident [`Value`] (UNKNOWN → `None`).
919fn v3_to_value(v: V3) -> Option<Value> {
920    match v {
921        V3::True => Some(Value::True),
922        V3::False => Some(Value::False),
923        V3::Unknown => None,
924    }
925}
926
927/// A removable source construct (one fact, one premise, or one rule) and the CNF
928/// clauses it contributes — the unit of an unsat-core explanation.
929struct Construct {
930    origin: Origin,
931    label: String,
932    clauses: Vec<Vec<sat::SatLit>>,
933}
934
935/// Two origins refer to the same source construct.
936fn same_origin(a: &Origin, b: &Origin) -> bool {
937    a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
938}
939
940/// Split the program into removable constructs. A premise that desugared into
941/// several clauses (e.g. an `EXCLUSIVE` over n atoms) is grouped back into one
942/// construct by origin, so the core blames whole premises, not clause shards.
943fn constructs(c: &Compiled) -> Vec<Construct> {
944    use sat::SatLit;
945    let mut out: Vec<Construct> = Vec::new();
946
947    for f in &c.facts {
948        let lit = match f.value {
949            Value::True => SatLit::positive(f.atom),
950            Value::False => SatLit::negative(f.atom),
951        };
952        out.push(Construct {
953            origin: f.origin.clone(),
954            label: label(c, f.atom),
955            clauses: vec![vec![lit]],
956        });
957    }
958
959    let mut premises: Vec<Construct> = Vec::new();
960    for clause in &c.clauses {
961        let lits: Vec<SatLit> = clause
962            .lits
963            .iter()
964            .map(|l| SatLit::new(l.atom, l.negated))
965            .collect();
966        match premises
967            .iter_mut()
968            .find(|k| same_origin(&k.origin, &clause.origin))
969        {
970            Some(k) => k.clauses.push(lits),
971            None => premises.push(Construct {
972                label: clause.origin.premise.clone().unwrap_or_default(),
973                origin: clause.origin.clone(),
974                clauses: vec![lits],
975            }),
976        }
977    }
978    out.extend(premises);
979
980    for r in &c.rules {
981        let clauses = r
982            .consequent
983            .iter()
984            .map(|cons| {
985                let mut lits: Vec<SatLit> = r
986                    .antecedent
987                    .iter()
988                    .map(|a| SatLit::new(a.atom, a.negated))
989                    .collect();
990                lits.push(SatLit::new(cons.atom, !cons.negated));
991                lits
992            })
993            .collect();
994        out.push(Construct {
995            label: r.origin.premise.clone().unwrap_or_default(),
996            origin: r.origin.clone(),
997            clauses,
998        });
999    }
1000    out
1001}
1002
1003/// Is the program satisfiable using only the constructs marked active?
1004fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1005    let mut cnf = sat::Cnf::new(num_vars);
1006    for (k, &keep) in all.iter().zip(active) {
1007        if keep {
1008            for cl in &k.clauses {
1009                cnf.add_clause(cl.clone());
1010            }
1011        }
1012    }
1013    sat::solve(&cnf).is_some()
1014}
1015
1016/// A fast sufficient core via one assumption-solve: each construct gets a fresh
1017/// selector variable `s_k`, every clause becomes `(¬s_k ∨ clause)`, and we solve
1018/// asserting all selectors true. The SAT core (a subset of the selectors) names a
1019/// sufficient set of constructs in a single solve — versus O(n) deletion solves.
1020/// Returns an `active` mask over `all`.
1021fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1022    let base = c.atoms.len();
1023    let mut cnf = sat::Cnf::new(base + all.len());
1024    let sel = |i: usize| (base + i) as sat::Var;
1025    for (i, k) in all.iter().enumerate() {
1026        let s_neg = sat::SatLit::negative(sel(i));
1027        for cl in &k.clauses {
1028            let mut lits = Vec::with_capacity(cl.len() + 1);
1029            lits.push(s_neg);
1030            lits.extend_from_slice(cl);
1031            cnf.add_clause(lits);
1032        }
1033    }
1034    let assumptions: Vec<sat::SatLit> = (0..all.len())
1035        .map(|i| sat::SatLit::positive(sel(i)))
1036        .collect();
1037    let mut active = vec![false; all.len()];
1038    match sat::solve_assuming(&cnf, &assumptions) {
1039        sat::Solved::Unsat(core) => {
1040            for lit in core {
1041                let v = lit.var() as usize;
1042                if v >= base {
1043                    active[v - base] = true;
1044                }
1045            }
1046        }
1047        // The caller only calls this when the system is UNSAT, so this is
1048        // unreachable; fall back to all-active so the deletion pass below is still
1049        // correct (just slower).
1050        sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1051    }
1052    active
1053}
1054
1055/// A 1-minimal unsat core. First an assumption-solve narrows the program to a
1056/// sufficient candidate ([`candidate_via_assumptions`]); then deletion-based
1057/// minimization over *that candidate only* drops each construct in turn — if the
1058/// rest is still unsatisfiable it was not needed — leaving an irreducible set
1059/// jointly to blame. Called only when the full system is UNSAT.
1060fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1061    let all = constructs(c);
1062    let mut active = candidate_via_assumptions(c, &all);
1063    for i in 0..all.len() {
1064        if active[i] {
1065            active[i] = false;
1066            if subset_is_sat(c.atoms.len(), &all, &active) {
1067                active[i] = true; // removing it restored SAT → it is part of the core
1068            }
1069        }
1070    }
1071    let mut core: Vec<CoreItem> = all
1072        .iter()
1073        .zip(&active)
1074        .filter(|&(_, &keep)| keep)
1075        .map(|(k, _)| CoreItem {
1076            origin: k.origin.clone(),
1077            label: k.label.clone(),
1078        })
1079        .collect();
1080    core.sort_by_key(|it| key(&it.origin));
1081    core
1082}
1083
1084/// Sort key giving conflicts/warnings a stable, source-then-line order.
1085fn key(o: &Origin) -> (String, u32) {
1086    (o.source.clone(), o.line)
1087}
1088
1089/// Parse → compile → solve a single source.
1090pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1091    Ok(solve(&compile_source(name, src)?))
1092}
1093
1094/// Parse → compile (resolving imports) → solve, given a [`Resolver`].
1095pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1096    Ok(solve(&compile(root, resolver)?))
1097}
1098
1099// --- human-readable report -------------------------------------------------
1100
1101impl fmt::Display for Status {
1102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1103        f.write_str(match self {
1104            Status::Consistent => "CONSISTENT",
1105            Status::Underdetermined => "UNDERDETERMINED",
1106            Status::Warning => "WARNING",
1107            Status::Conflict => "CONFLICT",
1108        })
1109    }
1110}
1111
1112/// Format provenance as `name (KIND)  [source:line]` for the human report.
1113fn premise_tag(o: &Origin) -> String {
1114    let name = o.premise.as_deref().unwrap_or("-");
1115    alloc::format!("{} ({})  [{}:{}]", name, o.kind, o.source, o.line)
1116}
1117
1118/// One derivation-trace line for the human report.
1119fn trace_line(step: &TraceStep) -> String {
1120    let v = match step.value {
1121        Value::True => "TRUE",
1122        Value::False => "FALSE",
1123    };
1124    match &step.reason {
1125        TraceReason::Asserted(o) => {
1126            alloc::format!(
1127                "{} = {}   [{} {}:{}]",
1128                step.atom,
1129                v,
1130                o.kind,
1131                o.source,
1132                o.line
1133            )
1134        }
1135        TraceReason::Derived { origin, from } => alloc::format!(
1136            "{} = {}   from {} ({})  [{}:{}]  <= {}",
1137            step.atom,
1138            v,
1139            origin.premise.as_deref().unwrap_or("-"),
1140            origin.kind,
1141            origin.source,
1142            origin.line,
1143            from.join(", ")
1144        ),
1145    }
1146}
1147
1148impl fmt::Display for Report {
1149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1150        writeln!(f, "RESULT: {}", self.status)?;
1151        for c in &self.conflicts {
1152            writeln!(f, "  CONFLICT  {}", premise_tag(&c.origin))?;
1153            for a in &c.atoms {
1154                writeln!(f, "      {}", a)?;
1155            }
1156            if !c.trace.is_empty() {
1157                writeln!(f, "      why:")?;
1158                for step in &c.trace {
1159                    writeln!(f, "        {}", trace_line(step))?;
1160                }
1161            }
1162        }
1163        if !self.unsat_core.is_empty() {
1164            writeln!(
1165                f,
1166                "  CORE  smallest jointly-unsatisfiable set ({}):",
1167                self.unsat_core.len()
1168            )?;
1169            for it in &self.unsat_core {
1170                let name = if it.label.is_empty() { "-" } else { &it.label };
1171                writeln!(
1172                    f,
1173                    "        {} ({})  [{}:{}]",
1174                    name, it.origin.kind, it.origin.source, it.origin.line
1175                )?;
1176            }
1177        }
1178        for w in &self.warnings {
1179            writeln!(f, "  WARNING   {}", premise_tag(&w.origin))?;
1180            writeln!(f, "      blocked by: {}", w.blocked_by.join(", "))?;
1181        }
1182        if let Some(atom) = &self.underdetermined {
1183            writeln!(f, "  UNDERDETERMINED  an alternative model exists")?;
1184            writeln!(f, "      pin it down: add  FACT {atom}  or  NOT {atom}")?;
1185        }
1186        for d in &self.derived {
1187            let v = match d.value {
1188                Value::True => "TRUE",
1189                Value::False => "FALSE",
1190            };
1191            writeln!(
1192                f,
1193                "  DERIVED   {} = {}   from {}",
1194                d.atom,
1195                v,
1196                premise_tag(&d.origin)
1197            )?;
1198        }
1199        for h in &self.hints {
1200            writeln!(
1201                f,
1202                "  HINT      possible typo — '{}' and '{}' look like the same atom ({})",
1203                h.a, h.b, h.reason
1204            )?;
1205        }
1206        let underdetermined = usize::from(self.status == Status::Underdetermined);
1207        writeln!(
1208            f,
1209            "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1210            self.conflicts.len(),
1211            underdetermined,
1212            self.warnings.len(),
1213            self.derived.len()
1214        )?;
1215        write!(f, "EXIT_CODE: {}", self.exit_code())
1216    }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222
1223    #[test]
1224    fn clean_consistent() {
1225        let r = verify_source("<t>", "FACT x a\nCHECK x\n").unwrap();
1226        assert_eq!(r.status, Status::Consistent);
1227        assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1228    }
1229
1230    #[test]
1231    fn fact_contradiction_is_conflict() {
1232        let r = verify_source("<t>", "FACT x a\nNOT x a\n").unwrap();
1233        assert_eq!(r.status, Status::Conflict);
1234        assert_eq!(r.conflicts.len(), 1);
1235    }
1236
1237    #[test]
1238    fn exclusive_violation_is_conflict() {
1239        let src = include_str!("../../../docs/examples/conflict.vrf");
1240        let r = verify_source("conflict.vrf", src).unwrap();
1241        assert_eq!(r.status, Status::Conflict);
1242        assert_eq!(
1243            r.conflicts[0].origin.premise.as_deref(),
1244            Some("fly_xor_swim")
1245        );
1246        assert_eq!(r.conflicts[0].atoms.len(), 2);
1247    }
1248
1249    #[test]
1250    fn exclusive_with_unknown_is_consistent_not_warning() {
1251        // flying TRUE, swimming UNKNOWN — at most one can hold, no conflict, no warning.
1252        let r = verify_source("<t>", "FACT A has flying\nPREMISE e:\n    EXCLUSIVE\n        A has flying\n        A has swimming\n").unwrap();
1253        assert_eq!(r.status, Status::Consistent);
1254        assert!(r.warnings.is_empty());
1255    }
1256
1257    #[test]
1258    fn implication_missing_consequent_is_warning() {
1259        // WHEN flying THEN wing: flying TRUE, wing UNKNOWN → blocked → WARNING.
1260        let r = verify_source(
1261            "<t>",
1262            r#"
1263        FACT A has flying
1264        PREMISE w:
1265            WHEN A has flying
1266            THEN A has wing
1267        "#,
1268        )
1269        .unwrap();
1270        assert_eq!(r.status, Status::Warning);
1271        assert_eq!(r.warnings.len(), 1);
1272        assert_eq!(r.warnings[0].blocked_by, vec![String::from("A has wing")]);
1273    }
1274
1275    #[test]
1276    fn implication_satisfied_is_consistent() {
1277        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();
1278        assert_eq!(r.status, Status::Consistent);
1279    }
1280
1281    #[test]
1282    fn implication_violated_is_conflict() {
1283        // antecedent TRUE, consequent FALSE → CONFLICT.
1284        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();
1285        assert_eq!(r.status, Status::Conflict);
1286    }
1287
1288    #[test]
1289    fn rule_derives_fact() {
1290        let r = verify_source(
1291            "<t>",
1292            r#"
1293        FACT A has flying
1294        RULE o:
1295            WHEN A has flying
1296            THEN A needs oxygen
1297        "#,
1298        )
1299        .unwrap();
1300        assert_eq!(r.status, Status::Consistent);
1301        assert_eq!(r.derived.len(), 1);
1302        assert_eq!(r.derived[0].atom, "A needs oxygen");
1303    }
1304
1305    #[test]
1306    fn rule_derivation_contradiction_is_conflict() {
1307        // rule derives `A needs oxygen` TRUE, but it's asserted FALSE.
1308        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();
1309        assert_eq!(r.status, Status::Conflict);
1310    }
1311
1312    #[test]
1313    fn bidirectional_finds_alternative_model_underdetermined() {
1314        // EXCLUSIVE(a,b) with no facts: {FF, TF, FT} all satisfy → not unique.
1315        let r = verify_source(
1316            "<t>",
1317            r#"
1318        PREMISE e:
1319            EXCLUSIVE
1320                x a
1321                x b
1322        CHECK x BIDIRECTIONAL
1323        "#,
1324        )
1325        .unwrap();
1326        assert_eq!(r.status, Status::Underdetermined);
1327    }
1328
1329    #[test]
1330    fn fact_pins_unique_model_consistent() {
1331        // Same premise, but FACT x a forces b false → the only model → CONSISTENT.
1332        let r = verify_source(
1333            "<t>",
1334            r#"
1335        FACT x a
1336        PREMISE e:
1337            EXCLUSIVE
1338                x a
1339                x b
1340        CHECK x BIDIRECTIONAL
1341        "#,
1342        )
1343        .unwrap();
1344        assert_eq!(r.status, Status::Consistent);
1345    }
1346
1347    #[test]
1348    fn no_bidirectional_skips_backward_pass() {
1349        // Plain CHECK: alternatives are not searched → stays CONSISTENT, not UNDERDETERMINED.
1350        let r = verify_source(
1351            "<t>",
1352            r#"
1353        PREMISE e:
1354            EXCLUSIVE
1355                x a
1356                x b
1357        CHECK x
1358        "#,
1359        )
1360        .unwrap();
1361        assert_eq!(r.status, Status::Consistent);
1362    }
1363
1364    #[test]
1365    fn creature_example_forward_pass() {
1366        let src = include_str!("../../../docs/examples/creature.vrf");
1367        let r = verify_source("creature.vrf", src).unwrap();
1368        // fly_xor_swim & no_dual_temp consistent; wings_need_bone → 2 warnings
1369        // (wing, bone); needs_oxygen derived; no conflicts.
1370        assert_eq!(r.status, Status::Warning);
1371        assert!(r.conflicts.is_empty());
1372        assert_eq!(r.warnings.len(), 2);
1373        assert_eq!(r.derived.len(), 1);
1374        assert_eq!(r.derived[0].atom, "Creature.A needs oxygen");
1375    }
1376
1377    #[test]
1378    fn roles_puzzle_is_uniquely_solved() {
1379        // 3 people × 3 roles, ONEOF per person and per role, two clues. The
1380        // backward (SAT) pass must find the assignment satisfiable AND unique —
1381        // i.e. CONSISTENT, not UNDERDETERMINED.
1382        let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1383        let r = verify_source("roles-puzzle.vrf", src).unwrap();
1384        assert_eq!(r.status, Status::Consistent);
1385        assert!(r.conflicts.is_empty());
1386        assert!(r.underdetermined.is_none());
1387    }
1388
1389    #[test]
1390    fn roles_puzzle_underdetermined_without_a_clue() {
1391        // Drop the `NOT bob is qa` clue and the solution is no longer unique
1392        // (bob/carol can swap dev/qa) — the SAT pass reports UNDERDETERMINED.
1393        // Normalize CRLF first: on a Windows checkout include_str! embeds the file
1394        // with \r\n, so a literal "...\n" match would otherwise miss the line.
1395        let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1396            .replace("\r\n", "\n")
1397            .replace("NOT  bob is qa\n", "");
1398        let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1399        assert_eq!(r.status, Status::Underdetermined);
1400    }
1401
1402    #[test]
1403    fn socrates_chain_is_a_conflict() {
1404        // human → animal → living → mortal (3 derivations), then mortal EXCLUSIVE
1405        // immortal with `immortal` asserted → CONFLICT on the exclusivity premise.
1406        let src = include_str!("../../../docs/examples/socrates.vrf");
1407        let r = verify_source("socrates.vrf", src).unwrap();
1408        assert_eq!(r.status, Status::Conflict);
1409        assert_eq!(r.conflicts.len(), 1);
1410        assert_eq!(
1411            r.conflicts[0].origin.premise.as_deref(),
1412            Some("mortal_xor_immortal")
1413        );
1414        assert_eq!(r.derived.len(), 3); // animal, living, mortal
1415    }
1416
1417    // --- conflict explainability: derivation trace + minimal unsat core ------
1418
1419    #[test]
1420    fn forward_conflict_carries_a_trace_of_its_facts() {
1421        let r = verify_source(
1422            "<t>",
1423            r#"
1424        FACT x a
1425        FACT x b
1426        PREMISE e:
1427            EXCLUSIVE
1428                x a
1429                x b
1430        CHECK x
1431        "#,
1432        )
1433        .unwrap();
1434        assert_eq!(r.status, Status::Conflict);
1435        let t = &r.conflicts[0].trace;
1436        assert_eq!(t.len(), 2);
1437        assert_eq!(t[0].atom, "x a");
1438        assert_eq!(t[0].value, Value::True);
1439        assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1440        assert!(r.unsat_core.is_empty());
1441    }
1442
1443    #[test]
1444    fn derivation_chain_is_traced_back_to_the_root_fact() {
1445        // human → animal → living → mortal, then mortal XOR immortal (immortal asserted).
1446        let src = include_str!("../../../docs/examples/socrates.vrf");
1447        let r = verify_source("socrates.vrf", src).unwrap();
1448        let t = &r.conflicts[0].trace;
1449        // human (fact) + animal, living, mortal (derived) + immortal (fact) = 5 steps,
1450        // supports before dependents.
1451        assert_eq!(t.len(), 5);
1452        assert_eq!(t[0].atom, "socrates is human");
1453        assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1454        let mortal = t.iter().find(|s| s.atom == "socrates is mortal").unwrap();
1455        match &mortal.reason {
1456            TraceReason::Derived { from, .. } => {
1457                assert_eq!(from, &vec![String::from("socrates is living")]);
1458            }
1459            _ => panic!("mortal should be derived, not asserted"),
1460        }
1461    }
1462
1463    #[test]
1464    fn direct_fact_contradiction_has_no_trace() {
1465        let r = verify_source("<t>", "FACT x a\nNOT x a\nCHECK x\n").unwrap();
1466        assert_eq!(r.status, Status::Conflict);
1467        assert!(r.conflicts[0].trace.is_empty());
1468    }
1469
1470    #[test]
1471    fn jointly_unsatisfiable_reports_a_minimal_core() {
1472        // ONEOF(a,b); a→c; b→c; NOT c. Unsat only via case-split, so the forward
1473        // pass misses it and the backward pass produces the core.
1474        let src = r#"
1475        PREMISE one:
1476            ONEOF
1477                x a
1478                x b
1479        PREMISE ac:
1480            WHEN x a
1481            THEN x c
1482        PREMISE bc:
1483            WHEN x b
1484            THEN x c
1485        NOT x c
1486        CHECK x BIDIRECTIONAL
1487        "#;
1488        let r = verify_source("<t>", src).unwrap();
1489        assert_eq!(r.status, Status::Conflict);
1490        assert_eq!(r.conflicts[0].origin.kind, "UNSAT");
1491        assert_eq!(r.unsat_core.len(), 4);
1492        let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1493        assert!(labels.contains(&"one"));
1494        assert!(labels.contains(&"x c")); // the bare NOT fact is labelled by its atom
1495    }
1496
1497    #[test]
1498    fn unsat_core_excludes_irrelevant_constructs() {
1499        // The same unsat cluster, plus an unrelated fact + premise that must not
1500        // appear in the (irreducible) core.
1501        let src = r#"
1502        PREMISE one:
1503            ONEOF
1504                x a
1505                x b
1506        PREMISE ac:
1507            WHEN x a
1508            THEN x c
1509        PREMISE bc:
1510            WHEN x b
1511            THEN x c
1512        NOT x c
1513        FACT z here
1514        PREMISE noise:
1515            EXCLUSIVE
1516                z here
1517                z gone
1518        CHECK x BIDIRECTIONAL
1519        "#;
1520        let r = verify_source("<t>", src).unwrap();
1521        assert_eq!(r.status, Status::Conflict);
1522        assert_eq!(r.unsat_core.len(), 4);
1523        let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1524        assert!(!labels.contains(&"noise"));
1525        assert!(!labels.iter().any(|l| l.contains("here")));
1526    }
1527
1528    #[test]
1529    fn consistent_report_has_empty_core_and_no_trace() {
1530        let r = verify_source("<t>", "FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
1531        assert_eq!(r.status, Status::Consistent);
1532        assert!(r.unsat_core.is_empty());
1533        assert!(r.conflicts.is_empty());
1534    }
1535
1536    // --- near-duplicate atom hints (advisory typo detector) ----------------
1537
1538    #[test]
1539    fn hint_flags_underscore_vs_space_and_is_advisory_only() {
1540        // The real trap: `is rolled_back` (obj) vs `is_rolled_back` (pred) are
1541        // DIFFERENT atoms — no contradiction, so the verdict stays CONSISTENT —
1542        // but the hint warns they were probably meant to be one atom.
1543        let r = verify_source(
1544            "<t>",
1545            r#"
1546        FACT auth is rolled_back
1547        NOT auth is_rolled_back
1548        CHECK
1549        "#,
1550        )
1551        .unwrap();
1552        assert_eq!(
1553            r.status,
1554            Status::Consistent,
1555            "hint must not change the verdict"
1556        );
1557        assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
1558        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1559        assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
1560    }
1561
1562    #[test]
1563    fn hint_flags_case_only_difference() {
1564        let r = verify_source("<t>", "FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
1565        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1566    }
1567
1568    #[test]
1569    fn hint_flags_single_char_typo_same_subject() {
1570        // alphabetic, same subject, edit distance 1, len >= 5 → signal B.
1571        let r = verify_source("<t>", "FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
1572        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1573    }
1574
1575    #[test]
1576    fn no_hint_for_short_distinct_atoms() {
1577        // `x a` vs `x b`: distance 1 but intentionally different — must NOT flag.
1578        let r = verify_source("<t>", "FACT x a\nNOT x b\nCHECK\n").unwrap();
1579        assert!(r.hints.is_empty(), "{:?}", r.hints);
1580    }
1581
1582    #[test]
1583    fn no_hint_for_distinct_words() {
1584        let r = verify_source("<t>", "FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
1585        assert!(r.hints.is_empty(), "{:?}", r.hints);
1586    }
1587
1588    #[test]
1589    fn russian_case_typo_is_flagged() {
1590        // Signal A is script-agnostic: lowercasing works for Cyrillic too.
1591        let r = verify_source("<t>", "FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
1592        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1593    }
1594
1595    #[test]
1596    fn russian_single_char_typo_is_flagged() {
1597        let r = verify_source("<t>", "FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
1598        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1599    }
1600
1601    #[test]
1602    fn cjk_one_char_difference_is_not_flagged() {
1603        // Caseless script: a one-character change is a different word, not a typo,
1604        // so the edit-distance signal is skipped (only exact fold-equality fires).
1605        let r = verify_source("<t>", "FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
1606        assert!(r.hints.is_empty(), "{:?}", r.hints);
1607    }
1608
1609    #[test]
1610    fn cjk_underscore_vs_space_is_flagged() {
1611        // Signal A still applies to any script: `a 猫_黑` (pred) vs `a 猫 黑`
1612        // (pred+obj) fold to the same name.
1613        let r = verify_source("<t>", "FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
1614        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1615    }
1616}