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;
57use elenchus_compiler::{
58 AtomId, AtomKey, Clause, Compiled, Fact, KIND_UNSAT, Lit, Origin, Rule, Value, kw, levenshtein,
59};
60pub use elenchus_compiler::{
61 CompileError, MemoryResolver, Resolver, UnusedImport, compile, compile_source,
62};
63
64pub const VERSION: &str = env!("CARGO_PKG_VERSION");
69
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72pub enum V3 {
73 True,
75 False,
77 Unknown,
79}
80
81impl V3 {
82 fn not(self) -> V3 {
84 match self {
85 V3::True => V3::False,
86 V3::False => V3::True,
87 V3::Unknown => V3::Unknown,
88 }
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Status {
95 Consistent,
97 Underdetermined,
100 Warning,
102 Conflict,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct Conflict {
109 pub origin: Origin,
111 pub atoms: Vec<String>,
113 pub trace: Vec<TraceStep>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct TraceStep {
125 pub atom: String,
127 pub value: Value,
129 pub reason: TraceReason,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub enum TraceReason {
136 Asserted(Origin),
138 Derived {
140 origin: Origin,
142 from: Vec<String>,
144 },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Warning {
150 pub origin: Origin,
152 pub blocked_by: Vec<String>,
154 pub hint: Option<String>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
164pub struct Derived {
165 pub atom: String,
167 pub value: Value,
169 pub origin: Origin,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct Report {
176 pub status: Status,
178 pub conflicts: Vec<Conflict>,
180 pub warnings: Vec<Warning>,
182 pub derived: Vec<Derived>,
184 pub underdetermined: Option<String>,
187 pub unsat_core: Vec<CoreItem>,
192 pub retract: Vec<CoreItem>,
200 pub hints: Vec<SimilarAtoms>,
203 pub orphans: Vec<OrphanFact>,
208 pub unused_imports: Vec<UnusedImport>,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct SimilarAtoms {
222 pub a: String,
224 pub b: String,
226 pub reason: &'static str,
228}
229
230#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct OrphanFact {
238 pub atom: String,
240 pub value: Value,
242 pub origin: Origin,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct CoreItem {
249 pub origin: Origin,
251 pub label: String,
253}
254
255impl Report {
256 pub fn exit_code(&self) -> i32 {
258 match self.status {
259 Status::Conflict => 2,
260 Status::Underdetermined | Status::Warning => 1,
261 Status::Consistent => 0,
262 }
263 }
264
265 pub fn to_json(&self) -> String {
268 use core::fmt::Write as _;
269 let mut s = String::new();
270 let _ = write!(s, "{{\"status\":");
271 json_str(status_name(self.status), &mut s);
272 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
273
274 s.push_str(",\"conflicts\":[");
275 for (i, c) in self.conflicts.iter().enumerate() {
276 if i > 0 {
277 s.push(',');
278 }
279 json_origin(&c.origin, &mut s);
280 s.push_str(",\"atoms\":");
281 json_array(&c.atoms, &mut s);
282 s.push_str(",\"trace\":[");
283 for (j, step) in c.trace.iter().enumerate() {
284 if j > 0 {
285 s.push(',');
286 }
287 json_trace_step(step, &mut s);
288 }
289 s.push_str("]}");
290 }
291 s.push_str("],\"warnings\":[");
292 for (i, w) in self.warnings.iter().enumerate() {
293 if i > 0 {
294 s.push(',');
295 }
296 json_origin(&w.origin, &mut s);
297 s.push_str(",\"blocked_by\":");
298 json_array(&w.blocked_by, &mut s);
299 s.push_str(",\"hint\":");
300 match &w.hint {
301 Some(h) => json_str(h, &mut s),
302 None => s.push_str("null"),
303 }
304 s.push('}');
305 }
306 s.push_str("],\"derived\":[");
307 for (i, d) in self.derived.iter().enumerate() {
308 if i > 0 {
309 s.push(',');
310 }
311 s.push('{');
312 json_origin_fields(&d.origin, &mut s);
313 s.push_str(",\"atom\":");
314 json_str(&d.atom, &mut s);
315 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
316 s.push('}');
317 }
318 s.push_str("],\"underdetermined\":");
319 match &self.underdetermined {
320 Some(atom) => json_str(atom, &mut s),
321 None => s.push_str("null"),
322 }
323 s.push_str(",\"unsat_core\":[");
324 for (i, it) in self.unsat_core.iter().enumerate() {
325 if i > 0 {
326 s.push(',');
327 }
328 json_origin(&it.origin, &mut s);
329 s.push_str(",\"label\":");
330 json_str(&it.label, &mut s);
331 s.push('}');
332 }
333 s.push_str("],\"retract\":[");
334 for (i, it) in self.retract.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("],\"hints\":[");
344 for (i, h) in self.hints.iter().enumerate() {
345 if i > 0 {
346 s.push(',');
347 }
348 s.push_str("{\"a\":");
349 json_str(&h.a, &mut s);
350 s.push_str(",\"b\":");
351 json_str(&h.b, &mut s);
352 s.push_str(",\"reason\":");
353 json_str(h.reason, &mut s);
354 s.push('}');
355 }
356 s.push_str("],\"orphans\":[");
357 for (i, o) in self.orphans.iter().enumerate() {
358 if i > 0 {
359 s.push(',');
360 }
361 json_origin(&o.origin, &mut s);
362 s.push_str(",\"atom\":");
363 json_str(&o.atom, &mut s);
364 let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
365 s.push('}');
366 }
367 s.push_str("],\"unused_imports\":[");
368 for (i, u) in self.unused_imports.iter().enumerate() {
369 if i > 0 {
370 s.push(',');
371 }
372 s.push_str("{\"file\":");
373 json_str(&u.file, &mut s);
374 s.push_str(",\"domain\":");
375 json_str(&u.domain, &mut s);
376 s.push_str(",\"alias\":");
377 match &u.alias {
378 Some(a) => json_str(a, &mut s),
379 None => s.push_str("null"),
380 }
381 let _ = write!(s, ",\"line\":{}", u.line);
382 s.push('}');
383 }
384 s.push_str("]}");
385 s
386 }
387}
388
389fn json_trace_step(step: &TraceStep, out: &mut String) {
391 use core::fmt::Write as _;
392 out.push_str("{\"atom\":");
393 json_str(&step.atom, out);
394 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
395 match &step.reason {
396 TraceReason::Asserted(o) => {
397 out.push_str(",\"how\":\"asserted\",");
398 json_origin_fields(o, out);
399 out.push_str(",\"from\":[]");
400 }
401 TraceReason::Derived { origin, from } => {
402 out.push_str(",\"how\":\"derived\",");
403 json_origin_fields(origin, out);
404 out.push_str(",\"from\":");
405 json_array(from, out);
406 }
407 }
408 out.push('}');
409}
410
411fn status_name(s: Status) -> &'static str {
412 match s {
413 Status::Consistent => "CONSISTENT",
414 Status::Underdetermined => "UNDERDETERMINED",
415 Status::Warning => "WARNING",
416 Status::Conflict => "CONFLICT",
417 }
418}
419
420fn json_str(value: &str, out: &mut String) {
422 use core::fmt::Write as _;
423 out.push('"');
424 for ch in value.chars() {
425 match ch {
426 '"' => out.push_str("\\\""),
427 '\\' => out.push_str("\\\\"),
428 '\n' => out.push_str("\\n"),
429 '\r' => out.push_str("\\r"),
430 '\t' => out.push_str("\\t"),
431 c if (c as u32) < 0x20 => {
432 let _ = write!(out, "\\u{:04x}", c as u32);
433 }
434 c => out.push(c),
435 }
436 }
437 out.push('"');
438}
439
440fn json_array(items: &[String], out: &mut String) {
442 out.push('[');
443 for (i, item) in items.iter().enumerate() {
444 if i > 0 {
445 out.push(',');
446 }
447 json_str(item, out);
448 }
449 out.push(']');
450}
451
452fn json_origin_fields(o: &Origin, out: &mut String) {
454 use core::fmt::Write as _;
455 out.push_str("\"premise\":");
456 match &o.premise {
457 Some(name) => json_str(name, out),
458 None => out.push_str("null"),
459 }
460 out.push_str(",\"kind\":");
461 json_str(o.kind, out);
462 out.push_str(",\"source\":");
463 json_str(&o.source, out);
464 let _ = write!(out, ",\"line\":{}", o.line);
465}
466
467fn json_origin(o: &Origin, out: &mut String) {
469 out.push('{');
470 json_origin_fields(o, out);
471}
472
473fn label(c: &Compiled, a: AtomId) -> String {
477 let k = &c.atoms[a as usize];
478 match &k.object {
479 Some(o) => alloc::format!("{}.{} {} {}", k.domain, k.subject, k.predicate, o),
480 None => alloc::format!("{}.{} {}", k.domain, k.subject, k.predicate),
481 }
482}
483
484fn lit_value(model: &[V3], l: &Lit) -> V3 {
486 let v = model[l.atom as usize];
487 if l.negated { v.not() } else { v }
488}
489
490fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
492 let mut result = V3::True;
493 for l in lits {
494 match lit_value(model, l) {
495 V3::False => return V3::False,
496 V3::Unknown => result = V3::Unknown,
497 V3::True => {}
498 }
499 }
500 result
501}
502
503enum ClauseEval {
505 Violated,
507 Satisfied,
509 Blocked(Vec<AtomId>),
511}
512
513fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
516 let mut any_false = false;
517 let mut all_true = true;
518 let mut blocked = Vec::new();
519 for l in &clause.lits {
520 match lit_value(model, l) {
521 V3::False => {
522 any_false = true;
523 all_true = false;
524 }
525 V3::Unknown => {
526 all_true = false;
527 blocked.push(l.atom);
528 }
529 V3::True => {}
530 }
531 }
532 if all_true {
533 ClauseEval::Violated
534 } else if any_false {
535 ClauseEval::Satisfied
536 } else {
537 ClauseEval::Blocked(blocked)
538 }
539}
540
541#[derive(Clone)]
544enum AtomReason {
545 Asserted(Origin),
547 Derived { origin: Origin, from: Vec<AtomId> },
549}
550
551struct RawConflict {
555 origin: Origin,
556 atoms: Vec<String>,
557 cause: Vec<AtomId>,
558}
559
560struct Eval<'a> {
562 c: &'a Compiled,
563 model: Vec<V3>,
564 reason: Vec<Option<AtomReason>>,
567 conflicts: Vec<RawConflict>,
568 warnings: Vec<Warning>,
569 derived: Vec<Derived>,
570 unsat_core: Vec<CoreItem>,
572}
573
574impl<'a> Eval<'a> {
575 fn new(c: &'a Compiled) -> Self {
576 Eval {
577 c,
578 model: vec![V3::Unknown; c.atoms.len()],
579 reason: vec![None; c.atoms.len()],
580 conflicts: Vec::new(),
581 warnings: Vec::new(),
582 derived: Vec::new(),
583 unsat_core: Vec::new(),
584 }
585 }
586
587 fn label(&self, a: AtomId) -> String {
588 label(self.c, a)
589 }
590
591 fn seed_facts(&mut self) {
593 let c = self.c;
594 let n = c.atoms.len();
595 let mut t: Vec<Option<Origin>> = vec![None; n];
596 let mut f: Vec<Option<Origin>> = vec![None; n];
597 for fact in &c.facts {
598 let slot = match fact.value {
599 Value::True => &mut t,
600 Value::False => &mut f,
601 };
602 if slot[fact.atom as usize].is_none() {
603 slot[fact.atom as usize] = Some(fact.origin.clone());
604 }
605 }
606 for a in 0..n {
607 match (&t[a], &f[a]) {
608 (Some(o), Some(_)) => {
609 self.model[a] = V3::True;
610 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
611 self.conflicts.push(RawConflict {
612 origin: o.clone(),
613 atoms: vec![alloc::format!(
614 "{} (asserted both TRUE and FALSE)",
615 self.label(a as AtomId)
616 )],
617 cause: Vec::new(),
618 });
619 }
620 (Some(o), None) => {
621 self.model[a] = V3::True;
622 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
623 }
624 (None, Some(o)) => {
625 self.model[a] = V3::False;
626 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
627 }
628 (None, None) => {}
629 }
630 }
631 }
632
633 fn saturate_rules(&mut self) {
635 let c = self.c;
636 loop {
637 let mut changed = false;
638 for r in &c.rules {
639 if conjunction(&self.model, &r.antecedent) != V3::True {
640 continue; }
642 for cl in &r.consequent {
643 let target = if cl.negated { V3::False } else { V3::True };
644 match self.model[cl.atom as usize] {
645 V3::Unknown => {
646 self.model[cl.atom as usize] = target;
647 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
648 origin: r.origin.clone(),
649 from: r.antecedent.iter().map(|l| l.atom).collect(),
650 });
651 changed = true;
652 self.derived.push(Derived {
653 atom: self.label(cl.atom),
654 value: if cl.negated {
655 Value::False
656 } else {
657 Value::True
658 },
659 origin: r.origin.clone(),
660 });
661 }
662 v if v == target => {}
663 _ => {
664 let mut cause: Vec<AtomId> =
668 r.antecedent.iter().map(|l| l.atom).collect();
669 cause.push(cl.atom);
670 self.conflicts.push(RawConflict {
671 origin: r.origin.clone(),
672 atoms: vec![alloc::format!(
673 "{} (derived value contradicts a known fact)",
674 self.label(cl.atom)
675 )],
676 cause,
677 });
678 }
679 }
680 }
681 }
682 if !changed {
683 break;
684 }
685 }
686 }
687
688 fn check_premises(&mut self) {
690 let c = self.c;
691 let derivable: BTreeSet<AtomId> = c
696 .rules
697 .iter()
698 .flat_map(|r| r.consequent.iter().map(|l| l.atom))
699 .collect();
700 for clause in &c.clauses {
701 match eval_clause(&self.model, clause) {
702 ClauseEval::Violated => self.conflicts.push(RawConflict {
703 origin: clause.origin.clone(),
704 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
705 cause: clause.lits.iter().map(|l| l.atom).collect(),
706 }),
707 ClauseEval::Satisfied => {}
708 ClauseEval::Blocked(unknowns) if clause.origin.kind == kw::PREMISE => {
711 let hint = self.warning_hint(&unknowns, &derivable);
712 self.warnings.push(Warning {
713 origin: clause.origin.clone(),
714 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
715 hint,
716 });
717 }
718 ClauseEval::Blocked(_) => {}
719 }
720 }
721 }
722
723 fn warning_hint(&self, unknowns: &[AtomId], derivable: &BTreeSet<AtomId>) -> Option<String> {
728 let free = unknowns.iter().find(|a| !derivable.contains(a));
729 match free {
730 Some(&a) => Some(alloc::format!(
731 "nothing determines `{}` — add `FACT {}` (or `NOT …`), or if a PREMISE's \
732 THEN is meant to establish it, make that PREMISE a RULE so it derives the value",
733 self.label(a),
734 self.label(a),
735 )),
736 None => unknowns.first().map(|&a| {
737 alloc::format!(
738 "`{}` is derived by a RULE that has not fired — assert that rule's antecedent",
739 self.label(a),
740 )
741 }),
742 }
743 }
744
745 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
751 let mut visited = vec![false; self.c.atoms.len()];
752 let mut out = Vec::new();
753 for &a in causes {
754 self.trace_dfs(a, &mut visited, &mut out);
755 }
756 out
757 }
758
759 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
760 if visited[a as usize] {
761 return;
762 }
763 visited[a as usize] = true;
764 let value = match v3_to_value(self.model[a as usize]) {
765 Some(v) => v,
766 None => return, };
768 let reason = match &self.reason[a as usize] {
769 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
770 Some(AtomReason::Derived { origin, from }) => {
771 for &f in from {
772 self.trace_dfs(f, visited, out); }
774 TraceReason::Derived {
775 origin: origin.clone(),
776 from: from.iter().map(|&f| self.label(f)).collect(),
777 }
778 }
779 None => return,
780 };
781 out.push(TraceStep {
782 atom: self.label(a),
783 value,
784 reason,
785 });
786 }
787
788 fn backward_pass(&mut self) -> Option<String> {
795 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
796 return None;
797 }
798 let (cnf, project) = build_cnf(self.c);
799 let found = sat::models(&cnf, &project, 2);
800 match found.len() {
801 0 if self.conflicts.is_empty() => {
802 self.unsat_core = minimal_unsat_core(self.c);
803 self.conflicts.push(RawConflict {
804 origin: Origin {
805 source: String::from("<system>"),
806 line: 0,
807 premise: None,
808 kind: KIND_UNSAT,
809 },
810 atoms: vec![String::from(
811 "the premises and facts are jointly unsatisfiable",
812 )],
813 cause: Vec::new(),
814 });
815 None
816 }
817 n if n >= 2 => {
818 let (m0, m1) = (&found[0], &found[1]);
819 project
820 .iter()
821 .find(|&&v| m0[v as usize] != m1[v as usize])
822 .map(|&v| self.label(v))
823 .or_else(|| Some(String::from("a free atom")))
824 }
825 _ => None,
826 }
827 }
828
829 fn finish(mut self) -> Report {
831 let underdetermined = self.backward_pass();
832 self.conflicts.sort_by_key(|c| key(&c.origin));
833 self.warnings.sort_by_key(|w| key(&w.origin));
834 let status = if !self.conflicts.is_empty() {
835 Status::Conflict
836 } else if underdetermined.is_some() {
837 Status::Underdetermined
838 } else if !self.warnings.is_empty() {
839 Status::Warning
840 } else {
841 Status::Consistent
842 };
843 let conflicts: Vec<Conflict> = self
846 .conflicts
847 .iter()
848 .map(|rc| Conflict {
849 origin: rc.origin.clone(),
850 atoms: rc.atoms.clone(),
851 trace: self.build_trace(&rc.cause),
852 })
853 .collect();
854 Report {
855 status,
856 conflicts,
857 warnings: self.warnings,
858 derived: self.derived,
859 underdetermined,
860 unsat_core: self.unsat_core,
861 retract: Vec::new(), hints: Vec::new(), orphans: Vec::new(), unused_imports: Vec::new(), }
866 }
867}
868
869pub fn solve(c: &Compiled) -> Report {
872 let mut e = Eval::new(c);
873 e.seed_facts();
874 e.saturate_rules();
875 e.check_premises();
876 let mut report = e.finish();
877 if report.status == Status::Conflict {
883 let retract = retract_assumptions(c);
884 if !retract.is_empty() {
885 report.unsat_core = Vec::new();
886 report.retract = retract;
887 }
888 }
889 report.hints = similar_atom_pairs(c);
892 report.orphans = orphan_facts(c);
895 report.unused_imports = c.unused_imports.clone();
898 report
899}
900
901fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
911 let mut referenced = vec![false; c.atoms.len()];
912 for clause in &c.clauses {
913 for l in &clause.lits {
914 referenced[l.atom as usize] = true;
915 }
916 }
917 for r in &c.rules {
918 for l in r.antecedent.iter().chain(r.consequent.iter()) {
919 referenced[l.atom as usize] = true;
920 }
921 }
922 for &a in &c.consumed {
924 referenced[a as usize] = true;
925 }
926 let mut out: Vec<OrphanFact> = c
927 .facts
928 .iter()
929 .filter(|f| !referenced[f.atom as usize])
930 .map(|f| OrphanFact {
931 atom: label(c, f.atom),
932 value: f.value,
933 origin: f.origin.clone(),
934 })
935 .collect();
936 out.sort_by_key(|o| key(&o.origin));
937 out
938}
939
940fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
956 if !c.facts.iter().any(|f| f.soft) {
957 return Vec::new();
958 }
959 let all = constructs(c);
960 let is_soft: Vec<bool> = (0..all.len())
962 .map(|i| i < c.facts.len() && c.facts[i].soft)
963 .collect();
964
965 let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
968 if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
969 return Vec::new();
970 }
971 let mut active = vec![true; all.len()];
973 if subset_is_sat(c.atoms.len(), &all, &active) {
974 return Vec::new();
975 }
976 for i in 0..all.len() {
978 if active[i] && is_soft[i] {
979 active[i] = false;
980 if subset_is_sat(c.atoms.len(), &all, &active) {
981 active[i] = true; }
983 }
984 }
985 let mut core: Vec<CoreItem> = (0..all.len())
986 .filter(|&i| active[i] && is_soft[i])
987 .map(|i| {
988 let f = &c.facts[i];
989 let label = if matches!(f.value, Value::False) {
991 alloc::format!("NOT {}", label(c, f.atom))
992 } else {
993 label(c, f.atom)
994 };
995 CoreItem {
996 origin: f.origin.clone(),
997 label,
998 }
999 })
1000 .collect();
1001 core.sort_by_key(|it| key(&it.origin));
1002 core
1003}
1004
1005fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
1025 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
1026 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
1027 let mut consumed = vec![false; c.atoms.len()];
1030 for &a in &c.consumed {
1031 consumed[a as usize] = true;
1032 }
1033 let mut out = Vec::new();
1034 for i in 0..c.atoms.len() {
1035 if consumed[i] {
1036 continue;
1037 }
1038 for j in (i + 1)..c.atoms.len() {
1039 if consumed[j] {
1040 continue;
1041 }
1042 if let Some(reason) = atoms_look_similar(
1043 &c.atoms[i],
1044 &folded[i],
1045 cased[i],
1046 &c.atoms[j],
1047 &folded[j],
1048 cased[j],
1049 ) {
1050 out.push(SimilarAtoms {
1051 a: label(c, i as AtomId),
1052 b: label(c, j as AtomId),
1053 reason,
1054 });
1055 }
1056 }
1057 }
1058 out
1059}
1060
1061fn fold_atom(k: &AtomKey) -> Vec<char> {
1065 let mut raw = String::new();
1066 raw.push_str(&k.subject);
1067 raw.push(' ');
1068 raw.push_str(&k.predicate);
1069 if let Some(o) = &k.object {
1070 raw.push(' ');
1071 raw.push_str(o);
1072 }
1073 let mut out: Vec<char> = Vec::new();
1074 let mut prev_space = false;
1075 for ch in raw.chars() {
1076 if ch == '_' || ch.is_whitespace() {
1077 if !prev_space && !out.is_empty() {
1078 out.push(' ');
1079 prev_space = true;
1080 }
1081 } else {
1082 for lc in ch.to_lowercase() {
1083 out.push(lc);
1084 }
1085 prev_space = false;
1086 }
1087 }
1088 if out.last() == Some(&' ') {
1089 out.pop();
1090 }
1091 out
1092}
1093
1094fn is_cased_alphabetic(folded: &[char]) -> bool {
1101 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
1102}
1103
1104fn atoms_look_similar(
1107 ka: &AtomKey,
1108 fa: &[char],
1109 cased_a: bool,
1110 kb: &AtomKey,
1111 fb: &[char],
1112 cased_b: bool,
1113) -> Option<&'static str> {
1114 if fa == fb && ka.domain == kb.domain {
1118 return Some("same name up to case, '_', or spaces");
1119 }
1120 if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
1125 return None;
1126 }
1127 if fa.len().abs_diff(fb.len()) > 1 {
1128 return None; }
1130 let min_len = fa.len().min(fb.len());
1131 if min_len >= 5 && levenshtein(fa, fb) == 1 {
1132 return Some("looks like a one-character typo of each other");
1133 }
1134 None
1135}
1136
1137fn clause_lit(l: &Lit) -> sat::SatLit {
1142 sat::SatLit::new(l.atom, l.negated)
1143}
1144
1145fn fact_lit(f: &Fact) -> sat::SatLit {
1147 match f.value {
1148 Value::True => sat::SatLit::positive(f.atom),
1149 Value::False => sat::SatLit::negative(f.atom),
1150 }
1151}
1152
1153fn rule_consequent_clause(r: &Rule, cons: &Lit) -> Vec<sat::SatLit> {
1158 let mut lits: Vec<sat::SatLit> = r.antecedent.iter().map(clause_lit).collect();
1159 lits.push(sat::SatLit::new(cons.atom, !cons.negated));
1160 lits
1161}
1162
1163fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
1168 let mut cnf = sat::Cnf::new(c.atoms.len());
1169 let mut constrained = vec![false; c.atoms.len()];
1170
1171 let add_constraining =
1175 |cnf: &mut sat::Cnf, constrained: &mut [bool], lits: Vec<sat::SatLit>| {
1176 for l in &lits {
1177 constrained[l.var() as usize] = true;
1178 }
1179 cnf.add_clause(lits);
1180 };
1181
1182 for clause in &c.clauses {
1184 add_constraining(
1185 &mut cnf,
1186 &mut constrained,
1187 clause.lits.iter().map(clause_lit).collect(),
1188 );
1189 }
1190 for r in &c.rules {
1192 for cons in &r.consequent {
1193 add_constraining(&mut cnf, &mut constrained, rule_consequent_clause(r, cons));
1194 }
1195 }
1196 for f in &c.facts {
1198 cnf.add_clause(vec![fact_lit(f)]);
1199 }
1200
1201 let project = (0..c.atoms.len() as AtomId)
1202 .filter(|&a| constrained[a as usize])
1203 .collect();
1204 (cnf, project)
1205}
1206
1207fn v3_to_value(v: V3) -> Option<Value> {
1209 match v {
1210 V3::True => Some(Value::True),
1211 V3::False => Some(Value::False),
1212 V3::Unknown => None,
1213 }
1214}
1215
1216struct Construct {
1219 origin: Origin,
1220 label: String,
1221 clauses: Vec<Vec<sat::SatLit>>,
1222}
1223
1224fn same_origin(a: &Origin, b: &Origin) -> bool {
1226 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1227}
1228
1229fn constructs(c: &Compiled) -> Vec<Construct> {
1233 let mut out: Vec<Construct> = Vec::new();
1234
1235 for f in &c.facts {
1236 out.push(Construct {
1237 origin: f.origin.clone(),
1238 label: label(c, f.atom),
1239 clauses: vec![vec![fact_lit(f)]],
1240 });
1241 }
1242
1243 let mut premises: Vec<Construct> = Vec::new();
1244 for clause in &c.clauses {
1245 let lits: Vec<sat::SatLit> = clause.lits.iter().map(clause_lit).collect();
1246 match premises
1247 .iter_mut()
1248 .find(|k| same_origin(&k.origin, &clause.origin))
1249 {
1250 Some(k) => k.clauses.push(lits),
1251 None => premises.push(Construct {
1252 label: clause.origin.premise.clone().unwrap_or_default(),
1253 origin: clause.origin.clone(),
1254 clauses: vec![lits],
1255 }),
1256 }
1257 }
1258 out.extend(premises);
1259
1260 for r in &c.rules {
1261 let clauses = r
1262 .consequent
1263 .iter()
1264 .map(|cons| rule_consequent_clause(r, cons))
1265 .collect();
1266 out.push(Construct {
1267 label: r.origin.premise.clone().unwrap_or_default(),
1268 origin: r.origin.clone(),
1269 clauses,
1270 });
1271 }
1272 out
1273}
1274
1275fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1277 let mut cnf = sat::Cnf::new(num_vars);
1278 for (k, &keep) in all.iter().zip(active) {
1279 if keep {
1280 for cl in &k.clauses {
1281 cnf.add_clause(cl.clone());
1282 }
1283 }
1284 }
1285 sat::solve(&cnf).is_some()
1286}
1287
1288fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1294 let base = c.atoms.len();
1295 let mut cnf = sat::Cnf::new(base + all.len());
1296 let sel = |i: usize| (base + i) as sat::Var;
1297 for (i, k) in all.iter().enumerate() {
1298 let s_neg = sat::SatLit::negative(sel(i));
1299 for cl in &k.clauses {
1300 let mut lits = Vec::with_capacity(cl.len() + 1);
1301 lits.push(s_neg);
1302 lits.extend_from_slice(cl);
1303 cnf.add_clause(lits);
1304 }
1305 }
1306 let assumptions: Vec<sat::SatLit> = (0..all.len())
1307 .map(|i| sat::SatLit::positive(sel(i)))
1308 .collect();
1309 let mut active = vec![false; all.len()];
1310 match sat::solve_assuming(&cnf, &assumptions) {
1311 sat::Solved::Unsat(core) => {
1312 for lit in core {
1313 let v = lit.var() as usize;
1314 if v >= base {
1315 active[v - base] = true;
1316 }
1317 }
1318 }
1319 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1323 }
1324 active
1325}
1326
1327fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1333 let all = constructs(c);
1334 let mut active = candidate_via_assumptions(c, &all);
1335 for i in 0..all.len() {
1336 if active[i] {
1337 active[i] = false;
1338 if subset_is_sat(c.atoms.len(), &all, &active) {
1339 active[i] = true; }
1341 }
1342 }
1343 let mut core: Vec<CoreItem> = all
1344 .iter()
1345 .zip(&active)
1346 .filter(|&(_, &keep)| keep)
1347 .map(|(k, _)| CoreItem {
1348 origin: k.origin.clone(),
1349 label: k.label.clone(),
1350 })
1351 .collect();
1352 core.sort_by_key(|it| key(&it.origin));
1353 core
1354}
1355
1356fn key(o: &Origin) -> (String, u32) {
1358 (o.source.clone(), o.line)
1359}
1360
1361pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1363 Ok(solve(&compile_source(name, src)?))
1364}
1365
1366pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1368 Ok(solve(&compile(root, resolver)?))
1369}
1370
1371impl fmt::Display for Status {
1374 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1375 f.write_str(status_name(*self))
1376 }
1377}
1378
1379fn premise_tag(o: &Origin) -> String {
1381 let name = o.premise.as_deref().unwrap_or("-");
1382 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1383}
1384
1385fn trace_line(step: &TraceStep) -> String {
1387 let v = match step.value {
1388 Value::True => "TRUE",
1389 Value::False => "FALSE",
1390 };
1391 match &step.reason {
1392 TraceReason::Asserted(o) => {
1393 alloc::format!(
1394 "{} = {} [{} {}:{}]",
1395 step.atom,
1396 v,
1397 o.kind,
1398 o.source,
1399 o.line
1400 )
1401 }
1402 TraceReason::Derived { origin, from } => alloc::format!(
1403 "{} = {} from {} ({}) [{}:{}] <= {}",
1404 step.atom,
1405 v,
1406 origin.premise.as_deref().unwrap_or("-"),
1407 origin.kind,
1408 origin.source,
1409 origin.line,
1410 from.join(", ")
1411 ),
1412 }
1413}
1414
1415mod indent {
1421 pub const ROOT: usize = 0;
1423 pub const SECTION: usize = 2;
1426 pub const ITEM: usize = 6;
1428 pub const NESTED: usize = 8;
1430}
1431
1432struct ReportWriter<'a, 'b> {
1435 f: &'a mut fmt::Formatter<'b>,
1436}
1437
1438impl ReportWriter<'_, '_> {
1439 fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1441 write!(self.f, "{:width$}{}", "", args, width = indent)?;
1442 self.f.write_str("\n")
1443 }
1444
1445 fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1448 write!(self.f, "{:width$}{}", "", args, width = indent)
1449 }
1450}
1451
1452macro_rules! emit {
1456 ($out:expr, $indent:expr, $($arg:tt)*) => {
1457 $out.line($indent, format_args!($($arg)*))
1458 };
1459}
1460
1461impl fmt::Display for Report {
1462 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1463 use indent::{ITEM, NESTED, ROOT, SECTION};
1464 let mut out = ReportWriter { f };
1465
1466 emit!(out, ROOT, "RESULT: {}", self.status)?;
1467
1468 if !self.retract.is_empty() {
1472 emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
1476 emit!(
1477 out,
1478 ITEM,
1479 "But these ASSUME guesses cannot all be true together."
1480 )?;
1481 emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1482 for it in &self.retract {
1483 emit!(
1484 out,
1485 ITEM,
1486 "ASSUME {} [{}:{}]",
1487 it.label,
1488 it.origin.source,
1489 it.origin.line
1490 )?;
1491 }
1492 } else {
1493 for c in &self.conflicts {
1494 emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
1495 for a in &c.atoms {
1496 emit!(out, ITEM, "{}", a)?;
1497 }
1498 if !c.trace.is_empty() {
1499 emit!(out, ITEM, "why:")?;
1500 for step in &c.trace {
1501 emit!(out, NESTED, "{}", trace_line(step))?;
1502 }
1503 }
1504 }
1505 if !self.unsat_core.is_empty() {
1506 emit!(
1507 out,
1508 SECTION,
1509 "CORE smallest jointly-unsatisfiable set ({}):",
1510 self.unsat_core.len()
1511 )?;
1512 for it in &self.unsat_core {
1513 let name = if it.label.is_empty() { "-" } else { &it.label };
1514 emit!(
1515 out,
1516 NESTED,
1517 "{} ({}) [{}:{}]",
1518 name,
1519 it.origin.kind,
1520 it.origin.source,
1521 it.origin.line
1522 )?;
1523 }
1524 }
1525 }
1526
1527 let mut shown_fixes: Vec<&str> = Vec::new();
1532 for w in &self.warnings {
1533 emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
1534 emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1535 if let Some(hint) = &w.hint
1536 && !shown_fixes.contains(&hint.as_str())
1537 {
1538 shown_fixes.push(hint);
1539 emit!(out, ITEM, "fix: {hint}")?;
1540 }
1541 }
1542 if let Some(atom) = &self.underdetermined {
1543 emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
1544 emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
1545 }
1546 for d in &self.derived {
1547 let v = match d.value {
1548 Value::True => "TRUE",
1549 Value::False => "FALSE",
1550 };
1551 emit!(
1552 out,
1553 SECTION,
1554 "DERIVED {} = {} from {}",
1555 d.atom,
1556 v,
1557 premise_tag(&d.origin)
1558 )?;
1559 }
1560 for h in &self.hints {
1561 emit!(
1562 out,
1563 SECTION,
1564 "HINT possible typo — '{}' and '{}' look like the same atom ({})",
1565 h.a,
1566 h.b,
1567 h.reason
1568 )?;
1569 }
1570 for o in &self.orphans {
1571 let surface = if o.origin.kind == kw::ASSUME && matches!(o.value, Value::False) {
1574 alloc::format!("{} {} {}", kw::ASSUME, kw::NOT, o.atom)
1575 } else {
1576 alloc::format!("{} {}", o.origin.kind, o.atom)
1577 };
1578 emit!(
1579 out,
1580 SECTION,
1581 "ORPHAN {} — not used by any premise or rule (no effect on the result)",
1582 surface
1583 )?;
1584 }
1585 for u in &self.unused_imports {
1586 let via = match &u.alias {
1587 Some(a) => alloc::format!("{} AS {}", u.domain, a),
1588 None => u.domain.clone(),
1589 };
1590 emit!(
1591 out,
1592 SECTION,
1593 "UNUSED IMPORT {} — imported in {}:{} but never referenced (no effect on the result)",
1594 via,
1595 u.file,
1596 u.line
1597 )?;
1598 }
1599
1600 let underdetermined = usize::from(self.status == Status::Underdetermined);
1601 emit!(
1602 out,
1603 ROOT,
1604 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1605 self.conflicts.len(),
1606 underdetermined,
1607 self.warnings.len(),
1608 self.derived.len()
1609 )?;
1610 out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1611 }
1612}
1613
1614#[cfg(test)]
1615mod tests {
1616 use super::*;
1617
1618 fn vs(src: &str) -> Result<Report, CompileError> {
1621 verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
1622 }
1623
1624 #[test]
1625 fn clean_consistent() {
1626 let r = vs("FACT x a\nCHECK x\n").unwrap();
1627 assert_eq!(r.status, Status::Consistent);
1628 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1629 }
1630
1631 #[test]
1632 fn fact_contradiction_is_conflict() {
1633 let r = vs("FACT x a\nNOT x a\n").unwrap();
1634 assert_eq!(r.status, Status::Conflict);
1635 assert_eq!(r.conflicts.len(), 1);
1636 }
1637
1638 #[test]
1639 fn exclusive_violation_is_conflict() {
1640 let src = include_str!("../../../docs/examples/conflict.vrf");
1641 let r = verify_source("conflict.vrf", src).unwrap();
1642 assert_eq!(r.status, Status::Conflict);
1643 assert_eq!(
1644 r.conflicts[0].origin.premise.as_deref(),
1645 Some("fly_xor_swim")
1646 );
1647 assert_eq!(r.conflicts[0].atoms.len(), 2);
1648 }
1649
1650 #[test]
1651 fn exclusive_with_unknown_is_consistent_not_warning() {
1652 let r = vs(r"
1654 FACT A has flying
1655 PREMISE e:
1656 EXCLUSIVE
1657 A has flying
1658 A has swimming
1659 ")
1660 .unwrap();
1661 assert_eq!(r.status, Status::Consistent);
1662 assert!(r.warnings.is_empty());
1663 }
1664
1665 #[test]
1666 fn implication_missing_consequent_is_warning() {
1667 let r = vs(r#"
1669 FACT A has flying
1670 PREMISE w:
1671 WHEN A has flying
1672 THEN A has wing
1673 "#)
1674 .unwrap();
1675 assert_eq!(r.status, Status::Warning);
1676 assert_eq!(r.warnings.len(), 1);
1677 assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
1678 }
1679
1680 #[test]
1681 fn warning_hint_points_at_rule_when_atom_is_a_free_input() {
1682 let r = vs(r"
1685 FACT A has flying
1686 PREMISE w:
1687 WHEN A has flying
1688 THEN A has wing
1689 ")
1690 .unwrap();
1691 let hint = r.warnings[0].hint.as_deref().unwrap();
1692 assert!(hint.contains("make that PREMISE a RULE"), "{hint}");
1693 assert!(hint.contains("t.A has wing"), "{hint}");
1694 }
1695
1696 #[test]
1697 fn warning_hint_points_at_antecedent_when_a_rule_could_derive_it() {
1698 let r = vs(r"
1701 RULE d:
1702 WHEN x trigger
1703 THEN c ready
1704 FACT go now
1705 PREMISE p:
1706 WHEN go now
1707 THEN c ready
1708 ")
1709 .unwrap();
1710 assert_eq!(r.status, Status::Warning);
1711 let hint = r.warnings[0].hint.as_deref().unwrap();
1712 assert!(
1713 hint.contains("derived by a RULE that has not fired"),
1714 "{hint}"
1715 );
1716 }
1717
1718 #[test]
1719 fn human_report_dedupes_repeated_fix_lines() {
1720 let r = vs(r"
1726 FACT a on
1727 FACT b on
1728 PREMISE p1:
1729 WHEN a on
1730 THEN gate one_ok
1731 PREMISE p2:
1732 WHEN b on
1733 THEN gate one_ok
1734 PREMISE p3:
1735 WHEN a on
1736 THEN gate two_ok
1737 ")
1738 .unwrap();
1739 assert_eq!(r.warnings.len(), 3);
1740 assert!(r.warnings.iter().all(|w| w.hint.is_some()));
1742 let distinct_hints: BTreeSet<&str> = r
1743 .warnings
1744 .iter()
1745 .filter_map(|w| w.hint.as_deref())
1746 .collect();
1747 assert_eq!(distinct_hints.len(), 2);
1748 let text = alloc::format!("{r}");
1750 let shown = text
1751 .lines()
1752 .filter(|l| l.trim_start().starts_with("fix:"))
1753 .count();
1754 assert_eq!(shown, distinct_hints.len());
1755 }
1756
1757 #[test]
1758 fn implication_satisfied_is_consistent() {
1759 let r = vs(r"
1760 FACT A has flying
1761 FACT A has wing
1762 PREMISE w:
1763 WHEN A has flying
1764 THEN A has wing
1765 ")
1766 .unwrap();
1767 assert_eq!(r.status, Status::Consistent);
1768 }
1769
1770 #[test]
1771 fn implication_violated_is_conflict() {
1772 let r = vs(r"
1774 FACT A has flying
1775 NOT A has wing
1776 PREMISE w:
1777 WHEN A has flying
1778 THEN A has wing
1779 ")
1780 .unwrap();
1781 assert_eq!(r.status, Status::Conflict);
1782 }
1783
1784 #[test]
1785 fn rule_derives_fact() {
1786 let r = vs(r#"
1787 FACT A has flying
1788 RULE o:
1789 WHEN A has flying
1790 THEN A needs oxygen
1791 "#)
1792 .unwrap();
1793 assert_eq!(r.status, Status::Consistent);
1794 assert_eq!(r.derived.len(), 1);
1795 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1796 }
1797
1798 #[test]
1799 fn rule_derivation_contradiction_is_conflict() {
1800 let r = vs(r"
1802 FACT A has flying
1803 NOT A needs oxygen
1804 RULE o:
1805 WHEN A has flying
1806 THEN A needs oxygen
1807 ")
1808 .unwrap();
1809 assert_eq!(r.status, Status::Conflict);
1810 }
1811
1812 #[test]
1813 fn bidirectional_finds_alternative_model_underdetermined() {
1814 let r = vs(r#"
1816 PREMISE e:
1817 EXCLUSIVE
1818 x a
1819 x b
1820 CHECK x BIDIRECTIONAL
1821 "#)
1822 .unwrap();
1823 assert_eq!(r.status, Status::Underdetermined);
1824 }
1825
1826 #[test]
1827 fn fact_pins_unique_model_consistent() {
1828 let r = vs(r#"
1830 FACT x a
1831 PREMISE e:
1832 EXCLUSIVE
1833 x a
1834 x b
1835 CHECK x BIDIRECTIONAL
1836 "#)
1837 .unwrap();
1838 assert_eq!(r.status, Status::Consistent);
1839 }
1840
1841 #[test]
1842 fn no_bidirectional_skips_backward_pass() {
1843 let r = vs(r#"
1845 PREMISE e:
1846 EXCLUSIVE
1847 x a
1848 x b
1849 CHECK x
1850 "#)
1851 .unwrap();
1852 assert_eq!(r.status, Status::Consistent);
1853 }
1854
1855 #[test]
1856 fn creature_example_forward_pass() {
1857 let src = include_str!("../../../docs/examples/creature.vrf");
1858 let r = verify_source("creature.vrf", src).unwrap();
1859 assert_eq!(r.status, Status::Warning);
1862 assert!(r.conflicts.is_empty());
1863 assert_eq!(r.warnings.len(), 2);
1864 assert_eq!(r.derived.len(), 1);
1865 assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
1866 }
1867
1868 #[test]
1869 fn roles_puzzle_is_uniquely_solved() {
1870 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1874 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1875 assert_eq!(r.status, Status::Consistent);
1876 assert!(r.conflicts.is_empty());
1877 assert!(r.underdetermined.is_none());
1878 }
1879
1880 #[test]
1881 fn roles_puzzle_underdetermined_without_a_clue() {
1882 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1887 .replace("\r\n", "\n")
1888 .replace("NOT bob is qa\n", "");
1889 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1890 assert_eq!(r.status, Status::Underdetermined);
1891 }
1892
1893 #[test]
1894 fn socrates_chain_is_a_conflict() {
1895 let src = include_str!("../../../docs/examples/socrates.vrf");
1898 let r = verify_source("socrates.vrf", src).unwrap();
1899 assert_eq!(r.status, Status::Conflict);
1900 assert_eq!(r.conflicts.len(), 1);
1901 assert_eq!(
1902 r.conflicts[0].origin.premise.as_deref(),
1903 Some("mortal_xor_immortal")
1904 );
1905 assert_eq!(r.derived.len(), 3); }
1907
1908 #[test]
1911 fn forward_conflict_carries_a_trace_of_its_facts() {
1912 let r = vs(r#"
1913 FACT x a
1914 FACT x b
1915 PREMISE e:
1916 EXCLUSIVE
1917 x a
1918 x b
1919 CHECK x
1920 "#)
1921 .unwrap();
1922 assert_eq!(r.status, Status::Conflict);
1923 let t = &r.conflicts[0].trace;
1924 assert_eq!(t.len(), 2);
1925 assert_eq!(t[0].atom, "t.x a");
1926 assert_eq!(t[0].value, Value::True);
1927 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1928 assert!(r.unsat_core.is_empty());
1929 }
1930
1931 #[test]
1932 fn derivation_chain_is_traced_back_to_the_root_fact() {
1933 let src = include_str!("../../../docs/examples/socrates.vrf");
1935 let r = verify_source("socrates.vrf", src).unwrap();
1936 let t = &r.conflicts[0].trace;
1937 assert_eq!(t.len(), 5);
1940 assert_eq!(t[0].atom, "philosophy.socrates is human");
1941 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1942 let mortal = t
1943 .iter()
1944 .find(|s| s.atom == "philosophy.socrates is mortal")
1945 .unwrap();
1946 match &mortal.reason {
1947 TraceReason::Derived { from, .. } => {
1948 assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
1949 }
1950 _ => panic!("mortal should be derived, not asserted"),
1951 }
1952 }
1953
1954 #[test]
1955 fn direct_fact_contradiction_has_no_trace() {
1956 let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
1957 assert_eq!(r.status, Status::Conflict);
1958 assert!(r.conflicts[0].trace.is_empty());
1959 }
1960
1961 #[test]
1962 fn jointly_unsatisfiable_reports_a_minimal_core() {
1963 let src = r#"
1966 PREMISE one:
1967 ONEOF
1968 x a
1969 x b
1970 PREMISE ac:
1971 WHEN x a
1972 THEN x c
1973 PREMISE bc:
1974 WHEN x b
1975 THEN x c
1976 NOT x c
1977 CHECK x BIDIRECTIONAL
1978 "#;
1979 let r = vs(src).unwrap();
1980 assert_eq!(r.status, Status::Conflict);
1981 assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
1982 assert_eq!(r.unsat_core.len(), 4);
1983 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1984 assert!(labels.contains(&"one"));
1985 assert!(labels.contains(&"t.x c")); }
1987
1988 #[test]
1989 fn unsat_core_excludes_irrelevant_constructs() {
1990 let src = r#"
1993 PREMISE one:
1994 ONEOF
1995 x a
1996 x b
1997 PREMISE ac:
1998 WHEN x a
1999 THEN x c
2000 PREMISE bc:
2001 WHEN x b
2002 THEN x c
2003 NOT x c
2004 FACT z here
2005 PREMISE noise:
2006 EXCLUSIVE
2007 z here
2008 z gone
2009 CHECK x BIDIRECTIONAL
2010 "#;
2011 let r = vs(src).unwrap();
2012 assert_eq!(r.status, Status::Conflict);
2013 assert_eq!(r.unsat_core.len(), 4);
2014 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2015 assert!(!labels.contains(&"noise"));
2016 assert!(!labels.iter().any(|l| l.contains("here")));
2017 }
2018
2019 #[test]
2020 fn unsat_core_blames_the_rules_that_form_it() {
2021 let src = r#"
2026 PREMISE one:
2027 ONEOF
2028 x a
2029 x b
2030 RULE ac:
2031 WHEN x a
2032 THEN x c
2033 RULE bc:
2034 WHEN x b
2035 THEN x c
2036 NOT x c
2037 CHECK x BIDIRECTIONAL
2038 "#;
2039 let r = vs(src).unwrap();
2040 assert_eq!(r.status, Status::Conflict);
2041 assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
2042 assert_eq!(r.unsat_core.len(), 4);
2043 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2044 assert!(labels.contains(&"one"));
2045 assert!(labels.contains(&"ac"));
2046 assert!(labels.contains(&"bc"));
2047 assert!(labels.contains(&"t.x c")); }
2049
2050 #[test]
2051 fn consistent_report_has_empty_core_and_no_trace() {
2052 let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
2053 assert_eq!(r.status, Status::Consistent);
2054 assert!(r.unsat_core.is_empty());
2055 assert!(r.conflicts.is_empty());
2056 }
2057
2058 #[test]
2061 fn compatible_assumptions_behave_like_facts() {
2062 let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
2065 assert_eq!(r.status, Status::Consistent);
2066 assert!(r.retract.is_empty());
2067 assert!(r.conflicts.is_empty());
2068 }
2069
2070 #[test]
2071 fn assume_drives_a_rule_like_a_fact() {
2072 let r = vs(r"
2074 ASSUME A has flying
2075 RULE o:
2076 WHEN A has flying
2077 THEN A needs oxygen
2078 CHECK A
2079 ")
2080 .unwrap();
2081 assert_eq!(r.status, Status::Consistent);
2082 assert_eq!(r.derived.len(), 1);
2083 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
2084 }
2085
2086 #[test]
2087 fn clashing_assumptions_yield_conflict_with_a_retract_set() {
2088 let src = r#"
2091 FACT rel reviewed
2092 PREMISE prod_needs_safety:
2093 WHEN rel in_prod
2094 THEN rel has_rollback
2095 OR rel has_feature_flag
2096 ASSUME rel in_prod
2097 ASSUME NOT rel has_rollback
2098 ASSUME NOT rel has_feature_flag
2099 CHECK rel
2100 "#;
2101 let r = vs(src).unwrap();
2102 assert_eq!(r.status, Status::Conflict);
2103 assert_eq!(r.exit_code(), 2);
2104 assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
2106 let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
2107 assert!(labels.contains(&"t.rel in_prod"));
2108 assert!(labels.contains(&"NOT t.rel has_rollback"));
2109 assert!(labels.contains(&"NOT t.rel has_feature_flag"));
2110 assert!(r.retract.iter().all(|it| it.origin.kind == kw::ASSUME));
2112 let shown = alloc::format!("{r}");
2114 assert!(shown.contains("RETRACT"), "{shown}");
2115 assert!(!shown.contains("CONFLICT "), "{shown}");
2116 }
2117
2118 #[test]
2119 fn assume_vs_fact_retracts_only_the_assumption() {
2120 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2122 assert_eq!(r.status, Status::Conflict);
2123 assert_eq!(r.retract.len(), 1);
2124 assert_eq!(r.retract[0].label, "NOT t.x a");
2125 assert_eq!(r.retract[0].origin.kind, kw::ASSUME);
2126 }
2127
2128 #[test]
2129 fn hard_conflict_is_not_blamed_on_assumptions() {
2130 let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
2133 assert_eq!(r.status, Status::Conflict);
2134 assert!(r.retract.is_empty(), "{:?}", r.retract);
2135 }
2136
2137 #[test]
2138 fn two_assumptions_directly_contradict() {
2139 let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2140 assert_eq!(r.status, Status::Conflict);
2141 assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
2142 }
2143
2144 #[test]
2145 fn assume_retract_is_in_json() {
2146 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2147 let j = r.to_json();
2148 assert!(j.contains("\"retract\":["), "{j}");
2149 assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
2150 assert!(j.contains("NOT t.x a"), "{j}");
2151 }
2152
2153 #[test]
2156 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
2157 let r = vs(r#"
2161 FACT auth is rolled_back
2162 NOT auth is_rolled_back
2163 CHECK
2164 "#)
2165 .unwrap();
2166 assert_eq!(
2167 r.status,
2168 Status::Consistent,
2169 "hint must not change the verdict"
2170 );
2171 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
2172 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2173 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
2174 }
2175
2176 #[test]
2177 fn hint_flags_case_only_difference() {
2178 let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
2179 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2180 }
2181
2182 #[test]
2183 fn hint_flags_single_char_typo_same_subject() {
2184 let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
2186 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2187 }
2188
2189 #[test]
2190 fn no_hint_for_short_distinct_atoms() {
2191 let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
2193 assert!(r.hints.is_empty(), "{:?}", r.hints);
2194 }
2195
2196 #[test]
2197 fn no_hint_for_distinct_words() {
2198 let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
2199 assert!(r.hints.is_empty(), "{:?}", r.hints);
2200 }
2201
2202 #[test]
2203 fn russian_case_typo_is_flagged() {
2204 let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
2206 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2207 }
2208
2209 #[test]
2210 fn russian_single_char_typo_is_flagged() {
2211 let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
2212 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2213 }
2214
2215 #[test]
2216 fn cjk_one_char_difference_is_not_flagged() {
2217 let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
2220 assert!(r.hints.is_empty(), "{:?}", r.hints);
2221 }
2222
2223 #[test]
2224 fn cjk_underscore_vs_space_is_flagged() {
2225 let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
2228 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2229 }
2230
2231 #[test]
2234 fn orphan_fact_is_flagged_but_advisory_only() {
2235 let r = vs("FACT x a\nCHECK\n").unwrap();
2238 assert_eq!(
2239 r.status,
2240 Status::Consistent,
2241 "orphan must not change verdict"
2242 );
2243 assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
2244 assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
2245 assert_eq!(r.orphans[0].atom, "t.x a");
2246 assert_eq!(r.orphans[0].origin.kind, kw::FACT);
2247 }
2248
2249 #[test]
2250 fn fact_used_by_a_premise_is_not_orphan() {
2251 let r = vs(r"
2253 FACT x a
2254 PREMISE p:
2255 EXCLUSIVE
2256 x a
2257 x b
2258 CHECK
2259 ")
2260 .unwrap();
2261 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2262 }
2263
2264 #[test]
2265 fn fact_used_by_a_rule_antecedent_is_not_orphan() {
2266 let r = vs(r"
2267 FACT x a
2268 RULE r:
2269 WHEN x a
2270 THEN x c
2271 CHECK
2272 ")
2273 .unwrap();
2274 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2275 }
2276
2277 #[test]
2278 fn negation_and_assumption_orphans_keep_their_surface_polarity() {
2279 let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
2282 assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
2283 let text = alloc::format!("{r}");
2284 assert!(text.contains("ORPHAN NOT t.x a"), "{text}");
2285 assert!(text.contains("ORPHAN ASSUME NOT t.y b"), "{text}");
2286 }
2287
2288 #[test]
2289 fn orphan_is_in_json() {
2290 let r = vs("FACT x a\nCHECK\n").unwrap();
2291 let j = r.to_json();
2292 assert!(j.contains("\"orphans\":["), "{j}");
2293 assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
2294 assert!(j.contains("\"kind\":\"FACT\""), "{j}");
2295 }
2296
2297 #[test]
2298 fn unused_import_is_advisory_only_in_the_report() {
2299 let mut r = MemoryResolver::new();
2302 r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
2303 r.add(
2304 "main.vrf",
2305 "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\nCHECK\n",
2306 );
2307 let rep = verify("main.vrf", &r).unwrap();
2308 assert_eq!(rep.status, Status::Consistent);
2309 assert_eq!(rep.exit_code(), 0);
2310 assert_eq!(rep.unused_imports.len(), 1);
2311 assert_eq!(rep.unused_imports[0].domain, "physics");
2312 let text = alloc::format!("{rep}");
2313 assert!(text.contains("UNUSED IMPORT physics"), "{text}");
2314 assert!(
2315 rep.to_json().contains("\"unused_imports\":[{"),
2316 "{}",
2317 rep.to_json()
2318 );
2319 }
2320
2321 #[test]
2322 fn a_derived_atom_does_not_make_its_consumer_orphan() {
2323 let r = vs(r"
2326 FACT x a
2327 RULE r:
2328 WHEN x a
2329 THEN x c
2330 PREMISE p:
2331 WHEN x c
2332 THEN x d
2333 CHECK
2334 ")
2335 .unwrap();
2336 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2337 }
2338}