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::{AtomId, AtomKey, Clause, Compiled, Lit, Origin, Value};
58pub use elenchus_compiler::{
59 CompileError, MemoryResolver, Resolver, UnusedImport, compile, compile_source,
60};
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum V3 {
65 True,
67 False,
69 Unknown,
71}
72
73impl V3 {
74 fn not(self) -> V3 {
76 match self {
77 V3::True => V3::False,
78 V3::False => V3::True,
79 V3::Unknown => V3::Unknown,
80 }
81 }
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum Status {
87 Consistent,
89 Underdetermined,
92 Warning,
94 Conflict,
96}
97
98#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct Conflict {
101 pub origin: Origin,
103 pub atoms: Vec<String>,
105 pub trace: Vec<TraceStep>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
116pub struct TraceStep {
117 pub atom: String,
119 pub value: Value,
121 pub reason: TraceReason,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum TraceReason {
128 Asserted(Origin),
130 Derived {
132 origin: Origin,
134 from: Vec<String>,
136 },
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct Warning {
142 pub origin: Origin,
144 pub blocked_by: Vec<String>,
146 pub hint: Option<String>,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct Derived {
157 pub atom: String,
159 pub value: Value,
161 pub origin: Origin,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct Report {
168 pub status: Status,
170 pub conflicts: Vec<Conflict>,
172 pub warnings: Vec<Warning>,
174 pub derived: Vec<Derived>,
176 pub underdetermined: Option<String>,
179 pub unsat_core: Vec<CoreItem>,
184 pub retract: Vec<CoreItem>,
192 pub hints: Vec<SimilarAtoms>,
195 pub orphans: Vec<OrphanFact>,
200 pub unused_imports: Vec<UnusedImport>,
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct SimilarAtoms {
214 pub a: String,
216 pub b: String,
218 pub reason: &'static str,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct OrphanFact {
230 pub atom: String,
232 pub value: Value,
234 pub origin: Origin,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct CoreItem {
241 pub origin: Origin,
243 pub label: String,
245}
246
247impl Report {
248 pub fn exit_code(&self) -> i32 {
250 match self.status {
251 Status::Conflict => 2,
252 Status::Underdetermined | Status::Warning => 1,
253 Status::Consistent => 0,
254 }
255 }
256
257 pub fn to_json(&self) -> String {
260 use core::fmt::Write as _;
261 let mut s = String::new();
262 let _ = write!(s, "{{\"status\":");
263 json_str(status_name(self.status), &mut s);
264 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
265
266 s.push_str(",\"conflicts\":[");
267 for (i, c) in self.conflicts.iter().enumerate() {
268 if i > 0 {
269 s.push(',');
270 }
271 json_origin(&c.origin, &mut s);
272 s.push_str(",\"atoms\":");
273 json_array(&c.atoms, &mut s);
274 s.push_str(",\"trace\":[");
275 for (j, step) in c.trace.iter().enumerate() {
276 if j > 0 {
277 s.push(',');
278 }
279 json_trace_step(step, &mut s);
280 }
281 s.push_str("]}");
282 }
283 s.push_str("],\"warnings\":[");
284 for (i, w) in self.warnings.iter().enumerate() {
285 if i > 0 {
286 s.push(',');
287 }
288 json_origin(&w.origin, &mut s);
289 s.push_str(",\"blocked_by\":");
290 json_array(&w.blocked_by, &mut s);
291 s.push_str(",\"hint\":");
292 match &w.hint {
293 Some(h) => json_str(h, &mut s),
294 None => s.push_str("null"),
295 }
296 s.push('}');
297 }
298 s.push_str("],\"derived\":[");
299 for (i, d) in self.derived.iter().enumerate() {
300 if i > 0 {
301 s.push(',');
302 }
303 s.push('{');
304 json_origin_fields(&d.origin, &mut s);
305 s.push_str(",\"atom\":");
306 json_str(&d.atom, &mut s);
307 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
308 s.push('}');
309 }
310 s.push_str("],\"underdetermined\":");
311 match &self.underdetermined {
312 Some(atom) => json_str(atom, &mut s),
313 None => s.push_str("null"),
314 }
315 s.push_str(",\"unsat_core\":[");
316 for (i, it) in self.unsat_core.iter().enumerate() {
317 if i > 0 {
318 s.push(',');
319 }
320 json_origin(&it.origin, &mut s);
321 s.push_str(",\"label\":");
322 json_str(&it.label, &mut s);
323 s.push('}');
324 }
325 s.push_str("],\"retract\":[");
326 for (i, it) in self.retract.iter().enumerate() {
327 if i > 0 {
328 s.push(',');
329 }
330 json_origin(&it.origin, &mut s);
331 s.push_str(",\"label\":");
332 json_str(&it.label, &mut s);
333 s.push('}');
334 }
335 s.push_str("],\"hints\":[");
336 for (i, h) in self.hints.iter().enumerate() {
337 if i > 0 {
338 s.push(',');
339 }
340 s.push_str("{\"a\":");
341 json_str(&h.a, &mut s);
342 s.push_str(",\"b\":");
343 json_str(&h.b, &mut s);
344 s.push_str(",\"reason\":");
345 json_str(h.reason, &mut s);
346 s.push('}');
347 }
348 s.push_str("],\"orphans\":[");
349 for (i, o) in self.orphans.iter().enumerate() {
350 if i > 0 {
351 s.push(',');
352 }
353 json_origin(&o.origin, &mut s);
354 s.push_str(",\"atom\":");
355 json_str(&o.atom, &mut s);
356 let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
357 s.push('}');
358 }
359 s.push_str("],\"unused_imports\":[");
360 for (i, u) in self.unused_imports.iter().enumerate() {
361 if i > 0 {
362 s.push(',');
363 }
364 s.push_str("{\"file\":");
365 json_str(&u.file, &mut s);
366 s.push_str(",\"domain\":");
367 json_str(&u.domain, &mut s);
368 s.push_str(",\"alias\":");
369 match &u.alias {
370 Some(a) => json_str(a, &mut s),
371 None => s.push_str("null"),
372 }
373 let _ = write!(s, ",\"line\":{}", u.line);
374 s.push('}');
375 }
376 s.push_str("]}");
377 s
378 }
379}
380
381fn json_trace_step(step: &TraceStep, out: &mut String) {
383 use core::fmt::Write as _;
384 out.push_str("{\"atom\":");
385 json_str(&step.atom, out);
386 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
387 match &step.reason {
388 TraceReason::Asserted(o) => {
389 out.push_str(",\"how\":\"asserted\",");
390 json_origin_fields(o, out);
391 out.push_str(",\"from\":[]");
392 }
393 TraceReason::Derived { origin, from } => {
394 out.push_str(",\"how\":\"derived\",");
395 json_origin_fields(origin, out);
396 out.push_str(",\"from\":");
397 json_array(from, out);
398 }
399 }
400 out.push('}');
401}
402
403fn status_name(s: Status) -> &'static str {
404 match s {
405 Status::Consistent => "CONSISTENT",
406 Status::Underdetermined => "UNDERDETERMINED",
407 Status::Warning => "WARNING",
408 Status::Conflict => "CONFLICT",
409 }
410}
411
412fn json_str(value: &str, out: &mut String) {
414 use core::fmt::Write as _;
415 out.push('"');
416 for ch in value.chars() {
417 match ch {
418 '"' => out.push_str("\\\""),
419 '\\' => out.push_str("\\\\"),
420 '\n' => out.push_str("\\n"),
421 '\r' => out.push_str("\\r"),
422 '\t' => out.push_str("\\t"),
423 c if (c as u32) < 0x20 => {
424 let _ = write!(out, "\\u{:04x}", c as u32);
425 }
426 c => out.push(c),
427 }
428 }
429 out.push('"');
430}
431
432fn json_array(items: &[String], out: &mut String) {
434 out.push('[');
435 for (i, item) in items.iter().enumerate() {
436 if i > 0 {
437 out.push(',');
438 }
439 json_str(item, out);
440 }
441 out.push(']');
442}
443
444fn json_origin_fields(o: &Origin, out: &mut String) {
446 use core::fmt::Write as _;
447 out.push_str("\"premise\":");
448 match &o.premise {
449 Some(name) => json_str(name, out),
450 None => out.push_str("null"),
451 }
452 out.push_str(",\"kind\":");
453 json_str(o.kind, out);
454 out.push_str(",\"source\":");
455 json_str(&o.source, out);
456 let _ = write!(out, ",\"line\":{}", o.line);
457}
458
459fn json_origin(o: &Origin, out: &mut String) {
461 out.push('{');
462 json_origin_fields(o, out);
463}
464
465fn label(c: &Compiled, a: AtomId) -> String {
469 let k = &c.atoms[a as usize];
470 match &k.object {
471 Some(o) => alloc::format!("{}.{} {} {}", k.domain, k.subject, k.predicate, o),
472 None => alloc::format!("{}.{} {}", k.domain, k.subject, k.predicate),
473 }
474}
475
476fn lit_value(model: &[V3], l: &Lit) -> V3 {
478 let v = model[l.atom as usize];
479 if l.negated { v.not() } else { v }
480}
481
482fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
484 let mut result = V3::True;
485 for l in lits {
486 match lit_value(model, l) {
487 V3::False => return V3::False,
488 V3::Unknown => result = V3::Unknown,
489 V3::True => {}
490 }
491 }
492 result
493}
494
495enum ClauseEval {
497 Violated,
499 Satisfied,
501 Blocked(Vec<AtomId>),
503}
504
505fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
508 let mut any_false = false;
509 let mut all_true = true;
510 let mut blocked = Vec::new();
511 for l in &clause.lits {
512 match lit_value(model, l) {
513 V3::False => {
514 any_false = true;
515 all_true = false;
516 }
517 V3::Unknown => {
518 all_true = false;
519 blocked.push(l.atom);
520 }
521 V3::True => {}
522 }
523 }
524 if all_true {
525 ClauseEval::Violated
526 } else if any_false {
527 ClauseEval::Satisfied
528 } else {
529 ClauseEval::Blocked(blocked)
530 }
531}
532
533#[derive(Clone)]
536enum AtomReason {
537 Asserted(Origin),
539 Derived { origin: Origin, from: Vec<AtomId> },
541}
542
543struct RawConflict {
547 origin: Origin,
548 atoms: Vec<String>,
549 cause: Vec<AtomId>,
550}
551
552struct Eval<'a> {
554 c: &'a Compiled,
555 model: Vec<V3>,
556 reason: Vec<Option<AtomReason>>,
559 conflicts: Vec<RawConflict>,
560 warnings: Vec<Warning>,
561 derived: Vec<Derived>,
562 unsat_core: Vec<CoreItem>,
564}
565
566impl<'a> Eval<'a> {
567 fn new(c: &'a Compiled) -> Self {
568 Eval {
569 c,
570 model: vec![V3::Unknown; c.atoms.len()],
571 reason: vec![None; c.atoms.len()],
572 conflicts: Vec::new(),
573 warnings: Vec::new(),
574 derived: Vec::new(),
575 unsat_core: Vec::new(),
576 }
577 }
578
579 fn label(&self, a: AtomId) -> String {
580 label(self.c, a)
581 }
582
583 fn seed_facts(&mut self) {
585 let c = self.c;
586 let n = c.atoms.len();
587 let mut t: Vec<Option<Origin>> = vec![None; n];
588 let mut f: Vec<Option<Origin>> = vec![None; n];
589 for fact in &c.facts {
590 let slot = match fact.value {
591 Value::True => &mut t,
592 Value::False => &mut f,
593 };
594 if slot[fact.atom as usize].is_none() {
595 slot[fact.atom as usize] = Some(fact.origin.clone());
596 }
597 }
598 for a in 0..n {
599 match (&t[a], &f[a]) {
600 (Some(o), Some(_)) => {
601 self.model[a] = V3::True;
602 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
603 self.conflicts.push(RawConflict {
604 origin: o.clone(),
605 atoms: vec![alloc::format!(
606 "{} (asserted both TRUE and FALSE)",
607 self.label(a as AtomId)
608 )],
609 cause: Vec::new(),
610 });
611 }
612 (Some(o), None) => {
613 self.model[a] = V3::True;
614 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
615 }
616 (None, Some(o)) => {
617 self.model[a] = V3::False;
618 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
619 }
620 (None, None) => {}
621 }
622 }
623 }
624
625 fn saturate_rules(&mut self) {
627 let c = self.c;
628 loop {
629 let mut changed = false;
630 for r in &c.rules {
631 if conjunction(&self.model, &r.antecedent) != V3::True {
632 continue; }
634 for cl in &r.consequent {
635 let target = if cl.negated { V3::False } else { V3::True };
636 match self.model[cl.atom as usize] {
637 V3::Unknown => {
638 self.model[cl.atom as usize] = target;
639 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
640 origin: r.origin.clone(),
641 from: r.antecedent.iter().map(|l| l.atom).collect(),
642 });
643 changed = true;
644 self.derived.push(Derived {
645 atom: self.label(cl.atom),
646 value: if cl.negated {
647 Value::False
648 } else {
649 Value::True
650 },
651 origin: r.origin.clone(),
652 });
653 }
654 v if v == target => {}
655 _ => {
656 let mut cause: Vec<AtomId> =
660 r.antecedent.iter().map(|l| l.atom).collect();
661 cause.push(cl.atom);
662 self.conflicts.push(RawConflict {
663 origin: r.origin.clone(),
664 atoms: vec![alloc::format!(
665 "{} (derived value contradicts a known fact)",
666 self.label(cl.atom)
667 )],
668 cause,
669 });
670 }
671 }
672 }
673 }
674 if !changed {
675 break;
676 }
677 }
678 }
679
680 fn check_premises(&mut self) {
682 let c = self.c;
683 let derivable: BTreeSet<AtomId> = c
688 .rules
689 .iter()
690 .flat_map(|r| r.consequent.iter().map(|l| l.atom))
691 .collect();
692 for clause in &c.clauses {
693 match eval_clause(&self.model, clause) {
694 ClauseEval::Violated => self.conflicts.push(RawConflict {
695 origin: clause.origin.clone(),
696 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
697 cause: clause.lits.iter().map(|l| l.atom).collect(),
698 }),
699 ClauseEval::Satisfied => {}
700 ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
703 let hint = self.warning_hint(&unknowns, &derivable);
704 self.warnings.push(Warning {
705 origin: clause.origin.clone(),
706 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
707 hint,
708 });
709 }
710 ClauseEval::Blocked(_) => {}
711 }
712 }
713 }
714
715 fn warning_hint(&self, unknowns: &[AtomId], derivable: &BTreeSet<AtomId>) -> Option<String> {
720 let free = unknowns.iter().find(|a| !derivable.contains(a));
721 match free {
722 Some(&a) => Some(alloc::format!(
723 "nothing determines `{}` — add `FACT {}` (or `NOT …`), or if a PREMISE's \
724 THEN is meant to establish it, make that PREMISE a RULE so it derives the value",
725 self.label(a),
726 self.label(a),
727 )),
728 None => unknowns.first().map(|&a| {
729 alloc::format!(
730 "`{}` is derived by a RULE that has not fired — assert that rule's antecedent",
731 self.label(a),
732 )
733 }),
734 }
735 }
736
737 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
743 let mut visited = vec![false; self.c.atoms.len()];
744 let mut out = Vec::new();
745 for &a in causes {
746 self.trace_dfs(a, &mut visited, &mut out);
747 }
748 out
749 }
750
751 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
752 if visited[a as usize] {
753 return;
754 }
755 visited[a as usize] = true;
756 let value = match v3_to_value(self.model[a as usize]) {
757 Some(v) => v,
758 None => return, };
760 let reason = match &self.reason[a as usize] {
761 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
762 Some(AtomReason::Derived { origin, from }) => {
763 for &f in from {
764 self.trace_dfs(f, visited, out); }
766 TraceReason::Derived {
767 origin: origin.clone(),
768 from: from.iter().map(|&f| self.label(f)).collect(),
769 }
770 }
771 None => return,
772 };
773 out.push(TraceStep {
774 atom: self.label(a),
775 value,
776 reason,
777 });
778 }
779
780 fn backward_pass(&mut self) -> Option<String> {
787 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
788 return None;
789 }
790 let (cnf, project) = build_cnf(self.c);
791 let found = sat::models(&cnf, &project, 2);
792 match found.len() {
793 0 if self.conflicts.is_empty() => {
794 self.unsat_core = minimal_unsat_core(self.c);
795 self.conflicts.push(RawConflict {
796 origin: Origin {
797 source: String::from("<system>"),
798 line: 0,
799 premise: None,
800 kind: "UNSAT",
801 },
802 atoms: vec![String::from(
803 "the premises and facts are jointly unsatisfiable",
804 )],
805 cause: Vec::new(),
806 });
807 None
808 }
809 n if n >= 2 => {
810 let (m0, m1) = (&found[0], &found[1]);
811 project
812 .iter()
813 .find(|&&v| m0[v as usize] != m1[v as usize])
814 .map(|&v| self.label(v))
815 .or_else(|| Some(String::from("a free atom")))
816 }
817 _ => None,
818 }
819 }
820
821 fn finish(mut self) -> Report {
823 let underdetermined = self.backward_pass();
824 self.conflicts.sort_by_key(|c| key(&c.origin));
825 self.warnings.sort_by_key(|w| key(&w.origin));
826 let status = if !self.conflicts.is_empty() {
827 Status::Conflict
828 } else if underdetermined.is_some() {
829 Status::Underdetermined
830 } else if !self.warnings.is_empty() {
831 Status::Warning
832 } else {
833 Status::Consistent
834 };
835 let conflicts: Vec<Conflict> = self
838 .conflicts
839 .iter()
840 .map(|rc| Conflict {
841 origin: rc.origin.clone(),
842 atoms: rc.atoms.clone(),
843 trace: self.build_trace(&rc.cause),
844 })
845 .collect();
846 Report {
847 status,
848 conflicts,
849 warnings: self.warnings,
850 derived: self.derived,
851 underdetermined,
852 unsat_core: self.unsat_core,
853 retract: Vec::new(), hints: Vec::new(), orphans: Vec::new(), unused_imports: Vec::new(), }
858 }
859}
860
861pub fn solve(c: &Compiled) -> Report {
864 let mut e = Eval::new(c);
865 e.seed_facts();
866 e.saturate_rules();
867 e.check_premises();
868 let mut report = e.finish();
869 if report.status == Status::Conflict {
875 let retract = retract_assumptions(c);
876 if !retract.is_empty() {
877 report.unsat_core = Vec::new();
878 report.retract = retract;
879 }
880 }
881 report.hints = similar_atom_pairs(c);
884 report.orphans = orphan_facts(c);
887 report.unused_imports = c.unused_imports.clone();
890 report
891}
892
893fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
903 let mut referenced = vec![false; c.atoms.len()];
904 for clause in &c.clauses {
905 for l in &clause.lits {
906 referenced[l.atom as usize] = true;
907 }
908 }
909 for r in &c.rules {
910 for l in r.antecedent.iter().chain(r.consequent.iter()) {
911 referenced[l.atom as usize] = true;
912 }
913 }
914 let mut out: Vec<OrphanFact> = c
915 .facts
916 .iter()
917 .filter(|f| !referenced[f.atom as usize])
918 .map(|f| OrphanFact {
919 atom: label(c, f.atom),
920 value: f.value,
921 origin: f.origin.clone(),
922 })
923 .collect();
924 out.sort_by_key(|o| key(&o.origin));
925 out
926}
927
928fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
944 if !c.facts.iter().any(|f| f.soft) {
945 return Vec::new();
946 }
947 let all = constructs(c);
948 let is_soft: Vec<bool> = (0..all.len())
950 .map(|i| i < c.facts.len() && c.facts[i].soft)
951 .collect();
952
953 let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
956 if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
957 return Vec::new();
958 }
959 let mut active = vec![true; all.len()];
961 if subset_is_sat(c.atoms.len(), &all, &active) {
962 return Vec::new();
963 }
964 for i in 0..all.len() {
966 if active[i] && is_soft[i] {
967 active[i] = false;
968 if subset_is_sat(c.atoms.len(), &all, &active) {
969 active[i] = true; }
971 }
972 }
973 let mut core: Vec<CoreItem> = (0..all.len())
974 .filter(|&i| active[i] && is_soft[i])
975 .map(|i| {
976 let f = &c.facts[i];
977 let label = if matches!(f.value, Value::False) {
979 alloc::format!("NOT {}", label(c, f.atom))
980 } else {
981 label(c, f.atom)
982 };
983 CoreItem {
984 origin: f.origin.clone(),
985 label,
986 }
987 })
988 .collect();
989 core.sort_by_key(|it| key(&it.origin));
990 core
991}
992
993fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
1013 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
1014 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
1015 let mut out = Vec::new();
1016 for i in 0..c.atoms.len() {
1017 for j in (i + 1)..c.atoms.len() {
1018 if let Some(reason) = atoms_look_similar(
1019 &c.atoms[i],
1020 &folded[i],
1021 cased[i],
1022 &c.atoms[j],
1023 &folded[j],
1024 cased[j],
1025 ) {
1026 out.push(SimilarAtoms {
1027 a: label(c, i as AtomId),
1028 b: label(c, j as AtomId),
1029 reason,
1030 });
1031 }
1032 }
1033 }
1034 out
1035}
1036
1037fn fold_atom(k: &AtomKey) -> Vec<char> {
1041 let mut raw = String::new();
1042 raw.push_str(&k.subject);
1043 raw.push(' ');
1044 raw.push_str(&k.predicate);
1045 if let Some(o) = &k.object {
1046 raw.push(' ');
1047 raw.push_str(o);
1048 }
1049 let mut out: Vec<char> = Vec::new();
1050 let mut prev_space = false;
1051 for ch in raw.chars() {
1052 if ch == '_' || ch.is_whitespace() {
1053 if !prev_space && !out.is_empty() {
1054 out.push(' ');
1055 prev_space = true;
1056 }
1057 } else {
1058 for lc in ch.to_lowercase() {
1059 out.push(lc);
1060 }
1061 prev_space = false;
1062 }
1063 }
1064 if out.last() == Some(&' ') {
1065 out.pop();
1066 }
1067 out
1068}
1069
1070fn is_cased_alphabetic(folded: &[char]) -> bool {
1077 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
1078}
1079
1080fn atoms_look_similar(
1083 ka: &AtomKey,
1084 fa: &[char],
1085 cased_a: bool,
1086 kb: &AtomKey,
1087 fb: &[char],
1088 cased_b: bool,
1089) -> Option<&'static str> {
1090 if fa == fb && ka.domain == kb.domain {
1094 return Some("same name up to case, '_', or spaces");
1095 }
1096 if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
1101 return None;
1102 }
1103 if fa.len().abs_diff(fb.len()) > 1 {
1104 return None; }
1106 let min_len = fa.len().min(fb.len());
1107 if min_len >= 5 && levenshtein(fa, fb) == 1 {
1108 return Some("looks like a one-character typo of each other");
1109 }
1110 None
1111}
1112
1113fn levenshtein(a: &[char], b: &[char]) -> usize {
1115 let mut prev: Vec<usize> = (0..=b.len()).collect();
1116 let mut cur = vec![0usize; b.len() + 1];
1117 for (i, &ca) in a.iter().enumerate() {
1118 cur[0] = i + 1;
1119 for (j, &cb) in b.iter().enumerate() {
1120 let cost = usize::from(ca != cb);
1121 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1122 }
1123 core::mem::swap(&mut prev, &mut cur);
1124 }
1125 prev[b.len()]
1126}
1127
1128fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
1133 use sat::SatLit;
1134 let mut cnf = sat::Cnf::new(c.atoms.len());
1135 let mut constrained = vec![false; c.atoms.len()];
1136 let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
1137
1138 for clause in &c.clauses {
1140 let lits = clause
1141 .lits
1142 .iter()
1143 .map(|l| {
1144 mark(l.atom, &mut constrained);
1145 SatLit::new(l.atom, l.negated)
1146 })
1147 .collect();
1148 cnf.add_clause(lits);
1149 }
1150 for r in &c.rules {
1152 for cons in &r.consequent {
1153 let mut lits: Vec<SatLit> = r
1154 .antecedent
1155 .iter()
1156 .map(|a| {
1157 mark(a.atom, &mut constrained);
1158 SatLit::new(a.atom, a.negated)
1159 })
1160 .collect();
1161 mark(cons.atom, &mut constrained);
1162 lits.push(SatLit::new(cons.atom, !cons.negated));
1163 cnf.add_clause(lits);
1164 }
1165 }
1166 for f in &c.facts {
1168 let lit = match f.value {
1169 Value::True => SatLit::positive(f.atom),
1170 Value::False => SatLit::negative(f.atom),
1171 };
1172 cnf.add_clause(vec![lit]);
1173 }
1174
1175 let project = (0..c.atoms.len() as AtomId)
1176 .filter(|&a| constrained[a as usize])
1177 .collect();
1178 (cnf, project)
1179}
1180
1181fn v3_to_value(v: V3) -> Option<Value> {
1183 match v {
1184 V3::True => Some(Value::True),
1185 V3::False => Some(Value::False),
1186 V3::Unknown => None,
1187 }
1188}
1189
1190struct Construct {
1193 origin: Origin,
1194 label: String,
1195 clauses: Vec<Vec<sat::SatLit>>,
1196}
1197
1198fn same_origin(a: &Origin, b: &Origin) -> bool {
1200 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1201}
1202
1203fn constructs(c: &Compiled) -> Vec<Construct> {
1207 use sat::SatLit;
1208 let mut out: Vec<Construct> = Vec::new();
1209
1210 for f in &c.facts {
1211 let lit = match f.value {
1212 Value::True => SatLit::positive(f.atom),
1213 Value::False => SatLit::negative(f.atom),
1214 };
1215 out.push(Construct {
1216 origin: f.origin.clone(),
1217 label: label(c, f.atom),
1218 clauses: vec![vec![lit]],
1219 });
1220 }
1221
1222 let mut premises: Vec<Construct> = Vec::new();
1223 for clause in &c.clauses {
1224 let lits: Vec<SatLit> = clause
1225 .lits
1226 .iter()
1227 .map(|l| SatLit::new(l.atom, l.negated))
1228 .collect();
1229 match premises
1230 .iter_mut()
1231 .find(|k| same_origin(&k.origin, &clause.origin))
1232 {
1233 Some(k) => k.clauses.push(lits),
1234 None => premises.push(Construct {
1235 label: clause.origin.premise.clone().unwrap_or_default(),
1236 origin: clause.origin.clone(),
1237 clauses: vec![lits],
1238 }),
1239 }
1240 }
1241 out.extend(premises);
1242
1243 for r in &c.rules {
1244 let clauses = r
1245 .consequent
1246 .iter()
1247 .map(|cons| {
1248 let mut lits: Vec<SatLit> = r
1249 .antecedent
1250 .iter()
1251 .map(|a| SatLit::new(a.atom, a.negated))
1252 .collect();
1253 lits.push(SatLit::new(cons.atom, !cons.negated));
1254 lits
1255 })
1256 .collect();
1257 out.push(Construct {
1258 label: r.origin.premise.clone().unwrap_or_default(),
1259 origin: r.origin.clone(),
1260 clauses,
1261 });
1262 }
1263 out
1264}
1265
1266fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1268 let mut cnf = sat::Cnf::new(num_vars);
1269 for (k, &keep) in all.iter().zip(active) {
1270 if keep {
1271 for cl in &k.clauses {
1272 cnf.add_clause(cl.clone());
1273 }
1274 }
1275 }
1276 sat::solve(&cnf).is_some()
1277}
1278
1279fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1285 let base = c.atoms.len();
1286 let mut cnf = sat::Cnf::new(base + all.len());
1287 let sel = |i: usize| (base + i) as sat::Var;
1288 for (i, k) in all.iter().enumerate() {
1289 let s_neg = sat::SatLit::negative(sel(i));
1290 for cl in &k.clauses {
1291 let mut lits = Vec::with_capacity(cl.len() + 1);
1292 lits.push(s_neg);
1293 lits.extend_from_slice(cl);
1294 cnf.add_clause(lits);
1295 }
1296 }
1297 let assumptions: Vec<sat::SatLit> = (0..all.len())
1298 .map(|i| sat::SatLit::positive(sel(i)))
1299 .collect();
1300 let mut active = vec![false; all.len()];
1301 match sat::solve_assuming(&cnf, &assumptions) {
1302 sat::Solved::Unsat(core) => {
1303 for lit in core {
1304 let v = lit.var() as usize;
1305 if v >= base {
1306 active[v - base] = true;
1307 }
1308 }
1309 }
1310 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1314 }
1315 active
1316}
1317
1318fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1324 let all = constructs(c);
1325 let mut active = candidate_via_assumptions(c, &all);
1326 for i in 0..all.len() {
1327 if active[i] {
1328 active[i] = false;
1329 if subset_is_sat(c.atoms.len(), &all, &active) {
1330 active[i] = true; }
1332 }
1333 }
1334 let mut core: Vec<CoreItem> = all
1335 .iter()
1336 .zip(&active)
1337 .filter(|&(_, &keep)| keep)
1338 .map(|(k, _)| CoreItem {
1339 origin: k.origin.clone(),
1340 label: k.label.clone(),
1341 })
1342 .collect();
1343 core.sort_by_key(|it| key(&it.origin));
1344 core
1345}
1346
1347fn key(o: &Origin) -> (String, u32) {
1349 (o.source.clone(), o.line)
1350}
1351
1352pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1354 Ok(solve(&compile_source(name, src)?))
1355}
1356
1357pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1359 Ok(solve(&compile(root, resolver)?))
1360}
1361
1362impl fmt::Display for Status {
1365 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1366 f.write_str(match self {
1367 Status::Consistent => "CONSISTENT",
1368 Status::Underdetermined => "UNDERDETERMINED",
1369 Status::Warning => "WARNING",
1370 Status::Conflict => "CONFLICT",
1371 })
1372 }
1373}
1374
1375fn premise_tag(o: &Origin) -> String {
1377 let name = o.premise.as_deref().unwrap_or("-");
1378 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1379}
1380
1381fn trace_line(step: &TraceStep) -> String {
1383 let v = match step.value {
1384 Value::True => "TRUE",
1385 Value::False => "FALSE",
1386 };
1387 match &step.reason {
1388 TraceReason::Asserted(o) => {
1389 alloc::format!(
1390 "{} = {} [{} {}:{}]",
1391 step.atom,
1392 v,
1393 o.kind,
1394 o.source,
1395 o.line
1396 )
1397 }
1398 TraceReason::Derived { origin, from } => alloc::format!(
1399 "{} = {} from {} ({}) [{}:{}] <= {}",
1400 step.atom,
1401 v,
1402 origin.premise.as_deref().unwrap_or("-"),
1403 origin.kind,
1404 origin.source,
1405 origin.line,
1406 from.join(", ")
1407 ),
1408 }
1409}
1410
1411mod indent {
1417 pub const ROOT: usize = 0;
1419 pub const SECTION: usize = 2;
1422 pub const ITEM: usize = 6;
1424 pub const NESTED: usize = 8;
1426}
1427
1428struct ReportWriter<'a, 'b> {
1431 f: &'a mut fmt::Formatter<'b>,
1432}
1433
1434impl ReportWriter<'_, '_> {
1435 fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1437 write!(self.f, "{:width$}{}", "", args, width = indent)?;
1438 self.f.write_str("\n")
1439 }
1440
1441 fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1444 write!(self.f, "{:width$}{}", "", args, width = indent)
1445 }
1446}
1447
1448macro_rules! emit {
1452 ($out:expr, $indent:expr, $($arg:tt)*) => {
1453 $out.line($indent, format_args!($($arg)*))
1454 };
1455}
1456
1457impl fmt::Display for Report {
1458 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1459 use indent::{ITEM, NESTED, ROOT, SECTION};
1460 let mut out = ReportWriter { f };
1461
1462 emit!(out, ROOT, "RESULT: {}", self.status)?;
1463
1464 if !self.retract.is_empty() {
1468 emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
1472 emit!(
1473 out,
1474 ITEM,
1475 "But these ASSUME guesses cannot all be true together."
1476 )?;
1477 emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1478 for it in &self.retract {
1479 emit!(
1480 out,
1481 ITEM,
1482 "ASSUME {} [{}:{}]",
1483 it.label,
1484 it.origin.source,
1485 it.origin.line
1486 )?;
1487 }
1488 } else {
1489 for c in &self.conflicts {
1490 emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
1491 for a in &c.atoms {
1492 emit!(out, ITEM, "{}", a)?;
1493 }
1494 if !c.trace.is_empty() {
1495 emit!(out, ITEM, "why:")?;
1496 for step in &c.trace {
1497 emit!(out, NESTED, "{}", trace_line(step))?;
1498 }
1499 }
1500 }
1501 if !self.unsat_core.is_empty() {
1502 emit!(
1503 out,
1504 SECTION,
1505 "CORE smallest jointly-unsatisfiable set ({}):",
1506 self.unsat_core.len()
1507 )?;
1508 for it in &self.unsat_core {
1509 let name = if it.label.is_empty() { "-" } else { &it.label };
1510 emit!(
1511 out,
1512 NESTED,
1513 "{} ({}) [{}:{}]",
1514 name,
1515 it.origin.kind,
1516 it.origin.source,
1517 it.origin.line
1518 )?;
1519 }
1520 }
1521 }
1522
1523 let mut shown_fixes: Vec<&str> = Vec::new();
1528 for w in &self.warnings {
1529 emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
1530 emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1531 if let Some(hint) = &w.hint
1532 && !shown_fixes.contains(&hint.as_str())
1533 {
1534 shown_fixes.push(hint);
1535 emit!(out, ITEM, "fix: {hint}")?;
1536 }
1537 }
1538 if let Some(atom) = &self.underdetermined {
1539 emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
1540 emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
1541 }
1542 for d in &self.derived {
1543 let v = match d.value {
1544 Value::True => "TRUE",
1545 Value::False => "FALSE",
1546 };
1547 emit!(
1548 out,
1549 SECTION,
1550 "DERIVED {} = {} from {}",
1551 d.atom,
1552 v,
1553 premise_tag(&d.origin)
1554 )?;
1555 }
1556 for h in &self.hints {
1557 emit!(
1558 out,
1559 SECTION,
1560 "HINT possible typo — '{}' and '{}' look like the same atom ({})",
1561 h.a,
1562 h.b,
1563 h.reason
1564 )?;
1565 }
1566 for o in &self.orphans {
1567 let surface = if o.origin.kind == "ASSUME" && matches!(o.value, Value::False) {
1570 alloc::format!("ASSUME NOT {}", o.atom)
1571 } else {
1572 alloc::format!("{} {}", o.origin.kind, o.atom)
1573 };
1574 emit!(
1575 out,
1576 SECTION,
1577 "ORPHAN {} — not used by any premise or rule (no effect on the result)",
1578 surface
1579 )?;
1580 }
1581 for u in &self.unused_imports {
1582 let via = match &u.alias {
1583 Some(a) => alloc::format!("{} AS {}", u.domain, a),
1584 None => u.domain.clone(),
1585 };
1586 emit!(
1587 out,
1588 SECTION,
1589 "UNUSED IMPORT {} — imported in {}:{} but never referenced (no effect on the result)",
1590 via,
1591 u.file,
1592 u.line
1593 )?;
1594 }
1595
1596 let underdetermined = usize::from(self.status == Status::Underdetermined);
1597 emit!(
1598 out,
1599 ROOT,
1600 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1601 self.conflicts.len(),
1602 underdetermined,
1603 self.warnings.len(),
1604 self.derived.len()
1605 )?;
1606 out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1607 }
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612 use super::*;
1613
1614 fn vs(src: &str) -> Result<Report, CompileError> {
1617 verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
1618 }
1619
1620 #[test]
1621 fn clean_consistent() {
1622 let r = vs("FACT x a\nCHECK x\n").unwrap();
1623 assert_eq!(r.status, Status::Consistent);
1624 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1625 }
1626
1627 #[test]
1628 fn fact_contradiction_is_conflict() {
1629 let r = vs("FACT x a\nNOT x a\n").unwrap();
1630 assert_eq!(r.status, Status::Conflict);
1631 assert_eq!(r.conflicts.len(), 1);
1632 }
1633
1634 #[test]
1635 fn exclusive_violation_is_conflict() {
1636 let src = include_str!("../../../docs/examples/conflict.vrf");
1637 let r = verify_source("conflict.vrf", src).unwrap();
1638 assert_eq!(r.status, Status::Conflict);
1639 assert_eq!(
1640 r.conflicts[0].origin.premise.as_deref(),
1641 Some("fly_xor_swim")
1642 );
1643 assert_eq!(r.conflicts[0].atoms.len(), 2);
1644 }
1645
1646 #[test]
1647 fn exclusive_with_unknown_is_consistent_not_warning() {
1648 let r = vs("FACT A has flying\nPREMISE e:\n EXCLUSIVE\n A has flying\n A has swimming\n").unwrap();
1650 assert_eq!(r.status, Status::Consistent);
1651 assert!(r.warnings.is_empty());
1652 }
1653
1654 #[test]
1655 fn implication_missing_consequent_is_warning() {
1656 let r = vs(r#"
1658 FACT A has flying
1659 PREMISE w:
1660 WHEN A has flying
1661 THEN A has wing
1662 "#)
1663 .unwrap();
1664 assert_eq!(r.status, Status::Warning);
1665 assert_eq!(r.warnings.len(), 1);
1666 assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
1667 }
1668
1669 #[test]
1670 fn warning_hint_points_at_rule_when_atom_is_a_free_input() {
1671 let r = vs("FACT A has flying\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n")
1674 .unwrap();
1675 let hint = r.warnings[0].hint.as_deref().unwrap();
1676 assert!(hint.contains("make that PREMISE a RULE"), "{hint}");
1677 assert!(hint.contains("t.A has wing"), "{hint}");
1678 }
1679
1680 #[test]
1681 fn warning_hint_points_at_antecedent_when_a_rule_could_derive_it() {
1682 let r = vs(concat!(
1685 "RULE d:\n WHEN x trigger\n THEN c ready\n",
1686 "FACT go now\n",
1687 "PREMISE p:\n WHEN go now\n THEN c ready\n",
1688 ))
1689 .unwrap();
1690 assert_eq!(r.status, Status::Warning);
1691 let hint = r.warnings[0].hint.as_deref().unwrap();
1692 assert!(
1693 hint.contains("derived by a RULE that has not fired"),
1694 "{hint}"
1695 );
1696 }
1697
1698 #[test]
1699 fn human_report_dedupes_repeated_fix_lines() {
1700 let r = vs(concat!(
1706 "FACT a on\n",
1707 "FACT b on\n",
1708 "PREMISE p1:\n WHEN a on\n THEN gate one_ok\n",
1709 "PREMISE p2:\n WHEN b on\n THEN gate one_ok\n",
1710 "PREMISE p3:\n WHEN a on\n THEN gate two_ok\n",
1711 ))
1712 .unwrap();
1713 assert_eq!(r.warnings.len(), 3);
1714 assert!(r.warnings.iter().all(|w| w.hint.is_some()));
1716 let distinct_hints: BTreeSet<&str> = r
1717 .warnings
1718 .iter()
1719 .filter_map(|w| w.hint.as_deref())
1720 .collect();
1721 assert_eq!(distinct_hints.len(), 2);
1722 let text = alloc::format!("{r}");
1724 let shown = text
1725 .lines()
1726 .filter(|l| l.trim_start().starts_with("fix:"))
1727 .count();
1728 assert_eq!(shown, distinct_hints.len());
1729 }
1730
1731 #[test]
1732 fn implication_satisfied_is_consistent() {
1733 let r = vs("FACT A has flying\nFACT A has wing\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n").unwrap();
1734 assert_eq!(r.status, Status::Consistent);
1735 }
1736
1737 #[test]
1738 fn implication_violated_is_conflict() {
1739 let r = vs("FACT A has flying\nNOT A has wing\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n").unwrap();
1741 assert_eq!(r.status, Status::Conflict);
1742 }
1743
1744 #[test]
1745 fn rule_derives_fact() {
1746 let r = vs(r#"
1747 FACT A has flying
1748 RULE o:
1749 WHEN A has flying
1750 THEN A needs oxygen
1751 "#)
1752 .unwrap();
1753 assert_eq!(r.status, Status::Consistent);
1754 assert_eq!(r.derived.len(), 1);
1755 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1756 }
1757
1758 #[test]
1759 fn rule_derivation_contradiction_is_conflict() {
1760 let r = vs("FACT A has flying\nNOT A needs oxygen\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\n").unwrap();
1762 assert_eq!(r.status, Status::Conflict);
1763 }
1764
1765 #[test]
1766 fn bidirectional_finds_alternative_model_underdetermined() {
1767 let r = vs(r#"
1769 PREMISE e:
1770 EXCLUSIVE
1771 x a
1772 x b
1773 CHECK x BIDIRECTIONAL
1774 "#)
1775 .unwrap();
1776 assert_eq!(r.status, Status::Underdetermined);
1777 }
1778
1779 #[test]
1780 fn fact_pins_unique_model_consistent() {
1781 let r = vs(r#"
1783 FACT x a
1784 PREMISE e:
1785 EXCLUSIVE
1786 x a
1787 x b
1788 CHECK x BIDIRECTIONAL
1789 "#)
1790 .unwrap();
1791 assert_eq!(r.status, Status::Consistent);
1792 }
1793
1794 #[test]
1795 fn no_bidirectional_skips_backward_pass() {
1796 let r = vs(r#"
1798 PREMISE e:
1799 EXCLUSIVE
1800 x a
1801 x b
1802 CHECK x
1803 "#)
1804 .unwrap();
1805 assert_eq!(r.status, Status::Consistent);
1806 }
1807
1808 #[test]
1809 fn creature_example_forward_pass() {
1810 let src = include_str!("../../../docs/examples/creature.vrf");
1811 let r = verify_source("creature.vrf", src).unwrap();
1812 assert_eq!(r.status, Status::Warning);
1815 assert!(r.conflicts.is_empty());
1816 assert_eq!(r.warnings.len(), 2);
1817 assert_eq!(r.derived.len(), 1);
1818 assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
1819 }
1820
1821 #[test]
1822 fn roles_puzzle_is_uniquely_solved() {
1823 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1827 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1828 assert_eq!(r.status, Status::Consistent);
1829 assert!(r.conflicts.is_empty());
1830 assert!(r.underdetermined.is_none());
1831 }
1832
1833 #[test]
1834 fn roles_puzzle_underdetermined_without_a_clue() {
1835 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1840 .replace("\r\n", "\n")
1841 .replace("NOT bob is qa\n", "");
1842 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1843 assert_eq!(r.status, Status::Underdetermined);
1844 }
1845
1846 #[test]
1847 fn socrates_chain_is_a_conflict() {
1848 let src = include_str!("../../../docs/examples/socrates.vrf");
1851 let r = verify_source("socrates.vrf", src).unwrap();
1852 assert_eq!(r.status, Status::Conflict);
1853 assert_eq!(r.conflicts.len(), 1);
1854 assert_eq!(
1855 r.conflicts[0].origin.premise.as_deref(),
1856 Some("mortal_xor_immortal")
1857 );
1858 assert_eq!(r.derived.len(), 3); }
1860
1861 #[test]
1864 fn forward_conflict_carries_a_trace_of_its_facts() {
1865 let r = vs(r#"
1866 FACT x a
1867 FACT x b
1868 PREMISE e:
1869 EXCLUSIVE
1870 x a
1871 x b
1872 CHECK x
1873 "#)
1874 .unwrap();
1875 assert_eq!(r.status, Status::Conflict);
1876 let t = &r.conflicts[0].trace;
1877 assert_eq!(t.len(), 2);
1878 assert_eq!(t[0].atom, "t.x a");
1879 assert_eq!(t[0].value, Value::True);
1880 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1881 assert!(r.unsat_core.is_empty());
1882 }
1883
1884 #[test]
1885 fn derivation_chain_is_traced_back_to_the_root_fact() {
1886 let src = include_str!("../../../docs/examples/socrates.vrf");
1888 let r = verify_source("socrates.vrf", src).unwrap();
1889 let t = &r.conflicts[0].trace;
1890 assert_eq!(t.len(), 5);
1893 assert_eq!(t[0].atom, "philosophy.socrates is human");
1894 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1895 let mortal = t
1896 .iter()
1897 .find(|s| s.atom == "philosophy.socrates is mortal")
1898 .unwrap();
1899 match &mortal.reason {
1900 TraceReason::Derived { from, .. } => {
1901 assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
1902 }
1903 _ => panic!("mortal should be derived, not asserted"),
1904 }
1905 }
1906
1907 #[test]
1908 fn direct_fact_contradiction_has_no_trace() {
1909 let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
1910 assert_eq!(r.status, Status::Conflict);
1911 assert!(r.conflicts[0].trace.is_empty());
1912 }
1913
1914 #[test]
1915 fn jointly_unsatisfiable_reports_a_minimal_core() {
1916 let src = r#"
1919 PREMISE one:
1920 ONEOF
1921 x a
1922 x b
1923 PREMISE ac:
1924 WHEN x a
1925 THEN x c
1926 PREMISE bc:
1927 WHEN x b
1928 THEN x c
1929 NOT x c
1930 CHECK x BIDIRECTIONAL
1931 "#;
1932 let r = vs(src).unwrap();
1933 assert_eq!(r.status, Status::Conflict);
1934 assert_eq!(r.conflicts[0].origin.kind, "UNSAT");
1935 assert_eq!(r.unsat_core.len(), 4);
1936 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1937 assert!(labels.contains(&"one"));
1938 assert!(labels.contains(&"t.x c")); }
1940
1941 #[test]
1942 fn unsat_core_excludes_irrelevant_constructs() {
1943 let src = r#"
1946 PREMISE one:
1947 ONEOF
1948 x a
1949 x b
1950 PREMISE ac:
1951 WHEN x a
1952 THEN x c
1953 PREMISE bc:
1954 WHEN x b
1955 THEN x c
1956 NOT x c
1957 FACT z here
1958 PREMISE noise:
1959 EXCLUSIVE
1960 z here
1961 z gone
1962 CHECK x BIDIRECTIONAL
1963 "#;
1964 let r = vs(src).unwrap();
1965 assert_eq!(r.status, Status::Conflict);
1966 assert_eq!(r.unsat_core.len(), 4);
1967 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1968 assert!(!labels.contains(&"noise"));
1969 assert!(!labels.iter().any(|l| l.contains("here")));
1970 }
1971
1972 #[test]
1973 fn consistent_report_has_empty_core_and_no_trace() {
1974 let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
1975 assert_eq!(r.status, Status::Consistent);
1976 assert!(r.unsat_core.is_empty());
1977 assert!(r.conflicts.is_empty());
1978 }
1979
1980 #[test]
1983 fn compatible_assumptions_behave_like_facts() {
1984 let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
1987 assert_eq!(r.status, Status::Consistent);
1988 assert!(r.retract.is_empty());
1989 assert!(r.conflicts.is_empty());
1990 }
1991
1992 #[test]
1993 fn assume_drives_a_rule_like_a_fact() {
1994 let r = vs(
1996 "ASSUME A has flying\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\nCHECK A\n",
1997 )
1998 .unwrap();
1999 assert_eq!(r.status, Status::Consistent);
2000 assert_eq!(r.derived.len(), 1);
2001 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
2002 }
2003
2004 #[test]
2005 fn clashing_assumptions_yield_conflict_with_a_retract_set() {
2006 let src = r#"
2009 FACT rel reviewed
2010 PREMISE prod_needs_safety:
2011 WHEN rel in_prod
2012 THEN rel has_rollback
2013 OR rel has_feature_flag
2014 ASSUME rel in_prod
2015 ASSUME NOT rel has_rollback
2016 ASSUME NOT rel has_feature_flag
2017 CHECK rel
2018 "#;
2019 let r = vs(src).unwrap();
2020 assert_eq!(r.status, Status::Conflict);
2021 assert_eq!(r.exit_code(), 2);
2022 assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
2024 let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
2025 assert!(labels.contains(&"t.rel in_prod"));
2026 assert!(labels.contains(&"NOT t.rel has_rollback"));
2027 assert!(labels.contains(&"NOT t.rel has_feature_flag"));
2028 assert!(r.retract.iter().all(|it| it.origin.kind == "ASSUME"));
2030 let shown = alloc::format!("{r}");
2032 assert!(shown.contains("RETRACT"), "{shown}");
2033 assert!(!shown.contains("CONFLICT "), "{shown}");
2034 }
2035
2036 #[test]
2037 fn assume_vs_fact_retracts_only_the_assumption() {
2038 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2040 assert_eq!(r.status, Status::Conflict);
2041 assert_eq!(r.retract.len(), 1);
2042 assert_eq!(r.retract[0].label, "NOT t.x a");
2043 assert_eq!(r.retract[0].origin.kind, "ASSUME");
2044 }
2045
2046 #[test]
2047 fn hard_conflict_is_not_blamed_on_assumptions() {
2048 let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
2051 assert_eq!(r.status, Status::Conflict);
2052 assert!(r.retract.is_empty(), "{:?}", r.retract);
2053 }
2054
2055 #[test]
2056 fn two_assumptions_directly_contradict() {
2057 let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2058 assert_eq!(r.status, Status::Conflict);
2059 assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
2060 }
2061
2062 #[test]
2063 fn assume_retract_is_in_json() {
2064 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2065 let j = r.to_json();
2066 assert!(j.contains("\"retract\":["), "{j}");
2067 assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
2068 assert!(j.contains("NOT t.x a"), "{j}");
2069 }
2070
2071 #[test]
2074 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
2075 let r = vs(r#"
2079 FACT auth is rolled_back
2080 NOT auth is_rolled_back
2081 CHECK
2082 "#)
2083 .unwrap();
2084 assert_eq!(
2085 r.status,
2086 Status::Consistent,
2087 "hint must not change the verdict"
2088 );
2089 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
2090 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2091 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
2092 }
2093
2094 #[test]
2095 fn hint_flags_case_only_difference() {
2096 let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
2097 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2098 }
2099
2100 #[test]
2101 fn hint_flags_single_char_typo_same_subject() {
2102 let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
2104 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2105 }
2106
2107 #[test]
2108 fn no_hint_for_short_distinct_atoms() {
2109 let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
2111 assert!(r.hints.is_empty(), "{:?}", r.hints);
2112 }
2113
2114 #[test]
2115 fn no_hint_for_distinct_words() {
2116 let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
2117 assert!(r.hints.is_empty(), "{:?}", r.hints);
2118 }
2119
2120 #[test]
2121 fn russian_case_typo_is_flagged() {
2122 let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
2124 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2125 }
2126
2127 #[test]
2128 fn russian_single_char_typo_is_flagged() {
2129 let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
2130 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2131 }
2132
2133 #[test]
2134 fn cjk_one_char_difference_is_not_flagged() {
2135 let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
2138 assert!(r.hints.is_empty(), "{:?}", r.hints);
2139 }
2140
2141 #[test]
2142 fn cjk_underscore_vs_space_is_flagged() {
2143 let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
2146 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2147 }
2148
2149 #[test]
2152 fn orphan_fact_is_flagged_but_advisory_only() {
2153 let r = vs("FACT x a\nCHECK\n").unwrap();
2156 assert_eq!(
2157 r.status,
2158 Status::Consistent,
2159 "orphan must not change verdict"
2160 );
2161 assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
2162 assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
2163 assert_eq!(r.orphans[0].atom, "t.x a");
2164 assert_eq!(r.orphans[0].origin.kind, "FACT");
2165 }
2166
2167 #[test]
2168 fn fact_used_by_a_premise_is_not_orphan() {
2169 let r =
2171 vs("FACT x a\nPREMISE p:\n EXCLUSIVE\n x a\n x b\nCHECK\n").unwrap();
2172 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2173 }
2174
2175 #[test]
2176 fn fact_used_by_a_rule_antecedent_is_not_orphan() {
2177 let r = vs("FACT x a\nRULE r:\n WHEN x a\n THEN x c\nCHECK\n").unwrap();
2178 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2179 }
2180
2181 #[test]
2182 fn negation_and_assumption_orphans_keep_their_surface_polarity() {
2183 let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
2186 assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
2187 let text = alloc::format!("{r}");
2188 assert!(text.contains("ORPHAN NOT t.x a"), "{text}");
2189 assert!(text.contains("ORPHAN ASSUME NOT t.y b"), "{text}");
2190 }
2191
2192 #[test]
2193 fn orphan_is_in_json() {
2194 let r = vs("FACT x a\nCHECK\n").unwrap();
2195 let j = r.to_json();
2196 assert!(j.contains("\"orphans\":["), "{j}");
2197 assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
2198 assert!(j.contains("\"kind\":\"FACT\""), "{j}");
2199 }
2200
2201 #[test]
2202 fn unused_import_is_advisory_only_in_the_report() {
2203 let mut r = MemoryResolver::new();
2206 r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
2207 r.add(
2208 "main.vrf",
2209 "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\nCHECK\n",
2210 );
2211 let rep = verify("main.vrf", &r).unwrap();
2212 assert_eq!(rep.status, Status::Consistent);
2213 assert_eq!(rep.exit_code(), 0);
2214 assert_eq!(rep.unused_imports.len(), 1);
2215 assert_eq!(rep.unused_imports[0].domain, "physics");
2216 let text = alloc::format!("{rep}");
2217 assert!(text.contains("UNUSED IMPORT physics"), "{text}");
2218 assert!(
2219 rep.to_json().contains("\"unused_imports\":[{"),
2220 "{}",
2221 rep.to_json()
2222 );
2223 }
2224
2225 #[test]
2226 fn a_derived_atom_does_not_make_its_consumer_orphan() {
2227 let r = vs(
2230 "FACT x a\nRULE r:\n WHEN x a\n THEN x c\nPREMISE p:\n WHEN x c\n THEN x d\nCHECK\n",
2231 )
2232 .unwrap();
2233 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2234 }
2235}