1#![no_std]
22#![warn(missing_docs)]
24
25extern crate alloc;
26
27#[cfg(feature = "std")]
28extern crate std;
29
30pub mod sat;
31
32use alloc::string::String;
33use alloc::vec;
34use alloc::vec::Vec;
35use core::fmt;
36
37use elenchus_compiler::{AtomId, AtomKey, Clause, Compiled, Lit, Origin, Value};
38pub use elenchus_compiler::{CompileError, MemoryResolver, Resolver, compile, compile_source};
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum V3 {
43 True,
45 False,
47 Unknown,
49}
50
51impl V3 {
52 fn not(self) -> V3 {
54 match self {
55 V3::True => V3::False,
56 V3::False => V3::True,
57 V3::Unknown => V3::Unknown,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum Status {
65 Consistent,
67 Underdetermined,
70 Warning,
72 Conflict,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct Conflict {
79 pub origin: Origin,
81 pub atoms: Vec<String>,
83 pub trace: Vec<TraceStep>,
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct TraceStep {
95 pub atom: String,
97 pub value: Value,
99 pub reason: TraceReason,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum TraceReason {
106 Asserted(Origin),
108 Derived {
110 origin: Origin,
112 from: Vec<String>,
114 },
115}
116
117#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct Warning {
120 pub origin: Origin,
122 pub blocked_by: Vec<String>,
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Derived {
129 pub atom: String,
131 pub value: Value,
133 pub origin: Origin,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub struct Report {
140 pub status: Status,
142 pub conflicts: Vec<Conflict>,
144 pub warnings: Vec<Warning>,
146 pub derived: Vec<Derived>,
148 pub underdetermined: Option<String>,
151 pub unsat_core: Vec<CoreItem>,
156 pub hints: Vec<SimilarAtoms>,
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct SimilarAtoms {
168 pub a: String,
170 pub b: String,
172 pub reason: &'static str,
174}
175
176#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct CoreItem {
179 pub origin: Origin,
181 pub label: String,
183}
184
185impl Report {
186 pub fn exit_code(&self) -> i32 {
188 match self.status {
189 Status::Conflict => 2,
190 Status::Underdetermined | Status::Warning => 1,
191 Status::Consistent => 0,
192 }
193 }
194
195 pub fn to_json(&self) -> String {
198 use core::fmt::Write as _;
199 let mut s = String::new();
200 let _ = write!(s, "{{\"status\":");
201 json_str(status_name(self.status), &mut s);
202 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
203
204 s.push_str(",\"conflicts\":[");
205 for (i, c) in self.conflicts.iter().enumerate() {
206 if i > 0 {
207 s.push(',');
208 }
209 json_origin(&c.origin, &mut s);
210 s.push_str(",\"atoms\":");
211 json_array(&c.atoms, &mut s);
212 s.push_str(",\"trace\":[");
213 for (j, step) in c.trace.iter().enumerate() {
214 if j > 0 {
215 s.push(',');
216 }
217 json_trace_step(step, &mut s);
218 }
219 s.push_str("]}");
220 }
221 s.push_str("],\"warnings\":[");
222 for (i, w) in self.warnings.iter().enumerate() {
223 if i > 0 {
224 s.push(',');
225 }
226 json_origin(&w.origin, &mut s);
227 s.push_str(",\"blocked_by\":");
228 json_array(&w.blocked_by, &mut s);
229 s.push('}');
230 }
231 s.push_str("],\"derived\":[");
232 for (i, d) in self.derived.iter().enumerate() {
233 if i > 0 {
234 s.push(',');
235 }
236 s.push('{');
237 json_origin_fields(&d.origin, &mut s);
238 s.push_str(",\"atom\":");
239 json_str(&d.atom, &mut s);
240 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
241 s.push('}');
242 }
243 s.push_str("],\"underdetermined\":");
244 match &self.underdetermined {
245 Some(atom) => json_str(atom, &mut s),
246 None => s.push_str("null"),
247 }
248 s.push_str(",\"unsat_core\":[");
249 for (i, it) in self.unsat_core.iter().enumerate() {
250 if i > 0 {
251 s.push(',');
252 }
253 json_origin(&it.origin, &mut s);
254 s.push_str(",\"label\":");
255 json_str(&it.label, &mut s);
256 s.push('}');
257 }
258 s.push_str("],\"hints\":[");
259 for (i, h) in self.hints.iter().enumerate() {
260 if i > 0 {
261 s.push(',');
262 }
263 s.push_str("{\"a\":");
264 json_str(&h.a, &mut s);
265 s.push_str(",\"b\":");
266 json_str(&h.b, &mut s);
267 s.push_str(",\"reason\":");
268 json_str(h.reason, &mut s);
269 s.push('}');
270 }
271 s.push_str("]}");
272 s
273 }
274}
275
276fn json_trace_step(step: &TraceStep, out: &mut String) {
278 use core::fmt::Write as _;
279 out.push_str("{\"atom\":");
280 json_str(&step.atom, out);
281 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
282 match &step.reason {
283 TraceReason::Asserted(o) => {
284 out.push_str(",\"how\":\"asserted\",");
285 json_origin_fields(o, out);
286 out.push_str(",\"from\":[]");
287 }
288 TraceReason::Derived { origin, from } => {
289 out.push_str(",\"how\":\"derived\",");
290 json_origin_fields(origin, out);
291 out.push_str(",\"from\":");
292 json_array(from, out);
293 }
294 }
295 out.push('}');
296}
297
298fn status_name(s: Status) -> &'static str {
299 match s {
300 Status::Consistent => "CONSISTENT",
301 Status::Underdetermined => "UNDERDETERMINED",
302 Status::Warning => "WARNING",
303 Status::Conflict => "CONFLICT",
304 }
305}
306
307fn json_str(value: &str, out: &mut String) {
309 use core::fmt::Write as _;
310 out.push('"');
311 for ch in value.chars() {
312 match ch {
313 '"' => out.push_str("\\\""),
314 '\\' => out.push_str("\\\\"),
315 '\n' => out.push_str("\\n"),
316 '\r' => out.push_str("\\r"),
317 '\t' => out.push_str("\\t"),
318 c if (c as u32) < 0x20 => {
319 let _ = write!(out, "\\u{:04x}", c as u32);
320 }
321 c => out.push(c),
322 }
323 }
324 out.push('"');
325}
326
327fn json_array(items: &[String], out: &mut String) {
329 out.push('[');
330 for (i, item) in items.iter().enumerate() {
331 if i > 0 {
332 out.push(',');
333 }
334 json_str(item, out);
335 }
336 out.push(']');
337}
338
339fn json_origin_fields(o: &Origin, out: &mut String) {
341 use core::fmt::Write as _;
342 out.push_str("\"premise\":");
343 match &o.premise {
344 Some(name) => json_str(name, out),
345 None => out.push_str("null"),
346 }
347 out.push_str(",\"kind\":");
348 json_str(o.kind, out);
349 out.push_str(",\"source\":");
350 json_str(&o.source, out);
351 let _ = write!(out, ",\"line\":{}", o.line);
352}
353
354fn json_origin(o: &Origin, out: &mut String) {
356 out.push('{');
357 json_origin_fields(o, out);
358}
359
360fn label(c: &Compiled, a: AtomId) -> String {
362 let k = &c.atoms[a as usize];
363 match &k.object {
364 Some(o) => alloc::format!("{} {} {}", k.subject, k.predicate, o),
365 None => alloc::format!("{} {}", k.subject, k.predicate),
366 }
367}
368
369fn lit_value(model: &[V3], l: &Lit) -> V3 {
371 let v = model[l.atom as usize];
372 if l.negated { v.not() } else { v }
373}
374
375fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
377 let mut result = V3::True;
378 for l in lits {
379 match lit_value(model, l) {
380 V3::False => return V3::False,
381 V3::Unknown => result = V3::Unknown,
382 V3::True => {}
383 }
384 }
385 result
386}
387
388enum ClauseEval {
390 Violated,
392 Satisfied,
394 Blocked(Vec<AtomId>),
396}
397
398fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
401 let mut any_false = false;
402 let mut all_true = true;
403 let mut blocked = Vec::new();
404 for l in &clause.lits {
405 match lit_value(model, l) {
406 V3::False => {
407 any_false = true;
408 all_true = false;
409 }
410 V3::Unknown => {
411 all_true = false;
412 blocked.push(l.atom);
413 }
414 V3::True => {}
415 }
416 }
417 if all_true {
418 ClauseEval::Violated
419 } else if any_false {
420 ClauseEval::Satisfied
421 } else {
422 ClauseEval::Blocked(blocked)
423 }
424}
425
426#[derive(Clone)]
429enum AtomReason {
430 Asserted(Origin),
432 Derived { origin: Origin, from: Vec<AtomId> },
434}
435
436struct RawConflict {
440 origin: Origin,
441 atoms: Vec<String>,
442 cause: Vec<AtomId>,
443}
444
445struct Eval<'a> {
447 c: &'a Compiled,
448 model: Vec<V3>,
449 reason: Vec<Option<AtomReason>>,
452 conflicts: Vec<RawConflict>,
453 warnings: Vec<Warning>,
454 derived: Vec<Derived>,
455 unsat_core: Vec<CoreItem>,
457}
458
459impl<'a> Eval<'a> {
460 fn new(c: &'a Compiled) -> Self {
461 Eval {
462 c,
463 model: vec![V3::Unknown; c.atoms.len()],
464 reason: vec![None; c.atoms.len()],
465 conflicts: Vec::new(),
466 warnings: Vec::new(),
467 derived: Vec::new(),
468 unsat_core: Vec::new(),
469 }
470 }
471
472 fn label(&self, a: AtomId) -> String {
473 label(self.c, a)
474 }
475
476 fn seed_facts(&mut self) {
478 let c = self.c;
479 let n = c.atoms.len();
480 let mut t: Vec<Option<Origin>> = vec![None; n];
481 let mut f: Vec<Option<Origin>> = vec![None; n];
482 for fact in &c.facts {
483 let slot = match fact.value {
484 Value::True => &mut t,
485 Value::False => &mut f,
486 };
487 if slot[fact.atom as usize].is_none() {
488 slot[fact.atom as usize] = Some(fact.origin.clone());
489 }
490 }
491 for a in 0..n {
492 match (&t[a], &f[a]) {
493 (Some(o), Some(_)) => {
494 self.model[a] = V3::True;
495 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
496 self.conflicts.push(RawConflict {
497 origin: o.clone(),
498 atoms: vec![alloc::format!(
499 "{} (asserted both TRUE and FALSE)",
500 self.label(a as AtomId)
501 )],
502 cause: Vec::new(),
503 });
504 }
505 (Some(o), None) => {
506 self.model[a] = V3::True;
507 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
508 }
509 (None, Some(o)) => {
510 self.model[a] = V3::False;
511 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
512 }
513 (None, None) => {}
514 }
515 }
516 }
517
518 fn saturate_rules(&mut self) {
520 let c = self.c;
521 loop {
522 let mut changed = false;
523 for r in &c.rules {
524 if conjunction(&self.model, &r.antecedent) != V3::True {
525 continue; }
527 for cl in &r.consequent {
528 let target = if cl.negated { V3::False } else { V3::True };
529 match self.model[cl.atom as usize] {
530 V3::Unknown => {
531 self.model[cl.atom as usize] = target;
532 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
533 origin: r.origin.clone(),
534 from: r.antecedent.iter().map(|l| l.atom).collect(),
535 });
536 changed = true;
537 self.derived.push(Derived {
538 atom: self.label(cl.atom),
539 value: if cl.negated {
540 Value::False
541 } else {
542 Value::True
543 },
544 origin: r.origin.clone(),
545 });
546 }
547 v if v == target => {}
548 _ => {
549 let mut cause: Vec<AtomId> =
553 r.antecedent.iter().map(|l| l.atom).collect();
554 cause.push(cl.atom);
555 self.conflicts.push(RawConflict {
556 origin: r.origin.clone(),
557 atoms: vec![alloc::format!(
558 "{} (derived value contradicts a known fact)",
559 self.label(cl.atom)
560 )],
561 cause,
562 });
563 }
564 }
565 }
566 }
567 if !changed {
568 break;
569 }
570 }
571 }
572
573 fn check_premises(&mut self) {
575 let c = self.c;
576 for clause in &c.clauses {
577 match eval_clause(&self.model, clause) {
578 ClauseEval::Violated => self.conflicts.push(RawConflict {
579 origin: clause.origin.clone(),
580 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
581 cause: clause.lits.iter().map(|l| l.atom).collect(),
582 }),
583 ClauseEval::Satisfied => {}
584 ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
587 self.warnings.push(Warning {
588 origin: clause.origin.clone(),
589 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
590 });
591 }
592 ClauseEval::Blocked(_) => {}
593 }
594 }
595 }
596
597 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
603 let mut visited = vec![false; self.c.atoms.len()];
604 let mut out = Vec::new();
605 for &a in causes {
606 self.trace_dfs(a, &mut visited, &mut out);
607 }
608 out
609 }
610
611 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
612 if visited[a as usize] {
613 return;
614 }
615 visited[a as usize] = true;
616 let value = match v3_to_value(self.model[a as usize]) {
617 Some(v) => v,
618 None => return, };
620 let reason = match &self.reason[a as usize] {
621 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
622 Some(AtomReason::Derived { origin, from }) => {
623 for &f in from {
624 self.trace_dfs(f, visited, out); }
626 TraceReason::Derived {
627 origin: origin.clone(),
628 from: from.iter().map(|&f| self.label(f)).collect(),
629 }
630 }
631 None => return,
632 };
633 out.push(TraceStep {
634 atom: self.label(a),
635 value,
636 reason,
637 });
638 }
639
640 fn backward_pass(&mut self) -> Option<String> {
647 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
648 return None;
649 }
650 let (cnf, project) = build_cnf(self.c);
651 let found = sat::models(&cnf, &project, 2);
652 match found.len() {
653 0 if self.conflicts.is_empty() => {
654 self.unsat_core = minimal_unsat_core(self.c);
655 self.conflicts.push(RawConflict {
656 origin: Origin {
657 source: String::from("<system>"),
658 line: 0,
659 premise: None,
660 kind: "UNSAT",
661 },
662 atoms: vec![String::from(
663 "the premises and facts are jointly unsatisfiable",
664 )],
665 cause: Vec::new(),
666 });
667 None
668 }
669 n if n >= 2 => {
670 let (m0, m1) = (&found[0], &found[1]);
671 project
672 .iter()
673 .find(|&&v| m0[v as usize] != m1[v as usize])
674 .map(|&v| self.label(v))
675 .or_else(|| Some(String::from("a free atom")))
676 }
677 _ => None,
678 }
679 }
680
681 fn finish(mut self) -> Report {
683 let underdetermined = self.backward_pass();
684 self.conflicts.sort_by_key(|c| key(&c.origin));
685 self.warnings.sort_by_key(|w| key(&w.origin));
686 let status = if !self.conflicts.is_empty() {
687 Status::Conflict
688 } else if underdetermined.is_some() {
689 Status::Underdetermined
690 } else if !self.warnings.is_empty() {
691 Status::Warning
692 } else {
693 Status::Consistent
694 };
695 let conflicts: Vec<Conflict> = self
698 .conflicts
699 .iter()
700 .map(|rc| Conflict {
701 origin: rc.origin.clone(),
702 atoms: rc.atoms.clone(),
703 trace: self.build_trace(&rc.cause),
704 })
705 .collect();
706 Report {
707 status,
708 conflicts,
709 warnings: self.warnings,
710 derived: self.derived,
711 underdetermined,
712 unsat_core: self.unsat_core,
713 hints: Vec::new(), }
715 }
716}
717
718pub fn solve(c: &Compiled) -> Report {
721 let mut e = Eval::new(c);
722 e.seed_facts();
723 e.saturate_rules();
724 e.check_premises();
725 let mut report = e.finish();
726 report.hints = similar_atom_pairs(c);
729 report
730}
731
732fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
752 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
753 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
754 let mut out = Vec::new();
755 for i in 0..c.atoms.len() {
756 for j in (i + 1)..c.atoms.len() {
757 if let Some(reason) = atoms_look_similar(
758 &c.atoms[i],
759 &folded[i],
760 cased[i],
761 &c.atoms[j],
762 &folded[j],
763 cased[j],
764 ) {
765 out.push(SimilarAtoms {
766 a: label(c, i as AtomId),
767 b: label(c, j as AtomId),
768 reason,
769 });
770 }
771 }
772 }
773 out
774}
775
776fn fold_atom(k: &AtomKey) -> Vec<char> {
780 let mut raw = String::new();
781 raw.push_str(&k.subject);
782 raw.push(' ');
783 raw.push_str(&k.predicate);
784 if let Some(o) = &k.object {
785 raw.push(' ');
786 raw.push_str(o);
787 }
788 let mut out: Vec<char> = Vec::new();
789 let mut prev_space = false;
790 for ch in raw.chars() {
791 if ch == '_' || ch.is_whitespace() {
792 if !prev_space && !out.is_empty() {
793 out.push(' ');
794 prev_space = true;
795 }
796 } else {
797 for lc in ch.to_lowercase() {
798 out.push(lc);
799 }
800 prev_space = false;
801 }
802 }
803 if out.last() == Some(&' ') {
804 out.pop();
805 }
806 out
807}
808
809fn is_cased_alphabetic(folded: &[char]) -> bool {
816 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
817}
818
819fn atoms_look_similar(
822 ka: &AtomKey,
823 fa: &[char],
824 cased_a: bool,
825 kb: &AtomKey,
826 fb: &[char],
827 cased_b: bool,
828) -> Option<&'static str> {
829 if fa == fb {
831 return Some("same name up to case, '_', or spaces");
832 }
833 if !cased_a || !cased_b || ka.subject != kb.subject {
838 return None;
839 }
840 if fa.len().abs_diff(fb.len()) > 1 {
841 return None; }
843 let min_len = fa.len().min(fb.len());
844 if min_len >= 5 && levenshtein(fa, fb) == 1 {
845 return Some("looks like a one-character typo of each other");
846 }
847 None
848}
849
850fn levenshtein(a: &[char], b: &[char]) -> usize {
852 let mut prev: Vec<usize> = (0..=b.len()).collect();
853 let mut cur = vec![0usize; b.len() + 1];
854 for (i, &ca) in a.iter().enumerate() {
855 cur[0] = i + 1;
856 for (j, &cb) in b.iter().enumerate() {
857 let cost = usize::from(ca != cb);
858 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
859 }
860 core::mem::swap(&mut prev, &mut cur);
861 }
862 prev[b.len()]
863}
864
865fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
870 use sat::SatLit;
871 let mut cnf = sat::Cnf::new(c.atoms.len());
872 let mut constrained = vec![false; c.atoms.len()];
873 let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
874
875 for clause in &c.clauses {
877 let lits = clause
878 .lits
879 .iter()
880 .map(|l| {
881 mark(l.atom, &mut constrained);
882 SatLit::new(l.atom, l.negated)
883 })
884 .collect();
885 cnf.add_clause(lits);
886 }
887 for r in &c.rules {
889 for cons in &r.consequent {
890 let mut lits: Vec<SatLit> = r
891 .antecedent
892 .iter()
893 .map(|a| {
894 mark(a.atom, &mut constrained);
895 SatLit::new(a.atom, a.negated)
896 })
897 .collect();
898 mark(cons.atom, &mut constrained);
899 lits.push(SatLit::new(cons.atom, !cons.negated));
900 cnf.add_clause(lits);
901 }
902 }
903 for f in &c.facts {
905 let lit = match f.value {
906 Value::True => SatLit::positive(f.atom),
907 Value::False => SatLit::negative(f.atom),
908 };
909 cnf.add_clause(vec![lit]);
910 }
911
912 let project = (0..c.atoms.len() as AtomId)
913 .filter(|&a| constrained[a as usize])
914 .collect();
915 (cnf, project)
916}
917
918fn v3_to_value(v: V3) -> Option<Value> {
920 match v {
921 V3::True => Some(Value::True),
922 V3::False => Some(Value::False),
923 V3::Unknown => None,
924 }
925}
926
927struct Construct {
930 origin: Origin,
931 label: String,
932 clauses: Vec<Vec<sat::SatLit>>,
933}
934
935fn same_origin(a: &Origin, b: &Origin) -> bool {
937 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
938}
939
940fn constructs(c: &Compiled) -> Vec<Construct> {
944 use sat::SatLit;
945 let mut out: Vec<Construct> = Vec::new();
946
947 for f in &c.facts {
948 let lit = match f.value {
949 Value::True => SatLit::positive(f.atom),
950 Value::False => SatLit::negative(f.atom),
951 };
952 out.push(Construct {
953 origin: f.origin.clone(),
954 label: label(c, f.atom),
955 clauses: vec![vec![lit]],
956 });
957 }
958
959 let mut premises: Vec<Construct> = Vec::new();
960 for clause in &c.clauses {
961 let lits: Vec<SatLit> = clause
962 .lits
963 .iter()
964 .map(|l| SatLit::new(l.atom, l.negated))
965 .collect();
966 match premises
967 .iter_mut()
968 .find(|k| same_origin(&k.origin, &clause.origin))
969 {
970 Some(k) => k.clauses.push(lits),
971 None => premises.push(Construct {
972 label: clause.origin.premise.clone().unwrap_or_default(),
973 origin: clause.origin.clone(),
974 clauses: vec![lits],
975 }),
976 }
977 }
978 out.extend(premises);
979
980 for r in &c.rules {
981 let clauses = r
982 .consequent
983 .iter()
984 .map(|cons| {
985 let mut lits: Vec<SatLit> = r
986 .antecedent
987 .iter()
988 .map(|a| SatLit::new(a.atom, a.negated))
989 .collect();
990 lits.push(SatLit::new(cons.atom, !cons.negated));
991 lits
992 })
993 .collect();
994 out.push(Construct {
995 label: r.origin.premise.clone().unwrap_or_default(),
996 origin: r.origin.clone(),
997 clauses,
998 });
999 }
1000 out
1001}
1002
1003fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1005 let mut cnf = sat::Cnf::new(num_vars);
1006 for (k, &keep) in all.iter().zip(active) {
1007 if keep {
1008 for cl in &k.clauses {
1009 cnf.add_clause(cl.clone());
1010 }
1011 }
1012 }
1013 sat::solve(&cnf).is_some()
1014}
1015
1016fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1022 let base = c.atoms.len();
1023 let mut cnf = sat::Cnf::new(base + all.len());
1024 let sel = |i: usize| (base + i) as sat::Var;
1025 for (i, k) in all.iter().enumerate() {
1026 let s_neg = sat::SatLit::negative(sel(i));
1027 for cl in &k.clauses {
1028 let mut lits = Vec::with_capacity(cl.len() + 1);
1029 lits.push(s_neg);
1030 lits.extend_from_slice(cl);
1031 cnf.add_clause(lits);
1032 }
1033 }
1034 let assumptions: Vec<sat::SatLit> = (0..all.len())
1035 .map(|i| sat::SatLit::positive(sel(i)))
1036 .collect();
1037 let mut active = vec![false; all.len()];
1038 match sat::solve_assuming(&cnf, &assumptions) {
1039 sat::Solved::Unsat(core) => {
1040 for lit in core {
1041 let v = lit.var() as usize;
1042 if v >= base {
1043 active[v - base] = true;
1044 }
1045 }
1046 }
1047 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1051 }
1052 active
1053}
1054
1055fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1061 let all = constructs(c);
1062 let mut active = candidate_via_assumptions(c, &all);
1063 for i in 0..all.len() {
1064 if active[i] {
1065 active[i] = false;
1066 if subset_is_sat(c.atoms.len(), &all, &active) {
1067 active[i] = true; }
1069 }
1070 }
1071 let mut core: Vec<CoreItem> = all
1072 .iter()
1073 .zip(&active)
1074 .filter(|&(_, &keep)| keep)
1075 .map(|(k, _)| CoreItem {
1076 origin: k.origin.clone(),
1077 label: k.label.clone(),
1078 })
1079 .collect();
1080 core.sort_by_key(|it| key(&it.origin));
1081 core
1082}
1083
1084fn key(o: &Origin) -> (String, u32) {
1086 (o.source.clone(), o.line)
1087}
1088
1089pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1091 Ok(solve(&compile_source(name, src)?))
1092}
1093
1094pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1096 Ok(solve(&compile(root, resolver)?))
1097}
1098
1099impl fmt::Display for Status {
1102 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1103 f.write_str(match self {
1104 Status::Consistent => "CONSISTENT",
1105 Status::Underdetermined => "UNDERDETERMINED",
1106 Status::Warning => "WARNING",
1107 Status::Conflict => "CONFLICT",
1108 })
1109 }
1110}
1111
1112fn premise_tag(o: &Origin) -> String {
1114 let name = o.premise.as_deref().unwrap_or("-");
1115 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1116}
1117
1118fn trace_line(step: &TraceStep) -> String {
1120 let v = match step.value {
1121 Value::True => "TRUE",
1122 Value::False => "FALSE",
1123 };
1124 match &step.reason {
1125 TraceReason::Asserted(o) => {
1126 alloc::format!(
1127 "{} = {} [{} {}:{}]",
1128 step.atom,
1129 v,
1130 o.kind,
1131 o.source,
1132 o.line
1133 )
1134 }
1135 TraceReason::Derived { origin, from } => alloc::format!(
1136 "{} = {} from {} ({}) [{}:{}] <= {}",
1137 step.atom,
1138 v,
1139 origin.premise.as_deref().unwrap_or("-"),
1140 origin.kind,
1141 origin.source,
1142 origin.line,
1143 from.join(", ")
1144 ),
1145 }
1146}
1147
1148impl fmt::Display for Report {
1149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1150 writeln!(f, "RESULT: {}", self.status)?;
1151 for c in &self.conflicts {
1152 writeln!(f, " CONFLICT {}", premise_tag(&c.origin))?;
1153 for a in &c.atoms {
1154 writeln!(f, " {}", a)?;
1155 }
1156 if !c.trace.is_empty() {
1157 writeln!(f, " why:")?;
1158 for step in &c.trace {
1159 writeln!(f, " {}", trace_line(step))?;
1160 }
1161 }
1162 }
1163 if !self.unsat_core.is_empty() {
1164 writeln!(
1165 f,
1166 " CORE smallest jointly-unsatisfiable set ({}):",
1167 self.unsat_core.len()
1168 )?;
1169 for it in &self.unsat_core {
1170 let name = if it.label.is_empty() { "-" } else { &it.label };
1171 writeln!(
1172 f,
1173 " {} ({}) [{}:{}]",
1174 name, it.origin.kind, it.origin.source, it.origin.line
1175 )?;
1176 }
1177 }
1178 for w in &self.warnings {
1179 writeln!(f, " WARNING {}", premise_tag(&w.origin))?;
1180 writeln!(f, " blocked by: {}", w.blocked_by.join(", "))?;
1181 }
1182 if let Some(atom) = &self.underdetermined {
1183 writeln!(f, " UNDERDETERMINED an alternative model exists")?;
1184 writeln!(f, " pin it down: add FACT {atom} or NOT {atom}")?;
1185 }
1186 for d in &self.derived {
1187 let v = match d.value {
1188 Value::True => "TRUE",
1189 Value::False => "FALSE",
1190 };
1191 writeln!(
1192 f,
1193 " DERIVED {} = {} from {}",
1194 d.atom,
1195 v,
1196 premise_tag(&d.origin)
1197 )?;
1198 }
1199 for h in &self.hints {
1200 writeln!(
1201 f,
1202 " HINT possible typo — '{}' and '{}' look like the same atom ({})",
1203 h.a, h.b, h.reason
1204 )?;
1205 }
1206 let underdetermined = usize::from(self.status == Status::Underdetermined);
1207 writeln!(
1208 f,
1209 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1210 self.conflicts.len(),
1211 underdetermined,
1212 self.warnings.len(),
1213 self.derived.len()
1214 )?;
1215 write!(f, "EXIT_CODE: {}", self.exit_code())
1216 }
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221 use super::*;
1222
1223 #[test]
1224 fn clean_consistent() {
1225 let r = verify_source("<t>", "FACT x a\nCHECK x\n").unwrap();
1226 assert_eq!(r.status, Status::Consistent);
1227 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1228 }
1229
1230 #[test]
1231 fn fact_contradiction_is_conflict() {
1232 let r = verify_source("<t>", "FACT x a\nNOT x a\n").unwrap();
1233 assert_eq!(r.status, Status::Conflict);
1234 assert_eq!(r.conflicts.len(), 1);
1235 }
1236
1237 #[test]
1238 fn exclusive_violation_is_conflict() {
1239 let src = include_str!("../../../docs/examples/conflict.vrf");
1240 let r = verify_source("conflict.vrf", src).unwrap();
1241 assert_eq!(r.status, Status::Conflict);
1242 assert_eq!(
1243 r.conflicts[0].origin.premise.as_deref(),
1244 Some("fly_xor_swim")
1245 );
1246 assert_eq!(r.conflicts[0].atoms.len(), 2);
1247 }
1248
1249 #[test]
1250 fn exclusive_with_unknown_is_consistent_not_warning() {
1251 let r = verify_source("<t>", "FACT A has flying\nPREMISE e:\n EXCLUSIVE\n A has flying\n A has swimming\n").unwrap();
1253 assert_eq!(r.status, Status::Consistent);
1254 assert!(r.warnings.is_empty());
1255 }
1256
1257 #[test]
1258 fn implication_missing_consequent_is_warning() {
1259 let r = verify_source(
1261 "<t>",
1262 r#"
1263 FACT A has flying
1264 PREMISE w:
1265 WHEN A has flying
1266 THEN A has wing
1267 "#,
1268 )
1269 .unwrap();
1270 assert_eq!(r.status, Status::Warning);
1271 assert_eq!(r.warnings.len(), 1);
1272 assert_eq!(r.warnings[0].blocked_by, vec![String::from("A has wing")]);
1273 }
1274
1275 #[test]
1276 fn implication_satisfied_is_consistent() {
1277 let r = verify_source("<t>", "FACT A has flying\nFACT A has wing\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n").unwrap();
1278 assert_eq!(r.status, Status::Consistent);
1279 }
1280
1281 #[test]
1282 fn implication_violated_is_conflict() {
1283 let r = verify_source("<t>", "FACT A has flying\nNOT A has wing\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n").unwrap();
1285 assert_eq!(r.status, Status::Conflict);
1286 }
1287
1288 #[test]
1289 fn rule_derives_fact() {
1290 let r = verify_source(
1291 "<t>",
1292 r#"
1293 FACT A has flying
1294 RULE o:
1295 WHEN A has flying
1296 THEN A needs oxygen
1297 "#,
1298 )
1299 .unwrap();
1300 assert_eq!(r.status, Status::Consistent);
1301 assert_eq!(r.derived.len(), 1);
1302 assert_eq!(r.derived[0].atom, "A needs oxygen");
1303 }
1304
1305 #[test]
1306 fn rule_derivation_contradiction_is_conflict() {
1307 let r = verify_source("<t>", "FACT A has flying\nNOT A needs oxygen\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\n").unwrap();
1309 assert_eq!(r.status, Status::Conflict);
1310 }
1311
1312 #[test]
1313 fn bidirectional_finds_alternative_model_underdetermined() {
1314 let r = verify_source(
1316 "<t>",
1317 r#"
1318 PREMISE e:
1319 EXCLUSIVE
1320 x a
1321 x b
1322 CHECK x BIDIRECTIONAL
1323 "#,
1324 )
1325 .unwrap();
1326 assert_eq!(r.status, Status::Underdetermined);
1327 }
1328
1329 #[test]
1330 fn fact_pins_unique_model_consistent() {
1331 let r = verify_source(
1333 "<t>",
1334 r#"
1335 FACT x a
1336 PREMISE e:
1337 EXCLUSIVE
1338 x a
1339 x b
1340 CHECK x BIDIRECTIONAL
1341 "#,
1342 )
1343 .unwrap();
1344 assert_eq!(r.status, Status::Consistent);
1345 }
1346
1347 #[test]
1348 fn no_bidirectional_skips_backward_pass() {
1349 let r = verify_source(
1351 "<t>",
1352 r#"
1353 PREMISE e:
1354 EXCLUSIVE
1355 x a
1356 x b
1357 CHECK x
1358 "#,
1359 )
1360 .unwrap();
1361 assert_eq!(r.status, Status::Consistent);
1362 }
1363
1364 #[test]
1365 fn creature_example_forward_pass() {
1366 let src = include_str!("../../../docs/examples/creature.vrf");
1367 let r = verify_source("creature.vrf", src).unwrap();
1368 assert_eq!(r.status, Status::Warning);
1371 assert!(r.conflicts.is_empty());
1372 assert_eq!(r.warnings.len(), 2);
1373 assert_eq!(r.derived.len(), 1);
1374 assert_eq!(r.derived[0].atom, "Creature.A needs oxygen");
1375 }
1376
1377 #[test]
1378 fn roles_puzzle_is_uniquely_solved() {
1379 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1383 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1384 assert_eq!(r.status, Status::Consistent);
1385 assert!(r.conflicts.is_empty());
1386 assert!(r.underdetermined.is_none());
1387 }
1388
1389 #[test]
1390 fn roles_puzzle_underdetermined_without_a_clue() {
1391 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1396 .replace("\r\n", "\n")
1397 .replace("NOT bob is qa\n", "");
1398 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1399 assert_eq!(r.status, Status::Underdetermined);
1400 }
1401
1402 #[test]
1403 fn socrates_chain_is_a_conflict() {
1404 let src = include_str!("../../../docs/examples/socrates.vrf");
1407 let r = verify_source("socrates.vrf", src).unwrap();
1408 assert_eq!(r.status, Status::Conflict);
1409 assert_eq!(r.conflicts.len(), 1);
1410 assert_eq!(
1411 r.conflicts[0].origin.premise.as_deref(),
1412 Some("mortal_xor_immortal")
1413 );
1414 assert_eq!(r.derived.len(), 3); }
1416
1417 #[test]
1420 fn forward_conflict_carries_a_trace_of_its_facts() {
1421 let r = verify_source(
1422 "<t>",
1423 r#"
1424 FACT x a
1425 FACT x b
1426 PREMISE e:
1427 EXCLUSIVE
1428 x a
1429 x b
1430 CHECK x
1431 "#,
1432 )
1433 .unwrap();
1434 assert_eq!(r.status, Status::Conflict);
1435 let t = &r.conflicts[0].trace;
1436 assert_eq!(t.len(), 2);
1437 assert_eq!(t[0].atom, "x a");
1438 assert_eq!(t[0].value, Value::True);
1439 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1440 assert!(r.unsat_core.is_empty());
1441 }
1442
1443 #[test]
1444 fn derivation_chain_is_traced_back_to_the_root_fact() {
1445 let src = include_str!("../../../docs/examples/socrates.vrf");
1447 let r = verify_source("socrates.vrf", src).unwrap();
1448 let t = &r.conflicts[0].trace;
1449 assert_eq!(t.len(), 5);
1452 assert_eq!(t[0].atom, "socrates is human");
1453 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1454 let mortal = t.iter().find(|s| s.atom == "socrates is mortal").unwrap();
1455 match &mortal.reason {
1456 TraceReason::Derived { from, .. } => {
1457 assert_eq!(from, &vec![String::from("socrates is living")]);
1458 }
1459 _ => panic!("mortal should be derived, not asserted"),
1460 }
1461 }
1462
1463 #[test]
1464 fn direct_fact_contradiction_has_no_trace() {
1465 let r = verify_source("<t>", "FACT x a\nNOT x a\nCHECK x\n").unwrap();
1466 assert_eq!(r.status, Status::Conflict);
1467 assert!(r.conflicts[0].trace.is_empty());
1468 }
1469
1470 #[test]
1471 fn jointly_unsatisfiable_reports_a_minimal_core() {
1472 let src = r#"
1475 PREMISE one:
1476 ONEOF
1477 x a
1478 x b
1479 PREMISE ac:
1480 WHEN x a
1481 THEN x c
1482 PREMISE bc:
1483 WHEN x b
1484 THEN x c
1485 NOT x c
1486 CHECK x BIDIRECTIONAL
1487 "#;
1488 let r = verify_source("<t>", src).unwrap();
1489 assert_eq!(r.status, Status::Conflict);
1490 assert_eq!(r.conflicts[0].origin.kind, "UNSAT");
1491 assert_eq!(r.unsat_core.len(), 4);
1492 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1493 assert!(labels.contains(&"one"));
1494 assert!(labels.contains(&"x c")); }
1496
1497 #[test]
1498 fn unsat_core_excludes_irrelevant_constructs() {
1499 let src = r#"
1502 PREMISE one:
1503 ONEOF
1504 x a
1505 x b
1506 PREMISE ac:
1507 WHEN x a
1508 THEN x c
1509 PREMISE bc:
1510 WHEN x b
1511 THEN x c
1512 NOT x c
1513 FACT z here
1514 PREMISE noise:
1515 EXCLUSIVE
1516 z here
1517 z gone
1518 CHECK x BIDIRECTIONAL
1519 "#;
1520 let r = verify_source("<t>", src).unwrap();
1521 assert_eq!(r.status, Status::Conflict);
1522 assert_eq!(r.unsat_core.len(), 4);
1523 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1524 assert!(!labels.contains(&"noise"));
1525 assert!(!labels.iter().any(|l| l.contains("here")));
1526 }
1527
1528 #[test]
1529 fn consistent_report_has_empty_core_and_no_trace() {
1530 let r = verify_source("<t>", "FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
1531 assert_eq!(r.status, Status::Consistent);
1532 assert!(r.unsat_core.is_empty());
1533 assert!(r.conflicts.is_empty());
1534 }
1535
1536 #[test]
1539 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
1540 let r = verify_source(
1544 "<t>",
1545 r#"
1546 FACT auth is rolled_back
1547 NOT auth is_rolled_back
1548 CHECK
1549 "#,
1550 )
1551 .unwrap();
1552 assert_eq!(
1553 r.status,
1554 Status::Consistent,
1555 "hint must not change the verdict"
1556 );
1557 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
1558 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1559 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
1560 }
1561
1562 #[test]
1563 fn hint_flags_case_only_difference() {
1564 let r = verify_source("<t>", "FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
1565 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1566 }
1567
1568 #[test]
1569 fn hint_flags_single_char_typo_same_subject() {
1570 let r = verify_source("<t>", "FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
1572 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1573 }
1574
1575 #[test]
1576 fn no_hint_for_short_distinct_atoms() {
1577 let r = verify_source("<t>", "FACT x a\nNOT x b\nCHECK\n").unwrap();
1579 assert!(r.hints.is_empty(), "{:?}", r.hints);
1580 }
1581
1582 #[test]
1583 fn no_hint_for_distinct_words() {
1584 let r = verify_source("<t>", "FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
1585 assert!(r.hints.is_empty(), "{:?}", r.hints);
1586 }
1587
1588 #[test]
1589 fn russian_case_typo_is_flagged() {
1590 let r = verify_source("<t>", "FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
1592 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1593 }
1594
1595 #[test]
1596 fn russian_single_char_typo_is_flagged() {
1597 let r = verify_source("<t>", "FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
1598 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1599 }
1600
1601 #[test]
1602 fn cjk_one_char_difference_is_not_flagged() {
1603 let r = verify_source("<t>", "FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
1606 assert!(r.hints.is_empty(), "{:?}", r.hints);
1607 }
1608
1609 #[test]
1610 fn cjk_underscore_vs_space_is_flagged() {
1611 let r = verify_source("<t>", "FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
1614 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1615 }
1616}