Skip to main content

arora_behavior/
graph.rs

1//! The shared behavior **graph model**: one serde-friendly data representation
2//! every [`BehaviorInterpreter`](super::BehaviorInterpreter) reads.
3//!
4//! A behavior — a behavior tree, a node graph — is authored as a [`Graph`]: a
5//! set of [`Node`]s, each bound to a **function** (a statically-known id the
6//! interpreter handles natively, **or** a module call routed by that same id —
7//! exactly how `arora-behavior-tree` already treats its builtins and module
8//! actions homogeneously), with typed slots ([`Io`] — inputs and outputs, told apart by
9//! which [`Node`] list holds them) and **links**
10//! ([`Link`]) wiring an output/port of one node into the input of another.
11//!
12//! This module is *only* the data model. It does not tick anything and it does
13//! not know how any particular interpreter walks the links — the behavior tree
14//! reads them as argument/child edges; a node graph reads them as dataflow. Each
15//! interpreter lowers this shared model into its own runtime form (see
16//! `arora-behavior-tree`'s `graph` module for the tree lowering).
17//!
18//! Edition happens through [`GraphDiff`]: a set of node/link additions and
19//! removals (plus predetermined-key overrides). "Loading" a behavior is just
20//! [`Graph::apply`]ing a diff onto an [`Graph::empty`] graph — which is why
21//! [`BehaviorInterpreter::apply`](super::BehaviorInterpreter::apply) is the one
22//! edition entry point.
23
24use std::collections::HashMap;
25
26use arora_types::data::Key;
27use arora_types::module::high::TypeRef;
28use arora_types::value::Value;
29use serde::{Deserialize, Serialize};
30use uuid::Uuid;
31
32/// A typed slot on a [`Node`] — one entry of [`Node::inputs`] or
33/// [`Node::outputs`]. **Which of the two lists holds it is what makes it an
34/// input or an output**; the slot itself carries no direction, so the two can
35/// never disagree.
36///
37/// `id` matches the function's parameter id (for an input) or is the node's
38/// output slot id (a return is conventionally the function id itself). `ty` is
39/// the arora **`Value` type** of the slot, taken from the frozen `Function`
40/// record's signature when known; `None` means "leave it to the interpreter to
41/// derive from the function record at build time".
42#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
43pub struct Io {
44    /// The slot id: a parameter id for an input, an output slot id for an output.
45    pub id: Uuid,
46    /// The slot's arora `Value` type, when known from the function signature.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub ty: Option<TypeRef>,
49    /// An optional **predetermined key**: the slot's default store binding (an
50    /// animation track's authored key, a sink node's path). The interpreter
51    /// binds the slot to this key unless a [`Link`] overrides it. Carried here
52    /// per proposal §3.6; the full predetermined-I/O semantics land in a later
53    /// pass, but the field travels with the model now.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub predetermined_key: Option<String>,
56}
57
58impl Io {
59    /// A bare slot with no type or predetermined key.
60    pub fn new(id: Uuid) -> Self {
61        Self {
62            id,
63            ty: None,
64            predetermined_key: None,
65        }
66    }
67}
68
69/// A reference to one slot on one node: `(node, port)`. Generalizes the behavior
70/// tree's `NodeParameterId`.
71#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
72pub struct Port {
73    /// The node the slot belongs to.
74    pub node: Uuid,
75    /// The slot id on that node (an [`Io::id`]).
76    pub port: Uuid,
77}
78
79impl Port {
80    /// A `(node, port)` reference.
81    pub fn new(node: Uuid, port: Uuid) -> Self {
82        Self { node, port }
83    }
84}
85
86/// Where the value feeding a [`Link`]'s target comes from.
87///
88/// Generalizes the behavior tree's `Expression` link kinds (a literal value, a
89/// shared blackboard variable, or another node's slot). Full expression links
90/// (arithmetic over sources) are deferred — proposal Q-D.
91#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
92#[serde(rename_all = "snake_case")]
93pub enum LinkSource {
94    /// A constant value.
95    Literal(Value),
96    /// A shared blackboard variable, by id — the interpreter resolves it to a
97    /// store slot (or a tree-local cell) at build time.
98    Variable(Uuid),
99    /// Another node's slot: the output/port the link reads from. Generalizes
100    /// `Expression::NodeArgument`.
101    Port(Port),
102    /// Read a [`Key`] sub-path of another source's value on read — a structured
103    /// output's field (by id) or an array element (by index). Selection is an
104    /// *operation over any source*, not a property of one: `Select { source:
105    /// Variable(id), .. }` selects into a variable, and nested `Select`s chain.
106    /// The path segments are resolved **ids**, not names (the type registry
107    /// maps authored field names to ids when a graph is built), so the runtime
108    /// resolves nothing. See [`Key::select`]. Heavier "compute a value"
109    /// operations (calls, arithmetic) are nodes, not link sources.
110    Select {
111        /// The source whose value is projected.
112        source: Box<LinkSource>,
113        /// The sub-path (`Key` attributes = ids) read out of that value.
114        path: Key,
115    },
116}
117
118/// The source `Port` a link ultimately reads from, through any [`LinkSource::Select`]
119/// wrappers; `None` for a literal or variable source.
120pub fn source_port(source: &LinkSource) -> Option<Port> {
121    match source {
122        LinkSource::Port(port) => Some(*port),
123        LinkSource::Select { source, .. } => source_port(source),
124        _ => None,
125    }
126}
127
128/// A directed wire feeding one node input from a [`LinkSource`].
129#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
130pub struct Link {
131    /// The input slot being fed. At most one link targets a given port.
132    pub target: Port,
133    /// What feeds it.
134    pub source: LinkSource,
135}
136
137impl Link {
138    /// A link feeding `target` from `source`.
139    pub fn new(target: Port, source: LinkSource) -> Self {
140        Self { target, source }
141    }
142}
143
144/// A node bound to a function, with its typed inputs/outputs and (optionally)
145/// ordered children.
146///
147/// One node kind, routed by `function`: an interpreter dispatches natively for
148/// the ids it knows and calls a module for the rest — the same homogeneous split
149/// the behavior tree already makes.
150#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
151pub struct Node {
152    /// This node's id.
153    pub id: Uuid,
154    /// The function bound to this node: a statically-known id **or** a module
155    /// function id. The interpreter routes on it.
156    pub function: Uuid,
157    /// Declared inputs.
158    #[serde(default)]
159    pub inputs: Vec<Io>,
160    /// Declared outputs.
161    #[serde(default)]
162    pub outputs: Vec<Io>,
163    /// Ordered children, for interpreters (like the tree) whose structure is a
164    /// child relation. `None` for a leaf.
165    #[serde(default)]
166    pub children: Option<Vec<Uuid>>,
167}
168
169/// The shared behavior graph: nodes, the links between their slots, the named
170/// blackboard variables, and (for tree-shaped interpreters) the root node.
171#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default)]
172pub struct Graph {
173    /// The entry node, for interpreters that need one (a behavior tree's root).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub root: Option<Uuid>,
176    /// All nodes, indexed by id.
177    #[serde(default)]
178    pub nodes: HashMap<Uuid, Node>,
179    /// The links wiring node slots together.
180    #[serde(default)]
181    pub links: Vec<Link>,
182    /// Named blackboard variables (`id -> name`); a [`LinkSource::Variable`]
183    /// refers to one of these, and the name is what an interpreter resolves
184    /// against the data store.
185    #[serde(default)]
186    pub variables: HashMap<Uuid, String>,
187}
188
189/// A graph edition failed to apply.
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct GraphError {
192    /// Human-readable description.
193    pub message: String,
194}
195
196impl std::fmt::Display for GraphError {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        f.write_str(&self.message)
199    }
200}
201
202impl std::error::Error for GraphError {}
203
204/// An edit to a [`Graph`]: what to add and remove.
205///
206/// Applied in a fixed order (removals of links, then nodes; additions of nodes,
207/// then links; then predetermined-key and root/variable settings) so a single
208/// diff can both delete and rebuild a region. Loading a fresh behavior is
209/// [`Graph::apply`]ing a diff whose `add_nodes`/`add_links` describe the whole
210/// graph onto an [`Graph::empty`] graph.
211#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
212pub struct GraphDiff {
213    /// Nodes to insert (replacing any node with the same id).
214    #[serde(default)]
215    pub add_nodes: Vec<Node>,
216    /// Node ids to remove. Links touching a removed node are dropped too.
217    #[serde(default)]
218    pub remove_nodes: Vec<Uuid>,
219    /// Links to insert. Adding a link whose `target` already has one replaces it
220    /// (at most one link per input).
221    #[serde(default)]
222    pub add_links: Vec<Link>,
223    /// Link targets to unwire.
224    #[serde(default)]
225    pub remove_links: Vec<Port>,
226    /// Predetermined-key overrides: set (`Some`) or clear (`None`) the
227    /// [`Io::predetermined_key`] of the slot at each [`Port`].
228    #[serde(default)]
229    pub set_predetermined: Vec<(Port, Option<String>)>,
230    /// If set, becomes the graph's [`root`](Graph::root).
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub set_root: Option<Uuid>,
233    /// Named variables to declare/rename (`id -> name`), merged in.
234    #[serde(default)]
235    pub variables: HashMap<Uuid, String>,
236}
237
238impl GraphDiff {
239    /// A diff that loads `graph` wholesale: every node and link as additions,
240    /// carrying its root and variables. `apply`ing this onto [`Graph::empty`]
241    /// reproduces `graph`.
242    pub fn load(graph: Graph) -> Self {
243        let mut add_nodes: Vec<Node> = graph.nodes.into_values().collect();
244        // Root first keeps interpreters that treat node order as significant
245        // (the behavior tree takes the first node as the root) well-defined.
246        if let Some(root) = graph.root {
247            add_nodes.sort_by_key(|n| n.id != root);
248        }
249        Self {
250            add_nodes,
251            add_links: graph.links,
252            set_root: graph.root,
253            variables: graph.variables,
254            ..Self::default()
255        }
256    }
257}
258
259impl Graph {
260    /// An empty graph — the starting point a load diff is applied onto.
261    pub fn empty() -> Self {
262        Self::default()
263    }
264
265    /// The node with id `id`, if present.
266    pub fn node(&self, id: &Uuid) -> Option<&Node> {
267        self.nodes.get(id)
268    }
269
270    /// The link feeding `target`, if any.
271    pub fn link_to(&self, target: &Port) -> Option<&Link> {
272        self.links.iter().find(|l| &l.target == target)
273    }
274
275    /// Apply `diff` in place.
276    pub fn apply(&mut self, diff: GraphDiff) -> Result<(), GraphError> {
277        // 1. Remove links, then nodes (and any links still touching them).
278        for target in &diff.remove_links {
279            self.links.retain(|l| &l.target != target);
280        }
281        for id in &diff.remove_nodes {
282            self.nodes.remove(id);
283            self.links
284                .retain(|l| &l.target.node != id && !links_source_is_node(&l.source, id));
285            if self.root == Some(*id) {
286                self.root = None;
287            }
288        }
289
290        // 2. Add nodes, then links (replacing any link on the same target).
291        for node in diff.add_nodes {
292            self.nodes.insert(node.id, node);
293        }
294        for link in diff.add_links {
295            self.links.retain(|l| l.target != link.target);
296            self.links.push(link);
297        }
298
299        // 3. Predetermined-key overrides.
300        for (port, key) in diff.set_predetermined {
301            let node = self.nodes.get_mut(&port.node).ok_or_else(|| GraphError {
302                message: format!("predetermined key targets unknown node {}", port.node),
303            })?;
304            let io = node
305                .inputs
306                .iter_mut()
307                .chain(node.outputs.iter_mut())
308                .find(|io| io.id == port.port)
309                .ok_or_else(|| GraphError {
310                    message: format!(
311                        "predetermined key targets unknown slot {} on node {}",
312                        port.port, port.node
313                    ),
314                })?;
315            io.predetermined_key = key;
316        }
317
318        // 4. Root and variables.
319        if let Some(root) = diff.set_root {
320            self.root = Some(root);
321        }
322        self.variables.extend(diff.variables);
323        Ok(())
324    }
325}
326
327/// Whether a [`LinkSource`] reads from node `id` (so the link dangles once that
328/// node is removed).
329fn links_source_is_node(source: &LinkSource, id: &Uuid) -> bool {
330    source_port(source).is_some_and(|port| &port.node == id)
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn node(id: Uuid, function: Uuid) -> Node {
338        Node {
339            id,
340            function,
341            ..Node::default()
342        }
343    }
344
345    #[test]
346    fn load_onto_empty_reproduces_the_graph() {
347        let a = Uuid::from_u128(0xA);
348        let b = Uuid::from_u128(0xB);
349        let mut graph = Graph::empty();
350        graph.nodes.insert(a, node(a, Uuid::from_u128(0xF1)));
351        graph.nodes.insert(b, node(b, Uuid::from_u128(0xF2)));
352        graph.links.push(Link::new(
353            Port::new(b, Uuid::from_u128(0x1)),
354            LinkSource::Port(Port::new(a, Uuid::from_u128(0x2))),
355        ));
356        graph.root = Some(a);
357        graph
358            .variables
359            .insert(Uuid::from_u128(0x9), "battery".into());
360
361        let mut rebuilt = Graph::empty();
362        rebuilt.apply(GraphDiff::load(graph.clone())).unwrap();
363        assert_eq!(rebuilt, graph);
364    }
365
366    #[test]
367    fn load_diff_lists_the_root_node_first() {
368        let a = Uuid::from_u128(0xA);
369        let b = Uuid::from_u128(0xB);
370        let c = Uuid::from_u128(0xC);
371        let mut graph = Graph::empty();
372        for id in [a, b, c] {
373            graph.nodes.insert(id, node(id, Uuid::from_u128(0xF0)));
374        }
375        graph.root = Some(c);
376        let diff = GraphDiff::load(graph);
377        assert_eq!(diff.add_nodes.first().unwrap().id, c);
378    }
379
380    #[test]
381    fn removing_a_node_drops_links_touching_it() {
382        let a = Uuid::from_u128(0xA);
383        let b = Uuid::from_u128(0xB);
384        let mut graph = Graph::empty();
385        graph.nodes.insert(a, node(a, Uuid::from_u128(0xF1)));
386        graph.nodes.insert(b, node(b, Uuid::from_u128(0xF2)));
387        // a's input is fed from b's output; removing b must drop the link.
388        graph.links.push(Link::new(
389            Port::new(a, Uuid::from_u128(0x1)),
390            LinkSource::Port(Port::new(b, Uuid::from_u128(0x2))),
391        ));
392
393        graph
394            .apply(GraphDiff {
395                remove_nodes: vec![b],
396                ..GraphDiff::default()
397            })
398            .unwrap();
399        assert!(!graph.nodes.contains_key(&b));
400        assert!(graph.links.is_empty(), "dangling link dropped");
401    }
402
403    #[test]
404    fn adding_a_link_replaces_the_one_on_the_same_target() {
405        let a = Uuid::from_u128(0xA);
406        let target = Port::new(a, Uuid::from_u128(0x1));
407        let mut graph = Graph::empty();
408        graph.nodes.insert(a, node(a, Uuid::from_u128(0xF1)));
409        graph
410            .apply(GraphDiff {
411                add_links: vec![Link::new(target, LinkSource::Literal(Value::U8(1)))],
412                ..GraphDiff::default()
413            })
414            .unwrap();
415        graph
416            .apply(GraphDiff {
417                add_links: vec![Link::new(target, LinkSource::Literal(Value::U8(2)))],
418                ..GraphDiff::default()
419            })
420            .unwrap();
421        assert_eq!(graph.links.len(), 1);
422        assert_eq!(
423            graph.link_to(&target).unwrap().source,
424            LinkSource::Literal(Value::U8(2))
425        );
426    }
427
428    #[test]
429    fn set_predetermined_key_overrides_the_slot() {
430        let a = Uuid::from_u128(0xA);
431        let slot = Uuid::from_u128(0x1);
432        let mut graph = Graph::empty();
433        graph.nodes.insert(
434            a,
435            Node {
436                id: a,
437                function: Uuid::from_u128(0xF1),
438                inputs: vec![Io::new(slot)],
439                ..Node::default()
440            },
441        );
442        graph
443            .apply(GraphDiff {
444                set_predetermined: vec![(Port::new(a, slot), Some("head/pitch".into()))],
445                ..GraphDiff::default()
446            })
447            .unwrap();
448        assert_eq!(
449            graph.nodes[&a].inputs[0].predetermined_key.as_deref(),
450            Some("head/pitch")
451        );
452    }
453
454    #[test]
455    fn predetermined_key_on_unknown_slot_is_an_error() {
456        let a = Uuid::from_u128(0xA);
457        let mut graph = Graph::empty();
458        graph.nodes.insert(a, node(a, Uuid::from_u128(0xF1)));
459        let err = graph.apply(GraphDiff {
460            set_predetermined: vec![(Port::new(a, Uuid::from_u128(0x2)), Some("k".into()))],
461            ..GraphDiff::default()
462        });
463        assert!(err.is_err());
464    }
465}