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}