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