Skip to main content

somatize_core/
graph.rs

1//! Computational graph — DAG of filter nodes connected by edges.
2//!
3//! The graph is the user-facing representation of a pipeline topology.
4//! It gets compiled into an `ExecutionPlan` by the compiler.
5
6use crate::control::LoopCondition;
7use crate::error::{Result, SomaError};
8use crate::strategy::TrainingStrategy;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11
12/// Unique identifier for a node in a graph.
13///
14/// Currently a type alias. Will be promoted to a newtype in a future version
15/// for stronger type safety. Deliberately deferred — see the
16/// "NodeId stays a String" entry in docs design/decisions.
17pub type NodeId = String;
18
19/// Unique identifier for an edge in a graph.
20pub type EdgeId = String;
21
22/// What kind of computation a node represents.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(tag = "type")]
25#[non_exhaustive]
26pub enum NodeKind {
27    /// A single filter (the common case).
28    Filter {
29        /// Name the filter is registered under in the `NodeCatalog`.
30        filter_name: String,
31    },
32    /// A nested sub-graph (compiled recursively).
33    SubGraph {
34        /// The inner graph, boxed to keep `NodeKind` a fixed size.
35        graph: Box<Graph>,
36    },
37    /// A loop node. Its body is the sub-graph reached through its *control*
38    /// edges; `until` names what decides to stop.
39    Loop {
40        /// Hard cap on iterations; `None` means the body runs until `until`
41        /// signals stop.
42        max_iterations: Option<usize>,
43        /// Defaults to [`LoopCondition::BodyTerminal`], resolved by the
44        /// compiler. Never inferred at runtime from execution order.
45        #[serde(default)]
46        until: LoopCondition,
47    },
48    /// A branch/conditional node. Arms are the labelled control edges
49    /// leaving it; `arms` optionally declares the complete set of labels the
50    /// condition may produce, so the compiler can catch a mislabelled edge
51    /// before the run rather than at the moment the branch is taken.
52    Branch {
53        /// Declared labels. Empty means "infer from the edges" — the
54        /// backwards-compatible default.
55        #[serde(default, skip_serializing_if = "Vec::is_empty")]
56        arms: Vec<String>,
57    },
58    /// An effectful node: calls models, tools, or other graphs, and decides
59    /// what happens next. See [`crate::step::Step`].
60    Step {
61        /// Name the step is registered under in the `NodeCatalog`.
62        step_name: String,
63    },
64}
65
66/// A node in the computational graph.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct Node {
69    /// Unique id within the graph; edges and trained states refer to it.
70    pub id: NodeId,
71    /// Human-readable name shown in diagrams; cosmetic, excluded from the
72    /// architecture fingerprint.
73    pub label: String,
74    /// What kind of computation this node represents.
75    pub kind: NodeKind,
76    /// Execution target: "local" (reserved, always local), or a worker tag.
77    /// None means: use default (remote if workers available, else local).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub target: Option<String>,
80}
81
82impl Node {
83    /// Create a filter node (backward-compatible with old 3-arg constructor).
84    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    /// Create a filter node with explicit id and filter_name.
100    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    /// Create a filter node where id defaults to filter_name.
113    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    /// Create a sub-graph node.
124    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    /// Create a loop node whose stop condition is its body's terminal node.
137    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    /// Create a loop node with an explicit stop condition.
142    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    /// Create an effectful step node.
160    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    /// Create a branch node whose arms are inferred from its control edges.
173    pub fn branch(id: impl Into<String>) -> Self {
174        Self::branch_over(id, Vec::<String>::new())
175    }
176
177    /// Create a branch node declaring the labels its condition may produce.
178    ///
179    /// The compiler then checks the edges against this list in both
180    /// directions: a declared arm with no edge, or an edge labelling an arm
181    /// that was never declared, is a compile error rather than a branch that
182    /// silently never fires.
183    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    /// Set the execution target for this node.
199    pub fn with_target(mut self, target: impl Into<String>) -> Self {
200        self.target = Some(target.into());
201        self
202    }
203
204    /// Whether this node is forced local.
205    pub fn is_local(&self) -> bool {
206        self.target.as_deref() == Some("local")
207    }
208
209    /// Get the filter name if this is a Filter node.
210    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/// Type of connection between nodes.
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
220pub enum EdgeKind {
221    /// Normal data flow: output of source becomes input of target.
222    Data,
223    /// Control flow edge (for conditional/loop logic).
224    Control,
225}
226
227/// A directed edge connecting two nodes.
228#[derive(Debug, Clone, Serialize, Deserialize)]
229pub struct Edge {
230    /// Unique id within the graph; cosmetic — excluded from the
231    /// architecture fingerprint.
232    pub id: EdgeId,
233    /// The node this edge leaves.
234    pub source: NodeId,
235    /// The node this edge enters.
236    pub target: NodeId,
237    /// Whether the edge carries data or control.
238    pub kind: EdgeKind,
239    /// Optional label; on an edge leaving a `Branch` node it names the arm.
240    pub label: Option<String>,
241}
242
243impl Edge {
244    /// Create a data edge: `source`'s output becomes an input of `target`.
245    ///
246    /// This is what `Graph::connect` builds, and what input resolution
247    /// follows — a node's inputs are the outputs of its data predecessors,
248    /// not "whatever ran last".
249    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    /// Create a control edge: `source` decides whether `target` runs, but
264    /// hands it no data.
265    ///
266    /// Control edges are how the compiler claims loop bodies and branch
267    /// arms (by dominance); a branch passes its *input* to the chosen arm,
268    /// not the selector's output.
269    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    /// Attach a label. On an edge leaving a `Branch` node the label names the
284    /// arm; the branch condition's value is matched against it.
285    pub fn with_label(mut self, label: impl Into<String>) -> Self {
286        self.label = Some(label.into());
287        self
288    }
289}
290
291/// A directed graph of computational nodes.
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct Graph {
294    /// The nodes, in insertion order (execution order comes from the edges).
295    pub nodes: Vec<Node>,
296    /// The directed edges connecting them.
297    pub edges: Vec<Edge>,
298    /// Training strategy for distributed execution.
299    /// Inherited by subgraphs unless overridden.
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub training_strategy: Option<TrainingStrategy>,
302}
303
304impl Graph {
305    /// Create an empty graph with no training strategy set.
306    pub fn new() -> Self {
307        Self {
308            nodes: Vec::new(),
309            edges: Vec::new(),
310            training_strategy: None,
311        }
312    }
313
314    /// Set the training strategy for this graph.
315    pub fn with_strategy(mut self, strategy: TrainingStrategy) -> Self {
316        self.training_strategy = Some(strategy);
317        self
318    }
319
320    /// Set the training strategy (mutable).
321    pub fn set_strategy(&mut self, strategy: TrainingStrategy) {
322        self.training_strategy = Some(strategy);
323    }
324
325    /// Whether the effective strategy needs more than this process.
326    ///
327    /// `Local` does not, and neither does an absent one — which is the
328    /// same thing. Everything else asks for workers, so a caller can use
329    /// this to decide whether to take the distributed path at all rather
330    /// than matching on the enum in three places.
331    pub fn effective_strategy_is_distributed(&self) -> bool {
332        !matches!(self.effective_strategy(), TrainingStrategy::Local)
333    }
334
335    /// Get the effective training strategy (defaults to Local).
336    pub fn effective_strategy(&self) -> &TrainingStrategy {
337        static LOCAL: TrainingStrategy = TrainingStrategy::Local;
338        self.training_strategy.as_ref().unwrap_or(&LOCAL)
339    }
340
341    /// Add a node. Duplicate ids are not checked here; [`Self::validate`]
342    /// rejects them at compile time.
343    pub fn add_node(&mut self, node: Node) {
344        self.nodes.push(node);
345    }
346
347    /// Add a filter node using the filter name as the node id.
348    /// If a node with that name already exists, appends a suffix.
349    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    /// Add an edge. Endpoints are not checked here; [`Self::validate`]
368    /// rejects edges to unknown nodes at compile time.
369    pub fn add_edge(&mut self, edge: Edge) {
370        self.edges.push(edge);
371    }
372
373    /// Connect two nodes with a data edge (auto-generates edge id).
374    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    /// Get a node by its ID.
380    pub fn node(&self, id: &str) -> Option<&Node> {
381        self.nodes.iter().find(|n| n.id == id)
382    }
383
384    /// Get all node IDs.
385    pub fn node_ids(&self) -> Vec<&str> {
386        self.nodes.iter().map(|n| n.id.as_str()).collect()
387    }
388
389    /// Get predecessors of a node (nodes with edges pointing to it).
390    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    /// Get successors of a node (nodes it points to).
399    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    /// Find root nodes (no incoming edges).
408    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    /// Find leaf nodes (no outgoing edges).
418    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    /// Compute in-degree for each node.
428    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    /// Topological sort using Kahn's algorithm.
438    /// Returns Err if the graph contains a cycle.
439    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(); // deterministic order
447
448        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            // Insert at beginning so we process in deterministic order
463            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    /// Validate the graph structure (recursively validates sub-graphs).
476    pub fn validate(&self) -> Result<()> {
477        // Check for duplicate node IDs
478        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        // Check that all edge endpoints reference existing nodes
489        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        // Check for cycles
500        self.topological_sort()?;
501
502        // Recursively validate sub-graphs
503        for node in &self.nodes {
504            if let NodeKind::SubGraph { graph } = &node.kind {
505                graph.validate()?;
506            }
507        }
508
509        Ok(())
510    }
511
512    /// Does this graph — or any sub-graph nested inside it — contain a step?
513    ///
514    /// A step calls models and tools, so a graph that contains one is not a
515    /// deterministic function of its input. [`crate::effect::Effect::is_pure`]
516    /// asks this before memoizing a graph effect by content.
517    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
526// ── Visualization ──
527
528impl Graph {
529    /// Render as a Mermaid diagram.
530    ///
531    /// ```text
532    /// graph LR
533    ///     scaler[scaler]
534    ///     model[model]
535    ///     scaler --> model
536    /// ```
537    pub fn to_mermaid(&self) -> String {
538        self.to_mermaid_with(&crate::viz::GraphOverlay::default())
539    }
540
541    /// Render as a Mermaid diagram with per-node execution annotations.
542    ///
543    /// Each annotated node gets a second label line (duration, cache
544    /// tier, health flags — see [`crate::viz::NodeOverlay::sublabel_text`])
545    /// and a status `classDef` for coloring. An empty overlay produces
546    /// exactly [`Graph::to_mermaid`]'s output.
547    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            // A sublabel needs a quoted label to allow `<br/>`.
553            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                // Parallelogram: the I/O shape, which is what an effectful
575                // node is — it reaches outside the graph.
576                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    /// Render as Graphviz DOT format.
627    pub fn to_graphviz(&self) -> String {
628        self.to_graphviz_with(&crate::viz::GraphOverlay::default())
629    }
630
631    /// Render as Graphviz DOT with per-node execution annotations:
632    /// a second label line plus fill/border status colors. An empty
633    /// overlay produces exactly [`Graph::to_graphviz`]'s output.
634    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    /// Render as an ASCII text tree for terminal display.
688    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
745/// Builder for constructing linear pipelines easily.
746pub 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        // root must be first, merge must be last
814        assert_eq!(sorted[0], "root");
815        assert_eq!(sorted[3], "merge");
816        // b1 and b2 can be in any order between root and merge
817        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")); // cycle!
829
830        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    // ── NodeKind tests ──
882
883    #[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"); // duplicate name → gets suffix
915
916        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        // SubGraph node has no filter_name
936        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    // ── Visualization tests ──
961
962    #[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        // No classDef/style leaks into the plain rendering.
1008        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        // Edges unchanged.
1054        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        // Unannotated node keeps the plain attribute set.
1100        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}