1#![no_std]
33#![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
47pub use elenchus_parser::Diagnostics;
50use elenchus_parser::{Atom, Body, Conn, ListOp, Literal, Statement, kw};
51use sha2::{Digest, Sha256};
52use thiserror::Error;
53
54pub 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
69pub type AtomId = u32;
73
74#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
80pub struct AtomKey {
81 pub domain: String,
84 pub subject: String,
86 pub predicate: String,
88 pub object: Option<String>,
90}
91
92struct DomainCtx {
97 current: String,
99 aliases: BTreeMap<String, String>,
102}
103
104impl DomainCtx {
105 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135pub struct Lit {
136 pub atom: AtomId,
138 pub negated: bool,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum Value {
145 True,
147 False,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct Origin {
154 pub source: String,
156 pub line: u32,
158 pub premise: Option<String>,
160 pub kind: &'static str,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Fact {
168 pub atom: AtomId,
170 pub value: Value,
172 pub origin: Origin,
174 pub soft: bool,
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct Clause {
184 pub lits: Vec<Lit>,
186 pub origin: Origin,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Rule {
194 pub antecedent: Vec<Lit>,
196 pub consequent: Vec<Lit>,
198 pub origin: Origin,
200}
201
202#[derive(Debug, Clone, PartialEq, Eq)]
204pub struct Check {
205 pub subject: Option<String>,
207 pub bidirectional: bool,
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct Compiled {
214 pub atoms: Vec<AtomKey>,
216 pub facts: Vec<Fact>,
218 pub clauses: Vec<Clause>,
220 pub rules: Vec<Rule>,
222 pub checks: Vec<Check>,
224 pub pending_imports: Vec<String>,
227 pub unused_imports: Vec<UnusedImport>,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
239pub struct UnusedImport {
240 pub file: String,
242 pub domain: String,
244 pub alias: Option<String>,
246 pub line: u32,
248}
249
250#[derive(Debug, Error, PartialEq, Eq)]
252pub enum CompileError {
253 #[error("{0}")]
257 Parse(elenchus_parser::Diagnostics),
258 #[error("'{name}' redefined with a different body")]
260 PremiseRedefinition {
261 name: String,
263 },
264 #[error("{file}: missing a DOMAIN declaration (every file must start with `DOMAIN <name>`)")]
267 MissingDomain {
268 file: String,
270 },
271 #[error("{file}: more than one DOMAIN declaration (a file has exactly one domain)")]
273 DuplicateDomain {
274 file: String,
276 },
277 #[error("unknown domain '{domain}' — declare it with DOMAIN, or IMPORT it in this file")]
280 UnknownDomain {
281 domain: String,
283 },
284 #[error("domain name '{alias}' is bound to two different imports (disambiguate with AS)")]
287 DomainAliasClash {
288 alias: String,
290 },
291 #[error("import not found: {0}")]
293 ImportNotFound(String),
294 #[error("circular import: {0}")]
296 CircularImport(String),
297 #[error("rule '{name}' cannot derive a disjunction (OR in THEN); use a PREMISE instead")]
301 RuleDisjunctiveConsequent {
302 name: String,
304 },
305}
306
307#[derive(Clone)]
315struct RawLit {
316 key: AtomKey,
317 negated: bool,
318}
319
320struct RawFact {
322 key: AtomKey,
323 value: Value,
324 origin: Origin,
325 soft: bool,
326}
327
328struct RawClause {
330 lits: Vec<RawLit>,
331 origin: Origin,
332}
333
334struct RawRule {
336 antecedent: Vec<RawLit>,
337 consequent: Vec<RawLit>,
338 origin: Origin,
339}
340
341#[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 defined: BTreeMap<(String, String), String>,
357 clause_sigs: BTreeSet<String>,
359 fact_sigs: BTreeSet<String>,
361}
362
363impl Compiler {
364 pub fn new() -> Self {
366 Self::default()
367 }
368
369 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 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 fn add_statement(
416 &mut self,
417 source: &str,
418 stmt: &Statement,
419 ctx: &DomainCtx,
420 ) -> Result<(), CompileError> {
421 match stmt {
422 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 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 fn intern(&mut self, key: &AtomKey) {
464 if !self.keys.contains(key) {
465 self.keys.insert(key.clone());
466 }
467 }
468
469 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 "" );
490 if !self.fact_sigs.insert(sig) {
491 return Ok(()); }
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 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(()), Some(_) => {
523 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 if let Body::Impl {
536 antecedent,
537 ante_conn,
538 consequent,
539 cons_conn,
540 } = body
541 {
542 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 Conn::And => self.rules.push(RawRule {
557 antecedent: ante,
558 consequent: cons,
559 origin,
560 }),
561 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 ListOp::Exclusive | ListOp::Forbids => {
590 self.emit_pairwise(&keys, &origin);
591 }
592 ListOp::OneOf => {
594 self.emit_pairwise(&keys, &origin);
595 self.emit_at_least_one(&keys, &origin);
596 }
597 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 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 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 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 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 }
691
692 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 pub fn finalize(self) -> Compiled {
704 let atoms: Vec<AtomKey> = self.keys.into_iter().collect(); 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(), }
751 }
752}
753
754pub 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
762pub trait Resolver {
768 fn load(&self, path: &str) -> Result<String, CompileError>;
770
771 fn resolve(&self, _base: &str, relative: &str) -> String {
774 relative.to_string()
775 }
776}
777
778#[derive(Default)]
781pub struct MemoryResolver {
782 sources: BTreeMap<String, String>,
783}
784
785impl MemoryResolver {
786 pub fn new() -> Self {
788 Self::default()
789 }
790
791 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#[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 out.to_string_lossy().replace('\\', "/")
838 }
839}
840
841struct ResolvedFile {
844 path: String,
845 content: String,
846 ctx: DomainCtx,
847}
848
849pub 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
875struct ImportEdge {
878 alias: Option<String>,
879 child_path: String,
880 line: u32,
881}
882
883struct DiscoveredFile {
888 path: String,
889 content: String,
890 domain: String,
891 edges: Vec<ImportEdge>,
892 used_prefixes: BTreeSet<Option<String>>,
893}
894
895fn resolve_graph<R: Resolver>(
903 root: &str,
904 resolver: &R,
905) -> Result<(Vec<ResolvedFile>, Vec<UnusedImport>), CompileError> {
906 enum Step {
908 Enter(String),
910 Exit(String),
912 }
913
914 let mut discovered: BTreeMap<String, DiscoveredFile> = BTreeMap::new(); let mut path_hash: BTreeMap<String, String> = BTreeMap::new(); let mut order: Vec<String> = Vec::new(); let mut active: BTreeSet<String> = BTreeSet::new(); 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)); }
933 if discovered.contains_key(&hash) {
934 continue; }
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); 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 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 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 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
1051fn 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
1075fn 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
1096fn 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
1114fn 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
1124fn 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
1137fn 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
1148fn 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
1188fn 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 fn cs(src: &str) -> Result<Compiled, CompileError> {
1215 compile_source("<t>", &alloc::format!("DOMAIN t\n{src}"))
1216 }
1217
1218 fn key(subject: &str, predicate: &str, object: Option<&str>) -> AtomKey {
1220 key_in("t", subject, predicate, object)
1221 }
1222
1223 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 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 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 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 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 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 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 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 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 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 assert_eq!(c.clauses.len(), 2);
1421 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 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 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); assert_eq!(c.facts.len(), 2);
1550
1551 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 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 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 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 assert_eq!(c.atoms.iter().filter(|a| a.domain == "physics").count(), 2);
1593 }
1594
1595 #[test]
1596 fn unknown_domain_reference_errors() {
1597 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 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 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); 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 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 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 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 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); }
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 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 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 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); }
1761
1762 #[test]
1763 fn very_deep_linear_chain_does_not_overflow() {
1764 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 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 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 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 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); 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 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 assert_eq!(c.clauses.len(), 2);
1913 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); assert_eq!(c.rules.len(), 1); assert_eq!(c.checks.len(), 1);
1930 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); 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 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 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 let c = cs("FACT x p a\nFACT x p b\n").unwrap();
2033 assert_eq!(c.atoms.len(), 2);
2034 }
2035}