1use serde::{Deserialize, Serialize};
7use somatize_core::control::LoopCondition;
8use somatize_core::filter::RemoteTarget;
9use somatize_core::graph::NodeId;
10use std::fmt;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18#[non_exhaustive]
19pub enum ExecutionPlan {
20 Sequence(Vec<ExecutionPlan>),
22
23 Parallel(Vec<ExecutionPlan>),
25
26 Execute {
28 node_id: NodeId,
30 },
31
32 Step {
36 node_id: NodeId,
38 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 handoffs: Vec<(NodeId, ExecutionPlan)>,
46 },
47
48 Loop {
50 node_id: NodeId,
53 body: Box<ExecutionPlan>,
55 max_iterations: Option<usize>,
57 #[serde(default)]
60 until: LoopCondition,
61 #[serde(default)]
71 carry_from: Option<NodeId>,
72 },
73
74 Branch {
76 node_id: NodeId,
79 arms: Vec<(String, ExecutionPlan)>,
81 },
82
83 Remote {
85 node_id: NodeId,
89 target: RemoteTarget,
91 plan: Box<ExecutionPlan>,
93 },
94
95 Composite {
99 node_ids: Vec<NodeId>,
101 },
102
103 Stream {
107 node_ids: Vec<NodeId>,
109 chunk_size: usize,
111 },
112
113 Empty,
115}
116
117impl ExecutionPlan {
118 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 pub fn children(&self) -> impl Iterator<Item = (Option<&str>, &ExecutionPlan)> {
143 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 pub fn node_count(&self) -> usize {
194 self.own_node_ids().len() + self.children().map(|(_, p)| p.node_count()).sum::<usize>()
195 }
196
197 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 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 pub fn summary(&self) -> somatize_core::event::PlanSummary {
221 somatize_core::event::PlanSummary {
222 total_nodes: self.node_count(),
223 cached_nodes: 0,
225 parallel_branches: self.parallel_branch_count(),
226 }
227 }
228
229 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 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 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 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 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; 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 #[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 #[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 #[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 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}