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}