Skip to content

Commit 3e4c1be

Browse files
Merge pull request #279 from mitchmindtree/refactor/plain-graph
Represent graphs with a plain `petgraph::Graph` (not `StableGraph`)
2 parents 2032a97 + fa36230 commit 3e4c1be

6 files changed

Lines changed: 275 additions & 21 deletions

File tree

crates/gantz_core/src/node/graph.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ use serde::{Deserialize, Serialize};
2020
use std::hash::Hash;
2121

2222
/// The graph type used to represent a nested graph.
23-
pub type Graph<N> = petgraph::stable_graph::StableGraph<N, Edge, Directed, Index>;
23+
///
24+
/// A plain (non-stable) `petgraph::Graph`: node indices stay contiguous (`0..n`)
25+
/// because `remove_node` swap-removes (the former-last node adopts the removed
26+
/// index). Callers that key persistent data by node index must migrate the
27+
/// swapped node on removal - see `gantz_core::node::state::move_value`.
28+
pub type Graph<N> = petgraph::graph::Graph<N, Edge, Directed, Index>;
2429

2530
/// The type used for indexing into the graph.
2631
pub type Index = usize;

crates/gantz_core/src/node/state.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,20 @@ pub fn remove_value(vm: &mut Engine, node_path: &[usize]) -> Result<(), SteelErr
253253
Ok(())
254254
}
255255

