1use nibli_protocol::{LogicalTerm, ProofRule, ProofTrace};
11
12use crate::fact::humanize_fact;
13use crate::register::Register;
14
15pub(crate) const NAF_NOTE: &str =
19 "[Note: result depends on negation-as-failure (closed-world assumption)]";
20
21pub(crate) const CWA_FALSE_NOTE: &str =
25 "[Note: FALSE is closed-world — not derivable from the KB, not a proof of the negation]";
26
27#[derive(Clone, Debug, PartialEq)]
30pub struct RenderedNode {
31 pub icon: &'static str,
32 pub label: String,
33 pub css_class: &'static str,
34 pub holds: bool,
35 pub is_leaf: bool,
36 pub inline: bool,
39 pub children: Vec<RenderedNode>,
40}
41
42pub fn render_proof(trace: &ProofTrace, _register: Register) -> RenderedNode {
44 build_node(trace, trace.root)
45}
46
47pub(crate) fn build_node(trace: &ProofTrace, idx: u32) -> RenderedNode {
48 let Some(step) = trace.steps.get(idx as usize) else {
49 return RenderedNode {
50 icon: "?",
51 label: format!("[invalid step index {idx}]"),
52 css_class: "proof-failed",
53 holds: false,
54 is_leaf: true,
55 inline: false,
56 children: Vec::new(),
57 };
58 };
59 let is_ref = matches!(step.rule, ProofRule::ProofRef { .. });
62 RenderedNode {
63 icon: icon(&step.rule),
64 label: label(&step.rule),
65 css_class: css_class(&step.rule),
66 holds: step.holds,
67 is_leaf: step.children.is_empty(),
68 inline: is_ref,
69 children: if is_ref {
70 Vec::new()
71 } else {
72 step.children
73 .iter()
74 .map(|&c| build_node(trace, c))
75 .collect()
76 },
77 }
78}
79
80pub fn render_proof_text(trace: &ProofTrace, register: Register) -> String {
82 render_proof_text_indented(trace, register, 0)
83}
84
85pub fn render_proof_text_indented(
87 trace: &ProofTrace,
88 _register: Register,
89 base_indent: usize,
90) -> String {
91 let mut out = String::new();
92 if trace.naf_dependent {
93 out.push_str(NAF_NOTE);
94 out.push('\n');
95 }
96 if trace.cwa_false {
97 out.push_str(CWA_FALSE_NOTE);
98 out.push('\n');
99 }
100 format_step(trace, trace.root, base_indent, &mut out);
101 out
102}
103
104fn format_step(trace: &ProofTrace, idx: u32, indent: usize, out: &mut String) {
105 let Some(step) = trace.steps.get(idx as usize) else {
106 for _ in 0..indent {
107 out.push_str(" ");
108 }
109 out.push_str(&format!("[invalid step index {idx}]\n"));
110 return;
111 };
112 for _ in 0..indent {
113 out.push_str(" ");
114 }
115 out.push_str(&trace_display(&step.rule, step.holds));
116 out.push('\n');
117 for &child in &step.children {
118 format_step(trace, child, indent + 1, out);
119 }
120}
121
122pub fn icon(rule: &ProofRule) -> &'static str {
126 match rule {
127 ProofRule::Conjunction => "∧",
128 ProofRule::DisjunctionCheck { .. } | ProofRule::DisjunctionIntro { .. } => "∨",
129 ProofRule::Negation => "¬",
130 ProofRule::ModalPassthrough { .. } => "◷",
131 ProofRule::ExistsWitness { .. } | ProofRule::ExistsFailed => "∃",
132 ProofRule::ForallVacuous
133 | ProofRule::ForallVerified { .. }
134 | ProofRule::ForallCounterexample { .. } => "∀",
135 ProofRule::CountResult { .. } => "#",
136 ProofRule::PredicateCheck { .. } | ProofRule::ComputeCheck { .. } => "⊢",
137 ProofRule::Asserted { .. } => "▣",
138 ProofRule::Derived { .. } => "⊢",
139 ProofRule::ProofRef { .. } => "↑",
140 ProofRule::EqualitySubstitution { .. } => "≡",
141 ProofRule::RuleAttemptFailed { .. } => "✗",
142 ProofRule::PredicateNotFound { .. } => "?",
143 }
144}
145
146pub fn css_class(rule: &ProofRule) -> &'static str {
148 match rule {
149 ProofRule::Asserted { .. } => "proof-asserted",
150 ProofRule::Derived { .. } => "proof-derived",
151 ProofRule::ProofRef { .. } => "proof-ref",
152 ProofRule::ExistsWitness { .. } | ProofRule::ModalPassthrough { .. } => "proof-exists",
153 ProofRule::ExistsFailed | ProofRule::ForallCounterexample { .. } => "proof-failed",
154 ProofRule::Negation => "proof-negation",
155 ProofRule::PredicateCheck { .. } | ProofRule::ComputeCheck { .. } => "proof-check",
156 ProofRule::Conjunction => "proof-conjunction",
157 ProofRule::DisjunctionCheck { .. } | ProofRule::DisjunctionIntro { .. } => "proof-derived",
158 ProofRule::ForallVacuous | ProofRule::ForallVerified { .. } => "proof-exists",
159 ProofRule::CountResult { .. } => "proof-check",
160 ProofRule::EqualitySubstitution { .. } => "proof-derived",
161 ProofRule::RuleAttemptFailed { .. } | ProofRule::PredicateNotFound { .. } => "proof-failed",
162 }
163}
164
165pub fn label(rule: &ProofRule) -> String {
167 match rule {
168 ProofRule::Conjunction => "Conjunction".to_string(),
169 ProofRule::DisjunctionCheck { detail } => format!("Disjunction Check: {detail}"),
170 ProofRule::DisjunctionIntro { side } => format!("Disjunction Intro: {side}"),
171 ProofRule::Negation => "Negation".to_string(),
172 ProofRule::ModalPassthrough { kind } => kind.to_string(),
173 ProofRule::ExistsWitness { var, term } => {
174 format!("Witness: {} = {}", var, term.display())
175 }
176 ProofRule::ExistsFailed => "No witness found".to_string(),
177 ProofRule::ForallVacuous => "Vacuously true".to_string(),
178 ProofRule::ForallVerified { entities } => {
179 let names: Vec<String> = entities.iter().map(LogicalTerm::display).collect();
180 format!("Verified: [{}]", names.join(", "))
181 }
182 ProofRule::ForallCounterexample { entity } => {
183 format!("Counterexample: {}", entity.display())
184 }
185 ProofRule::CountResult { expected, actual } => {
186 format!("Count: expected {expected}, got {actual}")
187 }
188 ProofRule::PredicateCheck { method, detail } => {
189 format!("Predicate ({method}): {}", humanize_fact(detail))
190 }
191 ProofRule::ComputeCheck { method, detail } => {
192 format!("Compute ({method}): {}", humanize_fact(detail))
193 }
194 ProofRule::Asserted { fact } => format!("Asserted: {}", humanize_fact(fact)),
195 ProofRule::Derived { label, fact } => {
196 format!(
197 "Derived ({}): {}",
198 humanize_rule_label(label),
199 humanize_fact(fact)
200 )
201 }
202 ProofRule::ProofRef { fact } => format!("(proved above): {}", humanize_fact(fact)),
203 ProofRule::EqualitySubstitution {
204 original,
205 equality_facts,
206 substituted,
207 } => format!(
208 "Equality: {} via {} → {}",
209 humanize_fact(original),
210 equality_facts,
211 humanize_fact(substituted)
212 ),
213 ProofRule::RuleAttemptFailed {
214 rule_label,
215 failed_condition,
216 } => format!(
217 "Rule failed ({}): condition {} not satisfied",
218 humanize_rule_label(rule_label),
219 humanize_fact(failed_condition)
220 ),
221 ProofRule::PredicateNotFound { predicate } => {
222 format!("Not found: {}", humanize_fact(predicate))
223 }
224 }
225}
226
227pub fn trace_display(rule: &ProofRule, result: bool) -> String {
229 let tag = if result { "TRUE" } else { "FALSE" };
230 match rule {
231 ProofRule::Conjunction => format!("Conjunction -> {tag}"),
232 ProofRule::DisjunctionCheck { detail } => format!("Disjunction (check: {detail}) -> {tag}"),
233 ProofRule::DisjunctionIntro { side } => format!("Disjunction ({side}) -> {tag}"),
234 ProofRule::Negation => {
235 if result {
236 format!("Negation [NAF] -> {tag}")
237 } else {
238 format!("Negation -> {tag}")
239 }
240 }
241 ProofRule::ModalPassthrough { kind } => format!("Modal ({kind}) -> {tag}"),
242 ProofRule::ExistsWitness { var, term } => {
243 format!("Exists: {} = {} -> {}", var, term.trace_display(), tag)
244 }
245 ProofRule::ExistsFailed => format!("Exists: no witness -> {tag}"),
246 ProofRule::ForallVacuous => format!("ForAll: vacuous (empty domain) -> {tag}"),
247 ProofRule::ForallVerified { entities } => {
248 let names: Vec<String> = entities.iter().map(LogicalTerm::trace_display).collect();
249 format!("ForAll: verified [{}] -> {}", names.join(", "), tag)
250 }
251 ProofRule::ForallCounterexample { entity } => {
252 format!(
253 "ForAll: counterexample {} -> {}",
254 entity.trace_display(),
255 tag
256 )
257 }
258 ProofRule::CountResult { expected, actual } => {
259 format!("Count: expected={expected}, actual={actual} -> {tag}")
260 }
261 ProofRule::PredicateCheck { method, detail } => {
262 format!("{}: {} -> {}", method, humanize_fact(detail), tag)
263 }
264 ProofRule::ComputeCheck { method, detail } => {
265 format!("Compute ({}): {} -> {}", method, humanize_fact(detail), tag)
266 }
267 ProofRule::Asserted { fact } => format!("Fact: {} -> {}", humanize_fact(fact), tag),
268 ProofRule::Derived { label, fact } => {
269 format!(
270 "Rule ({}): {} -> {}",
271 humanize_rule_label(label),
272 humanize_fact(fact),
273 tag
274 )
275 }
276 ProofRule::ProofRef { fact } => format!("(see above): {} -> {}", humanize_fact(fact), tag),
277 ProofRule::EqualitySubstitution {
278 original,
279 equality_facts,
280 substituted,
281 } => format!(
282 "Equality: {} via {} → {} -> {}",
283 humanize_fact(original),
284 equality_facts,
285 humanize_fact(substituted),
286 tag
287 ),
288 ProofRule::RuleAttemptFailed {
289 rule_label,
290 failed_condition,
291 } => format!(
292 "Rule failed ({}): {} not satisfied -> {}",
293 humanize_rule_label(rule_label),
294 humanize_fact(failed_condition),
295 tag
296 ),
297 ProofRule::PredicateNotFound { predicate } => {
298 format!("Not found: {} -> {}", humanize_fact(predicate), tag)
299 }
300 }
301}
302
303pub(crate) fn humanize_rule_label(label: &str) -> String {
308 if let Some((lhs, rhs)) = label.split_once(" → ") {
309 format!(
310 "{} → {}",
311 collapse_role_predicates(lhs),
312 collapse_role_predicates(rhs)
313 )
314 } else {
315 label.to_string()
316 }
317}
318
319fn collapse_role_predicates(s: &str) -> String {
321 let parts: Vec<&str> = s.split(" ∧ ").collect();
322 let bases: Vec<&str> = parts
323 .iter()
324 .filter(|p| !p.contains("_x"))
325 .copied()
326 .collect();
327 if bases.is_empty() {
328 if let Some(first) = parts.first()
329 && let Some(underscore) = first.rfind('_')
330 {
331 return first[..underscore].to_string();
332 }
333 s.to_string()
334 } else {
335 bases.join(" ∧ ")
336 }
337}
338
339#[cfg(test)]
344mod tests {
345 use super::*;
346 use nibli_protocol::ProofStep;
347
348 fn trace(rule: ProofRule, holds: bool) -> ProofTrace {
349 ProofTrace {
350 steps: vec![ProofStep {
351 rule,
352 holds,
353 children: vec![],
354 }],
355 root: 0,
356 naf_dependent: false,
357 cwa_false: false,
358 }
359 }
360
361 #[test]
362 fn asserted_flat_fact_renders_args() {
363 let t = trace(
365 ProofRule::Asserted {
366 fact: "dog_x1(sk_2, adam)".to_string(),
367 },
368 true,
369 );
370 assert_eq!(
371 render_proof_text(&t, Register::Spec),
372 "Fact: dog.dog(adam) -> TRUE\n"
373 );
374 }
375
376 #[test]
377 fn rendered_node_carries_metadata() {
378 let t = trace(
379 ProofRule::Derived {
380 label: "dog ∧ gerku_x1 → animal ∧ danlu_x1".to_string(),
381 fact: "animal(adam)".to_string(),
382 },
383 true,
384 );
385 let node = render_proof(&t, Register::Spec);
386 assert_eq!(node.icon, "⊢");
387 assert_eq!(node.css_class, "proof-derived");
388 assert!(node.holds);
389 assert!(node.is_leaf);
390 assert_eq!(node.label, "Derived (dog → animal): animal(adam)");
391 }
392
393 #[test]
394 fn naf_note_prefixes_text() {
395 let mut t = trace(ProofRule::Negation, true);
396 t.naf_dependent = true;
397 let out = render_proof_text(&t, Register::Spec);
398 assert!(out.starts_with("[Note: result depends on negation-as-failure"));
399 assert!(out.contains("Negation [NAF] -> TRUE"));
400 }
401}