Skip to main content

nibli_render/
proof.rs

1//! Proof-trace rendering: ONE walker producing both indented text (CLI/REPL) and
2//! a structured [`RenderedNode`] tree (the Dioxus component path). Keyed on the
3//! wire `nibli_protocol::ProofTrace`, so every host/UI consumer shares it.
4//!
5//! Ported from nibli-protocol's former `trace_display`/`icon`/`label`/`css_class`
6//! family; the only behavioral change is that fact payloads now route through the
7//! fixed flat-form [`crate::fact::humanize_fact`] (the old S-expr humanizer dropped
8//! arguments when fed the `relation(args)` form the trace actually carries).
9
10use nibli_protocol::{LogicalTerm, ProofRule, ProofTrace};
11
12use crate::fact::humanize_fact;
13use crate::register::Register;
14
15/// The closed-world honesty caveat prepended to a proof whose TRUE verdict rests
16/// on negation-as-failure. One definition shared by the verbose and collapsed
17/// text renderers.
18pub(crate) const NAF_NOTE: &str =
19    "[Note: result depends on negation-as-failure (closed-world assumption)]";
20
21/// The closed-world honesty caveat prepended to a proof whose FALSE verdict rests on
22/// the closed-world assumption ("not derivable from the KB"), as opposed to a
23/// numeric/arithmetic FALSE that was genuinely decided. The dual of [`NAF_NOTE`].
24pub(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/// A rendered proof node: everything the text and component renderers need,
28/// computed once. Children are rendered recursively.
29#[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    /// A memoized back-reference (`ProofRef`): the UI renders it inline without
37    /// expanding the already-shown subtree (so its `children` are left empty).
38    pub inline: bool,
39    pub children: Vec<RenderedNode>,
40}
41
42/// Build the structured rendered proof tree from a wire proof trace.
43pub 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    // A ProofRef is a back-reference to an already-rendered subtree; render it
60    // inline and do not re-expand the cached children in the UI tree.
61    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
80/// Render the proof tree as indented text (REPL/CLI), with the NAF note header.
81pub fn render_proof_text(trace: &ProofTrace, register: Register) -> String {
82    render_proof_text_indented(trace, register, 0)
83}
84
85/// As [`render_proof_text`], with a base indentation (nibli-host indents by one).
86pub 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
122// ── Per-rule rendering (ported from nibli-protocol) ──
123
124/// Unicode icon for a proof rule type.
125pub 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
146/// CSS class for color-coding in the UI proof tree.
147pub 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
165/// Human-readable label describing the proof step (UI component form).
166pub 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
227/// Compact textual rendering used in CLI proof traces (`… -> TRUE`).
228pub 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
303// ── Rule-label humanization (ported verbatim) ──
304
305/// Collapse event decomposition predicates in a rule label.
306/// "dog ∧ gerku_x1 ∧ gerku_x2 → animal ∧ danlu_x1 ∧ danlu_x2" → "dog → animal".
307pub(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
319/// Given "dog ∧ gerku_x1 ∧ gerku_x2", return just "dog".
320fn 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// Term rendering (`display` / `trace_display`) lives as inherent methods on the
340// canonical `LogicalTerm` enum (re-exported via `nibli_protocol`); the per-rule
341// renderers above call them directly.
342
343#[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        // The bug this whole change fixes: args must survive.
364        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}