Skip to main content

somatize_core/
viz.rs

1//! Rendering overlays for graph visualization.
2//!
3//! A [`GraphOverlay`] carries per-node execution facts (status, timing,
4//! cache tier, health flags) that [`Graph::to_mermaid_with`] and
5//! [`Graph::to_graphviz_with`] fold into the rendered diagram. The
6//! overlay is pure data — computed elsewhere (e.g. `soma-runtime`'s
7//! `RunReader` aggregates it from a run's event log) and passed in, so
8//! rendering stays a dependency-free data→string transform.
9//!
10//! [`Graph::to_mermaid_with`]: crate::graph::Graph::to_mermaid_with
11//! [`Graph::to_graphviz_with`]: crate::graph::Graph::to_graphviz_with
12
13use serde::{Deserialize, Serialize};
14use std::collections::BTreeMap;
15
16/// Execution outcome of a node, for status coloring.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19#[non_exhaustive]
20pub enum NodeStatus {
21    /// Executed successfully.
22    Completed,
23    /// Served from cache without executing.
24    Cached,
25    /// Execution failed.
26    Failed,
27    /// Started but not finished (live run, or died mid-node).
28    Running,
29}
30
31/// Per-node annotation folded into the rendered label and style.
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct NodeOverlay {
34    /// How the node finished, driving the status color.
35    #[serde(default)]
36    pub status: Option<NodeStatus>,
37    /// Total compute time across this node's executions.
38    #[serde(default)]
39    pub duration_ms: Option<u64>,
40    /// Cache tier that served a hit (`memory`, `local`, `remote`).
41    #[serde(default)]
42    pub cache_tier: Option<String>,
43    /// Health flags raised on this node (`DEAD_CHANNELS`, `LEAKAGE`, …).
44    #[serde(default)]
45    pub flags: Vec<String>,
46    /// Free-form extra label line (appended after the derived parts).
47    #[serde(default)]
48    pub sublabel: Option<String>,
49}
50
51/// Per-node annotations for one rendering, keyed by node id.
52#[derive(Debug, Clone, Default, Serialize, Deserialize)]
53pub struct GraphOverlay {
54    /// Annotations keyed by node id; nodes absent here render plain.
55    #[serde(default)]
56    pub nodes: BTreeMap<String, NodeOverlay>,
57}
58
59impl GraphOverlay {
60    /// No annotations at all — renderers then emit byte-identical plain
61    /// output, so an empty overlay is indistinguishable from none.
62    pub fn is_empty(&self) -> bool {
63        self.nodes.is_empty()
64    }
65}
66
67impl NodeOverlay {
68    /// The extra label line derived from this overlay, e.g.
69    /// `"1.2s · mem hit · ⚠ LEAKAGE"`. `None` when there is nothing
70    /// to show.
71    pub fn sublabel_text(&self) -> Option<String> {
72        let mut parts: Vec<String> = Vec::new();
73        if let Some(ms) = self.duration_ms {
74            parts.push(format_duration_ms(ms));
75        }
76        if let Some(tier) = &self.cache_tier {
77            let short = match tier.as_str() {
78                "memory" => "mem",
79                other => other,
80            };
81            parts.push(format!("{short} hit"));
82        } else if self.status == Some(NodeStatus::Failed) {
83            parts.push("failed".into());
84        }
85        for flag in &self.flags {
86            parts.push(format!("⚠ {flag}"));
87        }
88        if let Some(extra) = &self.sublabel {
89            parts.push(extra.clone());
90        }
91        if parts.is_empty() {
92            None
93        } else {
94            Some(parts.join(" · "))
95        }
96    }
97
98    /// The style class this node gets (mermaid classDef / graphviz
99    /// fillcolor). Flags win over status: an unhealthy node must stand
100    /// out even when it completed.
101    pub fn style_class(&self) -> Option<&'static str> {
102        if !self.flags.is_empty() {
103            return Some("soma_flagged");
104        }
105        match self.status? {
106            NodeStatus::Completed => Some("soma_completed"),
107            NodeStatus::Cached => Some("soma_cached"),
108            NodeStatus::Failed => Some("soma_failed"),
109            NodeStatus::Running => Some("soma_running"),
110        }
111    }
112}
113
114/// Mermaid `classDef` body for a status class.
115pub(crate) fn mermaid_class_style(class: &str) -> &'static str {
116    match class {
117        "soma_completed" => "fill:#e8f5e9,stroke:#2e7d32,color:#1b5e20;",
118        "soma_cached" => "fill:#e3f2fd,stroke:#1565c0,color:#0d47a1;",
119        "soma_failed" => "fill:#ffebee,stroke:#c62828,color:#b71c1c;",
120        "soma_running" => "fill:#fff8e1,stroke:#f9a825,color:#f57f17;",
121        _ => "fill:#fff3e0,stroke:#ef6c00,stroke-width:3px,color:#e65100;",
122    }
123}
124
125/// Graphviz node attributes for a status class.
126pub(crate) fn dot_class_style(class: &str) -> String {
127    let (fill, border, extra) = match class {
128        "soma_completed" => ("#e8f5e9", "#2e7d32", ""),
129        "soma_cached" => ("#e3f2fd", "#1565c0", ""),
130        "soma_failed" => ("#ffebee", "#c62828", ""),
131        "soma_running" => ("#fff8e1", "#f9a825", ""),
132        _ => ("#fff3e0", "#ef6c00", " penwidth=3"),
133    };
134    format!(" style=filled fillcolor=\"{fill}\" color=\"{border}\"{extra}")
135}
136
137/// Compact human duration: `340ms`, `1.2s`, `3.5m`, `2.1h`.
138pub fn format_duration_ms(ms: u64) -> String {
139    if ms < 1_000 {
140        format!("{ms}ms")
141    } else if ms < 120_000 {
142        format!("{:.1}s", ms as f64 / 1_000.0)
143    } else if ms < 7_200_000 {
144        format!("{:.1}m", ms as f64 / 60_000.0)
145    } else {
146        format!("{:.1}h", ms as f64 / 3_600_000.0)
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn sublabel_composes_parts_in_order() {
156        let ov = NodeOverlay {
157            status: Some(NodeStatus::Cached),
158            duration_ms: Some(1_200),
159            cache_tier: Some("memory".into()),
160            flags: vec!["LEAKAGE".into()],
161            sublabel: Some("×3".into()),
162        };
163        assert_eq!(
164            ov.sublabel_text().unwrap(),
165            "1.2s · mem hit · ⚠ LEAKAGE · ×3"
166        );
167    }
168
169    #[test]
170    fn empty_overlay_has_no_sublabel_or_class() {
171        let ov = NodeOverlay::default();
172        assert!(ov.sublabel_text().is_none());
173        assert!(ov.style_class().is_none());
174    }
175
176    #[test]
177    fn failed_status_shows_in_sublabel_and_class() {
178        let ov = NodeOverlay {
179            status: Some(NodeStatus::Failed),
180            ..Default::default()
181        };
182        assert_eq!(ov.sublabel_text().unwrap(), "failed");
183        assert_eq!(ov.style_class(), Some("soma_failed"));
184    }
185
186    #[test]
187    fn flags_take_style_precedence_over_status() {
188        let ov = NodeOverlay {
189            status: Some(NodeStatus::Completed),
190            flags: vec!["DEAD_CHANNELS".into()],
191            ..Default::default()
192        };
193        assert_eq!(ov.style_class(), Some("soma_flagged"));
194    }
195
196    #[test]
197    fn duration_formatting_ranges() {
198        assert_eq!(format_duration_ms(340), "340ms");
199        assert_eq!(format_duration_ms(1_234), "1.2s");
200        assert_eq!(format_duration_ms(150_000), "2.5m");
201        assert_eq!(format_duration_ms(9_000_000), "2.5h");
202    }
203
204    #[test]
205    fn overlay_deserializes_from_partial_json() {
206        // The Python side passes overlays as JSON dicts — every field
207        // must be optional.
208        let ov: GraphOverlay =
209            serde_json::from_str(r#"{"nodes": {"a": {"status": "completed", "duration_ms": 42}}}"#)
210                .unwrap();
211        assert_eq!(ov.nodes["a"].status, Some(NodeStatus::Completed));
212        assert_eq!(ov.nodes["a"].duration_ms, Some(42));
213        assert!(ov.nodes["a"].flags.is_empty());
214    }
215}