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(3, 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        };
141        Placed {
142            program,
143            wire_id,
144            placement: crate::place::Solution {
145                tiers: Default::default(),
146                explanations: Vec::new(),
147                method: crate::place::Method::Exhaustive,
148                total: 0,
149                churn: Vec::new(),
150                ties: Vec::new(),
151            },
152            render: crate::render::Decision::of(&roles, false, None, span),
153            roles,
154            graph,
155            kind: Kind::Library,
156        }
157    }
158}
159
160/// One durable accumulator the program declared.
161#[derive(Clone, Debug)]
162pub struct StateRole {
163    pub name: Arc<str>,
164    pub ty: Ty,
165    /// The field this fold occupies in the fused accumulator, when there is more than one fold.
166    /// `None` when the program has a single fold and its own type *is* the accumulator.
167    pub field: Option<Arc<str>>,
168    pub node: SigId,
169}
170
171/// The five things the runtime needs, each a `Core` value it can call.
172///
173/// This is deliberately still five: a runtime that drives one log, one accumulator and one page is
174/// what Phase 1 built and what Phase 3 has not replaced. What changed is that these are now
175/// *derived from the graph* — fusing several folds, inlining or sharing intermediate signals — so
176/// the shape of the program and the shape of the runtime are no longer required to be the same.
177#[derive(Clone, Debug)]
178pub struct Roles {
179    /// `(state, proposal) -> Result[list[Event], Rejection]` — the authority chokepoint.
180    pub validate: Core,
181    /// `(state, Envelope[Event]) -> state` — the replay-pure fold.
182    pub fold: Core,
183    /// The fold's initial accumulator.
184    pub init: Core,
185    /// `(state, session, presence) -> Html` — the client-placed view, with intermediate signals
186    /// inlined or shared.
187    ///
188    /// Three parameters whether or not the program reads the third: a role the runtime calls has
189    /// one arity, and a view that ignores its presence argument is cheaper than two code paths
190    /// that could disagree about which one it has.
191    pub view: Core,
192    pub state_ty: Ty,
193    pub event_ty: Ty,
194    pub command_ty: Ty,
195    /// Names, for `beck explain` and for the report.
196    pub proposals_name: Arc<str>,
197    pub events_name: Arc<str>,
198    pub state_name: Arc<str>,
199    pub page_name: Arc<str>,
200    /// Signals that were inlined into the view rather than surviving as their own node.
201    pub inlined: Vec<Arc<str>>,
202    /// Signals read by more than one consumer, and therefore bound once in the sliced view rather
203    /// than recomputed per use. §5.3's shared prefix, at compile time.
204    pub shared: Vec<Arc<str>>,
205    /// The durable folds, in declaration order. One entry for the ordinary program; several when
206    /// the accumulator is fused.
207    pub states: Vec<StateRole>,
208    pub view_is_per_session: bool,
209    /// Whether the page reads `presence()`, and therefore has an input the log does not contain.
210    pub view_reads_presence: bool,
211}
212
213impl Roles {
214    /// Whether the accumulator is a synthetic record over several folds.
215    pub fn is_fused(&self) -> bool {
216        self.states.len() > 1
217    }
218}
219
220/// Slice a checked, placement-verified program.
221pub fn split(mut program: Program, diags: &mut Diagnostics) -> Option<Placed> {
222    let graph = Graph::build(&program, diags)?;
223
224    // ---- the three roles the graph has to contain, found by op rather than by position ----
225
226    let ingress = graph.ingress();
227    let Some(&proposals) = ingress.first() else {
228        diags.push(
229            Diagnostic::error("B0500", "this program has no merge point", Span::NONE)
230                .with_note(
231                    "a Beck application is a fold over an event stream, and the stream starts at \
232                     `merge_clients()` — the one place time enters",
233                )
234                .with_fix("add `@on(server)` and `proposals: Stream[Proposal] = merge_clients()`"),
235        );
236        return None;
237    };
238
239    let states = graph.states();
240    if states.is_empty() {
241        diags.push(
242            Diagnostic::error("B0501", "this program has no durable state", Span::NONE)
243                .with_note("`durable(fold(f, init, s))` is what makes the log a database")
244                .with_fix("wrap the fold: `@on(data)` and `durable(fold(apply_event, …, events))`"),
245        );
246        return None;
247    }
248
249    // Every `durable` must wrap a fold: only a fold has an accumulator to persist.
250    let mut folds: Vec<(SigId, SigId)> = Vec::new(); // (durable node, fold node)
251    for &s in &states {
252        let inner = follow_alias(&graph, graph.node(s).inputs[0]);
253        if !matches!(graph.node(inner).op, Op::Fold { .. }) {
254            diags.push(
255                Diagnostic::error("B0502", "`durable` must wrap a `fold`", graph.node(s).span)
256                    .with_primary_label("only a fold has an accumulator to persist")
257                    .with_label(
258                        graph.node(inner).span,
259                        format!("this is a `{}`", graph.node(inner).op.name()),
260                    ),
261            );
262            return None;
263        }
264        folds.push((s, inner));
265    }
266
267    // §3.5: "authority is one chokepoint". The graph can hold any number of `decide` nodes; a
268    // program may not, and the diagnostic says which sentence in the design that is.
269    let decides = graph.decides();
270    let Some(&decide) = decides.first() else {
271        diags.push(
272            Diagnostic::error(
273                "B0504",
274                "events must come from `decide`",
275                graph.node(folds[0].1).span,
276            )
277            .with_primary_label("this fold has no chokepoint upstream of it")
278            .with_note(
279                "`decide` is the sole consumer of ingress and the one place a command becomes \
280                     an event — §3.5's \"authority is one chokepoint\"",
281            ),
282        );
283        return None;
284    };
285    if decides.len() > 1 {
286        diags.push(
287            Diagnostic::error(
288                "B0511",
289                "a program has one authority chokepoint",
290                graph.node(decides[1]).span,
291            )
292            .with_primary_label("a second `decide`")
293            .with_label(graph.node(decide).span, "the first one is here")
294            .with_note(
295                "§3.5 rests on validation being one place: two of them are two answers to \"may \
296                 this actor do this\", and the log would record whichever ran",
297            ),
298        );
299        return None;
300    }
301
302    // §3.7's replay rule, at the granularity of the graph: what the log records must be a function
303    // of the log. `presence()` is the one signal that is not, so the chokepoint may not read it —
304    // directly or through any number of maps.
305    if let Some(&here) = graph
306        .presences()
307        .iter()
308        .find(|&&p| reaches(&graph, decide, p))
309    {
310        diags.push(
311            Diagnostic::error(
312                "B0515",
313                "the chokepoint reads `presence`, which is not in the log",
314                graph.node(decide).span,
315            )
316            .with_primary_label(format!(
317                "`{}` decides from `{}`",
318                graph.label(decide),
319                graph.label(here)
320            ))
321            .with_label(graph.node(here).span, "who is connected is decided here")
322            .with_note(
323                "an event is what a replay reproduces, and who was connected when it was recorded \
324                 is not written down anywhere. A `validate` that read the roster would decide one \
325                 thing today and another on replay, and the log would no longer be the whole \
326                 history",
327            )
328            .with_fix(
329                "record the fact instead: propose a command when a client arrives, and decide from \
330                 the state that fold produces",
331            ),
332        );
333        return None;
334    }
335
336    // Each fold's stream, after any `filter_map`, must be the chokepoint's output. Anything else
337    // is an event stream the log does not contain.
338    let mut fold_filters: Vec<Option<Core>> = Vec::new();
339    for &(_, f) in &folds {
340        let mut node = follow_alias(&graph, graph.node(f).inputs[0]);
341        let mut filter = None;
342        if let Op::FilterMap { f: pred } = &graph.node(node).op {
343            filter = Some(pred.clone());
344            node = follow_alias(&graph, graph.node(node).inputs[0]);
345        }
346        if node != decide {
347            diags.push(
348                Diagnostic::error(
349                    "B0504",
350                    "events must come from `decide`",
351                    graph.node(f).span,
352                )
353                .with_primary_label(format!(
354                    "this fold reads `{}`",
355                    graph.label(graph.node(f).inputs[0])
356                ))
357                .with_note(
358                    "the log holds what the chokepoint decided, so a fold reads `decide` — \
359                         optionally through one `filter_map`, which is how two folds take \
360                         different slices of one stream",
361                ),
362            );
363            return None;
364        }
365        fold_filters.push(filter);
366    }
367
368    // ---- the page: a sink, placed on the client, carrying Html ----
369
370    let pages: Vec<SigId> = graph
371        .sinks
372        .iter()
373        .copied()
374        .filter(|&s| graph.node(s).tier == Tier::Client && is_html(&graph.node(s).ty))
375        .collect();
376    let Some(&page) = pages.first() else {
377        diags.push(
378            Diagnostic::error("B0505", "no signal is placed on the client", Span::NONE)
379                .with_note(
380                    "`page` is the tier crossing: a `Signal[Html]` the browser subscribes to",
381                )
382                .with_fix("add `@on(client)` and `page: Signal[Html] = per_session(todos, view)`"),
383        );
384        return None;
385    };
386    if pages.len() > 1 {
387        diags.push(
388            Diagnostic::error(
389                "B0510",
390                "two signals are the page, and there is no router yet",
391                graph.node(pages[1]).span,
392            )
393            .with_primary_label(format!("`{}`", graph.label(pages[1])))
394            .with_label(graph.node(page).span, format!("`{}`", graph.label(page)))
395            .with_note(
396                "the slicer will slice both; the runtime serves one document per connection, and \
397                 choosing between them is routing — a Phase 3 client bullet that is not built",
398            )
399            .with_fix("combine them in one view, or read one from the other"),
400        );
401        return None;
402    }
403
404    // ---- slicing ----
405
406    let fused = states.len() > 1;
407    let mut vars = Vars(max_var(&program));
408    let state_var = vars.fresh();
409    let session_var = vars.fresh();
410    let presence_var = vars.fresh();
411
412    let state_roles: Vec<StateRole> = folds
413        .iter()
414        .map(|&(d, _)| {
415            let n = graph.node(d);
416            StateRole {
417                name: n.label.clone(),
418                ty: signal_elem(&n.ty),
419                field: fused.then(|| n.label.clone()),
420                node: d,
421            }
422        })
423        .collect();
424
425    let mut slicer = Slicer {
426        graph: &graph,
427        states: &state_roles,
428        state_var,
429        session_var,
430        presence_var,
431        bound: BTreeMap::new(),
432        lets: Vec::new(),
433        inlined: Vec::new(),
434        shared: Vec::new(),
435        per_session: false,
436        reads_presence: false,
437        vars: &mut vars,
438        diags,
439    };
440    let view_body = slicer.lower_sink(page)?;
441    let view_body = slicer.wrap(view_body);
442    let inlined = slicer.inlined.clone();
443    let shared = slicer.shared.clone();
444    let per_session = slicer.per_session;
445    let reads_presence = slicer.reads_presence;
446
447    let state_ty = if fused {
448        Ty::con(FUSED_STATE)
449    } else {
450        state_roles[0].ty.clone()
451    };
452
453    let view = Core {
454        kind: CoreKind::Lam {
455            params: vec![state_var, session_var, presence_var].into(),
456            body: Arc::new(view_body),
457        },
458        ty: Ty::fun(
459            vec![
460                state_ty.clone(),
461                Ty::con("Session"),
462                Ty::map(Ty::str_(), Ty::int()),
463            ],
464            Ty::html(),
465        ),
466        tier: Tier::Client,
467        span: graph.node(page).span,
468        last_use: false,
469        order: crate::fields::UNORDERED,
470        locals: 0,
471    };
472
473    // ---- the accumulator, fused when the program declared more than one fold ----
474
475    let (fold, init) = if fused {
476        program.types.insert(
477            Arc::from(FUSED_STATE),
478            crate::signal::fused_state_decl(
479                &state_roles
480                    .iter()
481                    .map(|s| (s.name.clone(), s.ty.clone()))
482                    .collect::<Vec<_>>(),
483            ),
484        );
485        fuse(
486            &graph,
487            &folds,
488            &fold_filters,
489            &state_roles,
490            &state_ty,
491            &mut vars,
492            graph.node(page).span,
493        )
494    } else {
495        let Op::Fold { step, init } = &graph.node(folds[0].1).op else {
496            return None;
497        };
498        match &fold_filters[0] {
499            None => (step.clone(), init.clone()),
500            Some(pred) => (
501                filtered_step(
502                    step,
503                    pred,
504                    &state_ty,
505                    &mut vars,
506                    graph.node(folds[0].1).span,
507                ),
508                init.clone(),
509            ),
510        }
511    };
512
513    // ---- `validate`, which reads whichever accumulator the chokepoint was given ----
514
515    let Op::Decide { validate } = &graph.node(decide).op else {
516        return None;
517    };
518    let validate = if fused {
519        let src = follow_alias(&graph, graph.node(decide).inputs[1]);
520        let Some(role) = state_roles.iter().find(|s| s.node == src) else {
521            diags.push(
522                Diagnostic::error(
523                    "B0512",
524                    "the chokepoint does not read a durable fold",
525                    graph.node(decide).span,
526                )
527                .with_primary_label(format!("it reads `{}`", graph.label(src)))
528                .with_note(
529                    "`decide` threads the accumulator through validation, so what it reads has to \
530                     be one — that is what makes first-writer-wins and ownership decidable (§3.7)",
531                ),
532            );
533            return None;
534        };
535        let p = vars.fresh();
536        let s = vars.fresh();
537        let span = graph.node(decide).span;
538        Core {
539            kind: CoreKind::Lam {
540                params: vec![s, p].into(),
541                body: Arc::new(Core {
542                    kind: CoreKind::App {
543                        func: Box::new(validate.clone()),
544                        args: vec![
545                            field(var(s, state_ty.clone(), span), role, span),
546                            var(p, Ty::con("Proposal"), span),
547                        ],
548                    },
549                    ty: Ty::unit(),
550                    tier: Tier::Server,
551                    span,
552                    last_use: false,
553                    order: crate::fields::UNORDERED,
554                    locals: 0,
555                }),
556            },
557            ty: Ty::unit(),
558            tier: Tier::Server,
559            span,
560            last_use: false,
561            order: crate::fields::UNORDERED,
562            locals: 0,
563        }
564    } else {
565        validate.clone()
566    };
567
568    let event_ty = signal_elem(&graph.node(decide).ty);
569    let command_ty = program
570        .types
571        .get("Command")
572        .map(|_| Ty::con("Command"))
573        .unwrap_or_else(Ty::unit);
574
575    // §4.3: "a stable, content-derived operation id … *not* a URL a human maintains, and stable
576    // across refactors that don't change the signature".
577    //
578    // **Content**, not name. Hashing `"Event"` would produce an id that never moves — including
579    // when a variant is added, which is precisely the change that breaks every open tab. The three
580    // types are hashed *structurally*, through every field of every variant they reach.
581    let mut hasher = blake3::Hasher::new();
582    hasher.update(program.name.as_bytes());
583    for t in [&command_ty, &event_ty, &state_ty] {
584        hasher.update(crate::iface::structural(t, &program.types).as_bytes());
585        hasher.update(b"\x00");
586    }
587    let wire_id = hasher.finalize().to_hex()[..16].to_string();
588
589    let roles = Roles {
590        validate,
591        fold,
592        init,
593        view,
594        state_ty,
595        event_ty,
596        command_ty,
597        proposals_name: graph.node(proposals).label.clone(),
598        events_name: graph.node(decide).label.clone(),
599        state_name: state_roles[0].name.clone(),
600        page_name: graph.node(page).label.clone(),
601        inlined,
602        shared,
603        states: state_roles,
604        view_is_per_session: per_session,
605        view_reads_presence: reads_presence,
606    };
607
608    // Where the page renders, and whether it may. `@render(client)` turns the crossing from a
609    // rendering of the state into the state itself, so this is the one decision in the splitter
610    // that can disclose something — and it is refused here rather than at build time, because a
611    // program that would leak should not compile.
612    let declared = program
613        .signals
614        .iter()
615        .find(|s| s.name == graph.node(page).label)
616        .and_then(|s| s.render);
617    let render = crate::render::Decision::of(&roles, true, declared, graph.node(page).span);
618    render.refuse(diags);
619    if diags.has_errors() {
620        return None;
621    }
622
623    Some(Placed {
624        kind: Kind::Application,
625        placement: crate::place::Solution {
626            tiers: Default::default(),
627            explanations: Vec::new(),
628            method: crate::place::Method::Exhaustive,
629            total: 0,
630            churn: Vec::new(),
631            ties: Vec::new(),
632        },
633        render,
634        roles,
635        wire_id,
636        program,
637        graph,
638    })
639}
640
641fn is_html(t: &Ty) -> bool {
642    signal_elem(t).con_name() == Some(Ty::HTML)
643}
644
645/// Whether `from` reads `target`, at any depth.
646///
647/// The graph is legitimately cyclic (§3.7's `decide → durable → fold → decide`), so this is a
648/// visited-set walk rather than a recursion that would follow the cycle forever.
649fn reaches(graph: &Graph, from: SigId, target: SigId) -> bool {
650    let mut seen = BTreeSet::new();
651    let mut stack = vec![from];
652    while let Some(id) = stack.pop() {
653        if id == target {
654            return true;
655        }
656        if !seen.insert(id) {
657            continue;
658        }
659        stack.extend(graph.node(id).inputs.iter().copied());
660    }
661    false
662}
663
664/// Step past `mirror: Signal[T] = todos` declarations, which name a vertex without adding one.
665fn follow_alias(graph: &Graph, mut id: SigId) -> SigId {
666    let mut guard = 0;
667    while matches!(graph.node(id).op, Op::Alias) && guard < graph.nodes.len() {
668        id = graph.node(id).inputs[0];
669        guard += 1;
670    }
671    id
672}
673
674fn var(v: VarId, ty: Ty, span: Span) -> Core {
675    Core {
676        kind: CoreKind::Var(v),
677        ty,
678        tier: Tier::Any,
679        span,
680        last_use: false,
681        order: crate::fields::UNORDERED,
682        locals: 0,
683    }
684}
685
686/// Reach one fold's accumulator out of the state parameter.
687fn field(base: Core, role: &StateRole, span: Span) -> Core {
688    match &role.field {
689        None => base,
690        Some(f) => Core {
691            kind: CoreKind::Field {
692                base: Box::new(base),
693                name: f.clone(),
694            },
695            ty: role.ty.clone(),
696            tier: Tier::Any,
697            span,
698            last_use: false,
699            order: crate::fields::UNORDERED,
700            locals: 0,
701        },
702    }
703}
704
705/// `f(args…)`, at a given result type.
706fn call(func: Core, args: Vec<Core>, ty: Ty, span: Span) -> Core {
707    Core {
708        kind: CoreKind::App {
709            func: Box::new(func),
710            args,
711        },
712        ty,
713        tier: Tier::Any,
714        span,
715        last_use: false,
716        order: crate::fields::UNORDERED,
717        locals: 0,
718    }
719}
720
721/// One fold's contribution to a step: `step(state.field, env)`, guarded by the fold's
722/// `filter_map` if it has one.
723fn fold_field(
724    step: &Core,
725    filter: &Option<Core>,
726    acc: Core,
727    env: Core,
728    ty: &Ty,
729    vars: &mut Vars,
730    span: Span,
731) -> Core {
732    let applied = call(
733        step.clone(),
734        vec![acc.clone(), env.clone()],
735        ty.clone(),
736        span,
737    );
738    let Some(pred) = filter else {
739        return applied;
740    };
741    // `filter_map` between the chokepoint and a fold means this fold sees a *slice* of the log.
742    // The runtime appends one stream and folds it once, so the filter moves into the step:
743    //
744    //     let o = pred(env.body) in
745    //     if is_some(o) then step(acc, env.with(body = o.value)) else acc
746    //
747    // Written with the prims the language already has rather than a synthesised `match`, because
748    // an `Arm` carries a pattern and this needs no pattern — only the two answers `Option` has.
749    let o = vars.fresh();
750    let opt_ty = Ty::option(Ty::unit());
751    let body = Core {
752        kind: CoreKind::Field {
753            base: Box::new(env.clone()),
754            name: Arc::from("body"),
755        },
756        ty: Ty::unit(),
757        tier: Tier::Any,
758        span,
759        last_use: false,
760        order: crate::fields::UNORDERED,
761        locals: 0,
762    };
763    let inner = Core {
764        kind: CoreKind::Field {
765            base: Box::new(var(o, opt_ty.clone(), span)),
766            name: Arc::from("value"),
767        },
768        ty: Ty::unit(),
769        tier: Tier::Any,
770        span,
771        last_use: false,
772        order: crate::fields::UNORDERED,
773        locals: 0,
774    };
775    let narrowed = Core {
776        kind: CoreKind::With {
777            base: Box::new(env),
778            fields: vec![(Arc::from("body"), inner)],
779        },
780        ty: Ty::unit(),
781        tier: Tier::Any,
782        span,
783        last_use: false,
784        order: crate::fields::UNORDERED,
785        locals: 0,
786    };
787    Core {
788        kind: CoreKind::Let {
789            var: o,
790            value: Box::new(call(pred.clone(), vec![body], opt_ty.clone(), span)),
791            body: Box::new(Core {
792                kind: CoreKind::If {
793                    cond: Box::new(Core {
794                        kind: CoreKind::Prim {
795                            op: Prim::OptionIsSome,
796                            args: vec![var(o, opt_ty, span)],
797                        },
798                        ty: Ty::bool_(),
799                        tier: Tier::Any,
800                        span,
801                        last_use: false,
802                        order: crate::fields::UNORDERED,
803                        locals: 0,
804                    }),
805                    then: Box::new(call(
806                        step.clone(),
807                        vec![acc.clone(), narrowed],
808                        ty.clone(),
809                        span,
810                    )),
811                    alt: Box::new(acc),
812                },
813                ty: ty.clone(),
814                tier: Tier::Any,
815                span,
816                last_use: false,
817                order: crate::fields::UNORDERED,
818                locals: 0,
819            }),
820        },
821        ty: ty.clone(),
822        tier: Tier::Any,
823        span,
824        last_use: false,
825        order: crate::fields::UNORDERED,
826        locals: 0,
827    }
828}
829
830/// The single-fold case of [`fold_field`]: a step wrapped in its own `filter_map`.
831fn filtered_step(step: &Core, pred: &Core, state_ty: &Ty, vars: &mut Vars, span: Span) -> Core {
832    let s = vars.fresh();
833    let e = vars.fresh();
834    let body = fold_field(
835        step,
836        &Some(pred.clone()),
837        var(s, state_ty.clone(), span),
838        var(e, Ty::unit(), span),
839        state_ty,
840        vars,
841        span,
842    );
843    Core {
844        kind: CoreKind::Lam {
845            params: vec![s, e].into(),
846            body: Arc::new(body),
847        },
848        ty: Ty::fun(vec![state_ty.clone(), Ty::unit()], state_ty.clone()),
849        tier: Tier::Data,
850        span,
851        last_use: false,
852        order: crate::fields::UNORDERED,
853        locals: 0,
854    }
855}
856
857/// Fuse several durable folds into one accumulator.
858///
859/// §3.7 fixes one totally-ordered log per application. Two `durable` folds are therefore not two
860/// logs; they are two projections of one, and the runtime holds a record with one field per fold.
861/// The step applies every fold's own step to its own field, in declaration order, so replay is
862/// exactly as deterministic as it was with one.
863fn fuse(
864    graph: &Graph,
865    folds: &[(SigId, SigId)],
866    filters: &[Option<Core>],
867    roles: &[StateRole],
868    state_ty: &Ty,
869    vars: &mut Vars,
870    span: Span,
871) -> (Core, Core) {
872    let s = vars.fresh();
873    let e = vars.fresh();
874
875    let mut step_fields = Vec::new();
876    let mut init_fields = Vec::new();
877    for (i, &(_, f)) in folds.iter().enumerate() {
878        let Op::Fold { step, init } = &graph.node(f).op else {
879            continue;
880        };
881        let role = &roles[i];
882        let acc = field(var(s, state_ty.clone(), span), role, span);
883        step_fields.push((
884            role.name.clone(),
885            fold_field(
886                step,
887                &filters[i],
888                acc,
889                var(e, Ty::unit(), span),
890                &role.ty,
891                vars,
892                span,
893            ),
894        ));
895        init_fields.push((role.name.clone(), init.clone()));
896    }
897
898    // The fused state is synthesised here, after `fields::order_program` has run over what the
899    // user wrote, so it asks for its own layout rather than going without one.
900    let make = |fields: Vec<(Arc<str>, Core)>| {
901        let mut c = Core {
902            kind: CoreKind::Make {
903                ty: Arc::from(FUSED_STATE),
904                variant: None,
905                fields,
906            },
907            ty: state_ty.clone(),
908            tier: Tier::Data,
909            span,
910            last_use: false,
911            order: crate::fields::UNORDERED,
912            locals: 0,
913        };
914        crate::fields::order_here(&mut c);
915        c
916    };
917
918    (
919        Core {
920            kind: CoreKind::Lam {
921                params: vec![s, e].into(),
922                body: Arc::new(make(step_fields)),
923            },
924            ty: Ty::fun(vec![state_ty.clone(), Ty::unit()], state_ty.clone()),
925            tier: Tier::Data,
926            span,
927            last_use: false,
928            order: crate::fields::UNORDERED,
929            locals: 0,
930        },
931        make(init_fields),
932    )
933}
934
935/// A source of variables the program does not use.
936///
937/// The old splitter used variables 0 and 1 for the state and the session, which are also the first
938/// two the checker hands out. It got away with it because the sliced body only ever *calls* the
939/// program's functions rather than inlining their bodies — but "got away with it" is the whole
940/// objection, and a slicer that now emits `let` bindings of its own has no reason to keep it.
941struct Vars(VarId);
942
943impl Vars {
944    fn fresh(&mut self) -> VarId {
945        self.0 += 1;
946        self.0
947    }
948}
949
950/// The largest variable the program uses, so the slicer's own bindings cannot shadow one.
951fn max_var(program: &Program) -> VarId {
952    fn go(c: &Core, max: &mut VarId) {
953        match &c.kind {
954            CoreKind::Const(_) | CoreKind::Global(_) => {}
955            CoreKind::Var(v) => *max = (*max).max(*v),
956            CoreKind::Lam { params, body } => {
957                for p in params.iter() {
958                    *max = (*max).max(*p);
959                }
960                go(body, max);
961            }
962            CoreKind::App { func, args } => {
963                go(func, max);
964                args.iter().for_each(|a| go(a, max));
965            }
966            CoreKind::Prim { args, .. } => args.iter().for_each(|a| go(a, max)),
967            CoreKind::Let { var, value, body } => {
968                *max = (*max).max(*var);
969                go(value, max);
970                go(body, max);
971            }
972            CoreKind::If { cond, then, alt } => {
973                go(cond, max);
974                go(then, max);
975                go(alt, max);
976            }
977            CoreKind::Match { scrutinee, arms } => {
978                go(scrutinee, max);
979                for a in arms {
980                    for v in a.pattern.binders() {
981                        *max = (*max).max(v);
982                    }
983                    for e in a.exprs() {
984                        go(e, max);
985                    }
986                }
987            }
988            CoreKind::Make { fields, .. } => fields.iter().for_each(|(_, f)| go(f, max)),
989            CoreKind::Field { base, .. } => go(base, max),
990            CoreKind::With { base, fields } => {
991                go(base, max);
992                fields.iter().for_each(|(_, f)| go(f, max));
993            }
994            CoreKind::ListLit(items) => items.iter().for_each(|i| go(i, max)),
995            CoreKind::MapLit(pairs) => pairs.iter().for_each(|(k, v)| {
996                go(k, max);
997                go(v, max);
998            }),
999        }
1000    }
1001    let mut max = 0;
1002    for d in program.defs.values() {
1003        go(&d.body, &mut max);
1004    }
1005    for s in &program.signals {
1006        go(&s.expr, &mut max);
1007    }
1008    for t in &program.tests {
1009        max = max
1010            .max(t.bindings.state)
1011            .max(t.bindings.events)
1012            .max(t.bindings.result);
1013    }
1014    max
1015}
1016
1017/// Rewrites a signal expression into a function of the durable state (and the session).
1018///
1019/// This is the slicing itself. `signal_map(s, f)` becomes `f(lower(s))`, `map2(f, a, b)` becomes
1020/// `f(lower(a), lower(b))`, `per_session(s, f)` becomes `f(lower(s), session)`, and a reference to
1021/// a durable signal becomes the state parameter — or, when several folds were fused, the field of
1022/// it that fold occupies.
1023///
1024/// What is not a rewrite is the sharing: a vertex read by more than one consumer is bound once, in
1025/// a `let`, and referred to by name. Under the old splitter it was inlined per use, so a program
1026/// whose two views both read `summary` recomputed it twice per event and nothing recorded that
1027/// they were the same computation. §5.3's arrangement sharing needs the opposite, and the plan is
1028/// the only place a later view engine could learn it.
1029struct Slicer<'a, 'd> {
1030    graph: &'a Graph,
1031    states: &'a [StateRole],
1032    state_var: VarId,
1033    session_var: VarId,
1034    presence_var: VarId,
1035    vars: &'d mut Vars,
1036    /// Vertices already bound in this slice.
1037    bound: BTreeMap<SigId, VarId>,
1038    /// The bindings, dependencies first.
1039    lets: Vec<(VarId, Core)>,
1040    inlined: Vec<Arc<str>>,
1041    shared: Vec<Arc<str>>,
1042    per_session: bool,
1043    reads_presence: bool,
1044    diags: &'d mut Diagnostics,
1045}
1046
1047impl Slicer<'_, '_> {
1048    /// Slice a sink. The sink itself is not "inlined into the view": it *is* the view, and
1049    /// listing it as one of the signals that disappeared into it would be a report about nothing.
1050    fn lower_sink(&mut self, id: SigId) -> Option<Core> {
1051        let body = self.lower(id)?;
1052        if let Some(name) = &self.graph.node(follow_alias(self.graph, id)).name {
1053            self.inlined.retain(|n| n != name);
1054        }
1055        Some(body)
1056    }
1057
1058    fn lower(&mut self, id: SigId) -> Option<Core> {
1059        let id = follow_alias(self.graph, id);
1060        let node = self.graph.node(id);
1061
1062        // A durable accumulator is where slicing stops: it is a parameter, not a computation. This
1063        // is also why a cycle through a fold terminates and one without a fold cannot.
1064        if let Some(role) = self.states.iter().find(|s| s.node == id) {
1065            return Some(field(
1066                var(self.state_var, Ty::unit(), node.span),
1067                role,
1068                node.span,
1069            ));
1070        }
1071        // Presence is the other input a view has that is not a computation: a parameter, like the
1072        // accumulator, and unlike the accumulator not a function of the log.
1073        if matches!(node.op, Op::Presence) {
1074            self.reads_presence = true;
1075            return Some(var(self.presence_var, signal_elem(&node.ty), node.span));
1076        }
1077        if let Some(&v) = self.bound.get(&id) {
1078            return Some(var(v, signal_elem(&node.ty), node.span));
1079        }
1080
1081        let span = node.span;
1082        let ty = signal_elem(&node.ty);
1083        let body = match &node.op {
1084            Op::Map { f } => {
1085                let input = self.lower(node.inputs[0])?;
1086                call(f.clone(), vec![input], ty.clone(), span)
1087            }
1088            Op::Map2 { f } => {
1089                let a = self.lower(node.inputs[0])?;
1090                let b = self.lower(node.inputs[1])?;
1091                call(f.clone(), vec![a, b], ty.clone(), span)
1092            }
1093            Op::PerSession { f } => {
1094                self.per_session = true;
1095                let input = self.lower(node.inputs[0])?;
1096                let session = var(self.session_var, Ty::con("Session"), span);
1097                call(f.clone(), vec![input, session], ty.clone(), span)
1098            }
1099            Op::Fold { .. } => {
1100                // A fold the program did not mark `durable`. The value exists in the semantics —
1101                // a transient accumulator — and there is nowhere to keep it: the runtime persists
1102                // the log and snapshots what `durable` names, and nothing else.
1103                self.diags.push(
1104                    Diagnostic::error(
1105                        "B0513",
1106                        format!("`{}` is a fold that is not durable", self.graph.label(id)),
1107                        span,
1108                    )
1109                    .with_primary_label("its accumulator has nowhere to live across a restart")
1110                    .with_note(
1111                        "the log is what survives, and `durable` is what says an accumulator is \
1112                         folded from it — a fold outside one would be rebuilt from nothing on \
1113                         every deploy",
1114                    )
1115                    .with_fix("wrap it: `durable(fold(…))`"),
1116                );
1117                return None;
1118            }
1119            Op::Ingress | Op::Decide { .. } | Op::FilterMap { .. } => {
1120                self.diags.push(
1121                    Diagnostic::error(
1122                        "B0507",
1123                        format!(
1124                            "a view cannot read `{}`, which is a stream",
1125                            self.graph.label(id)
1126                        ),
1127                        span,
1128                    )
1129                    .with_primary_label(format!("`{}` produces occurrences", node.op.name()))
1130                    .with_note(
1131                        "§3.7: a `Stream` is discrete occurrences and a `Signal` is a value \
1132                         defined at all times. A view renders a value, so it reads what a stream \
1133                         was folded into",
1134                    ),
1135                );
1136                return None;
1137            }
1138            Op::Durable | Op::Alias | Op::Presence => unreachable!("handled above"),
1139        };
1140
1141        if let Some(name) = &node.name {
1142            if !self.inlined.contains(name) {
1143                self.inlined.push(name.clone());
1144            }
1145        }
1146
1147        // Shared: read by more than one consumer, so computing it once is the whole difference
1148        // between a plan and an expansion.
1149        if self.graph.consumers(id).len() > 1 {
1150            let v = self.vars.fresh();
1151            self.bound.insert(id, v);
1152            self.lets.push((v, body));
1153            if let Some(name) = &node.name {
1154                self.shared.push(name.clone());
1155            }
1156            return Some(var(v, ty, span));
1157        }
1158        Some(body)
1159    }
1160
1161    /// Wrap the sliced expression in the bindings it accumulated, dependencies outermost.
1162    fn wrap(&self, body: Core) -> Core {
1163        self.lets.iter().rev().fold(body, |acc, (v, value)| Core {
1164            kind: CoreKind::Let {
1165                var: *v,
1166                value: Box::new(value.clone()),
1167                body: Box::new(acc.clone()),
1168            },
1169            ty: acc.ty.clone(),
1170            tier: Tier::Client,
1171            span: acc.span,
1172            last_use: false,
1173            order: crate::fields::UNORDERED,
1174            locals: 0,
1175        })
1176    }
1177}
1178
1179/// What `beck explain flow` prints: the graph as a graph, rather than the four names the one
1180/// recognised topology had.
1181///
1182/// §4.7 asks `beck explain` to answer "why is this here" from the compiler's own data. The old
1183/// version printed a fixed four-line summary and one hard-coded sentence claiming there was
1184/// exactly one tier crossing — true of the todo sketch and of nothing the general slicer now
1185/// accepts. This prints what the slicer read.
1186pub fn flow_report(placed: &Placed) -> String {
1187    use std::fmt::Write;
1188    let g = &placed.graph;
1189    let r = &placed.roles;
1190    let mut out = String::new();
1191    let cycles = g.dep.cycles().count();
1192    let _ = writeln!(
1193        out,
1194        "signal graph — {} vertices, {} {}, {} tier {}\n",
1195        g.nodes.len(),
1196        cycles,
1197        if cycles == 1 { "cycle" } else { "cycles" },
1198        g.cuts.len(),
1199        if g.cuts.len() == 1 {
1200            "crossing"
1201        } else {
1202            "crossings"
1203        },
1204    );
1205
1206    let in_cycle: BTreeSet<SigId> = g
1207        .dep
1208        .cycles()
1209        .flat_map(|c| c.iter().map(|n| n.0 as usize))
1210        .collect();
1211    let page = g.by_name.get(&r.page_name).copied();
1212    let rows: Vec<(SigId, String, String)> = g
1213        .order()
1214        .into_iter()
1215        .map(|id| {
1216            let n = g.node(id);
1217            let inputs: Vec<&str> = n.inputs.iter().map(|&i| g.label(i)).collect();
1218            (
1219                id,
1220                n.label.to_string(),
1221                format!("{}({})", n.op.name(), inputs.join(", ")),
1222            )
1223        })
1224        .collect();
1225    let lw = rows.iter().map(|r| r.1.chars().count()).max().unwrap_or(0);
1226    let ew = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(0);
1227    for (id, label, expr) in &rows {
1228        let mut note = String::new();
1229        if in_cycle.contains(id) {
1230            note.push_str("  ↺");
1231        }
1232        if Some(*id) == page {
1233            note.push_str(if r.view_is_per_session {
1234                "  ← the page, per session"
1235            } else {
1236                "  ← the page, broadcast"
1237            });
1238        } else if g.sinks.contains(id) {
1239            note.push_str("  ← a sink nothing reads");
1240        }
1241        let _ = writeln!(
1242            out,
1243            "  {label:<lw$}  {expr:<ew$}  {:<7}{note}",
1244            g.node(*id).tier.name(),
1245        );
1246    }
1247
1248    let _ = writeln!(out, "\naccumulator");
1249    if r.is_fused() {
1250        let _ = writeln!(
1251            out,
1252            "  {} durable folds, fused into one record — §3.7 fixes one totally-ordered log per\n  \
1253             application, so two folds are two projections of it rather than two logs.",
1254            r.states.len()
1255        );
1256        for s in &r.states {
1257            let _ = writeln!(out, "    {FUSED_STATE}.{} : {}", s.name, s.ty);
1258        }
1259    } else {
1260        let _ = writeln!(
1261            out,
1262            "  one durable fold — `{}` : {}",
1263            r.states[0].name, r.states[0].ty
1264        );
1265    }
1266
1267    let plan = slice_of(g, page.unwrap_or(0));
1268    let computed: Vec<&str> = plan
1269        .iter()
1270        .copied()
1271        .filter(|&i| {
1272            !matches!(g.node(i).op, Op::Durable | Op::Fold { .. })
1273                && !g.node(i).op.is_stream()
1274                && Some(i) != page
1275        })
1276        .map(|i| g.label(i))
1277        .collect();
1278    let _ = writeln!(out, "\nthe view recomputes, per event");
1279    let _ = writeln!(
1280        out,
1281        "  {}",
1282        if computed.is_empty() {
1283            "nothing between the accumulator and the page".to_string()
1284        } else {
1285            computed.join(", ")
1286        }
1287    );
1288    let _ = writeln!(
1289        out,
1290        "  shared: {}",
1291        if r.shared.is_empty() {
1292            "—  (no signal is read by two consumers, so nothing is bound twice)".to_string()
1293        } else {
1294            format!(
1295                "{}  (read by more than one consumer, so computed once)",
1296                r.shared
1297                    .iter()
1298                    .map(|s| s.to_string())
1299                    .collect::<Vec<_>>()
1300                    .join(", ")
1301            )
1302        }
1303    );
1304    let _ = writeln!(
1305        out,
1306        "  (§5.3 makes these incremental; today every one is a full recompute)"
1307    );
1308
1309    if !g.cuts.is_empty() {
1310        let _ = writeln!(
1311            out,
1312            "\ntier crossings — each is one subscription, resumable by (id, seq) (§4.3)"
1313        );
1314        let edges: Vec<(String, String, String)> = g
1315            .cuts
1316            .iter()
1317            .map(|c| {
1318                (
1319                    format!("{} → {}", g.label(c.from), g.label(c.to)),
1320                    format!(
1321                        "{} → {}",
1322                        g.node(c.from).tier.name(),
1323                        g.node(c.to).tier.name()
1324                    ),
1325                    format!("{}", c.carries),
1326                )
1327            })
1328            .collect();
1329        let nw = edges.iter().map(|e| e.0.chars().count()).max().unwrap_or(0);
1330        let tw = edges.iter().map(|e| e.1.chars().count()).max().unwrap_or(0);
1331        let cw = edges.iter().map(|e| e.2.chars().count()).max().unwrap_or(0);
1332        for (c, (names, tiers, carries)) in g.cuts.iter().zip(&edges) {
1333            let _ = writeln!(
1334                out,
1335                "  {names:<nw$}  {tiers:<tw$}  carries {carries:<cw$}  {}",
1336                c.id
1337            );
1338        }
1339    }
1340    out
1341}
1342
1343/// Every tier crossing, for `beck explain flow` and for the report.
1344pub fn crossings(placed: &Placed) -> &[Cut] {
1345    &placed.graph.cuts
1346}
1347
1348/// Every vertex reachable from a sink, in dependency order — the sub-plan one role executes.
1349pub fn slice_of(graph: &Graph, sink: SigId) -> Vec<SigId> {
1350    let mut seen = BTreeSet::new();
1351    let mut stack = vec![sink];
1352    while let Some(id) = stack.pop() {
1353        if !seen.insert(id) {
1354            continue;
1355        }
1356        for &i in &graph.node(id).inputs {
1357            stack.push(i);
1358        }
1359    }
1360    graph
1361        .order()
1362        .into_iter()
1363        .filter(|i| seen.contains(i))
1364        .collect()
1365}
1366
1367#[cfg(test)]
1368pub(crate) mod tests {
1369    use super::*;
1370    use crate::compile_str;
1371
1372    /// The sketch's program shape, in the Python surface.
1373    pub const TODO: &str = r#"
1374type Id = newtype[Str]
1375
1376model Todo:
1377    id: Id
1378    text: Str
1379    done: Bool
1380    owner: Str
1381
1382model State:
1383    todos: Map[Id, Todo]
1384
1385union Command:
1386    Add(id: Id, text: Str)
1387    Toggle(id: Id)
1388    Delete(id: Id)
1389
1390union Event:
1391    Added(id: Id, text: Str)
1392    Toggled(id: Id)
1393    Deleted(id: Id)
1394
1395union Rejection:
1396    BlankText
1397    IdTaken
1398    NoSuchTodo
1399    NotOwner
1400
1401def apply_event(s: State, env: Envelope[Event]) -> State:
1402    match env.body:
1403        case Added(id, text):
1404            return s.with(todos=map_insert(s.todos, id, Todo(id=id, text=text, done=False, owner=env.actor)))
1405        case Toggled(id):
1406            return toggle(s, id)
1407        case Deleted(id):
1408            return s.with(todos=map_remove(s.todos, id))
1409
1410def toggle(s: State, id: Id) -> State:
1411    match map_get(s.todos, id):
1412        case Some(value):
1413            return s.with(todos=map_insert(s.todos, id, value.with(done=not value.done)))
1414        case None:
1415            return s
1416
1417def validate(s: State, p: Proposal) -> Result[list[Event], Rejection]:
1418    match p.command:
1419        case Add(id, text):
1420            if str_is_empty(str_trim(text)):
1421                return Err(error=BlankText)
1422            if map_contains(s.todos, id):
1423                return Err(error=IdTaken)
1424            return Ok(value=[Added(id=id, text=text)])
1425        case Toggle(id):
1426            return owned(s, p, id, [Toggled(id=id)])
1427        case Delete(id):
1428            return owned(s, p, id, [Deleted(id=id)])
1429
1430def owned(s: State, p: Proposal, id: Id, evs: list[Event]) -> Result[list[Event], Rejection]:
1431    match map_get(s.todos, id):
1432        case Some(value):
1433            if value.owner != p.session.actor:
1434                return Err(error=NotOwner)
1435            return Ok(value=evs)
1436        case None:
1437            return Err(error=NoSuchTodo)
1438
1439def mine(s: State, session: Session) -> list[Todo]:
1440    return sort_by(filter_list(map_values(s.todos), lambda t: t.owner == session.actor), lambda t: t.text)
1441
1442def remaining_of(todos: list[Todo]) -> Int:
1443    return list_len(filter_list(todos, lambda t: not t.done))
1444
1445def view(s: State, session: Session) -> Html:
1446    todos = mine(s, session)
1447    return render(todos, remaining_of(todos))
1448
1449def render(todos: list[Todo], remaining: Int) -> Html:
1450    return ui:
1451        main:
1452            h1: "todos"
1453            ul:
1454                for t in todos:
1455                    li(key=t.id, class=done_class(t)):
1456                        span(on_click=Toggle(id=t.id)): t.text
1457            footer: (str(remaining) + " remaining")
1458
1459def done_class(t: Todo) -> Str:
1460    return "done" if t.done else ""
1461
1462@on(server)
1463proposals: Stream[Proposal] = merge_clients()
1464
1465@on(server)
1466events: Stream[Event] = decide(proposals, todos, validate)
1467
1468@on(data)
1469todos: Signal[State] = durable(fold(apply_event, State(todos={}), events))
1470
1471@on(client)
1472page: Signal[Html] = per_session(todos, view)
1473"#;
1474
1475    #[test]
1476    fn the_sketch_compiles_and_slices_into_roles() {
1477        let (placed, d, map) = compile_str("todo.beck", TODO);
1478        assert!(!d.has_errors(), "{}", d.render(&map));
1479        let placed = placed.expect("splitting succeeds");
1480        assert_eq!(placed.roles.state_name.as_ref(), "todos");
1481        assert_eq!(placed.roles.events_name.as_ref(), "events");
1482        assert_eq!(placed.roles.page_name.as_ref(), "page");
1483        assert!(placed.roles.view_is_per_session);
1484        assert_eq!(placed.roles.event_ty.con_name(), Some("Event"));
1485        assert_eq!(placed.roles.command_ty.con_name(), Some("Command"));
1486        assert_eq!(placed.wire_id.len(), 16);
1487        // One fold, so the accumulator is the program's own type and nothing is fused: every
1488        // claim any earlier phase made about this program is unchanged by the general slicer.
1489        assert!(!placed.roles.is_fused());
1490        assert_eq!(placed.roles.states.len(), 1);
1491        assert_eq!(placed.roles.state_ty.con_name(), Some("State"));
1492    }
1493
1494    #[test]
1495    fn the_graph_holds_the_fold_as_its_own_vertex() {
1496        // `durable(fold(…))` is two operations and therefore two vertices, even though the program
1497        // named only one of them. That is the difference between a graph and a pattern: nothing
1498        // downstream has to know that `durable` "means" `durable-of-a-fold`.
1499        let (placed, _, _) = compile_str("todo.beck", TODO);
1500        let g = &placed.expect("placed").graph;
1501        assert_eq!(g.states().len(), 1);
1502        let durable = g.states()[0];
1503        let inner = g.node(durable).inputs[0];
1504        assert!(matches!(g.node(inner).op, Op::Fold { .. }));
1505        assert_eq!(g.label(inner), "todos·fold");
1506        assert_eq!(
1507            g.node(inner).name,
1508            None,
1509            "an inner vertex has no written name"
1510        );
1511    }
1512
1513    #[test]
1514    fn the_only_cycle_is_the_one_the_design_says_is_sound() {
1515        // §3.7: "`events` is decided from the state, and the state is folded from `events`. The
1516        // cycle is real and it is sound."
1517        let (placed, _, _) = compile_str("todo.beck", TODO);
1518        let g = &placed.expect("placed").graph;
1519        let cycles: Vec<Vec<String>> = g
1520            .dep
1521            .cycles()
1522            .map(|c| {
1523                c.iter()
1524                    .map(|n| g.label(n.0 as usize).to_string())
1525                    .collect()
1526            })
1527            .collect();
1528        assert_eq!(cycles.len(), 1, "{cycles:?}");
1529        assert!(cycles[0].iter().any(|n| n == "events"));
1530        assert!(cycles[0].iter().any(|n| n == "todos"));
1531        assert!(cycles[0].iter().any(|n| n == "todos·fold"));
1532    }
1533
1534    #[test]
1535    fn the_wire_id_is_content_derived_and_stable_under_a_body_edit() {
1536        let (a, _, _) = compile_str("todo.beck", TODO);
1537        // Change a body, not a signature: the operation id must not move (§4.3).
1538        let edited = TODO.replace(
1539            r#""done" if t.done else """#,
1540            r#""done" if t.done else " ""#,
1541        );
1542        let (b, d, map) = compile_str("todo.beck", &edited);
1543        assert!(!d.has_errors(), "{}", d.render(&map));
1544        assert_eq!(
1545            a.expect("a").wire_id,
1546            b.expect("b").wire_id,
1547            "a body edit must not change the wire id"
1548        );
1549    }
1550
1551    #[test]
1552    fn the_wire_id_moves_when_the_wire_actually_changes() {
1553        // The other half of the same requirement, and the one a name-hash silently fails: adding a
1554        // variant to `Event` changes what a subscriber can be sent, so the operation id has to move
1555        // or a rolling deploy has no way to notice.
1556        let (a, _, _) = compile_str("todo.beck", TODO);
1557        let changed = TODO
1558            .replace(
1559                "    Toggled(id: Id)\n    Deleted(id: Id)",
1560                "    Toggled(id: Id)\n    Deleted(id: Id)\n    Starred(id: Id)",
1561            )
1562            .replace(
1563                "        case Deleted(id):\n            return s.with(todos=map_remove(s.todos, id))",
1564                "        case Deleted(id):\n            return s.with(todos=map_remove(s.todos, id))\n        case Starred(id):\n            return toggle(s, id)",
1565            );
1566        let (b, d, map) = compile_str("todo.beck", &changed);
1567        assert!(!d.has_errors(), "{}", d.render(&map));
1568        assert_ne!(a.expect("a").wire_id, b.expect("b").wire_id);
1569
1570        // …and a field added to a command moves it too, which a hash of the type's *name* would
1571        // not have caught either.
1572        let widened = TODO
1573            .replace(
1574                "    Toggle(id: Id)\n    Delete(id: Id)",
1575                "    Toggle(id: Id, at: Int)\n    Delete(id: Id)",
1576            )
1577            .replace("case Toggle(id):", "case Toggle(id, at):")
1578            .replace(
1579                "span(on_click=Toggle(id=t.id)): t.text",
1580                "span(on_click=Toggle(id=t.id, at=0)): t.text",
1581            );
1582        let (c, d, map) = compile_str("todo.beck", &widened);
1583        assert!(!d.has_errors(), "{}", d.render(&map));
1584        let (a, _, _) = compile_str("todo.beck", TODO);
1585        assert_ne!(a.expect("a").wire_id, c.expect("c").wire_id);
1586    }
1587
1588    #[test]
1589    fn a_program_with_no_merge_point_is_told_what_is_missing() {
1590        let (_, d, _) = compile_str("t.beck", "def f() -> Int:\n    return 1\n");
1591        assert!(d.iter().any(|x| x.code == "B0500" && x.fix.is_some()));
1592    }
1593
1594    #[test]
1595    fn a_view_that_reads_a_stream_is_refused_by_name() {
1596        // The narrowness that remains is about *meaning*: a `Stream` is occurrences and a view
1597        // renders a value. B0507 says which, rather than "unsupported".
1598        let src = TODO
1599            .replace(
1600                "@on(client)\npage: Signal[Html] = per_session(todos, view)",
1601                "@on(client)\npage: Signal[Html] = signal_map(events, render_ev)",
1602            )
1603            .replace(
1604                "@on(server)\nproposals",
1605                "def render_ev(e: Event) -> Html:\n    return ui:\n        main: \"x\"\n\n@on(server)\nproposals",
1606            );
1607        let (placed, d, _) = compile_str("t.beck", &src);
1608        assert!(placed.is_none());
1609        assert!(d.has_errors(), "a refusal must say why");
1610    }
1611
1612    #[test]
1613    fn a_cycle_with_no_fold_in_it_is_refused_rather_than_looped_on() {
1614        // The rule that makes slicing terminate, stated as a program the compiler must reject.
1615        let src = TODO.replace(
1616            "@on(data)\ntodos: Signal[State] = durable(fold(apply_event, State(todos={}), events))",
1617            "@on(data)\ntodos: Signal[State] = durable(fold(apply_event, State(todos={}), events))\n\
1618             \nloop_a: Signal[State] = signal_map(loop_b, identity_state)\n\
1619             \nloop_b: Signal[State] = signal_map(loop_a, identity_state)",
1620        );
1621        let src = src.replace(
1622            "def done_class",
1623            "def identity_state(s: State) -> State:\n    return s\n\ndef done_class",
1624        );
1625        let (placed, d, _) = compile_str("t.beck", &src);
1626        assert!(placed.is_none(), "a self-defined signal has no first value");
1627        assert!(
1628            d.iter().any(|x| x.code == "B0509"),
1629            "{:?}",
1630            d.iter().map(|x| x.code).collect::<Vec<_>>()
1631        );
1632    }
1633}