use std::collections::BTreeMap;
use nibli_protocol::{ProofRule, ProofTrace};
use crate::fact::humanize_fact;
use crate::overlay::DomainGloss;
use crate::proof::{CWA_FALSE_NOTE, NAF_NOTE, RenderedNode, build_node, humanize_rule_label};
use crate::register::Register;
use crate::summary::{
LeafKey, fact_to_english, parse_raw_fact, regroup_event_leaves, render_group, rule_to_english,
};
use crate::term::{is_event_skolem_arg, role_base, role_index};
const MAX_DEPTH: usize = 64;
pub fn collapse_proof(trace: &ProofTrace, register: Register) -> RenderedNode {
if trace.steps.is_empty() {
return build_node(trace, trace.root);
}
let mut steps = collapse_to_macrosteps(trace, register);
match steps.len() {
1 => step_to_rendered(&steps.pop().unwrap()),
0 => build_node(trace, trace.root),
_ => {
let nodes: Vec<RenderedNode> = steps.iter().map(step_to_rendered).collect();
let holds = nodes.iter().all(|n| n.holds);
RenderedNode {
icon: "∧",
label: "all of the following hold".to_string(),
css_class: "proof-conjunction",
holds,
is_leaf: false,
inline: false,
children: nodes,
}
}
}
}
#[derive(Clone)]
pub(crate) struct MacroStep {
pub statement: String,
pub kind: MacroKind,
pub holds: bool,
pub premises: Vec<MacroStep>,
pub role_detail: Option<RenderedNode>,
}
#[derive(Clone)]
pub(crate) enum MacroKind {
Given,
Derived(String),
Computed(String),
Checked,
NotDerivable,
Reference,
Raw(Box<RenderedNode>),
}
pub(crate) fn collapse_to_macrosteps(trace: &ProofTrace, register: Register) -> Vec<MacroStep> {
if trace.steps.is_empty() {
return Vec::new();
}
let mut seen: Vec<String> = Vec::new();
let leaves = collect_goal_leaves(trace, trace.root, 0);
collapse_goal_steps(trace, &leaves, register, &mut seen, 0)
}
fn computed_label(method: &str) -> &'static str {
match method {
"backend" => "computed (trusted backend)",
"arithmetic" | "numeric" => "computed (local)",
_ => "computed",
}
}
fn step_to_rendered(step: &MacroStep) -> RenderedNode {
match &step.kind {
MacroKind::Raw(node) => (**node).clone(),
MacroKind::Reference => reference_node(step.statement.clone()),
MacroKind::Given => macro_leaf(
&step.statement,
"given",
"▣",
"proof-asserted",
step.holds,
step.role_detail.clone(),
),
MacroKind::Computed(method) => macro_leaf(
&step.statement,
computed_label(method),
"⊢",
"proof-check",
step.holds,
step.role_detail.clone(),
),
MacroKind::Checked => macro_leaf(
&step.statement,
"checked",
"⊢",
"proof-check",
step.holds,
step.role_detail.clone(),
),
MacroKind::NotDerivable => macro_leaf(
&step.statement,
"not derivable",
"✗",
"proof-failed",
false,
step.role_detail.clone(),
),
MacroKind::Derived(label) => {
let just = rule_justification(label);
let mut children: Vec<RenderedNode> =
step.premises.iter().map(step_to_rendered).collect();
if let Some(rd) = &step.role_detail {
children.push(rd.clone());
}
RenderedNode {
icon: "⊢",
label: format!("{} [{just}]", step.statement),
css_class: "proof-derived",
holds: step.holds,
is_leaf: children.is_empty(),
inline: false,
children,
}
}
}
}
pub fn collapse_proof_with(
trace: &ProofTrace,
register: Register,
overlay: Option<&'static DomainGloss>,
) -> RenderedNode {
crate::overlay::with_overlay(overlay, || collapse_proof(trace, register))
}
pub fn render_collapsed_text(
trace: &ProofTrace,
register: Register,
base_indent: usize,
include_detail: bool,
) -> String {
let mut out = String::new();
if trace.naf_dependent {
out.push_str(NAF_NOTE);
out.push('\n');
}
if trace.cwa_false {
out.push_str(CWA_FALSE_NOTE);
out.push('\n');
}
let node = collapse_proof(trace, register);
out.push_str(&render_node_text(&node, base_indent, include_detail));
out
}
pub fn render_collapsed_text_with(
trace: &ProofTrace,
register: Register,
base_indent: usize,
include_detail: bool,
overlay: Option<&'static DomainGloss>,
) -> String {
crate::overlay::with_overlay(overlay, || {
render_collapsed_text(trace, register, base_indent, include_detail)
})
}
pub fn render_node_text(node: &RenderedNode, base_indent: usize, include_detail: bool) -> String {
let mut out = String::new();
write_node_text(node, base_indent, include_detail, &mut out);
out
}
fn write_node_text(node: &RenderedNode, indent: usize, include_detail: bool, out: &mut String) {
if !include_detail && node.css_class == "proof-role-detail" {
return;
}
for _ in 0..indent {
out.push_str(" ");
}
if node.inline {
out.push_str(&format!("{} {}\n", node.icon, node.label));
} else {
let tag = if node.holds { "TRUE" } else { "FALSE" };
out.push_str(&format!("{} {} -> {}\n", node.icon, node.label, tag));
}
for child in &node.children {
write_node_text(child, indent + 1, include_detail, out);
}
}
fn collect_goal_leaves(trace: &ProofTrace, idx: u32, depth: usize) -> Vec<u32> {
if depth > MAX_DEPTH {
return vec![idx];
}
match trace.steps.get(idx as usize).map(|s| &s.rule) {
Some(
ProofRule::ExistsWitness { .. }
| ProofRule::Conjunction
| ProofRule::ModalPassthrough { .. },
) => {
let mut out = Vec::new();
for &c in &trace.steps[idx as usize].children {
out.extend(collect_goal_leaves(trace, c, depth + 1));
}
out
}
_ => vec![idx],
}
}
fn collapse_goal_steps(
trace: &ProofTrace,
goals: &[u32],
register: Register,
seen: &mut Vec<String>,
depth: usize,
) -> Vec<MacroStep> {
if depth > MAX_DEPTH {
return goals
.iter()
.map(|&g| raw_step(build_node(trace, g)))
.collect();
}
let mut order: Vec<LeafKey> = Vec::new();
let mut buckets: BTreeMap<LeafKey, Vec<u32>> = BTreeMap::new();
let mut singles: Vec<u32> = Vec::new();
for &g in goals {
if let Some(key) = goal_event_key(trace, g) {
if !buckets.contains_key(&key) {
order.push(key.clone());
}
buckets.entry(key).or_default().push(g);
} else {
singles.push(g);
}
}
let mut out = Vec::new();
for key in &order {
out.push(build_macro_step(
trace,
&buckets[key],
register,
seen,
depth,
));
}
for &g in &singles {
out.push(build_single_step(trace, g, register, seen, depth));
}
out
}
fn leaf_step(
statement: String,
kind: MacroKind,
holds: bool,
role_detail: Option<RenderedNode>,
) -> MacroStep {
MacroStep {
statement,
kind,
holds,
premises: Vec::new(),
role_detail,
}
}
fn raw_step(node: RenderedNode) -> MacroStep {
let statement = node.label.clone();
let holds = node.holds;
MacroStep {
statement,
kind: MacroKind::Raw(Box::new(node)),
holds,
premises: Vec::new(),
role_detail: None,
}
}
fn goal_event_key(trace: &ProofTrace, g: u32) -> Option<LeafKey> {
let rule = &trace.steps.get(g as usize)?.rule;
let fact = goal_fact(rule)?;
let (wrapper, relation, args) = parse_raw_fact(&fact)?;
if let (Some(base), Some(_)) = (role_base(&relation), role_index(&relation))
&& args.len() >= 2
&& is_event_skolem_arg(&args[0])
{
return Some((wrapper, base.to_string(), args[0].clone()));
}
if args.len() == 1 && is_event_skolem_arg(&args[0]) {
return Some((wrapper, relation, args[0].clone()));
}
None
}
fn goal_fact(rule: &ProofRule) -> Option<String> {
match rule {
ProofRule::Asserted { fact }
| ProofRule::Derived { fact, .. }
| ProofRule::ProofRef { fact } => Some(fact.clone()),
ProofRule::PredicateCheck { detail, .. } | ProofRule::ComputeCheck { detail, .. } => {
Some(detail.clone())
}
ProofRule::PredicateNotFound { predicate } => Some(predicate.clone()),
_ => None,
}
}
enum GroupKind {
Given,
Derived(String),
Computed(String),
Checked,
NotDerivable,
Reference,
}
fn classify_group(trace: &ProofTrace, steps: &[u32]) -> GroupKind {
let mut derived: Option<String> = None;
let mut computed_method: Option<String> = None;
let (mut given, mut checked, mut notfound, mut nonref) = (false, false, false, false);
for &g in steps {
match &trace.steps[g as usize].rule {
ProofRule::Derived { label, .. } => {
derived = Some(label.clone());
nonref = true;
}
ProofRule::Asserted { .. } => {
given = true;
nonref = true;
}
ProofRule::ComputeCheck { method, .. } => {
computed_method = Some(method.clone());
nonref = true;
}
ProofRule::PredicateCheck { .. } => {
checked = true;
nonref = true;
}
ProofRule::PredicateNotFound { .. }
| ProofRule::RuleAttemptFailed { .. }
| ProofRule::ExistsFailed => {
notfound = true;
nonref = true;
}
ProofRule::ProofRef { .. } => {}
_ => nonref = true,
}
}
if let Some(l) = derived {
GroupKind::Derived(l)
} else if given {
GroupKind::Given
} else if let Some(m) = computed_method {
GroupKind::Computed(m)
} else if checked {
GroupKind::Checked
} else if notfound {
GroupKind::NotDerivable
} else if !nonref {
GroupKind::Reference
} else {
GroupKind::Given
}
}
fn build_macro_step(
trace: &ProofTrace,
steps: &[u32],
register: Register,
seen: &mut Vec<String>,
depth: usize,
) -> MacroStep {
let facts: Vec<String> = steps
.iter()
.filter_map(|&g| goal_fact(&trace.steps[g as usize].rule))
.collect();
let holds = steps.iter().all(|&g| trace.steps[g as usize].holds);
let Some(statement) = surface_statement(&facts, register) else {
return raw_step(verbose_group(trace, steps, holds)); };
if seen.contains(&statement) {
return leaf_step(statement, MacroKind::Reference, true, None);
}
seen.push(statement.clone());
match classify_group(trace, steps) {
GroupKind::Reference => leaf_step(statement, MacroKind::Reference, true, None),
GroupKind::NotDerivable => leaf_step(
statement,
MacroKind::NotDerivable,
false,
role_detail(trace, steps),
),
GroupKind::Given => leaf_step(
statement,
MacroKind::Given,
holds,
role_detail(trace, steps),
),
GroupKind::Computed(method) => leaf_step(
statement,
MacroKind::Computed(method),
holds,
role_detail(trace, steps),
),
GroupKind::Checked => leaf_step(
statement,
MacroKind::Checked,
holds,
role_detail(trace, steps),
),
GroupKind::Derived(label) => {
let mut cond_leaves: Vec<u32> = Vec::new();
for &g in steps {
if matches!(trace.steps[g as usize].rule, ProofRule::Derived { .. }) {
for &c in &trace.steps[g as usize].children {
cond_leaves.extend(collect_goal_leaves(trace, c, depth + 1));
}
}
}
let premises = collapse_goal_steps(trace, &cond_leaves, register, seen, depth + 1);
MacroStep {
statement,
kind: MacroKind::Derived(label),
holds,
premises,
role_detail: role_detail(trace, steps),
}
}
}
}
fn build_single_step(
trace: &ProofTrace,
g: u32,
register: Register,
seen: &mut Vec<String>,
depth: usize,
) -> MacroStep {
let rule = &trace.steps[g as usize].rule;
let holds = trace.steps[g as usize].holds;
match rule {
ProofRule::Asserted { fact } => {
let stmt = flat_statement(fact, register);
if seen.contains(&stmt) {
return leaf_step(stmt, MacroKind::Reference, true, None);
}
seen.push(stmt.clone());
leaf_step(stmt, MacroKind::Given, holds, None)
}
ProofRule::ProofRef { fact } => leaf_step(
flat_statement(fact, register),
MacroKind::Reference,
true,
None,
),
ProofRule::PredicateNotFound { predicate } => leaf_step(
flat_statement(predicate, register),
MacroKind::NotDerivable,
false,
None,
),
ProofRule::ComputeCheck { detail, method } => leaf_step(
flat_statement(detail, register),
MacroKind::Computed(method.clone()),
holds,
None,
),
ProofRule::PredicateCheck { detail, .. } => leaf_step(
flat_statement(detail, register),
MacroKind::Checked,
holds,
None,
),
ProofRule::Derived { label, fact } => {
let stmt = flat_statement(fact, register);
if seen.contains(&stmt) {
return leaf_step(stmt, MacroKind::Reference, true, None);
}
seen.push(stmt.clone());
let mut cond_leaves: Vec<u32> = Vec::new();
for &c in &trace.steps[g as usize].children {
cond_leaves.extend(collect_goal_leaves(trace, c, depth + 1));
}
let premises = collapse_goal_steps(trace, &cond_leaves, register, seen, depth + 1);
MacroStep {
statement: stmt,
kind: MacroKind::Derived(label.clone()),
holds,
premises,
role_detail: None,
}
}
_ => raw_step(build_node(trace, g)),
}
}
fn surface_statement(facts: &[String], register: Register) -> Option<String> {
let (groups, flat) = regroup_event_leaves(facts, register);
if let Some((key, pm)) = groups.first() {
render_group(key.0.as_deref(), &key.1, pm)
} else {
flat.first().cloned()
}
}
fn flat_statement(fact: &str, register: Register) -> String {
fact_to_english(fact, register).unwrap_or_else(|| humanize_fact(fact))
}
fn rule_justification(label: &str) -> String {
let humanized = humanize_rule_label(label);
match rule_to_english(&humanized) {
Some(e) => format!("by the rule: {e}"),
None => format!("by rule: {humanized}"),
}
}
fn macro_leaf(
statement: &str,
just: &str,
icon: &'static str,
css_class: &'static str,
holds: bool,
detail: Option<RenderedNode>,
) -> RenderedNode {
let mut children = Vec::new();
if let Some(d) = detail {
children.push(d);
}
RenderedNode {
icon,
label: format!("{statement} [{just}]"),
css_class,
holds,
is_leaf: children.is_empty(),
inline: false,
children,
}
}
fn reference_node(statement: String) -> RenderedNode {
RenderedNode {
icon: "↑",
label: format!("{statement} (shown above)"),
css_class: "proof-ref",
holds: true,
is_leaf: true,
inline: true,
children: Vec::new(),
}
}
fn role_detail(trace: &ProofTrace, steps: &[u32]) -> Option<RenderedNode> {
if steps.len() <= 1 {
return None;
}
let children: Vec<RenderedNode> = steps.iter().map(|&g| build_node(trace, g)).collect();
Some(RenderedNode {
icon: "▸",
label: "role-level detail".to_string(),
css_class: "proof-role-detail",
holds: true,
is_leaf: false,
inline: false,
children,
})
}
fn verbose_group(trace: &ProofTrace, steps: &[u32], holds: bool) -> RenderedNode {
if steps.len() == 1 {
return build_node(trace, steps[0]);
}
RenderedNode {
icon: "∧",
label: "(detail)".to_string(),
css_class: "proof-conjunction",
holds,
is_leaf: false,
inline: false,
children: steps.iter().map(|&g| build_node(trace, g)).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use nibli_protocol::ProofStep;
fn step(rule: ProofRule, holds: bool, children: Vec<u32>) -> ProofStep {
ProofStep {
rule,
holds,
children,
}
}
fn asserted(fact: &str) -> ProofRule {
ProofRule::Asserted {
fact: fact.to_string(),
}
}
fn proofref(fact: &str) -> ProofRule {
ProofRule::ProofRef {
fact: fact.to_string(),
}
}
fn derived(fact: &str) -> ProofRule {
ProofRule::Derived {
label: "dog ∧ gerku_x1 ∧ gerku_x2 → animal ∧ danlu_x1 ∧ danlu_x2".to_string(),
fact: fact.to_string(),
}
}
fn syllogism_trace() -> ProofTrace {
ProofTrace {
steps: vec![
step(asserted("dog(sk_2)"), true, vec![]), step(asserted("dog_x1(sk_2, adam)"), true, vec![]), step(asserted("dog_x2(sk_2, zo'e)"), true, vec![]), step(derived("animal(sk_3(adam))"), true, vec![0, 1, 2]), step(proofref("dog(sk_2)"), true, vec![]), step(proofref("dog_x1(sk_2, adam)"), true, vec![]), step(proofref("dog_x2(sk_2, zo'e)"), true, vec![]), step(derived("animal_x1(sk_3(adam), adam)"), true, vec![4, 5, 6]), step(proofref("dog(sk_2)"), true, vec![]), step(proofref("dog_x1(sk_2, adam)"), true, vec![]), step(proofref("dog_x2(sk_2, zo'e)"), true, vec![]), step(derived("animal_x2(sk_3(adam), zo'e)"), true, vec![8, 9, 10]), step(ProofRule::Conjunction, true, vec![3, 7]), step(ProofRule::Conjunction, true, vec![12, 11]), step(
ProofRule::ExistsWitness {
var: "_ev0".to_string(),
term: nibli_protocol::LogicalTerm::Constant("sk_3(adam)".to_string()),
},
true,
vec![13],
), ],
root: 14,
naf_dependent: false,
cwa_false: false,
}
}
#[test]
fn syllogism_collapses_to_conclusion_then_one_given_premise() {
let trace = syllogism_trace();
let node = collapse_proof(&trace, Register::Spec);
assert_eq!(node.css_class, "proof-derived");
assert!(node.holds);
assert!(node.label.contains("animal"), "label: {}", node.label);
assert!(
node.label.contains("by the rule") && node.label.contains("dog"),
"label: {}",
node.label
);
let premises: Vec<&RenderedNode> = node
.children
.iter()
.filter(|c| c.css_class != "proof-role-detail")
.collect();
assert_eq!(premises.len(), 1, "premises: {:?}", node.children);
assert!(premises[0].label.contains("dog"));
assert!(premises[0].label.contains("given"));
}
#[test]
fn syllogism_text_is_clean_two_lines() {
let trace = syllogism_trace();
let node = collapse_proof(&trace, Register::Spec);
let text = render_node_text(&node, 0, false);
assert!(!text.contains("role-level detail"), "text:\n{text}");
assert!(!text.contains("dog.dog"), "text:\n{text}");
assert!(!text.contains("Conjunction"), "text:\n{text}");
assert!(!text.contains("(see above)"), "text:\n{text}");
assert!(text.contains("animal"), "text:\n{text}");
assert!(text.contains("by the rule"), "text:\n{text}");
assert!(text.contains("dog"), "text:\n{text}");
assert!(text.contains("given"), "text:\n{text}");
assert_eq!(text.lines().count(), 2, "text:\n{text}");
}
#[test]
fn role_detail_is_present_but_only_shown_with_include_detail() {
let trace = syllogism_trace();
let node = collapse_proof(&trace, Register::Spec);
assert!(
node.children
.iter()
.any(|c| c.css_class == "proof-role-detail"),
"no role-detail cluster: {:?}",
node.children
);
let with = render_node_text(&node, 0, true);
assert!(with.contains("role-level detail"), "with:\n{with}");
assert!(
with.contains("animal") || with.contains("dog"),
"with:\n{with}"
);
}
#[test]
fn false_query_is_not_derivable() {
let trace = ProofTrace {
steps: vec![step(
ProofRule::PredicateNotFound {
predicate: "animal(adam)".to_string(),
},
false,
vec![],
)],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let node = collapse_proof(&trace, Register::Spec);
assert!(!node.holds);
assert_eq!(node.css_class, "proof-failed");
assert!(
node.label.contains("not derivable"),
"label: {}",
node.label
);
assert!(node.label.contains("animal"), "label: {}", node.label);
}
#[test]
fn flat_given_fact_renders() {
let trace = ProofTrace {
steps: vec![step(asserted("animal(adam)"), true, vec![])],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let node = collapse_proof(&trace, Register::Spec);
assert_eq!(node.css_class, "proof-asserted");
assert!(node.label.contains("given"), "label: {}", node.label);
assert!(node.label.contains("animal"), "label: {}", node.label);
}
#[test]
fn multi_hop_nests_two_rule_steps() {
let dl = |lhs_base: &str, rhs: &str, fact: &str| ProofRule::Derived {
label: format!("{lhs_base} ∧ {lhs_base}_x1 → {rhs} ∧ {rhs}_x1"),
fact: fact.to_string(),
};
let trace = ProofTrace {
steps: vec![
step(asserted("dog(sk_1)"), true, vec![]), step(asserted("dog_x1(sk_1, adam)"), true, vec![]), step(ProofRule::Conjunction, true, vec![0, 1]), step(
ProofRule::ExistsWitness {
var: "_e".into(),
term: nibli_protocol::LogicalTerm::Constant("sk_1".into()),
},
true,
vec![2],
), step(dl("dog", "animal", "animal(sk_2)"), true, vec![3]), step(dl("dog", "animal", "animal_x1(sk_2, adam)"), true, vec![3]), step(ProofRule::Conjunction, true, vec![4, 5]), step(
ProofRule::ExistsWitness {
var: "_e".into(),
term: nibli_protocol::LogicalTerm::Constant("sk_2".into()),
},
true,
vec![6],
), step(dl("animal", "alive", "alive(sk_3)"), true, vec![7]), step(dl("animal", "alive", "alive_x1(sk_3, adam)"), true, vec![7]), step(ProofRule::Conjunction, true, vec![8, 9]), step(
ProofRule::ExistsWitness {
var: "_e".into(),
term: nibli_protocol::LogicalTerm::Constant("sk_3".into()),
},
true,
vec![10],
), ],
root: 11,
naf_dependent: false,
cwa_false: false,
};
let node = collapse_proof(&trace, Register::Spec);
let text = render_node_text(&node, 0, false);
assert_eq!(node.css_class, "proof-derived"); let mid: Vec<&RenderedNode> = node
.children
.iter()
.filter(|c| c.css_class != "proof-role-detail")
.collect();
assert_eq!(mid.len(), 1, "text:\n{text}");
assert_eq!(mid[0].css_class, "proof-derived"); let leaf: Vec<&RenderedNode> = mid[0]
.children
.iter()
.filter(|c| c.css_class != "proof-role-detail")
.collect();
assert_eq!(leaf.len(), 1, "text:\n{text}");
assert_eq!(leaf[0].css_class, "proof-asserted"); assert!(!text.contains("Conjunction"), "text:\n{text}");
assert_eq!(text.lines().count(), 3, "text:\n{text}");
}
#[test]
fn degrades_without_panic_on_unrecognized_shape() {
let trace = ProofTrace {
steps: vec![step(ProofRule::Negation, true, vec![])],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let node = collapse_proof(&trace, Register::Spec);
let _ = render_node_text(&node, 0, false); assert!(!node.label.is_empty());
}
#[test]
fn empty_trace_does_not_panic() {
let trace = ProofTrace {
steps: vec![],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let node = collapse_proof(&trace, Register::Spec);
let _ = render_node_text(&node, 0, false);
}
}