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