beck_core/
signal.rs

1//! The signal graph, as a graph.
2//!
3//! [`docs/03-type-and-effect-system.md`](../../../../../docs/03-type-and-effect-system.md) §3.7:
4//! "**The signal graph is a graph, not a pipeline.** This section reads top-to-bottom, and the
5//! programs it describes do not: `events` is decided from the state, and the state is folded from
6//! `events`. The cycle is real and it is sound."
7//!
8//! Phase 1 and Phase 2 read the graph by *recognising one shape*: find the `merge_clients()`, find
9//! the `durable`, find the `decide`, find the first client-placed signal, and inline everything
10//! between them ([`docs/19-phase-1-report.md`](../../../../../docs/19-phase-1-report.md) §19.9). That
11//! was legitimate narrowness because it announced itself — nine diagnostics refused every other
12//! shape — and it was named as debt by two phases running. It also had a hole neither report knew
13//! about: a program with *two* durable folds matched the shape, was accepted, and was sliced with
14//! both folds reading the same accumulator. See
15//! [`docs/23-general-slicer-report.md`](../../../../../docs/23-general-slicer-report.md) §23.2.
16//!
17//! This module is the replacement. It does not recognise a shape. It builds the graph the program
18//! wrote — one vertex per signal operation, including the ones nested inside a declaration —
19//! computes its strongly connected components, and hands [`crate::split`] a structure to slice.
20//! What used to be "the durable one" is now "the vertices whose op is [`Op::Durable`]", and there
21//! may be any number of them.
22//!
23//! # What a vertex is
24//!
25//! A *declared* signal contributes one vertex per prim application in its expression, not one per
26//! declaration. `todos: Signal[State] = durable(fold(apply_event, empty, events))` is two vertices
27//! — a [`Op::Durable`] over a [`Op::Fold`] — because the fold is a node in the dataflow whether or
28//! not the program gave it a name. Only the outermost carries the declared name; the inner one is
29//! labelled `todos·fold` so a diagnostic and `beck explain flow` can still point at it.
30//!
31//! That is the difference between a graph and a pattern: `map2(f, durable(fold(…)), summary)`
32//! needs no new case here, because there was never a case to begin with.
33//!
34//! # Cycles
35//!
36//! The condensation is computed by [`crate::graph::DepGraph`], which already does Tarjan
37//! iteratively over a CSR adjacency and numbers components in topological order. Reusing it rather
38//! than writing a second SCC pass is the point of it being a separate module.
39//!
40//! One rule is imposed on the result: **every cycle must contain a fold**. The `decide → durable →
41//! fold → decide` cycle is sound because the fold is where the recursion bottoms out — the
42//! accumulator is a value the slicer can take as a parameter. A cycle of pure `signal_map`s has no
43//! such point and is a program with no meaning; [`Graph::build`] refuses it by name rather than
44//! looping.
45
46use std::collections::BTreeMap;
47use std::sync::Arc;
48
49use beck_diag::{Diagnostic, Diagnostics, Span};
50
51use crate::check::Program;
52use crate::core::{Core, CoreKind, Prim};
53use crate::graph::{DepGraph, EdgeKind, GraphBuilder, GraphNode, NodeId, NodeKind};
54use crate::ty::{Tier, Ty};
55
56/// An index into [`Graph::nodes`].
57pub type SigId = usize;
58
59/// The accumulator a program with several durable folds is compiled to.
60///
61/// §3.7 fixes "one totally-ordered log per application", and the runtime holds one accumulator over
62/// it. A program that writes two `durable` folds has not asked for two logs; it has asked for two
63/// projections of one. [`crate::split`] fuses them into a record of this type, which is why the
64/// name is unwritable in the surface syntax: it is a compiler product, and no module publishes it.
65pub const FUSED_STATE: &str = "$State";
66
67/// What a vertex does. One variant per construct in §3.7's signal vocabulary.
68#[derive(Clone, Debug)]
69pub enum Op {
70    /// `merge_clients()` — the one place time and nondeterminism enter.
71    Ingress,
72    /// `presence()` — who is connected now.
73    ///
74    /// A source like [`Op::Ingress`] and a `Signal` rather than a `Stream`: connections are a value
75    /// defined at all times, not occurrences. It is the only vertex here that is neither the log
76    /// nor derived from it, which is the one fact everything else about it follows from — see
77    /// [`crate::split`] for what that forbids and [`crate::plan`] for what it costs.
78    Presence,
79    /// `decide(proposals, state, validate)` — §3.5's authority chokepoint, as a node.
80    Decide { validate: Core },
81    /// `fold(step, init, stream)`. The accumulator, and the point at which a cycle bottoms out.
82    Fold { step: Core, init: Core },
83    /// `durable(signal)` — the accumulator that survives a restart, and therefore the one the log
84    /// is *of*.
85    Durable,
86    /// `signal_map(s, f)`.
87    Map { f: Core },
88    /// `map2(f, a, b)`.
89    Map2 { f: Core },
90    /// `per_session(s, f)` — §3.8's fanout point, first-class so that Phase 3 can share the
91    /// arrangement above it.
92    PerSession { f: Core },
93    /// `filter_map(s, f)` on a stream.
94    FilterMap { f: Core },
95    /// A signal declared as another signal: `mirror: Signal[T] = todos`.
96    Alias,
97}
98
99impl Op {
100    pub fn name(&self) -> &'static str {
101        match self {
102            Op::Ingress => "merge_clients",
103            Op::Presence => "presence",
104            Op::Decide { .. } => "decide",
105            Op::Fold { .. } => "fold",
106            Op::Durable => "durable",
107            Op::Map { .. } => "signal_map",
108            Op::Map2 { .. } => "map2",
109            Op::PerSession { .. } => "per_session",
110            Op::FilterMap { .. } => "filter_map",
111            Op::Alias => "alias",
112        }
113    }
114
115    /// Whether this vertex carries a `Stream` rather than a `Signal` — occurrences rather than a
116    /// value defined at all times (§3.7). A view is a function of signals, so a stream vertex on a
117    /// view's path is an error rather than a missing feature.
118    pub fn is_stream(&self) -> bool {
119        matches!(self, Op::Ingress | Op::Decide { .. } | Op::FilterMap { .. })
120    }
121}
122
123#[derive(Clone, Debug)]
124pub struct Node {
125    /// The declared name, when this vertex *is* a signal declaration rather than a sub-expression
126    /// of one.
127    pub name: Option<Arc<str>>,
128    /// What to call it in a diagnostic: the declared name, or `<parent>·<op>` for an inner vertex.
129    pub label: Arc<str>,
130    pub op: Op,
131    pub ty: Ty,
132    pub tier: Tier,
133    /// The vertices this one reads, in the order the construct takes them.
134    pub inputs: Vec<SigId>,
135    pub span: Span,
136}
137
138/// A dataflow edge whose two ends are on different tiers.
139///
140/// §4.3: "Every signal edge that crosses tiers becomes a subscription: the server side gets a diff
141/// operator, the client side a resumable `(subscription, seq)` consumer." Phase 1 and Phase 2 knew
142/// about exactly one crossing and printed a sentence about it; this enumerates them, and gives each
143/// the content-derived id a resumable subscription is keyed by.
144#[derive(Clone, Debug)]
145pub struct Cut {
146    /// The consumer — the downstream end, which subscribes.
147    pub to: SigId,
148    /// The producer — the upstream end, which diffs and streams.
149    pub from: SigId,
150    pub carries: Ty,
151    /// `blake3(module, producer, consumer, structural(carried))[..16]`, by the same rule as the
152    /// command channel's operation id: content, not names a human maintains.
153    pub id: String,
154}
155
156/// The signal graph of one program.
157#[derive(Clone, Debug)]
158pub struct Graph {
159    pub nodes: Vec<Node>,
160    /// Declared signal names to their vertices. Inner vertices are not in here — they have no name
161    /// a program can write.
162    pub by_name: BTreeMap<Arc<str>, SigId>,
163    /// The condensation, for cycles and for order.
164    pub dep: DepGraph,
165    pub cuts: Vec<Cut>,
166    /// Vertices nothing reads. A view is one; so is a materialised read model.
167    pub sinks: Vec<SigId>,
168}
169
170impl Graph {
171    pub fn node(&self, id: SigId) -> &Node {
172        &self.nodes[id]
173    }
174
175    /// Every vertex, dependencies before dependents, cycle members adjacent.
176    pub fn order(&self) -> Vec<SigId> {
177        self.dep
178            .topological()
179            .iter()
180            .map(|n| n.0 as usize)
181            .collect()
182    }
183
184    /// What reads this vertex.
185    pub fn consumers(&self, id: SigId) -> Vec<SigId> {
186        self.dep
187            .dependents(NodeId(id as u32))
188            .iter()
189            .map(|e| e.to.0 as usize)
190            .collect()
191    }
192
193    pub fn find(&self, f: impl Fn(&Op) -> bool) -> Vec<SigId> {
194        self.nodes
195            .iter()
196            .enumerate()
197            .filter(|(_, n)| f(&n.op))
198            .map(|(i, _)| i)
199            .collect()
200    }
201
202    /// The durable accumulators, in declaration order — what the log is of.
203    pub fn states(&self) -> Vec<SigId> {
204        self.find(|o| matches!(o, Op::Durable))
205    }
206
207    pub fn ingress(&self) -> Vec<SigId> {
208        self.find(|o| matches!(o, Op::Ingress))
209    }
210
211    /// The connection sets — what is *not* in the log.
212    pub fn presences(&self) -> Vec<SigId> {
213        self.find(|o| matches!(o, Op::Presence))
214    }
215
216    pub fn decides(&self) -> Vec<SigId> {
217        self.find(|o| matches!(o, Op::Decide { .. }))
218    }
219
220    /// The name a report should use for a vertex.
221    pub fn label(&self, id: SigId) -> &str {
222        &self.nodes[id].label
223    }
224
225    // -------------------------------------------------------------------------------------
226    // Building
227    // -------------------------------------------------------------------------------------
228
229    /// Build the graph a checked program declares, or refuse it by name.
230    pub fn build(program: &Program, diags: &mut Diagnostics) -> Option<Graph> {
231        let mut by_name = BTreeMap::new();
232        for (i, s) in program.signals.iter().enumerate() {
233            by_name.insert(s.name.clone(), i);
234        }
235
236        let mut b = Builder {
237            by_name: &by_name,
238            nodes: (0..program.signals.len()).map(|_| None).collect(),
239            labels: program.signals.iter().map(|s| s.name.clone()).collect(),
240            diags,
241            ok: true,
242        };
243        for (i, s) in program.signals.iter().enumerate() {
244            let Some((op, inputs)) = b.classify(&s.expr, &s.name, s.tier) else {
245                continue;
246            };
247            b.nodes[i] = Some(Node {
248                name: Some(s.name.clone()),
249                label: s.name.clone(),
250                op,
251                ty: s.ty.clone(),
252                tier: s.tier,
253                inputs,
254                span: s.span,
255            });
256        }
257        if !b.ok {
258            return None;
259        }
260        let nodes: Vec<Node> = b.nodes.into_iter().collect::<Option<Vec<_>>>()?;
261
262        // The condensation, from the module that already knows how to compute one.
263        let mut gb = GraphBuilder::new();
264        for n in &nodes {
265            gb.node(GraphNode {
266                name: n.label.clone(),
267                kind: NodeKind::Signal,
268                tier: n.tier,
269                effects: Vec::new(),
270                because: String::new(),
271                span: n.span,
272            });
273        }
274        for (i, n) in nodes.iter().enumerate() {
275            for &input in &n.inputs {
276                gb.edge(NodeId(i as u32), NodeId(input as u32), EdgeKind::Reads);
277            }
278        }
279        let dep = gb.finish();
280
281        // §3.7's cycle is sound because a fold is in it: the accumulator is a value, so slicing
282        // stops there. A cycle without one is a signal defined in terms of itself, and there is
283        // nothing to compute.
284        let mut ok = true;
285        for cycle in dep.cycles() {
286            if cycle
287                .iter()
288                .any(|c| matches!(nodes[c.0 as usize].op, Op::Fold { .. }))
289            {
290                continue;
291            }
292            ok = false;
293            let members: Vec<&str> = cycle
294                .iter()
295                .map(|c| nodes[c.0 as usize].label.as_ref())
296                .collect();
297            diags.push(
298                Diagnostic::error(
299                    "B0509",
300                    format!("`{}` is defined in terms of itself", members[0]),
301                    nodes[cycle[0].0 as usize].span,
302                )
303                .with_primary_label(format!("the cycle is {}", members.join(" → ")))
304                .with_note(
305                    "§3.7's `events → todos → events` cycle is sound because a `fold` is in it: an \
306                     accumulator is a value, so the recursion has a bottom. This one has no fold, \
307                     so there is no first value to compute",
308                ),
309            );
310        }
311        if !ok {
312            return None;
313        }
314
315        let sinks: Vec<SigId> = (0..nodes.len())
316            .filter(|i| dep.dependents(NodeId(*i as u32)).is_empty())
317            .collect();
318
319        let mut cuts = Vec::new();
320        for (i, n) in nodes.iter().enumerate() {
321            for &input in &n.inputs {
322                let up = &nodes[input];
323                if n.tier == Tier::Any || up.tier == Tier::Any || n.tier == up.tier {
324                    continue;
325                }
326                let carries = signal_elem(&up.ty);
327                let mut h = blake3::Hasher::new();
328                h.update(program.name.as_bytes());
329                h.update(up.label.as_bytes());
330                h.update(b"\x00");
331                h.update(n.label.as_bytes());
332                h.update(b"\x00");
333                h.update(crate::iface::structural(&carries, &program.types).as_bytes());
334                cuts.push(Cut {
335                    to: i,
336                    from: input,
337                    carries,
338                    id: h.finalize().to_hex()[..16].to_string(),
339                });
340            }
341        }
342
343        Some(Graph {
344            nodes,
345            by_name,
346            dep,
347            cuts,
348            sinks,
349        })
350    }
351}
352
353/// The element a `Signal[T]` or `Stream[T]` carries.
354pub fn signal_elem(t: &Ty) -> Ty {
355    match t {
356        Ty::Con(n, args)
357            if (n.as_ref() == Ty::STREAM || n.as_ref() == Ty::SIGNAL) && args.len() == 1 =>
358        {
359            args[0].clone()
360        }
361        other => other.clone(),
362    }
363}
364
365/// The synthetic accumulator a program with several durable folds is compiled to.
366///
367/// One field per fold, named for the signal that declared it — so `beck explain flow` and a
368/// diagnostic can say `$State.counts` and mean something the programmer wrote.
369pub fn fused_state_decl(folds: &[(Arc<str>, Ty)]) -> crate::ty::TyDecl {
370    crate::ty::TyDecl::Model {
371        name: Arc::from(FUSED_STATE),
372        params: Vec::new(),
373        fields: folds.to_vec(),
374    }
375}
376
377/// Every `durable` a set of signal declarations holds, labelled exactly as [`Graph::build`] labels
378/// it, in declaration order.
379///
380/// [`crate::split`] reads this off the graph. The **checker** has to answer the same question
381/// before a graph exists, because a `test` block's `state` is typed against the accumulator and a
382/// fused one is a type the program did not write. Both go through here so the two cannot disagree
383/// about how many folds there are or what their fields are called.
384///
385/// `resolve` is the caller's substitution: mid-check a declaration's type is still a variable, and
386/// after checking it is not.
387pub fn durables(
388    signals: &[crate::check::SignalDecl],
389    resolve: &mut dyn FnMut(&Ty) -> Ty,
390) -> Vec<(Arc<str>, Ty)> {
391    fn walk(expr: &Core, owner: &Arc<str>, out: &mut Vec<(Arc<str>, Ty)>, top: bool) {
392        let CoreKind::Prim { op, args } = &expr.kind else {
393            return;
394        };
395        if *op == Prim::Durable {
396            // The same rule [`Builder::input`] uses: the outermost vertex of a declaration carries
397            // the declared name, an inner one is `<owner>·<op>`. Only durables can collide with
398            // durables, so counting them alone gives the same suffixes the full walk does.
399            let label: Arc<str> = if top {
400                owner.clone()
401            } else {
402                let base = format!("{owner}·durable");
403                let mut candidate: Arc<str> = Arc::from(base.as_str());
404                let mut n = 2;
405                while out.iter().any(|(l, _)| *l == candidate) {
406                    candidate = Arc::from(format!("{base}{n}"));
407                    n += 1;
408                }
409                candidate
410            };
411            out.push((label, expr.ty.clone()));
412        }
413        for a in args {
414            walk(a, owner, out, false);
415        }
416    }
417    let mut out = Vec::new();
418    for s in signals {
419        walk(&s.expr, &s.name, &mut out, true);
420    }
421    // Mid-check a `durable`'s type is still a variable, so resolve before unwrapping the `Signal`.
422    for (_, ty) in out.iter_mut() {
423        *ty = signal_elem(&resolve(ty));
424    }
425    out
426}
427
428struct Builder<'a, 'd> {
429    by_name: &'a BTreeMap<Arc<str>, SigId>,
430    nodes: Vec<Option<Node>>,
431    /// Every label handed out so far. An inner vertex is named for its owner and its op, and one
432    /// declaration can hold two of the same op — `map2(f, signal_map(a, g), signal_map(b, h))` —
433    /// so the second gets a number. Labels are the graph's vertex keys, and two vertices sharing
434    /// one would silently become a single vertex.
435    labels: std::collections::BTreeSet<Arc<str>>,
436    diags: &'d mut Diagnostics,
437    ok: bool,
438}
439
440impl Builder<'_, '_> {
441    /// Turn one signal expression into an op and the vertices it reads, creating vertices for any
442    /// nested prim application on the way.
443    fn classify(&mut self, expr: &Core, owner: &Arc<str>, tier: Tier) -> Option<(Op, Vec<SigId>)> {
444        match &expr.kind {
445            CoreKind::Global(name) => {
446                let id = self.reference(name, expr)?;
447                Some((Op::Alias, vec![id]))
448            }
449            CoreKind::Prim { op, args } => match (op, args.len()) {
450                (Prim::MergeClients, 0) => Some((Op::Ingress, Vec::new())),
451                (Prim::Presence, 0) => Some((Op::Presence, Vec::new())),
452                (Prim::Decide, 3) => {
453                    let proposals = self.input(&args[0], owner, tier)?;
454                    let state = self.input(&args[1], owner, tier)?;
455                    Some((
456                        Op::Decide {
457                            validate: args[2].clone(),
458                        },
459                        vec![proposals, state],
460                    ))
461                }
462                (Prim::Fold, 3) => {
463                    let stream = self.input(&args[2], owner, tier)?;
464                    Some((
465                        Op::Fold {
466                            step: args[0].clone(),
467                            init: args[1].clone(),
468                        },
469                        vec![stream],
470                    ))
471                }
472                (Prim::Durable, 1) => {
473                    let inner = self.input(&args[0], owner, tier)?;
474                    Some((Op::Durable, vec![inner]))
475                }
476                (Prim::SignalMap, 2) => {
477                    let input = self.input(&args[0], owner, tier)?;
478                    Some((Op::Map { f: args[1].clone() }, vec![input]))
479                }
480                (Prim::SignalMap2, 3) => {
481                    let a = self.input(&args[1], owner, tier)?;
482                    let b = self.input(&args[2], owner, tier)?;
483                    Some((Op::Map2 { f: args[0].clone() }, vec![a, b]))
484                }
485                (Prim::PerSession, 2) => {
486                    let input = self.input(&args[0], owner, tier)?;
487                    Some((Op::PerSession { f: args[1].clone() }, vec![input]))
488                }
489                (Prim::StreamFilterMap, 2) => {
490                    let input = self.input(&args[0], owner, tier)?;
491                    Some((Op::FilterMap { f: args[1].clone() }, vec![input]))
492                }
493                (other, n) => {
494                    self.fail(
495                        Diagnostic::error(
496                            "B0507",
497                            format!("`{}` is not a signal construct", other.name()),
498                            expr.span,
499                        )
500                        .with_primary_label(format!("applied to {n} arguments here"))
501                        .with_note(
502                            "§3.7's signal vocabulary is `merge_clients`, `presence`, \
503                             `filter_map`, `fold`, `durable`, `signal_map`, `map2`, `per_session` \
504                             and `decide`; a signal's expression is built from those and nothing \
505                             else",
506                        ),
507                    );
508                    None
509                }
510            },
511            _ => {
512                self.fail(
513                    Diagnostic::error("B0508", "unsupported signal expression", expr.span)
514                        .with_primary_label("a signal is a node in the dataflow, not a computation")
515                        .with_note(
516                            "the computation goes in a `def`, and the signal names it: \
517                             `summary: Signal[Summary] = signal_map(counts, summarise)`",
518                        ),
519                );
520                None
521            }
522        }
523    }
524
525    /// The vertex an argument denotes: a named signal, or a fresh vertex for a nested application.
526    fn input(&mut self, expr: &Core, owner: &Arc<str>, tier: Tier) -> Option<SigId> {
527        if let CoreKind::Global(name) = &expr.kind {
528            return self.reference(name, expr);
529        }
530        let (op, inputs) = self.classify(expr, owner, tier)?;
531        let label = self.label(format!("{owner}·{}", op.name()));
532        self.nodes.push(Some(Node {
533            name: None,
534            label,
535            op,
536            ty: expr.ty.clone(),
537            tier,
538            inputs,
539            span: expr.span,
540        }));
541        Some(self.nodes.len() - 1)
542    }
543
544    fn reference(&mut self, name: &Arc<str>, at: &Core) -> Option<SigId> {
545        match self.by_name.get(name) {
546            Some(id) => Some(*id),
547            None => {
548                self.fail(
549                    Diagnostic::error(
550                        "B0506",
551                        format!("`{name}` is not a signal"),
552                        at.span,
553                    )
554                    .with_primary_label("a signal's inputs are other signals")
555                    .with_note(
556                        "a function is applied *through* a construct — `signal_map(s, f)` — rather \
557                         than named as an input",
558                    ),
559                );
560                None
561            }
562        }
563    }
564
565    fn label(&mut self, base: String) -> Arc<str> {
566        let mut candidate: Arc<str> = Arc::from(base.as_str());
567        let mut n = 2;
568        while self.labels.contains(&candidate) {
569            candidate = Arc::from(format!("{base}{n}"));
570            n += 1;
571        }
572        self.labels.insert(candidate.clone());
573        candidate
574    }
575
576    fn fail(&mut self, d: Diagnostic) {
577        self.ok = false;
578        self.diags.push(d);
579    }
580}