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(&self) -> &TrainingStrategy {
327 static LOCAL: TrainingStrategy = TrainingStrategy::Local;
328 self.training_strategy.as_ref().unwrap_or(&LOCAL)
329 }
330
331 pub fn add_node(&mut self, node: Node) {
334 self.nodes.push(node);
335 }
336
337 pub fn add_filter(&mut self, filter_name: impl Into<String>) -> &str {
340 let name = filter_name.into();
341 let id = if self.nodes.iter().any(|n| n.id == name) {
342 let mut i = 2;
343 loop {
344 let candidate = format!("{name}_{i}");
345 if !self.nodes.iter().any(|n| n.id == candidate) {
346 break candidate;
347 }
348 i += 1;
349 }
350 } else {
351 name.clone()
352 };
353 self.nodes.push(Node::filter_with_id(&id, &name));
354 &self.nodes.last().unwrap().id
355 }
356
357 pub fn add_edge(&mut self, edge: Edge) {
360 self.edges.push(edge);
361 }
362
363 pub fn connect(&mut self, source: impl Into<String>, target: impl Into<String>) {
365 let id = format!("e_{}", self.edges.len());
366 self.edges.push(Edge::data(id, source, target));
367 }
368
369 pub fn node(&self, id: &str) -> Option<&Node> {
371 self.nodes.iter().find(|n| n.id == id)
372 }
373
374 pub fn node_ids(&self) -> Vec<&str> {
376 self.nodes.iter().map(|n| n.id.as_str()).collect()
377 }
378
379 pub fn predecessors(&self, node_id: &str) -> Vec<&str> {
381 self.edges
382 .iter()
383 .filter(|e| e.target == node_id)
384 .map(|e| e.source.as_str())
385 .collect()
386 }
387
388 pub fn successors(&self, node_id: &str) -> Vec<&str> {
390 self.edges
391 .iter()
392 .filter(|e| e.source == node_id)
393 .map(|e| e.target.as_str())
394 .collect()
395 }
396
397 pub fn roots(&self) -> Vec<&str> {
399 let has_incoming: HashSet<&str> = self.edges.iter().map(|e| e.target.as_str()).collect();
400 self.nodes
401 .iter()
402 .filter(|n| !has_incoming.contains(n.id.as_str()))
403 .map(|n| n.id.as_str())
404 .collect()
405 }
406
407 pub fn leaves(&self) -> Vec<&str> {
409 let has_outgoing: HashSet<&str> = self.edges.iter().map(|e| e.source.as_str()).collect();
410 self.nodes
411 .iter()
412 .filter(|n| !has_outgoing.contains(n.id.as_str()))
413 .map(|n| n.id.as_str())
414 .collect()
415 }
416
417 fn in_degrees(&self) -> HashMap<&str, usize> {
419 let mut degrees: HashMap<&str, usize> =
420 self.nodes.iter().map(|n| (n.id.as_str(), 0)).collect();
421 for edge in &self.edges {
422 *degrees.entry(edge.target.as_str()).or_insert(0) += 1;
423 }
424 degrees
425 }
426
427 pub fn topological_sort(&self) -> Result<Vec<&str>> {
430 let mut in_deg = self.in_degrees();
431 let mut queue: Vec<&str> = in_deg
432 .iter()
433 .filter(|(_, deg)| **deg == 0)
434 .map(|(&id, _)| id)
435 .collect();
436 queue.sort(); let mut sorted = Vec::with_capacity(self.nodes.len());
439
440 while let Some(node) = queue.pop() {
441 sorted.push(node);
442 let mut next = Vec::new();
443 for succ in self.successors(node) {
444 if let Some(deg) = in_deg.get_mut(succ) {
445 *deg -= 1;
446 if *deg == 0 {
447 next.push(succ);
448 }
449 }
450 }
451 next.sort();
452 for n in next.into_iter().rev() {
454 queue.push(n);
455 }
456 }
457
458 if sorted.len() != self.nodes.len() {
459 return Err(SomaError::CycleDetected);
460 }
461
462 Ok(sorted)
463 }
464
465 pub fn validate(&self) -> Result<()> {
467 let mut seen = HashSet::new();
469 for node in &self.nodes {
470 if !seen.insert(&node.id) {
471 return Err(SomaError::Compilation(format!(
472 "duplicate node id: `{}`",
473 node.id
474 )));
475 }
476 }
477
478 let node_ids: HashSet<&str> = self.nodes.iter().map(|n| n.id.as_str()).collect();
480 for edge in &self.edges {
481 if !node_ids.contains(edge.source.as_str()) {
482 return Err(SomaError::NodeNotFound(edge.source.clone()));
483 }
484 if !node_ids.contains(edge.target.as_str()) {
485 return Err(SomaError::NodeNotFound(edge.target.clone()));
486 }
487 }
488
489 self.topological_sort()?;
491
492 for node in &self.nodes {
494 if let NodeKind::SubGraph { graph } = &node.kind {
495 graph.validate()?;
496 }
497 }
498
499 Ok(())
500 }
501
502 pub fn contains_steps(&self) -> bool {
508 self.nodes.iter().any(|node| match &node.kind {
509 NodeKind::Step { .. } => true,
510 NodeKind::SubGraph { graph } => graph.contains_steps(),
511 _ => false,
512 })
513 }
514}
515
516impl Graph {
519 pub fn to_mermaid(&self) -> String {
528 self.to_mermaid_with(&crate::viz::GraphOverlay::default())
529 }
530
531 pub fn to_mermaid_with(&self, overlay: &crate::viz::GraphOverlay) -> String {
538 use std::fmt::Write;
539 let mut out = String::from("graph LR\n");
540 for node in &self.nodes {
541 let ov = overlay.nodes.get(&node.id);
542 let label_with = |base: &str| match ov.and_then(|o| o.sublabel_text()) {
544 Some(sub) => format!("\"{base}<br/>{sub}\""),
545 None => base.to_string(),
546 };
547 let shape = match &node.kind {
548 NodeKind::Filter { .. } => {
549 format!(" {}[{}]", node.id, label_with(&node.label))
550 }
551 NodeKind::SubGraph { .. } => {
552 format!(" {}[[{}]]", node.id, label_with(&node.label))
553 }
554 NodeKind::Loop { max_iterations, .. } => {
555 let label = match max_iterations {
556 Some(n) => format!("{} (max {})", node.label, n),
557 None => node.label.clone(),
558 };
559 format!(" {}(({}))", node.id, label_with(&label))
560 }
561 NodeKind::Branch { .. } => {
562 format!(" {}{{{{{}}}}}", node.id, label_with(&node.label))
563 }
564 NodeKind::Step { .. } => {
567 format!(" {}[/{}/]", node.id, label_with(&node.label))
568 }
569 };
570 let _ = writeln!(out, "{shape}");
571 }
572 for edge in &self.edges {
573 let arrow = match edge.kind {
574 EdgeKind::Data => "-->",
575 EdgeKind::Control => "-.->",
576 };
577 if let Some(label) = &edge.label {
578 let _ = writeln!(
579 out,
580 " {} {}|{}| {}",
581 edge.source, arrow, label, edge.target
582 );
583 } else {
584 let _ = writeln!(out, " {} {} {}", edge.source, arrow, edge.target);
585 }
586 }
587 let assignments: Vec<(&str, &'static str)> = self
588 .nodes
589 .iter()
590 .filter_map(|n| {
591 overlay
592 .nodes
593 .get(&n.id)
594 .and_then(|o| o.style_class())
595 .map(|class| (n.id.as_str(), class))
596 })
597 .collect();
598 if !assignments.is_empty() {
599 let mut used: Vec<&'static str> = assignments.iter().map(|(_, c)| *c).collect();
600 used.sort_unstable();
601 used.dedup();
602 for class in used {
603 let _ = writeln!(
604 out,
605 " classDef {class} {}",
606 crate::viz::mermaid_class_style(class)
607 );
608 }
609 for (id, class) in assignments {
610 let _ = writeln!(out, " class {id} {class}");
611 }
612 }
613 out
614 }
615
616 pub fn to_graphviz(&self) -> String {
618 self.to_graphviz_with(&crate::viz::GraphOverlay::default())
619 }
620
621 pub fn to_graphviz_with(&self, overlay: &crate::viz::GraphOverlay) -> String {
625 use std::fmt::Write;
626 let mut out = String::from("digraph G {\n rankdir=LR;\n");
627 for node in &self.nodes {
628 let shape = match &node.kind {
629 NodeKind::Filter { .. } => "box",
630 NodeKind::SubGraph { .. } => "doubleoctagon",
631 NodeKind::Loop { .. } => "ellipse",
632 NodeKind::Branch { .. } => "diamond",
633 NodeKind::Step { .. } => "parallelogram",
634 };
635 let ov = overlay.nodes.get(&node.id);
636 let label = match ov.and_then(|o| o.sublabel_text()) {
637 Some(sub) => format!("{}\\n{}", node.label, sub),
638 None => node.label.clone(),
639 };
640 let style = ov
641 .and_then(|o| o.style_class())
642 .map(crate::viz::dot_class_style)
643 .unwrap_or_default();
644 let _ = writeln!(
645 out,
646 " \"{}\" [label=\"{}\" shape={}{}];",
647 node.id, label, shape, style
648 );
649 }
650 for edge in &self.edges {
651 let style = match edge.kind {
652 EdgeKind::Data => "",
653 EdgeKind::Control => " [style=dashed]",
654 };
655 let label = edge
656 .label
657 .as_ref()
658 .map(|l| format!(" [label=\"{l}\"]"))
659 .unwrap_or_default();
660 let attrs = if style.is_empty() && label.is_empty() {
661 String::new()
662 } else if label.is_empty() {
663 style.to_string()
664 } else {
665 label
666 };
667 let _ = writeln!(
668 out,
669 " \"{}\" -> \"{}\"{};",
670 edge.source, edge.target, attrs
671 );
672 }
673 out.push_str("}\n");
674 out
675 }
676
677 pub fn to_text(&self) -> String {
679 use std::fmt::Write;
680 let mut out = String::new();
681 let sorted = self.topological_sort().unwrap_or_default();
682 let total_nodes = self.nodes.len();
683 let total_edges = self.edges.len();
684 let _ = writeln!(out, "Graph ({total_nodes} nodes, {total_edges} edges)");
685
686 for (i, node_id) in sorted.iter().enumerate() {
687 let node = match self.node(node_id) {
688 Some(n) => n,
689 None => continue,
690 };
691 let is_last = i == sorted.len() - 1;
692 let prefix = if is_last { "└── " } else { "├── " };
693 let kind_tag = match &node.kind {
694 NodeKind::Filter { filter_name } => {
695 if filter_name == &node.id {
696 String::new()
697 } else {
698 format!(" ({})", filter_name)
699 }
700 }
701 NodeKind::SubGraph { graph } => {
702 format!(" [subgraph: {} nodes]", graph.nodes.len())
703 }
704 NodeKind::Loop { max_iterations, .. } => match max_iterations {
705 Some(n) => format!(" [loop max={n}]"),
706 None => " [loop]".into(),
707 },
708 NodeKind::Branch { .. } => " [branch]".into(),
709 NodeKind::Step { step_name } => format!(" [step: {step_name}]"),
710 };
711 let preds = self.predecessors(node_id);
712 let pred_info = if preds.is_empty() {
713 String::new()
714 } else {
715 format!(" ← {}", preds.join(", "))
716 };
717 let _ = writeln!(out, "{prefix}{}{kind_tag}{pred_info}", node.id);
718 }
719 out
720 }
721}
722
723impl std::fmt::Display for Graph {
724 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
725 write!(f, "{}", self.to_text())
726 }
727}
728
729impl Default for Graph {
730 fn default() -> Self {
731 Self::new()
732 }
733}
734
735pub fn linear_pipeline(nodes: Vec<Node>) -> Graph {
737 let mut graph = Graph::new();
738 for (i, node) in nodes.iter().enumerate() {
739 graph.add_node(node.clone());
740 if i > 0 {
741 graph.add_edge(Edge::data(format!("e_{}", i), &nodes[i - 1].id, &node.id));
742 }
743 }
744 graph
745}
746
747#[cfg(test)]
748mod tests {
749 use super::*;
750
751 fn sample_linear_graph() -> Graph {
752 linear_pipeline(vec![
753 Node::new("a", "Scaler", "StandardScaler"),
754 Node::new("b", "PCA", "PCA"),
755 Node::new("c", "SVM", "SVM"),
756 ])
757 }
758
759 #[test]
760 fn linear_pipeline_structure() {
761 let g = sample_linear_graph();
762 assert_eq!(g.nodes.len(), 3);
763 assert_eq!(g.edges.len(), 2);
764 }
765
766 #[test]
767 fn roots_and_leaves() {
768 let g = sample_linear_graph();
769 assert_eq!(g.roots(), vec!["a"]);
770 assert_eq!(g.leaves(), vec!["c"]);
771 }
772
773 #[test]
774 fn predecessors_and_successors() {
775 let g = sample_linear_graph();
776 assert!(g.predecessors("a").is_empty());
777 assert_eq!(g.predecessors("b"), vec!["a"]);
778 assert_eq!(g.successors("a"), vec!["b"]);
779 assert_eq!(g.successors("b"), vec!["c"]);
780 assert!(g.successors("c").is_empty());
781 }
782
783 #[test]
784 fn topological_sort_linear() {
785 let g = sample_linear_graph();
786 let sorted = g.topological_sort().unwrap();
787 assert_eq!(sorted, vec!["a", "b", "c"]);
788 }
789
790 #[test]
791 fn topological_sort_parallel() {
792 let mut g = Graph::new();
793 g.add_node(Node::new("root", "Root", "Input"));
794 g.add_node(Node::new("b1", "Branch1", "F1"));
795 g.add_node(Node::new("b2", "Branch2", "F2"));
796 g.add_node(Node::new("merge", "Merge", "Merge"));
797 g.add_edge(Edge::data("e1", "root", "b1"));
798 g.add_edge(Edge::data("e2", "root", "b2"));
799 g.add_edge(Edge::data("e3", "b1", "merge"));
800 g.add_edge(Edge::data("e4", "b2", "merge"));
801
802 let sorted = g.topological_sort().unwrap();
803 assert_eq!(sorted[0], "root");
805 assert_eq!(sorted[3], "merge");
806 let middle: HashSet<&str> = sorted[1..3].iter().copied().collect();
808 assert!(middle.contains("b1"));
809 assert!(middle.contains("b2"));
810 }
811
812 #[test]
813 fn topological_sort_detects_cycle() {
814 let mut g = Graph::new();
815 g.add_node(Node::new("a", "A", "F"));
816 g.add_node(Node::new("b", "B", "F"));
817 g.add_edge(Edge::data("e1", "a", "b"));
818 g.add_edge(Edge::data("e2", "b", "a")); let result = g.topological_sort();
821 assert!(matches!(result, Err(SomaError::CycleDetected)));
822 }
823
824 #[test]
825 fn validate_accepts_valid_graph() {
826 let g = sample_linear_graph();
827 assert!(g.validate().is_ok());
828 }
829
830 #[test]
831 fn validate_rejects_duplicate_ids() {
832 let mut g = Graph::new();
833 g.add_node(Node::new("a", "A", "F"));
834 g.add_node(Node::new("a", "A2", "F"));
835 assert!(matches!(g.validate(), Err(SomaError::Compilation(_))));
836 }
837
838 #[test]
839 fn validate_rejects_missing_edge_target() {
840 let mut g = Graph::new();
841 g.add_node(Node::new("a", "A", "F"));
842 g.add_edge(Edge::data("e1", "a", "nonexistent"));
843 assert!(matches!(g.validate(), Err(SomaError::NodeNotFound(_))));
844 }
845
846 #[test]
847 fn graph_serde_roundtrip() {
848 let g = sample_linear_graph();
849 let json = serde_json::to_string(&g).unwrap();
850 let deserialized: Graph = serde_json::from_str(&json).unwrap();
851 assert_eq!(deserialized.nodes.len(), 3);
852 assert_eq!(deserialized.edges.len(), 2);
853 }
854
855 #[test]
856 fn empty_graph_is_valid() {
857 let g = Graph::new();
858 assert!(g.validate().is_ok());
859 assert!(g.topological_sort().unwrap().is_empty());
860 }
861
862 #[test]
863 fn single_node_graph() {
864 let mut g = Graph::new();
865 g.add_node(Node::new("solo", "Solo", "F"));
866 assert_eq!(g.roots(), vec!["solo"]);
867 assert_eq!(g.leaves(), vec!["solo"]);
868 assert_eq!(g.topological_sort().unwrap(), vec!["solo"]);
869 }
870
871 #[test]
874 fn node_filter_shorthand() {
875 let n = Node::filter("StandardScaler");
876 assert_eq!(n.id, "StandardScaler");
877 assert_eq!(n.filter_name(), Some("StandardScaler"));
878 }
879
880 #[test]
881 fn node_filter_with_id() {
882 let n = Node::filter_with_id("my_scaler", "StandardScaler");
883 assert_eq!(n.id, "my_scaler");
884 assert_eq!(n.filter_name(), Some("StandardScaler"));
885 }
886
887 #[test]
888 fn graph_add_filter_auto_names() {
889 let mut g = Graph::new();
890 g.add_filter("Scaler");
891 g.add_filter("PCA");
892 g.connect("Scaler", "PCA");
893
894 assert!(g.validate().is_ok());
895 assert_eq!(g.nodes.len(), 2);
896 assert_eq!(g.nodes[0].id, "Scaler");
897 assert_eq!(g.nodes[1].id, "PCA");
898 }
899
900 #[test]
901 fn graph_add_filter_deduplicates() {
902 let mut g = Graph::new();
903 g.add_filter("Scaler");
904 g.add_filter("Scaler"); assert_eq!(g.nodes.len(), 2);
907 assert_eq!(g.nodes[0].id, "Scaler");
908 assert_eq!(g.nodes[1].id, "Scaler_2");
909 }
910
911 #[test]
912 fn subgraph_node() {
913 let inner = linear_pipeline(vec![Node::new("a", "A", "F"), Node::new("b", "B", "F")]);
914
915 let mut outer = Graph::new();
916 outer.add_node(Node::new("input", "Input", "Input"));
917 outer.add_node(Node::subgraph("pipeline", inner));
918 outer.add_node(Node::new("output", "Output", "Output"));
919 outer.add_edge(Edge::data("e1", "input", "pipeline"));
920 outer.add_edge(Edge::data("e2", "pipeline", "output"));
921
922 assert!(outer.validate().is_ok());
923 assert_eq!(outer.nodes.len(), 3);
924
925 assert!(outer.node("pipeline").unwrap().filter_name().is_none());
927 }
928
929 #[test]
930 fn loop_and_branch_nodes() {
931 let mut g = Graph::new();
932 g.add_node(Node::loop_node("train_loop", Some(100)));
933 g.add_node(Node::branch("check_convergence"));
934 g.add_edge(Edge::data("e1", "train_loop", "check_convergence"));
935
936 assert!(g.validate().is_ok());
937 assert!(matches!(
938 g.node("train_loop").unwrap().kind,
939 NodeKind::Loop {
940 max_iterations: Some(100),
941 ..
942 }
943 ));
944 assert!(matches!(
945 g.node("check_convergence").unwrap().kind,
946 NodeKind::Branch { .. }
947 ));
948 }
949
950 #[test]
953 fn to_mermaid_linear() {
954 let g = sample_linear_graph();
955 let m = g.to_mermaid();
956 assert!(m.starts_with("graph LR"));
957 assert!(m.contains("a[Scaler]"));
958 assert!(m.contains("b[PCA]"));
959 assert!(m.contains("c[SVM]"));
960 assert!(m.contains("a --> b"));
961 assert!(m.contains("b --> c"));
962 }
963
964 #[test]
965 fn to_mermaid_branch_and_loop() {
966 let mut g = Graph::new();
967 g.add_node(Node::loop_node("train", Some(100)));
968 g.add_node(Node::branch("check"));
969 g.add_edge(Edge::data("e1", "train", "check"));
970
971 let m = g.to_mermaid();
972 assert!(m.contains("train((train (max 100)))"));
973 assert!(m.contains("check{"));
974 assert!(m.contains("train --> check"));
975 }
976
977 #[test]
978 fn to_graphviz_output() {
979 let g = sample_linear_graph();
980 let dot = g.to_graphviz();
981 assert!(dot.starts_with("digraph G {"));
982 assert!(dot.contains("rankdir=LR"));
983 assert!(dot.contains("\"a\" [label=\"Scaler\" shape=box]"));
984 assert!(dot.contains("\"a\" -> \"b\""));
985 assert!(dot.ends_with("}\n"));
986 }
987
988 #[test]
989 fn overlay_empty_is_identical_to_plain_rendering() {
990 use crate::viz::GraphOverlay;
991 let g = sample_linear_graph();
992 assert_eq!(g.to_mermaid(), g.to_mermaid_with(&GraphOverlay::default()));
993 assert_eq!(
994 g.to_graphviz(),
995 g.to_graphviz_with(&GraphOverlay::default())
996 );
997 assert!(!g.to_mermaid().contains("classDef"));
999 assert!(!g.to_graphviz().contains("fillcolor"));
1000 }
1001
1002 #[test]
1003 fn to_mermaid_with_overlay_annotates_and_styles() {
1004 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
1005 let g = sample_linear_graph();
1006 let mut ov = GraphOverlay::default();
1007 ov.nodes.insert(
1008 "a".into(),
1009 NodeOverlay {
1010 status: Some(NodeStatus::Completed),
1011 duration_ms: Some(1_200),
1012 ..Default::default()
1013 },
1014 );
1015 ov.nodes.insert(
1016 "b".into(),
1017 NodeOverlay {
1018 status: Some(NodeStatus::Cached),
1019 duration_ms: Some(3),
1020 cache_tier: Some("memory".into()),
1021 ..Default::default()
1022 },
1023 );
1024 ov.nodes.insert(
1025 "c".into(),
1026 NodeOverlay {
1027 status: Some(NodeStatus::Completed),
1028 flags: vec!["LEAKAGE".into()],
1029 ..Default::default()
1030 },
1031 );
1032
1033 let m = g.to_mermaid_with(&ov);
1034 assert!(m.contains("a[\"Scaler<br/>1.2s\"]"), "{m}");
1035 assert!(m.contains("b[\"PCA<br/>3ms · mem hit\"]"), "{m}");
1036 assert!(m.contains("c[\"SVM<br/>⚠ LEAKAGE\"]"), "{m}");
1037 assert!(m.contains("classDef soma_completed"));
1038 assert!(m.contains("classDef soma_cached"));
1039 assert!(m.contains("classDef soma_flagged"));
1040 assert!(m.contains("class a soma_completed"));
1041 assert!(m.contains("class b soma_cached"));
1042 assert!(m.contains("class c soma_flagged"), "flags win over status");
1043 assert!(m.contains("a --> b"));
1045 }
1046
1047 #[test]
1048 fn to_mermaid_with_overlay_ignores_unknown_nodes() {
1049 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
1050 let g = sample_linear_graph();
1051 let mut ov = GraphOverlay::default();
1052 ov.nodes.insert(
1053 "ghost".into(),
1054 NodeOverlay {
1055 status: Some(NodeStatus::Failed),
1056 ..Default::default()
1057 },
1058 );
1059 let m = g.to_mermaid_with(&ov);
1060 assert_eq!(m, g.to_mermaid(), "unknown node ids change nothing");
1061 }
1062
1063 #[test]
1064 fn to_graphviz_with_overlay_annotates_and_styles() {
1065 use crate::viz::{GraphOverlay, NodeOverlay, NodeStatus};
1066 let g = sample_linear_graph();
1067 let mut ov = GraphOverlay::default();
1068 ov.nodes.insert(
1069 "a".into(),
1070 NodeOverlay {
1071 status: Some(NodeStatus::Failed),
1072 ..Default::default()
1073 },
1074 );
1075 ov.nodes.insert(
1076 "b".into(),
1077 NodeOverlay {
1078 flags: vec!["DEAD_CHANNELS".into()],
1079 ..Default::default()
1080 },
1081 );
1082 let dot = g.to_graphviz_with(&ov);
1083 assert!(
1084 dot.contains("\"a\" [label=\"Scaler\\nfailed\" shape=box"),
1085 "{dot}"
1086 );
1087 assert!(dot.contains("fillcolor=\"#ffebee\""), "failed fill: {dot}");
1088 assert!(dot.contains("penwidth=3"), "flagged border: {dot}");
1089 assert!(dot.contains("\"c\" [label=\"SVM\" shape=box];"));
1091 }
1092
1093 #[test]
1094 fn to_text_output() {
1095 let g = sample_linear_graph();
1096 let text = g.to_text();
1097 assert!(text.contains("Graph (3 nodes, 2 edges)"));
1098 assert!(text.contains("a"));
1099 assert!(text.contains("b"));
1100 assert!(text.contains("c"));
1101 assert!(text.contains("← a"));
1102 }
1103
1104 #[test]
1105 fn display_trait() {
1106 let g = sample_linear_graph();
1107 let s = format!("{g}");
1108 assert!(s.contains("Graph (3 nodes"));
1109 }
1110
1111 #[test]
1112 fn node_kind_serde_roundtrip() {
1113 let inner = linear_pipeline(vec![Node::new("x", "X", "F")]);
1114 let nodes = vec![
1115 Node::filter("Scaler"),
1116 Node::subgraph("sub", inner),
1117 Node::loop_node("loop", Some(50)),
1118 Node::branch("cond"),
1119 ];
1120
1121 for node in &nodes {
1122 let json = serde_json::to_string(node).unwrap();
1123 let parsed: Node = serde_json::from_str(&json).unwrap();
1124 assert_eq!(parsed.id, node.id);
1125 }
1126 }
1127}