1use crate::control::LoopCondition;
7use crate::error::{Result, SomaError};
8use crate::strategy::TrainingStrategy;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12pub type NodeId = String;
18
19pub type EdgeId = String;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type")]
25#[non_exhaustive]
26pub enum NodeKind {
27 Filter {
29 filter_name: String,
31 },
32 SubGraph {
34 graph: Box<Graph>,
36 },
37 Loop {
40 max_iterations: Option<usize>,
43 #[serde(default)]
46 until: LoopCondition,
47 },
48 Branch {
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
56 arms: Vec<String>,
57 },
58 Step {
61 step_name: String,
63 },
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Node {
69 pub id: NodeId,
71 pub label: String,
74 pub kind: NodeKind,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub target: Option<String>,
80}
81
82impl Node {
83 pub fn new(
85 id: impl Into<String>,
86 label: impl Into<String>,
87 filter_name: impl Into<String>,
88 ) -> Self {
89 Self {
90 id: id.into(),
91 label: label.into(),
92 kind: NodeKind::Filter {
93 filter_name: filter_name.into(),
94 },
95 target: None,
96 }
97 }
98
99 pub fn filter_with_id(id: impl Into<String>, filter_name: impl Into<String>) -> Self {
101 let id = id.into();
102 Self {
103 label: id.clone(),
104 id,
105 kind: NodeKind::Filter {
106 filter_name: filter_name.into(),
107 },
108 target: None,
109 }
110 }
111
112 pub fn filter(filter_name: impl Into<String>) -> Self {
114 let name = filter_name.into();
115 Self {
116 id: name.clone(),
117 label: name.clone(),
118 kind: NodeKind::Filter { filter_name: name },
119 target: None,
120 }
121 }
122
123 pub fn subgraph(id: impl Into<String>, graph: Graph) -> Self {
125 let id = id.into();
126 Self {
127 id: id.clone(),
128 label: id,
129 kind: NodeKind::SubGraph {
130 graph: Box::new(graph),
131 },
132 target: None,
133 }
134 }
135
136 pub fn loop_node(id: impl Into<String>, max_iterations: Option<usize>) -> Self {
138 Self::loop_until(id, max_iterations, LoopCondition::BodyTerminal)
139 }
140
141 pub fn loop_until(
143 id: impl Into<String>,
144 max_iterations: Option<usize>,
145 until: LoopCondition,
146 ) -> Self {
147 let id = id.into();
148 Self {
149 id: id.clone(),
150 label: id,
151 kind: NodeKind::Loop {
152 max_iterations,
153 until,
154 },
155 target: None,
156 }
157 }
158
159 pub fn step(id: impl Into<String>, step_name: impl Into<String>) -> Self {
161 let id = id.into();
162 Self {
163 label: id.clone(),
164 id,
165 kind: NodeKind::Step {
166 step_name: step_name.into(),
167 },
168 target: None,
169 }
170 }
171
172 pub fn branch(id: impl Into<String>) -> Self {
174 Self::branch_over(id, Vec::<String>::new())
175 }
176
177 pub fn branch_over(
184 id: impl Into<String>,
185 arms: impl IntoIterator<Item = impl Into<String>>,
186 ) -> Self {
187 let id = id.into();
188 Self {
189 id: id.clone(),
190 label: id,
191 kind: NodeKind::Branch {
192 arms: arms.into_iter().map(Into::into).collect(),
193 },
194 target: None,
195 }
196 }
197
198 pub fn with_target(mut self, target: impl Into<String>) -> Self {
200 self.target = Some(target.into());
201 self
202 }
203
204 pub fn is_local(&self) -> bool {
206 self.target.as_deref() == Some("local")
207 }
208
209 pub fn filter_name(&self) -> Option<&str> {
211 match &self.kind {
212 NodeKind::Filter { filter_name } => Some(filter_name),
213 _ => None,
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220pub enum EdgeKind {
221 Data,
223 Control,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct Edge {
230 pub id: EdgeId,
233 pub source: NodeId,
235 pub target: NodeId,
237 pub kind: EdgeKind,
239 pub label: Option<String>,
241}
242
243impl Edge {
244 pub fn data(
250 id: impl Into<String>,
251 source: impl Into<String>,
252 target: impl Into<String>,
253 ) -> Self {
254 Self {
255 id: id.into(),
256 source: source.into(),
257 target: target.into(),
258 kind: EdgeKind::Data,
259 label: None,
260 }
261 }
262
263 pub fn control(
270 id: impl Into<String>,
271 source: impl Into<String>,
272 target: impl Into<String>,
273 ) -> Self {
274 Self {
275 id: id.into(),
276 source: source.into(),
277 target: target.into(),
278 kind: EdgeKind::Control,
279 label: None,
280 }
281 }
282
283 pub fn with_label(mut self, label: impl Into<String>) -> Self {
286 self.label = Some(label.into());
287 self
288 }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Graph {
294 pub nodes: Vec<Node>,
296 pub edges: Vec<Edge>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub training_strategy: Option<TrainingStrategy>,
302}
303
304impl Graph {
305 pub fn new() -> Self {
307 Self {
308 nodes: Vec::new(),
309 edges: Vec::new(),
310 training_strategy: None,
311 }
312 }
313
314 pub fn with_strategy(mut self, strategy: TrainingStrategy) -> Self {
316 self.training_strategy = Some(strategy);
317 self
318 }
319
320 pub fn set_strategy(&mut self, strategy: TrainingStrategy) {
322 self.training_strategy = Some(strategy);
323 }
324
325 pub fn effective_strategy_is_distributed(&self) -> bool {
332 !matches!(self.effective_strategy(), TrainingStrategy::Local)
333 }
334
335 pub fn effective_strategy(&self) -> &TrainingStrategy {
337 static LOCAL: TrainingStrategy = TrainingStrategy::Local;
338 self.training_strategy.as_ref().unwrap_or(&LOCAL)
339 }
340
341 pub fn add_node(&mut self, node: Node) {
344 self.nodes.push(node);
345 }
346
347 pub fn add_filter(&mut self, filter_name: impl Into<String>) -> &str {
350 let name = filter_name.into();
351 let id = if self.nodes.iter().any(|n| n.id == name) {
352 let mut i = 2;
353 loop {
354 let candidate = format!("{name}_{i}");
355 if !self.nodes.iter().any(|n| n.id == candidate) {
356 break candidate;
357 }
358 i += 1;
359 }
360 } else {
361 name.clone()
362 };
363 self.nodes.push(Node::filter_with_id(&id, &name));
364 &self.nodes.last().unwrap().id
365 }
366
367 pub fn add_edge(&mut self, edge: Edge) {
370 self.edges.push(edge);
371 }
372
373 pub fn connect(&mut self, source: impl Into<String>, target: impl Into<String>) {
375 let id = format!("e_{}", self.edges.len());
376 self.edges.push(Edge::data(id, source, target));
377 }
378
379 pub fn node(&self, id: &str) -> Option<&Node> {
381 self.nodes.iter().find(|n| n.id == id)
382 }
383
384 pub fn node_ids(&self) -> Vec<&str> {
386 self.nodes.iter().map(|n| n.id.as_str()).collect()
387 }
388
389 pub fn predecessors(&self, node_id: &str) -> Vec<&str> {
391 self.edges
392 .iter()
393 .filter(|e| e.target == node_id)
394 .map(|e| e.source.as_str())
395 .collect()
396 }
397
398 pub fn successors(&self, node_id: &str) -> Vec<&str> {
400 self.edges
401 .iter()
402 .filter(|e| e.source == node_id)
403 .map(|e| e.target.as_str())
404 .collect()
405 }
406
407 pub fn roots(&self) -> Vec<&str> {
409 let has_incoming: HashSet<&str> = self.edges.iter().map(|e| e.target.as_str()).collect();
410 self.nodes
411 .iter()
412 .filter(|n| !has_incoming.contains(n.id.as_str()))
413 .map(|n| n.id.as_str())
414 .collect()
415 }
416
417 pub fn leaves(&self) -> Vec<&str> {
419 let has_outgoing: HashSet<&str> = self.edges.iter().map(|e| e.source.as_str()).collect();
420 self.nodes
421 .iter()
422 .filter(|n| !has_outgoing.contains(n.id.as_str()))
423 .map(|n| n.id.as_str())
424 .collect()
425 }
426
427 fn in_degrees(&self) -> HashMap<&str, usize> {
429 let mut degrees: HashMap<&str, usize> =
430 self.nodes.iter().map(|n| (n.id.as_str(), 0)).collect();
431 for edge in &self.edges {
432 *degrees.entry(edge.target.as_str()).or_insert(0) += 1;
433 }
434 degrees
435 }
436
437 pub fn topological_sort(&self) -> Result<Vec<&str>> {
440 let mut in_deg = self.in_degrees();
441 let mut queue: Vec<&str> = in_deg
442 .iter()
443 .filter(|(_, deg)| **deg == 0)
444 .map(|(&id, _)| id)
445 .collect();
446 queue.sort(); let mut sorted = Vec::with_capacity(self.nodes.len());
449
450 while let Some(node) = queue.pop() {
451 sorted.push(node);
452 let mut next = Vec::new();
453 for succ in self.successors(node) {
454 if let Some(deg) = in_deg.get_mut(succ) {
455 *deg -= 1;
456 if *deg == 0 {
457 next.push(succ);
458 }
459 }
460 }
461 next.sort();
462 for n in next.into_iter().rev() {
464 queue.push(n);
465 }
466 }
467
468 if sorted.len() != self.nodes.len() {
469 return Err(SomaError::CycleDetected);
470 }
471
472 Ok(sorted)
473 }
474
475 pub fn validate(&self) -> Result<()> {
477 let mut seen = HashSet::new();
479 for node in &self.nodes {
480 if !seen.insert(&node.id) {
481 return Err(SomaError::Compilation(format!(
482 "duplicate node id: `{}`",
483 node.id
484 )));
485 }
486 }
487
488 let node_ids: HashSet<&str> = self.nodes.iter().map(|n| n.id.as_str()).collect();
490 for edge in &self.edges {
491 if !node_ids.contains(edge.source.as_str()) {
492 return Err(SomaError::NodeNotFound(edge.source.clone()));
493 }
494 if !node_ids.contains(edge.target.as_str()) {
495 return Err(SomaError::NodeNotFound(edge.target.clone()));
496 }
497 }
498
499 self.topological_sort()?;
501
502 for node in &self.nodes {
504 if let NodeKind::SubGraph { graph } = &node.kind {
505 graph.validate()?;
506 }
507 }
508
509 Ok(())
510 }
511
512 pub fn contains_steps(&self) -> bool {
518 self.nodes.iter().any(|node| match &node.kind {
519 NodeKind::Step { .. } => true,
520 NodeKind::SubGraph { graph } => graph.contains_steps(),
521 _ => false,
522 })
523 }
524}
525
526impl Graph {
529 pub fn to_mermaid(&self) -> String {
538 self.to_mermaid_with(&crate::viz::GraphOverlay::default())
539 }
540
541 pub fn to_mermaid_with(&self, overlay: &crate::viz::GraphOverlay) -> String {
548 use std::fmt::Write;
549 let mut out = String::from("graph LR\n");
550 for node in &self.nodes {
551 let ov = overlay.nodes.get(&node.id);
552 let label_with = |base: &str| match ov.and_then(|o| o.sublabel_text()) {
554 Some(sub) => format!("\"{base}<br/>{sub}\""),
555 None => base.to_string(),
556 };
557 let shape = match &node.kind {
558 NodeKind::Filter { .. } => {
559 format!(" {}[{}]", node.id, label_with(&node.label))
560 }
561 NodeKind::SubGraph { .. } => {
562 format!(" {}[[{}]]", node.id, label_with(&node.label))
563 }
564 NodeKind::Loop { max_iterations, .. } => {
565 let label = match max_iterations {
566 Some(n) => format!("{} (max {})", node.label, n),
567 None => node.label.clone(),
568 };
569 format!(" {}(({}))", node.id, label_with(&label))
570 }
571 NodeKind::Branch { .. } => {
572 format!(" {}{{{{{}}}}}", node.id, label_with(&node.label))
573 }
574 NodeKind::Step { .. } => {
577 format!(" {}[/{}/]", node.id, label_with(&node.label))
578 }
579 };
580 let _ = writeln!(out, "{shape}");
581 }
582 for edge in &self.edges {
583 let arrow = match edge.kind {
584 EdgeKind::Data => "-->",
585 EdgeKind::Control => "-.->",
586 };
587 if let Some(label) = &edge.label {
588 let _ = writeln!(
589 out,
590 " {} {}|{}| {}",
591 edge.source, arrow, label, edge.target
592 );
593 } else {
594 let _ = writeln!(out, " {} {} {}", edge.source, arrow, edge.target);
595 }
596 }
597 let assignments: Vec<(&str, &'static str)> = self
598 .nodes
599 .iter()
600 .filter_map(|n| {
601 overlay
602 .nodes
603 .get(&n.id)
604 .and_then(|o| o.style_class())
605 .map(|class| (n.id.as_str(), class))
606 })
607 .collect();
608 if !assignments.is_empty() {
609 let mut used: Vec<&'static str> = assignments.iter().map(|(_, c)| *c).collect();
610 used.sort_unstable();
611 used.dedup();
612 for class in used {
613 let _ = writeln!(
614 out,
615 " classDef {class} {}",
616 crate::viz::mermaid_class_style(class)
617 );
618 }
619 for (id, class) in assignments {
620 let _ = writeln!(out, " class {id} {class}");
621 }
622 }
623 out
624 }
625
626 pub fn to_graphviz(&self) -> String {
628 self.to_graphviz_with(&crate::viz::GraphOverlay::default())
629 }
630
631 pub fn to_graphviz_with(&self, overlay: &crate::viz::GraphOverlay) -> String {
635 use std::fmt::Write;
636 let mut out = String::from("digraph G {\n rankdir=LR;\n");
637 for node in &self.nodes {
638 let shape = match &node.kind {
639 NodeKind::Filter { .. } => "box",
640 NodeKind::SubGraph { .. } => "doubleoctagon",
641 NodeKind::Loop { .. } => "ellipse",
642 NodeKind::Branch { .. } => "diamond",
643 NodeKind::Step { .. } => "parallelogram",
644 };
645 let ov = overlay.nodes.get(&node.id);
646 let label = match ov.and_then(|o| o.sublabel_text()) {
647 Some(sub) => format!("{}\\n{}", node.label, sub),
648 None => node.label.clone(),
649 };
650 let style = ov
651 .and_then(|o| o.style_class())
652 .map(crate::viz::dot_class_style)
653 .unwrap_or_default();
654 let _ = writeln!(
655 out,
656 " \"{}\" [label=\"{}\" shape={}{}];",
657 node.id, label, shape, style
658 );
659 }
660 for edge in &self.edges {
661 let style = match edge.kind {
662 EdgeKind::Data => "",
663 EdgeKind::Control => " [style=dashed]",
664 };
665 let label = edge
666 .label
667 .as_ref()
668 .map(|l| format!(" [label=\"{l}\"]"))
669 .unwrap_or_default();
670 let attrs = if style.is_empty() && label.is_empty() {
671 String::new()
672 } else if label.is_empty() {
673 style.to_string()
674 } else {
675 label
676 };
677 let _ = writeln!(
678 out,
679 " \"{}\" -> \"{}\"{};",
680 edge.source, edge.target, attrs
681 );
682 }
683 out.push_str("}\n");
684 out
685 }
686
687 pub fn to_text(&self) -> String {
689 use std::fmt::Write;
690 let mut out = String::new();
691 let sorted = self.topological_sort().unwrap_or_default();
692 let total_nodes = self.nodes.len();
693 let total_edges = self.edges.len();
694 let _ = writeln!(out, "Graph ({total_nodes} nodes, {total_edges} edges)");
695
696 for (i, node_id) in sorted.iter().enumerate() {
697 let node = match self.node(node_id) {
698 Some(n) => n,
699 None => continue,
700 };
701 let is_last = i == sorted.len() - 1;
702 let prefix = if is_last { "└── " } else { "├── " };
703 let kind_tag = match &node.kind {
704 NodeKind::Filter { filter_name } => {
705 if filter_name == &node.id {
706 String::new()
707 } else {
708 format!(" ({})", filter_name)
709 }
710 }
711 NodeKind::SubGraph { graph } => {
712 format!(" [subgraph: {} nodes]", graph.nodes.len())
713 }
714 NodeKind::Loop { max_iterations, .. } => match max_iterations {
715 Some(n) => format!(" [loop max={n}]"),
716 None => " [loop]".into(),
717 },
718 NodeKind::Branch { .. } => " [branch]".into(),
719 NodeKind::Step { step_name } => format!(" [step: {step_name}]"),
720 };
721 let preds = self.predecessors(node_id);
722 let pred_info = if preds.is_empty() {
723 String::new()
724 } else {
725 format!(" ← {}", preds.join(", "))
726 };
727 let _ = writeln!(out, "{prefix}{}{kind_tag}{pred_info}", node.id);
728 }
729 out
730 }
731}
732
733impl std::fmt::Display for Graph {
734 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
735 write!(f, "{}", self.to_text())
736 }
737}
738
739impl Default for Graph {
740 fn default() -> Self {
741 Self::new()
742 }
743}
744
745pub fn linear_pipeline(nodes: Vec<Node>) -> Graph {
747 let mut graph = Graph::new();
748 for (i, node) in nodes.iter().enumerate() {
749 graph.add_node(node.clone());
750 if i > 0 {
751 graph.add_edge(Edge::data(format!("e_{}", i), &nodes[i - 1].id, &node.id));
752 }
753 }
754 graph
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760
761 fn sample_linear_graph() -> Graph {
762 linear_pipeline(vec![
763 Node::new("a", "Scaler", "StandardScaler"),
764 Node::new("b", "PCA", "PCA"),
765 Node::new("c", "SVM", "SVM"),
766 ])
767 }
768
769 #[test]
770 fn linear_pipeline_structure() {
771 let g = sample_linear_graph();
772 assert_eq!(g.nodes.len(), 3);
773 assert_eq!(g.edges.len(), 2);
774 }
775
776 #[test]
777 fn roots_and_leaves() {
778 let g = sample_linear_graph();
779 assert_eq!(g.roots(), vec!["a"]);
780 assert_eq!(g.leaves(), vec!["c"]);
781 }
782
783 #[test]
784 fn predecessors_and_successors() {
785 let g = sample_linear_graph();
786 assert!(g.predecessors("a").is_empty());
787 assert_eq!(g.predecessors("b"), vec!["a"]);
788 assert_eq!(g.successors("a"), vec!["b"]);
789 assert_eq!(g.successors("b"), vec!["c"]);
790 assert!(g.successors("c").is_empty());
791 }
792
793 #[test]
794 fn topological_sort_linear() {
795 let g = sample_linear_graph();
796 let sorted = g.topological_sort().unwrap();
797 assert_eq!(sorted, vec!["a", "b", "c"]);
798 }
799
800 #[test]
801 fn topological_sort_parallel() {
802 let mut g = Graph::new();
803 g.add_node(Node::new("root", "Root", "Input"));
804 g.add_node(Node::new("b1", "Branch1", "F1"));
805 g.add_node(Node::new("b2", "Branch2", "F2"));
806 g.add_node(Node::new("merge", "Merge", "Merge"));
807 g.add_edge(Edge::data("e1", "root", "b1"));
808 g.add_edge(Edge::data("e2", "root", "b2"));
809 g.add_edge(Edge::data("e3", "b1", "merge"));
810 g.add_edge(Edge::data("e4", "b2", "merge"));
811
812 let sorted = g.topological_sort().unwrap();
813 assert_eq!(sorted[0], "root");
815 assert_eq!(sorted[3], "merge");
816 let middle: HashSet<&str> = sorted[1..3].iter().copied().collect();
818 assert!(middle.contains("b1"));
819 assert!(middle.contains("b2"));
820 }
821
822 #[test]
823 fn topological_sort_detects_cycle() {
824 let mut g = Graph::new();
825 g.add_node(Node::new("a", "A", "F"));
826 g.add_node(Node::new("b", "B", "F"));
827 g.add_edge(Edge::data("e1", "a", "b"));
828 g.add_edge(Edge::data("e2", "b", "a")); let result = g.topological_sort();
831 assert!(matches!(result, Err(SomaError::CycleDetected)));
832 }
833
834 #[test]
835 fn validate_accepts_valid_graph() {
836 let g = sample_linear_graph();
837 assert!(g.validate().is_ok());
838 }
839
840 #[test]
841 fn validate_rejects_duplicate_ids() {
842 let mut g = Graph::new();
843 g.add_node(Node::new("a", "A", "F"));
844 g.add_node(Node::new("a", "A2", "F"));
845 assert!(matches!(g.validate(), Err(SomaError::Compilation(_))));
846 }
847
848 #[test]
849 fn validate_rejects_missing_edge_target() {
850 let mut g = Graph::new();
851 g.add_node(Node::new("a", "A", "F"));
852 g.add_edge(Edge::data("e1", "a", "nonexistent"));
853 assert!(matches!(g.validate(), Err(SomaError::NodeNotFound(_))));
854 }
855
856 #[test]
857 fn graph_serde_roundtrip() {
858 let g = sample_linear_graph();
859 let json = serde_json::to_string(&g).unwrap();
860 let deserialized: Graph = serde_json::from_str(&json).unwrap();
861 assert_eq!(deserialized.nodes.len(), 3);
862 assert_eq!(deserialized.edges.len(), 2);
863 }
864
865 #[test]
866 fn empty_graph_is_valid() {
867 let g = Graph::new();
868 assert!(g.validate().is_ok());
869 assert!(g.topological_sort().unwrap().is_empty());
870 }
871
872 #[test]
873 fn single_node_graph() {
874 let mut g = Graph::new();
875 g.add_node(Node::new("solo", "Solo", "F"));
876 assert_eq!(g.roots(), vec!["solo"]);
877 assert_eq!(g.leaves(), vec!["solo"]);
878 assert_eq!(g.topological_sort().unwrap(), vec!["solo"]);
879 }
880
881 #[test]
884 fn node_filter_shorthand() {
885 let n = Node::filter("StandardScaler");
886 assert_eq!(n.id, "StandardScaler");
887 assert_eq!(n.filter_name(), Some("StandardScaler"));
888 }
889
890 #[test]
891 fn node_filter_with_id() {
892 let n = Node::filter_with_id("my_scaler", "StandardScaler");
893 assert_eq!(n.id, "my_scaler");
894 assert_eq!(n.filter_name(), Some("StandardScaler"));
895 }
896
897 #[test]
898 fn graph_add_filter_auto_names() {
899 let mut g = Graph::new();
900 g.add_filter("Scaler");
901 g.add_filter("PCA");
902 g.connect("Scaler", "PCA");
903
904 assert!(g.validate().is_ok());
905 assert_eq!(g.nodes.len(), 2);
906 assert_eq!(g.nodes[0].id, "Scaler");
907 assert_eq!(g.nodes[1].id, "PCA");
908 }
909
910 #[test]
911 fn graph_add_filter_deduplicates() {
912 let mut g = Graph::new();
913 g.add_filter("Scaler");
914 g.add_filter("Scaler"); assert_eq!(g.nodes.len(), 2);
917 assert_eq!(g.nodes[0].id, "Scaler");
918 assert_eq!(g.nodes[1].id, "Scaler_2");
919 }
920
921 #[test]
922 fn subgraph_node() {
923 let inner = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
924
925 let mut outer = Graph::new();
926 outer.add_node(Node::new("input", "Input", "Input"));
927 outer.add_node(Node::subgraph("pipeline", inner));
928 outer.add_node(Node::new("output", "Output", "Output"));
929 outer.add_edge(Edge::data("e1", "input", "pipeline"));
930 outer.add_edge(Edge::data("e2", "pipeline", "output"));
931
932 assert!(outer.validate().is_ok());
933 assert_eq!(outer.nodes.len(), 3);
934
935 assert!(outer.node("pipeline").unwrap().filter_name().is_none());
937 }
938
939 #[test]
940 fn loop_and_branch_nodes() {
941 let mut g = Graph::new();
942 g.add_node(Node::loop_node("train_loop", Some(100)));
943 g.add_node(Node::branch("check_convergence"));
944 g.add_edge(Edge::data("e1", "train_loop", "check_convergence"));
945
946 assert!(g.validate().is_ok());
947 assert!(matches!(
948 g.node("train_loop").unwrap().kind,
949 NodeKind::Loop {
950 max_iterations: Some(100),
951 ..
952 }
953 ));
954 assert!(matches!(
955 g.node("check_convergence").unwrap().kind,
956 NodeKind::Branch { .. }
957 ));
958 }
959
960 #[test]
963 fn to_mermaid_linear() {
964 let g = sample_linear_graph();
965 let m = g.to_mermaid();
966 assert!(m.starts_with("graph LR"));
967 assert!(m.contains("a[Scaler]"));
968 assert!(m.contains("b[PCA]"));
969 assert!(m.contains("c[SVM]"));
970 assert!(m.contains("a --> b"));
971 assert!(m.contains("b --> c"));
972 }
973
974 #[test]
975 fn to_mermaid_branch_and_loop() {
976 let mut g = Graph::new();
977 g.add_node(Node::loop_node("train", Some(100)));
978 g.add_node(Node::branch("check"));
979 g.add_edge(Edge::data("e1", "train", "check"));
980
981 let m = g.to_mermaid();
982 assert!(m.contains("train((train (max 100)))"));
983 assert!(m.contains("check{"));
984 assert!(m.contains("train --> check"));
985 }
986
987 #[test]
988 fn to_graphviz_output() {
989 let g = sample_linear_graph();
990 let dot = g.to_graphviz();
991 assert!(dot.starts_with("digraph G {"));
992 assert!(dot.contains("rankdir=LR"));
993 assert!(dot.contains("\"a\" [label=\"Scaler\" shape=box]"));
994 assert!(dot.contains("\"a\" -> \"b\""));
995 assert!(dot.ends_with("}\n"));
996 }
997
998 #[test]
999 fn overlay_empty_is_identical_to_plain_rendering() {
1000 use crate::viz::GraphOverlay;
1001 let g = sample_linear_graph();
1002 assert_eq!(g.to_mermaid(), g.to_mermaid_with(&GraphOverlay::default()));
1003 assert_eq!(
1004 g.to_graphviz(),
1005 g.to_graphviz_with(&GraphOverlay::default())
1006 );
1007 assert!(!g.to_mermaid().contains("classDef"));
1009 assert!(!g.to_graphviz().contains("fillcolor"));
1010 }
1011
1012 #[test]
1013 fn to_mermaid_with_overlay_annotates_and_styles() {
1014 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
1015 let g = sample_linear_graph();
1016 let mut ov = GraphOverlay::default();
1017 ov.nodes.insert(
1018 "a".into(),
1019 NodeOverlay {
1020 status: Some(NodeStatus::Completed),
1021 duration_ms: Some(1_200),
1022 ..Default::default()
1023 },
1024 );
1025 ov.nodes.insert(
1026 "b".into(),
1027 NodeOverlay {
1028 status: Some(NodeStatus::Cached),
1029 duration_ms: Some(3),
1030 cache_tier: Some("memory".into()),
1031 ..Default::default()
1032 },
1033 );
1034 ov.nodes.insert(
1035 "c".into(),
1036 NodeOverlay {
1037 status: Some(NodeStatus::Completed),
1038 flags: vec!["LEAKAGE".into()],
1039 ..Default::default()
1040 },
1041 );
1042
1043 let m = g.to_mermaid_with(&ov);
1044 assert!(m.contains("a[\"Scaler<br/>1.2s\"]"), "{m}");
1045 assert!(m.contains("b[\"PCA<br/>3ms · mem hit\"]"), "{m}");
1046 assert!(m.contains("c[\"SVM<br/>⚠ LEAKAGE\"]"), "{m}");
1047 assert!(m.contains("classDef soma_completed"));
1048 assert!(m.contains("classDef soma_cached"));
1049 assert!(m.contains("classDef soma_flagged"));
1050 assert!(m.contains("class a soma_completed"));
1051 assert!(m.contains("class b soma_cached"));
1052 assert!(m.contains("class c soma_flagged"), "flags win over status");
1053 assert!(m.contains("a --> b"));
1055 }
1056
1057 #[test]
1058 fn to_mermaid_with_overlay_ignores_unknown_nodes() {
1059 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
1060 let g = sample_linear_graph();
1061 let mut ov = GraphOverlay::default();
1062 ov.nodes.insert(
1063 "ghost".into(),
1064 NodeOverlay {
1065 status: Some(NodeStatus::Failed),
1066 ..Default::default()
1067 },
1068 );
1069 let m = g.to_mermaid_with(&ov);
1070 assert_eq!(m, g.to_mermaid(), "unknown node ids change nothing");
1071 }
1072
1073 #[test]
1074 fn to_graphviz_with_overlay_annotates_and_styles() {
1075 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
1076 let g = sample_linear_graph();
1077 let mut ov = GraphOverlay::default();
1078 ov.nodes.insert(
1079 "a".into(),
1080 NodeOverlay {
1081 status: Some(NodeStatus::Failed),
1082 ..Default::default()
1083 },
1084 );
1085 ov.nodes.insert(
1086 "b".into(),
1087 NodeOverlay {
1088 flags: vec!["DEAD_CHANNELS".into()],
1089 ..Default::default()
1090 },
1091 );
1092 let dot = g.to_graphviz_with(&ov);
1093 assert!(
1094 dot.contains("\"a\" [label=\"Scaler\\nfailed\" shape=box"),
1095 "{dot}"
1096 );
1097 assert!(dot.contains("fillcolor=\"#ffebee\""), "failed fill: {dot}");
1098 assert!(dot.contains("penwidth=3"), "flagged border: {dot}");
1099 assert!(dot.contains("\"c\" [label=\"SVM\" shape=box];"));
1101 }
1102
1103 #[test]
1104 fn to_text_output() {
1105 let g = sample_linear_graph();
1106 let text = g.to_text();
1107 assert!(text.contains("Graph (3 nodes, 2 edges)"));
1108 assert!(text.contains("a"));
1109 assert!(text.contains("b"));
1110 assert!(text.contains("c"));
1111 assert!(text.contains("← a"));
1112 }
1113
1114 #[test]
1115 fn display_trait() {
1116 let g = sample_linear_graph();
1117 let s = format!("{g}");
1118 assert!(s.contains("Graph (3 nodes"));
1119 }
1120
1121 #[test]
1122 fn node_kind_serde_roundtrip() {
1123 let inner = linear_pipeline(vec![Node::new("x", "X", "F")]);
1124 let nodes = vec![
1125 Node::filter("Scaler"),
1126 Node::subgraph("sub", inner),
1127 Node::loop_node("loop", Some(50)),
1128 Node::branch("cond"),
1129 ];
1130
1131 for node in &nodes {
1132 let json = serde_json::to_string(node).unwrap();
1133 let parsed: Node = serde_json::from_str(&json).unwrap();
1134 assert_eq!(parsed.id, node.id);
1135 }
1136 }
1137}