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