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