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}