Skip to main content

nibli_render/
collapse.rs

1//! Macro-logical DAG collapse: a render-only transform from the verbose
2//! Neo-Davidsonian [`ProofTrace`] into a compressed [`RenderedNode`] tree of
3//! surface-level inference steps ("adam is an animal — by the rule: every dog is
4//! an animal — └ adam is a dog (given)"), with the low-level role/event
5//! scaffolding folded into expandable `proof-role-detail` clusters.
6//!
7//! DRY by construction: it emits the SAME [`RenderedNode`] the verbose renderer
8//! and the Dioxus UI already consume (so the UI reuses `RenderedNodeView`
9//! unchanged and only a tiny [`render_node_text`] is added for the text
10//! surfaces), and it reuses the `[Why]`-summary regroup + English helpers
11//! ([`regroup_event_leaves`], [`render_group`], [`rule_to_english`]) — one place
12//! that turns role predicates back into surface facts.
13//!
14//! The transform is TOTAL: anything it cannot cleanly collapse degrades to the
15//! verbose [`build_node`] rendering. It never panics and never fabricates
16//! English (an un-renderable surface falls back to the functional form).
17
18use std::collections::BTreeMap;
19
20use nibli_protocol::{ProofRule, ProofTrace};
21
22use crate::fact::humanize_fact;
23use crate::overlay::DomainGloss;
24use crate::proof::{CWA_FALSE_NOTE, NAF_NOTE, RenderedNode, build_node, humanize_rule_label};
25use crate::register::Register;
26use crate::summary::{
27    LeafKey, fact_to_english, parse_raw_fact, regroup_event_leaves, render_group, rule_to_english,
28};
29use crate::term::{is_event_skolem_arg, role_base, role_index};
30
31/// Recursion backstop (proofs are depth-limited at ~10; this only guards a
32/// malformed trace from unbounded recursion).
33const MAX_DEPTH: usize = 64;
34
35/// Collapse a verbose proof trace into a macro-logical DAG (a [`RenderedNode`]
36/// tree). Render-only; the trace is unchanged.
37pub fn collapse_proof(trace: &ProofTrace, register: Register) -> RenderedNode {
38    if trace.steps.is_empty() {
39        return build_node(trace, trace.root);
40    }
41    let mut steps = collapse_to_macrosteps(trace, register);
42    match steps.len() {
43        1 => step_to_rendered(&steps.pop().unwrap()),
44        0 => build_node(trace, trace.root),
45        _ => {
46            let nodes: Vec<RenderedNode> = steps.iter().map(step_to_rendered).collect();
47            let holds = nodes.iter().all(|n| n.holds);
48            RenderedNode {
49                icon: "∧",
50                label: "all of the following hold".to_string(),
51                css_class: "proof-conjunction",
52                holds,
53                is_leaf: false,
54                inline: false,
55                children: nodes,
56            }
57        }
58    }
59}
60
61/// One structured step of the collapsed macro-logical derivation: an already
62/// INSTANTIATED surface statement (real entities — `varfarin is in danger`), how
63/// it was established, and its immediate premises. This is the single source of
64/// truth both the [`RenderedNode`] tree ([`step_to_rendered`]) and the
65/// plain-English [`crate::summary`] narrative are built from — so the two never
66/// drift, and neither has to re-parse the other's display strings.
67#[derive(Clone)]
68pub(crate) struct MacroStep {
69    pub statement: String,
70    pub kind: MacroKind,
71    pub holds: bool,
72    pub premises: Vec<MacroStep>,
73    /// Foldable role-level scaffolding cluster (UI-only); carried verbatim.
74    pub role_detail: Option<RenderedNode>,
75}
76
77/// How a [`MacroStep`]'s surface statement was established.
78#[derive(Clone)]
79pub(crate) enum MacroKind {
80    Given,
81    /// Derived by a rule; the string is the (relation-only) rule label.
82    Derived(String),
83    /// A computed leaf; the string is the `ComputeCheck` source method, so the
84    /// rendered label can distinguish a LOCAL computation (built-in arithmetic /
85    /// numeric comparison) from a value TRUSTED from the external backend (an
86    /// oracle, not a derivation).
87    Computed(String),
88    Checked,
89    NotDerivable,
90    /// A back-reference to a statement already shown (deduped).
91    Reference,
92    /// A shape the collapse could not phrase: keep the verbose rendering verbatim
93    /// (never fabricate English). The narrative skips these.
94    Raw(Box<RenderedNode>),
95}
96
97/// Build the structured macro-step forest for a whole trace (shared by the
98/// collapsed tree and the `[Why]` summary).
99pub(crate) fn collapse_to_macrosteps(trace: &ProofTrace, register: Register) -> Vec<MacroStep> {
100    if trace.steps.is_empty() {
101        return Vec::new();
102    }
103    let mut seen: Vec<String> = Vec::new();
104    let leaves = collect_goal_leaves(trace, trace.root, 0);
105    collapse_goal_steps(trace, &leaves, register, &mut seen, 0)
106}
107
108/// Collapsed-view label for a computed leaf, by its `ComputeCheck` method.
109/// Surfaces the honesty distinction: a LOCAL computation (built-in arithmetic
110/// `pilji`/`sumji`/`dilcu`, or a `zmadu`/`mleca`/`dunli` numeric comparison) vs a
111/// value TRUSTED from the external backend (an axiom from an oracle, not derived).
112/// Only a SUCCESSFUL backend reply (`backend`) earns the trusted label — an
113/// unavailable backend (`backend_unavailable`) computed nothing, and the edge
114/// methods (`indeterminate`/`build_failed`) keep the bare "computed".
115fn computed_label(method: &str) -> &'static str {
116    match method {
117        "backend" => "computed (trusted backend)",
118        "arithmetic" | "numeric" => "computed (local)",
119        _ => "computed",
120    }
121}
122
123/// Render a structured [`MacroStep`] to the display [`RenderedNode`] — the
124/// labels/icons/classes are byte-for-byte what the old direct builders produced.
125fn step_to_rendered(step: &MacroStep) -> RenderedNode {
126    match &step.kind {
127        MacroKind::Raw(node) => (**node).clone(),
128        MacroKind::Reference => reference_node(step.statement.clone()),
129        MacroKind::Given => macro_leaf(
130            &step.statement,
131            "given",
132            "▣",
133            "proof-asserted",
134            step.holds,
135            step.role_detail.clone(),
136        ),
137        MacroKind::Computed(method) => macro_leaf(
138            &step.statement,
139            computed_label(method),
140            "⊢",
141            "proof-check",
142            step.holds,
143            step.role_detail.clone(),
144        ),
145        MacroKind::Checked => macro_leaf(
146            &step.statement,
147            "checked",
148            "⊢",
149            "proof-check",
150            step.holds,
151            step.role_detail.clone(),
152        ),
153        MacroKind::NotDerivable => macro_leaf(
154            &step.statement,
155            "not derivable",
156            "✗",
157            "proof-failed",
158            false,
159            step.role_detail.clone(),
160        ),
161        MacroKind::Derived(label) => {
162            let just = rule_justification(label);
163            let mut children: Vec<RenderedNode> =
164                step.premises.iter().map(step_to_rendered).collect();
165            if let Some(rd) = &step.role_detail {
166                children.push(rd.clone());
167            }
168            RenderedNode {
169                icon: "⊢",
170                label: format!("{}  [{just}]", step.statement),
171                css_class: "proof-derived",
172                holds: step.holds,
173                is_leaf: children.is_empty(),
174                inline: false,
175                children,
176            }
177        }
178    }
179}
180
181/// As [`collapse_proof`], rendering under a domain-gloss `overlay` (`None` = the
182/// dictionary-fallback default).
183pub fn collapse_proof_with(
184    trace: &ProofTrace,
185    register: Register,
186    overlay: Option<&'static DomainGloss>,
187) -> RenderedNode {
188    crate::overlay::with_overlay(overlay, || collapse_proof(trace, register))
189}
190
191/// The collapsed macro-logical-DAG proof of a whole trace as indented text (the
192/// REPL / server / book view): the closed-world NAF caveat (when the verdict
193/// rests on it), then the collapsed tree. With `include_detail = false` the
194/// role-level clusters are omitted (the clean macro view). One call every text
195/// surface shares — nibli-host, nibli-server, nibli-wasm.
196pub fn render_collapsed_text(
197    trace: &ProofTrace,
198    register: Register,
199    base_indent: usize,
200    include_detail: bool,
201) -> String {
202    let mut out = String::new();
203    if trace.naf_dependent {
204        out.push_str(NAF_NOTE);
205        out.push('\n');
206    }
207    if trace.cwa_false {
208        out.push_str(CWA_FALSE_NOTE);
209        out.push('\n');
210    }
211    let node = collapse_proof(trace, register);
212    out.push_str(&render_node_text(&node, base_indent, include_detail));
213    out
214}
215
216/// As [`render_collapsed_text`], rendering under a domain-gloss `overlay`
217/// (`None` = the dictionary-fallback default).
218pub fn render_collapsed_text_with(
219    trace: &ProofTrace,
220    register: Register,
221    base_indent: usize,
222    include_detail: bool,
223    overlay: Option<&'static DomainGloss>,
224) -> String {
225    crate::overlay::with_overlay(overlay, || {
226        render_collapsed_text(trace, register, base_indent, include_detail)
227    })
228}
229
230/// Render any [`RenderedNode`] tree as indented text (the building block of
231/// [`render_collapsed_text`]). With `include_detail = false`, `proof-role-detail`
232/// clusters are skipped (the clean macro view); with `true`, they render too.
233pub fn render_node_text(node: &RenderedNode, base_indent: usize, include_detail: bool) -> String {
234    let mut out = String::new();
235    write_node_text(node, base_indent, include_detail, &mut out);
236    out
237}
238
239fn write_node_text(node: &RenderedNode, indent: usize, include_detail: bool, out: &mut String) {
240    if !include_detail && node.css_class == "proof-role-detail" {
241        return;
242    }
243    for _ in 0..indent {
244        out.push_str("  ");
245    }
246    if node.inline {
247        out.push_str(&format!("{} {}\n", node.icon, node.label));
248    } else {
249        let tag = if node.holds { "TRUE" } else { "FALSE" };
250        out.push_str(&format!("{} {} -> {}\n", node.icon, node.label, tag));
251    }
252    for child in &node.children {
253        write_node_text(child, indent + 1, include_detail, out);
254    }
255}
256
257// ── the collapse recursion ──
258
259/// Descend the event-decomposition scaffolding (`ExistsWitness` / `Conjunction`
260/// / `ModalPassthrough`) to the role-goal leaves; everything else is a leaf.
261fn collect_goal_leaves(trace: &ProofTrace, idx: u32, depth: usize) -> Vec<u32> {
262    if depth > MAX_DEPTH {
263        return vec![idx];
264    }
265    match trace.steps.get(idx as usize).map(|s| &s.rule) {
266        Some(
267            ProofRule::ExistsWitness { .. }
268            | ProofRule::Conjunction
269            | ProofRule::ModalPassthrough { .. },
270        ) => {
271            let mut out = Vec::new();
272            for &c in &trace.steps[idx as usize].children {
273                out.extend(collect_goal_leaves(trace, c, depth + 1));
274            }
275            out
276        }
277        _ => vec![idx],
278    }
279}
280
281/// Group goal leaves by event into surface-fact macro STEPS (one per distinct
282/// surface fact, first-seen order).
283fn collapse_goal_steps(
284    trace: &ProofTrace,
285    goals: &[u32],
286    register: Register,
287    seen: &mut Vec<String>,
288    depth: usize,
289) -> Vec<MacroStep> {
290    if depth > MAX_DEPTH {
291        return goals
292            .iter()
293            .map(|&g| raw_step(build_node(trace, g)))
294            .collect();
295    }
296    let mut order: Vec<LeafKey> = Vec::new();
297    let mut buckets: BTreeMap<LeafKey, Vec<u32>> = BTreeMap::new();
298    let mut singles: Vec<u32> = Vec::new();
299    for &g in goals {
300        if let Some(key) = goal_event_key(trace, g) {
301            if !buckets.contains_key(&key) {
302                order.push(key.clone());
303            }
304            buckets.entry(key).or_default().push(g);
305        } else {
306            singles.push(g);
307        }
308    }
309    let mut out = Vec::new();
310    for key in &order {
311        out.push(build_macro_step(
312            trace,
313            &buckets[key],
314            register,
315            seen,
316            depth,
317        ));
318    }
319    for &g in &singles {
320        out.push(build_single_step(trace, g, register, seen, depth));
321    }
322    out
323}
324
325/// A leaf macro-step (no premises).
326fn leaf_step(
327    statement: String,
328    kind: MacroKind,
329    holds: bool,
330    role_detail: Option<RenderedNode>,
331) -> MacroStep {
332    MacroStep {
333        statement,
334        kind,
335        holds,
336        premises: Vec::new(),
337        role_detail,
338    }
339}
340
341/// Wrap a pre-rendered (un-phraseable) node as an opaque macro-step.
342fn raw_step(node: RenderedNode) -> MacroStep {
343    let statement = node.label.clone();
344    let holds = node.holds;
345    MacroStep {
346        statement,
347        kind: MacroKind::Raw(Box::new(node)),
348        holds,
349        premises: Vec::new(),
350        role_detail: None,
351    }
352}
353
354/// The (wrapper, base, event) key of a role/event-type goal, or `None` for a
355/// flat / non-event goal (handled as a single).
356fn goal_event_key(trace: &ProofTrace, g: u32) -> Option<LeafKey> {
357    let rule = &trace.steps.get(g as usize)?.rule;
358    let fact = goal_fact(rule)?;
359    let (wrapper, relation, args) = parse_raw_fact(&fact)?;
360    if let (Some(base), Some(_)) = (role_base(&relation), role_index(&relation))
361        && args.len() >= 2
362        && is_event_skolem_arg(&args[0])
363    {
364        return Some((wrapper, base.to_string(), args[0].clone()));
365    }
366    if args.len() == 1 && is_event_skolem_arg(&args[0]) {
367        return Some((wrapper, relation, args[0].clone()));
368    }
369    None
370}
371
372/// The surface fact string a goal step is about, if any.
373fn goal_fact(rule: &ProofRule) -> Option<String> {
374    match rule {
375        ProofRule::Asserted { fact }
376        | ProofRule::Derived { fact, .. }
377        | ProofRule::ProofRef { fact } => Some(fact.clone()),
378        ProofRule::PredicateCheck { detail, .. } | ProofRule::ComputeCheck { detail, .. } => {
379            Some(detail.clone())
380        }
381        ProofRule::PredicateNotFound { predicate } => Some(predicate.clone()),
382        _ => None,
383    }
384}
385
386/// How a surface fact was established (drives the macro label + premises).
387enum GroupKind {
388    Given,
389    Derived(String),
390    /// String is the `ComputeCheck` source method (see [`computed_label`]).
391    Computed(String),
392    Checked,
393    NotDerivable,
394    Reference,
395}
396
397fn classify_group(trace: &ProofTrace, steps: &[u32]) -> GroupKind {
398    let mut derived: Option<String> = None;
399    let mut computed_method: Option<String> = None;
400    let (mut given, mut checked, mut notfound, mut nonref) = (false, false, false, false);
401    for &g in steps {
402        match &trace.steps[g as usize].rule {
403            ProofRule::Derived { label, .. } => {
404                derived = Some(label.clone());
405                nonref = true;
406            }
407            ProofRule::Asserted { .. } => {
408                given = true;
409                nonref = true;
410            }
411            ProofRule::ComputeCheck { method, .. } => {
412                computed_method = Some(method.clone());
413                nonref = true;
414            }
415            ProofRule::PredicateCheck { .. } => {
416                checked = true;
417                nonref = true;
418            }
419            ProofRule::PredicateNotFound { .. }
420            | ProofRule::RuleAttemptFailed { .. }
421            | ProofRule::ExistsFailed => {
422                notfound = true;
423                nonref = true;
424            }
425            ProofRule::ProofRef { .. } => {}
426            _ => nonref = true,
427        }
428    }
429    if let Some(l) = derived {
430        GroupKind::Derived(l)
431    } else if given {
432        GroupKind::Given
433    } else if let Some(m) = computed_method {
434        GroupKind::Computed(m)
435    } else if checked {
436        GroupKind::Checked
437    } else if notfound {
438        GroupKind::NotDerivable
439    } else if !nonref {
440        GroupKind::Reference
441    } else {
442        GroupKind::Given
443    }
444}
445
446/// Build the macro step for one surface fact (a bucket of its role goals).
447fn build_macro_step(
448    trace: &ProofTrace,
449    steps: &[u32],
450    register: Register,
451    seen: &mut Vec<String>,
452    depth: usize,
453) -> MacroStep {
454    let facts: Vec<String> = steps
455        .iter()
456        .filter_map(|&g| goal_fact(&trace.steps[g as usize].rule))
457        .collect();
458    let holds = steps.iter().all(|&g| trace.steps[g as usize].holds);
459    let Some(statement) = surface_statement(&facts, register) else {
460        return raw_step(verbose_group(trace, steps, holds)); // degrade, never fabricate
461    };
462    if seen.contains(&statement) {
463        return leaf_step(statement, MacroKind::Reference, true, None);
464    }
465    seen.push(statement.clone());
466
467    match classify_group(trace, steps) {
468        GroupKind::Reference => leaf_step(statement, MacroKind::Reference, true, None),
469        GroupKind::NotDerivable => leaf_step(
470            statement,
471            MacroKind::NotDerivable,
472            false,
473            role_detail(trace, steps),
474        ),
475        GroupKind::Given => leaf_step(
476            statement,
477            MacroKind::Given,
478            holds,
479            role_detail(trace, steps),
480        ),
481        GroupKind::Computed(method) => leaf_step(
482            statement,
483            MacroKind::Computed(method),
484            holds,
485            role_detail(trace, steps),
486        ),
487        GroupKind::Checked => leaf_step(
488            statement,
489            MacroKind::Checked,
490            holds,
491            role_detail(trace, steps),
492        ),
493        GroupKind::Derived(label) => {
494            let mut cond_leaves: Vec<u32> = Vec::new();
495            for &g in steps {
496                if matches!(trace.steps[g as usize].rule, ProofRule::Derived { .. }) {
497                    for &c in &trace.steps[g as usize].children {
498                        cond_leaves.extend(collect_goal_leaves(trace, c, depth + 1));
499                    }
500                }
501            }
502            let premises = collapse_goal_steps(trace, &cond_leaves, register, seen, depth + 1);
503            MacroStep {
504                statement,
505                kind: MacroKind::Derived(label),
506                holds,
507                premises,
508                role_detail: role_detail(trace, steps),
509            }
510        }
511    }
512}
513
514/// Build a step for a non-event ("flat") goal (a directly-asserted flat fact, a
515/// not-found leaf, a compute check, …). Degrades to a [`build_node`] raw step for
516/// anything it cannot phrase.
517fn build_single_step(
518    trace: &ProofTrace,
519    g: u32,
520    register: Register,
521    seen: &mut Vec<String>,
522    depth: usize,
523) -> MacroStep {
524    let rule = &trace.steps[g as usize].rule;
525    let holds = trace.steps[g as usize].holds;
526    match rule {
527        ProofRule::Asserted { fact } => {
528            let stmt = flat_statement(fact, register);
529            if seen.contains(&stmt) {
530                return leaf_step(stmt, MacroKind::Reference, true, None);
531            }
532            seen.push(stmt.clone());
533            leaf_step(stmt, MacroKind::Given, holds, None)
534        }
535        ProofRule::ProofRef { fact } => leaf_step(
536            flat_statement(fact, register),
537            MacroKind::Reference,
538            true,
539            None,
540        ),
541        ProofRule::PredicateNotFound { predicate } => leaf_step(
542            flat_statement(predicate, register),
543            MacroKind::NotDerivable,
544            false,
545            None,
546        ),
547        ProofRule::ComputeCheck { detail, method } => leaf_step(
548            flat_statement(detail, register),
549            MacroKind::Computed(method.clone()),
550            holds,
551            None,
552        ),
553        ProofRule::PredicateCheck { detail, .. } => leaf_step(
554            flat_statement(detail, register),
555            MacroKind::Checked,
556            holds,
557            None,
558        ),
559        ProofRule::Derived { label, fact } => {
560            let stmt = flat_statement(fact, register);
561            if seen.contains(&stmt) {
562                return leaf_step(stmt, MacroKind::Reference, true, None);
563            }
564            seen.push(stmt.clone());
565            let mut cond_leaves: Vec<u32> = Vec::new();
566            for &c in &trace.steps[g as usize].children {
567                cond_leaves.extend(collect_goal_leaves(trace, c, depth + 1));
568            }
569            let premises = collapse_goal_steps(trace, &cond_leaves, register, seen, depth + 1);
570            MacroStep {
571                statement: stmt,
572                kind: MacroKind::Derived(label.clone()),
573                holds,
574                premises,
575                role_detail: None,
576            }
577        }
578        // Negation / ForallCounterexample / CountResult / … : keep the honest
579        // functional rendering rather than invent English.
580        _ => raw_step(build_node(trace, g)),
581    }
582}
583
584// ── helpers ──
585
586/// Regroup an event's role facts back to one surface English clause.
587fn surface_statement(facts: &[String], register: Register) -> Option<String> {
588    let (groups, flat) = regroup_event_leaves(facts, register);
589    if let Some((key, pm)) = groups.first() {
590        render_group(key.0.as_deref(), &key.1, pm)
591    } else {
592        flat.first().cloned()
593    }
594}
595
596/// A flat (non-event) fact rendered to English, falling back to the functional
597/// `relation(args)` form.
598fn flat_statement(fact: &str, register: Register) -> String {
599    fact_to_english(fact, register).unwrap_or_else(|| humanize_fact(fact))
600}
601
602/// "by the rule: every dog is an animal" (or the functional label when the rule
603/// has no clean English form, e.g. an abstraction conclusion).
604fn rule_justification(label: &str) -> String {
605    let humanized = humanize_rule_label(label);
606    match rule_to_english(&humanized) {
607        Some(e) => format!("by the rule: {e}"),
608        None => format!("by rule: {humanized}"),
609    }
610}
611
612fn macro_leaf(
613    statement: &str,
614    just: &str,
615    icon: &'static str,
616    css_class: &'static str,
617    holds: bool,
618    detail: Option<RenderedNode>,
619) -> RenderedNode {
620    let mut children = Vec::new();
621    if let Some(d) = detail {
622        children.push(d);
623    }
624    RenderedNode {
625        icon,
626        label: format!("{statement}  [{just}]"),
627        css_class,
628        holds,
629        is_leaf: children.is_empty(),
630        inline: false,
631        children,
632    }
633}
634
635fn reference_node(statement: String) -> RenderedNode {
636    RenderedNode {
637        icon: "↑",
638        label: format!("{statement}  (shown above)"),
639        css_class: "proof-ref",
640        holds: true,
641        is_leaf: true,
642        inline: true,
643        children: Vec::new(),
644    }
645}
646
647/// The folded low-level scaffolding for a surface fact: a `proof-role-detail`
648/// cluster wrapping the verbose per-role sub-trees (an expandable `<details>` in
649/// the UI; skipped in the clean collapsed text). Only emitted when role
650/// decomposition actually happened (more than one role step).
651fn role_detail(trace: &ProofTrace, steps: &[u32]) -> Option<RenderedNode> {
652    if steps.len() <= 1 {
653        return None;
654    }
655    let children: Vec<RenderedNode> = steps.iter().map(|&g| build_node(trace, g)).collect();
656    Some(RenderedNode {
657        icon: "▸",
658        label: "role-level detail".to_string(),
659        css_class: "proof-role-detail",
660        holds: true,
661        is_leaf: false,
662        inline: false,
663        children,
664    })
665}
666
667/// Last-resort degradation: keep the verbose sub-tree(s) for a group we cannot
668/// phrase as a surface clause.
669fn verbose_group(trace: &ProofTrace, steps: &[u32], holds: bool) -> RenderedNode {
670    if steps.len() == 1 {
671        return build_node(trace, steps[0]);
672    }
673    RenderedNode {
674        icon: "∧",
675        label: "(detail)".to_string(),
676        css_class: "proof-conjunction",
677        holds,
678        is_leaf: false,
679        inline: false,
680        children: steps.iter().map(|&g| build_node(trace, g)).collect(),
681    }
682}
683
684#[cfg(test)]
685mod tests {
686    use super::*;
687    use nibli_protocol::ProofStep;
688
689    fn step(rule: ProofRule, holds: bool, children: Vec<u32>) -> ProofStep {
690        ProofStep {
691            rule,
692            holds,
693            children,
694        }
695    }
696
697    fn asserted(fact: &str) -> ProofRule {
698        ProofRule::Asserted {
699            fact: fact.to_string(),
700        }
701    }
702
703    fn proofref(fact: &str) -> ProofRule {
704        ProofRule::ProofRef {
705            fact: fact.to_string(),
706        }
707    }
708
709    fn derived(fact: &str) -> ProofRule {
710        ProofRule::Derived {
711            label: "dog ∧ gerku_x1 ∧ gerku_x2 → animal ∧ danlu_x1 ∧ danlu_x2".to_string(),
712            fact: fact.to_string(),
713        }
714    }
715
716    /// The exact ~15-step shape nibli-host captures for `? la .adam. cu animal` over
717    /// `dog(adam)` + `ro lo dog cu animal`: an ExistsWitness over a left-leaning
718    /// Conjunction spine of three per-role `Derived(animal*)` steps, each re-tracing
719    /// the same `dog*` conditions (deduped to ProofRefs after the first). The
720    /// asserted dog event is a BARE Skolem (`sk_2`); the derived animal event is a
721    /// DEPENDENT Skolem (`sk_3(adam)`) — a universal rule's conclusion event
722    /// depends on the quantified individual, exactly as the engine emits.
723    fn syllogism_trace() -> ProofTrace {
724        ProofTrace {
725            steps: vec![
726                step(asserted("dog(sk_2)"), true, vec![]),          // 0
727                step(asserted("dog_x1(sk_2, adam)"), true, vec![]), // 1
728                step(asserted("dog_x2(sk_2, zo'e)"), true, vec![]), // 2
729                step(derived("animal(sk_3(adam))"), true, vec![0, 1, 2]), // 3
730                step(proofref("dog(sk_2)"), true, vec![]),          // 4
731                step(proofref("dog_x1(sk_2, adam)"), true, vec![]), // 5
732                step(proofref("dog_x2(sk_2, zo'e)"), true, vec![]), // 6
733                step(derived("animal_x1(sk_3(adam), adam)"), true, vec![4, 5, 6]), // 7
734                step(proofref("dog(sk_2)"), true, vec![]),          // 8
735                step(proofref("dog_x1(sk_2, adam)"), true, vec![]), // 9
736                step(proofref("dog_x2(sk_2, zo'e)"), true, vec![]), // 10
737                step(derived("animal_x2(sk_3(adam), zo'e)"), true, vec![8, 9, 10]), // 11
738                step(ProofRule::Conjunction, true, vec![3, 7]),     // 12
739                step(ProofRule::Conjunction, true, vec![12, 11]),   // 13
740                step(
741                    ProofRule::ExistsWitness {
742                        var: "_ev0".to_string(),
743                        term: nibli_protocol::LogicalTerm::Constant("sk_3(adam)".to_string()),
744                    },
745                    true,
746                    vec![13],
747                ), // 14
748            ],
749            root: 14,
750            naf_dependent: false,
751            cwa_false: false,
752        }
753    }
754
755    #[test]
756    fn syllogism_collapses_to_conclusion_then_one_given_premise() {
757        let trace = syllogism_trace();
758        let node = collapse_proof(&trace, Register::Spec);
759        // The conclusion is the macro root: a Derived (rule) step.
760        assert_eq!(node.css_class, "proof-derived");
761        assert!(node.holds);
762        assert!(node.label.contains("animal"), "label: {}", node.label);
763        assert!(
764            node.label.contains("by the rule") && node.label.contains("dog"),
765            "label: {}",
766            node.label
767        );
768        // Exactly ONE surface premise (the 3 dog facts + their 6 ProofRef
769        // repeats collapse to a single "is a dog" given), plus the role-detail
770        // cluster.
771        let premises: Vec<&RenderedNode> = node
772            .children
773            .iter()
774            .filter(|c| c.css_class != "proof-role-detail")
775            .collect();
776        assert_eq!(premises.len(), 1, "premises: {:?}", node.children);
777        assert!(premises[0].label.contains("dog"));
778        assert!(premises[0].label.contains("given"));
779    }
780
781    #[test]
782    fn syllogism_text_is_clean_two_lines() {
783        let trace = syllogism_trace();
784        let node = collapse_proof(&trace, Register::Spec);
785        let text = render_node_text(&node, 0, false);
786        // No role-level scaffolding leaks into the clean text view.
787        assert!(!text.contains("role-level detail"), "text:\n{text}");
788        assert!(!text.contains("dog.dog"), "text:\n{text}");
789        assert!(!text.contains("Conjunction"), "text:\n{text}");
790        assert!(!text.contains("(see above)"), "text:\n{text}");
791        // Two macro lines: the conclusion, then the given premise.
792        assert!(text.contains("animal"), "text:\n{text}");
793        assert!(text.contains("by the rule"), "text:\n{text}");
794        assert!(text.contains("dog"), "text:\n{text}");
795        assert!(text.contains("given"), "text:\n{text}");
796        assert_eq!(text.lines().count(), 2, "text:\n{text}");
797    }
798
799    #[test]
800    fn role_detail_is_present_but_only_shown_with_include_detail() {
801        let trace = syllogism_trace();
802        let node = collapse_proof(&trace, Register::Spec);
803        // The cluster exists on the node (UI expandable).
804        assert!(
805            node.children
806                .iter()
807                .any(|c| c.css_class == "proof-role-detail"),
808            "no role-detail cluster: {:?}",
809            node.children
810        );
811        // …and shows up only when detail is requested.
812        let with = render_node_text(&node, 0, true);
813        assert!(with.contains("role-level detail"), "with:\n{with}");
814        // The verbose role predicates surface under the cluster.
815        assert!(
816            with.contains("animal") || with.contains("dog"),
817            "with:\n{with}"
818        );
819    }
820
821    #[test]
822    fn false_query_is_not_derivable() {
823        let trace = ProofTrace {
824            steps: vec![step(
825                ProofRule::PredicateNotFound {
826                    predicate: "animal(adam)".to_string(),
827                },
828                false,
829                vec![],
830            )],
831            root: 0,
832            naf_dependent: false,
833            cwa_false: false,
834        };
835        let node = collapse_proof(&trace, Register::Spec);
836        assert!(!node.holds);
837        assert_eq!(node.css_class, "proof-failed");
838        assert!(
839            node.label.contains("not derivable"),
840            "label: {}",
841            node.label
842        );
843        assert!(node.label.contains("animal"), "label: {}", node.label);
844    }
845
846    #[test]
847    fn flat_given_fact_renders() {
848        let trace = ProofTrace {
849            steps: vec![step(asserted("animal(adam)"), true, vec![])],
850            root: 0,
851            naf_dependent: false,
852            cwa_false: false,
853        };
854        let node = collapse_proof(&trace, Register::Spec);
855        assert_eq!(node.css_class, "proof-asserted");
856        assert!(node.label.contains("given"), "label: {}", node.label);
857        assert!(node.label.contains("animal"), "label: {}", node.label);
858    }
859
860    #[test]
861    fn multi_hop_nests_two_rule_steps() {
862        // alive(adam) <- animal(adam) <- dog(adam), each a single-role event for
863        // a compact fixture. Exercises premise-of-premise recursion.
864        let dl = |lhs_base: &str, rhs: &str, fact: &str| ProofRule::Derived {
865            label: format!("{lhs_base} ∧ {lhs_base}_x1 → {rhs} ∧ {rhs}_x1"),
866            fact: fact.to_string(),
867        };
868        let trace = ProofTrace {
869            steps: vec![
870                step(asserted("dog(sk_1)"), true, vec![]),          // 0
871                step(asserted("dog_x1(sk_1, adam)"), true, vec![]), // 1
872                step(ProofRule::Conjunction, true, vec![0, 1]),     // 2
873                step(
874                    ProofRule::ExistsWitness {
875                        var: "_e".into(),
876                        term: nibli_protocol::LogicalTerm::Constant("sk_1".into()),
877                    },
878                    true,
879                    vec![2],
880                ), // 3 (dog event)
881                step(dl("dog", "animal", "animal(sk_2)"), true, vec![3]), // 4
882                step(dl("dog", "animal", "animal_x1(sk_2, adam)"), true, vec![3]), // 5
883                step(ProofRule::Conjunction, true, vec![4, 5]),     // 6
884                step(
885                    ProofRule::ExistsWitness {
886                        var: "_e".into(),
887                        term: nibli_protocol::LogicalTerm::Constant("sk_2".into()),
888                    },
889                    true,
890                    vec![6],
891                ), // 7 (animal event)
892                step(dl("animal", "alive", "alive(sk_3)"), true, vec![7]), // 8
893                step(dl("animal", "alive", "alive_x1(sk_3, adam)"), true, vec![7]), // 9
894                step(ProofRule::Conjunction, true, vec![8, 9]),     // 10
895                step(
896                    ProofRule::ExistsWitness {
897                        var: "_e".into(),
898                        term: nibli_protocol::LogicalTerm::Constant("sk_3".into()),
899                    },
900                    true,
901                    vec![10],
902                ), // 11 (alive event) ROOT
903            ],
904            root: 11,
905            naf_dependent: false,
906            cwa_false: false,
907        };
908        let node = collapse_proof(&trace, Register::Spec);
909        let text = render_node_text(&node, 0, false);
910        // Three nesting levels: alive <- animal <- dog.
911        assert_eq!(node.css_class, "proof-derived"); // alive by rule
912        let mid: Vec<&RenderedNode> = node
913            .children
914            .iter()
915            .filter(|c| c.css_class != "proof-role-detail")
916            .collect();
917        assert_eq!(mid.len(), 1, "text:\n{text}");
918        assert_eq!(mid[0].css_class, "proof-derived"); // animal by rule
919        let leaf: Vec<&RenderedNode> = mid[0]
920            .children
921            .iter()
922            .filter(|c| c.css_class != "proof-role-detail")
923            .collect();
924        assert_eq!(leaf.len(), 1, "text:\n{text}");
925        assert_eq!(leaf[0].css_class, "proof-asserted"); // dog given
926        // No verbose scaffolding in the clean text.
927        assert!(!text.contains("Conjunction"), "text:\n{text}");
928        assert_eq!(text.lines().count(), 3, "text:\n{text}");
929    }
930
931    #[test]
932    fn degrades_without_panic_on_unrecognized_shape() {
933        // A bare Negation root (no event scaffolding) must not panic and must
934        // produce SOME node (the honest functional form).
935        let trace = ProofTrace {
936            steps: vec![step(ProofRule::Negation, true, vec![])],
937            root: 0,
938            naf_dependent: false,
939            cwa_false: false,
940        };
941        let node = collapse_proof(&trace, Register::Spec);
942        let _ = render_node_text(&node, 0, false); // no panic
943        assert!(!node.label.is_empty());
944    }
945
946    #[test]
947    fn empty_trace_does_not_panic() {
948        let trace = ProofTrace {
949            steps: vec![],
950            root: 0,
951            naf_dependent: false,
952            cwa_false: false,
953        };
954        let node = collapse_proof(&trace, Register::Spec);
955        let _ = render_node_text(&node, 0, false);
956    }
957}