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/// What a view engine could do with one vertex.
128#[derive(Clone, Debug, PartialEq, Eq)]
129pub enum Verdict {
130    /// Every operation has a delta rule: this vertex could be maintained rather than recomputed.
131    Incremental,
132    /// Pure, and it applies no collection operation at all — a vertex that rebuilds a value from
133    /// its inputs, as `map2(combine, board, here)` does when `combine` is a record constructor.
134    ///
135    /// Neither of the two interesting answers: there is nothing to maintain by delta and nothing
136    /// that would cost a recount. It is its own verdict because saying "incremental" about it
137    /// produced a row with an empty explanation, which is what
138    /// `incremental.rs`'s "none of them is a mystery" gate exists to catch — and did, the first
139    /// time a program in the corpus applied nothing (`docs/96` §96.5).
140    Trivial,
141    /// Pure, but something in it has no delta rule. The reason names the first blocker found, in
142    /// source order, because the first is the one to fix.
143    Recompute { because: String },
144    /// The row is not empty, so §3.8's precondition fails before the shape is even looked at.
145    Effectful { effects: Vec<Effect> },
146}
147
148impl Verdict {
149    pub fn name(&self) -> &'static str {
150        match self {
151            Verdict::Incremental => "incremental",
152            Verdict::Trivial => "no collection work",
153            Verdict::Recompute { .. } => "recompute",
154            Verdict::Effectful { .. } => "not a candidate",
155        }
156    }
157}
158
159/// One vertex's assessment.
160#[derive(Clone, Debug)]
161pub struct Assessment {
162    pub node: SigId,
163    pub label: Arc<str>,
164    pub verdict: Verdict,
165    /// The operations found in this vertex's function, with the rule each would be maintained by.
166    /// Empty for a vertex that applies nothing — a `durable`, an alias.
167    pub ops: Vec<(Prim, &'static str)>,
168    /// True when this vertex's value is read by more than one consumer, so an engine would share
169    /// one arrangement rather than build two ([`05`](../../../../../docs/05-tier-lowering.md) §5.3).
170    pub shared: bool,
171    /// True when this vertex is at or below a `per_session`, so an engine would run it *per
172    /// subscriber* rather than once. §3.8: "per-session views are the norm, not the exception."
173    pub per_session: bool,
174}
175
176/// Assess every vertex between the durable folds and the sinks.
177///
178/// Vertices that are not views — the ingress, the chokepoint, the folds themselves — are left out,
179/// because §3.8's question is about views and answering it about a `merge_clients()` would be
180/// filling a report with rows nobody asked for.
181pub fn assess(placed: &Placed) -> Vec<Assessment> {
182    let g = &placed.graph;
183    let below = per_session_closure(placed);
184    let mut out = Vec::new();
185    for id in g.order() {
186        let node = g.node(id);
187        let f = match &node.op {
188            Op::Map { f } | Op::Map2 { f } | Op::PerSession { f } => f,
189            // A `filter_map` on the *stream* side is not a view: it decides which events a fold
190            // sees, and a fold is not maintained by delta — it *is* the delta consumer.
191            _ => continue,
192        };
193        let (verdict, ops) = judge(f, &placed.program);
194        out.push(Assessment {
195            node: id,
196            label: node.label.clone(),
197            verdict,
198            ops,
199            shared: g.consumers(id).len() > 1,
200            per_session: below.contains(&id),
201        });
202    }
203    out
204}
205
206/// Every vertex at or downstream of a `per_session`.
207///
208/// §5.3's shape is "one shared dataflow whose final per-session operators run per subscriber", so
209/// the boundary is the thing a report has to be able to point at.
210fn per_session_closure(placed: &Placed) -> BTreeSet<SigId> {
211    let g = &placed.graph;
212    let mut below = BTreeSet::new();
213    // The order is dependencies-first, so a vertex's inputs are decided before it is.
214    for id in g.order() {
215        let node = g.node(id);
216        // `presence` joins the session on this side of the cut, for the reason
217        // [`crate::plan::Op::Presence`] gives: what it produces is not a function of the
218        // accumulator, and the shared dataflow is versioned by the accumulator.
219        if matches!(node.op, Op::PerSession { .. } | Op::Presence)
220            || node.inputs.iter().any(|i| below.contains(i))
221        {
222            below.insert(id);
223        }
224    }
225    below
226}
227
228/// Judge one signal function: the thing `signal_map(s, f)` applies.
229fn judge(f: &Core, program: &Program) -> (Verdict, Vec<(Prim, &'static str)>) {
230    let mut found: Vec<(Prim, &'static str)> = Vec::new();
231    let mut blocker: Option<String> = None;
232    let mut seen: BTreeSet<Arc<str>> = BTreeSet::new();
233
234    // §3.8's precondition, from the row Phase 2 already inferred.
235    let mut effects = Vec::new();
236    f.effects(&globals_of(program), &mut effects);
237    effects.retain(|e| !e.is_ambient());
238    if !effects.is_empty() {
239        return (Verdict::Effectful { effects }, found);
240    }
241
242    walk_through(f, program, &mut seen, &mut |c| {
243        if blocker.is_some() {
244            return;
245        }
246        match &c.kind {
247            CoreKind::Prim { op, .. } => match rule(*op) {
248                Some(r) => {
249                    if !found.iter().any(|(p, _)| p == op) {
250                        found.push((*op, r));
251                    }
252                }
253                None => {
254                    blocker = Some(format!(
255                        "`{}` has no delta rule: a change to its input can change all of its \
256                         output",
257                        op.name()
258                    ))
259                }
260            },
261            // A `match` chooses a *shape*, and a delta that changes which arm applies changes
262            // everything downstream of it. Differential dataflow handles this by treating the
263            // scrutinee as a collection and each arm as a branch of the plan; that is a real
264            // technique and it is not this table.
265            CoreKind::Match { .. } => {
266                blocker = Some(
267                    "a `match` on the input picks which computation runs, and a delta can move it \
268                     between arms"
269                        .to_string(),
270                )
271            }
272            CoreKind::Global(name) => {
273                if program.defs.contains_key(name) {
274                    return;
275                }
276                blocker = Some(format!(
277                    "`{name}` is not a definition this analysis can see into"
278                ));
279            }
280            _ => {}
281        }
282    });
283
284    match blocker {
285        Some(because) => (Verdict::Recompute { because }, found),
286        None if found.is_empty() => (Verdict::Trivial, found),
287        None => (Verdict::Incremental, found),
288    }
289}
290
291fn globals_of(program: &Program) -> impl Fn(&str) -> Vec<Effect> + '_ {
292    move |name: &str| {
293        program
294            .defs
295            .get(name)
296            .map(|d| d.effects.clone())
297            .unwrap_or_default()
298    }
299}
300
301/// Walk an expression, following calls into the definitions it names.
302///
303/// Recursion is cut by `seen`, and a recursive definition is *not* a blocker on its own: a
304/// self-recursive pure function over a list is exactly what `map`/`filter` desugar from in most
305/// languages. What blocks is an operation with no rule, wherever it is found.
306fn walk_through(
307    c: &Core,
308    program: &Program,
309    seen: &mut BTreeSet<Arc<str>>,
310    f: &mut impl FnMut(&Core),
311) {
312    f(c);
313    if let CoreKind::Global(name) = &c.kind {
314        if seen.insert(name.clone()) {
315            if let Some(def) = program.defs.get(name) {
316                walk_through(&def.body, program, seen, f);
317            }
318        }
319        return;
320    }
321    children(c, &mut |k| walk_through(k, program, seen, f));
322}
323
324fn children(c: &Core, f: &mut impl FnMut(&Core)) {
325    match &c.kind {
326        CoreKind::Const(_) | CoreKind::Var(_) | CoreKind::Global(_) => {}
327        CoreKind::Lam { body, .. } => f(body),
328        CoreKind::App { func, args } => {
329            f(func);
330            args.iter().for_each(f);
331        }
332        CoreKind::Prim { args, .. } => args.iter().for_each(f),
333        CoreKind::Let { value, body, .. } => {
334            f(value);
335            f(body);
336        }
337        CoreKind::If { cond, then, alt } => {
338            f(cond);
339            f(then);
340            f(alt);
341        }
342        CoreKind::Match { scrutinee, arms } => {
343            f(scrutinee);
344            for e in arms.iter().flat_map(|a| a.exprs()) {
345                f(e);
346            }
347        }
348        CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, v)| f(v)),
349        CoreKind::Field { base, .. } => f(base),
350        CoreKind::With { base, fields } => {
351            f(base);
352            fields.iter().for_each(|(_, v)| f(v));
353        }
354        CoreKind::ListLit(items) => items.iter().for_each(f),
355        CoreKind::MapLit(pairs) => pairs.iter().for_each(|(k, v)| {
356            f(k);
357            f(v);
358        }),
359    }
360}
361
362/// What `beck explain incremental` prints.
363pub fn report(placed: &Placed, only: Option<&str>) -> String {
364    use std::fmt::Write;
365    let all = assess(placed);
366    let rows: Vec<&Assessment> = match only {
367        None => all.iter().collect(),
368        Some(name) => all.iter().filter(|a| a.label.as_ref() == name).collect(),
369    };
370    let mut out = String::new();
371
372    if let Some(name) = only {
373        if rows.is_empty() {
374            let known: Vec<&str> = all.iter().map(|a| a.label.as_ref()).collect();
375            let _ = writeln!(
376                out,
377                "`{name}` is not a view in this program.\nviews: {}",
378                if known.is_empty() {
379                    "none — every signal is the fold, the chokepoint or the ingress".to_string()
380                } else {
381                    known.join(", ")
382                }
383            );
384            return out;
385        }
386    }
387
388    // The first line is what is true of this program *now*, because that is the thing a reader
389    // most needs and least expects. It was "every view is a full recompute" until the engine
390    // existed; it says what the engine does because the engine does it (docs/24).
391    let plan = Plan::compile(placed);
392    let (maintained, recomputed) = plan.counts();
393    let _ = writeln!(out, "{}\n", headline(maintained, recomputed));
394
395    if rows.is_empty() {
396        let _ = writeln!(
397            out,
398            "This program has no views: the page reads the accumulator directly, so there is\n\
399             nothing between the fold and the browser that is a view in §3.8's sense. What the\n\
400             page itself does is still decomposed — see the operators below."
401        );
402        let _ = write!(out, "{}", plan_section(&plan));
403        return out;
404    }
405
406    let w = rows
407        .iter()
408        .map(|a| a.label.chars().count())
409        .max()
410        .unwrap_or(0);
411    for a in &rows {
412        let mut tags = Vec::new();
413        if a.shared {
414            tags.push("shared");
415        }
416        if a.per_session {
417            tags.push("per session");
418        }
419        let _ = writeln!(
420            out,
421            "  {:<w$}  {:<15}{}",
422            a.label,
423            a.verdict.name(),
424            if tags.is_empty() {
425                String::new()
426            } else {
427                format!("({})", tags.join(", "))
428            },
429        );
430        match &a.verdict {
431            Verdict::Incremental => {
432                for (op, r) in &a.ops {
433                    let _ = writeln!(out, "  {:w$}    {:<14} {r}", "", op.name());
434                }
435            }
436            Verdict::Trivial => {
437                let _ = writeln!(
438                    out,
439                    "  {:w$}    applies no collection operation: the value is rebuilt from its \
440                     inputs",
441                    ""
442                );
443            }
444            Verdict::Recompute { because } => {
445                let _ = writeln!(out, "  {:w$}    {because}", "");
446                if !a.ops.is_empty() {
447                    let _ = writeln!(
448                        out,
449                        "  {:w$}    the rest would have been: {}",
450                        "",
451                        a.ops
452                            .iter()
453                            .map(|(p, _)| p.name())
454                            .collect::<Vec<_>>()
455                            .join(", ")
456                    );
457                }
458            }
459            Verdict::Effectful { effects } => {
460                let _ = writeln!(
461                    out,
462                    "  {:w$}    performs {{{}}}, so §3.8's precondition — an empty row — does not \
463                     hold",
464                    "",
465                    effects
466                        .iter()
467                        .map(|e| e.name())
468                        .collect::<Vec<_>>()
469                        .join(", ")
470                );
471            }
472        }
473    }
474
475    if only.is_none() {
476        let shared: Vec<&str> = rows
477            .iter()
478            .filter(|a| a.shared)
479            .map(|a| a.label.as_ref())
480            .collect();
481        let fanout: Vec<&str> = rows
482            .iter()
483            .filter(|a| a.per_session)
484            .map(|a| a.label.as_ref())
485            .collect();
486        let _ = write!(out, "{}", plan_section(&plan));
487        let _ = writeln!(out, "\nthe shape of the signal graph (§5.3)");
488        let _ = writeln!(
489            out,
490            "  shared arrangement: {}",
491            if shared.is_empty() {
492                "nothing is read twice, so there is no prefix to share".to_string()
493            } else {
494                shared.join(", ")
495            }
496        );
497        let _ = writeln!(
498            out,
499            "  per subscriber:     {}",
500            if fanout.is_empty() {
501                "nothing — this program broadcasts one view to every connection".to_string()
502            } else {
503                format!(
504                    "{}  (one plan, these operators per connected session)",
505                    fanout.join(", ")
506                )
507            }
508        );
509        let shared_ops = plan.shared().len();
510        let _ = writeln!(
511            out,
512            "  in the plan:        {shared_ops} of {} operators read neither the session nor who \n\
513             \x20                     is connected, and the runtime holds those once for every \n\
514             \x20                     subscriber — one shared dataflow, advanced per event rather \n\
515             \x20                     than per connection (docs/26). The other {} run per \n\
516             \x20                     subscriber.",
517            plan.nodes.len(),
518            plan.nodes.len() - shared_ops,
519        );
520        let n = rows.len();
521        let inc = rows
522            .iter()
523            .filter(|a| a.verdict == Verdict::Incremental)
524            .count();
525        let eff = rows
526            .iter()
527            .filter(|a| matches!(a.verdict, Verdict::Effectful { .. }))
528            .count();
529        let _ = write!(
530            out,
531            "\n{inc} of {n} view{} could be maintained by delta",
532            if n == 1 { "" } else { "s" },
533        );
534        if n - inc - eff > 0 {
535            let _ = write!(out, "; {} would be recomputed", n - inc - eff);
536        }
537        if eff > 0 {
538            let _ = write!(
539                out,
540                "; {eff} {} not a candidate, because an effect decides when it runs",
541                if eff == 1 { "is" } else { "are" }
542            );
543        }
544        let _ = writeln!(out, ".");
545    }
546    out
547}
548
549/// The first line, which has to be true of *this* program rather than of the feature.
550///
551/// It said "every view below is a full recompute per event" until there was an engine, and the
552/// obligation has not changed now that there is one: a program whose view holds no collection has
553/// nothing maintained, and a report that led with the feature would tell its reader otherwise.
554fn headline(maintained: usize, recomputed: usize) -> String {
555    if maintained == 0 {
556        return format!(
557            "**Nothing in this view is maintained by delta.** The plan found no collection for a\n\
558             delta to flow through, so all {recomputed} of its operators are recomputed — each one\n\
559             only when an input actually moved, which is what a plan buys even here."
560        );
561    }
562    format!(
563        "Views are **maintained by delta** as far as the plan can decompose them: {maintained} of\n\
564         this view's {} operators update from the change itself, {recomputed} are recomputed when\n\
565         an input moves, and the page's children are still assembled in full every time\n\
566         (docs/24 §24.6).",
567        maintained + recomputed
568    )
569}
570
571/// What the compiled plan actually does — the half of the report that is about the engine rather
572/// than about the analysis.
573///
574/// A view this module calls `recompute` can still have most of its work maintained, because the
575/// decomposition goes *inside* the view: `match` on a field blocks the vertex, not the
576/// `filter_list` above it. Printing both is what stops the two answers being mistaken for one.
577fn plan_section(plan: &Plan) -> String {
578    use std::fmt::Write;
579    let mut out = String::new();
580    let (maintained, recomputed) = plan.counts();
581    let _ = writeln!(out, "\nthe operators the view compiles to");
582
583    let mut kinds: BTreeMap<&str, (usize, usize)> = BTreeMap::new();
584    for node in &plan.nodes {
585        let e = kinds.entry(node.op.name()).or_default();
586        e.0 += 1;
587        if !node.per_session {
588            e.1 += 1;
589        }
590    }
591    for (name, (n, shared)) in &kinds {
592        let example = plan
593            .nodes
594            .iter()
595            .find(|x| x.op.name() == *name)
596            .map(|x| &x.op);
597        let kind = match example {
598            Some(op) if op.is_source() => "source",
599            Some(op) if op.maintained() => "maintained",
600            _ => "recomputed",
601        };
602        let _ = writeln!(
603            out,
604            "  {:<14} ×{:<4} {:<11} {}",
605            name,
606            n,
607            kind,
608            if *shared == 0 {
609                "per session".to_string()
610            } else if shared == n {
611                "shared".to_string()
612            } else {
613                format!("{shared} of {n} shared")
614            }
615        );
616    }
617
618    // The reasons, deduplicated: a plan with twenty pointwise operators has three reasons, and a
619    // list of twenty would bury them.
620    let mut reasons: Vec<&str> = plan
621        .nodes
622        .iter()
623        .filter_map(|n| n.because.as_deref())
624        .collect();
625    reasons.sort();
626    reasons.dedup();
627    if !reasons.is_empty() {
628        let _ = writeln!(out, "\n  what could not be pushed a delta through");
629        for r in reasons {
630            let _ = writeln!(out, "    {r}");
631        }
632    }
633    let _ = writeln!(
634        out,
635        "\n  {maintained} maintained, {recomputed} recomputed. A recomputed operator is\n  \
636         re-evaluated only when one of its inputs moved, which is what a plan buys even where a\n  \
637         delta rule does not exist."
638    );
639    out
640}
641
642/// A map from vertex label to verdict, for a test that wants the answer rather than the prose.
643pub fn verdicts(placed: &Placed) -> BTreeMap<Arc<str>, Verdict> {
644    assess(placed)
645        .into_iter()
646        .map(|a| (a.label, a.verdict))
647        .collect()
648}