cuttlefish_host/dag.rs
1//! Typechecking a node graph: topological order, fan-in composition via
2//! `InputExpr`, cycle rejection (unless marked `repeat_until`), and
3//! branch-exclusivity analysis for conditional dispatch.
4//!
5//! See docs/superpowers/specs/2026-08-03-dag-core-design.md for the full
6//! design. This module's `check_graph` is the graph-shaped analogue of
7//! `crate::pipeline::check` — same idea (typecheck seams before anything
8//! runs), different shape (a graph of named nodes instead of a `Vec`).
9
10use crate::pipeline::{read_stage_signature, PipelineError, ResolvedInput};
11use cuttlefish_abi::Ty;
12use cuttlefish_core::graph::{Branches, InputExpr, NodeGraph};
13use std::collections::{BTreeMap, HashMap, HashSet};
14use wasmtime::Engine;
15
16/// One checked node, ready to execute.
17///
18/// `Clone` because a later task's `resume_job` rebuilds a `JobSpec` from an
19/// `Arc<Vec<CheckedNode>>` held in `AppState` — every field type here
20/// (`Signature`, `ArtifactKind`, `InputExpr`) is already `Clone`.
21#[derive(Clone)]
22pub struct CheckedNode {
23 /// The node's name, as written in `nodes = {...}`.
24 pub name: String,
25 /// Block or bundle.
26 pub kind: crate::catalog::ArtifactKind,
27 /// The exact `name@version` this node resolved to, if it came from the
28 /// catalog.
29 pub resolved: Option<String>,
30 /// The compiled module (block) or `.cfbundle` (bundle) bytes.
31 pub module_bytes: Vec<u8>,
32 /// What it declared.
33 pub signature: cuttlefish_abi::Signature,
34 /// What feeds this node, if anything.
35 pub input: Option<InputExpr>,
36 /// Bounded-loop marker, if this node re-runs on its own output.
37 pub repeat_until: Option<String>,
38 /// Iteration bound, required alongside `repeat_until`.
39 pub max_iterations: Option<u32>,
40}
41
42/// A whole graph, typechecked and topologically ordered.
43pub struct CheckedGraph {
44 /// In topological order — safe to execute front-to-back, threading
45 /// `outputs` forward, per the spec's execution-semantics section.
46 pub nodes: Vec<CheckedNode>,
47 /// Which nodes are exclusive to which branch label, keyed by the
48 /// branching node's name — see "skip propagation" in `check_graph`.
49 pub exclusive_to: HashMap<String, BranchExclusivity>,
50}
51
52/// Which branch decision + label a node is exclusive to — a node is only
53/// executed when this decision's chosen route matches `label`. Carrying
54/// `decision` (not just `label`) is what lets two independent `branches`
55/// decisions that happen to reuse the same label string coexist without
56/// being confused for a conflict.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct BranchExclusivity {
59 /// The branching decision (key in `branches.decisions`) this exclusivity
60 /// belongs to.
61 pub decision: String,
62 /// The label within that decision.
63 pub label: String,
64}
65
66/// Why a graph was rejected.
67#[derive(Debug, thiserror::Error)]
68pub enum DagError {
69 /// A per-node signature lookup or seam check failed the same way a
70 /// linear pipeline's would.
71 #[error(transparent)]
72 Pipeline(#[from] PipelineError),
73 /// A node's `in` expression names a node that doesn't exist.
74 #[error("node `{node}` references unknown node `{referenced}`")]
75 UnknownReference {
76 /// The node whose `in` expression has the bad reference.
77 node: String,
78 /// The name it referenced.
79 referenced: String,
80 },
81 /// A node has an inbound edge that would form a cycle without an
82 /// explicit `repeat_until` marker.
83 #[error(
84 "node `{node}` has an inbound edge that would form a cycle with no \
85 repeat_until marker — add one (with max_iterations) if this loop is intentional"
86 )]
87 UnmarkedCycle {
88 /// The node that could not be ordered.
89 node: String,
90 },
91 /// A node's input mixes outputs from two mutually-exclusive branch labels
92 /// of the same decision.
93 #[error(
94 "node `{node}`'s input mixes output from branch label `{label_a}` and \
95 label `{label_b}` of the same `branches.{decision}` decision — a node \
96 cannot depend on more than one mutually-exclusive branch outcome at once"
97 )]
98 ConflictingBranchFanIn {
99 /// The node whose input mixes two labels.
100 node: String,
101 /// The branching decision both labels belong to.
102 decision: String,
103 /// The first label found.
104 label_a: String,
105 /// The second, conflicting label.
106 label_b: String,
107 },
108 /// A node's declared input doesn't accept what its `in` expression
109 /// produces.
110 #[error("node `{consumer}` needs {expected}, but `{producer}` produces {produced}")]
111 SeamMismatch {
112 /// What produced the mismatched value.
113 producer: String,
114 /// What it produces.
115 produced: String,
116 /// The node that needed something else.
117 consumer: String,
118 /// What it needs.
119 expected: String,
120 },
121 /// The graph had no nodes.
122 #[error("a graph needs at least one node")]
123 Empty,
124}
125
126/// Typecheck a graph. `resolved` must already contain one entry per node in
127/// `graph.nodes`, in the same order — building that mapping (via
128/// `pipeline::resolve_and_load`, one call per node) is the caller's job
129/// (a later task), same division of responsibility `pipeline::check` already has.
130pub fn check_graph(
131 engine: &Engine,
132 graph: &NodeGraph,
133 branches: &Branches,
134 resolved: &HashMap<String, ResolvedInput>,
135) -> Result<CheckedGraph, DagError> {
136 if graph.nodes.is_empty() {
137 return Err(DagError::Empty);
138 }
139
140 // 1. Read every node's signature up front — needed before topological
141 // evaluation since InputExpr composition needs to know each
142 // referenced node's *output* type, and a node can be referenced
143 // before it's "current" in visit order.
144 let mut signatures = HashMap::new();
145 for (name, _) in &graph.nodes {
146 let input = resolved.get(name).expect("caller resolved every node");
147 signatures.insert(name.clone(), read_stage_signature(engine, input)?);
148 }
149
150 // 2. Topological sort with cycle detection. An edge node -> referenced
151 // is only legal going "backward" (referenced already visited) unless
152 // `node` declares repeat_until, in which case a self-edge is exactly
153 // what's expected and not an error.
154 let order = topological_order(graph)?;
155
156 // 3. Branch-exclusivity: for each `branches` decision, walk forward from
157 // each label's target, marking every node whose InputExpr needs that
158 // target (transitively) as exclusive to that label. A node needing
159 // two labels of the *same* decision is a build-time error.
160 let exclusive_to = compute_branch_exclusivity(graph, branches)?;
161
162 // 4. For each node in topological order, evaluate its InputExpr into a
163 // Ty (composing referenced nodes' output types) and check
164 // assignable_to against its own declared input.
165 let mut nodes = Vec::with_capacity(order.len());
166 for name in &order {
167 let node = graph
168 .get(name)
169 .expect("topological_order only returns known nodes");
170 let input_resolved = resolved.get(name).expect("caller resolved every node");
171 let signature = signatures.get(name).unwrap().clone();
172
173 if let Some(expr) = &node.input {
174 let produced = evaluate_expr_ty(expr, &signatures, graph)?;
175 if !produced.assignable_to(&signature.input) {
176 let (producer, produced_str) = describe_expr(expr, &signatures);
177 return Err(DagError::SeamMismatch {
178 producer,
179 produced: produced_str,
180 consumer: name.clone(),
181 expected: signature.input.to_string(),
182 });
183 }
184 }
185
186 nodes.push(CheckedNode {
187 name: name.clone(),
188 kind: input_resolved.kind,
189 resolved: input_resolved.resolved.clone(),
190 module_bytes: input_resolved.bytes.clone(),
191 signature,
192 input: node.input.clone(),
193 repeat_until: node.repeat_until.clone(),
194 max_iterations: node.max_iterations,
195 });
196 }
197
198 Ok(CheckedGraph {
199 nodes,
200 exclusive_to,
201 })
202}
203
204/// A stable fingerprint of a checked graph's shape and contents — every
205/// node's name and declared signature, joined and hashed. Two graphs with
206/// the same fingerprint are the same, for resume-safety purposes; this
207/// isn't a security boundary, just a "did the loaded spec actually change"
208/// guard, so a simple SHA-256 (already a workspace dependency, same crate
209/// the catalog's own content-hashing uses) is all this needs.
210pub fn graph_fingerprint(nodes: &[CheckedNode]) -> String {
211 use sha2::{Digest, Sha256};
212 let mut hasher = Sha256::new();
213 for node in nodes {
214 hash_length_prefixed(&mut hasher, node.name.as_bytes());
215 hash_length_prefixed(&mut hasher, node.signature.to_string().as_bytes());
216 }
217 format!("{:x}", hasher.finalize())
218}
219
220/// Feed `bytes` into `hasher` prefixed with its own length (as a fixed
221/// 8-byte big-endian `u64`), rather than delimiting with a fixed byte —
222/// delimiter-joining is only injective if the delimiter can never appear
223/// inside the content itself, which isn't guaranteed here (a wasm block's
224/// declared signature can contain arbitrary field-name text, including, in
225/// principle, an embedded NUL byte decoded from its `cf_signature` export's
226/// JSON). Length-prefixing has no such assumption.
227fn hash_length_prefixed(hasher: &mut sha2::Sha256, bytes: &[u8]) {
228 use sha2::Digest;
229 hasher.update((bytes.len() as u64).to_be_bytes());
230 hasher.update(bytes);
231}
232
233/// Kahn's algorithm, with the repeat_until exception: an edge from `node`
234/// back to itself (or forming a cycle) is only legal when `node` declares
235/// `repeat_until` — everything else with an unresolved inbound edge after
236/// the sort terminates is an undeclared cycle.
237fn topological_order(graph: &NodeGraph) -> Result<Vec<String>, DagError> {
238 let mut deps: HashMap<&str, HashSet<&str>> = HashMap::new();
239 for (name, node) in &graph.nodes {
240 deps.entry(name).or_default();
241 if let Some(expr) = &node.input {
242 for referenced in referenced_nodes(expr) {
243 if graph.get(referenced).is_none() {
244 return Err(DagError::UnknownReference {
245 node: name.clone(),
246 referenced: referenced.to_string(),
247 });
248 }
249 // A node's own repeat_until self-reference is not a real
250 // dependency edge for ordering purposes — it re-runs on its
251 // own prior output, which the executor (a later task)
252 // special-cases, not the topological sort. A self-reference
253 // WITHOUT repeat_until is not this case — it's exactly the
254 // undeclared-cycle mistake the spec's `repeat_until`-as-sole-
255 // marker rule exists to catch, so it must still become a
256 // dependency edge (on itself) that can never be satisfied,
257 // tripping UnmarkedCycle below.
258 let is_marked_self_loop = referenced == name && node.repeat_until.is_some();
259 if !is_marked_self_loop {
260 deps.entry(name).or_default().insert(referenced);
261 }
262 }
263 }
264 }
265
266 let mut order = Vec::new();
267 let mut remaining: HashMap<&str, HashSet<&str>> = deps.clone();
268 loop {
269 let ready: Vec<&str> = remaining
270 .iter()
271 .filter(|(_, d)| d.is_empty())
272 .map(|(n, _)| *n)
273 .collect();
274 if ready.is_empty() {
275 break;
276 }
277 let mut ready = ready;
278 ready.sort(); // deterministic order among independent nodes
279 for n in &ready {
280 order.push(n.to_string());
281 remaining.remove(n);
282 }
283 for deps in remaining.values_mut() {
284 for n in &ready {
285 deps.remove(n);
286 }
287 }
288 }
289
290 if order.len() != graph.nodes.len() {
291 let stuck = graph
292 .nodes
293 .iter()
294 .map(|(n, _)| n.as_str())
295 .find(|n| !order.contains(&n.to_string()))
296 .unwrap();
297 return Err(DagError::UnmarkedCycle {
298 node: stuck.to_string(),
299 });
300 }
301 Ok(order)
302}
303
304fn referenced_nodes(expr: &InputExpr) -> Vec<&str> {
305 match expr {
306 InputExpr::FromNode(n) => vec![n.as_str()],
307 InputExpr::Record(fields) => fields.values().flat_map(referenced_nodes).collect(),
308 InputExpr::List(items) => items.iter().flat_map(referenced_nodes).collect(),
309 }
310}
311
312/// Compose an `InputExpr` into the `Ty` it produces, by looking up each
313/// referenced node's declared output type.
314// `graph` is only threaded through the recursive calls today (not read at
315// any base case); kept as a parameter rather than dropped since a future
316// case is plausible to need it (e.g. validating a reference more richly
317// than `signatures` alone allows) and this is purely a lint suppression,
318// not a change in behavior.
319#[allow(clippy::only_used_in_recursion)]
320fn evaluate_expr_ty(
321 expr: &InputExpr,
322 signatures: &HashMap<String, cuttlefish_abi::Signature>,
323 graph: &NodeGraph,
324) -> Result<Ty, DagError> {
325 match expr {
326 InputExpr::FromNode(n) => Ok(signatures
327 .get(n)
328 .ok_or_else(|| DagError::UnknownReference {
329 node: "?".into(),
330 referenced: n.clone(),
331 })?
332 .output
333 .clone()),
334 InputExpr::Record(fields) => {
335 let mut out = BTreeMap::new();
336 for (k, v) in fields {
337 out.insert(k.clone(), evaluate_expr_ty(v, signatures, graph)?);
338 }
339 Ok(Ty::Record(out))
340 }
341 InputExpr::List(items) => {
342 // All list items must agree on a type for `Ty::List(T)` to mean
343 // anything; take the first and let assignable_to's own equality
344 // fall through to a SeamMismatch if a later one disagrees. (A
345 // more precise per-item error is a reasonable follow-up; this is
346 // the minimal correct behavior for v1.)
347 let first = items.first().ok_or_else(|| DagError::UnknownReference {
348 node: "?".into(),
349 referenced: "<empty list>".into(),
350 })?;
351 Ok(Ty::List(Box::new(evaluate_expr_ty(
352 first, signatures, graph,
353 )?)))
354 }
355 }
356}
357
358fn describe_expr(
359 expr: &InputExpr,
360 signatures: &HashMap<String, cuttlefish_abi::Signature>,
361) -> (String, String) {
362 match expr {
363 InputExpr::FromNode(n) => (
364 n.clone(),
365 signatures
366 .get(n)
367 .map(|s| s.output.to_string())
368 .unwrap_or_default(),
369 ),
370 other => ("<composite>".to_string(), format!("{other:?}")),
371 }
372}
373
374/// Implements the spec's "Conditional dispatch and skipped nodes" rule
375/// exactly: a node is exclusive to label L if any node its InputExpr
376/// references is exclusive to L (transitively, from L's branch target). A
377/// node that would be exclusive to two labels of the *same* decision at
378/// once is a build-time error.
379fn compute_branch_exclusivity(
380 graph: &NodeGraph,
381 branches: &Branches,
382) -> Result<HashMap<String, BranchExclusivity>, DagError> {
383 let mut exclusive_to: HashMap<String, BranchExclusivity> = HashMap::new();
384
385 // Validate every branch target actually names a real node before
386 // seeding anything — an undeclared target is a build-time error (the
387 // node it should have gated instead runs unconditionally, which is
388 // exactly the silent-wrong-behavior this typechecker exists to prevent),
389 // named against the decision (branching node) that references it.
390 for (decision, labels) in &branches.decisions {
391 for (label, target) in labels {
392 if graph.get(target).is_none() {
393 return Err(DagError::UnknownReference {
394 node: decision.clone(),
395 referenced: target.clone(),
396 });
397 }
398 exclusive_to.insert(
399 target.clone(),
400 BranchExclusivity {
401 decision: decision.clone(),
402 label: label.clone(),
403 },
404 );
405 }
406 }
407
408 // Propagate: repeat until no new node gains an exclusivity marker.
409 // O(n^2) worst case, fine at this scale (specs have tens of nodes, not
410 // thousands) — larger-scale fan-out is explicitly deferred to a future
411 // cycle, not this one.
412 loop {
413 let mut changed = false;
414 for (name, node) in &graph.nodes {
415 let Some(expr) = &node.input else { continue };
416 let mut found: Option<BranchExclusivity> = None;
417 for referenced in referenced_nodes(expr) {
418 if let Some(ex) = exclusive_to.get(referenced) {
419 match &found {
420 None => found = Some(ex.clone()),
421 Some(existing)
422 if existing.decision == ex.decision && existing.label != ex.label =>
423 {
424 return Err(DagError::ConflictingBranchFanIn {
425 node: name.clone(),
426 decision: ex.decision.clone(),
427 label_a: existing.label.clone(),
428 label_b: ex.label.clone(),
429 });
430 }
431 _ => {}
432 }
433 }
434 }
435 if let Some(ex) = found {
436 if exclusive_to.insert(name.clone(), ex).is_none() {
437 changed = true;
438 }
439 }
440 }
441 if !changed {
442 break;
443 }
444 }
445
446 Ok(exclusive_to)
447}