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//!
22//! # Example
23//!
24//! ```
25//! use elenchus_solver::{Status, verify_source};
26//!
27//! // `A has flying` fires the premise, but `A has wing` was never stated — so
28//! // the engine cannot confirm the rule and reports WARNING (not CONFLICT).
29//! let report = verify_source(
30//!     "demo.vrf",
31//!     "DOMAIN d\nFACT A has flying\nPREMISE w:\n    WHEN A has flying\n    THEN A has wing\n",
32//! )
33//! .unwrap();
34//! assert_eq!(report.status, Status::Warning); // `A has wing` is UNKNOWN
35//! println!("{report}"); // the full human report, ready to show a model
36//! ```
37#![no_std]
38// Every public item is documented; CI (`clippy -D warnings`) keeps it that way.
39#![warn(missing_docs)]
40
41extern crate alloc;
42
43#[cfg(feature = "std")]
44extern crate std;
45
46pub mod sat;
47
48use alloc::collections::BTreeSet;
49use alloc::string::String;
50use alloc::vec;
51use alloc::vec::Vec;
52use core::fmt;
53
54/// Re-exported so library users handling a [`CompileError::Parse`] can render the
55/// syntax diagnostics with their own error limit (e.g. CLI `--max-errors`).
56pub use elenchus_compiler::Diagnostics;
57use elenchus_compiler::{
58    AtomId, AtomKey, Clause, Compiled, Fact, KIND_UNSAT, Lit, Origin, Rule, Value, kw, levenshtein,
59};
60pub use elenchus_compiler::{
61    CompileError, MemoryResolver, Resolver, UnusedImport, compile, compile_source,
62};
63
64/// The engine version (this crate's package version). Exposed so a wrapper —
65/// e.g. the wasm/npm build, which carries its own, independent package version —
66/// can report the *engine* version (and compare it to a skill's
67/// `<!-- skill-version -->` marker) rather than its own.
68pub const VERSION: &str = env!("CARGO_PKG_VERSION");
69
70/// Three-valued truth (Kleene). UNKNOWN is a first-class value, not hidden FALSE.
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum V3 {
73    /// Known true.
74    True,
75    /// Known false.
76    False,
77    /// Not asserted and not derivable — the absence of information, not falsity.
78    Unknown,
79}
80
81impl V3 {
82    /// Kleene negation: TRUE↔FALSE, and UNKNOWN stays UNKNOWN.
83    fn not(self) -> V3 {
84        match self {
85            V3::True => V3::False,
86            V3::False => V3::True,
87            V3::Unknown => V3::Unknown,
88        }
89    }
90}
91
92/// Overall verdict for the system.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Status {
95    /// No contradictions, and (when checked) the model is pinned down.
96    Consistent,
97    /// The constraints are satisfiable but do not pin a unique assignment — an
98    /// alternative model exists (found by the backward pass on `BIDIRECTIONAL`).
99    Underdetermined,
100    /// A premise could not be checked because a needed atom is UNKNOWN.
101    Warning,
102    /// A premise is violated, or the premises + facts are jointly unsatisfiable.
103    Conflict,
104}
105
106/// A violated constraint (or a fact-level contradiction).
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Conflict {
109    /// Provenance of the violated constraint (source, line, premise name, kind).
110    pub origin: Origin,
111    /// Human labels of the atoms participating in the contradiction.
112    pub atoms: Vec<String>,
113    /// The derivation chain that forced the participating atoms to the values
114    /// which made the constraint fire — supporting facts first, then each rule
115    /// built on them, ending at the conflict. This is the answer to "CONFLICT,
116    /// but *why*?". Empty for a direct `FACT X` + `NOT X` contradiction and for
117    /// the `<system>` joint-unsatisfiability conflict (neither has a chain).
118    pub trace: Vec<TraceStep>,
119}
120
121/// One link in a [`Conflict`]'s derivation chain: an atom, the value it was
122/// forced to, and why it holds that value.
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct TraceStep {
125    /// Human label of the atom (`subject predicate [object]`).
126    pub atom: String,
127    /// The confident value the atom was forced to (TRUE or FALSE).
128    pub value: Value,
129    /// Why the atom holds that value.
130    pub reason: TraceReason,
131}
132
133/// Why a [`TraceStep`] atom holds its value.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum TraceReason {
136    /// Asserted directly by a `FACT` / `NOT`.
137    Asserted(Origin),
138    /// Derived by a `RULE` whose antecedent atoms all held.
139    Derived {
140        /// Provenance of the firing rule.
141        origin: Origin,
142        /// Human labels of the antecedent atoms that supported the derivation.
143        from: Vec<String>,
144    },
145}
146
147/// A constraint that could not be checked because a needed atom is UNKNOWN.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Warning {
150    /// Provenance of the constraint that could not be checked.
151    pub origin: Origin,
152    /// Human labels of the UNKNOWN atoms blocking the check.
153    pub blocked_by: Vec<String>,
154    /// A directed fix for the most informative blocking atom, distinguishing the
155    /// two reasons a check stays blocked: the atom is a *free input* nothing can
156    /// determine (→ add a `FACT`/`NOT`, or make a `PREMISE` a `RULE` so it derives
157    /// the value), versus the atom *is* derivable by a `RULE` that has not fired
158    /// (→ assert that rule's antecedent). Advisory text; never changes the verdict.
159    pub hint: Option<String>,
160}
161
162/// A fact produced by a `RULE` during forward chaining.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct Derived {
165    /// Human label of the atom whose value was derived.
166    pub atom: String,
167    /// The value the rule assigned (TRUE, or FALSE for a `THEN NOT …`).
168    pub value: Value,
169    /// Provenance of the `RULE` that produced it.
170    pub origin: Origin,
171}
172
173/// The result of solving, self-contained (atom ids already resolved to labels).
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Report {
176    /// The overall verdict.
177    pub status: Status,
178    /// Every violated constraint / fact contradiction (sorted by source+line).
179    pub conflicts: Vec<Conflict>,
180    /// Every premise blocked by an UNKNOWN atom (sorted by source+line).
181    pub warnings: Vec<Warning>,
182    /// Facts produced by forward-chaining `RULE`s.
183    pub derived: Vec<Derived>,
184    /// When `UNDERDETERMINED`, the label of an atom left free by the constraints
185    /// (asserting it would pin the model down).
186    pub underdetermined: Option<String>,
187    /// When the system is jointly unsatisfiable but the forward pass found no
188    /// single violated constraint, the minimal set of constructs (facts /
189    /// premises / rules) whose removal restores satisfiability — i.e. the
190    /// smallest group jointly to blame. Empty in every other case.
191    pub unsat_core: Vec<CoreItem>,
192    /// When `ASSUME` hypotheses are what break an otherwise-consistent program,
193    /// the minimal set of assumptions that cannot all hold *together with the
194    /// (consistent) facts, premises and rules* — dropping any one restores
195    /// consistency. Only ever lists `ASSUME` constructs: facts and premises are
196    /// never blamed. Empty whenever the facts/premises are themselves to blame
197    /// (a hard contradiction) or there is no conflict at all. The verdict stays
198    /// `CONFLICT` (exit code 2); this field only says *which dial to turn*.
199    pub retract: Vec<CoreItem>,
200    /// Advisory near-duplicate atom-name hints (possible typos). Never affects
201    /// [`Report::status`] or [`Report::exit_code`] — purely informational.
202    pub hints: Vec<SimilarAtoms>,
203    /// Advisory "orphan fact" lints: a `FACT`/`NOT`/`ASSUME` whose atom is never
204    /// referenced by any `PREMISE` or `RULE`, so it can neither be checked nor
205    /// derive anything — it has no effect on the verdict. Never affects
206    /// [`Report::status`] or [`Report::exit_code`] — purely informational.
207    pub orphans: Vec<OrphanFact>,
208    /// Advisory "unused import" lints: a file `IMPORT`s a domain it never
209    /// references (no `domain.atom` from that file uses it), so the import is
210    /// inert. Never affects [`Report::status`] or [`Report::exit_code`] — purely
211    /// informational. (Carried through from compilation; see [`UnusedImport`].)
212    pub unused_imports: Vec<UnusedImport>,
213}
214
215/// An advisory hint that two atom names look like the same atom typed two
216/// different ways (e.g. `is_rolled_back` vs `is rolled_back`). **Purely a
217/// suggestion** — it never changes the verdict, the warning pool, or the exit
218/// code. It exists to catch the silent-typo trap where a misspelling creates a
219/// new UNKNOWN atom that quietly never links to the rest of the program.
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct SimilarAtoms {
222    /// One atom's human label (`subject predicate [object]`).
223    pub a: String,
224    /// The other atom's human label.
225    pub b: String,
226    /// Why the pair was flagged (a short, fixed explanation).
227    pub reason: &'static str,
228}
229
230/// An advisory lint: a `FACT`/`NOT`/`ASSUME` whose atom appears in **no**
231/// `PREMISE` or `RULE`. Such an assertion is logically inert — nothing checks it
232/// and nothing is derived from it, so it can never produce a CONFLICT, WARNING or
233/// DERIVED. It is almost always a typo'd atom name or a leftover line. **Purely
234/// informational** — it never changes the verdict, the warning pool, or the exit
235/// code (a program full of orphans is still perfectly CONSISTENT).
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct OrphanFact {
238    /// The atom's human label (`subject predicate [object]`), without polarity.
239    pub atom: String,
240    /// The asserted value — `False` means the surface line was `NOT`/`ASSUME NOT`.
241    pub value: Value,
242    /// Provenance of the inert assertion (source, line, kind = `FACT`/`NOT`/`ASSUME`).
243    pub origin: Origin,
244}
245
246/// One construct named in an [`Report::unsat_core`].
247#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct CoreItem {
249    /// Provenance of the construct (source, line, kind, premise name if any).
250    pub origin: Origin,
251    /// A human label: the premise/rule name, or the atom for a bare `FACT`/`NOT`.
252    pub label: String,
253}
254
255impl Report {
256    /// CLI-style exit code: 0 = consistent, 1 = underdetermined/warnings, 2 = conflicts.
257    pub fn exit_code(&self) -> i32 {
258        match self.status {
259            Status::Conflict => 2,
260            Status::Underdetermined | Status::Warning => 1,
261            Status::Consistent => 0,
262        }
263    }
264
265    /// Render the report as a single-line JSON object (for tooling / MCP).
266    /// Hand-written so the crate stays dependency-free and `no_std`.
267    pub fn to_json(&self) -> String {
268        use core::fmt::Write as _;
269        let mut s = String::new();
270        let _ = write!(s, "{{\"status\":");
271        json_str(status_name(self.status), &mut s);
272        let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
273
274        s.push_str(",\"conflicts\":[");
275        for (i, c) in self.conflicts.iter().enumerate() {
276            if i > 0 {
277                s.push(',');
278            }
279            json_origin(&c.origin, &mut s);
280            s.push_str(",\"atoms\":");
281            json_array(&c.atoms, &mut s);
282            s.push_str(",\"trace\":[");
283            for (j, step) in c.trace.iter().enumerate() {
284                if j > 0 {
285                    s.push(',');
286                }
287                json_trace_step(step, &mut s);
288            }
289            s.push_str("]}");
290        }
291        s.push_str("],\"warnings\":[");
292        for (i, w) in self.warnings.iter().enumerate() {
293            if i > 0 {
294                s.push(',');
295            }
296            json_origin(&w.origin, &mut s);
297            s.push_str(",\"blocked_by\":");
298            json_array(&w.blocked_by, &mut s);
299            s.push_str(",\"hint\":");
300            match &w.hint {
301                Some(h) => json_str(h, &mut s),
302                None => s.push_str("null"),
303            }
304            s.push('}');
305        }
306        s.push_str("],\"derived\":[");
307        for (i, d) in self.derived.iter().enumerate() {
308            if i > 0 {
309                s.push(',');
310            }
311            s.push('{');
312            json_origin_fields(&d.origin, &mut s);
313            s.push_str(",\"atom\":");
314            json_str(&d.atom, &mut s);
315            let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
316            s.push('}');
317        }
318        s.push_str("],\"underdetermined\":");
319        match &self.underdetermined {
320            Some(atom) => json_str(atom, &mut s),
321            None => s.push_str("null"),
322        }
323        s.push_str(",\"unsat_core\":[");
324        for (i, it) in self.unsat_core.iter().enumerate() {
325            if i > 0 {
326                s.push(',');
327            }
328            json_origin(&it.origin, &mut s);
329            s.push_str(",\"label\":");
330            json_str(&it.label, &mut s);
331            s.push('}');
332        }
333        s.push_str("],\"retract\":[");
334        for (i, it) in self.retract.iter().enumerate() {
335            if i > 0 {
336                s.push(',');
337            }
338            json_origin(&it.origin, &mut s);
339            s.push_str(",\"label\":");
340            json_str(&it.label, &mut s);
341            s.push('}');
342        }
343        s.push_str("],\"hints\":[");
344        for (i, h) in self.hints.iter().enumerate() {
345            if i > 0 {
346                s.push(',');
347            }
348            s.push_str("{\"a\":");
349            json_str(&h.a, &mut s);
350            s.push_str(",\"b\":");
351            json_str(&h.b, &mut s);
352            s.push_str(",\"reason\":");
353            json_str(h.reason, &mut s);
354            s.push('}');
355        }
356        s.push_str("],\"orphans\":[");
357        for (i, o) in self.orphans.iter().enumerate() {
358            if i > 0 {
359                s.push(',');
360            }
361            json_origin(&o.origin, &mut s);
362            s.push_str(",\"atom\":");
363            json_str(&o.atom, &mut s);
364            let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
365            s.push('}');
366        }
367        s.push_str("],\"unused_imports\":[");
368        for (i, u) in self.unused_imports.iter().enumerate() {
369            if i > 0 {
370                s.push(',');
371            }
372            s.push_str("{\"file\":");
373            json_str(&u.file, &mut s);
374            s.push_str(",\"domain\":");
375            json_str(&u.domain, &mut s);
376            s.push_str(",\"alias\":");
377            match &u.alias {
378                Some(a) => json_str(a, &mut s),
379                None => s.push_str("null"),
380            }
381            let _ = write!(s, ",\"line\":{}", u.line);
382            s.push('}');
383        }
384        s.push_str("]}");
385        s
386    }
387}
388
389/// Push one derivation-trace step as a JSON object.
390fn json_trace_step(step: &TraceStep, out: &mut String) {
391    use core::fmt::Write as _;
392    out.push_str("{\"atom\":");
393    json_str(&step.atom, out);
394    let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
395    match &step.reason {
396        TraceReason::Asserted(o) => {
397            out.push_str(",\"how\":\"asserted\",");
398            json_origin_fields(o, out);
399            out.push_str(",\"from\":[]");
400        }
401        TraceReason::Derived { origin, from } => {
402            out.push_str(",\"how\":\"derived\",");
403            json_origin_fields(origin, out);
404            out.push_str(",\"from\":");
405            json_array(from, out);
406        }
407    }
408    out.push('}');
409}
410
411fn status_name(s: Status) -> &'static str {
412    match s {
413        Status::Consistent => "CONSISTENT",
414        Status::Underdetermined => "UNDERDETERMINED",
415        Status::Warning => "WARNING",
416        Status::Conflict => "CONFLICT",
417    }
418}
419
420/// Push a JSON-escaped string literal (including the surrounding quotes).
421fn json_str(value: &str, out: &mut String) {
422    use core::fmt::Write as _;
423    out.push('"');
424    for ch in value.chars() {
425        match ch {
426            '"' => out.push_str("\\\""),
427            '\\' => out.push_str("\\\\"),
428            '\n' => out.push_str("\\n"),
429            '\r' => out.push_str("\\r"),
430            '\t' => out.push_str("\\t"),
431            c if (c as u32) < 0x20 => {
432                let _ = write!(out, "\\u{:04x}", c as u32);
433            }
434            c => out.push(c),
435        }
436    }
437    out.push('"');
438}
439
440/// Push a JSON array of escaped strings.
441fn json_array(items: &[String], out: &mut String) {
442    out.push('[');
443    for (i, item) in items.iter().enumerate() {
444        if i > 0 {
445            out.push(',');
446        }
447        json_str(item, out);
448    }
449    out.push(']');
450}
451
452/// `"premise":..,"kind":..,"source":..,"line":..` (no braces).
453fn json_origin_fields(o: &Origin, out: &mut String) {
454    use core::fmt::Write as _;
455    out.push_str("\"premise\":");
456    match &o.premise {
457        Some(name) => json_str(name, out),
458        None => out.push_str("null"),
459    }
460    out.push_str(",\"kind\":");
461    json_str(o.kind, out);
462    out.push_str(",\"source\":");
463    json_str(&o.source, out);
464    let _ = write!(out, ",\"line\":{}", o.line);
465}
466
467/// Open an object `{` and write the origin fields.
468fn json_origin(o: &Origin, out: &mut String) {
469    out.push('{');
470    json_origin_fields(o, out);
471}
472
473/// Render atom `a` as the human string `domain.subject predicate [object]`. The
474/// domain prefix is always shown — atom identity now includes it, so the label
475/// is unambiguous (`physics.engine runs` vs `plan.engine runs`).
476fn label(c: &Compiled, a: AtomId) -> String {
477    let k = &c.atoms[a as usize];
478    match &k.object {
479        Some(o) => alloc::format!("{}.{} {} {}", k.domain, k.subject, k.predicate, o),
480        None => alloc::format!("{}.{} {}", k.domain, k.subject, k.predicate),
481    }
482}
483
484/// The three-valued value of a literal: the atom's value, flipped if negated.
485fn lit_value(model: &[V3], l: &Lit) -> V3 {
486    let v = model[l.atom as usize];
487    if l.negated { v.not() } else { v }
488}
489
490/// Kleene AND over a conjunction of literals (a rule antecedent / clause prefix).
491fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
492    let mut result = V3::True;
493    for l in lits {
494        match lit_value(model, l) {
495            V3::False => return V3::False,
496            V3::Unknown => result = V3::Unknown,
497            V3::True => {}
498        }
499    }
500    result
501}
502
503/// The status of one `Impossible` clause under the current model.
504enum ClauseEval {
505    /// Every literal is forced TRUE → the constraint is violated.
506    Violated,
507    /// Some literal is FALSE → the literals cannot all hold → satisfied.
508    Satisfied,
509    /// No FALSE literal, but an UNKNOWN remains: the check is blocked on these atoms.
510    Blocked(Vec<AtomId>),
511}
512
513/// Classify one `Impossible` clause under the model: all-true is a violation,
514/// any-false satisfies it, otherwise it is blocked on the remaining UNKNOWNs.
515fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
516    let mut any_false = false;
517    let mut all_true = true;
518    let mut blocked = Vec::new();
519    for l in &clause.lits {
520        match lit_value(model, l) {
521            V3::False => {
522                any_false = true;
523                all_true = false;
524            }
525            V3::Unknown => {
526                all_true = false;
527                blocked.push(l.atom);
528            }
529            V3::True => {}
530        }
531    }
532    if all_true {
533        ClauseEval::Violated
534    } else if any_false {
535        ClauseEval::Satisfied
536    } else {
537        ClauseEval::Blocked(blocked)
538    }
539}
540
541/// Why an atom holds its confident value — the forward pass records this so a
542/// conflict can be traced back to the facts and rules that forced it.
543#[derive(Clone)]
544enum AtomReason {
545    /// Set directly by a `FACT` / `NOT`.
546    Asserted(Origin),
547    /// Derived by a firing `RULE` from the listed antecedent atoms.
548    Derived { origin: Origin, from: Vec<AtomId> },
549}
550
551/// An internal conflict before labels and trace are materialized. `atoms` are the
552/// exact display strings; `cause` are the atoms whose forcing chains explain it
553/// (empty when there is no chain to show, e.g. a direct fact contradiction).
554struct RawConflict {
555    origin: Origin,
556    atoms: Vec<String>,
557    cause: Vec<AtomId>,
558}
559
560/// Working state of the forward + backward evaluation, evaluated as a pipeline.
561struct Eval<'a> {
562    c: &'a Compiled,
563    model: Vec<V3>,
564    /// Per-atom provenance, filled by `seed_facts` and `saturate_rules`; read by
565    /// `build_trace` once the model is final.
566    reason: Vec<Option<AtomReason>>,
567    conflicts: Vec<RawConflict>,
568    warnings: Vec<Warning>,
569    derived: Vec<Derived>,
570    /// Minimal set of constructs to blame when the backward pass finds UNSAT.
571    unsat_core: Vec<CoreItem>,
572}
573
574impl<'a> Eval<'a> {
575    fn new(c: &'a Compiled) -> Self {
576        Eval {
577            c,
578            model: vec![V3::Unknown; c.atoms.len()],
579            reason: vec![None; c.atoms.len()],
580            conflicts: Vec::new(),
581            warnings: Vec::new(),
582            derived: Vec::new(),
583            unsat_core: Vec::new(),
584        }
585    }
586
587    fn label(&self, a: AtomId) -> String {
588        label(self.c, a)
589    }
590
591    /// 1. Seed the model from confident facts; `FACT X` + `NOT X` is a CONFLICT.
592    fn seed_facts(&mut self) {
593        let c = self.c;
594        let n = c.atoms.len();
595        let mut t: Vec<Option<Origin>> = vec![None; n];
596        let mut f: Vec<Option<Origin>> = vec![None; n];
597        for fact in &c.facts {
598            let slot = match fact.value {
599                Value::True => &mut t,
600                Value::False => &mut f,
601            };
602            if slot[fact.atom as usize].is_none() {
603                slot[fact.atom as usize] = Some(fact.origin.clone());
604            }
605        }
606        for a in 0..n {
607            match (&t[a], &f[a]) {
608                (Some(o), Some(_)) => {
609                    self.model[a] = V3::True;
610                    self.reason[a] = Some(AtomReason::Asserted(o.clone()));
611                    self.conflicts.push(RawConflict {
612                        origin: o.clone(),
613                        atoms: vec![alloc::format!(
614                            "{} (asserted both TRUE and FALSE)",
615                            self.label(a as AtomId)
616                        )],
617                        cause: Vec::new(),
618                    });
619                }
620                (Some(o), None) => {
621                    self.model[a] = V3::True;
622                    self.reason[a] = Some(AtomReason::Asserted(o.clone()));
623                }
624                (None, Some(o)) => {
625                    self.model[a] = V3::False;
626                    self.reason[a] = Some(AtomReason::Asserted(o.clone()));
627                }
628                (None, None) => {}
629            }
630        }
631    }
632
633    /// 2. Forward-chain RULEs to a fixpoint, deriving facts (Kleene antecedent).
634    fn saturate_rules(&mut self) {
635        let c = self.c;
636        loop {
637            let mut changed = false;
638            for r in &c.rules {
639                if conjunction(&self.model, &r.antecedent) != V3::True {
640                    continue; // rule does not fire (FALSE, or blocked by UNKNOWN)
641                }
642                for cl in &r.consequent {
643                    let target = if cl.negated { V3::False } else { V3::True };
644                    match self.model[cl.atom as usize] {
645                        V3::Unknown => {
646                            self.model[cl.atom as usize] = target;
647                            self.reason[cl.atom as usize] = Some(AtomReason::Derived {
648                                origin: r.origin.clone(),
649                                from: r.antecedent.iter().map(|l| l.atom).collect(),
650                            });
651                            changed = true;
652                            self.derived.push(Derived {
653                                atom: self.label(cl.atom),
654                                value: if cl.negated {
655                                    Value::False
656                                } else {
657                                    Value::True
658                                },
659                                origin: r.origin.clone(),
660                            });
661                        }
662                        v if v == target => {}
663                        _ => {
664                            // The rule wants the opposite of a value the atom already
665                            // holds. Trace both sides: why the antecedent fired (its
666                            // atoms) and how the atom got its existing value.
667                            let mut cause: Vec<AtomId> =
668                                r.antecedent.iter().map(|l| l.atom).collect();
669                            cause.push(cl.atom);
670                            self.conflicts.push(RawConflict {
671                                origin: r.origin.clone(),
672                                atoms: vec![alloc::format!(
673                                    "{} (derived value contradicts a known fact)",
674                                    self.label(cl.atom)
675                                )],
676                                cause,
677                            });
678                        }
679                    }
680                }
681            }
682            if !changed {
683                break;
684            }
685        }
686    }
687
688    /// 3. Evaluate every `Impossible` clause against the model.
689    fn check_premises(&mut self) {
690        let c = self.c;
691        // Atoms some RULE can derive (appear in a rule's consequent). The pivot for
692        // the warning hint: a blocked atom in this set is a derivation waiting on
693        // its rule's antecedent; one *not* in it can only be set by a FACT/NOT — or
694        // by turning a PREMISE that means to establish it into a RULE.
695        let derivable: BTreeSet<AtomId> = c
696            .rules
697            .iter()
698            .flat_map(|r| r.consequent.iter().map(|l| l.atom))
699            .collect();
700        for clause in &c.clauses {
701            match eval_clause(&self.model, clause) {
702                ClauseEval::Violated => self.conflicts.push(RawConflict {
703                    origin: clause.origin.clone(),
704                    atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
705                    cause: clause.lits.iter().map(|l| l.atom).collect(),
706                }),
707                ClauseEval::Satisfied => {}
708                // Implication premises warn on missing data; list premises treat
709                // UNKNOWN as "no conflict yet" and stay consistent.
710                ClauseEval::Blocked(unknowns) if clause.origin.kind == kw::PREMISE => {
711                    let hint = self.warning_hint(&unknowns, &derivable);
712                    self.warnings.push(Warning {
713                        origin: clause.origin.clone(),
714                        blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
715                        hint,
716                    });
717                }
718                ClauseEval::Blocked(_) => {}
719            }
720        }
721    }
722
723    /// Pick the most informative blocked atom and phrase a directed fix for it.
724    /// Prefers a *free input* (nothing derives it) — the common "I used a PREMISE
725    /// where I needed a RULE / forgot a FACT" trap — over an atom a RULE could
726    /// still derive.
727    fn warning_hint(&self, unknowns: &[AtomId], derivable: &BTreeSet<AtomId>) -> Option<String> {
728        let free = unknowns.iter().find(|a| !derivable.contains(a));
729        match free {
730            Some(&a) => Some(alloc::format!(
731                "nothing determines `{}` — add `FACT {}` (or `NOT …`), or if a PREMISE's \
732                 THEN is meant to establish it, make that PREMISE a RULE so it derives the value",
733                self.label(a),
734                self.label(a),
735            )),
736            None => unknowns.first().map(|&a| {
737                alloc::format!(
738                    "`{}` is derived by a RULE that has not fired — assert that rule's antecedent",
739                    self.label(a),
740                )
741            }),
742        }
743    }
744
745    /// Build the derivation chain that explains why the `causes` atoms hold their
746    /// current values: a post-order walk of the reason graph so each atom's
747    /// supports appear before it (facts first, the conflict atoms last), with
748    /// every atom emitted once. Atoms with no recorded reason (UNKNOWN) are
749    /// skipped — a forced atom always has one.
750    fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
751        let mut visited = vec![false; self.c.atoms.len()];
752        let mut out = Vec::new();
753        for &a in causes {
754            self.trace_dfs(a, &mut visited, &mut out);
755        }
756        out
757    }
758
759    fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
760        if visited[a as usize] {
761            return;
762        }
763        visited[a as usize] = true;
764        let value = match v3_to_value(self.model[a as usize]) {
765            Some(v) => v,
766            None => return, // UNKNOWN: nothing forced it, nothing to explain
767        };
768        let reason = match &self.reason[a as usize] {
769            Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
770            Some(AtomReason::Derived { origin, from }) => {
771                for &f in from {
772                    self.trace_dfs(f, visited, out); // supports first
773                }
774                TraceReason::Derived {
775                    origin: origin.clone(),
776                    from: from.iter().map(|&f| self.label(f)).collect(),
777                }
778            }
779            None => return,
780        };
781        out.push(TraceStep {
782            atom: self.label(a),
783            value,
784            reason,
785        });
786    }
787
788    /// Backward pass (model finding), run only when a CHECK requests BIDIRECTIONAL.
789    /// Encodes premises + rules + facts as CNF and asks the SAT core for models.
790    /// No model means the system is jointly unsatisfiable (a CONFLICT the forward
791    /// pass may have missed). Two or more models means an alternative exists; we
792    /// return the UNDERDETERMINED witness — the first constrained atom the two
793    /// models disagree on.
794    fn backward_pass(&mut self) -> Option<String> {
795        if !self.c.checks.iter().any(|ch| ch.bidirectional) {
796            return None;
797        }
798        let (cnf, project) = build_cnf(self.c);
799        let found = sat::models(&cnf, &project, 2);
800        match found.len() {
801            0 if self.conflicts.is_empty() => {
802                self.unsat_core = minimal_unsat_core(self.c);
803                self.conflicts.push(RawConflict {
804                    origin: Origin {
805                        source: String::from("<system>"),
806                        line: 0,
807                        premise: None,
808                        kind: KIND_UNSAT,
809                    },
810                    atoms: vec![String::from(
811                        "the premises and facts are jointly unsatisfiable",
812                    )],
813                    cause: Vec::new(),
814                });
815                None
816            }
817            n if n >= 2 => {
818                let (m0, m1) = (&found[0], &found[1]);
819                project
820                    .iter()
821                    .find(|&&v| m0[v as usize] != m1[v as usize])
822                    .map(|&v| self.label(v))
823                    .or_else(|| Some(String::from("a free atom")))
824            }
825            _ => None,
826        }
827    }
828
829    /// Run the backward pass, sort deterministically, and assemble the report.
830    fn finish(mut self) -> Report {
831        let underdetermined = self.backward_pass();
832        self.conflicts.sort_by_key(|c| key(&c.origin));
833        self.warnings.sort_by_key(|w| key(&w.origin));
834        let status = if !self.conflicts.is_empty() {
835            Status::Conflict
836        } else if underdetermined.is_some() {
837            Status::Underdetermined
838        } else if !self.warnings.is_empty() {
839            Status::Warning
840        } else {
841            Status::Consistent
842        };
843        // Materialize each raw conflict into its public form, attaching the
844        // derivation chain (reasons are final once the forward pass is done).
845        let conflicts: Vec<Conflict> = self
846            .conflicts
847            .iter()
848            .map(|rc| Conflict {
849                origin: rc.origin.clone(),
850                atoms: rc.atoms.clone(),
851                trace: self.build_trace(&rc.cause),
852            })
853            .collect();
854        Report {
855            status,
856            conflicts,
857            warnings: self.warnings,
858            derived: self.derived,
859            underdetermined,
860            unsat_core: self.unsat_core,
861            retract: Vec::new(), // filled by `solve` when assumptions are to blame
862            hints: Vec::new(),   // filled by `solve` (advisory, post-verdict)
863            orphans: Vec::new(), // filled by `solve` (advisory, post-verdict)
864            unused_imports: Vec::new(), // copied from the IR by `solve` (advisory)
865        }
866    }
867}
868
869/// Evaluate a compiled program: the three-valued forward pass, then the backward
870/// pass on `BIDIRECTIONAL`.
871pub fn solve(c: &Compiled) -> Report {
872    let mut e = Eval::new(c);
873    e.seed_facts();
874    e.saturate_rules();
875    e.check_premises();
876    let mut report = e.finish();
877    // If the program is a CONFLICT but the facts/premises are consistent on their
878    // own, the `ASSUME` hypotheses are what break it: name which to retract. The
879    // verdict stays CONFLICT — this only adds the "drop one of these" hint and,
880    // when it applies, supersedes the raw conflict/unsat-core pools (which would
881    // otherwise point at the assumption clause itself).
882    if report.status == Status::Conflict {
883        let retract = retract_assumptions(c);
884        if !retract.is_empty() {
885            report.unsat_core = Vec::new();
886            report.retract = retract;
887        }
888    }
889    // Advisory only: surface likely atom-name typos. Computed after the verdict
890    // so it can never influence status/exit code.
891    report.hints = similar_atom_pairs(c);
892    // Advisory only: surface logically-inert assertions (orphan facts). Also
893    // post-verdict, so it can never influence status/exit code.
894    report.orphans = orphan_facts(c);
895    // Advisory only: imports a file never references (computed at compile time,
896    // carried through the IR). Never influences status/exit code.
897    report.unused_imports = c.unused_imports.clone();
898    report
899}
900
901/// Collect the **orphan facts**: every `FACT`/`NOT`/`ASSUME` whose atom appears
902/// in no `PREMISE` clause and no `RULE`. Such an assertion is inert — it cannot be
903/// checked and derives nothing, so it has no bearing on the verdict.
904///
905/// An atom is "referenced" if it occurs in any desugared `PREMISE` clause
906/// (`c.clauses`) or in any `RULE`'s antecedent or consequent. (The built-in
907/// non-contradiction is not a clause here — it is enforced during fact seeding —
908/// so it does not mask an orphan.) Deterministic: facts keep source order; the
909/// result is sorted by origin (source, line).
910fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
911    let mut referenced = vec![false; c.atoms.len()];
912    for clause in &c.clauses {
913        for l in &clause.lits {
914            referenced[l.atom as usize] = true;
915        }
916    }
917    for r in &c.rules {
918        for l in r.antecedent.iter().chain(r.consequent.iter()) {
919            referenced[l.atom as usize] = true;
920        }
921    }
922    // Edges consumed by a relation `FOR EACH` are read as data, not idle facts.
923    for &a in &c.consumed {
924        referenced[a as usize] = true;
925    }
926    let mut out: Vec<OrphanFact> = c
927        .facts
928        .iter()
929        .filter(|f| !referenced[f.atom as usize])
930        .map(|f| OrphanFact {
931            atom: label(c, f.atom),
932            value: f.value,
933            origin: f.origin.clone(),
934        })
935        .collect();
936    out.sort_by_key(|o| key(&o.origin));
937    out
938}
939
940/// The minimal set of `ASSUME` hypotheses to retract so an
941/// otherwise-consistent program stops contradicting itself.
942///
943/// Returns empty unless **all three** hold: there is at least one soft fact; the
944/// hard program (facts + premises + rules, no assumptions) is satisfiable on its
945/// own; and the full program (with assumptions) is unsatisfiable. In that case
946/// the assumptions are the cause, and we deletion-minimize **over the soft facts
947/// only** — every hard construct stays active, so a `FACT`/`PREMISE` can never be
948/// blamed. What survives is an irreducible set of assumptions that cannot all
949/// hold together; dropping any one restores consistency.
950///
951/// Reuses the same CNF / SAT machinery as [`minimal_unsat_core`]
952/// ([`constructs`], [`subset_is_sat`]); the only difference is that hard
953/// constructs are pinned active. Labels carry polarity (`NOT …`) so a small
954/// model sees exactly what it assumed.
955fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
956    if !c.facts.iter().any(|f| f.soft) {
957        return Vec::new();
958    }
959    let all = constructs(c);
960    // The first `c.facts.len()` constructs mirror `c.facts` 1:1 (see `constructs`).
961    let is_soft: Vec<bool> = (0..all.len())
962        .map(|i| i < c.facts.len() && c.facts[i].soft)
963        .collect();
964
965    // The hard program (drop every soft construct) must be consistent on its own,
966    // else the facts/premises are to blame and we must not point at assumptions.
967    let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
968    if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
969        return Vec::new();
970    }
971    // The full program must actually be UNSAT for there to be anything to drop.
972    let mut active = vec![true; all.len()];
973    if subset_is_sat(c.atoms.len(), &all, &active) {
974        return Vec::new();
975    }
976    // Deletion-minimize over the soft constructs only; hard ones stay pinned.
977    for i in 0..all.len() {
978        if active[i] && is_soft[i] {
979            active[i] = false;
980            if subset_is_sat(c.atoms.len(), &all, &active) {
981                active[i] = true; // still needed for the contradiction
982            }
983        }
984    }
985    let mut core: Vec<CoreItem> = (0..all.len())
986        .filter(|&i| active[i] && is_soft[i])
987        .map(|i| {
988            let f = &c.facts[i];
989            // Show the assumed polarity so `ASSUME NOT x` reads as `NOT x`.
990            let label = if matches!(f.value, Value::False) {
991                alloc::format!("NOT {}", label(c, f.atom))
992            } else {
993                label(c, f.atom)
994            };
995            CoreItem {
996                origin: f.origin.clone(),
997                label,
998            }
999        })
1000        .collect();
1001    core.sort_by_key(|it| key(&it.origin));
1002    core
1003}
1004
1005// --- near-duplicate atom detection (advisory typo hints) -------------------
1006
1007/// Detect pairs of distinct atoms whose names look like the same atom typed two
1008/// ways. Two deliberately conservative signals (keep false positives minimal):
1009///
1010/// - **A — fold-equal:** identical after lowercasing and treating `_`/whitespace
1011///   as one separator (`Has_fuel`/`has_fuel`, `is_rolled_back`/`is rolled_back`).
1012///   Distinct atoms that fold to the same string are almost always one typo.
1013/// - **B — near edit:** *same subject*, an *alphabetic (cased)* script, and a
1014///   Levenshtein distance of exactly 1 over the folded form (names ≥ 5 chars).
1015///   Distance 1 only — distance 2 flags real antonyms (mortal/immortal) far too
1016///   often. Edit distance is a typo signal only where a word spans many
1017///   characters; in caseless scripts (CJK / kana / hangul) one character is a
1018///   whole word, so a one-character change is normally a *different* word — those
1019///   are skipped by the "cased letters only" test (no hard-coded Unicode ranges).
1020///
1021/// Signal A is fully script-agnostic; signal B is the script-sensitive one.
1022/// `O(n²)` over the (typically small) atom set, with a length-difference quick
1023/// reject. Deterministic: atoms are already canonically sorted in `Compiled`.
1024fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
1025    let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
1026    let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
1027    // Edge atoms consumed by a relation FOR EACH (e.g. `a linked b`, `a linked c`)
1028    // legitimately differ by one character — never flag them as look-alike typos.
1029    let mut consumed = vec![false; c.atoms.len()];
1030    for &a in &c.consumed {
1031        consumed[a as usize] = true;
1032    }
1033    let mut out = Vec::new();
1034    for i in 0..c.atoms.len() {
1035        if consumed[i] {
1036            continue;
1037        }
1038        for j in (i + 1)..c.atoms.len() {
1039            if consumed[j] {
1040                continue;
1041            }
1042            if let Some(reason) = atoms_look_similar(
1043                &c.atoms[i],
1044                &folded[i],
1045                cased[i],
1046                &c.atoms[j],
1047                &folded[j],
1048                cased[j],
1049            ) {
1050                out.push(SimilarAtoms {
1051                    a: label(c, i as AtomId),
1052                    b: label(c, j as AtomId),
1053                    reason,
1054                });
1055            }
1056        }
1057    }
1058    out
1059}
1060
1061/// Fold an atom to its comparison form: `subject predicate [object]` lowercased,
1062/// every `_`/whitespace run collapsed to a single space. So `_` vs space vs case
1063/// can never distinguish two names.
1064fn fold_atom(k: &AtomKey) -> Vec<char> {
1065    let mut raw = String::new();
1066    raw.push_str(&k.subject);
1067    raw.push(' ');
1068    raw.push_str(&k.predicate);
1069    if let Some(o) = &k.object {
1070        raw.push(' ');
1071        raw.push_str(o);
1072    }
1073    let mut out: Vec<char> = Vec::new();
1074    let mut prev_space = false;
1075    for ch in raw.chars() {
1076        if ch == '_' || ch.is_whitespace() {
1077            if !prev_space && !out.is_empty() {
1078                out.push(' ');
1079                prev_space = true;
1080            }
1081        } else {
1082            for lc in ch.to_lowercase() {
1083                out.push(lc);
1084            }
1085            prev_space = false;
1086        }
1087    }
1088    if out.last() == Some(&' ') {
1089        out.pop();
1090    }
1091    out
1092}
1093
1094/// Whether every character of a folded name is a space or a *cased* letter — the
1095/// script-agnostic gate for edit-distance (signal B). Cased scripts (Latin,
1096/// Cyrillic, Greek, …) span many characters per word, so a one-character edit is
1097/// a plausible typo. Caseless scripts (CJK / kana / hangul, where one character
1098/// is a whole word) and digits report `is_lowercase() == false` after folding, so
1099/// they fall out here without enumerating any Unicode ranges.
1100fn is_cased_alphabetic(folded: &[char]) -> bool {
1101    folded.iter().all(|&c| c == ' ' || c.is_lowercase())
1102}
1103
1104/// The two-signal similarity test (see [`similar_atom_pairs`]). Returns the
1105/// reason string when the pair looks like a typo, else `None`.
1106fn atoms_look_similar(
1107    ka: &AtomKey,
1108    fa: &[char],
1109    cased_a: bool,
1110    kb: &AtomKey,
1111    fb: &[char],
1112    cased_b: bool,
1113) -> Option<&'static str> {
1114    // A — same folded form in the SAME domain (the AtomKeys differ, so the raw
1115    // spelling differs). Atoms in different domains are legitimately distinct
1116    // even when their triples fold equal, so they are never flagged.
1117    if fa == fb && ka.domain == kb.domain {
1118        return Some("same name up to case, '_', or spaces");
1119    }
1120    // B — same subject, an alphabetic (cased) script, a single-character slip.
1121    // Only distance 1: distance 2 flags real antonyms (mortal/immortal) and word
1122    // pairs far too often — genuine typos are almost always a one-character edit,
1123    // and the underscore/case case is already covered by signal A.
1124    if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
1125        return None;
1126    }
1127    if fa.len().abs_diff(fb.len()) > 1 {
1128        return None; // edit distance >= length difference, so it can't be 1
1129    }
1130    let min_len = fa.len().min(fb.len());
1131    if min_len >= 5 && levenshtein(fa, fb) == 1 {
1132        return Some("looks like a one-character typo of each other");
1133    }
1134    None
1135}
1136
1137/// One IR literal as it enters a CNF clause, with the polarity flip folded in: a
1138/// surface-positive literal `L` becomes `¬L`. This is exactly what both a PREMISE
1139/// `Impossible([L..])` (== ¬L1 ∨ … ∨ ¬Ln) and a RULE antecedent need. (`SatLit::new`'s
1140/// second argument is the *positive* flag, so passing `negated` performs the flip.)
1141fn clause_lit(l: &Lit) -> sat::SatLit {
1142    sat::SatLit::new(l.atom, l.negated)
1143}
1144
1145/// The unit literal a confident fact contributes: `TRUE → positive`, `FALSE → negative`.
1146fn fact_lit(f: &Fact) -> sat::SatLit {
1147    match f.value {
1148        Value::True => sat::SatLit::positive(f.atom),
1149        Value::False => sat::SatLit::negative(f.atom),
1150    }
1151}
1152
1153/// The CNF clause one rule consequent contributes: `WHEN A.. THEN C` == `(¬A1 ∨ … ∨ C)`.
1154/// The antecedent literals enter negated (via [`clause_lit`]); the consequent enters
1155/// with its surface polarity. The single encoding of "rule ⇒ clause", shared by the
1156/// backward-pass CNF and the unsat-core constructs.
1157fn rule_consequent_clause(r: &Rule, cons: &Lit) -> Vec<sat::SatLit> {
1158    let mut lits: Vec<sat::SatLit> = r.antecedent.iter().map(clause_lit).collect();
1159    lits.push(sat::SatLit::new(cons.atom, !cons.negated));
1160    lits
1161}
1162
1163/// Encode the premises (`Impossible` clauses), rules (as implications), and
1164/// confident facts (as unit clauses) into CNF for the backward pass. Also
1165/// returns the constrained atoms (those appearing in a clause or rule) to
1166/// project model counting onto.
1167fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
1168    let mut cnf = sat::Cnf::new(c.atoms.len());
1169    let mut constrained = vec![false; c.atoms.len()];
1170
1171    // Premises and rules constrain every atom they mention; facts pin a value but do
1172    // not by themselves make an atom a candidate for the underdetermined witness, so
1173    // only these two add to `constrained`.
1174    let add_constraining =
1175        |cnf: &mut sat::Cnf, constrained: &mut [bool], lits: Vec<sat::SatLit>| {
1176            for l in &lits {
1177                constrained[l.var() as usize] = true;
1178            }
1179            cnf.add_clause(lits);
1180        };
1181
1182    // Impossible([L1..Ln]) == (¬L1 ∨ … ∨ ¬Ln).
1183    for clause in &c.clauses {
1184        add_constraining(
1185            &mut cnf,
1186            &mut constrained,
1187            clause.lits.iter().map(clause_lit).collect(),
1188        );
1189    }
1190    // RULE WHEN A.. THEN C.. == for each C: (¬A1 ∨ … ∨ C).
1191    for r in &c.rules {
1192        for cons in &r.consequent {
1193            add_constraining(&mut cnf, &mut constrained, rule_consequent_clause(r, cons));
1194        }
1195    }
1196    // Confident facts as unit clauses (not marked constrained — see above).
1197    for f in &c.facts {
1198        cnf.add_clause(vec![fact_lit(f)]);
1199    }
1200
1201    let project = (0..c.atoms.len() as AtomId)
1202        .filter(|&a| constrained[a as usize])
1203        .collect();
1204    (cnf, project)
1205}
1206
1207/// Convert a three-valued model entry to a confident [`Value`] (UNKNOWN → `None`).
1208fn v3_to_value(v: V3) -> Option<Value> {
1209    match v {
1210        V3::True => Some(Value::True),
1211        V3::False => Some(Value::False),
1212        V3::Unknown => None,
1213    }
1214}
1215
1216/// A removable source construct (one fact, one premise, or one rule) and the CNF
1217/// clauses it contributes — the unit of an unsat-core explanation.
1218struct Construct {
1219    origin: Origin,
1220    label: String,
1221    clauses: Vec<Vec<sat::SatLit>>,
1222}
1223
1224/// Two origins refer to the same source construct.
1225fn same_origin(a: &Origin, b: &Origin) -> bool {
1226    a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1227}
1228
1229/// Split the program into removable constructs. A premise that desugared into
1230/// several clauses (e.g. an `EXCLUSIVE` over n atoms) is grouped back into one
1231/// construct by origin, so the core blames whole premises, not clause shards.
1232fn constructs(c: &Compiled) -> Vec<Construct> {
1233    let mut out: Vec<Construct> = Vec::new();
1234
1235    for f in &c.facts {
1236        out.push(Construct {
1237            origin: f.origin.clone(),
1238            label: label(c, f.atom),
1239            clauses: vec![vec![fact_lit(f)]],
1240        });
1241    }
1242
1243    let mut premises: Vec<Construct> = Vec::new();
1244    for clause in &c.clauses {
1245        let lits: Vec<sat::SatLit> = clause.lits.iter().map(clause_lit).collect();
1246        match premises
1247            .iter_mut()
1248            .find(|k| same_origin(&k.origin, &clause.origin))
1249        {
1250            Some(k) => k.clauses.push(lits),
1251            None => premises.push(Construct {
1252                label: clause.origin.premise.clone().unwrap_or_default(),
1253                origin: clause.origin.clone(),
1254                clauses: vec![lits],
1255            }),
1256        }
1257    }
1258    out.extend(premises);
1259
1260    for r in &c.rules {
1261        let clauses = r
1262            .consequent
1263            .iter()
1264            .map(|cons| rule_consequent_clause(r, cons))
1265            .collect();
1266        out.push(Construct {
1267            label: r.origin.premise.clone().unwrap_or_default(),
1268            origin: r.origin.clone(),
1269            clauses,
1270        });
1271    }
1272    out
1273}
1274
1275/// Is the program satisfiable using only the constructs marked active?
1276fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1277    let mut cnf = sat::Cnf::new(num_vars);
1278    for (k, &keep) in all.iter().zip(active) {
1279        if keep {
1280            for cl in &k.clauses {
1281                cnf.add_clause(cl.clone());
1282            }
1283        }
1284    }
1285    sat::solve(&cnf).is_some()
1286}
1287
1288/// A fast sufficient core via one assumption-solve: each construct gets a fresh
1289/// selector variable `s_k`, every clause becomes `(¬s_k ∨ clause)`, and we solve
1290/// asserting all selectors true. The SAT core (a subset of the selectors) names a
1291/// sufficient set of constructs in a single solve — versus O(n) deletion solves.
1292/// Returns an `active` mask over `all`.
1293fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1294    let base = c.atoms.len();
1295    let mut cnf = sat::Cnf::new(base + all.len());
1296    let sel = |i: usize| (base + i) as sat::Var;
1297    for (i, k) in all.iter().enumerate() {
1298        let s_neg = sat::SatLit::negative(sel(i));
1299        for cl in &k.clauses {
1300            let mut lits = Vec::with_capacity(cl.len() + 1);
1301            lits.push(s_neg);
1302            lits.extend_from_slice(cl);
1303            cnf.add_clause(lits);
1304        }
1305    }
1306    let assumptions: Vec<sat::SatLit> = (0..all.len())
1307        .map(|i| sat::SatLit::positive(sel(i)))
1308        .collect();
1309    let mut active = vec![false; all.len()];
1310    match sat::solve_assuming(&cnf, &assumptions) {
1311        sat::Solved::Unsat(core) => {
1312            for lit in core {
1313                let v = lit.var() as usize;
1314                if v >= base {
1315                    active[v - base] = true;
1316                }
1317            }
1318        }
1319        // The caller only calls this when the system is UNSAT, so this is
1320        // unreachable; fall back to all-active so the deletion pass below is still
1321        // correct (just slower).
1322        sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1323    }
1324    active
1325}
1326
1327/// A 1-minimal unsat core. First an assumption-solve narrows the program to a
1328/// sufficient candidate ([`candidate_via_assumptions`]); then deletion-based
1329/// minimization over *that candidate only* drops each construct in turn — if the
1330/// rest is still unsatisfiable it was not needed — leaving an irreducible set
1331/// jointly to blame. Called only when the full system is UNSAT.
1332fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1333    let all = constructs(c);
1334    let mut active = candidate_via_assumptions(c, &all);
1335    for i in 0..all.len() {
1336        if active[i] {
1337            active[i] = false;
1338            if subset_is_sat(c.atoms.len(), &all, &active) {
1339                active[i] = true; // removing it restored SAT → it is part of the core
1340            }
1341        }
1342    }
1343    let mut core: Vec<CoreItem> = all
1344        .iter()
1345        .zip(&active)
1346        .filter(|&(_, &keep)| keep)
1347        .map(|(k, _)| CoreItem {
1348            origin: k.origin.clone(),
1349            label: k.label.clone(),
1350        })
1351        .collect();
1352    core.sort_by_key(|it| key(&it.origin));
1353    core
1354}
1355
1356/// Sort key giving conflicts/warnings a stable, source-then-line order.
1357fn key(o: &Origin) -> (String, u32) {
1358    (o.source.clone(), o.line)
1359}
1360
1361/// Parse → compile → solve a single source.
1362pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1363    Ok(solve(&compile_source(name, src)?))
1364}
1365
1366/// Parse → compile (resolving imports) → solve, given a [`Resolver`].
1367pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1368    Ok(solve(&compile(root, resolver)?))
1369}
1370
1371// --- human-readable report -------------------------------------------------
1372
1373impl fmt::Display for Status {
1374    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1375        f.write_str(status_name(*self))
1376    }
1377}
1378
1379/// Format provenance as `name (KIND)  [source:line]` for the human report.
1380fn premise_tag(o: &Origin) -> String {
1381    let name = o.premise.as_deref().unwrap_or("-");
1382    alloc::format!("{} ({})  [{}:{}]", name, o.kind, o.source, o.line)
1383}
1384
1385/// One derivation-trace line for the human report.
1386fn trace_line(step: &TraceStep) -> String {
1387    let v = match step.value {
1388        Value::True => "TRUE",
1389        Value::False => "FALSE",
1390    };
1391    match &step.reason {
1392        TraceReason::Asserted(o) => {
1393            alloc::format!(
1394                "{} = {}   [{} {}:{}]",
1395                step.atom,
1396                v,
1397                o.kind,
1398                o.source,
1399                o.line
1400            )
1401        }
1402        TraceReason::Derived { origin, from } => alloc::format!(
1403            "{} = {}   from {} ({})  [{}:{}]  <= {}",
1404            step.atom,
1405            v,
1406            origin.premise.as_deref().unwrap_or("-"),
1407            origin.kind,
1408            origin.source,
1409            origin.line,
1410            from.join(", ")
1411        ),
1412    }
1413}
1414
1415/// Indentation levels for the human report. This module is the **single** place
1416/// leading whitespace is defined: every line is emitted through
1417/// [`ReportWriter::line`] with one of these as the `indent` argument, so no
1418/// format string ever carries leading spaces. To restyle the report, change a
1419/// number here — not spaces scattered across `write!` calls.
1420mod indent {
1421    /// `RESULT:` / `SUMMARY:` / `EXIT_CODE:` — flush left.
1422    pub const ROOT: usize = 0;
1423    /// A section header: `CONFLICT` / `WARNING` / `CORE` / `RETRACT` / `DERIVED`
1424    /// / `HINT` / `UNDERDETERMINED`.
1425    pub const SECTION: usize = 2;
1426    /// A line belonging to a section (conflict atoms, `blocked by:`, an `ASSUME`).
1427    pub const ITEM: usize = 6;
1428    /// A line nested under an item (a `why:` trace step, a `CORE` member).
1429    pub const NESTED: usize = 8;
1430}
1431
1432/// The human report's one output primitive. It owns the indentation rule so
1433/// callers pass a semantic [`indent`] level and the text — never raw spaces.
1434struct ReportWriter<'a, 'b> {
1435    f: &'a mut fmt::Formatter<'b>,
1436}
1437
1438impl ReportWriter<'_, '_> {
1439    /// Write `indent` leading spaces, the formatted text, then a newline.
1440    fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1441        write!(self.f, "{:width$}{}", "", args, width = indent)?;
1442        self.f.write_str("\n")
1443    }
1444
1445    /// Like [`line`](Self::line) but without the trailing newline — for the final
1446    /// `EXIT_CODE` line, so the report ends exactly as it always has.
1447    fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1448        write!(self.f, "{:width$}{}", "", args, width = indent)
1449    }
1450}
1451
1452/// `emit!(out, LEVEL, "fmt", args…)` — one indented report line. A thin wrapper
1453/// over [`ReportWriter::line`] so call sites read `emit!(out, SECTION, …)` with
1454/// the indent as an explicit parameter and zero leading spaces in the string.
1455macro_rules! emit {
1456    ($out:expr, $indent:expr, $($arg:tt)*) => {
1457        $out.line($indent, format_args!($($arg)*))
1458    };
1459}
1460
1461impl fmt::Display for Report {
1462    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1463        use indent::{ITEM, NESTED, ROOT, SECTION};
1464        let mut out = ReportWriter { f };
1465
1466        emit!(out, ROOT, "RESULT: {}", self.status)?;
1467
1468        // A pure assumption clash: lead with the one action a small model needs
1469        // and suppress the raw conflict / CORE pools (they would only echo the
1470        // ASSUME clause). The verdict is still CONFLICT (exit code 2).
1471        if !self.retract.is_empty() {
1472            // Spell out what is wrong and why — this report is the debugger a
1473            // small model reads. The commitments are sound; the hypotheses are
1474            // the only dial to turn, so say so explicitly before listing them.
1475            emit!(out, SECTION, "RETRACT  your FACTs and PREMISEs are fine.")?;
1476            emit!(
1477                out,
1478                ITEM,
1479                "But these ASSUME guesses cannot all be true together."
1480            )?;
1481            emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1482            for it in &self.retract {
1483                emit!(
1484                    out,
1485                    ITEM,
1486                    "ASSUME {}   [{}:{}]",
1487                    it.label,
1488                    it.origin.source,
1489                    it.origin.line
1490                )?;
1491            }
1492        } else {
1493            for c in &self.conflicts {
1494                emit!(out, SECTION, "CONFLICT  {}", premise_tag(&c.origin))?;
1495                for a in &c.atoms {
1496                    emit!(out, ITEM, "{}", a)?;
1497                }
1498                if !c.trace.is_empty() {
1499                    emit!(out, ITEM, "why:")?;
1500                    for step in &c.trace {
1501                        emit!(out, NESTED, "{}", trace_line(step))?;
1502                    }
1503                }
1504            }
1505            if !self.unsat_core.is_empty() {
1506                emit!(
1507                    out,
1508                    SECTION,
1509                    "CORE  smallest jointly-unsatisfiable set ({}):",
1510                    self.unsat_core.len()
1511                )?;
1512                for it in &self.unsat_core {
1513                    let name = if it.label.is_empty() { "-" } else { &it.label };
1514                    emit!(
1515                        out,
1516                        NESTED,
1517                        "{} ({})  [{}:{}]",
1518                        name,
1519                        it.origin.kind,
1520                        it.origin.source,
1521                        it.origin.line
1522                    )?;
1523                }
1524            }
1525        }
1526
1527        // Many warnings often share one root cause (e.g. the same missing FACT),
1528        // so the `fix:` line is deduped in the human report — each distinct fix
1529        // prints once — to keep a wall of warnings readable. The full per-warning
1530        // hint is still in the JSON for tools that select/filter programmatically.
1531        let mut shown_fixes: Vec<&str> = Vec::new();
1532        for w in &self.warnings {
1533            emit!(out, SECTION, "WARNING   {}", premise_tag(&w.origin))?;
1534            emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1535            if let Some(hint) = &w.hint
1536                && !shown_fixes.contains(&hint.as_str())
1537            {
1538                shown_fixes.push(hint);
1539                emit!(out, ITEM, "fix: {hint}")?;
1540            }
1541        }
1542        if let Some(atom) = &self.underdetermined {
1543            emit!(out, SECTION, "UNDERDETERMINED  an alternative model exists")?;
1544            emit!(out, ITEM, "pin it down: add  FACT {atom}  or  NOT {atom}")?;
1545        }
1546        for d in &self.derived {
1547            let v = match d.value {
1548                Value::True => "TRUE",
1549                Value::False => "FALSE",
1550            };
1551            emit!(
1552                out,
1553                SECTION,
1554                "DERIVED   {} = {}   from {}",
1555                d.atom,
1556                v,
1557                premise_tag(&d.origin)
1558            )?;
1559        }
1560        for h in &self.hints {
1561            emit!(
1562                out,
1563                SECTION,
1564                "HINT      possible typo — '{}' and '{}' look like the same atom ({})",
1565                h.a,
1566                h.b,
1567                h.reason
1568            )?;
1569        }
1570        for o in &self.orphans {
1571            // Reconstruct the surface line; `kind` already carries the polarity
1572            // except for `ASSUME NOT`, where the value supplies it.
1573            let surface = if o.origin.kind == kw::ASSUME && matches!(o.value, Value::False) {
1574                alloc::format!("{} {} {}", kw::ASSUME, kw::NOT, o.atom)
1575            } else {
1576                alloc::format!("{} {}", o.origin.kind, o.atom)
1577            };
1578            emit!(
1579                out,
1580                SECTION,
1581                "ORPHAN    {} — not used by any premise or rule (no effect on the result)",
1582                surface
1583            )?;
1584        }
1585        for u in &self.unused_imports {
1586            let via = match &u.alias {
1587                Some(a) => alloc::format!("{} AS {}", u.domain, a),
1588                None => u.domain.clone(),
1589            };
1590            emit!(
1591                out,
1592                SECTION,
1593                "UNUSED IMPORT  {} — imported in {}:{} but never referenced (no effect on the result)",
1594                via,
1595                u.file,
1596                u.line
1597            )?;
1598        }
1599
1600        let underdetermined = usize::from(self.status == Status::Underdetermined);
1601        emit!(
1602            out,
1603            ROOT,
1604            "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1605            self.conflicts.len(),
1606            underdetermined,
1607            self.warnings.len(),
1608            self.derived.len()
1609        )?;
1610        out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1611    }
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616    use super::*;
1617
1618    /// Verify a single inline source under a default `DOMAIN t` (so test programs
1619    /// need not repeat it); atoms land in domain `t`, labelled `t.subject …`.
1620    fn vs(src: &str) -> Result<Report, CompileError> {
1621        verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
1622    }
1623
1624    #[test]
1625    fn clean_consistent() {
1626        let r = vs("FACT x a\nCHECK x\n").unwrap();
1627        assert_eq!(r.status, Status::Consistent);
1628        assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1629    }
1630
1631    #[test]
1632    fn fact_contradiction_is_conflict() {
1633        let r = vs("FACT x a\nNOT x a\n").unwrap();
1634        assert_eq!(r.status, Status::Conflict);
1635        assert_eq!(r.conflicts.len(), 1);
1636    }
1637
1638    #[test]
1639    fn exclusive_violation_is_conflict() {
1640        let src = include_str!("../../../docs/examples/conflict.vrf");
1641        let r = verify_source("conflict.vrf", src).unwrap();
1642        assert_eq!(r.status, Status::Conflict);
1643        assert_eq!(
1644            r.conflicts[0].origin.premise.as_deref(),
1645            Some("fly_xor_swim")
1646        );
1647        assert_eq!(r.conflicts[0].atoms.len(), 2);
1648    }
1649
1650    #[test]
1651    fn exclusive_with_unknown_is_consistent_not_warning() {
1652        // flying TRUE, swimming UNKNOWN — at most one can hold, no conflict, no warning.
1653        let r = vs(r"
1654        FACT A has flying
1655        PREMISE e:
1656            EXCLUSIVE
1657                A has flying
1658                A has swimming
1659        ")
1660        .unwrap();
1661        assert_eq!(r.status, Status::Consistent);
1662        assert!(r.warnings.is_empty());
1663    }
1664
1665    #[test]
1666    fn implication_missing_consequent_is_warning() {
1667        // WHEN flying THEN wing: flying TRUE, wing UNKNOWN → blocked → WARNING.
1668        let r = vs(r#"
1669        FACT A has flying
1670        PREMISE w:
1671            WHEN A has flying
1672            THEN A has wing
1673        "#)
1674        .unwrap();
1675        assert_eq!(r.status, Status::Warning);
1676        assert_eq!(r.warnings.len(), 1);
1677        assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
1678    }
1679
1680    #[test]
1681    fn warning_hint_points_at_rule_when_atom_is_a_free_input() {
1682        // `A has wing` is nothing's consequent: it can only be set by a FACT, or by
1683        // turning a PREMISE meant to establish it into a RULE. The hint says so.
1684        let r = vs(r"
1685        FACT A has flying
1686        PREMISE w:
1687            WHEN A has flying
1688            THEN A has wing
1689        ")
1690        .unwrap();
1691        let hint = r.warnings[0].hint.as_deref().unwrap();
1692        assert!(hint.contains("make that PREMISE a RULE"), "{hint}");
1693        assert!(hint.contains("t.A has wing"), "{hint}");
1694    }
1695
1696    #[test]
1697    fn warning_hint_points_at_antecedent_when_a_rule_could_derive_it() {
1698        // `c ready` IS a RULE consequent, but the rule has not fired (its `x trigger`
1699        // is UNKNOWN). The blocking premise's hint sends you to the rule's input.
1700        let r = vs(r"
1701        RULE d:
1702            WHEN x trigger
1703            THEN c ready
1704        FACT go now
1705        PREMISE p:
1706            WHEN go now
1707            THEN c ready
1708        ")
1709        .unwrap();
1710        assert_eq!(r.status, Status::Warning);
1711        let hint = r.warnings[0].hint.as_deref().unwrap();
1712        assert!(
1713            hint.contains("derived by a RULE that has not fired"),
1714            "{hint}"
1715        );
1716    }
1717
1718    #[test]
1719    fn human_report_dedupes_repeated_fix_lines() {
1720        // Three *distinct* premises all blocked: p1 and p2 by the SAME atom
1721        // (`gate one_ok`, via different antecedents so the clauses don't dedupe)
1722        // → one identical fix, deduped; p3 by a different atom → its own fix. So:
1723        // 3 warnings, but only 2 distinct `fix:` lines in the human report (while
1724        // every warning still carries its hint in the structured data / JSON).
1725        let r = vs(r"
1726        FACT a on
1727        FACT b on
1728        PREMISE p1:
1729            WHEN a on
1730            THEN gate one_ok
1731        PREMISE p2:
1732            WHEN b on
1733            THEN gate one_ok
1734        PREMISE p3:
1735            WHEN a on
1736            THEN gate two_ok
1737        ")
1738        .unwrap();
1739        assert_eq!(r.warnings.len(), 3);
1740        // Every warning keeps its hint in the structured form.
1741        assert!(r.warnings.iter().all(|w| w.hint.is_some()));
1742        let distinct_hints: BTreeSet<&str> = r
1743            .warnings
1744            .iter()
1745            .filter_map(|w| w.hint.as_deref())
1746            .collect();
1747        assert_eq!(distinct_hints.len(), 2);
1748        // The human report prints exactly one `fix:` per distinct hint.
1749        let text = alloc::format!("{r}");
1750        let shown = text
1751            .lines()
1752            .filter(|l| l.trim_start().starts_with("fix:"))
1753            .count();
1754        assert_eq!(shown, distinct_hints.len());
1755    }
1756
1757    #[test]
1758    fn implication_satisfied_is_consistent() {
1759        let r = vs(r"
1760        FACT A has flying
1761        FACT A has wing
1762        PREMISE w:
1763            WHEN A has flying
1764            THEN A has wing
1765        ")
1766        .unwrap();
1767        assert_eq!(r.status, Status::Consistent);
1768    }
1769
1770    #[test]
1771    fn implication_violated_is_conflict() {
1772        // antecedent TRUE, consequent FALSE → CONFLICT.
1773        let r = vs(r"
1774        FACT A has flying
1775        NOT A has wing
1776        PREMISE w:
1777            WHEN A has flying
1778            THEN A has wing
1779        ")
1780        .unwrap();
1781        assert_eq!(r.status, Status::Conflict);
1782    }
1783
1784    #[test]
1785    fn rule_derives_fact() {
1786        let r = vs(r#"
1787        FACT A has flying
1788        RULE o:
1789            WHEN A has flying
1790            THEN A needs oxygen
1791        "#)
1792        .unwrap();
1793        assert_eq!(r.status, Status::Consistent);
1794        assert_eq!(r.derived.len(), 1);
1795        assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1796    }
1797
1798    #[test]
1799    fn rule_derivation_contradiction_is_conflict() {
1800        // rule derives `A needs oxygen` TRUE, but it's asserted FALSE.
1801        let r = vs(r"
1802        FACT A has flying
1803        NOT A needs oxygen
1804        RULE o:
1805            WHEN A has flying
1806            THEN A needs oxygen
1807        ")
1808        .unwrap();
1809        assert_eq!(r.status, Status::Conflict);
1810    }
1811
1812    #[test]
1813    fn bidirectional_finds_alternative_model_underdetermined() {
1814        // EXCLUSIVE(a,b) with no facts: {FF, TF, FT} all satisfy → not unique.
1815        let r = vs(r#"
1816        PREMISE e:
1817            EXCLUSIVE
1818                x a
1819                x b
1820        CHECK x BIDIRECTIONAL
1821        "#)
1822        .unwrap();
1823        assert_eq!(r.status, Status::Underdetermined);
1824    }
1825
1826    #[test]
1827    fn fact_pins_unique_model_consistent() {
1828        // Same premise, but FACT x a forces b false → the only model → CONSISTENT.
1829        let r = vs(r#"
1830        FACT x a
1831        PREMISE e:
1832            EXCLUSIVE
1833                x a
1834                x b
1835        CHECK x BIDIRECTIONAL
1836        "#)
1837        .unwrap();
1838        assert_eq!(r.status, Status::Consistent);
1839    }
1840
1841    #[test]
1842    fn no_bidirectional_skips_backward_pass() {
1843        // Plain CHECK: alternatives are not searched → stays CONSISTENT, not UNDERDETERMINED.
1844        let r = vs(r#"
1845        PREMISE e:
1846            EXCLUSIVE
1847                x a
1848                x b
1849        CHECK x
1850        "#)
1851        .unwrap();
1852        assert_eq!(r.status, Status::Consistent);
1853    }
1854
1855    #[test]
1856    fn creature_example_forward_pass() {
1857        let src = include_str!("../../../docs/examples/creature.vrf");
1858        let r = verify_source("creature.vrf", src).unwrap();
1859        // fly_xor_swim & no_dual_temp consistent; wings_need_bone → 2 warnings
1860        // (wing, bone); needs_oxygen derived; no conflicts.
1861        assert_eq!(r.status, Status::Warning);
1862        assert!(r.conflicts.is_empty());
1863        assert_eq!(r.warnings.len(), 2);
1864        assert_eq!(r.derived.len(), 1);
1865        assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
1866    }
1867
1868    #[test]
1869    fn roles_puzzle_is_uniquely_solved() {
1870        // 3 people × 3 roles, ONEOF per person and per role, two clues. The
1871        // backward (SAT) pass must find the assignment satisfiable AND unique —
1872        // i.e. CONSISTENT, not UNDERDETERMINED.
1873        let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1874        let r = verify_source("roles-puzzle.vrf", src).unwrap();
1875        assert_eq!(r.status, Status::Consistent);
1876        assert!(r.conflicts.is_empty());
1877        assert!(r.underdetermined.is_none());
1878    }
1879
1880    #[test]
1881    fn roles_puzzle_underdetermined_without_a_clue() {
1882        // Drop the `NOT bob is qa` clue and the solution is no longer unique
1883        // (bob/carol can swap dev/qa) — the SAT pass reports UNDERDETERMINED.
1884        // Normalize CRLF first: on a Windows checkout include_str! embeds the file
1885        // with \r\n, so a literal "...\n" match would otherwise miss the line.
1886        let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1887            .replace("\r\n", "\n")
1888            .replace("NOT  bob is qa\n", "");
1889        let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1890        assert_eq!(r.status, Status::Underdetermined);
1891    }
1892
1893    #[test]
1894    fn socrates_chain_is_a_conflict() {
1895        // human → animal → living → mortal (3 derivations), then mortal EXCLUSIVE
1896        // immortal with `immortal` asserted → CONFLICT on the exclusivity premise.
1897        let src = include_str!("../../../docs/examples/socrates.vrf");
1898        let r = verify_source("socrates.vrf", src).unwrap();
1899        assert_eq!(r.status, Status::Conflict);
1900        assert_eq!(r.conflicts.len(), 1);
1901        assert_eq!(
1902            r.conflicts[0].origin.premise.as_deref(),
1903            Some("mortal_xor_immortal")
1904        );
1905        assert_eq!(r.derived.len(), 3); // animal, living, mortal
1906    }
1907
1908    // --- conflict explainability: derivation trace + minimal unsat core ------
1909
1910    #[test]
1911    fn forward_conflict_carries_a_trace_of_its_facts() {
1912        let r = vs(r#"
1913        FACT x a
1914        FACT x b
1915        PREMISE e:
1916            EXCLUSIVE
1917                x a
1918                x b
1919        CHECK x
1920        "#)
1921        .unwrap();
1922        assert_eq!(r.status, Status::Conflict);
1923        let t = &r.conflicts[0].trace;
1924        assert_eq!(t.len(), 2);
1925        assert_eq!(t[0].atom, "t.x a");
1926        assert_eq!(t[0].value, Value::True);
1927        assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1928        assert!(r.unsat_core.is_empty());
1929    }
1930
1931    #[test]
1932    fn derivation_chain_is_traced_back_to_the_root_fact() {
1933        // human → animal → living → mortal, then mortal XOR immortal (immortal asserted).
1934        let src = include_str!("../../../docs/examples/socrates.vrf");
1935        let r = verify_source("socrates.vrf", src).unwrap();
1936        let t = &r.conflicts[0].trace;
1937        // human (fact) + animal, living, mortal (derived) + immortal (fact) = 5 steps,
1938        // supports before dependents.
1939        assert_eq!(t.len(), 5);
1940        assert_eq!(t[0].atom, "philosophy.socrates is human");
1941        assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1942        let mortal = t
1943            .iter()
1944            .find(|s| s.atom == "philosophy.socrates is mortal")
1945            .unwrap();
1946        match &mortal.reason {
1947            TraceReason::Derived { from, .. } => {
1948                assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
1949            }
1950            _ => panic!("mortal should be derived, not asserted"),
1951        }
1952    }
1953
1954    #[test]
1955    fn direct_fact_contradiction_has_no_trace() {
1956        let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
1957        assert_eq!(r.status, Status::Conflict);
1958        assert!(r.conflicts[0].trace.is_empty());
1959    }
1960
1961    #[test]
1962    fn jointly_unsatisfiable_reports_a_minimal_core() {
1963        // ONEOF(a,b); a→c; b→c; NOT c. Unsat only via case-split, so the forward
1964        // pass misses it and the backward pass produces the core.
1965        let src = r#"
1966        PREMISE one:
1967            ONEOF
1968                x a
1969                x b
1970        PREMISE ac:
1971            WHEN x a
1972            THEN x c
1973        PREMISE bc:
1974            WHEN x b
1975            THEN x c
1976        NOT x c
1977        CHECK x BIDIRECTIONAL
1978        "#;
1979        let r = vs(src).unwrap();
1980        assert_eq!(r.status, Status::Conflict);
1981        assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
1982        assert_eq!(r.unsat_core.len(), 4);
1983        let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1984        assert!(labels.contains(&"one"));
1985        assert!(labels.contains(&"t.x c")); // the bare NOT fact is labelled by its atom
1986    }
1987
1988    #[test]
1989    fn unsat_core_excludes_irrelevant_constructs() {
1990        // The same unsat cluster, plus an unrelated fact + premise that must not
1991        // appear in the (irreducible) core.
1992        let src = r#"
1993        PREMISE one:
1994            ONEOF
1995                x a
1996                x b
1997        PREMISE ac:
1998            WHEN x a
1999            THEN x c
2000        PREMISE bc:
2001            WHEN x b
2002            THEN x c
2003        NOT x c
2004        FACT z here
2005        PREMISE noise:
2006            EXCLUSIVE
2007                z here
2008                z gone
2009        CHECK x BIDIRECTIONAL
2010        "#;
2011        let r = vs(src).unwrap();
2012        assert_eq!(r.status, Status::Conflict);
2013        assert_eq!(r.unsat_core.len(), 4);
2014        let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2015        assert!(!labels.contains(&"noise"));
2016        assert!(!labels.iter().any(|l| l.contains("here")));
2017    }
2018
2019    #[test]
2020    fn unsat_core_blames_the_rules_that_form_it() {
2021        // Same case-split unsat as `jointly_unsatisfiable_reports_a_minimal_core`,
2022        // but the implications are RULEs (→ `c.rules`), not PREMISEs (→ `c.clauses`).
2023        // This is the only path that drives the RULE branch of `constructs`, so the
2024        // minimal core must name the two rules (plus `one` and the `NOT x c` fact).
2025        let src = r#"
2026        PREMISE one:
2027            ONEOF
2028                x a
2029                x b
2030        RULE ac:
2031            WHEN x a
2032            THEN x c
2033        RULE bc:
2034            WHEN x b
2035            THEN x c
2036        NOT x c
2037        CHECK x BIDIRECTIONAL
2038        "#;
2039        let r = vs(src).unwrap();
2040        assert_eq!(r.status, Status::Conflict);
2041        assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
2042        assert_eq!(r.unsat_core.len(), 4);
2043        let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2044        assert!(labels.contains(&"one"));
2045        assert!(labels.contains(&"ac"));
2046        assert!(labels.contains(&"bc"));
2047        assert!(labels.contains(&"t.x c")); // the bare NOT fact, labelled by its atom
2048    }
2049
2050    #[test]
2051    fn consistent_report_has_empty_core_and_no_trace() {
2052        let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
2053        assert_eq!(r.status, Status::Consistent);
2054        assert!(r.unsat_core.is_empty());
2055        assert!(r.conflicts.is_empty());
2056    }
2057
2058    // --- ASSUME: soft (retractable) hypotheses -----------------------------
2059
2060    #[test]
2061    fn compatible_assumptions_behave_like_facts() {
2062        // ASSUME that does not clash with anything → ordinary CONSISTENT, and the
2063        // assumption participates like a fact (no retract, no conflict).
2064        let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
2065        assert_eq!(r.status, Status::Consistent);
2066        assert!(r.retract.is_empty());
2067        assert!(r.conflicts.is_empty());
2068    }
2069
2070    #[test]
2071    fn assume_drives_a_rule_like_a_fact() {
2072        // A soft assumption fires forward chaining just like a hard fact.
2073        let r = vs(r"
2074        ASSUME A has flying
2075        RULE o:
2076            WHEN A has flying
2077            THEN A needs oxygen
2078        CHECK A
2079        ")
2080        .unwrap();
2081        assert_eq!(r.status, Status::Consistent);
2082        assert_eq!(r.derived.len(), 1);
2083        assert_eq!(r.derived[0].atom, "t.A needs oxygen");
2084    }
2085
2086    #[test]
2087    fn clashing_assumptions_yield_conflict_with_a_retract_set() {
2088        // in_prod needs a rollback OR a feature flag; assuming in_prod plus
2089        // neither makes the premise unsatisfiable — but only via the guesses.
2090        let src = r#"
2091        FACT rel reviewed
2092        PREMISE prod_needs_safety:
2093            WHEN rel in_prod
2094            THEN rel has_rollback
2095            OR   rel has_feature_flag
2096        ASSUME rel in_prod
2097        ASSUME NOT rel has_rollback
2098        ASSUME NOT rel has_feature_flag
2099        CHECK rel
2100        "#;
2101        let r = vs(src).unwrap();
2102        assert_eq!(r.status, Status::Conflict);
2103        assert_eq!(r.exit_code(), 2);
2104        // All three guesses are jointly to blame: dropping any one fixes it.
2105        assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
2106        let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
2107        assert!(labels.contains(&"t.rel in_prod"));
2108        assert!(labels.contains(&"NOT t.rel has_rollback"));
2109        assert!(labels.contains(&"NOT t.rel has_feature_flag"));
2110        // Every retract item is an ASSUME — a FACT/PREMISE is never blamed.
2111        assert!(r.retract.iter().all(|it| it.origin.kind == kw::ASSUME));
2112        // The human report leads with RETRACT and hides the raw conflict pool.
2113        let shown = alloc::format!("{r}");
2114        assert!(shown.contains("RETRACT"), "{shown}");
2115        assert!(!shown.contains("CONFLICT  "), "{shown}");
2116    }
2117
2118    #[test]
2119    fn assume_vs_fact_retracts_only_the_assumption() {
2120        // FACT x a is ground truth; ASSUME NOT x a is the only removable thing.
2121        let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2122        assert_eq!(r.status, Status::Conflict);
2123        assert_eq!(r.retract.len(), 1);
2124        assert_eq!(r.retract[0].label, "NOT t.x a");
2125        assert_eq!(r.retract[0].origin.kind, kw::ASSUME);
2126    }
2127
2128    #[test]
2129    fn hard_conflict_is_not_blamed_on_assumptions() {
2130        // The FACTs themselves contradict; an unrelated ASSUME must NOT appear in
2131        // a retract set (the hard program is already broken).
2132        let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
2133        assert_eq!(r.status, Status::Conflict);
2134        assert!(r.retract.is_empty(), "{:?}", r.retract);
2135    }
2136
2137    #[test]
2138    fn two_assumptions_directly_contradict() {
2139        let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2140        assert_eq!(r.status, Status::Conflict);
2141        assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
2142    }
2143
2144    #[test]
2145    fn assume_retract_is_in_json() {
2146        let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2147        let j = r.to_json();
2148        assert!(j.contains("\"retract\":["), "{j}");
2149        assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
2150        assert!(j.contains("NOT t.x a"), "{j}");
2151    }
2152
2153    // --- near-duplicate atom hints (advisory typo detector) ----------------
2154
2155    #[test]
2156    fn hint_flags_underscore_vs_space_and_is_advisory_only() {
2157        // The real trap: `is rolled_back` (obj) vs `is_rolled_back` (pred) are
2158        // DIFFERENT atoms — no contradiction, so the verdict stays CONSISTENT —
2159        // but the hint warns they were probably meant to be one atom.
2160        let r = vs(r#"
2161        FACT auth is rolled_back
2162        NOT auth is_rolled_back
2163        CHECK
2164        "#)
2165        .unwrap();
2166        assert_eq!(
2167            r.status,
2168            Status::Consistent,
2169            "hint must not change the verdict"
2170        );
2171        assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
2172        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2173        assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
2174    }
2175
2176    #[test]
2177    fn hint_flags_case_only_difference() {
2178        let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
2179        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2180    }
2181
2182    #[test]
2183    fn hint_flags_single_char_typo_same_subject() {
2184        // alphabetic, same subject, edit distance 1, len >= 5 → signal B.
2185        let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
2186        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2187    }
2188
2189    #[test]
2190    fn no_hint_for_short_distinct_atoms() {
2191        // `x a` vs `x b`: distance 1 but intentionally different — must NOT flag.
2192        let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
2193        assert!(r.hints.is_empty(), "{:?}", r.hints);
2194    }
2195
2196    #[test]
2197    fn no_hint_for_distinct_words() {
2198        let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
2199        assert!(r.hints.is_empty(), "{:?}", r.hints);
2200    }
2201
2202    #[test]
2203    fn russian_case_typo_is_flagged() {
2204        // Signal A is script-agnostic: lowercasing works for Cyrillic too.
2205        let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
2206        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2207    }
2208
2209    #[test]
2210    fn russian_single_char_typo_is_flagged() {
2211        let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
2212        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2213    }
2214
2215    #[test]
2216    fn cjk_one_char_difference_is_not_flagged() {
2217        // Caseless script: a one-character change is a different word, not a typo,
2218        // so the edit-distance signal is skipped (only exact fold-equality fires).
2219        let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
2220        assert!(r.hints.is_empty(), "{:?}", r.hints);
2221    }
2222
2223    #[test]
2224    fn cjk_underscore_vs_space_is_flagged() {
2225        // Signal A still applies to any script: `a 猫_黑` (pred) vs `a 猫 黑`
2226        // (pred+obj) fold to the same name.
2227        let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
2228        assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2229    }
2230
2231    // --- orphan facts (advisory: assertions no premise/rule references) -----
2232
2233    #[test]
2234    fn orphan_fact_is_flagged_but_advisory_only() {
2235        // `x a` is asserted but never referenced by a premise or rule: inert.
2236        // The verdict stays CONSISTENT and the exit code stays 0.
2237        let r = vs("FACT x a\nCHECK\n").unwrap();
2238        assert_eq!(
2239            r.status,
2240            Status::Consistent,
2241            "orphan must not change verdict"
2242        );
2243        assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
2244        assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
2245        assert_eq!(r.orphans[0].atom, "t.x a");
2246        assert_eq!(r.orphans[0].origin.kind, kw::FACT);
2247    }
2248
2249    #[test]
2250    fn fact_used_by_a_premise_is_not_orphan() {
2251        // `x a` feeds an EXCLUSIVE constraint → referenced → not an orphan.
2252        let r = vs(r"
2253        FACT x a
2254        PREMISE p:
2255            EXCLUSIVE
2256                x a
2257                x b
2258        CHECK
2259        ")
2260        .unwrap();
2261        assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2262    }
2263
2264    #[test]
2265    fn fact_used_by_a_rule_antecedent_is_not_orphan() {
2266        let r = vs(r"
2267        FACT x a
2268        RULE r:
2269            WHEN x a
2270            THEN x c
2271        CHECK
2272        ")
2273        .unwrap();
2274        assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2275    }
2276
2277    #[test]
2278    fn negation_and_assumption_orphans_keep_their_surface_polarity() {
2279        // A `NOT` orphan and an `ASSUME NOT` orphan render with the polarity the
2280        // model wrote, so the report points at the exact line it typed.
2281        let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
2282        assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
2283        let text = alloc::format!("{r}");
2284        assert!(text.contains("ORPHAN    NOT t.x a"), "{text}");
2285        assert!(text.contains("ORPHAN    ASSUME NOT t.y b"), "{text}");
2286    }
2287
2288    #[test]
2289    fn orphan_is_in_json() {
2290        let r = vs("FACT x a\nCHECK\n").unwrap();
2291        let j = r.to_json();
2292        assert!(j.contains("\"orphans\":["), "{j}");
2293        assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
2294        assert!(j.contains("\"kind\":\"FACT\""), "{j}");
2295    }
2296
2297    #[test]
2298    fn unused_import_is_advisory_only_in_the_report() {
2299        // main imports physics but never references it. The advisory shows up but
2300        // the verdict stays CONSISTENT (exit 0) — it is purely informational.
2301        let mut r = MemoryResolver::new();
2302        r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
2303        r.add(
2304            "main.vrf",
2305            "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\nCHECK\n",
2306        );
2307        let rep = verify("main.vrf", &r).unwrap();
2308        assert_eq!(rep.status, Status::Consistent);
2309        assert_eq!(rep.exit_code(), 0);
2310        assert_eq!(rep.unused_imports.len(), 1);
2311        assert_eq!(rep.unused_imports[0].domain, "physics");
2312        let text = alloc::format!("{rep}");
2313        assert!(text.contains("UNUSED IMPORT  physics"), "{text}");
2314        assert!(
2315            rep.to_json().contains("\"unused_imports\":[{"),
2316            "{}",
2317            rep.to_json()
2318        );
2319    }
2320
2321    #[test]
2322    fn a_derived_atom_does_not_make_its_consumer_orphan() {
2323        // `x c` is derived by the rule and then referenced by the premise; the
2324        // seeding fact `x a` is referenced by the rule. Nothing is orphan.
2325        let r = vs(r"
2326        FACT x a
2327        RULE r:
2328            WHEN x a
2329            THEN x c
2330        PREMISE p:
2331            WHEN x c
2332            THEN x d
2333        CHECK
2334        ")
2335        .unwrap();
2336        assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2337    }
2338}