beck_core/
plan.rs

1//! The view, as a dataflow plan rather than as one expression.
2//!
3//! [`docs/05-tier-lowering.md`](../../../../../docs/05-tier-lowering.md) §5.3:
4//!
5//! > a thousand connected users of `todos.map(filter_by(session.user))` must compile to *one*
6//! > shared dataflow whose final per-session operators (filter, project, diff) run per subscriber
7//!
8//! and [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.8:
9//!
10//! > `remaining` updates by ±1 per event, never by recount.
11//!
12//! [`crate::split`] produces a `Core` *function* of the accumulator — full recompute per event,
13//! which Phase 1 called "semantically final, later made incremental". [`crate::incremental`]
14//! answers *which* vertices a plan could maintain. This module is the plan itself: the same view,
15//! decomposed into operators that a delta can flow through, with [`crate::engine`] as the thing
16//! that flows them.
17//!
18//! # What an operator is
19//!
20//! Two kinds, and the distinction is the whole design:
21//!
22//! * A **delta operator** ([`Op::MapValues`], [`Op::MapList`], [`Op::FilterList`], [`Op::SortBy`],
23//!   [`Op::Concat`], [`Op::Flatten`], [`Op::FlatMap`], [`Op::Count`], [`Op::IsEmpty`]) holds an
24//!   ordered *arrangement* — its output as
25//!   a keyed collection — and updates it from the changes at its input. Work is proportional to the
26//!   change, not to the collection.
27//! * A **pointwise operator** ([`Op::Pointwise`]) holds a value and recomputes it when an input
28//!   changed. That is what today's runtime does for the whole view, so a plan of nothing but
29//!   pointwise operators is exactly as fast as no plan at all — and no slower, which is what makes
30//!   this safe to switch on for every program.
31//!
32//! Everything the decomposition cannot see through becomes one pointwise operator over the plan
33//! nodes it reads: a `match`, an `if`, a call through a value, a primitive with no delta rule. The
34//! fallback is the reason the engine can be correct for programs it cannot accelerate, and
35//! [`Node::because`] records which construct forced it so `beck explain incremental` can say so.
36//!
37//! # Where the keys come from
38//!
39//! An arrangement is a `BTreeMap` from an ordering key to a value, and the key is what makes the
40//! output's *order* a consequence of the plan rather than of a sort at the end. Iteration order
41//! reaches the rendered page and the replay digest ([`crate::pmap`]), so an incremental view that
42//! produced the right entries in a different order would be a correctness bug, not a cosmetic one.
43//!
44//! | operator | key |
45//! |---|---|
46//! | `map_values(m)` | the map's key — so the arrangement is already in the order `map_values` yields |
47//! | `map_list`, `filter_list` | the input's key, unchanged: neither moves an element |
48//! | `sort_by(xs, k)` | `k(x)` followed by the input's key — a stable sort, expressed as an order |
49//! | `concat_lists([a, b])` | the input's position, followed by that input's key |
50//! | `flatten`, `flat_map` | the input's key, followed by the position inside that element's list |
51//!
52//! # What this is not
53//!
54//! It is not a *query* plan. §4.2 keeps the `Query` sub-language symbolic and nothing compiles one;
55//! this compiles the signal graph, which is a different thing that happens to share the word.
56//! `beck explain query` prints *this*, and [`crate::fuse`] rewrites it.
57
58use std::collections::{BTreeMap, BTreeSet};
59use std::sync::Arc;
60
61use crate::check::Program;
62use crate::core::{Core, CoreKind, Prim, VarId};
63use crate::signal::{signal_elem, Graph, Op as SigOp, SigId};
64use crate::split::{Placed, StateRole};
65use crate::ty::{Tier, Ty};
66
67pub type OpId = usize;
68
69/// A function an operator applies per element, closed over the plan nodes it reads.
70///
71/// The captures are why this is not simply a `Core` lambda: `lambda t: t.owner == session.actor`
72/// reads the session, which is a *node* — so the operator has to be re-run wholesale when a capture
73/// changes, and per element when only the collection changed. Both are expressible only if the
74/// captured nodes are named.
75#[derive(Clone, Debug)]
76pub struct Fun {
77    /// `Lam` over the captured nodes' variables followed by the element.
78    pub code: Core,
79    pub captures: Vec<OpId>,
80}
81
82/// What one operator does.
83#[derive(Clone, Debug)]
84pub enum Op {
85    /// The durable accumulator, supplied by the caller. The plan's one source.
86    State,
87    /// The subscriber's `Session`. Constant for the life of one subscription, which is what makes
88    /// everything not downstream of it shareable (§5.3).
89    Session,
90    /// Who is connected — `presence()`, supplied by the caller like the other two sources.
91    ///
92    /// Everything downstream of it is **per subscriber** even though the value is the same for
93    /// everybody, and the reason is a clock rather than a privacy rule: the shared dataflow is
94    /// versioned by the log's `seq` ([`crate::engine::SharedDataflow`]), and presence moves when
95    /// the log does not. Sharing it would need a second version, which is
96    /// [`docs/96`](../../../../../docs/96-presence-report.md) §96.8's first unbuilt item.
97    Presence,
98    /// A closed expression, evaluated once when the plan is prepared.
99    Const,
100    /// Recomputed when an input changed. Carries a `Lam` over its inputs.
101    Pointwise {
102        code: Core,
103    },
104    /// `map_values(m)` — where every delta in a Beck program is born, because the accumulator is a
105    /// value and a plan consumes changes. [`crate::pmap::PMap::diff`] is the conversion.
106    MapValues,
107    MapList {
108        f: Fun,
109    },
110    FilterList {
111        f: Fun,
112    },
113    SortBy {
114        f: Fun,
115    },
116    /// `concat_lists([a, b, …])` — a union of delta streams, one per named part.
117    Concat,
118    /// `concat_lists(map_list(xs, f))` as one operator — what [`crate::fuse`] makes of the pair,
119    /// and the shape every `for` loop in a `ui:` block has. Applies `f` and takes the resulting
120    /// list apart in one step, so the list of lists in between is never arranged.
121    FlatMap {
122        f: Fun,
123    },
124    /// `concat_lists(xs)` where `xs` is itself a collection of lists: a flatten.
125    ///
126    /// A `for` loop in a `ui:` block lowers to `concat_lists(map_list(todos, …))` and
127    /// [`crate::fuse`] turns that pair into [`Op::FlatMap`], so this is what remains when the
128    /// collection of lists came from somewhere else — a `map_values` whose values are lists, a
129    /// `sort_by`, or a `map_list` the fusion refused.
130    Flatten,
131    /// `list_len` — §3.8's `remaining`. The arrangement's size, so ±1 per delta and never a
132    /// recount; and it does not force its input to be materialised.
133    Count,
134    IsEmpty,
135}
136
137/// Every operator the engine implements, by name.
138///
139/// Published for the same reason [`crate::fuse::RULES`] is: `fusion.rs` holds this set to the
140/// operators the programs in the tree actually compile to, so an operator with a delta rule and no
141/// program is a hole in the differential rather than a line in a match.
142pub const OPERATORS: &[&str] = &[
143    "state",
144    "session",
145    "presence",
146    "const",
147    "recompute",
148    "map_values",
149    "map_list",
150    "filter_list",
151    "sort_by",
152    "concat_lists",
153    "flatten",
154    "flat_map",
155    "list_len",
156    "list_is_empty",
157];
158
159impl Op {
160    pub fn name(&self) -> &'static str {
161        match self {
162            Op::State => "state",
163            Op::Session => "session",
164            Op::Presence => "presence",
165            Op::Const => "const",
166            Op::Pointwise { .. } => "recompute",
167            Op::MapValues => "map_values",
168            Op::MapList { .. } => "map_list",
169            Op::FilterList { .. } => "filter_list",
170            Op::SortBy { .. } => "sort_by",
171            Op::Concat => "concat_lists",
172            Op::Flatten => "flatten",
173            Op::FlatMap { .. } => "flat_map",
174            Op::Count => "list_len",
175            Op::IsEmpty => "list_is_empty",
176        }
177    }
178
179    /// Whether this operator is maintained by delta rather than recomputed.
180    pub fn maintained(&self) -> bool {
181        matches!(
182            self,
183            Op::MapValues
184                | Op::MapList { .. }
185                | Op::FilterList { .. }
186                | Op::SortBy { .. }
187                | Op::Concat
188                | Op::Flatten
189                | Op::FlatMap { .. }
190                | Op::Count
191                | Op::IsEmpty
192        )
193    }
194
195    /// Whether this is an input to the dataflow rather than a step in it.
196    pub fn is_source(&self) -> bool {
197        matches!(self, Op::State | Op::Session | Op::Presence | Op::Const)
198    }
199
200    /// What orders this operator's arrangement — the table in this module's own documentation, as
201    /// a sentence, so `beck explain query` states the thing that makes the output *order* a
202    /// consequence of the plan rather than of a sort at the end.
203    pub fn key(&self) -> &'static str {
204        match self {
205            Op::State | Op::Session | Op::Presence | Op::Const => "a source",
206            Op::Pointwise { .. } | Op::Count | Op::IsEmpty => "a value, not an arrangement",
207            Op::MapValues => "the map's key",
208            Op::MapList { .. } | Op::FilterList { .. } => "the input's key, unchanged",
209            Op::SortBy { .. } => "the sort key, then the input's key — a stable sort as an order",
210            Op::Concat => "which input, then that input's key",
211            Op::Flatten | Op::FlatMap { .. } => {
212                "the input's key, then the position inside its list"
213            }
214        }
215    }
216
217    /// Whether this operator's output is an arrangement rather than a value.
218    pub fn is_arrangement(&self) -> bool {
219        matches!(
220            self,
221            Op::MapValues
222                | Op::MapList { .. }
223                | Op::FilterList { .. }
224                | Op::SortBy { .. }
225                | Op::Concat
226                | Op::Flatten
227                | Op::FlatMap { .. }
228        )
229    }
230}
231
232#[derive(Clone, Debug)]
233pub struct Node {
234    pub op: Op,
235    pub inputs: Vec<OpId>,
236    /// Set when this operator is a fallback: which construct had no delta rule.
237    pub because: Option<String>,
238    /// True when this node reads the session, directly or through an input. §5.3's boundary: the
239    /// nodes for which this is false are the shared dataflow, the rest run per subscriber.
240    pub per_session: bool,
241    /// How many operators read this one. Two or more is §5.3's shared prefix, at the granularity
242    /// the engine actually shares at.
243    pub consumers: usize,
244}
245
246/// The view as a dataflow.
247///
248/// Nodes are in dependency order — every input's index is less than its consumer's — so the engine
249/// is one forward pass with no scheduling.
250#[derive(Clone, Debug)]
251pub struct Plan {
252    pub nodes: Vec<Node>,
253    /// Constants, in the same index space as `nodes`, for the ones whose op is [`Op::Const`].
254    pub constants: BTreeMap<OpId, Core>,
255    pub root: OpId,
256    pub state: OpId,
257    pub session: OpId,
258    pub presence: OpId,
259    /// The declared signals that survived as nodes, so a report can use the program's own names.
260    pub signals: Vec<(Arc<str>, OpId)>,
261}
262
263impl Plan {
264    /// How many *operators* are maintained by delta, and how many are recomputed.
265    ///
266    /// Sources and constants are neither: the accumulator, the session and a string literal are
267    /// inputs to the dataflow rather than steps in it, and counting them as "recomputed" would
268    /// make every program look worse than it is by a fixed amount.
269    pub fn counts(&self) -> (usize, usize) {
270        let operators = self.nodes.iter().filter(|n| !n.op.is_source());
271        let maintained = operators.clone().filter(|n| n.op.maintained()).count();
272        (maintained, operators.count() - maintained)
273    }
274
275    /// The nodes that do not read the session: §5.3's shared dataflow.
276    pub fn shared(&self) -> Vec<OpId> {
277        (0..self.nodes.len())
278            .filter(|&i| !self.nodes[i].per_session)
279            .collect()
280    }
281
282    /// Compile the view of a sliced program, and fuse it.
283    ///
284    /// Everything downstream — the engine, the read models, both reports — reads the *fused* plan,
285    /// so there is one plan a program has rather than two that could disagree.
286    /// [`Plan::unfused`] is what the differential gate compares against.
287    pub fn compile(placed: &Placed) -> Plan {
288        crate::fuse::fuse(Plan::unfused(placed)).0
289    }
290
291    /// The plan as the decomposition produced it, before [`crate::fuse`] rewrites it.
292    ///
293    /// Works from the *graph* rather than from [`crate::split::Roles::view`], for the reason
294    /// [`23`](../../../../../docs/23-general-slicer-report.md) built the graph in the first place: the
295    /// sliced expression has already lost which signal each part came from, and a plan whose nodes
296    /// cannot be named is a plan no report can explain.
297    pub fn unfused(placed: &Placed) -> Plan {
298        let graph = &placed.graph;
299        let mut b = Builder {
300            program: &placed.program,
301            nodes: Vec::new(),
302            constants: BTreeMap::new(),
303            cse: BTreeMap::new(),
304            inlining: Vec::new(),
305            states: &placed.roles.states,
306            state: 0,
307            session: 0,
308            presence: 0,
309            vertices: BTreeMap::new(),
310        };
311        b.state = b.push(Op::State, Vec::new(), None);
312        b.session = b.push(Op::Session, Vec::new(), None);
313        b.presence = b.push(Op::Presence, Vec::new(), None);
314
315        let root = match graph.by_name.get(&placed.roles.page_name).copied() {
316            Some(page) if page < graph.nodes.len() => b.vertex(graph, page),
317            // A **library** has no page, and an empty graph to look it up in. Its plan is its two
318            // sources and a unit — nothing renders it, and the `unwrap_or(0)` this replaced indexed
319            // vertex zero of a graph with no vertices (docs/27 §27.4).
320            _ => {
321                let id = b.push(Op::Const, Vec::new(), None);
322                b.constants.insert(
323                    id,
324                    Core {
325                        kind: CoreKind::Const(crate::core::Const::Unit),
326                        ty: Ty::unit(),
327                        tier: Tier::Any,
328                        span: beck_diag::Span::NONE,
329                        last_use: false,
330                        order: crate::fields::UNORDERED,
331                        locals: 0,
332                    },
333                );
334                id
335            }
336        };
337
338        let mut signals: Vec<(Arc<str>, OpId)> = Vec::new();
339        for (&sig, &id) in &b.vertices {
340            if let Some(name) = &graph.node(sig).name {
341                signals.push((name.clone(), id));
342            }
343        }
344        signals.sort();
345
346        let mut plan = Plan {
347            nodes: b.nodes,
348            constants: b.constants,
349            root,
350            state: b.state,
351            session: b.session,
352            presence: b.presence,
353            signals,
354        };
355        plan.finish();
356        plan.prune();
357        plan
358    }
359
360    /// Propagate `per_session` forward and count consumers.
361    pub(crate) fn finish(&mut self) {
362        for node in &mut self.nodes {
363            node.per_session = false;
364            node.consumers = 0;
365        }
366        for i in 0..self.nodes.len() {
367            let per = matches!(self.nodes[i].op, Op::Session | Op::Presence)
368                || self.nodes[i]
369                    .inputs
370                    .iter()
371                    .any(|&j| self.nodes[j].per_session);
372            self.nodes[i].per_session = per;
373            if let Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } =
374                &self.nodes[i].op
375            {
376                let captured: Vec<OpId> = f.captures.clone();
377                if captured.iter().any(|&j| self.nodes[j].per_session) {
378                    self.nodes[i].per_session = true;
379                }
380            }
381        }
382        for i in 0..self.nodes.len() {
383            for j in self.dependencies(i) {
384                self.nodes[j].consumers += 1;
385            }
386        }
387    }
388
389    /// Drop every operator the plan's roots cannot reach, renumber, and recompute what
390    /// [`Plan::finish`] computes. Returns the old-to-new map.
391    ///
392    /// The decomposition builds an operator for every argument of a call it inlines and for every
393    /// `let`'s value, before it knows whether the body reads them — and a bounded call's arguments
394    /// include one dictionary per method of each bound ([`39`](../../../../../docs/39-bounds-report.md)),
395    /// of which the body may use none. Deciding that lazily would mean a scope of thunks rather
396    /// than of operators, which changes the order operators are created in and therefore what
397    /// hash-consing shares; pruning afterwards costs one pass and changes nothing else.
398    ///
399    /// The roots are the page, the two sources, and every **named** signal — a name is projected as
400    /// a read-model table ([`88`](../../../../../docs/88-read-models-and-pgwire-report.md)), so it
401    /// keeps its operator alive whether or not the page reads it.
402    pub(crate) fn prune(&mut self) -> BTreeMap<OpId, OpId> {
403        let mut live = vec![false; self.nodes.len()];
404        let mut stack = vec![self.root, self.state, self.session, self.presence];
405        stack.extend(self.signals.iter().map(|(_, id)| *id));
406        while let Some(id) = stack.pop() {
407            if std::mem::replace(&mut live[id], true) {
408                continue;
409            }
410            stack.extend(self.dependencies(id));
411        }
412
413        let mut map = BTreeMap::new();
414        let mut next = 0;
415        for (i, &keep) in live.iter().enumerate() {
416            if keep {
417                map.insert(i, next);
418                next += 1;
419            }
420        }
421        // Dependency order survives renumbering because it is monotone: an input's index was below
422        // its consumer's, and a monotone map keeps it there.
423        let mut nodes = Vec::with_capacity(next);
424        for (i, node) in std::mem::take(&mut self.nodes).into_iter().enumerate() {
425            if !live[i] {
426                continue;
427            }
428            let mut node = node;
429            node.inputs.iter_mut().for_each(|id| *id = map[id]);
430            if let Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } =
431                &mut node.op
432            {
433                f.captures.iter_mut().for_each(|id| *id = map[id]);
434            }
435            nodes.push(node);
436        }
437        self.nodes = nodes;
438        self.constants = std::mem::take(&mut self.constants)
439            .into_iter()
440            .filter_map(|(id, c)| map.get(&id).map(|&n| (n, c)))
441            .collect();
442        self.signals.iter_mut().for_each(|(_, id)| *id = map[&*id]);
443        self.root = map[&self.root];
444        self.state = map[&self.state];
445        self.session = map[&self.session];
446        self.presence = map[&self.presence];
447        self.finish();
448        map
449    }
450
451    /// Every node an operator reads, including the ones its per-element function captured.
452    pub fn dependencies(&self, i: OpId) -> Vec<OpId> {
453        let mut out = self.nodes[i].inputs.clone();
454        if let Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } =
455            &self.nodes[i].op
456        {
457            out.extend(f.captures.iter().copied());
458        }
459        out
460    }
461
462    /// The names this plan gives one operator, if any — a declared signal is a name a developer
463    /// wrote, and a report that can use it should.
464    pub fn names_of(&self, i: OpId) -> Vec<&str> {
465        self.signals
466            .iter()
467            .filter(|(_, id)| *id == i)
468            .map(|(n, _)| n.as_ref())
469            .collect()
470    }
471}
472
473/// `beck explain query` — the view as a dataflow plan, operator by operator.
474///
475/// [`04`](../../../../../docs/04-compiler-architecture.md) §4.7 asks for this command and
476/// [`20`](../../../../../docs/20-phase-2-report.md) §20.5 says why it could not exist: "the `Query`
477/// sub-language is deliberately symbolic and there is no plan to explain". There is one now, and
478/// this prints it — including the two things a developer cannot see any other way: which operator
479/// **orders** the output, and which side of §5.3's session cut each one is on.
480pub fn query_report(plan: &Plan) -> String {
481    use std::fmt::Write;
482    let mut out = String::new();
483    let _ = writeln!(
484        out,
485        "the view as a dataflow plan (§5.3). Operators are in dependency order, so every input\n\
486         is above its consumer and the engine is one forward pass.\n"
487    );
488    for (i, node) in plan.nodes.iter().enumerate() {
489        let deps = plan.dependencies(i);
490        let reads = if deps.is_empty() {
491            String::new()
492        } else {
493            format!(
494                "← {}",
495                deps.iter()
496                    .map(|d| format!("#{d}"))
497                    .collect::<Vec<_>>()
498                    .join(" ")
499            )
500        };
501        let names = plan.names_of(i);
502        let _ = writeln!(
503            out,
504            "  #{:<3} {:<14} {:<12} {:<12} {}",
505            i,
506            node.op.name(),
507            reads,
508            if node.per_session {
509                "per session"
510            } else {
511                "shared"
512            },
513            match (node.consumers, names.is_empty()) {
514                (_, false) => format!("`{}`", names.join("`, `")),
515                (0, true) => String::new(),
516                (1, true) => String::new(),
517                (n, true) => format!("read by {n}"),
518            }
519        );
520        if node.op.is_arrangement() {
521            let _ = writeln!(out, "       {:<14} ordered by {}", "", node.op.key());
522        }
523        if let Some(why) = &node.because {
524            let _ = writeln!(out, "       {:<14} recomputed: {why}", "");
525        }
526    }
527    let (maintained, recomputed) = plan.counts();
528    let arrangements = plan.nodes.iter().filter(|n| n.op.is_arrangement()).count();
529    let _ = writeln!(
530        out,
531        "\n  #{} is the root — the page.\n\
532         \x20 {maintained} maintained, {recomputed} recomputed, {arrangements} of them holding an \
533         arrangement.",
534        plan.root
535    );
536    out
537}
538
539/// What one operator costs per event, in the units [`crate::engine::Work`] counts.
540///
541/// `δ` is how many entries moved at its input and `n` how many its input holds. The distinction is
542/// the whole point of the engine, so a cost that mentions `n` is a cost worth reading.
543fn op_cost(plan: &Plan, i: OpId) -> String {
544    let node = &plan.nodes[i];
545    // A pointwise operator forces every arrangement it reads into a `Value::List`, which copies
546    // that arrangement's entries: docs/24 §24.6's "the page's children are still assembled in
547    // full", located at the operator that does it.
548    let forced: Vec<OpId> = node
549        .inputs
550        .iter()
551        .copied()
552        .filter(|&j| plan.nodes[j].op.is_arrangement())
553        .collect();
554    match &node.op {
555        Op::State | Op::Session | Op::Presence => "—  a source, read by reference".to_string(),
556        Op::Const => "—  evaluated once, when the plan is prepared".to_string(),
557        Op::Pointwise { .. } if forced.is_empty() => {
558            "1 recompute, and only when an input moved".to_string()
559        }
560        Op::Pointwise { .. } => format!(
561            "1 recompute + n entries copied, forcing {}",
562            forced
563                .iter()
564                .map(|j| format!("#{j}"))
565                .collect::<Vec<_>>()
566                .join(" ")
567        ),
568        Op::MapValues => "δ touched  —  O(δ log n), the persistent map's own diff".to_string(),
569        Op::MapList { .. } => "δ applications, δ touched".to_string(),
570        Op::FilterList { .. } => "δ applications, at most δ touched".to_string(),
571        Op::SortBy { .. } => {
572            "δ applications, at most 2δ touched — a move is a remove and an insert".to_string()
573        }
574        Op::Concat => "δ touched".to_string(),
575        Op::Flatten => "the entries of each changed element's list".to_string(),
576        Op::FlatMap { .. } => {
577            "δ applications, then the entries of each changed element's list".to_string()
578        }
579        Op::Count | Op::IsEmpty => "O(1)  —  the arrangement's size, never a recount".to_string(),
580    }
581}
582
583/// `beck explain cost` — what one event costs this view.
584///
585/// [`20`](../../../../../docs/20-phase-2-report.md) §20.5 left this command unbuilt with a reason
586/// rather than a shrug: `beck explain place` already prints every candidate's cost, and "whether a
587/// separate `cost` view earns its place is a question for when there is a second cost dimension to
588/// show". The plan is that second dimension. Placement costs are about *where* a definition runs,
589/// once, at compile time; these are about what the program does *per event*, for as long as it is
590/// running, and no placement decision can see them.
591pub fn cost_report(plan: &Plan) -> String {
592    use std::fmt::Write;
593    let mut out = String::new();
594    let _ = writeln!(
595        out,
596        "what one event costs this view, in the units the engine counts (§3.8).\n\
597         \x20 δ is how many entries moved; n is how many the collection holds.\n"
598    );
599    let mut linear: Vec<OpId> = Vec::new();
600    for i in 0..plan.nodes.len() {
601        let cost = op_cost(plan, i);
602        if cost.contains("n entries copied") {
603            linear.push(i);
604        }
605        let _ = writeln!(out, "  #{:<3} {:<14} {}", i, plan.nodes[i].op.name(), cost);
606        // An operator whose per-element function reads another operator is a different function
607        // when that one moves, so the whole collection is reconsidered. It is not per event — a
608        // session is constant for a subscription — but it is the one place δ stops bounding the
609        // work, and a reader who does not know that will misread every line above.
610        let captures = match &plan.nodes[i].op {
611            Op::MapList { f } | Op::FilterList { f } | Op::SortBy { f } | Op::FlatMap { f } => {
612                f.captures.clone()
613            }
614            _ => Vec::new(),
615        };
616        if !captures.is_empty() {
617            let _ = writeln!(
618                out,
619                "       {:<14} n applications whenever {} moves — its function captured it",
620                "",
621                captures
622                    .iter()
623                    .map(|j| format!("#{j}"))
624                    .collect::<Vec<_>>()
625                    .join(" or ")
626            );
627        }
628    }
629    let _ = writeln!(out);
630    if linear.is_empty() {
631        let _ = writeln!(
632            out,
633            "  Nothing here is proportional to the collection: no operator forces an arrangement\n\
634             \x20 into a list, so one event costs what the event changed."
635        );
636    } else {
637        let _ = writeln!(
638            out,
639            "  {} of {} operators cost O(n) per event, and all of them for the same reason: a\n\
640             \x20 recompute needs a `list`, and an arrangement is a keyed collection. That is\n\
641             \x20 docs/24 §24.6's remaining constant factor, at {}.",
642            linear.len(),
643            plan.nodes.len(),
644            linear
645                .iter()
646                .map(|j| format!("#{j}"))
647                .collect::<Vec<_>>()
648                .join(" ")
649        );
650    }
651    let _ = writeln!(
652        out,
653        "\n  These are the plan's arithmetic rather than a measurement: `Work` is what\n\
654         \x20 `Engine::render` counts, so `measure_incremental` checks this arithmetic against the\n\
655         \x20 count rather than against a clock."
656    );
657    out
658}
659
660struct Builder<'a> {
661    program: &'a Program,
662    nodes: Vec<Node>,
663    constants: BTreeMap<OpId, Core>,
664    /// Structural hash-consing, so `state.todos` read from two places is one operator with two
665    /// consumers rather than two operators. That is the fact §5.3's arrangement sharing is about,
666    /// and it has to be a property of the plan before it can be a property of the engine.
667    cse: BTreeMap<String, OpId>,
668    /// Definitions currently being inlined, so a recursive one falls back rather than looping.
669    inlining: Vec<Arc<str>>,
670    states: &'a [StateRole],
671    state: OpId,
672    session: OpId,
673    presence: OpId,
674    vertices: BTreeMap<SigId, OpId>,
675}
676
677/// The symbolic environment: a program variable, and the operator that produces its value.
678type Scope = BTreeMap<VarId, OpId>;
679
680impl Builder<'_> {
681    fn push(&mut self, op: Op, inputs: Vec<OpId>, because: Option<String>) -> OpId {
682        self.nodes.push(Node {
683            op,
684            inputs,
685            because,
686            per_session: false,
687            consumers: 0,
688        });
689        self.nodes.len() - 1
690    }
691
692    /// Push, or reuse an identical operator already in the plan.
693    fn shared(&mut self, key: String, op: Op, inputs: Vec<OpId>, because: Option<String>) -> OpId {
694        if let Some(&id) = self.cse.get(&key) {
695            return id;
696        }
697        let id = self.push(op, inputs, because);
698        self.cse.insert(key, id);
699        id
700    }
701
702    // ---------------------------------------------------------------------------------------
703    // The graph
704    // ---------------------------------------------------------------------------------------
705
706    /// The operator one signal vertex's value comes from.
707    fn vertex(&mut self, graph: &Graph, id: SigId) -> OpId {
708        let id = follow_alias(graph, id);
709        if let Some(&done) = self.vertices.get(&id) {
710            return done;
711        }
712        let node = graph.node(id);
713        // A durable accumulator is where the plan's sources are: the state parameter, or the field
714        // of it that this fold occupies when several were fused.
715        if let Some(role) = self.states.iter().find(|s| s.node == id) {
716            let out = match &role.field {
717                None => self.state,
718                Some(f) => {
719                    let code = lam(
720                        vec![0],
721                        Core {
722                            kind: CoreKind::Field {
723                                base: Box::new(var(0, Ty::unit(), node.span)),
724                                name: f.clone(),
725                            },
726                            ty: role.ty.clone(),
727                            tier: Tier::Any,
728                            span: node.span,
729                            last_use: false,
730                            order: crate::fields::UNORDERED,
731                            locals: 0,
732                        },
733                    );
734                    let state = self.state;
735                    self.shared(
736                        format!("field/{f}/{state}"),
737                        Op::Pointwise { code },
738                        vec![state],
739                        None,
740                    )
741                }
742            };
743            self.vertices.insert(id, out);
744            return out;
745        }
746
747        let out = match &node.op {
748            SigOp::Map { f } => {
749                let input = self.vertex(graph, node.inputs[0]);
750                self.apply(
751                    f,
752                    vec![input],
753                    &Scope::new(),
754                    signal_elem(&node.ty),
755                    node.span,
756                )
757            }
758            SigOp::Map2 { f } => {
759                let a = self.vertex(graph, node.inputs[0]);
760                let b = self.vertex(graph, node.inputs[1]);
761                self.apply(
762                    f,
763                    vec![a, b],
764                    &Scope::new(),
765                    signal_elem(&node.ty),
766                    node.span,
767                )
768            }
769            SigOp::Presence => self.presence,
770            SigOp::PerSession { f } => {
771                let input = self.vertex(graph, node.inputs[0]);
772                let session = self.session;
773                self.apply(
774                    f,
775                    vec![input, session],
776                    &Scope::new(),
777                    signal_elem(&node.ty),
778                    node.span,
779                )
780            }
781            // The slicer has already refused every other op before a plan is asked for — a stream
782            // under a view, a fold that is not durable, a cycle with no fold. Reaching one here
783            // would be a plan compiled for a program that did not slice, so it becomes an opaque
784            // node rather than a panic: the engine recomputes and the report says why.
785            other => self.push(
786                Op::Pointwise {
787                    code: lam(vec![0], var(0, Ty::unit(), node.span)),
788                },
789                vec![self.state],
790                Some(format!("`{}` is not a view operator", other.name())),
791            ),
792        };
793        self.vertices.insert(id, out);
794        out
795    }
796
797    /// `f(args…)`, symbolically: inline the function and decompose its body.
798    ///
799    /// `scope` is the caller's, needed only for the fallback: a function expression this analysis
800    /// cannot see into may still *read* variables the plan has operators for, and an opaque node
801    /// has to take them as inputs rather than leave them unbound.
802    fn apply(
803        &mut self,
804        f: &Core,
805        args: Vec<OpId>,
806        scope: &Scope,
807        ty: Ty,
808        span: beck_diag::Span,
809    ) -> OpId {
810        let Some((params, body)) = self.as_lambda(f) else {
811            return self.opaque_call(
812                f,
813                args,
814                scope,
815                ty,
816                span,
817                "a view applies a function this analysis cannot see into",
818            );
819        };
820        if params.len() != args.len() {
821            return self.opaque_call(
822                f,
823                args,
824                scope,
825                ty,
826                span,
827                "a view applies a function to a different number of arguments",
828            );
829        }
830        // The guard goes on here rather than at the call site, because `as_lambda` consults it: a
831        // definition may not be inlined into its own body, and pushing the name before resolving it
832        // would refuse the outermost call as well as the recursive one.
833        let named = match &f.kind {
834            CoreKind::Global(n) => {
835                self.inlining.push(n.clone());
836                true
837            }
838            _ => false,
839        };
840        let inner: Scope = params.into_iter().zip(args).collect();
841        let out = self.expr(&body, &inner);
842        if named {
843            self.inlining.pop();
844        }
845        out
846    }
847
848    /// One operator for a call this analysis will not enter, over the arguments *and* whatever the
849    /// function expression itself reads.
850    fn opaque_call(
851        &mut self,
852        f: &Core,
853        args: Vec<OpId>,
854        scope: &Scope,
855        ty: Ty,
856        span: beck_diag::Span,
857        why: &str,
858    ) -> OpId {
859        let mut free = BTreeSet::new();
860        free_vars(f, &mut BTreeSet::new(), &mut free);
861        let captured: Vec<VarId> = free.into_iter().filter(|v| scope.contains_key(v)).collect();
862        let base = captured.iter().copied().max().unwrap_or(0) + 1;
863        let ps: Vec<VarId> = (0..args.len() as VarId).map(|i| base + i).collect();
864        let call = Core {
865            kind: CoreKind::App {
866                func: Box::new(f.clone()),
867                args: ps.iter().map(|&p| var(p, Ty::unit(), span)).collect(),
868            },
869            ty,
870            tier: Tier::Any,
871            span,
872            last_use: false,
873            order: crate::fields::UNORDERED,
874            locals: 0,
875        };
876        let mut params = captured.clone();
877        params.extend(ps);
878        let mut inputs: Vec<OpId> = captured.iter().map(|v| scope[v]).collect();
879        inputs.extend(args);
880        self.push(
881            Op::Pointwise {
882                code: lam(params, call),
883            },
884            inputs,
885            Some(why.to_string()),
886        )
887    }
888
889    /// A function expression as parameters and a body, following one level of naming.
890    fn as_lambda(&self, f: &Core) -> Option<(Vec<VarId>, Core)> {
891        match &f.kind {
892            CoreKind::Lam { params, body } => Some((params.to_vec(), (**body).clone())),
893            CoreKind::Global(name) if !self.inlining.contains(name) => {
894                let def = self.program.defs.get(name)?;
895                match &def.body.kind {
896                    CoreKind::Lam { params, body } => Some((params.to_vec(), (**body).clone())),
897                    _ => None,
898                }
899            }
900            _ => None,
901        }
902    }
903
904    // ---------------------------------------------------------------------------------------
905    // The expression
906    // ---------------------------------------------------------------------------------------
907
908    /// Decompose one expression into operators, in the scope of the variables already bound to
909    /// operators.
910    fn expr(&mut self, c: &Core, scope: &Scope) -> OpId {
911        match &c.kind {
912            CoreKind::Var(v) => match scope.get(v) {
913                Some(&id) => id,
914                // A variable bound by something the decomposition did not enter — a `match` arm's
915                // binder reached through a path that should not exist. Opaque rather than wrong.
916                None => self.opaque(c, scope, "a variable bound outside the plan"),
917            },
918            CoreKind::Const(_) => {
919                let key = format!("const/{:?}", c.kind);
920                let id = self.shared(key, Op::Const, Vec::new(), None);
921                self.constants.entry(id).or_insert_with(|| c.clone());
922                id
923            }
924            CoreKind::Let { var, value, body } => {
925                let v = self.expr(value, scope);
926                let mut inner = scope.clone();
927                inner.insert(*var, v);
928                self.expr(body, &inner)
929            }
930            CoreKind::App { func, args } => {
931                let ids: Vec<OpId> = args.iter().map(|a| self.expr(a, scope)).collect();
932                self.apply(func, ids, scope, c.ty.clone(), c.span)
933            }
934            CoreKind::Prim { op, args } => self.prim(c, *op, args, scope),
935            // The two constructs a delta cannot be pushed through: both pick which computation
936            // runs, and a change to the scrutinee can move the answer between arms.
937            CoreKind::If { .. } => self.opaque(
938                c,
939                scope,
940                "an `if` picks which computation runs, and a delta can move it between branches",
941            ),
942            CoreKind::Match { .. } => self.opaque(
943                c,
944                scope,
945                "a `match` on the input picks which computation runs, and a delta can move it \
946                 between arms",
947            ),
948            CoreKind::Lam { .. } => self.opaque(c, scope, "a function used as a value"),
949            CoreKind::Global(name) => match self.program.defs.get(name) {
950                Some(def) if !matches!(def.body.kind, CoreKind::Lam { .. }) => {
951                    let body = def.body.clone();
952                    self.expr(&body, &Scope::new())
953                }
954                _ => self.opaque(c, scope, "a definition used as a value"),
955            },
956            // Structural constructors are pointwise: a change at an input is a change at the
957            // output, and there is nothing collection-shaped to maintain.
958            CoreKind::Make {
959                ty,
960                variant,
961                fields,
962            } => {
963                let ids: Vec<OpId> = fields.iter().map(|(_, v)| self.expr(v, scope)).collect();
964                let ps: Vec<VarId> = (0..fields.len() as VarId).collect();
965                let code = lam(
966                    ps.clone(),
967                    Core {
968                        kind: CoreKind::Make {
969                            ty: ty.clone(),
970                            variant: variant.clone(),
971                            fields: fields
972                                .iter()
973                                .zip(&ps)
974                                .map(|((n, f), &p)| (n.clone(), var(p, f.ty.clone(), f.span)))
975                                .collect(),
976                        },
977                        ty: c.ty.clone(),
978                        tier: c.tier,
979                        span: c.span,
980                        last_use: false,
981                        // The same field names in the same written order, so the layout the pass
982                        // computed for the literal is the layout of the operator that replaces it.
983                        order: c.order,
984                        locals: 0,
985                    },
986                );
987                let names: Vec<&str> = fields.iter().map(|(n, _)| n.as_ref()).collect();
988                let key = format!("make/{ty}/{variant:?}/{names:?}/{ids:?}");
989                self.shared(key, Op::Pointwise { code }, ids, None)
990            }
991            CoreKind::Field { base, name } => {
992                let b = self.expr(base, scope);
993                let code = lam(
994                    vec![0],
995                    Core {
996                        kind: CoreKind::Field {
997                            base: Box::new(var(0, base.ty.clone(), c.span)),
998                            name: name.clone(),
999                        },
1000                        ty: c.ty.clone(),
1001                        tier: c.tier,
1002                        span: c.span,
1003                        last_use: false,
1004                        order: crate::fields::UNORDERED,
1005                        locals: 0,
1006                    },
1007                );
1008                self.shared(
1009                    format!("field/{name}/{b}"),
1010                    Op::Pointwise { code },
1011                    vec![b],
1012                    None,
1013                )
1014            }
1015            CoreKind::With { base, fields } => {
1016                let mut ids = vec![self.expr(base, scope)];
1017                ids.extend(fields.iter().map(|(_, v)| self.expr(v, scope)));
1018                let ps: Vec<VarId> = (0..ids.len() as VarId).collect();
1019                let code = lam(
1020                    ps.clone(),
1021                    Core {
1022                        kind: CoreKind::With {
1023                            base: Box::new(var(0, base.ty.clone(), c.span)),
1024                            fields: fields
1025                                .iter()
1026                                .zip(&ps[1..])
1027                                .map(|((n, f), &p)| (n.clone(), var(p, f.ty.clone(), f.span)))
1028                                .collect(),
1029                        },
1030                        ty: c.ty.clone(),
1031                        tier: c.tier,
1032                        span: c.span,
1033                        last_use: false,
1034                        order: crate::fields::UNORDERED,
1035                        locals: 0,
1036                    },
1037                );
1038                self.push(Op::Pointwise { code }, ids, None)
1039            }
1040            CoreKind::ListLit(items) => {
1041                let ids: Vec<OpId> = items.iter().map(|i| self.expr(i, scope)).collect();
1042                let ps: Vec<VarId> = (0..items.len() as VarId).collect();
1043                let code = lam(
1044                    ps.clone(),
1045                    Core {
1046                        kind: CoreKind::ListLit(
1047                            items
1048                                .iter()
1049                                .zip(&ps)
1050                                .map(|(i, &p)| var(p, i.ty.clone(), i.span))
1051                                .collect(),
1052                        ),
1053                        ty: c.ty.clone(),
1054                        tier: c.tier,
1055                        span: c.span,
1056                        last_use: false,
1057                        order: crate::fields::UNORDERED,
1058                        locals: 0,
1059                    },
1060                );
1061                let key = format!("list/{ids:?}");
1062                self.shared(key, Op::Pointwise { code }, ids, None)
1063            }
1064            CoreKind::MapLit(pairs) => {
1065                let mut ids = Vec::new();
1066                for (k, v) in pairs {
1067                    ids.push(self.expr(k, scope));
1068                    ids.push(self.expr(v, scope));
1069                }
1070                let ps: Vec<VarId> = (0..ids.len() as VarId).collect();
1071                let code = lam(
1072                    ps.clone(),
1073                    Core {
1074                        kind: CoreKind::MapLit(
1075                            pairs
1076                                .iter()
1077                                .enumerate()
1078                                .map(|(i, (k, v))| {
1079                                    (
1080                                        var(ps[i * 2], k.ty.clone(), k.span),
1081                                        var(ps[i * 2 + 1], v.ty.clone(), v.span),
1082                                    )
1083                                })
1084                                .collect(),
1085                        ),
1086                        ty: c.ty.clone(),
1087                        tier: c.tier,
1088                        span: c.span,
1089                        last_use: false,
1090                        order: crate::fields::UNORDERED,
1091                        locals: 0,
1092                    },
1093                );
1094                self.push(Op::Pointwise { code }, ids, None)
1095            }
1096        }
1097    }
1098
1099    /// A primitive application: a delta operator when there is a rule for it, pointwise otherwise.
1100    fn prim(&mut self, c: &Core, op: Prim, args: &[Core], scope: &Scope) -> OpId {
1101        match (op, args.len()) {
1102            (Prim::MapValues, 1) => {
1103                let m = self.expr(&args[0], scope);
1104                self.shared(format!("map_values/{m}"), Op::MapValues, vec![m], None)
1105            }
1106            (Prim::MapList, 2) | (Prim::FilterList, 2) | (Prim::SortBy, 2) => {
1107                let xs = self.expr(&args[0], scope);
1108                let f = self.fun(&args[1], scope, &args[0].ty);
1109                let node = match op {
1110                    Prim::MapList => Op::MapList { f },
1111                    Prim::FilterList => Op::FilterList { f },
1112                    _ => Op::SortBy { f },
1113                };
1114                self.push(node, vec![xs], None)
1115            }
1116            // `concat_lists` takes one argument: a list *of* lists. The `ui:` loop lowering builds
1117            // it as a literal, which is the shape a union of delta streams needs — and the only
1118            // shape a plan can enumerate the inputs of.
1119            (Prim::ConcatLists, 1) => match &args[0].kind {
1120                CoreKind::ListLit(parts) => {
1121                    let ids: Vec<OpId> = parts.iter().map(|p| self.expr(p, scope)).collect();
1122                    self.push(Op::Concat, ids, None)
1123                }
1124                // Not a literal: a computed collection whose elements are lists, which is what
1125                // `for t in todos:` lowers to. That is a flatten, and a flatten has a delta rule —
1126                // one element's list is replaced, and the rest keep their place because the key
1127                // says where they are.
1128                _ => {
1129                    let xs = self.expr(&args[0], scope);
1130                    self.shared(format!("flatten/{xs}"), Op::Flatten, vec![xs], None)
1131                }
1132            },
1133            (Prim::ListLen, 1) => {
1134                let xs = self.expr(&args[0], scope);
1135                self.shared(format!("count/{xs}"), Op::Count, vec![xs], None)
1136            }
1137            (Prim::ListIsEmpty, 1) => {
1138                let xs = self.expr(&args[0], scope);
1139                self.shared(format!("empty/{xs}"), Op::IsEmpty, vec![xs], None)
1140            }
1141            _ => self.pointwise_prim(c, op, args, scope, None),
1142        }
1143    }
1144
1145    fn pointwise_prim(
1146        &mut self,
1147        c: &Core,
1148        op: Prim,
1149        args: &[Core],
1150        scope: &Scope,
1151        because: Option<String>,
1152    ) -> OpId {
1153        let ids: Vec<OpId> = args.iter().map(|a| self.expr(a, scope)).collect();
1154        let ps: Vec<VarId> = (0..args.len() as VarId).collect();
1155        let code = lam(
1156            ps.clone(),
1157            Core {
1158                kind: CoreKind::Prim {
1159                    op,
1160                    args: args
1161                        .iter()
1162                        .zip(&ps)
1163                        .map(|(a, &p)| var(p, a.ty.clone(), a.span))
1164                        .collect(),
1165                },
1166                ty: c.ty.clone(),
1167                tier: c.tier,
1168                span: c.span,
1169                last_use: false,
1170                order: crate::fields::UNORDERED,
1171                locals: 0,
1172            },
1173        );
1174        let key = format!("prim/{}/{ids:?}", op.name());
1175        self.shared(key, Op::Pointwise { code }, ids, because)
1176    }
1177
1178    /// The per-element function of a collection operator, closed over the operators it reads.
1179    fn fun(&mut self, f: &Core, scope: &Scope, elem_ty: &Ty) -> Fun {
1180        let mut free = BTreeSet::new();
1181        free_vars(f, &mut BTreeSet::new(), &mut free);
1182        let captured: Vec<VarId> = free.into_iter().filter(|v| scope.contains_key(v)).collect();
1183        // The element parameter cannot collide with a captured variable, because a captured one is
1184        // free in `f` and this one is bound by the lambda this builds.
1185        let x = captured.iter().copied().max().unwrap_or(0) + 1;
1186        let mut params = captured.clone();
1187        params.push(x);
1188        let call = Core {
1189            kind: CoreKind::App {
1190                func: Box::new(f.clone()),
1191                args: vec![var(x, signal_elem(elem_ty), f.span)],
1192            },
1193            ty: Ty::unit(),
1194            tier: Tier::Any,
1195            span: f.span,
1196            last_use: false,
1197            order: crate::fields::UNORDERED,
1198            locals: 0,
1199        };
1200        Fun {
1201            code: lam(params, call),
1202            captures: captured.iter().map(|v| scope[v]).collect(),
1203        }
1204    }
1205
1206    /// One operator for an expression the decomposition will not enter, over the plan nodes it
1207    /// reads.
1208    fn opaque(&mut self, c: &Core, scope: &Scope, because: &str) -> OpId {
1209        let mut free = BTreeSet::new();
1210        free_vars(c, &mut BTreeSet::new(), &mut free);
1211        let params: Vec<VarId> = free.into_iter().filter(|v| scope.contains_key(v)).collect();
1212        let inputs: Vec<OpId> = params.iter().map(|v| scope[v]).collect();
1213        let code = lam(params, c.clone());
1214        self.push(Op::Pointwise { code }, inputs, Some(because.to_string()))
1215    }
1216}
1217
1218// -------------------------------------------------------------------------------------------
1219// Small `Core` constructors
1220// -------------------------------------------------------------------------------------------
1221
1222fn lam(params: Vec<VarId>, body: Core) -> Core {
1223    Core {
1224        ty: Ty::fun(params.iter().map(|_| Ty::unit()).collect(), body.ty.clone()),
1225        tier: body.tier,
1226        span: body.span,
1227        kind: CoreKind::Lam {
1228            params: params.into(),
1229            body: Arc::new(body),
1230        },
1231        last_use: false,
1232        order: crate::fields::UNORDERED,
1233        locals: 0,
1234    }
1235}
1236
1237fn var(v: VarId, ty: Ty, span: beck_diag::Span) -> Core {
1238    Core {
1239        kind: CoreKind::Var(v),
1240        ty,
1241        tier: Tier::Any,
1242        span,
1243        last_use: false,
1244        order: crate::fields::UNORDERED,
1245        locals: 0,
1246    }
1247}
1248
1249fn follow_alias(graph: &Graph, mut id: SigId) -> SigId {
1250    let mut guard = 0;
1251    while matches!(graph.node(id).op, SigOp::Alias) && guard < graph.nodes.len() {
1252        id = graph.node(id).inputs[0];
1253        guard += 1;
1254    }
1255    id
1256}
1257
1258/// Every variable an expression reads and does not itself bind.
1259fn free_vars(c: &Core, bound: &mut BTreeSet<VarId>, out: &mut BTreeSet<VarId>) {
1260    match &c.kind {
1261        CoreKind::Var(v) => {
1262            if !bound.contains(v) {
1263                out.insert(*v);
1264            }
1265        }
1266        CoreKind::Const(_) | CoreKind::Global(_) => {}
1267        CoreKind::Lam { params, body } => {
1268            let added: Vec<VarId> = params
1269                .iter()
1270                .copied()
1271                .filter(|p| bound.insert(*p))
1272                .collect();
1273            free_vars(body, bound, out);
1274            for p in added {
1275                bound.remove(&p);
1276            }
1277        }
1278        CoreKind::App { func, args } => {
1279            free_vars(func, bound, out);
1280            for a in args {
1281                free_vars(a, bound, out);
1282            }
1283        }
1284        CoreKind::Prim { args, .. } => {
1285            for a in args {
1286                free_vars(a, bound, out);
1287            }
1288        }
1289        CoreKind::Let { var, value, body } => {
1290            free_vars(value, bound, out);
1291            let added = bound.insert(*var);
1292            free_vars(body, bound, out);
1293            if added {
1294                bound.remove(var);
1295            }
1296        }
1297        CoreKind::If { cond, then, alt } => {
1298            free_vars(cond, bound, out);
1299            free_vars(then, bound, out);
1300            free_vars(alt, bound, out);
1301        }
1302        CoreKind::Match { scrutinee, arms } => {
1303            free_vars(scrutinee, bound, out);
1304            for a in arms {
1305                let added: Vec<VarId> = a
1306                    .pattern
1307                    .binders()
1308                    .into_iter()
1309                    .filter(|p| bound.insert(*p))
1310                    .collect();
1311                for e in a.exprs() {
1312                    free_vars(e, bound, out);
1313                }
1314                for p in added {
1315                    bound.remove(&p);
1316                }
1317            }
1318        }
1319        CoreKind::Make { fields, .. } => {
1320            for (_, f) in fields {
1321                free_vars(f, bound, out);
1322            }
1323        }
1324        CoreKind::Field { base, .. } => free_vars(base, bound, out),
1325        CoreKind::With { base, fields } => {
1326            free_vars(base, bound, out);
1327            for (_, f) in fields {
1328                free_vars(f, bound, out);
1329            }
1330        }
1331        CoreKind::ListLit(items) => {
1332            for i in items {
1333                free_vars(i, bound, out);
1334            }
1335        }
1336        CoreKind::MapLit(pairs) => {
1337            for (k, v) in pairs {
1338                free_vars(k, bound, out);
1339                free_vars(v, bound, out);
1340            }
1341        }
1342    }
1343}