Skip to main content

nibli_render/
summary.rs

1//! Plain-English "Why" summary of a proof trace (render-only).
2//!
3//! The technical proof trace ([`crate::proof`]) is exhaustive but cryptic — it
4//! shows functional notation (`dog.dog(adam)`) and the Neo-Davidsonian
5//! decomposition explodes each fact into role-predicate lines. This produces a
6//! short, plain-English explanation of WHY a query holds (or fails), reusing the
7//! same place-frame machinery as the back-translation: it walks the flat
8//! `ProofTrace`, regroups the role predicates back to surface facts, and fills
9//! the curated English templates (`dog` -> `"{x1} is a dog"`).
10//!
11//! Best-effort and total: anything that does not parse or has no frame falls
12//! back to the functional [`crate::fact::humanize_fact`] string — it never
13//! invents English. The `ProofTrace` data is unchanged; this is pure rendering,
14//! layered ABOVE the detailed trace (which stays for experts).
15
16use std::collections::BTreeMap;
17
18use nibli_protocol::{ProofRule, ProofTrace};
19
20use crate::collapse::{MacroKind, MacroStep, collapse_to_macrosteps};
21use crate::fact::humanize_fact;
22use crate::frame::{
23    fill_template, frame_template, gloss_for, strip_trailing_particle, template_max_place,
24};
25use crate::overlay::DomainGloss;
26use crate::register::Register;
27use crate::term::{humanize_skolem, is_event_skolem, is_event_skolem_arg, role_base, role_index};
28
29/// Build a one-block plain-English "why" explanation of the trace, or `None` if
30/// there is nothing summarizable (callers then print nothing extra).
31pub fn summarize_proof(trace: &ProofTrace, register: Register) -> Option<String> {
32    let root = trace.steps.get(trace.root as usize)?;
33    if root.holds {
34        summarize_true(trace, register)
35    } else {
36        summarize_false(trace, register)
37    }
38}
39
40/// As [`summarize_proof`], but renders under a domain-gloss `overlay` (curated
41/// examples read in real domain terms; `None` = the dictionary-fallback default).
42pub fn summarize_proof_with(
43    trace: &ProofTrace,
44    register: Register,
45    overlay: Option<&'static DomainGloss>,
46) -> Option<String> {
47    crate::overlay::with_overlay(overlay, || summarize_proof(trace, register))
48}
49
50/// Translate one humanized/raw fact string to an English clause via the place
51/// frames. `None` when it cannot be rendered (caller falls back to functional).
52pub fn fact_to_english(raw: &str, _register: Register) -> Option<String> {
53    let (wrapper, relation, args) = parse_raw_fact(raw)?;
54    let clause = if let (Some(base), Some(idx)) = (role_base(&relation), role_index(&relation)) {
55        // A single role predicate `base_xN(event, filler)` -> fill place N only.
56        if args.len() < 2 || !is_event_skolem(&args[0]) {
57            return None;
58        }
59        let mut slots: Vec<Option<String>> = vec![None; idx];
60        slots[idx - 1] = Some(humanize_skolem(&args[1]));
61        fill_template(&frame_template(base), &slots)
62    } else if args.len() == 1 && is_event_skolem(&args[0]) {
63        // An event-type predicate `base(event)` -> existence.
64        format!("there is {}", a_noun(&relation))
65    } else {
66        // A flat fact -> fill the frame with all args directly.
67        let slots: Vec<Option<String>> = args.iter().map(|a| Some(humanize_skolem(a))).collect();
68        fill_template(&frame_template(&relation), &slots)
69    };
70    if clause.trim().is_empty() {
71        None
72    } else {
73        Some(prefix_wrapper(wrapper.as_deref(), clause))
74    }
75}
76
77fn summarize_true(trace: &ProofTrace, register: Register) -> Option<String> {
78    // Narrate the INSTANTIATED derivation the collapse already computes (real
79    // entities — `varfarin is in danger` — never a bare `X`): one "<premises>,
80    // <conclusion>" clause per derived step, deepest first (a bottom-up reading).
81    let steps = collapse_to_macrosteps(trace, register);
82    let mut clauses: Vec<String> = Vec::new();
83    narrate_steps(&steps, &mut clauses);
84    // Compute results + equality substitutions live outside the collapsed tree.
85    clauses.extend(collect_extras(trace));
86    if clauses.is_empty() {
87        // Fallback for a root the collapse cannot phrase as a derivation (e.g. a
88        // negation-as-failure root): list the asserted givens so a holding proof
89        // never renders an empty "why".
90        clauses = asserted_givens(trace, register);
91    }
92    if clauses.is_empty() {
93        return None;
94    }
95    let mut s = format!("Because {}.", join_clauses(&clauses));
96    if trace.naf_dependent {
97        s.push_str(" (Under the closed-world assumption — nothing known contradicts it.)");
98    }
99    Some(s)
100}
101
102/// Append narrative clauses describing how the proof's derived conclusions follow
103/// from their premises (deepest derivation first). A proof with no rule firing —
104/// the conclusion is a directly known fact — lists the given statement(s).
105fn narrate_steps(steps: &[MacroStep], out: &mut Vec<String>) {
106    let mut any_derived = false;
107    for s in steps {
108        collect_derived_clauses(s, out, &mut any_derived);
109    }
110    if !any_derived {
111        for s in steps {
112            if matches!(s.kind, MacroKind::Given) {
113                let stmt = s.statement.trim().to_string();
114                if !stmt.is_empty() && !out.contains(&stmt) {
115                    out.push(stmt);
116                }
117            }
118        }
119    }
120}
121
122/// Depth-first emit: a derived step contributes "<premises>, <conclusion>" after
123/// its premises' own derivations (so the chain reads bottom-up).
124fn collect_derived_clauses(step: &MacroStep, out: &mut Vec<String>, any_derived: &mut bool) {
125    for p in &step.premises {
126        collect_derived_clauses(p, out, any_derived);
127    }
128    if matches!(step.kind, MacroKind::Derived(_)) {
129        *any_derived = true;
130        let prem: Vec<String> = step
131            .premises
132            .iter()
133            .map(|p| p.statement.trim().to_string())
134            .filter(|s| !s.is_empty())
135            .collect();
136        let concl = step.statement.trim();
137        let clause = if prem.is_empty() {
138            concl.to_string()
139        } else {
140            format!("{}, {concl}", join_and(&prem))
141        };
142        if !out.contains(&clause) {
143            out.push(clause);
144        }
145    }
146}
147
148/// Chain the narrative clauses: `Because <c1>; and because <c2>; …`.
149fn join_clauses(items: &[String]) -> String {
150    let mut s = String::new();
151    for (i, c) in items.iter().enumerate() {
152        if i == 0 {
153            s.push_str(c);
154        } else {
155            s.push_str("; and because ");
156            s.push_str(c);
157        }
158    }
159    s
160}
161
162/// The asserted (given) facts, regrouped from role predicates back to surface
163/// facts and translated to English. Distinct, first-seen order. The fallback
164/// narrative when the collapse cannot phrase the root as a derivation.
165fn asserted_givens(trace: &ProofTrace, register: Register) -> Vec<String> {
166    let facts: Vec<String> = trace
167        .steps
168        .iter()
169        .filter_map(|s| match &s.rule {
170            ProofRule::Asserted { fact } => Some(fact.clone()),
171            _ => None,
172        })
173        .collect();
174    let (groups, flat) = regroup_event_leaves(&facts, register);
175    let mut out: Vec<String> = Vec::new();
176    for (key, pm) in &groups {
177        if let Some(e) = render_group(key.0.as_deref(), &key.1, pm)
178            && !out.contains(&e)
179        {
180            out.push(e);
181        }
182    }
183    for f in flat {
184        if !out.contains(&f) {
185            out.push(f);
186        }
187    }
188    out
189}
190
191fn summarize_false(trace: &ProofTrace, register: Register) -> Option<String> {
192    for step in &trace.steps {
193        match &step.rule {
194            ProofRule::PredicateNotFound { predicate } => {
195                let what = fact_to_english(predicate, register)
196                    .unwrap_or_else(|| humanize_fact(predicate));
197                return Some(format!("Nothing known establishes that {what}."));
198            }
199            ProofRule::ForallCounterexample { entity } => {
200                return Some(format!(
201                    "It is not true for every case — counterexample: {}.",
202                    entity.display()
203                ));
204            }
205            ProofRule::ExistsFailed => {
206                return Some("No example could be found that satisfies the query.".to_string());
207            }
208            _ => {}
209        }
210    }
211    Some("This could not be derived from the known facts and rules.".to_string())
212}
213
214/// Key for an event group: (tense/deontic wrapper, base relation, event Skolem).
215pub(crate) type LeafKey = (Option<String>, String, String);
216
217/// One regrouped event: its key + a `{place -> filler}` map.
218pub(crate) type EventGroup = (LeafKey, BTreeMap<usize, String>);
219
220/// Regroup raw fact strings (Neo-Davidsonian role + event-type predicates) back
221/// to surface facts: bucket `base_xN(event, filler)` / `base(event)` by
222/// `(wrapper, base, event Skolem)` into `{place -> filler}` maps (first-seen
223/// order), returning non-event "flat" facts (rendered to English directly) on the
224/// side. The shared DRY core of the `[Why]` summary and the macro-DAG collapse.
225pub(crate) fn regroup_event_leaves(
226    facts: &[String],
227    register: Register,
228) -> (Vec<EventGroup>, Vec<String>) {
229    let mut order: Vec<LeafKey> = Vec::new();
230    let mut places: BTreeMap<LeafKey, BTreeMap<usize, String>> = BTreeMap::new();
231    let mut flat: Vec<String> = Vec::new();
232
233    for fact in facts {
234        let Some((wrapper, relation, args)) = parse_raw_fact(fact) else {
235            flat.push(humanize_fact(fact));
236            continue;
237        };
238        // Role predicate `base_xN(event, filler)` — the arg0 event may be a bare
239        // (`sk_N`) OR a dependent (`sk_N(rex)`, a universal rule's conclusion
240        // event) Skolem, hence the loose check.
241        if let (Some(base), Some(idx)) = (role_base(&relation), role_index(&relation))
242            && args.len() >= 2
243            && is_event_skolem_arg(&args[0])
244        {
245            let key = (wrapper.clone(), base.to_string(), args[0].clone());
246            if !places.contains_key(&key) {
247                order.push(key.clone());
248            }
249            places
250                .entry(key)
251                .or_default()
252                .insert(idx, humanize_skolem(&args[1]));
253            continue;
254        }
255        // Event-type predicate `base(event)` — registers the group (no place).
256        if args.len() == 1 && is_event_skolem_arg(&args[0]) {
257            let key = (wrapper.clone(), relation.clone(), args[0].clone());
258            if !places.contains_key(&key) {
259                order.push(key.clone());
260            }
261            places.entry(key).or_default();
262            continue;
263        }
264        // A flat fact (no event Skolem) — render directly.
265        if let Some(e) = fact_to_english(fact, register) {
266            flat.push(e);
267        } else {
268            flat.push(humanize_fact(fact));
269        }
270    }
271
272    let groups = order
273        .into_iter()
274        .map(|k| {
275            let pm = places.remove(&k).unwrap_or_default();
276            (k, pm)
277        })
278        .collect();
279    (groups, flat)
280}
281
282/// Render a regrouped event's place-map via the frame template.
283pub(crate) fn render_group(
284    wrapper: Option<&str>,
285    base: &str,
286    place_map: &BTreeMap<usize, String>,
287) -> Option<String> {
288    let max = *place_map.keys().max()?; // empty (event-type only) -> None, skip
289    // Skip a group whose SUBJECT (x1) is an internal witness Skolem (`#N`): these
290    // are presupposition / existential witnesses (e.g. the witness an existential
291    // query found), not user-given facts — listing them as "(given)" is noise.
292    if place_map.get(&1).is_some_and(|s| s.starts_with('#')) {
293        return None;
294    }
295    let mut slots: Vec<Option<String>> = vec![None; max];
296    for (&p, filler) in place_map {
297        if (1..=max).contains(&p) {
298            slots[p - 1] = Some(filler.clone());
299        }
300    }
301    let filled = fill_template(&frame_template(base), &slots);
302    if filled.trim().is_empty() {
303        None
304    } else {
305        Some(prefix_wrapper(wrapper, filled))
306    }
307}
308
309/// `dog → animal` -> "every dog is an animal"; `increases ∧ cinla ∧ chemical → dangerous`
310/// -> "every chemical that increases and is thin is in danger". When the rule's
311/// conditions carry a class restrictor ("{x1} is a &lt;noun&gt;") it heads the
312/// reading ("every &lt;noun&gt; …"); otherwise it falls back to "anything that …".
313/// Either way the bare algebra variable `X` is gone. Each side may carry several
314/// base predicates; abstraction operators (`nu`/`du'u`/…) are dropped.
315///
316/// HONEST LIMIT: the rule LABEL has lost variable identity, so the reading treats
317/// the rule as a single-variable universal. For the common case (`ro lo X cu Y`,
318/// `ganai P gi Q`) that is correct; a multi-variable prenex join is an
319/// approximation — `:proof-verbose` is the authoritative view.
320pub(crate) fn rule_to_english(label: &str) -> Option<String> {
321    let (lhs, rhs) = label.split_once(" → ")?;
322    let conds: Vec<&str> = lhs.split(" ∧ ").map(str::trim).collect();
323    let concls: Vec<String> = rhs
324        .split(" ∧ ")
325        .filter_map(|c| relation_predicate(c.trim()))
326        .collect();
327    if concls.is_empty() {
328        return None;
329    }
330    // A class restrictor ("{x1} is a <noun>") among the conditions heads the
331    // universal as "every <noun> that …"; the rest become subject-elided phrases.
332    let mut head: Option<String> = None;
333    let mut cond_phrases: Vec<String> = Vec::new();
334    for &c in &conds {
335        if head.is_none()
336            && let Some(noun) = class_noun(c)
337        {
338            head = Some(noun);
339            continue;
340        }
341        if let Some(p) = relation_predicate(c) {
342            cond_phrases.push(p);
343        }
344    }
345    let subject_clause = match head {
346        Some(noun) if cond_phrases.is_empty() => format!("every {noun}"),
347        Some(noun) => format!("every {noun} that {}", join_and(&cond_phrases)),
348        None if cond_phrases.is_empty() => return None,
349        None => format!("anything that {}", join_and(&cond_phrases)),
350    };
351    Some(format!("{subject_clause} {}", join_and(&concls)))
352}
353
354/// The subject-stripped predicate phrase of a rule clause ("increases", "permits
355/// something") — the universal reading elides the shared subject. `None` for an
356/// abstraction operator or an empty fill.
357///
358/// A `se`-converted overlay template ("{x2} is metabolized by {x1}") puts the
359/// subject in x2, so eliding x1 leaves a spurious leading "something" and a now-
360/// dangling trailing particle ("something is metabolized by"); both are trimmed so
361/// the phrase reads "is metabolized".
362fn relation_predicate(relation: &str) -> Option<String> {
363    let phrase = relation_clause(relation, "")?;
364    let phrase = phrase.trim();
365    let phrase = phrase.strip_prefix("something ").unwrap_or(phrase);
366    let phrase = strip_trailing_particle(phrase).trim();
367    if phrase.is_empty() {
368        None
369    } else {
370        Some(phrase.to_string())
371    }
372}
373
374/// If `relation`'s frame is a class predicate ("{x1} is a/an &lt;noun&gt;"),
375/// return the bare noun ("dog", "chemical"); otherwise `None`. Drives the
376/// "every &lt;noun&gt; …" universal heading.
377fn class_noun(relation: &str) -> Option<String> {
378    if is_abstraction(relation) {
379        return None;
380    }
381    let phrase = relation_predicate(relation)?;
382    for prefix in ["is a ", "is an "] {
383        if let Some(noun) = phrase.strip_prefix(prefix) {
384            return Some(noun.to_string());
385        }
386    }
387    None
388}
389
390/// Render a single rule-clause predicate with `subject` in x1 and a generic
391/// "something" in every other place of its frame, so a multi-place predicate
392/// reads "permits something" / "is a rule about something" instead of collapsing
393/// to the bare subject. Called with an empty `subject` by [`relation_predicate`]
394/// to get the subject-elided phrase for the universal "every …/anything that …"
395/// reading. `None` for an abstraction operator (no surface frame) or an empty fill.
396fn relation_clause(relation: &str, subject: &str) -> Option<String> {
397    if is_abstraction(relation) {
398        return None;
399    }
400    let tmpl = frame_template(relation);
401    let n = template_max_place(&tmpl).max(1);
402    let mut places = vec![Some("something".to_string()); n];
403    places[0] = Some(subject.to_string());
404    let filled = fill_template(&tmpl, &places);
405    let trimmed = filled.trim();
406    // Reject a degenerate fill that is just the bare subject (the relation had no
407    // usable gloss/frame text) — dropping the clause beats emitting "X".
408    if trimmed.is_empty() || trimmed == subject {
409        None
410    } else {
411        Some(trimmed.to_string())
412    }
413}
414
415/// Abstraction type predicates (the `AbstractionKind` set — `event`/`fact`/
416/// `property`/`amount`/`concept`): they wrap an event/property and have no
417/// surface place-frame, so they are skipped when glossing a rule.
418fn is_abstraction(relation: &str) -> bool {
419    crate::is_internal_relation(relation)
420        || matches!(
421            relation,
422            "event" | "fact" | "property" | "amount" | "concept"
423        )
424}
425
426/// `[Why]` supporting-clause phrasing for a computed result, distinguishing a
427/// LOCAL computation from a value TRUSTED from the external backend (an oracle,
428/// not a derivation) — the same honesty distinction the collapsed proof label makes.
429fn computed_extra_label(method: &str) -> &'static str {
430    match method {
431        "backend" => "computed by the trusted backend",
432        "arithmetic" | "numeric" => "computed locally",
433        _ => "computed",
434    }
435}
436
437/// Compute results + equality substitutions, as supporting clauses.
438fn collect_extras(trace: &ProofTrace) -> Vec<String> {
439    let mut out: Vec<String> = Vec::new();
440    for step in &trace.steps {
441        let clause = match &step.rule {
442            ProofRule::ComputeCheck { detail, method } => {
443                format!(
444                    "{} ({})",
445                    humanize_fact(detail),
446                    computed_extra_label(method)
447                )
448            }
449            ProofRule::EqualitySubstitution {
450                original,
451                substituted,
452                ..
453            } => format!(
454                "{} is the same as {}",
455                humanize_fact(original),
456                humanize_fact(substituted)
457            ),
458            _ => continue,
459        };
460        if !out.contains(&clause) {
461            out.push(clause);
462        }
463    }
464    out
465}
466
467// ── small text helpers ──
468
469/// `["a"]` -> "a"; `["a","b"]` -> "a and b"; `["a","b","c"]` -> "a, b, and c".
470fn join_and(items: &[String]) -> String {
471    match items {
472        [] => String::new(),
473        [a] => a.clone(),
474        [a, b] => format!("{a} and {b}"),
475        [rest @ .., last] => format!("{}, and {}", rest.join(", "), last),
476    }
477}
478
479fn prefix_wrapper(wrapper: Option<&str>, clause: String) -> String {
480    match wrapper {
481        Some("past") => format!("in the past, {clause}"),
482        Some("present") => format!("currently, {clause}"),
483        Some("future") => format!("in the future, {clause}"),
484        Some("obligatory") => format!("it must be that {clause}"),
485        Some("permitted") => format!("it is permitted that {clause}"),
486        _ => clause,
487    }
488}
489
490/// "a dog" / "an animal" from a relation's gloss (best-effort indefinite
491/// article), via the overlay -> dictionary -> bare resolution chain.
492fn a_noun(relation: &str) -> String {
493    let gloss = gloss_for(relation);
494    let article = match gloss.chars().next() {
495        Some(c) if "aeiou".contains(c.to_ascii_lowercase()) => "an",
496        _ => "a",
497    };
498    format!("{article} {gloss}")
499}
500
501fn wrapper_label(name: &str) -> Option<&'static str> {
502    match name {
503        "Past" => Some("past"),
504        "Present" => Some("present"),
505        "Future" => Some("future"),
506        "Obligatory" => Some("obligatory"),
507        "Permitted" => Some("permitted"),
508        _ => None,
509    }
510}
511
512/// Parse a raw nibli-reason fact string into `(wrapper, relation, args)`. `None` for
513/// anything that is not the `relation(args)` / `Wrapper(relation(args))` form.
514pub(crate) fn parse_raw_fact(raw: &str) -> Option<(Option<String>, String, Vec<String>)> {
515    let s = raw.trim();
516    if s.is_empty() || s.starts_with('(') {
517        return None;
518    }
519    let Some(open) = s.find('(') else {
520        // Bare relation, no args.
521        return Some((None, s.to_string(), Vec::new()));
522    };
523    if !s.ends_with(')') {
524        return None;
525    }
526    let name = &s[..open];
527    let inner = &s[open + 1..s.len() - 1];
528    if let Some(label) = wrapper_label(name) {
529        let (_, rel, args) = parse_raw_fact(inner)?;
530        return Some((Some(label.to_string()), rel, args));
531    }
532    Some((None, name.to_string(), split_top_level_commas(inner)))
533}
534
535/// Split on commas that are not nested inside parentheses.
536fn split_top_level_commas(s: &str) -> Vec<String> {
537    let mut out = Vec::new();
538    let mut depth = 0i32;
539    let mut cur = String::new();
540    for c in s.chars() {
541        match c {
542            '(' => {
543                depth += 1;
544                cur.push(c);
545            }
546            ')' => {
547                depth -= 1;
548                cur.push(c);
549            }
550            ',' if depth == 0 => {
551                let t = cur.trim();
552                if !t.is_empty() {
553                    out.push(t.to_string());
554                }
555                cur.clear();
556            }
557            _ => cur.push(c),
558        }
559    }
560    let t = cur.trim();
561    if !t.is_empty() {
562        out.push(t.to_string());
563    }
564    out
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use nibli_protocol::ProofStep;
571
572    fn step(rule: ProofRule, holds: bool, children: Vec<u32>) -> ProofStep {
573        ProofStep {
574            rule,
575            holds,
576            children,
577        }
578    }
579
580    #[test]
581    fn fact_to_english_role_predicate() {
582        // Robust across XML (curated "adam is a dog") and no-XML fallback
583        // ("adam is dog") builds — assert the subject + the gloss noun.
584        let dog = fact_to_english("dog_x1(sk_2, adam)", Register::Spec).unwrap();
585        assert!(dog.starts_with("adam"), "got: {dog}");
586        assert!(dog.contains("dog"), "got: {dog}");
587        let animal = fact_to_english("animal_x1(sk_3, adam)", Register::Spec).unwrap();
588        assert!(animal.starts_with("adam"), "got: {animal}");
589        assert!(animal.contains("animal"), "got: {animal}");
590    }
591
592    #[test]
593    fn fact_to_english_unknown_relation_returns_none_or_generic() {
594        // An uncurated relation falls back to its generic gloss frame, never an
595        // S-expr or panic. (`frobnicatezzzz` has no gloss -> "X is frobnicatezzzz".)
596        let out = fact_to_english("frobnicatezzzz_x1(sk_9, adam)", Register::Spec);
597        assert!(out.is_some(), "should produce a generic frame");
598        assert!(out.unwrap().starts_with("adam"));
599    }
600
601    #[test]
602    fn summarize_syllogism_true() {
603        // The Ch 11 syllogism shape: a `animal` event derived from `dog` facts
604        // via the rule `dog → animal`. Root holds.
605        let trace = ProofTrace {
606            steps: vec![
607                step(
608                    ProofRule::Asserted {
609                        fact: "dog_x1(sk_2, adam)".to_string(),
610                    },
611                    true,
612                    vec![],
613                ),
614                step(
615                    ProofRule::Derived {
616                        label: "dog ∧ gerku_x1 → animal ∧ danlu_x1".to_string(),
617                        fact: "animal_x1(sk_3, adam)".to_string(),
618                    },
619                    true,
620                    vec![0],
621                ),
622            ],
623            root: 1,
624            naf_dependent: false,
625            cwa_false: false,
626        };
627        let s = summarize_proof(&trace, Register::Spec).unwrap();
628        // The instantiated narrative: "Because adam is a dog, adam is an animal."
629        // Structure-robust across XML / no-XML builds (article/phrasing differ).
630        assert!(s.starts_with("Because "), "got: {s}");
631        assert!(s.ends_with('.'), "got: {s}");
632        // The given premise and the derived conclusion, both with the real entity.
633        assert!(
634            s.contains("adam") && s.contains("dog"),
635            "premise missing: {s}"
636        );
637        assert!(s.contains("animal"), "conclusion missing: {s}");
638        // No bare algebra variable leaks into the explanation.
639        assert!(!s.contains('X'), "bare variable leaked: {s}");
640    }
641
642    #[test]
643    fn summarize_naf_appends_note() {
644        let trace = ProofTrace {
645            steps: vec![
646                step(
647                    ProofRule::Asserted {
648                        fact: "dog_x1(sk_2, adam)".to_string(),
649                    },
650                    true,
651                    vec![],
652                ),
653                step(ProofRule::Negation, true, vec![0]),
654            ],
655            root: 1,
656            naf_dependent: true,
657            cwa_false: false,
658        };
659        let s = summarize_proof(&trace, Register::Spec).unwrap();
660        // The negation root is unphraseable as a derivation, so the fallback lists
661        // the asserted given; the NAF caveat is still appended.
662        assert!(s.contains("adam is a dog"), "given missing: {s}");
663        assert!(
664            s.contains("closed-world assumption"),
665            "naf note missing: {s}"
666        );
667    }
668
669    #[test]
670    fn summarize_false_predicate_not_found() {
671        let trace = ProofTrace {
672            steps: vec![step(
673                ProofRule::PredicateNotFound {
674                    predicate: "animal_x1(sk_3, adam)".to_string(),
675                },
676                false,
677                vec![],
678            )],
679            root: 0,
680            naf_dependent: false,
681            cwa_false: false,
682        };
683        let s = summarize_proof(&trace, Register::Spec).unwrap();
684        assert!(s.starts_with("Nothing known establishes that"), "got: {s}");
685        assert!(s.contains("adam") && s.contains("animal"), "got: {s}");
686    }
687
688    #[test]
689    fn summarize_compute_extra() {
690        let trace = ProofTrace {
691            steps: vec![step(
692                ProofRule::ComputeCheck {
693                    method: "arithmetic".to_string(),
694                    detail: "sumji(adam)".to_string(),
695                },
696                true,
697                vec![],
698            )],
699            root: 0,
700            naf_dependent: false,
701            cwa_false: false,
702        };
703        // No givens/rules, but a compute extra -> still summarized. A local
704        // arithmetic method is labelled "computed locally" (distinct from a
705        // trusted-backend result).
706        let s = summarize_proof(&trace, Register::Spec).unwrap();
707        assert!(s.contains("(computed locally)"), "got: {s}");
708    }
709
710    #[test]
711    fn rule_multi_predicate_conclusion_renders_each() {
712        // `dog → animal ∧ alive`: the universal rule reading renders both
713        // conclusion predicates, joined. (The "why" narrative shows the
714        // instantiated fact; the multi-conclusion lives in the rule justification.)
715        let e = rule_to_english("dog → animal ∧ alive").expect("renders");
716        assert!(e.starts_with("every dog"), "type head missing: {e}");
717        assert!(e.contains("animal"), "animal missing: {e}");
718        assert!(e.contains("alive"), "alive missing: {e}");
719        assert!(e.contains(" and "), "multi-conclusion not joined: {e}");
720    }
721
722    #[test]
723    fn rule_skips_abstraction_operators_no_broken_output() {
724        // `person → nu ∧ animal`: the abstraction `nu` is dropped (no surface
725        // frame); the conclusion never leaks a raw `∧` or a dangling subject.
726        let trace = ProofTrace {
727            steps: vec![step(
728                ProofRule::Derived {
729                    label: "person → nu ∧ animal".to_string(),
730                    fact: "animal_x1(sk_3, adam)".to_string(),
731                },
732                true,
733                vec![],
734            )],
735            root: 0,
736            naf_dependent: false,
737            cwa_false: false,
738        };
739        let s = summarize_proof(&trace, Register::Spec).expect("renders the animal conclusion");
740        assert!(!s.contains('∧'), "abstraction/raw-conjunction leaked: {s}");
741        assert!(!s.contains(" X and X"), "broken output: {s}");
742        assert!(s.contains("animal"), "animal conclusion missing: {s}"); // nu skipped, animal kept
743    }
744
745    #[test]
746    fn rule_multi_place_predicate_keeps_its_object() {
747        // A 2-place rule (`permits → rule`) with no class restrictor reads as
748        // "anything that …"; multi-place predicates keep a generic object rather
749        // than collapsing to a dangling verb.
750        let e = rule_to_english("permits → rule").expect("multi-place rule now renders");
751        assert!(e.starts_with("anything that "), "got: {e}");
752        assert!(e.contains("permits something"), "permits truncated: {e}");
753        assert!(
754            e.contains("is a rule about something"),
755            "javni truncated: {e}"
756        );
757        // No bare variable, and no dangling multi-place verb with no object.
758        assert!(!e.contains('X'), "bare variable leaked: {e}");
759        assert!(!e.ends_with("permits"), "dangling: {e}");
760    }
761
762    #[test]
763    fn rule_single_place_reads_as_every_type() {
764        // A class restrictor heads the universal: "every dog is an animal" — no
765        // bare `X`, no spurious "something" filler.
766        assert_eq!(
767            rule_to_english("dog → animal").as_deref(),
768            Some("every dog is an animal")
769        );
770        let e = rule_to_english("dog → animal").unwrap();
771        assert!(!e.contains("something"), "1-place leaked a filler: {e}");
772        assert!(!e.contains('X'), "bare variable leaked: {e}");
773    }
774
775    #[test]
776    fn deontic_wrapper_is_preserved_on_the_statement() {
777        // A conclusion fact carrying an `Obligatory(…)` wrapper keeps its mood
778        // ("it must be that …") — render-only, no code change (the guard).
779        let e = fact_to_english("Obligatory(person(adam))", Register::Spec)
780            .expect("deontic flat fact renders");
781        assert!(e.contains("it must be that"), "mood dropped: {e}");
782        assert!(e.contains("person"), "predicate dropped: {e}");
783        // Permitted likewise.
784        let p = fact_to_english("Permitted(person(adam))", Register::Spec).unwrap();
785        assert!(p.contains("it is permitted that"), "mood dropped: {p}");
786    }
787
788    #[test]
789    fn join_and_variants() {
790        assert_eq!(join_and(&["a".into()]), "a");
791        assert_eq!(join_and(&["a".into(), "b".into()]), "a and b");
792        assert_eq!(
793            join_and(&["a".into(), "b".into(), "c".into()]),
794            "a, b, and c"
795        );
796    }
797}