Skip to main content

delhi_lang/
problem.rs

1//! The front end assembled: a checked [`Problem`], and [`load`] to read one from disk.
2
3use crate::ast::Init;
4use crate::lower_formula::{lower_formula, Bindings};
5use crate::{
6    build_declarative, build_explicit, ground_actions, parse_file, Constants, Ctx, Diagnostics,
7    GroundAction, Sig,
8};
9use delhi_mb::State;
10use delhi_syntax::{FormulaId, Store};
11
12/// A fully checked problem: signature, initial state, goal, and ground actions.
13///
14/// `Debug` is derived because `Problem::parse` returns it in a `Result`, and
15/// `Result::unwrap_err` requires the success type to be printable.
16#[derive(Debug)]
17pub struct Problem {
18    /// Formula arena shared by everything below.
19    pub store: Store,
20    /// The checked signature.
21    pub sig: Sig,
22    /// The constant table, kept so later queries lower against the same one.
23    pub consts: Constants,
24    /// The definition table, kept for the same reason: a name that works in the file
25    /// must work at the prompt, and a query is lowered after the file has been checked.
26    pub defs: crate::Defs,
27    /// The initial state.
28    pub state: State,
29    /// The declared goal, if the file had one.
30    pub goal: Option<FormulaId>,
31    /// Declared invariants, each with the source text that wrote it.
32    ///
33    /// The text is kept so a violation can name the constraint as the author wrote it
34    /// rather than as a formula id or a re-rendering.
35    pub invariants: Vec<(FormulaId, String)>,
36    /// Every ground action whose precondition is satisfiable.
37    pub actions: Vec<GroundAction>,
38}
39
40impl Problem {
41    /// Parses and checks a source file.
42    ///
43    /// On failure returns every diagnostic rendered against the source, so one call
44    /// reports all the problems rather than only the first. A construction that
45    /// produced a state but also raised a diagnostic is a failure too: the state is
46    /// only as trustworthy as the checks that passed alongside it.
47    pub fn parse(src: &str) -> Result<Problem, String> {
48        match Problem::check(src) {
49            (Some(p), diags) if diags.is_empty() => Ok(p),
50            (_, diags) => Err(diags.render(src)),
51        }
52    }
53
54    /// Parses and checks, returning the diagnostics rather than a rendering of them.
55    ///
56    /// A caller that wants to *act* on a fault — jump a cursor to it, underline it —
57    /// needs the spans, which `parse`'s rendered string has already thrown away. The
58    /// problem comes back even when diagnostics were raised, so a UI can report the
59    /// errors and still show whatever was successfully built.
60    pub fn check(src: &str) -> (Option<Problem>, Diagnostics) {
61        let mut diags = Diagnostics::default();
62        let mut ast = parse_file(src, &mut diags);
63
64        // Definitions are expanded away before anything else looks at the tree, so the
65        // signature, the constants, the initial state and the actions never see a name
66        // that is not a real proposition.
67        let defs = crate::Defs::build(&ast, &mut diags);
68        crate::expand_ast(&mut ast, &defs, &mut diags);
69
70        let sig = Sig::build(&ast, &mut diags);
71        let mut consts = Constants::build(&ast, &sig, &mut diags);
72        // Horn rules saturate into the constant table, so a derived predicate is an
73        // ordinary constant by the time anything is lowered.
74        crate::rules::saturate(&ast, &sig, &mut consts, &mut diags);
75        let mut store = Store::default();
76
77        let state = {
78            let ctx = Ctx { sig: &sig, consts: &consts };
79            match &ast.init {
80                // The block's own span goes through: it is what whole-block failures
81                // are reported against, and reconstructing one from the entries would
82                // blame an arbitrary entry (or, for an empty block, byte zero).
83                Some(Init::Declarative(items, block)) => {
84                    build_declarative(items, *block, &ctx, &mut store, &mut diags)
85                }
86                Some(Init::Explicit { worlds, edges, span }) => {
87                    build_explicit(worlds, edges, *span, &ctx, &mut store, &mut diags)
88                }
89                None => None,
90            }
91        };
92
93        let goal = ast
94            .goal
95            .as_ref()
96            .map(|g| lower_formula(g, &sig, &consts, &Bindings::default(), &mut store, &mut diags));
97
98        let invariants: Vec<(FormulaId, String)> = ast
99            .invariants
100            .iter()
101            .map(|(e, sp)| {
102                let f =
103                    lower_formula(e, &sig, &consts, &Bindings::default(), &mut store, &mut diags);
104                (f, src[sp.start.min(src.len())..sp.end.min(src.len())].trim().to_string())
105            })
106            .collect();
107
108        let actions = ground_actions(&ast.actions, &sig, &consts, &mut store, &mut diags);
109
110        let problem = state.map(|state| Problem {
111            store,
112            sig,
113            consts,
114            defs,
115            state,
116            goal,
117            invariants,
118            actions,
119        });
120        (problem, diags)
121    }
122
123    /// A ground action by its display name, e.g. `move(alice,hall,study)`. A
124    /// zero-parameter action keeps its empty argument list, so `peek_c` is `peek_c()`.
125    pub fn action(&self, name: &str) -> Option<&GroundAction> {
126        self.actions.iter().find(|a| a.name == name)
127    }
128
129    /// Whether the initial state models `f`.
130    ///
131    /// Precondition: `f` was produced by this problem's [`Problem::store`].
132    pub fn entails(&self, f: FormulaId) -> bool {
133        debug_assert!(
134            (f as usize) < self.store.len(),
135            "formula must come from this problem's store"
136        );
137        self.state.entails(&self.store, f)
138    }
139
140    /// The declared invariants that `state` violates, as the author wrote them.
141    ///
142    /// Takes the state rather than using `self.state`, because the point of an invariant
143    /// is that it is checked *after every action* — a version that could only inspect the
144    /// initial state would be a slower way of writing an `initially` entry.
145    pub fn violated(&self, state: &State) -> Vec<&str> {
146        self.invariants
147            .iter()
148            .filter(|(f, _)| !state.entails(&self.store, *f))
149            .map(|(_, text)| text.as_str())
150            .collect()
151    }
152}
153
154/// Reads and parses a file from disk. Read errors are reported with the path, so a
155/// missing file reads the same way as a malformed one.
156pub fn load(path: &str) -> Result<Problem, String> {
157    let src = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
158    Problem::parse(&src)
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    const SRC: &str = r#"
166        types{ Actor - Object } objects{ a, b - Actor } agents{ a, b } props{ p }
167        initially { p, ?[a] p, B[a] p }
168        invariants {
169            !((B[a] p & B[b] !p) | (B[a] !p & B[b] p))
170            K[b] p
171        }
172        actions { lie() { actor b, announces !p, a observes, b observes } }
173    "#;
174
175    #[test]
176    fn an_invariant_holding_initially_can_still_be_broken_by_an_action() {
177        // The whole point of an invariant over an `initially` assertion: it is checked
178        // against states the file never mentions.
179        let mut p = Problem::parse(SRC).unwrap_or_else(|e| panic!("{e}"));
180        assert!(p.violated(&p.state).is_empty(), "clean at the start");
181
182        let n = p.sig.n_agents();
183        let def = p.action("lie()").expect("action").def.clone();
184        let am = delhi_mb::build(&def, &mut p.store, n);
185        let after = p.state.clone().apply(&p.store, &am).expect("applicable");
186
187        let bad = p.violated(&after);
188        assert_eq!(bad.len(), 1, "exactly the disagreement one: {bad:?}");
189        assert!(bad[0].starts_with("!(("), "got {:?}", bad[0]);
190    }
191
192    #[test]
193    fn a_violation_quotes_the_constraint_exactly_as_written() {
194        // Guards a real bug: `Expr::span()` of a parenthesised expression covers only its
195        // contents, so slicing by it truncated `!(a | b)` to `!(a | b`. The span is taken
196        // from the parser's token positions instead.
197        let p = Problem::parse(SRC).unwrap_or_else(|e| panic!("{e}"));
198        let texts: Vec<&str> = p.invariants.iter().map(|(_, t)| t.as_str()).collect();
199        assert_eq!(texts[0], "!((B[a] p & B[b] !p) | (B[a] !p & B[b] p))");
200        assert_eq!(texts[1], "K[b] p");
201        for t in &texts {
202            assert_eq!(
203                t.matches('(').count(),
204                t.matches(')').count(),
205                "parens must balance in the quoted text: {t}"
206            );
207        }
208    }
209
210    #[test]
211    fn a_file_with_no_invariants_section_has_none_and_violates_nothing() {
212        let p = Problem::parse(r#"types{} objects{} agents{} props{ p } initially{ p } actions{}"#)
213            .unwrap_or_else(|e| panic!("{e}"));
214        assert!(p.invariants.is_empty());
215        assert!(p.violated(&p.state).is_empty());
216    }
217}