1#![no_std]
38#![warn(missing_docs)]
40
41extern crate alloc;
42
43#[cfg(feature = "std")]
44extern crate std;
45
46pub mod sat;
47
48use alloc::collections::BTreeSet;
49use alloc::string::String;
50use alloc::vec;
51use alloc::vec::Vec;
52use core::fmt;
53
54pub use elenchus_compiler::Diagnostics;
57#[cfg(feature = "std")]
59pub use elenchus_compiler::FileResolver;
60use elenchus_compiler::{
61 AtomId, AtomKey, Clause, Compiled, Fact, KIND_UNSAT, Lit, Origin, Rule, Value, kw, levenshtein,
62};
63pub use elenchus_compiler::{
64 CompileError, MemoryResolver, PlaceholderInfo, PlaceholderStatus, PortBinding, Resolver,
65 UnusedImport, compile, compile_source, compile_source_with, compile_with,
66 normalize_import_path, read_data_bindings, read_data_source,
67};
68
69pub const VERSION: &str = env!("CARGO_PKG_VERSION");
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum V3 {
78 True,
80 False,
82 Unknown,
84}
85
86impl V3 {
87 fn not(self) -> V3 {
89 match self {
90 V3::True => V3::False,
91 V3::False => V3::True,
92 V3::Unknown => V3::Unknown,
93 }
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum Status {
100 Consistent,
102 Underdetermined,
105 Warning,
107 Conflict,
109}
110
111#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Conflict {
114 pub origin: Origin,
116 pub atoms: Vec<String>,
118 pub trace: Vec<TraceStep>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct TraceStep {
130 pub atom: String,
132 pub value: Value,
134 pub reason: TraceReason,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub enum TraceReason {
141 Asserted(Origin),
143 Derived {
145 origin: Origin,
147 from: Vec<String>,
149 },
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct Warning {
155 pub origin: Origin,
157 pub blocked_by: Vec<String>,
159 pub hint: Option<String>,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Derived {
170 pub atom: String,
172 pub value: Value,
174 pub origin: Origin,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct Report {
181 pub status: Status,
183 pub conflicts: Vec<Conflict>,
185 pub warnings: Vec<Warning>,
187 pub derived: Vec<Derived>,
189 pub underdetermined: Option<String>,
192 pub unsat_core: Vec<CoreItem>,
197 pub retract: Vec<CoreItem>,
205 pub hints: Vec<SimilarAtoms>,
208 pub orphans: Vec<OrphanFact>,
213 pub unused_imports: Vec<UnusedImport>,
218 pub placeholders: Vec<PlaceholderInfo>,
223}
224
225#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct SimilarAtoms {
232 pub a: String,
234 pub b: String,
236 pub reason: &'static str,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct OrphanFact {
248 pub atom: String,
250 pub value: Value,
252 pub origin: Origin,
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct CoreItem {
259 pub origin: Origin,
261 pub label: String,
263}
264
265impl Report {
266 pub fn exit_code(&self) -> i32 {
268 match self.status {
269 Status::Conflict => 2,
270 Status::Underdetermined | Status::Warning => 1,
271 Status::Consistent => 0,
272 }
273 }
274
275 pub fn to_json(&self) -> String {
278 use core::fmt::Write as _;
279 let mut s = String::new();
280 let _ = write!(s, "{{\"status\":");
281 json_str(status_name(self.status), &mut s);
282 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
283
284 s.push_str(",\"conflicts\":[");
285 for (i, c) in self.conflicts.iter().enumerate() {
286 if i > 0 {
287 s.push(',');
288 }
289 json_origin(&c.origin, &mut s);
290 s.push_str(",\"atoms\":");
291 json_array(&c.atoms, &mut s);
292 s.push_str(",\"trace\":[");
293 for (j, step) in c.trace.iter().enumerate() {
294 if j > 0 {
295 s.push(',');
296 }
297 json_trace_step(step, &mut s);
298 }
299 s.push_str("]}");
300 }
301 s.push_str("],\"warnings\":[");
302 for (i, w) in self.warnings.iter().enumerate() {
303 if i > 0 {
304 s.push(',');
305 }
306 json_origin(&w.origin, &mut s);
307 s.push_str(",\"blocked_by\":");
308 json_array(&w.blocked_by, &mut s);
309 s.push_str(",\"hint\":");
310 match &w.hint {
311 Some(h) => json_str(h, &mut s),
312 None => s.push_str("null"),
313 }
314 s.push('}');
315 }
316 s.push_str("],\"derived\":[");
317 for (i, d) in self.derived.iter().enumerate() {
318 if i > 0 {
319 s.push(',');
320 }
321 s.push('{');
322 json_origin_fields(&d.origin, &mut s);
323 s.push_str(",\"atom\":");
324 json_str(&d.atom, &mut s);
325 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
326 s.push('}');
327 }
328 s.push_str("],\"underdetermined\":");
329 match &self.underdetermined {
330 Some(atom) => json_str(atom, &mut s),
331 None => s.push_str("null"),
332 }
333 s.push_str(",\"unsat_core\":[");
334 for (i, it) in self.unsat_core.iter().enumerate() {
335 if i > 0 {
336 s.push(',');
337 }
338 json_origin(&it.origin, &mut s);
339 s.push_str(",\"label\":");
340 json_str(&it.label, &mut s);
341 s.push('}');
342 }
343 s.push_str("],\"retract\":[");
344 for (i, it) in self.retract.iter().enumerate() {
345 if i > 0 {
346 s.push(',');
347 }
348 json_origin(&it.origin, &mut s);
349 s.push_str(",\"label\":");
350 json_str(&it.label, &mut s);
351 s.push('}');
352 }
353 s.push_str("],\"hints\":[");
354 for (i, h) in self.hints.iter().enumerate() {
355 if i > 0 {
356 s.push(',');
357 }
358 s.push_str("{\"a\":");
359 json_str(&h.a, &mut s);
360 s.push_str(",\"b\":");
361 json_str(&h.b, &mut s);
362 s.push_str(",\"reason\":");
363 json_str(h.reason, &mut s);
364 s.push('}');
365 }
366 s.push_str("],\"orphans\":[");
367 for (i, o) in self.orphans.iter().enumerate() {
368 if i > 0 {
369 s.push(',');
370 }
371 json_origin(&o.origin, &mut s);
372 s.push_str(",\"atom\":");
373 json_str(&o.atom, &mut s);
374 let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
375 s.push('}');
376 }
377 s.push_str("],\"unused_imports\":[");
378 for (i, u) in self.unused_imports.iter().enumerate() {
379 if i > 0 {
380 s.push(',');
381 }
382 s.push_str("{\"file\":");
383 json_str(&u.file, &mut s);
384 s.push_str(",\"domain\":");
385 json_str(&u.domain, &mut s);
386 s.push_str(",\"alias\":");
387 match &u.alias {
388 Some(a) => json_str(a, &mut s),
389 None => s.push_str("null"),
390 }
391 let _ = write!(s, ",\"line\":{}", u.line);
392 s.push('}');
393 }
394 s.push_str("],\"placeholders\":[");
395 for (i, p) in self.placeholders.iter().enumerate() {
396 if i > 0 {
397 s.push(',');
398 }
399 s.push_str("{\"key\":");
400 json_str(&p.key, &mut s);
401 let status = match p.status {
402 PlaceholderStatus::Supplied => "supplied",
403 PlaceholderStatus::DefaultUsed => "default",
404 PlaceholderStatus::Unset => "unset",
405 };
406 s.push_str(",\"status\":");
407 json_str(status, &mut s);
408 match p.value {
409 Some(v) => {
410 let _ = write!(s, ",\"value\":{v}");
411 }
412 None => s.push_str(",\"value\":null"),
413 }
414 s.push_str(",\"origin\":");
415 match &p.origin {
416 Some(o) => json_str(o, &mut s),
417 None => s.push_str("null"),
418 }
419 s.push('}');
420 }
421 s.push_str("]}");
422 s
423 }
424}
425
426fn json_trace_step(step: &TraceStep, out: &mut String) {
428 use core::fmt::Write as _;
429 out.push_str("{\"atom\":");
430 json_str(&step.atom, out);
431 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
432 match &step.reason {
433 TraceReason::Asserted(o) => {
434 out.push_str(",\"how\":\"asserted\",");
435 json_origin_fields(o, out);
436 out.push_str(",\"from\":[]");
437 }
438 TraceReason::Derived { origin, from } => {
439 out.push_str(",\"how\":\"derived\",");
440 json_origin_fields(origin, out);
441 out.push_str(",\"from\":");
442 json_array(from, out);
443 }
444 }
445 out.push('}');
446}
447
448fn status_name(s: Status) -> &'static str {
449 match s {
450 Status::Consistent => "CONSISTENT",
451 Status::Underdetermined => "UNDERDETERMINED",
452 Status::Warning => "WARNING",
453 Status::Conflict => "CONFLICT",
454 }
455}
456
457fn json_str(value: &str, out: &mut String) {
459 use core::fmt::Write as _;
460 out.push('"');
461 for ch in value.chars() {
462 match ch {
463 '"' => out.push_str("\\\""),
464 '\\' => out.push_str("\\\\"),
465 '\n' => out.push_str("\\n"),
466 '\r' => out.push_str("\\r"),
467 '\t' => out.push_str("\\t"),
468 c if (c as u32) < 0x20 => {
469 let _ = write!(out, "\\u{:04x}", c as u32);
470 }
471 c => out.push(c),
472 }
473 }
474 out.push('"');
475}
476
477fn json_array(items: &[String], out: &mut String) {
479 out.push('[');
480 for (i, item) in items.iter().enumerate() {
481 if i > 0 {
482 out.push(',');
483 }
484 json_str(item, out);
485 }
486 out.push(']');
487}
488
489fn json_origin_fields(o: &Origin, out: &mut String) {
491 use core::fmt::Write as _;
492 out.push_str("\"premise\":");
493 match &o.premise {
494 Some(name) => json_str(name, out),
495 None => out.push_str("null"),
496 }
497 out.push_str(",\"kind\":");
498 json_str(o.kind, out);
499 out.push_str(",\"source\":");
500 json_str(&o.source, out);
501 let _ = write!(out, ",\"line\":{}", o.line);
502}
503
504fn json_origin(o: &Origin, out: &mut String) {
506 out.push('{');
507 json_origin_fields(o, out);
508}
509
510fn label(c: &Compiled, a: AtomId) -> String {
514 let k = &c.atoms[a as usize];
515 match (&k.predicate, &k.object) {
516 (Some(p), Some(o)) => alloc::format!("{}.{} {} {}", k.domain, k.subject, p, o),
518 (Some(p), None) => alloc::format!("{}.{} {}", k.domain, k.subject, p),
520 (None, _) => alloc::format!("{}.{}", k.domain, k.subject),
522 }
523}
524
525fn lit_value(model: &[V3], l: &Lit) -> V3 {
527 let v = model[l.atom as usize];
528 if l.negated { v.not() } else { v }
529}
530
531fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
533 let mut result = V3::True;
534 for l in lits {
535 match lit_value(model, l) {
536 V3::False => return V3::False,
537 V3::Unknown => result = V3::Unknown,
538 V3::True => {}
539 }
540 }
541 result
542}
543
544enum ClauseEval {
546 Violated,
548 Satisfied,
550 Blocked(Vec<AtomId>),
552}
553
554fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
557 let mut any_false = false;
558 let mut all_true = true;
559 let mut blocked = Vec::new();
560 for l in &clause.lits {
561 match lit_value(model, l) {
562 V3::False => {
563 any_false = true;
564 all_true = false;
565 }
566 V3::Unknown => {
567 all_true = false;
568 blocked.push(l.atom);
569 }
570 V3::True => {}
571 }
572 }
573 if all_true {
574 ClauseEval::Violated
575 } else if any_false {
576 ClauseEval::Satisfied
577 } else {
578 ClauseEval::Blocked(blocked)
579 }
580}
581
582#[derive(Clone)]
585enum AtomReason {
586 Asserted(Origin),
588 Derived { origin: Origin, from: Vec<AtomId> },
590}
591
592struct RawConflict {
596 origin: Origin,
597 atoms: Vec<String>,
598 cause: Vec<AtomId>,
599}
600
601struct Eval<'a> {
603 c: &'a Compiled,
604 model: Vec<V3>,
605 reason: Vec<Option<AtomReason>>,
608 conflicts: Vec<RawConflict>,
609 warnings: Vec<Warning>,
610 derived: Vec<Derived>,
611 unsat_core: Vec<CoreItem>,
613}
614
615impl<'a> Eval<'a> {
616 fn new(c: &'a Compiled) -> Self {
617 Eval {
618 c,
619 model: vec![V3::Unknown; c.atoms.len()],
620 reason: vec![None; c.atoms.len()],
621 conflicts: Vec::new(),
622 warnings: Vec::new(),
623 derived: Vec::new(),
624 unsat_core: Vec::new(),
625 }
626 }
627
628 fn label(&self, a: AtomId) -> String {
629 label(self.c, a)
630 }
631
632 fn seed_facts(&mut self) {
634 let c = self.c;
635 let n = c.atoms.len();
636 let mut t: Vec<Option<Origin>> = vec![None; n];
637 let mut f: Vec<Option<Origin>> = vec![None; n];
638 for fact in &c.facts {
639 let slot = match fact.value {
640 Value::True => &mut t,
641 Value::False => &mut f,
642 };
643 if slot[fact.atom as usize].is_none() {
644 slot[fact.atom as usize] = Some(fact.origin.clone());
645 }
646 }
647 for a in 0..n {
648 match (&t[a], &f[a]) {
649 (Some(o), Some(_)) => {
650 self.model[a] = V3::True;
651 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
652 self.conflicts.push(RawConflict {
653 origin: o.clone(),
654 atoms: vec![alloc::format!(
655 "{} (asserted both TRUE and FALSE)",
656 self.label(a as AtomId)
657 )],
658 cause: Vec::new(),
659 });
660 }
661 (Some(o), None) => {
662 self.model[a] = V3::True;
663 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
664 }
665 (None, Some(o)) => {
666 self.model[a] = V3::False;
667 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
668 }
669 (None, None) => {}
670 }
671 }
672 }
673
674 fn saturate_rules(&mut self) {
676 let c = self.c;
677 loop {
678 let mut changed = false;
679 for r in &c.rules {
680 if conjunction(&self.model, &r.antecedent) != V3::True {
681 continue; }
683 for cl in &r.consequent {
684 let target = if cl.negated { V3::False } else { V3::True };
685 match self.model[cl.atom as usize] {
686 V3::Unknown => {
687 self.model[cl.atom as usize] = target;
688 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
689 origin: r.origin.clone(),
690 from: r.antecedent.iter().map(|l| l.atom).collect(),
691 });
692 changed = true;
693 self.derived.push(Derived {
694 atom: self.label(cl.atom),
695 value: if cl.negated {
696 Value::False
697 } else {
698 Value::True
699 },
700 origin: r.origin.clone(),
701 });
702 }
703 v if v == target => {}
704 _ => {
705 let mut cause: Vec<AtomId> =
709 r.antecedent.iter().map(|l| l.atom).collect();
710 cause.push(cl.atom);
711 self.conflicts.push(RawConflict {
712 origin: r.origin.clone(),
713 atoms: vec![alloc::format!(
714 "{} (derived value contradicts a known fact)",
715 self.label(cl.atom)
716 )],
717 cause,
718 });
719 }
720 }
721 }
722 }
723 if !changed {
724 break;
725 }
726 }
727 }
728
729 fn check_premises(&mut self) {
731 let c = self.c;
732 let derivable: BTreeSet<AtomId> = c
737 .rules
738 .iter()
739 .flat_map(|r| r.consequent.iter().map(|l| l.atom))
740 .collect();
741 for clause in &c.clauses {
742 match eval_clause(&self.model, clause) {
743 ClauseEval::Violated => self.conflicts.push(RawConflict {
744 origin: clause.origin.clone(),
745 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
746 cause: clause.lits.iter().map(|l| l.atom).collect(),
747 }),
748 ClauseEval::Satisfied => {}
749 ClauseEval::Blocked(unknowns) if clause.origin.kind == kw::PREMISE => {
752 let hint = self.warning_hint(&unknowns, &derivable);
753 self.warnings.push(Warning {
754 origin: clause.origin.clone(),
755 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
756 hint,
757 });
758 }
759 ClauseEval::Blocked(_) => {}
760 }
761 }
762 }
763
764 fn warning_hint(&self, unknowns: &[AtomId], derivable: &BTreeSet<AtomId>) -> Option<String> {
769 let free = unknowns.iter().find(|a| !derivable.contains(a));
770 match free {
771 Some(&a) => Some(alloc::format!(
772 "nothing determines `{}` — add `FACT {}` (or `NOT …`), or if a PREMISE's \
773 THEN is meant to establish it, make that PREMISE a RULE so it derives the value",
774 self.label(a),
775 self.label(a),
776 )),
777 None => unknowns.first().map(|&a| {
778 alloc::format!(
779 "`{}` is derived by a RULE that has not fired — assert that rule's antecedent",
780 self.label(a),
781 )
782 }),
783 }
784 }
785
786 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
792 let mut visited = vec![false; self.c.atoms.len()];
793 let mut out = Vec::new();
794 for &a in causes {
795 self.trace_dfs(a, &mut visited, &mut out);
796 }
797 out
798 }
799
800 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
801 if visited[a as usize] {
802 return;
803 }
804 visited[a as usize] = true;
805 let value = match v3_to_value(self.model[a as usize]) {
806 Some(v) => v,
807 None => return, };
809 let reason = match &self.reason[a as usize] {
810 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
811 Some(AtomReason::Derived { origin, from }) => {
812 for &f in from {
813 self.trace_dfs(f, visited, out); }
815 TraceReason::Derived {
816 origin: origin.clone(),
817 from: from.iter().map(|&f| self.label(f)).collect(),
818 }
819 }
820 None => return,
821 };
822 out.push(TraceStep {
823 atom: self.label(a),
824 value,
825 reason,
826 });
827 }
828
829 fn backward_pass(&mut self) -> Option<String> {
836 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
837 return None;
838 }
839 let (cnf, project) = build_cnf(self.c);
840 let found = sat::models(&cnf, &project, 2);
841 match found.len() {
842 0 if self.conflicts.is_empty() => {
843 self.unsat_core = minimal_unsat_core(self.c);
844 self.conflicts.push(RawConflict {
845 origin: Origin {
846 source: String::from("<system>"),
847 line: 0,
848 premise: None,
849 kind: KIND_UNSAT,
850 },
851 atoms: vec![String::from(
852 "the premises and facts are jointly unsatisfiable",
853 )],
854 cause: Vec::new(),
855 });
856 None
857 }
858 n if n >= 2 => {
859 let (m0, m1) = (&found[0], &found[1]);
860 project
861 .iter()
862 .find(|&&v| m0[v as usize] != m1[v as usize])
863 .map(|&v| self.label(v))
864 .or_else(|| Some(String::from("a free atom")))
865 }
866 _ => None,
867 }
868 }
869
870 fn finish(mut self) -> Report {
872 let underdetermined = self.backward_pass();
873 self.conflicts.sort_by_key(|c| key(&c.origin));
874 self.warnings.sort_by_key(|w| key(&w.origin));
875 let status = if !self.conflicts.is_empty() {
876 Status::Conflict
877 } else if underdetermined.is_some() {
878 Status::Underdetermined
879 } else if !self.warnings.is_empty() {
880 Status::Warning
881 } else {
882 Status::Consistent
883 };
884 let conflicts: Vec<Conflict> = self
887 .conflicts
888 .iter()
889 .map(|rc| Conflict {
890 origin: rc.origin.clone(),
891 atoms: rc.atoms.clone(),
892 trace: self.build_trace(&rc.cause),
893 })
894 .collect();
895 Report {
896 status,
897 conflicts,
898 warnings: self.warnings,
899 derived: self.derived,
900 underdetermined,
901 unsat_core: self.unsat_core,
902 retract: Vec::new(), hints: Vec::new(), orphans: Vec::new(), unused_imports: Vec::new(), placeholders: Vec::new(), }
908 }
909}
910
911pub fn solve(c: &Compiled) -> Report {
914 let mut e = Eval::new(c);
915 e.seed_facts();
916 e.saturate_rules();
917 e.check_premises();
918 let mut report = e.finish();
919 if report.status == Status::Conflict {
925 let retract = retract_assumptions(c);
926 if !retract.is_empty() {
927 report.unsat_core = Vec::new();
928 report.retract = retract;
929 }
930 }
931 report.hints = similar_atom_pairs(c);
934 report.orphans = orphan_facts(c);
937 report.unused_imports = c.unused_imports.clone();
940 report.placeholders = c.placeholders.clone();
943 report
944}
945
946fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
956 let mut referenced = vec![false; c.atoms.len()];
957 for clause in &c.clauses {
958 for l in &clause.lits {
959 referenced[l.atom as usize] = true;
960 }
961 }
962 for r in &c.rules {
963 for l in r.antecedent.iter().chain(r.consequent.iter()) {
964 referenced[l.atom as usize] = true;
965 }
966 }
967 for &a in &c.consumed {
969 referenced[a as usize] = true;
970 }
971 let mut out: Vec<OrphanFact> = c
972 .facts
973 .iter()
974 .filter(|f| !referenced[f.atom as usize])
975 .map(|f| OrphanFact {
976 atom: label(c, f.atom),
977 value: f.value,
978 origin: f.origin.clone(),
979 })
980 .collect();
981 out.sort_by_key(|o| key(&o.origin));
982 out
983}
984
985fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
1001 if !c.facts.iter().any(|f| f.soft) {
1002 return Vec::new();
1003 }
1004 let all = constructs(c);
1005 let is_soft: Vec<bool> = (0..all.len())
1007 .map(|i| i < c.facts.len() && c.facts[i].soft)
1008 .collect();
1009
1010 let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
1013 if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
1014 return Vec::new();
1015 }
1016 let mut active = vec![true; all.len()];
1018 if subset_is_sat(c.atoms.len(), &all, &active) {
1019 return Vec::new();
1020 }
1021 for i in 0..all.len() {
1023 if active[i] && is_soft[i] {
1024 active[i] = false;
1025 if subset_is_sat(c.atoms.len(), &all, &active) {
1026 active[i] = true; }
1028 }
1029 }
1030 let mut core: Vec<CoreItem> = (0..all.len())
1031 .filter(|&i| active[i] && is_soft[i])
1032 .map(|i| {
1033 let f = &c.facts[i];
1034 let label = if matches!(f.value, Value::False) {
1036 alloc::format!("NOT {}", label(c, f.atom))
1037 } else {
1038 label(c, f.atom)
1039 };
1040 CoreItem {
1041 origin: f.origin.clone(),
1042 label,
1043 }
1044 })
1045 .collect();
1046 core.sort_by_key(|it| key(&it.origin));
1047 core
1048}
1049
1050fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
1070 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
1071 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
1072 let mut consumed = vec![false; c.atoms.len()];
1075 for &a in &c.consumed {
1076 consumed[a as usize] = true;
1077 }
1078 let mut out = Vec::new();
1079 for i in 0..c.atoms.len() {
1080 if consumed[i] {
1081 continue;
1082 }
1083 for j in (i + 1)..c.atoms.len() {
1084 if consumed[j] {
1085 continue;
1086 }
1087 if let Some(reason) = atoms_look_similar(
1088 &c.atoms[i],
1089 &folded[i],
1090 cased[i],
1091 &c.atoms[j],
1092 &folded[j],
1093 cased[j],
1094 ) {
1095 out.push(SimilarAtoms {
1096 a: label(c, i as AtomId),
1097 b: label(c, j as AtomId),
1098 reason,
1099 });
1100 }
1101 }
1102 }
1103 out
1104}
1105
1106fn fold_atom(k: &AtomKey) -> Vec<char> {
1110 let mut raw = String::new();
1111 raw.push_str(&k.subject);
1112 if let Some(p) = &k.predicate {
1113 raw.push(' ');
1114 raw.push_str(p);
1115 }
1116 if let Some(o) = &k.object {
1117 raw.push(' ');
1118 raw.push_str(o);
1119 }
1120 let mut out: Vec<char> = Vec::new();
1121 let mut prev_space = false;
1122 for ch in raw.chars() {
1123 if ch == '_' || ch.is_whitespace() {
1124 if !prev_space && !out.is_empty() {
1125 out.push(' ');
1126 prev_space = true;
1127 }
1128 } else {
1129 for lc in ch.to_lowercase() {
1130 out.push(lc);
1131 }
1132 prev_space = false;
1133 }
1134 }
1135 if out.last() == Some(&' ') {
1136 out.pop();
1137 }
1138 out
1139}
1140
1141fn is_cased_alphabetic(folded: &[char]) -> bool {
1148 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
1149}
1150
1151fn atoms_look_similar(
1154 ka: &AtomKey,
1155 fa: &[char],
1156 cased_a: bool,
1157 kb: &AtomKey,
1158 fb: &[char],
1159 cased_b: bool,
1160) -> Option<&'static str> {
1161 if fa == fb && ka.domain == kb.domain {
1165 return Some("same name up to case, '_', or spaces");
1166 }
1167 if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
1172 return None;
1173 }
1174 if fa.len().abs_diff(fb.len()) > 1 {
1175 return None; }
1177 let min_len = fa.len().min(fb.len());
1178 if min_len >= 5 && levenshtein(fa, fb) == 1 {
1179 return Some("looks like a one-character typo of each other");
1180 }
1181 None
1182}
1183
1184fn clause_lit(l: &Lit) -> sat::SatLit {
1189 sat::SatLit::new(l.atom, l.negated)
1190}
1191
1192fn fact_lit(f: &Fact) -> sat::SatLit {
1194 match f.value {
1195 Value::True => sat::SatLit::positive(f.atom),
1196 Value::False => sat::SatLit::negative(f.atom),
1197 }
1198}
1199
1200fn rule_consequent_clause(r: &Rule, cons: &Lit) -> Vec<sat::SatLit> {
1205 let mut lits: Vec<sat::SatLit> = r.antecedent.iter().map(clause_lit).collect();
1206 lits.push(sat::SatLit::new(cons.atom, !cons.negated));
1207 lits
1208}
1209
1210fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
1215 let mut cnf = sat::Cnf::new(c.atoms.len());
1216 let mut constrained = vec![false; c.atoms.len()];
1217
1218 let add_constraining =
1222 |cnf: &mut sat::Cnf, constrained: &mut [bool], lits: Vec<sat::SatLit>| {
1223 for l in &lits {
1224 constrained[l.var() as usize] = true;
1225 }
1226 cnf.add_clause(lits);
1227 };
1228
1229 for clause in &c.clauses {
1231 add_constraining(
1232 &mut cnf,
1233 &mut constrained,
1234 clause.lits.iter().map(clause_lit).collect(),
1235 );
1236 }
1237 for r in &c.rules {
1239 for cons in &r.consequent {
1240 add_constraining(&mut cnf, &mut constrained, rule_consequent_clause(r, cons));
1241 }
1242 }
1243 for f in &c.facts {
1245 cnf.add_clause(vec![fact_lit(f)]);
1246 }
1247
1248 let project = (0..c.atoms.len() as AtomId)
1249 .filter(|&a| constrained[a as usize])
1250 .collect();
1251 (cnf, project)
1252}
1253
1254fn v3_to_value(v: V3) -> Option<Value> {
1256 match v {
1257 V3::True => Some(Value::True),
1258 V3::False => Some(Value::False),
1259 V3::Unknown => None,
1260 }
1261}
1262
1263struct Construct {
1266 origin: Origin,
1267 label: String,
1268 clauses: Vec<Vec<sat::SatLit>>,
1269}
1270
1271fn same_origin(a: &Origin, b: &Origin) -> bool {
1273 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1274}
1275
1276fn constructs(c: &Compiled) -> Vec<Construct> {
1280 let mut out: Vec<Construct> = Vec::new();
1281
1282 for f in &c.facts {
1283 out.push(Construct {
1284 origin: f.origin.clone(),
1285 label: label(c, f.atom),
1286 clauses: vec![vec![fact_lit(f)]],
1287 });
1288 }
1289
1290 let mut premises: Vec<Construct> = Vec::new();
1291 for clause in &c.clauses {
1292 let lits: Vec<sat::SatLit> = clause.lits.iter().map(clause_lit).collect();
1293 match premises
1294 .iter_mut()
1295 .find(|k| same_origin(&k.origin, &clause.origin))
1296 {
1297 Some(k) => k.clauses.push(lits),
1298 None => premises.push(Construct {
1299 label: clause.origin.premise.clone().unwrap_or_default(),
1300 origin: clause.origin.clone(),
1301 clauses: vec![lits],
1302 }),
1303 }
1304 }
1305 out.extend(premises);
1306
1307 for r in &c.rules {
1308 let clauses = r
1309 .consequent
1310 .iter()
1311 .map(|cons| rule_consequent_clause(r, cons))
1312 .collect();
1313 out.push(Construct {
1314 label: r.origin.premise.clone().unwrap_or_default(),
1315 origin: r.origin.clone(),
1316 clauses,
1317 });
1318 }
1319 out
1320}
1321
1322fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1324 let mut cnf = sat::Cnf::new(num_vars);
1325 for (k, &keep) in all.iter().zip(active) {
1326 if keep {
1327 for cl in &k.clauses {
1328 cnf.add_clause(cl.clone());
1329 }
1330 }
1331 }
1332 sat::solve(&cnf).is_some()
1333}
1334
1335fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1341 let base = c.atoms.len();
1342 let mut cnf = sat::Cnf::new(base + all.len());
1343 let sel = |i: usize| (base + i) as sat::Var;
1344 for (i, k) in all.iter().enumerate() {
1345 let s_neg = sat::SatLit::negative(sel(i));
1346 for cl in &k.clauses {
1347 let mut lits = Vec::with_capacity(cl.len() + 1);
1348 lits.push(s_neg);
1349 lits.extend_from_slice(cl);
1350 cnf.add_clause(lits);
1351 }
1352 }
1353 let assumptions: Vec<sat::SatLit> = (0..all.len())
1354 .map(|i| sat::SatLit::positive(sel(i)))
1355 .collect();
1356 let mut active = vec![false; all.len()];
1357 match sat::solve_assuming(&cnf, &assumptions) {
1358 sat::Solved::Unsat(core) => {
1359 for lit in core {
1360 let v = lit.var() as usize;
1361 if v >= base {
1362 active[v - base] = true;
1363 }
1364 }
1365 }
1366 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1370 }
1371 active
1372}
1373
1374fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1380 let all = constructs(c);
1381 let mut active = candidate_via_assumptions(c, &all);
1382 for i in 0..all.len() {
1383 if active[i] {
1384 active[i] = false;
1385 if subset_is_sat(c.atoms.len(), &all, &active) {
1386 active[i] = true; }
1388 }
1389 }
1390 let mut core: Vec<CoreItem> = all
1391 .iter()
1392 .zip(&active)
1393 .filter(|&(_, &keep)| keep)
1394 .map(|(k, _)| CoreItem {
1395 origin: k.origin.clone(),
1396 label: k.label.clone(),
1397 })
1398 .collect();
1399 core.sort_by_key(|it| key(&it.origin));
1400 core
1401}
1402
1403fn key(o: &Origin) -> (String, u32) {
1405 (o.source.clone(), o.line)
1406}
1407
1408pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1410 verify_source_with(name, src, &[])
1411}
1412
1413pub fn verify_source_with(
1416 name: &str,
1417 src: &str,
1418 inputs: &[(String, PortBinding)],
1419) -> Result<Report, CompileError> {
1420 Ok(solve(&compile_source_with(name, src, inputs)?))
1421}
1422
1423pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1425 verify_with(root, resolver, &[])
1426}
1427
1428pub fn verify_with<R: Resolver>(
1430 root: &str,
1431 resolver: &R,
1432 inputs: &[(String, PortBinding)],
1433) -> Result<Report, CompileError> {
1434 Ok(solve(&compile_with(root, resolver, inputs)?))
1435}
1436
1437impl fmt::Display for Status {
1440 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1441 f.write_str(status_name(*self))
1442 }
1443}
1444
1445fn premise_tag(o: &Origin) -> String {
1447 let name = o.premise.as_deref().unwrap_or("-");
1448 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1449}
1450
1451fn trace_line(step: &TraceStep) -> String {
1453 let v = match step.value {
1454 Value::True => "TRUE",
1455 Value::False => "FALSE",
1456 };
1457 match &step.reason {
1458 TraceReason::Asserted(o) => {
1459 alloc::format!(
1460 "{} = {} [{} {}:{}]",
1461 step.atom,
1462 v,
1463 o.kind,
1464 o.source,
1465 o.line
1466 )
1467 }
1468 TraceReason::Derived { origin, from } => alloc::format!(
1469 "{} = {} from {} ({}) [{}:{}] <= {}",
1470 step.atom,
1471 v,
1472 origin.premise.as_deref().unwrap_or("-"),
1473 origin.kind,
1474 origin.source,
1475 origin.line,
1476 from.join(", ")
1477 ),
1478 }
1479}
1480
1481mod indent {
1487 pub const ROOT: usize = 0;
1489 pub const SECTION: usize = 2;
1492 pub const ITEM: usize = 6;
1494 pub const NESTED: usize = 8;
1496}
1497
1498struct ReportWriter<'a, 'b> {
1501 f: &'a mut fmt::Formatter<'b>,
1502}
1503
1504impl ReportWriter<'_, '_> {
1505 fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1507 write!(self.f, "{:width$}{}", "", args, width = indent)?;
1508 self.f.write_str("\n")
1509 }
1510
1511 fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1514 write!(self.f, "{:width$}{}", "", args, width = indent)
1515 }
1516}
1517
1518macro_rules! emit {
1522 ($out:expr, $indent:expr, $($arg:tt)*) => {
1523 $out.line($indent, format_args!($($arg)*))
1524 };
1525}
1526
1527impl Report {
1528 fn render(&self, f: &mut fmt::Formatter<'_>, show_placeholders: bool) -> fmt::Result {
1532 use indent::{ITEM, NESTED, ROOT, SECTION};
1533 let mut out = ReportWriter { f };
1534
1535 emit!(out, ROOT, "RESULT: {}", self.status)?;
1536
1537 if !self.retract.is_empty() {
1541 emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
1545 emit!(
1546 out,
1547 ITEM,
1548 "But these ASSUME guesses cannot all be true together."
1549 )?;
1550 emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1551 for it in &self.retract {
1552 emit!(
1553 out,
1554 ITEM,
1555 "ASSUME {} [{}:{}]",
1556 it.label,
1557 it.origin.source,
1558 it.origin.line
1559 )?;
1560 }
1561 } else {
1562 for c in &self.conflicts {
1563 emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
1564 for a in &c.atoms {
1565 emit!(out, ITEM, "{}", a)?;
1566 }
1567 if !c.trace.is_empty() {
1568 emit!(out, ITEM, "why:")?;
1569 for step in &c.trace {
1570 emit!(out, NESTED, "{}", trace_line(step))?;
1571 }
1572 }
1573 }
1574 if !self.unsat_core.is_empty() {
1575 emit!(
1576 out,
1577 SECTION,
1578 "CORE smallest jointly-unsatisfiable set ({}):",
1579 self.unsat_core.len()
1580 )?;
1581 for it in &self.unsat_core {
1582 let name = if it.label.is_empty() { "-" } else { &it.label };
1583 emit!(
1584 out,
1585 NESTED,
1586 "{} ({}) [{}:{}]",
1587 name,
1588 it.origin.kind,
1589 it.origin.source,
1590 it.origin.line
1591 )?;
1592 }
1593 }
1594 }
1595
1596 let mut shown_fixes: Vec<&str> = Vec::new();
1601 for w in &self.warnings {
1602 emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
1603 emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1604 if let Some(hint) = &w.hint
1605 && !shown_fixes.contains(&hint.as_str())
1606 {
1607 shown_fixes.push(hint);
1608 emit!(out, ITEM, "fix: {hint}")?;
1609 }
1610 }
1611 if let Some(atom) = &self.underdetermined {
1612 emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
1613 emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
1614 }
1615 for d in &self.derived {
1616 let v = match d.value {
1617 Value::True => "TRUE",
1618 Value::False => "FALSE",
1619 };
1620 emit!(
1621 out,
1622 SECTION,
1623 "DERIVED {} = {} from {}",
1624 d.atom,
1625 v,
1626 premise_tag(&d.origin)
1627 )?;
1628 }
1629 for h in &self.hints {
1630 emit!(
1631 out,
1632 SECTION,
1633 "HINT possible typo — '{}' and '{}' look like the same atom ({})",
1634 h.a,
1635 h.b,
1636 h.reason
1637 )?;
1638 }
1639 for o in &self.orphans {
1640 let surface = if o.origin.kind == kw::ASSUME && matches!(o.value, Value::False) {
1643 alloc::format!("{} {} {}", kw::ASSUME, kw::NOT, o.atom)
1644 } else {
1645 alloc::format!("{} {}", o.origin.kind, o.atom)
1646 };
1647 emit!(
1648 out,
1649 SECTION,
1650 "ORPHAN {} — not used by any premise or rule (no effect on the result)",
1651 surface
1652 )?;
1653 }
1654 for u in &self.unused_imports {
1655 let via = match &u.alias {
1656 Some(a) => alloc::format!("{} AS {}", u.domain, a),
1657 None => u.domain.clone(),
1658 };
1659 emit!(
1660 out,
1661 SECTION,
1662 "UNUSED IMPORT {} — imported in {}:{} but never referenced (no effect on the result)",
1663 via,
1664 u.file,
1665 u.line
1666 )?;
1667 }
1668 if show_placeholders {
1671 for p in &self.placeholders {
1672 match p.status {
1673 PlaceholderStatus::Supplied => emit!(
1674 out,
1675 SECTION,
1676 "PARAM {} = {} (supplied{})",
1677 p.key,
1678 bool_word(p.value),
1679 p.origin
1680 .as_deref()
1681 .map(|o| alloc::format!(": {o}"))
1682 .unwrap_or_default()
1683 )?,
1684 PlaceholderStatus::DefaultUsed => emit!(
1685 out,
1686 SECTION,
1687 "PARAM {} = {} (DEFAULT)",
1688 p.key,
1689 bool_word(p.value)
1690 )?,
1691 PlaceholderStatus::Unset => emit!(
1692 out,
1693 SECTION,
1694 "PARAM {} = UNKNOWN (no value supplied, no DEFAULT)",
1695 p.key
1696 )?,
1697 }
1698 }
1699 }
1700
1701 let underdetermined = usize::from(self.status == Status::Underdetermined);
1702 emit!(
1703 out,
1704 ROOT,
1705 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1706 self.conflicts.len(),
1707 underdetermined,
1708 self.warnings.len(),
1709 self.derived.len()
1710 )?;
1711 out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1712 }
1713
1714 pub fn render_human(&self, show_placeholders: bool) -> String {
1718 alloc::format!(
1719 "{}",
1720 HumanReport {
1721 report: self,
1722 show_placeholders
1723 }
1724 )
1725 }
1726}
1727
1728struct HumanReport<'a> {
1731 report: &'a Report,
1732 show_placeholders: bool,
1733}
1734
1735impl fmt::Display for HumanReport<'_> {
1736 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1737 self.report.render(f, self.show_placeholders)
1738 }
1739}
1740
1741impl fmt::Display for Report {
1742 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1743 self.render(f, true)
1744 }
1745}
1746
1747fn bool_word(v: Option<bool>) -> &'static str {
1750 match v {
1751 Some(true) => "true",
1752 Some(false) => "false",
1753 None => "UNKNOWN",
1754 }
1755}
1756
1757#[cfg(test)]
1758mod tests {
1759 use super::*;
1760
1761 fn vs(src: &str) -> Result<Report, CompileError> {
1764 verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
1765 }
1766
1767 #[test]
1768 fn clean_consistent() {
1769 let r = vs("FACT x a\nCHECK x\n").unwrap();
1770 assert_eq!(r.status, Status::Consistent);
1771 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1772 }
1773
1774 #[test]
1775 fn fact_contradiction_is_conflict() {
1776 let r = vs("FACT x a\nNOT x a\n").unwrap();
1777 assert_eq!(r.status, Status::Conflict);
1778 assert_eq!(r.conflicts.len(), 1);
1779 }
1780
1781 #[test]
1782 fn exclusive_violation_is_conflict() {
1783 let src = include_str!("../../../docs/examples/conflict.vrf");
1784 let r = verify_source("conflict.vrf", src).unwrap();
1785 assert_eq!(r.status, Status::Conflict);
1786 assert_eq!(
1787 r.conflicts[0].origin.premise.as_deref(),
1788 Some("fly_xor_swim")
1789 );
1790 assert_eq!(r.conflicts[0].atoms.len(), 2);
1791 }
1792
1793 #[test]
1794 fn exclusive_with_unknown_is_consistent_not_warning() {
1795 let r = vs(r"
1797 FACT A has flying
1798 PREMISE e:
1799 EXCLUSIVE
1800 A has flying
1801 A has swimming
1802 ")
1803 .unwrap();
1804 assert_eq!(r.status, Status::Consistent);
1805 assert!(r.warnings.is_empty());
1806 }
1807
1808 #[test]
1809 fn implication_missing_consequent_is_warning() {
1810 let r = vs(r#"
1812 FACT A has flying
1813 PREMISE w:
1814 WHEN A has flying
1815 THEN A has wing
1816 "#)
1817 .unwrap();
1818 assert_eq!(r.status, Status::Warning);
1819 assert_eq!(r.warnings.len(), 1);
1820 assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
1821 }
1822
1823 #[test]
1824 fn warning_hint_points_at_rule_when_atom_is_a_free_input() {
1825 let r = vs(r"
1828 FACT A has flying
1829 PREMISE w:
1830 WHEN A has flying
1831 THEN A has wing
1832 ")
1833 .unwrap();
1834 let hint = r.warnings[0].hint.as_deref().unwrap();
1835 assert!(hint.contains("make that PREMISE a RULE"), "{hint}");
1836 assert!(hint.contains("t.A has wing"), "{hint}");
1837 }
1838
1839 #[test]
1840 fn warning_hint_points_at_antecedent_when_a_rule_could_derive_it() {
1841 let r = vs(r"
1844 RULE d:
1845 WHEN x trigger
1846 THEN c ready
1847 FACT go now
1848 PREMISE p:
1849 WHEN go now
1850 THEN c ready
1851 ")
1852 .unwrap();
1853 assert_eq!(r.status, Status::Warning);
1854 let hint = r.warnings[0].hint.as_deref().unwrap();
1855 assert!(
1856 hint.contains("derived by a RULE that has not fired"),
1857 "{hint}"
1858 );
1859 }
1860
1861 #[test]
1862 fn human_report_dedupes_repeated_fix_lines() {
1863 let r = vs(r"
1869 FACT a on
1870 FACT b on
1871 PREMISE p1:
1872 WHEN a on
1873 THEN gate one_ok
1874 PREMISE p2:
1875 WHEN b on
1876 THEN gate one_ok
1877 PREMISE p3:
1878 WHEN a on
1879 THEN gate two_ok
1880 ")
1881 .unwrap();
1882 assert_eq!(r.warnings.len(), 3);
1883 assert!(r.warnings.iter().all(|w| w.hint.is_some()));
1885 let distinct_hints: BTreeSet<&str> = r
1886 .warnings
1887 .iter()
1888 .filter_map(|w| w.hint.as_deref())
1889 .collect();
1890 assert_eq!(distinct_hints.len(), 2);
1891 let text = alloc::format!("{r}");
1893 let shown = text
1894 .lines()
1895 .filter(|l| l.trim_start().starts_with("fix:"))
1896 .count();
1897 assert_eq!(shown, distinct_hints.len());
1898 }
1899
1900 #[test]
1901 fn implication_satisfied_is_consistent() {
1902 let r = vs(r"
1903 FACT A has flying
1904 FACT A has wing
1905 PREMISE w:
1906 WHEN A has flying
1907 THEN A has wing
1908 ")
1909 .unwrap();
1910 assert_eq!(r.status, Status::Consistent);
1911 }
1912
1913 #[test]
1914 fn implication_violated_is_conflict() {
1915 let r = vs(r"
1917 FACT A has flying
1918 NOT A has wing
1919 PREMISE w:
1920 WHEN A has flying
1921 THEN A has wing
1922 ")
1923 .unwrap();
1924 assert_eq!(r.status, Status::Conflict);
1925 }
1926
1927 #[test]
1928 fn rule_derives_fact() {
1929 let r = vs(r#"
1930 FACT A has flying
1931 RULE o:
1932 WHEN A has flying
1933 THEN A needs oxygen
1934 "#)
1935 .unwrap();
1936 assert_eq!(r.status, Status::Consistent);
1937 assert_eq!(r.derived.len(), 1);
1938 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1939 }
1940
1941 #[test]
1942 fn rule_derivation_contradiction_is_conflict() {
1943 let r = vs(r"
1945 FACT A has flying
1946 NOT A needs oxygen
1947 RULE o:
1948 WHEN A has flying
1949 THEN A needs oxygen
1950 ")
1951 .unwrap();
1952 assert_eq!(r.status, Status::Conflict);
1953 }
1954
1955 #[test]
1956 fn bidirectional_finds_alternative_model_underdetermined() {
1957 let r = vs(r#"
1959 PREMISE e:
1960 EXCLUSIVE
1961 x a
1962 x b
1963 CHECK x BIDIRECTIONAL
1964 "#)
1965 .unwrap();
1966 assert_eq!(r.status, Status::Underdetermined);
1967 }
1968
1969 #[test]
1970 fn fact_pins_unique_model_consistent() {
1971 let r = vs(r#"
1973 FACT x a
1974 PREMISE e:
1975 EXCLUSIVE
1976 x a
1977 x b
1978 CHECK x BIDIRECTIONAL
1979 "#)
1980 .unwrap();
1981 assert_eq!(r.status, Status::Consistent);
1982 }
1983
1984 #[test]
1985 fn no_bidirectional_skips_backward_pass() {
1986 let r = vs(r#"
1988 PREMISE e:
1989 EXCLUSIVE
1990 x a
1991 x b
1992 CHECK x
1993 "#)
1994 .unwrap();
1995 assert_eq!(r.status, Status::Consistent);
1996 }
1997
1998 #[test]
1999 fn creature_example_forward_pass() {
2000 let src = include_str!("../../../docs/examples/creature.vrf");
2001 let r = verify_source("creature.vrf", src).unwrap();
2002 assert_eq!(r.status, Status::Warning);
2005 assert!(r.conflicts.is_empty());
2006 assert_eq!(r.warnings.len(), 2);
2007 assert_eq!(r.derived.len(), 1);
2008 assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
2009 }
2010
2011 #[test]
2012 fn roles_puzzle_is_uniquely_solved() {
2013 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
2017 let r = verify_source("roles-puzzle.vrf", src).unwrap();
2018 assert_eq!(r.status, Status::Consistent);
2019 assert!(r.conflicts.is_empty());
2020 assert!(r.underdetermined.is_none());
2021 }
2022
2023 #[test]
2024 fn roles_puzzle_underdetermined_without_a_clue() {
2025 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
2030 .replace("\r\n", "\n")
2031 .replace("NOT bob is qa\n", "");
2032 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
2033 assert_eq!(r.status, Status::Underdetermined);
2034 }
2035
2036 #[test]
2037 fn socrates_chain_is_a_conflict() {
2038 let src = include_str!("../../../docs/examples/socrates.vrf");
2041 let r = verify_source("socrates.vrf", src).unwrap();
2042 assert_eq!(r.status, Status::Conflict);
2043 assert_eq!(r.conflicts.len(), 1);
2044 assert_eq!(
2045 r.conflicts[0].origin.premise.as_deref(),
2046 Some("mortal_xor_immortal")
2047 );
2048 assert_eq!(r.derived.len(), 3); }
2050
2051 #[test]
2054 fn forward_conflict_carries_a_trace_of_its_facts() {
2055 let r = vs(r#"
2056 FACT x a
2057 FACT x b
2058 PREMISE e:
2059 EXCLUSIVE
2060 x a
2061 x b
2062 CHECK x
2063 "#)
2064 .unwrap();
2065 assert_eq!(r.status, Status::Conflict);
2066 let t = &r.conflicts[0].trace;
2067 assert_eq!(t.len(), 2);
2068 assert_eq!(t[0].atom, "t.x a");
2069 assert_eq!(t[0].value, Value::True);
2070 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
2071 assert!(r.unsat_core.is_empty());
2072 }
2073
2074 #[test]
2075 fn derivation_chain_is_traced_back_to_the_root_fact() {
2076 let src = include_str!("../../../docs/examples/socrates.vrf");
2078 let r = verify_source("socrates.vrf", src).unwrap();
2079 let t = &r.conflicts[0].trace;
2080 assert_eq!(t.len(), 5);
2083 assert_eq!(t[0].atom, "philosophy.socrates is human");
2084 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
2085 let mortal = t
2086 .iter()
2087 .find(|s| s.atom == "philosophy.socrates is mortal")
2088 .unwrap();
2089 match &mortal.reason {
2090 TraceReason::Derived { from, .. } => {
2091 assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
2092 }
2093 _ => panic!("mortal should be derived, not asserted"),
2094 }
2095 }
2096
2097 #[test]
2098 fn direct_fact_contradiction_has_no_trace() {
2099 let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
2100 assert_eq!(r.status, Status::Conflict);
2101 assert!(r.conflicts[0].trace.is_empty());
2102 }
2103
2104 #[test]
2105 fn jointly_unsatisfiable_reports_a_minimal_core() {
2106 let src = r#"
2109 PREMISE one:
2110 ONEOF
2111 x a
2112 x b
2113 PREMISE ac:
2114 WHEN x a
2115 THEN x c
2116 PREMISE bc:
2117 WHEN x b
2118 THEN x c
2119 NOT x c
2120 CHECK x BIDIRECTIONAL
2121 "#;
2122 let r = vs(src).unwrap();
2123 assert_eq!(r.status, Status::Conflict);
2124 assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
2125 assert_eq!(r.unsat_core.len(), 4);
2126 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2127 assert!(labels.contains(&"one"));
2128 assert!(labels.contains(&"t.x c")); }
2130
2131 #[test]
2132 fn unsat_core_excludes_irrelevant_constructs() {
2133 let src = r#"
2136 PREMISE one:
2137 ONEOF
2138 x a
2139 x b
2140 PREMISE ac:
2141 WHEN x a
2142 THEN x c
2143 PREMISE bc:
2144 WHEN x b
2145 THEN x c
2146 NOT x c
2147 FACT z here
2148 PREMISE noise:
2149 EXCLUSIVE
2150 z here
2151 z gone
2152 CHECK x BIDIRECTIONAL
2153 "#;
2154 let r = vs(src).unwrap();
2155 assert_eq!(r.status, Status::Conflict);
2156 assert_eq!(r.unsat_core.len(), 4);
2157 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2158 assert!(!labels.contains(&"noise"));
2159 assert!(!labels.iter().any(|l| l.contains("here")));
2160 }
2161
2162 #[test]
2163 fn unsat_core_blames_the_rules_that_form_it() {
2164 let src = r#"
2169 PREMISE one:
2170 ONEOF
2171 x a
2172 x b
2173 RULE ac:
2174 WHEN x a
2175 THEN x c
2176 RULE bc:
2177 WHEN x b
2178 THEN x c
2179 NOT x c
2180 CHECK x BIDIRECTIONAL
2181 "#;
2182 let r = vs(src).unwrap();
2183 assert_eq!(r.status, Status::Conflict);
2184 assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
2185 assert_eq!(r.unsat_core.len(), 4);
2186 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2187 assert!(labels.contains(&"one"));
2188 assert!(labels.contains(&"ac"));
2189 assert!(labels.contains(&"bc"));
2190 assert!(labels.contains(&"t.x c")); }
2192
2193 #[test]
2194 fn consistent_report_has_empty_core_and_no_trace() {
2195 let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
2196 assert_eq!(r.status, Status::Consistent);
2197 assert!(r.unsat_core.is_empty());
2198 assert!(r.conflicts.is_empty());
2199 }
2200
2201 #[test]
2204 fn compatible_assumptions_behave_like_facts() {
2205 let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
2208 assert_eq!(r.status, Status::Consistent);
2209 assert!(r.retract.is_empty());
2210 assert!(r.conflicts.is_empty());
2211 }
2212
2213 #[test]
2214 fn assume_drives_a_rule_like_a_fact() {
2215 let r = vs(r"
2217 ASSUME A has flying
2218 RULE o:
2219 WHEN A has flying
2220 THEN A needs oxygen
2221 CHECK A
2222 ")
2223 .unwrap();
2224 assert_eq!(r.status, Status::Consistent);
2225 assert_eq!(r.derived.len(), 1);
2226 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
2227 }
2228
2229 #[test]
2230 fn clashing_assumptions_yield_conflict_with_a_retract_set() {
2231 let src = r#"
2234 FACT rel reviewed
2235 PREMISE prod_needs_safety:
2236 WHEN rel in_prod
2237 THEN rel has_rollback
2238 OR rel has_feature_flag
2239 ASSUME rel in_prod
2240 ASSUME NOT rel has_rollback
2241 ASSUME NOT rel has_feature_flag
2242 CHECK rel
2243 "#;
2244 let r = vs(src).unwrap();
2245 assert_eq!(r.status, Status::Conflict);
2246 assert_eq!(r.exit_code(), 2);
2247 assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
2249 let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
2250 assert!(labels.contains(&"t.rel in_prod"));
2251 assert!(labels.contains(&"NOT t.rel has_rollback"));
2252 assert!(labels.contains(&"NOT t.rel has_feature_flag"));
2253 assert!(r.retract.iter().all(|it| it.origin.kind == kw::ASSUME));
2255 let shown = alloc::format!("{r}");
2257 assert!(shown.contains("RETRACT"), "{shown}");
2258 assert!(!shown.contains("CONFLICT "), "{shown}");
2259 }
2260
2261 #[test]
2262 fn assume_vs_fact_retracts_only_the_assumption() {
2263 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2265 assert_eq!(r.status, Status::Conflict);
2266 assert_eq!(r.retract.len(), 1);
2267 assert_eq!(r.retract[0].label, "NOT t.x a");
2268 assert_eq!(r.retract[0].origin.kind, kw::ASSUME);
2269 }
2270
2271 #[test]
2272 fn hard_conflict_is_not_blamed_on_assumptions() {
2273 let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
2276 assert_eq!(r.status, Status::Conflict);
2277 assert!(r.retract.is_empty(), "{:?}", r.retract);
2278 }
2279
2280 #[test]
2281 fn two_assumptions_directly_contradict() {
2282 let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2283 assert_eq!(r.status, Status::Conflict);
2284 assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
2285 }
2286
2287 #[test]
2288 fn assume_retract_is_in_json() {
2289 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2290 let j = r.to_json();
2291 assert!(j.contains("\"retract\":["), "{j}");
2292 assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
2293 assert!(j.contains("NOT t.x a"), "{j}");
2294 }
2295
2296 #[test]
2299 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
2300 let r = vs(r#"
2304 FACT auth is rolled_back
2305 NOT auth is_rolled_back
2306 CHECK
2307 "#)
2308 .unwrap();
2309 assert_eq!(
2310 r.status,
2311 Status::Consistent,
2312 "hint must not change the verdict"
2313 );
2314 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
2315 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2316 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
2317 }
2318
2319 #[test]
2320 fn hint_flags_case_only_difference() {
2321 let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
2322 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2323 }
2324
2325 #[test]
2326 fn hint_flags_single_char_typo_same_subject() {
2327 let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
2329 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2330 }
2331
2332 #[test]
2333 fn no_hint_for_short_distinct_atoms() {
2334 let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
2336 assert!(r.hints.is_empty(), "{:?}", r.hints);
2337 }
2338
2339 #[test]
2340 fn no_hint_for_distinct_words() {
2341 let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
2342 assert!(r.hints.is_empty(), "{:?}", r.hints);
2343 }
2344
2345 #[test]
2346 fn russian_case_typo_is_flagged() {
2347 let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
2349 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2350 }
2351
2352 #[test]
2353 fn russian_single_char_typo_is_flagged() {
2354 let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
2355 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2356 }
2357
2358 #[test]
2359 fn cjk_one_char_difference_is_not_flagged() {
2360 let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
2363 assert!(r.hints.is_empty(), "{:?}", r.hints);
2364 }
2365
2366 #[test]
2367 fn cjk_underscore_vs_space_is_flagged() {
2368 let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
2371 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2372 }
2373
2374 #[test]
2377 fn orphan_fact_is_flagged_but_advisory_only() {
2378 let r = vs("FACT x a\nCHECK\n").unwrap();
2381 assert_eq!(
2382 r.status,
2383 Status::Consistent,
2384 "orphan must not change verdict"
2385 );
2386 assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
2387 assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
2388 assert_eq!(r.orphans[0].atom, "t.x a");
2389 assert_eq!(r.orphans[0].origin.kind, kw::FACT);
2390 }
2391
2392 #[test]
2393 fn fact_used_by_a_premise_is_not_orphan() {
2394 let r = vs(r"
2396 FACT x a
2397 PREMISE p:
2398 EXCLUSIVE
2399 x a
2400 x b
2401 CHECK
2402 ")
2403 .unwrap();
2404 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2405 }
2406
2407 #[test]
2408 fn fact_used_by_a_rule_antecedent_is_not_orphan() {
2409 let r = vs(r"
2410 FACT x a
2411 RULE r:
2412 WHEN x a
2413 THEN x c
2414 CHECK
2415 ")
2416 .unwrap();
2417 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2418 }
2419
2420 #[test]
2421 fn negation_and_assumption_orphans_keep_their_surface_polarity() {
2422 let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
2425 assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
2426 let text = alloc::format!("{r}");
2427 assert!(text.contains("ORPHAN NOT t.x a"), "{text}");
2428 assert!(text.contains("ORPHAN ASSUME NOT t.y b"), "{text}");
2429 }
2430
2431 #[test]
2432 fn orphan_is_in_json() {
2433 let r = vs("FACT x a\nCHECK\n").unwrap();
2434 let j = r.to_json();
2435 assert!(j.contains("\"orphans\":["), "{j}");
2436 assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
2437 assert!(j.contains("\"kind\":\"FACT\""), "{j}");
2438 }
2439
2440 #[test]
2441 fn unused_import_is_advisory_only_in_the_report() {
2442 let mut r = MemoryResolver::new();
2445 r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
2446 r.add(
2447 "main.vrf",
2448 "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\nCHECK\n",
2449 );
2450 let rep = verify("main.vrf", &r).unwrap();
2451 assert_eq!(rep.status, Status::Consistent);
2452 assert_eq!(rep.exit_code(), 0);
2453 assert_eq!(rep.unused_imports.len(), 1);
2454 assert_eq!(rep.unused_imports[0].domain, "physics");
2455 let text = alloc::format!("{rep}");
2456 assert!(text.contains("UNUSED IMPORT physics"), "{text}");
2457 assert!(
2458 rep.to_json().contains("\"unused_imports\":[{"),
2459 "{}",
2460 rep.to_json()
2461 );
2462 }
2463
2464 #[test]
2465 fn a_derived_atom_does_not_make_its_consumer_orphan() {
2466 let r = vs(r"
2469 FACT x a
2470 RULE r:
2471 WHEN x a
2472 THEN x c
2473 PREMISE p:
2474 WHEN x c
2475 THEN x d
2476 CHECK
2477 ")
2478 .unwrap();
2479 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2480 }
2481}