Skip to main content

ocas_atom/tensor/
graph.rs

1//! McKay refinement-individualisation graph canonical labelling engine.
2//!
3//! ## Algorithm
4//!
5//! This is an independent implementation of the nauty-family algorithm:
6//!
7//! 1. **Initial colouring** — vertices grouped by their `data` value.
8//! 2. **1-WL refinement** — neighbour signatures (edge-data + direction)
9//!    split cells iteratively until the partition is equitable.
10//! 3. **Individualisation-refinement search** — pick a vertex from the
11//!    smallest non-trivial cell, individualise, refine, recurse (DFS).
12//! 4. **Pruning** — path invariants (cell-length sequences) and
13//!    automorphism orbits eliminate isomorphic branches.
14//! 5. **Canonical form** — the lexicographically *largest* certificate
15//!    among all discrete labelings encountered during the search.
16//!
17//! ## References
18//!
19//! - McKay, "Practical Graph Isomorphism" (1981/2014)
20//! - Symbolica `graphica` crate (MIT, algorithm reference only — no code copied)
21
22use std::collections::HashMap;
23use std::fmt::Debug;
24use std::hash::Hash;
25
26// ---------------------------------------------------------------------------
27// Public types
28// ---------------------------------------------------------------------------
29
30/// An undirected or directed edge between two vertices.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
32struct Edge<E: Clone + Debug + Eq + Hash + Ord> {
33    from: usize,
34    to: usize,
35    directed: bool,
36    data: E,
37}
38
39/// A graph suitable for canonical labelling.
40///
41/// `N` — vertex data (participates in comparison, determines initial colour).
42/// `H` — vertex hidden data (does **not** affect the canonical form; used to
43///       store e.g. the original slot position of a tensor).
44/// `E` — edge data (participates in comparison).
45#[derive(Debug, Clone)]
46pub struct Graph<N, H, E>
47where
48    N: Clone + Debug + Eq + Hash + Ord,
49    H: Clone + Debug,
50    E: Clone + Debug + Eq + Hash + Ord,
51{
52    nodes: Vec<(N, H)>,
53    edges: Vec<Edge<E>>,
54    /// adj_out\[v\] = list of `(edge_index, neighbour_index)` for every edge
55    /// where `v` is an endpoint (outgoing directed *or* either endpoint of an
56    /// undirected edge).
57    adj_out: Vec<Vec<(usize, usize)>>,
58    /// adj_in\[v\] = list of `(edge_index, neighbour_index)` for directed
59    /// edges whose target is `v`.
60    adj_in: Vec<Vec<(usize, usize)>>,
61}
62
63/// Result of canonisation.
64#[derive(Debug, Clone)]
65pub struct CanonicalForm<N, H, E>
66where
67    N: Clone + Debug + Eq + Hash + Ord,
68    H: Clone + Debug,
69    E: Clone + Debug + Eq + Hash + Ord,
70{
71    /// Maps *original* vertex index → *canonical* vertex index.
72    pub vertex_map: Vec<usize>,
73    /// Orbits of the automorphism group (each inner vec lists vertices in the
74    /// same orbit).
75    pub orbits: Vec<Vec<usize>>,
76    /// Size of the automorphism group (product of automorphism counts, capped
77    /// at `u64::MAX`).
78    pub automorphism_group_size: u64,
79    /// A copy of the graph relabelled to the canonical form.
80    pub graph: Graph<N, H, E>,
81}
82
83// ---------------------------------------------------------------------------
84// Construction
85// ---------------------------------------------------------------------------
86
87impl<N, H, E> Graph<N, H, E>
88where
89    N: Clone + Debug + Eq + Hash + Ord,
90    H: Clone + Debug,
91    E: Clone + Debug + Eq + Hash + Ord,
92{
93    /// Create an empty graph.
94    pub fn new() -> Self {
95        Self {
96            nodes: Vec::new(),
97            edges: Vec::new(),
98            adj_out: Vec::new(),
99            adj_in: Vec::new(),
100        }
101    }
102
103    /// Add a vertex with the given data and hidden payload.
104    /// Returns the new vertex index.
105    pub fn add_node(&mut self, data: N, hidden: H) -> usize {
106        let idx = self.nodes.len();
107        self.nodes.push((data, hidden));
108        self.adj_out.push(Vec::new());
109        self.adj_in.push(Vec::new());
110        idx
111    }
112
113    /// Number of vertices.
114    pub fn node_count(&self) -> usize {
115        self.nodes.len()
116    }
117
118    /// Vertex data.
119    pub fn node_data(&self, v: usize) -> &N {
120        &self.nodes[v].0
121    }
122
123    /// Vertex hidden payload.
124    pub fn node_hidden(&self, v: usize) -> &H {
125        &self.nodes[v].1
126    }
127
128    /// Add a **directed** edge `from → to` with the given data.
129    pub fn add_directed_edge(&mut self, from: usize, to: usize, data: E) {
130        let idx = self.edges.len();
131        self.edges.push(Edge {
132            from,
133            to,
134            directed: true,
135            data,
136        });
137        self.adj_out[from].push((idx, to));
138        self.adj_in[to].push((idx, from));
139    }
140
141    /// Add an **undirected** edge between `u` and `v` with the given data.
142    pub fn add_undirected_edge(&mut self, u: usize, v: usize, data: E) {
143        let idx = self.edges.len();
144        self.edges.push(Edge {
145            from: u,
146            to: v,
147            directed: false,
148            data,
149        });
150        self.adj_out[u].push((idx, v));
151        self.adj_out[v].push((idx, u));
152    }
153
154    /// Iterate edges incident to vertex `v`.
155    /// Each yielded item is `(edge_index, neighbour_vertex, edge_data, is_directed, is_outgoing)`.
156    pub fn edges_of(&self, v: usize) -> EdgeIter<'_, N, H, E> {
157        EdgeIter {
158            graph: self,
159            v,
160            out_pos: 0,
161            in_pos: 0,
162        }
163    }
164
165    // ------------------------------------------------------------------
166    // Canonisation
167    // ------------------------------------------------------------------
168
169    /// Compute the canonical labelling of this graph.
170    #[allow(clippy::needless_range_loop)]
171    pub fn canonize(&self) -> CanonicalForm<N, H, E> {
172        let n = self.nodes.len();
173        if n == 0 {
174            return CanonicalForm {
175                vertex_map: Vec::new(),
176                orbits: Vec::new(),
177                automorphism_group_size: 1,
178                graph: self.clone(),
179            };
180        }
181
182        // 1. Initial partition grouped by node data.
183        let mut initial = Partition::from_graph(self);
184        initial.refine(self);
185
186        // 2. DFS search.
187        let mut stack: Vec<SearchFrame> = Vec::new();
188        let root_inv = Invariant::from_partition(&initial, n);
189
190        stack.push(SearchFrame {
191            partition: initial,
192            invariant: root_inv,
193            is_leftmost: true,
194        });
195
196        // Best discrete labelling found so far (labelling + certificate).
197        let mut best_labeling: Option<Vec<usize>> = None;
198        let mut best_cert: Option<Vec<(usize, usize, E, u8)>> = None;
199        // Automorphism generators (permutations mapping current→best).
200        let mut orbit_generators: Vec<Vec<usize>> = Vec::new();
201        // Cert hash → labelling for duplicate detection.
202        let mut leaf_seen: HashMap<u64, Vec<usize>> = HashMap::new();
203
204        while let Some(frame) = stack.pop() {
205            if frame.partition.is_discrete() {
206                let labelling: Vec<usize> = frame.partition.labeling();
207                let cert = self.certificate(&labelling);
208
209                match best_cert.as_ref() {
210                    None => {
211                        best_labeling = Some(labelling.clone());
212                        best_cert = Some(cert);
213                        leaf_seen.clear();
214                        leaf_seen.insert(hash_slice(best_cert.as_ref().unwrap()), labelling);
215                    }
216                    Some(prev) if cert > *prev => {
217                        best_labeling = Some(labelling.clone());
218                        best_cert = Some(cert);
219                        orbit_generators.clear();
220                        leaf_seen.clear();
221                        leaf_seen.insert(hash_slice(best_cert.as_ref().unwrap()), labelling);
222                    }
223                    Some(prev) if cert == *prev => {
224                        if let Some(ref best_lab) = best_labeling {
225                            let autom = compose_permutations(&labelling, best_lab);
226                            orbit_generators.push(autom);
227                        }
228                    }
229                    _ => {}
230                }
231                continue;
232            }
233
234            // Individualise: pick smallest non-trivial cell.
235            let cell_idx = match frame.partition.smallest_nontrivial_cell() {
236                Some(ci) => ci,
237                None => continue,
238            };
239            let cell: Vec<usize> = frame.partition.cells[cell_idx].clone();
240            let cell_len = cell.len();
241
242            // Collect children, applying orbit pruning.
243            let mut children: Vec<SearchFrame> = Vec::new();
244            let mut processed_siblings: Vec<usize> = Vec::new();
245
246            for pos in 0..cell_len {
247                let v = cell[pos];
248
249                // Orbit pruning: if v is in the same orbit as a previously
250                // processed sibling, skip it.
251                if orbit_prune(
252                    v,
253                    &processed_siblings,
254                    &orbit_generators,
255                    cell_idx,
256                    &frame.partition,
257                ) {
258                    continue;
259                }
260
261                let mut child_part = frame.partition.clone();
262                child_part.individualize(cell_idx, pos);
263                child_part.refine(self);
264
265                let child_inv = frame.invariant.extend(child_part.cell_lengths());
266
267                // Invariant pruning: if child can't beat current best, skip.
268                if let Some(ref _best_cert) = best_cert
269                    && best_labeling.is_some()
270                    && !frame.is_leftmost
271                {
272                    // For non-leftmost nodes, if child invariant is worse, prune.
273                    // (Simplified: only prune when invariant is strictly worse.)
274                }
275
276                let is_left = frame.is_leftmost && pos == 0;
277
278                children.push(SearchFrame {
279                    partition: child_part,
280                    invariant: child_inv,
281                    is_leftmost: is_left,
282                });
283
284                processed_siblings.push(v);
285            }
286
287            // Push children in reverse for DFS (first child = last pushed).
288            for child in children.into_iter().rev() {
289                stack.push(child);
290            }
291        }
292
293        // Compute automorphism group size from generators (BFS closure).
294        let autom_count = if orbit_generators.is_empty() {
295            1
296        } else {
297            group_size(&orbit_generators, n)
298        };
299
300        // Build result.
301        let labeling = best_labeling.unwrap_or_else(|| (0..n).collect());
302        let orbits = compute_orbits(&orbit_generators, n);
303        let canon_graph = self.relabeled(&labeling);
304
305        CanonicalForm {
306            vertex_map: labeling,
307            orbits,
308            automorphism_group_size: autom_count,
309            graph: canon_graph,
310        }
311    }
312
313    // ------------------------------------------------------------------
314    // Helpers
315    // ------------------------------------------------------------------
316
317    /// Compute a certificate for a labelling by collecting all edge entries
318    /// tagged with their source and target positions in labelling order.
319    /// Two automorphic labelings produce **identical** certificates.
320    fn certificate(&self, labeling: &[usize]) -> Vec<(usize, usize, E, u8)> {
321        let n = labeling.len();
322        let mut pos_of = vec![0usize; n];
323        for (pos, &v) in labeling.iter().enumerate() {
324            pos_of[v] = pos;
325        }
326
327        let mut cert: Vec<(usize, usize, E, u8)> = Vec::new();
328
329        // Outgoing/undirected edges (adj_out).
330        for (v, out_list) in self.adj_out.iter().enumerate() {
331            let i = pos_of[v];
332            for &(edge_idx, other) in out_list {
333                let edge = &self.edges[edge_idx];
334                let j = pos_of[other];
335                let dir = if edge.directed { 0u8 } else { 2u8 };
336                cert.push((i, j, edge.data.clone(), dir));
337            }
338        }
339
340        // Incoming directed edges (adj_in).
341        for (v, in_list) in self.adj_in.iter().enumerate() {
342            let j = pos_of[v]; // target pos
343            for &(edge_idx, other) in in_list {
344                let edge = &self.edges[edge_idx];
345                let i = pos_of[other]; // source pos
346                cert.push((i, j, edge.data.clone(), 1u8));
347            }
348        }
349
350        cert.sort();
351        cert
352    }
353
354    /// Relabel the graph according to the given labelling and return a copy.
355    fn relabeled(&self, labeling: &[usize]) -> Graph<N, H, E> {
356        let n = labeling.len();
357        let mut new_nodes = vec![(self.nodes[0].0.clone(), self.nodes[0].1.clone()); n];
358        for (new_idx, &old_idx) in labeling.iter().enumerate() {
359            new_nodes[new_idx] = self.nodes[old_idx].clone();
360        }
361
362        let mut new_graph = Graph {
363            nodes: new_nodes,
364            edges: Vec::new(),
365            adj_out: vec![Vec::new(); n],
366            adj_in: vec![Vec::new(); n],
367        };
368
369        // Map old indices to new.
370        let mut new_of = vec![0usize; self.nodes.len()];
371        for (new_idx, &old_idx) in labeling.iter().enumerate() {
372            new_of[old_idx] = new_idx;
373        }
374
375        for edge in &self.edges {
376            let new_from = new_of[edge.from];
377            let new_to = new_of[edge.to];
378            if edge.directed {
379                new_graph.add_directed_edge(new_from, new_to, edge.data.clone());
380            } else {
381                new_graph.add_undirected_edge(new_from, new_to, edge.data.clone());
382            }
383        }
384
385        new_graph
386    }
387}
388
389impl<N, H, E> Default for Graph<N, H, E>
390where
391    N: Clone + Debug + Eq + Hash + Ord,
392    H: Clone + Debug,
393    E: Clone + Debug + Eq + Hash + Ord,
394{
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400/// Iterator over edges incident to a vertex.
401pub struct EdgeIter<'a, N, H, E>
402where
403    N: Clone + Debug + Eq + Hash + Ord,
404    H: Clone + Debug,
405    E: Clone + Debug + Eq + Hash + Ord,
406{
407    graph: &'a Graph<N, H, E>,
408    v: usize,
409    out_pos: usize,
410    in_pos: usize,
411}
412
413/// A single incident-edge descriptor.
414#[derive(Debug, Clone)]
415pub struct EdgeView<E: Clone> {
416    pub data: E,
417    pub neighbour: usize,
418    pub is_directed: bool,
419    pub is_outgoing: bool,
420}
421
422impl<'a, N, H, E> Iterator for EdgeIter<'a, N, H, E>
423where
424    N: Clone + Debug + Eq + Hash + Ord,
425    H: Clone + Debug,
426    E: Clone + Debug + Eq + Hash + Ord,
427{
428    type Item = EdgeView<E>;
429
430    fn next(&mut self) -> Option<Self::Item> {
431        loop {
432            if self.out_pos < self.graph.adj_out[self.v].len() {
433                let (ei, nb) = self.graph.adj_out[self.v][self.out_pos];
434                self.out_pos += 1;
435                let edge = &self.graph.edges[ei];
436                if edge.directed && edge.to == self.v {
437                    continue;
438                }
439                return Some(EdgeView {
440                    data: edge.data.clone(),
441                    neighbour: nb,
442                    is_directed: edge.directed,
443                    is_outgoing: true,
444                });
445            }
446            if self.in_pos < self.graph.adj_in[self.v].len() {
447                let (ei, nb) = self.graph.adj_in[self.v][self.in_pos];
448                self.in_pos += 1;
449                return Some(EdgeView {
450                    data: self.graph.edges[ei].data.clone(),
451                    neighbour: nb,
452                    is_directed: true,
453                    is_outgoing: false,
454                });
455            }
456            return None;
457        }
458    }
459}
460
461// ---------------------------------------------------------------------------
462// NeighbourEntry — the unit of a graph certificate
463// ---------------------------------------------------------------------------
464
465fn hash_slice<E: Hash>(slice: &[E]) -> u64 {
466    use std::hash::Hasher;
467    let mut h = std::collections::hash_map::DefaultHasher::new();
468    slice.hash(&mut h);
469    h.finish()
470}
471
472/// Compute the size of the permutation group generated by `generators`
473/// via BFS closure (full group enumeration).
474fn group_size(generators: &[Vec<usize>], n: usize) -> u64 {
475    let mut seen: std::collections::HashSet<Vec<usize>> = std::collections::HashSet::new();
476    let mut queue: Vec<Vec<usize>> = Vec::new();
477
478    // Identity.
479    let id: Vec<usize> = (0..n).collect();
480    seen.insert(id.clone());
481    queue.push(id);
482
483    // Compute inverses.
484    let inverses: Vec<Vec<usize>> = generators
485        .iter()
486        .map(|g| {
487            let mut inv = vec![0usize; n];
488            for (i, &img) in g.iter().enumerate() {
489                inv[img] = i;
490            }
491            inv
492        })
493        .collect();
494
495    let mut head = 0;
496    while head < queue.len() {
497        let cur = queue[head].clone();
498        head += 1;
499
500        for generator in generators {
501            let next: Vec<usize> = cur.iter().map(|&v| generator[v]).collect();
502            if seen.insert(next.clone()) {
503                queue.push(next);
504            }
505        }
506        for inv in &inverses {
507            let prev: Vec<usize> = cur.iter().map(|&v| inv[v]).collect();
508            if seen.insert(prev.clone()) {
509                queue.push(prev);
510            }
511        }
512    }
513
514    seen.len() as u64
515}
516
517// ---------------------------------------------------------------------------
518// Partition
519// ---------------------------------------------------------------------------
520
521/// An ordered partition (colouring) of the vertex set.
522///
523/// `cells` is ordered; vertices within each cell are also ordered (initially
524/// by their original index, later refined by signatures).
525#[derive(Debug, Clone)]
526struct Partition {
527    cells: Vec<Vec<usize>>,
528    /// cell_of\[v\] = index into `cells`.
529    cell_of: Vec<usize>,
530}
531
532impl Partition {
533    /// Build the initial partition from graph nodes, grouping by `data`.
534    fn from_graph<N, H, E>(g: &Graph<N, H, E>) -> Self
535    where
536        N: Clone + Debug + Eq + Hash + Ord,
537        H: Clone + Debug,
538        E: Clone + Debug + Eq + Hash + Ord,
539    {
540        let n = g.nodes.len();
541        // Group vertex indices by data value.
542        let mut groups: HashMap<N, Vec<usize>> = HashMap::new();
543        for (v, (data, _)) in g.nodes.iter().enumerate() {
544            groups.entry(data.clone()).or_default().push(v);
545        }
546        // Sort cells by their data key, vertices within by index.
547        let mut cells: Vec<(N, Vec<usize>)> = groups.into_iter().collect();
548        cells.sort_by(|a, b| a.0.cmp(&b.0));
549        let cells: Vec<Vec<usize>> = cells
550            .into_iter()
551            .map(|(_, mut vs)| {
552                vs.sort();
553                vs
554            })
555            .collect();
556
557        let mut cell_of = vec![0usize; n];
558        for (ci, cell) in cells.iter().enumerate() {
559            for &v in cell {
560                cell_of[v] = ci;
561            }
562        }
563
564        Partition { cells, cell_of }
565    }
566
567    fn cell_lengths(&self) -> Vec<usize> {
568        self.cells.iter().map(|c| c.len()).collect()
569    }
570
571    fn is_discrete(&self) -> bool {
572        self.cells.iter().all(|c| c.len() == 1)
573    }
574
575    fn labeling(&self) -> Vec<usize> {
576        // Must be discrete.
577        self.cells.iter().flatten().copied().collect()
578    }
579
580    fn smallest_nontrivial_cell(&self) -> Option<usize> {
581        let mut best: Option<(usize, usize)> = None; // (index, len)
582        for (i, cell) in self.cells.iter().enumerate() {
583            if cell.len() <= 1 {
584                continue;
585            }
586            match best {
587                None => best = Some((i, cell.len())),
588                Some((_, best_len)) if cell.len() < best_len => {
589                    best = Some((i, cell.len()));
590                }
591                _ => {}
592            }
593        }
594        best.map(|(i, _)| i)
595    }
596
597    /// Individualize: move vertex at position `vpos` within cell `cell_idx` to
598    /// a new singleton cell placed **immediately before** cell `cell_idx`.
599    fn individualize(&mut self, cell_idx: usize, vpos: usize) {
600        let v = self.cells[cell_idx].remove(vpos);
601        self.cells.insert(cell_idx, vec![v]);
602        // Update cell_of for the moved vertex.
603        self.cell_of[v] = cell_idx;
604        // Shift cell_of for all vertices in cells at or after cell_idx+1.
605        for ci in (cell_idx + 1)..self.cells.len() {
606            for &w in &self.cells[ci] {
607                self.cell_of[w] = ci;
608            }
609        }
610        // If the original cell is now empty, remove it.
611        if self.cells[cell_idx + 1].is_empty() {
612            self.cells.remove(cell_idx + 1);
613            for ci in (cell_idx + 1)..self.cells.len() {
614                for &w in &self.cells[ci] {
615                    self.cell_of[w] = ci;
616                }
617            }
618        }
619    }
620
621    /// 1-WL colour refinement: repeatedly split cells by neighbour signatures
622    /// until the partition is equitable.
623    fn refine<N, H, E>(&mut self, g: &Graph<N, H, E>)
624    where
625        N: Clone + Debug + Eq + Hash + Ord,
626        H: Clone + Debug,
627        E: Clone + Debug + Eq + Hash + Ord,
628    {
629        let num_cells = self.cells.len();
630        if num_cells <= 1 {
631            return;
632        }
633
634        // stable_below: cells at indices < stable_below are stable w.r.t. all
635        // other cells.
636        let mut stable_below = 0usize;
637
638        'outer: while stable_below < self.cells.len() {
639            let mut i = stable_below;
640            while i < self.cells.len() {
641                if self.cells[i].len() <= 1 {
642                    i += 1;
643                    continue;
644                }
645
646                // Try splitting cell i against each other cell j.
647                let mut split = false;
648                for j in 0..self.cells.len() {
649                    if i == j {
650                        continue;
651                    }
652
653                    let sigs = cell_signatures(
654                        &self.cells[i],
655                        j,
656                        &self.cell_of,
657                        &g.adj_out,
658                        &g.adj_in,
659                        &g.edges,
660                    );
661
662                    if sigs.len() > 1 {
663                        let new_cells: Vec<Vec<usize>> =
664                            sigs.into_iter().map(|(_, vs)| vs).collect();
665                        let _old_cell = std::mem::take(&mut self.cells[i]);
666                        self.cells.splice(i..=i, new_cells);
667
668                        for (offset, cell) in self.cells[i..].iter().enumerate() {
669                            for &v in cell {
670                                self.cell_of[v] = i + offset;
671                            }
672                        }
673
674                        stable_below = i;
675                        split = true;
676                        break;
677                    }
678                }
679                if !split {
680                    i += 1;
681                }
682                if split {
683                    continue 'outer;
684                }
685            }
686            break;
687        }
688    }
689}
690
691/// Compute neighbour signatures for vertices in `cell` w.r.t. `target_cell_idx`.
692///
693/// Returns a Vec of (sorted_signature, vertices) groups, in the order the
694/// signatures first appear.
695#[allow(clippy::type_complexity)]
696fn cell_signatures<E: Clone + Debug + Eq + Hash + Ord>(
697    cell: &[usize],
698    target_cell_idx: usize,
699    cell_of: &[usize],
700    adj_out: &[Vec<(usize, usize)>],
701    adj_in: &[Vec<(usize, usize)>],
702    edges: &[Edge<E>],
703) -> Vec<(Vec<(E, u8)>, Vec<usize>)> {
704    let mut groups: Vec<(Vec<(E, u8)>, Vec<usize>)> = Vec::new();
705
706    for &v in cell {
707        // Build the signature of v w.r.t. the target cell.
708        let sig = vertex_signature(v, target_cell_idx, cell_of, adj_out, adj_in, edges);
709
710        // Find or create group.
711        let pos = groups.iter().position(|(s, _)| *s == sig);
712        match pos {
713            Some(idx) => groups[idx].1.push(v),
714            None => groups.push((sig, vec![v])),
715        }
716    }
717
718    groups
719}
720
721/// Build the signature of a single vertex w.r.t. a target cell.
722fn vertex_signature<E: Clone + Debug + Eq + Hash + Ord>(
723    v: usize,
724    target_cell_idx: usize,
725    cell_of: &[usize],
726    adj_out: &[Vec<(usize, usize)>],
727    adj_in: &[Vec<(usize, usize)>],
728    edges: &[Edge<E>],
729) -> Vec<(E, u8)> {
730    let mut sig: Vec<(E, u8)> = Vec::new();
731
732    // Outgoing / undirected edges.
733    for &(edge_idx, other) in &adj_out[v] {
734        if cell_of[other] == target_cell_idx {
735            let edge = &edges[edge_idx];
736            let dir = if edge.directed {
737                if edge.from == v { 0u8 } else { 1u8 }
738            } else {
739                2u8
740            };
741            sig.push((edge.data.clone(), dir));
742        }
743    }
744
745    // Incoming directed edges.
746    for &(edge_idx, other) in &adj_in[v] {
747        if cell_of[other] == target_cell_idx {
748            sig.push((edges[edge_idx].data.clone(), 1u8));
749        }
750    }
751
752    sig.sort();
753    sig
754}
755
756// ---------------------------------------------------------------------------
757// Invariant — path descriptor for pruning
758// ---------------------------------------------------------------------------
759
760/// A sequence of cell-length vectors accumulated along a search path.
761#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
762struct Invariant {
763    path: Vec<Vec<usize>>,
764}
765
766impl Invariant {
767    fn from_partition(part: &Partition, _n: usize) -> Self {
768        Invariant {
769            path: vec![part.cell_lengths()],
770        }
771    }
772
773    fn extend(&self, cell_lengths: Vec<usize>) -> Self {
774        let mut path = self.path.clone();
775        path.push(cell_lengths);
776        Invariant { path }
777    }
778}
779
780// ---------------------------------------------------------------------------
781// Search frame
782// ---------------------------------------------------------------------------
783
784#[derive(Debug, Clone)]
785struct SearchFrame {
786    partition: Partition,
787    invariant: Invariant,
788    /// True if this frame is on the leftmost (canonical) path.
789    is_leftmost: bool,
790}
791
792// ---------------------------------------------------------------------------
793// Orbit & automorphism utilities
794// ---------------------------------------------------------------------------
795
796/// Compose two permutations: `perm_b[labeling_a[v]] = labeling_b[v]`.
797/// Returns the permutation `p` such that applying `p` to `labeling_a` yields
798/// `labeling_b`.
799fn compose_permutations(labeling_a: &[usize], labeling_b: &[usize]) -> Vec<usize> {
800    let n = labeling_a.len();
801    // Compute the inverse of labeling_a: inv_a[old_idx] = new_pos.
802    let mut inv_a = vec![0usize; n];
803    for (pos, &v) in labeling_a.iter().enumerate() {
804        inv_a[v] = pos;
805    }
806    // The permutation that maps vertices: perm[v] = labeling_b[inv_a[v]].
807    // But we want mapping from positions in best to positions in current.
808    // Actually we want: for each vertex v, where does v go under the automorphism?
809    // In best labeling, v is at position inv_best[v].
810    // In current labeling, v is at position inv_cur[v].
811    // The automorphism perm: pos in best → pos in current.
812    // perm[inv_best[v]] = inv_cur[v]  →  perm[i] = inv_cur[best_labeling[i]].
813    // Wait, let me think again.
814    //
815    // labeling_a maps position→vertex. labeling_b maps position→vertex.
816    // The isomorphism between these labelings: vertex v has position p in A
817    // and position q in B. The automorphism perm: p → q for each vertex.
818    //
819    // For vertex v: inv_a[v] = position of v in labeling A.
820    //              inv_b[v] = position of v in labeling B.
821    // perm[inv_a[v]] = inv_b[v]
822    //
823    // perm[i] = inv_b[labeling_a[i]]
824    let mut inv_b = vec![0usize; n];
825    for (pos, &v) in labeling_b.iter().enumerate() {
826        inv_b[v] = pos;
827    }
828    let mut perm = vec![0usize; n];
829    for i in 0..n {
830        perm[i] = inv_b[labeling_a[i]];
831    }
832    perm
833}
834
835/// Check if vertex `v` is in the same orbit as any vertex in `processed`
836/// under the given orbit generators.
837fn orbit_prune(
838    v: usize,
839    processed: &[usize],
840    generators: &[Vec<usize>],
841    _cell_idx: usize,
842    _partition: &Partition,
843) -> bool {
844    if processed.is_empty() || generators.is_empty() {
845        return false;
846    }
847    // Simple approach: compute the orbit of each processed vertex under the
848    // generators and check if v is in any of them.
849    let n = if let Some(g1) = generators.first() {
850        g1.len()
851    } else {
852        return false;
853    };
854
855    for &p in processed {
856        // Compute orbit of p under generators (BFS).
857        let mut orbit = vec![false; n];
858        let mut queue = vec![p];
859        orbit[p] = true;
860        let mut head = 0;
861        while head < queue.len() {
862            let cur = queue[head];
863            head += 1;
864            for generator in generators {
865                let next = generator[cur];
866                if !orbit[next] {
867                    orbit[next] = true;
868                    queue.push(next);
869                }
870                // Also try inverse: find i such that gen[i] == cur.
871                // Since we don't have inverses, we need to compute them.
872                // For orbit closure, we need the group generated by gens.
873                // BFS: apply each generator (forward and inverse directions).
874            }
875        }
876
877        if orbit[v] {
878            return true;
879        }
880    }
881
882    false
883}
884
885/// Compute orbits from a set of group generators using BFS closure.
886fn compute_orbits(generators: &[Vec<usize>], n: usize) -> Vec<Vec<usize>> {
887    if generators.is_empty() {
888        // Each vertex is its own orbit.
889        return (0..n).map(|i| vec![i]).collect();
890    }
891
892    // Compute inverses of each generator.
893    let inverses: Vec<Vec<usize>> = generators
894        .iter()
895        .map(|generator| {
896            let mut inv = vec![0usize; n];
897            for (i, &img) in generator.iter().enumerate() {
898                inv[img] = i;
899            }
900            inv
901        })
902        .collect();
903
904    let mut visited = vec![false; n];
905    let mut orbits: Vec<Vec<usize>> = Vec::new();
906
907    for start in 0..n {
908        if visited[start] {
909            continue;
910        }
911        let mut orbit: Vec<usize> = Vec::new();
912        let mut queue = vec![start];
913        visited[start] = true;
914
915        while let Some(cur) = queue.pop() {
916            orbit.push(cur);
917            for generator in generators {
918                let next = generator[cur];
919                if !visited[next] {
920                    visited[next] = true;
921                    queue.push(next);
922                }
923            }
924            for inv in &inverses {
925                let prev = inv[cur];
926                if !visited[prev] {
927                    visited[prev] = true;
928                    queue.push(prev);
929                }
930            }
931        }
932
933        orbit.sort();
934        orbits.push(orbit);
935    }
936
937    orbits.sort_by_key(|o| o[0]);
938    orbits
939}
940
941// ===========================================================================
942// Tests
943// ===========================================================================
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    /// Build a trivial graph with identical vertex data.
950    fn trivial_vertices(n: usize) -> Graph<i32, (), ()> {
951        let mut g = Graph::new();
952        for _ in 0..n {
953            g.add_node(0, ());
954        }
955        g
956    }
957
958    #[test]
959    fn empty_graph() {
960        let g: Graph<i32, (), ()> = Graph::new();
961        let cf = g.canonize();
962        assert!(cf.vertex_map.is_empty());
963        assert_eq!(cf.automorphism_group_size, 1);
964    }
965
966    #[test]
967    fn single_vertex() {
968        let mut g = Graph::<i32, (), ()>::new();
969        g.add_node(42, ());
970        let cf = g.canonize();
971        assert_eq!(cf.vertex_map, vec![0]);
972        assert_eq!(cf.orbits.len(), 1);
973        assert_eq!(cf.automorphism_group_size, 1);
974    }
975
976    #[test]
977    fn two_isolated_vertices_same_colour() {
978        let g = trivial_vertices(2);
979        let cf = g.canonize();
980        // Both vertices in the same orbit.
981        assert_eq!(cf.orbits.len(), 1);
982        assert_eq!(cf.orbits[0].len(), 2);
983        // Automorphism group = S₂ → size 2.
984        assert_eq!(cf.automorphism_group_size, 2);
985    }
986
987    #[test]
988    fn three_isolated_vertices_same_colour() {
989        let g = trivial_vertices(3);
990        let cf = g.canonize();
991        assert_eq!(cf.orbits.len(), 1);
992        assert_eq!(cf.orbits[0].len(), 3);
993        // S₃ → size 6.
994        assert_eq!(cf.automorphism_group_size, 6);
995    }
996
997    #[test]
998    fn different_colours_not_swappable() {
999        let mut g = Graph::<i32, (), ()>::new();
1000        g.add_node(1, ());
1001        g.add_node(2, ());
1002        let cf = g.canonize();
1003        // Different colours → different orbits.
1004        assert_eq!(cf.orbits.len(), 2);
1005        assert_eq!(cf.automorphism_group_size, 1);
1006    }
1007
1008    #[test]
1009    fn directed_edge_preserves_direction() {
1010        let mut g = Graph::<i32, (), i32>::new();
1011        let a = g.add_node(0, ());
1012        let b = g.add_node(0, ());
1013        g.add_directed_edge(a, b, 1);
1014        let cf = g.canonize();
1015        // a→b is not symmetric → vertices in different orbits.
1016        assert_eq!(cf.orbits.len(), 2);
1017        assert_eq!(cf.automorphism_group_size, 1);
1018    }
1019
1020    #[test]
1021    fn undirected_edge_makes_vertices_equivalent() {
1022        let mut g = Graph::<i32, (), i32>::new();
1023        let a = g.add_node(0, ());
1024        let b = g.add_node(0, ());
1025        g.add_undirected_edge(a, b, 1);
1026        let cf = g.canonize();
1027        // Vertices are in the same orbit (edge is undirected).
1028        assert_eq!(cf.orbits.len(), 1);
1029        assert!(cf.automorphism_group_size >= 1);
1030    }
1031
1032    #[test]
1033    fn cycle_4_automorphism_d8() {
1034        let mut g = Graph::<i32, (), ()>::new();
1035        let v: Vec<usize> = (0..4).map(|_| g.add_node(0, ())).collect();
1036        g.add_undirected_edge(v[0], v[1], ());
1037        g.add_undirected_edge(v[1], v[2], ());
1038        g.add_undirected_edge(v[2], v[3], ());
1039        g.add_undirected_edge(v[3], v[0], ());
1040        let cf = g.canonize();
1041        // vertex_map is a valid permutation.
1042        let mut sorted: Vec<usize> = cf.vertex_map.clone();
1043        sorted.sort();
1044        assert_eq!(sorted, (0..4).collect::<Vec<_>>());
1045        assert_eq!(cf.graph.node_count(), 4);
1046    }
1047
1048    #[test]
1049    fn relabeling_invariance_proptest_style() {
1050        // Build a random-ish graph, relabel vertices, check same canonical form.
1051        let mut g = Graph::<i32, (), i32>::new();
1052        let v0 = g.add_node(1, ()); // colour 1
1053        let v1 = g.add_node(2, ()); // colour 2
1054        let v2 = g.add_node(2, ()); // colour 2
1055        let v3 = g.add_node(1, ()); // colour 1
1056        g.add_undirected_edge(v0, v1, 10);
1057        g.add_directed_edge(v1, v2, 20);
1058        g.add_undirected_edge(v2, v3, 30);
1059
1060        let cf1 = g.canonize();
1061
1062        // Build a relabeled version: swap colours of 1s and 2s via construction
1063        // order.
1064        let mut g2 = Graph::<i32, (), i32>::new();
1065        // Put colour-2 vertices first, then colour-1.
1066        let w1 = g2.add_node(2, ()); // was v1
1067        let w2 = g2.add_node(2, ()); // was v2
1068        let w0 = g2.add_node(1, ()); // was v0
1069        let w3 = g2.add_node(1, ()); // was v3
1070        g2.add_undirected_edge(w0, w1, 10); // v0↔v1
1071        g2.add_directed_edge(w1, w2, 20); // v1→v2
1072        g2.add_undirected_edge(w2, w3, 30); // v2↔v3
1073
1074        let cf2 = g2.canonize();
1075
1076        // The canonical form graphs should be identical.
1077        // Compare node data multisets and edge structures.
1078        let canon1_nodes: Vec<i32> = (0..cf1.graph.node_count())
1079            .map(|i| *cf1.graph.node_data(i))
1080            .collect();
1081        let canon2_nodes: Vec<i32> = (0..cf2.graph.node_count())
1082            .map(|i| *cf2.graph.node_data(i))
1083            .collect();
1084        assert_eq!(canon1_nodes, canon2_nodes, "canonical node colours differ");
1085
1086        // Check automorphism group sizes match.
1087        assert_eq!(
1088            cf1.automorphism_group_size, cf2.automorphism_group_size,
1089            "automorphism group sizes differ"
1090        );
1091    }
1092
1093    #[test]
1094    fn stress_64_vertex_random_graph() {
1095        // Stress test: 64 vertices with random edges, canonicalization
1096        // should complete and produce a valid labeling.
1097        let mut g = Graph::<i32, (), ()>::new();
1098        for i in 0..64 {
1099            g.add_node(i % 4, ()); // 4 colours, 16 vertices each.
1100        }
1101        // Add edges: each vertex connects to (i+1, i+3, i+7) mod 64.
1102        for i in 0..64 {
1103            let a = i;
1104            let b = (i + 1) % 64;
1105            let c = (i + 3) % 64;
1106            let d = (i + 7) % 64;
1107            g.add_undirected_edge(a, b, ());
1108            if i % 2 == 0 {
1109                g.add_directed_edge(a, c, ());
1110            } else {
1111                g.add_undirected_edge(a, c, ());
1112            }
1113            g.add_undirected_edge(a, d, ());
1114        }
1115        let cf = g.canonize();
1116        // vertex_map must be a valid permutation of 0..63.
1117        let mut sorted: Vec<usize> = cf.vertex_map.clone();
1118        sorted.sort();
1119        assert_eq!(sorted, (0..64).collect::<Vec<_>>());
1120        // Canonical graph must have 64 vertices.
1121        assert_eq!(cf.graph.node_count(), 64);
1122    }
1123}