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) belongs to the future `elenchus-solver` crate.
17//!
18//! `IMPORT` resolution (a source-agnostic `Resolver`, flat-merge into the shared
19//! atom universe) lands next; for now imports are recorded as pending.
20#![no_std]
21// Every public item is documented; CI (`clippy -D warnings`) keeps it that way.
22#![warn(missing_docs)]
23
24extern crate alloc;
25
26#[cfg(feature = "std")]
27extern crate std;
28
29use alloc::collections::{BTreeMap, BTreeSet};
30use alloc::string::{String, ToString};
31use alloc::vec;
32use alloc::vec::Vec;
33use core::fmt::Write as _;
34
35use elenchus_parser::{Atom, Body, Conn, ListOp, Literal, Statement};
36use sha2::{Digest, Sha256};
37use thiserror::Error;
38
39// --- content-addressing (mirrors vsm-guard::hashing) -----------------------
40
41/// SHA-256 content addressing. Used only for dedup / redefinition / provenance,
42/// never for namespacing atoms.
43pub fn hash_hex(data: &[u8]) -> String {
44    let mut hasher = Sha256::new();
45    hasher.update(data);
46    let out = hasher.finalize();
47    let mut s = String::with_capacity(out.len() * 2);
48    for b in out {
49        let _ = write!(s, "{:02x}", b);
50    }
51    s
52}
53
54// --- IR types --------------------------------------------------------------
55
56/// Dense atom identifier (also the SAT variable number).
57pub type AtomId = u32;
58
59/// The identity of an atom: the triple `(subject, predicate, object?)`, owned so
60/// it survives across merged sources. Ordering is lexicographic → canonical.
61#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
62pub struct AtomKey {
63    /// The entity the claim is about (owned copy of the parser's `subject`).
64    pub subject: String,
65    /// The relation or property asserted.
66    pub predicate: String,
67    /// Optional object; part of identity, so `has flying` ≠ `has swimming`.
68    pub object: Option<String>,
69}
70
71impl AtomKey {
72    /// Owned copy of a borrowed parser [`Atom`].
73    fn from_atom(a: &Atom) -> Self {
74        AtomKey {
75            subject: a.subject.to_string(),
76            predicate: a.predicate.to_string(),
77            object: a.object.map(|o| o.to_string()),
78        }
79    }
80}
81
82/// A literal as it appears *inside* an `Impossible` clause: an atom, optionally
83/// negated. `negated = true` means the literal is `NOT atom`.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Lit {
86    /// Interned id of the atom (also its SAT variable number).
87    pub atom: AtomId,
88    /// `true` means this literal is `NOT atom` inside the clause.
89    pub negated: bool,
90}
91
92/// A confident truth value. UNKNOWN is the *absence* of a fact, never stored.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Value {
95    /// The atom is asserted TRUE (from `FACT`).
96    True,
97    /// The atom is asserted FALSE (from `NOT`).
98    False,
99}
100
101/// Where a piece of IR came from — for readable conflict/warning pools.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Origin {
104    /// The source label this came from (file name or `"<root>"`/`"<text>"`).
105    pub source: String,
106    /// 1-based line number of the originating statement.
107    pub line: u32,
108    /// The premise/rule name, if it came from a named construct.
109    pub premise: Option<String>,
110    /// Surface kind for the report, e.g. `"FACT"`, `"EXCLUSIVE"`, `"PREMISE"`.
111    pub kind: &'static str,
112}
113
114/// A confident fact (from `FACT` / `NOT`). Conflicting facts on the same atom
115/// are preserved (both kept) — the solver reports that as a CONFLICT.
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct Fact {
118    /// The atom this fact pins down.
119    pub atom: AtomId,
120    /// The asserted truth value.
121    pub value: Value,
122    /// Where it came from (for the report).
123    pub origin: Origin,
124}
125
126/// An `Impossible` clause: the listed literals cannot all hold simultaneously.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Clause {
129    /// The literals that cannot all hold at once (an `Impossible([...])`).
130    pub lits: Vec<Lit>,
131    /// Where it came from (for the report).
132    pub origin: Origin,
133}
134
135/// A forward-chaining rule (from `RULE`): if all antecedent literals hold, derive
136/// the consequent literals.
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct Rule {
139    /// Literals that must all hold for the rule to fire.
140    pub antecedent: Vec<Lit>,
141    /// Literals derived (asserted) when the antecedent holds.
142    pub consequent: Vec<Lit>,
143    /// Where it came from (for the report).
144    pub origin: Origin,
145}
146
147/// A `CHECK` query.
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Check {
150    /// Restrict the report to this subject; `None` means check everything.
151    pub subject: Option<String>,
152    /// `true` runs the backward (all-SAT) pass to detect UNDERDETERMINED.
153    pub bidirectional: bool,
154}
155
156/// The compiled IR: the solver's input.
157#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Compiled {
159    /// Indexed by [`AtomId`]; canonically sorted.
160    pub atoms: Vec<AtomKey>,
161    /// Confident assertions from `FACT`/`NOT`.
162    pub facts: Vec<Fact>,
163    /// `Impossible` clauses (desugared premises + the built-in non-contradiction).
164    pub clauses: Vec<Clause>,
165    /// Forward-chaining rules from `RULE`.
166    pub rules: Vec<Rule>,
167    /// `CHECK` queries.
168    pub checks: Vec<Check>,
169    /// Imports seen but not yet resolved (only populated by [`compile_source`];
170    /// [`compile`] resolves them, leaving this empty).
171    pub pending_imports: Vec<String>,
172}
173
174/// Anything that can go wrong while compiling (and resolving imports).
175#[derive(Debug, Error, PartialEq, Eq)]
176pub enum CompileError {
177    /// A source failed to parse; carries the file label and the parser message.
178    #[error("parse error in {file}: {message}")]
179    Parse {
180        /// The source label that failed to parse.
181        file: String,
182        /// The parser's error message.
183        message: String,
184    },
185    /// A name was reused with a different body *within the same source*.
186    #[error("'{name}' redefined with a different body")]
187    PremiseRedefinition {
188        /// The clashing premise/rule name.
189        name: String,
190    },
191    /// An `IMPORT` target could not be loaded by the [`Resolver`].
192    #[error("import not found: {0}")]
193    ImportNotFound(String),
194    /// Imports form a cycle (a source transitively imports itself).
195    #[error("circular import: {0}")]
196    CircularImport(String),
197    /// A `RULE` used `OR` in its `THEN`: forward chaining cannot derive a
198    /// disjunction (it would not know which literal to assert). Model it as a
199    /// `PREMISE` constraint instead.
200    #[error("rule '{name}' cannot derive a disjunction (OR in THEN); use a PREMISE instead")]
201    RuleDisjunctiveConsequent {
202        /// The offending rule name.
203        name: String,
204    },
205}
206
207// --- raw (key-based) intermediate, before interning ------------------------
208// While accumulating we key everything by `AtomKey` (the owned triple) rather
209// than by `AtomId`, because ids only become stable once *all* sources are merged
210// and the atom set is sorted in `finalize`. These mirror the public IR types but
211// hold keys instead of ids.
212
213/// A literal keyed by atom identity (pre-interning counterpart of [`Lit`]).
214#[derive(Clone)]
215struct RawLit {
216    key: AtomKey,
217    negated: bool,
218}
219
220/// A fact keyed by atom identity (pre-interning counterpart of [`Fact`]).
221struct RawFact {
222    key: AtomKey,
223    value: Value,
224    origin: Origin,
225}
226
227/// A clause keyed by atom identity (pre-interning counterpart of [`Clause`]).
228struct RawClause {
229    lits: Vec<RawLit>,
230    origin: Origin,
231}
232
233/// A rule keyed by atom identity (pre-interning counterpart of [`Rule`]).
234struct RawRule {
235    antecedent: Vec<RawLit>,
236    consequent: Vec<RawLit>,
237    origin: Origin,
238}
239
240// --- compiler --------------------------------------------------------------
241
242/// Accumulates statements from one or more sources, then interns + emits the IR.
243#[derive(Default)]
244pub struct Compiler {
245    keys: BTreeSet<AtomKey>,
246    facts: Vec<RawFact>,
247    clauses: Vec<RawClause>,
248    rules: Vec<RawRule>,
249    checks: Vec<Check>,
250    pending_imports: Vec<String>,
251    /// (source, name) → content hash of its body, for redefinition detection.
252    /// Scoped per source: premise/rule names are labels, not global identifiers,
253    /// so different files (domains) may reuse a name. A clash is only an error
254    /// *within the same source*.
255    defined: BTreeMap<(String, String), String>,
256    /// dedup of identical clauses by canonical content hash.
257    clause_sigs: BTreeSet<String>,
258    /// dedup of identical facts by (key, value).
259    fact_sigs: BTreeSet<String>,
260}
261
262impl Compiler {
263    /// A fresh, empty compiler.
264    pub fn new() -> Self {
265        Self::default()
266    }
267
268    /// Parse one source and accumulate its statements. `source` is a label used
269    /// in provenance (e.g. a file name or `"<root>"`).
270    pub fn add_source(&mut self, source: &str, src: &str) -> Result<(), CompileError> {
271        let program = elenchus_parser::parse(src).map_err(|e| CompileError::Parse {
272            file: source.to_string(),
273            message: e.message,
274        })?;
275        for stmt in &program.statements {
276            self.add_statement(source, stmt)?;
277        }
278        Ok(())
279    }
280
281    /// Resolve and flat-merge `path` and its transitive imports.
282    /// `visited` holds content hashes already merged (dedup); `stack` holds the
283    /// hashes on the current path (cycle detection).
284    fn load_recursive<R: Resolver>(
285        &mut self,
286        path: &str,
287        resolver: &R,
288        visited: &mut BTreeSet<String>,
289        stack: &mut Vec<String>,
290    ) -> Result<(), CompileError> {
291        let content = resolver.load(path)?;
292        let hash = hash_hex(content.as_bytes());
293        if visited.contains(&hash) {
294            return Ok(()); // already merged — idempotent
295        }
296        if stack.contains(&hash) {
297            return Err(CompileError::CircularImport(path.to_string()));
298        }
299        stack.push(hash.clone());
300
301        let program = elenchus_parser::parse(&content).map_err(|e| CompileError::Parse {
302            file: path.to_string(),
303            message: e.message,
304        })?;
305        for stmt in &program.statements {
306            if let Statement::Import(p) = stmt {
307                let resolved = resolver.resolve(path, p.data);
308                self.load_recursive(&resolved, resolver, visited, stack)?;
309            } else {
310                self.add_statement(path, stmt)?;
311            }
312        }
313
314        stack.pop();
315        visited.insert(hash);
316        Ok(())
317    }
318
319    /// Route one statement to the right accumulator (facts, checks, named
320    /// constructs); `IMPORT` is only recorded as pending here (see [`compile`]
321    /// / [`load_recursive`] for actual resolution).
322    fn add_statement(&mut self, source: &str, stmt: &Statement) -> Result<(), CompileError> {
323        match stmt {
324            Statement::Import(path) => {
325                self.pending_imports.push(path.data.to_string());
326            }
327            Statement::Fact(a) => self.add_fact(source, a, Value::True, "FACT"),
328            Statement::Negation(a) => self.add_fact(source, a, Value::False, "NOT"),
329            Statement::Check {
330                subject,
331                bidirectional,
332            } => self.checks.push(Check {
333                subject: subject.as_ref().map(|s| s.data.to_string()),
334                bidirectional: *bidirectional,
335            }),
336            Statement::Premise { name, body } => {
337                let line = name.span.location_line();
338                self.add_named(source, name.data, line, body, false)?;
339            }
340            Statement::Rule { name, body } => {
341                let line = name.span.location_line();
342                self.add_named(source, name.data, line, body, true)?;
343            }
344        }
345        Ok(())
346    }
347
348    /// Record an atom identity in the shared universe (deduped by the `BTreeSet`).
349    fn intern(&mut self, key: &AtomKey) {
350        if !self.keys.contains(key) {
351            self.keys.insert(key.clone());
352        }
353    }
354
355    /// Accumulate a `FACT`/`NOT`; exact duplicates (same key+value+kind) are
356    /// dropped as idempotent, while a `FACT` and a `NOT` on the same atom are
357    /// both kept so the solver can report the CONFLICT.
358    fn add_fact(
359        &mut self,
360        source: &str,
361        a: &elenchus_parser::Located<Atom>,
362        value: Value,
363        kind: &'static str,
364    ) {
365        let key = AtomKey::from_atom(&a.data);
366        self.intern(&key);
367        let sig = alloc::format!(
368            "{}|{}|{}|{}",
369            key_sig(&key),
370            matches!(value, Value::True) as u8,
371            kind,
372            "" // facts dedup ignores line; identical FACT twice is idempotent
373        );
374        if !self.fact_sigs.insert(sig) {
375            return; // exact duplicate fact — idempotent
376        }
377        self.facts.push(RawFact {
378            key,
379            value,
380            origin: Origin {
381                source: source.to_string(),
382                line: a.span.location_line(),
383                premise: None,
384                kind,
385            },
386        });
387    }
388
389    /// Handle a named construct (`PREMISE` or `RULE`). `is_rule` selects derivation
390    /// vs checking. Returns an error on redefinition with a different body.
391    fn add_named(
392        &mut self,
393        source: &str,
394        name: &str,
395        line: u32,
396        body: &Body,
397        is_rule: bool,
398    ) -> Result<(), CompileError> {
399        let body_hash = hash_hex(canonical_body(name, body, is_rule).as_bytes());
400        let key = (source.to_string(), name.to_string());
401        match self.defined.get(&key) {
402            Some(prev) if *prev == body_hash => return Ok(()), // identical → idempotent
403            Some(_) => {
404                // Same name + different body *in the same source* — a real mistake.
405                return Err(CompileError::PremiseRedefinition {
406                    name: name.to_string(),
407                });
408            }
409            None => {
410                self.defined.insert(key, body_hash);
411            }
412        }
413
414        if is_rule {
415            // RULE always has an implication body (guaranteed by the grammar).
416            if let Body::Impl {
417                antecedent,
418                ante_conn,
419                consequent,
420                cons_conn,
421            } = body
422            {
423                // A rule *derives* its consequent; an `OR` consequent is not a
424                // single fact to assert, so reject it (use a PREMISE instead).
425                if *cons_conn == Conn::Or {
426                    return Err(CompileError::RuleDisjunctiveConsequent {
427                        name: name.to_string(),
428                    });
429                }
430                let (ante, cons) = (raw_lits(antecedent), raw_lits(consequent));
431                for l in ante.iter().chain(cons.iter()) {
432                    self.intern(&l.key);
433                }
434                let origin = self.origin(source, line, Some(name), "RULE");
435                match ante_conn {
436                    // a ∧ b → C : one rule firing on the whole antecedent.
437                    Conn::And => self.rules.push(RawRule {
438                        antecedent: ante,
439                        consequent: cons,
440                        origin,
441                    }),
442                    // (a ∨ b) → C == (a → C) ∧ (b → C): one rule per antecedent.
443                    Conn::Or => {
444                        for a in &ante {
445                            self.rules.push(RawRule {
446                                antecedent: vec![a.clone()],
447                                consequent: cons.clone(),
448                                origin: origin.clone(),
449                            });
450                        }
451                    }
452                }
453            }
454            return Ok(());
455        }
456
457        match body {
458            Body::List { op, atoms } => {
459                let keys: Vec<AtomKey> =
460                    atoms.iter().map(|a| AtomKey::from_atom(&a.data)).collect();
461                for k in &keys {
462                    self.intern(k);
463                }
464                let kind = list_kind(*op);
465                let origin = self.origin(source, line, Some(name), kind);
466                match op {
467                    // EXCLUSIVE / FORBIDS: "at most one" → pairwise Impossible([a_i, a_j]).
468                    ListOp::Exclusive | ListOp::Forbids => {
469                        self.emit_pairwise(&keys, &origin);
470                    }
471                    // ONEOF: pairwise (at most one) + at-least-one.
472                    ListOp::OneOf => {
473                        self.emit_pairwise(&keys, &origin);
474                        self.emit_at_least_one(&keys, &origin);
475                    }
476                    // ATLEAST: Impossible([NOT a_1, …, NOT a_n]).
477                    ListOp::AtLeast => {
478                        self.emit_at_least_one(&keys, &origin);
479                    }
480                }
481            }
482            Body::Impl {
483                antecedent,
484                ante_conn,
485                consequent,
486                cons_conn,
487            } => {
488                // Implication A → C as `Impossible(A_true ∧ ¬C)`. We group each
489                // side by its connective and emit one clause per (ante × cons)
490                // group pair — a uniform rule covering all AND/OR combinations:
491                //   AND-ante → all its literals share every clause;
492                //   OR-ante  → one clause per literal;
493                //   AND-cons → one clause per (negated) literal;
494                //   OR-cons  → all its (negated) literals share every clause.
495                let ante = raw_lits(antecedent);
496                let cons = raw_lits(consequent);
497                for l in ante.iter().chain(cons.iter()) {
498                    self.intern(&l.key);
499                }
500                let origin = self.origin(source, line, Some(name), "PREMISE");
501
502                let ante_groups: Vec<Vec<RawLit>> = match ante_conn {
503                    Conn::And => vec![ante.clone()],
504                    Conn::Or => ante.iter().map(|l| vec![l.clone()]).collect(),
505                };
506                let cons_groups: Vec<Vec<RawLit>> = match cons_conn {
507                    Conn::And => cons.iter().map(|l| vec![l.clone()]).collect(),
508                    Conn::Or => vec![cons.clone()],
509                };
510                for ag in &ante_groups {
511                    for cg in &cons_groups {
512                        let mut lits = ag.clone();
513                        for c in cg {
514                            lits.push(RawLit {
515                                key: c.key.clone(),
516                                negated: !c.negated,
517                            });
518                        }
519                        self.push_clause(lits, origin.clone());
520                    }
521                }
522            }
523        }
524        Ok(())
525    }
526
527    /// Emit "at most one TRUE" as one `Impossible([a_i, a_j])` per unordered
528    /// pair. Pairwise (not a single big clause) because `Impossible([a,b,c])`
529    /// only forbids *all three* together — it would still allow two.
530    fn emit_pairwise(&mut self, keys: &[AtomKey], origin: &Origin) {
531        for i in 0..keys.len() {
532            for j in (i + 1)..keys.len() {
533                let lits = vec![
534                    RawLit {
535                        key: keys[i].clone(),
536                        negated: false,
537                    },
538                    RawLit {
539                        key: keys[j].clone(),
540                        negated: false,
541                    },
542                ];
543                self.push_clause(lits, origin.clone());
544            }
545        }
546    }
547
548    /// Emit "at least one TRUE" as a single `Impossible([NOT a_1, …, NOT a_n])`
549    /// — it is impossible for all of them to be false at once.
550    fn emit_at_least_one(&mut self, keys: &[AtomKey], origin: &Origin) {
551        let lits = keys
552            .iter()
553            .map(|k| RawLit {
554                key: k.clone(),
555                negated: true,
556            })
557            .collect();
558        self.push_clause(lits, origin.clone());
559    }
560
561    /// Append a clause unless an identical one (by canonical [`clause_sig`]) is
562    /// already present — `P ∧ P ≡ P`, so dedup keeps the IR minimal.
563    fn push_clause(&mut self, lits: Vec<RawLit>, origin: Origin) {
564        let sig = clause_sig(&lits);
565        if self.clause_sigs.insert(sig) {
566            self.clauses.push(RawClause { lits, origin });
567        }
568        // else: identical clause already present — idempotent.
569    }
570
571    /// Build an [`Origin`] for provenance from the current source/line/name.
572    fn origin(&self, source: &str, line: u32, premise: Option<&str>, kind: &'static str) -> Origin {
573        Origin {
574            source: source.to_string(),
575            line,
576            premise: premise.map(|s| s.to_string()),
577            kind,
578        }
579    }
580
581    /// Intern all atoms (canonical sort), then lower the raw IR to ids.
582    pub fn finalize(self) -> Compiled {
583        let atoms: Vec<AtomKey> = self.keys.into_iter().collect(); // BTreeSet → sorted
584        let mut id_of: BTreeMap<AtomKey, AtomId> = BTreeMap::new();
585        for (i, k) in atoms.iter().enumerate() {
586            id_of.insert(k.clone(), i as AtomId);
587        }
588        let lower = |l: &RawLit| Lit {
589            atom: id_of[&l.key],
590            negated: l.negated,
591        };
592
593        let facts = self
594            .facts
595            .into_iter()
596            .map(|f| Fact {
597                atom: id_of[&f.key],
598                value: f.value,
599                origin: f.origin,
600            })
601            .collect();
602        let clauses = self
603            .clauses
604            .into_iter()
605            .map(|c| Clause {
606                lits: c.lits.iter().map(lower).collect(),
607                origin: c.origin,
608            })
609            .collect();
610        let rules = self
611            .rules
612            .into_iter()
613            .map(|r| Rule {
614                antecedent: r.antecedent.iter().map(lower).collect(),
615                consequent: r.consequent.iter().map(lower).collect(),
616                origin: r.origin,
617            })
618            .collect();
619
620        Compiled {
621            atoms,
622            facts,
623            clauses,
624            rules,
625            checks: self.checks,
626            pending_imports: self.pending_imports,
627        }
628    }
629}
630
631/// Convenience: compile a single source into the IR. `IMPORT`s are recorded as
632/// pending, not resolved (use [`compile`] with a [`Resolver`] to resolve them).
633pub fn compile_source(source: &str, src: &str) -> Result<Compiled, CompileError> {
634    let mut c = Compiler::new();
635    c.add_source(source, src)?;
636    Ok(c.finalize())
637}
638
639// --- import resolution (source-agnostic) -----------------------------------
640
641/// Resolves `IMPORT "path"` to source text. The engine is source-agnostic: it
642/// consumes strings, so a file is merely one backing store. Mirrors
643/// vsm-grammar's `SourceResolver`.
644pub trait Resolver {
645    /// Load the raw source text for a resolved path.
646    fn load(&self, path: &str) -> Result<String, CompileError>;
647
648    /// Normalize `relative` against the importing source `base`.
649    /// Default: paths are absolute names, returned unchanged.
650    fn resolve(&self, _base: &str, relative: &str) -> String {
651        relative.to_string()
652    }
653}
654
655/// An in-memory resolver: serves sources from a name → content map. Pure
656/// `no_std`. Mirrors vsm-grammar's `MemoryResolver`.
657#[derive(Default)]
658pub struct MemoryResolver {
659    sources: BTreeMap<String, String>,
660}
661
662impl MemoryResolver {
663    /// An empty resolver with no sources.
664    pub fn new() -> Self {
665        Self::default()
666    }
667
668    /// Register `content` under `path`; returns `&mut self` for chaining.
669    pub fn add(&mut self, path: &str, content: &str) -> &mut Self {
670        self.sources.insert(path.to_string(), content.to_string());
671        self
672    }
673}
674
675impl Resolver for MemoryResolver {
676    fn load(&self, path: &str) -> Result<String, CompileError> {
677        self.sources
678            .get(path)
679            .cloned()
680            .ok_or_else(|| CompileError::ImportNotFound(path.to_string()))
681    }
682}
683
684/// A filesystem-backed resolver. Mirrors vsm-grammar's `FileResolver`:
685/// relative imports resolve against the importing file's directory, with manual
686/// `..` normalization (no canonicalization, to keep a virtual layout).
687#[cfg(feature = "std")]
688pub struct FileResolver;
689
690#[cfg(feature = "std")]
691impl Resolver for FileResolver {
692    fn load(&self, path: &str) -> Result<String, CompileError> {
693        std::fs::read_to_string(path)
694            .map_err(|e| CompileError::ImportNotFound(alloc::format!("{}: {}", path, e)))
695    }
696
697    fn resolve(&self, base: &str, relative: &str) -> String {
698        use std::path::{Component, Path, PathBuf};
699        let parent = Path::new(base).parent().unwrap_or_else(|| Path::new("."));
700        let joined = parent.join(relative);
701        let mut out = PathBuf::new();
702        for component in joined.components() {
703            match component {
704                Component::ParentDir => {
705                    out.pop();
706                }
707                Component::CurDir => {}
708                c => out.push(c),
709            }
710        }
711        // Normalize to forward slashes so resolved paths (and therefore the
712        // provenance recorded in the IR) are identical on Windows and Unix.
713        // Windows `std::fs` accepts `/` just fine.
714        out.to_string_lossy().replace('\\', "/")
715    }
716}
717
718/// Compile a root source and all its transitive `IMPORT`s into one IR.
719///
720/// Imports are flat-merged into a single shared atom universe (atoms unify by
721/// identity across files). Sources are content-addressed (sha256): a source
722/// already merged is skipped (dedup), and an import cycle is an error.
723///
724/// Premise/rule names are per-source labels, not global identifiers: different
725/// files (domains) may reuse a name, and the report qualifies them by source. A
726/// name reused with a different body is an error only *within the same source*.
727pub fn compile<R: Resolver>(root: &str, resolver: &R) -> Result<Compiled, CompileError> {
728    let mut c = Compiler::new();
729    let mut visited = BTreeSet::new();
730    let mut stack = Vec::new();
731    c.load_recursive(root, resolver, &mut visited, &mut stack)?;
732    Ok(c.finalize())
733}
734
735// --- helpers ---------------------------------------------------------------
736
737/// Lower parsed, located literals to key-based [`RawLit`]s (drops spans).
738fn raw_lits(lits: &[elenchus_parser::Located<Literal>]) -> Vec<RawLit> {
739    lits.iter()
740        .map(|l| RawLit {
741            key: AtomKey::from_atom(&l.data.atom),
742            negated: l.data.negated,
743        })
744        .collect()
745}
746
747/// The surface keyword for a list op, used as [`Origin::kind`] in the report.
748fn list_kind(op: ListOp) -> &'static str {
749    match op {
750        ListOp::Exclusive => "EXCLUSIVE",
751        ListOp::Forbids => "FORBIDS",
752        ListOp::OneOf => "ONEOF",
753        ListOp::AtLeast => "ATLEAST",
754    }
755}
756
757/// Stable `subject|predicate|object` string for an atom key (the unit from which
758/// clause/fact/body signatures are built).
759fn key_sig(k: &AtomKey) -> String {
760    alloc::format!(
761        "{}|{}|{}",
762        k.subject,
763        k.predicate,
764        k.object.as_deref().unwrap_or("")
765    )
766}
767
768/// Canonical, order-independent signature of a clause's literals (for dedup).
769fn clause_sig(lits: &[RawLit]) -> String {
770    let mut parts: Vec<String> = lits
771        .iter()
772        .map(|l| alloc::format!("{}|{}", key_sig(&l.key), l.negated as u8))
773        .collect();
774    parts.sort();
775    parts.dedup();
776    parts.join(";")
777}
778
779/// Canonical body string for a named construct, hashed for redefinition checks.
780fn canonical_body(name: &str, body: &Body, is_rule: bool) -> String {
781    let mut s = String::new();
782    let _ = write!(s, "{}|{}|", if is_rule { "RULE" } else { "PREMISE" }, name);
783    match body {
784        Body::List { op, atoms } => {
785            let _ = write!(s, "LIST|{}|", list_kind(*op));
786            let mut keys: Vec<String> = atoms
787                .iter()
788                .map(|a| key_sig(&AtomKey::from_atom(&a.data)))
789                .collect();
790            keys.sort();
791            s.push_str(&keys.join(";"));
792        }
793        Body::Impl {
794            antecedent,
795            ante_conn,
796            consequent,
797            cons_conn,
798        } => {
799            let conn = |c: &Conn| if *c == Conn::Or { "OR" } else { "AND" };
800            s.push_str("IMPL|ANTE|");
801            s.push_str(conn(ante_conn));
802            s.push('|');
803            s.push_str(&lit_sigs(antecedent));
804            s.push_str("|CONS|");
805            s.push_str(conn(cons_conn));
806            s.push('|');
807            s.push_str(&lit_sigs(consequent));
808        }
809    }
810    s
811}
812
813/// Sorted `key|negated` signature of a literal list (order-independent), used
814/// inside [`canonical_body`] so reordering a body does not look like a redefinition.
815fn lit_sigs(lits: &[elenchus_parser::Located<Literal>]) -> String {
816    let mut parts: Vec<String> = lits
817        .iter()
818        .map(|l| {
819            alloc::format!(
820                "{}|{}",
821                key_sig(&AtomKey::from_atom(&l.data.atom)),
822                l.data.negated as u8
823            )
824        })
825        .collect();
826    parts.sort();
827    parts.join(";")
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    fn key(subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
835        AtomKey {
836            subject: subject.to_string(),
837            predicate: predicate.to_string(),
838            object: object.map(|o| o.to_string()),
839        }
840    }
841
842    fn id(c: &Compiled, k: &AtomKey) -> AtomId {
843        c.atoms.iter().position(|a| a == k).unwrap() as AtomId
844    }
845
846    #[test]
847    fn exclusive_unfolds_pairwise() {
848        let src = r#"
849        PREMISE e:
850            EXCLUSIVE
851                x a
852                x b
853                x c
854        "#;
855        let c = compile_source("<t>", src).unwrap();
856        // C(3,2) = 3 clauses, each of 2 positive literals.
857        assert_eq!(c.clauses.len(), 3);
858        for cl in &c.clauses {
859            assert_eq!(cl.lits.len(), 2);
860            assert!(cl.lits.iter().all(|l| !l.negated));
861        }
862    }
863
864    #[test]
865    fn implication_negates_consequent() {
866        // WHEN x a THEN x b  ==  Impossible([x a, NOT x b])
867        let src = r#"
868        PREMISE r:
869            WHEN x a
870            THEN x b
871        "#;
872        let c = compile_source("<t>", src).unwrap();
873        assert_eq!(c.clauses.len(), 1);
874        let cl = &c.clauses[0];
875        assert_eq!(cl.lits.len(), 2);
876        let a = id(&c, &key("x", "a", None));
877        let b = id(&c, &key("x", "b", None));
878        assert!(cl.lits.contains(&Lit {
879            atom: a,
880            negated: false
881        }));
882        assert!(cl.lits.contains(&Lit {
883            atom: b,
884            negated: true
885        }));
886    }
887
888    #[test]
889    fn negated_consequent_flips_to_positive() {
890        // THEN NOT x b  →  NOT(NOT x b) = x b positive inside Impossible
891        let src = r#"
892        PREMISE r:
893            WHEN x a
894            THEN NOT x b
895        "#;
896        let c = compile_source("<t>", src).unwrap();
897        let b = id(&c, &key("x", "b", None));
898        assert!(c.clauses[0].lits.contains(&Lit {
899            atom: b,
900            negated: false
901        }));
902    }
903
904    #[test]
905    fn consequent_or_is_one_clause_with_all_negated() {
906        // WHEN x p THEN x a OR x b  ==  Impossible([x p, NOT x a, NOT x b])
907        let src = r#"
908        PREMISE r:
909            WHEN x p
910            THEN x a
911            OR x b
912        "#;
913        let c = compile_source("<t>", src).unwrap();
914        assert_eq!(c.clauses.len(), 1);
915        let cl = &c.clauses[0];
916        assert_eq!(cl.lits.len(), 3);
917        let p = id(&c, &key("x", "p", None));
918        let a = id(&c, &key("x", "a", None));
919        let b = id(&c, &key("x", "b", None));
920        assert!(cl.lits.contains(&Lit {
921            atom: p,
922            negated: false
923        }));
924        assert!(cl.lits.contains(&Lit {
925            atom: a,
926            negated: true
927        }));
928        assert!(cl.lits.contains(&Lit {
929            atom: b,
930            negated: true
931        }));
932    }
933
934    #[test]
935    fn antecedent_or_is_one_clause_per_disjunct() {
936        // WHEN x a OR x b THEN x c
937        //   == Impossible([x a, NOT x c]) ∧ Impossible([x b, NOT x c])
938        let src = r#"
939        PREMISE r:
940            WHEN x a
941            OR x b
942            THEN x c
943        "#;
944        let c = compile_source("<t>", src).unwrap();
945        assert_eq!(c.clauses.len(), 2);
946        let a = id(&c, &key("x", "a", None));
947        let b = id(&c, &key("x", "b", None));
948        let cc = id(&c, &key("x", "c", None));
949        // every clause has exactly two lits and carries NOT c
950        for cl in &c.clauses {
951            assert_eq!(cl.lits.len(), 2);
952            assert!(cl.lits.contains(&Lit {
953                atom: cc,
954                negated: true
955            }));
956        }
957        let has = |atom| {
958            c.clauses.iter().any(|cl| {
959                cl.lits.contains(&Lit {
960                    atom,
961                    negated: false,
962                })
963            })
964        };
965        assert!(has(a) && has(b));
966    }
967
968    #[test]
969    fn antecedent_or_with_consequent_or_distributes() {
970        // (a ∨ b) → (c ∨ d): Impossible([a,¬c,¬d]) ∧ Impossible([b,¬c,¬d])
971        let src = r#"
972        PREMISE r:
973            WHEN x a
974            OR x b
975            THEN x c
976            OR x d
977        "#;
978        let c = compile_source("<t>", src).unwrap();
979        assert_eq!(c.clauses.len(), 2);
980        for cl in &c.clauses {
981            assert_eq!(cl.lits.len(), 3);
982        }
983    }
984
985    #[test]
986    fn rule_with_or_antecedent_splits_into_two_rules() {
987        // (a ∨ b) → c derives c whenever either fires: two single-antecedent rules.
988        let src = r#"
989        RULE r:
990            WHEN x a
991            OR x b
992            THEN x c
993        "#;
994        let c = compile_source("<t>", src).unwrap();
995        assert_eq!(c.rules.len(), 2);
996        assert!(
997            c.rules
998                .iter()
999                .all(|r| r.antecedent.len() == 1 && r.consequent.len() == 1)
1000        );
1001    }
1002
1003    #[test]
1004    fn rule_with_or_consequent_is_rejected() {
1005        // A rule cannot derive a disjunction — must be a PREMISE.
1006        let src = r#"
1007        RULE r:
1008            WHEN x a
1009            THEN x b
1010            OR x c
1011        "#;
1012        let err = compile_source("<t>", src).unwrap_err();
1013        assert!(matches!(
1014            err,
1015            CompileError::RuleDisjunctiveConsequent { .. }
1016        ));
1017    }
1018
1019    #[test]
1020    fn oneof_is_pairwise_plus_at_least_one() {
1021        let src = r#"
1022        PREMISE o:
1023            ONEOF
1024                x a
1025                x b
1026        "#;
1027        let c = compile_source("<t>", src).unwrap();
1028        // pairwise C(2,2)=1 + 1 at-least-one = 2 clauses
1029        assert_eq!(c.clauses.len(), 2);
1030        // the at-least-one clause is the all-negated one
1031        assert!(c.clauses.iter().any(|cl| cl.lits.iter().all(|l| l.negated)));
1032    }
1033
1034    #[test]
1035    fn atleast_is_one_negated_clause() {
1036        let src = r#"
1037        PREMISE a:
1038            ATLEAST
1039                x a
1040                x b
1041                x c
1042        "#;
1043        let c = compile_source("<t>", src).unwrap();
1044        assert_eq!(c.clauses.len(), 1);
1045        assert_eq!(c.clauses[0].lits.len(), 3);
1046        assert!(c.clauses[0].lits.iter().all(|l| l.negated));
1047    }
1048
1049    #[test]
1050    fn rules_are_separate_from_clauses() {
1051        let src = r#"
1052        RULE needs:
1053            WHEN x a
1054            THEN x b
1055        "#;
1056        let c = compile_source("<t>", src).unwrap();
1057        assert_eq!(c.clauses.len(), 0);
1058        assert_eq!(c.rules.len(), 1);
1059        assert_eq!(c.rules[0].antecedent.len(), 1);
1060        assert_eq!(c.rules[0].consequent.len(), 1);
1061    }
1062
1063    #[test]
1064    fn atoms_are_canonically_sorted() {
1065        let src = r#"
1066        FACT z z
1067        FACT a a
1068        FACT m m
1069        "#;
1070        let c = compile_source("<t>", src).unwrap();
1071        let mut sorted = c.atoms.clone();
1072        sorted.sort();
1073        assert_eq!(c.atoms, sorted);
1074    }
1075
1076    #[test]
1077    fn duplicate_premise_is_idempotent() {
1078        let src = r#"
1079        PREMISE e:
1080            EXCLUSIVE
1081                x a
1082                x b
1083        PREMISE e:
1084            EXCLUSIVE
1085                x a
1086                x b
1087        "#;
1088        let c = compile_source("<t>", src).unwrap();
1089        assert_eq!(c.clauses.len(), 1);
1090    }
1091
1092    #[test]
1093    fn redefinition_with_different_body_errors() {
1094        let src = r#"
1095        PREMISE e:
1096            EXCLUSIVE
1097                x a
1098                x b
1099        PREMISE e:
1100            EXCLUSIVE
1101                x a
1102                x c
1103        "#;
1104        let err = compile_source("<t>", src).unwrap_err();
1105        assert_eq!(
1106            err,
1107            CompileError::PremiseRedefinition {
1108                name: "e".to_string()
1109            }
1110        );
1111    }
1112
1113    #[test]
1114    fn duplicate_fact_is_idempotent() {
1115        let c = compile_source("<t>", "FACT x a\nFACT x a\n").unwrap();
1116        assert_eq!(c.facts.len(), 1);
1117    }
1118
1119    #[test]
1120    fn conflicting_facts_are_both_kept() {
1121        // FACT X + NOT X is a CONFLICT for the solver, not a compile error.
1122        let c = compile_source("<t>", "FACT x a\nNOT x a\n").unwrap();
1123        assert_eq!(c.facts.len(), 2);
1124    }
1125
1126    #[test]
1127    fn import_is_recorded_pending() {
1128        let c = compile_source("<t>", "IMPORT \"physics.vrf\"\nFACT x a\n").unwrap();
1129        assert_eq!(c.pending_imports, vec!["physics.vrf".to_string()]);
1130    }
1131
1132    #[test]
1133    fn import_flat_merges_and_atoms_unify() {
1134        // The library constrains `Engine.X has fuel`; the main file asserts it.
1135        // After merge the atom must be ONE shared id (unification across files).
1136        let mut r = MemoryResolver::new();
1137        r.add(
1138            "lib.vrf",
1139            r#"
1140        PREMISE needs_fuel:
1141            WHEN Engine.X has engine
1142            THEN Engine.X has fuel
1143        "#,
1144        );
1145        r.add(
1146            "main.vrf",
1147            r#"
1148        IMPORT "lib.vrf"
1149        FACT Engine.X has engine
1150        FACT Engine.X has fuel
1151        "#,
1152        );
1153        let c = compile("main.vrf", &r).unwrap();
1154        assert!(c.pending_imports.is_empty());
1155        assert_eq!(c.clauses.len(), 1); // the imported premise
1156        assert_eq!(c.facts.len(), 2);
1157
1158        // `Engine.X has fuel` from the FACT and from the imported premise share an id.
1159        let fuel = key("Engine.X", "has", Some("fuel"));
1160        let fuel_id = id(&c, &fuel);
1161        assert!(c.facts.iter().any(|f| f.atom == fuel_id));
1162        assert!(c.clauses[0].lits.iter().any(|l| l.atom == fuel_id));
1163    }
1164
1165    #[test]
1166    fn diamond_import_is_deduped() {
1167        // main → a, b ; a → base ; b → base. base merged once.
1168        let mut r = MemoryResolver::new();
1169        r.add(
1170            "base.vrf",
1171            r#"
1172        PREMISE b:
1173            EXCLUSIVE
1174                x a
1175                x b
1176        "#,
1177        );
1178        r.add("a.vrf", "IMPORT \"base.vrf\"\n");
1179        r.add("c.vrf", "IMPORT \"base.vrf\"\n");
1180        r.add("main.vrf", "IMPORT \"a.vrf\"\nIMPORT \"c.vrf\"\n");
1181        let c = compile("main.vrf", &r).unwrap();
1182        assert_eq!(c.clauses.len(), 1); // base's single clause, not two
1183    }
1184
1185    #[test]
1186    fn circular_import_errors() {
1187        let mut r = MemoryResolver::new();
1188        r.add("a.vrf", "IMPORT \"b.vrf\"\n");
1189        r.add("b.vrf", "IMPORT \"a.vrf\"\n");
1190        let err = compile("a.vrf", &r).unwrap_err();
1191        assert!(matches!(err, CompileError::CircularImport(_)));
1192    }
1193
1194    #[test]
1195    fn missing_import_errors() {
1196        let mut r = MemoryResolver::new();
1197        r.add("main.vrf", "IMPORT \"ghost.vrf\"\n");
1198        let err = compile("main.vrf", &r).unwrap_err();
1199        assert!(matches!(err, CompileError::ImportNotFound(_)));
1200    }
1201
1202    #[test]
1203    fn same_name_across_domains_coexists() {
1204        // Two files may legitimately reuse a premise NAME with different bodies
1205        // (different domains). Names are per-source labels — both premises apply,
1206        // and the report qualifies them by source. NOT a redefinition error.
1207        let mut r = MemoryResolver::new();
1208        r.add(
1209            "physics.vrf",
1210            r#"
1211        PREMISE safety:
1212            EXCLUSIVE
1213                x a
1214                x b
1215        "#,
1216        );
1217        r.add(
1218            "main.vrf",
1219            r#"
1220        IMPORT "physics.vrf"
1221        PREMISE safety:
1222            EXCLUSIVE
1223                x a
1224                x c
1225        "#,
1226        );
1227        let c = compile("main.vrf", &r).unwrap();
1228        assert_eq!(c.clauses.len(), 2); // a-b from physics, a-c from main
1229        assert!(c.clauses.iter().any(|cl| cl.origin.source == "physics.vrf"));
1230        assert!(c.clauses.iter().any(|cl| cl.origin.source == "main.vrf"));
1231    }
1232
1233    #[test]
1234    fn two_libs_same_name_into_one_consumer() {
1235        // A.vrf and B.vrf each define their OWN `x` (different bodies); C imports
1236        // both. Both `x` coexist (per-source labels). Meanwhile the atom they
1237        // share (S has a) unifies into ONE id — names are scoped, atoms are not.
1238        let mut r = MemoryResolver::new();
1239        r.add(
1240            "A.vrf",
1241            r#"
1242        PREMISE x:
1243            EXCLUSIVE
1244                S has a
1245                S has b
1246        "#,
1247        );
1248        r.add(
1249            "B.vrf",
1250            r#"
1251        PREMISE x:
1252            EXCLUSIVE
1253                S has a
1254                S has c
1255        "#,
1256        );
1257        r.add("C.vrf", "IMPORT \"A.vrf\"\nIMPORT \"B.vrf\"\n");
1258        let c = compile("C.vrf", &r).unwrap();
1259
1260        // both `x` premises contributed a clause, kept apart by source
1261        assert_eq!(c.clauses.len(), 2);
1262        assert!(
1263            c.clauses
1264                .iter()
1265                .any(|cl| cl.origin.source == "A.vrf" && cl.origin.premise.as_deref() == Some("x"))
1266        );
1267        assert!(
1268            c.clauses
1269                .iter()
1270                .any(|cl| cl.origin.source == "B.vrf" && cl.origin.premise.as_deref() == Some("x"))
1271        );
1272
1273        // the shared atom `S has a` is a single interned id used by both clauses
1274        let s_a = id(&c, &key("S", "has", Some("a")));
1275        assert!(
1276            c.clauses
1277                .iter()
1278                .filter(|cl| cl.lits.iter().any(|l| l.atom == s_a))
1279                .count()
1280                == 2,
1281            "both clauses must reference the same unified `S has a` atom"
1282        );
1283    }
1284
1285    #[test]
1286    fn redefinition_within_one_source_still_errors() {
1287        // But reusing a name with a different body *inside one source* is a mistake.
1288        let src = r#"
1289        PREMISE e:
1290            EXCLUSIVE
1291                x a
1292                x b
1293        PREMISE e:
1294            EXCLUSIVE
1295                x a
1296                x c
1297        "#;
1298        let err = compile_source("main.vrf", src).unwrap_err();
1299        assert_eq!(
1300            err,
1301            CompileError::PremiseRedefinition {
1302                name: "e".to_string()
1303            }
1304        );
1305    }
1306
1307    #[test]
1308    fn import_demo_examples_resolve() {
1309        let mut r = MemoryResolver::new();
1310        r.add(
1311            "physics.vrf",
1312            include_str!("../../../docs/examples/physics.vrf"),
1313        );
1314        r.add(
1315            "import-demo.vrf",
1316            include_str!("../../../docs/examples/import-demo.vrf"),
1317        );
1318        let c = compile("import-demo.vrf", &r).unwrap();
1319        assert!(c.pending_imports.is_empty());
1320        // physics.vrf: one_path (EXCLUSIVE, 1 clause) + speed_order (impl, 1 clause)
1321        assert_eq!(c.clauses.len(), 2);
1322        // over_200 / over_100 unify between the facts and the imported premise.
1323        let over_100 = id(&c, &key("Motor", "over_100", None));
1324        assert!(c.facts.iter().any(|f| f.atom == over_100));
1325        assert!(
1326            c.clauses
1327                .iter()
1328                .any(|cl| cl.lits.iter().any(|l| l.atom == over_100))
1329        );
1330    }
1331
1332    #[test]
1333    fn creature_example_compiles() {
1334        let src = include_str!("../../../docs/examples/creature.vrf");
1335        let c = compile_source("creature.vrf", src).unwrap();
1336        assert_eq!(c.facts.len(), 2); // flying, warm_blood
1337        assert_eq!(c.rules.len(), 1); // needs_oxygen
1338        assert_eq!(c.checks.len(), 1);
1339        // fly_xor_swim (1) + wings_need_bone (THEN wing AND bone → 2) + no_dual_temp (1) = 4
1340        assert_eq!(c.clauses.len(), 4);
1341        assert_eq!(c.atoms.len(), 7);
1342    }
1343
1344    #[test]
1345    fn forbids_unfolds_pairwise() {
1346        let src = r#"
1347        PREMISE f:
1348            FORBIDS
1349                x a
1350                x b
1351                x c
1352        "#;
1353        let c = compile_source("<t>", src).unwrap();
1354        assert_eq!(c.clauses.len(), 3); // C(3,2), like EXCLUSIVE
1355        assert!(
1356            c.clauses
1357                .iter()
1358                .all(|cl| cl.lits.len() == 2 && cl.lits.iter().all(|l| !l.negated))
1359        );
1360    }
1361
1362    #[test]
1363    fn rule_with_multiple_consequents() {
1364        let src = r#"
1365        RULE r:
1366            WHEN x a
1367            THEN x b
1368            AND  x c
1369        "#;
1370        let c = compile_source("<t>", src).unwrap();
1371        assert_eq!(c.rules.len(), 1);
1372        assert_eq!(c.rules[0].consequent.len(), 2);
1373    }
1374
1375    #[test]
1376    fn negated_antecedent_literal_keeps_polarity() {
1377        // WHEN NOT x a THEN x b  ==  Impossible([NOT x a, NOT x b])
1378        let src = r#"
1379        PREMISE a:
1380            WHEN NOT x a
1381            THEN x b
1382        "#;
1383        let c = compile_source("<t>", src).unwrap();
1384        let xa = id(&c, &key("x", "a", None));
1385        assert!(c.clauses[0].lits.contains(&Lit {
1386            atom: xa,
1387            negated: true
1388        }));
1389    }
1390
1391    #[test]
1392    fn rule_keeps_consequent_negation() {
1393        let src = r#"
1394        RULE r:
1395            WHEN x a
1396            THEN NOT x b
1397        "#;
1398        let c = compile_source("<t>", src).unwrap();
1399        assert!(c.rules[0].consequent[0].negated);
1400    }
1401
1402    #[test]
1403    fn compilation_is_deterministic() {
1404        let src = r#"
1405        PREMISE e:
1406            EXCLUSIVE
1407                z z
1408                a a
1409                m m
1410        FACT q q
1411        "#;
1412        assert_eq!(
1413            compile_source("<t>", src).unwrap(),
1414            compile_source("<t>", src).unwrap()
1415        );
1416    }
1417
1418    #[test]
1419    fn empty_program_compiles_to_empty_ir() {
1420        let c = compile_source("<t>", "// nothing here\n").unwrap();
1421        assert!(c.atoms.is_empty() && c.clauses.is_empty() && c.facts.is_empty());
1422    }
1423
1424    #[test]
1425    fn same_clause_from_two_named_premises_is_deduped() {
1426        // Different names, identical logical content → one clause, no redefinition.
1427        let src = r#"
1428        PREMISE e1:
1429            EXCLUSIVE
1430                x a
1431                x b
1432        PREMISE e2:
1433            EXCLUSIVE
1434                x a
1435                x b
1436        "#;
1437        let c = compile_source("<t>", src).unwrap();
1438        assert_eq!(c.clauses.len(), 1);
1439    }
1440
1441    #[test]
1442    fn object_distinguishes_atom_identity() {
1443        // `x p a` and `x p b` differ only by object → two distinct atoms.
1444        let c = compile_source("<t>", "FACT x p a\nFACT x p b\n").unwrap();
1445        assert_eq!(c.atoms.len(), 2);
1446    }
1447}