Skip to main content

delhi_lang/
ask.rs

1//! Enumerating the formulas of a given shape that hold, rather than checking one.
2//!
3//! Evaluating a formula answers "is this true?". This answers "which of these are true?"
4//! — what does alice believe, what is she ignorant of, what holds two levels down. The
5//! difference matters when you are debugging a scenario and do not yet know what to ask.
6//!
7//! # What is enumerated
8//!
9//! Not "all formulas": there are infinitely many, since conjunction alone generates
10//! without bound. The candidates are **modal literals** — a literal under some sequence
11//! of `K`/`B` modalities, as in `B[alice] K[carol] !h`. That is a real and well-studied
12//! restriction: it is the representation Muise et al.'s PDKB planner is built on, chosen
13//! there for the same reason it is chosen here, that the set is finite and its size is
14//! predictable from the signature and the depth.
15//!
16//! The count is `Σ_{k≤d} (2·agents)^k · 2·atoms`, which grows fast — three agents and
17//! nine atoms reach ~3,900 at depth 3 — so [`MAX_CANDIDATES`] bounds it and the caller
18//! is told when the bound bit.
19//!
20//! # The hole
21//!
22//! The caller supplies a pattern containing `_`, and each candidate is substituted for
23//! it: `B[alice] _` asks what alice believes, `?[alice] _` what she is ignorant of,
24//! `K[bob] K[alice] _` what bob knows alice knows. Substitution is textual and the
25//! candidate is parenthesised, so the pattern needs no special parsing and any operator
26//! the language has — including sugar — works in it for free.
27
28use crate::ast::Expr;
29use crate::lower_formula::{lower_formula, Bindings};
30use crate::{Diagnostics, Parser, Problem, Sig};
31use delhi_mb::State;
32
33/// Ceiling on candidates, so a careless depth cannot hang the tool.
34///
35/// Each candidate costs a parse, a lowering and an evaluation — tens of microseconds —
36/// so this is a fraction of a second, chosen to stay interactive rather than to be the
37/// largest survivable number.
38pub const MAX_CANDIDATES: usize = 20_000;
39
40/// The placeholder a pattern must contain.
41pub const HOLE: &str = "_";
42
43/// What an enumeration found.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Answer {
46    /// The instantiated formulas that hold, in enumeration order: shallow before deep.
47    pub matches: Vec<String>,
48    /// How many candidates were tried.
49    pub considered: usize,
50    /// Whether [`MAX_CANDIDATES`] cut the enumeration short. When true, `matches` is a
51    /// prefix of the real answer, not the whole of it.
52    pub truncated: bool,
53}
54
55/// Every modal literal of depth at most `depth`, shallowest first.
56///
57/// Depth 0 is the bare literals. Each further level prefixes `K[i]` and `B[i]` for every
58/// agent. Shallowest-first ordering matters for readability — the short, usually more
59/// interesting answers come out on top — and it is what makes truncation a sensible
60/// prefix rather than an arbitrary sample.
61pub fn modal_literals(sig: &Sig, depth: usize) -> Vec<String> {
62    let mut level: Vec<String> = Vec::new();
63    for a in 0..sig.n_atoms() {
64        let name = sig.atom_name(a as u32);
65        level.push(name.to_string());
66        level.push(format!("!{name}"));
67    }
68    let mut all = level.clone();
69    for _ in 0..depth {
70        let mut next = Vec::new();
71        for inner in &level {
72            for i in 0..sig.n_agents() {
73                let who = sig.agent_name(i as u32);
74                next.push(format!("K[{who}] {inner}"));
75                next.push(format!("B[{who}] {inner}"));
76            }
77            if all.len() + next.len() > MAX_CANDIDATES {
78                break;
79            }
80        }
81        all.extend(next.iter().cloned());
82        level = next;
83        if all.len() > MAX_CANDIDATES {
84            break;
85        }
86    }
87    all.truncate(MAX_CANDIDATES);
88    all
89}
90
91/// Replaces every hole in `pattern` with `filler`, structurally.
92///
93/// Substitution is on the tree, not the text. Textual replacement was wrong twice over:
94/// it corrupted any identifier containing an underscore — `at_park` became
95/// `at(candidate)park` — and it needed defensive parentheses to keep precedence, which
96/// structure gives for free.
97///
98/// Every hole receives the same filler. One hole is the common case; repeating it asks
99/// "where does this same formula appear twice", as in `B[a] _ & !B[b] _`.
100fn fill(pattern: &Expr, filler: &Expr) -> Expr {
101    match pattern {
102        Expr::Hole(_) => filler.clone(),
103        Expr::True(_) | Expr::False(_) | Expr::Atom(_) => pattern.clone(),
104        Expr::Not(a, s) => Expr::Not(Box::new(fill(a, filler)), *s),
105        Expr::And(a, b, s) => Expr::And(Box::new(fill(a, filler)), Box::new(fill(b, filler)), *s),
106        Expr::Or(a, b, s) => Expr::Or(Box::new(fill(a, filler)), Box::new(fill(b, filler)), *s),
107        Expr::Implies(a, b, s) => {
108            Expr::Implies(Box::new(fill(a, filler)), Box::new(fill(b, filler)), *s)
109        }
110        Expr::Modality { op, agents, cond, body, span } => Expr::Modality {
111            op: op.clone(),
112            agents: agents.clone(),
113            cond: cond.as_ref().map(|c| Box::new(fill(c, filler))),
114            body: Box::new(fill(body, filler)),
115            span: *span,
116        },
117    }
118}
119
120/// Whether an expression contains a hole.
121fn has_hole(e: &Expr) -> bool {
122    match e {
123        Expr::Hole(_) => true,
124        Expr::True(_) | Expr::False(_) | Expr::Atom(_) => false,
125        Expr::Not(a, _) => has_hole(a),
126        Expr::And(a, b, _) | Expr::Or(a, b, _) | Expr::Implies(a, b, _) => {
127            has_hole(a) || has_hole(b)
128        }
129        Expr::Modality { cond, body, .. } => {
130            cond.as_ref().is_some_and(|c| has_hole(c)) || has_hole(body)
131        }
132    }
133}
134
135/// Parses one formula, returning it with any diagnostics raised.
136fn parse(text: &str) -> (Expr, Diagnostics) {
137    let mut diags = Diagnostics::default();
138    let toks = crate::lex(text, &mut diags);
139    let expr = Parser::new(&toks).parse_expr(&mut diags);
140    (expr, diags)
141}
142
143/// Byte ranges of the `_` tokens in `pattern`, ascending.
144fn hole_spans(pattern: &str) -> Vec<(usize, usize)> {
145    let mut diags = Diagnostics::default();
146    crate::lex(pattern, &mut diags)
147        .iter()
148        .filter(|t| t.tok == crate::Tok::Hole)
149        .map(|t| (t.span.start, t.span.end))
150        .collect()
151}
152
153/// Renders a filled pattern for display, splicing at the holes' byte ranges.
154///
155/// Splicing at spans rather than replacing the text `_` is the same correctness point as
156/// [`fill`]: a pattern like `_ & at_park` has one hole, not two, and only the lexer knows
157/// which underscore is which.
158fn render(pattern: &str, holes: &[(usize, usize)], candidate: &str) -> String {
159    let mut out = String::with_capacity(pattern.len() + candidate.len());
160    let mut last = 0;
161    for &(start, end) in holes {
162        out.push_str(&pattern[last..start]);
163        out.push('(');
164        out.push_str(candidate);
165        out.push(')');
166        last = end;
167    }
168    out.push_str(&pattern[last..]);
169    out
170}
171
172/// Flips the polarity of a candidate's innermost literal, leaving its modalities alone.
173///
174/// The literal is whatever follows the last `] `, since every modality ends in one.
175fn complement(candidate: &str) -> String {
176    let (prefix, lit) = match candidate.rfind("] ") {
177        Some(i) => candidate.split_at(i + 2),
178        None => ("", candidate),
179    };
180    match lit.strip_prefix('!') {
181        Some(rest) => format!("{prefix}{rest}"),
182        None => format!("{prefix}!{lit}"),
183    }
184}
185
186/// Enumerates the instantiations of `pattern` that hold at `state`.
187///
188/// `pattern` must contain [`HOLE`]. Returns rendered diagnostics if the pattern does not
189/// parse or does not lower once filled — checked on the first candidate, so a typo is
190/// reported as itself rather than as an empty answer.
191pub fn ask(p: &mut Problem, state: &State, pattern: &str, depth: usize) -> Result<Answer, String> {
192    let (pat, mut diags) = parse(pattern);
193    if !diags.is_empty() {
194        return Err(diags.render(pattern));
195    }
196    // Expanded like any other formula, so a pattern may use a `define` name.
197    let pat = crate::expand(&pat, &p.defs, &mut diags);
198    if !diags.is_empty() {
199        return Err(diags.render(pattern));
200    }
201    if !has_hole(&pat) {
202        return Err(format!(
203            "the pattern needs a `{HOLE}` to fill — try `B[agent] {HOLE}`, or `{HOLE}` on its own"
204        ));
205    }
206    let holes = hole_spans(pattern);
207    let candidates = modal_literals(&p.sig, depth);
208    if candidates.is_empty() {
209        return Ok(Answer { matches: Vec::new(), considered: 0, truncated: false });
210    }
211
212    // Parse each candidate once, not once per pattern: the pattern is filled with the
213    // parsed tree, so a candidate's text is scanned a single time however many holes
214    // the pattern has.
215    let parsed: Vec<Expr> = candidates.iter().map(|c| parse(c).0).collect();
216
217    // Check the filled pattern once. A pattern naming an undeclared agent is the user's
218    // mistake and must surface as its own diagnostic, not as "nothing matched".
219    let mut probe = Diagnostics::default();
220    let first = fill(&pat, &parsed[0]);
221    let _ =
222        lower_formula(&first, &p.sig, &p.consts, &Bindings::default(), &mut p.store, &mut probe);
223    if !probe.is_empty() {
224        return Err(probe.render(pattern));
225    }
226
227    let mut hit: std::collections::HashSet<&str> = std::collections::HashSet::new();
228    let mut order: Vec<&String> = Vec::new();
229    for (c, tree) in candidates.iter().zip(&parsed) {
230        let mut quiet = Diagnostics::default();
231        let f = lower_formula(
232            &fill(&pat, tree),
233            &p.sig,
234            &p.consts,
235            &Bindings::default(),
236            &mut p.store,
237            &mut quiet,
238        );
239        if quiet.is_empty() && state.entails(&p.store, f) {
240            hit.insert(c.as_str());
241            order.push(c);
242        }
243    }
244
245    // Some patterns cannot see polarity: being ignorant of `h` *is* being ignorant of
246    // `!h`, and likewise for `Kw`/`Bw`. Those return both twins, which reads as two
247    // findings when there is one. Where both a candidate and its complement matched,
248    // only the positive form is kept.
249    //
250    // The rule fires exactly when the pattern is polarity-blind, and never for a
251    // consistent attitude — belief is KD, so `B[a] h` and `B[a] !h` cannot both hold.
252    let matches = order
253        .into_iter()
254        .filter(|c| {
255            let is_negative = complement(c).len() < c.len();
256            !(is_negative && hit.contains(complement(c).as_str()))
257        })
258        // Presentation only — the answer was decided on the trees. Parenthesised so the
259        // printed form reparses to exactly what was evaluated.
260        .map(|c| render(pattern, &holes, c))
261        .collect();
262
263    Ok(Answer {
264        matches,
265        considered: candidates.len(),
266        truncated: candidates.len() >= MAX_CANDIDATES,
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    const COIN: &str = r#"
275        types{ Actor - Object } objects{ a, b - Actor } agents{ a, b } props{ h }
276        initially { h, ?[a] h, B[a] h }
277        actions {}
278    "#;
279
280    fn problem(src: &str) -> (Problem, State) {
281        let p = Problem::parse(src).unwrap_or_else(|e| panic!("{e}"));
282        let s = p.state.clone();
283        (p, s)
284    }
285
286    #[test]
287    fn candidate_count_follows_the_signature_and_the_depth() {
288        // 1 atom, 2 agents: 2 literals at depth 0, then x4 per level (2 ops x 2 agents).
289        // Getting this wrong silently changes what "up to depth d" means.
290        let (p, _) = problem(COIN);
291        assert_eq!(modal_literals(&p.sig, 0).len(), 2);
292        assert_eq!(modal_literals(&p.sig, 1).len(), 2 + 8);
293        assert_eq!(modal_literals(&p.sig, 2).len(), 2 + 8 + 32);
294    }
295
296    #[test]
297    fn candidates_are_ordered_shallowest_first() {
298        // Truncation keeps a prefix, so the ordering is what makes a cut-short answer
299        // useful rather than arbitrary.
300        let (p, _) = problem(COIN);
301        let c = modal_literals(&p.sig, 2);
302        let depth_of = |s: &str| s.matches('[').count();
303        let depths: Vec<usize> = c.iter().map(|s| depth_of(s)).collect();
304        assert!(depths.windows(2).all(|w| w[0] <= w[1]), "not shallowest-first: {depths:?}");
305    }
306
307    #[test]
308    fn asking_what_an_agent_believes_separates_belief_from_knowledge() {
309        // `a` believes h without knowing it; `b` knows it. Both believe it, so the
310        // belief query must return h for both while the knowledge query returns it
311        // only for b — that contrast is the whole point of the tool.
312        let (mut p, s) = problem(COIN);
313        let believes_a = ask(&mut p, &s, "B[a] _", 0).expect("valid pattern");
314        assert_eq!(believes_a.matches, vec!["B[a] (h)"]);
315
316        let knows_a = ask(&mut p, &s, "K[a] _", 0).expect("valid pattern");
317        assert!(knows_a.matches.is_empty(), "a knows nothing here: {:?}", knows_a.matches);
318
319        let knows_b = ask(&mut p, &s, "K[b] _", 0).expect("valid pattern");
320        assert_eq!(knows_b.matches, vec!["K[b] (h)"]);
321    }
322
323    #[test]
324    fn asking_what_an_agent_is_ignorant_of_reports_the_atom_once() {
325        // Ignorance is symmetric — being ignorant of h is being ignorant of !h — so a
326        // naive enumeration returns both polarities and reads as two findings when
327        // there is one. The positive literal is the one worth showing.
328        let (mut p, s) = problem(COIN);
329        let ignorant = ask(&mut p, &s, "?[a] _", 0).expect("valid pattern");
330        assert!(ignorant.matches.iter().any(|m| m.contains("(h)")), "got {:?}", ignorant.matches);
331        assert!(
332            !ignorant.matches.iter().any(|m| m.contains("(!h)")),
333            "the negated twin is redundant: {:?}",
334            ignorant.matches
335        );
336    }
337
338    #[test]
339    fn depth_reaches_nested_attitudes_that_depth_zero_cannot() {
340        // `b` knows h and knows that a is unsure, so `K[b] B[a] h` holds at depth 1 but
341        // no depth-0 query can express it.
342        let (mut p, s) = problem(COIN);
343        let shallow = ask(&mut p, &s, "K[b] _", 0).expect("ok");
344        assert!(!shallow.matches.iter().any(|m| m.contains("B[a]")));
345
346        let deep = ask(&mut p, &s, "K[b] _", 1).expect("ok");
347        assert!(deep.matches.iter().any(|m| m == "K[b] (B[a] h)"), "got {:?}", deep.matches);
348        assert!(deep.considered > shallow.considered);
349    }
350
351    #[test]
352    fn a_bare_hole_enumerates_what_simply_holds() {
353        let (mut p, s) = problem(COIN);
354        let a = ask(&mut p, &s, "_", 0).expect("ok");
355        assert_eq!(a.matches, vec!["(h)"], "h is true, !h is not");
356    }
357
358    #[test]
359    fn an_underscore_inside_an_identifier_is_not_a_hole() {
360        // The bug that forced the hole to become a real token. Under textual
361        // substitution `_ & at_park` had *two* holes as far as `str::replace` was
362        // concerned, and the second one tore an atom in half: `at(cand)park`.
363        let src = r#"
364            types{ Actor - Object } objects{ a - Actor } agents{ a }
365            props{ at_park, mary_home }
366            initially { at_park }
367            actions {}
368        "#;
369        let (mut p, s) = problem(src);
370        let a = ask(&mut p, &s, "_ & at_park", 0).expect("the pattern is valid");
371        assert!(
372            a.matches.iter().any(|m| m == "(at_park) & at_park"),
373            "the atom must survive intact: {:?}",
374            a.matches
375        );
376        assert!(
377            !a.matches.iter().any(|m| m.contains("at(")),
378            "no match may contain a torn identifier: {:?}",
379            a.matches
380        );
381        // And the candidates themselves are the underscored atoms, unmangled.
382        let c = modal_literals(&p.sig, 0);
383        assert!(c.contains(&"at_park".to_string()) && c.contains(&"!mary_home".to_string()));
384    }
385
386    #[test]
387    fn every_hole_in_a_pattern_takes_the_same_filler() {
388        // Repeating the hole asks "where does this same formula appear twice". Both
389        // occurrences must receive the same candidate, or the question is meaningless.
390        let (mut p, s) = problem(COIN);
391        let a = ask(&mut p, &s, "_ & _", 0).expect("valid");
392        assert_eq!(a.matches, vec!["(h) & (h)"], "got {:?}", a.matches);
393
394        // `a` believes h and `b` knows it, so this holds for h and for nothing else.
395        let both = ask(&mut p, &s, "B[a] _ & K[b] _", 0).expect("valid");
396        assert_eq!(both.matches, vec!["B[a] (h) & K[b] (h)"], "got {:?}", both.matches);
397    }
398
399    #[test]
400    fn substitution_is_structural_so_precedence_cannot_bite() {
401        // Filling by tree means `!_` negates the candidate, whatever it is. Under a
402        // careless textual splice `!_` with candidate `K[a] h` could read as `(!K[a]) h`.
403        let (mut p, s) = problem(COIN);
404        let a = ask(&mut p, &s, "!_", 1).expect("valid");
405        assert!(a.matches.iter().any(|m| m == "!(K[a] h)"), "got {:?}", a.matches);
406        assert!(!a.matches.iter().any(|m| m == "!(B[a] h)"), "a does believe h: {:?}", a.matches);
407    }
408
409    #[test]
410    fn a_hole_written_in_a_file_is_rejected_with_a_diagnostic() {
411        // A hole means nothing outside a query. Lowering must say so rather than
412        // quietly treating it as false, which would make a goal silently unsatisfiable.
413        let e = Problem::parse(
414            r#"types{} objects{} agents{} props{ h } initially{ h } goal { _ } actions{}"#,
415        )
416        .unwrap_err();
417        assert!(e.contains("query hole"), "got {e}");
418    }
419
420    #[test]
421    fn a_pattern_without_a_hole_is_rejected_as_such() {
422        // Otherwise it would evaluate one fixed formula thousands of times and return
423        // either everything or nothing, which looks like a broken query rather than a
424        // misspelled one.
425        let (mut p, s) = problem(COIN);
426        let e = ask(&mut p, &s, "B[a] h", 0).unwrap_err();
427        assert!(e.contains('_'), "the error should say what is missing: {e}");
428    }
429
430    #[test]
431    fn a_malformed_pattern_reports_its_own_diagnostic() {
432        let (mut p, s) = problem(COIN);
433        let e = ask(&mut p, &s, "B[nobody] _", 0).unwrap_err();
434        assert!(e.contains("nobody"), "got {e}");
435    }
436
437    #[test]
438    fn the_candidate_bound_is_honoured_and_declared() {
439        // A careless depth must degrade to a truncated answer, not to a hang. The flag
440        // is what stops a partial answer being read as a complete one.
441        let src = r#"
442            types{ Actor - Object } objects{ a, b, c - Actor } agents{ a, b, c }
443            props{ p, q, r, s }
444            initially { p } actions {}
445        "#;
446        let (mut p, st) = problem(src);
447        let a = ask(&mut p, &st, "_", 9).expect("ok");
448        assert!(a.truncated, "depth 9 over 3 agents must hit the bound");
449        assert!(a.considered <= MAX_CANDIDATES);
450    }
451}