Skip to main content

bynk_ide/
agent_effects.rs

1//! #2198: the per-handler effect summary — what each of an agent's handlers
2//! writes, reads and calls, path by path (agent-state-views track, slice 4;
3//! summary in `design/archive/retired-tracks.md`, §3.1).
4//!
5//! The state model (`agent_state`) answers "which state does each handler
6//! move the agent to?" for a field of an enum or sum type. Most agents have no
7//! such field — maps, counters, logs — and for them the useful question is
8//! "what does each handler touch, under which conditions?". For every handler
9//! this returns rows, one per merged path:
10//!
11//! - its **guards**, as source text (`found is Some`);
12//! - the store fields it **writes**, and how (`:=`, `put`, `append`, …);
13//! - the fields it only **reads** (a read of a field after the path writes
14//!   it is not listed separately);
15//! - the **agents it calls** (`Counter.bump`), classified as the sequence
16//!   diagram classifies them;
17//! - its **outcomes**.
18//!
19//! The walk follows the state model's conventions (#2198 DECISION A): the same
20//! statement-level scope (ADR 0259), the same store-write recogniser, joins
21//! that merge paths producing the same row (here: the same writes, reads and
22//! calls, with guards combined and outcomes collected), and the same
23//! [`PATH_BUDGET`] backstop — a handler past it is one truncated row. It is a
24//! separate, smaller walker: it tracks no state field, so none of the state
25//! model's narrowing applies.
26
27use std::collections::{BTreeSet, HashMap, HashSet};
28
29use bynk_syntax::ast::*;
30use bynk_syntax::span::Span;
31
32use crate::agent_state::{PATH_BUDGET, combine_guards};
33use bynk_check::ast_text::{branch_outcome, pattern_summary, peel_paren};
34use bynk_check::store_write::{store_kinds, store_write};
35
36/// One handler's effects.
37#[derive(Debug, Clone, PartialEq)]
38pub struct HandlerEffects {
39    pub handler: String,
40    pub rows: Vec<EffectRow>,
41    /// The handler exceeded [`PATH_BUDGET`]; `rows` is empty.
42    pub truncated: bool,
43}
44
45/// One merged path through a handler.
46#[derive(Debug, Clone, PartialEq)]
47pub struct EffectRow {
48    /// Conditions on the path, as source text.
49    pub guards: Vec<String>,
50    /// Writes in source order.
51    pub writes: Vec<EffectWrite>,
52    /// Store fields read but not written on this path, sorted.
53    pub reads: Vec<String>,
54    /// Agent calls as `Agent.method`, sorted.
55    pub calls: Vec<String>,
56    /// The values the path yields; empty for unit.
57    pub outcomes: Vec<String>,
58}
59
60#[derive(Debug, Clone, PartialEq)]
61pub struct EffectWrite {
62    pub field: String,
63    /// `:=` for a cell assignment, else the store operation (`put`).
64    pub op: String,
65    /// The written key or item when it is a plain name or literal
66    /// (`items.put(id, …)` → `id`), for the label.
67    pub key: Option<String>,
68    pub span: Span,
69}
70
71/// The effects of every handler of `agent`. `agents` names the agents a
72/// handler can call (the context's own, as `ContextSequenceInfo::agents`
73/// lists them).
74pub fn agent_effects(agent: &AgentDecl, agents: &HashSet<String>) -> Vec<HandlerEffects> {
75    let store = store_kinds(agent);
76    agent
77        .handlers
78        .iter()
79        .map(|h| {
80            let mut w = Walker {
81                store: &store,
82                agents,
83                bound: h.params.iter().map(|p| p.name.name.clone()).collect(),
84                truncated: false,
85            };
86            let paths = w.walk_block(&h.body, vec![Path::default()], true);
87            let handler = h
88                .method_name
89                .as_ref()
90                .map_or_else(|| "call".to_string(), |m| m.name.clone());
91            HandlerEffects {
92                handler,
93                truncated: w.truncated,
94                rows: if w.truncated {
95                    Vec::new()
96                } else {
97                    paths.into_iter().map(Path::into_row).collect()
98                },
99            }
100        })
101        .collect()
102}
103
104#[derive(Debug, Clone, Default)]
105struct Path {
106    guards: BTreeSet<String>,
107    writes: Vec<EffectWrite>,
108    reads: BTreeSet<String>,
109    calls: BTreeSet<String>,
110    outcomes: BTreeSet<String>,
111}
112
113impl Path {
114    fn written(&self, field: &str) -> bool {
115        self.writes.iter().any(|w| w.field == field)
116    }
117
118    /// Paths with the same key produce the same row, guards and outcomes aside.
119    /// A write's key is part of it: a row shows, and links to, its own writes.
120    fn same_key(&self, other: &Path) -> bool {
121        let ops = |p: &Path| -> Vec<(String, String, Option<String>)> {
122            p.writes
123                .iter()
124                .map(|w| (w.field.clone(), w.op.clone(), w.key.clone()))
125                .collect()
126        };
127        ops(self) == ops(other) && self.reads == other.reads && self.calls == other.calls
128    }
129
130    fn into_row(self) -> EffectRow {
131        EffectRow {
132            guards: self.guards.into_iter().collect(),
133            writes: self.writes,
134            reads: self.reads.into_iter().collect(),
135            calls: self.calls.into_iter().collect(),
136            outcomes: self.outcomes.into_iter().collect(),
137        }
138    }
139}
140
141struct Walker<'a> {
142    store: &'a HashMap<String, String>,
143    agents: &'a HashSet<String>,
144    bound: Vec<String>,
145    truncated: bool,
146}
147
148impl Walker<'_> {
149    fn walk_block(&mut self, block: &Block, mut paths: Vec<Path>, tail: bool) -> Vec<Path> {
150        let scope = self.bound.len();
151        for stmt in &block.statements {
152            if self.truncated {
153                return Vec::new();
154            }
155            paths = match stmt {
156                Statement::Let(l) | Statement::EffectLet(l) => {
157                    let paths = self.walk_expr(&l.value, paths, false);
158                    self.bound.push(l.name.name.clone());
159                    paths
160                }
161                Statement::Do(d) => self.walk_expr(&d.value, paths, false),
162                Statement::Expect(e) => self.walk_expr(&e.value, paths, false),
163                Statement::Send(s) => self.walk_expr(&s.value, paths, false),
164                Statement::Advance(a) => self.walk_expr(&a.amount, paths, false),
165                Statement::Assign(a) => {
166                    let mut paths = self.walk_expr(&a.value, paths, false);
167                    for p in &mut paths {
168                        p.writes.push(EffectWrite {
169                            field: a.target.name.clone(),
170                            op: ":=".to_string(),
171                            key: None,
172                            span: a.span,
173                        });
174                    }
175                    paths
176                }
177            };
178        }
179        let paths = self.walk_expr(&block.tail, paths, tail);
180        self.bound.truncate(scope);
181        paths
182    }
183
184    fn walk_expr(&mut self, expr: &Expr, mut paths: Vec<Path>, tail: bool) -> Vec<Path> {
185        if self.truncated || paths.is_empty() {
186            return paths;
187        }
188        let inner = peel_paren(expr);
189        match &inner.kind {
190            ExprKind::If {
191                cond,
192                then_block,
193                else_block,
194            } => {
195                self.scan(cond, &mut paths);
196                let text = bynk_fmt::expr_to_string(cond);
197                let then_paths = guarded(paths.clone(), text.clone());
198                let else_paths = guarded(paths, format!("!({text})"));
199                let mut out = self.walk_block(then_block, then_paths, tail);
200                out.extend(self.walk_block(else_block, else_paths, tail));
201                self.join(out)
202            }
203            ExprKind::Match { discriminant, arms } => {
204                self.scan(discriminant, &mut paths);
205                let subject = bynk_fmt::expr_to_string(discriminant);
206                let mut out = Vec::new();
207                for arm in arms {
208                    let mut arm_paths = guarded(
209                        paths.clone(),
210                        format!("{subject} is {}", pattern_summary(&arm.pattern)),
211                    );
212                    let scope = self.bound.len();
213                    self.bound.extend(
214                        arm.pattern
215                            .bound_names()
216                            .into_iter()
217                            .map(|n| n.name.clone()),
218                    );
219                    if let Some(g) = &arm.guard {
220                        self.scan(g, &mut arm_paths);
221                        arm_paths = guarded(arm_paths, bynk_fmt::expr_to_string(g));
222                    }
223                    out.extend(match &arm.body {
224                        MatchBody::Expr(e) => self.walk_expr(e, arm_paths, tail),
225                        MatchBody::Block(b) => self.walk_block(b, arm_paths, tail),
226                    });
227                    self.bound.truncate(scope);
228                }
229                self.join(out)
230            }
231            ExprKind::Block(b) => self.walk_block(b, paths, tail),
232            _ => {
233                if let Some(w) = store_write(self.store, &self.bound, inner) {
234                    for a in w.args {
235                        self.scan(a, &mut paths);
236                    }
237                    let key = w.args.first().and_then(simple_text);
238                    for p in &mut paths {
239                        p.writes.push(EffectWrite {
240                            field: w.field.name.clone(),
241                            op: w.method.name.clone(),
242                            key: key.clone(),
243                            span: inner.span,
244                        });
245                    }
246                    // A store write yields `()`: no outcome.
247                } else {
248                    self.scan(inner, &mut paths);
249                    if tail && let Some(o) = branch_outcome(inner) {
250                        for p in &mut paths {
251                            p.outcomes.insert(o.clone());
252                        }
253                    }
254                }
255                paths
256            }
257        }
258    }
259
260    /// Record the reads and agent calls in `expr` (descending into every
261    /// sub-expression, lambdas included) on each path.
262    fn scan(&self, expr: &Expr, paths: &mut [Path]) {
263        let mut reads = BTreeSet::new();
264        let mut calls = BTreeSet::new();
265        let mut shadow = self.bound.clone();
266        self.collect(expr, &mut shadow, &mut reads, &mut calls);
267        for p in paths {
268            let fresh: Vec<String> = reads.iter().filter(|f| !p.written(f)).cloned().collect();
269            p.reads.extend(fresh);
270            p.calls.extend(calls.iter().cloned());
271        }
272    }
273
274    /// `shadow` is the bound names in scope: the walker's, plus the
275    /// parameters of the lambdas `expr` sits in.
276    fn collect(
277        &self,
278        expr: &Expr,
279        shadow: &mut Vec<String>,
280        reads: &mut BTreeSet<String>,
281        calls: &mut BTreeSet<String>,
282    ) {
283        if let ExprKind::Lambda(l) = &expr.kind {
284            let scope = shadow.len();
285            shadow.extend(l.params.iter().map(|p| p.name.name.clone()));
286            self.collect(&l.body, shadow, reads, calls);
287            shadow.truncate(scope);
288            return;
289        }
290        // A write nested in an argument or lambda is out of scope (as in
291        // every agent view): neither a write nor a read of its field.
292        if let Some(w) = store_write(self.store, shadow, expr) {
293            for a in w.args {
294                self.collect(a, shadow, reads, calls);
295            }
296            return;
297        }
298        match &expr.kind {
299            ExprKind::Ident(id)
300                if self.store.contains_key(&id.name) && !shadow.contains(&id.name) =>
301            {
302                reads.insert(id.name.clone());
303            }
304            ExprKind::MethodCall {
305                receiver, method, ..
306            } => {
307                if let ExprKind::Call { name, .. } = &receiver.kind
308                    && self.agents.contains(&name.name)
309                {
310                    calls.insert(format!("{}.{}", name.name, method.name));
311                }
312            }
313            _ => {}
314        }
315        for c in expr_children(expr) {
316            self.collect(c, shadow, reads, calls);
317        }
318    }
319
320    fn join(&mut self, paths: Vec<Path>) -> Vec<Path> {
321        let mut out: Vec<Path> = Vec::new();
322        for p in paths {
323            match out.iter_mut().find(|q| q.same_key(&p)) {
324                Some(q) => {
325                    q.guards = combine_guards(&q.guards, &p.guards);
326                    q.outcomes.extend(p.outcomes);
327                }
328                None => out.push(p),
329            }
330        }
331        if out.len() > PATH_BUDGET {
332            self.truncated = true;
333            return Vec::new();
334        }
335        out
336    }
337}
338
339fn guarded(mut paths: Vec<Path>, label: String) -> Vec<Path> {
340    for p in &mut paths {
341        p.guards.insert(label.clone());
342    }
343    paths
344}
345
346/// A plain name or literal argument's text — what a write's label shows.
347fn simple_text(arg: &Expr) -> Option<String> {
348    matches!(
349        peel_paren(arg).kind,
350        ExprKind::Ident(_) | ExprKind::IntLit { .. } | ExprKind::StrLit(_) | ExprKind::BoolLit(_)
351    )
352    .then(|| bynk_fmt::expr_to_string(arg))
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    fn effects(src: &str, agent: &str, agents: &[&str]) -> Vec<HandlerEffects> {
360        let tokens = bynk_syntax::lexer::tokenize(src).expect("tokenize");
361        let (unit, errs) = bynk_syntax::parser::parse_unit_with_recovery(&tokens, src);
362        assert!(errs.is_empty(), "parse errors: {errs:?}");
363        let Some(SourceUnit::Context(ctx)) = unit else {
364            panic!("expected a context")
365        };
366        let a = ctx
367            .items
368            .iter()
369            .find_map(|i| match i {
370                CommonsItem::Agent(a) if a.name.name == agent => Some(a),
371                _ => None,
372            })
373            .expect("agent");
374        let names = agents.iter().map(|s| s.to_string()).collect();
375        agent_effects(a, &names)
376    }
377
378    fn handler<'a>(all: &'a [HandlerEffects], name: &str) -> &'a HandlerEffects {
379        all.iter().find(|h| h.handler == name).expect("handler")
380    }
381
382    /// `(guards, writes as field.op(key), reads, calls, outcomes)`.
383    type Summary = (
384        Vec<String>,
385        Vec<String>,
386        Vec<String>,
387        Vec<String>,
388        Vec<String>,
389    );
390
391    fn summary(h: &HandlerEffects) -> Vec<Summary> {
392        h.rows
393            .iter()
394            .map(|r| {
395                let writes = r
396                    .writes
397                    .iter()
398                    .map(|w| match (&w.key, w.op.as_str()) {
399                        (_, ":=") => format!("{} :=", w.field),
400                        (Some(k), op) => format!("{}.{op}({k})", w.field),
401                        (None, op) => format!("{}.{op}", w.field),
402                    })
403                    .collect();
404                (
405                    r.guards.clone(),
406                    writes,
407                    r.reads.clone(),
408                    r.calls.clone(),
409                    r.outcomes.clone(),
410                )
411            })
412            .collect()
413    }
414
415    fn v(x: &[&str]) -> Vec<String> {
416        x.iter().map(|s| s.to_string()).collect()
417    }
418
419    // examples/todo's agent, inline (bynk-ide tests stay self-contained).
420    const TODOS: &str = r#"context todo
421
422type UserId = String where NonEmpty
423type Title = String where NonEmpty
424type Stored = { seq: Int, title: Title, done: Bool }
425type TodoItem = { id: String, title: Title, done: Bool }
426type TodoError = enum { NotFound }
427
428fn view(id: String, s: Stored) -> TodoItem {
429  TodoItem { id: id, title: s.title, done: s.done }
430}
431
432agent Todos {
433  key owner: UserId
434
435  store items:   Map[String, Stored]
436  store lastSeq: Cell[Int]
437
438  on call add(title: Title) -> Effect[TodoItem] {
439    let next = lastSeq + 1
440    let id   = "\(next)"
441    let item = Stored { seq: next, title: title, done: false }
442    let _ <- items.put(id, item)
443    lastSeq := next
444    view(id, item)
445  }
446
447  on call pendingCount() -> Effect[Int] {
448    items.values.filter((it) => it.done == false).count()
449  }
450
451  on call complete(id: String) -> Effect[Result[(), TodoError]] {
452    let found <- items.get(id)
453    match found {
454      Some(it) => {
455        let _ <- items.put(id, Stored { ...it, done: true })
456        Ok(())
457      }
458      None => Err(NotFound)
459    }
460  }
461}
462"#;
463
464    #[test]
465    fn todos_add_writes_both_fields_and_reads_the_counter_first() {
466        let all = effects(TODOS, "Todos", &[]);
467        assert_eq!(
468            summary(handler(&all, "add")),
469            [(
470                v(&[]),
471                v(&["items.put(id)", "lastSeq :="]),
472                v(&["lastSeq"]),
473                v(&[]),
474                v(&["view(id, item)"])
475            )]
476        );
477    }
478
479    #[test]
480    fn todos_complete_writes_only_when_the_item_is_found() {
481        let all = effects(TODOS, "Todos", &[]);
482        assert_eq!(
483            summary(handler(&all, "complete")),
484            [
485                (
486                    v(&["found is Some"]),
487                    v(&["items.put(id)"]),
488                    v(&["items"]),
489                    v(&[]),
490                    v(&["Ok(())"])
491                ),
492                (
493                    v(&["found is None"]),
494                    v(&[]),
495                    v(&["items"]),
496                    v(&[]),
497                    v(&["Err(NotFound)"])
498                ),
499            ]
500        );
501    }
502
503    #[test]
504    fn a_query_accessor_is_a_read() {
505        let all = effects(TODOS, "Todos", &[]);
506        let rows = summary(handler(&all, "pendingCount"));
507        assert_eq!(rows.len(), 1);
508        assert_eq!(rows[0].1, v(&[]));
509        assert_eq!(rows[0].2, v(&["items"]));
510    }
511
512    #[test]
513    fn the_rate_limiter_reads_then_writes_both_cells() {
514        let src = r#"context ratelimit
515type RateView = { allowed: Bool }
516agent Limiter {
517  key client: String
518  store windowStart: Cell[Int]
519  store count:       Cell[Int]
520  on call hit(now: Int) -> Effect[RateView] {
521    let d = decide(windowStart, count, now)
522    windowStart := d.windowStart
523    count := d.count
524    RateView { allowed: d.allowed }
525  }
526}
527"#;
528        let all = effects(src, "Limiter", &[]);
529        let rows = summary(handler(&all, "hit"));
530        assert_eq!(rows.len(), 1);
531        assert_eq!(rows[0].1, v(&["windowStart :=", "count :="]));
532        assert_eq!(rows[0].2, v(&["count", "windowStart"]));
533    }
534
535    #[test]
536    fn a_log_append_is_a_write_and_its_queries_are_reads() {
537        let src = r#"context events
538type Event = { kind: String }
539agent Activity {
540  key stream: String
541  store history: Log[Event]
542  on call add(event: Event) -> Effect[()] {
543    let _ <- history.append(event)
544    Effect.pure(())
545  }
546  on call countSince(t: Int) -> Effect[Int] {
547    history.since(t).count()
548  }
549}
550"#;
551        let all = effects(src, "Activity", &[]);
552        let add = summary(handler(&all, "add"));
553        assert_eq!(add[0].1, v(&["history.append(event)"]));
554        assert_eq!(add[0].2, v(&[]), "the write's receiver is not also a read");
555        let count = summary(handler(&all, "countSince"));
556        assert_eq!(count[0].1, v(&[]));
557        assert_eq!(count[0].2, v(&["history"]));
558    }
559
560    #[test]
561    fn agent_calls_are_listed_and_paths_that_agree_merge() {
562        let src = r#"context shop
563agent Counter {
564  key id: String
565  store n: Cell[Int]
566  on call bump() -> Effect[Int] { n := n + 1
567    n }
568}
569agent Basket {
570  key id: String
571  store total: Cell[Int]
572  on call add(k: String, big: Bool) -> Effect[Int] {
573    let c <- Counter(k).bump()
574    let _ = if big { total := total + 10
575      () } else { total := total + 1
576      () }
577    c
578  }
579}
580"#;
581        let all = effects(src, "Basket", &["Counter", "Basket"]);
582        let rows = summary(handler(&all, "add"));
583        // Both branches write `total` and read it, so they merge into one row
584        // whose guard is either side's — no condition at all.
585        assert_eq!(rows.len(), 1);
586        assert_eq!(rows[0].0, v(&[]));
587        assert_eq!(rows[0].1, v(&["total :="]));
588        assert_eq!(rows[0].2, v(&["total"]));
589        assert_eq!(rows[0].3, v(&["Counter.bump"]));
590        assert_eq!(rows[0].4, v(&["c"]));
591    }
592
593    #[test]
594    fn a_shadowed_field_is_neither_read_nor_written() {
595        let src = r#"context demo
596agent A {
597  key id: String
598  store items: Map[String, Int]
599  on call go(items: List[Int]) -> Effect[Int] {
600    items.size()
601  }
602}
603"#;
604        let all = effects(src, "A", &[]);
605        let rows = summary(handler(&all, "go"));
606        assert_eq!(rows[0].1, v(&[]));
607        assert_eq!(rows[0].2, v(&[]));
608    }
609
610    #[test]
611    fn writes_with_different_keys_stay_separate_rows() {
612        let src = r#"context demo
613agent A {
614  key id: String
615  store items: Map[String, Int]
616  on call go(c: Bool, a: String, b: String, x: Int) -> Effect[()] {
617    let _ = if c { let _ <- items.put(a, x)
618      () } else { let _ <- items.put(b, x)
619      () }
620    Effect.pure(())
621  }
622}
623"#;
624        let all = effects(src, "A", &[]);
625        let h = handler(&all, "go");
626        let rows = summary(h);
627        // Each row shows, and links to, its own write.
628        assert_eq!(rows.len(), 2);
629        assert_eq!(rows[0].1, v(&["items.put(a)"]));
630        assert_eq!(rows[1].1, v(&["items.put(b)"]));
631        assert_ne!(h.rows[0].writes[0].span, h.rows[1].writes[0].span);
632    }
633
634    #[test]
635    fn a_lambda_parameter_shadows_a_field_and_a_nested_write_is_not_a_read() {
636        let src = r#"context demo
637agent A {
638  key id: String
639  store count: Cell[Int]
640  store items: Map[String, Int]
641  on call go(xs: List[Int], k: String) -> Effect[Int] {
642    let ys = xs.map((count) => count + 1)
643    let _ = ignore(items.put(k, 1))
644    ys.size()
645  }
646}
647"#;
648        let all = effects(src, "A", &[]);
649        let rows = summary(handler(&all, "go"));
650        assert_eq!(rows.len(), 1);
651        assert_eq!(rows[0].1, v(&[]), "a nested write is out of scope");
652        assert_eq!(rows[0].2, v(&[]), "neither `count` nor `items` is read");
653    }
654
655    #[test]
656    fn a_handler_past_the_budget_is_truncated() {
657        // Nine sequential choices between writing `a` or `b` give 2^9 = 512
658        // distinct write sequences, which no join can merge.
659        let choices: String = (0..9)
660            .map(|i| {
661                format!(
662                    "    let _ = if c{i} {{ a := {i}\n      () }} else {{ b := {i}\n      () }}\n"
663                )
664            })
665            .collect();
666        let params: Vec<String> = (0..9).map(|i| format!("c{i}: Bool")).collect();
667        let src = format!(
668            "context demo\nagent A {{\n  key id: String\n  store a: Cell[Int]\n  store b: Cell[Int]\n  on call go({}) -> Effect[()] {{\n{choices}    ()\n  }}\n}}\n",
669            params.join(", ")
670        );
671        let all = effects(&src, "A", &[]);
672        let go = handler(&all, "go");
673        assert!(go.truncated);
674        assert!(go.rows.is_empty());
675    }
676}