Skip to main content

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}