beck_core/incremental.rs
1//! Which views can be maintained by delta, and which have to be recomputed — and why.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.8:
4//!
5//! > **Subscribed views** (anything feeding a live `page`, or marked `materialized`) compile to
6//! > **incremental dataflow plans** … `remaining` updates by ±1 per event, never by recount. …
7//! > Arbitrary pure code is incrementalized where analysis allows, recomputed where not —
8//! > **`beck explain incremental <view>` shows which, and why**.
9//!
10//! [`20`](../../../../../docs/20-phase-2-report.md) §20.6 item 3 said the input for this existed
11//! ("a view whose row is empty is a pure function of the signal — which is §3.8's precondition")
12//! and §20.5 said the command was not built, because until the general slicer there was no plan to
13//! ask about: an inlined view is one expression, and "which vertices are incremental" is not a
14//! question an expression can answer.
15//!
16//! # What this is, and what now sits beside it
17//!
18//! It is the **analysis**: a verdict per *view*, from the shape of what that view computes. When it
19//! was written there was nothing behind it — every view was a full recompute per event and the
20//! report said so in its first line, because a command called `explain incremental` that printed
21//! "incremental" about a recompute would be the most misleading output in the compiler.
22//!
23//! There is now an engine ([`crate::plan`], [`crate::engine`]), and the report's first line changed
24//! with it rather than before it. The two answer different questions and the report gives both:
25//!
26//! * this module asks whether a **view** — a vertex of the signal graph — is a pure function built
27//! only from operations with delta rules;
28//! * [`crate::plan`] decomposes what the view *does* into operators, so a view this module calls
29//! `recompute` because it contains a `match` may still have its collections maintained around
30//! that `match`.
31//!
32//! The plan is the truth about what runs. This is the truth about what a view is, which is the
33//! answer a developer needs before writing one that quietly costs a recount per event over a
34//! million rows.
35//!
36//! # The rule, and where it comes from
37//!
38//! Three things have to hold before a vertex can be maintained by delta, and they are checked in
39//! this order because that is the order in which the answers are useful:
40//!
41//! 1. **The row is empty.** §3.8's precondition, and the one Phase 2 already computes. A view that
42//! performs an effect is re-evaluated when the effect says so, not when its input changes.
43//! 2. **Every operation it applies has a delta rule.** `list_len` after a `filter_list` updates by
44//! ±1; a `sort_by` maintains a sorted arrangement; arithmetic and record construction are
45//! pointwise. A `match` on the accumulator, or a function this analysis cannot see through, has
46//! no rule, and the honest answer is "recompute".
47//! 3. **It is downstream of a `durable` fold and upstream of a sink.** A vertex nothing subscribes
48//! to is not a view; §3.8's scope is "anything feeding a live `page`, or marked `materialized`".
49//!
50//! [`RULES`] is the table for step 2. Like [`crate::cost`]'s numbers it is **stated, not
51//! measured** — each entry is a delta rule the differential-dataflow literature already has, and it
52//! is written down so that it can be argued with rather than discovered in a profiler. Nothing in
53//! this module claims an implementation exists for any of them.
54
55use std::collections::{BTreeMap, BTreeSet};
56use std::sync::Arc;
57
58use crate::check::Program;
59use crate::core::{Core, CoreKind, Prim};
60use crate::plan::Plan;
61use crate::signal::{Op, SigId};
62use crate::split::Placed;
63use crate::ty::Effect;
64
65/// The operations with a known delta rule, and the rule.
66///
67/// "Known" means known to the literature, not implemented here. The second column is what a view
68/// engine would have to do, and it is written out because a table of names would be a list of
69/// opinions.
70pub const RULES: &[(Prim, &str)] = &[
71 (Prim::MapList, "a delta in, the same delta mapped out"),
72 (
73 Prim::FilterList,
74 "a delta in, kept or dropped by the predicate",
75 ),
76 (
77 Prim::ListLen,
78 "±1 per delta — §3.8's `remaining`, never a recount",
79 ),
80 (Prim::ListIsEmpty, "a count, thresholded"),
81 (Prim::MapValues, "the arrangement, read by value"),
82 (Prim::MapLen, "±1 per insert or remove"),
83 (Prim::MapGet, "a point lookup into the arrangement"),
84 (Prim::MapContains, "a point lookup into the arrangement"),
85 (
86 Prim::SortBy,
87 "an ordered arrangement, maintained by insertion",
88 ),
89 (Prim::ConcatLists, "a union of delta streams"),
90 // Pointwise on a value, so a delta at the input is a delta at the output.
91 (Prim::Add, "pointwise"),
92 (Prim::Sub, "pointwise"),
93 (Prim::Mul, "pointwise"),
94 (Prim::Div, "pointwise"),
95 (Prim::Rem, "pointwise"),
96 (Prim::Neg, "pointwise"),
97 (Prim::Eq, "pointwise"),
98 (Prim::Ne, "pointwise"),
99 (Prim::Lt, "pointwise"),
100 (Prim::Le, "pointwise"),
101 (Prim::Gt, "pointwise"),
102 (Prim::Ge, "pointwise"),
103 (Prim::And, "pointwise"),
104 (Prim::Or, "pointwise"),
105 (Prim::Not, "pointwise"),
106 (Prim::ToStr, "pointwise"),
107 (Prim::StrTrim, "pointwise"),
108 (Prim::StrIsEmpty, "pointwise"),
109 (Prim::OptionIsSome, "pointwise"),
110 (Prim::OptionUnwrapOr, "pointwise"),
111 // The `ui:` vocabulary is a tree constructor, and a tree of deltas is what the patch protocol
112 // already carries (§5.1). This is the one row where the runtime half exists.
113 (
114 Prim::HtmlEl,
115 "a subtree delta — what the patch protocol already streams",
116 ),
117 (Prim::HtmlText, "a text patch"),
118 (Prim::HtmlAttr, "an attribute patch"),
119 (Prim::HtmlOn, "an attribute patch"),
120 (Prim::HtmlKey, "the key a keyed-children diff is by"),
121];
122
123fn rule(op: Prim) -> Option<&'static str> {
124 RULES.iter().find(|(p, _)| *p == op).map(|(_, r)| *r)
125}
126
127/// The rule for one *application*, which for one primitive depends on what it is applied to.
128///
129/// [`RULES`] maps a name to a rule, and that is the right shape for every primitive but
130/// `str_join`, whose answer depends on whether its first argument is a **collection** or a fixed
131/// list of parts. It cannot be a row in the table for exactly that reason, and it is here rather
132/// than left out because leaving it out is what made the analysis wrong:
133///
134/// * `str_join(map_list(xs, f), ", ")` reduces a maintained collection to one string. A change to
135/// any element changes the whole answer and there is no delta rule to have;
136/// * `str_join([a, b, c], " ")` is a function of `a`, `b` and `c`. Its arity is fixed at compile
137/// time, so a change at an input is a change at the output and nothing is scanned — which is
138/// what every "pointwise" row in the table above means. It is `to_str` with three arguments.
139///
140/// The second is the shape [`docs/104`](../../../../../docs/104-styling-and-the-component-library.md)
141/// §104.4 asks every program to write: `class=["btn", "primary" if hot else "plain"]` lowers to one
142/// of these, and calling it a recompute made a *report* say that a page had stopped being
143/// maintained when the plan it compiles to is unchanged, entry for entry.
144fn rule_of(op: Prim, args: &[Core]) -> Option<&'static str> {
145 if op == Prim::StrJoin {
146 return match args.first().map(|a| &a.kind) {
147 Some(CoreKind::ListLit(_)) => {
148 Some("pointwise — a join of a fixed list of parts is a function of those parts")
149 }
150 _ => None,
151 };
152 }
153 rule(op)
154}
155
156/// What a view engine could do with one vertex.
157#[derive(Clone, Debug, PartialEq, Eq)]
158pub enum Verdict {
159 /// Every operation has a delta rule: this vertex could be maintained rather than recomputed.
160 Incremental,
161 /// Pure, and it applies no collection operation at all — a vertex that rebuilds a value from
162 /// its inputs, as `map2(combine, board, here)` does when `combine` is a record constructor.
163 ///
164 /// Neither of the two interesting answers: there is nothing to maintain by delta and nothing
165 /// that would cost a recount. It is its own verdict because saying "incremental" about it
166 /// produced a row with an empty explanation, which is what
167 /// `incremental.rs`'s "none of them is a mystery" gate exists to catch — and did, the first
168 /// time a program in the corpus applied nothing (`docs/48` §48.9).
169 Trivial,
170 /// Pure, but something in it has no delta rule. The reason names the first blocker found, in
171 /// source order, because the first is the one to fix.
172 Recompute { because: String },
173 /// The row is not empty, so §3.8's precondition fails before the shape is even looked at.
174 Effectful { effects: Vec<Effect> },
175}
176
177impl Verdict {
178 pub fn name(&self) -> &'static str {
179 match self {
180 Verdict::Incremental => "incremental",
181 Verdict::Trivial => "no collection work",
182 Verdict::Recompute { .. } => "recompute",
183 Verdict::Effectful { .. } => "not a candidate",
184 }
185 }
186}
187
188/// One vertex's assessment.
189#[derive(Clone, Debug)]
190pub struct Assessment {
191 pub node: SigId,
192 pub label: Arc<str>,
193 pub verdict: Verdict,
194 /// The operations found in this vertex's function, with the rule each would be maintained by.
195 /// Empty for a vertex that applies nothing — a `durable`, an alias.
196 pub ops: Vec<(Prim, &'static str)>,
197 /// True when this vertex's value is read by more than one consumer, so an engine would share
198 /// one arrangement rather than build two ([`05`](../../../../../docs/05-tier-lowering.md) §5.3).
199 pub shared: bool,
200 /// True when this vertex is at or below a `per_session`, so an engine would run it *per
201 /// subscriber* rather than once. §3.8: "per-session views are the norm, not the exception."
202 pub per_session: bool,
203}
204
205/// Assess every vertex between the durable folds and the sinks.
206///
207/// Vertices that are not views — the ingress, the chokepoint, the folds themselves — are left out,
208/// because §3.8's question is about views and answering it about a `merge_clients()` would be
209/// filling a report with rows nobody asked for.
210pub fn assess(placed: &Placed) -> Vec<Assessment> {
211 let g = &placed.graph;
212 let below = per_session_closure(placed);
213 let mut out = Vec::new();
214 for id in g.order() {
215 let node = g.node(id);
216 let f = match &node.op {
217 Op::Map { f } | Op::Map2 { f } | Op::PerSession { f } => f,
218 // A `filter_map` on the *stream* side is not a view: it decides which events a fold
219 // sees, and a fold is not maintained by delta — it *is* the delta consumer.
220 _ => continue,
221 };
222 let (verdict, ops) = judge(f, &placed.program);
223 out.push(Assessment {
224 node: id,
225 label: node.label.clone(),
226 verdict,
227 ops,
228 shared: g.consumers(id).len() > 1,
229 per_session: below.contains(&id),
230 });
231 }
232 out
233}
234
235/// Every vertex at or downstream of a `per_session`.
236///
237/// §5.3's shape is "one shared dataflow whose final per-session operators run per subscriber", so
238/// the boundary is the thing a report has to be able to point at.
239fn per_session_closure(placed: &Placed) -> BTreeSet<SigId> {
240 let g = &placed.graph;
241 let mut below = BTreeSet::new();
242 // The order is dependencies-first, so a vertex's inputs are decided before it is.
243 for id in g.order() {
244 let node = g.node(id);
245 // `presence` joins the session on this side of the cut, for the reason
246 // [`crate::plan::Op::Presence`] gives: what it produces is not a function of the
247 // accumulator, and the shared dataflow is versioned by the accumulator.
248 if matches!(
249 node.op,
250 Op::PerSession { .. } | Op::Presence | Op::Awareness { .. }
251 ) || node.inputs.iter().any(|i| below.contains(i))
252 {
253 below.insert(id);
254 }
255 }
256 below
257}
258
259/// Judge one signal function: the thing `signal_map(s, f)` applies.
260fn judge(f: &Core, program: &Program) -> (Verdict, Vec<(Prim, &'static str)>) {
261 let mut found: Vec<(Prim, &'static str)> = Vec::new();
262 let mut blocker: Option<String> = None;
263 let mut seen: BTreeSet<Arc<str>> = BTreeSet::new();
264
265 // §3.8's precondition, from the row Phase 2 already inferred.
266 let mut effects = Vec::new();
267 f.effects(&globals_of(program), &mut effects);
268 effects.retain(|e| !e.is_ambient());
269 if !effects.is_empty() {
270 return (Verdict::Effectful { effects }, found);
271 }
272
273 walk_through(f, program, &mut seen, &mut |c| {
274 if blocker.is_some() {
275 return;
276 }
277 match &c.kind {
278 CoreKind::Prim { op, args } => {
279 match rule_of(*op, args) {
280 Some(r) => {
281 if !found.iter().any(|(p, _)| p == op) {
282 found.push((*op, r));
283 }
284 }
285 None if *op == Prim::StrJoin => blocker = Some(
286 "`str_join` over a collection has no delta rule: a change to one element \
287 can change all of its output"
288 .to_string(),
289 ),
290 None => {
291 blocker = Some(format!(
292 "`{}` has no delta rule: a change to its input can change all of \
293 its output",
294 op.name()
295 ))
296 }
297 }
298 }
299 // A `match` chooses a *shape*, and a delta that changes which arm applies changes
300 // everything downstream of it. Differential dataflow handles this by treating the
301 // scrutinee as a collection and each arm as a branch of the plan; that is a real
302 // technique and it is not this table.
303 CoreKind::Match { .. } => {
304 blocker = Some(
305 "a `match` on the input picks which computation runs, and a delta can move it \
306 between arms"
307 .to_string(),
308 )
309 }
310 CoreKind::Global(name) => {
311 if program.defs.contains_key(name) {
312 return;
313 }
314 blocker = Some(format!(
315 "`{name}` is not a definition this analysis can see into"
316 ));
317 }
318 _ => {}
319 }
320 });
321
322 match blocker {
323 Some(because) => (Verdict::Recompute { because }, found),
324 None if found.is_empty() => (Verdict::Trivial, found),
325 None => (Verdict::Incremental, found),
326 }
327}
328
329fn globals_of(program: &Program) -> impl Fn(&str) -> Vec<Effect> + '_ {
330 move |name: &str| {
331 program
332 .defs
333 .get(name)
334 .map(|d| d.effects.clone())
335 .unwrap_or_default()
336 }
337}
338
339/// Walk an expression, following calls into the definitions it names.
340///
341/// Recursion is cut by `seen`, and a recursive definition is *not* a blocker on its own: a
342/// self-recursive pure function over a list is exactly what `map`/`filter` desugar from in most
343/// languages. What blocks is an operation with no rule, wherever it is found.
344fn walk_through(
345 c: &Core,
346 program: &Program,
347 seen: &mut BTreeSet<Arc<str>>,
348 f: &mut impl FnMut(&Core),
349) {
350 f(c);
351 if let CoreKind::Global(name) = &c.kind {
352 if seen.insert(name.clone()) {
353 if let Some(def) = program.defs.get(name) {
354 walk_through(&def.body, program, seen, f);
355 }
356 }
357 return;
358 }
359 children(c, &mut |k| walk_through(k, program, seen, f));
360}
361
362fn children(c: &Core, f: &mut impl FnMut(&Core)) {
363 match &c.kind {
364 CoreKind::Const(_) | CoreKind::Var(_) | CoreKind::Global(_) => {}
365 CoreKind::Lam { body, .. } => f(body),
366 CoreKind::App { func, args } => {
367 f(func);
368 args.iter().for_each(f);
369 }
370 CoreKind::Prim { args, .. } => args.iter().for_each(f),
371 CoreKind::Let { value, body, .. } => {
372 f(value);
373 f(body);
374 }
375 CoreKind::If { cond, then, alt } => {
376 f(cond);
377 f(then);
378 f(alt);
379 }
380 CoreKind::Match { scrutinee, arms } => {
381 f(scrutinee);
382 for e in arms.iter().flat_map(|a| a.exprs()) {
383 f(e);
384 }
385 }
386 CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, v)| f(v)),
387 CoreKind::Field { base, .. } => f(base),
388 CoreKind::With { base, fields } => {
389 f(base);
390 fields.iter().for_each(|(_, v)| f(v));
391 }
392 CoreKind::ListLit(items) => items.iter().for_each(f),
393 CoreKind::MapLit(pairs) => pairs.iter().for_each(|(k, v)| {
394 f(k);
395 f(v);
396 }),
397 }
398}
399
400/// What `beck explain incremental` prints.
401pub fn report(placed: &Placed, only: Option<&str>) -> String {
402 use std::fmt::Write;
403 let all = assess(placed);
404 let rows: Vec<&Assessment> = match only {
405 None => all.iter().collect(),
406 Some(name) => all.iter().filter(|a| a.label.as_ref() == name).collect(),
407 };
408 let mut out = String::new();
409
410 if let Some(name) = only {
411 if rows.is_empty() {
412 let known: Vec<&str> = all.iter().map(|a| a.label.as_ref()).collect();
413 let _ = writeln!(
414 out,
415 "`{name}` is not a view in this program.\nviews: {}",
416 if known.is_empty() {
417 "none — every signal is the fold, the chokepoint or the ingress".to_string()
418 } else {
419 known.join(", ")
420 }
421 );
422 return out;
423 }
424 }
425
426 // The first line is what is true of this program *now*, because that is the thing a reader
427 // most needs and least expects. It was "every view is a full recompute" until the engine
428 // existed; it says what the engine does because the engine does it (docs/23).
429 let plan = Plan::compile(placed);
430 let (maintained, recomputed) = plan.counts();
431 let _ = writeln!(out, "{}\n", headline(maintained, recomputed));
432
433 if rows.is_empty() {
434 let _ = writeln!(
435 out,
436 "This program has no views: the page reads the accumulator directly, so there is\n\
437 nothing between the fold and the browser that is a view in §3.8's sense. What the\n\
438 page itself does is still decomposed — see the operators below."
439 );
440 let _ = write!(out, "{}", plan_section(&plan));
441 return out;
442 }
443
444 let w = rows
445 .iter()
446 .map(|a| a.label.chars().count())
447 .max()
448 .unwrap_or(0);
449 for a in &rows {
450 let mut tags = Vec::new();
451 if a.shared {
452 tags.push("shared");
453 }
454 if a.per_session {
455 tags.push("per session");
456 }
457 let _ = writeln!(
458 out,
459 " {:<w$} {:<15}{}",
460 a.label,
461 a.verdict.name(),
462 if tags.is_empty() {
463 String::new()
464 } else {
465 format!("({})", tags.join(", "))
466 },
467 );
468 match &a.verdict {
469 Verdict::Incremental => {
470 for (op, r) in &a.ops {
471 let _ = writeln!(out, " {:w$} {:<14} {r}", "", op.name());
472 }
473 }
474 Verdict::Trivial => {
475 let _ = writeln!(
476 out,
477 " {:w$} applies no collection operation: the value is rebuilt from its \
478 inputs",
479 ""
480 );
481 }
482 Verdict::Recompute { because } => {
483 let _ = writeln!(out, " {:w$} {because}", "");
484 if !a.ops.is_empty() {
485 let _ = writeln!(
486 out,
487 " {:w$} the rest would have been: {}",
488 "",
489 a.ops
490 .iter()
491 .map(|(p, _)| p.name())
492 .collect::<Vec<_>>()
493 .join(", ")
494 );
495 }
496 }
497 Verdict::Effectful { effects } => {
498 let _ = writeln!(
499 out,
500 " {:w$} performs {{{}}}, so §3.8's precondition — an empty row — does not \
501 hold",
502 "",
503 effects
504 .iter()
505 .map(|e| e.name())
506 .collect::<Vec<_>>()
507 .join(", ")
508 );
509 }
510 }
511 }
512
513 if only.is_none() {
514 let shared: Vec<&str> = rows
515 .iter()
516 .filter(|a| a.shared)
517 .map(|a| a.label.as_ref())
518 .collect();
519 let fanout: Vec<&str> = rows
520 .iter()
521 .filter(|a| a.per_session)
522 .map(|a| a.label.as_ref())
523 .collect();
524 let _ = write!(out, "{}", plan_section(&plan));
525 let _ = writeln!(out, "\nthe shape of the signal graph (§5.3)");
526 let _ = writeln!(
527 out,
528 " shared arrangement: {}",
529 if shared.is_empty() {
530 "nothing is read twice, so there is no prefix to share".to_string()
531 } else {
532 shared.join(", ")
533 }
534 );
535 let _ = writeln!(
536 out,
537 " per subscriber: {}",
538 if fanout.is_empty() {
539 "nothing — this program broadcasts one view to every connection".to_string()
540 } else {
541 format!(
542 "{} (one plan, these operators per connected session)",
543 fanout.join(", ")
544 )
545 }
546 );
547 let shared_ops = plan.shared().len();
548 let _ = writeln!(
549 out,
550 " in the plan: {shared_ops} of {} operators read neither the session nor who \n\
551 \x20 is connected, and the runtime holds those once for every \n\
552 \x20 subscriber — one shared dataflow, advanced per event rather \n\
553 \x20 than per connection (docs/23). The other {} run per \n\
554 \x20 subscriber.",
555 plan.nodes.len(),
556 plan.nodes.len() - shared_ops,
557 );
558 let n = rows.len();
559 let inc = rows
560 .iter()
561 .filter(|a| a.verdict == Verdict::Incremental)
562 .count();
563 let eff = rows
564 .iter()
565 .filter(|a| matches!(a.verdict, Verdict::Effectful { .. }))
566 .count();
567 let _ = write!(
568 out,
569 "\n{inc} of {n} view{} could be maintained by delta",
570 if n == 1 { "" } else { "s" },
571 );
572 if n - inc - eff > 0 {
573 let _ = write!(out, "; {} would be recomputed", n - inc - eff);
574 }
575 if eff > 0 {
576 let _ = write!(
577 out,
578 "; {eff} {} not a candidate, because an effect decides when it runs",
579 if eff == 1 { "is" } else { "are" }
580 );
581 }
582 let _ = writeln!(out, ".");
583 }
584 out
585}
586
587/// The first line, which has to be true of *this* program rather than of the feature.
588///
589/// It said "every view below is a full recompute per event" until there was an engine, and the
590/// obligation has not changed now that there is one: a program whose view holds no collection has
591/// nothing maintained, and a report that led with the feature would tell its reader otherwise.
592fn headline(maintained: usize, recomputed: usize) -> String {
593 if maintained == 0 {
594 return format!(
595 "**Nothing in this view is maintained by delta.** The plan found no collection for a\n\
596 delta to flow through, so all {recomputed} of its operators are recomputed — each one\n\
597 only when an input actually moved, which is what a plan buys even here."
598 );
599 }
600 format!(
601 "Views are **maintained by delta** as far as the plan can decompose them: {maintained} of\n\
602 this view's {} operators update from the change itself, {recomputed} are recomputed when\n\
603 an input moves, and the page's children are still assembled in full every time\n\
604 (docs/23 §23.8).",
605 maintained + recomputed
606 )
607}
608
609/// What the compiled plan actually does — the half of the report that is about the engine rather
610/// than about the analysis.
611///
612/// A view this module calls `recompute` can still have most of its work maintained, because the
613/// decomposition goes *inside* the view: `match` on a field blocks the vertex, not the
614/// `filter_list` above it. Printing both is what stops the two answers being mistaken for one.
615fn plan_section(plan: &Plan) -> String {
616 use std::fmt::Write;
617 let mut out = String::new();
618 let (maintained, recomputed) = plan.counts();
619 let _ = writeln!(out, "\nthe operators the view compiles to");
620
621 let mut kinds: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
622 for node in &plan.nodes {
623 let e = kinds.entry(node.op.name()).or_default();
624 e.0 += 1;
625 if !node.per_session {
626 e.1 += 1;
627 }
628 }
629 for (name, (n, shared)) in &kinds {
630 let example = plan
631 .nodes
632 .iter()
633 .find(|x| x.op.name() == *name)
634 .map(|x| &x.op);
635 let kind = match example {
636 Some(op) if op.is_source() => "source",
637 Some(op) if op.maintained() => "maintained",
638 _ => "recomputed",
639 };
640 let _ = writeln!(
641 out,
642 " {:<14} ×{:<4} {:<11} {}",
643 name,
644 n,
645 kind,
646 if *shared == 0 {
647 "per session".to_string()
648 } else if shared == n {
649 "shared".to_string()
650 } else {
651 format!("{shared} of {n} shared")
652 }
653 );
654 }
655
656 // The reasons, deduplicated: a plan with twenty pointwise operators has three reasons, and a
657 // list of twenty would bury them.
658 let mut reasons: Vec<&str> = plan
659 .nodes
660 .iter()
661 .filter_map(|n| n.because.as_deref())
662 .collect();
663 reasons.sort();
664 reasons.dedup();
665 if !reasons.is_empty() {
666 let _ = writeln!(out, "\n what could not be pushed a delta through");
667 for r in reasons {
668 let _ = writeln!(out, " {r}");
669 }
670 }
671 let _ = writeln!(
672 out,
673 "\n {maintained} maintained, {recomputed} recomputed. A recomputed operator is\n \
674 re-evaluated only when one of its inputs moved, which is what a plan buys even where a\n \
675 delta rule does not exist."
676 );
677 out
678}
679
680/// A map from vertex label to verdict, for a test that wants the answer rather than the prose.
681pub fn verdicts(placed: &Placed) -> BTreeMap<Arc<str>, Verdict> {
682 assess(placed)
683 .into_iter()
684 .map(|a| (a.label, a.verdict))
685 .collect()
686}