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