1use 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
61pub const PATH_BUDGET: usize = 256;
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct AgentStateModel {
69 pub agent: String,
70 pub machines: Vec<StateMachine>,
71}
72
73#[derive(Debug, Clone, PartialEq)]
75pub struct StateMachine {
76 pub field: String,
77 pub field_span: Span,
78 pub states: Vec<String>,
80 pub initial: Option<String>,
83 pub edges: Vec<Edge>,
84 pub reachable: Vec<String>,
87 pub reachable_after_commit: Vec<String>,
95 pub truncated: Vec<String>,
97 pub not_evaluated: Vec<NotEvaluated>,
100}
101
102#[derive(Debug, Clone, PartialEq)]
104pub struct Edge {
105 pub handler: String,
106 pub from: Vec<String>,
108 pub guards: Vec<String>,
110 pub to: Target,
111 pub outcome: Option<String>,
113 pub span: Span,
115 pub violations: Vec<Violation>,
117 pub commits: bool,
122}
123
124#[derive(Debug, Clone, PartialEq)]
126pub struct Violation {
127 pub transition: String,
129 pub span: Span,
130 pub certainty: Certainty,
131 pub from: Vec<String>,
134 pub targets: Vec<String>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141pub enum Certainty {
142 Known,
144 Possible,
146}
147
148#[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 Unknown,
160 Unchanged,
162}
163
164pub 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
230fn witness(sm: &StateMachine, state: &str) -> Option<(Span, String)> {
234 let field = &sm.field;
235 let initial = sm.initial.as_deref()?;
236 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
272pub 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
294fn 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
400fn 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 if !e.commits {
435 continue;
436 }
437 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
472pub 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
485fn 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 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
568enum 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
592fn 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 (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
658fn 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
690fn 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 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
724fn 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#[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 Pre,
758 Known(usize),
759 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1067pub 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 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
1096fn 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 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 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 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 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 (
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 assert_eq!(sm.reachable, s(&["Pending", "Placed"]));
1307 assert!(sm.truncated.is_empty());
1308 let write_at = ORDER.find("status := Placed").unwrap();
1310 assert_eq!(sm.edges[1].span.start, write_at);
1311 }
1312
1313 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 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 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 assert!(violations(sm, "pay").is_empty());
1910 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 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 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 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 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 assert_eq!(
2169 violations(only(&m), "go"),
2170 [("a_stays".into(), Certainty::Possible, s(&["A"]), s(&["B"]))]
2171 );
2172 assert!(violations(only(&m), "peek").is_empty());
2174 }
2175
2176 #[test]
2177 fn certainty_and_targets_are_per_start_state() {
2178 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 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 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 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 assert!(!w.iter().any(|(m, _)| m.starts_with("`spin()`")), "{w:?}");
2294 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 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 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}