Skip to main content

dspk_app_core/domain/
graph.rs

1//! The session's processing graph: what the Pipeline canvas draws and edits. Nodes are sources
2//! (a recording, optionally a channel subset), ops (a dspk-pipeline node), views (a stream shown
3//! in Explore: its display and what plugins keep for it) and writes (a processed file); edges
4//! carry the signal from a node's output to another's input.
5//!
6//! Every node has at most one input and its output may feed any number of nodes, so one
7//! recording can feed several branches (a spike band and an LFP band, each to its own view).
8//! Each view or write is therefore one chain: the path from its source through its ops. The
9//! graph is the truth; the session's `views` are compiled from it ([`GraphDoc::compile`]).
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use dspk_pipeline::Node;
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16
17use super::recipe::RecipeDoc;
18use super::session::{op_name, DisplayDoc, OpDoc, RecordingRef, ViewDoc};
19
20/// The session's recipe definitions, by name.
21pub type Recipes = BTreeMap<String, RecipeDoc>;
22
23/// Spacing of nodes laid out by the app (canvas units): one column per step, one row per chain.
24pub const COLUMN: f32 = 260.;
25pub const ROW: f32 = 150.;
26
27#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)]
28#[serde(deny_unknown_fields)]
29pub struct GraphDoc {
30    #[serde(default)]
31    pub nodes: Vec<GraphNode>,
32    #[serde(default)]
33    pub edges: Vec<EdgeDoc>,
34}
35
36/// A node and where it sits on the canvas (canvas units; y grows downwards).
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
38#[serde(deny_unknown_fields)]
39pub struct GraphNode {
40    /// Unique in the session; a view node's id is its view's id.
41    pub id: String,
42    #[serde(default)]
43    pub x: f32,
44    #[serde(default)]
45    pub y: f32,
46    pub kind: NodeKind,
47}
48
49#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
50#[serde(tag = "type", rename_all = "snake_case")]
51pub enum NodeKind {
52    /// A recording (one source of one file, optionally a channel subset): no input.
53    Source { recording: RecordingRef },
54    /// Processing applied to every channel of its input.
55    Op {
56        /// Off: kept in the graph, skipped when processing.
57        #[serde(default = "yes")]
58        enabled: bool,
59        node: Node,
60    },
61    /// A stream shown in Explore (a view of the session).
62    View {
63        name: String,
64        #[serde(default)]
65        display: DisplayDoc,
66        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
67        #[schemars(with = "BTreeMap<String, serde_json::Value>")]
68        plugins: BTreeMap<String, serde_json::Value>,
69    },
70    /// The chain written to `<recording>.<id>.processed.zarr`.
71    Write {},
72    /// A use of recipe `recipe` (defined in the session's `recipes`), with its own values.
73    Recipe {
74        recipe: String,
75        #[serde(default = "yes")]
76        enabled: bool,
77        /// `"<op>.<key>"` → value, over the recipe's.
78        #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
79        #[schemars(with = "BTreeMap<String, serde_json::Value>")]
80        overrides: BTreeMap<String, serde_json::Value>,
81    },
82}
83
84fn yes() -> bool {
85    true
86}
87
88impl NodeKind {
89    /// Whether the node takes an input (everything but a source).
90    pub fn has_input(&self) -> bool {
91        !matches!(self, NodeKind::Source { .. })
92    }
93
94    /// Whether the node's output can feed others (sources and ops).
95    pub fn has_output(&self) -> bool {
96        matches!(self, NodeKind::Source { .. } | NodeKind::Op { .. } | NodeKind::Recipe { .. })
97    }
98}
99
100/// The signal of `from`'s output into `to`'s input.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
102#[serde(deny_unknown_fields)]
103pub struct EdgeDoc {
104    pub from: String,
105    pub to: String,
106}
107
108impl GraphDoc {
109    pub fn is_empty(&self) -> bool {
110        self.nodes.is_empty()
111    }
112
113    pub fn node(&self, id: &str) -> Option<&GraphNode> {
114        self.nodes.iter().find(|n| n.id == id)
115    }
116
117    pub fn node_mut(&mut self, id: &str) -> Option<&mut GraphNode> {
118        self.nodes.iter_mut().find(|n| n.id == id)
119    }
120
121    pub fn contains(&self, id: &str) -> bool {
122        self.node(id).is_some()
123    }
124
125    /// The node feeding `id`.
126    pub fn input(&self, id: &str) -> Option<&str> {
127        self.edges.iter().find(|e| e.to == id).map(|e| e.from.as_str())
128    }
129
130    /// The nodes `id` feeds, in edge order.
131    pub fn outputs(&self, id: &str) -> Vec<&str> {
132        self.edges.iter().filter(|e| e.from == id).map(|e| e.to.as_str()).collect()
133    }
134
135    /// `id`'s chain from its source: `[source, op…, id]`; `None` when it does not reach one.
136    pub fn path(&self, id: &str) -> Option<Vec<String>> {
137        let mut path = vec![id.to_string()];
138        let mut cur = id;
139        // At most one input per node: the walk ends at a source, at a loose end, or (a cycle a
140        // hand-edited file may hold) after visiting every node
141        for _ in 0..=self.nodes.len() {
142            if matches!(self.node(cur)?.kind, NodeKind::Source { .. }) {
143                path.reverse();
144                return Some(path);
145            }
146            cur = self.input(cur)?;
147            path.push(cur.to_string());
148        }
149        None
150    }
151
152    /// Every node upstream of `id`.
153    pub fn ancestors(&self, id: &str) -> BTreeSet<String> {
154        let mut out = BTreeSet::new();
155        let mut cur = id;
156        while let Some(i) = self.input(cur) {
157            if !out.insert(i.to_string()) {
158                break;
159            }
160            cur = i;
161        }
162        out
163    }
164
165    /// Every node downstream of `id`.
166    pub fn descendants(&self, id: &str) -> BTreeSet<String> {
167        let mut out = BTreeSet::new();
168        let mut stack = vec![id.to_string()];
169        while let Some(n) = stack.pop() {
170            for o in self.outputs(&n) {
171                if out.insert(o.to_string()) {
172                    stack.push(o.to_string());
173                }
174            }
175        }
176        out
177    }
178
179    /// An id not used yet: `base` itself, else `base1`, `base2`, …
180    pub fn fresh_id(&self, base: &str) -> String {
181        if !self.contains(base) && base != "read" && base != "write" {
182            return base.to_string();
183        }
184        (1..).map(|i| format!("{base}{i}")).find(|id| !self.contains(id)).expect("ids are unbounded")
185    }
186
187    /// `prefix1`, `prefix2`, … (sources `s1`, writes `w1`).
188    pub fn numbered_id(&self, prefix: &str, taken: impl Fn(&str) -> bool) -> String {
189        (1..).map(|i| format!("{prefix}{i}")).find(|id| !self.contains(id) && !taken(id)).expect("ids are unbounded")
190    }
191
192    /// The y of a new row under every node.
193    pub fn next_row(&self) -> f32 {
194        self.nodes.iter().map(|n| n.y + ROW).fold(0.0, f32::max)
195    }
196
197    /// Connects `from`'s output to `to`'s input (replacing `to`'s input).
198    pub fn connect(&mut self, from: &str, to: &str) -> Result<(), String> {
199        let (Some(a), Some(b)) = (self.node(from), self.node(to)) else { return Err(format!("no node {from:?} or {to:?}")) };
200        if !a.kind.has_output() {
201            return Err(format!("{from} has no output (views and writes end a chain)"));
202        }
203        if !b.kind.has_input() {
204            return Err(format!("{to} is a recording: it takes no input"));
205        }
206        if from == to || self.descendants(to).contains(from) {
207            return Err("that connection would make a loop".into());
208        }
209        self.edges.retain(|e| e.to != to);
210        self.edges.push(EdgeDoc { from: from.to_string(), to: to.to_string() });
211        Ok(())
212    }
213
214    /// Removes `to`'s input.
215    pub fn disconnect(&mut self, to: &str) {
216        self.edges.retain(|e| e.to != to);
217    }
218
219    /// Removes a node and its edges.
220    pub fn remove(&mut self, id: &str) {
221        self.nodes.retain(|n| n.id != id);
222        self.edges.retain(|e| e.from != id && e.to != id);
223    }
224
225    /// Removes a node, its input feeding what it fed.
226    pub fn remove_bridging(&mut self, id: &str) {
227        self.unlink_bridging(id);
228        self.nodes.retain(|n| n.id != id);
229    }
230
231    /// Takes a node out of its chains (its input feeds what it fed); the node stays, unlinked.
232    pub fn unlink_bridging(&mut self, id: &str) {
233        let input = self.input(id).map(str::to_string);
234        let outputs: Vec<String> = self.outputs(id).into_iter().map(str::to_string).collect();
235        self.edges.retain(|e| e.from != id && e.to != id);
236        if let Some(i) = input {
237            for o in outputs {
238                self.edges.push(EdgeDoc { from: i.clone(), to: o });
239            }
240        }
241    }
242
243    /// Puts `id` on the edge `a → b` (`a → id → b`).
244    pub fn splice(&mut self, a: &str, b: &str, id: &str) {
245        self.edges.retain(|e| !(e.from == a && e.to == b));
246        self.edges.push(EdgeDoc { from: a.to_string(), to: id.to_string() });
247        self.edges.push(EdgeDoc { from: id.to_string(), to: b.to_string() });
248    }
249
250    /// The recording and ops (enabled or not) of `id`'s chain; a recipe node gives its ops
251    /// (`<node>.<op>`, its overrides applied).
252    pub fn chain(&self, id: &str, recipes: &Recipes) -> Option<(RecordingRef, Vec<OpDoc>)> {
253        let path = self.path(id)?;
254        let NodeKind::Source { recording } = &self.node(&path[0])?.kind else { return None };
255        let mut ops = Vec::new();
256        for n in &path[1..path.len() - 1] {
257            match &self.node(n)?.kind {
258                NodeKind::Op { enabled, node } => ops.push(OpDoc { id: n.clone(), enabled: *enabled, node: node.clone() }),
259                NodeKind::Recipe { recipe, enabled, overrides } => {
260                    // A use of a recipe the session does not define breaks the chain
261                    let def = recipes.get(recipe)?;
262                    ops.extend(def.ops_with(overrides).into_iter().map(|op| OpDoc { id: format!("{n}.{}", op.id), enabled: op.enabled && *enabled, node: op.node }));
263                }
264                _ => {}
265            }
266        }
267        Some((recording.clone(), ops))
268    }
269
270    /// The views: each view node that reaches a source, in node order.
271    pub fn compile(&self, recipes: &Recipes) -> Vec<ViewDoc> {
272        self.nodes
273            .iter()
274            .filter_map(|n| {
275                let NodeKind::View { name, display, plugins } = &n.kind else { return None };
276                let (recording, ops) = self.chain(&n.id, recipes)?;
277                Some(ViewDoc { id: n.id.clone(), name: name.clone(), recording, ops, display: display.clone(), plugins: plugins.clone() })
278            })
279            .collect()
280    }
281
282    /// The graph of a document that has views only (written before the graph, or by hand): one
283    /// row per view, its source, its ops, the view.
284    pub fn from_views(views: &[ViewDoc]) -> Self {
285        let mut g = GraphDoc::default();
286        let view_ids: BTreeSet<&str> = views.iter().map(|v| v.id.as_str()).collect();
287        for (row, v) in views.iter().enumerate() {
288            let y = row as f32 * ROW;
289            let s = g.numbered_id("s", |id| view_ids.contains(id));
290            g.nodes.push(GraphNode { id: s.clone(), x: 0.0, y, kind: NodeKind::Source { recording: v.recording.clone() } });
291            let mut last = s;
292            for (k, op) in v.ops.iter().enumerate() {
293                let id = if g.contains(&op.id) || view_ids.contains(op.id.as_str()) { g.fresh_id(op_name(&op.node)) } else { op.id.clone() };
294                g.nodes.push(GraphNode { id: id.clone(), x: (k + 1) as f32 * COLUMN, y, kind: NodeKind::Op { enabled: op.enabled, node: op.node.clone() } });
295                g.edges.push(EdgeDoc { from: last, to: id.clone() });
296                last = id;
297            }
298            let kind = NodeKind::View { name: v.name.clone(), display: v.display.clone(), plugins: v.plugins.clone() };
299            g.nodes.push(GraphNode { id: v.id.clone(), x: (v.ops.len() + 1) as f32 * COLUMN, y, kind });
300            g.edges.push(EdgeDoc { from: last, to: v.id.clone() });
301        }
302        g
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn node(id: &str, kind: NodeKind) -> GraphNode {
311        GraphNode { id: id.into(), x: 0.0, y: 0.0, kind }
312    }
313
314    fn view(name: &str) -> NodeKind {
315        NodeKind::View { name: name.into(), display: DisplayDoc::default(), plugins: BTreeMap::new() }
316    }
317
318    /// s1 → hp → cmr → spikes, and s1 → lp → lfp
319    fn branched() -> GraphDoc {
320        let rec = RecordingRef { path: "/d/rec.bin".into(), source: None, channels: None };
321        let hp: Node = serde_json::from_value(serde_json::json!({"op": "iir", "band": "highpass", "cutoff": [300.0]})).unwrap();
322        let lp: Node = serde_json::from_value(serde_json::json!({"op": "iir", "band": "lowpass", "cutoff": [300.0]})).unwrap();
323        let mut g = GraphDoc::default();
324        g.nodes = vec![
325            node("s1", NodeKind::Source { recording: rec }),
326            node("hp", NodeKind::Op { enabled: true, node: hp }),
327            node("cmr", NodeKind::Op { enabled: true, node: Node::Cmr {} }),
328            node("spikes", view("Spikes")),
329            node("lp", NodeKind::Op { enabled: true, node: lp }),
330            node("lfp", view("LFP")),
331        ];
332        for (a, b) in [("s1", "hp"), ("hp", "cmr"), ("cmr", "spikes"), ("s1", "lp"), ("lp", "lfp")] {
333            g.connect(a, b).unwrap();
334        }
335        g
336    }
337
338    #[test]
339    fn test_branches_compile_to_one_view_each() {
340        let views = branched().compile(&Recipes::new());
341        assert_eq!(views.len(), 2);
342        assert_eq!(views[0].ops.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["hp", "cmr"]);
343        assert_eq!(views[1].ops.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["lp"]);
344    }
345
346    #[test]
347    fn test_paths_and_highlight_sets() {
348        let g = branched();
349        assert_eq!(g.path("spikes").unwrap(), ["s1", "hp", "cmr", "spikes"]);
350        assert_eq!(g.ancestors("cmr").into_iter().collect::<Vec<_>>(), ["hp", "s1"]);
351        assert_eq!(g.descendants("s1").len(), 5);
352        assert!(g.descendants("hp").contains("spikes") && !g.descendants("hp").contains("lfp"));
353    }
354
355    #[test]
356    fn test_connections_are_checked() {
357        let mut g = branched();
358        assert!(g.connect("spikes", "lp").is_err(), "a view has no output");
359        assert!(g.connect("cmr", "s1").is_err(), "a source has no input");
360        assert!(g.connect("cmr", "hp").is_err(), "a loop");
361        // A new input replaces the old one: lfp now hangs off the spike band
362        g.connect("cmr", "lfp").unwrap();
363        assert_eq!(g.input("lfp"), Some("cmr"));
364        assert_eq!(g.edges.iter().filter(|e| e.to == "lfp").count(), 1);
365    }
366
367    #[test]
368    fn test_removing_bridges_and_loose_views_drop_out() {
369        let mut g = branched();
370        g.remove_bridging("hp");
371        assert_eq!(g.path("spikes").unwrap(), ["s1", "cmr", "spikes"]);
372        g.disconnect("lfp");
373        assert_eq!(g.compile(&Recipes::new()).len(), 1, "a view without a source is not a view yet");
374    }
375
376    #[test]
377    fn test_from_views_round_trips() {
378        let views = branched().compile(&Recipes::new());
379        let again = GraphDoc::from_views(&views).compile(&Recipes::new());
380        assert_eq!(again.iter().map(|v| (&v.id, v.ops.len())).collect::<Vec<_>>(), views.iter().map(|v| (&v.id, v.ops.len())).collect::<Vec<_>>());
381    }
382}