1use 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
29pub 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
40pub 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
50pub 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 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 format!("there is {}", a_noun(&relation))
65 } else {
66 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 let steps = collapse_to_macrosteps(trace, register);
82 let mut clauses: Vec<String> = Vec::new();
83 narrate_steps(&steps, &mut clauses);
84 clauses.extend(collect_extras(trace));
86 if clauses.is_empty() {
87 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
102fn 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
122fn 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
148fn 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
162fn 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
214pub(crate) type LeafKey = (Option<String>, String, String);
216
217pub(crate) type EventGroup = (LeafKey, BTreeMap<usize, String>);
219
220pub(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 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 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 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
282pub(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()?; 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
309pub(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 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
354fn 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
374fn 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
390fn 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 if trimmed.is_empty() || trimmed == subject {
409 None
410 } else {
411 Some(trimmed.to_string())
412 }
413}
414
415fn is_abstraction(relation: &str) -> bool {
419 crate::is_internal_relation(relation)
420 || matches!(
421 relation,
422 "event" | "fact" | "property" | "amount" | "concept"
423 )
424}
425
426fn 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
437fn 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
467fn 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
490fn 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
512pub(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 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
535fn 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 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 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 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 assert!(s.starts_with("Because "), "got: {s}");
631 assert!(s.ends_with('.'), "got: {s}");
632 assert!(
634 s.contains("adam") && s.contains("dog"),
635 "premise missing: {s}"
636 );
637 assert!(s.contains("animal"), "conclusion missing: {s}");
638 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 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 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 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 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}"); }
744
745 #[test]
746 fn rule_multi_place_predicate_keeps_its_object() {
747 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 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 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 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 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}