Skip to main content

g2g_core/
graph.rs

1//! DAG pipeline graph + validation (DESIGN_TODO "DAG runner" D1).
2//!
3//! `Graph<E>` is the builder for an arbitrary multimedia DAG: linear, fan-out
4//! (tee), fan-in (muxer), and nested branches in one topology. It carries an
5//! opaque element payload `E` per source/transform/sink node so it stays
6//! `no_std` and independent of the std-gated runner; the runner instantiates
7//! `Graph<Box<dyn DynAsyncElement>>`, embedded/wasm callers use their own.
8//!
9//! `finish()` runs the validation the runner relies on: every pad linked
10//! exactly once, no cycles (Kahn topological sort), and the pad counts match
11//! each node kind. The solver (D2) and runner (D3) consume the resulting
12//! `ValidatedGraph`'s topological order and adjacency. This module is data and
13//! computation only, no I/O.
14
15use alloc::string::String;
16use alloc::vec;
17use alloc::vec::Vec;
18
19use crate::link::LinkPolicy;
20
21/// Opaque index of a node within a [`Graph`].
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub struct NodeId(pub u32);
24
25/// A pad on a node. In an edge's source position the index selects an output
26/// pad; in the destination position, an input pad. Most 1-in-1-out elements
27/// use index 0 via `NodeId: Into<PadId>`.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub struct PadId {
30    pub node: NodeId,
31    pub index: u8,
32}
33
34impl From<NodeId> for PadId {
35    fn from(node: NodeId) -> Self {
36        PadId { node, index: 0 }
37    }
38}
39
40/// The topology role of a node, which fixes its pad counts. `Tee(n)` is
41/// 1-in/n-out, `Muxer(n)` is n-in/1-out, `FaninSink(n)` is n-in/0-out (a
42/// terminal fan-in element: a multi-input sink with no merged output), and
43/// `FanoutSrc(n)` is 0-in/n-out (a terminal fan-out source generating each
44/// output itself, e.g. a WebRTC session receiving several tracks).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum NodeKind {
47    Source,
48    Transform,
49    Sink,
50    Tee(u8),
51    Muxer(u8),
52    FaninSink(u8),
53    FanoutSrc(u8),
54}
55
56impl NodeKind {
57    /// Number of input pads this kind exposes.
58    pub fn in_pads(self) -> u8 {
59        match self {
60            NodeKind::Source | NodeKind::FanoutSrc(_) => 0,
61            NodeKind::Transform | NodeKind::Sink | NodeKind::Tee(_) => 1,
62            NodeKind::Muxer(n) | NodeKind::FaninSink(n) => n,
63        }
64    }
65
66    /// Number of output pads this kind exposes.
67    pub fn out_pads(self) -> u8 {
68        match self {
69            NodeKind::Sink | NodeKind::FaninSink(_) => 0,
70            NodeKind::Source | NodeKind::Transform | NodeKind::Muxer(_) => 1,
71            NodeKind::Tee(n) | NodeKind::FanoutSrc(n) => n,
72        }
73    }
74}
75
76/// Input vs output side of a pad, for error reporting.
77// Closed set: intentionally exhaustive (not #[non_exhaustive]); see STABILITY.md.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum PadDir {
80    In,
81    Out,
82}
83
84/// How a tee handles a branch that rejects a mid-stream `CapsChanged` it cannot
85/// negotiate. The default fails the whole run loud (a shared upstream cannot
86/// honour a per-branch reconfigure, so a silent partial pipeline would be worse
87/// than a clear failure). `AllowBranchDrop` instead lets that one branch fall
88/// away (its arm ends, the tee stops broadcasting to it) while the siblings keep
89/// flowing, for fan-outs where a branch is genuinely optional (a preview window
90/// that can't follow a format switch should not kill the recording).
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
92pub enum FanOutPolicy {
93    /// A rejecting branch fails the whole run with `CapsMismatch` (default).
94    #[default]
95    FailLoud,
96    /// A rejecting branch drops out; the remaining branches continue.
97    AllowBranchDrop,
98}
99
100/// The `NodeId` shift applied when one graph is merged into another
101/// ([`Graph::merge`]). Translates a node id, and the pad ids on it, from the
102/// merged-in graph's local id space into the host graph's. The shift is the
103/// host's node count at merge time, because nodes are a flat `Vec` indexed by
104/// `NodeId`.
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct NodeIdOffset(u32);
107
108impl NodeIdOffset {
109    /// Translate a node id from the merged-in graph into the host graph.
110    pub fn apply(self, node: NodeId) -> NodeId {
111        NodeId(node.0 + self.0)
112    }
113
114    /// Translate a pad (re-base its node id; the pad index is unchanged).
115    pub fn apply_pad(self, pad: PadId) -> PadId {
116        PadId {
117            node: self.apply(pad.node),
118            index: pad.index,
119        }
120    }
121}
122
123/// A directed link from an output pad (`src`) to an input pad (`dst`), with
124/// its backpressure policy.
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
126pub struct Edge {
127    pub src: PadId,
128    pub dst: PadId,
129    pub policy: LinkPolicy,
130    /// Per-edge channel depth, or `None` to use the runner's graph-wide
131    /// `link_capacity`. The launch parser sets it from a `queue max-size-buffers=N`
132    /// (the gst per-queue depth), so one branch can buffer more/less than the rest.
133    pub capacity: Option<usize>,
134}
135
136/// A tee handle returned by [`Graph::add_tee`]: 1 input pad, `n` output pads.
137#[derive(Debug, Clone, Copy)]
138pub struct Tee(NodeId);
139
140impl Tee {
141    pub fn node(self) -> NodeId {
142        self.0
143    }
144    pub fn input(self) -> PadId {
145        PadId {
146            node: self.0,
147            index: 0,
148        }
149    }
150    pub fn out(self, index: u8) -> PadId {
151        PadId {
152            node: self.0,
153            index,
154        }
155    }
156}
157
158/// A demux handle returned by [`Graph::add_demux`]: 1 input pad, `n` output
159/// pads. Structurally a tee (its node kind is `Tee(n)`), but the node carries a
160/// content-routing element rather than broadcasting; see
161/// [`GraphNodeRef::Demux`](crate::runtime::GraphNodeRef::Demux).
162#[derive(Debug, Clone, Copy)]
163pub struct Demux(NodeId);
164
165impl Demux {
166    pub fn node(self) -> NodeId {
167        self.0
168    }
169    pub fn input(self) -> PadId {
170        PadId {
171            node: self.0,
172            index: 0,
173        }
174    }
175    pub fn out(self, index: u8) -> PadId {
176        PadId {
177            node: self.0,
178            index,
179        }
180    }
181}
182
183/// A muxer handle returned by [`Graph::add_muxer`]: `n` input pads, 1 output.
184#[derive(Debug, Clone, Copy)]
185pub struct Muxer(NodeId);
186
187impl Muxer {
188    pub fn node(self) -> NodeId {
189        self.0
190    }
191    pub fn input(self, index: u8) -> PadId {
192        PadId {
193            node: self.0,
194            index,
195        }
196    }
197    pub fn output(self) -> PadId {
198        PadId {
199            node: self.0,
200            index: 0,
201        }
202    }
203}
204
205/// A terminal fan-in handle returned by [`Graph::add_fanin_sink`]: `n` input
206/// pads, no output. The multi-input analog of a plain sink: the element is the
207/// destination (a WebRTC session publishing its inputs over one PeerConnection),
208/// so there is no merged output pad to link.
209#[derive(Debug, Clone, Copy)]
210pub struct FaninSink(NodeId);
211
212impl FaninSink {
213    pub fn node(self) -> NodeId {
214        self.0
215    }
216    pub fn input(self, index: u8) -> PadId {
217        PadId {
218            node: self.0,
219            index,
220        }
221    }
222}
223
224/// A terminal fan-out handle returned by [`Graph::add_fanout_src`]: no inputs,
225/// `n` output pads. The source-side mirror of [`FaninSink`]: the element
226/// generates its outputs itself (a WebRTC session receiving N tracks).
227#[derive(Debug, Clone, Copy)]
228pub struct FanoutSrc(NodeId);
229
230impl FanoutSrc {
231    pub fn node(self) -> NodeId {
232        self.0
233    }
234    pub fn output(self, index: u8) -> PadId {
235        PadId {
236            node: self.0,
237            index,
238        }
239    }
240}
241
242/// Validation failures from [`Graph::link`] and [`Graph::finish`].
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub enum GraphError {
245    /// A linked pad referenced a node id that doesn't exist.
246    UnknownNode(NodeId),
247    /// A pad index is past the node kind's pad count for that direction.
248    PadOutOfRange {
249        node: NodeId,
250        index: u8,
251        direction: PadDir,
252    },
253    /// A pad has no link where the kind requires one.
254    UnlinkedPad {
255        node: NodeId,
256        index: u8,
257        direction: PadDir,
258    },
259    /// A pad has more than one link (a pad peers with exactly one other pad;
260    /// fan-out/in is expressed with `Tee`/`Muxer`, not multi-linked pads).
261    PadCountMismatch {
262        node: NodeId,
263        index: u8,
264        direction: PadDir,
265    },
266    /// A node participates in no link at all.
267    OrphanNode(NodeId),
268    /// The graph has a cycle; the listed nodes are the unresolved set.
269    Cycle { nodes: Vec<NodeId> },
270    /// The same interior pad was exposed as a ghost pad twice on a [`Bin`]. A
271    /// ghost pad peers 1:1 with one internal pad (as in GStreamer), so a pad can
272    /// back at most one ghost.
273    DuplicateGhostPad {
274        node: NodeId,
275        index: u8,
276        direction: PadDir,
277    },
278    /// A `Tee`/`Demux` with zero outputs or a `Muxer` with zero inputs. The
279    /// runner's broadcast computes `senders.len() - 1`, so a zero-fan node
280    /// underflows and panics; reject it at validation instead.
281    DegenerateFanNode(NodeId),
282}
283
284struct Node<E> {
285    kind: NodeKind,
286    /// `Some` for source/transform/sink; `None` for tee/muxer (runner shapes).
287    element: Option<E>,
288    /// Fan-out rejection policy; only meaningful on a `Tee` node, `FailLoud`
289    /// elsewhere.
290    fanout: FanOutPolicy,
291    /// Explicit instance name (a launch line's `name=`); `None` leaves the
292    /// runner's auto `<category>N` naming in charge.
293    name: Option<String>,
294    /// Per-instance log category (a launch line's `log-category=`); `None` keeps
295    /// the element type as the category.
296    log_category: Option<String>,
297    /// Animated properties (M882); `None` when nothing on this node is animated.
298    #[cfg(feature = "runtime")]
299    control: Option<crate::controller::ControlProgram>,
300}
301
302/// Builder for a multimedia DAG. Add nodes, link their pads, then `finish()`
303/// to validate and produce a [`ValidatedGraph`].
304pub struct Graph<E> {
305    nodes: Vec<Node<E>>,
306    edges: Vec<Edge>,
307}
308
309impl<E> Default for Graph<E> {
310    fn default() -> Self {
311        Self::new()
312    }
313}
314
315impl<E> Graph<E> {
316    pub fn new() -> Self {
317        Self {
318            nodes: Vec::new(),
319            edges: Vec::new(),
320        }
321    }
322
323    pub fn add_source(&mut self, element: E) -> NodeId {
324        self.push(NodeKind::Source, Some(element))
325    }
326
327    pub fn add_transform(&mut self, element: E) -> NodeId {
328        self.push(NodeKind::Transform, Some(element))
329    }
330
331    pub fn add_sink(&mut self, element: E) -> NodeId {
332        self.push(NodeKind::Sink, Some(element))
333    }
334
335    pub fn add_tee(&mut self, outputs: u8) -> Tee {
336        Tee(self.push(NodeKind::Tee(outputs), None))
337    }
338
339    /// Add a tee with an explicit [`FanOutPolicy`]. `add_tee` is the
340    /// `FailLoud` shorthand; this opts a fan-out into `AllowBranchDrop` so a
341    /// branch that cannot follow a mid-stream `CapsChanged` drops out instead of
342    /// failing the run.
343    pub fn add_tee_with_policy(&mut self, outputs: u8, policy: FanOutPolicy) -> Tee {
344        let id = self.push(NodeKind::Tee(outputs), None);
345        self.nodes[id.0 as usize].fanout = policy;
346        Tee(id)
347    }
348
349    pub fn add_muxer(&mut self, element: E, inputs: u8) -> Muxer {
350        Muxer(self.push(NodeKind::Muxer(inputs), Some(element)))
351    }
352
353    /// Add a terminal fan-in element: `inputs` input pads, no output. `element`
354    /// is a [`MultiInputElement`](crate::MultiInputElement) that consumes all its
355    /// inputs and produces no downstream stream (a WebRTC publisher, a
356    /// multi-stream batching sink). The runner drives it the `run_fanin_session`
357    /// way: per-input `Eos` flush, end once every input has ended, and per-input
358    /// reverse-signal routing back to the arm feeding each pad.
359    pub fn add_fanin_sink(&mut self, element: E, inputs: u8) -> FaninSink {
360        FaninSink(self.push(NodeKind::FaninSink(inputs), Some(element)))
361    }
362
363    /// Add a terminal fan-out source: no inputs, `outputs` output pads.
364    /// `element` is a [`MultiOutputSource`](crate::MultiOutputSource) that
365    /// generates every output itself (a WebRTC session receiving its tracks
366    /// over one PeerConnection). The runner drives it the `run_fanout_session`
367    /// way: the element pushes to each port and owes every port an `Eos`.
368    pub fn add_fanout_src(&mut self, element: E, outputs: u8) -> FanoutSrc {
369        FanoutSrc(self.push(NodeKind::FanoutSrc(outputs), Some(element)))
370    }
371
372    /// Add a content-routing demultiplexer: 1 input, `outputs` outputs. The node
373    /// is `Tee(outputs)`-shaped (so it validates and negotiates exactly like a
374    /// tee, all outputs initially carrying the input caps) but carries a
375    /// routing `element`; the runner drives it via
376    /// [`GraphNodeRef::Demux`](crate::runtime::GraphNodeRef::Demux) and each
377    /// branch retypes from a per-output `CapsChanged` at runtime (M210). Unlike
378    /// `add_tee`, a demux carries an element payload.
379    pub fn add_demux(&mut self, element: E, outputs: u8) -> Demux {
380        Demux(self.push(NodeKind::Tee(outputs), Some(element)))
381    }
382
383    fn push(&mut self, kind: NodeKind, element: Option<E>) -> NodeId {
384        let id = NodeId(self.nodes.len() as u32);
385        self.nodes.push(Node {
386            kind,
387            element,
388            fanout: FanOutPolicy::FailLoud,
389            name: None,
390            log_category: None,
391            #[cfg(feature = "runtime")]
392            control: None,
393        });
394        id
395    }
396
397    /// Give a node an explicit instance name, the runner's `<category>N` naming
398    /// otherwise. The launch parser sets it from a `name=`.
399    pub fn set_node_name(&mut self, node: NodeId, name: String) {
400        self.nodes[node.0 as usize].name = Some(name);
401    }
402
403    /// A node's explicit instance name, if one was set.
404    pub fn node_name(&self, node: NodeId) -> Option<&str> {
405        self.nodes
406            .get(node.0 as usize)
407            .and_then(|n| n.name.as_deref())
408    }
409
410    /// Override this node's log category, the element type otherwise. The launch
411    /// parser sets it from a `log-category=`; the runner hands it to the element
412    /// before naming, so `G2G_DEBUG` filtering keys off it for this instance.
413    pub fn set_node_log_category(&mut self, node: NodeId, category: String) {
414        self.nodes[node.0 as usize].log_category = Some(category);
415    }
416
417    /// A node's log-category override, if one was set.
418    pub fn node_log_category(&self, node: NodeId) -> Option<&str> {
419        self.nodes
420            .get(node.0 as usize)
421            .and_then(|n| n.log_category.as_deref())
422    }
423
424    /// The node carrying this instance name, for attaching to a graph someone
425    /// else built (a `parse_launch` line's `name=`).
426    pub fn node_by_name(&self, name: &str) -> Option<NodeId> {
427        self.nodes
428            .iter()
429            .position(|n| n.name.as_deref() == Some(name))
430            .map(|i| NodeId(i as u32))
431    }
432
433    /// Animate this node's properties over stream time (M882): the runner samples
434    /// `program` at each frame's PTS and sets the bound properties on the element
435    /// before it processes that frame. Replaces any program already attached.
436    ///
437    /// Validated when the run starts, against the element's own declared
438    /// properties, so an unknown or non-animatable property name fails the run
439    /// before any frame flows.
440    #[cfg(feature = "runtime")]
441    pub fn set_node_control(&mut self, node: NodeId, program: crate::controller::ControlProgram) {
442        self.nodes[node.0 as usize].control = Some(program);
443    }
444
445    /// Link an output pad to an input pad with the default `Block` policy.
446    pub fn link(&mut self, from: impl Into<PadId>, to: impl Into<PadId>) -> Result<(), GraphError> {
447        self.link_with(from, to, LinkPolicy::Block)
448    }
449
450    /// Link an output pad to an input pad with an explicit backpressure policy.
451    pub fn link_with(
452        &mut self,
453        from: impl Into<PadId>,
454        to: impl Into<PadId>,
455        policy: LinkPolicy,
456    ) -> Result<(), GraphError> {
457        self.link_full(from, to, policy, None)
458    }
459
460    /// Link with an explicit policy *and* a per-edge channel depth (`None` = use
461    /// the runner's graph-wide `link_capacity`). The launch parser passes the
462    /// depth from a `queue max-size-buffers=N`.
463    pub fn link_full(
464        &mut self,
465        from: impl Into<PadId>,
466        to: impl Into<PadId>,
467        policy: LinkPolicy,
468        capacity: Option<usize>,
469    ) -> Result<(), GraphError> {
470        let (src, dst) = (from.into(), to.into());
471        self.check_pad(src, PadDir::Out)?;
472        self.check_pad(dst, PadDir::In)?;
473        self.edges.push(Edge {
474            src,
475            dst,
476            policy,
477            capacity,
478        });
479        Ok(())
480    }
481
482    /// The edges in declaration order, including each one's backpressure
483    /// [`LinkPolicy`]. Lets callers inspect the wiring before [`finish`](Self::finish)
484    /// (e.g. the launch parser's `queue`-to-policy mapping).
485    pub fn edges(&self) -> &[Edge] {
486        &self.edges
487    }
488
489    /// Splice a new transform node carrying `element` onto edge `edge_idx`,
490    /// returning its id. The edge `P -> C` becomes `P -> K -> C` (K the new
491    /// node), preserving the original [`LinkPolicy`] on both halves. Existing node
492    /// and edge ids are unchanged (the new node is appended, the new `K -> C` edge
493    /// is appended, and the original edge is rewired to `P -> K`), so a caller
494    /// iterating a snapshot of the original edge ids can splice several without
495    /// re-indexing. The new node is a single-pad [`Transform`](NodeKind::Transform),
496    /// so the spliced element must be a 1-in/1-out transform (e.g. a memory-domain
497    /// converter). Used by the domain-converter auto-plug (M354).
498    pub fn insert_on_edge(&mut self, edge_idx: usize, element: E) -> NodeId {
499        let new = self.push(NodeKind::Transform, Some(element));
500        let old_dst = self.edges[edge_idx].dst;
501        let policy = self.edges[edge_idx].policy;
502        let capacity = self.edges[edge_idx].capacity;
503        // P -> K (rewire the original edge's destination to the new node).
504        self.edges[edge_idx].dst = PadId {
505            node: new,
506            index: 0,
507        };
508        // K -> C (the original destination), preserving policy + depth on both halves.
509        self.edges.push(Edge {
510            src: PadId {
511                node: new,
512                index: 0,
513            },
514            dst: old_dst,
515            policy,
516            capacity,
517        });
518        new
519    }
520
521    /// Number of nodes added so far. With [`edges`](Self::edges) and
522    /// [`node_kind`](Self::node_kind) this is enough to render the wiring
523    /// before validation (the DOT dump).
524    pub fn node_count(&self) -> usize {
525        self.nodes.len()
526    }
527
528    /// The [`NodeKind`] of a node, or `None` if the id is past the node count.
529    pub fn node_kind(&self, node: NodeId) -> Option<NodeKind> {
530        self.nodes.get(node.0 as usize).map(|n| n.kind)
531    }
532
533    /// Borrow a node's element payload (`None` for tee nodes or an unknown id),
534    /// for labeling a pre-validation dump from the element itself.
535    pub fn element(&self, node: NodeId) -> Option<&E> {
536        self.nodes
537            .get(node.0 as usize)
538            .and_then(|n| n.element.as_ref())
539    }
540
541    /// Append every node and edge of `inner` into this graph, returning the
542    /// [`NodeIdOffset`] that maps `inner`'s ids into this graph's id space.
543    /// Composition is a pure index shift: nodes are a flat `Vec` and edges carry
544    /// only pad indices, so re-basing `inner`'s ids by the current node count is
545    /// all it takes. The union is not re-validated here; the host's `finish()`
546    /// validates the whole. This is the one primitive under bin flattening
547    /// ([`add_bin`](Self::add_bin)) and the decodebin / uridecodebin / autoplug
548    /// splices.
549    pub fn merge(&mut self, inner: Graph<E>) -> NodeIdOffset {
550        let offset = NodeIdOffset(self.nodes.len() as u32);
551        self.nodes.extend(inner.nodes);
552        for e in inner.edges {
553            self.edges.push(Edge {
554                src: offset.apply_pad(e.src),
555                dst: offset.apply_pad(e.dst),
556                policy: e.policy,
557                capacity: e.capacity,
558            });
559        }
560        offset
561    }
562
563    /// Flatten `bin` into this graph, returning a [`BinInstance`] whose ghost pads
564    /// are this graph's pad ids: link them like any other pad
565    /// (`graph.link(src, inst.input(0))`, `graph.link(inst.output(0), dst)`).
566    /// Construction-time only, no new node kind, so the solver and runner see the
567    /// flattened union with no awareness the bin ever existed.
568    pub fn add_bin(&mut self, bin: Bin<E>) -> BinInstance {
569        let Bin {
570            graph,
571            ghost_in,
572            ghost_out,
573        } = bin;
574        let offset = self.merge(graph);
575        BinInstance {
576            ghost_in: ghost_in.into_iter().map(|p| offset.apply_pad(p)).collect(),
577            ghost_out: ghost_out.into_iter().map(|p| offset.apply_pad(p)).collect(),
578        }
579    }
580
581    fn kind_of(&self, node: NodeId) -> Result<NodeKind, GraphError> {
582        self.nodes
583            .get(node.0 as usize)
584            .map(|n| n.kind)
585            .ok_or(GraphError::UnknownNode(node))
586    }
587
588    fn check_pad(&self, pad: PadId, direction: PadDir) -> Result<(), GraphError> {
589        let kind = self.kind_of(pad.node)?;
590        let count = match direction {
591            PadDir::In => kind.in_pads(),
592            PadDir::Out => kind.out_pads(),
593        };
594        if pad.index >= count {
595            return Err(GraphError::PadOutOfRange {
596                node: pad.node,
597                index: pad.index,
598                direction,
599            });
600        }
601        Ok(())
602    }
603
604    /// Validate the graph and compute its topological order + adjacency.
605    pub fn finish(self) -> Result<ValidatedGraph<E>, GraphError> {
606        let n = self.nodes.len();
607        let mut in_edges: Vec<Vec<usize>> = vec![Vec::new(); n];
608        let mut out_edges: Vec<Vec<usize>> = vec![Vec::new(); n];
609        for (eid, e) in self.edges.iter().enumerate() {
610            out_edges[e.src.node.0 as usize].push(eid);
611            in_edges[e.dst.node.0 as usize].push(eid);
612        }
613
614        for (i, node) in self.nodes.iter().enumerate() {
615            let id = NodeId(i as u32);
616            if in_edges[i].is_empty() && out_edges[i].is_empty() {
617                return Err(GraphError::OrphanNode(id));
618            }
619            if matches!(
620                node.kind,
621                NodeKind::Tee(0)
622                    | NodeKind::Muxer(0)
623                    | NodeKind::FaninSink(0)
624                    | NodeKind::FanoutSrc(0)
625            ) {
626                return Err(GraphError::DegenerateFanNode(id));
627            }
628            check_pads(
629                node.kind.in_pads(),
630                in_edges[i].iter().map(|&e| self.edges[e].dst.index),
631                id,
632                PadDir::In,
633            )?;
634            check_pads(
635                node.kind.out_pads(),
636                out_edges[i].iter().map(|&e| self.edges[e].src.index),
637                id,
638                PadDir::Out,
639            )?;
640        }
641
642        let topo = topo_sort(n, &in_edges, &out_edges, &self.edges)?;
643        Ok(ValidatedGraph {
644            nodes: self.nodes,
645            edges: self.edges,
646            topo,
647            in_edges,
648            out_edges,
649        })
650    }
651}
652
653impl<E> core::fmt::Debug for Graph<E> {
654    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
655        let kinds: Vec<NodeKind> = self.nodes.iter().map(|n| n.kind).collect();
656        f.debug_struct("Graph")
657            .field("nodes", &kinds)
658            .field("edges", &self.edges)
659            .finish()
660    }
661}
662
663/// A reusable subgraph with designated ghost pads, flattened into a host graph
664/// by [`Graph::add_bin`]. Build its interior with the same `add_*` / `link` calls
665/// as a [`Graph`], then expose interior boundary pads as ghost pads (the bin's
666/// external pads, in designation order).
667///
668/// A bin is never validated on its own: its ghost pads are intentionally
669/// unlinked inside the bin and get their peer only when the host graph links the
670/// returned [`BinInstance`], so the host's `finish()` is what validates. This is
671/// pure construction-time encapsulation, no new [`NodeKind`]: the bin's nodes
672/// become first-class host nodes on flattening (DESIGN.md the bins section).
673pub struct Bin<E> {
674    graph: Graph<E>,
675    ghost_in: Vec<PadId>,
676    ghost_out: Vec<PadId>,
677}
678
679impl<E> Default for Bin<E> {
680    fn default() -> Self {
681        Self::new()
682    }
683}
684
685impl<E> Bin<E> {
686    pub fn new() -> Self {
687        Self {
688            graph: Graph::new(),
689            ghost_in: Vec::new(),
690            ghost_out: Vec::new(),
691        }
692    }
693
694    pub fn add_source(&mut self, element: E) -> NodeId {
695        self.graph.add_source(element)
696    }
697
698    pub fn add_transform(&mut self, element: E) -> NodeId {
699        self.graph.add_transform(element)
700    }
701
702    pub fn add_sink(&mut self, element: E) -> NodeId {
703        self.graph.add_sink(element)
704    }
705
706    pub fn add_tee(&mut self, outputs: u8) -> Tee {
707        self.graph.add_tee(outputs)
708    }
709
710    pub fn add_muxer(&mut self, element: E, inputs: u8) -> Muxer {
711        self.graph.add_muxer(element, inputs)
712    }
713
714    pub fn link(&mut self, from: impl Into<PadId>, to: impl Into<PadId>) -> Result<(), GraphError> {
715        self.graph.link(from, to)
716    }
717
718    pub fn link_with(
719        &mut self,
720        from: impl Into<PadId>,
721        to: impl Into<PadId>,
722        policy: LinkPolicy,
723    ) -> Result<(), GraphError> {
724        self.graph.link_with(from, to, policy)
725    }
726
727    /// Expose an interior input pad as the bin's next ghost input pad. The pad
728    /// must be a real input pad on a node in this bin, and not already a ghost.
729    pub fn ghost_input(&mut self, interior: impl Into<PadId>) -> Result<(), GraphError> {
730        let pad = interior.into();
731        self.graph.check_pad(pad, PadDir::In)?;
732        if self.ghost_in.contains(&pad) {
733            return Err(GraphError::DuplicateGhostPad {
734                node: pad.node,
735                index: pad.index,
736                direction: PadDir::In,
737            });
738        }
739        self.ghost_in.push(pad);
740        Ok(())
741    }
742
743    /// Expose an interior output pad as the bin's next ghost output pad. The pad
744    /// must be a real output pad on a node in this bin, and not already a ghost.
745    pub fn ghost_output(&mut self, interior: impl Into<PadId>) -> Result<(), GraphError> {
746        let pad = interior.into();
747        self.graph.check_pad(pad, PadDir::Out)?;
748        if self.ghost_out.contains(&pad) {
749            return Err(GraphError::DuplicateGhostPad {
750                node: pad.node,
751                index: pad.index,
752                direction: PadDir::Out,
753            });
754        }
755        self.ghost_out.push(pad);
756        Ok(())
757    }
758}
759
760impl<E> core::fmt::Debug for Bin<E> {
761    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
762        f.debug_struct("Bin")
763            .field("graph", &self.graph)
764            .field("ghost_in", &self.ghost_in)
765            .field("ghost_out", &self.ghost_out)
766            .finish()
767    }
768}
769
770/// A [`Bin`] flattened into a host graph: its ghost pads, as host-graph pad ids.
771/// Link these like any pad to wire the bin into the surrounding graph.
772#[derive(Debug, Clone)]
773pub struct BinInstance {
774    ghost_in: Vec<PadId>,
775    ghost_out: Vec<PadId>,
776}
777
778impl BinInstance {
779    /// The bin's `i`th ghost input pad, in host-graph space.
780    pub fn input(&self, i: usize) -> PadId {
781        self.ghost_in[i]
782    }
783
784    /// The bin's `i`th ghost output pad, in host-graph space.
785    pub fn output(&self, i: usize) -> PadId {
786        self.ghost_out[i]
787    }
788
789    /// Number of ghost input pads the bin exposes.
790    pub fn input_count(&self) -> usize {
791        self.ghost_in.len()
792    }
793
794    /// Number of ghost output pads the bin exposes.
795    pub fn output_count(&self) -> usize {
796        self.ghost_out.len()
797    }
798}
799
800/// A validated DAG: every pad linked once, acyclic, pad counts consistent.
801/// Carries the topological node order and per-node edge adjacency for the
802/// solver and runner.
803pub struct ValidatedGraph<E> {
804    nodes: Vec<Node<E>>,
805    edges: Vec<Edge>,
806    topo: Vec<NodeId>,
807    in_edges: Vec<Vec<usize>>,
808    out_edges: Vec<Vec<usize>>,
809}
810
811impl<E> ValidatedGraph<E> {
812    pub fn node_count(&self) -> usize {
813        self.nodes.len()
814    }
815
816    pub fn edge_count(&self) -> usize {
817        self.edges.len()
818    }
819
820    /// Nodes in topological order (every node appears after all its inputs).
821    pub fn topo(&self) -> &[NodeId] {
822        &self.topo
823    }
824
825    pub fn kind(&self, node: NodeId) -> NodeKind {
826        self.nodes[node.0 as usize].kind
827    }
828
829    /// This node's fan-out rejection policy (meaningful only on a `Tee`).
830    pub fn fanout_policy(&self, node: NodeId) -> FanOutPolicy {
831        self.nodes[node.0 as usize].fanout
832    }
833
834    /// This node's explicit instance name (a launch line's `name=`), if any. The
835    /// runner uses it instead of the auto `<category>N`.
836    pub fn node_name(&self, node: NodeId) -> Option<&str> {
837        self.nodes[node.0 as usize].name.as_deref()
838    }
839
840    /// This node's log-category override (a launch line's `log-category=`), if
841    /// any. The runner hands it to the element in place of the type category.
842    pub fn node_log_category(&self, node: NodeId) -> Option<&str> {
843        self.nodes[node.0 as usize].log_category.as_deref()
844    }
845
846    pub fn edge(&self, id: usize) -> &Edge {
847        &self.edges[id]
848    }
849
850    /// All edges, indexed by edge id (the same index the solver's `Vec<Caps>`
851    /// solution and the DOT renderer's per-edge annotations use).
852    pub fn edges(&self) -> &[Edge] {
853        &self.edges
854    }
855
856    /// Edge ids feeding this node's input pads.
857    pub fn in_edges(&self, node: NodeId) -> &[usize] {
858        &self.in_edges[node.0 as usize]
859    }
860
861    /// Edge ids leaving this node's output pads.
862    pub fn out_edges(&self, node: NodeId) -> &[usize] {
863        &self.out_edges[node.0 as usize]
864    }
865
866    /// Take the element payload out of a node (the runner moves each element
867    /// into its spawned arm). `None` for tee/muxer nodes or after a prior take.
868    pub fn take_element(&mut self, node: NodeId) -> Option<E> {
869        self.nodes[node.0 as usize].element.take()
870    }
871
872    /// Take this node's animated-property program (M882), which the runner
873    /// resolves against the element and hands to the arm that owns it. `None`
874    /// when nothing on the node is animated.
875    #[cfg(feature = "runtime")]
876    pub fn take_node_control(&mut self, node: NodeId) -> Option<crate::controller::ControlProgram> {
877        self.nodes[node.0 as usize].control.take()
878    }
879
880    /// Borrow a node's element payload, for building its negotiation
881    /// constraint before the runner takes it. `None` for tee/muxer nodes.
882    pub fn element(&self, node: NodeId) -> Option<&E> {
883        self.nodes[node.0 as usize].element.as_ref()
884    }
885
886    /// Mutably borrow a node's element payload, for the async source caps
887    /// probe and per-node `configure_pipeline`. `None` for tee/muxer nodes.
888    pub fn element_mut(&mut self, node: NodeId) -> Option<&mut E> {
889        self.nodes[node.0 as usize].element.as_mut()
890    }
891}
892
893impl<E> core::fmt::Debug for ValidatedGraph<E> {
894    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
895        let kinds: Vec<NodeKind> = self.nodes.iter().map(|n| n.kind).collect();
896        f.debug_struct("ValidatedGraph")
897            .field("nodes", &kinds)
898            .field("edges", &self.edges)
899            .field("topo", &self.topo)
900            .finish()
901    }
902}
903
904/// Each pad index in `0..count` must be referenced by exactly one edge.
905fn check_pads(
906    count: u8,
907    indices: impl Iterator<Item = u8>,
908    node: NodeId,
909    direction: PadDir,
910) -> Result<(), GraphError> {
911    let mut seen = vec![0u32; count as usize];
912    for idx in indices {
913        // link() range-checks pad indices, so idx is always in range here.
914        seen[idx as usize] += 1;
915    }
916    for (idx, &c) in seen.iter().enumerate() {
917        let index = idx as u8;
918        if c == 0 {
919            return Err(GraphError::UnlinkedPad {
920                node,
921                index,
922                direction,
923            });
924        }
925        if c > 1 {
926            return Err(GraphError::PadCountMismatch {
927                node,
928                index,
929                direction,
930            });
931        }
932    }
933    Ok(())
934}
935
936/// Kahn's algorithm: repeatedly remove zero-in-degree nodes. If fewer than `n`
937/// come out, the remainder is a cycle.
938fn topo_sort(
939    n: usize,
940    in_edges: &[Vec<usize>],
941    out_edges: &[Vec<usize>],
942    edges: &[Edge],
943) -> Result<Vec<NodeId>, GraphError> {
944    let mut indeg: Vec<usize> = in_edges.iter().map(|e| e.len()).collect();
945    let mut queue: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
946    let mut topo: Vec<NodeId> = Vec::with_capacity(n);
947    let mut processed = vec![false; n];
948
949    let mut head = 0;
950    while head < queue.len() {
951        let node = queue[head];
952        head += 1;
953        processed[node] = true;
954        topo.push(NodeId(node as u32));
955        for &eid in &out_edges[node] {
956            let succ = edges[eid].dst.node.0 as usize;
957            indeg[succ] -= 1;
958            if indeg[succ] == 0 {
959                queue.push(succ);
960            }
961        }
962    }
963
964    if topo.len() < n {
965        let nodes = (0..n)
966            .filter(|&i| !processed[i])
967            .map(|i| NodeId(i as u32))
968            .collect();
969        return Err(GraphError::Cycle { nodes });
970    }
971    Ok(topo)
972}
973
974#[cfg(test)]
975mod tests {
976    use super::*;
977
978    // element payload: a label, so tests read clearly.
979    type G = Graph<&'static str>;
980
981    #[test]
982    fn linear_chain_validates_in_topo_order() {
983        let mut g = G::new();
984        let src = g.add_source("src");
985        let tx = g.add_transform("tx");
986        let sink = g.add_sink("sink");
987        g.link(src, tx).unwrap();
988        g.link(tx, sink).unwrap();
989        let v = g.finish().expect("linear chain validates");
990        assert_eq!(v.topo(), &[src, tx, sink]);
991        assert_eq!(v.in_edges(src).len(), 0);
992        assert_eq!(v.out_edges(sink).len(), 0);
993    }
994
995    #[test]
996    fn fan_out_through_tee_validates() {
997        let mut g = G::new();
998        let src = g.add_source("src");
999        let tee = g.add_tee(2);
1000        let a = g.add_sink("a");
1001        let b = g.add_sink("b");
1002        g.link(src, tee.input()).unwrap();
1003        g.link(tee.out(0), a).unwrap();
1004        g.link(tee.out(1), b).unwrap();
1005        let v = g.finish().expect("fan-out validates");
1006        assert_eq!(v.out_edges(tee.node()).len(), 2);
1007        // src precedes the tee precedes both sinks.
1008        let pos = |n: NodeId| v.topo().iter().position(|&x| x == n).unwrap();
1009        assert!(pos(src) < pos(tee.node()));
1010        assert!(pos(tee.node()) < pos(a) && pos(tee.node()) < pos(b));
1011    }
1012
1013    #[test]
1014    fn fan_in_through_muxer_validates() {
1015        let mut g = G::new();
1016        let s0 = g.add_source("s0");
1017        let s1 = g.add_source("s1");
1018        let mux = g.add_muxer("mux", 2);
1019        let sink = g.add_sink("sink");
1020        g.link(s0, mux.input(0)).unwrap();
1021        g.link(s1, mux.input(1)).unwrap();
1022        g.link(mux.output(), sink).unwrap();
1023        let v = g.finish().expect("fan-in validates");
1024        assert_eq!(v.in_edges(mux.node()).len(), 2);
1025    }
1026
1027    #[test]
1028    fn tee_to_muxer_diamond_validates() {
1029        let mut g = G::new();
1030        let src = g.add_source("src");
1031        let tee = g.add_tee(2);
1032        let a = g.add_transform("a");
1033        let b = g.add_transform("b");
1034        let mux = g.add_muxer("mux", 2);
1035        let sink = g.add_sink("sink");
1036        g.link(src, tee.input()).unwrap();
1037        g.link(tee.out(0), a).unwrap();
1038        g.link(tee.out(1), b).unwrap();
1039        g.link(a, mux.input(0)).unwrap();
1040        g.link(b, mux.input(1)).unwrap();
1041        g.link(mux.output(), sink).unwrap();
1042        let v = g.finish().expect("diamond validates");
1043        assert_eq!(v.node_count(), 6);
1044        let pos = |n: NodeId| v.topo().iter().position(|&x| x == n).unwrap();
1045        assert!(pos(a) < pos(mux.node()) && pos(b) < pos(mux.node()));
1046    }
1047
1048    #[test]
1049    fn cycle_is_rejected() {
1050        // a -> b -> a: each pad linked once, but no zero-in-degree node.
1051        let mut g = G::new();
1052        let a = g.add_transform("a");
1053        let b = g.add_transform("b");
1054        g.link(a, b).unwrap();
1055        g.link(b, a).unwrap();
1056        match g.finish() {
1057            Err(GraphError::Cycle { nodes }) => {
1058                assert_eq!(nodes.len(), 2);
1059                assert!(nodes.contains(&a) && nodes.contains(&b));
1060            }
1061            other => panic!("expected Cycle, got {other:?}"),
1062        }
1063    }
1064
1065    #[test]
1066    fn unlinked_pad_is_rejected() {
1067        // tee with one output left dangling.
1068        let mut g = G::new();
1069        let src = g.add_source("src");
1070        let tee = g.add_tee(2);
1071        let a = g.add_sink("a");
1072        g.link(src, tee.input()).unwrap();
1073        g.link(tee.out(0), a).unwrap();
1074        match g.finish() {
1075            Err(GraphError::UnlinkedPad {
1076                node,
1077                index,
1078                direction,
1079            }) => {
1080                assert_eq!((node, index, direction), (tee.node(), 1, PadDir::Out));
1081            }
1082            other => panic!("expected UnlinkedPad, got {other:?}"),
1083        }
1084    }
1085
1086    #[test]
1087    fn double_linked_pad_is_rejected() {
1088        // a sink's single input pad linked from two sources.
1089        let mut g = G::new();
1090        let s0 = g.add_source("s0");
1091        let s1 = g.add_source("s1");
1092        let sink = g.add_sink("sink");
1093        g.link(s0, sink).unwrap();
1094        g.link(s1, sink).unwrap();
1095        match g.finish() {
1096            Err(GraphError::PadCountMismatch {
1097                node,
1098                index,
1099                direction,
1100            }) => {
1101                assert_eq!((node, index, direction), (sink, 0, PadDir::In));
1102            }
1103            other => panic!("expected PadCountMismatch, got {other:?}"),
1104        }
1105    }
1106
1107    #[test]
1108    fn orphan_node_is_rejected() {
1109        let mut g = G::new();
1110        let src = g.add_source("src");
1111        let sink = g.add_sink("sink");
1112        let _orphan = g.add_transform("orphan");
1113        g.link(src, sink).unwrap();
1114        assert_eq!(g.finish().err(), Some(GraphError::OrphanNode(NodeId(2))));
1115    }
1116
1117    #[test]
1118    fn zero_output_tee_is_rejected() {
1119        // a tee with no outputs would underflow senders.len()-1 in the runner.
1120        let mut g = G::new();
1121        let src = g.add_source("src");
1122        let tee = g.add_tee(0);
1123        g.link(src, tee.input()).unwrap();
1124        assert_eq!(
1125            g.finish().err(),
1126            Some(GraphError::DegenerateFanNode(tee.node()))
1127        );
1128    }
1129
1130    #[test]
1131    fn pad_index_out_of_range_is_rejected_at_link() {
1132        let mut g = G::new();
1133        let src = g.add_source("src");
1134        let tee = g.add_tee(2);
1135        let s = g.add_sink("s");
1136        g.link(src, tee.input()).unwrap();
1137        // tee(2) has output pads 0 and 1; pad 2 is out of range.
1138        assert_eq!(
1139            g.link(tee.out(2), s).err(),
1140            Some(GraphError::PadOutOfRange {
1141                node: tee.node(),
1142                index: 2,
1143                direction: PadDir::Out
1144            })
1145        );
1146    }
1147
1148    #[test]
1149    fn take_element_moves_payload_once() {
1150        let mut g = G::new();
1151        let src = g.add_source("src");
1152        let sink = g.add_sink("sink");
1153        g.link(src, sink).unwrap();
1154        let mut v = g.finish().unwrap();
1155        assert_eq!(v.take_element(src), Some("src"));
1156        assert_eq!(v.take_element(src), None, "payload taken only once");
1157    }
1158
1159    #[test]
1160    fn merge_offsets_node_ids_and_edges() {
1161        // Host already has one node; merging a 2-node linked graph must re-base
1162        // the merged ids past it and carry the interior edge across.
1163        let mut host = G::new();
1164        let h0 = host.add_source("h0");
1165        assert_eq!(h0, NodeId(0));
1166
1167        let mut inner = G::new();
1168        let i0 = inner.add_transform("i0");
1169        let i1 = inner.add_sink("i1");
1170        inner.link(i0, i1).unwrap();
1171
1172        let off = host.merge(inner);
1173        // The first inner node lands right after the host's nodes.
1174        assert_eq!(off.apply(i0), NodeId(1));
1175        assert_eq!(off.apply(i1), NodeId(2));
1176        // Link host source into the merged transform; the interior edge survived.
1177        host.link(h0, off.apply(i0)).unwrap();
1178        let v = host.finish().expect("merged graph validates");
1179        assert_eq!(v.node_count(), 3);
1180        assert_eq!(v.topo(), &[NodeId(0), NodeId(1), NodeId(2)]);
1181        // The merged transform's element payload moved across intact.
1182        assert_eq!(v.element(NodeId(1)), Some(&"i0"));
1183    }
1184
1185    #[test]
1186    fn add_bin_flattens_with_ghost_pads() {
1187        // A bin wrapping transform -> transform, exposing the first's input and
1188        // the second's output as ghost pads, flattens into source -> bin -> sink.
1189        let mut bin: Bin<&'static str> = Bin::new();
1190        let a = bin.add_transform("a");
1191        let b = bin.add_transform("b");
1192        bin.link(a, b).unwrap();
1193        bin.ghost_input(a).unwrap();
1194        bin.ghost_output(b).unwrap();
1195
1196        let mut g = G::new();
1197        let src = g.add_source("src");
1198        let sink = g.add_sink("sink");
1199        let inst = g.add_bin(bin);
1200        assert_eq!(inst.input_count(), 1);
1201        assert_eq!(inst.output_count(), 1);
1202        g.link(src, inst.input(0)).unwrap();
1203        g.link(inst.output(0), sink).unwrap();
1204
1205        let v = g.finish().expect("flattened bin validates");
1206        // The bin's two interior nodes are now first-class host nodes.
1207        assert_eq!(v.node_count(), 4);
1208        let pos = |n: NodeId| v.topo().iter().position(|&x| x == n).unwrap();
1209        assert!(pos(src) < pos(inst.input(0).node));
1210        assert!(pos(inst.output(0).node) < pos(sink));
1211    }
1212
1213    #[test]
1214    fn bin_ghosts_an_interior_tee_output() {
1215        // Ghost pads can expose a specific pad index, e.g. one branch of a tee.
1216        let mut bin: Bin<&'static str> = Bin::new();
1217        let tx = bin.add_transform("tx");
1218        let tee = bin.add_tee(2);
1219        bin.link(tx, tee.input()).unwrap();
1220        bin.ghost_input(tx).unwrap();
1221        bin.ghost_output(tee.out(0)).unwrap();
1222        bin.ghost_output(tee.out(1)).unwrap();
1223
1224        let mut g = G::new();
1225        let src = g.add_source("src");
1226        let a = g.add_sink("a");
1227        let b = g.add_sink("b");
1228        let inst = g.add_bin(bin);
1229        g.link(src, inst.input(0)).unwrap();
1230        g.link(inst.output(0), a).unwrap();
1231        g.link(inst.output(1), b).unwrap();
1232        let v = g.finish().expect("bin with a ghosted tee validates");
1233        // Both ghost outputs map onto the same interior tee node, distinct pads.
1234        assert_eq!(inst.output(0).node, inst.output(1).node);
1235        assert_ne!(inst.output(0).index, inst.output(1).index);
1236        assert_eq!(v.out_edges(inst.output(0).node).len(), 2);
1237    }
1238
1239    #[test]
1240    fn duplicate_ghost_pad_is_rejected() {
1241        let mut bin: Bin<&'static str> = Bin::new();
1242        let a = bin.add_transform("a");
1243        bin.ghost_output(a).unwrap();
1244        assert_eq!(
1245            bin.ghost_output(a),
1246            Err(GraphError::DuplicateGhostPad {
1247                node: a,
1248                index: 0,
1249                direction: PadDir::Out
1250            }),
1251            "the same interior pad cannot back two ghosts",
1252        );
1253    }
1254
1255    #[test]
1256    fn ghost_pad_out_of_range_is_rejected() {
1257        let mut bin: Bin<&'static str> = Bin::new();
1258        let a = bin.add_transform("a");
1259        // A transform has one output pad (index 0); index 1 is out of range.
1260        assert!(matches!(
1261            bin.ghost_output(PadId { node: a, index: 1 }),
1262            Err(GraphError::PadOutOfRange { .. }),
1263        ));
1264    }
1265}