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, 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}
157
158#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct CoreItem {
161 pub origin: Origin,
163 pub label: String,
165}
166
167impl Report {
168 pub fn exit_code(&self) -> i32 {
170 match self.status {
171 Status::Conflict => 2,
172 Status::Underdetermined | Status::Warning => 1,
173 Status::Consistent => 0,
174 }
175 }
176
177 pub fn to_json(&self) -> String {
180 use core::fmt::Write as _;
181 let mut s = String::new();
182 let _ = write!(s, "{{\"status\":");
183 json_str(status_name(self.status), &mut s);
184 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
185
186 s.push_str(",\"conflicts\":[");
187 for (i, c) in self.conflicts.iter().enumerate() {
188 if i > 0 {
189 s.push(',');
190 }
191 json_origin(&c.origin, &mut s);
192 s.push_str(",\"atoms\":");
193 json_array(&c.atoms, &mut s);
194 s.push_str(",\"trace\":[");
195 for (j, step) in c.trace.iter().enumerate() {
196 if j > 0 {
197 s.push(',');
198 }
199 json_trace_step(step, &mut s);
200 }
201 s.push_str("]}");
202 }
203 s.push_str("],\"warnings\":[");
204 for (i, w) in self.warnings.iter().enumerate() {
205 if i > 0 {
206 s.push(',');
207 }
208 json_origin(&w.origin, &mut s);
209 s.push_str(",\"blocked_by\":");
210 json_array(&w.blocked_by, &mut s);
211 s.push('}');
212 }
213 s.push_str("],\"derived\":[");
214 for (i, d) in self.derived.iter().enumerate() {
215 if i > 0 {
216 s.push(',');
217 }
218 s.push('{');
219 json_origin_fields(&d.origin, &mut s);
220 s.push_str(",\"atom\":");
221 json_str(&d.atom, &mut s);
222 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
223 s.push('}');
224 }
225 s.push_str("],\"underdetermined\":");
226 match &self.underdetermined {
227 Some(atom) => json_str(atom, &mut s),
228 None => s.push_str("null"),
229 }
230 s.push_str(",\"unsat_core\":[");
231 for (i, it) in self.unsat_core.iter().enumerate() {
232 if i > 0 {
233 s.push(',');
234 }
235 json_origin(&it.origin, &mut s);
236 s.push_str(",\"label\":");
237 json_str(&it.label, &mut s);
238 s.push('}');
239 }
240 s.push_str("]}");
241 s
242 }
243}
244
245fn json_trace_step(step: &TraceStep, out: &mut String) {
247 use core::fmt::Write as _;
248 out.push_str("{\"atom\":");
249 json_str(&step.atom, out);
250 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
251 match &step.reason {
252 TraceReason::Asserted(o) => {
253 out.push_str(",\"how\":\"asserted\",");
254 json_origin_fields(o, out);
255 out.push_str(",\"from\":[]");
256 }
257 TraceReason::Derived { origin, from } => {
258 out.push_str(",\"how\":\"derived\",");
259 json_origin_fields(origin, out);
260 out.push_str(",\"from\":");
261 json_array(from, out);
262 }
263 }
264 out.push('}');
265}
266
267fn status_name(s: Status) -> &'static str {
268 match s {
269 Status::Consistent => "CONSISTENT",
270 Status::Underdetermined => "UNDERDETERMINED",
271 Status::Warning => "WARNING",
272 Status::Conflict => "CONFLICT",
273 }
274}
275
276fn json_str(value: &str, out: &mut String) {
278 use core::fmt::Write as _;
279 out.push('"');
280 for ch in value.chars() {
281 match ch {
282 '"' => out.push_str("\\\""),
283 '\\' => out.push_str("\\\\"),
284 '\n' => out.push_str("\\n"),
285 '\r' => out.push_str("\\r"),
286 '\t' => out.push_str("\\t"),
287 c if (c as u32) < 0x20 => {
288 let _ = write!(out, "\\u{:04x}", c as u32);
289 }
290 c => out.push(c),
291 }
292 }
293 out.push('"');
294}
295
296fn json_array(items: &[String], out: &mut String) {
298 out.push('[');
299 for (i, item) in items.iter().enumerate() {
300 if i > 0 {
301 out.push(',');
302 }
303 json_str(item, out);
304 }
305 out.push(']');
306}
307
308fn json_origin_fields(o: &Origin, out: &mut String) {
310 use core::fmt::Write as _;
311 out.push_str("\"premise\":");
312 match &o.premise {
313 Some(name) => json_str(name, out),
314 None => out.push_str("null"),
315 }
316 out.push_str(",\"kind\":");
317 json_str(o.kind, out);
318 out.push_str(",\"source\":");
319 json_str(&o.source, out);
320 let _ = write!(out, ",\"line\":{}", o.line);
321}
322
323fn json_origin(o: &Origin, out: &mut String) {
325 out.push('{');
326 json_origin_fields(o, out);
327}
328
329fn label(c: &Compiled, a: AtomId) -> String {
331 let k = &c.atoms[a as usize];
332 match &k.object {
333 Some(o) => alloc::format!("{} {} {}", k.subject, k.predicate, o),
334 None => alloc::format!("{} {}", k.subject, k.predicate),
335 }
336}
337
338fn lit_value(model: &[V3], l: &Lit) -> V3 {
340 let v = model[l.atom as usize];
341 if l.negated { v.not() } else { v }
342}
343
344fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
346 let mut result = V3::True;
347 for l in lits {
348 match lit_value(model, l) {
349 V3::False => return V3::False,
350 V3::Unknown => result = V3::Unknown,
351 V3::True => {}
352 }
353 }
354 result
355}
356
357enum ClauseEval {
359 Violated,
361 Satisfied,
363 Blocked(Vec<AtomId>),
365}
366
367fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
370 let mut any_false = false;
371 let mut all_true = true;
372 let mut blocked = Vec::new();
373 for l in &clause.lits {
374 match lit_value(model, l) {
375 V3::False => {
376 any_false = true;
377 all_true = false;
378 }
379 V3::Unknown => {
380 all_true = false;
381 blocked.push(l.atom);
382 }
383 V3::True => {}
384 }
385 }
386 if all_true {
387 ClauseEval::Violated
388 } else if any_false {
389 ClauseEval::Satisfied
390 } else {
391 ClauseEval::Blocked(blocked)
392 }
393}
394
395#[derive(Clone)]
398enum AtomReason {
399 Asserted(Origin),
401 Derived { origin: Origin, from: Vec<AtomId> },
403}
404
405struct RawConflict {
409 origin: Origin,
410 atoms: Vec<String>,
411 cause: Vec<AtomId>,
412}
413
414struct Eval<'a> {
416 c: &'a Compiled,
417 model: Vec<V3>,
418 reason: Vec<Option<AtomReason>>,
421 conflicts: Vec<RawConflict>,
422 warnings: Vec<Warning>,
423 derived: Vec<Derived>,
424 unsat_core: Vec<CoreItem>,
426}
427
428impl<'a> Eval<'a> {
429 fn new(c: &'a Compiled) -> Self {
430 Eval {
431 c,
432 model: vec![V3::Unknown; c.atoms.len()],
433 reason: vec![None; c.atoms.len()],
434 conflicts: Vec::new(),
435 warnings: Vec::new(),
436 derived: Vec::new(),
437 unsat_core: Vec::new(),
438 }
439 }
440
441 fn label(&self, a: AtomId) -> String {
442 label(self.c, a)
443 }
444
445 fn seed_facts(&mut self) {
447 let c = self.c;
448 let n = c.atoms.len();
449 let mut t: Vec<Option<Origin>> = vec![None; n];
450 let mut f: Vec<Option<Origin>> = vec![None; n];
451 for fact in &c.facts {
452 let slot = match fact.value {
453 Value::True => &mut t,
454 Value::False => &mut f,
455 };
456 if slot[fact.atom as usize].is_none() {
457 slot[fact.atom as usize] = Some(fact.origin.clone());
458 }
459 }
460 for a in 0..n {
461 match (&t[a], &f[a]) {
462 (Some(o), Some(_)) => {
463 self.model[a] = V3::True;
464 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
465 self.conflicts.push(RawConflict {
466 origin: o.clone(),
467 atoms: vec![alloc::format!(
468 "{} (asserted both TRUE and FALSE)",
469 self.label(a as AtomId)
470 )],
471 cause: Vec::new(),
472 });
473 }
474 (Some(o), None) => {
475 self.model[a] = V3::True;
476 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
477 }
478 (None, Some(o)) => {
479 self.model[a] = V3::False;
480 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
481 }
482 (None, None) => {}
483 }
484 }
485 }
486
487 fn saturate_rules(&mut self) {
489 let c = self.c;
490 loop {
491 let mut changed = false;
492 for r in &c.rules {
493 if conjunction(&self.model, &r.antecedent) != V3::True {
494 continue; }
496 for cl in &r.consequent {
497 let target = if cl.negated { V3::False } else { V3::True };
498 match self.model[cl.atom as usize] {
499 V3::Unknown => {
500 self.model[cl.atom as usize] = target;
501 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
502 origin: r.origin.clone(),
503 from: r.antecedent.iter().map(|l| l.atom).collect(),
504 });
505 changed = true;
506 self.derived.push(Derived {
507 atom: self.label(cl.atom),
508 value: if cl.negated {
509 Value::False
510 } else {
511 Value::True
512 },
513 origin: r.origin.clone(),
514 });
515 }
516 v if v == target => {}
517 _ => {
518 let mut cause: Vec<AtomId> =
522 r.antecedent.iter().map(|l| l.atom).collect();
523 cause.push(cl.atom);
524 self.conflicts.push(RawConflict {
525 origin: r.origin.clone(),
526 atoms: vec![alloc::format!(
527 "{} (derived value contradicts a known fact)",
528 self.label(cl.atom)
529 )],
530 cause,
531 });
532 }
533 }
534 }
535 }
536 if !changed {
537 break;
538 }
539 }
540 }
541
542 fn check_premises(&mut self) {
544 let c = self.c;
545 for clause in &c.clauses {
546 match eval_clause(&self.model, clause) {
547 ClauseEval::Violated => self.conflicts.push(RawConflict {
548 origin: clause.origin.clone(),
549 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
550 cause: clause.lits.iter().map(|l| l.atom).collect(),
551 }),
552 ClauseEval::Satisfied => {}
553 ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
556 self.warnings.push(Warning {
557 origin: clause.origin.clone(),
558 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
559 });
560 }
561 ClauseEval::Blocked(_) => {}
562 }
563 }
564 }
565
566 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
572 let mut visited = vec![false; self.c.atoms.len()];
573 let mut out = Vec::new();
574 for &a in causes {
575 self.trace_dfs(a, &mut visited, &mut out);
576 }
577 out
578 }
579
580 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
581 if visited[a as usize] {
582 return;
583 }
584 visited[a as usize] = true;
585 let value = match v3_to_value(self.model[a as usize]) {
586 Some(v) => v,
587 None => return, };
589 let reason = match &self.reason[a as usize] {
590 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
591 Some(AtomReason::Derived { origin, from }) => {
592 for &f in from {
593 self.trace_dfs(f, visited, out); }
595 TraceReason::Derived {
596 origin: origin.clone(),
597 from: from.iter().map(|&f| self.label(f)).collect(),
598 }
599 }
600 None => return,
601 };
602 out.push(TraceStep {
603 atom: self.label(a),
604 value,
605 reason,
606 });
607 }
608
609 fn backward_pass(&mut self) -> Option<String> {
616 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
617 return None;
618 }
619 let (cnf, project) = build_cnf(self.c);
620 let found = sat::models(&cnf, &project, 2);
621 match found.len() {
622 0 if self.conflicts.is_empty() => {
623 self.unsat_core = minimal_unsat_core(self.c);
624 self.conflicts.push(RawConflict {
625 origin: Origin {
626 source: String::from("<system>"),
627 line: 0,
628 premise: None,
629 kind: "UNSAT",
630 },
631 atoms: vec![String::from(
632 "the premises and facts are jointly unsatisfiable",
633 )],
634 cause: Vec::new(),
635 });
636 None
637 }
638 n if n >= 2 => {
639 let (m0, m1) = (&found[0], &found[1]);
640 project
641 .iter()
642 .find(|&&v| m0[v as usize] != m1[v as usize])
643 .map(|&v| self.label(v))
644 .or_else(|| Some(String::from("a free atom")))
645 }
646 _ => None,
647 }
648 }
649
650 fn finish(mut self) -> Report {
652 let underdetermined = self.backward_pass();
653 self.conflicts.sort_by_key(|c| key(&c.origin));
654 self.warnings.sort_by_key(|w| key(&w.origin));
655 let status = if !self.conflicts.is_empty() {
656 Status::Conflict
657 } else if underdetermined.is_some() {
658 Status::Underdetermined
659 } else if !self.warnings.is_empty() {
660 Status::Warning
661 } else {
662 Status::Consistent
663 };
664 let conflicts: Vec<Conflict> = self
667 .conflicts
668 .iter()
669 .map(|rc| Conflict {
670 origin: rc.origin.clone(),
671 atoms: rc.atoms.clone(),
672 trace: self.build_trace(&rc.cause),
673 })
674 .collect();
675 Report {
676 status,
677 conflicts,
678 warnings: self.warnings,
679 derived: self.derived,
680 underdetermined,
681 unsat_core: self.unsat_core,
682 }
683 }
684}
685
686pub fn solve(c: &Compiled) -> Report {
689 let mut e = Eval::new(c);
690 e.seed_facts();
691 e.saturate_rules();
692 e.check_premises();
693 e.finish()
694}
695
696fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
701 use sat::SatLit;
702 let mut cnf = sat::Cnf::new(c.atoms.len());
703 let mut constrained = vec![false; c.atoms.len()];
704 let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
705
706 for clause in &c.clauses {
708 let lits = clause
709 .lits
710 .iter()
711 .map(|l| {
712 mark(l.atom, &mut constrained);
713 SatLit::new(l.atom, l.negated)
714 })
715 .collect();
716 cnf.add_clause(lits);
717 }
718 for r in &c.rules {
720 for cons in &r.consequent {
721 let mut lits: Vec<SatLit> = r
722 .antecedent
723 .iter()
724 .map(|a| {
725 mark(a.atom, &mut constrained);
726 SatLit::new(a.atom, a.negated)
727 })
728 .collect();
729 mark(cons.atom, &mut constrained);
730 lits.push(SatLit::new(cons.atom, !cons.negated));
731 cnf.add_clause(lits);
732 }
733 }
734 for f in &c.facts {
736 let lit = match f.value {
737 Value::True => SatLit::positive(f.atom),
738 Value::False => SatLit::negative(f.atom),
739 };
740 cnf.add_clause(vec![lit]);
741 }
742
743 let project = (0..c.atoms.len() as AtomId)
744 .filter(|&a| constrained[a as usize])
745 .collect();
746 (cnf, project)
747}
748
749fn v3_to_value(v: V3) -> Option<Value> {
751 match v {
752 V3::True => Some(Value::True),
753 V3::False => Some(Value::False),
754 V3::Unknown => None,
755 }
756}
757
758struct Construct {
761 origin: Origin,
762 label: String,
763 clauses: Vec<Vec<sat::SatLit>>,
764}
765
766fn same_origin(a: &Origin, b: &Origin) -> bool {
768 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
769}
770
771fn constructs(c: &Compiled) -> Vec<Construct> {
775 use sat::SatLit;
776 let mut out: Vec<Construct> = Vec::new();
777
778 for f in &c.facts {
779 let lit = match f.value {
780 Value::True => SatLit::positive(f.atom),
781 Value::False => SatLit::negative(f.atom),
782 };
783 out.push(Construct {
784 origin: f.origin.clone(),
785 label: label(c, f.atom),
786 clauses: vec![vec![lit]],
787 });
788 }
789
790 let mut premises: Vec<Construct> = Vec::new();
791 for clause in &c.clauses {
792 let lits: Vec<SatLit> = clause
793 .lits
794 .iter()
795 .map(|l| SatLit::new(l.atom, l.negated))
796 .collect();
797 match premises
798 .iter_mut()
799 .find(|k| same_origin(&k.origin, &clause.origin))
800 {
801 Some(k) => k.clauses.push(lits),
802 None => premises.push(Construct {
803 label: clause.origin.premise.clone().unwrap_or_default(),
804 origin: clause.origin.clone(),
805 clauses: vec![lits],
806 }),
807 }
808 }
809 out.extend(premises);
810
811 for r in &c.rules {
812 let clauses = r
813 .consequent
814 .iter()
815 .map(|cons| {
816 let mut lits: Vec<SatLit> = r
817 .antecedent
818 .iter()
819 .map(|a| SatLit::new(a.atom, a.negated))
820 .collect();
821 lits.push(SatLit::new(cons.atom, !cons.negated));
822 lits
823 })
824 .collect();
825 out.push(Construct {
826 label: r.origin.premise.clone().unwrap_or_default(),
827 origin: r.origin.clone(),
828 clauses,
829 });
830 }
831 out
832}
833
834fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
836 let mut cnf = sat::Cnf::new(num_vars);
837 for (k, &keep) in all.iter().zip(active) {
838 if keep {
839 for cl in &k.clauses {
840 cnf.add_clause(cl.clone());
841 }
842 }
843 }
844 sat::solve(&cnf).is_some()
845}
846
847fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
852 let all = constructs(c);
853 let mut active = vec![true; all.len()];
854 for i in 0..all.len() {
855 active[i] = false;
856 if subset_is_sat(c.atoms.len(), &all, &active) {
857 active[i] = true; }
859 }
860 let mut core: Vec<CoreItem> = all
861 .iter()
862 .zip(&active)
863 .filter(|&(_, &keep)| keep)
864 .map(|(k, _)| CoreItem {
865 origin: k.origin.clone(),
866 label: k.label.clone(),
867 })
868 .collect();
869 core.sort_by_key(|it| key(&it.origin));
870 core
871}
872
873fn key(o: &Origin) -> (String, u32) {
875 (o.source.clone(), o.line)
876}
877
878pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
880 Ok(solve(&compile_source(name, src)?))
881}
882
883pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
885 Ok(solve(&compile(root, resolver)?))
886}
887
888impl fmt::Display for Status {
891 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
892 f.write_str(match self {
893 Status::Consistent => "CONSISTENT",
894 Status::Underdetermined => "UNDERDETERMINED",
895 Status::Warning => "WARNING",
896 Status::Conflict => "CONFLICT",
897 })
898 }
899}
900
901fn premise_tag(o: &Origin) -> String {
903 let name = o.premise.as_deref().unwrap_or("-");
904 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
905}
906
907fn trace_line(step: &TraceStep) -> String {
909 let v = match step.value {
910 Value::True => "TRUE",
911 Value::False => "FALSE",
912 };
913 match &step.reason {
914 TraceReason::Asserted(o) => {
915 alloc::format!(
916 "{} = {} [{} {}:{}]",
917 step.atom,
918 v,
919 o.kind,
920 o.source,
921 o.line
922 )
923 }
924 TraceReason::Derived { origin, from } => alloc::format!(
925 "{} = {} from {} ({}) [{}:{}] <= {}",
926 step.atom,
927 v,
928 origin.premise.as_deref().unwrap_or("-"),
929 origin.kind,
930 origin.source,
931 origin.line,
932 from.join(", ")
933 ),
934 }
935}
936
937impl fmt::Display for Report {
938 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
939 writeln!(f, "RESULT: {}", self.status)?;
940 for c in &self.conflicts {
941 writeln!(f, " CONFLICT {}", premise_tag(&c.origin))?;
942 for a in &c.atoms {
943 writeln!(f, " {}", a)?;
944 }
945 if !c.trace.is_empty() {
946 writeln!(f, " why:")?;
947 for step in &c.trace {
948 writeln!(f, " {}", trace_line(step))?;
949 }
950 }
951 }
952 if !self.unsat_core.is_empty() {
953 writeln!(
954 f,
955 " CORE smallest jointly-unsatisfiable set ({}):",
956 self.unsat_core.len()
957 )?;
958 for it in &self.unsat_core {
959 let name = if it.label.is_empty() { "-" } else { &it.label };
960 writeln!(
961 f,
962 " {} ({}) [{}:{}]",
963 name, it.origin.kind, it.origin.source, it.origin.line
964 )?;
965 }
966 }
967 for w in &self.warnings {
968 writeln!(f, " WARNING {}", premise_tag(&w.origin))?;
969 writeln!(f, " blocked by: {}", w.blocked_by.join(", "))?;
970 }
971 if let Some(atom) = &self.underdetermined {
972 writeln!(f, " UNDERDETERMINED an alternative model exists")?;
973 writeln!(f, " pin it down: add FACT {atom} or NOT {atom}")?;
974 }
975 for d in &self.derived {
976 let v = match d.value {
977 Value::True => "TRUE",
978 Value::False => "FALSE",
979 };
980 writeln!(
981 f,
982 " DERIVED {} = {} from {}",
983 d.atom,
984 v,
985 premise_tag(&d.origin)
986 )?;
987 }
988 let underdetermined = usize::from(self.status == Status::Underdetermined);
989 writeln!(
990 f,
991 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
992 self.conflicts.len(),
993 underdetermined,
994 self.warnings.len(),
995 self.derived.len()
996 )?;
997 write!(f, "EXIT_CODE: {}", self.exit_code())
998 }
999}
1000
1001#[cfg(test)]
1002mod tests {
1003 use super::*;
1004
1005 #[test]
1006 fn clean_consistent() {
1007 let r = verify_source("<t>", "FACT x a\nCHECK x\n").unwrap();
1008 assert_eq!(r.status, Status::Consistent);
1009 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1010 }
1011
1012 #[test]
1013 fn fact_contradiction_is_conflict() {
1014 let r = verify_source("<t>", "FACT x a\nNOT x a\n").unwrap();
1015 assert_eq!(r.status, Status::Conflict);
1016 assert_eq!(r.conflicts.len(), 1);
1017 }
1018
1019 #[test]
1020 fn exclusive_violation_is_conflict() {
1021 let src = include_str!("../../../docs/examples/conflict.vrf");
1022 let r = verify_source("conflict.vrf", src).unwrap();
1023 assert_eq!(r.status, Status::Conflict);
1024 assert_eq!(
1025 r.conflicts[0].origin.premise.as_deref(),
1026 Some("fly_xor_swim")
1027 );
1028 assert_eq!(r.conflicts[0].atoms.len(), 2);
1029 }
1030
1031 #[test]
1032 fn exclusive_with_unknown_is_consistent_not_warning() {
1033 let r = verify_source("<t>", "FACT A has flying\nPREMISE e:\n EXCLUSIVE\n A has flying\n A has swimming\n").unwrap();
1035 assert_eq!(r.status, Status::Consistent);
1036 assert!(r.warnings.is_empty());
1037 }
1038
1039 #[test]
1040 fn implication_missing_consequent_is_warning() {
1041 let r = verify_source(
1043 "<t>",
1044 "FACT A has flying\nPREMISE w:\n WHEN A has flying\n THEN A has wing\n",
1045 )
1046 .unwrap();
1047 assert_eq!(r.status, Status::Warning);
1048 assert_eq!(r.warnings.len(), 1);
1049 assert_eq!(r.warnings[0].blocked_by, vec![String::from("A has wing")]);
1050 }
1051
1052 #[test]
1053 fn implication_satisfied_is_consistent() {
1054 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();
1055 assert_eq!(r.status, Status::Consistent);
1056 }
1057
1058 #[test]
1059 fn implication_violated_is_conflict() {
1060 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();
1062 assert_eq!(r.status, Status::Conflict);
1063 }
1064
1065 #[test]
1066 fn rule_derives_fact() {
1067 let r = verify_source(
1068 "<t>",
1069 "FACT A has flying\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\n",
1070 )
1071 .unwrap();
1072 assert_eq!(r.status, Status::Consistent);
1073 assert_eq!(r.derived.len(), 1);
1074 assert_eq!(r.derived[0].atom, "A needs oxygen");
1075 }
1076
1077 #[test]
1078 fn rule_derivation_contradiction_is_conflict() {
1079 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();
1081 assert_eq!(r.status, Status::Conflict);
1082 }
1083
1084 #[test]
1085 fn bidirectional_finds_alternative_model_underdetermined() {
1086 let r = verify_source(
1088 "<t>",
1089 "PREMISE e:\n EXCLUSIVE\n x a\n x b\nCHECK x BIDIRECTIONAL\n",
1090 )
1091 .unwrap();
1092 assert_eq!(r.status, Status::Underdetermined);
1093 }
1094
1095 #[test]
1096 fn fact_pins_unique_model_consistent() {
1097 let r = verify_source(
1099 "<t>",
1100 "FACT x a\nPREMISE e:\n EXCLUSIVE\n x a\n x b\nCHECK x BIDIRECTIONAL\n",
1101 )
1102 .unwrap();
1103 assert_eq!(r.status, Status::Consistent);
1104 }
1105
1106 #[test]
1107 fn no_bidirectional_skips_backward_pass() {
1108 let r = verify_source(
1110 "<t>",
1111 "PREMISE e:\n EXCLUSIVE\n x a\n x b\nCHECK x\n",
1112 )
1113 .unwrap();
1114 assert_eq!(r.status, Status::Consistent);
1115 }
1116
1117 #[test]
1118 fn creature_example_forward_pass() {
1119 let src = include_str!("../../../docs/examples/creature.vrf");
1120 let r = verify_source("creature.vrf", src).unwrap();
1121 assert_eq!(r.status, Status::Warning);
1124 assert!(r.conflicts.is_empty());
1125 assert_eq!(r.warnings.len(), 2);
1126 assert_eq!(r.derived.len(), 1);
1127 assert_eq!(r.derived[0].atom, "Creature.A needs oxygen");
1128 }
1129
1130 #[test]
1131 fn roles_puzzle_is_uniquely_solved() {
1132 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1136 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1137 assert_eq!(r.status, Status::Consistent);
1138 assert!(r.conflicts.is_empty());
1139 assert!(r.underdetermined.is_none());
1140 }
1141
1142 #[test]
1143 fn roles_puzzle_underdetermined_without_a_clue() {
1144 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1149 .replace("\r\n", "\n")
1150 .replace("NOT bob is qa\n", "");
1151 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1152 assert_eq!(r.status, Status::Underdetermined);
1153 }
1154
1155 #[test]
1156 fn socrates_chain_is_a_conflict() {
1157 let src = include_str!("../../../docs/examples/socrates.vrf");
1160 let r = verify_source("socrates.vrf", src).unwrap();
1161 assert_eq!(r.status, Status::Conflict);
1162 assert_eq!(r.conflicts.len(), 1);
1163 assert_eq!(
1164 r.conflicts[0].origin.premise.as_deref(),
1165 Some("mortal_xor_immortal")
1166 );
1167 assert_eq!(r.derived.len(), 3); }
1169
1170 #[test]
1173 fn forward_conflict_carries_a_trace_of_its_facts() {
1174 let r = verify_source(
1175 "<t>",
1176 "FACT x a\nFACT x b\nPREMISE e:\n EXCLUSIVE\n x a\n x b\nCHECK x\n",
1177 )
1178 .unwrap();
1179 assert_eq!(r.status, Status::Conflict);
1180 let t = &r.conflicts[0].trace;
1181 assert_eq!(t.len(), 2);
1182 assert_eq!(t[0].atom, "x a");
1183 assert_eq!(t[0].value, Value::True);
1184 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1185 assert!(r.unsat_core.is_empty());
1186 }
1187
1188 #[test]
1189 fn derivation_chain_is_traced_back_to_the_root_fact() {
1190 let src = include_str!("../../../docs/examples/socrates.vrf");
1192 let r = verify_source("socrates.vrf", src).unwrap();
1193 let t = &r.conflicts[0].trace;
1194 assert_eq!(t.len(), 5);
1197 assert_eq!(t[0].atom, "socrates is human");
1198 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1199 let mortal = t.iter().find(|s| s.atom == "socrates is mortal").unwrap();
1200 match &mortal.reason {
1201 TraceReason::Derived { from, .. } => {
1202 assert_eq!(from, &vec![String::from("socrates is living")]);
1203 }
1204 _ => panic!("mortal should be derived, not asserted"),
1205 }
1206 }
1207
1208 #[test]
1209 fn direct_fact_contradiction_has_no_trace() {
1210 let r = verify_source("<t>", "FACT x a\nNOT x a\nCHECK x\n").unwrap();
1211 assert_eq!(r.status, Status::Conflict);
1212 assert!(r.conflicts[0].trace.is_empty());
1213 }
1214
1215 #[test]
1216 fn jointly_unsatisfiable_reports_a_minimal_core() {
1217 let src = "PREMISE one:\n ONEOF\n x a\n x b\nPREMISE ac:\n WHEN x a\n THEN x c\nPREMISE bc:\n WHEN x b\n THEN x c\nNOT x c\nCHECK x BIDIRECTIONAL\n";
1220 let r = verify_source("<t>", src).unwrap();
1221 assert_eq!(r.status, Status::Conflict);
1222 assert_eq!(r.conflicts[0].origin.kind, "UNSAT");
1223 assert_eq!(r.unsat_core.len(), 4);
1224 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1225 assert!(labels.contains(&"one"));
1226 assert!(labels.contains(&"x c")); }
1228
1229 #[test]
1230 fn unsat_core_excludes_irrelevant_constructs() {
1231 let src = "PREMISE one:\n ONEOF\n x a\n x b\nPREMISE ac:\n WHEN x a\n THEN x c\nPREMISE bc:\n WHEN x b\n THEN x c\nNOT x c\nFACT z here\nPREMISE noise:\n EXCLUSIVE\n z here\n z gone\nCHECK x BIDIRECTIONAL\n";
1234 let r = verify_source("<t>", src).unwrap();
1235 assert_eq!(r.status, Status::Conflict);
1236 assert_eq!(r.unsat_core.len(), 4);
1237 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1238 assert!(!labels.contains(&"noise"));
1239 assert!(!labels.iter().any(|l| l.contains("here")));
1240 }
1241
1242 #[test]
1243 fn consistent_report_has_empty_core_and_no_trace() {
1244 let r = verify_source("<t>", "FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
1245 assert_eq!(r.status, Status::Consistent);
1246 assert!(r.unsat_core.is_empty());
1247 assert!(r.conflicts.is_empty());
1248 }
1249}