Skip to main content

bynk_check/
agent_state.rs

1//! #2195: the agent state model — each agent's state machine, derived from its
2//! handlers (agent-state-views track, slice 1; retired-track
3//! summary in `design/archive/retired-tracks.md`, §3.1, §3.3, §3.7).
4//!
5//! The language leaves an agent's machine implicit: a `store` `Cell` of a
6//! user sum type (`enum { … }` or pipe form) *is* the machine, `match`/`is`
7//! read it and `:=` moves it. This query recovers, for every such field, the
8//! edges the handlers can produce — `(from-set, handler, guards, target,
9//! outcome, span)` — and the set of states reachable from the initialiser.
10//!
11//! **A dataflow pass, not path enumeration (§3.3 step 1).** Each handler body
12//! is walked over a set of abstract paths. A path carries the states it can
13//! have started in (the from-set), the field's *current* value, the last
14//! write, its opaque guards, and — once it reaches a tail — its outcome:
15//!
16//! - Before the first write, a `match <field>` arm or an `is`/`==` test
17//!   narrows the from-set. After a known write the same test is decided
18//!   statically (a read after a write sees the staged value — ADR 0125 D4),
19//!   pruning infeasible branches. After a computed write it narrows nothing
20//!   and becomes an opaque guard.
21//! - Any other condition is an opaque guard, labelled with its source text.
22//! - At each join, paths that agree on from-set, current value, last write
23//!   and outcome merge; their guards combine.
24//! - The last write on a path wins, since writes commit together at return.
25//!   A domain `Err` is a value, so a write before it still makes an edge.
26//! - Past [`PATH_BUDGET`] live paths a handler is truncated: its edges become
27//!   one [`Target::Unknown`] edge from every state, which keeps reachability
28//!   (and slice 3's overlay) sound.
29//!
30//! Reachability treats an [`Target::Unknown`] edge as reaching every state —
31//! a sound over-approximation, so a state outside [`StateMachine::reachable`]
32//! is truly unreachable from the handlers.
33//!
34//! **Placement (§3.7; #2220).** A pure function of an [`AgentDecl`] and the
35//! type table, taking no LSP or checker-internal type. It lives in
36//! `bynk-check` so the state view (`bynk-ide`, `bynk-lsp`) and a checker
37//! diagnostic read one model; `bynk-ide` re-exports it as
38//! `bynk_ide::agent_state`.
39//!
40//! Fault paths (a failing `expect` or `<-`) persist nothing and are not
41//! modelled as edges: the walk follows the success path of every statement.
42//!
43//! Out of scope, as in the sequence diagram (ADR 0259): control flow and
44//! writes nested inside an argument expression (`Ok(if c { … } else { … })`,
45//! a `field.update(f)` that is not the top of its expression) or a lambda.
46//! This costs guard and outcome precision, never edges: a `:=` is a
47//! statement, and a `field.update(f)` only runs when it is awaited (`<-`,
48//! `do`) or returned as a tail — an `Effect` buried in an argument is a value
49//! that never executes.
50
51use std::collections::{BTreeSet, HashMap};
52use std::sync::Arc;
53
54use bynk_syntax::CompileError;
55use bynk_syntax::ast::*;
56use bynk_syntax::span::Span;
57
58use crate::ast_text::{branch_outcome, pattern_summary, peel_paren};
59use crate::store_write::{store_kinds, store_write};
60
61/// The most live abstract paths one handler may carry before it is truncated
62/// (#2195 DECISION C) — a backstop against a pathological handler in a live
63/// view, far above anything in the corpus.
64pub const PATH_BUDGET: usize = 256;
65
66/// The state machines of one agent — one per state field.
67#[derive(Debug, Clone, PartialEq)]
68pub struct AgentStateModel {
69    pub agent: String,
70    pub machines: Vec<StateMachine>,
71}
72
73/// One state field's machine.
74#[derive(Debug, Clone, PartialEq)]
75pub struct StateMachine {
76    pub field: String,
77    pub field_span: Span,
78    /// The sum type's variant constructors, in declaration order.
79    pub states: Vec<String>,
80    /// The initialiser's variant; `None` when it is absent or not a variant
81    /// literal, in which case every state is treated as reachable.
82    pub initial: Option<String>,
83    pub edges: Vec<Edge>,
84    /// States reachable from `initial` over `edges` (sound: an `Unknown`
85    /// edge reaches every state), in declaration order.
86    pub reachable: Vec<String>,
87    /// #2219: the states the agent can be in *after a commit*, in declaration
88    /// order. A `transition` is checked from the second commit onward (the
89    /// genesis commit has no `old`), so only a move from one of these can
90    /// fault. Every reachable state but the initial one is here (reaching it
91    /// took a committing write); the initial state is here only when some
92    /// committing path leaves the agent in it. [`Self::reachable`] still
93    /// drives what the view dims.
94    pub reachable_after_commit: Vec<String>,
95    /// Handlers that exceeded [`PATH_BUDGET`].
96    pub truncated: Vec<String>,
97    /// #2197: `transition`s that mention this field but fall outside the
98    /// evaluated fragment — listed, never guessed.
99    pub not_evaluated: Vec<NotEvaluated>,
100}
101
102/// One way a handler can leave the field.
103#[derive(Debug, Clone, PartialEq)]
104pub struct Edge {
105    pub handler: String,
106    /// The states this path can start in, in declaration order.
107    pub from: Vec<String>,
108    /// Conditions on the path that don't constrain the field, as source text.
109    pub guards: Vec<String>,
110    pub to: Target,
111    /// The value the handler yields on this path (`Ok(())`); `None` for unit.
112    pub outcome: Option<String>,
113    /// The write, or for an unchanged path the tail, for click-to-code.
114    pub span: Span,
115    /// #2197: the `transition` step invariants this edge can violate.
116    pub violations: Vec<Violation>,
117    /// #2197: whether this edge's handler commits — the emitter wraps a
118    /// handler in the commit that runs `transition`s only when it writes some
119    /// store field (`bynk_lower::body_writes_state`); a handler that doesn't
120    /// can violate none.
121    pub commits: bool,
122}
123
124/// #2197: an edge a `transition` would refuse at commit (`InvariantViolation`).
125#[derive(Debug, Clone, PartialEq)]
126pub struct Violation {
127    /// The `transition`'s name and declaration span.
128    pub transition: String,
129    pub span: Span,
130    pub certainty: Certainty,
131    /// The reachable start state from which it violates — one record per
132    /// start, so `certainty` and `targets` are that start's own.
133    pub from: Vec<String>,
134    /// The violating targets from that start: the edge's target, the start
135    /// itself for an unchanged path, or for a computed write the targets
136    /// that violate.
137    pub targets: Vec<String>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Certainty {
142    /// Every way the edge can go violates (a known target always does).
143    Known,
144    /// A computed write may land on a violating target, or on one that isn't.
145    Possible,
146}
147
148/// #2197: a `transition` on the field outside the evaluated fragment.
149#[derive(Debug, Clone, PartialEq)]
150pub struct NotEvaluated {
151    pub transition: String,
152    pub span: Span,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum Target {
157    State(String),
158    /// A computed write (`status := next(x)`, `status.update(f)`).
159    Unknown,
160    /// No write to the field on this path.
161    Unchanged,
162}
163
164/// #2221: `bynk.transition.may_violate` — a warning for each move a handler
165/// can make that a `transition` refuses at commit (track §3.2–§3.5).
166///
167/// Only [`Certainty::Known`] violations fire, from start states the agent can
168/// be in after a commit (the overlay already filters on
169/// [`StateMachine::reachable_after_commit`]); a computed write that only *may*
170/// land badly, a truncated handler and a `transition` outside the evaluated
171/// fragment raise nothing. The warning sits on the write (or, for an unchanged
172/// path, its tail), names the path's guards, and carries the `transition` and
173/// one handler that makes the start state reachable as related information.
174/// The commit-time check is unchanged.
175pub fn may_violate_diagnostics(
176    agent: &AgentDecl,
177    types: &HashMap<String, Arc<TypeDecl>>,
178) -> Vec<CompileError> {
179    if agent.transitions.is_empty() {
180        return Vec::new();
181    }
182    let mut out = Vec::new();
183    for sm in agent_state_model(agent, types).machines {
184        let field = &sm.field;
185        for e in &sm.edges {
186            if sm.truncated.contains(&e.handler) {
187                continue;
188            }
189            for v in e
190                .violations
191                .iter()
192                .filter(|v| v.certainty == Certainty::Known)
193            {
194                for start in &v.from {
195                    let change = match &e.to {
196                        Target::State(t) => format!("move `{field}` from `{start}` to `{t}`"),
197                        Target::Unchanged => format!("leave `{field}` at `{start}`"),
198                        Target::Unknown => {
199                            format!("move `{field}` from `{start}` to a state it refuses")
200                        }
201                    };
202                    let when = if e.guards.is_empty() {
203                        String::new()
204                    } else {
205                        format!(" (when `{}`)", e.guards.join("` and `"))
206                    };
207                    let mut err = CompileError::new(
208                        "bynk.transition.may_violate",
209                        e.span,
210                        format!(
211                            "`{}()` can {change}{when}, which `{}` refuses at commit",
212                            e.handler, v.transition
213                        ),
214                    )
215                    .with_label(v.span, format!("`{}` is declared here", v.transition));
216                    if let Some((span, label)) = witness(&sm, start) {
217                        err = err.with_label(span, label);
218                    }
219                    out.push(err.with_note(
220                        "at run time this call faults with `InvariantViolation`; the \
221                         commit-time check is unchanged",
222                    ));
223                }
224            }
225        }
226    }
227    out
228}
229
230/// One handler path that leaves the agent in `state` after a commit — why the
231/// warning considers `state` reachable. `None` without a literal initialiser,
232/// where every state is assumed reachable.
233fn witness(sm: &StateMachine, state: &str) -> Option<(Span, String)> {
234    let field = &sm.field;
235    let initial = sm.initial.as_deref()?;
236    // Only a path that can start in a reachable state explains anything: an
237    // edge's `from` is what its pattern allows, not what is reachable.
238    let live = |e: &Edge| e.from.iter().any(|s| sm.reachable.contains(s));
239    let reaches = |e: &&Edge| {
240        live(e)
241            && match &e.to {
242                Target::State(t) => t == state,
243                Target::Unchanged => {
244                    state == initial && e.commits && e.from.iter().any(|s| s == state)
245                }
246                Target::Unknown => false,
247            }
248    };
249    if let Some(e) = sm.edges.iter().find(reaches) {
250        let label = match &e.to {
251            Target::Unchanged => {
252                format!(
253                    "`{}()` commits and leaves `{field}` at `{state}`",
254                    e.handler
255                )
256            }
257            _ => format!("`{}()` moves `{field}` to `{state}`", e.handler),
258        };
259        return Some((e.span, label));
260    }
261    sm.edges
262        .iter()
263        .find(|e| e.to == Target::Unknown && e.commits && live(e))
264        .map(|e| {
265            (
266                e.span,
267                format!("`{}()` can move `{field}` to any state", e.handler),
268            )
269        })
270}
271
272/// Derive every state machine of `agent`. `types` is the unit's combined type
273/// table (`ContextBoundaryInfo::types`); a state field whose type doesn't
274/// resolve to a sum in it is skipped, not guessed (#2195 DECISION A).
275pub fn agent_state_model(
276    agent: &AgentDecl,
277    types: &HashMap<String, Arc<TypeDecl>>,
278) -> AgentStateModel {
279    let store = store_kinds(agent);
280    let machines = agent
281        .store_fields
282        .iter()
283        .filter_map(|f| {
284            let states = state_variants(f, types)?;
285            Some(machine(agent, f, states, &store))
286        })
287        .collect();
288    AgentStateModel {
289        agent: agent.name.name.clone(),
290        machines,
291    }
292}
293
294/// The variant names of `field` when it is a `Cell` of a user sum type.
295fn state_variants(
296    field: &StoreField,
297    types: &HashMap<String, Arc<TypeDecl>>,
298) -> Option<Vec<String>> {
299    if field.kind.head.name != "Cell" {
300        return None;
301    }
302    let [TypeRef::Named(name)] = field.kind.args.as_slice() else {
303        return None;
304    };
305    let TypeBody::Sum(sum) = &types.get(&name.name)?.body else {
306        return None;
307    };
308    Some(sum.variants.iter().map(|v| v.name.name.clone()).collect())
309}
310
311fn machine(
312    agent: &AgentDecl,
313    field: &StoreField,
314    states: Vec<String>,
315    store: &HashMap<String, String>,
316) -> StateMachine {
317    let all: BTreeSet<usize> = (0..states.len()).collect();
318    let initial = field
319        .init
320        .as_ref()
321        .and_then(|e| variant_literal(e, &states));
322    let mut edges = Vec::new();
323    let mut truncated = Vec::new();
324    for handler in &agent.handlers {
325        let name = handler
326            .method_name
327            .as_ref()
328            .map_or_else(|| "call".to_string(), |m| m.name.clone());
329        let commits = handler_commits_in(handler, store);
330        let mut w = Walker {
331            field: &field.name.name,
332            states: &states,
333            store,
334            bound: handler.params.iter().map(|p| p.name.name.clone()).collect(),
335            truncated: false,
336        };
337        let start = Path {
338            from: all.clone(),
339            current: Cur::Pre,
340            write: None,
341            guards: BTreeSet::new(),
342            outcome: None,
343            outcome_span: None,
344        };
345        let paths = w.walk_block(&handler.body, vec![start], true);
346        if w.truncated {
347            truncated.push(name.clone());
348            edges.push(Edge {
349                handler: name,
350                from: states.clone(),
351                guards: Vec::new(),
352                to: Target::Unknown,
353                outcome: None,
354                span: handler.span,
355                violations: Vec::new(),
356                commits,
357            });
358            continue;
359        }
360        for p in paths {
361            let (to, span) = match p.write {
362                Some((Write::Known(i), span)) => (Target::State(states[i].clone()), span),
363                Some((Write::Unknown, span)) => (Target::Unknown, span),
364                None => (Target::Unchanged, p.outcome_span.unwrap_or(handler.span)),
365            };
366            edges.push(Edge {
367                handler: name.clone(),
368                from: p.from.iter().map(|&i| states[i].clone()).collect(),
369                guards: p.guards.into_iter().collect(),
370                to,
371                outcome: p.outcome,
372                span,
373                violations: Vec::new(),
374                commits,
375            });
376        }
377    }
378    let reachable = reachable(&states, initial, &edges);
379    let reachable_after_commit = reachable_after_commit(&states, initial, &reachable, &edges);
380    let not_evaluated = overlay(
381        agent,
382        &field.name.name,
383        &states,
384        &reachable_after_commit,
385        &mut edges,
386    );
387    StateMachine {
388        field: field.name.name.clone(),
389        field_span: field.name.span,
390        initial: initial.map(|i| states[i].clone()),
391        states,
392        edges,
393        reachable,
394        reachable_after_commit,
395        truncated,
396        not_evaluated,
397    }
398}
399
400/// #2197: flag each edge a `transition` would refuse at commit, and list the
401/// `transition`s on `field` the fragment can't evaluate (track §3.5).
402///
403/// - A known target `S → T` violates when the predicate is false for
404///   `old = S, new = T`.
405/// - An unchanged path is `S → S`, but only in a handler that commits — the
406///   emitter wraps a handler in the commit (where transitions are checked)
407///   only when it writes some store field (`bynk_lower::body_writes_state`).
408/// - A computed write is checked against every target: all violating is
409///   [`Certainty::Known`], some is [`Certainty::Possible`].
410/// - Only start states the agent can be in after a commit count (#2219): a
411///   move only the genesis commit can make is never checked.
412fn overlay(
413    agent: &AgentDecl,
414    field: &str,
415    states: &[String],
416    after_commit: &[String],
417    edges: &mut [Edge],
418) -> Vec<NotEvaluated> {
419    let index = |name: &str| states.iter().position(|s| s == name);
420    let mut not_evaluated = Vec::new();
421    for t in &agent.transitions {
422        if !mentions_field(&t.predicate, field) {
423            continue;
424        }
425        let Some(pred) = compile(&t.predicate, field, states) else {
426            not_evaluated.push(NotEvaluated {
427                transition: t.name.name.clone(),
428                span: t.span,
429            });
430            continue;
431        };
432        for e in edges.iter_mut() {
433            // A handler that never commits runs no `transition` at all.
434            if !e.commits {
435                continue;
436            }
437            // One record per start state: certainty and targets differ by
438            // start (a computed write can be Known from one, Possible from
439            // another), and the view reads them per start.
440            for start in e.from.iter().filter(|s| after_commit.contains(s)) {
441                let Some(si) = index(start) else { continue };
442                let candidates: Vec<usize> = match &e.to {
443                    Target::State(t) => index(t).into_iter().collect(),
444                    Target::Unchanged => vec![si],
445                    Target::Unknown => (0..states.len()).collect(),
446                };
447                let bad: Vec<usize> = candidates
448                    .iter()
449                    .copied()
450                    .filter(|&ti| !pred.eval(si, ti))
451                    .collect();
452                if bad.is_empty() {
453                    continue;
454                }
455                e.violations.push(Violation {
456                    transition: t.name.name.clone(),
457                    span: t.span,
458                    certainty: if bad.len() == candidates.len() {
459                        Certainty::Known
460                    } else {
461                        Certainty::Possible
462                    },
463                    from: vec![start.clone()],
464                    targets: bad.into_iter().map(|i| states[i].clone()).collect(),
465                });
466            }
467        }
468    }
469    not_evaluated
470}
471
472/// Whether `handler` commits: whether its body writes any of `agent`'s store
473/// fields anywhere, the emitter's condition for wrapping it in the commit that
474/// checks `transition`s. This recomputes `bynk_lower::body_writes_state`
475/// syntactically; `bynk-lower`'s tests pin the two together (#2220).
476pub fn handler_commits(agent: &AgentDecl, handler: &Handler) -> bool {
477    handler_commits_in(handler, &store_kinds(agent))
478}
479
480fn handler_commits_in(handler: &Handler, store: &HashMap<String, String>) -> bool {
481    let mut bound: Vec<String> = handler.params.iter().map(|p| p.name.name.clone()).collect();
482    block_commits(&handler.body, store, &mut bound)
483}
484
485/// Whether a block writes any store field anywhere — see [`handler_commits`].
486/// `bound` is the names in scope that shadow a store field: the handler's
487/// parameters, and (#2220) every `let`, match-arm and lambda binding on the
488/// way down, so a local named like a field is never taken for it — the
489/// emitter decides over resolved callees, which can't be fooled that way.
490fn block_commits(b: &Block, store: &HashMap<String, String>, bound: &mut Vec<String>) -> bool {
491    let scope = bound.len();
492    let mut commits = false;
493    for s in &b.statements {
494        commits |= match s {
495            Statement::Assign(_) => true,
496            Statement::Let(l) | Statement::EffectLet(l) => {
497                let c = expr_commits(&l.value, store, bound);
498                bound.push(l.name.name.clone());
499                c
500            }
501            Statement::Expect(a) => expr_commits(&a.value, store, bound),
502            Statement::Send(s) => expr_commits(&s.value, store, bound),
503            Statement::Do(d) => expr_commits(&d.value, store, bound),
504            Statement::Advance(a) => expr_commits(&a.amount, store, bound),
505        };
506    }
507    commits |= expr_commits(&b.tail, store, bound);
508    bound.truncate(scope);
509    commits
510}
511
512fn expr_commits(e: &Expr, store: &HashMap<String, String>, bound: &mut Vec<String>) -> bool {
513    if store_write(store, bound, e).is_some() {
514        return true;
515    }
516    match &e.kind {
517        ExprKind::Block(b) => block_commits(b, store, bound),
518        ExprKind::If {
519            cond,
520            then_block,
521            else_block,
522        } => {
523            // Not short-circuited: each branch scopes its own bindings.
524            let c = expr_commits(cond, store, bound);
525            let t = block_commits(then_block, store, bound);
526            let f = block_commits(else_block, store, bound);
527            c || t || f
528        }
529        ExprKind::Match { discriminant, arms } => {
530            let mut commits = expr_commits(discriminant, store, bound);
531            for a in arms {
532                let scope = bound.len();
533                bound.extend(a.pattern.bound_names().into_iter().map(|n| n.name.clone()));
534                if let Some(g) = &a.guard {
535                    commits |= expr_commits(g, store, bound);
536                }
537                commits |= match &a.body {
538                    MatchBody::Expr(e) => expr_commits(e, store, bound),
539                    MatchBody::Block(b) => block_commits(b, store, bound),
540                };
541                bound.truncate(scope);
542            }
543            commits
544        }
545        ExprKind::Lambda(l) => {
546            let scope = bound.len();
547            bound.extend(l.params.iter().map(|p| p.name.name.clone()));
548            let commits = expr_commits(&l.body, store, bound);
549            bound.truncate(scope);
550            commits
551        }
552        _ => {
553            let mut commits = false;
554            for c in expr_children(e) {
555                commits |= expr_commits(c, store, bound);
556            }
557            commits
558        }
559    }
560}
561
562#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563enum Side {
564    Old,
565    New,
566}
567
568/// A `transition` predicate in the evaluated fragment: `is`/`==`/`!=` tests
569/// of `old.f`/`new.f` against irrefutable patterns or nullary variants,
570/// composed with `!`, `&&`, `||` and `implies`.
571enum Pred {
572    Test(Side, BTreeSet<usize>),
573    Not(Box<Pred>),
574    And(Box<Pred>, Box<Pred>),
575    Or(Box<Pred>, Box<Pred>),
576    Implies(Box<Pred>, Box<Pred>),
577}
578
579impl Pred {
580    fn eval(&self, old: usize, new: usize) -> bool {
581        match self {
582            Pred::Test(Side::Old, s) => s.contains(&old),
583            Pred::Test(Side::New, s) => s.contains(&new),
584            Pred::Not(p) => !p.eval(old, new),
585            Pred::And(a, b) => a.eval(old, new) && b.eval(old, new),
586            Pred::Or(a, b) => a.eval(old, new) || b.eval(old, new),
587            Pred::Implies(a, b) => !a.eval(old, new) || b.eval(old, new),
588        }
589    }
590}
591
592/// `old.<field>` or `new.<field>`.
593fn side_of(expr: &Expr, field: &str) -> Option<Side> {
594    let ExprKind::FieldAccess { receiver, field: f } = &peel_paren(expr).kind else {
595        return None;
596    };
597    let ExprKind::Ident(r) = &receiver.kind else {
598        return None;
599    };
600    if f.name != field {
601        return None;
602    }
603    match r.name.as_str() {
604        "old" => Some(Side::Old),
605        "new" => Some(Side::New),
606        _ => None,
607    }
608}
609
610fn mentions_field(expr: &Expr, field: &str) -> bool {
611    side_of(expr, field).is_some()
612        || expr_children(expr)
613            .into_iter()
614            .any(|c| mentions_field(c, field))
615}
616
617fn compile(expr: &Expr, field: &str, states: &[String]) -> Option<Pred> {
618    let inner = peel_paren(expr);
619    match &inner.kind {
620        ExprKind::UnaryOp(UnaryOp::Not, e) => Some(Pred::Not(Box::new(compile(e, field, states)?))),
621        ExprKind::BinOp(op @ (BinOp::And | BinOp::Or | BinOp::Implies), l, r) => {
622            let (a, b) = (
623                Box::new(compile(l, field, states)?),
624                Box::new(compile(r, field, states)?),
625            );
626            Some(match op {
627                BinOp::And => Pred::And(a, b),
628                BinOp::Or => Pred::Or(a, b),
629                _ => Pred::Implies(a, b),
630            })
631        }
632        ExprKind::Is { value, pattern } => {
633            let side = side_of(value, field)?;
634            let m = pattern_sets(pattern, states);
635            // A refutable pattern can't be decided on constructors alone.
636            (m.may == m.must).then_some(Pred::Test(side, m.must))
637        }
638        ExprKind::BinOp(op @ (BinOp::Eq | BinOp::NotEq), l, r) => {
639            let (side, other) = match (side_of(l, field), side_of(r, field)) {
640                (Some(s), None) => (s, r),
641                (None, Some(s)) => (s, l),
642                _ => return None,
643            };
644            if has_payload(other) {
645                return None;
646            }
647            let test = Pred::Test(side, BTreeSet::from([variant_literal(other, states)?]));
648            Some(if *op == BinOp::Eq {
649                test
650            } else {
651                Pred::Not(Box::new(test))
652            })
653        }
654        _ => None,
655    }
656}
657
658/// States reachable from `initial` (every state when there is none), with an
659/// `Unknown` edge reaching every state.
660fn reachable(states: &[String], initial: Option<usize>, edges: &[Edge]) -> Vec<String> {
661    let index = |name: &str| states.iter().position(|s| s == name);
662    let mut seen: BTreeSet<usize> = match initial {
663        Some(i) => BTreeSet::from([i]),
664        None => (0..states.len()).collect(),
665    };
666    loop {
667        let before = seen.len();
668        for e in edges {
669            if !e
670                .from
671                .iter()
672                .filter_map(|s| index(s))
673                .any(|i| seen.contains(&i))
674            {
675                continue;
676            }
677            match &e.to {
678                Target::State(s) => seen.extend(index(s)),
679                Target::Unknown => seen.extend(0..states.len()),
680                Target::Unchanged => {}
681            }
682        }
683        if seen.len() == before {
684            break;
685        }
686    }
687    seen.into_iter().map(|i| states[i].clone()).collect()
688}
689
690/// #2219: the states the agent can be in after a commit (see
691/// [`StateMachine::reachable_after_commit`]). Without a literal initialiser
692/// every state counts, as for [`reachable`].
693fn reachable_after_commit(
694    states: &[String],
695    initial: Option<usize>,
696    reachable: &[String],
697    edges: &[Edge],
698) -> Vec<String> {
699    let Some(init) = initial else {
700        return reachable.to_vec();
701    };
702    let initial = &states[init];
703    // Back in the initial state after a commit: a committing path from a
704    // reachable start that ends there — an unchanged path from it, a move
705    // into it, or a computed write (which can land anywhere).
706    let returns = edges.iter().any(|e| {
707        e.commits
708            && e.from.iter().any(|s| {
709                reachable.contains(s)
710                    && match &e.to {
711                        Target::State(t) => t == initial,
712                        Target::Unchanged => s == initial,
713                        Target::Unknown => true,
714                    }
715            })
716    });
717    reachable
718        .iter()
719        .filter(|s| *s != initial || returns)
720        .cloned()
721        .collect()
722}
723
724/// A variant literal of the state type: `Placed`, `Square(1)`,
725/// `OrderStatus.Placed` or `OrderStatus.Square(1)`.
726/// Whether a variant literal carries a payload (`Square(1)`), so comparing
727/// against it pins the value, not just the constructor.
728fn has_payload(expr: &Expr) -> bool {
729    match &peel_paren(expr).kind {
730        ExprKind::Call { args, .. } | ExprKind::MethodCall { args, .. } => !args.is_empty(),
731        _ => false,
732    }
733}
734
735/// What a pattern says about the field's constructor (see
736/// [`Walker::pattern_states`]).
737#[derive(Default)]
738struct PatternStates {
739    may: BTreeSet<usize>,
740    must: BTreeSet<usize>,
741}
742
743fn variant_literal(expr: &Expr, states: &[String]) -> Option<usize> {
744    let name = match &peel_paren(expr).kind {
745        ExprKind::Ident(id) => &id.name,
746        ExprKind::Call { name, .. } => &name.name,
747        ExprKind::FieldAccess { field, .. } => &field.name,
748        ExprKind::MethodCall { method, .. } => &method.name,
749        _ => return None,
750    };
751    states.iter().position(|s| s == name)
752}
753
754#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
755enum Cur {
756    /// Not yet written on this path: the field holds the pre-state.
757    Pre,
758    Known(usize),
759    /// Written with a computed value.
760    Computed,
761}
762
763#[derive(Debug, Clone, Copy, PartialEq, Eq)]
764enum Write {
765    Known(usize),
766    Unknown,
767}
768
769#[derive(Debug, Clone)]
770struct Path {
771    from: BTreeSet<usize>,
772    current: Cur,
773    write: Option<(Write, Span)>,
774    guards: BTreeSet<String>,
775    outcome: Option<String>,
776    outcome_span: Option<Span>,
777}
778
779impl Path {
780    /// Paths with the same key produce the same edge, guards aside.
781    fn same_key(&self, other: &Path) -> bool {
782        self.from == other.from
783            && self.current == other.current
784            && self.write.map(|w| w.0) == other.write.map(|w| w.0)
785            && self.outcome == other.outcome
786    }
787}
788
789struct Walker<'a> {
790    field: &'a str,
791    states: &'a [String],
792    store: &'a HashMap<String, String>,
793    /// Names bound in scope; a binding named like the field shadows it.
794    bound: Vec<String>,
795    truncated: bool,
796}
797
798impl Walker<'_> {
799    fn walk_block(&mut self, block: &Block, mut paths: Vec<Path>, tail: bool) -> Vec<Path> {
800        let scope = self.bound.len();
801        for stmt in &block.statements {
802            if self.truncated {
803                return Vec::new();
804            }
805            paths = self.walk_stmt(stmt, paths);
806        }
807        let paths = self.walk_expr(&block.tail, paths, tail);
808        self.bound.truncate(scope);
809        paths
810    }
811
812    fn walk_stmt(&mut self, stmt: &Statement, paths: Vec<Path>) -> Vec<Path> {
813        match stmt {
814            Statement::Let(l) | Statement::EffectLet(l) => {
815                let paths = self.walk_expr(&l.value, paths, false);
816                self.bound.push(l.name.name.clone());
817                paths
818            }
819            Statement::Do(d) => self.walk_expr(&d.value, paths, false),
820            Statement::Expect(e) => self.walk_expr(&e.value, paths, false),
821            Statement::Send(s) => self.walk_expr(&s.value, paths, false),
822            Statement::Advance(a) => self.walk_expr(&a.amount, paths, false),
823            Statement::Assign(a) => {
824                let mut paths = self.walk_expr(&a.value, paths, false);
825                // No shadowing check: `:=` only ever targets a `store Cell`
826                // (`bynk.cell.invalid_target`), never a local.
827                if a.target.name == self.field {
828                    let write = match variant_literal(&a.value, self.states) {
829                        Some(i) => Write::Known(i),
830                        None => Write::Unknown,
831                    };
832                    for p in &mut paths {
833                        Self::apply_write(p, write, a.span);
834                    }
835                }
836                paths
837            }
838        }
839    }
840
841    fn apply_write(p: &mut Path, write: Write, span: Span) {
842        p.current = match write {
843            Write::Known(i) => Cur::Known(i),
844            Write::Unknown => Cur::Computed,
845        };
846        p.write = Some((write, span));
847    }
848
849    fn walk_expr(&mut self, expr: &Expr, mut paths: Vec<Path>, tail: bool) -> Vec<Path> {
850        if self.truncated || paths.is_empty() {
851            return paths;
852        }
853        let inner = peel_paren(expr);
854        match &inner.kind {
855            ExprKind::If {
856                cond,
857                then_block,
858                else_block,
859            } => {
860                let then_paths = self.narrow(paths.clone(), cond, true);
861                let else_paths = self.narrow(paths, cond, false);
862                let mut out = self.walk_block(then_block, then_paths, tail);
863                out.extend(self.walk_block(else_block, else_paths, tail));
864                self.join(out)
865            }
866            ExprKind::Match { discriminant, arms } => {
867                self.walk_match(discriminant, arms, paths, tail)
868            }
869            ExprKind::Block(b) => self.walk_block(b, paths, tail),
870            _ => {
871                if let Some(w) = store_write(self.store, &self.bound, inner) {
872                    // `field.update(f)` on the state field: a computed write.
873                    if w.field.name == self.field {
874                        for p in &mut paths {
875                            Self::apply_write(p, Write::Unknown, inner.span);
876                        }
877                    }
878                    if tail {
879                        // A store write yields `()`, so it carries no outcome.
880                        for p in &mut paths {
881                            p.outcome = None;
882                            p.outcome_span = Some(inner.span);
883                        }
884                    }
885                } else if tail {
886                    let outcome = branch_outcome(inner);
887                    for p in &mut paths {
888                        p.outcome = outcome.clone();
889                        p.outcome_span = Some(inner.span);
890                    }
891                }
892                paths
893            }
894        }
895    }
896
897    fn walk_match(
898        &mut self,
899        scrutinee: &Expr,
900        arms: &[MatchArm],
901        paths: Vec<Path>,
902        tail: bool,
903    ) -> Vec<Path> {
904        let on_field = self.is_field(scrutinee);
905        // States taken by earlier unguarded arms — an arm only sees the rest.
906        let mut covered = BTreeSet::new();
907        let mut out = Vec::new();
908        for arm in arms {
909            let mut arm_paths = if on_field {
910                let m = self.pattern_states(&arm.pattern);
911                let states: BTreeSet<usize> = m.may.difference(&covered).copied().collect();
912                // Only a constructor the arm matches whatever its payload is
913                // taken from later arms: `Square(1) =>` leaves `Square(n) =>`
914                // its share.
915                if arm.guard.is_none() {
916                    covered.extend(m.must.iter().copied());
917                }
918                self.constrain(paths.clone(), &states)
919            } else {
920                let label = format!(
921                    "{} is {}",
922                    bynk_fmt::expr_to_string(scrutinee),
923                    pattern_summary(&arm.pattern)
924                );
925                Self::guarded(paths.clone(), label)
926            };
927            let scope = self.bound.len();
928            self.bound.extend(
929                arm.pattern
930                    .bound_names()
931                    .into_iter()
932                    .map(|n| n.name.clone()),
933            );
934            if let Some(guard) = &arm.guard {
935                arm_paths = self.narrow(arm_paths, guard, true);
936            }
937            let arm_out = match &arm.body {
938                MatchBody::Expr(e) => self.walk_expr(e, arm_paths, tail),
939                MatchBody::Block(b) => self.walk_block(b, arm_paths, tail),
940            };
941            self.bound.truncate(scope);
942            out.extend(arm_out);
943        }
944        self.join(out)
945    }
946
947    /// Keep the paths consistent with `cond` evaluating to `truth`.
948    fn narrow(&mut self, paths: Vec<Path>, cond: &Expr, truth: bool) -> Vec<Path> {
949        match self.facts(cond, truth) {
950            Some(allowed) => self.constrain(paths, &allowed),
951            None => {
952                let text = bynk_fmt::expr_to_string(cond);
953                let label = if truth { text } else { format!("!({text})") };
954                Self::guarded(paths, label)
955            }
956        }
957    }
958
959    /// Constrain each path's field to `allowed`: narrow the from-set before a
960    /// write, decide statically after a known write, or record a guard after a
961    /// computed one.
962    fn constrain(&self, paths: Vec<Path>, allowed: &BTreeSet<usize>) -> Vec<Path> {
963        paths
964            .into_iter()
965            .filter_map(|mut p| match p.current {
966                Cur::Pre => {
967                    p.from = p.from.intersection(allowed).copied().collect();
968                    (!p.from.is_empty()).then_some(p)
969                }
970                Cur::Known(i) => allowed.contains(&i).then_some(p),
971                Cur::Computed => {
972                    let names: Vec<&str> =
973                        allowed.iter().map(|&i| self.states[i].as_str()).collect();
974                    p.guards
975                        .insert(format!("{} is {}", self.field, names.join(" | ")));
976                    Some(p)
977                }
978            })
979            .collect()
980    }
981
982    fn guarded(mut paths: Vec<Path>, label: String) -> Vec<Path> {
983        for p in &mut paths {
984            p.guards.insert(label.clone());
985        }
986        paths
987    }
988
989    /// What `cond == truth` says about the field, when it says anything a
990    /// state set can express: `is`, `==`/`!=` against a variant, `!`, and
991    /// `&&`/`||` whose sides both speak about the field.
992    fn facts(&self, cond: &Expr, truth: bool) -> Option<BTreeSet<usize>> {
993        let all = || (0..self.states.len()).collect::<BTreeSet<usize>>();
994        let complement = |s: BTreeSet<usize>| all().difference(&s).copied().collect();
995        let inner = peel_paren(cond);
996        match &inner.kind {
997            // True: the field may be any constructor the pattern can match.
998            // False: only a constructor it matches irrefutably is excluded —
999            // `shape is Square(1)` false still allows `Square(2)`.
1000            ExprKind::Is { value, pattern } if self.is_field(value) => {
1001                let m = self.pattern_states(pattern);
1002                Some(if truth { m.may } else { complement(m.must) })
1003            }
1004            ExprKind::BinOp(op @ (BinOp::Eq | BinOp::NotEq), l, r) => {
1005                let other = if self.is_field(l) {
1006                    r
1007                } else if self.is_field(r) {
1008                    l
1009                } else {
1010                    return None;
1011                };
1012                let i = variant_literal(other, self.states)?;
1013                let s = BTreeSet::from([i]);
1014                // Equal to a nullary variant pins the constructor both ways;
1015                // unequal to `Square(1)` still allows `Square(2)`.
1016                let pinned = !has_payload(other);
1017                Some(if truth == (*op == BinOp::Eq) {
1018                    s
1019                } else if pinned {
1020                    complement(s)
1021                } else {
1022                    all()
1023                })
1024            }
1025            ExprKind::UnaryOp(UnaryOp::Not, e) => self.facts(e, !truth),
1026            ExprKind::BinOp(op @ (BinOp::And | BinOp::Or), l, r) => {
1027                let a = self.facts(l, truth)?;
1028                let b = self.facts(r, truth)?;
1029                // `a && b` true, or `a || b` false, needs both sides to hold.
1030                Some(if (*op == BinOp::And) == truth {
1031                    a.intersection(&b).copied().collect()
1032                } else {
1033                    a.union(&b).copied().collect()
1034                })
1035            }
1036            _ => None,
1037        }
1038    }
1039
1040    fn pattern_states(&self, pattern: &Pattern) -> PatternStates {
1041        pattern_sets(pattern, self.states)
1042    }
1043
1044    fn is_field(&self, expr: &Expr) -> bool {
1045        matches!(&peel_paren(expr).kind, ExprKind::Ident(id)
1046            if id.name == self.field && !self.bound.contains(&id.name))
1047    }
1048
1049    /// Merge paths that would produce the same edge, combining their guards,
1050    /// and enforce the budget.
1051    fn join(&mut self, paths: Vec<Path>) -> Vec<Path> {
1052        let mut out: Vec<Path> = Vec::new();
1053        for p in paths {
1054            match out.iter_mut().find(|q| q.same_key(&p)) {
1055                Some(q) => q.guards = combine_guards(&q.guards, &p.guards),
1056                None => out.push(p),
1057            }
1058        }
1059        if out.len() > PATH_BUDGET {
1060            self.truncated = true;
1061            return Vec::new();
1062        }
1063        out
1064    }
1065}
1066
1067/// The guards of two merged paths: either side holding is enough, so an
1068/// unconditional side makes the merge unconditional, and two different
1069/// conjunctions become one disjunction label.
1070pub fn combine_guards(a: &BTreeSet<String>, b: &BTreeSet<String>) -> BTreeSet<String> {
1071    if a == b {
1072        return a.clone();
1073    }
1074    if a.is_empty() || b.is_empty() {
1075        return BTreeSet::new();
1076    }
1077    // `c` on one side and `!(c)` on the other: together, no condition.
1078    if let ([x], [y]) = (
1079        a.iter().collect::<Vec<_>>().as_slice(),
1080        b.iter().collect::<Vec<_>>().as_slice(),
1081    ) && (**y == format!("!({x})") || **x == format!("!({y})"))
1082    {
1083        return BTreeSet::new();
1084    }
1085    let side = |s: &BTreeSet<String>| {
1086        let parts: Vec<&str> = s.iter().map(String::as_str).collect();
1087        if parts.len() == 1 {
1088            parts[0].to_string()
1089        } else {
1090            format!("({})", parts.join(" && "))
1091        }
1092    };
1093    BTreeSet::from([format!("{} || {}", side(a), side(b))])
1094}
1095
1096/// The constructors a pattern may match, and those it matches whatever
1097/// the payload: `Square(n)` must-matches `Square`, `Square(1)` only
1098/// may-match it, and a refined `_ where p` may match anything but must
1099/// match nothing.
1100fn pattern_sets(pattern: &Pattern, states: &[String]) -> PatternStates {
1101    let all = || (0..states.len()).collect::<BTreeSet<usize>>();
1102    match pattern {
1103        Pattern::Wildcard(_) | Pattern::Binding(_) => PatternStates {
1104            may: all(),
1105            must: all(),
1106        },
1107        Pattern::Variant {
1108            variant, bindings, ..
1109        } => {
1110            let may: BTreeSet<usize> = states
1111                .iter()
1112                .position(|s| *s == variant.name)
1113                .into_iter()
1114                .collect();
1115            let irrefutable = bindings
1116                .iter()
1117                .all(|b| matches!(b.pattern(), Pattern::Wildcard(_) | Pattern::Binding(_)));
1118            let must = if irrefutable {
1119                may.clone()
1120            } else {
1121                BTreeSet::new()
1122            };
1123            PatternStates { may, must }
1124        }
1125        Pattern::Refined { inner, .. } => PatternStates {
1126            may: pattern_sets(inner, states).may,
1127            must: BTreeSet::new(),
1128        },
1129        Pattern::Or(alts, _) => {
1130            let mut out = PatternStates::default();
1131            for a in alts {
1132                let m = pattern_sets(a, states);
1133                out.may.extend(m.may);
1134                out.must.extend(m.must);
1135            }
1136            out
1137        }
1138        Pattern::Literal { .. } => PatternStates::default(),
1139    }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145
1146    /// Parse a single-file context (parse only — the state model reads the
1147    /// AST) and derive the model of `agent`, with the type table built from
1148    /// the file's own `type` declarations. Sources are inline copies of the
1149    /// compiler's state-machine fixtures: `bynk-ide` tests stay self-contained.
1150    fn model(src: &str, agent: &str) -> AgentStateModel {
1151        let tokens = bynk_syntax::lexer::tokenize(src).expect("tokenize");
1152        let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, src);
1153        assert!(errs.is_empty(), "parse errors: {errs:?}");
1154        let Some(SourceUnit::Context(ctx)) = unit else {
1155            panic!("expected a context")
1156        };
1157        let types = ctx
1158            .items
1159            .iter()
1160            .filter_map(|i| match i {
1161                CommonsItem::Type(t) => Some((t.name.name.clone(), Arc::new(t.clone()))),
1162                _ => None,
1163            })
1164            .collect();
1165        let a = ctx
1166            .items
1167            .iter()
1168            .find_map(|i| match i {
1169                CommonsItem::Agent(a) if a.name.name == agent => Some(a),
1170                _ => None,
1171            })
1172            .expect("agent");
1173        agent_state_model(a, &types)
1174    }
1175
1176    /// #2221: the `bynk.transition.may_violate` warnings for `agent`, as
1177    /// `(message, labels)`.
1178    fn warnings(src: &str, agent: &str) -> Vec<(String, Vec<String>)> {
1179        let tokens = bynk_syntax::lexer::tokenize(src).expect("tokenize");
1180        let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, src);
1181        assert!(errs.is_empty(), "parse errors: {errs:?}");
1182        let Some(SourceUnit::Context(ctx)) = unit else {
1183            panic!("expected a context")
1184        };
1185        let types = ctx
1186            .items
1187            .iter()
1188            .filter_map(|i| match i {
1189                CommonsItem::Type(t) => Some((t.name.name.clone(), Arc::new(t.clone()))),
1190                _ => None,
1191            })
1192            .collect();
1193        let a = ctx
1194            .items
1195            .iter()
1196            .find_map(|i| match i {
1197                CommonsItem::Agent(a) if a.name.name == agent => Some(a),
1198                _ => None,
1199            })
1200            .expect("agent");
1201        may_violate_diagnostics(a, &types)
1202            .into_iter()
1203            .map(|e| {
1204                assert_eq!(e.category, "bynk.transition.may_violate");
1205                (e.message, e.labels.into_iter().map(|(_, l)| l).collect())
1206            })
1207            .collect()
1208    }
1209
1210    fn only(m: &AgentStateModel) -> &StateMachine {
1211        assert_eq!(m.machines.len(), 1, "one state field");
1212        &m.machines[0]
1213    }
1214
1215    /// `(handler, from, to, outcome)` per edge, for compact assertions.
1216    fn edges(sm: &StateMachine) -> Vec<(String, Vec<String>, Target, Option<String>)> {
1217        sm.edges
1218            .iter()
1219            .map(|e| {
1220                (
1221                    e.handler.clone(),
1222                    e.from.clone(),
1223                    e.to.clone(),
1224                    e.outcome.clone(),
1225                )
1226            })
1227            .collect()
1228    }
1229
1230    fn s(v: &[&str]) -> Vec<String> {
1231        v.iter().map(|x| x.to_string()).collect()
1232    }
1233
1234    fn to(state: &str) -> Target {
1235        Target::State(state.to_string())
1236    }
1237
1238    fn out(o: &str) -> Option<String> {
1239        Some(o.to_string())
1240    }
1241
1242    // 159_order_lifecycle
1243    const ORDER: &str = r#"context orders
1244
1245type OrderStatus = enum { Pending, Placed, Cancelled }
1246type OrderError = enum { AlreadyPlaced, AlreadyCancelled }
1247
1248agent Order {
1249  key id: String
1250
1251  store status: Cell[OrderStatus] = Pending
1252  store items: Cell[Int]
1253
1254  on call addItem(quantity: Int) -> Effect[Result[(), OrderError]] {
1255    let cur = items
1256    items := cur + quantity
1257    Ok(())
1258  }
1259
1260  on call place() -> Effect[Result[(), OrderError]] {
1261    match status {
1262      Pending => {
1263        status := Placed
1264        Ok(())
1265      }
1266      Placed    => Err(AlreadyPlaced)
1267      Cancelled => Err(AlreadyCancelled)
1268    }
1269  }
1270}
1271"#;
1272
1273    #[test]
1274    fn the_order_lifecycle_moves_pending_to_placed_and_refuses_the_rest() {
1275        let m = model(ORDER, "Order");
1276        let sm = only(&m);
1277        assert_eq!(sm.field, "status");
1278        assert_eq!(sm.states, s(&["Pending", "Placed", "Cancelled"]));
1279        assert_eq!(sm.initial.as_deref(), Some("Pending"));
1280        assert_eq!(
1281            edges(sm),
1282            [
1283                // `addItem` never touches `status`: unchanged from every state.
1284                (
1285                    "addItem".into(),
1286                    s(&["Pending", "Placed", "Cancelled"]),
1287                    Target::Unchanged,
1288                    out("Ok(())")
1289                ),
1290                ("place".into(), s(&["Pending"]), to("Placed"), out("Ok(())")),
1291                (
1292                    "place".into(),
1293                    s(&["Placed"]),
1294                    Target::Unchanged,
1295                    out("Err(AlreadyPlaced)")
1296                ),
1297                (
1298                    "place".into(),
1299                    s(&["Cancelled"]),
1300                    Target::Unchanged,
1301                    out("Err(AlreadyCancelled)")
1302                ),
1303            ]
1304        );
1305        // Nothing moves to `Cancelled`, so it is unreachable from `Pending`.
1306        assert_eq!(sm.reachable, s(&["Pending", "Placed"]));
1307        assert!(sm.truncated.is_empty());
1308        // The edge's span is the write, for click-to-code.
1309        let write_at = ORDER.find("status := Placed").unwrap();
1310        assert_eq!(sm.edges[1].span.start, write_at);
1311    }
1312
1313    // 245_transition_step_invariant
1314    const PAID: &str = r#"context commerce.order
1315
1316type AuthId = String where NonEmpty
1317type OrderStatus = enum { Pending, Placed, Paid }
1318
1319agent Order {
1320  key id: String
1321
1322  store status: Cell[OrderStatus] = Pending
1323  store paymentRef: Cell[Option[AuthId]]
1324
1325  invariant paid_has_payment_ref:
1326    status == Paid implies paymentRef.isSome()
1327
1328  transition paid_is_terminal:
1329    old.status is Paid implies new.status is Paid
1330
1331  on call place() -> Effect[()] {
1332    status := Placed
1333    Effect.pure(())
1334  }
1335
1336  on call pay(ref: AuthId) -> Effect[()] {
1337    status := Paid
1338    paymentRef := Some(ref)
1339    Effect.pure(())
1340  }
1341}
1342"#;
1343
1344    #[test]
1345    fn an_unguarded_write_is_an_edge_from_every_state_including_paid() {
1346        let m = model(PAID, "Order");
1347        let sm = only(&m);
1348        let all = s(&["Pending", "Placed", "Paid"]);
1349        assert_eq!(
1350            edges(sm),
1351            [
1352                (
1353                    "place".into(),
1354                    all.clone(),
1355                    to("Placed"),
1356                    out("Effect.pure(())")
1357                ),
1358                ("pay".into(), all, to("Paid"), out("Effect.pure(())")),
1359            ]
1360        );
1361        assert_eq!(sm.reachable, s(&["Pending", "Placed", "Paid"]));
1362    }
1363
1364    // 156_state_transition and 2018_history_transition_near_miss
1365    #[test]
1366    fn a_write_in_tail_or_single_statement_position_is_still_the_write() {
1367        let turnstile = r#"context turnstile
1368type Gate = enum { Locked, Unlocked }
1369agent Turnstile {
1370  key id: String
1371  store gate: Cell[Gate] = Locked
1372  on call unlock() -> Effect[Result[(), String]] {
1373    gate := Unlocked
1374    Ok(())
1375  }
1376}
1377"#;
1378        let m = model(turnstile, "Turnstile");
1379        assert_eq!(
1380            edges(only(&m)),
1381            [(
1382                "unlock".into(),
1383                s(&["Locked", "Unlocked"]),
1384                to("Unlocked"),
1385                out("Ok(())")
1386            )]
1387        );
1388        let door = r#"context demo.door
1389type Status = enum { Open, Closed }
1390agent Door {
1391  key id: String
1392  store status: Cell[Status] = Open
1393  transition closed_is_final: old.status is Closed implies new.status is Closed
1394  on call close() -> Effect[()] { status := Closed }
1395}
1396"#;
1397        let m = model(door, "Door");
1398        assert_eq!(
1399            edges(only(&m)),
1400            [("close".into(), s(&["Open", "Closed"]), to("Closed"), None)]
1401        );
1402    }
1403
1404    // 155_state_sum_machine
1405    #[test]
1406    fn a_read_only_handler_is_unchanged_and_a_comparison_is_not_a_write() {
1407        let src = r#"context signal
1408type Light = enum { Red, Green }
1409agent Signal {
1410  key id: String
1411  store colour: Cell[Light] = Red
1412  on call peek() -> Effect[Result[(), String]] {
1413    Ok(())
1414  }
1415  on call turnGreen() -> Effect[()] {
1416    colour := Green
1417  }
1418  on call isGreen() -> Effect[Bool] {
1419    colour == Green
1420  }
1421}
1422"#;
1423        let m = model(src, "Signal");
1424        let sm = only(&m);
1425        let both = s(&["Red", "Green"]);
1426        assert_eq!(
1427            edges(sm),
1428            [
1429                (
1430                    "peek".into(),
1431                    both.clone(),
1432                    Target::Unchanged,
1433                    out("Ok(())")
1434                ),
1435                ("turnGreen".into(), both.clone(), to("Green"), None),
1436                (
1437                    "isGreen".into(),
1438                    both,
1439                    Target::Unchanged,
1440                    out("colour == Green")
1441                ),
1442            ]
1443        );
1444        assert_eq!(sm.reachable, s(&["Red", "Green"]));
1445    }
1446
1447    // 1649_agent_state_round_trip: a sum with payloads, and computed writes.
1448    #[test]
1449    fn payloads_abstract_to_constructors_and_a_computed_write_is_unknown() {
1450        let src = r#"context demo
1451type Shape =
1452  | Circle(r: Int)
1453  | Square(side: Int)
1454agent Figure {
1455  key id: String
1456  store shape: Cell[Shape] = Square(1)
1457  on call set(s: Shape) -> Effect[()] { shape := s }
1458  on call circle(r: Int) -> Effect[()] { shape := Circle(r) }
1459  on call get() -> Effect[Shape] { shape }
1460}
1461"#;
1462        let m = model(src, "Figure");
1463        let sm = only(&m);
1464        assert_eq!(sm.states, s(&["Circle", "Square"]));
1465        assert_eq!(sm.initial.as_deref(), Some("Square"));
1466        let both = s(&["Circle", "Square"]);
1467        assert_eq!(
1468            edges(sm),
1469            [
1470                ("set".into(), both.clone(), Target::Unknown, None),
1471                ("circle".into(), both.clone(), to("Circle"), None),
1472                ("get".into(), both, Target::Unchanged, out("shape")),
1473            ]
1474        );
1475    }
1476
1477    #[test]
1478    fn option_result_records_and_scalars_are_not_state_fields() {
1479        let src = r#"context demo
1480type Light = enum { Red, Green }
1481type Rec = { n: Int }
1482agent Many {
1483  key id: String
1484  store n: Cell[Option[Int]]
1485  store r: Cell[Result[Int, String]] = Err("unset")
1486  store rec: Cell[Rec] = Rec { n: 0 }
1487  store count: Cell[Int]
1488  store lights: Map[String, Light]
1489  store light: Cell[Light] = Red
1490  on call get() -> Effect[Int] { count }
1491}
1492"#;
1493        let m = model(src, "Many");
1494        assert_eq!(only(&m).field, "light");
1495    }
1496
1497    #[test]
1498    fn a_field_whose_type_does_not_resolve_is_skipped() {
1499        // `Elsewhere` is not in this unit's type table.
1500        let src = r#"context demo
1501agent A {
1502  key id: String
1503  store s: Cell[Elsewhere]
1504  on call get() -> Effect[()] { () }
1505}
1506"#;
1507        assert!(model(src, "A").machines.is_empty());
1508    }
1509
1510    const LIFECYCLE: &str = r#"context demo
1511type Status = enum { Draft, Live, Closed }
1512type DocError = enum { NotDraft, Invalid }
1513agent Doc {
1514  key id: String
1515  store status: Cell[Status] = Draft
1516  store attempts: Cell[Int]
1517
1518  // A domain `Err` after a write still commits the write.
1519  on call publish(ok: Bool) -> Effect[Result[(), DocError]] {
1520    if status is Draft {
1521      status := Live
1522      if ok { Ok(()) } else { Err(Invalid) }
1523    } else {
1524      Err(NotDraft)
1525    }
1526  }
1527
1528  // A read after a write sees the write: the second `if` is decided.
1529  on call reopen() -> Effect[Bool] {
1530    status := Draft
1531    if status == Draft { true } else { false }
1532  }
1533
1534  // `update` on the state field is a computed write; a later test on the
1535  // field narrows nothing and becomes a guard.
1536  on call cycle() -> Effect[Int] {
1537    let _ <- status.update((s) => s)
1538    if status is Closed { 0 } else { 1 }
1539  }
1540
1541  // A guard that isn't about the field is kept as text.
1542  on call close(force: Bool) -> Effect[()] {
1543    if force && attempts > 3 { status := Closed } else { () }
1544  }
1545}
1546"#;
1547
1548    #[test]
1549    fn a_domain_err_after_a_write_is_still_an_edge() {
1550        let m = model(LIFECYCLE, "Doc");
1551        let publish: Vec<_> = edges(only(&m))
1552            .into_iter()
1553            .filter(|e| e.0 == "publish")
1554            .collect();
1555        assert_eq!(
1556            publish,
1557            [
1558                ("publish".into(), s(&["Draft"]), to("Live"), out("Ok(())")),
1559                (
1560                    "publish".into(),
1561                    s(&["Draft"]),
1562                    to("Live"),
1563                    out("Err(Invalid)")
1564                ),
1565                (
1566                    "publish".into(),
1567                    s(&["Live", "Closed"]),
1568                    Target::Unchanged,
1569                    out("Err(NotDraft)")
1570                ),
1571            ]
1572        );
1573        let guarded: Vec<_> = only(&m)
1574            .edges
1575            .iter()
1576            .filter(|e| e.handler == "publish")
1577            .map(|e| e.guards.clone())
1578            .collect();
1579        assert_eq!(guarded, [s(&["ok"]), s(&["!(ok)"]), vec![]]);
1580    }
1581
1582    #[test]
1583    fn a_read_after_a_write_is_decided_not_narrowed() {
1584        let m = model(LIFECYCLE, "Doc");
1585        let reopen: Vec<_> = only(&m)
1586            .edges
1587            .iter()
1588            .filter(|e| e.handler == "reopen")
1589            .collect();
1590        // One path: the `else` is infeasible after `status := Draft`, and the
1591        // from-set is not narrowed by the read.
1592        assert_eq!(reopen.len(), 1);
1593        assert_eq!(reopen[0].from, s(&["Draft", "Live", "Closed"]));
1594        assert_eq!(reopen[0].to, to("Draft"));
1595        assert_eq!(reopen[0].outcome.as_deref(), Some("true"));
1596        assert!(reopen[0].guards.is_empty());
1597    }
1598
1599    #[test]
1600    fn a_cell_update_on_the_state_field_is_unknown_and_later_tests_are_guards() {
1601        let m = model(LIFECYCLE, "Doc");
1602        let cycle: Vec<_> = only(&m)
1603            .edges
1604            .iter()
1605            .filter(|e| e.handler == "cycle")
1606            .collect();
1607        assert_eq!(cycle.len(), 2);
1608        assert!(cycle.iter().all(|e| e.to == Target::Unknown));
1609        assert!(
1610            cycle
1611                .iter()
1612                .all(|e| e.from == s(&["Draft", "Live", "Closed"]))
1613        );
1614        assert_eq!(cycle[0].guards, s(&["status is Closed"]));
1615        assert_eq!(cycle[1].guards, s(&["status is Draft | Live"]));
1616        // An `Unknown` edge reaches every state.
1617        assert_eq!(only(&m).reachable, s(&["Draft", "Live", "Closed"]));
1618    }
1619
1620    #[test]
1621    fn an_opaque_guard_is_kept_as_source_text() {
1622        let m = model(LIFECYCLE, "Doc");
1623        let close: Vec<_> = only(&m)
1624            .edges
1625            .iter()
1626            .filter(|e| e.handler == "close")
1627            .map(|e| (e.to.clone(), e.guards.clone()))
1628            .collect();
1629        assert_eq!(
1630            close,
1631            [
1632                (to("Closed"), s(&["force && attempts > 3"])),
1633                (Target::Unchanged, s(&["!(force && attempts > 3)"])),
1634            ]
1635        );
1636    }
1637
1638    #[test]
1639    fn paths_that_agree_merge_at_the_join_and_combine_guards() {
1640        let src = r#"context demo
1641type S = enum { A, B }
1642agent M {
1643  key id: String
1644  store s: Cell[S] = A
1645  on call go(x: Bool, y: Bool) -> Effect[()] {
1646    if x { s := B } else { if y { s := B } else { () } }
1647  }
1648}
1649"#;
1650        let m = model(src, "M");
1651        let sm = only(&m);
1652        // Both `s := B` paths merge into one edge whose guard is either side.
1653        assert_eq!(sm.edges.len(), 2);
1654        assert_eq!(sm.edges[0].to, to("B"));
1655        assert_eq!(sm.edges[0].guards, s(&["x || (!(x) && y)"]));
1656        assert_eq!(sm.edges[1].to, Target::Unchanged);
1657    }
1658
1659    #[test]
1660    fn a_condition_and_its_negation_merge_to_no_guard() {
1661        let src = r#"context demo
1662type S = enum { A, B }
1663agent M {
1664  key id: String
1665  store s: Cell[S] = A
1666  on call go(x: Bool) -> Effect[()] {
1667    if x { s := B } else { s := B }
1668  }
1669}
1670"#;
1671        let m = model(src, "M");
1672        let sm = only(&m);
1673        // Both branches reach `B`: one edge, with no condition rather than
1674        // `x || !(x)`.
1675        assert_eq!(sm.edges.len(), 1);
1676        assert_eq!(sm.edges[0].to, to("B"));
1677        assert!(sm.edges[0].guards.is_empty(), "{:?}", sm.edges[0].guards);
1678    }
1679
1680    #[test]
1681    fn a_shadowed_field_name_is_not_the_field() {
1682        let src = r#"context demo
1683type S = enum { A, B }
1684agent M {
1685  key id: String
1686  store s: Cell[S] = A
1687  on call go(s: S) -> Effect[Int] {
1688    match s {
1689      A => 1
1690      B => 2
1691    }
1692  }
1693}
1694"#;
1695        let m = model(src, "M");
1696        let sm = only(&m);
1697        // The parameter `s` is matched, not the field: both arms keep the
1698        // full from-set and record the match as a guard.
1699        assert!(sm.edges.iter().all(|e| e.from == s(&["A", "B"])));
1700        assert_eq!(sm.edges[0].guards, s(&["s is A"]));
1701    }
1702
1703    #[test]
1704    fn a_wildcard_arm_takes_only_the_states_earlier_arms_left() {
1705        let src = r#"context demo
1706type S = enum { A, B, C }
1707agent M {
1708  key id: String
1709  store s: Cell[S] = A
1710  on call go() -> Effect[Int] {
1711    match s {
1712      A => 1
1713      _ => 2
1714    }
1715  }
1716}
1717"#;
1718        let m = model(src, "M");
1719        let from: Vec<_> = only(&m).edges.iter().map(|e| e.from.clone()).collect();
1720        assert_eq!(from, [s(&["A"]), s(&["B", "C"])]);
1721    }
1722
1723    #[test]
1724    fn a_handler_past_the_budget_is_truncated_to_an_unknown_edge_from_every_state() {
1725        let arms: String = (0..=PATH_BUDGET)
1726            .map(|i| format!("      {i} => {i}\n"))
1727            .collect();
1728        let src = format!(
1729            "context demo\ntype S = enum {{ A, B }}\nagent M {{\n  key id: String\n  store s: Cell[S] = A\n  on call go(n: Int) -> Effect[Int] {{\n    match n {{\n{arms}      _ => 0\n    }}\n  }}\n}}\n"
1730        );
1731        let m = model(&src, "M");
1732        let sm = only(&m);
1733        assert_eq!(sm.truncated, s(&["go"]));
1734        assert_eq!(
1735            edges(sm),
1736            [("go".into(), s(&["A", "B"]), Target::Unknown, None)]
1737        );
1738        assert_eq!(sm.reachable, s(&["A", "B"]));
1739    }
1740
1741    #[test]
1742    fn several_state_fields_give_one_machine_each() {
1743        let src = r#"context demo
1744type S = enum { A, B }
1745type T = enum { X, Y }
1746agent M {
1747  key id: String
1748  store s: Cell[S] = A
1749  store t: Cell[T] = X
1750  on call go() -> Effect[()] {
1751    match s {
1752      A => { t := Y }
1753      B => { () }
1754    }
1755  }
1756}
1757"#;
1758        let m = model(src, "M");
1759        assert_eq!(m.machines.len(), 2);
1760        // On `t`'s machine, the match on `s` is a guard.
1761        let t = &m.machines[1];
1762        assert_eq!(t.field, "t");
1763        assert_eq!(t.edges[0].to, to("Y"));
1764        assert_eq!(t.edges[0].guards, s(&["s is A"]));
1765    }
1766
1767    const SHAPES: &str = r#"context demo
1768type Shape =
1769  | Circle(r: Int)
1770  | Square(side: Int)
1771agent Figure {
1772  key id: String
1773  store shape: Cell[Shape] = Square(1)
1774
1775  // A refutable arm leaves its constructor to the later arms.
1776  on call grow() -> Effect[Int] {
1777    match shape {
1778      Square(1) => 0
1779      Square(n) => {
1780        shape := Circle(n)
1781        1
1782      }
1783      Circle(_) => 2
1784    }
1785  }
1786
1787  // A refined pattern, or a payload literal, covers nothing for the arms
1788  // after it.
1789  on call guarded() -> Effect[Int] {
1790    match shape {
1791      _ where Positive => 0
1792      Circle(0) => 1
1793      Circle(_) => 2
1794      Square(_) => 3
1795    }
1796  }
1797
1798  // `is` / `!=` against a payload: the false side keeps the constructor.
1799  on call probe() -> Effect[Int] {
1800    if shape is Square(1) { 0 } else { 1 }
1801  }
1802  on call probeNe() -> Effect[Int] {
1803    if shape != Square(1) { 0 } else { 1 }
1804  }
1805
1806  // After a known write, a refutable test is not decided.
1807  on call reset() -> Effect[Int] {
1808    shape := Square(1)
1809    if shape is Square(2) { 0 } else { 1 }
1810  }
1811}
1812"#;
1813
1814    fn from_of(sm: &StateMachine, handler: &str) -> Vec<(Vec<String>, Option<String>)> {
1815        sm.edges
1816            .iter()
1817            .filter(|e| e.handler == handler)
1818            .map(|e| (e.from.clone(), e.outcome.clone()))
1819            .collect()
1820    }
1821
1822    #[test]
1823    fn a_refutable_arm_does_not_take_its_constructor_from_later_arms() {
1824        let m = model(SHAPES, "Figure");
1825        let sm = only(&m);
1826        let grow: Vec<_> = sm.edges.iter().filter(|e| e.handler == "grow").collect();
1827        assert_eq!(grow.len(), 3);
1828        // `Square(n)` still sees `Square`, so `Square → Circle` survives.
1829        assert_eq!(grow[1].from, s(&["Square"]));
1830        assert_eq!(grow[1].to, to("Circle"));
1831        assert_eq!(grow[2].from, s(&["Circle"]));
1832        assert_eq!(sm.reachable, s(&["Circle", "Square"]));
1833    }
1834
1835    #[test]
1836    fn a_refined_wildcard_arm_leaves_every_state_to_the_later_arms() {
1837        let m = model(SHAPES, "Figure");
1838        assert_eq!(
1839            from_of(only(&m), "guarded"),
1840            [
1841                (s(&["Circle", "Square"]), out("0")),
1842                (s(&["Circle"]), out("1")),
1843                (s(&["Circle"]), out("2")),
1844                (s(&["Square"]), out("3")),
1845            ]
1846        );
1847    }
1848
1849    #[test]
1850    fn the_false_side_of_a_refutable_test_keeps_the_constructor() {
1851        let m = model(SHAPES, "Figure");
1852        let both = s(&["Circle", "Square"]);
1853        assert_eq!(
1854            from_of(only(&m), "probe"),
1855            [(s(&["Square"]), out("0")), (both.clone(), out("1"))]
1856        );
1857        // `!= Square(1)` true allows anything; false is only `Square`.
1858        assert_eq!(
1859            from_of(only(&m), "probeNe"),
1860            [(both, out("0")), (s(&["Square"]), out("1"))]
1861        );
1862    }
1863
1864    #[test]
1865    fn a_refutable_test_after_a_known_write_keeps_both_branches() {
1866        let m = model(SHAPES, "Figure");
1867        let reset: Vec<_> = from_of(only(&m), "reset")
1868            .into_iter()
1869            .map(|e| e.1)
1870            .collect();
1871        assert_eq!(reset, [out("0"), out("1")]);
1872    }
1873
1874    // -- #2197: the `transition` overlay.
1875
1876    fn violations(
1877        sm: &StateMachine,
1878        handler: &str,
1879    ) -> Vec<(String, Certainty, Vec<String>, Vec<String>)> {
1880        sm.edges
1881            .iter()
1882            .filter(|e| e.handler == handler)
1883            .flat_map(|e| e.violations.iter())
1884            .map(|v| {
1885                (
1886                    v.transition.clone(),
1887                    v.certainty,
1888                    v.from.clone(),
1889                    v.targets.clone(),
1890                )
1891            })
1892            .collect()
1893    }
1894
1895    #[test]
1896    fn an_unguarded_write_from_paid_violates_paid_is_terminal() {
1897        let m = model(PAID, "Order");
1898        let sm = only(&m);
1899        assert_eq!(
1900            violations(sm, "place"),
1901            [(
1902                "paid_is_terminal".into(),
1903                Certainty::Known,
1904                s(&["Paid"]),
1905                s(&["Placed"])
1906            )]
1907        );
1908        // `pay` writes `Paid`, which the predicate allows from anywhere.
1909        assert!(violations(sm, "pay").is_empty());
1910        // The snapshot `invariant` is not a step invariant: nothing listed.
1911        assert!(sm.not_evaluated.is_empty());
1912        let v = &sm.edges[0].violations[0];
1913        assert_eq!(
1914            v.span.start,
1915            PAID.find("transition paid_is_terminal").unwrap()
1916        );
1917    }
1918
1919    const OVERLAY: &str = r#"context demo
1920type S = enum { A, B, C }
1921agent M {
1922  key id: String
1923  store s: Cell[S] = A
1924  store hits: Cell[Int]
1925
1926  transition must_advance: old.s is A implies new.s is B
1927
1928  // Writes another field, so it commits: the refused path is A -> A.
1929  on call refuse() -> Effect[Bool] {
1930    hits := hits + 1
1931    match s {
1932      A => false
1933      B => true
1934      C => true
1935    }
1936  }
1937
1938  // Writes nothing, so it never commits: transitions are not checked.
1939  on call peek() -> Effect[Bool] {
1940    match s {
1941      A => false
1942      B => true
1943      C => true
1944    }
1945  }
1946
1947  // A computed write from A may land on B (fine) or A/C (violating).
1948  on call spin() -> Effect[()] {
1949    let _ <- s.update((x) => x)
1950    ()
1951  }
1952}
1953"#;
1954
1955    #[test]
1956    fn an_unchanged_path_violates_only_in_a_handler_that_commits() {
1957        let m = model(OVERLAY, "M");
1958        let sm = only(&m);
1959        assert_eq!(
1960            violations(sm, "refuse"),
1961            [(
1962                "must_advance".into(),
1963                Certainty::Known,
1964                s(&["A"]),
1965                s(&["A"])
1966            )]
1967        );
1968        assert!(violations(sm, "peek").is_empty(), "peek never commits");
1969    }
1970
1971    /// #2219: `ship()` moves `A -> C`, which `must_advance` refuses, but
1972    /// nothing leaves the agent in `A` after a commit, so the move can only be
1973    /// the genesis commit — which the runtime never checks.
1974    fn genesis(extra: &str) -> String {
1975        format!(
1976            r#"context demo
1977type S = enum {{ A, B, C }}
1978agent M {{
1979  key id: String
1980  store s: Cell[S] = A
1981  store hits: Cell[Int]
1982
1983  transition must_advance: old.s is A implies new.s is B
1984
1985  on call ship() -> Effect[()] {{
1986    if s is A {{ s := C }} else {{ () }}
1987  }}
1988{extra}
1989}}
1990"#
1991        )
1992    }
1993
1994    #[test]
1995    fn a_move_only_the_genesis_commit_can_make_is_not_flagged() {
1996        let m = model(&genesis(""), "M");
1997        let sm = only(&m);
1998        assert_eq!(
1999            sm.reachable,
2000            s(&["A", "C"]),
2001            "A is still a state the agent is in"
2002        );
2003        assert_eq!(sm.reachable_after_commit, s(&["C"]));
2004        assert!(
2005            violations(sm, "ship").is_empty(),
2006            "{:?}",
2007            violations(sm, "ship")
2008        );
2009    }
2010
2011    #[test]
2012    fn a_committing_unchanged_path_from_the_initial_state_brings_it_back() {
2013        // `touch()` commits (it writes `hits`) and leaves `s` at `A`: the
2014        // genesis call is skipped, but a second call from `A` is checked.
2015        let m = model(
2016            &genesis("  on call touch() -> Effect[()] {\n    hits := hits + 1\n  }"),
2017            "M",
2018        );
2019        let sm = only(&m);
2020        assert_eq!(sm.reachable_after_commit, s(&["A", "C"]));
2021        assert_eq!(
2022            violations(sm, "ship"),
2023            [(
2024                "must_advance".into(),
2025                Certainty::Known,
2026                s(&["A"]),
2027                s(&["C"])
2028            )]
2029        );
2030    }
2031
2032    #[test]
2033    fn a_move_back_into_the_initial_state_brings_it_back() {
2034        let m = model(
2035            &genesis("  on call reset() -> Effect[()] {\n    s := A\n  }"),
2036            "M",
2037        );
2038        let sm = only(&m);
2039        assert_eq!(sm.reachable_after_commit, s(&["A", "C"]));
2040        assert_eq!(violations(sm, "ship").len(), 1);
2041    }
2042
2043    #[test]
2044    fn a_computed_write_can_land_back_in_the_initial_state() {
2045        let m = model(
2046            &genesis(
2047                "  on call spin() -> Effect[()] {\n    let _ <- s.update((x) => x)\n    ()\n  }",
2048            ),
2049            "M",
2050        );
2051        assert_eq!(only(&m).reachable_after_commit, s(&["A", "B", "C"]));
2052    }
2053
2054    #[test]
2055    fn a_non_committing_handler_never_brings_the_initial_state_back() {
2056        // `peek()` writes nothing, so it never commits: being in `A` after it
2057        // is still being in `A` before any commit.
2058        let m = model(
2059            &genesis(
2060                "  on call peek() -> Effect[Bool] {\n    match s {\n      A => false\n      B => true\n      C => true\n    }\n  }",
2061            ),
2062            "M",
2063        );
2064        let sm = only(&m);
2065        assert_eq!(sm.reachable_after_commit, s(&["C"]));
2066        assert!(violations(sm, "ship").is_empty());
2067    }
2068
2069    #[test]
2070    fn without_a_literal_initialiser_every_reachable_state_counts() {
2071        let src = genesis("").replace("store s: Cell[S] = A", "store s: Cell[S]");
2072        let m = model(&src, "M");
2073        let sm = only(&m);
2074        assert_eq!(sm.reachable_after_commit, sm.reachable);
2075        assert_eq!(violations(sm, "ship").len(), 1);
2076    }
2077
2078    #[test]
2079    fn a_computed_write_is_possible_when_some_targets_violate() {
2080        let m = model(OVERLAY, "M");
2081        assert_eq!(
2082            violations(only(&m), "spin"),
2083            [(
2084                "must_advance".into(),
2085                Certainty::Possible,
2086                s(&["A"]),
2087                s(&["A", "C"])
2088            )]
2089        );
2090    }
2091
2092    #[test]
2093    fn a_computed_write_is_known_when_every_target_violates() {
2094        let src = r#"context demo
2095type S = enum { A, B }
2096agent M {
2097  key id: String
2098  store s: Cell[S] = A
2099  transition frozen: old.s is A implies !(new.s is A || new.s == B)
2100  on call spin() -> Effect[()] {
2101    let _ <- s.update((x) => x)
2102    ()
2103  }
2104}
2105"#;
2106        let m = model(src, "M");
2107        assert_eq!(
2108            violations(only(&m), "spin"),
2109            [("frozen".into(), Certainty::Known, s(&["A"]), s(&["A", "B"]))]
2110        );
2111    }
2112
2113    #[test]
2114    fn an_edge_from_an_unreachable_state_is_not_flagged() {
2115        let src = r#"context demo
2116type S = enum { Pending, Placed, Cancelled }
2117agent M {
2118  key id: String
2119  store s: Cell[S] = Pending
2120  transition cancelled_is_final: old.s is Cancelled implies new.s is Cancelled
2121  on call place() -> Effect[()] { s := Placed }
2122}
2123"#;
2124        let m = model(src, "M");
2125        let sm = only(&m);
2126        assert!(!sm.reachable.contains(&"Cancelled".to_string()));
2127        assert!(violations(sm, "place").is_empty());
2128    }
2129
2130    #[test]
2131    fn predicates_outside_the_fragment_are_listed_and_others_ignored() {
2132        // 1374's hoisting predicate, a refutable pattern, and a numeric
2133        // transition that never mentions the state field.
2134        let src = r#"context demo
2135type Shape = | Circle(r: Int) | Square(side: Int)
2136agent M {
2137  key id: String
2138  store shape: Cell[Shape] = Circle(1)
2139  store count: Cell[Int]
2140  transition hoisting: if old.shape == old.shape { true } else { false }
2141  transition refutable: old.shape is Circle(1) implies new.shape is Circle(1)
2142  transition counting: new.count >= old.count
2143  on call go() -> Effect[()] { shape := Square(1) }
2144}
2145"#;
2146        let m = model(src, "M");
2147        let sm = only(&m);
2148        let names: Vec<&str> = sm
2149            .not_evaluated
2150            .iter()
2151            .map(|n| n.transition.as_str())
2152            .collect();
2153        assert_eq!(names, ["hoisting", "refutable"]);
2154        assert!(sm.edges.iter().all(|e| e.violations.is_empty()));
2155    }
2156
2157    #[test]
2158    fn a_truncated_handler_is_checked_as_a_computed_write_only_if_it_commits() {
2159        let arms: String = (0..=PATH_BUDGET)
2160            .map(|i| format!("      {i} => {i}\n"))
2161            .collect();
2162        let src = format!(
2163            "context demo\ntype S = enum {{ A, B }}\nagent M {{\n  key id: String\n  store s: Cell[S] = A\n  store hits: Cell[Int]\n  transition a_stays: old.s is A implies new.s is A\n  on call go(n: Int) -> Effect[Int] {{\n    hits := n\n    match n {{\n{arms}      _ => 0\n    }}\n  }}\n  on call peek(n: Int) -> Effect[Int] {{\n    match n {{\n{arms}      _ => 0\n    }}\n  }}\n}}\n"
2164        );
2165        let m = model(&src, "M");
2166        assert_eq!(only(&m).truncated, s(&["go", "peek"]));
2167        // `go` writes `hits`, so it commits: checked as a computed write.
2168        assert_eq!(
2169            violations(only(&m), "go"),
2170            [("a_stays".into(), Certainty::Possible, s(&["A"]), s(&["B"]))]
2171        );
2172        // `peek` writes nothing, so it never commits: no transition runs.
2173        assert!(violations(only(&m), "peek").is_empty());
2174    }
2175
2176    #[test]
2177    fn certainty_and_targets_are_per_start_state() {
2178        // From C every target violates (Known); from A only A and C do
2179        // (Possible) — B is fine from A.
2180        let src = r#"context demo
2181type S = enum { A, B, C }
2182agent M {
2183  key id: String
2184  store s: Cell[S] = A
2185  transition t: !(old.s is C) && (old.s is A implies new.s is B)
2186  on call reset() -> Effect[()] { s := C }
2187  on call spin() -> Effect[()] {
2188    let _ <- s.update((x) => x)
2189    ()
2190  }
2191}
2192"#;
2193        let m = model(src, "M");
2194        assert_eq!(
2195            violations(only(&m), "spin"),
2196            [
2197                ("t".into(), Certainty::Possible, s(&["A"]), s(&["A", "C"])),
2198                ("t".into(), Certainty::Known, s(&["C"]), s(&["A", "B", "C"])),
2199            ]
2200        );
2201    }
2202
2203    #[test]
2204    fn a_parameter_shadowing_a_store_field_does_not_make_a_handler_commit() {
2205        let src = r#"context demo
2206type S = enum { A, B }
2207agent M {
2208  key id: String
2209  store s: Cell[S] = A
2210  store log: Log[Int]
2211  transition a_moves: old.s is A implies new.s is B
2212  on call peek(log: List[Int]) -> Effect[Bool] {
2213    let _ <- log.append(1)
2214    true
2215  }
2216}
2217"#;
2218        let m = model(src, "M");
2219        // `log` is the parameter, not the store field: nothing commits, so
2220        // the unchanged A -> A path is not flagged.
2221        assert!(violations(only(&m), "peek").is_empty());
2222    }
2223
2224    #[test]
2225    fn a_local_shadowing_a_store_field_does_not_make_a_handler_commit() {
2226        // #2220: a `let`, a match binding and a lambda parameter shadow the
2227        // field just as a handler parameter does (found by `bynk-lower`'s
2228        // agreement test against `body_writes_state`).
2229        let src = r#"context demo
2230type S = enum { A, B }
2231agent M {
2232  key id: String
2233  store s: Cell[S] = A
2234  store log: Log[Int]
2235  transition a_moves: old.s is A implies new.s is B
2236  on call byLet(xs: List[Int]) -> Effect[Bool] {
2237    let log = xs
2238    let _ <- log.append(1)
2239    true
2240  }
2241  on call byArm(o: Option[List[Int]]) -> Effect[()] {
2242    match o {
2243      Some(log) => log.append(1)
2244      None => Effect.pure(())
2245    }
2246  }
2247  on call byLambda(xss: List[List[Int]]) -> Effect[Bool] {
2248    let _ = xss.map((log) => log.append(1))
2249    true
2250  }
2251  on call byBlockLambda(xss: List[List[Int]]) -> Effect[Bool] {
2252    let _ = xss.map((x) => {
2253      let log = x
2254      log.append(1)
2255    })
2256    true
2257  }
2258}
2259"#;
2260        let m = model(src, "M");
2261        for h in ["byLet", "byArm", "byLambda", "byBlockLambda"] {
2262            assert!(violations(only(&m), h).is_empty(), "{h}");
2263        }
2264    }
2265
2266    #[test]
2267    fn a_known_violation_warns_at_the_write_with_the_transition_and_a_witness() {
2268        // Fixture 245: `place()` from `Paid`, reachable through `pay()`.
2269        let w = warnings(PAID, "Order");
2270        assert_eq!(
2271            w,
2272            [(
2273                "`place()` can move `status` from `Paid` to `Placed`, which `paid_is_terminal` refuses at commit"
2274                    .to_string(),
2275                vec![
2276                    "`paid_is_terminal` is declared here".to_string(),
2277                    "`pay()` moves `status` to `Paid`".to_string(),
2278                ],
2279            )]
2280        );
2281    }
2282
2283    #[test]
2284    fn a_refused_unchanged_path_in_a_committing_handler_warns() {
2285        let w = warnings(OVERLAY, "M");
2286        assert!(
2287            w.iter().any(|(m, l)| m
2288                == "`refuse()` can leave `s` at `A`, which `must_advance` refuses at commit"
2289                && l[1] == "`refuse()` commits and leaves `s` at `A`"),
2290            "{w:?}"
2291        );
2292        // `spin()`'s computed write only *may* land badly: no warning.
2293        assert!(!w.iter().any(|(m, _)| m.starts_with("`spin()`")), "{w:?}");
2294        // `peek()` never commits.
2295        assert!(!w.iter().any(|(m, _)| m.starts_with("`peek()`")), "{w:?}");
2296    }
2297
2298    #[test]
2299    fn the_witness_is_a_path_that_can_start_in_a_reachable_state() {
2300        // `C` is unreachable, so `x()`'s `C -> B` can't explain why `B` is
2301        // reachable even though it comes first; `y()` does.
2302        let src = r#"context demo
2303type S = enum { A, B, C }
2304agent M {
2305  key id: String
2306  store s: Cell[S] = A
2307  transition b_is_final: old.s is B implies new.s is B
2308  on call x() -> Effect[()] {
2309    match s {
2310      C => { s := B }
2311      _ => { () }
2312    }
2313  }
2314  on call y() -> Effect[()] { s := B }
2315  on call undo() -> Effect[()] { s := A }
2316}
2317"#;
2318        let w = warnings(src, "M");
2319        let undo = w
2320            .iter()
2321            .find(|(m, _)| m.starts_with("`undo()`"))
2322            .unwrap_or_else(|| panic!("{w:?}"));
2323        assert_eq!(undo.1[1], "`y()` moves `s` to `B`");
2324    }
2325
2326    #[test]
2327    fn a_warning_names_the_paths_guards() {
2328        let src = r#"context demo
2329type S = enum { A, B }
2330agent M {
2331  key id: String
2332  store s: Cell[S] = A
2333  transition b_is_final: old.s is B implies new.s is B
2334  on call finish() -> Effect[()] { s := B }
2335  on call undo(force: Bool) -> Effect[()] {
2336    if force { s := A } else { () }
2337  }
2338}
2339"#;
2340        assert_eq!(
2341            warnings(src, "M")[0].0,
2342            "`undo()` can move `s` from `B` to `A` (when `force`), which `b_is_final` refuses at commit"
2343        );
2344    }
2345
2346    #[test]
2347    fn a_move_only_the_genesis_commit_can_make_does_not_warn() {
2348        assert!(warnings(&genesis(""), "M").is_empty());
2349    }
2350
2351    #[test]
2352    fn a_not_evaluated_transition_does_not_warn() {
2353        let src = r#"context demo
2354type S = enum { A, B }
2355agent M {
2356  key id: String
2357  store s: Cell[S] = A
2358  store n: Cell[Int]
2359  transition counted: old.s is A implies new.n > old.n
2360  on call go() -> Effect[()] { s := B }
2361}
2362"#;
2363        assert!(only(&model(src, "M")).not_evaluated.len() == 1);
2364        assert!(warnings(src, "M").is_empty());
2365    }
2366
2367    #[test]
2368    fn a_truncated_handler_does_not_warn() {
2369        // Past the path budget, `go()` becomes one `?` edge from every state.
2370        // Every target from `A` violates `never_from_a`, so the overlay marks
2371        // it Known, but a truncated handler's edges aren't trustworthy enough
2372        // to warn on.
2373        let arms: String = (0..=PATH_BUDGET)
2374            .map(|i| format!("      {i} => {i}\n"))
2375            .collect();
2376        let src = format!(
2377            "context demo\ntype S = enum {{ A, B }}\nagent M {{\n  key id: String\n  store s: Cell[S] = A\n  store hits: Cell[Int]\n  transition never_from_a: !(old.s is A)\n  on call go(n: Int) -> Effect[Int] {{\n    hits := hits + 1\n    match n {{\n{arms}      _ => 0\n    }}\n  }}\n}}\n"
2378        );
2379        let m = model(&src, "M");
2380        assert_eq!(only(&m).truncated, s(&["go"]));
2381        assert!(
2382            !violations(only(&m), "go").is_empty(),
2383            "the overlay still flags it"
2384        );
2385        assert!(warnings(&src, "M").is_empty());
2386    }
2387
2388    #[test]
2389    fn an_agent_without_transitions_has_no_warnings() {
2390        let src = r#"context demo
2391type S = enum { A, B }
2392agent M {
2393  key id: String
2394  store s: Cell[S] = A
2395  on call go() -> Effect[()] { s := B }
2396}
2397"#;
2398        assert!(warnings(src, "M").is_empty());
2399    }
2400}