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