Skip to main content

aion_package/structure/
model.rs

1//! The workflow graph model: nodes, edges, and per-node correlation identity.
2//!
3//! A [`WorkflowGraph`] is a faithful projection of the typed Gleam source — a
4//! consumer (the dashboard canvas, RM-007) renders it and overlays a run's
5//! recorded events by matching each event onto a node's [`CorrelationKey`]. The
6//! model is never the authoritative artifact; the typed source is the single
7//! source of truth (ADR-014, CN6).
8
9use std::collections::BTreeSet;
10
11/// Stable identifier for a node within one extracted graph.
12///
13/// The id is the node's deterministic position in the order the extractor
14/// discovers nodes while walking the workflow's control flow depth-first from
15/// the entry function (success arms before error arms). It is a stable static
16/// position, not a claim about runtime execution order: under branching, which
17/// nodes a run actually executes is data-dependent. The id is opaque to
18/// consumers beyond identity and ordering.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
20pub struct NodeId(pub usize);
21
22/// The workflow primitive a node represents.
23///
24/// This is the fixed, known vocabulary of `aion/workflow` (the only surface the
25/// extractor understands), plus `Branch` for a `case` over a primitive result
26/// and `Opaque` for control flow the extractor recognises as present but cannot
27/// faithfully model. Adding a primitive to the SDK requires adding it here; an
28/// unrecognised call is never silently dropped, and control flow the walker
29/// cannot resolve is surfaced as an `Opaque` node, never flattened into a false
30/// sequential edge.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
32pub enum NodePrimitive {
33    /// `workflow.run` — a single recorded activity dispatch.
34    Run,
35    /// `workflow.all` — concurrent fan-out, all must succeed.
36    All,
37    /// `workflow.race` — concurrent fan-out, first to settle wins.
38    Race,
39    /// `workflow.map` — concurrent map over a list of activities.
40    Map,
41    /// `workflow.spawn` — start a child workflow without waiting.
42    Spawn,
43    /// `workflow.spawn_and_wait` — start a child workflow and await it.
44    SpawnAndWait,
45    /// `workflow.receive` — await a signal.
46    Receive,
47    /// `workflow.sleep` — a durable timer the workflow blocks on.
48    Sleep,
49    /// `workflow.start_timer` — arm a named durable timer.
50    StartTimer,
51    /// `workflow.cancel_timer` — cancel a previously armed timer.
52    CancelTimer,
53    /// A `case` expression branching on a workflow-primitive result: its arms
54    /// fan out to the real success/error subgraphs via labelled branch edges.
55    Branch,
56    /// Control flow the extractor detects but cannot faithfully model (a `case`
57    /// whose arms it cannot bound, a recursive helper call, an indirect
58    /// dispatch). It is surfaced as an explicit node carrying the offending
59    /// source snippet — the honest alternative to silently flattening an
60    /// unmodellable shape into a false sequence (the loud-on-unmodellable rule).
61    Opaque,
62}
63
64/// The per-node identity a consumer maps recorded events onto.
65///
66/// The event-bearing kinds mirror how AD/AT sequences recorded events:
67/// activities by their static source position plus name, signals by name,
68/// timers by id, and children by spawn ordinal plus name. `ControlFlow` carries
69/// no recorded event of its own; it positions a structural node (a branch) in
70/// the discovery order. `Opaque` carries the source snippet of control flow the
71/// extractor could not model, so a consumer can surface it for review.
72#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
73pub enum CorrelationKey {
74    /// An activity dispatch: its static source-order position and activity name.
75    ActivitySequence {
76        /// Zero-based static source-order position of this activity among all
77        /// activity dispatches the extractor discovers, walking control flow
78        /// depth-first from the entry function (success arms before error
79        /// arms). It is a stable structural index, NOT a claim about the order
80        /// replay re-applies recorded events: under branching, which activities
81        /// a given run executes — and in what order — is data-dependent. A
82        /// consumer aligns a recorded `ActivityCompleted` onto a node by the
83        /// activity name and the branch the run actually took, not by assuming
84        /// this ordinal equals the recorded sequence position.
85        ordinal: usize,
86        /// The engine-facing activity name (the `run` node's activity).
87        activity: String,
88    },
89    /// A signal receive, identified by the signal-reference expression text.
90    ///
91    /// The signal name is not a string literal at the `receive` call site (it
92    /// is a typed `SignalRef`), so the reference identifier is carried as the
93    /// stable correlation token a consumer aligns against the run's signal
94    /// reference.
95    Signal {
96        /// The signal-reference expression text passed to `receive`.
97        reference: String,
98    },
99    /// A timer, identified by its literal name as written at the call site.
100    Timer {
101        /// The timer name string literal (`start_timer`'s first argument).
102        id: String,
103    },
104    /// A child spawn: its deterministic spawn ordinal and the literal child
105    /// workflow name.
106    Child {
107        /// Zero-based position of this spawn among all child spawns, in call
108        /// order — the ordinal a recorded child event carries.
109        ordinal: usize,
110        /// The child workflow name string literal (the spawn's first argument).
111        name: String,
112    },
113    /// A control-flow node (a branch) that records no event of its own.
114    ControlFlow {
115        /// Zero-based position of this control-flow node among all control-flow
116        /// nodes, in discovery order.
117        ordinal: usize,
118    },
119    /// An unmodellable control-flow node: control flow the extractor detected
120    /// but could not faithfully classify, carrying the offending source snippet
121    /// so a consumer can render it for review. It records no event of its own.
122    Opaque {
123        /// Zero-based position of this opaque node among all opaque nodes, in
124        /// discovery order.
125        ordinal: usize,
126        /// The source snippet the extractor could not model.
127        snippet: String,
128    },
129}
130
131/// A single node in the workflow graph.
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct GraphNode {
134    /// Stable identity and call-order position of the node.
135    pub id: NodeId,
136    /// The workflow primitive the node represents.
137    pub primitive: NodePrimitive,
138    /// The per-node identity a consumer maps recorded events onto.
139    pub correlation: CorrelationKey,
140}
141
142/// Which arm of a `Branch` node a branch edge belongs to.
143///
144/// A `case` over a workflow primitive's `Result` has an `Ok(..)` arm (the
145/// success continuation) and an `Error(..)` arm (the compensation / failure
146/// continuation). A `case` over another subject the walker recurses through
147/// (a decode, a guard) labels each arm by its leading constructor, or
148/// [`ArmLabel::Wildcard`] for a `_` catch-all.
149#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
150pub enum ArmLabel {
151    /// The `Ok(..)` arm — the success continuation.
152    Ok,
153    /// The `Error(..)` arm — the failure / compensation continuation.
154    Error,
155    /// A `_` catch-all arm.
156    Wildcard,
157    /// Any other arm, labelled by its leading pattern constructor.
158    Pattern(String),
159}
160
161/// The relationship a directed edge expresses between two nodes.
162#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
163pub enum EdgeKind {
164    /// Sequential control flow: `from` precedes `to` in the workflow's order.
165    Sequence,
166    /// A branch arm: `from` is a `Branch` node, `to` is the first node reached
167    /// on the `arm` of that `case`. The label distinguishes the success arm
168    /// from the compensation arm so the extracted edge set matches the
169    /// workflow's actual control flow rather than a linear flatten.
170    Branch {
171        /// Which arm of the branch this edge follows.
172        arm: ArmLabel,
173    },
174    /// A concurrent fan-out member: `from` is an `All` / `Race` / `Map` node,
175    /// `to` is one member activity dispatched concurrently under it. The member
176    /// nodes carry their own `ActivitySequence` keys so a consumer can overlay
177    /// each member's recorded `ActivityCompleted` independently.
178    FanOut,
179}
180
181/// A directed edge between two nodes.
182#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
183pub struct GraphEdge {
184    /// Source node id.
185    pub from: NodeId,
186    /// Target node id.
187    pub to: NodeId,
188    /// The relationship the edge expresses.
189    pub kind: EdgeKind,
190}
191
192/// A workflow's primitive structure as an ordered node/edge graph.
193///
194/// A projection of the typed source: extracted, never authored. The node and
195/// edge vectors are in deterministic call order so two extractions of the same
196/// package are byte-identical.
197#[derive(Clone, Debug, PartialEq, Eq)]
198pub struct WorkflowGraph {
199    /// The logical entry-module name the graph was extracted from.
200    pub entry_module: String,
201    /// Nodes in deterministic workflow call order.
202    pub nodes: Vec<GraphNode>,
203    /// Directed edges in deterministic order.
204    pub edges: Vec<GraphEdge>,
205}
206
207impl WorkflowGraph {
208    /// The logical entry-module name the graph was extracted from.
209    #[must_use]
210    pub fn entry_module(&self) -> &str {
211        &self.entry_module
212    }
213
214    /// The graph's nodes in deterministic call order.
215    #[must_use]
216    pub fn nodes(&self) -> &[GraphNode] {
217        &self.nodes
218    }
219
220    /// The graph's edges in deterministic order.
221    #[must_use]
222    pub fn edges(&self) -> &[GraphEdge] {
223        &self.edges
224    }
225
226    /// Looks up a node by id.
227    #[must_use]
228    pub fn node(&self, id: NodeId) -> Option<&GraphNode> {
229        self.nodes.iter().find(|node| node.id == id)
230    }
231
232    /// Whether this graph and `other` describe the same structure as unordered
233    /// node and edge sets.
234    ///
235    /// This is the diff primitive the C23 test uses: it proves the extracted
236    /// node/edge set matches a workflow's known structure regardless of the
237    /// internal vector order, so a consumer can assert structural equality
238    /// without depending on emission order.
239    #[must_use]
240    pub fn structurally_equals(&self, other: &Self) -> bool {
241        if self.entry_module != other.entry_module {
242            return false;
243        }
244        let self_nodes: BTreeSet<(NodeId, NodePrimitive, CorrelationKey)> = self
245            .nodes
246            .iter()
247            .map(|node| (node.id, node.primitive, node.correlation.clone()))
248            .collect();
249        let other_nodes: BTreeSet<(NodeId, NodePrimitive, CorrelationKey)> = other
250            .nodes
251            .iter()
252            .map(|node| (node.id, node.primitive, node.correlation.clone()))
253            .collect();
254        if self_nodes != other_nodes {
255            return false;
256        }
257        let self_edges: BTreeSet<GraphEdge> = self.edges.iter().cloned().collect();
258        let other_edges: BTreeSet<GraphEdge> = other.edges.iter().cloned().collect();
259        self_edges == other_edges
260    }
261}