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::string::String;
49use alloc::vec;
50use alloc::vec::Vec;
51use core::fmt;
52
53pub use elenchus_compiler::Diagnostics;
56use elenchus_compiler::{AtomId, AtomKey, Clause, Compiled, Lit, Origin, Value};
57pub use elenchus_compiler::{
58 CompileError, MemoryResolver, Resolver, UnusedImport, compile, compile_source,
59};
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum V3 {
64 True,
66 False,
68 Unknown,
70}
71
72impl V3 {
73 fn not(self) -> V3 {
75 match self {
76 V3::True => V3::False,
77 V3::False => V3::True,
78 V3::Unknown => V3::Unknown,
79 }
80 }
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub enum Status {
86 Consistent,
88 Underdetermined,
91 Warning,
93 Conflict,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct Conflict {
100 pub origin: Origin,
102 pub atoms: Vec<String>,
104 pub trace: Vec<TraceStep>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct TraceStep {
116 pub atom: String,
118 pub value: Value,
120 pub reason: TraceReason,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum TraceReason {
127 Asserted(Origin),
129 Derived {
131 origin: Origin,
133 from: Vec<String>,
135 },
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct Warning {
141 pub origin: Origin,
143 pub blocked_by: Vec<String>,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct Derived {
150 pub atom: String,
152 pub value: Value,
154 pub origin: Origin,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Report {
161 pub status: Status,
163 pub conflicts: Vec<Conflict>,
165 pub warnings: Vec<Warning>,
167 pub derived: Vec<Derived>,
169 pub underdetermined: Option<String>,
172 pub unsat_core: Vec<CoreItem>,
177 pub retract: Vec<CoreItem>,
185 pub hints: Vec<SimilarAtoms>,
188 pub orphans: Vec<OrphanFact>,
193 pub unused_imports: Vec<UnusedImport>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct SimilarAtoms {
207 pub a: String,
209 pub b: String,
211 pub reason: &'static str,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
222pub struct OrphanFact {
223 pub atom: String,
225 pub value: Value,
227 pub origin: Origin,
229}
230
231#[derive(Debug, Clone, PartialEq, Eq)]
233pub struct CoreItem {
234 pub origin: Origin,
236 pub label: String,
238}
239
240impl Report {
241 pub fn exit_code(&self) -> i32 {
243 match self.status {
244 Status::Conflict => 2,
245 Status::Underdetermined | Status::Warning => 1,
246 Status::Consistent => 0,
247 }
248 }
249
250 pub fn to_json(&self) -> String {
253 use core::fmt::Write as _;
254 let mut s = String::new();
255 let _ = write!(s, "{{\"status\":");
256 json_str(status_name(self.status), &mut s);
257 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
258
259 s.push_str(",\"conflicts\":[");
260 for (i, c) in self.conflicts.iter().enumerate() {
261 if i > 0 {
262 s.push(',');
263 }
264 json_origin(&c.origin, &mut s);
265 s.push_str(",\"atoms\":");
266 json_array(&c.atoms, &mut s);
267 s.push_str(",\"trace\":[");
268 for (j, step) in c.trace.iter().enumerate() {
269 if j > 0 {
270 s.push(',');
271 }
272 json_trace_step(step, &mut s);
273 }
274 s.push_str("]}");
275 }
276 s.push_str("],\"warnings\":[");
277 for (i, w) in self.warnings.iter().enumerate() {
278 if i > 0 {
279 s.push(',');
280 }
281 json_origin(&w.origin, &mut s);
282 s.push_str(",\"blocked_by\":");
283 json_array(&w.blocked_by, &mut s);
284 s.push('}');
285 }
286 s.push_str("],\"derived\":[");
287 for (i, d) in self.derived.iter().enumerate() {
288 if i > 0 {
289 s.push(',');
290 }
291 s.push('{');
292 json_origin_fields(&d.origin, &mut s);
293 s.push_str(",\"atom\":");
294 json_str(&d.atom, &mut s);
295 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
296 s.push('}');
297 }
298 s.push_str("],\"underdetermined\":");
299 match &self.underdetermined {
300 Some(atom) => json_str(atom, &mut s),
301 None => s.push_str("null"),
302 }
303 s.push_str(",\"unsat_core\":[");
304 for (i, it) in self.unsat_core.iter().enumerate() {
305 if i > 0 {
306 s.push(',');
307 }
308 json_origin(&it.origin, &mut s);
309 s.push_str(",\"label\":");
310 json_str(&it.label, &mut s);
311 s.push('}');
312 }
313 s.push_str("],\"retract\":[");
314 for (i, it) in self.retract.iter().enumerate() {
315 if i > 0 {
316 s.push(',');
317 }
318 json_origin(&it.origin, &mut s);
319 s.push_str(",\"label\":");
320 json_str(&it.label, &mut s);
321 s.push('}');
322 }
323 s.push_str("],\"hints\":[");
324 for (i, h) in self.hints.iter().enumerate() {
325 if i > 0 {
326 s.push(',');
327 }
328 s.push_str("{\"a\":");
329 json_str(&h.a, &mut s);
330 s.push_str(",\"b\":");
331 json_str(&h.b, &mut s);
332 s.push_str(",\"reason\":");
333 json_str(h.reason, &mut s);
334 s.push('}');
335 }
336 s.push_str("],\"orphans\":[");
337 for (i, o) in self.orphans.iter().enumerate() {
338 if i > 0 {
339 s.push(',');
340 }
341 json_origin(&o.origin, &mut s);
342 s.push_str(",\"atom\":");
343 json_str(&o.atom, &mut s);
344 let _ = write!(s, ",\"value\":{}", matches!(o.value, Value::True));
345 s.push('}');
346 }
347 s.push_str("],\"unused_imports\":[");
348 for (i, u) in self.unused_imports.iter().enumerate() {
349 if i > 0 {
350 s.push(',');
351 }
352 s.push_str("{\"file\":");
353 json_str(&u.file, &mut s);
354 s.push_str(",\"domain\":");
355 json_str(&u.domain, &mut s);
356 s.push_str(",\"alias\":");
357 match &u.alias {
358 Some(a) => json_str(a, &mut s),
359 None => s.push_str("null"),
360 }
361 let _ = write!(s, ",\"line\":{}", u.line);
362 s.push('}');
363 }
364 s.push_str("]}");
365 s
366 }
367}
368
369fn json_trace_step(step: &TraceStep, out: &mut String) {
371 use core::fmt::Write as _;
372 out.push_str("{\"atom\":");
373 json_str(&step.atom, out);
374 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
375 match &step.reason {
376 TraceReason::Asserted(o) => {
377 out.push_str(",\"how\":\"asserted\",");
378 json_origin_fields(o, out);
379 out.push_str(",\"from\":[]");
380 }
381 TraceReason::Derived { origin, from } => {
382 out.push_str(",\"how\":\"derived\",");
383 json_origin_fields(origin, out);
384 out.push_str(",\"from\":");
385 json_array(from, out);
386 }
387 }
388 out.push('}');
389}
390
391fn status_name(s: Status) -> &'static str {
392 match s {
393 Status::Consistent => "CONSISTENT",
394 Status::Underdetermined => "UNDERDETERMINED",
395 Status::Warning => "WARNING",
396 Status::Conflict => "CONFLICT",
397 }
398}
399
400fn json_str(value: &str, out: &mut String) {
402 use core::fmt::Write as _;
403 out.push('"');
404 for ch in value.chars() {
405 match ch {
406 '"' => out.push_str("\\\""),
407 '\\' => out.push_str("\\\\"),
408 '\n' => out.push_str("\\n"),
409 '\r' => out.push_str("\\r"),
410 '\t' => out.push_str("\\t"),
411 c if (c as u32) < 0x20 => {
412 let _ = write!(out, "\\u{:04x}", c as u32);
413 }
414 c => out.push(c),
415 }
416 }
417 out.push('"');
418}
419
420fn json_array(items: &[String], out: &mut String) {
422 out.push('[');
423 for (i, item) in items.iter().enumerate() {
424 if i > 0 {
425 out.push(',');
426 }
427 json_str(item, out);
428 }
429 out.push(']');
430}
431
432fn json_origin_fields(o: &Origin, out: &mut String) {
434 use core::fmt::Write as _;
435 out.push_str("\"premise\":");
436 match &o.premise {
437 Some(name) => json_str(name, out),
438 None => out.push_str("null"),
439 }
440 out.push_str(",\"kind\":");
441 json_str(o.kind, out);
442 out.push_str(",\"source\":");
443 json_str(&o.source, out);
444 let _ = write!(out, ",\"line\":{}", o.line);
445}
446
447fn json_origin(o: &Origin, out: &mut String) {
449 out.push('{');
450 json_origin_fields(o, out);
451}
452
453fn label(c: &Compiled, a: AtomId) -> String {
457 let k = &c.atoms[a as usize];
458 match &k.object {
459 Some(o) => alloc::format!("{}.{} {} {}", k.domain, k.subject, k.predicate, o),
460 None => alloc::format!("{}.{} {}", k.domain, k.subject, k.predicate),
461 }
462}
463
464fn lit_value(model: &[V3], l: &Lit) -> V3 {
466 let v = model[l.atom as usize];
467 if l.negated { v.not() } else { v }
468}
469
470fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
472 let mut result = V3::True;
473 for l in lits {
474 match lit_value(model, l) {
475 V3::False => return V3::False,
476 V3::Unknown => result = V3::Unknown,
477 V3::True => {}
478 }
479 }
480 result
481}
482
483enum ClauseEval {
485 Violated,
487 Satisfied,
489 Blocked(Vec<AtomId>),
491}
492
493fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
496 let mut any_false = false;
497 let mut all_true = true;
498 let mut blocked = Vec::new();
499 for l in &clause.lits {
500 match lit_value(model, l) {
501 V3::False => {
502 any_false = true;
503 all_true = false;
504 }
505 V3::Unknown => {
506 all_true = false;
507 blocked.push(l.atom);
508 }
509 V3::True => {}
510 }
511 }
512 if all_true {
513 ClauseEval::Violated
514 } else if any_false {
515 ClauseEval::Satisfied
516 } else {
517 ClauseEval::Blocked(blocked)
518 }
519}
520
521#[derive(Clone)]
524enum AtomReason {
525 Asserted(Origin),
527 Derived { origin: Origin, from: Vec<AtomId> },
529}
530
531struct RawConflict {
535 origin: Origin,
536 atoms: Vec<String>,
537 cause: Vec<AtomId>,
538}
539
540struct Eval<'a> {
542 c: &'a Compiled,
543 model: Vec<V3>,
544 reason: Vec<Option<AtomReason>>,
547 conflicts: Vec<RawConflict>,
548 warnings: Vec<Warning>,
549 derived: Vec<Derived>,
550 unsat_core: Vec<CoreItem>,
552}
553
554impl<'a> Eval<'a> {
555 fn new(c: &'a Compiled) -> Self {
556 Eval {
557 c,
558 model: vec![V3::Unknown; c.atoms.len()],
559 reason: vec![None; c.atoms.len()],
560 conflicts: Vec::new(),
561 warnings: Vec::new(),
562 derived: Vec::new(),
563 unsat_core: Vec::new(),
564 }
565 }
566
567 fn label(&self, a: AtomId) -> String {
568 label(self.c, a)
569 }
570
571 fn seed_facts(&mut self) {
573 let c = self.c;
574 let n = c.atoms.len();
575 let mut t: Vec<Option<Origin>> = vec![None; n];
576 let mut f: Vec<Option<Origin>> = vec![None; n];
577 for fact in &c.facts {
578 let slot = match fact.value {
579 Value::True => &mut t,
580 Value::False => &mut f,
581 };
582 if slot[fact.atom as usize].is_none() {
583 slot[fact.atom as usize] = Some(fact.origin.clone());
584 }
585 }
586 for a in 0..n {
587 match (&t[a], &f[a]) {
588 (Some(o), Some(_)) => {
589 self.model[a] = V3::True;
590 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
591 self.conflicts.push(RawConflict {
592 origin: o.clone(),
593 atoms: vec![alloc::format!(
594 "{} (asserted both TRUE and FALSE)",
595 self.label(a as AtomId)
596 )],
597 cause: Vec::new(),
598 });
599 }
600 (Some(o), None) => {
601 self.model[a] = V3::True;
602 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
603 }
604 (None, Some(o)) => {
605 self.model[a] = V3::False;
606 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
607 }
608 (None, None) => {}
609 }
610 }
611 }
612
613 fn saturate_rules(&mut self) {
615 let c = self.c;
616 loop {
617 let mut changed = false;
618 for r in &c.rules {
619 if conjunction(&self.model, &r.antecedent) != V3::True {
620 continue; }
622 for cl in &r.consequent {
623 let target = if cl.negated { V3::False } else { V3::True };
624 match self.model[cl.atom as usize] {
625 V3::Unknown => {
626 self.model[cl.atom as usize] = target;
627 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
628 origin: r.origin.clone(),
629 from: r.antecedent.iter().map(|l| l.atom).collect(),
630 });
631 changed = true;
632 self.derived.push(Derived {
633 atom: self.label(cl.atom),
634 value: if cl.negated {
635 Value::False
636 } else {
637 Value::True
638 },
639 origin: r.origin.clone(),
640 });
641 }
642 v if v == target => {}
643 _ => {
644 let mut cause: Vec<AtomId> =
648 r.antecedent.iter().map(|l| l.atom).collect();
649 cause.push(cl.atom);
650 self.conflicts.push(RawConflict {
651 origin: r.origin.clone(),
652 atoms: vec![alloc::format!(
653 "{} (derived value contradicts a known fact)",
654 self.label(cl.atom)
655 )],
656 cause,
657 });
658 }
659 }
660 }
661 }
662 if !changed {
663 break;
664 }
665 }
666 }
667
668 fn check_premises(&mut self) {
670 let c = self.c;
671 for clause in &c.clauses {
672 match eval_clause(&self.model, clause) {
673 ClauseEval::Violated => self.conflicts.push(RawConflict {
674 origin: clause.origin.clone(),
675 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
676 cause: clause.lits.iter().map(|l| l.atom).collect(),
677 }),
678 ClauseEval::Satisfied => {}
679 ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
682 self.warnings.push(Warning {
683 origin: clause.origin.clone(),
684 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
685 });
686 }
687 ClauseEval::Blocked(_) => {}
688 }
689 }
690 }
691
692 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
698 let mut visited = vec![false; self.c.atoms.len()];
699 let mut out = Vec::new();
700 for &a in causes {
701 self.trace_dfs(a, &mut visited, &mut out);
702 }
703 out
704 }
705
706 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
707 if visited[a as usize] {
708 return;
709 }
710 visited[a as usize] = true;
711 let value = match v3_to_value(self.model[a as usize]) {
712 Some(v) => v,
713 None => return, };
715 let reason = match &self.reason[a as usize] {
716 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
717 Some(AtomReason::Derived { origin, from }) => {
718 for &f in from {
719 self.trace_dfs(f, visited, out); }
721 TraceReason::Derived {
722 origin: origin.clone(),
723 from: from.iter().map(|&f| self.label(f)).collect(),
724 }
725 }
726 None => return,
727 };
728 out.push(TraceStep {
729 atom: self.label(a),
730 value,
731 reason,
732 });
733 }
734
735 fn backward_pass(&mut self) -> Option<String> {
742 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
743 return None;
744 }
745 let (cnf, project) = build_cnf(self.c);
746 let found = sat::models(&cnf, &project, 2);
747 match found.len() {
748 0 if self.conflicts.is_empty() => {
749 self.unsat_core = minimal_unsat_core(self.c);
750 self.conflicts.push(RawConflict {
751 origin: Origin {
752 source: String::from("<system>"),
753 line: 0,
754 premise: None,
755 kind: "UNSAT",
756 },
757 atoms: vec![String::from(
758 "the premises and facts are jointly unsatisfiable",
759 )],
760 cause: Vec::new(),
761 });
762 None
763 }
764 n if n >= 2 => {
765 let (m0, m1) = (&found[0], &found[1]);
766 project
767 .iter()
768 .find(|&&v| m0[v as usize] != m1[v as usize])
769 .map(|&v| self.label(v))
770 .or_else(|| Some(String::from("a free atom")))
771 }
772 _ => None,
773 }
774 }
775
776 fn finish(mut self) -> Report {
778 let underdetermined = self.backward_pass();
779 self.conflicts.sort_by_key(|c| key(&c.origin));
780 self.warnings.sort_by_key(|w| key(&w.origin));
781 let status = if !self.conflicts.is_empty() {
782 Status::Conflict
783 } else if underdetermined.is_some() {
784 Status::Underdetermined
785 } else if !self.warnings.is_empty() {
786 Status::Warning
787 } else {
788 Status::Consistent
789 };
790 let conflicts: Vec<Conflict> = self
793 .conflicts
794 .iter()
795 .map(|rc| Conflict {
796 origin: rc.origin.clone(),
797 atoms: rc.atoms.clone(),
798 trace: self.build_trace(&rc.cause),
799 })
800 .collect();
801 Report {
802 status,
803 conflicts,
804 warnings: self.warnings,
805 derived: self.derived,
806 underdetermined,
807 unsat_core: self.unsat_core,
808 retract: Vec::new(), hints: Vec::new(), orphans: Vec::new(), unused_imports: Vec::new(), }
813 }
814}
815
816pub fn solve(c: &Compiled) -> Report {
819 let mut e = Eval::new(c);
820 e.seed_facts();
821 e.saturate_rules();
822 e.check_premises();
823 let mut report = e.finish();
824 if report.status == Status::Conflict {
830 let retract = retract_assumptions(c);
831 if !retract.is_empty() {
832 report.unsat_core = Vec::new();
833 report.retract = retract;
834 }
835 }
836 report.hints = similar_atom_pairs(c);
839 report.orphans = orphan_facts(c);
842 report.unused_imports = c.unused_imports.clone();
845 report
846}
847
848fn orphan_facts(c: &Compiled) -> Vec<OrphanFact> {
858 let mut referenced = vec![false; c.atoms.len()];
859 for clause in &c.clauses {
860 for l in &clause.lits {
861 referenced[l.atom as usize] = true;
862 }
863 }
864 for r in &c.rules {
865 for l in r.antecedent.iter().chain(r.consequent.iter()) {
866 referenced[l.atom as usize] = true;
867 }
868 }
869 let mut out: Vec<OrphanFact> = c
870 .facts
871 .iter()
872 .filter(|f| !referenced[f.atom as usize])
873 .map(|f| OrphanFact {
874 atom: label(c, f.atom),
875 value: f.value,
876 origin: f.origin.clone(),
877 })
878 .collect();
879 out.sort_by_key(|o| key(&o.origin));
880 out
881}
882
883fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
899 if !c.facts.iter().any(|f| f.soft) {
900 return Vec::new();
901 }
902 let all = constructs(c);
903 let is_soft: Vec<bool> = (0..all.len())
905 .map(|i| i < c.facts.len() && c.facts[i].soft)
906 .collect();
907
908 let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
911 if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
912 return Vec::new();
913 }
914 let mut active = vec![true; all.len()];
916 if subset_is_sat(c.atoms.len(), &all, &active) {
917 return Vec::new();
918 }
919 for i in 0..all.len() {
921 if active[i] && is_soft[i] {
922 active[i] = false;
923 if subset_is_sat(c.atoms.len(), &all, &active) {
924 active[i] = true; }
926 }
927 }
928 let mut core: Vec<CoreItem> = (0..all.len())
929 .filter(|&i| active[i] && is_soft[i])
930 .map(|i| {
931 let f = &c.facts[i];
932 let label = if matches!(f.value, Value::False) {
934 alloc::format!("NOT {}", label(c, f.atom))
935 } else {
936 label(c, f.atom)
937 };
938 CoreItem {
939 origin: f.origin.clone(),
940 label,
941 }
942 })
943 .collect();
944 core.sort_by_key(|it| key(&it.origin));
945 core
946}
947
948fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
968 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
969 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
970 let mut out = Vec::new();
971 for i in 0..c.atoms.len() {
972 for j in (i + 1)..c.atoms.len() {
973 if let Some(reason) = atoms_look_similar(
974 &c.atoms[i],
975 &folded[i],
976 cased[i],
977 &c.atoms[j],
978 &folded[j],
979 cased[j],
980 ) {
981 out.push(SimilarAtoms {
982 a: label(c, i as AtomId),
983 b: label(c, j as AtomId),
984 reason,
985 });
986 }
987 }
988 }
989 out
990}
991
992fn fold_atom(k: &AtomKey) -> Vec<char> {
996 let mut raw = String::new();
997 raw.push_str(&k.subject);
998 raw.push(' ');
999 raw.push_str(&k.predicate);
1000 if let Some(o) = &k.object {
1001 raw.push(' ');
1002 raw.push_str(o);
1003 }
1004 let mut out: Vec<char> = Vec::new();
1005 let mut prev_space = false;
1006 for ch in raw.chars() {
1007 if ch == '_' || ch.is_whitespace() {
1008 if !prev_space && !out.is_empty() {
1009 out.push(' ');
1010 prev_space = true;
1011 }
1012 } else {
1013 for lc in ch.to_lowercase() {
1014 out.push(lc);
1015 }
1016 prev_space = false;
1017 }
1018 }
1019 if out.last() == Some(&' ') {
1020 out.pop();
1021 }
1022 out
1023}
1024
1025fn is_cased_alphabetic(folded: &[char]) -> bool {
1032 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
1033}
1034
1035fn atoms_look_similar(
1038 ka: &AtomKey,
1039 fa: &[char],
1040 cased_a: bool,
1041 kb: &AtomKey,
1042 fb: &[char],
1043 cased_b: bool,
1044) -> Option<&'static str> {
1045 if fa == fb && ka.domain == kb.domain {
1049 return Some("same name up to case, '_', or spaces");
1050 }
1051 if !cased_a || !cased_b || ka.domain != kb.domain || ka.subject != kb.subject {
1056 return None;
1057 }
1058 if fa.len().abs_diff(fb.len()) > 1 {
1059 return None; }
1061 let min_len = fa.len().min(fb.len());
1062 if min_len >= 5 && levenshtein(fa, fb) == 1 {
1063 return Some("looks like a one-character typo of each other");
1064 }
1065 None
1066}
1067
1068fn levenshtein(a: &[char], b: &[char]) -> usize {
1070 let mut prev: Vec<usize> = (0..=b.len()).collect();
1071 let mut cur = vec![0usize; b.len() + 1];
1072 for (i, &ca) in a.iter().enumerate() {
1073 cur[0] = i + 1;
1074 for (j, &cb) in b.iter().enumerate() {
1075 let cost = usize::from(ca != cb);
1076 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1077 }
1078 core::mem::swap(&mut prev, &mut cur);
1079 }
1080 prev[b.len()]
1081}
1082
1083fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
1088 use sat::SatLit;
1089 let mut cnf = sat::Cnf::new(c.atoms.len());
1090 let mut constrained = vec![false; c.atoms.len()];
1091 let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
1092
1093 for clause in &c.clauses {
1095 let lits = clause
1096 .lits
1097 .iter()
1098 .map(|l| {
1099 mark(l.atom, &mut constrained);
1100 SatLit::new(l.atom, l.negated)
1101 })
1102 .collect();
1103 cnf.add_clause(lits);
1104 }
1105 for r in &c.rules {
1107 for cons in &r.consequent {
1108 let mut lits: Vec<SatLit> = r
1109 .antecedent
1110 .iter()
1111 .map(|a| {
1112 mark(a.atom, &mut constrained);
1113 SatLit::new(a.atom, a.negated)
1114 })
1115 .collect();
1116 mark(cons.atom, &mut constrained);
1117 lits.push(SatLit::new(cons.atom, !cons.negated));
1118 cnf.add_clause(lits);
1119 }
1120 }
1121 for f in &c.facts {
1123 let lit = match f.value {
1124 Value::True => SatLit::positive(f.atom),
1125 Value::False => SatLit::negative(f.atom),
1126 };
1127 cnf.add_clause(vec![lit]);
1128 }
1129
1130 let project = (0..c.atoms.len() as AtomId)
1131 .filter(|&a| constrained[a as usize])
1132 .collect();
1133 (cnf, project)
1134}
1135
1136fn v3_to_value(v: V3) -> Option<Value> {
1138 match v {
1139 V3::True => Some(Value::True),
1140 V3::False => Some(Value::False),
1141 V3::Unknown => None,
1142 }
1143}
1144
1145struct Construct {
1148 origin: Origin,
1149 label: String,
1150 clauses: Vec<Vec<sat::SatLit>>,
1151}
1152
1153fn same_origin(a: &Origin, b: &Origin) -> bool {
1155 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1156}
1157
1158fn constructs(c: &Compiled) -> Vec<Construct> {
1162 use sat::SatLit;
1163 let mut out: Vec<Construct> = Vec::new();
1164
1165 for f in &c.facts {
1166 let lit = match f.value {
1167 Value::True => SatLit::positive(f.atom),
1168 Value::False => SatLit::negative(f.atom),
1169 };
1170 out.push(Construct {
1171 origin: f.origin.clone(),
1172 label: label(c, f.atom),
1173 clauses: vec![vec![lit]],
1174 });
1175 }
1176
1177 let mut premises: Vec<Construct> = Vec::new();
1178 for clause in &c.clauses {
1179 let lits: Vec<SatLit> = clause
1180 .lits
1181 .iter()
1182 .map(|l| SatLit::new(l.atom, l.negated))
1183 .collect();
1184 match premises
1185 .iter_mut()
1186 .find(|k| same_origin(&k.origin, &clause.origin))
1187 {
1188 Some(k) => k.clauses.push(lits),
1189 None => premises.push(Construct {
1190 label: clause.origin.premise.clone().unwrap_or_default(),
1191 origin: clause.origin.clone(),
1192 clauses: vec![lits],
1193 }),
1194 }
1195 }
1196 out.extend(premises);
1197
1198 for r in &c.rules {
1199 let clauses = r
1200 .consequent
1201 .iter()
1202 .map(|cons| {
1203 let mut lits: Vec<SatLit> = r
1204 .antecedent
1205 .iter()
1206 .map(|a| SatLit::new(a.atom, a.negated))
1207 .collect();
1208 lits.push(SatLit::new(cons.atom, !cons.negated));
1209 lits
1210 })
1211 .collect();
1212 out.push(Construct {
1213 label: r.origin.premise.clone().unwrap_or_default(),
1214 origin: r.origin.clone(),
1215 clauses,
1216 });
1217 }
1218 out
1219}
1220
1221fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1223 let mut cnf = sat::Cnf::new(num_vars);
1224 for (k, &keep) in all.iter().zip(active) {
1225 if keep {
1226 for cl in &k.clauses {
1227 cnf.add_clause(cl.clone());
1228 }
1229 }
1230 }
1231 sat::solve(&cnf).is_some()
1232}
1233
1234fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1240 let base = c.atoms.len();
1241 let mut cnf = sat::Cnf::new(base + all.len());
1242 let sel = |i: usize| (base + i) as sat::Var;
1243 for (i, k) in all.iter().enumerate() {
1244 let s_neg = sat::SatLit::negative(sel(i));
1245 for cl in &k.clauses {
1246 let mut lits = Vec::with_capacity(cl.len() + 1);
1247 lits.push(s_neg);
1248 lits.extend_from_slice(cl);
1249 cnf.add_clause(lits);
1250 }
1251 }
1252 let assumptions: Vec<sat::SatLit> = (0..all.len())
1253 .map(|i| sat::SatLit::positive(sel(i)))
1254 .collect();
1255 let mut active = vec![false; all.len()];
1256 match sat::solve_assuming(&cnf, &assumptions) {
1257 sat::Solved::Unsat(core) => {
1258 for lit in core {
1259 let v = lit.var() as usize;
1260 if v >= base {
1261 active[v - base] = true;
1262 }
1263 }
1264 }
1265 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1269 }
1270 active
1271}
1272
1273fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1279 let all = constructs(c);
1280 let mut active = candidate_via_assumptions(c, &all);
1281 for i in 0..all.len() {
1282 if active[i] {
1283 active[i] = false;
1284 if subset_is_sat(c.atoms.len(), &all, &active) {
1285 active[i] = true; }
1287 }
1288 }
1289 let mut core: Vec<CoreItem> = all
1290 .iter()
1291 .zip(&active)
1292 .filter(|&(_, &keep)| keep)
1293 .map(|(k, _)| CoreItem {
1294 origin: k.origin.clone(),
1295 label: k.label.clone(),
1296 })
1297 .collect();
1298 core.sort_by_key(|it| key(&it.origin));
1299 core
1300}
1301
1302fn key(o: &Origin) -> (String, u32) {
1304 (o.source.clone(), o.line)
1305}
1306
1307pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1309 Ok(solve(&compile_source(name, src)?))
1310}
1311
1312pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1314 Ok(solve(&compile(root, resolver)?))
1315}
1316
1317impl fmt::Display for Status {
1320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1321 f.write_str(match self {
1322 Status::Consistent => "CONSISTENT",
1323 Status::Underdetermined => "UNDERDETERMINED",
1324 Status::Warning => "WARNING",
1325 Status::Conflict => "CONFLICT",
1326 })
1327 }
1328}
1329
1330fn premise_tag(o: &Origin) -> String {
1332 let name = o.premise.as_deref().unwrap_or("-");
1333 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1334}
1335
1336fn trace_line(step: &TraceStep) -> String {
1338 let v = match step.value {
1339 Value::True => "TRUE",
1340 Value::False => "FALSE",
1341 };
1342 match &step.reason {
1343 TraceReason::Asserted(o) => {
1344 alloc::format!(
1345 "{} = {} [{} {}:{}]",
1346 step.atom,
1347 v,
1348 o.kind,
1349 o.source,
1350 o.line
1351 )
1352 }
1353 TraceReason::Derived { origin, from } => alloc::format!(
1354 "{} = {} from {} ({}) [{}:{}] <= {}",
1355 step.atom,
1356 v,
1357 origin.premise.as_deref().unwrap_or("-"),
1358 origin.kind,
1359 origin.source,
1360 origin.line,
1361 from.join(", ")
1362 ),
1363 }
1364}
1365
1366mod indent {
1372 pub const ROOT: usize = 0;
1374 pub const SECTION: usize = 2;
1377 pub const ITEM: usize = 6;
1379 pub const NESTED: usize = 8;
1381}
1382
1383struct ReportWriter<'a, 'b> {
1386 f: &'a mut fmt::Formatter<'b>,
1387}
1388
1389impl ReportWriter<'_, '_> {
1390 fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1392 write!(self.f, "{:width$}{}", "", args, width = indent)?;
1393 self.f.write_str("\n")
1394 }
1395
1396 fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1399 write!(self.f, "{:width$}{}", "", args, width = indent)
1400 }
1401}
1402
1403macro_rules! emit {
1407 ($out:expr, $indent:expr, $($arg:tt)*) => {
1408 $out.line($indent, format_args!($($arg)*))
1409 };
1410}
1411
1412impl fmt::Display for Report {
1413 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1414 use indent::{ITEM, NESTED, ROOT, SECTION};
1415 let mut out = ReportWriter { f };
1416
1417 emit!(out, ROOT, "RESULT: {}", self.status)?;
1418
1419 if !self.retract.is_empty() {
1423 emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
1427 emit!(
1428 out,
1429 ITEM,
1430 "But these ASSUME guesses cannot all be true together."
1431 )?;
1432 emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1433 for it in &self.retract {
1434 emit!(
1435 out,
1436 ITEM,
1437 "ASSUME {} [{}:{}]",
1438 it.label,
1439 it.origin.source,
1440 it.origin.line
1441 )?;
1442 }
1443 } else {
1444 for c in &self.conflicts {
1445 emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
1446 for a in &c.atoms {
1447 emit!(out, ITEM, "{}", a)?;
1448 }
1449 if !c.trace.is_empty() {
1450 emit!(out, ITEM, "why:")?;
1451 for step in &c.trace {
1452 emit!(out, NESTED, "{}", trace_line(step))?;
1453 }
1454 }
1455 }
1456 if !self.unsat_core.is_empty() {
1457 emit!(
1458 out,
1459 SECTION,
1460 "CORE smallest jointly-unsatisfiable set ({}):",
1461 self.unsat_core.len()
1462 )?;
1463 for it in &self.unsat_core {
1464 let name = if it.label.is_empty() { "-" } else { &it.label };
1465 emit!(
1466 out,
1467 NESTED,
1468 "{} ({}) [{}:{}]",
1469 name,
1470 it.origin.kind,
1471 it.origin.source,
1472 it.origin.line
1473 )?;
1474 }
1475 }
1476 }
1477
1478 for w in &self.warnings {
1479 emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
1480 emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1481 }
1482 if let Some(atom) = &self.underdetermined {
1483 emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
1484 emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
1485 }
1486 for d in &self.derived {
1487 let v = match d.value {
1488 Value::True => "TRUE",
1489 Value::False => "FALSE",
1490 };
1491 emit!(
1492 out,
1493 SECTION,
1494 "DERIVED {} = {} from {}",
1495 d.atom,
1496 v,
1497 premise_tag(&d.origin)
1498 )?;
1499 }
1500 for h in &self.hints {
1501 emit!(
1502 out,
1503 SECTION,
1504 "HINT possible typo — '{}' and '{}' look like the same atom ({})",
1505 h.a,
1506 h.b,
1507 h.reason
1508 )?;
1509 }
1510 for o in &self.orphans {
1511 let surface = if o.origin.kind == "ASSUME" && matches!(o.value, Value::False) {
1514 alloc::format!("ASSUME NOT {}", o.atom)
1515 } else {
1516 alloc::format!("{} {}", o.origin.kind, o.atom)
1517 };
1518 emit!(
1519 out,
1520 SECTION,
1521 "ORPHAN {} — not used by any premise or rule (no effect on the result)",
1522 surface
1523 )?;
1524 }
1525 for u in &self.unused_imports {
1526 let via = match &u.alias {
1527 Some(a) => alloc::format!("{} AS {}", u.domain, a),
1528 None => u.domain.clone(),
1529 };
1530 emit!(
1531 out,
1532 SECTION,
1533 "UNUSED IMPORT {} — imported in {}:{} but never referenced (no effect on the result)",
1534 via,
1535 u.file,
1536 u.line
1537 )?;
1538 }
1539
1540 let underdetermined = usize::from(self.status == Status::Underdetermined);
1541 emit!(
1542 out,
1543 ROOT,
1544 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1545 self.conflicts.len(),
1546 underdetermined,
1547 self.warnings.len(),
1548 self.derived.len()
1549 )?;
1550 out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1551 }
1552}
1553
1554#[cfg(test)]
1555mod tests {
1556 use super::*;
1557
1558 fn vs(src: &str) -> Result<Report, CompileError> {
1561 verify_source("t.vrf", &alloc::format!("DOMAIN t\n{src}"))
1562 }
1563
1564 #[test]
1565 fn clean_consistent() {
1566 let r = vs("FACT x a\nCHECK x\n").unwrap();
1567 assert_eq!(r.status, Status::Consistent);
1568 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1569 }
1570
1571 #[test]
1572 fn fact_contradiction_is_conflict() {
1573 let r = vs("FACT x a\nNOT x a\n").unwrap();
1574 assert_eq!(r.status, Status::Conflict);
1575 assert_eq!(r.conflicts.len(), 1);
1576 }
1577
1578 #[test]
1579 fn exclusive_violation_is_conflict() {
1580 let src = include_str!("../../../docs/examples/conflict.vrf");
1581 let r = verify_source("conflict.vrf", src).unwrap();
1582 assert_eq!(r.status, Status::Conflict);
1583 assert_eq!(
1584 r.conflicts[0].origin.premise.as_deref(),
1585 Some("fly_xor_swim")
1586 );
1587 assert_eq!(r.conflicts[0].atoms.len(), 2);
1588 }
1589
1590 #[test]
1591 fn exclusive_with_unknown_is_consistent_not_warning() {
1592 let r = vs("FACT A has flying\nPREMISE e:\n EXCLUSIVE\n A has flying\n A has swimming\n").unwrap();
1594 assert_eq!(r.status, Status::Consistent);
1595 assert!(r.warnings.is_empty());
1596 }
1597
1598 #[test]
1599 fn implication_missing_consequent_is_warning() {
1600 let r = vs(r#"
1602 FACT A has flying
1603 PREMISE w:
1604 WHEN A has flying
1605 THEN A has wing
1606 "#)
1607 .unwrap();
1608 assert_eq!(r.status, Status::Warning);
1609 assert_eq!(r.warnings.len(), 1);
1610 assert_eq!(r.warnings[0].blocked_by, vec![String::from("t.A has wing")]);
1611 }
1612
1613 #[test]
1614 fn implication_satisfied_is_consistent() {
1615 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();
1616 assert_eq!(r.status, Status::Consistent);
1617 }
1618
1619 #[test]
1620 fn implication_violated_is_conflict() {
1621 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();
1623 assert_eq!(r.status, Status::Conflict);
1624 }
1625
1626 #[test]
1627 fn rule_derives_fact() {
1628 let r = vs(r#"
1629 FACT A has flying
1630 RULE o:
1631 WHEN A has flying
1632 THEN A needs oxygen
1633 "#)
1634 .unwrap();
1635 assert_eq!(r.status, Status::Consistent);
1636 assert_eq!(r.derived.len(), 1);
1637 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1638 }
1639
1640 #[test]
1641 fn rule_derivation_contradiction_is_conflict() {
1642 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();
1644 assert_eq!(r.status, Status::Conflict);
1645 }
1646
1647 #[test]
1648 fn bidirectional_finds_alternative_model_underdetermined() {
1649 let r = vs(r#"
1651 PREMISE e:
1652 EXCLUSIVE
1653 x a
1654 x b
1655 CHECK x BIDIRECTIONAL
1656 "#)
1657 .unwrap();
1658 assert_eq!(r.status, Status::Underdetermined);
1659 }
1660
1661 #[test]
1662 fn fact_pins_unique_model_consistent() {
1663 let r = vs(r#"
1665 FACT x a
1666 PREMISE e:
1667 EXCLUSIVE
1668 x a
1669 x b
1670 CHECK x BIDIRECTIONAL
1671 "#)
1672 .unwrap();
1673 assert_eq!(r.status, Status::Consistent);
1674 }
1675
1676 #[test]
1677 fn no_bidirectional_skips_backward_pass() {
1678 let r = vs(r#"
1680 PREMISE e:
1681 EXCLUSIVE
1682 x a
1683 x b
1684 CHECK x
1685 "#)
1686 .unwrap();
1687 assert_eq!(r.status, Status::Consistent);
1688 }
1689
1690 #[test]
1691 fn creature_example_forward_pass() {
1692 let src = include_str!("../../../docs/examples/creature.vrf");
1693 let r = verify_source("creature.vrf", src).unwrap();
1694 assert_eq!(r.status, Status::Warning);
1697 assert!(r.conflicts.is_empty());
1698 assert_eq!(r.warnings.len(), 2);
1699 assert_eq!(r.derived.len(), 1);
1700 assert_eq!(r.derived[0].atom, "creatures.Creature_A needs oxygen");
1701 }
1702
1703 #[test]
1704 fn roles_puzzle_is_uniquely_solved() {
1705 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1709 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1710 assert_eq!(r.status, Status::Consistent);
1711 assert!(r.conflicts.is_empty());
1712 assert!(r.underdetermined.is_none());
1713 }
1714
1715 #[test]
1716 fn roles_puzzle_underdetermined_without_a_clue() {
1717 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1722 .replace("\r\n", "\n")
1723 .replace("NOT bob is qa\n", "");
1724 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1725 assert_eq!(r.status, Status::Underdetermined);
1726 }
1727
1728 #[test]
1729 fn socrates_chain_is_a_conflict() {
1730 let src = include_str!("../../../docs/examples/socrates.vrf");
1733 let r = verify_source("socrates.vrf", src).unwrap();
1734 assert_eq!(r.status, Status::Conflict);
1735 assert_eq!(r.conflicts.len(), 1);
1736 assert_eq!(
1737 r.conflicts[0].origin.premise.as_deref(),
1738 Some("mortal_xor_immortal")
1739 );
1740 assert_eq!(r.derived.len(), 3); }
1742
1743 #[test]
1746 fn forward_conflict_carries_a_trace_of_its_facts() {
1747 let r = vs(r#"
1748 FACT x a
1749 FACT x b
1750 PREMISE e:
1751 EXCLUSIVE
1752 x a
1753 x b
1754 CHECK x
1755 "#)
1756 .unwrap();
1757 assert_eq!(r.status, Status::Conflict);
1758 let t = &r.conflicts[0].trace;
1759 assert_eq!(t.len(), 2);
1760 assert_eq!(t[0].atom, "t.x a");
1761 assert_eq!(t[0].value, Value::True);
1762 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1763 assert!(r.unsat_core.is_empty());
1764 }
1765
1766 #[test]
1767 fn derivation_chain_is_traced_back_to_the_root_fact() {
1768 let src = include_str!("../../../docs/examples/socrates.vrf");
1770 let r = verify_source("socrates.vrf", src).unwrap();
1771 let t = &r.conflicts[0].trace;
1772 assert_eq!(t.len(), 5);
1775 assert_eq!(t[0].atom, "philosophy.socrates is human");
1776 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1777 let mortal = t
1778 .iter()
1779 .find(|s| s.atom == "philosophy.socrates is mortal")
1780 .unwrap();
1781 match &mortal.reason {
1782 TraceReason::Derived { from, .. } => {
1783 assert_eq!(from, &vec![String::from("philosophy.socrates is living")]);
1784 }
1785 _ => panic!("mortal should be derived, not asserted"),
1786 }
1787 }
1788
1789 #[test]
1790 fn direct_fact_contradiction_has_no_trace() {
1791 let r = vs("FACT x a\nNOT x a\nCHECK x\n").unwrap();
1792 assert_eq!(r.status, Status::Conflict);
1793 assert!(r.conflicts[0].trace.is_empty());
1794 }
1795
1796 #[test]
1797 fn jointly_unsatisfiable_reports_a_minimal_core() {
1798 let src = r#"
1801 PREMISE one:
1802 ONEOF
1803 x a
1804 x b
1805 PREMISE ac:
1806 WHEN x a
1807 THEN x c
1808 PREMISE bc:
1809 WHEN x b
1810 THEN x c
1811 NOT x c
1812 CHECK x BIDIRECTIONAL
1813 "#;
1814 let r = vs(src).unwrap();
1815 assert_eq!(r.status, Status::Conflict);
1816 assert_eq!(r.conflicts[0].origin.kind, "UNSAT");
1817 assert_eq!(r.unsat_core.len(), 4);
1818 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1819 assert!(labels.contains(&"one"));
1820 assert!(labels.contains(&"t.x c")); }
1822
1823 #[test]
1824 fn unsat_core_excludes_irrelevant_constructs() {
1825 let src = r#"
1828 PREMISE one:
1829 ONEOF
1830 x a
1831 x b
1832 PREMISE ac:
1833 WHEN x a
1834 THEN x c
1835 PREMISE bc:
1836 WHEN x b
1837 THEN x c
1838 NOT x c
1839 FACT z here
1840 PREMISE noise:
1841 EXCLUSIVE
1842 z here
1843 z gone
1844 CHECK x BIDIRECTIONAL
1845 "#;
1846 let r = vs(src).unwrap();
1847 assert_eq!(r.status, Status::Conflict);
1848 assert_eq!(r.unsat_core.len(), 4);
1849 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1850 assert!(!labels.contains(&"noise"));
1851 assert!(!labels.iter().any(|l| l.contains("here")));
1852 }
1853
1854 #[test]
1855 fn consistent_report_has_empty_core_and_no_trace() {
1856 let r = vs("FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
1857 assert_eq!(r.status, Status::Consistent);
1858 assert!(r.unsat_core.is_empty());
1859 assert!(r.conflicts.is_empty());
1860 }
1861
1862 #[test]
1865 fn compatible_assumptions_behave_like_facts() {
1866 let r = vs("ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
1869 assert_eq!(r.status, Status::Consistent);
1870 assert!(r.retract.is_empty());
1871 assert!(r.conflicts.is_empty());
1872 }
1873
1874 #[test]
1875 fn assume_drives_a_rule_like_a_fact() {
1876 let r = vs(
1878 "ASSUME A has flying\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\nCHECK A\n",
1879 )
1880 .unwrap();
1881 assert_eq!(r.status, Status::Consistent);
1882 assert_eq!(r.derived.len(), 1);
1883 assert_eq!(r.derived[0].atom, "t.A needs oxygen");
1884 }
1885
1886 #[test]
1887 fn clashing_assumptions_yield_conflict_with_a_retract_set() {
1888 let src = r#"
1891 FACT rel reviewed
1892 PREMISE prod_needs_safety:
1893 WHEN rel in_prod
1894 THEN rel has_rollback
1895 OR rel has_feature_flag
1896 ASSUME rel in_prod
1897 ASSUME NOT rel has_rollback
1898 ASSUME NOT rel has_feature_flag
1899 CHECK rel
1900 "#;
1901 let r = vs(src).unwrap();
1902 assert_eq!(r.status, Status::Conflict);
1903 assert_eq!(r.exit_code(), 2);
1904 assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
1906 let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
1907 assert!(labels.contains(&"t.rel in_prod"));
1908 assert!(labels.contains(&"NOT t.rel has_rollback"));
1909 assert!(labels.contains(&"NOT t.rel has_feature_flag"));
1910 assert!(r.retract.iter().all(|it| it.origin.kind == "ASSUME"));
1912 let shown = alloc::format!("{r}");
1914 assert!(shown.contains("RETRACT"), "{shown}");
1915 assert!(!shown.contains("CONFLICT "), "{shown}");
1916 }
1917
1918 #[test]
1919 fn assume_vs_fact_retracts_only_the_assumption() {
1920 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
1922 assert_eq!(r.status, Status::Conflict);
1923 assert_eq!(r.retract.len(), 1);
1924 assert_eq!(r.retract[0].label, "NOT t.x a");
1925 assert_eq!(r.retract[0].origin.kind, "ASSUME");
1926 }
1927
1928 #[test]
1929 fn hard_conflict_is_not_blamed_on_assumptions() {
1930 let r = vs("FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
1933 assert_eq!(r.status, Status::Conflict);
1934 assert!(r.retract.is_empty(), "{:?}", r.retract);
1935 }
1936
1937 #[test]
1938 fn two_assumptions_directly_contradict() {
1939 let r = vs("ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
1940 assert_eq!(r.status, Status::Conflict);
1941 assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
1942 }
1943
1944 #[test]
1945 fn assume_retract_is_in_json() {
1946 let r = vs("FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
1947 let j = r.to_json();
1948 assert!(j.contains("\"retract\":["), "{j}");
1949 assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
1950 assert!(j.contains("NOT t.x a"), "{j}");
1951 }
1952
1953 #[test]
1956 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
1957 let r = vs(r#"
1961 FACT auth is rolled_back
1962 NOT auth is_rolled_back
1963 CHECK
1964 "#)
1965 .unwrap();
1966 assert_eq!(
1967 r.status,
1968 Status::Consistent,
1969 "hint must not change the verdict"
1970 );
1971 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
1972 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1973 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
1974 }
1975
1976 #[test]
1977 fn hint_flags_case_only_difference() {
1978 let r = vs("FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
1979 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1980 }
1981
1982 #[test]
1983 fn hint_flags_single_char_typo_same_subject() {
1984 let r = vs("FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
1986 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1987 }
1988
1989 #[test]
1990 fn no_hint_for_short_distinct_atoms() {
1991 let r = vs("FACT x a\nNOT x b\nCHECK\n").unwrap();
1993 assert!(r.hints.is_empty(), "{:?}", r.hints);
1994 }
1995
1996 #[test]
1997 fn no_hint_for_distinct_words() {
1998 let r = vs("FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
1999 assert!(r.hints.is_empty(), "{:?}", r.hints);
2000 }
2001
2002 #[test]
2003 fn russian_case_typo_is_flagged() {
2004 let r = vs("FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
2006 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2007 }
2008
2009 #[test]
2010 fn russian_single_char_typo_is_flagged() {
2011 let r = vs("FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
2012 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2013 }
2014
2015 #[test]
2016 fn cjk_one_char_difference_is_not_flagged() {
2017 let r = vs("FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
2020 assert!(r.hints.is_empty(), "{:?}", r.hints);
2021 }
2022
2023 #[test]
2024 fn cjk_underscore_vs_space_is_flagged() {
2025 let r = vs("FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
2028 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
2029 }
2030
2031 #[test]
2034 fn orphan_fact_is_flagged_but_advisory_only() {
2035 let r = vs("FACT x a\nCHECK\n").unwrap();
2038 assert_eq!(
2039 r.status,
2040 Status::Consistent,
2041 "orphan must not change verdict"
2042 );
2043 assert_eq!(r.exit_code(), 0, "orphan must not change exit code");
2044 assert_eq!(r.orphans.len(), 1, "{:?}", r.orphans);
2045 assert_eq!(r.orphans[0].atom, "t.x a");
2046 assert_eq!(r.orphans[0].origin.kind, "FACT");
2047 }
2048
2049 #[test]
2050 fn fact_used_by_a_premise_is_not_orphan() {
2051 let r =
2053 vs("FACT x a\nPREMISE p:\n EXCLUSIVE\n x a\n x b\nCHECK\n").unwrap();
2054 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2055 }
2056
2057 #[test]
2058 fn fact_used_by_a_rule_antecedent_is_not_orphan() {
2059 let r = vs("FACT x a\nRULE r:\n WHEN x a\n THEN x c\nCHECK\n").unwrap();
2060 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2061 }
2062
2063 #[test]
2064 fn negation_and_assumption_orphans_keep_their_surface_polarity() {
2065 let r = vs("NOT x a\nASSUME NOT y b\nCHECK\n").unwrap();
2068 assert_eq!(r.orphans.len(), 2, "{:?}", r.orphans);
2069 let text = alloc::format!("{r}");
2070 assert!(text.contains("ORPHAN NOT t.x a"), "{text}");
2071 assert!(text.contains("ORPHAN ASSUME NOT t.y b"), "{text}");
2072 }
2073
2074 #[test]
2075 fn orphan_is_in_json() {
2076 let r = vs("FACT x a\nCHECK\n").unwrap();
2077 let j = r.to_json();
2078 assert!(j.contains("\"orphans\":["), "{j}");
2079 assert!(j.contains("\"atom\":\"t.x a\""), "{j}");
2080 assert!(j.contains("\"kind\":\"FACT\""), "{j}");
2081 }
2082
2083 #[test]
2084 fn unused_import_is_advisory_only_in_the_report() {
2085 let mut r = MemoryResolver::new();
2088 r.add("physics.vrf", "DOMAIN physics\nFACT Motor over_100\n");
2089 r.add(
2090 "main.vrf",
2091 "DOMAIN main\nIMPORT \"physics.vrf\"\nFACT x a\nCHECK\n",
2092 );
2093 let rep = verify("main.vrf", &r).unwrap();
2094 assert_eq!(rep.status, Status::Consistent);
2095 assert_eq!(rep.exit_code(), 0);
2096 assert_eq!(rep.unused_imports.len(), 1);
2097 assert_eq!(rep.unused_imports[0].domain, "physics");
2098 let text = alloc::format!("{rep}");
2099 assert!(text.contains("UNUSED IMPORT physics"), "{text}");
2100 assert!(
2101 rep.to_json().contains("\"unused_imports\":[{"),
2102 "{}",
2103 rep.to_json()
2104 );
2105 }
2106
2107 #[test]
2108 fn a_derived_atom_does_not_make_its_consumer_orphan() {
2109 let r = vs(
2112 "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",
2113 )
2114 .unwrap();
2115 assert!(r.orphans.is_empty(), "{:?}", r.orphans);
2116 }
2117}