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}