Skip to main content

nibli_render/
lib.rs

1//! Shared human-readable rendering for Nibli.
2//!
3//! ONE place that turns the engine's internal representations into English for
4//! the two surfaces humans read to *verify* the engine:
5//!
6//! - [`render_logic_buffer`] — the Transparency Triad back-translation: compiles
7//!   a `LogicBuffer` (the FOL IR) into structure-exposing English.
8//! - [`humanize_fact`] / [`render_proof`] / [`render_proof_text`] — readable proof
9//!   traces, sharing the same fact humanizer and term rendering.
10//!
11//! Rendering is pure: it reads `LogicBuffer`/`ProofTrace` and never mutates a
12//! verdict or a proof's tree shape. Unknown predicates fall back to a generic
13//! `relation(args)` / gloss-based frame — never invented English.
14
15mod collapse;
16mod corpus_overlay;
17mod fact;
18mod frame;
19mod logic;
20mod overlay;
21mod proof;
22mod register;
23mod summary;
24mod term;
25
26pub use collapse::{
27    collapse_proof, collapse_proof_with, render_collapsed_text, render_collapsed_text_with,
28    render_node_text,
29};
30pub use corpus_overlay::{DRUG_INTERACTIONS_OVERLAY, GDPR_OVERLAY, UTOPIA_OVERLAY};
31pub use fact::humanize_fact;
32pub use logic::{render_logic_buffer, render_logic_tree};
33pub use overlay::DomainGloss;
34pub use proof::{
35    RenderedNode, css_class, icon, label, render_proof, render_proof_text,
36    render_proof_text_indented, trace_display,
37};
38pub use register::Register;
39pub use summary::{fact_to_english, summarize_proof, summarize_proof_with};
40
41/// True for relations that are internal reasoning artifacts, not surface content —
42/// currently the opaque abstraction marker (`__abs_<hash>`) nibli-semantics emits for
43/// `nu`/`du'u`/`ka`/`ni`/`si'o`. The renderer drops these everywhere so they never
44/// appear in back-translation, proof summaries, or fact listings.
45pub(crate) fn is_internal_relation(rel: &str) -> bool {
46    rel.starts_with("__abs_")
47}
48
49#[cfg(test)]
50mod tests {
51    use super::*;
52    use nibli_types::logic::{LogicBuffer, LogicNode, LogicalTerm};
53
54    /// Hand-build the compiled IR for `ro lo dog cu animal` ("every dog is an
55    /// animal"): `∀v0. (∃ev0. dog(ev0) ∧ gerku_x1(ev0,v0) ∧ gerku_x2(ev0,zo'e))
56    /// → (∃ev1. animal(ev1) ∧ danlu_x1(ev1,v0) ∧ danlu_x2(ev1,zo'e))`.
57    fn syllogism_buffer() -> LogicBuffer {
58        let v0 = || LogicalTerm::Variable("_v0".to_string());
59        let ev0 = || LogicalTerm::Variable("_ev0".to_string());
60        let ev1 = || LogicalTerm::Variable("_ev1".to_string());
61        let nodes = vec![
62            LogicNode::Predicate(("dog".into(), vec![ev0()])), // 0
63            LogicNode::Predicate(("dog_x1".into(), vec![ev0(), v0()])), // 1
64            LogicNode::Predicate(("dog_x2".into(), vec![ev0(), LogicalTerm::Unspecified])), // 2
65            LogicNode::AndNode((0, 1)),                        // 3
66            LogicNode::AndNode((3, 2)),                        // 4
67            LogicNode::ExistsNode(("_ev0".into(), 4)),         // 5
68            LogicNode::NotNode(5),                             // 6
69            LogicNode::Predicate(("animal".into(), vec![ev1()])), // 7
70            LogicNode::Predicate(("animal_x1".into(), vec![ev1(), v0()])), // 8
71            LogicNode::Predicate(("animal_x2".into(), vec![ev1(), LogicalTerm::Unspecified])), // 9
72            LogicNode::AndNode((7, 8)),                        // 10
73            LogicNode::AndNode((10, 9)),                       // 11
74            LogicNode::ExistsNode(("_ev1".into(), 11)),        // 12
75            LogicNode::OrNode((6, 12)),                        // 13
76            LogicNode::ForAllNode(("_v0".into(), 13)),         // 14
77        ];
78        LogicBuffer {
79            nodes,
80            roots: vec![14],
81        }
82    }
83
84    #[test]
85    fn syllogism_back_translation_is_readable_and_scope_exposing() {
86        let out = render_logic_buffer(&syllogism_buffer(), Register::Spec);
87        assert_eq!(out, "For every X, if X is a dog, then X is an animal.");
88        // The whole point: not the old word-salad gloss.
89        assert_ne!(out, "all the dog animal");
90    }
91
92    #[test]
93    fn utopia_floor_obligation_is_not_event_word_salad() {
94        // obligated_by(every person, event { secure() }) must NOT read
95        // "Y is event and Y is obligated to X".
96        let ast = nibli_kr::parse_checked("obligated_by(every person, event { secure() }).")
97            .expect("parse");
98        let buf = nibli_semantics::compile_from_ast(ast).expect("compile");
99        let out = render_logic_buffer(&buf, Register::Spec);
100        assert!(
101            out.to_lowercase().contains("obligated to be secure")
102                || out.to_lowercase().contains("obligated to be safe"),
103            "expected deontic+content collapse, got: {out}"
104        );
105        assert!(
106            !out.to_lowercase().contains("is event"),
107            "scaffolding 'event' leaked: {out}"
108        );
109    }
110
111    /// `entitled` is the claim-right predicate: x1 = holder, x2 = entitlement,
112    /// x3 = standard. Two properties matter for reader-facing honesty and are
113    /// pinned here.
114    #[test]
115    fn entitled_keeps_the_holder_in_subject_position_and_hides_the_standard() {
116        let render = |t: &str| {
117            let ast = nibli_kr::parse_checked(t).expect("parse");
118            let buf = nibli_semantics::compile_from_ast(ast).expect("compile");
119            render_logic_buffer(&buf, Register::Spec)
120        };
121
122        // (1) The x3 `standard` place NEVER reaches the English — the corpus
123        // template is 2-placeholder, so a filled OR unfilled standard is
124        // invisible. An unconditional floor therefore cannot read as though it
125        // carried a condition (the reason this entry exists rather than reusing
126        // `deserve`, whose x2/x3 are "wage"/"work").
127        assert_eq!(
128            render("entitled(Adam, Bread)."),
129            "Adam is entitled to Bread."
130        );
131        assert_eq!(
132            render("entitled(Adam, Bread, Law)."),
133            "Adam is entitled to Bread.",
134            "the `standard` place must not leak into the back-translation"
135        );
136
137        // (2) In the rights-floor form the HOLDER stays the grammatical subject.
138        // Contrast `permitted`, whose x1<->x2 swap surfaces as "Y permits X" —
139        // exactly the inversion a floor right must not have.
140        let floor = render("entitled(every person, event { eats() }).");
141        assert!(
142            floor.contains("X is entitled to"),
143            "the holder must stay in subject position, got: {floor}"
144        );
145        assert!(
146            !floor.contains("entitles"),
147            "the floor must not invert into an entitle-the-party reading: {floor}"
148        );
149        // KNOWN LIMITATION (pre-existing, not specific to `entitled`): the
150        // abstraction-scaffold collapse in logic.rs (`collapse_deontic_event_duties`
151        // / `is_deontic_duty_rel`) only covers "obligated_by"/"obliged", so every other
152        // event-taking predicate still renders the "Y is an event and …" scaffold —
153        // including the shipped GDPR Art 15 right `permitted(every person,
154        // event { data discovers() })`. Generalizing that collapse to be
155        // template-driven would let this read "X is entitled to eat"; it is
156        // deliberately NOT asserted here so the fix does not have to fight a test.
157    }
158
159    #[test]
160    fn lose_and_building_place_order_read_naturally() {
161        let lose = {
162            let ast = nibli_kr::parse_checked("lose(Points, Bela).").unwrap();
163            let buf = nibli_semantics::compile_from_ast(ast).unwrap();
164            render_logic_buffer(&buf, Register::Spec)
165        };
166        assert!(
167            lose.to_lowercase().contains("bela loses points"),
168            "got: {lose}"
169        );
170        let bld = {
171            let ast = nibli_kr::parse_checked("building(HighSec, Lalo).").unwrap();
172            let buf = nibli_semantics::compile_from_ast(ast).unwrap();
173            render_logic_buffer(&buf, Register::Spec)
174        };
175        assert!(
176            bld.to_lowercase().contains("lalo") && bld.to_lowercase().contains("highsec"),
177            "got: {bld}"
178        );
179        assert!(
180            bld.to_lowercase().contains("placed") || bld.to_lowercase().contains("housed"),
181            "got: {bld}"
182        );
183    }
184
185    #[test]
186    fn back_translation_exposes_scope() {
187        let out = render_logic_buffer(&syllogism_buffer(), Register::Spec);
188        assert!(
189            out.to_lowercase().contains("for every"),
190            "scope hidden: {out}"
191        );
192        assert!(
193            out.contains("if") && out.contains("then"),
194            "implication hidden: {out}"
195        );
196        assert!(out.contains("dog") && out.contains("animal"));
197    }
198
199    #[test]
200    fn spec_and_fluent_share_binder_order() {
201        let buf = syllogism_buffer();
202        let spec = render_logic_buffer(&buf, Register::Spec);
203        let fluent = render_logic_buffer(&buf, Register::Fluent);
204        // Whatever smoothing Fluent applies, the restrictor must precede the
205        // matrix in both — a scope/binder-order invariant.
206        let dog_before_animal = |s: &str| {
207            s.find("dog")
208                .zip(s.find("animal"))
209                .is_some_and(|(d, a)| d < a)
210        };
211        assert!(dog_before_animal(&spec), "spec: {spec}");
212        assert!(dog_before_animal(&fluent), "fluent: {fluent}");
213    }
214
215    #[test]
216    fn flat_fact_back_translation() {
217        // A directly-asserted flat fact animal(adam) -> "adam is an animal."
218        let buf = LogicBuffer {
219            nodes: vec![LogicNode::Predicate((
220                "animal".into(),
221                vec![LogicalTerm::Constant("adam".into())],
222            ))],
223            roots: vec![0],
224        };
225        assert_eq!(
226            render_logic_buffer(&buf, Register::Spec),
227            "Adam is an animal."
228        );
229    }
230
231    #[test]
232    fn interior_unspecified_place_is_not_dropped() {
233        // `goes fi le market` — x1 is unspecified (zo'e) but x3 is filled (le market).
234        // The English gloss must NOT collapse to empty (the pre-fix `:debug` bug);
235        // the interior/leading x1 renders the generic "something".
236        let ev0 = || LogicalTerm::Variable("_ev0".to_string());
237        let buf = LogicBuffer {
238            nodes: vec![
239                LogicNode::Predicate(("goes".into(), vec![ev0()])), // 0
240                LogicNode::Predicate(("goes_x1".into(), vec![ev0(), LogicalTerm::Unspecified])), // 1
241                LogicNode::Predicate((
242                    "goes_x3".into(),
243                    vec![ev0(), LogicalTerm::Description("market".into())],
244                )), // 2
245                LogicNode::AndNode((0, 1)),                // 3
246                LogicNode::AndNode((3, 2)),                // 4
247                LogicNode::ExistsNode(("_ev0".into(), 4)), // 5
248            ],
249            roots: vec![5],
250        };
251        let out = render_logic_buffer(&buf, Register::Spec);
252        assert!(
253            !out.is_empty(),
254            "an interior-unspecified frame must not render an empty gloss"
255        );
256        assert!(
257            out.to_lowercase().contains("something"),
258            "the unspecified x1 should render a generic filler, got: {out}"
259        );
260    }
261
262    #[test]
263    fn logic_tree_exposes_every_node_with_indentation() {
264        let tree = render_logic_tree(&syllogism_buffer(), Register::Spec);
265        // The structural tree shows the raw compiled FOL, NOT the regrouped English:
266        // every quantifier / connective / event binder is its own indented line.
267        assert!(tree.starts_with("\u{2200} _v0:\n"), "tree:\n{tree}");
268        assert!(tree.contains("\n  Or:\n"), "tree:\n{tree}");
269        assert!(tree.contains("\n    \u{00ac}:\n"), "tree:\n{tree}");
270        assert!(tree.contains("\n      \u{2203} _ev0:\n"), "tree:\n{tree}");
271        // Functional term notation (never LISP S-expr).
272        assert!(tree.contains("dog(_ev0)\n"), "tree:\n{tree}");
273        assert!(tree.contains("dog_x1(_ev0, _v0)\n"), "tree:\n{tree}");
274        assert!(tree.contains("dog_x2(_ev0, something)\n"), "tree:\n{tree}");
275        assert!(!tree.contains("(Pred"), "S-expr leaked: {tree}");
276        assert!(!tree.contains("(Cons"), "S-expr leaked: {tree}");
277    }
278
279    #[test]
280    fn logic_tree_renders_compute_and_integers() {
281        // A hand-built ComputeNode — the shape `exponential` takes once it is registered
282        // for compute dispatch (as in the Ch 18 `:debug` after `:compute exponential`);
283        // a bare `:debug li … exponential …` in a default session compiles `exponential` to a
284        // plain Predicate. Exercises the `[compute]` marker + integer term rendering.
285        let ev0 = || LogicalTerm::Variable("_ev0".to_string());
286        let buf = LogicBuffer {
287            nodes: vec![
288                LogicNode::ComputeNode(("exponential".into(), vec![ev0()])), // 0
289                LogicNode::Predicate((
290                    "exponential_x1".into(),
291                    vec![ev0(), LogicalTerm::Number(1024.0)],
292                )), // 1
293                LogicNode::AndNode((0, 1)),                                  // 2
294                LogicNode::Predicate((
295                    "exponential_x2".into(),
296                    vec![ev0(), LogicalTerm::Number(2.0)],
297                )), // 3
298                LogicNode::AndNode((2, 3)),                                  // 4
299                LogicNode::Predicate((
300                    "exponential_x3".into(),
301                    vec![ev0(), LogicalTerm::Number(10.0)],
302                )), // 5
303                LogicNode::AndNode((4, 5)),                                  // 6
304                LogicNode::ExistsNode(("_ev0".into(), 6)),                   // 7
305            ],
306            roots: vec![7],
307        };
308        let expected = "\u{2203} _ev0:\n  And:\n    And:\n      And:\n        exponential(_ev0) [compute]\n        exponential_x1(_ev0, 1024)\n      exponential_x2(_ev0, 2)\n    exponential_x3(_ev0, 10)\n";
309        assert_eq!(render_logic_tree(&buf, Register::Spec), expected);
310    }
311
312    #[test]
313    fn logic_tree_flat_fact() {
314        let buf = LogicBuffer {
315            nodes: vec![LogicNode::Predicate((
316                "animal".into(),
317                vec![LogicalTerm::Constant("adam".into())],
318            ))],
319            roots: vec![0],
320        };
321        assert_eq!(render_logic_tree(&buf, Register::Spec), "animal(adam)\n");
322    }
323
324    #[test]
325    fn logic_tree_invalid_root_is_reported() {
326        let buf = LogicBuffer {
327            nodes: vec![],
328            roots: vec![5],
329        };
330        assert_eq!(
331            render_logic_tree(&buf, Register::Spec),
332            "[invalid node 5]\n"
333        );
334    }
335}