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 /// The fan-out manifest this node runs over, if any — see
44 /// [`cuttlefish_core::graph::Node::over`]. When set, this node runs its
45 /// block once per manifest line and presents
46 /// [`fanout_collection_ty`] downstream rather than its block's own
47 /// declared output.
48 pub over: Option<std::path::PathBuf>,
49 /// For a fan-out node only: what its block declared as the output of
50 /// *one item*, before [`fanout_collection_ty`] replaced `signature.output`
51 /// for downstream typing.
52 ///
53 /// Both are needed and they are not the same type. Downstream nodes
54 /// consume the collection, so `signature.output` must describe that; but
55 /// each individual item's result still has to be validated against what
56 /// the block actually promised to produce, which is this. Collapsing them
57 /// into one field means every item gets checked against the collection
58 /// record and fails.
59 pub item_output: Option<cuttlefish_abi::Ty>,
60 /// What "done" means for this node beyond its type — see
61 /// [`crate::accept`]. Empty means the type signature is the whole
62 /// contract, which is the pre-existing behaviour.
63 pub accept: Vec<cuttlefish_core::graph::AcceptCheck>,
64 /// The recovery ladder climbed when an attempt is not accepted. Empty
65 /// means one attempt and then failure.
66 pub on_fail: Vec<cuttlefish_core::graph::Rung>,
67}
68
69/// What a fan-out node presents to the nodes downstream of it.
70///
71/// Deliberately *not* the block's own declared output: downstream consumes
72/// the collection of every item's result, not any one item's. The counts are
73/// [`Ty::Json`] because [`Ty`] has no number variant.
74pub fn fanout_collection_ty() -> Ty {
75 Ty::Record(BTreeMap::from([
76 ("results_path".to_string(), Ty::Text),
77 ("failures_path".to_string(), Ty::Text),
78 ("succeeded".to_string(), Ty::Json),
79 ("failed".to_string(), Ty::Json),
80 ]))
81}
82
83/// A whole graph, typechecked and topologically ordered.
84pub struct CheckedGraph {
85 /// In topological order — safe to execute front-to-back, threading
86 /// `outputs` forward, per the spec's execution-semantics section.
87 pub nodes: Vec<CheckedNode>,
88 /// Which nodes are exclusive to which branch label, keyed by the
89 /// branching node's name — see "skip propagation" in `check_graph`.
90 pub exclusive_to: HashMap<String, BranchExclusivity>,
91}
92
93/// Which branch decision + label a node is exclusive to — a node is only
94/// executed when this decision's chosen route matches `label`. Carrying
95/// `decision` (not just `label`) is what lets two independent `branches`
96/// decisions that happen to reuse the same label string coexist without
97/// being confused for a conflict.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct BranchExclusivity {
100 /// The branching decision (key in `branches.decisions`) this exclusivity
101 /// belongs to.
102 pub decision: String,
103 /// The label within that decision.
104 pub label: String,
105}
106
107/// Why a graph was rejected.
108#[derive(Debug, thiserror::Error)]
109pub enum DagError {
110 /// A per-node signature lookup or seam check failed the same way a
111 /// linear pipeline's would.
112 #[error(transparent)]
113 Pipeline(#[from] PipelineError),
114 /// A node's `in` expression names a node that doesn't exist.
115 #[error("node `{node}` references unknown node `{referenced}`")]
116 UnknownReference {
117 /// The node whose `in` expression has the bad reference.
118 node: String,
119 /// The name it referenced.
120 referenced: String,
121 },
122 /// A node has an inbound edge that would form a cycle without an
123 /// explicit `repeat_until` marker.
124 #[error(
125 "node `{node}` has an inbound edge that would form a cycle with no \
126 repeat_until marker — add one (with max_iterations) if this loop is intentional"
127 )]
128 UnmarkedCycle {
129 /// The node that could not be ordered.
130 node: String,
131 },
132 /// A node's input mixes outputs from two mutually-exclusive branch labels
133 /// of the same decision.
134 #[error(
135 "node `{node}`'s input mixes output from branch label `{label_a}` and \
136 label `{label_b}` of the same `branches.{decision}` decision — a node \
137 cannot depend on more than one mutually-exclusive branch outcome at once"
138 )]
139 ConflictingBranchFanIn {
140 /// The node whose input mixes two labels.
141 node: String,
142 /// The branching decision both labels belong to.
143 decision: String,
144 /// The first label found.
145 label_a: String,
146 /// The second, conflicting label.
147 label_b: String,
148 },
149 /// A node's declared input doesn't accept what its `in` expression
150 /// produces.
151 #[error("node `{consumer}` needs {expected}, but `{producer}` produces {produced}")]
152 SeamMismatch {
153 /// What produced the mismatched value.
154 producer: String,
155 /// What it produces.
156 produced: String,
157 /// The node that needed something else.
158 consumer: String,
159 /// What it needs.
160 expected: String,
161 },
162 /// The graph had no nodes.
163 #[error("a graph needs at least one node")]
164 Empty,
165}
166
167/// Typecheck a graph. `resolved` must already contain one entry per node in
168/// `graph.nodes`, in the same order — building that mapping (via
169/// `pipeline::resolve_and_load`, one call per node) is the caller's job
170/// (a later task), same division of responsibility `pipeline::check` already has.
171pub fn check_graph(
172 engine: &Engine,
173 graph: &NodeGraph,
174 branches: &Branches,
175 resolved: &HashMap<String, ResolvedInput>,
176) -> Result<CheckedGraph, DagError> {
177 if graph.nodes.is_empty() {
178 return Err(DagError::Empty);
179 }
180
181 // 1. Read every node's signature up front — needed before topological
182 // evaluation since InputExpr composition needs to know each
183 // referenced node's *output* type, and a node can be referenced
184 // before it's "current" in visit order.
185 let mut signatures = HashMap::new();
186 let mut item_outputs: HashMap<String, cuttlefish_abi::Ty> = HashMap::new();
187 for (name, node) in &graph.nodes {
188 let input = resolved.get(name).expect("caller resolved every node");
189 let mut signature = read_stage_signature(engine, input)?;
190 if node.over.is_some() {
191 // Keep what the block promised for one item — the substitution
192 // below is about downstream typing only, and each item's result
193 // still has to be checked against this at runtime.
194 item_outputs.insert(name.clone(), signature.output.clone());
195 // A fan-out node's block declares the shape of *one item's*
196 // result, but downstream nodes consume the collection of all of
197 // them. Substituting here — where per-node signatures are first
198 // collected — is what makes every seam check downstream correct
199 // with no further changes, since `evaluate_expr_ty` and each
200 // `assignable_to` comparison read from this same map. The
201 // block's own declared output is still used, per item, to
202 // validate what each run produced.
203 signature.output = fanout_collection_ty();
204 }
205 signatures.insert(name.clone(), signature);
206 }
207
208 // 2. Topological sort with cycle detection. An edge node -> referenced
209 // is only legal going "backward" (referenced already visited) unless
210 // `node` declares repeat_until, in which case a self-edge is exactly
211 // what's expected and not an error.
212 let order = topological_order(graph)?;
213
214 // 3. Branch-exclusivity: for each `branches` decision, walk forward from
215 // each label's target, marking every node whose InputExpr needs that
216 // target (transitively) as exclusive to that label. A node needing
217 // two labels of the *same* decision is a build-time error.
218 let exclusive_to = compute_branch_exclusivity(graph, branches)?;
219
220 // 4. For each node in topological order, evaluate its InputExpr into a
221 // Ty (composing referenced nodes' output types) and check
222 // assignable_to against its own declared input.
223 let mut nodes = Vec::with_capacity(order.len());
224 for name in &order {
225 let node = graph
226 .get(name)
227 .expect("topological_order only returns known nodes");
228 let input_resolved = resolved.get(name).expect("caller resolved every node");
229 let signature = signatures.get(name).unwrap().clone();
230
231 if let Some(expr) = &node.input {
232 let produced = evaluate_expr_ty(expr, &signatures, graph)?;
233 if !produced.assignable_to(&signature.input) {
234 let (producer, produced_str) = describe_expr(expr, &signatures);
235 return Err(DagError::SeamMismatch {
236 producer,
237 produced: produced_str,
238 consumer: name.clone(),
239 expected: signature.input.to_string(),
240 });
241 }
242 }
243
244 nodes.push(CheckedNode {
245 name: name.clone(),
246 kind: input_resolved.kind,
247 resolved: input_resolved.resolved.clone(),
248 module_bytes: input_resolved.bytes.clone(),
249 signature,
250 input: node.input.clone(),
251 repeat_until: node.repeat_until.clone(),
252 max_iterations: node.max_iterations,
253 script: input_resolved.script.clone(),
254 over: node.over.clone(),
255 item_output: item_outputs.get(name).cloned(),
256 accept: node.accept.clone(),
257 on_fail: node.on_fail.clone(),
258 });
259 }
260
261 Ok(CheckedGraph {
262 nodes,
263 exclusive_to,
264 })
265}
266
267/// A stable fingerprint of a checked graph's shape and contents — every
268/// node's name and declared signature, joined and hashed. Two graphs with
269/// the same fingerprint are the same, for resume-safety purposes; this
270/// isn't a security boundary, just a "did the loaded spec actually change"
271/// guard, so a simple SHA-256 (already a workspace dependency, same crate
272/// the catalog's own content-hashing uses) is all this needs.
273pub fn graph_fingerprint(nodes: &[CheckedNode]) -> String {
274 use sha2::{Digest, Sha256};
275 let mut hasher = Sha256::new();
276 for node in nodes {
277 hash_length_prefixed(&mut hasher, node.name.as_bytes());
278 hash_length_prefixed(&mut hasher, node.signature.to_string().as_bytes());
279 // A node fanning out over a different manifest is a different node
280 // for resume purposes even with an identical signature: its recorded
281 // item indices refer to inputs from the old manifest. Without this,
282 // repointing `over` would resume against checkpoints computed from
283 // entirely different data.
284 hash_length_prefixed(
285 &mut hasher,
286 node.over
287 .as_ref()
288 .map(|p| p.as_os_str().as_encoded_bytes())
289 .unwrap_or(b""),
290 );
291 // Acceptance and recovery change what a *completed* checkpoint means.
292 // A node whose `accept` list tightened has already-recorded rows that
293 // were never held to the new contract, and one whose `on_fail` gained
294 // an `escalate` has rows recorded under a policy that would now have
295 // given up instead — so resuming across either edit would mix
296 // conclusions reached under two different rules.
297 hash_length_prefixed(&mut hasher, policy_repr(node).as_bytes());
298 }
299 crate::hex::encode(hasher.finalize())
300}
301
302/// A node's `accept`/`on_fail` as one stable string, for fingerprinting.
303///
304/// Written out by hand rather than via `Debug`: `Debug` output is explicitly
305/// not a stable format, so a derive tweak in `cuttlefish-core` would silently
306/// invalidate every checkpoint on disk.
307fn policy_repr(node: &CheckedNode) -> String {
308 use cuttlefish_core::graph::{AcceptCheck, Rung};
309 let mut out = String::new();
310 for check in &node.accept {
311 match check {
312 AcceptCheck::Schema(path) => {
313 out.push_str("schema:");
314 out.push_str(&path.to_string_lossy());
315 }
316 AcceptCheck::Judge { model, prompt } => {
317 out.push_str("judge:");
318 if let Some(m) = model {
319 out.push_str(&m.to_string());
320 }
321 out.push(':');
322 out.push_str(prompt);
323 }
324 }
325 out.push('\n');
326 }
327 for rung in &node.on_fail {
328 match rung {
329 Rung::Retry(n) => out.push_str(&format!("retry:{n}")),
330 Rung::Reroute(m) => out.push_str(&format!("reroute:{m}")),
331 Rung::Escalate => out.push_str("escalate"),
332 }
333 out.push('\n');
334 }
335 out
336}
337
338/// Feed `bytes` into `hasher` prefixed with its own length (as a fixed
339/// 8-byte big-endian `u64`), rather than delimiting with a fixed byte —
340/// delimiter-joining is only injective if the delimiter can never appear
341/// inside the content itself, which isn't guaranteed here (a wasm block's
342/// declared signature can contain arbitrary field-name text, including, in
343/// principle, an embedded NUL byte decoded from its `cf_signature` export's
344/// JSON). Length-prefixing has no such assumption.
345fn hash_length_prefixed(hasher: &mut sha2::Sha256, bytes: &[u8]) {
346 use sha2::Digest;
347 hasher.update((bytes.len() as u64).to_be_bytes());
348 hasher.update(bytes);
349}
350
351/// Kahn's algorithm, with the repeat_until exception: an edge from `node`
352/// back to itself (or forming a cycle) is only legal when `node` declares
353/// `repeat_until` — everything else with an unresolved inbound edge after
354/// the sort terminates is an undeclared cycle.
355fn topological_order(graph: &NodeGraph) -> Result<Vec<String>, DagError> {
356 let mut deps: HashMap<&str, HashSet<&str>> = HashMap::new();
357 for (name, node) in &graph.nodes {
358 deps.entry(name).or_default();
359 if let Some(expr) = &node.input {
360 for referenced in referenced_nodes(expr) {
361 if graph.get(referenced).is_none() {
362 return Err(DagError::UnknownReference {
363 node: name.clone(),
364 referenced: referenced.to_string(),
365 });
366 }
367 // A node's own repeat_until self-reference is not a real
368 // dependency edge for ordering purposes — it re-runs on its
369 // own prior output, which the executor (a later task)
370 // special-cases, not the topological sort. A self-reference
371 // WITHOUT repeat_until is not this case — it's exactly the
372 // undeclared-cycle mistake the spec's `repeat_until`-as-sole-
373 // marker rule exists to catch, so it must still become a
374 // dependency edge (on itself) that can never be satisfied,
375 // tripping UnmarkedCycle below.
376 let is_marked_self_loop = referenced == name && node.repeat_until.is_some();
377 if !is_marked_self_loop {
378 deps.entry(name).or_default().insert(referenced);
379 }
380 }
381 }
382 }
383
384 let mut order = Vec::new();
385 let mut remaining: HashMap<&str, HashSet<&str>> = deps.clone();
386 loop {
387 let ready: Vec<&str> = remaining
388 .iter()
389 .filter(|(_, d)| d.is_empty())
390 .map(|(n, _)| *n)
391 .collect();
392 if ready.is_empty() {
393 break;
394 }
395 let mut ready = ready;
396 ready.sort(); // deterministic order among independent nodes
397 for n in &ready {
398 order.push(n.to_string());
399 remaining.remove(n);
400 }
401 for deps in remaining.values_mut() {
402 for n in &ready {
403 deps.remove(n);
404 }
405 }
406 }
407
408 if order.len() != graph.nodes.len() {
409 let stuck = graph
410 .nodes
411 .iter()
412 .map(|(n, _)| n.as_str())
413 .find(|n| !order.contains(&n.to_string()))
414 .unwrap();
415 return Err(DagError::UnmarkedCycle {
416 node: stuck.to_string(),
417 });
418 }
419 Ok(order)
420}
421
422fn referenced_nodes(expr: &InputExpr) -> Vec<&str> {
423 match expr {
424 InputExpr::FromNode(n) => vec![n.as_str()],
425 InputExpr::Record(fields) => fields.values().flat_map(referenced_nodes).collect(),
426 InputExpr::List(items) => items.iter().flat_map(referenced_nodes).collect(),
427 }
428}
429
430/// Compose an `InputExpr` into the `Ty` it produces, by looking up each
431/// referenced node's declared output type.
432// `graph` is only threaded through the recursive calls today (not read at
433// any base case); kept as a parameter rather than dropped since a future
434// case is plausible to need it (e.g. validating a reference more richly
435// than `signatures` alone allows) and this is purely a lint suppression,
436// not a change in behavior.
437#[allow(clippy::only_used_in_recursion)]
438fn evaluate_expr_ty(
439 expr: &InputExpr,
440 signatures: &HashMap<String, cuttlefish_abi::Signature>,
441 graph: &NodeGraph,
442) -> Result<Ty, DagError> {
443 match expr {
444 InputExpr::FromNode(n) => Ok(signatures
445 .get(n)
446 .ok_or_else(|| DagError::UnknownReference {
447 node: "?".into(),
448 referenced: n.clone(),
449 })?
450 .output
451 .clone()),
452 InputExpr::Record(fields) => {
453 let mut out = BTreeMap::new();
454 for (k, v) in fields {
455 out.insert(k.clone(), evaluate_expr_ty(v, signatures, graph)?);
456 }
457 Ok(Ty::Record(out))
458 }
459 InputExpr::List(items) => {
460 // All list items must agree on a type for `Ty::List(T)` to mean
461 // anything; take the first and let assignable_to's own equality
462 // fall through to a SeamMismatch if a later one disagrees. (A
463 // more precise per-item error is a reasonable follow-up; this is
464 // the minimal correct behavior for v1.)
465 let first = items.first().ok_or_else(|| DagError::UnknownReference {
466 node: "?".into(),
467 referenced: "<empty list>".into(),
468 })?;
469 Ok(Ty::List(Box::new(evaluate_expr_ty(
470 first, signatures, graph,
471 )?)))
472 }
473 }
474}
475
476fn describe_expr(
477 expr: &InputExpr,
478 signatures: &HashMap<String, cuttlefish_abi::Signature>,
479) -> (String, String) {
480 match expr {
481 InputExpr::FromNode(n) => (
482 n.clone(),
483 signatures
484 .get(n)
485 .map(|s| s.output.to_string())
486 .unwrap_or_default(),
487 ),
488 InputExpr::Record(fields) => (
489 "<composite>".to_string(),
490 match same_node_repeated_across_every_field(fields) {
491 // The single most common way to reach for `Record` wrong:
492 // wanting one upstream node's whole output passed through
493 // to a downstream node whose input shape happens to match
494 // it, but writing `{ a = x.out; b = x.out; c = x.out; }`
495 // instead of the bare `in = x.out;` a straight pass-through
496 // actually needs. The wrapped form nests x's whole output
497 // under *each* field instead of using its fields directly,
498 // producing the double-nested type this message would
499 // otherwise show with no explanation. Caught here rather
500 // than left to be found by trial and error against a
501 // confusing type dump -- see the cuttlefish-build skill.
502 Some(node) => format!(
503 "{expr:?} -- every field here maps to `{node}.out`; if you meant to pass \
504 `{node}`'s whole output through unchanged, write `in = {node}.out;` with no \
505 braces instead of wrapping it field by field"
506 ),
507 None => format!("{expr:?}"),
508 },
509 ),
510 other => ("<composite>".to_string(), format!("{other:?}")),
511 }
512}
513
514/// If every field of a `Record` maps to the same single node's whole
515/// output (`FromNode`), returns that node's name — see the call site in
516/// [`describe_expr`] for why this is worth detecting.
517fn same_node_repeated_across_every_field(fields: &BTreeMap<String, InputExpr>) -> Option<&str> {
518 let mut names = fields.values().map(|v| match v {
519 InputExpr::FromNode(n) => Some(n.as_str()),
520 _ => None,
521 });
522 let first = names.next()??;
523 names.all(|n| n == Some(first)).then_some(first)
524}
525
526/// Implements the spec's "Conditional dispatch and skipped nodes" rule
527/// exactly: a node is exclusive to label L if any node its InputExpr
528/// references is exclusive to L (transitively, from L's branch target). A
529/// node that would be exclusive to two labels of the *same* decision at
530/// once is a build-time error.
531fn compute_branch_exclusivity(
532 graph: &NodeGraph,
533 branches: &Branches,
534) -> Result<HashMap<String, BranchExclusivity>, DagError> {
535 let mut exclusive_to: HashMap<String, BranchExclusivity> = HashMap::new();
536
537 // Validate every branch target actually names a real node before
538 // seeding anything — an undeclared target is a build-time error (the
539 // node it should have gated instead runs unconditionally, which is
540 // exactly the silent-wrong-behavior this typechecker exists to prevent),
541 // named against the decision (branching node) that references it.
542 for (decision, labels) in &branches.decisions {
543 for (label, target) in labels {
544 if graph.get(target).is_none() {
545 return Err(DagError::UnknownReference {
546 node: decision.clone(),
547 referenced: target.clone(),
548 });
549 }
550 exclusive_to.insert(
551 target.clone(),
552 BranchExclusivity {
553 decision: decision.clone(),
554 label: label.clone(),
555 },
556 );
557 }
558 }
559
560 // Propagate: repeat until no new node gains an exclusivity marker.
561 // O(n^2) worst case, fine at this scale (specs have tens of nodes, not
562 // thousands) — larger-scale fan-out is explicitly deferred to a future
563 // cycle, not this one.
564 loop {
565 let mut changed = false;
566 for (name, node) in &graph.nodes {
567 let Some(expr) = &node.input else { continue };
568 let mut found: Option<BranchExclusivity> = None;
569 for referenced in referenced_nodes(expr) {
570 if let Some(ex) = exclusive_to.get(referenced) {
571 match &found {
572 None => found = Some(ex.clone()),
573 Some(existing)
574 if existing.decision == ex.decision && existing.label != ex.label =>
575 {
576 return Err(DagError::ConflictingBranchFanIn {
577 node: name.clone(),
578 decision: ex.decision.clone(),
579 label_a: existing.label.clone(),
580 label_b: ex.label.clone(),
581 });
582 }
583 _ => {}
584 }
585 }
586 }
587 if let Some(ex) = found {
588 if exclusive_to.insert(name.clone(), ex).is_none() {
589 changed = true;
590 }
591 }
592 }
593 if !changed {
594 break;
595 }
596 }
597
598 Ok(exclusive_to)
599}