use std::collections::BTreeMap;
use nibli_protocol::{ProofRule, ProofTrace};
use crate::collapse::{MacroKind, MacroStep, collapse_to_macrosteps};
use crate::fact::humanize_fact;
use crate::frame::{
fill_template, frame_template, gloss_for, strip_trailing_particle, template_max_place,
};
use crate::overlay::DomainGloss;
use crate::register::Register;
use crate::term::{humanize_skolem, is_event_skolem, is_event_skolem_arg, role_base, role_index};
pub fn summarize_proof(trace: &ProofTrace, register: Register) -> Option<String> {
let root = trace.steps.get(trace.root as usize)?;
if root.holds {
summarize_true(trace, register)
} else {
summarize_false(trace, register)
}
}
pub fn summarize_proof_with(
trace: &ProofTrace,
register: Register,
overlay: Option<&'static DomainGloss>,
) -> Option<String> {
crate::overlay::with_overlay(overlay, || summarize_proof(trace, register))
}
pub fn fact_to_english(raw: &str, _register: Register) -> Option<String> {
let (wrapper, relation, args) = parse_raw_fact(raw)?;
let clause = if let (Some(base), Some(idx)) = (role_base(&relation), role_index(&relation)) {
if args.len() < 2 || !is_event_skolem(&args[0]) {
return None;
}
let mut slots: Vec<Option<String>> = vec![None; idx];
slots[idx - 1] = Some(humanize_skolem(&args[1]));
fill_template(&frame_template(base), &slots)
} else if args.len() == 1 && is_event_skolem(&args[0]) {
format!("there is {}", a_noun(&relation))
} else {
let slots: Vec<Option<String>> = args.iter().map(|a| Some(humanize_skolem(a))).collect();
fill_template(&frame_template(&relation), &slots)
};
if clause.trim().is_empty() {
None
} else {
Some(prefix_wrapper(wrapper.as_deref(), clause))
}
}
fn summarize_true(trace: &ProofTrace, register: Register) -> Option<String> {
let steps = collapse_to_macrosteps(trace, register);
let mut clauses: Vec<String> = Vec::new();
narrate_steps(&steps, &mut clauses);
clauses.extend(collect_extras(trace));
if clauses.is_empty() {
clauses = asserted_givens(trace, register);
}
if clauses.is_empty() {
return None;
}
let mut s = format!("Because {}.", join_clauses(&clauses));
if trace.naf_dependent {
s.push_str(" (Under the closed-world assumption — nothing known contradicts it.)");
}
Some(s)
}
fn narrate_steps(steps: &[MacroStep], out: &mut Vec<String>) {
let mut any_derived = false;
for s in steps {
collect_derived_clauses(s, out, &mut any_derived);
}
if !any_derived {
for s in steps {
if matches!(s.kind, MacroKind::Given) {
let stmt = s.statement.trim().to_string();
if !stmt.is_empty() && !out.contains(&stmt) {
out.push(stmt);
}
}
}
}
}
fn collect_derived_clauses(step: &MacroStep, out: &mut Vec<String>, any_derived: &mut bool) {
for p in &step.premises {
collect_derived_clauses(p, out, any_derived);
}
if matches!(step.kind, MacroKind::Derived(_)) {
*any_derived = true;
let prem: Vec<String> = step
.premises
.iter()
.map(|p| p.statement.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
let concl = step.statement.trim();
let clause = if prem.is_empty() {
concl.to_string()
} else {
format!("{}, {concl}", join_and(&prem))
};
if !out.contains(&clause) {
out.push(clause);
}
}
}
fn join_clauses(items: &[String]) -> String {
let mut s = String::new();
for (i, c) in items.iter().enumerate() {
if i == 0 {
s.push_str(c);
} else {
s.push_str("; and because ");
s.push_str(c);
}
}
s
}
fn asserted_givens(trace: &ProofTrace, register: Register) -> Vec<String> {
let facts: Vec<String> = trace
.steps
.iter()
.filter_map(|s| match &s.rule {
ProofRule::Asserted { fact } => Some(fact.clone()),
_ => None,
})
.collect();
let (groups, flat) = regroup_event_leaves(&facts, register);
let mut out: Vec<String> = Vec::new();
for (key, pm) in &groups {
if let Some(e) = render_group(key.0.as_deref(), &key.1, pm)
&& !out.contains(&e)
{
out.push(e);
}
}
for f in flat {
if !out.contains(&f) {
out.push(f);
}
}
out
}
fn summarize_false(trace: &ProofTrace, register: Register) -> Option<String> {
for step in &trace.steps {
match &step.rule {
ProofRule::PredicateNotFound { predicate } => {
let what = fact_to_english(predicate, register)
.unwrap_or_else(|| humanize_fact(predicate));
return Some(format!("Nothing known establishes that {what}."));
}
ProofRule::ForallCounterexample { entity } => {
return Some(format!(
"It is not true for every case — counterexample: {}.",
entity.display()
));
}
ProofRule::ExistsFailed => {
return Some("No example could be found that satisfies the query.".to_string());
}
_ => {}
}
}
Some("This could not be derived from the known facts and rules.".to_string())
}
pub(crate) type LeafKey = (Option<String>, String, String);
pub(crate) type EventGroup = (LeafKey, BTreeMap<usize, String>);
pub(crate) fn regroup_event_leaves(
facts: &[String],
register: Register,
) -> (Vec<EventGroup>, Vec<String>) {
let mut order: Vec<LeafKey> = Vec::new();
let mut places: BTreeMap<LeafKey, BTreeMap<usize, String>> = BTreeMap::new();
let mut flat: Vec<String> = Vec::new();
for fact in facts {
let Some((wrapper, relation, args)) = parse_raw_fact(fact) else {
flat.push(humanize_fact(fact));
continue;
};
if let (Some(base), Some(idx)) = (role_base(&relation), role_index(&relation))
&& args.len() >= 2
&& is_event_skolem_arg(&args[0])
{
let key = (wrapper.clone(), base.to_string(), args[0].clone());
if !places.contains_key(&key) {
order.push(key.clone());
}
places
.entry(key)
.or_default()
.insert(idx, humanize_skolem(&args[1]));
continue;
}
if args.len() == 1 && is_event_skolem_arg(&args[0]) {
let key = (wrapper.clone(), relation.clone(), args[0].clone());
if !places.contains_key(&key) {
order.push(key.clone());
}
places.entry(key).or_default();
continue;
}
if let Some(e) = fact_to_english(fact, register) {
flat.push(e);
} else {
flat.push(humanize_fact(fact));
}
}
let groups = order
.into_iter()
.map(|k| {
let pm = places.remove(&k).unwrap_or_default();
(k, pm)
})
.collect();
(groups, flat)
}
pub(crate) fn render_group(
wrapper: Option<&str>,
base: &str,
place_map: &BTreeMap<usize, String>,
) -> Option<String> {
let max = *place_map.keys().max()?; if place_map.get(&1).is_some_and(|s| s.starts_with('#')) {
return None;
}
let mut slots: Vec<Option<String>> = vec![None; max];
for (&p, filler) in place_map {
if (1..=max).contains(&p) {
slots[p - 1] = Some(filler.clone());
}
}
let filled = fill_template(&frame_template(base), &slots);
if filled.trim().is_empty() {
None
} else {
Some(prefix_wrapper(wrapper, filled))
}
}
pub(crate) fn rule_to_english(label: &str) -> Option<String> {
let (lhs, rhs) = label.split_once(" → ")?;
let conds: Vec<&str> = lhs.split(" ∧ ").map(str::trim).collect();
let concls: Vec<String> = rhs
.split(" ∧ ")
.filter_map(|c| relation_predicate(c.trim()))
.collect();
if concls.is_empty() {
return None;
}
let mut head: Option<String> = None;
let mut cond_phrases: Vec<String> = Vec::new();
for &c in &conds {
if head.is_none()
&& let Some(noun) = class_noun(c)
{
head = Some(noun);
continue;
}
if let Some(p) = relation_predicate(c) {
cond_phrases.push(p);
}
}
let subject_clause = match head {
Some(noun) if cond_phrases.is_empty() => format!("every {noun}"),
Some(noun) => format!("every {noun} that {}", join_and(&cond_phrases)),
None if cond_phrases.is_empty() => return None,
None => format!("anything that {}", join_and(&cond_phrases)),
};
Some(format!("{subject_clause} {}", join_and(&concls)))
}
fn relation_predicate(relation: &str) -> Option<String> {
let phrase = relation_clause(relation, "")?;
let phrase = phrase.trim();
let phrase = phrase.strip_prefix("something ").unwrap_or(phrase);
let phrase = strip_trailing_particle(phrase).trim();
if phrase.is_empty() {
None
} else {
Some(phrase.to_string())
}
}
fn class_noun(relation: &str) -> Option<String> {
if is_abstraction(relation) {
return None;
}
let phrase = relation_predicate(relation)?;
for prefix in ["is a ", "is an "] {
if let Some(noun) = phrase.strip_prefix(prefix) {
return Some(noun.to_string());
}
}
None
}
fn relation_clause(relation: &str, subject: &str) -> Option<String> {
if is_abstraction(relation) {
return None;
}
let tmpl = frame_template(relation);
let n = template_max_place(&tmpl).max(1);
let mut places = vec![Some("something".to_string()); n];
places[0] = Some(subject.to_string());
let filled = fill_template(&tmpl, &places);
let trimmed = filled.trim();
if trimmed.is_empty() || trimmed == subject {
None
} else {
Some(trimmed.to_string())
}
}
fn is_abstraction(relation: &str) -> bool {
crate::is_internal_relation(relation)
|| matches!(
relation,
"event" | "fact" | "property" | "amount" | "concept"
)
}
fn computed_extra_label(method: &str) -> &'static str {
match method {
"backend" => "computed by the trusted backend",
"arithmetic" | "numeric" => "computed locally",
_ => "computed",
}
}
fn collect_extras(trace: &ProofTrace) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for step in &trace.steps {
let clause = match &step.rule {
ProofRule::ComputeCheck { detail, method } => {
format!(
"{} ({})",
humanize_fact(detail),
computed_extra_label(method)
)
}
ProofRule::EqualitySubstitution {
original,
substituted,
..
} => format!(
"{} is the same as {}",
humanize_fact(original),
humanize_fact(substituted)
),
_ => continue,
};
if !out.contains(&clause) {
out.push(clause);
}
}
out
}
fn join_and(items: &[String]) -> String {
match items {
[] => String::new(),
[a] => a.clone(),
[a, b] => format!("{a} and {b}"),
[rest @ .., last] => format!("{}, and {}", rest.join(", "), last),
}
}
fn prefix_wrapper(wrapper: Option<&str>, clause: String) -> String {
match wrapper {
Some("past") => format!("in the past, {clause}"),
Some("present") => format!("currently, {clause}"),
Some("future") => format!("in the future, {clause}"),
Some("obligatory") => format!("it must be that {clause}"),
Some("permitted") => format!("it is permitted that {clause}"),
_ => clause,
}
}
fn a_noun(relation: &str) -> String {
let gloss = gloss_for(relation);
let article = match gloss.chars().next() {
Some(c) if "aeiou".contains(c.to_ascii_lowercase()) => "an",
_ => "a",
};
format!("{article} {gloss}")
}
fn wrapper_label(name: &str) -> Option<&'static str> {
match name {
"Past" => Some("past"),
"Present" => Some("present"),
"Future" => Some("future"),
"Obligatory" => Some("obligatory"),
"Permitted" => Some("permitted"),
_ => None,
}
}
pub(crate) fn parse_raw_fact(raw: &str) -> Option<(Option<String>, String, Vec<String>)> {
let s = raw.trim();
if s.is_empty() || s.starts_with('(') {
return None;
}
let Some(open) = s.find('(') else {
return Some((None, s.to_string(), Vec::new()));
};
if !s.ends_with(')') {
return None;
}
let name = &s[..open];
let inner = &s[open + 1..s.len() - 1];
if let Some(label) = wrapper_label(name) {
let (_, rel, args) = parse_raw_fact(inner)?;
return Some((Some(label.to_string()), rel, args));
}
Some((None, name.to_string(), split_top_level_commas(inner)))
}
fn split_top_level_commas(s: &str) -> Vec<String> {
let mut out = Vec::new();
let mut depth = 0i32;
let mut cur = String::new();
for c in s.chars() {
match c {
'(' => {
depth += 1;
cur.push(c);
}
')' => {
depth -= 1;
cur.push(c);
}
',' if depth == 0 => {
let t = cur.trim();
if !t.is_empty() {
out.push(t.to_string());
}
cur.clear();
}
_ => cur.push(c),
}
}
let t = cur.trim();
if !t.is_empty() {
out.push(t.to_string());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use nibli_protocol::ProofStep;
fn step(rule: ProofRule, holds: bool, children: Vec<u32>) -> ProofStep {
ProofStep {
rule,
holds,
children,
}
}
#[test]
fn fact_to_english_role_predicate() {
let dog = fact_to_english("dog_x1(sk_2, adam)", Register::Spec).unwrap();
assert!(dog.starts_with("adam"), "got: {dog}");
assert!(dog.contains("dog"), "got: {dog}");
let animal = fact_to_english("animal_x1(sk_3, adam)", Register::Spec).unwrap();
assert!(animal.starts_with("adam"), "got: {animal}");
assert!(animal.contains("animal"), "got: {animal}");
}
#[test]
fn fact_to_english_unknown_relation_returns_none_or_generic() {
let out = fact_to_english("frobnicatezzzz_x1(sk_9, adam)", Register::Spec);
assert!(out.is_some(), "should produce a generic frame");
assert!(out.unwrap().starts_with("adam"));
}
#[test]
fn summarize_syllogism_true() {
let trace = ProofTrace {
steps: vec![
step(
ProofRule::Asserted {
fact: "dog_x1(sk_2, adam)".to_string(),
},
true,
vec![],
),
step(
ProofRule::Derived {
label: "dog ∧ gerku_x1 → animal ∧ danlu_x1".to_string(),
fact: "animal_x1(sk_3, adam)".to_string(),
},
true,
vec![0],
),
],
root: 1,
naf_dependent: false,
cwa_false: false,
};
let s = summarize_proof(&trace, Register::Spec).unwrap();
assert!(s.starts_with("Because "), "got: {s}");
assert!(s.ends_with('.'), "got: {s}");
assert!(
s.contains("adam") && s.contains("dog"),
"premise missing: {s}"
);
assert!(s.contains("animal"), "conclusion missing: {s}");
assert!(!s.contains('X'), "bare variable leaked: {s}");
}
#[test]
fn summarize_naf_appends_note() {
let trace = ProofTrace {
steps: vec![
step(
ProofRule::Asserted {
fact: "dog_x1(sk_2, adam)".to_string(),
},
true,
vec![],
),
step(ProofRule::Negation, true, vec![0]),
],
root: 1,
naf_dependent: true,
cwa_false: false,
};
let s = summarize_proof(&trace, Register::Spec).unwrap();
assert!(s.contains("adam is a dog"), "given missing: {s}");
assert!(
s.contains("closed-world assumption"),
"naf note missing: {s}"
);
}
#[test]
fn summarize_false_predicate_not_found() {
let trace = ProofTrace {
steps: vec![step(
ProofRule::PredicateNotFound {
predicate: "animal_x1(sk_3, adam)".to_string(),
},
false,
vec![],
)],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let s = summarize_proof(&trace, Register::Spec).unwrap();
assert!(s.starts_with("Nothing known establishes that"), "got: {s}");
assert!(s.contains("adam") && s.contains("animal"), "got: {s}");
}
#[test]
fn summarize_compute_extra() {
let trace = ProofTrace {
steps: vec![step(
ProofRule::ComputeCheck {
method: "arithmetic".to_string(),
detail: "sumji(adam)".to_string(),
},
true,
vec![],
)],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let s = summarize_proof(&trace, Register::Spec).unwrap();
assert!(s.contains("(computed locally)"), "got: {s}");
}
#[test]
fn rule_multi_predicate_conclusion_renders_each() {
let e = rule_to_english("dog → animal ∧ alive").expect("renders");
assert!(e.starts_with("every dog"), "type head missing: {e}");
assert!(e.contains("animal"), "animal missing: {e}");
assert!(e.contains("alive"), "alive missing: {e}");
assert!(e.contains(" and "), "multi-conclusion not joined: {e}");
}
#[test]
fn rule_skips_abstraction_operators_no_broken_output() {
let trace = ProofTrace {
steps: vec![step(
ProofRule::Derived {
label: "person → nu ∧ animal".to_string(),
fact: "animal_x1(sk_3, adam)".to_string(),
},
true,
vec![],
)],
root: 0,
naf_dependent: false,
cwa_false: false,
};
let s = summarize_proof(&trace, Register::Spec).expect("renders the animal conclusion");
assert!(!s.contains('∧'), "abstraction/raw-conjunction leaked: {s}");
assert!(!s.contains(" X and X"), "broken output: {s}");
assert!(s.contains("animal"), "animal conclusion missing: {s}"); }
#[test]
fn rule_multi_place_predicate_keeps_its_object() {
let e = rule_to_english("permits → rule").expect("multi-place rule now renders");
assert!(e.starts_with("anything that "), "got: {e}");
assert!(e.contains("permits something"), "permits truncated: {e}");
assert!(
e.contains("is a rule about something"),
"javni truncated: {e}"
);
assert!(!e.contains('X'), "bare variable leaked: {e}");
assert!(!e.ends_with("permits"), "dangling: {e}");
}
#[test]
fn rule_single_place_reads_as_every_type() {
assert_eq!(
rule_to_english("dog → animal").as_deref(),
Some("every dog is an animal")
);
let e = rule_to_english("dog → animal").unwrap();
assert!(!e.contains("something"), "1-place leaked a filler: {e}");
assert!(!e.contains('X'), "bare variable leaked: {e}");
}
#[test]
fn deontic_wrapper_is_preserved_on_the_statement() {
let e = fact_to_english("Obligatory(person(adam))", Register::Spec)
.expect("deontic flat fact renders");
assert!(e.contains("it must be that"), "mood dropped: {e}");
assert!(e.contains("person"), "predicate dropped: {e}");
let p = fact_to_english("Permitted(person(adam))", Register::Spec).unwrap();
assert!(p.contains("it is permitted that"), "mood dropped: {p}");
}
#[test]
fn join_and_variants() {
assert_eq!(join_and(&["a".into()]), "a");
assert_eq!(join_and(&["a".into(), "b".into()]), "a and b");
assert_eq!(
join_and(&["a".into(), "b".into(), "c".into()]),
"a, b, and c"
);
}
}