Skip to main content

cuttlefish_core/
graph.rs

1//! The graph AST: `nodes = {...}` and `branches = {...}`.
2//!
3//! A node's `in` is an expression over other nodes' outputs, not just a bare
4//! name — `Record`/`List` are what make fan-in possible. See
5//! `docs/superpowers/specs/2026-08-03-dag-core-design.md` for the full
6//! rationale; this module is purely the parsed shape, with no typechecking
7//! or execution logic (those live in `cuttlefish-host`).
8
9use crate::lex::{Tok, Token};
10use crate::spec::SpecError;
11use std::collections::BTreeMap;
12use std::path::PathBuf;
13
14/// What feeds a node's input.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum InputExpr {
17    /// `other_node.out` — this node's whole output.
18    FromNode(String),
19    /// `{ field = expr; ... }` — build a record from several nodes.
20    Record(BTreeMap<String, InputExpr>),
21    /// `[ expr, expr, ... ]` — build a list from several nodes, order significant.
22    List(Vec<InputExpr>),
23}
24
25/// One check a node's output must pass before it counts as done.
26///
27/// Evaluated in declaration order, short-circuiting on the first failure —
28/// see [`Node::accept`].
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum AcceptCheck {
31    /// Validate against the JSON Schema at this path. Deterministic, and
32    /// costs no inference.
33    Schema(PathBuf),
34    /// Ask a model whether the output is acceptable.
35    Judge {
36        /// Which model grades. `None` means the spec's own `model`.
37        model: Option<crate::spec::ModelRef>,
38        /// The grading prompt. The host appends the node's input and the
39        /// output under judgement, since "does this cite numbers *from the
40        /// input*" is unanswerable without both.
41        prompt: String,
42    },
43}
44
45/// One rung of a node's recovery ladder, climbed in order until a rung
46/// succeeds or the rungs run out.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum Rung {
49    /// Up to N further attempts, unchanged.
50    Retry(u32),
51    /// One attempt against a different model.
52    Reroute(crate::spec::ModelRef),
53    /// Terminal: stop, record why, and surface it to `cuttlefish
54    /// escalations`. Must be the last rung — see `GraphParser::ladder`.
55    Escalate,
56}
57
58/// One node in the graph.
59#[derive(Debug, Clone, PartialEq)]
60pub struct Node {
61    /// The block/bundle this node runs — same string shape as today's
62    /// `pipeline` entries (a path, or a bare `name@version`).
63    pub block: PathBuf,
64    /// What feeds this node. `None` only for a node with no inbound edge —
65    /// the graph's entry point(s).
66    pub input: Option<InputExpr>,
67    /// Bounded-loop marker: the `Ty::Text` output field compared against
68    /// `"done"`. Requires `max_iterations`.
69    pub repeat_until: Option<String>,
70    /// Mandatory alongside `repeat_until` — enforced at parse time, not left
71    /// as a typecheck-time gap, since a missing bound is a spec-authoring
72    /// mistake regardless of what the graph shape turns out to be.
73    pub max_iterations: Option<u32>,
74    /// Fan-out marker: a JSONL manifest, one JSON value per line, each the
75    /// complete input for one run of this node's block. `None` for an
76    /// ordinary node, which runs exactly once.
77    ///
78    /// Mutually exclusive with [`Node::repeat_until`] — both describe
79    /// iteration, but over different things (manifest items vs. this node's
80    /// own prior output), and there is no coherent combined meaning.
81    pub over: Option<PathBuf>,
82    /// Checks this node's output must pass beyond its declared type.
83    ///
84    /// Ordered and short-circuiting. Empty means the declared type is the
85    /// only contract — today's behaviour, unchanged.
86    pub accept: Vec<AcceptCheck>,
87    /// What to try when an attempt fails, in order.
88    ///
89    /// Empty (or omitted) means one attempt and no recovery — again,
90    /// today's behaviour. A ladder that runs out without [`Rung::Escalate`]
91    /// is an ordinary failure.
92    pub on_fail: Vec<Rung>,
93}
94
95/// `nodes = { name = { ... }; ... }`
96#[derive(Debug, Clone, PartialEq, Default)]
97pub struct NodeGraph {
98    /// Insertion order preserved (`BTreeMap` would reorder alphabetically,
99    /// which is fine for lookup but wrong for any diagnostic that lists
100    /// nodes "in the order the author wrote them").
101    pub nodes: Vec<(String, Node)>,
102}
103
104impl NodeGraph {
105    /// The one-node graph `block = "...";` desugars to.
106    pub fn single(block: PathBuf) -> Self {
107        Self {
108            nodes: vec![(
109                "block".to_string(),
110                Node {
111                    block,
112                    input: None,
113                    repeat_until: None,
114                    max_iterations: None,
115                    over: None,
116                    accept: Vec::new(),
117                    on_fail: Vec::new(),
118                },
119            )],
120        }
121    }
122
123    /// Look up a node by name.
124    pub fn get(&self, name: &str) -> Option<&Node> {
125        self.nodes
126            .iter()
127            .find(|(n, _)| n == name)
128            .map(|(_, node)| node)
129    }
130}
131
132/// Whether a graph is a strict linear chain — a single strand where node
133/// `i`'s sole input (if any) is `FromNode` of node `i-1`, nothing more:
134///
135/// - `branches` must be empty (no conditional dispatch to encode).
136/// - No node declares `repeat_until` (no loop to encode).
137/// - No node's `input` is `Record`/`List` fan-in.
138/// - **Beyond per-node checks:** the *whole graph* must be one strand, not
139///   just individually-simple nodes that still fan out or fan in as a
140///   group. Concretely: the first declared node has no input; every
141///   subsequent declared node's input must be exactly `FromNode` of the
142///   node declared immediately before it; and no node may be referenced by
143///   more than one other node's `FromNode` (that would be fan-out — two
144///   nodes both reading node `k`'s output — which individually satisfies
145///   every per-node check above while still not being a chain).
146///
147/// `cuttlefish build`'s bundle format only knows how to encode this exact
148/// shape, walked in `spec.nodes`' declaration order.
149pub fn is_simple_chain(graph: &NodeGraph, branches: &Branches) -> bool {
150    if !branches.decisions.is_empty() {
151        return false;
152    }
153    for (i, (_, node)) in graph.nodes.iter().enumerate() {
154        if node.repeat_until.is_some() {
155            return false;
156        }
157        // The bundle manifest carries a node's name, kind, resolution and
158        // signature — nothing about *how* it executes. `over` would
159        // therefore be dropped at bundle time and silently absent at run
160        // time, so a bundled fan-out node runs once against the job input
161        // instead of once per manifest line, and returns something that
162        // looks entirely reasonable. Refusing to bundle is the only honest
163        // option until the format carries this.
164        if node.over.is_some() {
165            return false;
166        }
167        match (i, &node.input) {
168            (0, None) => {}
169            (0, Some(_)) => return false, // the entry node must have no input
170            (_, Some(InputExpr::FromNode(referenced))) => {
171                let (previous_name, _) = &graph.nodes[i - 1];
172                if referenced != previous_name {
173                    return false; // not chained to the immediately-preceding node
174                }
175            }
176            _ => return false, // missing input, or Record/List fan-in
177        }
178    }
179    // Fan-out check: provably unreachable given the position check above
180    // already passed (if every node's input is exactly its immediate
181    // predecessor, no target can have two referrers) — kept as an explicit,
182    // cheap assertion rather than an implicit invariant, so a future change
183    // to the loop above that weakens it trips this instead of silently
184    // regressing.
185    let mut referenced_counts = std::collections::HashMap::new();
186    for (_, node) in &graph.nodes {
187        if let Some(InputExpr::FromNode(referenced)) = &node.input {
188            *referenced_counts.entry(referenced.clone()).or_insert(0) += 1;
189        }
190    }
191    referenced_counts.values().all(|&count| count <= 1)
192}
193
194/// `branches = { node_name = { "label" -> target; ... }; ... }`
195#[derive(Debug, Clone, PartialEq, Default)]
196pub struct Branches {
197    /// (branching node name) -> (label -> target node name), insertion order.
198    pub decisions: Vec<(String, Vec<(String, String)>)>,
199}
200
201/// A self-contained recursive-descent parser for the `nodes = {...}` and
202/// `branches = {...}` bodies, operating directly on a token slice.
203pub struct GraphParser<'a> {
204    /// The full token stream being parsed.
205    pub tokens: &'a [Token],
206    /// Current cursor position into `tokens`.
207    pub at: usize,
208}
209
210impl<'a> GraphParser<'a> {
211    fn peek(&self) -> Option<&'a Tok> {
212        self.tokens.get(self.at).map(|t| &t.tok)
213    }
214    fn here(&self) -> String {
215        match self.tokens.get(self.at) {
216            Some(t) => format!("{} at {}", t.tok.describe(), t.span),
217            None => "end of input".into(),
218        }
219    }
220    fn expect(&mut self, want: &Tok) -> Result<(), SpecError> {
221        match self.peek() {
222            Some(got) if got == want => {
223                self.at += 1;
224                Ok(())
225            }
226            _ => Err(SpecError::Malformed(format!(
227                "expected {}, found {}",
228                want.describe(),
229                self.here()
230            ))),
231        }
232    }
233    fn ident(&mut self) -> Result<String, SpecError> {
234        match self.tokens.get(self.at).map(|t| &t.tok) {
235            Some(Tok::Ident(name)) => {
236                self.at += 1;
237                Ok(name.clone())
238            }
239            _ => Err(SpecError::Malformed(format!(
240                "expected a name, found {}",
241                self.here()
242            ))),
243        }
244    }
245    fn string(&mut self) -> Result<String, SpecError> {
246        match self.tokens.get(self.at).map(|t| &t.tok) {
247            Some(Tok::Str(s)) => {
248                self.at += 1;
249                Ok(s.clone())
250            }
251            _ => Err(SpecError::Malformed(format!(
252                "expected a quoted string, found {}",
253                self.here()
254            ))),
255        }
256    }
257    fn skip_semi(&mut self) {
258        if self.peek() == Some(&Tok::Semicolon) {
259            self.at += 1;
260        }
261    }
262
263    /// `{ name = { field* }; ... }` — the whole `nodes = {...}` body.
264    ///
265    /// Returns the parsed graph together with the token position just past
266    /// its closing `}` — `spec.rs`'s `Parser` (a *separate* struct walking
267    /// the same token slice, Task 3) needs that position to resume parsing
268    /// the rest of the `spec {...}` body afterward, since it can't see
269    /// `GraphParser`'s internal cursor otherwise.
270    pub fn node_graph(&mut self) -> Result<(NodeGraph, usize), SpecError> {
271        self.expect(&Tok::OpenBrace)?;
272        let mut nodes = Vec::new();
273        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
274            let name = self.ident()?;
275            self.expect(&Tok::Equals)?;
276            let node = self.node_body()?;
277            nodes.push((name, node));
278            self.skip_semi();
279        }
280        self.expect(&Tok::CloseBrace)?;
281        if nodes.is_empty() {
282            return Err(SpecError::Malformed("nodes needs at least one node".into()));
283        }
284        Ok((NodeGraph { nodes }, self.at))
285    }
286
287    /// `{ block = "..."; in = expr; over = "..."; repeat_until = "..."; max_iterations = N; }`
288    fn node_body(&mut self) -> Result<Node, SpecError> {
289        self.expect(&Tok::OpenBrace)?;
290        let (mut block, mut input, mut repeat_until, mut max_iterations, mut over) =
291            (None, None, None, None, None);
292        let (mut accept, mut on_fail) = (Vec::new(), Vec::new());
293        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
294            let key = self.ident()?;
295            self.expect(&Tok::Equals)?;
296            match key.as_str() {
297                "block" => block = Some(PathBuf::from(self.string()?)),
298                "in" => input = Some(self.input_expr()?),
299                "over" => over = Some(PathBuf::from(self.string()?)),
300                "accept" => accept = self.accept_checks()?,
301                "on_fail" => on_fail = self.ladder()?,
302                "repeat_until" => repeat_until = Some(self.string_or_field()?),
303                "max_iterations" => max_iterations = Some(self.number()?),
304                other => return Err(SpecError::UnknownField(other.to_string())),
305            }
306            self.skip_semi();
307        }
308        self.expect(&Tok::CloseBrace)?;
309        if let (Some(_), None) = (&repeat_until, &max_iterations) {
310            return Err(SpecError::Malformed(
311                "repeat_until requires max_iterations".into(),
312            ));
313        }
314        if over.is_some() && repeat_until.is_some() {
315            return Err(SpecError::Malformed(
316                "a node cannot declare both `over` (run once per manifest item) and \
317                 `repeat_until` (re-run on its own output) — they are two different \
318                 iteration semantics with no combined meaning"
319                    .into(),
320            ));
321        }
322        Ok(Node {
323            block: block.ok_or(SpecError::MissingField("block"))?,
324            input,
325            repeat_until,
326            max_iterations,
327            over,
328            accept,
329            on_fail,
330        })
331    }
332
333    /// `[ Schema "p.json", Judge "prompt", Judge { model = M "t"; prompt = "..."; } ]`
334    fn accept_checks(&mut self) -> Result<Vec<AcceptCheck>, SpecError> {
335        let mut checks = Vec::new();
336        self.expect(&Tok::OpenBracket)?;
337        while self.peek() != Some(&Tok::CloseBracket) {
338            let kind = self.ident()?;
339            match kind.as_str() {
340                "Schema" => checks.push(AcceptCheck::Schema(PathBuf::from(self.string()?))),
341                "Judge" => checks.push(self.judge()?),
342                other => {
343                    return Err(SpecError::Malformed(format!(
344                        "unknown accept check `{other}` — expected `Schema` or `Judge`"
345                    )))
346                }
347            }
348            if self.peek() == Some(&Tok::Comma) {
349                self.at += 1;
350            } else {
351                break;
352            }
353        }
354        self.expect(&Tok::CloseBracket)?;
355        Ok(checks)
356    }
357
358    /// Either `Judge "prompt"` or `Judge { model = M "t"; prompt = "..."; }`.
359    ///
360    /// Two spellings because the bare one costs nothing to declare and suits
361    /// a cheap sanity check, while naming a model is what lets a strong,
362    /// slow model grade a fast one's bulk output.
363    fn judge(&mut self) -> Result<AcceptCheck, SpecError> {
364        if self.peek() != Some(&Tok::OpenBrace) {
365            return Ok(AcceptCheck::Judge {
366                model: None,
367                prompt: self.string()?,
368            });
369        }
370        self.at += 1;
371        let (mut model, mut prompt) = (None, None);
372        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
373            let key = self.ident()?;
374            self.expect(&Tok::Equals)?;
375            match key.as_str() {
376                "model" => {
377                    let provider = self.ident()?;
378                    model = Some(crate::spec::ModelRef::new(provider, self.string()?));
379                }
380                "prompt" => prompt = Some(self.string()?),
381                other => return Err(SpecError::UnknownField(other.to_string())),
382            }
383            self.skip_semi();
384        }
385        self.expect(&Tok::CloseBrace)?;
386        Ok(AcceptCheck::Judge {
387            model,
388            prompt: prompt.ok_or(SpecError::MissingField("prompt"))?,
389        })
390    }
391
392    /// `[ retry 2, reroute Ollama "m", escalate ]`
393    fn ladder(&mut self) -> Result<Vec<Rung>, SpecError> {
394        let mut rungs: Vec<Rung> = Vec::new();
395        self.expect(&Tok::OpenBracket)?;
396        while self.peek() != Some(&Tok::CloseBracket) {
397            // A terminal rung with anything after it means the author
398            // expected the tail to run. It never would — so say so rather
399            // than accepting a ladder whose second half is decorative.
400            if rungs.last() == Some(&Rung::Escalate) {
401                return Err(SpecError::Malformed(
402                    "`escalate` must be the last rung of an on_fail ladder — nothing after it \
403                     can ever run"
404                        .into(),
405                ));
406            }
407            let kind = self.ident()?;
408            match kind.as_str() {
409                "retry" => {
410                    let n = self.number()?;
411                    if n == 0 {
412                        return Err(SpecError::Malformed(
413                            "`retry 0` expresses nothing — write `retry 1`, or omit the rung"
414                                .into(),
415                        ));
416                    }
417                    rungs.push(Rung::Retry(n));
418                }
419                "reroute" => {
420                    let provider = self.ident()?;
421                    rungs.push(Rung::Reroute(crate::spec::ModelRef::new(
422                        provider,
423                        self.string()?,
424                    )));
425                }
426                "escalate" => rungs.push(Rung::Escalate),
427                other => {
428                    return Err(SpecError::Malformed(format!(
429                        "unknown on_fail rung `{other}` — expected `retry`, `reroute`, or \
430                         `escalate`"
431                    )))
432                }
433            }
434            if self.peek() == Some(&Tok::Comma) {
435                self.at += 1;
436            } else {
437                break;
438            }
439        }
440        self.expect(&Tok::CloseBracket)?;
441        Ok(rungs)
442    }
443
444    /// `repeat_until = "done"` — a bare field-name string, not a node
445    /// reference, so this reuses `string()` (kept as its own method name at
446    /// the call site above for readability, not because parsing differs).
447    fn string_or_field(&mut self) -> Result<String, SpecError> {
448        self.string()
449    }
450
451    fn number(&mut self) -> Result<u32, SpecError> {
452        // Numbers aren't tokenized separately today (see lex.rs) — an
453        // integer like `5` lexes as `Ident("5")` since digits satisfy
454        // `is_alphanumeric()`. Parsing it here, rather than adding a
455        // dedicated numeric token, keeps this the only place that cares.
456        let s = self.ident()?;
457        s.parse::<u32>()
458            .map_err(|_| SpecError::Malformed(format!("`{s}` is not a valid max_iterations")))
459    }
460
461    /// `node.out` | `{ field = expr; ... }` | `[ expr, ... ]`
462    fn input_expr(&mut self) -> Result<InputExpr, SpecError> {
463        match self.peek() {
464            Some(Tok::OpenBrace) => {
465                self.at += 1;
466                let mut fields = BTreeMap::new();
467                while self.peek() != Some(&Tok::CloseBrace) {
468                    let field = self.ident()?;
469                    self.expect(&Tok::Equals)?;
470                    fields.insert(field, self.input_expr()?);
471                    self.skip_semi();
472                }
473                self.expect(&Tok::CloseBrace)?;
474                Ok(InputExpr::Record(fields))
475            }
476            Some(Tok::OpenBracket) => {
477                self.at += 1;
478                let mut items = Vec::new();
479                while self.peek() != Some(&Tok::CloseBracket) {
480                    items.push(self.input_expr()?);
481                    if self.peek() == Some(&Tok::Comma) {
482                        self.at += 1;
483                    } else {
484                        break;
485                    }
486                }
487                self.expect(&Tok::CloseBracket)?;
488                Ok(InputExpr::List(items))
489            }
490            Some(Tok::Ident(reference)) => {
491                let reference = reference.clone();
492                self.at += 1;
493                reference
494                    .strip_suffix(".out")
495                    .map(|node| InputExpr::FromNode(node.to_string()))
496                    .ok_or_else(|| {
497                        SpecError::Malformed(format!(
498                            "`{reference}` is not a node reference — expected `<node>.out`"
499                        ))
500                    })
501            }
502            _ => Err(SpecError::Malformed(format!(
503                "expected a node reference, `{{...}}`, or `[...]`, found {}",
504                self.here()
505            ))),
506        }
507    }
508
509    /// `{ node_name = { "label" -> target; ... }; ... }` — the whole
510    /// `branches = {...}` body. Returns `(Branches, new_at)`, same handoff
511    /// convention as [`Self::node_graph`].
512    pub fn branches(&mut self) -> Result<(Branches, usize), SpecError> {
513        self.expect(&Tok::OpenBrace)?;
514        let mut decisions = Vec::new();
515        while self.peek().is_some() && self.peek() != Some(&Tok::CloseBrace) {
516            let node_name = self.ident()?;
517            self.expect(&Tok::Equals)?;
518            self.expect(&Tok::OpenBrace)?;
519            let mut labels = Vec::new();
520            while self.peek() != Some(&Tok::CloseBrace) {
521                let label = self.string()?;
522                self.expect(&Tok::Arrow)?;
523                let target = self.ident()?;
524                labels.push((label, target));
525                self.skip_semi();
526            }
527            self.expect(&Tok::CloseBrace)?;
528            decisions.push((node_name, labels));
529            self.skip_semi();
530        }
531        self.expect(&Tok::CloseBrace)?;
532        Ok((Branches { decisions }, self.at))
533    }
534}
535
536#[cfg(test)]
537mod is_simple_chain_tests {
538    use super::*;
539
540    fn node(block: &str, input: Option<InputExpr>) -> Node {
541        Node {
542            block: PathBuf::from(block),
543            input,
544            repeat_until: None,
545            max_iterations: None,
546            over: None,
547            accept: Vec::new(),
548            on_fail: Vec::new(),
549        }
550    }
551
552    fn from_node(name: &str) -> InputExpr {
553        InputExpr::FromNode(name.to_string())
554    }
555
556    #[test]
557    fn a_genuine_three_node_chain_is_simple() {
558        let graph = NodeGraph {
559            nodes: vec![
560                ("a".into(), node("blocks/a", None)),
561                ("b".into(), node("blocks/b", Some(from_node("a")))),
562                ("c".into(), node("blocks/c", Some(from_node("b")))),
563            ],
564        };
565        assert!(is_simple_chain(&graph, &Branches::default()));
566    }
567
568    #[test]
569    fn record_fan_in_is_not_simple() {
570        let mut fields = BTreeMap::new();
571        fields.insert("x".to_string(), from_node("a"));
572        fields.insert("y".to_string(), from_node("b"));
573        let graph = NodeGraph {
574            nodes: vec![
575                ("a".into(), node("blocks/a", None)),
576                ("b".into(), node("blocks/b", None)),
577                (
578                    "c".into(),
579                    node("blocks/c", Some(InputExpr::Record(fields))),
580                ),
581            ],
582        };
583        assert!(!is_simple_chain(&graph, &Branches::default()));
584    }
585
586    #[test]
587    fn list_fan_in_is_not_simple() {
588        let graph = NodeGraph {
589            nodes: vec![
590                ("a".into(), node("blocks/a", None)),
591                ("b".into(), node("blocks/b", None)),
592                (
593                    "c".into(),
594                    node(
595                        "blocks/c",
596                        Some(InputExpr::List(vec![from_node("a"), from_node("b")])),
597                    ),
598                ),
599            ],
600        };
601        assert!(!is_simple_chain(&graph, &Branches::default()));
602    }
603
604    #[test]
605    fn a_repeat_until_node_is_not_simple() {
606        let mut looped = node("blocks/b", Some(from_node("a")));
607        looped.repeat_until = Some("done".to_string());
608        looped.max_iterations = Some(5);
609        let graph = NodeGraph {
610            nodes: vec![("a".into(), node("blocks/a", None)), ("b".into(), looped)],
611        };
612        assert!(!is_simple_chain(&graph, &Branches::default()));
613    }
614
615    /// A `.cfbundle` manifest records each node's name, kind, resolution and
616    /// signature — nothing about *how* it executes. A fan-out node bundled
617    /// anyway would lose `over` on the way in and be silently ignored on the
618    /// way out, running once against the job input instead of N times over
619    /// the manifest, and producing a plausible-looking result. Refusing to
620    /// bundle is the only honest option until the format carries it.
621    #[test]
622    fn a_fan_out_node_is_not_simple_because_a_bundle_cannot_carry_over() {
623        let mut fanned = node("blocks/a", None);
624        fanned.over = Some(PathBuf::from("corpus/manifest.jsonl"));
625        let graph = NodeGraph {
626            nodes: vec![
627                ("a".into(), fanned),
628                ("b".into(), node("blocks/b", Some(from_node("a")))),
629            ],
630        };
631        assert!(!is_simple_chain(&graph, &Branches::default()));
632    }
633
634    #[test]
635    fn a_branches_decision_is_not_simple() {
636        let graph = NodeGraph {
637            nodes: vec![
638                ("a".into(), node("blocks/a", None)),
639                ("b".into(), node("blocks/b", Some(from_node("a")))),
640            ],
641        };
642        let branches = Branches {
643            decisions: vec![("a".to_string(), vec![("done".to_string(), "b".to_string())])],
644        };
645        assert!(!is_simple_chain(&graph, &branches));
646    }
647
648    /// Two independent, individually-simple nodes that both declare
649    /// `in = a.out` — fan-out from `a`. Neither `b` nor `c` individually has
650    /// fan-in (each has exactly one `FromNode` input); what makes this not a
651    /// chain is a whole-graph property (two referrers of `a`), not anything
652    /// visible from a single node in isolation. In this implementation the
653    /// position check (node `i`'s input must be exactly node `i-1`) already
654    /// rejects this case before the dedicated fan-out tally ever runs — `c`'s
655    /// immediate predecessor is `b`, not `a` — which is exactly why that
656    /// tally is documented as unreachable-but-kept-explicit. This test
657    /// pins the required *behavior* (fan-out is rejected), independent of
658    /// which internal check catches it.
659    #[test]
660    fn fan_out_from_a_shared_predecessor_is_not_simple() {
661        let graph = NodeGraph {
662            nodes: vec![
663                ("a".into(), node("blocks/a", None)),
664                ("b".into(), node("blocks/b", Some(from_node("a")))),
665                ("c".into(), node("blocks/c", Some(from_node("a")))),
666            ],
667        };
668        assert!(!is_simple_chain(&graph, &Branches::default()));
669    }
670}