Skip to main content

radiate_gp/collections/graphs/
transaction.rs

1//! Transactional editing for `Graph<T>`.
2//!
3//! A `GraphTransaction` records reversible mutations (add/remove nodes and edges,
4//! direction changes), can mark cycles, and validates the graph on commit. If a commit
5//! fails, the graph is automatically rolled back and "replay" steps are returned so
6//! you can re-apply the same changes later (e.g., after adjustments or in a new context).
7//!
8//! Key features:
9//! - Atomic commit/rollback semantics
10//! - Cycle marking: nodes in detected cycles are marked `Direction::Backward`
11//! - Validation integration via `Valid`
12//! - Deterministic tests via `random_provider::set_seed(...)`
13//! - Repair of invalid nodes (e.g., missing connections) before final validation in `try_commit()`
14//!
15//! Typical flow:
16//! 1) Build with `push(...)`, `attach(...)`, `detach(...)`, `change_direction(...)`
17//! 2) `commit()`, `commit_with(...)`, or `try_commit()` to finalize
18//! 3) On invalid commit, use returned `replay` to re-apply later with `replay(...)`
19
20use super::{Direction, Graph, GraphNode};
21use crate::{Arity, NodeType, node::Node};
22use radiate_core::{RdRand, Valid, random_provider};
23use radiate_utils::SortedBuffer;
24use std::{fmt::Debug, ops::Deref};
25
26const SOURCE_NODE_TYPES: &[NodeType] = &[NodeType::Input, NodeType::Vertex, NodeType::Edge];
27const TARGET_NODE_TYPES: &[NodeType] = &[NodeType::Output, NodeType::Vertex, NodeType::Edge];
28const MAX_REPAIR_ATTEMPTS: usize = 10;
29
30/// A single reversible mutation applied during a transaction.
31///
32/// This is a structural log of intent (what you asked to do), used for introspection and
33/// reporting back to callers. Reversal is handled by `rollback()` which produces [ReplayStep]s.
34#[derive(Debug, Clone)]
35pub enum MutationStep {
36    AddNode(usize),
37    AddEdge(usize, usize),
38    RemoveEdge(usize, usize),
39    DirectionChange {
40        index: usize,
41        previous_direction: Direction,
42    },
43}
44
45/// A replayable step produced by `rollback()` to restore the effects that were undone.
46///
47/// Unlike [MutationStep], this is designed for re-applying operational effects on another
48/// transaction via `replay(...)`. For `AddNode`, the index is informational; re-application
49/// uses the provided [GraphNode] if present.
50#[derive(Clone)]
51pub enum ReplayStep<T> {
52    AddNode(usize, Option<GraphNode<T>>),
53    AddEdge(usize, usize),
54    RemoveEdge(usize, usize),
55    DirectionChange(usize, Direction),
56}
57
58/// Result of finalizing a transaction.
59///
60/// - `Valid(steps)`: the graph remained valid after cycle marking (and optional custom validation),
61///   and no rollback occurred.
62/// - `Invalid(steps, replay)`: validation failed; the graph was rolled back to its original state,
63///   and `replay` contains steps to re-apply the effects elsewhere or later.
64pub enum TransactionResult<T> {
65    Valid(Vec<MutationStep>),
66    Invalid(Vec<MutationStep>, Vec<ReplayStep<T>>),
67}
68
69impl<T> TransactionResult<T> {
70    pub fn is_valid(&self) -> bool {
71        matches!(self, TransactionResult::Valid(_))
72    }
73
74    pub fn is_invalid(&self) -> bool {
75        matches!(self, TransactionResult::Invalid(_, _))
76    }
77
78    pub fn replay(&self, graph: &mut Graph<T>)
79    where
80        T: Clone,
81    {
82        if let TransactionResult::Invalid(_, replay_steps) = self {
83            let mut transaction = GraphTransaction::new(graph);
84            transaction.replay(replay_steps.clone());
85        }
86    }
87}
88
89/// A declarative plan for inserting a node between two nodes.
90///
91/// Consumers must execute these steps themselves (e.g., with `attach`/`detach`) before committing.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum InsertStep {
94    Detach(usize, usize),
95    Connect(usize, usize),
96    Invalid,
97}
98
99/// Tracks reversible changes to a `Graph<T>` and provides commit/rollback.
100///
101/// Usage:
102/// - Mutate via `push(...)`, `attach(...)`, `detach(...)`, `change_direction(...)`.
103/// - Call `commit()`, `commit_with(...)`, or `try_commit()` to finalize.
104/// - On invalid commit, the graph is rolled back and you receive `replay` steps you can pass to
105///   `replay(...)` in a fresh transaction.
106pub struct GraphTransaction<'a, T> {
107    graph: &'a mut Graph<T>,
108    steps: Vec<MutationStep>,
109    effects: SortedBuffer<usize>,
110}
111
112impl<'a, T> GraphTransaction<'a, T> {
113    pub fn new(graph: &'a mut Graph<T>) -> Self {
114        GraphTransaction {
115            graph,
116            steps: Vec::with_capacity(5),
117            effects: SortedBuffer::new(),
118        }
119    }
120
121    /// Finalize the transaction:
122    /// - Marks cycles (`set_cycles()`).
123    /// - Validates with `Graph<T>: Valid`.
124    /// - If valid, returns `Valid(steps)`.
125    /// - If invalid, rolls back and returns `Invalid(steps, replay)`.
126    pub fn commit(self) -> TransactionResult<T> {
127        self.commit_internal::<fn(&Graph<T>) -> bool>(None)
128    }
129
130    /// Like `commit()`, but also requires `validator(&Graph<T>)` to pass.
131    ///
132    /// This is useful for domain-specific acceptance checks in addition to structural validity.
133    pub fn commit_with(self, validator: impl Fn(&Graph<T>) -> bool) -> TransactionResult<T> {
134        self.commit_internal(Some(validator))
135    }
136
137    /// Attempt to commit the transaction, repairing invalid nodes if possible.
138    /// - Calls `repair_invalid_nodes()` up to `MAX_REPAIR_ATTEMPTS` times.
139    ///
140    /// Note: This may produce different graphs on each call due to possible random repairs.
141    pub fn try_commit(mut self) -> TransactionResult<T> {
142        let mut repaired = false;
143        let mut attempts = 0;
144
145        self.set_cycles();
146        while repaired == false && attempts < MAX_REPAIR_ATTEMPTS {
147            repaired = self.repair_invalid_nodes();
148            if repaired {
149                self.set_cycles();
150            }
151
152            attempts += 1;
153        }
154
155        self.commit()
156    }
157
158    /// Append a node to the graph and record the change. Returns the new node's index.
159    pub fn push(&mut self, node: impl Into<GraphNode<T>>) -> usize {
160        let index = self.graph.len();
161        self.steps.push(MutationStep::AddNode(index));
162        self.graph.push(node);
163        SortedBuffer::insert_sorted_unique(&mut self.effects, index);
164        index
165    }
166
167    /// Create an edge from `from` to `to` and record the change.
168    pub fn attach(&mut self, from: usize, to: usize) {
169        self.steps.push(MutationStep::AddEdge(from, to));
170        self.graph.attach(from, to);
171        SortedBuffer::insert_sorted_unique(&mut self.effects, from);
172        SortedBuffer::insert_sorted_unique(&mut self.effects, to);
173    }
174
175    /// Remove an edge from `from` to `to` and record the change.
176    pub fn detach(&mut self, from: usize, to: usize) {
177        self.steps.push(MutationStep::RemoveEdge(from, to));
178        self.graph.detach(from, to);
179        SortedBuffer::insert_sorted_unique(&mut self.effects, from);
180        SortedBuffer::insert_sorted_unique(&mut self.effects, to);
181    }
182
183    /// Change the direction of the node at `index` if it differs from its current direction.
184    ///
185    /// Records the previous direction so the change can be rolled back or replayed.
186    pub fn change_direction(&mut self, index: usize, direction: Direction) {
187        if let Some(node) = self.graph.get_mut(index) {
188            if node.direction() == direction {
189                return;
190            }
191
192            self.steps.push(MutationStep::DirectionChange {
193                index,
194                previous_direction: node.direction(),
195            });
196            node.set_direction(direction);
197        }
198    }
199
200    /// Undo all recorded changes in reverse order, mutating the graph back to its original state.
201    ///
202    /// Returns a sequence of `ReplayStep`s that can be fed to `replay(...)` on a new transaction
203    /// to re-apply the same operational effects.
204    pub fn rollback(self) -> Vec<ReplayStep<T>> {
205        let mut replay_steps = Vec::new();
206        for step in self.steps.into_iter().rev() {
207            match step {
208                MutationStep::AddNode(_) => {
209                    let added_node = self.graph.pop();
210                    replay_steps.push(ReplayStep::AddNode(self.graph.len(), added_node));
211                }
212                MutationStep::AddEdge(from, to) => {
213                    self.graph.detach(from, to);
214                    replay_steps.push(ReplayStep::AddEdge(from, to));
215                }
216                MutationStep::RemoveEdge(from, to) => {
217                    self.graph.attach(from, to);
218                    replay_steps.push(ReplayStep::RemoveEdge(from, to));
219                }
220                MutationStep::DirectionChange {
221                    index,
222                    previous_direction,
223                    ..
224                } => {
225                    if let Some(node) = self.graph.get_mut(index) {
226                        let prev_dir = node.direction();
227                        node.set_direction(previous_direction);
228                        replay_steps.push(ReplayStep::DirectionChange(index, prev_dir));
229                    }
230                }
231            }
232        }
233
234        replay_steps.reverse();
235        replay_steps
236    }
237
238    /// Apply `ReplayStep`s (typically from a prior `rollback()`) to this transaction/graph.
239    ///
240    /// Steps are recorded as normal mutations (so they can be committed or rolled back again).
241    pub fn replay(&mut self, steps: Vec<ReplayStep<T>>) {
242        for step in steps {
243            match step {
244                ReplayStep::AddNode(_, node) => {
245                    if let Some(node) = node {
246                        self.push(node);
247                    }
248                }
249                ReplayStep::AddEdge(from, to) => {
250                    self.attach(from, to);
251                }
252                ReplayStep::RemoveEdge(from, to) => {
253                    self.detach(from, to);
254                }
255                ReplayStep::DirectionChange(index, direction) => {
256                    self.change_direction(index, direction);
257                }
258            }
259        }
260    }
261
262    /// Mark cycle participation for nodes touched in this transaction:
263    /// - Nodes without cycles are set to `Direction::Forward`.
264    /// - Nodes in cycles (via `get_cycles(idx)`) are set to `Direction::Backward`.
265    pub fn set_cycles(&mut self) {
266        let effects = self.effects.clone();
267
268        for &idx in effects.iter() {
269            let node_cycles = self.graph.get_cycles(idx);
270
271            if node_cycles.is_empty() {
272                self.change_direction(idx, Direction::Forward);
273            } else {
274                for cycle_idx in node_cycles {
275                    self.change_direction(cycle_idx, Direction::Backward);
276                }
277            }
278        }
279    }
280
281    /// Compute the steps needed to insert `new_node_idx` between `source_idx` and `target_idx`.
282    ///
283    /// Behavior:
284    /// - If `new_node` has `Arity::Zero` and `target` is not locked, connect `new_node -> target`.
285    /// - If `source` is an `Edge`, re-route its single outgoing through `new_node`.
286    /// - If `target` is an `Edge` or is locked, detach one incoming and rewire via `new_node`.
287    /// - Otherwise connect `source -> new_node -> target`.
288    #[inline]
289    pub fn get_insertion_steps(
290        &self,
291        source_idx: usize,
292        target_idx: usize,
293        new_node_idx: usize,
294        rand: &mut RdRand,
295    ) -> Vec<InsertStep> {
296        let target_node = self.graph.get(target_idx).unwrap();
297        let source_node = self.graph.get(source_idx).unwrap();
298        let new_node = self.graph.get(new_node_idx).unwrap();
299
300        let mut steps = Vec::with_capacity(4);
301
302        let source_is_edge = source_node.node_type() == NodeType::Edge;
303        let target_is_edge = target_node.node_type() == NodeType::Edge;
304        let new_node_arity = new_node.arity();
305
306        if new_node_arity == Arity::Zero && !target_node.is_locked() {
307            steps.push(InsertStep::Connect(new_node_idx, target_idx));
308            return steps;
309        }
310
311        if source_is_edge {
312            let source_outgoing = *rand.choose(source_node.outgoing());
313
314            if source_outgoing == new_node_idx {
315                steps.push(InsertStep::Connect(source_idx, new_node_idx));
316            } else {
317                steps.push(InsertStep::Connect(source_idx, new_node_idx));
318                steps.push(InsertStep::Connect(new_node_idx, source_outgoing));
319                steps.push(InsertStep::Detach(source_idx, source_outgoing));
320            }
321        } else if target_is_edge || target_node.is_locked() {
322            let target_incoming = *rand.choose(target_node.incoming());
323
324            if target_incoming == new_node_idx {
325                steps.push(InsertStep::Connect(target_incoming, new_node_idx));
326            } else {
327                steps.push(InsertStep::Connect(target_incoming, new_node_idx));
328                steps.push(InsertStep::Connect(new_node_idx, target_idx));
329                steps.push(InsertStep::Detach(target_incoming, target_idx));
330            }
331        } else {
332            steps.push(InsertStep::Connect(source_idx, new_node_idx));
333            steps.push(InsertStep::Connect(new_node_idx, target_idx));
334        }
335
336        steps
337    }
338
339    /// The below functions are used to get random nodes from the graph. These are useful for
340    /// creating connections between nodes. Neither of these functions will return an edge node.
341    /// This is because edge nodes are not valid source or target nodes for connections as they
342    /// only allow one incoming and one outgoing connection, thus they can't be used to create
343    /// new connections. Instead, edge nodes are used to represent the weights of the connections
344    ///
345    /// Get a random node that can be used as a source node for a connection.
346    /// A source node can be either an input or a vertex node.
347    #[inline]
348    pub fn random_source_node(&self, rand: &mut RdRand) -> Option<&GraphNode<T>> {
349        self.random_node_of_type(SOURCE_NODE_TYPES, rand)
350    }
351
352    /// Get a random node that can be used as a target node for a connection.
353    /// A target node can be either an output or a vertex node.
354    #[inline]
355    pub fn random_target_node(&self, rand: &mut RdRand) -> Option<&GraphNode<T>> {
356        self.random_node_of_type(TARGET_NODE_TYPES, rand)
357    }
358
359    /// Get a random target node that satisfies the provided filter function.
360    /// This is essentially a filtered version of the above function `random_target_node`.
361    #[inline]
362    pub fn random_target_node_where<F>(&self, rand: &mut RdRand, filter: F) -> Option<&GraphNode<T>>
363    where
364        F: Fn(&GraphNode<T>) -> bool,
365    {
366        let candidates = self
367            .iter()
368            .filter(|node| TARGET_NODE_TYPES.contains(&node.node_type()) && filter(node))
369            .collect::<Vec<&GraphNode<T>>>();
370
371        if candidates.is_empty() {
372            return None;
373        }
374
375        Some(*rand.choose(&candidates))
376    }
377
378    /// Get a random source node that satisfies the provided filter function.
379    /// This is essentially a filtered version of the above function `random_source_node`.
380    #[inline]
381    fn random_source_node_where<F>(&self, rand: &mut RdRand, filter: F) -> Option<&GraphNode<T>>
382    where
383        F: Fn(&GraphNode<T>) -> bool,
384    {
385        let candidates = self
386            .iter()
387            .filter(|node| SOURCE_NODE_TYPES.contains(&node.node_type()) && filter(node))
388            .collect::<Vec<&GraphNode<T>>>();
389
390        if candidates.is_empty() {
391            return None;
392        }
393
394        Some(*rand.choose(&candidates))
395    }
396
397    fn repair_invalid_nodes(&mut self) -> bool {
398        if self.is_valid() {
399            return false;
400        }
401
402        let mut repaired = false;
403
404        let invalid_nodes = self
405            .iter()
406            .filter(|node| !node.is_valid())
407            .map(|n| n.index())
408            .collect::<Vec<usize>>();
409
410        for idx in invalid_nodes.iter() {
411            let arity = self.graph[*idx].arity();
412            match arity {
413                Arity::Zero => {
414                    if self.repair_zero_arity_node(*idx) {
415                        repaired = true;
416                    }
417                }
418                Arity::Exact(_) => {
419                    if self.repair_exact_arity_node(*idx) {
420                        repaired = true;
421                    }
422                }
423                _ => {}
424            }
425        }
426
427        repaired
428    }
429
430    fn repair_zero_arity_node(&mut self, node_idx: usize) -> bool {
431        let node = self.graph.get(node_idx).unwrap();
432        if node.arity() != Arity::Zero {
433            return false;
434        }
435
436        if node.outgoing().is_empty() {
437            let random_target = random_provider::with_rng(|rand| {
438                self.random_target_node_where(rand, |n| !n.is_locked() && n.index() != node_idx)
439                    .map(|n| n.index())
440            });
441
442            if let Some(target) = random_target {
443                self.attach(node.index(), target);
444
445                if self.graph[node_idx].outgoing().len() > 0 {
446                    return true;
447                }
448            }
449        }
450
451        false
452    }
453
454    fn repair_exact_arity_node(&mut self, node_idx: usize) -> bool {
455        let arity = self.graph[node_idx].arity();
456        if let Arity::Exact(n) = arity {
457            let current_incoming = self.graph[node_idx].incoming().len();
458            if current_incoming < n {
459                let needed = n - current_incoming;
460
461                let available_sources = random_provider::with_rng(|rand| {
462                    (0..needed)
463                        .filter_map(|_| {
464                            self.random_source_node_where(rand, |n| {
465                                !n.is_locked() && n.index() != node_idx
466                            })
467                            .map(|n| n.index())
468                        })
469                        .collect::<Vec<usize>>()
470                });
471
472                for src in available_sources {
473                    self.attach(src, node_idx);
474                }
475
476                if self.graph[node_idx].incoming().len() == n {
477                    return true;
478                }
479            } else if current_incoming > n {
480                let to_detach = current_incoming - n;
481
482                let valid_incoming = self.graph[node_idx]
483                    .incoming()
484                    .iter()
485                    .cloned()
486                    .filter(|incoming| self.graph[*incoming].outgoing().len() > 1)
487                    .collect::<Vec<usize>>();
488
489                let rand_indices = random_provider::shuffled_indices(0..valid_incoming.len());
490                let rand_indices = &rand_indices[0..to_detach];
491
492                for &i in rand_indices.iter() {
493                    let source_idx = valid_incoming[i];
494                    self.detach(source_idx, node_idx);
495                }
496
497                if self.graph[node_idx].incoming().len() == n {
498                    return true;
499                }
500            }
501        }
502
503        false
504    }
505
506    /// Helper functions to get a random node of the specified type. If no nodes of the specified
507    /// type are found, the function will try to get a random node of a different type.
508    /// If no nodes are found, the function will panic.
509    #[inline]
510    fn random_node_of_type(
511        &self,
512        node_types: &[NodeType],
513        rand: &mut RdRand,
514    ) -> Option<&GraphNode<T>> {
515        if node_types.is_empty() {
516            return None;
517        }
518
519        let gene_node_type = rand.choose(&node_types);
520
521        let genes = match gene_node_type {
522            NodeType::Input => self
523                .iter()
524                .filter(|node| node.node_type() == NodeType::Input)
525                .collect::<Vec<&GraphNode<T>>>(),
526            NodeType::Output => self
527                .iter()
528                .filter(|node| node.node_type() == NodeType::Output)
529                .collect::<Vec<&GraphNode<T>>>(),
530            NodeType::Vertex => self
531                .iter()
532                .filter(|node| node.node_type() == NodeType::Vertex)
533                .collect::<Vec<&GraphNode<T>>>(),
534            NodeType::Edge => self
535                .iter()
536                .filter(|node| node.node_type() == NodeType::Edge)
537                .collect::<Vec<&GraphNode<T>>>(),
538            _ => vec![],
539        };
540
541        if genes.is_empty() {
542            return self.random_node_of_type(
543                node_types
544                    .iter()
545                    .filter(|nt| *nt != gene_node_type)
546                    .cloned()
547                    .collect::<Vec<NodeType>>()
548                    .as_slice(),
549                rand,
550            );
551        }
552
553        Some(*rand.choose(&genes))
554    }
555
556    fn commit_internal<F: Fn(&Graph<T>) -> bool>(
557        mut self,
558        validator: Option<F>,
559    ) -> TransactionResult<T> {
560        self.set_cycles();
561        let result_steps = self.steps.iter().map(|step| (*step).clone()).collect();
562
563        if let Some(validator) = validator {
564            return if validator(self.graph) && self.is_valid() {
565                TransactionResult::Valid(result_steps)
566            } else {
567                let replay_steps = self.rollback();
568                TransactionResult::Invalid(result_steps, replay_steps)
569            };
570        }
571
572        if self.is_valid() {
573            TransactionResult::Valid(result_steps)
574        } else {
575            let replay_steps = self.rollback();
576            TransactionResult::Invalid(result_steps, replay_steps)
577        }
578    }
579}
580
581impl<T> Deref for GraphTransaction<'_, T> {
582    type Target = Graph<T>;
583
584    fn deref(&self) -> &Self::Target {
585        self.graph
586    }
587}
588
589#[cfg(test)]
590mod tests {
591    use super::{GraphTransaction, InsertStep, MutationStep, TransactionResult};
592    use crate::collections::graphs::{Direction, Graph, GraphNode};
593    use crate::{Arity, Node, NodeType};
594    use radiate_core::{Valid, random_provider};
595
596    fn assert_has_direction_change(steps: &[MutationStep], idxs: &[usize]) {
597        let mut seen = vec![];
598        for s in steps {
599            if let MutationStep::DirectionChange { index, .. } = s {
600                seen.push(*index);
601            }
602        }
603        for idx in idxs {
604            assert!(
605                seen.contains(idx),
606                "Expected DirectionChange for node {} not found in steps: {:?}",
607                idx,
608                steps
609            );
610        }
611    }
612
613    #[test]
614    fn commit_valid_add_and_attach() {
615        let mut g = Graph::<i32>::default();
616        let mut tx = GraphTransaction::new(&mut g);
617
618        let i = tx.push((0, NodeType::Input, 0));
619        let o = tx.push((1, NodeType::Output, 1));
620        tx.attach(i, o);
621
622        match tx.commit() {
623            TransactionResult::Valid(steps) => {
624                assert_eq!(steps.len(), 3);
625                assert!(matches!(steps[0], MutationStep::AddNode(0)));
626                assert!(matches!(steps[1], MutationStep::AddNode(1)));
627                assert!(matches!(steps[2], MutationStep::AddEdge(0, 1)));
628                assert!(g.is_valid());
629                assert_eq!(g[0].outgoing().len(), 1);
630                assert_eq!(g[1].incoming().len(), 1);
631                assert_eq!(g[0].direction(), Direction::Forward);
632                assert_eq!(g[1].direction(), Direction::Forward);
633            }
634            _ => panic!("expected Valid"),
635        }
636    }
637
638    #[test]
639    fn commit_invalid_rolls_back_and_replay_restores() {
640        let mut g = Graph::<i32>::default();
641
642        // Build: Input -> Vertex(arity=2) -> Output (invalid: vertex missing one incoming)
643        let mut tx = GraphTransaction::new(&mut g);
644        let input = tx.push((0, NodeType::Input, 0));
645        let vertex = tx.push((1, NodeType::Vertex, 1, Arity::Exact(2)));
646        let output = tx.push((2, NodeType::Output, 2));
647
648        tx.attach(input, vertex);
649        tx.attach(vertex, output);
650
651        let (steps, replay) = match tx.commit() {
652            TransactionResult::Invalid(steps, replay) => (steps, replay),
653            _ => panic!("expected Invalid"),
654        };
655
656        // Graph must be rolled back to original state (empty)
657        assert_eq!(g.len(), 0, "graph should be rolled back to empty");
658        assert!(g.is_valid());
659
660        // Reapply the changes using replay steps
661        let mut tx2 = GraphTransaction::new(&mut g);
662        tx2.replay(replay);
663
664        assert_eq!(g.len(), 3);
665        assert_eq!(g[0].node_type(), NodeType::Input);
666        assert_eq!(g[1].node_type(), NodeType::Vertex);
667        assert_eq!(g[2].node_type(), NodeType::Output);
668        assert!(g[0].outgoing().contains(&1));
669        assert!(g[1].incoming().contains(&0));
670        assert!(g[1].outgoing().contains(&2));
671        assert!(g[2].incoming().contains(&1));
672
673        // Sanity: original mutation steps captured structure we tried
674        assert!(steps.iter().any(|s| matches!(s, MutationStep::AddNode(0))));
675        assert!(steps.iter().any(|s| matches!(s, MutationStep::AddNode(1))));
676        assert!(steps.iter().any(|s| matches!(s, MutationStep::AddNode(2))));
677        assert!(
678            steps
679                .iter()
680                .any(|s| matches!(s, MutationStep::AddEdge(0, 1)))
681        );
682        assert!(
683            steps
684                .iter()
685                .any(|s| matches!(s, MutationStep::AddEdge(1, 2)))
686        );
687    }
688
689    #[test]
690    fn commit_sets_cycles_and_marks_backward() {
691        let mut g = Graph::<i32>::default();
692        let mut tx = GraphTransaction::new(&mut g);
693
694        let a = tx.push((0, NodeType::Vertex, 10));
695        let b = tx.push((1, NodeType::Vertex, 20));
696        tx.attach(a, b);
697        tx.attach(b, a); // creates cycle {0,1}
698
699        match tx.commit() {
700            TransactionResult::Valid(steps) => {
701                assert!(g.is_valid());
702                // Both nodes in the cycle should be marked Backward
703                assert_eq!(g[0].direction(), Direction::Backward);
704                assert_eq!(g[1].direction(), Direction::Backward);
705                // And the mutation steps should include direction changes
706                assert_has_direction_change(&steps, &[0, 1]);
707            }
708            _ => panic!("expected Valid"),
709        }
710    }
711
712    #[test]
713    fn insertion_steps_new_zero_arity_connects_to_target_when_unlocked() {
714        let mut g = Graph::<i32>::default();
715        let mut tx = GraphTransaction::new(&mut g);
716
717        let src = tx.push((0, NodeType::Input, 0));
718        let tgt = tx.push((1, NodeType::Vertex, 1)); // Arity::Any => not locked
719        let newn = tx.push((2, NodeType::Input, 2)); // Arity::Zero
720
721        let steps = random_provider::with_rng(|r| tx.get_insertion_steps(src, tgt, newn, r));
722        assert_eq!(steps, vec![InsertStep::Connect(newn, tgt)]);
723    }
724
725    #[test]
726    fn insertion_steps_source_is_edge_with_single_outgoing_equal_new() {
727        let mut g = Graph::<i32>::default();
728        let mut tx = GraphTransaction::new(&mut g);
729
730        // source edge with outgoing already pointing to new node
731        let source = tx
732            .push(GraphNode::with_arity(0, NodeType::Edge, 0, Arity::Exact(1)).with_outgoing([2]));
733        let target = tx.push((1, NodeType::Vertex, 1));
734        let newn = tx.push((2, NodeType::Vertex, 2));
735
736        let steps = random_provider::with_rng(|r| tx.get_insertion_steps(source, target, newn, r));
737        assert_eq!(steps, vec![InsertStep::Connect(source, newn)]);
738    }
739
740    #[test]
741    fn insertion_steps_source_is_edge_redirects_through_new() {
742        let mut g = Graph::<i32>::default();
743        let mut tx = GraphTransaction::new(&mut g);
744
745        // source edge with single outgoing to target (not new)
746        let source = tx
747            .push(GraphNode::with_arity(0, NodeType::Edge, 0, Arity::Exact(1)).with_outgoing([1]));
748        let target = tx.push((1, NodeType::Vertex, 1));
749        let newn = tx.push((2, NodeType::Vertex, 2));
750
751        let steps = random_provider::with_rng(|r| tx.get_insertion_steps(source, target, newn, r));
752        assert_eq!(
753            steps,
754            vec![
755                InsertStep::Connect(source, newn),
756                InsertStep::Connect(newn, target),
757                InsertStep::Detach(source, target),
758            ]
759        );
760    }
761
762    #[test]
763    fn insertion_steps_target_locked_prefers_detach_rewire() {
764        let mut g = Graph::<i32>::default();
765        let mut tx = GraphTransaction::new(&mut g);
766
767        // target is "locked": Arity::Exact(1) with exactly one incoming; ensure not an Edge type
768        // by keeping outgoing empty.
769        let source = tx.push((0, NodeType::Vertex, 0));
770        let target = tx.push(
771            GraphNode::with_arity(1, NodeType::Vertex, 1, Arity::Exact(1)).with_incoming([0]),
772        );
773        let newn = tx.push((2, NodeType::Vertex, 2));
774
775        let steps = random_provider::with_rng(|r| tx.get_insertion_steps(source, target, newn, r));
776        assert_eq!(
777            steps,
778            vec![
779                InsertStep::Connect(source, newn),
780                InsertStep::Connect(newn, target),
781                InsertStep::Detach(source, target),
782            ]
783        );
784    }
785
786    #[test]
787    fn random_node_helpers_can_return_edges_when_only_edges_exist() {
788        random_provider::set_seed(1337);
789        random_provider::with_rng(|rand| {
790            let mut g = Graph::<i32>::default();
791            let mut tx = GraphTransaction::new(&mut g);
792
793            // Only edge nodes exist; helpers should still return something (and it will be an Edge).
794            tx.push((0, NodeType::Edge, 0, Arity::Exact(1)));
795            tx.push((1, NodeType::Edge, 1, Arity::Exact(1)));
796
797            let src = tx.random_source_node(rand).unwrap();
798            let tgt = tx.random_target_node(rand).unwrap();
799
800            assert_eq!(src.node_type(), NodeType::Edge);
801            assert_eq!(tgt.node_type(), NodeType::Edge);
802        });
803    }
804}