beck_core/
split.rs

1//! Stage 8 — signal-graph slicing, and the boundaries it synthesises.
2//!
3//! [`docs/04-compiler-architecture.md`](../../../../../docs/04-compiler-architecture.md) §4.3:
4//! "**Slice the signal graph.** Every signal edge that crosses tiers becomes a subscription: the
5//! server side gets a diff operator (DOM patches for Mode-A components), the client side a
6//! resumable `(subscription, seq)` consumer; `send` becomes the upstream command channel into the
7//! ingress. There is no cache-invalidation wiring to synthesise — views are downstream of the log
8//! by construction."
9//!
10//! # What slicing means concretely
11//!
12//! The program declares a *graph*, not a pipeline:
13//!
14//! ```text
15//! proposals = merge_clients()                        ! ingress   @on(server)
16//! events    = decide(proposals, todos, validate)                 @on(server)
17//! todos     = durable(fold(apply_event, empty, events))          @on(data)
18//! remaining = signal_map(todos, count_remaining)                 (unplaced)
19//! page      = per_session(todos, view)                           @on(client)
20//! ```
21//!
22//! [`crate::signal`] builds that graph. This module slices it: for each role the runtime drives it
23//! produces a `Core` *function*, because the roadmap says Phase 1's views are "full recompute per
24//! event — semantically final, later made incremental".
25//!
26//! # What changed when the general slicer arrived
27//!
28//! Phase 1 and Phase 2 recognised **one topology** and refused every other by name — legitimate
29//! narrowness, named as debt by `docs/19-phase-1-report.md` §19.9 and again by
30//! `docs/20-phase-2-report.md` §20.5. Three things are different now, and each is a property of
31//! working from a graph rather than from a pattern:
32//!
33//! 1. **Any number of durable folds.** They are *fused* into one accumulator — a synthetic record
34//!    with a field per fold — because §3.7 fixes one totally-ordered log per application, and two
35//!    `durable` folds are two projections of one log rather than two logs. Under the old splitter
36//!    a second fold was not refused: it was accepted and sliced with both folds reading the *first*
37//!    accumulator, which is the one outcome the narrowness was supposed to prevent.
38//! 2. **Any depth and any sharing above the fold.** A signal read by two consumers is computed
39//!    once, as a `let` in the sliced function, instead of being inlined per use. That is what §5.3
40//!    means by sharing an arrangement, expressed at the only place a Phase-3 view engine could
41//!    read it: the plan.
42//! 3. **Every tier crossing is enumerated**, with the content-derived id a resumable subscription
43//!    is keyed by, instead of one hard-coded sentence about the single crossing the old shape had.
44//!
45//! The refusals that remain are refusals about *meaning* — a cycle with no fold in it, a stream
46//! where a value is required, two pages and no router — and each says which.
47
48use std::collections::{BTreeMap, BTreeSet};
49use std::sync::Arc;
50
51use beck_diag::{Diagnostic, Diagnostics, Span};
52
53use crate::check::Program;
54use crate::core::{Core, CoreKind, Prim, VarId};
55use crate::signal::{signal_elem, Cut, Graph, Op, SigId, FUSED_STATE};
56use crate::ty::{Tier, Ty};
57
58/// A program with its signal graph sliced into the roles the runtime drives.
59#[derive(Clone, Debug)]
60pub struct Placed {
61    pub program: Program,
62    pub roles: Roles,
63    /// A content-derived id for the command channel, per §4.3: "a stable, content-derived
64    /// operation id (`sha256(module, name, signature)[..16]`) — *not* a URL a human maintains, and
65    /// stable across refactors that don't change the signature."
66    pub wire_id: String,
67    /// How stage 7 placed the program, kept so that `beck explain place` prints the derivation
68    /// rather than re-deriving it from a second, drifting copy.
69    pub placement: crate::place::Solution,
70    /// The signal graph itself, kept so that `beck explain flow`, the incremental analysis and any
71    /// later view engine read what the slicer read rather than re-deriving it.
72    pub graph: Graph,
73    /// Where this program's component renders, and what follows ([`crate::render`]). Decided here
74    /// because the fact it turns on — whether the sliced view reads the session — is this module's
75    /// to know.
76    pub render: crate::render::Decision,
77    /// Whether this is an application or a **library** — a module with no merge point, whose
78    /// [`Placed::roles`] are placeholders rather than a slice of anything.
79    ///
80    /// A library used to have no `Placed` at all, which meant it had no way to run its own tests
81    /// (`docs/22` §22.6, `docs/25` §25.6 item 1): every SICP exercise is a library, and so is
82    /// every domain module a real project would most want unit tests for. It has one now — but a
83    /// placeholder role is a lie a caller must not be able to tell by accident, so the flag is on
84    /// the struct and [`Placed::is_application`] is what the paths that drive a *program* ask.
85    pub kind: Kind,
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum Kind {
90    /// A merge point, a durable fold and a page: something [`crate::backend`] can drive.
91    Application,
92    /// Definitions and types, and no application parts. `beck check` has always said "a library";
93    /// this is that answer as a value rather than as a sentence.
94    Library,
95}
96
97impl Placed {
98    pub fn is_application(&self) -> bool {
99        self.kind == Kind::Application
100    }
101
102    /// A module with no merge point, wrapped so that the parts of the toolchain that do not need
103    /// one can still reach it.
104    ///
105    /// The four roles are placeholders and they are chosen to be *inert* rather than plausible: the
106    /// fold returns its accumulator unchanged, `validate` refuses everything, the view renders
107    /// nothing and the initial state is unit. Nothing should ever call them — [`Kind::Library`] is
108    /// the flag that says so, and `beck test` refuses a `given`, a `when` or a page expectation
109    /// against a library by name rather than running one of these and reporting a confusing pass.
110    pub fn library(program: Program, graph: Graph, wire_id: String) -> Placed {
111        let span = beck_diag::Span::NONE;
112        let unit = || Core::new(CoreKind::Const(crate::core::Const::Unit), Ty::unit(), span);
113        let lam = |n: usize, body: Core| {
114            Core::new(
115                CoreKind::Lam {
116                    params: (0..n as VarId).collect(),
117                    body: Arc::new(body),
118                },
119                Ty::fun((0..n).map(|_| Ty::unit()).collect(), Ty::unit()),
120                span,
121            )
122        };
123        let roles = Roles {
124            validate: lam(2, unit()),
125            fold: lam(2, Core::new(CoreKind::Var(0), Ty::unit(), span)),
126            init: unit(),
127            view: lam(6, unit()),
128            state_ty: Ty::unit(),
129            event_ty: Ty::unit(),
130            command_ty: Ty::unit(),
131            proposals_name: Arc::from(""),
132            events_name: Arc::from(""),
133            state_name: Arc::from(""),
134            page_name: Arc::from(""),
135            inlined: Vec::new(),
136            shared: Vec::new(),
137            states: Vec::new(),
138            view_is_per_session: false,
139            view_reads_presence: false,
140            awareness: None,
141            view_reads_freshness: false,
142            gestures: None,
143        };
144        let render = crate::render::Decision::of(&roles, &program.defs, false, None, span);
145        Placed {
146            program,
147            wire_id,
148            placement: crate::place::Solution {
149                tiers: Default::default(),
150                explanations: Vec::new(),
151                method: crate::place::Method::Exhaustive,
152                total: 0,
153                churn: Vec::new(),
154                ties: Vec::new(),
155            },
156            render,
157            roles,
158            graph,
159            kind: Kind::Library,
160        }
161    }
162}
163
164/// One durable accumulator the program declared.
165#[derive(Clone, Debug)]
166pub struct StateRole {
167    pub name: Arc<str>,
168    pub ty: Ty,
169    /// The field this fold occupies in the fused accumulator, when there is more than one fold.
170    /// `None` when the program has a single fold and its own type *is* the accumulator.
171    pub field: Option<Arc<str>>,
172    pub node: SigId,
173}
174
175/// The five things the runtime needs, each a `Core` value it can call.
176///
177/// This is deliberately still five: a runtime that drives one log, one accumulator and one page is
178/// what Phase 1 built and what Phase 3 has not replaced. What changed is that these are now
179/// *derived from the graph* — fusing several folds, inlining or sharing intermediate signals — so
180/// the shape of the program and the shape of the runtime are no longer required to be the same.
181#[derive(Clone, Debug)]
182pub struct Roles {
183    /// `(state, proposal) -> Result[list[Event], Rejection]` — the authority chokepoint.
184    pub validate: Core,
185    /// `(state, Envelope[Event]) -> state` — the replay-pure fold.
186    pub fold: Core,
187    /// The fold's initial accumulator.
188    pub init: Core,
189    /// `(state, session, presence, awareness, freshness) -> Html` — the client-placed view, with
190    /// intermediate signals inlined or shared.
191    ///
192    /// Five parameters whether or not the program reads the last three: a role the runtime calls
193    /// has one arity, and a view that ignores an argument is cheaper than two code paths that could
194    /// disagree about which one it has.
195    pub view: Core,
196    pub state_ty: Ty,
197    pub event_ty: Ty,
198    pub command_ty: Ty,
199    /// Names, for `beck explain` and for the report.
200    pub proposals_name: Arc<str>,
201    pub events_name: Arc<str>,
202    pub state_name: Arc<str>,
203    pub page_name: Arc<str>,
204    /// Signals that were inlined into the view rather than surviving as their own node.
205    pub inlined: Vec<Arc<str>>,
206    /// Signals read by more than one consumer, and therefore bound once in the sliced view rather
207    /// than recomputed per use. §5.3's shared prefix, at compile time.
208    pub shared: Vec<Arc<str>>,
209    /// The durable folds, in declaration order. One entry for the ordinary program; several when
210    /// the accumulator is fused.
211    pub states: Vec<StateRole>,
212    pub view_is_per_session: bool,
213    /// Whether the page reads `presence()`, and therefore has an input the log does not contain.
214    pub view_reads_presence: bool,
215    /// `awareness(f)`'s `f`, when the page reads a roster with a payload: `(Session) -> T`.
216    ///
217    /// A role rather than a signal, because the subscribers are the runtime's fact and not the
218    /// graph's — the runtime applies this to each connection's `Session` and hands the view the
219    /// roster it builds. `None` when the program reads no awareness, which is every program that
220    /// existed before it.
221    pub awareness: Option<Core>,
222    /// Whether the page reads `freshness()`, and therefore has an input only a Mode B client can
223    /// answer with anything but `Confirmed`.
224    pub view_reads_freshness: bool,
225    /// `gestures(step, init)`'s two halves, when the page reads a non-durable fold: D30's
226    /// client-local accumulator.
227    ///
228    /// A role for [`Roles::awareness`]'s reason and a different one: there is no signal to read it
229    /// from, because the stream it folds is one client's and the graph has nothing on that side of
230    /// the seam. The runtime holds the accumulator per connection, applies `step` to each gesture,
231    /// and hands the view what it has. `None` for every program that keeps no interface state,
232    /// which is every program written before D30.
233    pub gestures: Option<GestureRole>,
234}
235
236/// `G` from a `gestures` step function's type, `(S, G) -> S`.
237///
238/// The gesture union is what a handler in the page must construct to reach this fold, so the
239/// checker and the `ui:` lowering both need it by name and neither can read it off the accumulator.
240/// A step whose type is not a two-parameter function cannot reach here — the prelude's scheme is
241/// what admits the application — so the fallback is unreachable rather than a default.
242fn gesture_ty(step: &Core) -> Ty {
243    match &step.ty {
244        Ty::Fun(params, _, _) if params.len() == 2 => params[1].clone(),
245        _ => Ty::unit(),
246    }
247}
248
249/// The two halves of a `gestures(step, init)`, and the type they accumulate.
250#[derive(Clone, Debug)]
251pub struct GestureRole {
252    /// `(S, G) -> S`. The bare gesture and not an `Envelope[G]`: a gesture has no position in the
253    /// total order, so there is no envelope to give it (`prelude`'s note on the primitive).
254    pub step: Core,
255    /// The accumulator before any gesture — and what the *server* renders against, because a
256    /// server has seen none. `render` refuses Mode A to a page that reads this, so the constant is
257    /// unobservable for the same reason `freshness()`'s `Confirmed` is.
258    pub init: Core,
259    /// `S`, the accumulator's type.
260    pub ty: Ty,
261    /// `G`, the gesture union's type — what a handler must construct to reach this fold.
262    pub gesture_ty: Ty,
263}
264
265impl Roles {
266    /// Whether the accumulator is a synthetic record over several folds.
267    pub fn is_fused(&self) -> bool {
268        self.states.len() > 1
269    }
270}
271
272/// Slice a checked, placement-verified program.
273pub fn split(mut program: Program, diags: &mut Diagnostics) -> Option<Placed> {
274    let graph = Graph::build(&program, diags)?;
275
276    // ---- the three roles the graph has to contain, found by op rather than by position ----
277
278    let ingress = graph.ingress();
279    let Some(&proposals) = ingress.first() else {
280        diags.push(
281            Diagnostic::error("B0500", "this program has no merge point", Span::NONE)
282                .with_note(
283                    "a Beck application is a fold over an event stream, and the stream starts at \
284                     `merge_clients()` — the one place time enters",
285                )
286                .with_fix("add `@on(server)` and `proposals: Stream[Proposal] = merge_clients()`"),
287        );
288        return None;
289    };
290
291    let states = graph.states();
292    if states.is_empty() {
293        // A program whose only accumulator is a fold *nobody wrapped* is not a program that forgot
294        // the log — it is a program that reached for D1's non-durable fold. Saying "no durable
295        // state" to that author sends them to add `durable`, which is the opposite of what they
296        // asked for. D30 built the construct, so this now names it rather than apologising for it.
297        if let Some(fold) = graph
298            .find(|o| matches!(o, Op::Fold { .. }))
299            .into_iter()
300            .find(|&f| {
301                graph
302                    .consumers(f)
303                    .iter()
304                    .all(|&c| !matches!(graph.node(c).op, Op::Durable))
305            })
306        {
307            diags.push(
308                Diagnostic::error(
309                    "B0519",
310                    format!(
311                        "`{}` folds the log's own stream, so it has to be `durable`",
312                        graph.label(fold)
313                    ),
314                    graph.node(fold).span,
315                )
316                .with_primary_label(
317                    "a fold over the log's stream, with nowhere to keep what it folds",
318                )
319                .with_note(
320                    "this stream is the log's, so its accumulator *is* a function of the log \
321                     whatever it is called — every event on it was validated and recorded, and \
322                     replay would reproduce this state whether or not the program asked for it. \
323                     D30's rule is that ephemerality comes from the stream: a fold that should not \
324                     survive a restart folds gestures, which are never recorded, rather than \
325                     declining to persist events that were",
326                )
327                .with_fix(
328                    "`durable(fold(…))` if this is state the log should reproduce; \
329                     `gestures(step, init)` if it is interface state one client keeps to itself",
330                ),
331            );
332            return None;
333        }
334        diags.push(
335            Diagnostic::error("B0501", "this program has no durable state", Span::NONE)
336                .with_note("`durable(fold(f, init, s))` is what makes the log a database")
337                .with_fix("wrap the fold: `@on(data)` and `durable(fold(apply_event, …, events))`"),
338        );
339        return None;
340    }
341
342    // Every `durable` must wrap a fold: only a fold has an accumulator to persist.
343    let mut folds: Vec<(SigId, SigId)> = Vec::new(); // (durable node, fold node)
344    for &s in &states {
345        let inner = follow_alias(&graph, graph.node(s).inputs[0]);
346        if !matches!(graph.node(inner).op, Op::Fold { .. }) {
347            diags.push(
348                Diagnostic::error("B0502", "`durable` must wrap a `fold`", graph.node(s).span)
349                    .with_primary_label("only a fold has an accumulator to persist")
350                    .with_label(
351                        graph.node(inner).span,
352                        format!("this is a `{}`", graph.node(inner).op.name()),
353                    ),
354            );
355            return None;
356        }
357        folds.push((s, inner));
358    }
359
360    // §3.5: "authority is one chokepoint". The graph can hold any number of `decide` nodes; a
361    // program may not, and the diagnostic says which sentence in the design that is.
362    let decides = graph.decides();
363    let Some(&decide) = decides.first() else {
364        diags.push(
365            Diagnostic::error(
366                "B0504",
367                "events must come from `decide`",
368                graph.node(folds[0].1).span,
369            )
370            .with_primary_label("this fold has no chokepoint upstream of it")
371            .with_note(
372                "`decide` is the sole consumer of ingress and the one place a command becomes \
373                     an event — §3.5's \"authority is one chokepoint\"",
374            ),
375        );
376        return None;
377    };
378    if decides.len() > 1 {
379        diags.push(
380            Diagnostic::error(
381                "B0511",
382                "a program has one authority chokepoint",
383                graph.node(decides[1]).span,
384            )
385            .with_primary_label("a second `decide`")
386            .with_label(graph.node(decide).span, "the first one is here")
387            .with_note(
388                "§3.5 rests on validation being one place: two of them are two answers to \"may \
389                 this actor do this\", and the log would record whichever ran",
390            ),
391        );
392        return None;
393    }
394
395    // §3.7's replay rule, at the granularity of the graph: what the log records must be a function
396    // of the log. `presence()` is the one signal that is not, so the chokepoint may not read it —
397    // directly or through any number of maps.
398    if let Some(&here) = graph
399        .presences()
400        .iter()
401        .find(|&&p| reaches(&graph, decide, p))
402    {
403        diags.push(
404            Diagnostic::error(
405                "B0515",
406                "the chokepoint reads `presence`, which is not in the log",
407                graph.node(decide).span,
408            )
409            .with_primary_label(format!(
410                "`{}` decides from `{}`",
411                graph.label(decide),
412                graph.label(here)
413            ))
414            .with_label(graph.node(here).span, "who is connected is decided here")
415            .with_note(
416                "an event is what a replay reproduces, and who was connected when it was recorded \
417                 is not written down anywhere. A `validate` that read the roster would decide one \
418                 thing today and another on replay, and the log would no longer be the whole \
419                 history",
420            )
421            .with_fix(
422                "record the fact instead: propose a command when a client arrives, and decide from \
423                 the state that fold produces",
424            ),
425        );
426        return None;
427    }
428
429    // The same rule, for the roster with a payload. `awareness()` is `presence()` carrying what
430    // each connection contributes, so it is not in the log for the same reason and by more of it:
431    // what a client was looking at when an event was recorded is written down nowhere at all.
432    if let Some(&aware) = graph
433        .awarenesses()
434        .iter()
435        .find(|&&a| reaches(&graph, decide, a))
436    {
437        diags.push(
438            Diagnostic::error(
439                "B0520",
440                "the chokepoint reads `awareness`, which is not in the log",
441                graph.node(decide).span,
442            )
443            .with_primary_label(format!(
444                "`{}` decides from `{}`",
445                graph.label(decide),
446                graph.label(aware)
447            ))
448            .with_label(
449                graph.node(aware).span,
450                "what everybody is doing is decided here",
451            )
452            .with_note(
453                "an event is what a replay reproduces, and what each connection was contributing \
454                 when it was recorded is not written down anywhere. A `validate` that read the \
455                 roster would decide one thing today and another on replay, and the log would no \
456                 longer be the whole history",
457            )
458            .with_fix(
459                "record the fact instead: propose a command when the thing you are deciding from \
460                 happens, and decide from the state that fold produces",
461            ),
462        );
463        return None;
464    }
465
466    // The same rule, for D30's non-durable fold — and here it is not a near miss but the whole
467    // point of the construct. A gesture is never proposed, so no `validate` ever saw one; it is
468    // never recorded, so no replay can reach one. A chokepoint deciding from interface state would
469    // make an event's existence depend on whether somebody had a panel open, which is exactly the
470    // dependency D30 exists to make impossible.
471    if let Some(&g) = graph
472        .gestures()
473        .iter()
474        .find(|&&g| reaches(&graph, decide, g))
475    {
476        diags.push(
477            Diagnostic::error(
478                "B0523",
479                "the chokepoint reads a `gestures` fold, which is not in the log",
480                graph.node(decide).span,
481            )
482            .with_primary_label(format!(
483                "`{}` decides from `{}`",
484                graph.label(decide),
485                graph.label(g)
486            ))
487            .with_label(
488                graph.node(g).span,
489                "interface state one client keeps to itself",
490            )
491            .with_note(
492                "a gesture is not proposed, not validated and not recorded — it never leaves the \
493                 client that made it. An event whose existence depended on one could not be \
494                 replayed, because there is nothing to replay: the log holds no trace that the \
495                 gesture happened",
496            )
497            .with_fix(
498                "if this interface state should decide an event, it is not interface state — propose \
499                 a command when it changes and decide from the fold over the events that produces \
500                 (`docs/10` D30's fifth home)",
501            ),
502        );
503        return None;
504    }
505
506    // The same rule, for the other source that is not the log. `freshness()` is a client's account
507    // of what it has not heard back about yet, so a `validate` deciding from it would decide from
508    // the network — and on replay there is no network and nothing is pending.
509    if let Some(&how) = graph
510        .freshnesses()
511        .iter()
512        .find(|&&f| reaches(&graph, decide, f))
513    {
514        diags.push(
515            Diagnostic::error(
516                "B0517",
517                "the chokepoint reads `freshness`, which is not in the log",
518                graph.node(decide).span,
519            )
520            .with_primary_label(format!(
521                "`{}` decides from `{}`",
522                graph.label(decide),
523                graph.label(how)
524            ))
525            .with_label(
526                graph.node(how).span,
527                "whether a guess is outstanding is decided here",
528            )
529            .with_note(
530                "how many of a client's commands were in flight when an event was recorded is \
531                 written down nowhere, and on replay nothing is in flight at all. A `validate` \
532                 that read it would accept a command today and refuse it on the way back",
533            )
534            .with_fix(
535                "decide from the accumulator: what has actually been recorded is the fold's job to \
536                 say, and it is the same answer now and on replay",
537            ),
538        );
539        return None;
540    }
541
542    // Each fold's stream, after any `filter_map`, must be the chokepoint's output. Anything else
543    // is an event stream the log does not contain.
544    let mut fold_filters: Vec<Option<Core>> = Vec::new();
545    for &(_, f) in &folds {
546        let mut node = follow_alias(&graph, graph.node(f).inputs[0]);
547        let mut filter = None;
548        if let Op::FilterMap { f: pred } = &graph.node(node).op {
549            filter = Some(pred.clone());
550            node = follow_alias(&graph, graph.node(node).inputs[0]);
551        }
552        if node != decide {
553            diags.push(
554                Diagnostic::error(
555                    "B0504",
556                    "events must come from `decide`",
557                    graph.node(f).span,
558                )
559                .with_primary_label(format!(
560                    "this fold reads `{}`",
561                    graph.label(graph.node(f).inputs[0])
562                ))
563                .with_note(
564                    "the log holds what the chokepoint decided, so a fold reads `decide` — \
565                         optionally through one `filter_map`, which is how two folds take \
566                         different slices of one stream",
567                ),
568            );
569            return None;
570        }
571        fold_filters.push(filter);
572    }
573
574    // ---- the page: a sink, placed on the client, carrying Html ----
575
576    let pages: Vec<SigId> = graph
577        .sinks
578        .iter()
579        .copied()
580        .filter(|&s| graph.node(s).tier == Tier::Client && is_html(&graph.node(s).ty))
581        .collect();
582    let Some(&page) = pages.first() else {
583        diags.push(
584            Diagnostic::error("B0505", "no signal is placed on the client", Span::NONE)
585                .with_note(
586                    "`page` is the tier crossing: a `Signal[Html]` the browser subscribes to",
587                )
588                .with_fix("add `@on(client)` and `page: Signal[Html] = per_session(todos, view)`"),
589        );
590        return None;
591    };
592    if pages.len() > 1 {
593        diags.push(
594            Diagnostic::error(
595                "B0510",
596                "two signals are the page, and there is no router yet",
597                graph.node(pages[1]).span,
598            )
599            .with_primary_label(format!("`{}`", graph.label(pages[1])))
600            .with_label(graph.node(page).span, format!("`{}`", graph.label(page)))
601            .with_note(
602                "the slicer will slice both; the runtime serves one document per connection, and \
603                 choosing between them is routing — a Phase 3 client bullet that is not built",
604            )
605            .with_fix("combine them in one view, or read one from the other"),
606        );
607        return None;
608    }
609
610    // ---- slicing ----
611
612    let fused = states.len() > 1;
613    let mut vars = Vars(max_var(&program));
614    let state_var = vars.fresh();
615    let session_var = vars.fresh();
616    let presence_var = vars.fresh();
617    let awareness_var = vars.fresh();
618    let freshness_var = vars.fresh();
619    let gestures_var = vars.fresh();
620
621    let state_roles: Vec<StateRole> = folds
622        .iter()
623        .map(|&(d, _)| {
624            let n = graph.node(d);
625            StateRole {
626                name: n.label.clone(),
627                ty: signal_elem(&n.ty),
628                field: fused.then(|| n.label.clone()),
629                node: d,
630            }
631        })
632        .collect();
633
634    let mut slicer = Slicer {
635        graph: &graph,
636        states: &state_roles,
637        state_var,
638        session_var,
639        presence_var,
640        awareness_var,
641        freshness_var,
642        gestures_var,
643        bound: BTreeMap::new(),
644        lets: Vec::new(),
645        inlined: Vec::new(),
646        shared: Vec::new(),
647        per_session: false,
648        reads_presence: false,
649        awareness: None,
650        reads_freshness: false,
651        gestures: None,
652        vars: &mut vars,
653        diags,
654    };
655    let view_body = slicer.lower_sink(page)?;
656    let view_body = slicer.wrap(view_body);
657    let inlined = slicer.inlined.clone();
658    let shared = slicer.shared.clone();
659    let per_session = slicer.per_session;
660    let reads_presence = slicer.reads_presence;
661    let awareness = slicer.awareness.clone();
662    let reads_freshness = slicer.reads_freshness;
663    let gestures = slicer.gestures.clone();
664
665    let state_ty = if fused {
666        Ty::con(FUSED_STATE)
667    } else {
668        state_roles[0].ty.clone()
669    };
670
671    let view = Core {
672        kind: CoreKind::Lam {
673            params: vec![
674                state_var,
675                session_var,
676                presence_var,
677                awareness_var,
678                freshness_var,
679                gestures_var,
680            ]
681            .into(),
682            body: Arc::new(view_body),
683        },
684        ty: Ty::fun(
685            vec![
686                state_ty.clone(),
687                Ty::con("Session"),
688                Ty::map(Ty::str_(), Ty::int()),
689                // A view that reads no roster still takes the parameter, for the reason it takes
690                // the other two it may ignore: a role the runtime calls has one arity. The element
691                // type is the program's when it has one, and `()` when there is nothing to say.
692                awareness
693                    .as_ref()
694                    .map(|(_, ty)| ty.clone())
695                    .unwrap_or_else(|| Ty::map(Ty::str_(), Ty::unit())),
696                Ty::con("Freshness"),
697                // The client-local accumulator, `()` when the page keeps none — the same rule as
698                // the roster above, and for the same reason: one arity per role.
699                gestures
700                    .as_ref()
701                    .map(|g| g.ty.clone())
702                    .unwrap_or_else(Ty::unit),
703            ],
704            Ty::html(),
705        ),
706        tier: Tier::Client,
707        span: graph.node(page).span,
708        last_use: false,
709        order: crate::fields::UNORDERED,
710        locals: 0,
711    };
712
713    // ---- the accumulator, fused when the program declared more than one fold ----
714
715    let (fold, init) = if fused {
716        program.types.insert(
717            Arc::from(FUSED_STATE),
718            crate::signal::fused_state_decl(
719                &state_roles
720                    .iter()
721                    .map(|s| (s.name.clone(), s.ty.clone()))
722                    .collect::<Vec<_>>(),
723            ),
724        );
725        fuse(
726            &graph,
727            &folds,
728            &fold_filters,
729            &state_roles,
730            &state_ty,
731            &mut vars,
732            graph.node(page).span,
733        )
734    } else {
735        let Op::Fold { step, init } = &graph.node(folds[0].1).op else {
736            return None;
737        };
738        match &fold_filters[0] {
739            None => (step.clone(), init.clone()),
740            Some(pred) => (
741                filtered_step(
742                    step,
743                    pred,
744                    &state_ty,
745                    &mut vars,
746                    graph.node(folds[0].1).span,
747                ),
748                init.clone(),
749            ),
750        }
751    };
752
753    // ---- `validate`, which reads whichever accumulator the chokepoint was given ----
754
755    let Op::Decide { validate } = &graph.node(decide).op else {
756        return None;
757    };
758    let validate = if fused {
759        let src = follow_alias(&graph, graph.node(decide).inputs[1]);
760        let Some(role) = state_roles.iter().find(|s| s.node == src) else {
761            diags.push(
762                Diagnostic::error(
763                    "B0512",
764                    "the chokepoint does not read a durable fold",
765                    graph.node(decide).span,
766                )
767                .with_primary_label(format!("it reads `{}`", graph.label(src)))
768                .with_note(
769                    "`decide` threads the accumulator through validation, so what it reads has to \
770                     be one — that is what makes first-writer-wins and ownership decidable (§3.7)",
771                ),
772            );
773            return None;
774        };
775        let p = vars.fresh();
776        let s = vars.fresh();
777        let span = graph.node(decide).span;
778        Core {
779            kind: CoreKind::Lam {
780                params: vec![s, p].into(),
781                body: Arc::new(Core {
782                    kind: CoreKind::App {
783                        func: Box::new(validate.clone()),
784                        args: vec![
785                            field(var(s, state_ty.clone(), span), role, span),
786                            var(p, Ty::con("Proposal"), span),
787                        ],
788                    },
789                    ty: Ty::unit(),
790                    tier: Tier::Server,
791                    span,
792                    last_use: false,
793                    order: crate::fields::UNORDERED,
794                    locals: 0,
795                }),
796            },
797            ty: Ty::unit(),
798            tier: Tier::Server,
799            span,
800            last_use: false,
801            order: crate::fields::UNORDERED,
802            locals: 0,
803        }
804    } else {
805        validate.clone()
806    };
807
808    let event_ty = signal_elem(&graph.node(decide).ty);
809    let command_ty = program
810        .types
811        .get("Command")
812        .map(|_| Ty::con("Command"))
813        .unwrap_or_else(Ty::unit);
814
815    // D30: a handler in the page carries a *constructor*, and the client routes on its variant
816    // name — a name in the gesture union folds locally, a name in the command union goes up the
817    // socket. A name in both would be a page whose buttons do one thing or the other depending on
818    // which decoder ran first, so the two unions must not share one. This is the only new rule the
819    // construct needs that placement did not already give it.
820    if let Some(g) = graph.gestures().first() {
821        let gesture_ty = match &graph.node(*g).op {
822            Op::Gestures { step, .. } => gesture_ty(step),
823            _ => unreachable!("found by op"),
824        };
825        let names = |ty: &Ty| -> Vec<Arc<str>> {
826            match ty.con_name().and_then(|n| program.types.get(n)) {
827                Some(crate::ty::TyDecl::Union { variants, .. }) => {
828                    variants.iter().map(|v| v.name.clone()).collect()
829                }
830                _ => Vec::new(),
831            }
832        };
833        let commands = names(&command_ty);
834        if let Some(clash) = names(&gesture_ty)
835            .into_iter()
836            .find(|n| commands.contains(n))
837        {
838            diags.push(
839                Diagnostic::error(
840                    "B0524",
841                    format!("`{clash}` is both a command and a gesture"),
842                    graph.node(*g).span,
843                )
844                .with_primary_label(format!(
845                    "`{}` and `{}` share this variant",
846                    gesture_ty.con_name().unwrap_or("the gesture union"),
847                    command_ty.con_name().unwrap_or("the command union"),
848                ))
849                .with_note(
850                    "a handler in the page carries the constructor it builds, and the client routes \
851                     on its name: a gesture is folded where it was made and a command is proposed to \
852                     the server. A name that is both would make `on_click` mean whichever the client \
853                     tried first",
854                )
855                .with_fix("rename one of them — they are different things happening"),
856            );
857            return None;
858        }
859    }
860
861    // §4.3: "a stable, content-derived operation id … *not* a URL a human maintains, and stable
862    // across refactors that don't change the signature".
863    //
864    // **Content**, not name. Hashing `"Event"` would produce an id that never moves — including
865    // when a variant is added, which is precisely the change that breaks every open tab. The three
866    // types are hashed *structurally*, through every field of every variant they reach.
867    let mut hasher = blake3::Hasher::new();
868    hasher.update(program.name.as_bytes());
869    for t in [&command_ty, &event_ty, &state_ty] {
870        hasher.update(crate::iface::structural(t, &program.types).as_bytes());
871        hasher.update(b"\x00");
872    }
873    let wire_id = hasher.finalize().to_hex()[..16].to_string();
874
875    let roles = Roles {
876        validate,
877        fold,
878        init,
879        view,
880        state_ty,
881        event_ty,
882        command_ty,
883        proposals_name: graph.node(proposals).label.clone(),
884        events_name: graph.node(decide).label.clone(),
885        state_name: state_roles[0].name.clone(),
886        page_name: graph.node(page).label.clone(),
887        inlined,
888        shared,
889        states: state_roles,
890        view_is_per_session: per_session,
891        view_reads_presence: reads_presence,
892        awareness: awareness.map(|(f, _)| f),
893        view_reads_freshness: reads_freshness,
894        gestures,
895    };
896
897    // Where the page renders, and whether it may. `@render(client)` turns the crossing from a
898    // rendering of the state into the state itself, so this is the one decision in the splitter
899    // that can disclose something — and it is refused here rather than at build time, because a
900    // program that would leak should not compile.
901    let declared = program
902        .signals
903        .iter()
904        .find(|s| s.name == graph.node(page).label)
905        .and_then(|s| s.render);
906    let render =
907        crate::render::Decision::of(&roles, &program.defs, true, declared, graph.node(page).span);
908    render.refuse(diags);
909    if diags.has_errors() {
910        return None;
911    }
912
913    Some(Placed {
914        kind: Kind::Application,
915        placement: crate::place::Solution {
916            tiers: Default::default(),
917            explanations: Vec::new(),
918            method: crate::place::Method::Exhaustive,
919            total: 0,
920            churn: Vec::new(),
921            ties: Vec::new(),
922        },
923        render,
924        roles,
925        wire_id,
926        program,
927        graph,
928    })
929}
930
931fn is_html(t: &Ty) -> bool {
932    signal_elem(t).con_name() == Some(Ty::HTML)
933}
934
935/// Whether `from` reads `target`, at any depth.
936///
937/// The graph is legitimately cyclic (§3.7's `decide → durable → fold → decide`), so this is a
938/// visited-set walk rather than a recursion that would follow the cycle forever.
939fn reaches(graph: &Graph, from: SigId, target: SigId) -> bool {
940    let mut seen = BTreeSet::new();
941    let mut stack = vec![from];
942    while let Some(id) = stack.pop() {
943        if id == target {
944            return true;
945        }
946        if !seen.insert(id) {
947            continue;
948        }
949        stack.extend(graph.node(id).inputs.iter().copied());
950    }
951    false
952}
953
954/// Step past `mirror: Signal[T] = todos` declarations, which name a vertex without adding one.
955fn follow_alias(graph: &Graph, mut id: SigId) -> SigId {
956    let mut guard = 0;
957    while matches!(graph.node(id).op, Op::Alias) && guard < graph.nodes.len() {
958        id = graph.node(id).inputs[0];
959        guard += 1;
960    }
961    id
962}
963
964fn var(v: VarId, ty: Ty, span: Span) -> Core {
965    Core {
966        kind: CoreKind::Var(v),
967        ty,
968        tier: Tier::Any,
969        span,
970        last_use: false,
971        order: crate::fields::UNORDERED,
972        locals: 0,
973    }
974}
975
976/// Reach one fold's accumulator out of the state parameter.
977fn field(base: Core, role: &StateRole, span: Span) -> Core {
978    match &role.field {
979        None => base,
980        Some(f) => Core {
981            kind: CoreKind::Field {
982                base: Box::new(base),
983                name: f.clone(),
984            },
985            ty: role.ty.clone(),
986            tier: Tier::Any,
987            span,
988            last_use: false,
989            order: crate::fields::UNORDERED,
990            locals: 0,
991        },
992    }
993}
994
995/// `f(args…)`, at a given result type.
996fn call(func: Core, args: Vec<Core>, ty: Ty, span: Span) -> Core {
997    Core {
998        kind: CoreKind::App {
999            func: Box::new(func),
1000            args,
1001        },
1002        ty,
1003        tier: Tier::Any,
1004        span,
1005        last_use: false,
1006        order: crate::fields::UNORDERED,
1007        locals: 0,
1008    }
1009}
1010
1011/// One fold's contribution to a step: `step(state.field, env)`, guarded by the fold's
1012/// `filter_map` if it has one.
1013fn fold_field(
1014    step: &Core,
1015    filter: &Option<Core>,
1016    acc: Core,
1017    env: Core,
1018    ty: &Ty,
1019    vars: &mut Vars,
1020    span: Span,
1021) -> Core {
1022    let applied = call(
1023        step.clone(),
1024        vec![acc.clone(), env.clone()],
1025        ty.clone(),
1026        span,
1027    );
1028    let Some(pred) = filter else {
1029        return applied;
1030    };
1031    // `filter_map` between the chokepoint and a fold means this fold sees a *slice* of the log.
1032    // The runtime appends one stream and folds it once, so the filter moves into the step:
1033    //
1034    //     let o = pred(env.body) in
1035    //     if is_some(o) then step(acc, env.with(body = o.value)) else acc
1036    //
1037    // Written with the prims the language already has rather than a synthesised `match`, because
1038    // an `Arm` carries a pattern and this needs no pattern — only the two answers `Option` has.
1039    let o = vars.fresh();
1040    let opt_ty = Ty::option(Ty::unit());
1041    let body = Core {
1042        kind: CoreKind::Field {
1043            base: Box::new(env.clone()),
1044            name: Arc::from("body"),
1045        },
1046        ty: Ty::unit(),
1047        tier: Tier::Any,
1048        span,
1049        last_use: false,
1050        order: crate::fields::UNORDERED,
1051        locals: 0,
1052    };
1053    let inner = Core {
1054        kind: CoreKind::Field {
1055            base: Box::new(var(o, opt_ty.clone(), span)),
1056            name: Arc::from("value"),
1057        },
1058        ty: Ty::unit(),
1059        tier: Tier::Any,
1060        span,
1061        last_use: false,
1062        order: crate::fields::UNORDERED,
1063        locals: 0,
1064    };
1065    let narrowed = Core {
1066        kind: CoreKind::With {
1067            base: Box::new(env),
1068            fields: vec![(Arc::from("body"), inner)],
1069        },
1070        ty: Ty::unit(),
1071        tier: Tier::Any,
1072        span,
1073        last_use: false,
1074        order: crate::fields::UNORDERED,
1075        locals: 0,
1076    };
1077    Core {
1078        kind: CoreKind::Let {
1079            var: o,
1080            value: Box::new(call(pred.clone(), vec![body], opt_ty.clone(), span)),
1081            body: Box::new(Core {
1082                kind: CoreKind::If {
1083                    cond: Box::new(Core {
1084                        kind: CoreKind::Prim {
1085                            op: Prim::OptionIsSome,
1086                            args: vec![var(o, opt_ty, span)],
1087                        },
1088                        ty: Ty::bool_(),
1089                        tier: Tier::Any,
1090                        span,
1091                        last_use: false,
1092                        order: crate::fields::UNORDERED,
1093                        locals: 0,
1094                    }),
1095                    then: Box::new(call(
1096                        step.clone(),
1097                        vec![acc.clone(), narrowed],
1098                        ty.clone(),
1099                        span,
1100                    )),
1101                    alt: Box::new(acc),
1102                },
1103                ty: ty.clone(),
1104                tier: Tier::Any,
1105                span,
1106                last_use: false,
1107                order: crate::fields::UNORDERED,
1108                locals: 0,
1109            }),
1110        },
1111        ty: ty.clone(),
1112        tier: Tier::Any,
1113        span,
1114        last_use: false,
1115        order: crate::fields::UNORDERED,
1116        locals: 0,
1117    }
1118}
1119
1120/// The single-fold case of [`fold_field`]: a step wrapped in its own `filter_map`.
1121fn filtered_step(step: &Core, pred: &Core, state_ty: &Ty, vars: &mut Vars, span: Span) -> Core {
1122    let s = vars.fresh();
1123    let e = vars.fresh();
1124    let body = fold_field(
1125        step,
1126        &Some(pred.clone()),
1127        var(s, state_ty.clone(), span),
1128        var(e, Ty::unit(), span),
1129        state_ty,
1130        vars,
1131        span,
1132    );
1133    Core {
1134        kind: CoreKind::Lam {
1135            params: vec![s, e].into(),
1136            body: Arc::new(body),
1137        },
1138        ty: Ty::fun(vec![state_ty.clone(), Ty::unit()], state_ty.clone()),
1139        tier: Tier::Data,
1140        span,
1141        last_use: false,
1142        order: crate::fields::UNORDERED,
1143        locals: 0,
1144    }
1145}
1146
1147/// Fuse several durable folds into one accumulator.
1148///
1149/// §3.7 fixes one totally-ordered log per application. Two `durable` folds are therefore not two
1150/// logs; they are two projections of one, and the runtime holds a record with one field per fold.
1151/// The step applies every fold's own step to its own field, in declaration order, so replay is
1152/// exactly as deterministic as it was with one.
1153fn fuse(
1154    graph: &Graph,
1155    folds: &[(SigId, SigId)],
1156    filters: &[Option<Core>],
1157    roles: &[StateRole],
1158    state_ty: &Ty,
1159    vars: &mut Vars,
1160    span: Span,
1161) -> (Core, Core) {
1162    let s = vars.fresh();
1163    let e = vars.fresh();
1164
1165    let mut step_fields = Vec::new();
1166    let mut init_fields = Vec::new();
1167    for (i, &(_, f)) in folds.iter().enumerate() {
1168        let Op::Fold { step, init } = &graph.node(f).op else {
1169            continue;
1170        };
1171        let role = &roles[i];
1172        let acc = field(var(s, state_ty.clone(), span), role, span);
1173        step_fields.push((
1174            role.name.clone(),
1175            fold_field(
1176                step,
1177                &filters[i],
1178                acc,
1179                var(e, Ty::unit(), span),
1180                &role.ty,
1181                vars,
1182                span,
1183            ),
1184        ));
1185        init_fields.push((role.name.clone(), init.clone()));
1186    }
1187
1188    // The fused state is synthesised here, after `fields::order_program` has run over what the
1189    // user wrote, so it asks for its own layout rather than going without one.
1190    let make = |fields: Vec<(Arc<str>, Core)>| {
1191        let mut c = Core {
1192            kind: CoreKind::Make {
1193                ty: Arc::from(FUSED_STATE),
1194                variant: None,
1195                fields,
1196            },
1197            ty: state_ty.clone(),
1198            tier: Tier::Data,
1199            span,
1200            last_use: false,
1201            order: crate::fields::UNORDERED,
1202            locals: 0,
1203        };
1204        crate::fields::order_here(&mut c);
1205        c
1206    };
1207
1208    (
1209        Core {
1210            kind: CoreKind::Lam {
1211                params: vec![s, e].into(),
1212                body: Arc::new(make(step_fields)),
1213            },
1214            ty: Ty::fun(vec![state_ty.clone(), Ty::unit()], state_ty.clone()),
1215            tier: Tier::Data,
1216            span,
1217            last_use: false,
1218            order: crate::fields::UNORDERED,
1219            locals: 0,
1220        },
1221        make(init_fields),
1222    )
1223}
1224
1225/// A source of variables the program does not use.
1226///
1227/// The old splitter used variables 0 and 1 for the state and the session, which are also the first
1228/// two the checker hands out. It got away with it because the sliced body only ever *calls* the
1229/// program's functions rather than inlining their bodies — but "got away with it" is the whole
1230/// objection, and a slicer that now emits `let` bindings of its own has no reason to keep it.
1231struct Vars(VarId);
1232
1233impl Vars {
1234    fn fresh(&mut self) -> VarId {
1235        self.0 += 1;
1236        self.0
1237    }
1238}
1239
1240/// The largest variable the program uses, so the slicer's own bindings cannot shadow one.
1241fn max_var(program: &Program) -> VarId {
1242    fn go(c: &Core, max: &mut VarId) {
1243        match &c.kind {
1244            CoreKind::Const(_) | CoreKind::Global(_) => {}
1245            CoreKind::Var(v) => *max = (*max).max(*v),
1246            CoreKind::Lam { params, body } => {
1247                for p in params.iter() {
1248                    *max = (*max).max(*p);
1249                }
1250                go(body, max);
1251            }
1252            CoreKind::App { func, args } => {
1253                go(func, max);
1254                args.iter().for_each(|a| go(a, max));
1255            }
1256            CoreKind::Prim { args, .. } => args.iter().for_each(|a| go(a, max)),
1257            CoreKind::Let { var, value, body } => {
1258                *max = (*max).max(*var);
1259                go(value, max);
1260                go(body, max);
1261            }
1262            CoreKind::If { cond, then, alt } => {
1263                go(cond, max);
1264                go(then, max);
1265                go(alt, max);
1266            }
1267            CoreKind::Match { scrutinee, arms } => {
1268                go(scrutinee, max);
1269                for a in arms {
1270                    for v in a.pattern.binders() {
1271                        *max = (*max).max(v);
1272                    }
1273                    for e in a.exprs() {
1274                        go(e, max);
1275                    }
1276                }
1277            }
1278            CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, f)| go(f, max)),
1279            CoreKind::Field { base, .. } => go(base, max),
1280            CoreKind::With { base, fields } => {
1281                go(base, max);
1282                fields.iter().for_each(|(_, f)| go(f, max));
1283            }
1284            CoreKind::ListLit(items) => items.iter().for_each(|i| go(i, max)),
1285            CoreKind::MapLit(pairs) => pairs.iter().for_each(|(k, v)| {
1286                go(k, max);
1287                go(v, max);
1288            }),
1289        }
1290    }
1291    let mut max = 0;
1292    for d in program.defs.values() {
1293        go(&d.body, &mut max);
1294    }
1295    for s in &program.signals {
1296        go(&s.expr, &mut max);
1297    }
1298    for t in &program.tests {
1299        max = max
1300            .max(t.bindings.state)
1301            .max(t.bindings.events)
1302            .max(t.bindings.result);
1303    }
1304    max
1305}
1306
1307/// Rewrites a signal expression into a function of the durable state (and the session).
1308///
1309/// This is the slicing itself. `signal_map(s, f)` becomes `f(lower(s))`, `map2(f, a, b)` becomes
1310/// `f(lower(a), lower(b))`, `per_session(s, f)` becomes `f(lower(s), session)`, and a reference to
1311/// a durable signal becomes the state parameter — or, when several folds were fused, the field of
1312/// it that fold occupies.
1313///
1314/// What is not a rewrite is the sharing: a vertex read by more than one consumer is bound once, in
1315/// a `let`, and referred to by name. Under the old splitter it was inlined per use, so a program
1316/// whose two views both read `summary` recomputed it twice per event and nothing recorded that
1317/// they were the same computation. §5.3's arrangement sharing needs the opposite, and the plan is
1318/// the only place a later view engine could learn it.
1319struct Slicer<'a, 'd> {
1320    graph: &'a Graph,
1321    states: &'a [StateRole],
1322    state_var: VarId,
1323    session_var: VarId,
1324    presence_var: VarId,
1325    awareness_var: VarId,
1326    freshness_var: VarId,
1327    gestures_var: VarId,
1328    vars: &'d mut Vars,
1329    /// Vertices already bound in this slice.
1330    bound: BTreeMap<SigId, VarId>,
1331    /// The bindings, dependencies first.
1332    lets: Vec<(VarId, Core)>,
1333    inlined: Vec<Arc<str>>,
1334    shared: Vec<Arc<str>>,
1335    per_session: bool,
1336    reads_presence: bool,
1337    /// The awareness function this view reached, and the type of the roster it produces.
1338    awareness: Option<(Core, Ty)>,
1339    reads_freshness: bool,
1340    gestures: Option<GestureRole>,
1341    diags: &'d mut Diagnostics,
1342}
1343
1344impl Slicer<'_, '_> {
1345    /// Slice a sink. The sink itself is not "inlined into the view": it *is* the view, and
1346    /// listing it as one of the signals that disappeared into it would be a report about nothing.
1347    fn lower_sink(&mut self, id: SigId) -> Option<Core> {
1348        let body = self.lower(id)?;
1349        if let Some(name) = &self.graph.node(follow_alias(self.graph, id)).name {
1350            self.inlined.retain(|n| n != name);
1351        }
1352        Some(body)
1353    }
1354
1355    fn lower(&mut self, id: SigId) -> Option<Core> {
1356        let id = follow_alias(self.graph, id);
1357        let node = self.graph.node(id);
1358
1359        // A durable accumulator is where slicing stops: it is a parameter, not a computation. This
1360        // is also why a cycle through a fold terminates and one without a fold cannot.
1361        if let Some(role) = self.states.iter().find(|s| s.node == id) {
1362            return Some(field(
1363                var(self.state_var, Ty::unit(), node.span),
1364                role,
1365                node.span,
1366            ));
1367        }
1368        // Presence is the other input a view has that is not a computation: a parameter, like the
1369        // accumulator, and unlike the accumulator not a function of the log.
1370        if matches!(node.op, Op::Presence) {
1371            self.reads_presence = true;
1372            return Some(var(self.presence_var, signal_elem(&node.ty), node.span));
1373        }
1374        // Awareness is presence with a payload and arrives the same way: the runtime holds every
1375        // subscriber's `Session`, applies `f` to each and hands the roster in. The function is
1376        // captured here because the runtime is what calls it — there is no signal to read it from.
1377        if let Op::Awareness { f } = &node.op {
1378            let elem = signal_elem(&node.ty);
1379            self.awareness = Some((f.clone(), elem.clone()));
1380            return Some(var(self.awareness_var, elem, node.span));
1381        }
1382        // And so is freshness, for the same reason and from the other side: what the renderer
1383        // knows about its own guesses is handed to the view, not computed by it.
1384        if matches!(node.op, Op::Freshness) {
1385            self.reads_freshness = true;
1386            return Some(var(self.freshness_var, signal_elem(&node.ty), node.span));
1387        }
1388        // And so is the non-durable fold, which is the fourth input a view has that the log does
1389        // not contain. The accumulator is the *client's*: the runtime holds one per connection,
1390        // applies `step` to each gesture and hands the result in, exactly as it hands in the
1391        // roster. Carrying `step` and `init` here rather than lowering them into the view is what
1392        // makes that possible — a view is a function of the accumulator, not of the stream.
1393        if let Op::Gestures { step, init } = &node.op {
1394            let ty = signal_elem(&node.ty);
1395            self.gestures = Some(GestureRole {
1396                step: step.clone(),
1397                init: init.clone(),
1398                ty: ty.clone(),
1399                gesture_ty: gesture_ty(step),
1400            });
1401            return Some(var(self.gestures_var, ty, node.span));
1402        }
1403        if let Some(&v) = self.bound.get(&id) {
1404            return Some(var(v, signal_elem(&node.ty), node.span));
1405        }
1406
1407        let span = node.span;
1408        let ty = signal_elem(&node.ty);
1409        let body = match &node.op {
1410            Op::Map { f } => {
1411                let input = self.lower(node.inputs[0])?;
1412                call(f.clone(), vec![input], ty.clone(), span)
1413            }
1414            Op::Map2 { f } => {
1415                let a = self.lower(node.inputs[0])?;
1416                let b = self.lower(node.inputs[1])?;
1417                call(f.clone(), vec![a, b], ty.clone(), span)
1418            }
1419            Op::PerSession { f } => {
1420                self.per_session = true;
1421                let input = self.lower(node.inputs[0])?;
1422                let session = var(self.session_var, Ty::con("Session"), span);
1423                call(f.clone(), vec![input, session], ty.clone(), span)
1424            }
1425            Op::Fold { .. } => {
1426                // A fold the program did not mark `durable`. The value exists in the semantics —
1427                // a transient accumulator — and there is nowhere to keep it: the runtime persists
1428                // the log and snapshots what `durable` names, and nothing else.
1429                self.diags.push(
1430                    Diagnostic::error(
1431                        "B0513",
1432                        format!("`{}` is a fold that is not durable", self.graph.label(id)),
1433                        span,
1434                    )
1435                    .with_primary_label("its accumulator has nowhere to live across a restart")
1436                    .with_note(
1437                        "the log is what survives, and `durable` is what says an accumulator is \
1438                         folded from it — a fold outside one would be rebuilt from nothing on \
1439                         every deploy",
1440                    )
1441                    .with_fix(
1442                        "wrap it — `durable(fold(…))` — or, if this is interface state that should \
1443                         not survive a restart, fold gestures instead: `gestures(step, init)` \
1444                         (`docs/10` D30)",
1445                    ),
1446                );
1447                return None;
1448            }
1449            Op::Ingress | Op::Decide { .. } | Op::FilterMap { .. } => {
1450                self.diags.push(
1451                    Diagnostic::error(
1452                        "B0507",
1453                        format!(
1454                            "a view cannot read `{}`, which is a stream",
1455                            self.graph.label(id)
1456                        ),
1457                        span,
1458                    )
1459                    .with_primary_label(format!("`{}` produces occurrences", node.op.name()))
1460                    .with_note(
1461                        "§3.7: a `Stream` is discrete occurrences and a `Signal` is a value \
1462                         defined at all times. A view renders a value, so it reads what a stream \
1463                         was folded into",
1464                    ),
1465                );
1466                return None;
1467            }
1468            Op::Durable
1469            | Op::Alias
1470            | Op::Presence
1471            | Op::Awareness { .. }
1472            | Op::Freshness
1473            | Op::Gestures { .. } => {
1474                unreachable!("handled above")
1475            }
1476        };
1477
1478        if let Some(name) = &node.name {
1479            if !self.inlined.contains(name) {
1480                self.inlined.push(name.clone());
1481            }
1482        }
1483
1484        // Shared: read by more than one consumer, so computing it once is the whole difference
1485        // between a plan and an expansion.
1486        if self.graph.consumers(id).len() > 1 {
1487            let v = self.vars.fresh();
1488            self.bound.insert(id, v);
1489            self.lets.push((v, body));
1490            if let Some(name) = &node.name {
1491                self.shared.push(name.clone());
1492            }
1493            return Some(var(v, ty, span));
1494        }
1495        Some(body)
1496    }
1497
1498    /// Wrap the sliced expression in the bindings it accumulated, dependencies outermost.
1499    fn wrap(&self, body: Core) -> Core {
1500        self.lets.iter().rev().fold(body, |acc, (v, value)| Core {
1501            kind: CoreKind::Let {
1502                var: *v,
1503                value: Box::new(value.clone()),
1504                body: Box::new(acc.clone()),
1505            },
1506            ty: acc.ty.clone(),
1507            tier: Tier::Client,
1508            span: acc.span,
1509            last_use: false,
1510            order: crate::fields::UNORDERED,
1511            locals: 0,
1512        })
1513    }
1514}
1515
1516/// What `beck explain wire` prints: the command channel's content-derived operation id (§4.3).
1517///
1518/// A `String` for the reason [`crate::place::report`] is one — the playground asks the same
1519/// question the command line does, and out of the same compiler.
1520pub fn wire_report(placed: &Placed) -> String {
1521    use std::fmt::Write;
1522    let mut out = String::new();
1523    let _ = writeln!(out, "operation id  {}", placed.wire_id);
1524    let _ = writeln!(out, "command       {}", placed.roles.command_ty);
1525    let _ = writeln!(out, "event         {}", placed.roles.event_ty);
1526    let _ = writeln!(out, "state         {}", placed.roles.state_ty);
1527    let _ = writeln!(
1528        out,
1529        "\nthe id is content-derived from the module and those three types, so a body \
1530         edit does not move it and a signature change does."
1531    );
1532    out
1533}
1534
1535/// What `beck explain flow` prints: the graph as a graph, rather than the four names the one
1536/// recognised topology had.
1537///
1538/// §4.7 asks `beck explain` to answer "why is this here" from the compiler's own data. The old
1539/// version printed a fixed four-line summary and one hard-coded sentence claiming there was
1540/// exactly one tier crossing — true of the todo sketch and of nothing the general slicer now
1541/// accepts. This prints what the slicer read.
1542pub fn flow_report(placed: &Placed) -> String {
1543    use std::fmt::Write;
1544    let g = &placed.graph;
1545    let r = &placed.roles;
1546    let mut out = String::new();
1547    let cycles = g.dep.cycles().count();
1548    let _ = writeln!(
1549        out,
1550        "signal graph — {} vertices, {} {}, {} tier {}\n",
1551        g.nodes.len(),
1552        cycles,
1553        if cycles == 1 { "cycle" } else { "cycles" },
1554        g.cuts.len(),
1555        if g.cuts.len() == 1 {
1556            "crossing"
1557        } else {
1558            "crossings"
1559        },
1560    );
1561
1562    let in_cycle: BTreeSet<SigId> = g
1563        .dep
1564        .cycles()
1565        .flat_map(|c| c.iter().map(|n| n.0 as usize))
1566        .collect();
1567    let page = g.by_name.get(&r.page_name).copied();
1568    let rows: Vec<(SigId, String, String)> = g
1569        .order()
1570        .into_iter()
1571        .map(|id| {
1572            let n = g.node(id);
1573            let inputs: Vec<&str> = n.inputs.iter().map(|&i| g.label(i)).collect();
1574            (
1575                id,
1576                n.label.to_string(),
1577                format!("{}({})", n.op.name(), inputs.join(", ")),
1578            )
1579        })
1580        .collect();
1581    let lw = rows.iter().map(|r| r.1.chars().count()).max().unwrap_or(0);
1582    let ew = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(0);
1583    for (id, label, expr) in &rows {
1584        let mut note = String::new();
1585        if in_cycle.contains(id) {
1586            note.push_str("  ↺");
1587        }
1588        if Some(*id) == page {
1589            note.push_str(if r.view_is_per_session {
1590                "  ← the page, per session"
1591            } else {
1592                "  ← the page, broadcast"
1593            });
1594        } else if g.sinks.contains(id) {
1595            note.push_str("  ← a sink nothing reads");
1596        }
1597        let _ = writeln!(
1598            out,
1599            "  {label:<lw$}  {expr:<ew$}  {:<7}{note}",
1600            g.node(*id).tier.name(),
1601        );
1602    }
1603
1604    let _ = writeln!(out, "\naccumulator");
1605    if r.is_fused() {
1606        let _ = writeln!(
1607            out,
1608            "  {} durable folds, fused into one record — §3.7 fixes one totally-ordered log per\n  \
1609             application, so two folds are two projections of it rather than two logs.",
1610            r.states.len()
1611        );
1612        for s in &r.states {
1613            let _ = writeln!(out, "    {FUSED_STATE}.{} : {}", s.name, s.ty);
1614        }
1615    } else {
1616        let _ = writeln!(
1617            out,
1618            "  one durable fold — `{}` : {}",
1619            r.states[0].name, r.states[0].ty
1620        );
1621    }
1622
1623    let plan = slice_of(g, page.unwrap_or(0));
1624    let computed: Vec<&str> = plan
1625        .iter()
1626        .copied()
1627        .filter(|&i| {
1628            !matches!(g.node(i).op, Op::Durable | Op::Fold { .. })
1629                && !g.node(i).op.is_stream()
1630                && Some(i) != page
1631        })
1632        .map(|i| g.label(i))
1633        .collect();
1634    let _ = writeln!(out, "\nthe view recomputes, per event");
1635    let _ = writeln!(
1636        out,
1637        "  {}",
1638        if computed.is_empty() {
1639            "nothing between the accumulator and the page".to_string()
1640        } else {
1641            computed.join(", ")
1642        }
1643    );
1644    let _ = writeln!(
1645        out,
1646        "  shared: {}",
1647        if r.shared.is_empty() {
1648            "—  (no signal is read by two consumers, so nothing is bound twice)".to_string()
1649        } else {
1650            format!(
1651                "{}  (read by more than one consumer, so computed once)",
1652                r.shared
1653                    .iter()
1654                    .map(|s| s.to_string())
1655                    .collect::<Vec<_>>()
1656                    .join(", ")
1657            )
1658        }
1659    );
1660    let _ = writeln!(
1661        out,
1662        "  (§5.3 makes these incremental; today every one is a full recompute)"
1663    );
1664
1665    if !g.cuts.is_empty() {
1666        let _ = writeln!(
1667            out,
1668            "\ntier crossings — each is one subscription, resumable by (id, seq) (§4.3)"
1669        );
1670        let edges: Vec<(String, String, String)> = g
1671            .cuts
1672            .iter()
1673            .map(|c| {
1674                (
1675                    format!("{} → {}", g.label(c.from), g.label(c.to)),
1676                    format!(
1677                        "{} → {}",
1678                        g.node(c.from).tier.name(),
1679                        g.node(c.to).tier.name()
1680                    ),
1681                    format!("{}", c.carries),
1682                )
1683            })
1684            .collect();
1685        let nw = edges.iter().map(|e| e.0.chars().count()).max().unwrap_or(0);
1686        let tw = edges.iter().map(|e| e.1.chars().count()).max().unwrap_or(0);
1687        let cw = edges.iter().map(|e| e.2.chars().count()).max().unwrap_or(0);
1688        for (c, (names, tiers, carries)) in g.cuts.iter().zip(&edges) {
1689            let _ = writeln!(
1690                out,
1691                "  {names:<nw$}  {tiers:<tw$}  carries {carries:<cw$}  {}",
1692                c.id
1693            );
1694        }
1695    }
1696    out
1697}
1698
1699/// Every tier crossing, for `beck explain flow` and for the report.
1700pub fn crossings(placed: &Placed) -> &[Cut] {
1701    &placed.graph.cuts
1702}
1703
1704/// Every vertex reachable from a sink, in dependency order — the sub-plan one role executes.
1705pub fn slice_of(graph: &Graph, sink: SigId) -> Vec<SigId> {
1706    let mut seen = BTreeSet::new();
1707    let mut stack = vec![sink];
1708    while let Some(id) = stack.pop() {
1709        if !seen.insert(id) {
1710            continue;
1711        }
1712        for &i in &graph.node(id).inputs {
1713            stack.push(i);
1714        }
1715    }
1716    graph
1717        .order()
1718        .into_iter()
1719        .filter(|i| seen.contains(i))
1720        .collect()
1721}
1722
1723#[cfg(test)]
1724pub(crate) mod tests {
1725    use super::*;
1726    use crate::compile_str;
1727
1728    /// The sketch's program shape, in the Python surface.
1729    pub const TODO: &str = r#"
1730type Id = newtype[Str]
1731
1732model Todo:
1733    id: Id
1734    text: Str
1735    done: Bool
1736    owner: Str
1737
1738model State:
1739    todos: Map[Id, Todo]
1740
1741union Command:
1742    Add(id: Id, text: Str)
1743    Toggle(id: Id)
1744    Delete(id: Id)
1745
1746union Event:
1747    Added(id: Id, text: Str)
1748    Toggled(id: Id)
1749    Deleted(id: Id)
1750
1751union Rejection:
1752    BlankText
1753    IdTaken
1754    NoSuchTodo
1755    NotOwner
1756
1757def apply_event(s: State, env: Envelope[Event]) -> State:
1758    match env.body:
1759        case Added(id, text):
1760            return s.with(todos=map_insert(s.todos, id, Todo(id=id, text=text, done=False, owner=env.actor)))
1761        case Toggled(id):
1762            return toggle(s, id)
1763        case Deleted(id):
1764            return s.with(todos=map_remove(s.todos, id))
1765
1766def toggle(s: State, id: Id) -> State:
1767    match map_get(s.todos, id):
1768        case Some(value):
1769            return s.with(todos=map_insert(s.todos, id, value.with(done=not value.done)))
1770        case None:
1771            return s
1772
1773def validate(s: State, p: Proposal) -> Result[list[Event], Rejection]:
1774    match p.command:
1775        case Add(id, text):
1776            if str_is_empty(str_trim(text)):
1777                return Err(error=BlankText)
1778            if map_contains(s.todos, id):
1779                return Err(error=IdTaken)
1780            return Ok(value=[Added(id=id, text=text)])
1781        case Toggle(id):
1782            return owned(s, p, id, [Toggled(id=id)])
1783        case Delete(id):
1784            return owned(s, p, id, [Deleted(id=id)])
1785
1786def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection]:
1787    match map_get(s.todos, id):
1788        case Some(value):
1789            if value.owner != p.session.actor:
1790                return Err(error=NotOwner)
1791            return Ok(value=evs)
1792        case None:
1793            return Err(error=NoSuchTodo)
1794
1795def mine(s: State, session: Session) -> list[Todo]:
1796    return sort_by(filter_list(map_values(s.todos), lambda t: t.owner == session.actor), lambda t: t.text)
1797
1798def remaining_of(todos: list[Todo]) -> Int:
1799    return list_len(filter_list(todos, lambda t: not t.done))
1800
1801def view(s: State, session: Session) -> Html:
1802    todos = mine(s, session)
1803    return render(todos, remaining_of(todos))
1804
1805def render(todos: list[Todo], remaining: Int) -> Html:
1806    return ui:
1807        main:
1808            h1: "todos"
1809            ul:
1810                for t in todos:
1811                    li(key=t.id, class=done_class(t)):
1812                        span(on_click=Toggle(id=t.id)): t.text
1813            footer: (str(remaining) + " remaining")
1814
1815def done_class(t: Todo) -> Str:
1816    return "done" if t.done else ""
1817
1818@on(server)
1819proposals: Stream[Proposal] = merge_clients()
1820
1821@on(server)
1822events: Stream[Event] = decide(proposals, todos, validate)
1823
1824@on(data)
1825todos: Signal[State] = durable(fold(apply_event, State(todos={}), events))
1826
1827@on(client)
1828page: Signal[Html] = per_session(todos, view)
1829"#;
1830
1831    #[test]
1832    fn the_sketch_compiles_and_slices_into_roles() {
1833        let (placed, d, map) = compile_str("todo.beck", TODO);
1834        assert!(!d.has_errors(), "{}", d.render(&map));
1835        let placed = placed.expect("splitting succeeds");
1836        assert_eq!(placed.roles.state_name.as_ref(), "todos");
1837        assert_eq!(placed.roles.events_name.as_ref(), "events");
1838        assert_eq!(placed.roles.page_name.as_ref(), "page");
1839        assert!(placed.roles.view_is_per_session);
1840        assert_eq!(placed.roles.event_ty.con_name(), Some("Event"));
1841        assert_eq!(placed.roles.command_ty.con_name(), Some("Command"));
1842        assert_eq!(placed.wire_id.len(), 16);
1843        // One fold, so the accumulator is the program's own type and nothing is fused: every
1844        // claim any earlier phase made about this program is unchanged by the general slicer.
1845        assert!(!placed.roles.is_fused());
1846        assert_eq!(placed.roles.states.len(), 1);
1847        assert_eq!(placed.roles.state_ty.con_name(), Some("State"));
1848    }
1849
1850    #[test]
1851    fn the_graph_holds_the_fold_as_its_own_vertex() {
1852        // `durable(fold(…))` is two operations and therefore two vertices, even though the program
1853        // named only one of them. That is the difference between a graph and a pattern: nothing
1854        // downstream has to know that `durable` "means" `durable-of-a-fold`.
1855        let (placed, _, _) = compile_str("todo.beck", TODO);
1856        let g = &placed.expect("placed").graph;
1857        assert_eq!(g.states().len(), 1);
1858        let durable = g.states()[0];
1859        let inner = g.node(durable).inputs[0];
1860        assert!(matches!(g.node(inner).op, Op::Fold { .. }));
1861        assert_eq!(g.label(inner), "todos·fold");
1862        assert_eq!(
1863            g.node(inner).name,
1864            None,
1865            "an inner vertex has no written name"
1866        );
1867    }
1868
1869    #[test]
1870    fn the_only_cycle_is_the_one_the_design_says_is_sound() {
1871        // §3.7: "`events` is decided from the state, and the state is folded from `events`. The
1872        // cycle is real and it is sound."
1873        let (placed, _, _) = compile_str("todo.beck", TODO);
1874        let g = &placed.expect("placed").graph;
1875        let cycles: Vec<Vec<String>> = g
1876            .dep
1877            .cycles()
1878            .map(|c| {
1879                c.iter()
1880                    .map(|n| g.label(n.0 as usize).to_string())
1881                    .collect()
1882            })
1883            .collect();
1884        assert_eq!(cycles.len(), 1, "{cycles:?}");
1885        assert!(cycles[0].iter().any(|n| n == "events"));
1886        assert!(cycles[0].iter().any(|n| n == "todos"));
1887        assert!(cycles[0].iter().any(|n| n == "todos·fold"));
1888    }
1889
1890    #[test]
1891    fn the_wire_id_is_content_derived_and_stable_under_a_body_edit() {
1892        let (a, _, _) = compile_str("todo.beck", TODO);
1893        // Change a body, not a signature: the operation id must not move (§4.3).
1894        let edited = TODO.replace(
1895            r#""done" if t.done else """#,
1896            r#""done" if t.done else " ""#,
1897        );
1898        let (b, d, map) = compile_str("todo.beck", &edited);
1899        assert!(!d.has_errors(), "{}", d.render(&map));
1900        assert_eq!(
1901            a.expect("a").wire_id,
1902            b.expect("b").wire_id,
1903            "a body edit must not change the wire id"
1904        );
1905    }
1906
1907    #[test]
1908    fn the_wire_id_moves_when_the_wire_actually_changes() {
1909        // The other half of the same requirement, and the one a name-hash silently fails: adding a
1910        // variant to `Event` changes what a subscriber can be sent, so the operation id has to move
1911        // or a rolling deploy has no way to notice.
1912        let (a, _, _) = compile_str("todo.beck", TODO);
1913        let changed = TODO
1914            .replace(
1915                "    Toggled(id: Id)\n    Deleted(id: Id)",
1916                "    Toggled(id: Id)\n    Deleted(id: Id)\n    Starred(id: Id)",
1917            )
1918            .replace(
1919                "        case Deleted(id):\n            return s.with(todos=map_remove(s.todos, id))",
1920                "        case Deleted(id):\n            return s.with(todos=map_remove(s.todos, id))\n        case Starred(id):\n            return toggle(s, id)",
1921            );
1922        let (b, d, map) = compile_str("todo.beck", &changed);
1923        assert!(!d.has_errors(), "{}", d.render(&map));
1924        assert_ne!(a.expect("a").wire_id, b.expect("b").wire_id);
1925
1926        // …and a field added to a command moves it too, which a hash of the type's *name* would
1927        // not have caught either.
1928        let widened = TODO
1929            .replace(
1930                "    Toggle(id: Id)\n    Delete(id: Id)",
1931                "    Toggle(id: Id, at: Int)\n    Delete(id: Id)",
1932            )
1933            .replace("case Toggle(id):", "case Toggle(id, at):")
1934            .replace(
1935                "span(on_click=Toggle(id=t.id)): t.text",
1936                "span(on_click=Toggle(id=t.id, at=0)): t.text",
1937            );
1938        let (c, d, map) = compile_str("todo.beck", &widened);
1939        assert!(!d.has_errors(), "{}", d.render(&map));
1940        let (a, _, _) = compile_str("todo.beck", TODO);
1941        assert_ne!(a.expect("a").wire_id, c.expect("c").wire_id);
1942    }
1943
1944    #[test]
1945    fn a_program_with_no_merge_point_is_told_what_is_missing() {
1946        let (_, d, _) = compile_str("t.beck", "def f() -> Int:\n    return 1\n");
1947        assert!(d.iter().any(|x| x.code == "B0500" && x.fix.is_some()));
1948    }
1949
1950    #[test]
1951    fn a_view_that_reads_a_stream_is_refused_by_name() {
1952        // The narrowness that remains is about *meaning*: a `Stream` is occurrences and a view
1953        // renders a value. B0507 says which, rather than "unsupported".
1954        let src = TODO
1955            .replace(
1956                "@on(client)\npage: Signal[Html] = per_session(todos, view)",
1957                "@on(client)\npage: Signal[Html] = signal_map(events, render_ev)",
1958            )
1959            .replace(
1960                "@on(server)\nproposals",
1961                "def render_ev(e: Event) -> Html:\n    return ui:\n        main: \"x\"\n\n@on(server)\nproposals",
1962            );
1963        let (placed, d, _) = compile_str("t.beck", &src);
1964        assert!(placed.is_none());
1965        assert!(d.has_errors(), "a refusal must say why");
1966    }
1967
1968    #[test]
1969    fn a_cycle_with_no_fold_in_it_is_refused_rather_than_looped_on() {
1970        // The rule that makes slicing terminate, stated as a program the compiler must reject.
1971        let src = TODO.replace(
1972            "@on(data)\ntodos: Signal[State] = durable(fold(apply_event, State(todos={}), events))",
1973            "@on(data)\ntodos: Signal[State] = durable(fold(apply_event, State(todos={}), events))\n\
1974             \nloop_a: Signal[State] = signal_map(loop_b, identity_state)\n\
1975             \nloop_b: Signal[State] = signal_map(loop_a, identity_state)",
1976        );
1977        let src = src.replace(
1978            "def done_class",
1979            "def identity_state(s: State) -> State:\n    return s\n\ndef done_class",
1980        );
1981        let (placed, d, _) = compile_str("t.beck", &src);
1982        assert!(placed.is_none(), "a self-defined signal has no first value");
1983        assert!(
1984            d.iter().any(|x| x.code == "B0509"),
1985            "{:?}",
1986            d.iter().map(|x| x.code).collect::<Vec<_>>()
1987        );
1988    }
1989}