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, KIND_UNSAT, Lit, Origin, Value, kw, levenshtein,
59};
60pub use elenchus_compiler::{
61 CompileError, MemoryResolver, Resolver, UnusedImport, compile, compile_source,
62};
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum V3 {
67 True,
69 False,
71 Unknown,
73}
74
75impl V3 {
76 fn not(self) -> V3 {
78 match self {
79 V3::True => V3::False,
80 V3::False => V3::True,
81 V3::Unknown => V3::Unknown,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum Status {
89 Consistent,
91 Underdetermined,
94 Warning,
96 Conflict,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct Conflict {
103 pub origin: Origin,
105 pub atoms: Vec<String>,
107 pub trace: Vec<TraceStep>,
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct TraceStep {
119 pub atom: String,
121 pub value: Value,
123 pub reason: TraceReason,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum TraceReason {
130 Asserted(Origin),
132 Derived {
134 origin: Origin,
136 from: Vec<String>,
138 },
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct Warning {
144 pub origin: Origin,
146 pub blocked_by: Vec<String>,
148 pub hint: Option<String>,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
158pub struct Derived {
159 pub atom: String,
161 pub value: Value,
163 pub origin: Origin,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct Report {
170 pub status: Status,
172 pub conflicts: Vec<Conflict>,
174 pub warnings: Vec<Warning>,
176 pub derived: Vec<Derived>,
178 pub underdetermined: Option<String>,
181 pub unsat_core: Vec<CoreItem>,
186 pub retract: Vec<CoreItem>,
194 pub hints: Vec<SimilarAtoms>,
197 pub orphans: Vec<OrphanFact>,
202 pub unused_imports: Vec<UnusedImport>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct SimilarAtoms {
216 pub a: String,
218 pub b: String,
220 pub reason: &'static str,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
231pub struct OrphanFact {
232 pub atom: String,
234 pub value: Value,
236 pub origin: Origin,
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
242pub struct CoreItem {
243 pub origin: Origin,
245 pub label: String,
247}
248
249impl Report {
250 pub fn exit_code(&self) -> i32 {
252 match self.status {
253 Status::Conflict => 2,
254 Status::Underdetermined | Status::Warning => 1,
255 Status::Consistent => 0,
256 }
257 }
258
259 pub fn to_json(&self) -> String {
262 use core::fmt::Write as _;
263 let mut s = String::new();
264 let _ = write!(s, "{{\"status\":");
265 json_str(status_name(self.status), &mut s);
266 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
267
268 s.push_str(",\"conflicts\":[");
269 for (i, c) in self.conflicts.iter().enumerate() {
270 if i > 0 {
271 s.push(',');
272 }
273 json_origin(&c.origin, &mut s);
274 s.push_str(",\"atoms\":");
275 json_array(&c.atoms, &mut s);
276 s.push_str(",\"trace\":[");
277 for (j, step) in c.trace.iter().enumerate() {
278 if j > 0 {
279 s.push(',');
280 }
281 json_trace_step(step, &mut s);
282 }
283 s.push_str("]}");
284 }
285 s.push_str("],\"warnings\":[");
286 for (i, w) in self.warnings.iter().enumerate() {
287 if i > 0 {
288 s.push(',');
289 }
290 json_origin(&w.origin, &mut s);
291 s.push_str(",\"blocked_by\":");
292 json_array(&w.blocked_by, &mut s);
293 s.push_str(",\"hint\":");
294 match &w.hint {
295 Some(h) => json_str(h, &mut s),
296 None => s.push_str("null"),
297 }
298 s.push('}');
299 }
300 s.push_str("],\"derived\":[");
301 for (i, d) in self.derived.iter().enumerate() {
302 if i > 0 {
303 s.push(',');
304 }
305 s.push('{');
306 json_origin_fields(&d.origin, &mut s);
307 s.push_str(",\"atom\":");
308 json_str(&d.atom, &mut s);
309 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
310 s.push('}');
311 }
312 s.push_str("],\"underdetermined\":");
313 match &self.underdetermined {
314 Some(atom) => json_str(atom, &mut s),
315 None => s.push_str("null"),
316 }
317 s.push_str(",\"unsat_core\":[");
318 for (i, it) in self.unsat_core.iter().enumerate() {
319 if i > 0 {
320 s.push(',');
321 }
322 json_origin(&it.origin, &mut s);
323 s.push_str(",\"label\":");
324 json_str(&it.label, &mut s);
325 s.push('}');
326 }
327 s.push_str("],\"retract\":[");
328 for (i, it) in self.retract.iter().enumerate() {
329 if i > 0 {
330 s.push(',');
331 }
332 json_origin(&it.origin, &mut s);
333 s.push_str(",\"label\":");
334 json_str(&it.label, &mut s);
335 s.push('}');
336 }
337 s.push_str("],\"hints\":[");
338 for (i, h) in self.hints.iter().enumerate() {
339 if i > 0 {
340 s.push(',');
341 }
342 s.push_str("{\"a\":");
343 json_str(&h.a, &mut s);
344 s.push_str(",\"b\":");
345 json_str(&h.b, &mut s);
346 s.push_str(",\"reason\":");
347 json_str(h.reason, &mut s);
348 s.push('}');
349 }
350 s.push_str("],\"orphans\":[");
351 for (i, o) in self.orphans.iter().enumerate() {
352 if i > 0 {
353 s.push(',');
354 }
355 json_origin(&o.origin, &mut s);
356 s.push_str(",\"atom\":");
357 json_str(&o.atom, &mut s);
358 let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
359 s.push('}');
360 }
361 s.push_str("],\"unused_imports\":[");
362 for (i, u) in self.unused_imports.iter().enumerate() {
363 if i > 0 {
364 s.push(',');
365 }
366 s.push_str("{\"file\":");
367 json_str(&u.file, &mut s);
368 s.push_str(",\"domain\":");
369 json_str(&u.domain, &mut s);
370 s.push_str(",\"alias\":");
371 match &u.alias {
372 Some(a) => json_str(a, &mut s),
373 None => s.push_str("null"),
374 }
375 let _ = write!(s, ",\"line\":{}", u.line);
376 s.push('}');
377 }
378 s.push_str("]}");
379 s
380 }
381}
382
383fn json_trace_step(step: &TraceStep, out: &mut String) {
385 use core::fmt::Write as _;
386 out.push_str("{\"atom\":");
387 json_str(&step.atom, out);
388 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
389 match &step.reason {
390 TraceReason::Asserted(o) => {
391 out.push_str(",\"how\":\"asserted\",");
392 json_origin_fields(o, out);
393 out.push_str(",\"from\":[]");
394 }
395 TraceReason::Derived { origin, from } => {
396 out.push_str(",\"how\":\"derived\",");
397 json_origin_fields(origin, out);
398 out.push_str(",\"from\":");
399 json_array(from, out);
400 }
401 }
402 out.push('}');
403}
404
405fn status_name(s: Status) -> &'static str {
406 match s {
407 Status::Consistent => "CONSISTENT",
408 Status::Underdetermined => "UNDERDETERMINED",
409 Status::Warning => "WARNING",
410 Status::Conflict => "CONFLICT",
411 }
412}
413
414fn json_str(value: &str, out: &mut String) {
416 use core::fmt::Write as _;
417 out.push('"');
418 for ch in value.chars() {
419 match ch {
420 '"' => out.push_str("\\\""),
421 '\\' => out.push_str("\\\\"),
422 '\n' => out.push_str("\\n"),
423 '\r' => out.push_str("\\r"),
424 '\t' => out.push_str("\\t"),
425 c if (c as u32) < 0x20 => {
426 let _ = write!(out, "\\u{:04x}", c as u32);
427 }
428 c => out.push(c),
429 }
430 }
431 out.push('"');
432}
433
434fn json_array(items: &[String], out: &mut String) {
436 out.push('[');
437 for (i, item) in items.iter().enumerate() {
438 if i > 0 {
439 out.push(',');
440 }
441 json_str(item, out);
442 }
443 out.push(']');
444}
445
446fn json_origin_fields(o: &Origin, out: &mut String) {
448 use core::fmt::Write as _;
449 out.push_str("\"premise\":");
450 match &o.premise {
451 Some(name) => json_str(name, out),
452 None => out.push_str("null"),
453 }
454 out.push_str(",\"kind\":");
455 json_str(o.kind, out);
456 out.push_str(",\"source\":");
457 json_str(&o.source, out);
458 let _ = write!(out, ",\"line\":{}", o.line);
459}
460
461fn json_origin(o: &Origin, out: &mut String) {
463 out.push('{');
464 json_origin_fields(o, out);
465}
466
467fn label(c: &Compiled, a: AtomId) -> String {
471 let k = &c.atoms[a as usize];
472 match &k.object {
473 Some(o) => alloc::format!("{}.{} {} {}", k.domain, k.subject, k.predicate, o),
474 None => alloc::format!("{}.{} {}", k.domain, k.subject, k.predicate),
475 }
476}
477
478fn lit_value(model: &[V3], l: &Lit) -> V3 {
480 let v = model[l.atom as usize];
481 if l.negated { v.not() } else { v }
482}
483
484fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
486 let mut result = V3::True;
487 for l in lits {
488 match lit_value(model, l) {
489 V3::False => return V3::False,
490 V3::Unknown => result = V3::Unknown,
491 V3::True => {}
492 }
493 }
494 result
495}
496
497enum ClauseEval {
499 Violated,
501 Satisfied,
503 Blocked(Vec<AtomId>),
505}
506
507fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
510 let mut any_false = false;
511 let mut all_true = true;
512 let mut blocked = Vec::new();
513 for l in &clause.lits {
514 match lit_value(model, l) {
515 V3::False => {
516 any_false = true;
517 all_true = false;
518 }
519 V3::Unknown => {
520 all_true = false;
521 blocked.push(l.atom);
522 }
523 V3::True => {}
524 }
525 }
526 if all_true {
527 ClauseEval::Violated
528 } else if any_false {
529 ClauseEval::Satisfied
530 } else {
531 ClauseEval::Blocked(blocked)
532 }
533}
534
535#[derive(Clone)]
538enum AtomReason {
539 Asserted(Origin),
541 Derived { origin: Origin, from: Vec<AtomId> },
543}
544
545struct RawConflict {
549 origin: Origin,
550 atoms: Vec<String>,
551 cause: Vec<AtomId>,
552}
553
554struct Eval<'a> {
556 c: &'a Compiled,
557 model: Vec<V3>,
558 reason: Vec<Option<AtomReason>>,
561 conflicts: Vec<RawConflict>,
562 warnings: Vec<Warning>,
563 derived: Vec<Derived>,
564 unsat_core: Vec<CoreItem>,
566}
567
568impl<'a> Eval<'a> {
569 fn new(c: &'a Compiled) -> Self {
570 Eval {
571 c,
572 model: vec![V3::Unknown; c.atoms.len()],
573 reason: vec![None; c.atoms.len()],
574 conflicts: Vec::new(),
575 warnings: Vec::new(),
576 derived: Vec::new(),
577 unsat_core: Vec::new(),
578 }
579 }
580
581 fn label(&self, a: AtomId) -> String {
582 label(self.c, a)
583 }
584
585 fn seed_facts(&mut self) {
587 let c = self.c;
588 let n = c.atoms.len();
589 let mut t: Vec<Option<Origin>> = vec![None; n];
590 let mut f: Vec<Option<Origin>> = vec![None; n];
591 for fact in &c.facts {
592 let slot = match fact.value {
593 Value::True => &mut t,
594 Value::False => &mut f,
595 };
596 if slot[fact.atom as usize].is_none() {
597 slot[fact.atom as usize] = Some(fact.origin.clone());
598 }
599 }
600 for a in 0..n {
601 match (&t[a], &f[a]) {
602 (Some(o), Some(_)) => {
603 self.model[a] = V3::True;
604 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
605 self.conflicts.push(RawConflict {
606 origin: o.clone(),
607 atoms: vec![alloc::format!(
608 "{} (asserted both TRUE and FALSE)",
609 self.label(a as AtomId)
610 )],
611 cause: Vec::new(),
612 });
613 }
614 (Some(o), None) => {
615 self.model[a] = V3::True;
616 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
617 }
618 (None, Some(o)) => {
619 self.model[a] = V3::False;
620 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
621 }
622 (None, None) => {}
623 }
624 }
625 }
626
627 fn saturate_rules(&mut self) {
629 let c = self.c;
630 loop {
631 let mut changed = false;
632 for r in &c.rules {
633 if conjunction(&self.model, &r.antecedent) != V3::True {
634 continue; }
636 for cl in &r.consequent {
637 let target = if cl.negated { V3::False } else { V3::True };
638 match self.model[cl.atom as usize] {
639 V3::Unknown => {
640 self.model[cl.atom as usize] = target;
641 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
642 origin: r.origin.clone(),
643 from: r.antecedent.iter().map(|l| l.atom).collect(),
644 });
645 changed = true;
646 self.derived.push(Derived {
647 atom: self.label(cl.atom),
648 value: if cl.negated {
649 Value::False
650 } else {
651 Value::True
652 },
653 origin: r.origin.clone(),
654 });
655 }
656 v if v == target => {}
657 _ => {
658 let mut cause: Vec<AtomId> =
662 r.antecedent.iter().map(|l| l.atom).collect();
663 cause.push(cl.atom);
664 self.conflicts.push(RawConflict {
665 origin: r.origin.clone(),
666 atoms: vec![alloc::format!(
667 "{} (derived value contradicts a known fact)",
668 self.label(cl.atom)
669 )],
670 cause,
671 });
672 }
673 }
674 }
675 }
676 if !changed {
677 break;
678 }
679 }
680 }
681
682 fn check_premises(&mut self) {
684 let c = self.c;
685 let derivable: BTreeSet<AtomId> = c
690 .rules
691 .iter()
692 .flat_map(|r| r.consequent.iter().map(|l| l.atom))
693 .collect();
694 for clause in &c.clauses {
695 match eval_clause(&self.model, clause) {
696 ClauseEval::Violated => self.conflicts.push(RawConflict {
697 origin: clause.origin.clone(),
698 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
699 cause: clause.lits.iter().map(|l| l.atom).collect(),
700 }),
701 ClauseEval::Satisfied => {}
702 ClauseEval::Blocked(unknowns) if clause.origin.kind == kw::PREMISE => {
705 let hint = self.warning_hint(&unknowns, &derivable);
706 self.warnings.push(Warning {
707 origin: clause.origin.clone(),
708 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
709 hint,
710 });
711 }
712 ClauseEval::Blocked(_) => {}
713 }
714 }
715 }
716
717 fn warning_hint(&self, unknowns: &[AtomId], derivable: &BTreeSet<AtomId>) -> Option<String> {
722 let free = unknowns.iter().find(|a| !derivable.contains(a));
723 match free {
724 Some(&a) => Some(alloc::format!(
725 "nothing determines `{}` — add `FACT {}` (or `NOT …`), or if a PREMISE's \
726 THEN is meant to establish it, make that PREMISE a RULE so it derives the value",
727 self.label(a),
728 self.label(a),
729 )),
730 None => unknowns.first().map(|&a| {
731 alloc::format!(
732 "`{}` is derived by a RULE that has not fired — assert that rule's antecedent",
733 self.label(a),
734 )
735 }),
736 }
737 }
738
739 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
745 let mut visited = vec![false; self.c.atoms.len()];
746 let mut out = Vec::new();
747 for &a in causes {
748 self.trace_dfs(a, &mut visited, &mut out);
749 }
750 out
751 }
752
753 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
754 if visited[a as usize] {
755 return;
756 }
757 visited[a as usize] = true;
758 let value = match v3_to_value(self.model[a as usize]) {
759 Some(v) => v,
760 None => return, };
762 let reason = match &self.reason[a as usize] {
763 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
764 Some(AtomReason::Derived { origin, from }) => {
765 for &f in from {
766 self.trace_dfs(f, visited, out); }
768 TraceReason::Derived {
769 origin: origin.clone(),
770 from: from.iter().map(|&f| self.label(f)).collect(),
771 }
772 }
773 None => return,
774 };
775 out.push(TraceStep {
776 atom: self.label(a),
777 value,
778 reason,
779 });
780 }
781
782 fn backward_pass(&mut self) -> Option<String> {
789 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
790 return None;
791 }
792 let (cnf, project) = build_cnf(self.c);
793 let found = sat::models(&cnf, &project, 2);
794 match found.len() {
795 0 if self.conflicts.is_empty() => {
796 self.unsat_core = minimal_unsat_core(self.c);
797 self.conflicts.push(RawConflict {
798 origin: Origin {
799 source: String::from("<system>"),
800 line: 0,
801 premise: None,
802 kind: KIND_UNSAT,
803 },
804 atoms: vec![String::from(
805 "the premises and facts are jointly unsatisfiable",
806 )],
807 cause: Vec::new(),
808 });
809 None
810 }
811 n if n >= 2 => {
812 let (m0, m1) = (&found[0], &found[1]);
813 project
814 .iter()
815 .find(|&&v| m0[v as usize] != m1[v as usize])
816 .map(|&v| self.label(v))
817 .or_else(|| Some(String::from("a free atom")))
818 }
819 _ => None,
820 }
821 }
822
823 fn finish(mut self) -> Report {
825 let underdetermined = self.backward_pass();
826 self.conflicts.sort_by_key(|c| key(&c.origin));
827 self.warnings.sort_by_key(|w| key(&w.origin));
828 let status = if !self.conflicts.is_empty() {
829 Status::Conflict
830 } else if underdetermined.is_some() {
831 Status::Underdetermined
832 } else if !self.warnings.is_empty() {
833 Status::Warning
834 } else {
835 Status::Consistent
836 };
837 let conflicts: Vec<Conflict> = self
840 .conflicts
841 .iter()
842 .map(|rc| Conflict {
843 origin: rc.origin.clone(),
844 atoms: rc.atoms.clone(),
845 trace: self.build_trace(&rc.cause),
846 })
847 .collect();
848 Report {
849 status,
850 conflicts,
851 warnings: self.warnings,
852 derived: self.derived,
853 underdetermined,
854 unsat_core: self.unsat_core,
855 retract: Vec::new(), hints: Vec::new(), orphans: Vec::new(), unused_imports: Vec::new(), }
860 }
861}
862
863pub fn solve(c: &Compiled) -> Report {
866 let mut e = Eval::new(c);
867 e.seed_facts();
868 e.saturate_rules();
869 e.check_premises();
870 let mut report = e.finish();
871 if report.status == Status::Conflict {
877 let retract = retract_assumptions(c);
878 if !retract.is_empty() {
879 report.unsat_core = Vec::new();
880 report.retract = retract;
881 }
882 }
883 report.hints = similar_atom_pairs(c);
886 report.orphans = orphan_facts(c);
889 report.unused_imports = c.unused_imports.clone();
892 report
893}
894
895fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
905 let mut referenced = vec![false; c.atoms.len()];
906 for clause in &c.clauses {
907 for l in &clause.lits {
908 referenced[l.atom as usize] = true;
909 }
910 }
911 for r in &c.rules {
912 for l in r.antecedent.iter().chain(r.consequent.iter()) {
913 referenced[l.atom as usize] = true;
914 }
915 }
916 for &a in &c.consumed {
918 referenced[a as usize] = true;
919 }
920 let mut out: Vec<OrphanFact> = c
921 .facts
922 .iter()
923 .filter(|f| !referenced[f.atom as usize])
924 .map(|f| OrphanFact {
925 atom: label(c, f.atom),
926 value: f.value,
927 origin: f.origin.clone(),
928 })
929 .collect();
930 out.sort_by_key(|o| key(&o.origin));
931 out
932}
933
934fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
950 if !c.facts.iter().any(|f| f.soft) {
951 return Vec::new();
952 }
953 let all = constructs(c);
954 let is_soft: Vec<bool> = (0..all.len())
956 .map(|i| i < c.facts.len() && c.facts[i].soft)
957 .collect();
958
959 let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
962 if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
963 return Vec::new();
964 }
965 let mut active = vec![true; all.len()];
967 if subset_is_sat(c.atoms.len(), &all, &active) {
968 return Vec::new();
969 }
970 for i in 0..all.len() {
972 if active[i] && is_soft[i] {
973 active[i] = false;
974 if subset_is_sat(c.atoms.len(), &all, &active) {
975 active[i] = true; }
977 }
978 }
979 let mut core: Vec<CoreItem> = (0..all.len())
980 .filter(|&i| active[i] && is_soft[i])
981 .map(|i| {
982 let f = &c.facts[i];
983 let label = if matches!(f.value, Value::False) {
985 alloc::format!("NOT {}", label(c, f.atom))
986 } else {
987 label(c, f.atom)
988 };
989 CoreItem {
990 origin: f.origin.clone(),
991 label,
992 }
993 })
994 .collect();
995 core.sort_by_key(|it| key(&it.origin));
996 core
997}
998
999fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
1019 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
1020 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
1021 let mut consumed = vec![false; c.atoms.len()];
1024 for &a in &c.consumed {
1025 consumed[a as usize] = true;
1026 }
1027 let mut out = Vec::new();
1028 for i in 0..c.atoms.len() {
1029 if consumed[i] {
1030 continue;
1031 }
1032 for j in (i + 1)..c.atoms.len() {
1033 if consumed[j] {
1034 continue;
1035 }
1036 if let Some(reason) = atoms_look_similar(
1037 &c.atoms[i],
1038 &folded[i],
1039 cased[i],
1040 &c.atoms[j],
1041 &folded[j],
1042 cased[j],
1043 ) {
1044 out.push(SimilarAtoms {
1045 a: label(c, i as AtomId),
1046 b: label(c, j as AtomId),
1047 reason,
1048 });
1049 }
1050 }
1051 }
1052 out
1053}
1054
1055fn fold_atom(k: &AtomKey) -> Vec<char> {
1059 let mut raw = String::new();
1060 raw.push_str(&k.subject);
1061 raw.push(' ');
1062 raw.push_str(&k.predicate);
1063 if let Some(o) = &k.object {
1064 raw.push(' ');
1065 raw.push_str(o);
1066 }
1067 let mut out: Vec<char> = Vec::new();
1068 let mut prev_space = false;
1069 for ch in raw.chars() {
1070 if ch == '_' || ch.is_whitespace() {
1071 if !prev_space && !out.is_empty() {
1072 out.push(' ');
1073 prev_space = true;
1074 }
1075 } else {
1076 for lc in ch.to_lowercase() {
1077 out.push(lc);
1078 }
1079 prev_space = false;
1080 }
1081 }
1082 if out.last() == Some(&' ') {
1083 out.pop();
1084 }
1085 out
1086}
1087
1088fn is_cased_alphabetic(folded: &[char]) -> bool {
1095 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
1096}
1097
1098fn atoms_look_similar(
1101 ka: &AtomKey,
1102 fa: &[char],
1103 cased_a: bool,
1104 kb: &AtomKey,
1105 fb: &[char],
1106 cased_b: bool,
1107) -> Option<&'static str> {
1108 if fa == fb && ka.domain == kb.domain {
1112 return Some("same name up to case, '_', or spaces");
1113 }
1114 if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
1119 return None;
1120 }
1121 if fa.len().abs_diff(fb.len()) > 1 {
1122 return None; }
1124 let min_len = fa.len().min(fb.len());
1125 if min_len >= 5 && levenshtein(fa, fb) == 1 {
1126 return Some("looks like a one-character typo of each other");
1127 }
1128 None
1129}
1130
1131fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
1136 use sat::SatLit;
1137 let mut cnf = sat::Cnf::new(c.atoms.len());
1138 let mut constrained = vec![false; c.atoms.len()];
1139 let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
1140
1141 for clause in &c.clauses {
1143 let lits = clause
1144 .lits
1145 .iter()
1146 .map(|l| {
1147 mark(l.atom, &mut constrained);
1148 SatLit::new(l.atom, l.negated)
1149 })
1150 .collect();
1151 cnf.add_clause(lits);
1152 }
1153 for r in &c.rules {
1155 for cons in &r.consequent {
1156 let mut lits: Vec<SatLit> = r
1157 .antecedent
1158 .iter()
1159 .map(|a| {
1160 mark(a.atom, &mut constrained);
1161 SatLit::new(a.atom, a.negated)
1162 })
1163 .collect();
1164 mark(cons.atom, &mut constrained);
1165 lits.push(SatLit::new(cons.atom, !cons.negated));
1166 cnf.add_clause(lits);
1167 }
1168 }
1169 for f in &c.facts {
1171 let lit = match f.value {
1172 Value::True => SatLit::positive(f.atom),
1173 Value::False => SatLit::negative(f.atom),
1174 };
1175 cnf.add_clause(vec![lit]);
1176 }
1177
1178 let project = (0..c.atoms.len() as AtomId)
1179 .filter(|&a| constrained[a as usize])
1180 .collect();
1181 (cnf, project)
1182}
1183
1184fn v3_to_value(v: V3) -> Option<Value> {
1186 match v {
1187 V3::True => Some(Value::True),
1188 V3::False => Some(Value::False),
1189 V3::Unknown => None,
1190 }
1191}
1192
1193struct Construct {
1196 origin: Origin,
1197 label: String,
1198 clauses: Vec<Vec<sat::SatLit>>,
1199}
1200
1201fn same_origin(a: &Origin, b: &Origin) -> bool {
1203 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1204}
1205
1206fn constructs(c: &Compiled) -> Vec<Construct> {
1210 use sat::SatLit;
1211 let mut out: Vec<Construct> = Vec::new();
1212
1213 for f in &c.facts {
1214 let lit = match f.value {
1215 Value::True => SatLit::positive(f.atom),
1216 Value::False => SatLit::negative(f.atom),
1217 };
1218 out.push(Construct {
1219 origin: f.origin.clone(),
1220 label: label(c, f.atom),
1221 clauses: vec![vec![lit]],
1222 });
1223 }
1224
1225 let mut premises: Vec<Construct> = Vec::new();
1226 for clause in &c.clauses {
1227 let lits: Vec<SatLit> = clause
1228 .lits
1229 .iter()
1230 .map(|l| SatLit::new(l.atom, l.negated))
1231 .collect();
1232 match premises
1233 .iter_mut()
1234 .find(|k| same_origin(&k.origin, &clause.origin))
1235 {
1236 Some(k) => k.clauses.push(lits),
1237 None => premises.push(Construct {
1238 label: clause.origin.premise.clone().unwrap_or_default(),
1239 origin: clause.origin.clone(),
1240 clauses: vec![lits],
1241 }),
1242 }
1243 }
1244 out.extend(premises);
1245
1246 for r in &c.rules {
1247 let clauses = r
1248 .consequent
1249 .iter()
1250 .map(|cons| {
1251 let mut lits: Vec<SatLit> = r
1252 .antecedent
1253 .iter()
1254 .map(|a| SatLit::new(a.atom, a.negated))
1255 .collect();
1256 lits.push(SatLit::new(cons.atom, !cons.negated));
1257 lits
1258 })
1259 .collect();
1260 out.push(Construct {
1261 label: r.origin.premise.clone().unwrap_or_default(),
1262 origin: r.origin.clone(),
1263 clauses,
1264 });
1265 }
1266 out
1267}
1268
1269fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1271 let mut cnf = sat::Cnf::new(num_vars);
1272 for (k, &keep) in all.iter().zip(active) {
1273 if keep {
1274 for cl in &k.clauses {
1275 cnf.add_clause(cl.clone());
1276 }
1277 }
1278 }
1279 sat::solve(&cnf).is_some()
1280}
1281
1282fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1288 let base = c.atoms.len();
1289 let mut cnf = sat::Cnf::new(base + all.len());
1290 let sel = |i: usize| (base + i) as sat::Var;
1291 for (i, k) in all.iter().enumerate() {
1292 let s_neg = sat::SatLit::negative(sel(i));
1293 for cl in &k.clauses {
1294 let mut lits = Vec::with_capacity(cl.len() + 1);
1295 lits.push(s_neg);
1296 lits.extend_from_slice(cl);
1297 cnf.add_clause(lits);
1298 }
1299 }
1300 let assumptions: Vec<sat::SatLit> = (0..all.len())
1301 .map(|i| sat::SatLit::positive(sel(i)))
1302 .collect();
1303 let mut active = vec![false; all.len()];
1304 match sat::solve_assuming(&cnf, &assumptions) {
1305 sat::Solved::Unsat(core) => {
1306 for lit in core {
1307 let v = lit.var() as usize;
1308 if v >= base {
1309 active[v - base] = true;
1310 }
1311 }
1312 }
1313 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1317 }
1318 active
1319}
1320
1321fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1327 let all = constructs(c);
1328 let mut active = candidate_via_assumptions(c, &all);
1329 for i in 0..all.len() {
1330 if active[i] {
1331 active[i] = false;
1332 if subset_is_sat(c.atoms.len(), &all, &active) {
1333 active[i] = true; }
1335 }
1336 }
1337 let mut core: Vec<CoreItem> = all
1338 .iter()
1339 .zip(&active)
1340 .filter(|&(_, &keep)| keep)
1341 .map(|(k, _)| CoreItem {
1342 origin: k.origin.clone(),
1343 label: k.label.clone(),
1344 })
1345 .collect();
1346 core.sort_by_key(|it| key(&it.origin));
1347 core
1348}
1349
1350fn key(o: &Origin) -> (String, u32) {
1352 (o.source.clone(), o.line)
1353}
1354
1355pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1357 Ok(solve(&compile_source(name, src)?))
1358}
1359
1360pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1362 Ok(solve(&compile(root, resolver)?))
1363}
1364
1365impl fmt::Display for Status {
1368 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1369 f.write_str(status_name(*self))
1370 }
1371}
1372
1373fn premise_tag(o: &Origin) -> String {
1375 let name = o.premise.as_deref().unwrap_or("-");
1376 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1377}
1378
1379fn trace_line(step: &TraceStep) -> String {
1381 let v = match step.value {
1382 Value::True => "TRUE",
1383 Value::False => "FALSE",
1384 };
1385 match &step.reason {
1386 TraceReason::Asserted(o) => {
1387 alloc::format!(
1388 "{} = {} [{} {}:{}]",
1389 step.atom,
1390 v,
1391 o.kind,
1392 o.source,
1393 o.line
1394 )
1395 }
1396 TraceReason::Derived { origin, from } => alloc::format!(
1397 "{} = {} from {} ({}) [{}:{}] <= {}",
1398 step.atom,
1399 v,
1400 origin.premise.as_deref().unwrap_or("-"),
1401 origin.kind,
1402 origin.source,
1403 origin.line,
1404 from.join(", ")
1405 ),
1406 }
1407}
1408
1409mod indent {
1415 pub const ROOT: usize = 0;
1417 pub const SECTION: usize = 2;
1420 pub const ITEM: usize = 6;
1422 pub const NESTED: usize = 8;
1424}
1425
1426struct ReportWriter<'a, 'b> {
1429 f: &'a mut fmt::Formatter<'b>,
1430}
1431
1432impl ReportWriter<'_, '_> {
1433 fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1435 write!(self.f, "{:width$}{}", "", args, width = indent)?;
1436 self.f.write_str("\n")
1437 }
1438
1439 fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1442 write!(self.f, "{:width$}{}", "", args, width = indent)
1443 }
1444}
1445
1446macro_rules! emit {
1450 ($out:expr, $indent:expr, $($arg:tt)*) => {
1451 $out.line($indent, format_args!($($arg)*))
1452 };
1453}
1454
1455impl fmt::Display for Report {
1456 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1457 use indent::{ITEM, NESTED, ROOT, SECTION};
1458 let mut out = ReportWriter { f };
1459
1460 emit!(out, ROOT, "RESULT: {}", self.status)?;
1461
1462 if !self.retract.is_empty() {
1466 emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
1470 emit!(
1471 out,
1472 ITEM,
1473 "But these ASSUME guesses cannot all be true together."
1474 )?;
1475 emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1476 for it in &self.retract {
1477 emit!(
1478 out,
1479 ITEM,
1480 "ASSUME {} [{}:{}]",
1481 it.label,
1482 it.origin.source,
1483 it.origin.line
1484 )?;
1485 }
1486 } else {
1487 for c in &self.conflicts {
1488 emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
1489 for a in &c.atoms {
1490 emit!(out, ITEM, "{}", a)?;
1491 }
1492 if !c.trace.is_empty() {
1493 emit!(out, ITEM, "why:")?;
1494 for step in &c.trace {
1495 emit!(out, NESTED, "{}", trace_line(step))?;
1496 }
1497 }
1498 }
1499 if !self.unsat_core.is_empty() {
1500 emit!(
1501 out,
1502 SECTION,
1503 "CORE smallest jointly-unsatisfiable set ({}):",
1504 self.unsat_core.len()
1505 )?;
1506 for it in &self.unsat_core {
1507 let name = if it.label.is_empty() { "-" } else { &it.label };
1508 emit!(
1509 out,
1510 NESTED,
1511 "{} ({}) [{}:{}]",
1512 name,
1513 it.origin.kind,
1514 it.origin.source,
1515 it.origin.line
1516 )?;
1517 }
1518 }
1519 }
1520
1521 let mut shown_fixes: Vec<&str> = Vec::new();
1526 for w in &self.warnings {
1527 emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
1528 emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1529 if let Some(hint) = &w.hint
1530 && !shown_fixes.contains(&hint.as_str())
1531 {
1532 shown_fixes.push(hint);
1533 emit!(out, ITEM, "fix: {hint}")?;
1534 }
1535 }
1536 if let Some(atom) = &self.underdetermined {
1537 emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
1538 emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
1539 }
1540 for d in &self.derived {
1541 let v = match d.value {
1542 Value::True => "TRUE",
1543 Value::False => "FALSE",
1544 };
1545 emit!(
1546 out,
1547 SECTION,
1548 "DERIVED {} = {} from {}",
1549 d.atom,
1550 v,
1551 premise_tag(&d.origin)
1552 )?;
1553 }
1554 for h in &self.hints {
1555 emit!(
1556 out,
1557 SECTION,
1558 "HINT possible typo — '{}' and '{}' look like the same atom ({})",
1559 h.a,
1560 h.b,
1561 h.reason
1562 )?;
1563 }
1564 for o in &self.orphans {
1565 let surface = if o.origin.kind == kw::ASSUME && matches!(o.value, Value::False) {
1568 alloc::format!("{} {} {}", kw::ASSUME, kw::NOT, o.atom)
1569 } else {
1570 alloc::format!("{} {}", o.origin.kind, o.atom)
1571 };
1572 emit!(
1573 out,
1574 SECTION,
1575 "ORPHAN {} — not used by any premise or rule (no effect on the result)",
1576 surface
1577 )?;
1578 }
1579 for u in &self.unused_imports {
1580 let via = match &u.alias {
1581 Some(a) => alloc::format!("{} AS {}", u.domain, a),
1582 None => u.domain.clone(),
1583 };
1584 emit!(
1585 out,
1586 SECTION,
1587 "UNUSED IMPORT {} — imported in {}:{} but never referenced (no effect on the result)",
1588 via,
1589 u.file,
1590 u.line
1591 )?;
1592 }
1593
1594 let underdetermined = usize::from(self.status == Status::Underdetermined);
1595 emit!(
1596 out,
1597 ROOT,
1598 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1599 self.conflicts.len(),
1600 underdetermined,
1601 self.warnings.len(),
1602 self.derived.len()
1603 )?;
1604 out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1605 }
1606}
1607
1608#[cfg(test)]
1609mod tests {
1610 use super::*;
1611
1612 fn vs(src: &str) -> Result<Report, CompileError> {
1615 verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
1616 }
1617
1618 #[test]
1619 fn clean_consistent() {
1620 let r = vs("FACT x a\nCHECK x\n").unwrap();
1621 assert_eq!(r.status, Status::Consistent);
1622 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1623 }
1624
1625 #[test]
1626 fn fact_contradiction_is_conflict() {
1627 let r = vs("FACT x a\nNOT x a\n").unwrap();
1628 assert_eq!(r.status, Status::Conflict);
1629 assert_eq!(r.conflicts.len(), 1);
1630 }
1631
1632 #[test]
1633 fn exclusive_violation_is_conflict() {
1634 let src = include_str!("../../../docs/examples/conflict.vrf");
1635 let r = verify_source("conflict.vrf", src).unwrap();
1636 assert_eq!(r.status, Status::Conflict);
1637 assert_eq!(
1638 r.conflicts[0].origin.premise.as_deref(),
1639 Some("fly_xor_swim")
1640 );
1641 assert_eq!(r.conflicts[0].atoms.len(), 2);
1642 }
1643
1644 #[test]
1645 fn exclusive_with_unknown_is_consistent_not_warning() {
1646 let r = vs(r"
1648 FACT A has flying
1649 PREMISE e:
1650 EXCLUSIVE
1651 A has flying
1652 A has swimming
1653 ")
1654 .unwrap();
1655 assert_eq!(r.status, Status::Consistent);
1656 assert!(r.warnings.is_empty());
1657 }
1658
1659 #[test]
1660 fn implication_missing_consequent_is_warning() {
1661 let r = vs(r#"
1663 FACT A has flying
1664 PREMISE w:
1665 WHEN A has flying
1666 THEN A has wing
1667 "#)
1668 .unwrap();
1669 assert_eq!(r.status, Status::Warning);
1670 assert_eq!(r.warnings.len(), 1);
1671 assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
1672 }
1673
1674 #[test]
1675 fn warning_hint_points_at_rule_when_atom_is_a_free_input() {
1676 let r = vs(r"
1679 FACT A has flying
1680 PREMISE w:
1681 WHEN A has flying
1682 THEN A has wing
1683 ")
1684 .unwrap();
1685 let hint = r.warnings[0].hint.as_deref().unwrap();
1686 assert!(hint.contains("make that PREMISE a RULE"), "{hint}");
1687 assert!(hint.contains("t.A has wing"), "{hint}");
1688 }
1689
1690 #[test]
1691 fn warning_hint_points_at_antecedent_when_a_rule_could_derive_it() {
1692 let r = vs(r"
1695 RULE d:
1696 WHEN x trigger
1697 THEN c ready
1698 FACT go now
1699 PREMISE p:
1700 WHEN go now
1701 THEN c ready
1702 ")
1703 .unwrap();
1704 assert_eq!(r.status, Status::Warning);
1705 let hint = r.warnings[0].hint.as_deref().unwrap();
1706 assert!(
1707 hint.contains("derived by a RULE that has not fired"),
1708 "{hint}"
1709 );
1710 }
1711
1712 #[test]
1713 fn human_report_dedupes_repeated_fix_lines() {
1714 let r = vs(r"
1720 FACT a on
1721 FACT b on
1722 PREMISE p1:
1723 WHEN a on
1724 THEN gate one_ok
1725 PREMISE p2:
1726 WHEN b on
1727 THEN gate one_ok
1728 PREMISE p3:
1729 WHEN a on
1730 THEN gate two_ok
1731 ")
1732 .unwrap();
1733 assert_eq!(r.warnings.len(), 3);
1734 assert!(r.warnings.iter().all(|w| w.hint.is_some()));
1736 let distinct_hints: BTreeSet<&str> = r
1737 .warnings
1738 .iter()
1739 .filter_map(|w| w.hint.as_deref())
1740 .collect();
1741 assert_eq!(distinct_hints.len(), 2);
1742 let text = alloc::format!("{r}");
1744 let shown = text
1745 .lines()
1746 .filter(|l| l.trim_start().starts_with("fix:"))
1747 .count();
1748 assert_eq!(shown, distinct_hints.len());
1749 }
1750
1751 #[test]
1752 fn implication_satisfied_is_consistent() {
1753 let r = vs(r"
1754 FACT A has flying
1755 FACT A has wing
1756 PREMISE w:
1757 WHEN A has flying
1758 THEN A has wing
1759 ")
1760 .unwrap();
1761 assert_eq!(r.status, Status::Consistent);
1762 }
1763
1764 #[test]
1765 fn implication_violated_is_conflict() {
1766 let r = vs(r"
1768 FACT A has flying
1769 NOT A has wing
1770 PREMISE w:
1771 WHEN A has flying
1772 THEN A has wing
1773 ")
1774 .unwrap();
1775 assert_eq!(r.status, Status::Conflict);
1776 }
1777
1778 #[test]
1779 fn rule_derives_fact() {
1780 let r = vs(r#"
1781 FACT A has flying
1782 RULE o:
1783 WHEN A has flying
1784 THEN A needs oxygen
1785 "#)
1786 .unwrap();
1787 assert_eq!(r.status, Status::Consistent);
1788 assert_eq!(r.derived.len(), 1);
1789 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1790 }
1791
1792 #[test]
1793 fn rule_derivation_contradiction_is_conflict() {
1794 let r = vs(r"
1796 FACT A has flying
1797 NOT A needs oxygen
1798 RULE o:
1799 WHEN A has flying
1800 THEN A needs oxygen
1801 ")
1802 .unwrap();
1803 assert_eq!(r.status, Status::Conflict);
1804 }
1805
1806 #[test]
1807 fn bidirectional_finds_alternative_model_underdetermined() {
1808 let r = vs(r#"
1810 PREMISE e:
1811 EXCLUSIVE
1812 x a
1813 x b
1814 CHECK x BIDIRECTIONAL
1815 "#)
1816 .unwrap();
1817 assert_eq!(r.status, Status::Underdetermined);
1818 }
1819
1820 #[test]
1821 fn fact_pins_unique_model_consistent() {
1822 let r = vs(r#"
1824 FACT x a
1825 PREMISE e:
1826 EXCLUSIVE
1827 x a
1828 x b
1829 CHECK x BIDIRECTIONAL
1830 "#)
1831 .unwrap();
1832 assert_eq!(r.status, Status::Consistent);
1833 }
1834
1835 #[test]
1836 fn no_bidirectional_skips_backward_pass() {
1837 let r = vs(r#"
1839 PREMISE e:
1840 EXCLUSIVE
1841 x a
1842 x b
1843 CHECK x
1844 "#)
1845 .unwrap();
1846 assert_eq!(r.status, Status::Consistent);
1847 }
1848
1849 #[test]
1850 fn creature_example_forward_pass() {
1851 let src = include_str!("../../../docs/examples/creature.vrf");
1852 let r = verify_source("creature.vrf", src).unwrap();
1853 assert_eq!(r.status, Status::Warning);
1856 assert!(r.conflicts.is_empty());
1857 assert_eq!(r.warnings.len(), 2);
1858 assert_eq!(r.derived.len(), 1);
1859 assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
1860 }
1861
1862 #[test]
1863 fn roles_puzzle_is_uniquely_solved() {
1864 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1868 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1869 assert_eq!(r.status, Status::Consistent);
1870 assert!(r.conflicts.is_empty());
1871 assert!(r.underdetermined.is_none());
1872 }
1873
1874 #[test]
1875 fn roles_puzzle_underdetermined_without_a_clue() {
1876 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1881 .replace("\r\n", "\n")
1882 .replace("NOT bob is qa\n", "");
1883 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1884 assert_eq!(r.status, Status::Underdetermined);
1885 }
1886
1887 #[test]
1888 fn socrates_chain_is_a_conflict() {
1889 let src = include_str!("../../../docs/examples/socrates.vrf");
1892 let r = verify_source("socrates.vrf", src).unwrap();
1893 assert_eq!(r.status, Status::Conflict);
1894 assert_eq!(r.conflicts.len(), 1);
1895 assert_eq!(
1896 r.conflicts[0].origin.premise.as_deref(),
1897 Some("mortal_xor_immortal")
1898 );
1899 assert_eq!(r.derived.len(), 3); }
1901
1902 #[test]
1905 fn forward_conflict_carries_a_trace_of_its_facts() {
1906 let r = vs(r#"
1907 FACT x a
1908 FACT x b
1909 PREMISE e:
1910 EXCLUSIVE
1911 x a
1912 x b
1913 CHECK x
1914 "#)
1915 .unwrap();
1916 assert_eq!(r.status, Status::Conflict);
1917 let t = &r.conflicts[0].trace;
1918 assert_eq!(t.len(), 2);
1919 assert_eq!(t[0].atom, "t.x a");
1920 assert_eq!(t[0].value, Value::True);
1921 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1922 assert!(r.unsat_core.is_empty());
1923 }
1924
1925 #[test]
1926 fn derivation_chain_is_traced_back_to_the_root_fact() {
1927 let src = include_str!("../../../docs/examples/socrates.vrf");
1929 let r = verify_source("socrates.vrf", src).unwrap();
1930 let t = &r.conflicts[0].trace;
1931 assert_eq!(t.len(), 5);
1934 assert_eq!(t[0].atom, "philosophy.socrates is human");
1935 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1936 let mortal = t
1937 .iter()
1938 .find(|s| s.atom == "philosophy.socrates is mortal")
1939 .unwrap();
1940 match &mortal.reason {
1941 TraceReason::Derived { from, .. } => {
1942 assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
1943 }
1944 _ => panic!("mortal should be derived, not asserted"),
1945 }
1946 }
1947
1948 #[test]
1949 fn direct_fact_contradiction_has_no_trace() {
1950 let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
1951 assert_eq!(r.status, Status::Conflict);
1952 assert!(r.conflicts[0].trace.is_empty());
1953 }
1954
1955 #[test]
1956 fn jointly_unsatisfiable_reports_a_minimal_core() {
1957 let src = r#"
1960 PREMISE one:
1961 ONEOF
1962 x a
1963 x b
1964 PREMISE ac:
1965 WHEN x a
1966 THEN x c
1967 PREMISE bc:
1968 WHEN x b
1969 THEN x c
1970 NOT x c
1971 CHECK x BIDIRECTIONAL
1972 "#;
1973 let r = vs(src).unwrap();
1974 assert_eq!(r.status, Status::Conflict);
1975 assert_eq!(r.conflicts[0].origin.kind, KIND_UNSAT);
1976 assert_eq!(r.unsat_core.len(), 4);
1977 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1978 assert!(labels.contains(&"one"));
1979 assert!(labels.contains(&"t.x c")); }
1981
1982 #[test]
1983 fn unsat_core_excludes_irrelevant_constructs() {
1984 let src = r#"
1987 PREMISE one:
1988 ONEOF
1989 x a
1990 x b
1991 PREMISE ac:
1992 WHEN x a
1993 THEN x c
1994 PREMISE bc:
1995 WHEN x b
1996 THEN x c
1997 NOT x c
1998 FACT z here
1999 PREMISE noise:
2000 EXCLUSIVE
2001 z here
2002 z gone
2003 CHECK x BIDIRECTIONAL
2004 "#;
2005 let r = vs(src).unwrap();
2006 assert_eq!(r.status, Status::Conflict);
2007 assert_eq!(r.unsat_core.len(), 4);
2008 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
2009 assert!(!labels.contains(&"noise"));
2010 assert!(!labels.iter().any(|l| l.contains("here")));
2011 }
2012
2013 #[test]
2014 fn consistent_report_has_empty_core_and_no_trace() {
2015 let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
2016 assert_eq!(r.status, Status::Consistent);
2017 assert!(r.unsat_core.is_empty());
2018 assert!(r.conflicts.is_empty());
2019 }
2020
2021 #[test]
2024 fn compatible_assumptions_behave_like_facts() {
2025 let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
2028 assert_eq!(r.status, Status::Consistent);
2029 assert!(r.retract.is_empty());
2030 assert!(r.conflicts.is_empty());
2031 }
2032
2033 #[test]
2034 fn assume_drives_a_rule_like_a_fact() {
2035 let r = vs(r"
2037 ASSUME A has flying
2038 RULE o:
2039 WHEN A has flying
2040 THEN A needs oxygen
2041 CHECK A
2042 ")
2043 .unwrap();
2044 assert_eq!(r.status, Status::Consistent);
2045 assert_eq!(r.derived.len(), 1);
2046 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
2047 }
2048
2049 #[test]
2050 fn clashing_assumptions_yield_conflict_with_a_retract_set() {
2051 let src = r#"
2054 FACT rel reviewed
2055 PREMISE prod_needs_safety:
2056 WHEN rel in_prod
2057 THEN rel has_rollback
2058 OR rel has_feature_flag
2059 ASSUME rel in_prod
2060 ASSUME NOT rel has_rollback
2061 ASSUME NOT rel has_feature_flag
2062 CHECK rel
2063 "#;
2064 let r = vs(src).unwrap();
2065 assert_eq!(r.status, Status::Conflict);
2066 assert_eq!(r.exit_code(), 2);
2067 assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
2069 let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
2070 assert!(labels.contains(&"t.rel in_prod"));
2071 assert!(labels.contains(&"NOT t.rel has_rollback"));
2072 assert!(labels.contains(&"NOT t.rel has_feature_flag"));
2073 assert!(r.retract.iter().all(|it| it.origin.kind == kw::ASSUME));
2075 let shown = alloc::format!("{r}");
2077 assert!(shown.contains("RETRACT"), "{shown}");
2078 assert!(!shown.contains("CONFLICT "), "{shown}");
2079 }
2080
2081 #[test]
2082 fn assume_vs_fact_retracts_only_the_assumption() {
2083 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2085 assert_eq!(r.status, Status::Conflict);
2086 assert_eq!(r.retract.len(), 1);
2087 assert_eq!(r.retract[0].label, "NOT t.x a");
2088 assert_eq!(r.retract[0].origin.kind, kw::ASSUME);
2089 }
2090
2091 #[test]
2092 fn hard_conflict_is_not_blamed_on_assumptions() {
2093 let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
2096 assert_eq!(r.status, Status::Conflict);
2097 assert!(r.retract.is_empty(), "{:?}", r.retract);
2098 }
2099
2100 #[test]
2101 fn two_assumptions_directly_contradict() {
2102 let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2103 assert_eq!(r.status, Status::Conflict);
2104 assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
2105 }
2106
2107 #[test]
2108 fn assume_retract_is_in_json() {
2109 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
2110 let j = r.to_json();
2111 assert!(j.contains("\"retract\":["), "{j}");
2112 assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
2113 assert!(j.contains("NOT t.x a"), "{j}");
2114 }
2115
2116 #[test]
2119 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
2120 let r = vs(r#"
2124 FACT auth is rolled_back
2125 NOT auth is_rolled_back
2126 CHECK
2127 "#)
2128 .unwrap();
2129 assert_eq!(
2130 r.status,
2131 Status::Consistent,
2132 "hint must not change the verdict"
2133 );
2134 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
2135 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2136 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
2137 }
2138
2139 #[test]
2140 fn hint_flags_case_only_difference() {
2141 let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
2142 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2143 }
2144
2145 #[test]
2146 fn hint_flags_single_char_typo_same_subject() {
2147 let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
2149 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2150 }
2151
2152 #[test]
2153 fn no_hint_for_short_distinct_atoms() {
2154 let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
2156 assert!(r.hints.is_empty(), "{:?}", r.hints);
2157 }
2158
2159 #[test]
2160 fn no_hint_for_distinct_words() {
2161 let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
2162 assert!(r.hints.is_empty(), "{:?}", r.hints);
2163 }
2164
2165 #[test]
2166 fn russian_case_typo_is_flagged() {
2167 let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
2169 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2170 }
2171
2172 #[test]
2173 fn russian_single_char_typo_is_flagged() {
2174 let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
2175 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2176 }
2177
2178 #[test]
2179 fn cjk_one_char_difference_is_not_flagged() {
2180 let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
2183 assert!(r.hints.is_empty(), "{:?}", r.hints);
2184 }
2185
2186 #[test]
2187 fn cjk_underscore_vs_space_is_flagged() {
2188 let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
2191 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2192 }
2193
2194 #[test]
2197 fn orphan_fact_is_flagged_but_advisory_only() {
2198 let r = vs("FACT x a\nCHECK\n").unwrap();
2201 assert_eq!(
2202 r.status,
2203 Status::Consistent,
2204 "orphan must not change verdict"
2205 );
2206 assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
2207 assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
2208 assert_eq!(r.orphans[0].atom, "t.x a");
2209 assert_eq!(r.orphans[0].origin.kind, kw::FACT);
2210 }
2211
2212 #[test]
2213 fn fact_used_by_a_premise_is_not_orphan() {
2214 let r = vs(r"
2216 FACT x a
2217 PREMISE p:
2218 EXCLUSIVE
2219 x a
2220 x b
2221 CHECK
2222 ")
2223 .unwrap();
2224 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2225 }
2226
2227 #[test]
2228 fn fact_used_by_a_rule_antecedent_is_not_orphan() {
2229 let r = vs(r"
2230 FACT x a
2231 RULE r:
2232 WHEN x a
2233 THEN x c
2234 CHECK
2235 ")
2236 .unwrap();
2237 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2238 }
2239
2240 #[test]
2241 fn negation_and_assumption_orphans_keep_their_surface_polarity() {
2242 let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
2245 assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
2246 let text = alloc::format!("{r}");
2247 assert!(text.contains("ORPHAN NOT t.x a"), "{text}");
2248 assert!(text.contains("ORPHAN ASSUME NOT t.y b"), "{text}");
2249 }
2250
2251 #[test]
2252 fn orphan_is_in_json() {
2253 let r = vs("FACT x a\nCHECK\n").unwrap();
2254 let j = r.to_json();
2255 assert!(j.contains("\"orphans\":["), "{j}");
2256 assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
2257 assert!(j.contains("\"kind\":\"FACT\""), "{j}");
2258 }
2259
2260 #[test]
2261 fn unused_import_is_advisory_only_in_the_report() {
2262 let mut r = MemoryResolver::new();
2265 r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
2266 r.add(
2267 "main.vrf",
2268 "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\nCHECK\n",
2269 );
2270 let rep = verify("main.vrf", &r).unwrap();
2271 assert_eq!(rep.status, Status::Consistent);
2272 assert_eq!(rep.exit_code(), 0);
2273 assert_eq!(rep.unused_imports.len(), 1);
2274 assert_eq!(rep.unused_imports[0].domain, "physics");
2275 let text = alloc::format!("{rep}");
2276 assert!(text.contains("UNUSED IMPORT physics"), "{text}");
2277 assert!(
2278 rep.to_json().contains("\"unused_imports\":[{"),
2279 "{}",
2280 rep.to_json()
2281 );
2282 }
2283
2284 #[test]
2285 fn a_derived_atom_does_not_make_its_consumer_orphan() {
2286 let r = vs(r"
2289 FACT x a
2290 RULE r:
2291 WHEN x a
2292 THEN x c
2293 PREMISE p:
2294 WHEN x c
2295 THEN x d
2296 CHECK
2297 ")
2298 .unwrap();
2299 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2300 }
2301}