256+
/// Move the state value at `from` to `to`, clearing `from`.
257+
///
258+
/// The moved value carries any nested subtree with it, so this rekeys a whole
259+
/// node's state in one step. A no-op if there is no value at `from`. Used to
260+
/// migrate the swapped node after a plain `petgraph::Graph` swap-remove keeps
261+
/// node indices contiguous (see [`crate::node::graph::Graph`]).
262+
pub fn move_value(vm: &mut Engine, from: &[usize], to: &[usize]) -> Result<(), SteelErr> {
263+
if let Some(val) = extract_value(vm, from)? {
264+
update_value(vm, to, val)?;
265+
remove_value(vm, from)?;
266+
}
267+
Ok(())
268+
}
269+
256270
/// Extract the value for the node with the given ID.
257271
pub fn extract_value(vm: &Engine, node_path: &[usize]) -> Result<Option<SteelVal>, SteelErr> {
258272
let SteelVal::HashMapV(root_state) = vm.extract_value(ROOT_STATE)? else {

crates/gantz_core/tests/state.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,100 @@ fn test_graph_with_counters() {
233233
assert_eq!([a, b, c], [Counter(1), Counter(2), Counter(3)]);
234234
}
235235

236+
// `move_value` rekeys a top-level node, carrying its whole nested subtree along.
237+
#[test]
238+
fn move_value_moves_nested_subtree() {
239+
let mut vm = Engine::new_base();
240+
vm.register_value(ROOT_STATE, SteelVal::empty_hashmap());
241+
242+
// Node 5 is a nested graph whose child 3 holds counter state.
243+
node::state::update(&mut vm, &[5, 3], Counter(7)).unwrap();
244+
assert_eq!(
245+
node::state::extract::<Counter>(&vm, &[5, 3]).unwrap(),
246+
Some(Counter(7)),
247+
);
248+
249+
// Move node 5 -> 2; its child state must follow under the new key.
250+
node::state::move_value(&mut vm, &[5], &[2]).unwrap();
251+
assert_eq!(
252+
node::state::extract::<Counter>(&vm, &[2, 3]).unwrap(),
253+
Some(Counter(7)),
254+
);
255+
assert!(node::state::extract_value(&vm, &[5]).unwrap().is_none());
256+
257+
// Moving from an empty slot is a no-op.
258+
node::state::move_value(&mut vm, &[9], &[1]).unwrap();
259+
assert!(node::state::extract_value(&vm, &[1]).unwrap().is_none());
260+
}
261+
262+
// A plain `petgraph::Graph` swap-removes: the former-last node adopts the removed
263+
// index. The GUI removal migration (`remove_value` for the deleted node, then
264+
// `move_value` for the swapped node) must carry the swapped stateful node's
265+
// accumulated state to its new index, so the recompiled code - which reads state
266+
// by the new index - still finds it.
267+
#[test]
268+
fn swap_remove_migrates_stateful_node_state() {
269+
use gantz_core::node::graph::Graph;
270+
271+
// 0: standalone push (stateless), 1: push_b, 2: counter_b driven by push_b.
272+
let mut g: Graph<Box<dyn DebugNode>> = Graph::default();
273+
let p_a = g.add_node(Box::new(node_push()) as Box<dyn DebugNode>);
274+
let p_b = g.add_node(Box::new(node_push()) as Box<_>);
275+
let c_b = g.add_node(Box::new(node_counter()) as Box<_>);
276+
g.add_edge(p_b, c_b, Edge::from((0, 0)));
277+
278+
let mut vm = Engine::new_base();
279+
vm.register_value(ROOT_STATE, SteelVal::empty_hashmap());
280+
281+
// (Re)compile + register + load the eval fns for the current graph shape.
282+
let load = |g: &Graph<Box<dyn DebugNode>>, vm: &mut Engine| {
283+
gantz_core::graph::register(&no_lookup, g, &[], vm);
284+
let eps = push_pull_entrypoints(&no_lookup, g);
285+
let module = gantz_core::compile::module(&no_lookup, g, &eps, &Default::default()).unwrap();
286+
for f in module {
287+
vm.run(format!("{f}")).unwrap();
288+
}
289+
};
290+
let ctx = node::MetaCtx::new(&no_lookup);
291+
let push = |g: &Graph<Box<dyn DebugNode>>, vm: &mut Engine, n: usize| {
292+
let ep = entrypoint::push(vec![n], g[node::graph::NodeIx::new(n)].n_outputs(ctx) as u8);
293+
vm.call_function_by_name_with_args(&entry_fn_name(&ep.id()), vec![])
294+
.unwrap();
295+
};
296+
297+
// Push b twice -> counter_b (index 2) == 2.
298+
load(&g, &mut vm);
299+
push(&g, &mut vm, p_b.index());
300+
push(&g, &mut vm, p_b.index());
301+
assert_eq!(
302+
node::state::extract::<Counter>(&vm, &[c_b.index()]).unwrap(),
303+
Some(Counter(2)),
304+
);
305+
306+
// Delete p_a (index 0). Swap-remove moves counter_b (last) into index 0.
307+
let last = g.node_count() - 1;
308+
assert_ne!(p_a.index(), last);
309+
node::state::remove_value(&mut vm, &[p_a.index()]).unwrap();
310+
g.remove_node(p_a);
311+
node::state::move_value(&mut vm, &[last], &[p_a.index()]).unwrap();
312+
313+
// counter_b is now at index 0, p_b stayed at index 1. Recompile + re-register.
314+
let c_b = node::graph::NodeIx::new(0);
315+
let p_b = node::graph::NodeIx::new(1);
316+
load(&g, &mut vm);
317+
318+
// The migrated state survived the move; pushing b once more -> 3.
319+
assert_eq!(
320+
node::state::extract::<Counter>(&vm, &[c_b.index()]).unwrap(),
321+
Some(Counter(2)),
322+
);
323+
push(&g, &mut vm, p_b.index());
324+
assert_eq!(
325+
node::state::extract::<Counter>(&vm, &[c_b.index()]).unwrap(),
326+
Some(Counter(3)),
327+
);
328+
}
329+
236330
// Manual regression check for #266: a stateful node must not leak memory per
237331
// evaluation. Drives one over a *single* persistent `Engine`, sampling RSS, and
238332
// asserts it stays bounded once warmed up.

crates/gantz_egui/src/ops.rs

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,7 @@ where
123123
graph[node_ix].register(reg_ctx);
124124

125125
// Position the new node under the pointer, falling back to the center of the
126-
// current view. `insert` (not `entry`) so a reused stable-graph index can't
127-
// inherit a removed node's stale position.
126+
// current view.
128127
let pos = pos.unwrap_or_else(|| view.scene_rect.center());
129128
let egui_id = egui_graph::NodeId::from_u64(node_ix.index() as u64);
130129
view.layout.insert(egui_id, pos);
@@ -179,8 +178,7 @@ where
179178
let node_ix = graph.add_node(N::from(named_ref));
180179

181180
// Position the new node under the pointer, falling back to the center of the
182-
// current view. `insert` (not `entry`) so a reused stable-graph index can't
183-
// inherit a removed node's stale position.
181+
// current view.
184182
let pos = pos.unwrap_or_else(|| view.scene_rect.center());
185183
let egui_id = egui_graph::NodeId::from_u64(node_ix.index() as u64);
186184
view.layout.insert(egui_id, pos);
@@ -194,6 +192,60 @@ where
194192
Some(node_ix)
195193
}
196194

195+
/// Remove `nodes` from `graph`, migrating the per-node state, layout and
196+
/// selection that are keyed by node index.
197+
///
198+
/// `petgraph::Graph::remove_node` swap-removes: the former-last node adopts the
199+
/// removed index, so exactly one surviving node changes index per removal.
200+
/// Targets are processed highest-index first, so a swap only ever pulls a
201+
/// surviving node down into an already-freed higher slot and never invalidates a
202+
/// pending target. The swapped node's state, layout entry and selection are then
203+
/// moved to its new index. Edge selection is cleared because removing a node
204+
/// drops its incident edges with compounded edge swaps. Returns whether any node
205+
/// was removed.
206+
///
207+
/// Run this before the next recompile (`vm::sync`): the regenerated code reads
208+
/// state by the new index, so the migration must already be in place.
209+
pub fn remove_nodes<N>(
210+
graph: &mut Graph<N>,
211+
vm: &mut Engine,
212+
layout: &mut egui_graph::Layout,
213+
selection: &mut crate::widget::graph_scene::Selection,
214+
nodes: impl IntoIterator<Item = NodeIndex>,
215+
) -> bool {
216+
let node_id = |ix: usize| egui_graph::NodeId::from_u64(ix as u64);
217+
let mut targets: Vec<NodeIndex> = nodes.into_iter().collect();
218+
targets.sort_unstable_by_key(|n| std::cmp::Reverse(n.index()));
219+
targets.dedup();
220+
let mut removed_any = false;
221+
for t in targets {
222+
if graph.node_weight(t).is_none() {
223+
continue;
224+
}
225+
let last = graph.node_count() - 1;
226+
// Drop the removed node's index-keyed data.
227+
let _ = node::state::remove_value(vm, &[t.index()]);
228+
layout.remove(&node_id(t.index()));
229+
selection.nodes.remove(&t);
230+
graph.remove_node(t);
231+
// Migrate the node that swapped into `t` (the former `last`), if any.
232+
if t.index() != last {
233+
let _ = node::state::move_value(vm, &[last], &[t.index()]);
234+
if let Some(pos) = layout.remove(&node_id(last)) {
235+
layout.insert(node_id(t.index()), pos);
236+
}
237+
if selection.nodes.remove(&NodeIndex::new(last)) {
238+
selection.nodes.insert(t);
239+
}
240+
}
241+
removed_any = true;
242+
}
243+
if removed_any {
244+
selection.edges.clear();
245+
}
246+
removed_any
247+
}
248+
197249
/// Insert an Inspect node on the given edge, splicing it between the
198250
/// endpoints and positioning it at `cmd.pos`.
199251
pub fn inspect_edge<N>(
@@ -376,3 +428,56 @@ pub fn commit_layout<G>(
376428
head,
377429
))
378430
}
431+
432+
#[cfg(test)]
433+
mod tests {
434+
use super::*;
435+
use crate::widget::graph_scene::Selection;
436+
use gantz_core::node::graph::NodeIx;
437+
438+
// Deleting a node swap-removes the former-last node into its slot; the
439+
// swapped node's layout entry and selection must follow it to the new index.
440+
#[test]
441+
fn remove_nodes_migrates_layout_and_selection() {
442+
let node_id = |i: usize| egui_graph::NodeId::from_u64(i as u64);
443+
444+
// Five nodes 0..5 (weights 10..15) each with a distinct layout x.
445+
let mut graph: Graph<u32> = Graph::default();
446+
for w in 10u32..15 {
447+
graph.add_node(w);
448+
}
449+
let mut layout = egui_graph::Layout::default();
450+
for i in 0..5 {
451+
layout.insert(node_id(i), egui::pos2(i as f32, 0.0));
452+
}
453+
let mut selection = Selection::default();
454+
selection.nodes.insert(NodeIx::new(4)); // select the (to-be-swapped) last
455+
456+
let mut vm = Engine::new_base();
457+
458+
// Delete index 1: node 4 (weight 14) swap-removes into slot 1.
459+
let removed = remove_nodes(
460+
&mut graph,
461+
&mut vm,
462+
&mut layout,
463+
&mut selection,
464+
[NodeIx::new(1)],
465+
);
466+
assert!(removed);
467+
468+
// The swapped node now sits at index 1.
469+
assert_eq!(graph.node_count(), 4);
470+
assert_eq!(graph[NodeIx::new(1)], 14);
471+
472+
// Layout followed the swap; the deleted and old-last slots are gone.
473+
assert_eq!(layout.len(), 4);
474+
assert_eq!(layout.get(&node_id(1)).copied(), Some(egui::pos2(4.0, 0.0)));
475+
assert!(!layout.contains_key(&node_id(4)));
476+
477+
// Selection followed the swap: node 4 -> node 1.
478+
assert_eq!(
479+
selection.nodes.iter().copied().collect::<Vec<_>>(),
480+
vec![NodeIx::new(1)],
481+
);
482+
}
483+
}

0 commit comments

Comments
 (0)