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}
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}