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
53use elenchus_compiler::{AtomId, AtomKey, Clause, Compiled, Lit, Origin, Value};
54pub use elenchus_compiler::{CompileError, MemoryResolver, Resolver, compile, compile_source};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum V3 {
59 True,
61 False,
63 Unknown,
65}
66
67impl V3 {
68 fn not(self) -> V3 {
70 match self {
71 V3::True => V3::False,
72 V3::False => V3::True,
73 V3::Unknown => V3::Unknown,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Status {
81 Consistent,
83 Underdetermined,
86 Warning,
88 Conflict,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Conflict {
95 pub origin: Origin,
97 pub atoms: Vec<String>,
99 pub trace: Vec<TraceStep>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct TraceStep {
111 pub atom: String,
113 pub value: Value,
115 pub reason: TraceReason,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum TraceReason {
122 Asserted(Origin),
124 Derived {
126 origin: Origin,
128 from: Vec<String>,
130 },
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct Warning {
136 pub origin: Origin,
138 pub blocked_by: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Derived {
145 pub atom: String,
147 pub value: Value,
149 pub origin: Origin,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct Report {
156 pub status: Status,
158 pub conflicts: Vec<Conflict>,
160 pub warnings: Vec<Warning>,
162 pub derived: Vec<Derived>,
164 pub underdetermined: Option<String>,
167 pub unsat_core: Vec<CoreItem>,
172 pub retract: Vec<CoreItem>,
180 pub hints: Vec<SimilarAtoms>,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct SimilarAtoms {
192 pub a: String,
194 pub b: String,
196 pub reason: &'static str,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq)]
202pub struct CoreItem {
203 pub origin: Origin,
205 pub label: String,
207}
208
209impl Report {
210 pub fn exit_code(&self) -> i32 {
212 match self.status {
213 Status::Conflict => 2,
214 Status::Underdetermined | Status::Warning => 1,
215 Status::Consistent => 0,
216 }
217 }
218
219 pub fn to_json(&self) -> String {
222 use core::fmt::Write as _;
223 let mut s = String::new();
224 let _ = write!(s, "{{\"status\":");
225 json_str(status_name(self.status), &mut s);
226 let _ = write!(s, ",\"exit_code\":{}", self.exit_code());
227
228 s.push_str(",\"conflicts\":[");
229 for (i, c) in self.conflicts.iter().enumerate() {
230 if i > 0 {
231 s.push(',');
232 }
233 json_origin(&c.origin, &mut s);
234 s.push_str(",\"atoms\":");
235 json_array(&c.atoms, &mut s);
236 s.push_str(",\"trace\":[");
237 for (j, step) in c.trace.iter().enumerate() {
238 if j > 0 {
239 s.push(',');
240 }
241 json_trace_step(step, &mut s);
242 }
243 s.push_str("]}");
244 }
245 s.push_str("],\"warnings\":[");
246 for (i, w) in self.warnings.iter().enumerate() {
247 if i > 0 {
248 s.push(',');
249 }
250 json_origin(&w.origin, &mut s);
251 s.push_str(",\"blocked_by\":");
252 json_array(&w.blocked_by, &mut s);
253 s.push('}');
254 }
255 s.push_str("],\"derived\":[");
256 for (i, d) in self.derived.iter().enumerate() {
257 if i > 0 {
258 s.push(',');
259 }
260 s.push('{');
261 json_origin_fields(&d.origin, &mut s);
262 s.push_str(",\"atom\":");
263 json_str(&d.atom, &mut s);
264 let _ = write!(s, ",\"value\":{}", matches!(d.value, Value::True));
265 s.push('}');
266 }
267 s.push_str("],\"underdetermined\":");
268 match &self.underdetermined {
269 Some(atom) => json_str(atom, &mut s),
270 None => s.push_str("null"),
271 }
272 s.push_str(",\"unsat_core\":[");
273 for (i, it) in self.unsat_core.iter().enumerate() {
274 if i > 0 {
275 s.push(',');
276 }
277 json_origin(&it.origin, &mut s);
278 s.push_str(",\"label\":");
279 json_str(&it.label, &mut s);
280 s.push('}');
281 }
282 s.push_str("],\"retract\":[");
283 for (i, it) in self.retract.iter().enumerate() {
284 if i > 0 {
285 s.push(',');
286 }
287 json_origin(&it.origin, &mut s);
288 s.push_str(",\"label\":");
289 json_str(&it.label, &mut s);
290 s.push('}');
291 }
292 s.push_str("],\"hints\":[");
293 for (i, h) in self.hints.iter().enumerate() {
294 if i > 0 {
295 s.push(',');
296 }
297 s.push_str("{\"a\":");
298 json_str(&h.a, &mut s);
299 s.push_str(",\"b\":");
300 json_str(&h.b, &mut s);
301 s.push_str(",\"reason\":");
302 json_str(h.reason, &mut s);
303 s.push('}');
304 }
305 s.push_str("]}");
306 s
307 }
308}
309
310fn json_trace_step(step: &TraceStep, out: &mut String) {
312 use core::fmt::Write as _;
313 out.push_str("{\"atom\":");
314 json_str(&step.atom, out);
315 let _ = write!(out, ",\"value\":{}", matches!(step.value, Value::True));
316 match &step.reason {
317 TraceReason::Asserted(o) => {
318 out.push_str(",\"how\":\"asserted\",");
319 json_origin_fields(o, out);
320 out.push_str(",\"from\":[]");
321 }
322 TraceReason::Derived { origin, from } => {
323 out.push_str(",\"how\":\"derived\",");
324 json_origin_fields(origin, out);
325 out.push_str(",\"from\":");
326 json_array(from, out);
327 }
328 }
329 out.push('}');
330}
331
332fn status_name(s: Status) -> &'static str {
333 match s {
334 Status::Consistent => "CONSISTENT",
335 Status::Underdetermined => "UNDERDETERMINED",
336 Status::Warning => "WARNING",
337 Status::Conflict => "CONFLICT",
338 }
339}
340
341fn json_str(value: &str, out: &mut String) {
343 use core::fmt::Write as _;
344 out.push('"');
345 for ch in value.chars() {
346 match ch {
347 '"' => out.push_str("\\\""),
348 '\\' => out.push_str("\\\\"),
349 '\n' => out.push_str("\\n"),
350 '\r' => out.push_str("\\r"),
351 '\t' => out.push_str("\\t"),
352 c if (c as u32) < 0x20 => {
353 let _ = write!(out, "\\u{:04x}", c as u32);
354 }
355 c => out.push(c),
356 }
357 }
358 out.push('"');
359}
360
361fn json_array(items: &[String], out: &mut String) {
363 out.push('[');
364 for (i, item) in items.iter().enumerate() {
365 if i > 0 {
366 out.push(',');
367 }
368 json_str(item, out);
369 }
370 out.push(']');
371}
372
373fn json_origin_fields(o: &Origin, out: &mut String) {
375 use core::fmt::Write as _;
376 out.push_str("\"premise\":");
377 match &o.premise {
378 Some(name) => json_str(name, out),
379 None => out.push_str("null"),
380 }
381 out.push_str(",\"kind\":");
382 json_str(o.kind, out);
383 out.push_str(",\"source\":");
384 json_str(&o.source, out);
385 let _ = write!(out, ",\"line\":{}", o.line);
386}
387
388fn json_origin(o: &Origin, out: &mut String) {
390 out.push('{');
391 json_origin_fields(o, out);
392}
393
394fn label(c: &Compiled, a: AtomId) -> String {
396 let k = &c.atoms[a as usize];
397 match &k.object {
398 Some(o) => alloc::format!("{} {} {}", k.subject, k.predicate, o),
399 None => alloc::format!("{} {}", k.subject, k.predicate),
400 }
401}
402
403fn lit_value(model: &[V3], l: &Lit) -> V3 {
405 let v = model[l.atom as usize];
406 if l.negated { v.not() } else { v }
407}
408
409fn conjunction(model: &[V3], lits: &[Lit]) -> V3 {
411 let mut result = V3::True;
412 for l in lits {
413 match lit_value(model, l) {
414 V3::False => return V3::False,
415 V3::Unknown => result = V3::Unknown,
416 V3::True => {}
417 }
418 }
419 result
420}
421
422enum ClauseEval {
424 Violated,
426 Satisfied,
428 Blocked(Vec<AtomId>),
430}
431
432fn eval_clause(model: &[V3], clause: &Clause) -> ClauseEval {
435 let mut any_false = false;
436 let mut all_true = true;
437 let mut blocked = Vec::new();
438 for l in &clause.lits {
439 match lit_value(model, l) {
440 V3::False => {
441 any_false = true;
442 all_true = false;
443 }
444 V3::Unknown => {
445 all_true = false;
446 blocked.push(l.atom);
447 }
448 V3::True => {}
449 }
450 }
451 if all_true {
452 ClauseEval::Violated
453 } else if any_false {
454 ClauseEval::Satisfied
455 } else {
456 ClauseEval::Blocked(blocked)
457 }
458}
459
460#[derive(Clone)]
463enum AtomReason {
464 Asserted(Origin),
466 Derived { origin: Origin, from: Vec<AtomId> },
468}
469
470struct RawConflict {
474 origin: Origin,
475 atoms: Vec<String>,
476 cause: Vec<AtomId>,
477}
478
479struct Eval<'a> {
481 c: &'a Compiled,
482 model: Vec<V3>,
483 reason: Vec<Option<AtomReason>>,
486 conflicts: Vec<RawConflict>,
487 warnings: Vec<Warning>,
488 derived: Vec<Derived>,
489 unsat_core: Vec<CoreItem>,
491}
492
493impl<'a> Eval<'a> {
494 fn new(c: &'a Compiled) -> Self {
495 Eval {
496 c,
497 model: vec![V3::Unknown; c.atoms.len()],
498 reason: vec![None; c.atoms.len()],
499 conflicts: Vec::new(),
500 warnings: Vec::new(),
501 derived: Vec::new(),
502 unsat_core: Vec::new(),
503 }
504 }
505
506 fn label(&self, a: AtomId) -> String {
507 label(self.c, a)
508 }
509
510 fn seed_facts(&mut self) {
512 let c = self.c;
513 let n = c.atoms.len();
514 let mut t: Vec<Option<Origin>> = vec![None; n];
515 let mut f: Vec<Option<Origin>> = vec![None; n];
516 for fact in &c.facts {
517 let slot = match fact.value {
518 Value::True => &mut t,
519 Value::False => &mut f,
520 };
521 if slot[fact.atom as usize].is_none() {
522 slot[fact.atom as usize] = Some(fact.origin.clone());
523 }
524 }
525 for a in 0..n {
526 match (&t[a], &f[a]) {
527 (Some(o), Some(_)) => {
528 self.model[a] = V3::True;
529 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
530 self.conflicts.push(RawConflict {
531 origin: o.clone(),
532 atoms: vec![alloc::format!(
533 "{} (asserted both TRUE and FALSE)",
534 self.label(a as AtomId)
535 )],
536 cause: Vec::new(),
537 });
538 }
539 (Some(o), None) => {
540 self.model[a] = V3::True;
541 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
542 }
543 (None, Some(o)) => {
544 self.model[a] = V3::False;
545 self.reason[a] = Some(AtomReason::Asserted(o.clone()));
546 }
547 (None, None) => {}
548 }
549 }
550 }
551
552 fn saturate_rules(&mut self) {
554 let c = self.c;
555 loop {
556 let mut changed = false;
557 for r in &c.rules {
558 if conjunction(&self.model, &r.antecedent) != V3::True {
559 continue; }
561 for cl in &r.consequent {
562 let target = if cl.negated { V3::False } else { V3::True };
563 match self.model[cl.atom as usize] {
564 V3::Unknown => {
565 self.model[cl.atom as usize] = target;
566 self.reason[cl.atom as usize] = Some(AtomReason::Derived {
567 origin: r.origin.clone(),
568 from: r.antecedent.iter().map(|l| l.atom).collect(),
569 });
570 changed = true;
571 self.derived.push(Derived {
572 atom: self.label(cl.atom),
573 value: if cl.negated {
574 Value::False
575 } else {
576 Value::True
577 },
578 origin: r.origin.clone(),
579 });
580 }
581 v if v == target => {}
582 _ => {
583 let mut cause: Vec<AtomId> =
587 r.antecedent.iter().map(|l| l.atom).collect();
588 cause.push(cl.atom);
589 self.conflicts.push(RawConflict {
590 origin: r.origin.clone(),
591 atoms: vec![alloc::format!(
592 "{} (derived value contradicts a known fact)",
593 self.label(cl.atom)
594 )],
595 cause,
596 });
597 }
598 }
599 }
600 }
601 if !changed {
602 break;
603 }
604 }
605 }
606
607 fn check_premises(&mut self) {
609 let c = self.c;
610 for clause in &c.clauses {
611 match eval_clause(&self.model, clause) {
612 ClauseEval::Violated => self.conflicts.push(RawConflict {
613 origin: clause.origin.clone(),
614 atoms: clause.lits.iter().map(|l| self.label(l.atom)).collect(),
615 cause: clause.lits.iter().map(|l| l.atom).collect(),
616 }),
617 ClauseEval::Satisfied => {}
618 ClauseEval::Blocked(unknowns) if clause.origin.kind == "PREMISE" => {
621 self.warnings.push(Warning {
622 origin: clause.origin.clone(),
623 blocked_by: unknowns.iter().map(|a| self.label(*a)).collect(),
624 });
625 }
626 ClauseEval::Blocked(_) => {}
627 }
628 }
629 }
630
631 fn build_trace(&self, causes: &[AtomId]) -> Vec<TraceStep> {
637 let mut visited = vec![false; self.c.atoms.len()];
638 let mut out = Vec::new();
639 for &a in causes {
640 self.trace_dfs(a, &mut visited, &mut out);
641 }
642 out
643 }
644
645 fn trace_dfs(&self, a: AtomId, visited: &mut [bool], out: &mut Vec<TraceStep>) {
646 if visited[a as usize] {
647 return;
648 }
649 visited[a as usize] = true;
650 let value = match v3_to_value(self.model[a as usize]) {
651 Some(v) => v,
652 None => return, };
654 let reason = match &self.reason[a as usize] {
655 Some(AtomReason::Asserted(o)) => TraceReason::Asserted(o.clone()),
656 Some(AtomReason::Derived { origin, from }) => {
657 for &f in from {
658 self.trace_dfs(f, visited, out); }
660 TraceReason::Derived {
661 origin: origin.clone(),
662 from: from.iter().map(|&f| self.label(f)).collect(),
663 }
664 }
665 None => return,
666 };
667 out.push(TraceStep {
668 atom: self.label(a),
669 value,
670 reason,
671 });
672 }
673
674 fn backward_pass(&mut self) -> Option<String> {
681 if !self.c.checks.iter().any(|ch| ch.bidirectional) {
682 return None;
683 }
684 let (cnf, project) = build_cnf(self.c);
685 let found = sat::models(&cnf, &project, 2);
686 match found.len() {
687 0 if self.conflicts.is_empty() => {
688 self.unsat_core = minimal_unsat_core(self.c);
689 self.conflicts.push(RawConflict {
690 origin: Origin {
691 source: String::from("<system>"),
692 line: 0,
693 premise: None,
694 kind: "UNSAT",
695 },
696 atoms: vec![String::from(
697 "the premises and facts are jointly unsatisfiable",
698 )],
699 cause: Vec::new(),
700 });
701 None
702 }
703 n if n >= 2 => {
704 let (m0, m1) = (&found[0], &found[1]);
705 project
706 .iter()
707 .find(|&&v| m0[v as usize] != m1[v as usize])
708 .map(|&v| self.label(v))
709 .or_else(|| Some(String::from("a free atom")))
710 }
711 _ => None,
712 }
713 }
714
715 fn finish(mut self) -> Report {
717 let underdetermined = self.backward_pass();
718 self.conflicts.sort_by_key(|c| key(&c.origin));
719 self.warnings.sort_by_key(|w| key(&w.origin));
720 let status = if !self.conflicts.is_empty() {
721 Status::Conflict
722 } else if underdetermined.is_some() {
723 Status::Underdetermined
724 } else if !self.warnings.is_empty() {
725 Status::Warning
726 } else {
727 Status::Consistent
728 };
729 let conflicts: Vec<Conflict> = self
732 .conflicts
733 .iter()
734 .map(|rc| Conflict {
735 origin: rc.origin.clone(),
736 atoms: rc.atoms.clone(),
737 trace: self.build_trace(&rc.cause),
738 })
739 .collect();
740 Report {
741 status,
742 conflicts,
743 warnings: self.warnings,
744 derived: self.derived,
745 underdetermined,
746 unsat_core: self.unsat_core,
747 retract: Vec::new(), hints: Vec::new(), }
750 }
751}
752
753pub fn solve(c: &Compiled) -> Report {
756 let mut e = Eval::new(c);
757 e.seed_facts();
758 e.saturate_rules();
759 e.check_premises();
760 let mut report = e.finish();
761 if report.status == Status::Conflict {
767 let retract = retract_assumptions(c);
768 if !retract.is_empty() {
769 report.unsat_core = Vec::new();
770 report.retract = retract;
771 }
772 }
773 report.hints = similar_atom_pairs(c);
776 report
777}
778
779fn retract_assumptions(c: &Compiled) -> Vec<CoreItem> {
795 if !c.facts.iter().any(|f| f.soft) {
796 return Vec::new();
797 }
798 let all = constructs(c);
799 let is_soft: Vec<bool> = (0..all.len())
801 .map(|i| i < c.facts.len() && c.facts[i].soft)
802 .collect();
803
804 let hard_only: Vec<bool> = is_soft.iter().map(|&s| !s).collect();
807 if !subset_is_sat(c.atoms.len(), &all, &hard_only) {
808 return Vec::new();
809 }
810 let mut active = vec![true; all.len()];
812 if subset_is_sat(c.atoms.len(), &all, &active) {
813 return Vec::new();
814 }
815 for i in 0..all.len() {
817 if active[i] && is_soft[i] {
818 active[i] = false;
819 if subset_is_sat(c.atoms.len(), &all, &active) {
820 active[i] = true; }
822 }
823 }
824 let mut core: Vec<CoreItem> = (0..all.len())
825 .filter(|&i| active[i] && is_soft[i])
826 .map(|i| {
827 let f = &c.facts[i];
828 let label = if matches!(f.value, Value::False) {
830 alloc::format!("NOT {}", label(c, f.atom))
831 } else {
832 label(c, f.atom)
833 };
834 CoreItem {
835 origin: f.origin.clone(),
836 label,
837 }
838 })
839 .collect();
840 core.sort_by_key(|it| key(&it.origin));
841 core
842}
843
844fn similar_atom_pairs(c: &Compiled) -> Vec<SimilarAtoms> {
864 let folded: Vec<Vec<char>> = c.atoms.iter().map(fold_atom).collect();
865 let cased: Vec<bool> = folded.iter().map(|f| is_cased_alphabetic(f)).collect();
866 let mut out = Vec::new();
867 for i in 0..c.atoms.len() {
868 for j in (i + 1)..c.atoms.len() {
869 if let Some(reason) = atoms_look_similar(
870 &c.atoms[i],
871 &folded[i],
872 cased[i],
873 &c.atoms[j],
874 &folded[j],
875 cased[j],
876 ) {
877 out.push(SimilarAtoms {
878 a: label(c, i as AtomId),
879 b: label(c, j as AtomId),
880 reason,
881 });
882 }
883 }
884 }
885 out
886}
887
888fn fold_atom(k: &AtomKey) -> Vec<char> {
892 let mut raw = String::new();
893 raw.push_str(&k.subject);
894 raw.push(' ');
895 raw.push_str(&k.predicate);
896 if let Some(o) = &k.object {
897 raw.push(' ');
898 raw.push_str(o);
899 }
900 let mut out: Vec<char> = Vec::new();
901 let mut prev_space = false;
902 for ch in raw.chars() {
903 if ch == '_' || ch.is_whitespace() {
904 if !prev_space && !out.is_empty() {
905 out.push(' ');
906 prev_space = true;
907 }
908 } else {
909 for lc in ch.to_lowercase() {
910 out.push(lc);
911 }
912 prev_space = false;
913 }
914 }
915 if out.last() == Some(&' ') {
916 out.pop();
917 }
918 out
919}
920
921fn is_cased_alphabetic(folded: &[char]) -> bool {
928 folded.iter().all(|&c| c == ' ' || c.is_lowercase())
929}
930
931fn atoms_look_similar(
934 ka: &AtomKey,
935 fa: &[char],
936 cased_a: bool,
937 kb: &AtomKey,
938 fb: &[char],
939 cased_b: bool,
940) -> Option<&'static str> {
941 if fa == fb {
943 return Some("same name up to case, '_', or spaces");
944 }
945 if !cased_a || !cased_b || ka.subject != kb.subject {
950 return None;
951 }
952 if fa.len().abs_diff(fb.len()) > 1 {
953 return None; }
955 let min_len = fa.len().min(fb.len());
956 if min_len >= 5 && levenshtein(fa, fb) == 1 {
957 return Some("looks like a one-character typo of each other");
958 }
959 None
960}
961
962fn levenshtein(a: &[char], b: &[char]) -> usize {
964 let mut prev: Vec<usize> = (0..=b.len()).collect();
965 let mut cur = vec![0usize; b.len() + 1];
966 for (i, &ca) in a.iter().enumerate() {
967 cur[0] = i + 1;
968 for (j, &cb) in b.iter().enumerate() {
969 let cost = usize::from(ca != cb);
970 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
971 }
972 core::mem::swap(&mut prev, &mut cur);
973 }
974 prev[b.len()]
975}
976
977fn build_cnf(c: &Compiled) -> (sat::Cnf, Vec<sat::Var>) {
982 use sat::SatLit;
983 let mut cnf = sat::Cnf::new(c.atoms.len());
984 let mut constrained = vec![false; c.atoms.len()];
985 let mark = |a: AtomId, constrained: &mut [bool]| constrained[a as usize] = true;
986
987 for clause in &c.clauses {
989 let lits = clause
990 .lits
991 .iter()
992 .map(|l| {
993 mark(l.atom, &mut constrained);
994 SatLit::new(l.atom, l.negated)
995 })
996 .collect();
997 cnf.add_clause(lits);
998 }
999 for r in &c.rules {
1001 for cons in &r.consequent {
1002 let mut lits: Vec<SatLit> = r
1003 .antecedent
1004 .iter()
1005 .map(|a| {
1006 mark(a.atom, &mut constrained);
1007 SatLit::new(a.atom, a.negated)
1008 })
1009 .collect();
1010 mark(cons.atom, &mut constrained);
1011 lits.push(SatLit::new(cons.atom, !cons.negated));
1012 cnf.add_clause(lits);
1013 }
1014 }
1015 for f in &c.facts {
1017 let lit = match f.value {
1018 Value::True => SatLit::positive(f.atom),
1019 Value::False => SatLit::negative(f.atom),
1020 };
1021 cnf.add_clause(vec![lit]);
1022 }
1023
1024 let project = (0..c.atoms.len() as AtomId)
1025 .filter(|&a| constrained[a as usize])
1026 .collect();
1027 (cnf, project)
1028}
1029
1030fn v3_to_value(v: V3) -> Option<Value> {
1032 match v {
1033 V3::True => Some(Value::True),
1034 V3::False => Some(Value::False),
1035 V3::Unknown => None,
1036 }
1037}
1038
1039struct Construct {
1042 origin: Origin,
1043 label: String,
1044 clauses: Vec<Vec<sat::SatLit>>,
1045}
1046
1047fn same_origin(a: &Origin, b: &Origin) -> bool {
1049 a.source == b.source && a.line == b.line && a.premise == b.premise && a.kind == b.kind
1050}
1051
1052fn constructs(c: &Compiled) -> Vec<Construct> {
1056 use sat::SatLit;
1057 let mut out: Vec<Construct> = Vec::new();
1058
1059 for f in &c.facts {
1060 let lit = match f.value {
1061 Value::True => SatLit::positive(f.atom),
1062 Value::False => SatLit::negative(f.atom),
1063 };
1064 out.push(Construct {
1065 origin: f.origin.clone(),
1066 label: label(c, f.atom),
1067 clauses: vec![vec![lit]],
1068 });
1069 }
1070
1071 let mut premises: Vec<Construct> = Vec::new();
1072 for clause in &c.clauses {
1073 let lits: Vec<SatLit> = clause
1074 .lits
1075 .iter()
1076 .map(|l| SatLit::new(l.atom, l.negated))
1077 .collect();
1078 match premises
1079 .iter_mut()
1080 .find(|k| same_origin(&k.origin, &clause.origin))
1081 {
1082 Some(k) => k.clauses.push(lits),
1083 None => premises.push(Construct {
1084 label: clause.origin.premise.clone().unwrap_or_default(),
1085 origin: clause.origin.clone(),
1086 clauses: vec![lits],
1087 }),
1088 }
1089 }
1090 out.extend(premises);
1091
1092 for r in &c.rules {
1093 let clauses = r
1094 .consequent
1095 .iter()
1096 .map(|cons| {
1097 let mut lits: Vec<SatLit> = r
1098 .antecedent
1099 .iter()
1100 .map(|a| SatLit::new(a.atom, a.negated))
1101 .collect();
1102 lits.push(SatLit::new(cons.atom, !cons.negated));
1103 lits
1104 })
1105 .collect();
1106 out.push(Construct {
1107 label: r.origin.premise.clone().unwrap_or_default(),
1108 origin: r.origin.clone(),
1109 clauses,
1110 });
1111 }
1112 out
1113}
1114
1115fn subset_is_sat(num_vars: usize, all: &[Construct], active: &[bool]) -> bool {
1117 let mut cnf = sat::Cnf::new(num_vars);
1118 for (k, &keep) in all.iter().zip(active) {
1119 if keep {
1120 for cl in &k.clauses {
1121 cnf.add_clause(cl.clone());
1122 }
1123 }
1124 }
1125 sat::solve(&cnf).is_some()
1126}
1127
1128fn candidate_via_assumptions(c: &Compiled, all: &[Construct]) -> Vec<bool> {
1134 let base = c.atoms.len();
1135 let mut cnf = sat::Cnf::new(base + all.len());
1136 let sel = |i: usize| (base + i) as sat::Var;
1137 for (i, k) in all.iter().enumerate() {
1138 let s_neg = sat::SatLit::negative(sel(i));
1139 for cl in &k.clauses {
1140 let mut lits = Vec::with_capacity(cl.len() + 1);
1141 lits.push(s_neg);
1142 lits.extend_from_slice(cl);
1143 cnf.add_clause(lits);
1144 }
1145 }
1146 let assumptions: Vec<sat::SatLit> = (0..all.len())
1147 .map(|i| sat::SatLit::positive(sel(i)))
1148 .collect();
1149 let mut active = vec![false; all.len()];
1150 match sat::solve_assuming(&cnf, &assumptions) {
1151 sat::Solved::Unsat(core) => {
1152 for lit in core {
1153 let v = lit.var() as usize;
1154 if v >= base {
1155 active[v - base] = true;
1156 }
1157 }
1158 }
1159 sat::Solved::Sat(_) => active.iter_mut().for_each(|a| *a = true),
1163 }
1164 active
1165}
1166
1167fn minimal_unsat_core(c: &Compiled) -> Vec<CoreItem> {
1173 let all = constructs(c);
1174 let mut active = candidate_via_assumptions(c, &all);
1175 for i in 0..all.len() {
1176 if active[i] {
1177 active[i] = false;
1178 if subset_is_sat(c.atoms.len(), &all, &active) {
1179 active[i] = true; }
1181 }
1182 }
1183 let mut core: Vec<CoreItem> = all
1184 .iter()
1185 .zip(&active)
1186 .filter(|&(_, &keep)| keep)
1187 .map(|(k, _)| CoreItem {
1188 origin: k.origin.clone(),
1189 label: k.label.clone(),
1190 })
1191 .collect();
1192 core.sort_by_key(|it| key(&it.origin));
1193 core
1194}
1195
1196fn key(o: &Origin) -> (String, u32) {
1198 (o.source.clone(), o.line)
1199}
1200
1201pub fn verify_source(name: &str, src: &str) -> Result<Report, CompileError> {
1203 Ok(solve(&compile_source(name, src)?))
1204}
1205
1206pub fn verify<R: Resolver>(root: &str, resolver: &R) -> Result<Report, CompileError> {
1208 Ok(solve(&compile(root, resolver)?))
1209}
1210
1211impl fmt::Display for Status {
1214 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1215 f.write_str(match self {
1216 Status::Consistent => "CONSISTENT",
1217 Status::Underdetermined => "UNDERDETERMINED",
1218 Status::Warning => "WARNING",
1219 Status::Conflict => "CONFLICT",
1220 })
1221 }
1222}
1223
1224fn premise_tag(o: &Origin) -> String {
1226 let name = o.premise.as_deref().unwrap_or("-");
1227 alloc::format!("{} ({}) [{}:{}]", name, o.kind, o.source, o.line)
1228}
1229
1230fn trace_line(step: &TraceStep) -> String {
1232 let v = match step.value {
1233 Value::True => "TRUE",
1234 Value::False => "FALSE",
1235 };
1236 match &step.reason {
1237 TraceReason::Asserted(o) => {
1238 alloc::format!(
1239 "{} = {} [{} {}:{}]",
1240 step.atom,
1241 v,
1242 o.kind,
1243 o.source,
1244 o.line
1245 )
1246 }
1247 TraceReason::Derived { origin, from } => alloc::format!(
1248 "{} = {} from {} ({}) [{}:{}] <= {}",
1249 step.atom,
1250 v,
1251 origin.premise.as_deref().unwrap_or("-"),
1252 origin.kind,
1253 origin.source,
1254 origin.line,
1255 from.join(", ")
1256 ),
1257 }
1258}
1259
1260mod indent {
1266 pub const ROOT: usize = 0;
1268 pub const SECTION: usize = 2;
1271 pub const ITEM: usize = 6;
1273 pub const NESTED: usize = 8;
1275}
1276
1277struct ReportWriter<'a, 'b> {
1280 f: &'a mut fmt::Formatter<'b>,
1281}
1282
1283impl ReportWriter<'_, '_> {
1284 fn line(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1286 write!(self.f, "{:width$}{}", "", args, width = indent)?;
1287 self.f.write_str("\n")
1288 }
1289
1290 fn tail(&mut self, indent: usize, args: fmt::Arguments<'_>) -> fmt::Result {
1293 write!(self.f, "{:width$}{}", "", args, width = indent)
1294 }
1295}
1296
1297macro_rules! emit {
1301 ($out:expr, $indent:expr, $($arg:tt)*) => {
1302 $out.line($indent, format_args!($($arg)*))
1303 };
1304}
1305
1306impl fmt::Display for Report {
1307 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1308 use indent::{ITEM, NESTED, ROOT, SECTION};
1309 let mut out = ReportWriter { f };
1310
1311 emit!(out, ROOT, "RESULT: {}", self.status)?;
1312
1313 if !self.retract.is_empty() {
1317 emit!(out, SECTION, "RETRACT your FACTs and PREMISEs are fine.")?;
1321 emit!(
1322 out,
1323 ITEM,
1324 "But these ASSUME guesses cannot all be true together."
1325 )?;
1326 emit!(out, ITEM, "Remove or flip ONE of them, then check again:")?;
1327 for it in &self.retract {
1328 emit!(
1329 out,
1330 ITEM,
1331 "ASSUME {} [{}:{}]",
1332 it.label,
1333 it.origin.source,
1334 it.origin.line
1335 )?;
1336 }
1337 } else {
1338 for c in &self.conflicts {
1339 emit!(out, SECTION, "CONFLICT {}", premise_tag(&c.origin))?;
1340 for a in &c.atoms {
1341 emit!(out, ITEM, "{}", a)?;
1342 }
1343 if !c.trace.is_empty() {
1344 emit!(out, ITEM, "why:")?;
1345 for step in &c.trace {
1346 emit!(out, NESTED, "{}", trace_line(step))?;
1347 }
1348 }
1349 }
1350 if !self.unsat_core.is_empty() {
1351 emit!(
1352 out,
1353 SECTION,
1354 "CORE smallest jointly-unsatisfiable set ({}):",
1355 self.unsat_core.len()
1356 )?;
1357 for it in &self.unsat_core {
1358 let name = if it.label.is_empty() { "-" } else { &it.label };
1359 emit!(
1360 out,
1361 NESTED,
1362 "{} ({}) [{}:{}]",
1363 name,
1364 it.origin.kind,
1365 it.origin.source,
1366 it.origin.line
1367 )?;
1368 }
1369 }
1370 }
1371
1372 for w in &self.warnings {
1373 emit!(out, SECTION, "WARNING {}", premise_tag(&w.origin))?;
1374 emit!(out, ITEM, "blocked by: {}", w.blocked_by.join(", "))?;
1375 }
1376 if let Some(atom) = &self.underdetermined {
1377 emit!(out, SECTION, "UNDERDETERMINED an alternative model exists")?;
1378 emit!(out, ITEM, "pin it down: add FACT {atom} or NOT {atom}")?;
1379 }
1380 for d in &self.derived {
1381 let v = match d.value {
1382 Value::True => "TRUE",
1383 Value::False => "FALSE",
1384 };
1385 emit!(
1386 out,
1387 SECTION,
1388 "DERIVED {} = {} from {}",
1389 d.atom,
1390 v,
1391 premise_tag(&d.origin)
1392 )?;
1393 }
1394 for h in &self.hints {
1395 emit!(
1396 out,
1397 SECTION,
1398 "HINT possible typo — '{}' and '{}' look like the same atom ({})",
1399 h.a,
1400 h.b,
1401 h.reason
1402 )?;
1403 }
1404
1405 let underdetermined = usize::from(self.status == Status::Underdetermined);
1406 emit!(
1407 out,
1408 ROOT,
1409 "SUMMARY: {} conflicts, {} underdetermined, {} warnings, {} derived",
1410 self.conflicts.len(),
1411 underdetermined,
1412 self.warnings.len(),
1413 self.derived.len()
1414 )?;
1415 out.tail(ROOT, format_args!("EXIT_CODE: {}", self.exit_code()))
1416 }
1417}
1418
1419#[cfg(test)]
1420mod tests {
1421 use super::*;
1422
1423 #[test]
1424 fn clean_consistent() {
1425 let r = verify_source("<t>", "FACT x a\nCHECK x\n").unwrap();
1426 assert_eq!(r.status, Status::Consistent);
1427 assert!(r.conflicts.is_empty() && r.warnings.is_empty());
1428 }
1429
1430 #[test]
1431 fn fact_contradiction_is_conflict() {
1432 let r = verify_source("<t>", "FACT x a\nNOT x a\n").unwrap();
1433 assert_eq!(r.status, Status::Conflict);
1434 assert_eq!(r.conflicts.len(), 1);
1435 }
1436
1437 #[test]
1438 fn exclusive_violation_is_conflict() {
1439 let src = include_str!("../../../docs/examples/conflict.vrf");
1440 let r = verify_source("conflict.vrf", src).unwrap();
1441 assert_eq!(r.status, Status::Conflict);
1442 assert_eq!(
1443 r.conflicts[0].origin.premise.as_deref(),
1444 Some("fly_xor_swim")
1445 );
1446 assert_eq!(r.conflicts[0].atoms.len(), 2);
1447 }
1448
1449 #[test]
1450 fn exclusive_with_unknown_is_consistent_not_warning() {
1451 let r = verify_source("<t>", "FACT A has flying\nPREMISE e:\n EXCLUSIVE\n A has flying\n A has swimming\n").unwrap();
1453 assert_eq!(r.status, Status::Consistent);
1454 assert!(r.warnings.is_empty());
1455 }
1456
1457 #[test]
1458 fn implication_missing_consequent_is_warning() {
1459 let r = verify_source(
1461 "<t>",
1462 r#"
1463 FACT A has flying
1464 PREMISE w:
1465 WHEN A has flying
1466 THEN A has wing
1467 "#,
1468 )
1469 .unwrap();
1470 assert_eq!(r.status, Status::Warning);
1471 assert_eq!(r.warnings.len(), 1);
1472 assert_eq!(r.warnings[0].blocked_by, vec![String::from("A has wing")]);
1473 }
1474
1475 #[test]
1476 fn implication_satisfied_is_consistent() {
1477 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();
1478 assert_eq!(r.status, Status::Consistent);
1479 }
1480
1481 #[test]
1482 fn implication_violated_is_conflict() {
1483 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();
1485 assert_eq!(r.status, Status::Conflict);
1486 }
1487
1488 #[test]
1489 fn rule_derives_fact() {
1490 let r = verify_source(
1491 "<t>",
1492 r#"
1493 FACT A has flying
1494 RULE o:
1495 WHEN A has flying
1496 THEN A needs oxygen
1497 "#,
1498 )
1499 .unwrap();
1500 assert_eq!(r.status, Status::Consistent);
1501 assert_eq!(r.derived.len(), 1);
1502 assert_eq!(r.derived[0].atom, "A needs oxygen");
1503 }
1504
1505 #[test]
1506 fn rule_derivation_contradiction_is_conflict() {
1507 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();
1509 assert_eq!(r.status, Status::Conflict);
1510 }
1511
1512 #[test]
1513 fn bidirectional_finds_alternative_model_underdetermined() {
1514 let r = verify_source(
1516 "<t>",
1517 r#"
1518 PREMISE e:
1519 EXCLUSIVE
1520 x a
1521 x b
1522 CHECK x BIDIRECTIONAL
1523 "#,
1524 )
1525 .unwrap();
1526 assert_eq!(r.status, Status::Underdetermined);
1527 }
1528
1529 #[test]
1530 fn fact_pins_unique_model_consistent() {
1531 let r = verify_source(
1533 "<t>",
1534 r#"
1535 FACT x a
1536 PREMISE e:
1537 EXCLUSIVE
1538 x a
1539 x b
1540 CHECK x BIDIRECTIONAL
1541 "#,
1542 )
1543 .unwrap();
1544 assert_eq!(r.status, Status::Consistent);
1545 }
1546
1547 #[test]
1548 fn no_bidirectional_skips_backward_pass() {
1549 let r = verify_source(
1551 "<t>",
1552 r#"
1553 PREMISE e:
1554 EXCLUSIVE
1555 x a
1556 x b
1557 CHECK x
1558 "#,
1559 )
1560 .unwrap();
1561 assert_eq!(r.status, Status::Consistent);
1562 }
1563
1564 #[test]
1565 fn creature_example_forward_pass() {
1566 let src = include_str!("../../../docs/examples/creature.vrf");
1567 let r = verify_source("creature.vrf", src).unwrap();
1568 assert_eq!(r.status, Status::Warning);
1571 assert!(r.conflicts.is_empty());
1572 assert_eq!(r.warnings.len(), 2);
1573 assert_eq!(r.derived.len(), 1);
1574 assert_eq!(r.derived[0].atom, "Creature.A needs oxygen");
1575 }
1576
1577 #[test]
1578 fn roles_puzzle_is_uniquely_solved() {
1579 let src = include_str!("../../../docs/examples/roles-puzzle.vrf");
1583 let r = verify_source("roles-puzzle.vrf", src).unwrap();
1584 assert_eq!(r.status, Status::Consistent);
1585 assert!(r.conflicts.is_empty());
1586 assert!(r.underdetermined.is_none());
1587 }
1588
1589 #[test]
1590 fn roles_puzzle_underdetermined_without_a_clue() {
1591 let src = include_str!("../../../docs/examples/roles-puzzle.vrf")
1596 .replace("\r\n", "\n")
1597 .replace("NOT bob is qa\n", "");
1598 let r = verify_source("roles-puzzle.vrf", &src).unwrap();
1599 assert_eq!(r.status, Status::Underdetermined);
1600 }
1601
1602 #[test]
1603 fn socrates_chain_is_a_conflict() {
1604 let src = include_str!("../../../docs/examples/socrates.vrf");
1607 let r = verify_source("socrates.vrf", src).unwrap();
1608 assert_eq!(r.status, Status::Conflict);
1609 assert_eq!(r.conflicts.len(), 1);
1610 assert_eq!(
1611 r.conflicts[0].origin.premise.as_deref(),
1612 Some("mortal_xor_immortal")
1613 );
1614 assert_eq!(r.derived.len(), 3); }
1616
1617 #[test]
1620 fn forward_conflict_carries_a_trace_of_its_facts() {
1621 let r = verify_source(
1622 "<t>",
1623 r#"
1624 FACT x a
1625 FACT x b
1626 PREMISE e:
1627 EXCLUSIVE
1628 x a
1629 x b
1630 CHECK x
1631 "#,
1632 )
1633 .unwrap();
1634 assert_eq!(r.status, Status::Conflict);
1635 let t = &r.conflicts[0].trace;
1636 assert_eq!(t.len(), 2);
1637 assert_eq!(t[0].atom, "x a");
1638 assert_eq!(t[0].value, Value::True);
1639 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1640 assert!(r.unsat_core.is_empty());
1641 }
1642
1643 #[test]
1644 fn derivation_chain_is_traced_back_to_the_root_fact() {
1645 let src = include_str!("../../../docs/examples/socrates.vrf");
1647 let r = verify_source("socrates.vrf", src).unwrap();
1648 let t = &r.conflicts[0].trace;
1649 assert_eq!(t.len(), 5);
1652 assert_eq!(t[0].atom, "socrates is human");
1653 assert!(matches!(t[0].reason, TraceReason::Asserted(_)));
1654 let mortal = t.iter().find(|s| s.atom == "socrates is mortal").unwrap();
1655 match &mortal.reason {
1656 TraceReason::Derived { from, .. } => {
1657 assert_eq!(from, &vec![String::from("socrates is living")]);
1658 }
1659 _ => panic!("mortal should be derived, not asserted"),
1660 }
1661 }
1662
1663 #[test]
1664 fn direct_fact_contradiction_has_no_trace() {
1665 let r = verify_source("<t>", "FACT x a\nNOT x a\nCHECK x\n").unwrap();
1666 assert_eq!(r.status, Status::Conflict);
1667 assert!(r.conflicts[0].trace.is_empty());
1668 }
1669
1670 #[test]
1671 fn jointly_unsatisfiable_reports_a_minimal_core() {
1672 let src = r#"
1675 PREMISE one:
1676 ONEOF
1677 x a
1678 x b
1679 PREMISE ac:
1680 WHEN x a
1681 THEN x c
1682 PREMISE bc:
1683 WHEN x b
1684 THEN x c
1685 NOT x c
1686 CHECK x BIDIRECTIONAL
1687 "#;
1688 let r = verify_source("<t>", src).unwrap();
1689 assert_eq!(r.status, Status::Conflict);
1690 assert_eq!(r.conflicts[0].origin.kind, "UNSAT");
1691 assert_eq!(r.unsat_core.len(), 4);
1692 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1693 assert!(labels.contains(&"one"));
1694 assert!(labels.contains(&"x c")); }
1696
1697 #[test]
1698 fn unsat_core_excludes_irrelevant_constructs() {
1699 let src = r#"
1702 PREMISE one:
1703 ONEOF
1704 x a
1705 x b
1706 PREMISE ac:
1707 WHEN x a
1708 THEN x c
1709 PREMISE bc:
1710 WHEN x b
1711 THEN x c
1712 NOT x c
1713 FACT z here
1714 PREMISE noise:
1715 EXCLUSIVE
1716 z here
1717 z gone
1718 CHECK x BIDIRECTIONAL
1719 "#;
1720 let r = verify_source("<t>", src).unwrap();
1721 assert_eq!(r.status, Status::Conflict);
1722 assert_eq!(r.unsat_core.len(), 4);
1723 let labels: Vec<&str> = r.unsat_core.iter().map(|c| c.label.as_str()).collect();
1724 assert!(!labels.contains(&"noise"));
1725 assert!(!labels.iter().any(|l| l.contains("here")));
1726 }
1727
1728 #[test]
1729 fn consistent_report_has_empty_core_and_no_trace() {
1730 let r = verify_source("<t>", "FACT x a\nCHECK x BIDIRECTIONAL\n").unwrap();
1731 assert_eq!(r.status, Status::Consistent);
1732 assert!(r.unsat_core.is_empty());
1733 assert!(r.conflicts.is_empty());
1734 }
1735
1736 #[test]
1739 fn compatible_assumptions_behave_like_facts() {
1740 let r = verify_source("<t>", "ASSUME rel in_prod\nFACT rel reviewed\nCHECK rel\n").unwrap();
1743 assert_eq!(r.status, Status::Consistent);
1744 assert!(r.retract.is_empty());
1745 assert!(r.conflicts.is_empty());
1746 }
1747
1748 #[test]
1749 fn assume_drives_a_rule_like_a_fact() {
1750 let r = verify_source(
1752 "<t>",
1753 "ASSUME A has flying\nRULE o:\n WHEN A has flying\n THEN A needs oxygen\nCHECK A\n",
1754 )
1755 .unwrap();
1756 assert_eq!(r.status, Status::Consistent);
1757 assert_eq!(r.derived.len(), 1);
1758 assert_eq!(r.derived[0].atom, "A needs oxygen");
1759 }
1760
1761 #[test]
1762 fn clashing_assumptions_yield_conflict_with_a_retract_set() {
1763 let src = r#"
1766 FACT rel reviewed
1767 PREMISE prod_needs_safety:
1768 WHEN rel in_prod
1769 THEN rel has_rollback
1770 OR rel has_feature_flag
1771 ASSUME rel in_prod
1772 ASSUME NOT rel has_rollback
1773 ASSUME NOT rel has_feature_flag
1774 CHECK rel
1775 "#;
1776 let r = verify_source("<t>", src).unwrap();
1777 assert_eq!(r.status, Status::Conflict);
1778 assert_eq!(r.exit_code(), 2);
1779 assert_eq!(r.retract.len(), 3, "{:?}", r.retract);
1781 let labels: Vec<&str> = r.retract.iter().map(|it| it.label.as_str()).collect();
1782 assert!(labels.contains(&"rel in_prod"));
1783 assert!(labels.contains(&"NOT rel has_rollback"));
1784 assert!(labels.contains(&"NOT rel has_feature_flag"));
1785 assert!(r.retract.iter().all(|it| it.origin.kind == "ASSUME"));
1787 let shown = alloc::format!("{r}");
1789 assert!(shown.contains("RETRACT"), "{shown}");
1790 assert!(!shown.contains("CONFLICT "), "{shown}");
1791 }
1792
1793 #[test]
1794 fn assume_vs_fact_retracts_only_the_assumption() {
1795 let r = verify_source("<t>", "FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
1797 assert_eq!(r.status, Status::Conflict);
1798 assert_eq!(r.retract.len(), 1);
1799 assert_eq!(r.retract[0].label, "NOT x a");
1800 assert_eq!(r.retract[0].origin.kind, "ASSUME");
1801 }
1802
1803 #[test]
1804 fn hard_conflict_is_not_blamed_on_assumptions() {
1805 let r = verify_source("<t>", "FACT x a\nNOT x a\nASSUME y b\nCHECK x\n").unwrap();
1808 assert_eq!(r.status, Status::Conflict);
1809 assert!(r.retract.is_empty(), "{:?}", r.retract);
1810 }
1811
1812 #[test]
1813 fn two_assumptions_directly_contradict() {
1814 let r = verify_source("<t>", "ASSUME x a\nASSUME NOT x a\nCHECK x\n").unwrap();
1815 assert_eq!(r.status, Status::Conflict);
1816 assert_eq!(r.retract.len(), 2, "{:?}", r.retract);
1817 }
1818
1819 #[test]
1820 fn assume_retract_is_in_json() {
1821 let r = verify_source("<t>", "FACT x a\nASSUME NOT x a\nCHECK x\n").unwrap();
1822 let j = r.to_json();
1823 assert!(j.contains("\"retract\":["), "{j}");
1824 assert!(j.contains("\"kind\":\"ASSUME\""), "{j}");
1825 assert!(j.contains("NOT x a"), "{j}");
1826 }
1827
1828 #[test]
1831 fn hint_flags_underscore_vs_space_and_is_advisory_only() {
1832 let r = verify_source(
1836 "<t>",
1837 r#"
1838 FACT auth is rolled_back
1839 NOT auth is_rolled_back
1840 CHECK
1841 "#,
1842 )
1843 .unwrap();
1844 assert_eq!(
1845 r.status,
1846 Status::Consistent,
1847 "hint must not change the verdict"
1848 );
1849 assert_eq!(r.exit_code(), 0, "hint must not change the exit code");
1850 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1851 assert!(r.hints[0].reason.contains('_') || r.hints[0].reason.contains("case"));
1852 }
1853
1854 #[test]
1855 fn hint_flags_case_only_difference() {
1856 let r = verify_source("<t>", "FACT Engine has_fuel\nNOT Engine Has_fuel\nCHECK\n").unwrap();
1857 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1858 }
1859
1860 #[test]
1861 fn hint_flags_single_char_typo_same_subject() {
1862 let r = verify_source("<t>", "FACT svc deployed\nNOT svc deployd\nCHECK\n").unwrap();
1864 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1865 }
1866
1867 #[test]
1868 fn no_hint_for_short_distinct_atoms() {
1869 let r = verify_source("<t>", "FACT x a\nNOT x b\nCHECK\n").unwrap();
1871 assert!(r.hints.is_empty(), "{:?}", r.hints);
1872 }
1873
1874 #[test]
1875 fn no_hint_for_distinct_words() {
1876 let r = verify_source("<t>", "FACT p is lead\nNOT p is dev\nNOT p is qa\nCHECK\n").unwrap();
1877 assert!(r.hints.is_empty(), "{:?}", r.hints);
1878 }
1879
1880 #[test]
1881 fn russian_case_typo_is_flagged() {
1882 let r = verify_source("<t>", "FACT кот спит\nNOT Кот спит\nCHECK\n").unwrap();
1884 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1885 }
1886
1887 #[test]
1888 fn russian_single_char_typo_is_flagged() {
1889 let r = verify_source("<t>", "FACT кот пушистый\nNOT кот пушстый\nCHECK\n").unwrap();
1890 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1891 }
1892
1893 #[test]
1894 fn cjk_one_char_difference_is_not_flagged() {
1895 let r = verify_source("<t>", "FACT a 是黑\nNOT a 是白\nCHECK\n").unwrap();
1898 assert!(r.hints.is_empty(), "{:?}", r.hints);
1899 }
1900
1901 #[test]
1902 fn cjk_underscore_vs_space_is_flagged() {
1903 let r = verify_source("<t>", "FACT a 猫_黑\nNOT a 猫 黑\nCHECK\n").unwrap();
1906 assert_eq!(r.hints.len(), 1, "{:?}", r.hints);
1907 }
1908}