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