Skip to main content

somatize_compiler/
plan.rs

1//! Execution plan — the compiled representation of a pipeline.
2//!
3//! Variants: Sequence, Parallel, Execute, Loop, Branch, Remote, Stream, Empty.
4//! Plans are data-free (no filter implementations) and serializable.
5
6use serde::{Deserialize, Serialize};
7use somatize_core::control::LoopCondition;
8use somatize_core::filter::RemoteTarget;
9use somatize_core::graph::NodeId;
10use std::fmt;
11
12/// A compiled execution plan produced by the compiler.
13///
14/// This is a recursive tree that the runtime walks to execute a pipeline.
15/// The compiler resolves caching, parallelism, and distribution before
16/// the runtime sees the plan.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[non_exhaustive]
19pub enum ExecutionPlan {
20    /// Execute steps sequentially, one after another.
21    Sequence(Vec<ExecutionPlan>),
22
23    /// Execute branches concurrently (fork-join).
24    Parallel(Vec<ExecutionPlan>),
25
26    /// Execute a single filter node.
27    Execute {
28        /// The graph node to execute.
29        node_id: NodeId,
30    },
31
32    /// Run an effectful step to completion: poll, perform its effects,
33    /// repeat. Distinct from `Execute` because the runtime has to drive a
34    /// turn loop and journal what it performs, not call a function once.
35    Step {
36        /// The effectful node the runtime drives.
37        node_id: NodeId,
38        /// Where this step may hand control, by target node id.
39        ///
40        /// A handoff is a branch the *step* decides rather than a condition
41        /// value, so it compiles the same way: each target is claimed by the
42        /// step and appears exactly once, inside it. A `Goto` naming
43        /// something not listed here is an error, not a jump into the dark.
44        #[serde(default, skip_serializing_if = "Vec::is_empty")]
45        handoffs: Vec<(NodeId, ExecutionPlan)>,
46    },
47
48    /// Iterate: run `body` until `until` says stop, or `max_iterations` is hit.
49    Loop {
50        /// The loop controller node — the id events and assignments are
51        /// reported under, distinct from any node inside `body`.
52        node_id: NodeId,
53        /// The sub-plan executed once per iteration.
54        body: Box<ExecutionPlan>,
55        /// Hard iteration cap; `None` leaves stopping entirely to `until`.
56        max_iterations: Option<usize>,
57        /// Already resolved by the compiler — never `BodyTerminal` here.
58        /// The executor reads the signal from exactly this node.
59        #[serde(default)]
60        until: LoopCondition,
61        /// The node whose output each pass hands to the next one.
62        ///
63        /// Separate from `until` on purpose: what a loop carries and what
64        /// tells it to stop are different questions. A debate that runs a
65        /// fixed number of rounds has no stop signal at all, but every round
66        /// still has to start from what the last one said — otherwise the
67        /// loop just repeats its first iteration.
68        ///
69        /// `None` when the body has no single terminal to carry from.
70        #[serde(default)]
71        carry_from: Option<NodeId>,
72    },
73
74    /// Conditional branching: evaluate condition, pick an arm.
75    Branch {
76        /// The node whose output selects an arm. The selector is control,
77        /// not data: the chosen arm receives the branch's *input*.
78        node_id: NodeId,
79        /// `(label, sub-plan)` per arm; the condition value picks by label.
80        arms: Vec<(String, ExecutionPlan)>,
81    },
82
83    /// Execute a sub-plan on a remote worker.
84    Remote {
85        /// The node the distribution directive was attached to. The wrapped
86        /// `plan` names it again, which is why this wrapper contributes no
87        /// ids of its own to `node_ids()`.
88        node_id: NodeId,
89        /// Where to run: a specific worker by id, or any worker with a tag.
90        target: RemoteTarget,
91        /// The sub-plan the remote worker executes.
92        plan: Box<ExecutionPlan>,
93    },
94
95    /// Execute multiple differentiable nodes as a single block.
96    /// The executor passes tensors directly between filters (no Value conversion),
97    /// preserving PyTorch autograd for gradient flow.
98    Composite {
99        /// The differentiable nodes fused into the block, in execution order.
100        node_ids: Vec<NodeId>,
101    },
102
103    /// Streaming execution: process input in chunks through a filter chain.
104    /// Each filter's StreamMode (FixedState/Evolving/Barrier) defines its
105    /// per-chunk contract. Results flow progressively — no full materialization.
106    Stream {
107        /// The filter chain each chunk flows through, in order.
108        node_ids: Vec<NodeId>,
109        /// How many input rows each chunk carries.
110        chunk_size: usize,
111    },
112
113    /// No-op: nothing to execute (e.g. empty graph).
114    Empty,
115}
116
117impl ExecutionPlan {
118    /// The node ids this variant introduces itself, excluding its children.
119    ///
120    /// `Remote` introduces none: it wraps a plan that already names the
121    /// node. Counting it here as well is what made `node_ids()` return the
122    /// same id twice for every remote node — and `LocalRunner::fit`, which
123    /// iterates that list, fit it twice.
124    fn own_node_ids(&self) -> &[String] {
125        match self {
126            Self::Execute { node_id }
127            | Self::Step { node_id, .. }
128            | Self::Loop { node_id, .. }
129            | Self::Branch { node_id, .. } => std::slice::from_ref(node_id),
130            Self::Composite { node_ids } | Self::Stream { node_ids, .. } => node_ids,
131            Self::Remote { .. } | Self::Sequence(_) | Self::Parallel(_) | Self::Empty => &[],
132        }
133    }
134
135    /// The sub-plans nested inside this one, each with its edge label if it
136    /// has one — a branch arm's label, a handoff's target.
137    ///
138    /// One structural walk, so the accessors below cannot disagree about
139    /// the shape of the tree. They used to: `node_count` skipped a step's
140    /// handoffs while `node_ids` collected them, so an agentic plan
141    /// reported fewer nodes than it had.
142    pub fn children(&self) -> impl Iterator<Item = (Option<&str>, &ExecutionPlan)> {
143        // Spelled out rather than defaulted with `_ => &[]`. A wildcard here
144        // is how a variant that owns sub-plans became invisible to
145        // `node_count`/`node_ids` once already: the compiler cannot warn
146        // about a case that is already handled. Listing every variant means
147        // adding one breaks this walk at compile time, where the omission
148        // is cheap to see.
149        let labelled: &[(String, ExecutionPlan)] = match self {
150            Self::Step { handoffs, .. } => handoffs,
151            Self::Branch { arms, .. } => arms,
152            Self::Sequence(_)
153            | Self::Parallel(_)
154            | Self::Execute { .. }
155            | Self::Loop { .. }
156            | Self::Remote { .. }
157            | Self::Composite { .. }
158            | Self::Stream { .. }
159            | Self::Empty => &[],
160        };
161        let plain: &[ExecutionPlan] = match self {
162            Self::Sequence(steps) | Self::Parallel(steps) => steps,
163            Self::Execute { .. }
164            | Self::Step { .. }
165            | Self::Loop { .. }
166            | Self::Branch { .. }
167            | Self::Remote { .. }
168            | Self::Composite { .. }
169            | Self::Stream { .. }
170            | Self::Empty => &[],
171        };
172        let single: Option<&ExecutionPlan> = match self {
173            Self::Loop { body, .. } => Some(body),
174            Self::Remote { plan, .. } => Some(plan),
175            Self::Sequence(_)
176            | Self::Parallel(_)
177            | Self::Execute { .. }
178            | Self::Step { .. }
179            | Self::Branch { .. }
180            | Self::Composite { .. }
181            | Self::Stream { .. }
182            | Self::Empty => None,
183        };
184
185        labelled
186            .iter()
187            .map(|(l, p)| (Some(l.as_str()), p))
188            .chain(plain.iter().map(|p| (None, p)))
189            .chain(single.map(|p| (None, p)))
190    }
191
192    /// Count total nodes in the plan.
193    pub fn node_count(&self) -> usize {
194        self.own_node_ids().len() + self.children().map(|(_, p)| p.node_count()).sum::<usize>()
195    }
196
197    /// Count parallel branches at the top level of the plan.
198    ///
199    /// Top level only, deliberately: this feeds a run's summary, and a
200    /// fan-out inside a loop body happens once per iteration rather than
201    /// once per run.
202    pub fn parallel_branch_count(&self) -> usize {
203        match self {
204            Self::Parallel(branches) => branches.len(),
205            Self::Sequence(steps) => steps.iter().map(|s| s.parallel_branch_count()).sum(),
206            _ => 0,
207        }
208    }
209
210    /// Collect all node IDs referenced in the plan.
211    pub fn node_ids(&self) -> Vec<&str> {
212        let mut ids: Vec<&str> = self.own_node_ids().iter().map(String::as_str).collect();
213        for (_, child) in self.children() {
214            ids.extend(child.node_ids());
215        }
216        ids
217    }
218
219    /// Create a PlanSummary for event payloads.
220    pub fn summary(&self) -> somatize_core::event::PlanSummary {
221        somatize_core::event::PlanSummary {
222            total_nodes: self.node_count(),
223            // Cache resolution moved to runtime; plans carry no cached nodes.
224            cached_nodes: 0,
225            parallel_branches: self.parallel_branch_count(),
226        }
227    }
228
229    /// Flatten unnecessary nesting (e.g. Sequence of one element).
230    pub fn simplify(self) -> Self {
231        match self {
232            Self::Sequence(mut steps) => {
233                steps = steps.into_iter().map(|s| s.simplify()).collect();
234                steps.retain(|s| !matches!(s, Self::Empty));
235                match steps.len() {
236                    0 => Self::Empty,
237                    1 => steps.into_iter().next().unwrap(),
238                    _ => Self::Sequence(steps),
239                }
240            }
241            Self::Parallel(mut branches) => {
242                branches = branches.into_iter().map(|b| b.simplify()).collect();
243                branches.retain(|b| !matches!(b, Self::Empty));
244                match branches.len() {
245                    0 => Self::Empty,
246                    1 => branches.into_iter().next().unwrap(),
247                    _ => Self::Parallel(branches),
248                }
249            }
250            other => other,
251        }
252    }
253}
254
255impl ExecutionPlan {
256    /// Render the execution plan as a Mermaid flowchart.
257    pub fn to_mermaid(&self) -> String {
258        let mut out = String::from("graph TD\n");
259        let mut counter = 0;
260        self.mermaid_nodes(&mut out, &mut counter, None);
261        out
262    }
263
264    /// Renders directly rather than over [`Self::children`], and so does
265    /// [`Self::graph_nodes`], because the two do not draw the same picture:
266    /// mermaid synthesises an `arm_N` node between a branch and each arm
267    /// and draws handoffs as dotted edges to the target, while `to_graph`
268    /// parents an arm straight to the branch and puts the label on the
269    /// edge. Folding them together would have to change one of the two
270    /// outputs. They share the shape of the recursion, not its result.
271    fn mermaid_nodes(&self, out: &mut String, counter: &mut usize, parent: Option<&str>) {
272        use std::fmt::Write;
273        match self {
274            Self::Execute { node_id } => {
275                let _ = writeln!(out, "    {node_id}[{node_id}]");
276                if let Some(p) = parent {
277                    let _ = writeln!(out, "    {p} --> {node_id}");
278                }
279            }
280            Self::Step { node_id, handoffs } => {
281                // Parallelogram — an effectful node reaches outside the graph.
282                let _ = writeln!(out, "    {node_id}[/{node_id}/]");
283                if let Some(p) = parent {
284                    let _ = writeln!(out, "    {p} --> {node_id}");
285                }
286                for (target, plan) in handoffs {
287                    let _ = writeln!(out, "    {node_id} -.->|{target}| {target}");
288                    plan.mermaid_nodes(out, counter, None);
289                }
290            }
291            Self::Sequence(steps) => {
292                let mut prev = parent.map(String::from);
293                for step in steps {
294                    step.mermaid_nodes(out, counter, prev.as_deref());
295                    prev = step.first_node_id().map(String::from);
296                }
297            }
298            Self::Parallel(branches) => {
299                let fork_id = format!("fork_{counter}");
300                *counter += 1;
301                let _ = writeln!(out, "    {fork_id}{{{{fork}}}}");
302                if let Some(p) = parent {
303                    let _ = writeln!(out, "    {p} --> {fork_id}");
304                }
305                for branch in branches {
306                    branch.mermaid_nodes(out, counter, Some(&fork_id));
307                }
308            }
309            Self::Loop {
310                node_id,
311                body,
312                max_iterations,
313                ..
314            } => {
315                let label = match max_iterations {
316                    Some(n) => format!("{node_id} loop max={n}"),
317                    None => format!("{node_id} loop"),
318                };
319                let _ = writeln!(out, "    {node_id}(({label}))");
320                if let Some(p) = parent {
321                    let _ = writeln!(out, "    {p} --> {node_id}");
322                }
323                body.mermaid_nodes(out, counter, Some(node_id));
324            }
325            Self::Branch { node_id, arms } => {
326                let _ = writeln!(out, "    {node_id}{{{{{node_id}}}}}");
327                if let Some(p) = parent {
328                    let _ = writeln!(out, "    {p} --> {node_id}");
329                }
330                for (label, plan) in arms {
331                    let arm_id = format!("arm_{counter}");
332                    *counter += 1;
333                    let _ = writeln!(out, "    {node_id} -->|{label}| {arm_id}[{label}]");
334                    plan.mermaid_nodes(out, counter, Some(&arm_id));
335                }
336            }
337            Self::Remote {
338                node_id,
339                target,
340                plan,
341            } => {
342                let _ = writeln!(out, "    {node_id}>{{{node_id} remote: {target:?}}}]");
343                if let Some(p) = parent {
344                    let _ = writeln!(out, "    {p} --> {node_id}");
345                }
346                plan.mermaid_nodes(out, counter, Some(node_id));
347            }
348            Self::Composite { node_ids } | Self::Stream { node_ids, .. } => {
349                use std::fmt::Write;
350                let stream_label = matches!(self, Self::Stream { .. });
351                let mut prev: Option<&str> = None;
352                for nid in node_ids {
353                    if stream_label {
354                        let _ = writeln!(out, "    {nid}([{nid} stream])");
355                    } else {
356                        let _ = writeln!(out, "    {nid}[{nid}]");
357                    }
358                    if let Some(p) = prev.or(parent) {
359                        let _ = writeln!(out, "    {p} --> {nid}");
360                    }
361                    prev = Some(nid);
362                }
363            }
364            Self::Empty => {}
365        }
366    }
367
368    fn first_node_id(&self) -> Option<&str> {
369        match self {
370            Self::Execute { node_id } | Self::Step { node_id, .. } => Some(node_id),
371            Self::Sequence(steps) => steps.first().and_then(|s| s.first_node_id()),
372            Self::Parallel(_) => None,
373            Self::Loop { node_id, .. }
374            | Self::Branch { node_id, .. }
375            | Self::Remote { node_id, .. } => Some(node_id),
376            Self::Composite { node_ids } | Self::Stream { node_ids, .. } => {
377                node_ids.first().map(|s| s.as_str())
378            }
379            Self::Empty => None,
380        }
381    }
382
383    /// Synthesize a displayable [`Graph`](somatize_core::graph::Graph)
384    /// from this plan — the same node synthesis as [`Self::to_mermaid`]
385    /// (fork nodes for `Parallel`, arm nodes for `Branch`, pills for
386    /// streams) — so every Graph renderer applies: `to_svg()`,
387    /// `to_mermaid()`, `to_graphviz()`.
388    pub fn to_graph(&self) -> somatize_core::graph::Graph {
389        let mut g = somatize_core::graph::Graph::new();
390        let mut counter = 0usize;
391        self.graph_nodes(&mut g, &mut counter, None, None);
392        g
393    }
394
395    fn add_edge(
396        g: &mut somatize_core::graph::Graph,
397        source: &str,
398        target: &str,
399        label: Option<&str>,
400    ) {
401        let mut edge =
402            somatize_core::graph::Edge::data(format!("e{}", g.edges.len()), source, target);
403        edge.label = label.map(str::to_string);
404        g.add_edge(edge);
405    }
406
407    fn graph_nodes(
408        &self,
409        g: &mut somatize_core::graph::Graph,
410        counter: &mut usize,
411        parent: Option<&str>,
412        edge_label: Option<&str>,
413    ) {
414        use somatize_core::graph::Node;
415        match self {
416            Self::Execute { node_id } => {
417                g.add_node(Node::new(node_id, node_id, node_id));
418                if let Some(p) = parent {
419                    Self::add_edge(g, p, node_id, edge_label);
420                }
421            }
422            Self::Step { node_id, handoffs } => {
423                g.add_node(Node::step(node_id, node_id));
424                if let Some(p) = parent {
425                    Self::add_edge(g, p, node_id, edge_label);
426                }
427                for (target, plan) in handoffs {
428                    plan.graph_nodes(g, counter, Some(node_id), Some(target));
429                }
430            }
431            Self::Sequence(steps) => {
432                let mut prev = parent.map(String::from);
433                let mut label = edge_label;
434                for step in steps {
435                    step.graph_nodes(g, counter, prev.as_deref(), label);
436                    label = None; // only the first hop carries the arm label
437                    prev = step.first_node_id().map(String::from);
438                }
439            }
440            Self::Parallel(branches) => {
441                let fork_id = format!("fork_{counter}");
442                *counter += 1;
443                let mut fork = Node::branch(fork_id.clone());
444                fork.label = "fork".to_string();
445                g.add_node(fork);
446                if let Some(p) = parent {
447                    Self::add_edge(g, p, &fork_id, edge_label);
448                }
449                for branch in branches {
450                    branch.graph_nodes(g, counter, Some(&fork_id), None);
451                }
452            }
453            Self::Loop {
454                node_id,
455                body,
456                max_iterations,
457                ..
458            } => {
459                g.add_node(Node::loop_node(node_id.clone(), *max_iterations));
460                if let Some(p) = parent {
461                    Self::add_edge(g, p, node_id, edge_label);
462                }
463                body.graph_nodes(g, counter, Some(node_id), None);
464            }
465            Self::Branch { node_id, arms } => {
466                g.add_node(Node::branch(node_id.clone()));
467                if let Some(p) = parent {
468                    Self::add_edge(g, p, node_id, edge_label);
469                }
470                for (label, plan) in arms {
471                    plan.graph_nodes(g, counter, Some(node_id), Some(label));
472                }
473            }
474            Self::Remote {
475                node_id,
476                target,
477                plan,
478            } => {
479                let mut node = Node::subgraph(node_id.clone(), somatize_core::graph::Graph::new());
480                node.label = format!("{node_id} (remote {target:?})");
481                g.add_node(node);
482                if let Some(p) = parent {
483                    Self::add_edge(g, p, node_id, edge_label);
484                }
485                plan.graph_nodes(g, counter, Some(node_id), None);
486            }
487            Self::Composite { node_ids } | Self::Stream { node_ids, .. } => {
488                let stream = matches!(self, Self::Stream { .. });
489                let mut prev: Option<&str> = None;
490                let mut label = edge_label;
491                for nid in node_ids {
492                    if stream {
493                        let mut node = Node::loop_node(nid.clone(), None);
494                        node.label = format!("{nid} stream");
495                        g.add_node(node);
496                    } else {
497                        g.add_node(Node::new(nid, nid, nid));
498                    }
499                    if let Some(p) = prev.or(parent) {
500                        Self::add_edge(g, p, nid, label);
501                    }
502                    label = None;
503                    prev = Some(nid);
504                }
505            }
506            Self::Empty => {}
507        }
508    }
509}
510
511impl fmt::Display for ExecutionPlan {
512    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
513        self.fmt_indent(f, 0)
514    }
515}
516
517impl ExecutionPlan {
518    fn fmt_indent(&self, f: &mut fmt::Formatter<'_>, indent: usize) -> fmt::Result {
519        let pad = "  ".repeat(indent);
520        match self {
521            Self::Sequence(steps) => {
522                writeln!(f, "{pad}Sequence:")?;
523                for step in steps {
524                    step.fmt_indent(f, indent + 1)?;
525                }
526                Ok(())
527            }
528            Self::Parallel(branches) => {
529                writeln!(f, "{pad}Parallel:")?;
530                for branch in branches {
531                    branch.fmt_indent(f, indent + 1)?;
532                }
533                Ok(())
534            }
535            Self::Execute { node_id } => writeln!(f, "{pad}Execute({node_id})"),
536            Self::Step { node_id, handoffs } => {
537                writeln!(f, "{pad}Step({node_id})")?;
538                for (target, plan) in handoffs {
539                    writeln!(f, "{pad}  ~>{target}:")?;
540                    plan.fmt_indent(f, indent + 2)?;
541                }
542                Ok(())
543            }
544            Self::Loop {
545                node_id,
546                body,
547                max_iterations,
548                ..
549            } => {
550                writeln!(f, "{pad}Loop({node_id}, max={max_iterations:?}):")?;
551                body.fmt_indent(f, indent + 1)
552            }
553            Self::Branch { node_id, arms } => {
554                writeln!(f, "{pad}Branch({node_id}):")?;
555                for (label, plan) in arms {
556                    writeln!(f, "{pad}  [{label}]:")?;
557                    plan.fmt_indent(f, indent + 2)?;
558                }
559                Ok(())
560            }
561            Self::Remote {
562                node_id,
563                target,
564                plan,
565            } => {
566                writeln!(f, "{pad}Remote({node_id}, target={target:?}):")?;
567                plan.fmt_indent(f, indent + 1)
568            }
569            Self::Composite { node_ids } => {
570                let ids = node_ids
571                    .iter()
572                    .map(|s| s.as_str())
573                    .collect::<Vec<_>>()
574                    .join(" \u{2192} ");
575                writeln!(f, "{pad}Composite[{ids}]")
576            }
577            Self::Stream {
578                node_ids,
579                chunk_size,
580            } => {
581                let ids = node_ids
582                    .iter()
583                    .map(|s| s.as_str())
584                    .collect::<Vec<_>>()
585                    .join(" \u{2192} ");
586                writeln!(f, "{pad}Stream[{ids}](chunk_size={chunk_size})")
587            }
588            Self::Empty => writeln!(f, "{pad}Empty"),
589        }
590    }
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    /// `Remote` wraps a plan that already names the node, so counting the
598    /// wrapper's id as well listed it twice. `LocalRunner::fit` iterates
599    /// this list, so a remote trainable node was fitted twice.
600    #[test]
601    fn a_remote_node_is_listed_once() {
602        let plan = ExecutionPlan::Remote {
603            node_id: "n".into(),
604            target: somatize_core::filter::RemoteTarget::Tag("gpu".into()),
605            plan: Box::new(ExecutionPlan::Execute {
606                node_id: "n".into(),
607            }),
608        };
609        assert_eq!(plan.node_ids(), vec!["n"]);
610        assert_eq!(plan.node_count(), 1);
611    }
612
613    /// `node_count` and `node_ids` walk the same tree and must agree.
614    /// `node_count` used to skip a step's handoffs while `node_ids`
615    /// collected them, so an agentic plan reported fewer nodes than it ran.
616    #[test]
617    fn the_two_walks_agree_on_a_plan_with_handoffs() {
618        let plan = ExecutionPlan::Step {
619            node_id: "router".into(),
620            handoffs: vec![
621                (
622                    "billing".into(),
623                    ExecutionPlan::Execute {
624                        node_id: "billing".into(),
625                    },
626                ),
627                (
628                    "tech".into(),
629                    ExecutionPlan::Sequence(vec![
630                        ExecutionPlan::Execute {
631                            node_id: "triage".into(),
632                        },
633                        ExecutionPlan::Execute {
634                            node_id: "tech".into(),
635                        },
636                    ]),
637                ),
638            ],
639        };
640
641        assert_eq!(plan.node_ids(), vec!["router", "billing", "triage", "tech"]);
642        assert_eq!(plan.node_count(), plan.node_ids().len());
643    }
644
645    /// Whatever the shape, the two accessors count the same tree.
646    #[test]
647    fn node_count_is_the_length_of_node_ids() {
648        let plan = ExecutionPlan::Sequence(vec![
649            ExecutionPlan::Execute {
650                node_id: "prep".into(),
651            },
652            ExecutionPlan::Parallel(vec![
653                ExecutionPlan::Execute {
654                    node_id: "a".into(),
655                },
656                ExecutionPlan::Loop {
657                    node_id: "refine".into(),
658                    body: Box::new(ExecutionPlan::Execute {
659                        node_id: "draft".into(),
660                    }),
661                    max_iterations: Some(3),
662                    until: somatize_core::control::LoopCondition::Exhaust,
663                    carry_from: None,
664                },
665            ]),
666            ExecutionPlan::Branch {
667                node_id: "route".into(),
668                arms: vec![(
669                    "left".into(),
670                    ExecutionPlan::Execute {
671                        node_id: "l".into(),
672                    },
673                )],
674            },
675        ]);
676        assert_eq!(plan.node_count(), plan.node_ids().len());
677    }
678
679    #[test]
680    fn node_count_linear() {
681        let plan = ExecutionPlan::Sequence(vec![
682            ExecutionPlan::Execute {
683                node_id: "a".into(),
684            },
685            ExecutionPlan::Execute {
686                node_id: "b".into(),
687            },
688            ExecutionPlan::Execute {
689                node_id: "c".into(),
690            },
691        ]);
692        assert_eq!(plan.node_count(), 3);
693    }
694
695    #[test]
696    fn parallel_branch_count() {
697        let plan = ExecutionPlan::Sequence(vec![
698            ExecutionPlan::Execute {
699                node_id: "a".into(),
700            },
701            ExecutionPlan::Parallel(vec![
702                ExecutionPlan::Execute {
703                    node_id: "b".into(),
704                },
705                ExecutionPlan::Execute {
706                    node_id: "c".into(),
707                },
708                ExecutionPlan::Execute {
709                    node_id: "d".into(),
710                },
711            ]),
712            ExecutionPlan::Execute {
713                node_id: "e".into(),
714            },
715        ]);
716        assert_eq!(plan.parallel_branch_count(), 3);
717        assert_eq!(plan.node_count(), 5);
718    }
719
720    #[test]
721    fn node_ids_collected() {
722        let plan = ExecutionPlan::Sequence(vec![
723            ExecutionPlan::Execute {
724                node_id: "a".into(),
725            },
726            ExecutionPlan::Execute {
727                node_id: "b".into(),
728            },
729        ]);
730        let ids = plan.node_ids();
731        assert_eq!(ids, vec!["a", "b"]);
732    }
733
734    #[test]
735    fn simplify_removes_empty() {
736        let plan = ExecutionPlan::Sequence(vec![
737            ExecutionPlan::Empty,
738            ExecutionPlan::Execute {
739                node_id: "a".into(),
740            },
741            ExecutionPlan::Empty,
742        ]);
743        let simplified = plan.simplify();
744        assert!(matches!(simplified, ExecutionPlan::Execute { .. }));
745    }
746
747    #[test]
748    fn simplify_unwraps_single_element() {
749        let plan = ExecutionPlan::Sequence(vec![ExecutionPlan::Execute {
750            node_id: "a".into(),
751        }]);
752        let simplified = plan.simplify();
753        assert!(matches!(simplified, ExecutionPlan::Execute { .. }));
754    }
755
756    #[test]
757    fn simplify_preserves_multi() {
758        let plan = ExecutionPlan::Sequence(vec![
759            ExecutionPlan::Execute {
760                node_id: "a".into(),
761            },
762            ExecutionPlan::Execute {
763                node_id: "b".into(),
764            },
765        ]);
766        let simplified = plan.simplify();
767        assert!(matches!(simplified, ExecutionPlan::Sequence(_)));
768    }
769
770    #[test]
771    fn display_format() {
772        let plan = ExecutionPlan::Sequence(vec![
773            ExecutionPlan::Execute {
774                node_id: "scaler".into(),
775            },
776            ExecutionPlan::Parallel(vec![
777                ExecutionPlan::Execute {
778                    node_id: "pca".into(),
779                },
780                ExecutionPlan::Execute {
781                    node_id: "umap".into(),
782                },
783            ]),
784            ExecutionPlan::Execute {
785                node_id: "svm".into(),
786            },
787        ]);
788        let output = format!("{plan}");
789        assert!(output.contains("Sequence:"));
790        assert!(output.contains("Parallel:"));
791        assert!(output.contains("Execute(scaler)"));
792        assert!(output.contains("Execute(pca)"));
793    }
794
795    #[test]
796    fn summary_values() {
797        let plan = ExecutionPlan::Sequence(vec![
798            ExecutionPlan::Execute {
799                node_id: "a".into(),
800            },
801            ExecutionPlan::Parallel(vec![
802                ExecutionPlan::Execute {
803                    node_id: "b".into(),
804                },
805                ExecutionPlan::Execute {
806                    node_id: "c".into(),
807                },
808            ]),
809            ExecutionPlan::Execute {
810                node_id: "d".into(),
811            },
812        ]);
813        let summary = plan.summary();
814        assert_eq!(summary.total_nodes, 4);
815        assert_eq!(summary.cached_nodes, 0);
816        assert_eq!(summary.parallel_branches, 2);
817    }
818
819    #[test]
820    fn serde_roundtrip() {
821        let plan = ExecutionPlan::Sequence(vec![
822            ExecutionPlan::Execute {
823                node_id: "a".into(),
824            },
825            ExecutionPlan::Execute {
826                node_id: "b".into(),
827            },
828        ]);
829        let json = serde_json::to_string(&plan).unwrap();
830        let deserialized: ExecutionPlan = serde_json::from_str(&json).unwrap();
831        assert_eq!(deserialized.node_count(), 2);
832    }
833
834    #[test]
835    fn empty_plan() {
836        let plan = ExecutionPlan::Empty;
837        assert_eq!(plan.node_count(), 0);
838        assert!(plan.node_ids().is_empty());
839    }
840
841    #[test]
842    fn to_mermaid_sequence() {
843        let plan = ExecutionPlan::Sequence(vec![
844            ExecutionPlan::Execute {
845                node_id: "scaler".into(),
846            },
847            ExecutionPlan::Execute {
848                node_id: "model".into(),
849            },
850        ]);
851        let m = plan.to_mermaid();
852        assert!(m.starts_with("graph TD"));
853        assert!(m.contains("scaler[scaler]"));
854        assert!(m.contains("model[model]"));
855        assert!(m.contains("scaler --> model"));
856    }
857
858    #[test]
859    fn to_mermaid_parallel() {
860        let plan = ExecutionPlan::Parallel(vec![
861            ExecutionPlan::Execute {
862                node_id: "a".into(),
863            },
864            ExecutionPlan::Execute {
865                node_id: "b".into(),
866            },
867        ]);
868        let m = plan.to_mermaid();
869        assert!(m.contains("fork_0{"));
870        assert!(m.contains("fork_0 --> a"));
871        assert!(m.contains("fork_0 --> b"));
872    }
873}
874
875#[cfg(test)]
876mod to_graph_tests {
877    use super::*;
878
879    #[test]
880    fn plan_to_graph_mirrors_mermaid_synthesis() {
881        let plan = ExecutionPlan::Sequence(vec![
882            ExecutionPlan::Execute {
883                node_id: "load".into(),
884            },
885            ExecutionPlan::Parallel(vec![
886                ExecutionPlan::Execute {
887                    node_id: "a".into(),
888                },
889                ExecutionPlan::Execute {
890                    node_id: "b".into(),
891                },
892            ]),
893        ]);
894        let g = plan.to_graph();
895        let ids: Vec<&str> = g.nodes.iter().map(|n| n.id.as_str()).collect();
896        assert_eq!(ids, vec!["load", "fork_0", "a", "b"]);
897        assert_eq!(g.nodes[1].label, "fork");
898        let edges: Vec<(&str, &str)> = g
899            .edges
900            .iter()
901            .map(|e| (e.source.as_str(), e.target.as_str()))
902            .collect();
903        assert_eq!(
904            edges,
905            vec![("load", "fork_0"), ("fork_0", "a"), ("fork_0", "b")]
906        );
907        // Every Graph renderer now applies to the plan.
908        let svg = g.to_svg();
909        assert!(svg.starts_with("<svg"));
910        assert!(svg.contains(">fork</text>"));
911    }
912
913    #[test]
914    fn plan_to_graph_branch_arms_carry_edge_labels() {
915        let plan = ExecutionPlan::Branch {
916            node_id: "check".into(),
917            arms: vec![
918                (
919                    "converged".into(),
920                    ExecutionPlan::Execute {
921                        node_id: "stop".into(),
922                    },
923                ),
924                (
925                    "continue".into(),
926                    ExecutionPlan::Execute {
927                        node_id: "train".into(),
928                    },
929                ),
930            ],
931        };
932        let g = plan.to_graph();
933        let labels: Vec<Option<&str>> = g.edges.iter().map(|e| e.label.as_deref()).collect();
934        assert_eq!(labels, vec![Some("converged"), Some("continue")]);
935    }
936}