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}