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