Skip to main content

nibli_render/
logic.rs

1//! IR -> English back-translation.
2//!
3//! Walks the flat `LogicBuffer`, regroups Neo-Davidsonian role predicates
4//! (`dog(ev) ∧ gerku_x1(ev, x) ∧ gerku_x2(ev, zo'e)`) back into one place-frame
5//! per event, fills the curated English template, and renders the surrounding
6//! quantifier / connective structure. The output is structure-exposing: quantifier
7//! scope stays visible ("For every X, if X is a dog, then X is an animal").
8
9use std::collections::HashMap;
10
11use nibli_lexicon::get_gloss;
12use nibli_types::logic::{LogicBuffer, LogicNode, LogicalTerm};
13
14use crate::frame::{deontic_content_phrase, fill_template, frame_template};
15use crate::register::Register;
16use crate::term::{role_base, role_index};
17
18/// Abstraction-type scaffolding predicates (event/fact/property/…) that only
19/// exist to host a nested duty content — never surface as "Y is event".
20fn is_abstraction_scaffold(rel: &str) -> bool {
21    matches!(rel, "event" | "fact" | "property" | "amount" | "concept")
22}
23
24/// Deontic head predicates whose x1 is the duty/content and x2 the obligated party.
25fn is_deontic_duty_rel(rel: &str) -> bool {
26    matches!(rel, "obligated_by" | "obliged")
27}
28
29/// Render a compiled `LogicBuffer` as readable English.
30///
31/// Each root becomes one sentence (capitalized, terminated with `.`).
32pub fn render_logic_buffer(buf: &LogicBuffer, register: Register) -> String {
33    let mut ctx = Ctx::new(register);
34    let sentences: Vec<String> = buf
35        .roots
36        .iter()
37        .map(|&root| capitalize_terminate(&render_node(buf, root, &mut ctx)))
38        .filter(|s| !s.is_empty())
39        .collect();
40    sentences.join(" ")
41}
42
43/// Render a compiled `LogicBuffer` as an indented, one-node-per-line structural
44/// tree with functional term notation — the `[Logic]` half of `:debug`.
45///
46/// Unlike [`render_logic_buffer`] (which regroups Neo-Davidsonian role predicates
47/// into event place-frames and flattens And/Exists for readable English), this
48/// shows every node verbatim, so the reader sees the exact compiled FOL shape.
49/// The tree is always structural; `_register` is accepted only for signature
50/// symmetry with [`render_logic_buffer`] and is ignored. No LISP S-expression is
51/// ever emitted — terms render functionally (`dog(_ev0)`, `tenfa_x1(_ev0, 1024)`).
52pub fn render_logic_tree(buf: &LogicBuffer, _register: Register) -> String {
53    let mut out = String::with_capacity(256);
54    for (i, &root) in buf.roots.iter().enumerate() {
55        if i > 0 {
56            out.push('\n');
57        }
58        write_tree(&mut out, buf, root, 0);
59    }
60    out
61}
62
63fn write_tree(out: &mut String, buf: &LogicBuffer, id: u32, depth: usize) {
64    for _ in 0..depth {
65        out.push_str("  ");
66    }
67    let Some(node) = buf.nodes.get(id as usize) else {
68        out.push_str(&format!("[invalid node {id}]\n"));
69        return;
70    };
71    match node {
72        LogicNode::Predicate((rel, args)) => {
73            out.push_str(&format!("{rel}({})\n", render_term_list(args)));
74        }
75        LogicNode::ComputeNode((rel, args)) => {
76            out.push_str(&format!("{rel}({}) [compute]\n", render_term_list(args)));
77        }
78        LogicNode::AndNode((l, r)) => {
79            out.push_str("And:\n");
80            write_tree(out, buf, *l, depth + 1);
81            write_tree(out, buf, *r, depth + 1);
82        }
83        LogicNode::OrNode((l, r)) => {
84            out.push_str("Or:\n");
85            write_tree(out, buf, *l, depth + 1);
86            write_tree(out, buf, *r, depth + 1);
87        }
88        LogicNode::NotNode(inner) => {
89            out.push_str("\u{00ac}:\n"); // ¬
90            write_tree(out, buf, *inner, depth + 1);
91        }
92        LogicNode::ExistsNode((v, body)) => {
93            out.push_str(&format!("\u{2203} {v}:\n")); // ∃
94            write_tree(out, buf, *body, depth + 1);
95        }
96        LogicNode::ForAllNode((v, body)) => {
97            out.push_str(&format!("\u{2200} {v}:\n")); // ∀
98            write_tree(out, buf, *body, depth + 1);
99        }
100        LogicNode::PastNode(inner) => {
101            out.push_str("Past:\n");
102            write_tree(out, buf, *inner, depth + 1);
103        }
104        LogicNode::PresentNode(inner) => {
105            out.push_str("Present:\n");
106            write_tree(out, buf, *inner, depth + 1);
107        }
108        LogicNode::FutureNode(inner) => {
109            out.push_str("Future:\n");
110            write_tree(out, buf, *inner, depth + 1);
111        }
112        LogicNode::ObligatoryNode(inner) => {
113            out.push_str("Obligatory:\n");
114            write_tree(out, buf, *inner, depth + 1);
115        }
116        LogicNode::PermittedNode(inner) => {
117            out.push_str("Permitted:\n");
118            write_tree(out, buf, *inner, depth + 1);
119        }
120        LogicNode::CountNode((v, count, body)) => {
121            out.push_str(&format!("Count {v} = {count}:\n"));
122            write_tree(out, buf, *body, depth + 1);
123        }
124    }
125}
126
127fn render_term_list(args: &[LogicalTerm]) -> String {
128    args.iter()
129        .map(render_tree_term)
130        .collect::<Vec<_>>()
131        .join(", ")
132}
133
134fn render_tree_term(t: &LogicalTerm) -> String {
135    match t {
136        LogicalTerm::Variable(v) => v.clone(),
137        LogicalTerm::Constant(c) => c.clone(),
138        LogicalTerm::Description(d) => format!("the {d}"),
139        LogicalTerm::Unspecified => "something".to_string(),
140        LogicalTerm::Number(n) => format_number(*n),
141    }
142}
143
144struct Ctx {
145    #[allow(dead_code)]
146    register: Register,
147    var_names: HashMap<String, String>,
148}
149
150impl Ctx {
151    fn new(register: Register) -> Self {
152        Ctx {
153            register,
154            var_names: HashMap::new(),
155        }
156    }
157
158    /// Stable readable name for a logic variable: X, Y, Z, … by first-seen order.
159    fn var_name(&mut self, raw: &str) -> String {
160        if let Some(v) = self.var_names.get(raw) {
161            return v.clone();
162        }
163        const LETTERS: &[&str] = &["X", "Y", "Z", "W", "V", "U", "T", "S"];
164        let n = self.var_names.len();
165        let name = LETTERS
166            .get(n)
167            .map(|s| s.to_string())
168            .unwrap_or_else(|| format!("X{n}"));
169        self.var_names.insert(raw.to_string(), name.clone());
170        name
171    }
172}
173
174fn render_node(buf: &LogicBuffer, id: u32, ctx: &mut Ctx) -> String {
175    let Some(node) = buf.nodes.get(id as usize) else {
176        return String::new();
177    };
178    match node {
179        LogicNode::ForAllNode((var, body)) => render_forall(buf, var, *body, ctx),
180        LogicNode::CountNode((var, count, body)) => {
181            let _ = ctx.var_name(var);
182            format!(
183                "exactly {} things are such that {}",
184                count,
185                render_node(buf, *body, ctx)
186            )
187        }
188        LogicNode::OrNode((l, r)) => {
189            format!(
190                "either {} or {}",
191                render_node(buf, *l, ctx),
192                render_node(buf, *r, ctx)
193            )
194        }
195        LogicNode::NotNode(inner) => {
196            format!("it is not the case that {}", render_node(buf, *inner, ctx))
197        }
198        LogicNode::PastNode(inner) => format!("in the past, {}", render_node(buf, *inner, ctx)),
199        LogicNode::PresentNode(inner) => format!("currently, {}", render_node(buf, *inner, ctx)),
200        LogicNode::FutureNode(inner) => format!("in the future, {}", render_node(buf, *inner, ctx)),
201        LogicNode::ObligatoryNode(inner) => {
202            format!("it is required that {}", render_node(buf, *inner, ctx))
203        }
204        LogicNode::PermittedNode(inner) => {
205            format!("it is permitted that {}", render_node(buf, *inner, ctx))
206        }
207        // Conjunctions, existentials, and bare predicates all flatten into a set
208        // of event frames rendered together.
209        LogicNode::AndNode(_)
210        | LogicNode::ExistsNode(_)
211        | LogicNode::Predicate(_)
212        | LogicNode::ComputeNode(_) => render_conjunction(buf, id, ctx),
213    }
214}
215
216/// A universal: `∀var. body`, where `body` is usually the material conditional
217/// `Or(Not(antecedent), consequent)`.
218fn render_forall(buf: &LogicBuffer, var: &str, body: u32, ctx: &mut Ctx) -> String {
219    let x = ctx.var_name(var); // establish var -> X before rendering the body
220    if let Some(LogicNode::OrNode((l, r))) = buf.nodes.get(body as usize)
221        && let Some(LogicNode::NotNode(ante)) = buf.nodes.get(*l as usize)
222    {
223        let antecedent = render_node(buf, *ante, ctx);
224        let consequent = render_node(buf, *r, ctx);
225        return format!("for every {x}, if {antecedent}, then {consequent}");
226    }
227    let inner = render_node(buf, body, ctx);
228    format!("for every {x}, {inner}")
229}
230
231/// Collect every predicate reachable through And/Exists from `id` into event
232/// frames, render each, and conjoin them with any non-predicate sub-clauses.
233fn render_conjunction(buf: &LogicBuffer, id: u32, ctx: &mut Ctx) -> String {
234    let mut preds: Vec<(String, Vec<LogicalTerm>)> = Vec::new();
235    let mut extras: Vec<String> = Vec::new();
236    collect(buf, id, ctx, &mut preds, &mut extras);
237
238    let mut clauses = build_frames(&preds, ctx);
239    clauses.extend(extras);
240    join_clauses(&clauses)
241}
242
243fn collect(
244    buf: &LogicBuffer,
245    id: u32,
246    ctx: &mut Ctx,
247    preds: &mut Vec<(String, Vec<LogicalTerm>)>,
248    extras: &mut Vec<String>,
249) {
250    let Some(node) = buf.nodes.get(id as usize) else {
251        return;
252    };
253    match node {
254        LogicNode::AndNode((l, r)) => {
255            collect(buf, *l, ctx, preds, extras);
256            collect(buf, *r, ctx, preds, extras);
257        }
258        // Existential binders (event vars, witnesses) are transparent for frame
259        // collection — descend into the body.
260        LogicNode::ExistsNode((_var, body)) => collect(buf, *body, ctx, preds, extras),
261        LogicNode::Predicate((rel, args)) | LogicNode::ComputeNode((rel, args)) => {
262            // The opaque abstraction marker (`__abs_<hash>`) is an internal
263            // reasoning artifact, not surface content — never render it.
264            if crate::is_internal_relation(rel) {
265                return;
266            }
267            preds.push((rel.clone(), args.clone()));
268        }
269        // Any logical structure nested inside the conjunction is rendered as its
270        // own clause and conjoined.
271        _ => extras.push(render_node(buf, id, ctx)),
272    }
273}
274
275/// One accumulated event frame: the place fillers for a `(event, base-relation)`.
276struct FrameAcc {
277    base: String,
278    /// Role-place index (1-based) -> filler term. Empty for a flat (non-decomposed) fact.
279    places: HashMap<usize, LogicalTerm>,
280    /// Args of the non-role occurrence (flat fact, or the type predicate's event arg).
281    flat_args: Vec<LogicalTerm>,
282    has_roles: bool,
283}
284
285fn term_key(t: Option<&LogicalTerm>) -> String {
286    match t {
287        Some(LogicalTerm::Variable(s)) => format!("v:{s}"),
288        Some(LogicalTerm::Constant(s)) => format!("c:{s}"),
289        Some(LogicalTerm::Description(s)) => format!("d:{s}"),
290        Some(LogicalTerm::Number(n)) => format!("n:{n}"),
291        Some(LogicalTerm::Unspecified) => "u".to_string(),
292        None => "_".to_string(),
293    }
294}
295
296fn build_frames(preds: &[(String, Vec<LogicalTerm>)], ctx: &mut Ctx) -> Vec<String> {
297    let mut order: Vec<(String, String)> = Vec::new();
298    let mut map: HashMap<(String, String), FrameAcc> = HashMap::new();
299
300    for (rel, args) in preds {
301        if let (Some(base), Some(idx)) = (role_base(rel), role_index(rel)) {
302            // Role predicate rel_xN(event, filler).
303            let key = (term_key(args.first()), base.to_string());
304            let acc = map.entry(key.clone()).or_insert_with(|| {
305                order.push(key.clone());
306                FrameAcc {
307                    base: base.to_string(),
308                    places: HashMap::new(),
309                    flat_args: Vec::new(),
310                    has_roles: false,
311                }
312            });
313            acc.has_roles = true;
314            if let Some(filler) = args.get(1) {
315                acc.places.insert(idx, filler.clone());
316            }
317        } else {
318            // Type predicate rel(event) or a flat fact rel(a, b, …).
319            let key = (term_key(args.first()), rel.clone());
320            let acc = map.entry(key.clone()).or_insert_with(|| {
321                order.push(key.clone());
322                FrameAcc {
323                    base: rel.clone(),
324                    places: HashMap::new(),
325                    flat_args: Vec::new(),
326                    has_roles: false,
327                }
328            });
329            if acc.flat_args.is_empty() {
330                acc.flat_args = args.clone();
331            }
332        }
333    }
334
335    let accs: Vec<FrameAcc> = order
336        .into_iter()
337        .filter_map(|key| map.remove(&key))
338        .collect();
339    let accs = collapse_deontic_event_duties(accs);
340    accs.into_iter()
341        .map(|acc| render_frame(&acc, ctx))
342        .filter(|s| !s.is_empty())
343        .collect()
344}
345
346/// Collapse `obligated_by(person, event { P() })` packaging:
347///   event(Y) ∧ P(…) ∧ obliged(Y, X)  →  "X is obligated to be P"
348/// without the word-salad "Y is event and Y is obligated to X".
349fn collapse_deontic_event_duties(accs: Vec<FrameAcc>) -> Vec<FrameAcc> {
350    let has_scaffold = accs.iter().any(|a| is_abstraction_scaffold(&a.base));
351    let has_deontic = accs.iter().any(|a| is_deontic_duty_rel(&a.base));
352    if !has_scaffold || !has_deontic {
353        return accs;
354    }
355
356    let content: Vec<String> = accs
357        .iter()
358        .filter(|a| !is_deontic_duty_rel(&a.base) && !is_abstraction_scaffold(&a.base))
359        .map(|a| deontic_content_phrase(&a.base))
360        .collect();
361    if content.is_empty() {
362        return accs;
363    }
364    let content_joined = match content.len() {
365        1 => content[0].clone(),
366        2 => format!("{} and {}", content[0], content[1]),
367        _ => {
368            let head = &content[..content.len() - 1];
369            format!("{}, and {}", head.join(", "), content[content.len() - 1])
370        }
371    };
372
373    // One rewritten deontic frame per original deontic (usually one), carrying the
374    // obligated party in place 2; place 1 becomes the English content phrase via
375    // a synthetic constant so fill_template still works.
376    let mut out = Vec::new();
377    for acc in &accs {
378        if !is_deontic_duty_rel(&acc.base) {
379            continue;
380        }
381        let who = acc
382            .places
383            .get(&2)
384            .cloned()
385            .or_else(|| acc.flat_args.get(1).cloned());
386        let mut places = HashMap::new();
387        places.insert(1, LogicalTerm::Constant(content_joined.clone()));
388        if let Some(w) = who {
389            places.insert(2, w);
390        }
391        out.push(FrameAcc {
392            base: "obligated_by".into(),
393            places,
394            flat_args: Vec::new(),
395            has_roles: true,
396        });
397    }
398    // Prefer the collapsed form; if we somehow found no deontic heads, fall back.
399    if out.is_empty() { accs } else { out }
400}
401
402fn render_frame(acc: &FrameAcc, ctx: &mut Ctx) -> String {
403    // Deontic collapse stores the content phrase as place-1 constant and the
404    // obligated party as place-2 — render with a dedicated template so we get
405    // "X is obligated to be secure" not "X is obligated that be secure".
406    if is_deontic_duty_rel(&acc.base)
407        && let (Some(LogicalTerm::Constant(content)), Some(who_term)) =
408            (acc.places.get(&1), acc.places.get(&2))
409        && let Some(who) = render_term(who_term, ctx)
410    {
411        // Content phrases we synthesized start with "be " / bare verb — use "to".
412        if content.starts_with("be ")
413            || !content.contains(" is ")
414                && !content.chars().next().is_some_and(|c| c.is_uppercase())
415        {
416            return format!("{who} is obligated to {content}");
417        }
418        return format!("{who} is obligated that {content}");
419    }
420    let places: Vec<Option<String>> = if acc.has_roles {
421        let max_idx = acc.places.keys().copied().max().unwrap_or(0);
422        (1..=max_idx)
423            .map(|i| acc.places.get(&i).and_then(|t| render_term(t, ctx)))
424            .collect()
425    } else {
426        acc.flat_args.iter().map(|t| render_term(t, ctx)).collect()
427    };
428    fill_template(&frame_template(&acc.base), &places)
429}
430
431/// Render a term as an English noun phrase, or `None` for an unspecified place.
432fn render_term(t: &LogicalTerm, ctx: &mut Ctx) -> Option<String> {
433    match t {
434        LogicalTerm::Constant(s) => Some(display_constant(s)),
435        LogicalTerm::Description(s) => Some(format!("the {}", get_gloss(s).unwrap_or(s.as_str()))),
436        LogicalTerm::Variable(n) => Some(ctx.var_name(n)),
437        LogicalTerm::Number(n) => Some(format_number(*n)),
438        LogicalTerm::Unspecified => None,
439    }
440}
441
442/// Constants arrive lowercased from the IR (`bela`, `highsec`). Title-case the
443/// first letter for readable English; leave mixed/upper inputs alone.
444fn display_constant(s: &str) -> String {
445    let mut chars = s.chars();
446    match chars.next() {
447        Some(c)
448            if c.is_ascii_lowercase() && s.bytes().all(|b| b.is_ascii_lowercase() || b == b'_') =>
449        {
450            // highsec → Highsec; snake_case → first letter only (good enough).
451            format!("{}{}", c.to_ascii_uppercase(), chars.as_str())
452        }
453        Some(c) => format!("{c}{}", chars.as_str()),
454        None => String::new(),
455    }
456}
457
458fn format_number(n: f64) -> String {
459    if n == (n as i64) as f64 {
460        format!("{}", n as i64)
461    } else {
462        format!("{n}")
463    }
464}
465
466fn join_clauses(clauses: &[String]) -> String {
467    match clauses.len() {
468        0 => String::new(),
469        1 => clauses[0].clone(),
470        2 => format!("{} and {}", clauses[0], clauses[1]),
471        _ => {
472            let head = &clauses[..clauses.len() - 1];
473            format!("{}, and {}", head.join(", "), clauses[clauses.len() - 1])
474        }
475    }
476}
477
478fn capitalize_terminate(s: &str) -> String {
479    let s = s.trim();
480    if s.is_empty() {
481        return String::new();
482    }
483    let mut chars = s.chars();
484    let first = chars.next().unwrap().to_uppercase().to_string();
485    let mut out = format!("{first}{}", chars.as_str());
486    if !out.ends_with('.') {
487        out.push('.');
488    }
489    out
490}