Skip to main content

nibli_render/
fact.rs

1//! The single fact humanizer.
2//!
3//! Parses the flat display string produced by nibli-reason's `StoredFact::to_display_string()`
4//! — `relation(arg, arg)`, optionally wrapped in `Past(…)`/`Present(…)`/`Future(…)`/
5//! `Obligatory(…)`/`Permitted(…)`, with args that may be `sk_N(dep)` Skolem
6//! functions, `(a, b)` DepPairs, `the foo` descriptions, `_` (unspecified), numbers, or
7//! plain constants — and renders it readably: role predicates collapse
8//! (`gerku_x1` -> `dog.dog`), event Skolems are hidden, witness Skolems become
9//! `#N`.
10//!
11//! This replaces nibli-protocol's S-expr `humanize_fact`, which expected the
12//! `(Pred …)` representation and silently dropped arguments when fed the flat
13//! form that the proof trace actually carries.
14
15use crate::term::{collapse_role_name, humanize_skolem, is_event_skolem};
16
17/// Humanize a single flat fact-display string into readable notation.
18///
19/// Pure and total: any input that does not parse as the flat form (e.g. a stray
20/// S-expr starting with `(`) is returned unchanged.
21pub fn humanize_fact(input: &str) -> String {
22    let trimmed = input.trim();
23    if trimmed.is_empty() {
24        return String::new();
25    }
26    // Defensive passthrough: a leading '(' is not our `relation(...)` shape
27    // (e.g. a legacy S-expr `(Pred …)` or a bare DepPair) — leave it alone
28    // rather than mangle it.
29    if trimmed.starts_with('(') {
30        return trimmed.to_string();
31    }
32    let tokens = tokenize(trimmed);
33    let mut pos = 0;
34    match parse_fact(&tokens, &mut pos) {
35        Some(rendered) if pos == tokens.len() => rendered,
36        _ => trimmed.to_string(),
37    }
38}
39
40#[derive(Clone, Debug, PartialEq)]
41enum Tok {
42    Ident(String),
43    LParen,
44    RParen,
45    Comma,
46}
47
48fn tokenize(s: &str) -> Vec<Tok> {
49    let mut toks = Vec::new();
50    let mut buf = String::new();
51    for c in s.chars() {
52        match c {
53            '(' | ')' | ',' => {
54                let t = buf.trim();
55                if !t.is_empty() {
56                    toks.push(Tok::Ident(t.to_string()));
57                }
58                buf.clear();
59                toks.push(match c {
60                    '(' => Tok::LParen,
61                    ')' => Tok::RParen,
62                    _ => Tok::Comma,
63                });
64            }
65            _ => buf.push(c),
66        }
67    }
68    let t = buf.trim();
69    if !t.is_empty() {
70        toks.push(Tok::Ident(t.to_string()));
71    }
72    toks
73}
74
75fn is_wrapper(name: &str) -> Option<&'static str> {
76    match name {
77        "Past" => Some("past"),
78        "Present" => Some("present"),
79        "Future" => Some("future"),
80        "Obligatory" => Some("obligatory"),
81        "Permitted" => Some("permitted"),
82        _ => None,
83    }
84}
85
86/// fact := WRAPPER '(' fact ')' | predicate
87fn parse_fact(tokens: &[Tok], pos: &mut usize) -> Option<String> {
88    if let Some(Tok::Ident(name)) = tokens.get(*pos)
89        && let Some(label) = is_wrapper(name)
90        && tokens.get(*pos + 1) == Some(&Tok::LParen)
91    {
92        *pos += 2; // consume WRAPPER and '('
93        let inner = parse_fact(tokens, pos)?;
94        if tokens.get(*pos) != Some(&Tok::RParen) {
95            return None;
96        }
97        *pos += 1;
98        return Some(format!("[{label}] {inner}"));
99    }
100    parse_predicate(tokens, pos)
101}
102
103/// predicate := IDENT [ '(' termlist ')' ]
104fn parse_predicate(tokens: &[Tok], pos: &mut usize) -> Option<String> {
105    let Some(Tok::Ident(name)) = tokens.get(*pos) else {
106        return None;
107    };
108    let name = name.clone();
109    *pos += 1;
110    let args = if tokens.get(*pos) == Some(&Tok::LParen) {
111        *pos += 1;
112        let items = parse_term_list(tokens, pos)?;
113        if tokens.get(*pos) != Some(&Tok::RParen) {
114            return None;
115        }
116        *pos += 1;
117        items
118    } else {
119        Vec::new()
120    };
121    Some(render_predicate(&name, &args))
122}
123
124/// termlist := [ term (',' term)* ]
125fn parse_term_list(tokens: &[Tok], pos: &mut usize) -> Option<Vec<String>> {
126    let mut items = Vec::new();
127    if tokens.get(*pos) == Some(&Tok::RParen) {
128        return Some(items);
129    }
130    loop {
131        items.push(parse_term(tokens, pos)?);
132        match tokens.get(*pos) {
133            Some(Tok::Comma) => {
134                *pos += 1;
135            }
136            Some(Tok::RParen) => break,
137            _ => return None,
138        }
139    }
140    Some(items)
141}
142
143/// term := '(' termlist ')'            (DepPair)
144///       | IDENT '(' termlist ')'      (SkolemFn, e.g. sk_1(adam))
145///       | IDENT                       (constant / sk_N / "the foo" / "_" / number / "?x")
146///
147/// Returns the RAW reconstructed term string (Skolem humanization is applied
148/// later in `render_predicate`, after event-Skolem filtering on the raw form).
149fn parse_term(tokens: &[Tok], pos: &mut usize) -> Option<String> {
150    match tokens.get(*pos) {
151        Some(Tok::LParen) => {
152            *pos += 1;
153            let items = parse_term_list(tokens, pos)?;
154            if tokens.get(*pos) != Some(&Tok::RParen) {
155                return None;
156            }
157            *pos += 1;
158            Some(format!("({})", items.join(", ")))
159        }
160        Some(Tok::Ident(name)) => {
161            let name = name.clone();
162            *pos += 1;
163            if tokens.get(*pos) == Some(&Tok::LParen) {
164                *pos += 1;
165                let items = parse_term_list(tokens, pos)?;
166                if tokens.get(*pos) != Some(&Tok::RParen) {
167                    return None;
168                }
169                *pos += 1;
170                Some(format!("{}({})", name, items.join(", ")))
171            } else {
172                Some(name)
173            }
174        }
175        _ => None,
176    }
177}
178
179/// Render one predication: collapse a role-predicate name, hide event-Skolem
180/// arguments, humanize the surviving terms.
181fn render_predicate(name: &str, raw_args: &[String]) -> String {
182    let display_name = collapse_role_name(name).unwrap_or_else(|| name.to_string());
183    let kept: Vec<String> = raw_args
184        .iter()
185        .filter(|a| !is_event_skolem(a))
186        .map(|a| humanize_skolem(a))
187        .collect();
188    if kept.is_empty() {
189        display_name
190    } else {
191        format!("{}({})", display_name, kept.join(", "))
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn simple_flat_predicate() {
201        assert_eq!(humanize_fact("animal(adam)"), "animal(adam)");
202    }
203
204    #[test]
205    fn type_predicate_hides_event_skolem() {
206        // dog(sk_2): the lone arg is the event Skolem -> hidden -> bare "dog".
207        assert_eq!(humanize_fact("dog(sk_2)"), "dog");
208    }
209
210    #[test]
211    fn role_predicate_collapses_and_keeps_filler() {
212        // gerku_x1(sk_2, adam): collapse to dog.dog, hide the event Skolem.
213        assert_eq!(humanize_fact("dog_x1(sk_2, adam)"), "dog.dog(adam)");
214    }
215
216    #[test]
217    fn witness_skolem_becomes_hash() {
218        assert_eq!(humanize_fact("animal(sk_1(adam))"), "animal(#1(adam))");
219    }
220
221    #[test]
222    fn dep_pair_skolem() {
223        assert_eq!(
224            humanize_fact("nelci_x2(sk_5, sk_2(adam, bob))"),
225            "nelci.x2(#2(adam, bob))"
226        );
227    }
228
229    #[test]
230    fn tense_wrapper() {
231        assert_eq!(
232            humanize_fact("Past(goes(adam, paris))"),
233            "[past] goes(adam, paris)"
234        );
235    }
236
237    #[test]
238    fn deontic_wrapper() {
239        assert_eq!(
240            humanize_fact("Obligatory(permits(adam))"),
241            "[obligatory] permits(adam)"
242        );
243    }
244
245    #[test]
246    fn unspecified_and_description_args() {
247        assert_eq!(humanize_fact("goes(adam, _)"), "goes(adam, _)");
248        assert_eq!(humanize_fact("goes(the dog)"), "goes(the dog)");
249    }
250
251    #[test]
252    fn zero_arg_predicate() {
253        assert_eq!(humanize_fact("dog"), "dog");
254    }
255
256    #[test]
257    fn stray_s_expr_passes_through() {
258        let s = r#"(Pred "dog" (Cons (Const "adam") (Nil)))"#;
259        assert_eq!(humanize_fact(s), s);
260    }
261
262    #[test]
263    fn empty_is_empty() {
264        assert_eq!(humanize_fact(""), "");
265    }
266}