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