Skip to main content

elenchus_compiler/
lib.rs

1//! elenchus-compiler — compiles parsed elenchus DSL into a canonical clause IR.
2//!
3//! This crate is **preparation, not solving**. It takes the AST (from
4//! `elenchus-parser`) and produces a deterministic, solver-ready intermediate
5//! representation:
6//!
7//! - **atom interner**: `(subject, predicate, object?)` → dense `u32` id,
8//!   canonically sorted so ids (and any later enumeration) are deterministic;
9//! - **desugaring**: surface CAPS sugar → `Impossible` clauses
10//!   (`EXCLUSIVE` pairwise, `WHEN…THEN` → `Impossible([A, …, NOT C])`, etc.);
11//! - **content-addressing** (sha256, mirroring vsm-guard's CAS): identical
12//!   clauses are deduped (idempotent — `P ∧ P ≡ P`), and a named construct
13//!   redefined with a different body is a `PremiseRedefinition` error.
14//!
15//! The actual reasoning (3-valued forward chaining, SAT, all-SAT, the WARNING
16//! pool, the four results) lives in `elenchus-solver`. `IMPORT` resolution is a
17//! source-agnostic [`Resolver`] that flat-merges another source into the shared
18//! atom universe ([`compile`] resolves imports; [`compile_source`] leaves them
19//! pending).
20//!
21//! # Example
22//!
23//! ```
24//! use elenchus_compiler::compile_source;
25//!
26//! // `ASSUME` lowers to a *soft* fact: the same atom universe as a `FACT`, but
27//! // one the solver may retract. Here `x a` is asserted both ways (hard + soft).
28//! let ir = compile_source("demo.vrf", "DOMAIN d\nFACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
29//! assert_eq!(ir.facts.len(), 2);
30//! assert!(ir.facts.iter().any(|f| f.soft)); // the ASSUME is the soft one
31//! ```
32#![no_std]
33// Every public item is documented; CI (`clippy -D warnings`) keeps it that way.
34#![warn(missing_docs)]
35
36extern crate alloc;
37
38#[cfg(feature = "std")]
39extern crate std;
40
41use alloc::collections::{BTreeMap, BTreeSet};
42use alloc::string::{String, ToString};
43use alloc::vec;
44use alloc::vec::Vec;
45use core::fmt::Write as _;
46
47/// Re-exported so downstream crates can name the syntax diagnostics carried by
48/// [`CompileError::Parse`] (and render them with a custom error limit).
49pub use elenchus_parser::Diagnostics;
50use elenchus_parser::{Atom, Body, Conn, ListOp, Literal, Statement, kw};
51use sha2::{Digest, Sha256};
52use thiserror::Error;
53
54// --- content-addressing (mirrors vsm-guard::hashing) -----------------------
55
56/// SHA-256 content addressing. Used only for dedup / redefinition / provenance,
57/// never for namespacing atoms.
58pub fn hash_hex(data: &[u8]) -> String {
59    let mut hasher = Sha256::new();
60    hasher.update(data);
61    let out = hasher.finalize();
62    let mut s = String::with_capacity(out.len() * 2);
63    for b in out {
64        let _ = write!(s, "{:02x}", b);
65    }
66    s
67}
68
69// --- IR types --------------------------------------------------------------
70
71/// Dense atom identifier (also the SAT variable number).
72pub type AtomId = u32;
73
74/// The identity of an atom: the `domain` plus the triple
75/// `(subject, predicate, object?)`, owned so it survives across merged sources.
76/// The domain is the leading sort key, so atoms group by domain; ordering is
77/// otherwise lexicographic → canonical. Two atoms with the same triple in
78/// *different* domains are distinct (no cross-domain unification).
79#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
80pub struct AtomKey {
81    /// The domain this atom belongs to (the resolved namespace, never a raw
82    /// alias). `physics.engine` and `plan.engine` are different atoms.
83    pub domain: String,
84    /// The entity the claim is about (owned copy of the parser's `subject`).
85    pub subject: String,
86    /// The relation or property asserted.
87    pub predicate: String,
88    /// Optional object; part of identity, so `has flying` ≠ `has swimming`.
89    pub object: Option<String>,
90}
91
92/// The domain context of one file being compiled: its own declared domain (where
93/// bare atoms fall) and the local names — aliases or imported domain names — it
94/// may reference other domains by. Resolving an atom's optional `domain.` prefix
95/// against this context yields its canonical [`AtomKey`] domain.
96struct DomainCtx {
97    /// The file's own declared domain (the target for unqualified atoms).
98    current: String,
99    /// `local name -> canonical domain` for every name visible in this file
100    /// (always includes `current -> current`, plus one entry per `IMPORT`).
101    aliases: BTreeMap<String, String>,
102}
103
104impl DomainCtx {
105    /// Resolve an atom's optional `domain.` prefix to a canonical domain name.
106    /// `None` → the file's own domain; a prefix not imported here is an error.
107    fn resolve(&self, prefix: Option<&str>) -> Result<String, CompileError> {
108        match prefix {
109            None => Ok(self.current.clone()),
110            Some(p) => self
111                .aliases
112                .get(p)
113                .cloned()
114                .ok_or_else(|| CompileError::UnknownDomain {
115                    domain: p.to_string(),
116                }),
117        }
118    }
119
120    /// Build the owned [`AtomKey`] for a borrowed parser [`Atom`], resolving its
121    /// domain prefix against this file's context.
122    fn key(&self, a: &Atom) -> Result<AtomKey, CompileError> {
123        Ok(AtomKey {
124            domain: self.resolve(a.domain)?,
125            subject: a.subject.to_string(),
126            predicate: a.predicate.to_string(),
127            object: a.object.map(|o| o.to_string()),
128        })
129    }
130}
131
132/// A literal as it appears *inside* an `Impossible` clause: an atom, optionally
133/// negated. `negated = true` means the literal is `NOT atom`.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct Lit {
136    /// Interned id of the atom (also its SAT variable number).
137    pub atom: AtomId,
138    /// `true` means this literal is `NOT atom` inside the clause.
139    pub negated: bool,
140}
141
142/// A confident truth value. UNKNOWN is the *absence* of a fact, never stored.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum Value {
145    /// The atom is asserted TRUE (from `FACT`).
146    True,
147    /// The atom is asserted FALSE (from `NOT`).
148    False,
149}
150
151/// Where a piece of IR came from — for readable conflict/warning pools.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Origin {
154    /// The source label this came from (file name or `"<root>"`/`"<text>"`).
155    pub source: String,
156    /// 1-based line number of the originating statement.
157    pub line: u32,
158    /// The premise/rule name, if it came from a named construct.
159    pub premise: Option<String>,
160    /// Surface kind for the report, e.g. `"FACT"`, `"EXCLUSIVE"`, `"PREMISE"`.
161    pub kind: &'static str,
162}
163
164/// A confident fact (from `FACT` / `NOT`). Conflicting facts on the same atom
165/// are preserved (both kept) — the solver reports that as a CONFLICT.
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Fact {
168    /// The atom this fact pins down.
169    pub atom: AtomId,
170    /// The asserted truth value.
171    pub value: Value,
172    /// Where it came from (for the report).
173    pub origin: Origin,
174    /// `true` for an `ASSUME` (a *soft*, retractable hypothesis). A soft fact
175    /// behaves like a normal fact in the forward pass, but when the assumptions
176    /// cannot all hold the solver may drop it (and only it) to explain the
177    /// contradiction — a `FACT`/`NOT` is never retractable.
178    pub soft: bool,
179}
180
181/// An `Impossible` clause: the listed literals cannot all hold simultaneously.
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct Clause {
184    /// The literals that cannot all hold at once (an `Impossible([...])`).
185    pub lits: Vec<Lit>,
186    /// Where it came from (for the report).
187    pub origin: Origin,
188}
189
190/// A forward-chaining rule (from `RULE`): if all antecedent literals hold, derive
191/// the consequent literals.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Rule {
194    /// Literals that must all hold for the rule to fire.
195    pub antecedent: Vec<Lit>,
196    /// Literals derived (asserted) when the antecedent holds.
197    pub consequent: Vec<Lit>,
198    /// Where it came from (for the report).
199    pub origin: Origin,
200}
201
202/// A `CHECK` query.
203#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct Check {
205    /// Restrict the report to this subject; `None` means check everything.
206    pub subject: Option<String>,
207    /// `true` runs the backward (all-SAT) pass to detect UNDERDETERMINED.
208    pub bidirectional: bool,
209}
210
211/// The compiled IR: the solver's input.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct Compiled {
214    /// Indexed by [`AtomId`]; canonically sorted.
215    pub atoms: Vec<AtomKey>,
216    /// Confident assertions from `FACT`/`NOT`.
217    pub facts: Vec<Fact>,
218    /// `Impossible` clauses (desugared premises + the built-in non-contradiction).
219    pub clauses: Vec<Clause>,
220    /// Forward-chaining rules from `RULE`.
221    pub rules: Vec<Rule>,
222    /// `CHECK` queries.
223    pub checks: Vec<Check>,
224    /// Imports seen but not yet resolved (only populated by [`compile_source`];
225    /// [`compile`] resolves them, leaving this empty).
226    pub pending_imports: Vec<String>,
227    /// Advisory: imports that a file makes but never references (no `domain.atom`
228    /// from that file uses the imported domain). Structural, per-file, and inert —
229    /// it never affects the solve. Only populated by [`compile`] (an unresolved
230    /// import in [`compile_source`] cannot be classified). See [`UnusedImport`].
231    pub unused_imports: Vec<UnusedImport>,
232}
233
234/// An advisory record: a file `IMPORT`s a domain it never references. Such an
235/// import is inert — no `domain.atom` in that file mentions it, so removing it
236/// would not change the result. It is almost always a leftover or a forgotten
237/// `domain.` prefix. **Purely informational** — it never changes the verdict.
238#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
239pub struct UnusedImport {
240    /// The source that declared the unused `IMPORT`.
241    pub file: String,
242    /// The imported domain that is never referenced from `file`.
243    pub domain: String,
244    /// The local alias, if the import used `AS <alias>`.
245    pub alias: Option<String>,
246    /// 1-based line of the `IMPORT` statement in `file`.
247    pub line: u32,
248}
249
250/// Anything that can go wrong while compiling (and resolving imports).
251#[derive(Debug, Error, PartialEq, Eq)]
252pub enum CompileError {
253    /// A source failed to parse; carries the full syntax diagnostics (every
254    /// error, each as a caret block with the keyword's correct syntax). The
255    /// source label is already inside the [`Diagnostics`] header.
256    #[error("{0}")]
257    Parse(elenchus_parser::Diagnostics),
258    /// A name was reused with a different body *within the same source*.
259    #[error("'{name}' redefined with a different body")]
260    PremiseRedefinition {
261        /// The clashing premise/rule name.
262        name: String,
263    },
264    /// A source did not declare its `DOMAIN` (required, once, as the first
265    /// statement).
266    #[error("{file}: missing a DOMAIN declaration (every file must start with `DOMAIN <name>`)")]
267    MissingDomain {
268        /// The source label that lacked a `DOMAIN`.
269        file: String,
270    },
271    /// A source declared `DOMAIN` more than once (a file has exactly one domain).
272    #[error("{file}: more than one DOMAIN declaration (a file has exactly one domain)")]
273    DuplicateDomain {
274        /// The source label with the duplicate `DOMAIN`.
275        file: String,
276    },
277    /// An atom referenced a `domain.` prefix that is not the file's own domain and
278    /// was not imported in this file.
279    #[error("unknown domain '{domain}' — declare it with DOMAIN, or IMPORT it in this file")]
280    UnknownDomain {
281        /// The unresolved domain prefix.
282        domain: String,
283    },
284    /// Two imports bound the same local domain name to different domains (use a
285    /// distinct `AS <alias>` to tell them apart).
286    #[error("domain name '{alias}' is bound to two different imports (disambiguate with AS)")]
287    DomainAliasClash {
288        /// The clashing local domain name.
289        alias: String,
290    },
291    /// An `IMPORT` target could not be loaded by the [`Resolver`].
292    #[error("import not found: {0}")]
293    ImportNotFound(String),
294    /// Imports form a cycle (a source transitively imports itself).
295    #[error("circular import: {0}")]
296    CircularImport(String),
297    /// A `RULE` used `OR` in its `THEN`: forward chaining cannot derive a
298    /// disjunction (it would not know which literal to assert). Model it as a
299    /// `PREMISE` constraint instead.
300    #[error("rule '{name}' cannot derive a disjunction (OR in THEN); use a PREMISE instead")]
301    RuleDisjunctiveConsequent {
302        /// The offending rule name.
303        name: String,
304    },
305}
306
307// --- raw (key-based) intermediate, before interning ------------------------
308// While accumulating we key everything by `AtomKey` (the owned triple) rather
309// than by `AtomId`, because ids only become stable once *all* sources are merged
310// and the atom set is sorted in `finalize`. These mirror the public IR types but
311// hold keys instead of ids.
312
313/// A literal keyed by atom identity (pre-interning counterpart of [`Lit`]).
314#[derive(Clone)]
315struct RawLit {
316    key: AtomKey,
317    negated: bool,
318}
319
320/// A fact keyed by atom identity (pre-interning counterpart of [`Fact`]).
321struct RawFact {
322    key: AtomKey,
323    value: Value,
324    origin: Origin,
325    soft: bool,
326}
327
328/// A clause keyed by atom identity (pre-interning counterpart of [`Clause`]).
329struct RawClause {
330    lits: Vec<RawLit>,
331    origin: Origin,
332}
333
334/// A rule keyed by atom identity (pre-interning counterpart of [`Rule`]).
335struct RawRule {
336    antecedent: Vec<RawLit>,
337    consequent: Vec<RawLit>,
338    origin: Origin,
339}
340
341// --- compiler --------------------------------------------------------------
342
343/// Accumulates statements from one or more sources, then interns + emits the IR.
344#[derive(Default)]
345pub struct Compiler {
346    keys: BTreeSet<AtomKey>,
347    facts: Vec<RawFact>,
348    clauses: Vec<RawClause>,
349    rules: Vec<RawRule>,
350    checks: Vec<Check>,
351    pending_imports: Vec<String>,
352    /// (source, name) → content hash of its body, for redefinition detection.
353    /// Scoped per source: premise/rule names are labels, not global identifiers,
354    /// so different files (domains) may reuse a name. A clash is only an error
355    /// *within the same source*.
356    defined: BTreeMap<(String, String), String>,
357    /// dedup of identical clauses by canonical content hash.
358    clause_sigs: BTreeSet<String>,
359    /// dedup of identical facts by (key, value).
360    fact_sigs: BTreeSet<String>,
361}
362
363impl Compiler {
364    /// A fresh, empty compiler.
365    pub fn new() -> Self {
366        Self::default()
367    }
368
369    /// Parse one source and accumulate its statements. `source` is a label used
370    /// in provenance (e.g. a file name or `"<root>"`). The source must declare its
371    /// `DOMAIN`; `IMPORT`s are recorded as pending (their domains cannot be bound
372    /// without a [`Resolver`]), so a single source may only reference its own
373    /// domain. Use [`compile`] for cross-domain references.
374    pub fn add_source(&mut self, source: &str, src: &str) -> Result<(), CompileError> {
375        let program = elenchus_parser::parse(src).map_err(|mut diag| {
376            diag.set_file(source);
377            CompileError::Parse(diag)
378        })?;
379        let domain = extract_domain(&program, source)?;
380        let mut aliases = BTreeMap::new();
381        aliases.insert(domain.clone(), domain.clone());
382        let ctx = DomainCtx {
383            current: domain,
384            aliases,
385        };
386        for stmt in &program.statements {
387            match stmt {
388                Statement::Domain(_) => {}
389                Statement::Import { path, .. } => {
390                    self.pending_imports.push(path.data.to_string());
391                }
392                other => self.add_statement(source, other, &ctx)?,
393            }
394        }
395        Ok(())
396    }
397
398    /// Compile one already-resolved file's statements under its domain context.
399    fn add_resolved(&mut self, file: &ResolvedFile) -> Result<(), CompileError> {
400        let program = elenchus_parser::parse(&file.content).map_err(|mut diag| {
401            diag.set_file(&file.path);
402            CompileError::Parse(diag)
403        })?;
404        for stmt in &program.statements {
405            match stmt {
406                Statement::Import { .. } | Statement::Domain(_) => {}
407                other => self.add_statement(&file.path, other, &file.ctx)?,
408            }
409        }
410        Ok(())
411    }
412
413    /// Route one statement (never `IMPORT`/`DOMAIN` — handled by the loaders) to
414    /// the right accumulator, resolving atom domains through `ctx`.
415    fn add_statement(
416        &mut self,
417        source: &str,
418        stmt: &Statement,
419        ctx: &DomainCtx,
420    ) -> Result<(), CompileError> {
421        match stmt {
422            // Handled by `add_source` / `load_recursive`, never reach here.
423            Statement::Import { .. } | Statement::Domain(_) => {}
424            Statement::Fact(a) => self.add_fact(source, a, Value::True, kw::FACT, false, ctx)?,
425            Statement::Negation(a) => {
426                self.add_fact(source, a, Value::False, kw::NOT, false, ctx)?
427            }
428            Statement::Assume(l) => {
429                let value = if l.data.negated {
430                    Value::False
431                } else {
432                    Value::True
433                };
434                // A soft assertion shares the FACT accumulator; the atom is the
435                // literal's atom, the polarity its `NOT`, and `soft` marks it
436                // retractable. The span is the whole `ASSUME` line.
437                let located = elenchus_parser::Located {
438                    data: l.data.atom.clone(),
439                    span: l.span,
440                };
441                self.add_fact(source, &located, value, kw::ASSUME, true, ctx)?;
442            }
443            Statement::Check {
444                subject,
445                bidirectional,
446            } => self.checks.push(Check {
447                subject: subject.as_ref().map(|s| s.data.to_string()),
448                bidirectional: *bidirectional,
449            }),
450            Statement::Premise { name, body } => {
451                let line = name.span.location_line();
452                self.add_named(source, name.data, line, body, false, ctx)?;
453            }
454            Statement::Rule { name, body } => {
455                let line = name.span.location_line();
456                self.add_named(source, name.data, line, body, true, ctx)?;
457            }
458        }
459        Ok(())
460    }
461
462    /// Record an atom identity in the shared universe (deduped by the `BTreeSet`).
463    fn intern(&mut self, key: &AtomKey) {
464        if !self.keys.contains(key) {
465            self.keys.insert(key.clone());
466        }
467    }
468
469    /// Accumulate a `FACT`/`NOT`; exact duplicates (same key+value+kind) are
470    /// dropped as idempotent, while a `FACT` and a `NOT` on the same atom are
471    /// both kept so the solver can report the CONFLICT.
472    fn add_fact(
473        &mut self,
474        source: &str,
475        a: &elenchus_parser::Located<Atom>,
476        value: Value,
477        kind: &'static str,
478        soft: bool,
479        ctx: &DomainCtx,
480    ) -> Result<(), CompileError> {
481        let key = ctx.key(&a.data)?;
482        self.intern(&key);
483        let sig = alloc::format!(
484            "{}|{}|{}|{}",
485            key_sig(&key),
486            matches!(value, Value::True) as u8,
487            kind,
488            "" // facts dedup ignores line; identical FACT twice is idempotent
489        );
490        if !self.fact_sigs.insert(sig) {
491            return Ok(()); // exact duplicate fact — idempotent
492        }
493        self.facts.push(RawFact {
494            key,
495            value,
496            origin: Origin {
497                source: source.to_string(),
498                line: a.span.location_line(),
499                premise: None,
500                kind,
501            },
502            soft,
503        });
504        Ok(())
505    }
506
507    /// Handle a named construct (`PREMISE` or `RULE`). `is_rule` selects derivation
508    /// vs checking. Returns an error on redefinition with a different body.
509    fn add_named(
510        &mut self,
511        source: &str,
512        name: &str,
513        line: u32,
514        body: &Body,
515        is_rule: bool,
516        ctx: &DomainCtx,
517    ) -> Result<(), CompileError> {
518        let body_hash = hash_hex(canonical_body(name, body, is_rule, ctx)?.as_bytes());
519        let key = (source.to_string(), name.to_string());
520        match self.defined.get(&key) {
521            Some(prev) if *prev == body_hash => return Ok(()), // identical → idempotent
522            Some(_) => {
523                // Same name + different body *in the same source* — a real mistake.
524                return Err(CompileError::PremiseRedefinition {
525                    name: name.to_string(),
526                });
527            }
528            None => {
529                self.defined.insert(key, body_hash);
530            }
531        }
532
533        if is_rule {
534            // RULE always has an implication body (guaranteed by the grammar).
535            if let Body::Impl {
536                antecedent,
537                ante_conn,
538                consequent,
539                cons_conn,
540            } = body
541            {
542                // A rule *derives* its consequent; an `OR` consequent is not a
543                // single fact to assert, so reject it (use a PREMISE instead).
544                if *cons_conn == Conn::Or {
545                    return Err(CompileError::RuleDisjunctiveConsequent {
546                        name: name.to_string(),
547                    });
548                }
549                let (ante, cons) = (raw_lits(antecedent, ctx)?, raw_lits(consequent, ctx)?);
550                for l in ante.iter().chain(cons.iter()) {
551                    self.intern(&l.key);
552                }
553                let origin = self.origin(source, line, Some(name), kw::RULE);
554                match ante_conn {
555                    // a ∧ b → C : one rule firing on the whole antecedent.
556                    Conn::And => self.rules.push(RawRule {
557                        antecedent: ante,
558                        consequent: cons,
559                        origin,
560                    }),
561                    // (a ∨ b) → C == (a → C) ∧ (b → C): one rule per antecedent.
562                    Conn::Or => {
563                        for a in &ante {
564                            self.rules.push(RawRule {
565                                antecedent: vec![a.clone()],
566                                consequent: cons.clone(),
567                                origin: origin.clone(),
568                            });
569                        }
570                    }
571                }
572            }
573            return Ok(());
574        }
575
576        match body {
577            Body::List { op, atoms } => {
578                let keys: Vec<AtomKey> = atoms
579                    .iter()
580                    .map(|a| ctx.key(&a.data))
581                    .collect::<Result<_, _>>()?;
582                for k in &keys {
583                    self.intern(k);
584                }
585                let kind = list_kind(*op);
586                let origin = self.origin(source, line, Some(name), kind);
587                match op {
588                    // EXCLUSIVE / FORBIDS: "at most one" → pairwise Impossible([a_i, a_j]).
589                    ListOp::Exclusive | ListOp::Forbids => {
590                        self.emit_pairwise(&keys, &origin);
591                    }
592                    // ONEOF: pairwise (at most one) + at-least-one.
593                    ListOp::OneOf => {
594                        self.emit_pairwise(&keys, &origin);
595                        self.emit_at_least_one(&keys, &origin);
596                    }
597                    // ATLEAST: Impossible([NOT a_1, …, NOT a_n]).
598                    ListOp::AtLeast => {
599                        self.emit_at_least_one(&keys, &origin);
600                    }
601                }
602            }
603            Body::Impl {
604                antecedent,
605                ante_conn,
606                consequent,
607                cons_conn,
608            } => {
609                // Implication A → C as `Impossible(A_true ∧ ¬C)`. We group each
610                // side by its connective and emit one clause per (ante × cons)
611                // group pair — a uniform rule covering all AND/OR combinations:
612                //   AND-ante → all its literals share every clause;
613                //   OR-ante  → one clause per literal;
614                //   AND-cons → one clause per (negated) literal;
615                //   OR-cons  → all its (negated) literals share every clause.
616                let ante = raw_lits(antecedent, ctx)?;
617                let cons = raw_lits(consequent, ctx)?;
618                for l in ante.iter().chain(cons.iter()) {
619                    self.intern(&l.key);
620                }
621                let origin = self.origin(source, line, Some(name), kw::PREMISE);
622
623                let ante_groups: Vec<Vec<RawLit>> = match ante_conn {
624                    Conn::And => vec![ante.clone()],
625                    Conn::Or => ante.iter().map(|l| vec![l.clone()]).collect(),
626                };
627                let cons_groups: Vec<Vec<RawLit>> = match cons_conn {
628                    Conn::And => cons.iter().map(|l| vec![l.clone()]).collect(),
629                    Conn::Or => vec![cons.clone()],
630                };
631                for ag in &ante_groups {
632                    for cg in &cons_groups {
633                        let mut lits = ag.clone();
634                        for c in cg {
635                            lits.push(RawLit {
636                                key: c.key.clone(),
637                                negated: !c.negated,
638                            });
639                        }
640                        self.push_clause(lits, origin.clone());
641                    }
642                }
643            }
644        }
645        Ok(())
646    }
647
648    /// Emit "at most one TRUE" as one `Impossible([a_i, a_j])` per unordered
649    /// pair. Pairwise (not a single big clause) because `Impossible([a,b,c])`
650    /// only forbids *all three* together — it would still allow two.
651    fn emit_pairwise(&mut self, keys: &[AtomKey], origin: &Origin) {
652        for i in 0..keys.len() {
653            for j in (i + 1)..keys.len() {
654                let lits = vec![
655                    RawLit {
656                        key: keys[i].clone(),
657                        negated: false,
658                    },
659                    RawLit {
660                        key: keys[j].clone(),
661                        negated: false,
662                    },
663                ];
664                self.push_clause(lits, origin.clone());
665            }
666        }
667    }
668
669    /// Emit "at least one TRUE" as a single `Impossible([NOT a_1, …, NOT a_n])`
670    /// — it is impossible for all of them to be false at once.
671    fn emit_at_least_one(&mut self, keys: &[AtomKey], origin: &Origin) {
672        let lits = keys
673            .iter()
674            .map(|k| RawLit {
675                key: k.clone(),
676                negated: true,
677            })
678            .collect();
679        self.push_clause(lits, origin.clone());
680    }
681
682    /// Append a clause unless an identical one (by canonical [`clause_sig`]) is
683    /// already present — `P ∧ P ≡ P`, so dedup keeps the IR minimal.
684    fn push_clause(&mut self, lits: Vec<RawLit>, origin: Origin) {
685        let sig = clause_sig(&lits);
686        if self.clause_sigs.insert(sig) {
687            self.clauses.push(RawClause { lits, origin });
688        }
689        // else: identical clause already present — idempotent.
690    }
691
692    /// Build an [`Origin`] for provenance from the current source/line/name.
693    fn origin(&self, source: &str, line: u32, premise: Option<&str>, kind: &'static str) -> Origin {
694        Origin {
695            source: source.to_string(),
696            line,
697            premise: premise.map(|s| s.to_string()),
698            kind,
699        }
700    }
701
702    /// Intern all atoms (canonical sort), then lower the raw IR to ids.
703    pub fn finalize(self) -> Compiled {
704        let atoms: Vec<AtomKey> = self.keys.into_iter().collect(); // BTreeSet → sorted
705        let mut id_of: BTreeMap<AtomKey, AtomId> = BTreeMap::new();
706        for (i, k) in atoms.iter().enumerate() {
707            id_of.insert(k.clone(), i as AtomId);
708        }
709        let lower = |l: &RawLit| Lit {
710            atom: id_of[&l.key],
711            negated: l.negated,
712        };
713
714        let facts = self
715            .facts
716            .into_iter()
717            .map(|f| Fact {
718                atom: id_of[&f.key],
719                value: f.value,
720                origin: f.origin,
721                soft: f.soft,
722            })
723            .collect();
724        let clauses = self
725            .clauses
726            .into_iter()
727            .map(|c| Clause {
728                lits: c.lits.iter().map(lower).collect(),
729                origin: c.origin,
730            })
731            .collect();
732        let rules = self
733            .rules
734            .into_iter()
735            .map(|r| Rule {
736                antecedent: r.antecedent.iter().map(lower).collect(),
737                consequent: r.consequent.iter().map(lower).collect(),
738                origin: r.origin,
739            })
740            .collect();
741
742        Compiled {
743            atoms,
744            facts,
745            clauses,
746            rules,
747            checks: self.checks,
748            pending_imports: self.pending_imports,
749            unused_imports: Vec::new(), // filled by `compile` (advisory, post-resolution)
750        }
751    }
752}
753
754/// Convenience: compile a single source into the IR. `IMPORT`s are recorded as
755/// pending, not resolved (use [`compile`] with a [`Resolver`] to resolve them).
756pub fn compile_source(source: &str, src: &str) -> Result<Compiled, CompileError> {
757    let mut c = Compiler::new();
758    c.add_source(source, src)?;
759    Ok(c.finalize())
760}
761
762// --- import resolution (source-agnostic) -----------------------------------
763
764/// Resolves `IMPORT "path"` to source text. The engine is source-agnostic: it
765/// consumes strings, so a file is merely one backing store. Mirrors
766/// vsm-grammar's `SourceResolver`.
767pub trait Resolver {
768    /// Load the raw source text for a resolved path.
769    fn load(&self, path: &str) -> Result<String, CompileError>;
770
771    /// Normalize `relative` against the importing source `base`.
772    /// Default: paths are absolute names, returned unchanged.
773    fn resolve(&self, _base: &str, relative: &str) -> String {
774        relative.to_string()
775    }
776}
777
778/// An in-memory resolver: serves sources from a name → content map. Pure
779/// `no_std`. Mirrors vsm-grammar's `MemoryResolver`.
780#[derive(Default)]
781pub struct MemoryResolver {
782    sources: BTreeMap<String, String>,
783}
784
785impl MemoryResolver {
786    /// An empty resolver with no sources.
787    pub fn new() -> Self {
788        Self::default()
789    }
790
791    /// Register `content` under `path`; returns `&mut self` for chaining.
792    pub fn add(&mut self, path: &str, content: &str) -> &mut Self {
793        self.sources.insert(path.to_string(), content.to_string());
794        self
795    }
796}
797
798impl Resolver for MemoryResolver {
799    fn load(&self, path: &str) -> Result<String, CompileError> {
800        self.sources
801            .get(path)
802            .cloned()
803            .ok_or_else(|| CompileError::ImportNotFound(path.to_string()))
804    }
805}
806
807/// A filesystem-backed resolver. Mirrors vsm-grammar's `FileResolver`:
808/// relative imports resolve against the importing file's directory, with manual
809/// `..` normalization (no canonicalization, to keep a virtual layout).
810#[cfg(feature = "std")]
811pub struct FileResolver;
812
813#[cfg(feature = "std")]
814impl Resolver for FileResolver {
815    fn load(&self, path: &str) -> Result<String, CompileError> {
816        std::fs::read_to_string(path)
817            .map_err(|e| CompileError::ImportNotFound(alloc::format!("{}: {}", path, e)))
818    }
819
820    fn resolve(&self, base: &str, relative: &str) -> String {
821        use std::path::{Component, Path, PathBuf};
822        let parent = Path::new(base).parent().unwrap_or_else(|| Path::new("."));
823        let joined = parent.join(relative);
824        let mut out = PathBuf::new();
825        for component in joined.components() {
826            match component {
827                Component::ParentDir => {
828                    out.pop();
829                }
830                Component::CurDir => {}
831                c => out.push(c),
832            }
833        }
834        // Normalize to forward slashes so resolved paths (and therefore the
835        // provenance recorded in the IR) are identical on Windows and Unix.
836        // Windows `std::fs` accepts `/` just fine.
837        out.to_string_lossy().replace('\\', "/")
838    }
839}
840
841/// One resolved source ready to compile: its provenance path, raw text, and the
842/// domain context (own domain + import-alias bindings) its atoms resolve against.
843struct ResolvedFile {
844    path: String,
845    content: String,
846    ctx: DomainCtx,
847}
848
849/// Compile a root source and all its transitive `IMPORT`s into one IR.
850///
851/// Each file is keyed by `DOMAIN`; atoms unify only within a domain. Imports are
852/// referenced by `<domain>.<atom>` and visibility is file-local (naming is not
853/// transitive, though a dependency's clauses still participate). Sources are
854/// content-addressed (sha256): a source reached by several paths is compiled once
855/// (so a diamond — or an exponential fan-out — stays linear, never blowing up),
856/// and an import cycle is an error.
857///
858/// Resolution is **iterative** (an explicit work stack, not native recursion), so
859/// an arbitrarily deep import chain cannot overflow the call stack.
860///
861/// Premise/rule names are per-source labels, not global identifiers: different
862/// files may reuse a name, and the report qualifies them by source. A name reused
863/// with a different body is an error only *within the same source*.
864pub fn compile<R: Resolver>(root: &str, resolver: &R) -> Result<Compiled, CompileError> {
865    let (files, unused_imports) = resolve_graph(root, resolver)?;
866    let mut c = Compiler::new();
867    for file in &files {
868        c.add_resolved(file)?;
869    }
870    let mut compiled = c.finalize();
871    compiled.unused_imports = unused_imports;
872    Ok(compiled)
873}
874
875/// One `IMPORT` edge: the optional local alias, the resolved child path, and the
876/// `IMPORT` line (for the unused-import advisory).
877struct ImportEdge {
878    alias: Option<String>,
879    child_path: String,
880    line: u32,
881}
882
883/// A discovered source during graph resolution: its first-seen path, raw text,
884/// declared domain, import edges, and the set of domain prefixes its atoms use
885/// (`None` = its own domain; `Some(p)` = a `p.` prefix) — used to flag imports
886/// that the file never references.
887struct DiscoveredFile {
888    path: String,
889    content: String,
890    domain: String,
891    edges: Vec<ImportEdge>,
892    used_prefixes: BTreeSet<Option<String>>,
893}
894
895/// Resolve the whole import graph reachable from `root` into a flat list of
896/// [`ResolvedFile`]s, each distinct source appearing once.
897///
898/// Iterative depth-first traversal with an explicit work stack (`Enter`/`Exit`
899/// frames) — no native recursion, so depth is unbounded without risking a stack
900/// overflow. Memoized by content hash (a diamond/repeat is visited once); a hash
901/// re-encountered while still on the active path is a [`CompileError::CircularImport`].
902fn resolve_graph<R: Resolver>(
903    root: &str,
904    resolver: &R,
905) -> Result<(Vec<ResolvedFile>, Vec<UnusedImport>), CompileError> {
906    /// One unit of pending work on the traversal stack.
907    enum Step {
908        /// Visit a file at this resolved path (load, parse, enqueue its imports).
909        Enter(String),
910        /// Mark this content hash finished (pop it off the active path).
911        Exit(String),
912    }
913
914    let mut discovered: BTreeMap<String, DiscoveredFile> = BTreeMap::new(); // by hash
915    let mut path_hash: BTreeMap<String, String> = BTreeMap::new(); // resolved path → hash
916    let mut order: Vec<String> = Vec::new(); // finish order, by hash
917    let mut active: BTreeSet<String> = BTreeSet::new(); // hashes on the current DFS path
918    let mut work: Vec<Step> = vec![Step::Enter(root.to_string())];
919
920    while let Some(step) = work.pop() {
921        match step {
922            Step::Exit(hash) => {
923                active.remove(&hash);
924                order.push(hash);
925            }
926            Step::Enter(path) => {
927                let content = resolver.load(&path)?;
928                let hash = hash_hex(content.as_bytes());
929                path_hash.insert(path.clone(), hash.clone());
930                if active.contains(&hash) {
931                    return Err(CompileError::CircularImport(path)); // back-edge to an ancestor
932                }
933                if discovered.contains_key(&hash) {
934                    continue; // already fully resolved by another path — dedup
935                }
936                let program = elenchus_parser::parse(&content).map_err(|mut diag| {
937                    diag.set_file(&path);
938                    CompileError::Parse(diag)
939                })?;
940                let domain = extract_domain(&program, &path)?;
941                let mut edges = Vec::new();
942                let mut used_prefixes = BTreeSet::new();
943                for stmt in &program.statements {
944                    if let Statement::Import { path: p, alias } = stmt {
945                        edges.push(ImportEdge {
946                            alias: alias.as_ref().map(|a| a.data.to_string()),
947                            child_path: resolver.resolve(&path, p.data),
948                            line: p.span.location_line(),
949                        });
950                    } else {
951                        collect_prefixes(stmt, &mut used_prefixes);
952                    }
953                }
954                drop(program); // release the borrow on `content` before moving it
955                active.insert(hash.clone());
956                work.push(Step::Exit(hash.clone()));
957                for e in edges.iter().rev() {
958                    work.push(Step::Enter(e.child_path.clone()));
959                }
960                discovered.insert(
961                    hash,
962                    DiscoveredFile {
963                        path,
964                        content,
965                        domain,
966                        edges,
967                        used_prefixes,
968                    },
969                );
970            }
971        }
972    }
973
974    // Build each file's domain context now that every domain is known.
975    // Look up every file's domain (small strings) so we can then *move* each
976    // file's (potentially large) content out of `discovered` instead of cloning.
977    let domain_of: BTreeMap<&str, &str> = discovered
978        .iter()
979        .map(|(h, f)| (h.as_str(), f.domain.as_str()))
980        .collect();
981
982    let mut out = Vec::with_capacity(order.len());
983    let mut unused: Vec<UnusedImport> = Vec::new();
984    for hash in &order {
985        let file = &discovered[hash];
986        let mut aliases = BTreeMap::new();
987        aliases.insert(file.domain.clone(), file.domain.clone());
988        for edge in &file.edges {
989            let child_domain = domain_of[path_hash[&edge.child_path].as_str()];
990            let bind = edge
991                .alias
992                .clone()
993                .unwrap_or_else(|| child_domain.to_string());
994            match aliases.get(&bind) {
995                Some(existing) if existing != child_domain => {
996                    return Err(CompileError::DomainAliasClash { alias: bind });
997                }
998                _ => {
999                    aliases.insert(bind, child_domain.to_string());
1000                }
1001            }
1002        }
1003
1004        // The domains this file actually references (each used prefix resolved
1005        // against its own domain / imports). An imported domain absent from this
1006        // set is an unused import.
1007        let referenced: BTreeSet<&str> = file
1008            .used_prefixes
1009            .iter()
1010            .filter_map(|p| match p {
1011                None => Some(file.domain.as_str()),
1012                Some(name) => aliases.get(name).map(|d| d.as_str()),
1013            })
1014            .collect();
1015        for edge in &file.edges {
1016            let child_domain = domain_of[path_hash[&edge.child_path].as_str()];
1017            if !referenced.contains(child_domain) {
1018                unused.push(UnusedImport {
1019                    file: file.path.clone(),
1020                    domain: child_domain.to_string(),
1021                    alias: edge.alias.clone(),
1022                    line: edge.line,
1023                });
1024            }
1025        }
1026
1027        let ctx = DomainCtx {
1028            current: file.domain.clone(),
1029            aliases,
1030        };
1031        out.push((hash.clone(), ctx));
1032    }
1033    unused.sort();
1034
1035    // Now move content/path out of `discovered` (no large clones) and pair with
1036    // the contexts built above.
1037    let files = out
1038        .into_iter()
1039        .map(|(hash, ctx)| {
1040            let file = discovered.remove(&hash).expect("hash was discovered");
1041            ResolvedFile {
1042                path: file.path,
1043                content: file.content,
1044                ctx,
1045            }
1046        })
1047        .collect();
1048    Ok((files, unused))
1049}
1050
1051/// Collect the domain prefixes used by a statement's atoms into `out` (`None` for
1052/// a bare atom, `Some(p)` for a `p.`-qualified one) — feeds the unused-import lint.
1053fn collect_prefixes(stmt: &Statement, out: &mut BTreeSet<Option<String>>) {
1054    let mut add = |a: &Atom| {
1055        out.insert(a.domain.map(|d| d.to_string()));
1056    };
1057    match stmt {
1058        Statement::Fact(a) | Statement::Negation(a) => add(&a.data),
1059        Statement::Assume(l) => add(&l.data.atom),
1060        Statement::Premise { body, .. } | Statement::Rule { body, .. } => match body {
1061            Body::List { atoms, .. } => atoms.iter().for_each(|a| add(&a.data)),
1062            Body::Impl {
1063                antecedent,
1064                consequent,
1065                ..
1066            } => antecedent
1067                .iter()
1068                .chain(consequent)
1069                .for_each(|l| add(&l.data.atom)),
1070        },
1071        Statement::Domain(_) | Statement::Import { .. } | Statement::Check { .. } => {}
1072    }
1073}
1074
1075/// The single `DOMAIN` a source declares, or an error if it has none or several.
1076fn extract_domain(
1077    program: &elenchus_parser::Program,
1078    source: &str,
1079) -> Result<String, CompileError> {
1080    let mut found: Option<String> = None;
1081    for stmt in &program.statements {
1082        if let Statement::Domain(name) = stmt {
1083            if found.is_some() {
1084                return Err(CompileError::DuplicateDomain {
1085                    file: source.to_string(),
1086                });
1087            }
1088            found = Some(name.data.to_string());
1089        }
1090    }
1091    found.ok_or_else(|| CompileError::MissingDomain {
1092        file: source.to_string(),
1093    })
1094}
1095
1096// --- helpers ---------------------------------------------------------------
1097
1098/// Lower parsed, located literals to key-based [`RawLit`]s (drops spans),
1099/// resolving each atom's domain through `ctx`.
1100fn raw_lits(
1101    lits: &[elenchus_parser::Located<Literal>],
1102    ctx: &DomainCtx,
1103) -> Result<Vec<RawLit>, CompileError> {
1104    lits.iter()
1105        .map(|l| {
1106            Ok(RawLit {
1107                key: ctx.key(&l.data.atom)?,
1108                negated: l.data.negated,
1109            })
1110        })
1111        .collect()
1112}
1113
1114/// The surface keyword for a list op, used as [`Origin::kind`] in the report.
1115fn list_kind(op: ListOp) -> &'static str {
1116    match op {
1117        ListOp::Exclusive => kw::EXCLUSIVE,
1118        ListOp::Forbids => kw::FORBIDS,
1119        ListOp::OneOf => kw::ONEOF,
1120        ListOp::AtLeast => kw::ATLEAST,
1121    }
1122}
1123
1124/// Stable `domain|subject|predicate|object` string for an atom key (the unit from
1125/// which clause/fact/body signatures are built). Includes the domain so atoms in
1126/// different domains never share a signature.
1127fn key_sig(k: &AtomKey) -> String {
1128    alloc::format!(
1129        "{}|{}|{}|{}",
1130        k.domain,
1131        k.subject,
1132        k.predicate,
1133        k.object.as_deref().unwrap_or("")
1134    )
1135}
1136
1137/// Canonical, order-independent signature of a clause's literals (for dedup).
1138fn clause_sig(lits: &[RawLit]) -> String {
1139    let mut parts: Vec<String> = lits
1140        .iter()
1141        .map(|l| alloc::format!("{}|{}", key_sig(&l.key), l.negated as u8))
1142        .collect();
1143    parts.sort();
1144    parts.dedup();
1145    parts.join(";")
1146}
1147
1148/// Canonical body string for a named construct, hashed for redefinition checks.
1149/// Resolves atom domains through `ctx` so the signature keys on resolved identity.
1150fn canonical_body(
1151    name: &str,
1152    body: &Body,
1153    is_rule: bool,
1154    ctx: &DomainCtx,
1155) -> Result<String, CompileError> {
1156    let mut s = String::new();
1157    let _ = write!(s, "{}|{}|", if is_rule { "RULE" } else { "PREMISE" }, name);
1158    match body {
1159        Body::List { op, atoms } => {
1160            let _ = write!(s, "LIST|{}|", list_kind(*op));
1161            let mut keys: Vec<String> = atoms
1162                .iter()
1163                .map(|a| Ok(key_sig(&ctx.key(&a.data)?)))
1164                .collect::<Result<_, CompileError>>()?;
1165            keys.sort();
1166            s.push_str(&keys.join(";"));
1167        }
1168        Body::Impl {
1169            antecedent,
1170            ante_conn,
1171            consequent,
1172            cons_conn,
1173        } => {
1174            let conn = |c: &Conn| if *c == Conn::Or { "OR" } else { "AND" };
1175            s.push_str("IMPL|ANTE|");
1176            s.push_str(conn(ante_conn));
1177            s.push('|');
1178            s.push_str(&lit_sigs(antecedent, ctx)?);
1179            s.push_str("|CONS|");
1180            s.push_str(conn(cons_conn));
1181            s.push('|');
1182            s.push_str(&lit_sigs(consequent, ctx)?);
1183        }
1184    }
1185    Ok(s)
1186}
1187
1188/// Sorted `key|negated` signature of a literal list (order-independent), used
1189/// inside [`canonical_body`] so reordering a body does not look like a redefinition.
1190fn lit_sigs(
1191    lits: &[elenchus_parser::Located<Literal>],
1192    ctx: &DomainCtx,
1193) -> Result<String, CompileError> {
1194    let mut parts: Vec<String> = lits
1195        .iter()
1196        .map(|l| {
1197            Ok(alloc::format!(
1198                "{}|{}",
1199                key_sig(&ctx.key(&l.data.atom)?),
1200                l.data.negated as u8
1201            ))
1202        })
1203        .collect::<Result<_, CompileError>>()?;
1204    parts.sort();
1205    Ok(parts.join(";"))
1206}
1207
1208#[cfg(test)]
1209mod tests {
1210    use super::*;
1211
1212    /// Compile a single inline source under a default `DOMAIN t`, so test
1213    /// programs need not repeat the declaration. Atoms land in domain `t`.
1214    fn cs(src: &str) -> Result<Compiled, CompileError> {
1215        compile_source("<t>", &alloc::format!("DOMAIN t\n{src}"))
1216    }
1217
1218    /// An atom key in the default test domain `t`.
1219    fn key(subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
1220        key_in("t", subject, predicate, object)
1221    }
1222
1223    /// An atom key in an explicit domain.
1224    fn key_in(domain: &str, subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
1225        AtomKey {
1226            domain: domain.to_string(),
1227            subject: subject.to_string(),
1228            predicate: predicate.to_string(),
1229            object: object.map(|o| o.to_string()),
1230        }
1231    }
1232
1233    fn id(c: &Compiled, k: &AtomKey) -> AtomId {
1234        c.atoms.iter().position(|a| a == k).unwrap() as AtomId
1235    }
1236
1237    #[test]
1238    fn exclusive_unfolds_pairwise() {
1239        let src = r#"
1240        PREMISE e:
1241            EXCLUSIVE
1242                x a
1243                x b
1244                x c
1245        "#;
1246        let c = cs(src).unwrap();
1247        // C(3,2) = 3 clauses, each of 2 positive literals.
1248        assert_eq!(c.clauses.len(), 3);
1249        for cl in &c.clauses {
1250            assert_eq!(cl.lits.len(), 2);
1251            assert!(cl.lits.iter().all(|l| !l.negated));
1252        }
1253    }
1254
1255    #[test]
1256    fn implication_negates_consequent() {
1257        // WHEN x a THEN x b  ==  Impossible([x a, NOT x b])
1258        let src = r#"
1259        PREMISE r:
1260            WHEN x a
1261            THEN x b
1262        "#;
1263        let c = cs(src).unwrap();
1264        assert_eq!(c.clauses.len(), 1);
1265        let cl = &c.clauses[0];
1266        assert_eq!(cl.lits.len(), 2);
1267        let a = id(&c, &key("x", "a", None));
1268        let b = id(&c, &key("x", "b", None));
1269        assert!(cl.lits.contains(&Lit {
1270            atom: a,
1271            negated: false
1272        }));
1273        assert!(cl.lits.contains(&Lit {
1274            atom: b,
1275            negated: true
1276        }));
1277    }
1278
1279    #[test]
1280    fn negated_consequent_flips_to_positive() {
1281        // THEN NOT x b  →  NOT(NOT x b) = x b positive inside Impossible
1282        let src = r#"
1283        PREMISE r:
1284            WHEN x a
1285            THEN NOT x b
1286        "#;
1287        let c = cs(src).unwrap();
1288        let b = id(&c, &key("x", "b", None));
1289        assert!(c.clauses[0].lits.contains(&Lit {
1290            atom: b,
1291            negated: false
1292        }));
1293    }
1294
1295    #[test]
1296    fn consequent_or_is_one_clause_with_all_negated() {
1297        // WHEN x p THEN x a OR x b  ==  Impossible([x p, NOT x a, NOT x b])
1298        let src = r#"
1299        PREMISE r:
1300            WHEN x p
1301            THEN x a
1302            OR x b
1303        "#;
1304        let c = cs(src).unwrap();
1305        assert_eq!(c.clauses.len(), 1);
1306        let cl = &c.clauses[0];
1307        assert_eq!(cl.lits.len(), 3);
1308        let p = id(&c, &key("x", "p", None));
1309        let a = id(&c, &key("x", "a", None));
1310        let b = id(&c, &key("x", "b", None));
1311        assert!(cl.lits.contains(&Lit {
1312            atom: p,
1313            negated: false
1314        }));
1315        assert!(cl.lits.contains(&Lit {
1316            atom: a,
1317            negated: true
1318        }));
1319        assert!(cl.lits.contains(&Lit {
1320            atom: b,
1321            negated: true
1322        }));
1323    }
1324
1325    #[test]
1326    fn antecedent_or_is_one_clause_per_disjunct() {
1327        // WHEN x a OR x b THEN x c
1328        //   == Impossible([x a, NOT x c]) ∧ Impossible([x b, NOT x c])
1329        let src = r#"
1330        PREMISE r:
1331            WHEN x a
1332            OR x b
1333            THEN x c
1334        "#;
1335        let c = cs(src).unwrap();
1336        assert_eq!(c.clauses.len(), 2);
1337        let a = id(&c, &key("x", "a", None));
1338        let b = id(&c, &key("x", "b", None));
1339        let cc = id(&c, &key("x", "c", None));
1340        // every clause has exactly two lits and carries NOT c
1341        for cl in &c.clauses {
1342            assert_eq!(cl.lits.len(), 2);
1343            assert!(cl.lits.contains(&Lit {
1344                atom: cc,
1345                negated: true
1346            }));
1347        }
1348        let has = |atom| {
1349            c.clauses.iter().any(|cl| {
1350                cl.lits.contains(&Lit {
1351                    atom,
1352                    negated: false,
1353                })
1354            })
1355        };
1356        assert!(has(a) && has(b));
1357    }
1358
1359    #[test]
1360    fn antecedent_or_with_consequent_or_distributes() {
1361        // (a ∨ b) → (c ∨ d): Impossible([a,¬c,¬d]) ∧ Impossible([b,¬c,¬d])
1362        let src = r#"
1363        PREMISE r:
1364            WHEN x a
1365            OR x b
1366            THEN x c
1367            OR x d
1368        "#;
1369        let c = cs(src).unwrap();
1370        assert_eq!(c.clauses.len(), 2);
1371        for cl in &c.clauses {
1372            assert_eq!(cl.lits.len(), 3);
1373        }
1374    }
1375
1376    #[test]
1377    fn rule_with_or_antecedent_splits_into_two_rules() {
1378        // (a ∨ b) → c derives c whenever either fires: two single-antecedent rules.
1379        let src = r#"
1380        RULE r:
1381            WHEN x a
1382            OR x b
1383            THEN x c
1384        "#;
1385        let c = cs(src).unwrap();
1386        assert_eq!(c.rules.len(), 2);
1387        assert!(
1388            c.rules
1389                .iter()
1390                .all(|r| r.antecedent.len() == 1 && r.consequent.len() == 1)
1391        );
1392    }
1393
1394    #[test]
1395    fn rule_with_or_consequent_is_rejected() {
1396        // A rule cannot derive a disjunction — must be a PREMISE.
1397        let src = r#"
1398        RULE r:
1399            WHEN x a
1400            THEN x b
1401            OR x c
1402        "#;
1403        let err = cs(src).unwrap_err();
1404        assert!(matches!(
1405            err,
1406            CompileError::RuleDisjunctiveConsequent { .. }
1407        ));
1408    }
1409
1410    #[test]
1411    fn oneof_is_pairwise_plus_at_least_one() {
1412        let src = r#"
1413        PREMISE o:
1414            ONEOF
1415                x a
1416                x b
1417        "#;
1418        let c = cs(src).unwrap();
1419        // pairwise C(2,2)=1 + 1 at-least-one = 2 clauses
1420        assert_eq!(c.clauses.len(), 2);
1421        // the at-least-one clause is the all-negated one
1422        assert!(c.clauses.iter().any(|cl| cl.lits.iter().all(|l| l.negated)));
1423    }
1424
1425    #[test]
1426    fn atleast_is_one_negated_clause() {
1427        let src = r#"
1428        PREMISE a:
1429            ATLEAST
1430                x a
1431                x b
1432                x c
1433        "#;
1434        let c = cs(src).unwrap();
1435        assert_eq!(c.clauses.len(), 1);
1436        assert_eq!(c.clauses[0].lits.len(), 3);
1437        assert!(c.clauses[0].lits.iter().all(|l| l.negated));
1438    }
1439
1440    #[test]
1441    fn rules_are_separate_from_clauses() {
1442        let src = r#"
1443        RULE needs:
1444            WHEN x a
1445            THEN x b
1446        "#;
1447        let c = cs(src).unwrap();
1448        assert_eq!(c.clauses.len(), 0);
1449        assert_eq!(c.rules.len(), 1);
1450        assert_eq!(c.rules[0].antecedent.len(), 1);
1451        assert_eq!(c.rules[0].consequent.len(), 1);
1452    }
1453
1454    #[test]
1455    fn atoms_are_canonically_sorted() {
1456        let src = r#"
1457        FACT z z
1458        FACT a a
1459        FACT m m
1460        "#;
1461        let c = cs(src).unwrap();
1462        let mut sorted = c.atoms.clone();
1463        sorted.sort();
1464        assert_eq!(c.atoms, sorted);
1465    }
1466
1467    #[test]
1468    fn duplicate_premise_is_idempotent() {
1469        let src = r#"
1470        PREMISE e:
1471            EXCLUSIVE
1472                x a
1473                x b
1474        PREMISE e:
1475            EXCLUSIVE
1476                x a
1477                x b
1478        "#;
1479        let c = cs(src).unwrap();
1480        assert_eq!(c.clauses.len(), 1);
1481    }
1482
1483    #[test]
1484    fn redefinition_with_different_body_errors() {
1485        let src = r#"
1486        PREMISE e:
1487            EXCLUSIVE
1488                x a
1489                x b
1490        PREMISE e:
1491            EXCLUSIVE
1492                x a
1493                x c
1494        "#;
1495        let err = cs(src).unwrap_err();
1496        assert_eq!(
1497            err,
1498            CompileError::PremiseRedefinition {
1499                name: "e".to_string()
1500            }
1501        );
1502    }
1503
1504    #[test]
1505    fn duplicate_fact_is_idempotent() {
1506        let c = cs("FACT x a\nFACT x a\n").unwrap();
1507        assert_eq!(c.facts.len(), 1);
1508    }
1509
1510    #[test]
1511    fn conflicting_facts_are_both_kept() {
1512        // FACT X + NOT X is a CONFLICT for the solver, not a compile error.
1513        let c = cs("FACT x a\nNOT x a\n").unwrap();
1514        assert_eq!(c.facts.len(), 2);
1515    }
1516
1517    #[test]
1518    fn import_is_recorded_pending() {
1519        let c = cs("IMPORT \"physics.vrf\"\nFACT x a\n").unwrap();
1520        assert_eq!(c.pending_imports, vec!["physics.vrf".to_string()]);
1521    }
1522
1523    #[test]
1524    fn qualified_fact_lands_in_the_imported_domain() {
1525        // The library's premise is about `physics.Engine_X has fuel`; the main file
1526        // asserts a fact qualified INTO that domain, so the two share one atom id.
1527        let mut r = MemoryResolver::new();
1528        r.add(
1529            "lib.vrf",
1530            r#"
1531        DOMAIN physics
1532        PREMISE needs_fuel:
1533            WHEN Engine_X has engine
1534            THEN Engine_X has fuel
1535        "#,
1536        );
1537        r.add(
1538            "main.vrf",
1539            r#"
1540        DOMAIN main
1541        IMPORT "lib.vrf"
1542        FACT physics.Engine_X has engine
1543        FACT physics.Engine_X has fuel
1544        "#,
1545        );
1546        let c = compile("main.vrf", &r).unwrap();
1547        assert!(c.pending_imports.is_empty());
1548        assert_eq!(c.clauses.len(), 1); // the imported premise
1549        assert_eq!(c.facts.len(), 2);
1550
1551        // `physics.Engine_X has fuel` from the FACT and the imported premise share an id.
1552        let fuel = key_in("physics", "Engine_X", "has", Some("fuel"));
1553        let fuel_id = id(&c, &fuel);
1554        assert!(c.facts.iter().any(|f| f.atom == fuel_id));
1555        assert!(c.clauses[0].lits.iter().any(|l| l.atom == fuel_id));
1556    }
1557
1558    #[test]
1559    fn same_triple_in_different_domains_does_not_unify() {
1560        // Without a domain prefix the fact lands in `main`, NOT `physics`, so it is
1561        // a distinct atom from the library's `physics.Engine_X has fuel`.
1562        let mut r = MemoryResolver::new();
1563        r.add("lib.vrf", "DOMAIN physics\nFACT Engine_X has fuel\n");
1564        r.add(
1565            "main.vrf",
1566            "DOMAIN main\nIMPORT \"lib.vrf\"\nFACT Engine_X has fuel\n",
1567        );
1568        let c = compile("main.vrf", &r).unwrap();
1569        // Two distinct atoms: physics.Engine_X has fuel and main.Engine_X has fuel.
1570        assert!(c.atoms.iter().any(|a| a.domain == "physics"));
1571        assert!(c.atoms.iter().any(|a| a.domain == "main"));
1572        assert_eq!(
1573            c.atoms
1574                .iter()
1575                .filter(|a| a.subject == "Engine_X" && a.predicate == "has")
1576                .count(),
1577            2
1578        );
1579    }
1580
1581    #[test]
1582    fn import_alias_binds_a_local_domain_name() {
1583        // `AS phys` lets the consumer reference the imported domain by a local name.
1584        let mut r = MemoryResolver::new();
1585        r.add("lib.vrf", "DOMAIN physics\nFACT Motor over_200\n");
1586        r.add(
1587            "main.vrf",
1588            "DOMAIN main\nIMPORT \"lib.vrf\" AS phys\nFACT phys.Motor over_100\n",
1589        );
1590        let c = compile("main.vrf", &r).unwrap();
1591        // Both facts live in the physics domain (one via its own name, one via alias).
1592        assert_eq!(c.atoms.iter().filter(|a| a.domain == "physics").count(), 2);
1593    }
1594
1595    #[test]
1596    fn unknown_domain_reference_errors() {
1597        // Referencing a domain that is neither this file's nor imported here fails.
1598        let err = cs("FACT ghost.x a\n").unwrap_err();
1599        assert!(matches!(err, CompileError::UnknownDomain { .. }));
1600    }
1601
1602    #[test]
1603    fn imports_are_not_transitive_for_naming() {
1604        // main imports physics; physics imports math. main may NOT name math.
1605        let mut r = MemoryResolver::new();
1606        r.add("math.vrf", "DOMAIN math\nFACT foo bar\n");
1607        r.add(
1608            "physics.vrf",
1609            "DOMAIN physics\nIMPORT \"math.vrf\"\nFACT Motor over_100\n",
1610        );
1611        r.add(
1612            "main.vrf",
1613            "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT math.foo bar\n",
1614        );
1615        let err = compile("main.vrf", &r).unwrap_err();
1616        assert!(matches!(err, CompileError::UnknownDomain { .. }));
1617    }
1618
1619    #[test]
1620    fn transitive_dependency_clauses_still_load() {
1621        // Even though main can't *name* math, math's clauses still participate.
1622        let mut r = MemoryResolver::new();
1623        r.add(
1624            "math.vrf",
1625            "DOMAIN math\nPREMISE e:\n    EXCLUSIVE\n        x a\n        x b\n",
1626        );
1627        r.add("physics.vrf", "DOMAIN physics\nIMPORT \"math.vrf\"\n");
1628        r.add("main.vrf", "DOMAIN main\nIMPORT \"physics.vrf\"\n");
1629        let c = compile("main.vrf", &r).unwrap();
1630        assert_eq!(c.clauses.len(), 1); // math's clause loaded transitively
1631        assert!(c.clauses.iter().all(|cl| cl.origin.source == "math.vrf"));
1632    }
1633
1634    #[test]
1635    fn missing_domain_errors() {
1636        let err = compile_source("nodomain.vrf", "FACT x a\n").unwrap_err();
1637        assert!(matches!(err, CompileError::MissingDomain { .. }));
1638    }
1639
1640    #[test]
1641    fn duplicate_domain_errors() {
1642        let err = compile_source("dup.vrf", "DOMAIN a\nDOMAIN b\nFACT x a\n").unwrap_err();
1643        assert!(matches!(err, CompileError::DuplicateDomain { .. }));
1644    }
1645
1646    #[test]
1647    fn alias_clash_when_one_local_name_binds_two_domains() {
1648        // The same local alias `x` bound to two genuinely different domains is a
1649        // clash: disambiguate with distinct aliases.
1650        let mut r = MemoryResolver::new();
1651        r.add("a.vrf", "DOMAIN physics\nFACT Motor over_100\n");
1652        r.add("b.vrf", "DOMAIN chemistry\nFACT atom reacts\n");
1653        r.add(
1654            "main.vrf",
1655            "DOMAIN main\nIMPORT \"a.vrf\" AS x\nIMPORT \"b.vrf\" AS x\n",
1656        );
1657        let err = compile("main.vrf", &r).unwrap_err();
1658        assert!(matches!(err, CompileError::DomainAliasClash { .. }));
1659    }
1660
1661    #[test]
1662    fn two_files_with_the_same_domain_name_merge() {
1663        // Nominal domains: two files both declaring DOMAIN physics share it (the
1664        // value of importing a premise library is exactly this unification).
1665        let mut r = MemoryResolver::new();
1666        r.add("a.vrf", "DOMAIN physics\nFACT Motor over_100\n");
1667        r.add(
1668            "main.vrf",
1669            "DOMAIN physics\nIMPORT \"a.vrf\"\nFACT Motor over_200\n",
1670        );
1671        let c = compile("main.vrf", &r).unwrap();
1672        // Both motors live in the single shared `physics` domain.
1673        assert!(c.atoms.iter().all(|a| a.domain == "physics"));
1674        assert_eq!(c.atoms.len(), 2);
1675    }
1676
1677    #[test]
1678    fn diamond_import_is_deduped() {
1679        // main → a, c ; a → base ; c → base. base merged once.
1680        let mut r = MemoryResolver::new();
1681        r.add(
1682            "base.vrf",
1683            r#"
1684        DOMAIN base
1685        PREMISE b:
1686            EXCLUSIVE
1687                x a
1688                x b
1689        "#,
1690        );
1691        r.add("a.vrf", "DOMAIN a\nIMPORT \"base.vrf\"\n");
1692        r.add("c.vrf", "DOMAIN c\nIMPORT \"base.vrf\"\n");
1693        r.add(
1694            "main.vrf",
1695            "DOMAIN main\nIMPORT \"a.vrf\"\nIMPORT \"c.vrf\"\n",
1696        );
1697        let c = compile("main.vrf", &r).unwrap();
1698        assert_eq!(c.clauses.len(), 1); // base's single clause, not two
1699    }
1700
1701    #[test]
1702    fn circular_import_errors() {
1703        let mut r = MemoryResolver::new();
1704        r.add("a.vrf", "DOMAIN a\nIMPORT \"b.vrf\"\n");
1705        r.add("b.vrf", "DOMAIN b\nIMPORT \"a.vrf\"\n");
1706        let err = compile("a.vrf", &r).unwrap_err();
1707        assert!(matches!(err, CompileError::CircularImport(_)));
1708    }
1709
1710    #[test]
1711    fn three_node_cycle_errors() {
1712        // a → b → c → a. The back-edge to the on-path ancestor is detected.
1713        let mut r = MemoryResolver::new();
1714        r.add("a.vrf", "DOMAIN a\nIMPORT \"b.vrf\"\n");
1715        r.add("b.vrf", "DOMAIN b\nIMPORT \"c.vrf\"\n");
1716        r.add("c.vrf", "DOMAIN c\nIMPORT \"a.vrf\"\n");
1717        let err = compile("a.vrf", &r).unwrap_err();
1718        assert!(matches!(err, CompileError::CircularImport(_)));
1719    }
1720
1721    #[test]
1722    fn shared_grandchild_diamond_loads_once() {
1723        // The user's case: a imports B and C; C ALSO imports B. B must be compiled
1724        // exactly once (its single clause is not duplicated by the two paths to it).
1725        let mut r = MemoryResolver::new();
1726        r.add(
1727            "b.vrf",
1728            "DOMAIN b\nPREMISE e:\n    EXCLUSIVE\n        x a\n        x b\n",
1729        );
1730        r.add("c.vrf", "DOMAIN c\nIMPORT \"b.vrf\"\n");
1731        r.add("a.vrf", "DOMAIN a\nIMPORT \"b.vrf\"\nIMPORT \"c.vrf\"\n");
1732        let c = compile("a.vrf", &r).unwrap();
1733        assert_eq!(
1734            c.clauses.len(),
1735            1,
1736            "b.vrf's clause must appear exactly once"
1737        );
1738    }
1739
1740    #[test]
1741    fn exponential_fan_out_is_memoized_not_blown_up() {
1742        // f_k imports f_{k-1} TWICE. Without content-hash memoization the visit
1743        // count is 2^k (2^40 ≈ a trillion); with it, the work is linear, so this
1744        // finishes instantly. A guard against any combinatorial blow-up / DoS.
1745        let mut r = MemoryResolver::new();
1746        r.add("f0.vrf", "DOMAIN d0\nFACT x a\n");
1747        let n = 40;
1748        for k in 1..=n {
1749            r.add(
1750                &alloc::format!("f{k}.vrf"),
1751                &alloc::format!(
1752                    "DOMAIN d{k}\nIMPORT \"f{}.vrf\"\nIMPORT \"f{}.vrf\"\n",
1753                    k - 1,
1754                    k - 1
1755                ),
1756            );
1757        }
1758        let c = compile(&alloc::format!("f{n}.vrf"), &r).unwrap();
1759        assert_eq!(c.facts.len(), 1); // the single fact from f0, reached once
1760    }
1761
1762    #[test]
1763    fn very_deep_linear_chain_does_not_overflow() {
1764        // A long non-cyclic chain. Resolution is iterative (explicit work stack),
1765        // so a depth that would overflow a recursive loader compiles cleanly.
1766        let mut r = MemoryResolver::new();
1767        r.add("f0.vrf", "DOMAIN d0\nFACT x a\n");
1768        let n = 10_000;
1769        for k in 1..=n {
1770            r.add(
1771                &alloc::format!("f{k}.vrf"),
1772                &alloc::format!("DOMAIN d{k}\nIMPORT \"f{}.vrf\"\n", k - 1),
1773            );
1774        }
1775        let c = compile(&alloc::format!("f{n}.vrf"), &r).unwrap();
1776        assert_eq!(c.facts.len(), 1);
1777    }
1778
1779    #[test]
1780    fn missing_import_errors() {
1781        let mut r = MemoryResolver::new();
1782        r.add("main.vrf", "DOMAIN main\nIMPORT \"ghost.vrf\"\n");
1783        let err = compile("main.vrf", &r).unwrap_err();
1784        assert!(matches!(err, CompileError::ImportNotFound(_)));
1785    }
1786
1787    #[test]
1788    fn unused_import_is_flagged() {
1789        // main imports physics but never writes a `physics.` atom → unused.
1790        let mut r = MemoryResolver::new();
1791        r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
1792        r.add(
1793            "main.vrf",
1794            "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\n",
1795        );
1796        let c = compile("main.vrf", &r).unwrap();
1797        assert_eq!(c.unused_imports.len(), 1);
1798        assert_eq!(c.unused_imports[0].domain, "physics");
1799        assert_eq!(c.unused_imports[0].file, "main.vrf");
1800        assert_eq!(c.unused_imports[0].alias, None);
1801    }
1802
1803    #[test]
1804    fn referenced_import_is_not_unused() {
1805        // The same import, but now a `physics.` atom uses it → not flagged.
1806        let mut r = MemoryResolver::new();
1807        r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
1808        r.add(
1809            "main.vrf",
1810            "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT physics.Motor over_200\n",
1811        );
1812        let c = compile("main.vrf", &r).unwrap();
1813        assert!(c.unused_imports.is_empty(), "{:?}", c.unused_imports);
1814    }
1815
1816    #[test]
1817    fn unused_import_records_its_alias() {
1818        let mut r = MemoryResolver::new();
1819        r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
1820        r.add(
1821            "main.vrf",
1822            "DOMAIN main\nIMPORT \"physics.vrf\" AS phys\nFACT x a\n",
1823        );
1824        let c = compile("main.vrf", &r).unwrap();
1825        assert_eq!(c.unused_imports.len(), 1);
1826        assert_eq!(c.unused_imports[0].alias.as_deref(), Some("phys"));
1827    }
1828
1829    #[test]
1830    fn import_referenced_only_inside_a_premise_is_used() {
1831        // The reference can be anywhere — here inside a premise body, not a fact.
1832        let mut r = MemoryResolver::new();
1833        r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
1834        r.add(
1835            "main.vrf",
1836            "DOMAIN main\nIMPORT \"physics.vrf\"\nPREMISE p:\n    WHEN physics.Motor over_100\n    THEN x ok\n",
1837        );
1838        let c = compile("main.vrf", &r).unwrap();
1839        assert!(c.unused_imports.is_empty(), "{:?}", c.unused_imports);
1840    }
1841
1842    #[test]
1843    fn same_premise_name_across_files_coexists() {
1844        // Two files may legitimately reuse a premise NAME with different bodies.
1845        // Names are per-source labels — both premises apply, qualified by source.
1846        // NOT a redefinition error. (Atoms stay apart too: different domains.)
1847        let mut r = MemoryResolver::new();
1848        r.add(
1849            "physics.vrf",
1850            r#"
1851        DOMAIN physics
1852        PREMISE safety:
1853            EXCLUSIVE
1854                x a
1855                x b
1856        "#,
1857        );
1858        r.add(
1859            "main.vrf",
1860            r#"
1861        DOMAIN main
1862        IMPORT "physics.vrf"
1863        PREMISE safety:
1864            EXCLUSIVE
1865                x a
1866                x c
1867        "#,
1868        );
1869        let c = compile("main.vrf", &r).unwrap();
1870        assert_eq!(c.clauses.len(), 2); // a-b from physics, a-c from main
1871        assert!(c.clauses.iter().any(|cl| cl.origin.source == "physics.vrf"));
1872        assert!(c.clauses.iter().any(|cl| cl.origin.source == "main.vrf"));
1873    }
1874
1875    #[test]
1876    fn redefinition_within_one_source_still_errors() {
1877        // But reusing a name with a different body *inside one source* is a mistake.
1878        let src = r#"
1879        DOMAIN m
1880        PREMISE e:
1881            EXCLUSIVE
1882                x a
1883                x b
1884        PREMISE e:
1885            EXCLUSIVE
1886                x a
1887                x c
1888        "#;
1889        let err = compile_source("main.vrf", src).unwrap_err();
1890        assert_eq!(
1891            err,
1892            CompileError::PremiseRedefinition {
1893                name: "e".to_string()
1894            }
1895        );
1896    }
1897
1898    #[test]
1899    fn import_demo_examples_resolve() {
1900        let mut r = MemoryResolver::new();
1901        r.add(
1902            "physics.vrf",
1903            include_str!("../../../docs/examples/physics.vrf"),
1904        );
1905        r.add(
1906            "import-demo.vrf",
1907            include_str!("../../../docs/examples/import-demo.vrf"),
1908        );
1909        let c = compile("import-demo.vrf", &r).unwrap();
1910        assert!(c.pending_imports.is_empty());
1911        // physics.vrf: one_path (EXCLUSIVE, 1 clause) + speed_order (impl, 1 clause)
1912        assert_eq!(c.clauses.len(), 2);
1913        // The qualified facts (`physics.Motor …`) share ids with the imported premise.
1914        let over_100 = id(&c, &key_in("physics", "Motor", "over_100", None));
1915        assert!(c.facts.iter().any(|f| f.atom == over_100));
1916        assert!(
1917            c.clauses
1918                .iter()
1919                .any(|cl| cl.lits.iter().any(|l| l.atom == over_100))
1920        );
1921    }
1922
1923    #[test]
1924    fn creature_example_compiles() {
1925        let src = include_str!("../../../docs/examples/creature.vrf");
1926        let c = compile_source("creature.vrf", src).unwrap();
1927        assert_eq!(c.facts.len(), 2); // flying, warm_blood
1928        assert_eq!(c.rules.len(), 1); // needs_oxygen
1929        assert_eq!(c.checks.len(), 1);
1930        // fly_xor_swim (1) + wings_need_bone (THEN wing AND bone → 2) + no_dual_temp (1) = 4
1931        assert_eq!(c.clauses.len(), 4);
1932        assert_eq!(c.atoms.len(), 7);
1933    }
1934
1935    #[test]
1936    fn forbids_unfolds_pairwise() {
1937        let src = r#"
1938        PREMISE f:
1939            FORBIDS
1940                x a
1941                x b
1942                x c
1943        "#;
1944        let c = cs(src).unwrap();
1945        assert_eq!(c.clauses.len(), 3); // C(3,2), like EXCLUSIVE
1946        assert!(
1947            c.clauses
1948                .iter()
1949                .all(|cl| cl.lits.len() == 2 && cl.lits.iter().all(|l| !l.negated))
1950        );
1951    }
1952
1953    #[test]
1954    fn rule_with_multiple_consequents() {
1955        let src = r#"
1956        RULE r:
1957            WHEN x a
1958            THEN x b
1959            AND  x c
1960        "#;
1961        let c = cs(src).unwrap();
1962        assert_eq!(c.rules.len(), 1);
1963        assert_eq!(c.rules[0].consequent.len(), 2);
1964    }
1965
1966    #[test]
1967    fn negated_antecedent_literal_keeps_polarity() {
1968        // WHEN NOT x a THEN x b  ==  Impossible([NOT x a, NOT x b])
1969        let src = r#"
1970        PREMISE a:
1971            WHEN NOT x a
1972            THEN x b
1973        "#;
1974        let c = cs(src).unwrap();
1975        let xa = id(&c, &key("x", "a", None));
1976        assert!(c.clauses[0].lits.contains(&Lit {
1977            atom: xa,
1978            negated: true
1979        }));
1980    }
1981
1982    #[test]
1983    fn rule_keeps_consequent_negation() {
1984        let src = r#"
1985        RULE r:
1986            WHEN x a
1987            THEN NOT x b
1988        "#;
1989        let c = cs(src).unwrap();
1990        assert!(c.rules[0].consequent[0].negated);
1991    }
1992
1993    #[test]
1994    fn compilation_is_deterministic() {
1995        let src = r#"
1996        PREMISE e:
1997            EXCLUSIVE
1998                z z
1999                a a
2000                m m
2001        FACT q q
2002        "#;
2003        assert_eq!(cs(src).unwrap(), cs(src).unwrap());
2004    }
2005
2006    #[test]
2007    fn empty_program_compiles_to_empty_ir() {
2008        let c = cs("// nothing here\n").unwrap();
2009        assert!(c.atoms.is_empty() && c.clauses.is_empty() && c.facts.is_empty());
2010    }
2011
2012    #[test]
2013    fn same_clause_from_two_named_premises_is_deduped() {
2014        // Different names, identical logical content → one clause, no redefinition.
2015        let src = r#"
2016        PREMISE e1:
2017            EXCLUSIVE
2018                x a
2019                x b
2020        PREMISE e2:
2021            EXCLUSIVE
2022                x a
2023                x b
2024        "#;
2025        let c = cs(src).unwrap();
2026        assert_eq!(c.clauses.len(), 1);
2027    }
2028
2029    #[test]
2030    fn object_distinguishes_atom_identity() {
2031        // `x p a` and `x p b` differ only by object → two distinct atoms.
2032        let c = cs("FACT x p a\nFACT x p b\n").unwrap();
2033        assert_eq!(c.atoms.len(), 2);
2034    }
2035}