Skip to main content

logicaffeine_proof/
permgroup.rs

1//! **Schreier–Sims: a base and strong generating set (BSGS) for a permutation group** — the non-abelian
2//! generalization of Gaussian elimination, and the last rung of the algebra ladder
3//! (GF(2)→GF(p)→ℤ/m linear; this is the *group* level).
4//!
5//! Gaussian elimination decides membership/dimension of a vector subspace by a basis under a stabilizer
6//! chain of coordinate projections. Schreier–Sims does the same for a permutation group: a **base**
7//! `B = (β₁,…,β_k)` is a sequence of points whose pointwise stabilizer is trivial, giving the
8//! **stabilizer chain** `G = G⁽¹⁾ ≥ G⁽²⁾ ≥ … ≥ G⁽ᵏ⁺¹⁾ = {id}` with `G⁽ⁱ⁾` fixing `β₁…β_{i−1}`. A
9//! **strong generating set** generates every stage. The *stabilizer chain is the symmetry break* — fix a
10//! base point, descend to its stabilizer, repeat — and from it, in polynomial time:
11//! - **order** `|G| = Π |Δᵢ|` (the product of the basic orbit sizes), and
12//! - **membership / coset decision** by *sifting* (stripping `g` through the chain; it lies in `G` iff it
13//!   sifts to the identity), the decision procedure for **coset problems over non-abelian groups** —
14//!   exactly the rung the linear engines (abelian only) could not reach.
15//!
16//! Permutations act on the right: `xᵍ = g[x]`, so `(g·h)[x] = h[g[x]]`.
17
18use std::collections::{BTreeMap, BTreeSet, HashMap};
19
20/// A permutation of `{0,…,n−1}`: `p[x]` is the image of point `x`.
21pub type Perm = Vec<usize>;
22
23fn identity(n: usize) -> Perm {
24    (0..n).collect()
25}
26fn is_identity(p: &[usize]) -> bool {
27    p.iter().enumerate().all(|(i, &v)| i == v)
28}
29/// Right-action composition: apply `g` then `h`, so `(g·h)[x] = h[g[x]]`.
30fn compose(g: &[usize], h: &[usize]) -> Perm {
31    g.iter().map(|&x| h[x]).collect()
32}
33fn invert(g: &[usize]) -> Perm {
34    let mut inv = vec![0usize; g.len()];
35    for (x, &gx) in g.iter().enumerate() {
36        inv[gx] = x;
37    }
38    inv
39}
40
41/// The basic orbit of `base[level]` under `Gⁱ` (the strong generators fixing `base[0..level]`), with a
42/// transversal: `trans[δ]` is a permutation in `Gⁱ` carrying `base[level]` to `δ`.
43fn orbit_transversal(base: &[usize], strong: &[Perm], level: usize) -> HashMap<usize, Perm> {
44    let degree = strong.first().map(|p| p.len()).unwrap_or(base.len());
45    let stab: Vec<&Perm> =
46        strong.iter().filter(|g| (0..level).all(|j| g[base[j]] == base[j])).collect();
47    let mut trans: HashMap<usize, Perm> = HashMap::new();
48    trans.insert(base[level], identity(degree));
49    let mut queue = vec![base[level]];
50    while let Some(p) = queue.pop() {
51        let up = trans[&p].clone();
52        for s in &stab {
53            let q = s[p];
54            if !trans.contains_key(&q) {
55                trans.insert(q, compose(&up, s)); // base[level] → p → q
56                queue.push(q);
57            }
58        }
59    }
60    trans
61}
62
63/// Sift `g` through the chain: at each level send `g` into the stabilizer of `base[i]` by right-dividing
64/// the transversal element. Returns the residue and the level reached. `g ∈ ⟨strong⟩` iff the residue is
65/// the identity (and the full depth was reached).
66fn sift(base: &[usize], strong: &[Perm], mut g: Perm) -> (Perm, usize) {
67    for (i, &beta) in base.iter().enumerate() {
68        let trans = orbit_transversal(base, strong, i);
69        let img = g[beta];
70        match trans.get(&img) {
71            None => return (g, i),
72            Some(t) => g = compose(&g, &invert(t)), // now fixes base[i]
73        }
74    }
75    (g, base.len())
76}
77
78/// Add `g ∈ G` to the (base, strong) data: sift it, and if the residue is non-trivial it is a new strong
79/// generator — extend the base if the residue fixes the whole current base. Returns whether it grew.
80fn extend_with(base: &mut Vec<usize>, strong: &mut Vec<Perm>, g: Perm) -> bool {
81    let (res, lvl) = sift(base, strong, g);
82    if is_identity(&res) {
83        return false;
84    }
85    if lvl == base.len() {
86        let moved = (0..res.len()).find(|&x| res[x] != x).expect("a non-identity moves a point");
87        base.push(moved);
88    }
89    strong.push(res);
90    true
91}
92
93/// The orbits of `{0,…,degree−1}` under `⟨generators⟩`, as a partition (each orbit sorted ascending,
94/// orbits ordered by least element). Needs only the generators — a BFS, independent of the BSGS.
95pub fn orbits(degree: usize, generators: &[Perm]) -> Vec<Vec<usize>> {
96    let mut seen = vec![false; degree];
97    let mut out = Vec::new();
98    for start in 0..degree {
99        if seen[start] {
100            continue;
101        }
102        seen[start] = true;
103        let mut orbit = vec![start];
104        let mut i = 0;
105        while i < orbit.len() {
106            let p = orbit[i];
107            i += 1;
108            for g in generators {
109                let q = g[p];
110                if !seen[q] {
111                    seen[q] = true;
112                    orbit.push(q);
113                }
114            }
115        }
116        orbit.sort_unstable();
117        out.push(orbit);
118    }
119    out
120}
121
122fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
123    while parent[x] != x {
124        parent[x] = parent[parent[x]];
125        x = parent[x];
126    }
127    x
128}
129
130/// The minimal block (G-congruence class) containing both `alpha` and `beta`, returned as a block-id per
131/// point (Atkinson's algorithm): merge `α,β`, then whenever two points share a block so must their images
132/// under every generator — close under the generators with union–find. The class of `alpha` is the
133/// minimal block containing the pair.
134fn block_containing(degree: usize, gens: &[Perm], alpha: usize, beta: usize) -> Vec<usize> {
135    let mut parent: Vec<usize> = (0..degree).collect();
136    let mut queue: Vec<(usize, usize)> = Vec::new();
137    let (ra, rb) = (uf_find(&mut parent, alpha), uf_find(&mut parent, beta));
138    if ra != rb {
139        parent[ra] = rb;
140        queue.push((alpha, beta));
141    }
142    while let Some((x, y)) = queue.pop() {
143        for g in gens {
144            let (gx, gy) = (g[x], g[y]);
145            let (rx, ry) = (uf_find(&mut parent, gx), uf_find(&mut parent, gy));
146            if rx != ry {
147                parent[rx] = ry;
148                queue.push((gx, gy));
149            }
150        }
151    }
152    (0..degree).map(|x| uf_find(&mut parent, x)).collect()
153}
154
155/// The minimal non-trivial **block system** of a TRANSITIVE permutation group — the finest `G`-invariant
156/// partition into equal-size blocks bigger than a point and smaller than the whole set — or `None` if the
157/// group is **primitive** (only the trivial partitions are invariant) or not transitive. Imprimitivity is
158/// the symmetry's internal structure: a grid symmetry decomposes into its rows, a cyclic group of
159/// composite order into cosets. (Atkinson's algorithm over each pair `{0, β}`.)
160pub fn minimal_block_system(degree: usize, gens: &[Perm]) -> Option<Vec<Vec<usize>>> {
161    if degree < 2 || orbits(degree, gens).len() != 1 {
162        return None; // primitivity is a property of transitive groups only
163    }
164    let mut best: Option<Vec<usize>> = None;
165    let mut best_size = degree;
166    for beta in 1..degree {
167        let ids = block_containing(degree, gens, 0, beta);
168        let size = ids.iter().filter(|&&b| b == ids[0]).count();
169        if 1 < size && size < degree && size < best_size {
170            best_size = size;
171            best = Some(ids);
172        }
173    }
174    best.map(|ids| {
175        let mut by_block: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
176        for (x, &b) in ids.iter().enumerate() {
177            by_block.entry(b).or_default().push(x);
178        }
179        by_block.into_values().collect()
180    })
181}
182
183/// Is the group **primitive** — transitive with no non-trivial block system? Primitive groups are the
184/// indecomposable "atoms" of permutation-group structure; imprimitive ones split into blocks
185/// ([`minimal_block_system`]).
186pub fn is_primitive(degree: usize, gens: &[Perm]) -> bool {
187    degree >= 2 && orbits(degree, gens).len() == 1 && minimal_block_system(degree, gens).is_none()
188}
189
190/// The **orbitals** — the orbits of the group on ORDERED PAIRS `(i, j)` under the action
191/// `g·(i,j) = (g[i], g[j])`. The diagonal `{(i,i)}` is always one orbital; for a transitive group the
192/// non-diagonal orbitals are its "relation classes" (its association scheme). The count is the group's
193/// [`rank`]. One level finer than the point-orbits ([`orbits`]).
194pub fn orbitals(degree: usize, gens: &[Perm]) -> Vec<Vec<(usize, usize)>> {
195    let mut seen = vec![false; degree * degree];
196    let idx = |i: usize, j: usize| i * degree + j;
197    let mut out = Vec::new();
198    for i in 0..degree {
199        for j in 0..degree {
200            if seen[idx(i, j)] {
201                continue;
202            }
203            seen[idx(i, j)] = true;
204            let mut orbit = vec![(i, j)];
205            let mut k = 0;
206            while k < orbit.len() {
207                let (a, b) = orbit[k];
208                k += 1;
209                for g in gens {
210                    let (ga, gb) = (g[a], g[b]);
211                    if !seen[idx(ga, gb)] {
212                        seen[idx(ga, gb)] = true;
213                        orbit.push((ga, gb));
214                    }
215                }
216            }
217            out.push(orbit);
218        }
219    }
220    out
221}
222
223/// The **rank** of the group: the number of orbitals (orbits on ordered pairs). A transitive group has
224/// rank `2` iff it is 2-transitive; a regular group has rank equal to its degree.
225pub fn rank(degree: usize, gens: &[Perm]) -> usize {
226    orbitals(degree, gens).len()
227}
228
229/// Are all `degree` points connected by the undirected graph whose edges are `orbital`'s pairs?
230fn orbital_graph_connected(degree: usize, orbital: &[(usize, usize)]) -> bool {
231    let mut parent: Vec<usize> = (0..degree).collect();
232    for &(i, j) in orbital {
233        let (ri, rj) = (uf_find(&mut parent, i), uf_find(&mut parent, j));
234        parent[ri] = rj;
235    }
236    let r0 = uf_find(&mut parent, 0);
237    (0..degree).all(|v| uf_find(&mut parent, v) == r0)
238}
239
240/// Primitivity via **Higman's theorem**: a transitive group is primitive iff every non-diagonal orbital
241/// graph is connected. An independent route to [`is_primitive`]; when a non-diagonal orbital graph is
242/// disconnected, its connected components are a block system. (Returns `false` for an intransitive group.)
243pub fn is_primitive_via_orbitals(degree: usize, gens: &[Perm]) -> bool {
244    if degree < 2 || orbits(degree, gens).len() != 1 {
245        return false;
246    }
247    orbitals(degree, gens)
248        .iter()
249        .filter(|orb| orb.iter().any(|&(i, j)| i != j)) // skip the diagonal
250        .all(|orb| orbital_graph_connected(degree, orb))
251}
252
253/// Every ordered `k`-tuple of DISTINCT points from `0..degree`.
254fn distinct_tuples(degree: usize, k: usize) -> Vec<Vec<usize>> {
255    let mut out = Vec::new();
256    let mut cur = Vec::with_capacity(k);
257    let mut used = vec![false; degree];
258    fn rec(degree: usize, k: usize, cur: &mut Vec<usize>, used: &mut [bool], out: &mut Vec<Vec<usize>>) {
259        if cur.len() == k {
260            out.push(cur.clone());
261            return;
262        }
263        for x in 0..degree {
264            if !used[x] {
265                used[x] = true;
266                cur.push(x);
267                rec(degree, k, cur, used, out);
268                cur.pop();
269                used[x] = false;
270            }
271        }
272    }
273    rec(degree, k, &mut cur, &mut used, &mut out);
274    out
275}
276
277/// The orbits of the group on ordered `k`-tuples of distinct points (`g·(t₁,…,t_k) = (g[t₁],…,g[t_k])`).
278/// `k = 1` is the point-orbits ([`orbits`]); `k = 2` (on distinct pairs) refines [`orbitals`]; in general
279/// the group is `k`-transitive iff this is a single orbit — the rungs of the transitivity ladder.
280pub fn orbits_on_tuples(degree: usize, gens: &[Perm], k: usize) -> Vec<Vec<Vec<usize>>> {
281    if k == 0 || k > degree {
282        return Vec::new();
283    }
284    let tuples = distinct_tuples(degree, k);
285    let index: HashMap<Vec<usize>, usize> =
286        tuples.iter().enumerate().map(|(i, t)| (t.clone(), i)).collect();
287    let mut seen = vec![false; tuples.len()];
288    let mut out = Vec::new();
289    for start in 0..tuples.len() {
290        if seen[start] {
291            continue;
292        }
293        seen[start] = true;
294        let mut orbit = vec![tuples[start].clone()];
295        let mut i = 0;
296        while i < orbit.len() {
297            let cur = orbit[i].clone();
298            i += 1;
299            for g in gens {
300                let img: Vec<usize> = cur.iter().map(|&x| g[x]).collect();
301                let idx = index[&img];
302                if !seen[idx] {
303                    seen[idx] = true;
304                    orbit.push(img);
305                }
306            }
307        }
308        out.push(orbit);
309    }
310    out
311}
312
313/// The **transitivity degree**: the largest `t ≤ max_t` for which the group is transitive on ordered
314/// `t`-tuples of distinct points (`1` = transitive, `2` = 2-transitive, …). `0` if intransitive. Capped at
315/// `max_t` because the `t`-tuple space grows as `degree^t`. `Sₙ` is `n`-transitive; a regular group is only
316/// `1`-transitive.
317pub fn transitivity_degree(degree: usize, gens: &[Perm], max_t: usize) -> usize {
318    let mut t = 0;
319    for k in 1..=max_t.min(degree) {
320        if orbits_on_tuples(degree, gens, k).len() == 1 {
321            t = k;
322        } else {
323            break; // k-transitive ⟹ (k-1)-transitive, so the first failure is the ceiling
324        }
325    }
326    t
327}
328
329/// The commutator `[g, h] = g⁻¹ h⁻¹ g h`.
330fn commutator(g: &[usize], h: &[usize]) -> Perm {
331    compose(&compose(&invert(g), &invert(h)), &compose(g, h))
332}
333
334/// Generators of the **normal closure** of `⟨sub⟩` inside `⟨gens⟩`: close `sub` under conjugation by the
335/// generators and their inverses (which generates conjugation by the whole group) until the generated
336/// group stops growing.
337fn normal_closure(degree: usize, sub: &[Perm], gens: &[Perm]) -> Vec<Perm> {
338    let mut closure: Vec<Perm> = sub.iter().filter(|p| !is_identity(p)).cloned().collect();
339    if closure.is_empty() {
340        return closure;
341    }
342    let mut bsgs = schreier_sims(degree, &closure);
343    let mut i = 0;
344    while i < closure.len() {
345        let s = closure[i].clone();
346        i += 1;
347        for g in gens {
348            for conj in [compose(&compose(&invert(g), &s), g), compose(&compose(g, &s), &invert(g))] {
349                if !is_identity(&conj) && !bsgs.contains(&conj) {
350                    closure.push(conj);
351                    bsgs = schreier_sims(degree, &closure);
352                }
353            }
354        }
355    }
356    closure
357}
358
359/// Generators of the **commutator subgroup** `[A, B]` — the normal closure (in `⟨gens_a⟩`) of the
360/// commutators `[a, b]` for generators `a` of `A`, `b` of `B`. `[G, G]` is the derived subgroup; `[G, γ]`
361/// is a step of the lower central series.
362fn commutator_subgroup(degree: usize, gens_a: &[Perm], gens_b: &[Perm]) -> Vec<Perm> {
363    let mut comms = Vec::new();
364    for a in gens_a {
365        for b in gens_b {
366            let c = commutator(a, b);
367            if !is_identity(&c) {
368                comms.push(c);
369            }
370        }
371    }
372    normal_closure(degree, &comms, gens_a)
373}
374
375/// Generators of the **derived (commutator) subgroup** `[G, G]` — the normal closure of the commutators of
376/// the generators. Always normal; `G / [G, G]` is the abelianisation, and `[G, G]` is trivial iff `G` is
377/// abelian.
378pub fn derived_subgroup(degree: usize, gens: &[Perm]) -> Vec<Perm> {
379    commutator_subgroup(degree, gens, gens)
380}
381
382/// Is the group **nilpotent**? (Its lower central series reaches the trivial group.) Strictly stronger than
383/// solvability — every `p`-group is nilpotent, but `S₃` (solvable) is not.
384pub fn is_nilpotent(degree: usize, gens: &[Perm]) -> bool {
385    nilpotency_class(degree, gens).is_some()
386}
387
388/// Is the group **abelian**? (Its generators pairwise commute.)
389pub fn is_abelian(_degree: usize, gens: &[Perm]) -> bool {
390    gens.iter().all(|g| gens.iter().all(|h| compose(g, h) == compose(h, g)))
391}
392
393/// The **derived length** (solvability class): the number of steps the derived series `G ⊵ G' ⊵ G'' ⊵ …`
394/// takes to reach the trivial group, or `None` if it never does (`G` is unsolvable). `0` is the trivial
395/// group, `1` a non-trivial abelian group, `2` for `S₃`, `3` for `S₄`.
396pub fn derived_length(degree: usize, gens: &[Perm]) -> Option<usize> {
397    let mut cur: Vec<Perm> = gens.to_vec();
398    let mut len = 0;
399    loop {
400        let order = schreier_sims(degree, &cur).order();
401        if order == 1 {
402            return Some(len);
403        }
404        let d = derived_subgroup(degree, &cur);
405        if schreier_sims(degree, &d).order() == order {
406            return None; // G' = G ⇒ the series never descends to 1
407        }
408        cur = d;
409        len += 1;
410    }
411}
412
413/// Is the group **solvable**? (Its derived series reaches the trivial group.)
414pub fn is_solvable(degree: usize, gens: &[Perm]) -> bool {
415    derived_length(degree, gens).is_some()
416}
417
418/// The **nilpotency class**: the number of steps the lower central series `γ₁ = G`, `γ_{k+1} = [G, γ_k]`
419/// takes to reach the trivial group, or `None` if it never does (`G` is not nilpotent). `0` is trivial,
420/// `1` abelian, `2` for `D₄`.
421pub fn nilpotency_class(degree: usize, gens: &[Perm]) -> Option<usize> {
422    let mut gamma: Vec<Perm> = gens.to_vec();
423    let mut class = 0;
424    loop {
425        let order = schreier_sims(degree, &gamma).order();
426        if order == 1 {
427            return Some(class);
428        }
429        let next = commutator_subgroup(degree, gens, &gamma);
430        if schreier_sims(degree, &next).order() == order {
431            return None; // the series stalls above the identity
432        }
433        gamma = next;
434        class += 1;
435    }
436}
437
438/// The **conjugacy classes** of the group — the partition of its elements by `g ~ x⁻¹gx`. The number of
439/// classes equals the number of irreducible representations (the bridge to character theory); the
440/// singleton classes are exactly the centre `Z(G)`. Requires enumerating the group, so it returns `None`
441/// when `|G| > cap`.
442pub fn conjugacy_classes(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Vec<Perm>>> {
443    let elements = schreier_sims(degree, gens).elements(cap)?;
444    let mut remaining: BTreeSet<Perm> = elements.iter().cloned().collect();
445    let mut classes = Vec::new();
446    while let Some(g) = remaining.iter().next().cloned() {
447        let mut class: BTreeSet<Perm> = BTreeSet::new();
448        for x in &elements {
449            class.insert(compose(&compose(&invert(x), &g), x)); // x⁻¹ g x
450        }
451        for c in &class {
452            remaining.remove(c);
453        }
454        classes.push(class.into_iter().collect::<Vec<_>>());
455    }
456    Some(classes)
457}
458
459/// The order of the **centre** `Z(G)` — the elements commuting with all of `G`, which are exactly those in
460/// singleton conjugacy classes. `None` when `|G| > cap`.
461pub fn center_order(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
462    conjugacy_classes(degree, gens, cap)
463        .map(|classes| classes.iter().filter(|c| c.len() == 1).count() as u128)
464}
465
466/// The **order of a single permutation**: the least `k ≥ 1` with `gᵏ = id`.
467fn element_order(g: &[usize]) -> usize {
468    if is_identity(g) {
469        return 1;
470    }
471    let mut p = compose(g, g);
472    let mut k = 2;
473    while !is_identity(&p) {
474        p = compose(&p, g);
475        k += 1;
476    }
477    k
478}
479
480fn gcd(mut a: u128, mut b: u128) -> u128 {
481    while b != 0 {
482        (a, b) = (b, a % b);
483    }
484    a
485}
486
487fn lcm(a: u128, b: u128) -> u128 {
488    if a == 0 || b == 0 {
489        0
490    } else {
491        a / gcd(a, b) * b
492    }
493}
494
495/// The **order spectrum** — the set of distinct orders of the group's elements. `None` when `|G| > cap`.
496pub fn element_orders(degree: usize, gens: &[Perm], cap: usize) -> Option<BTreeSet<usize>> {
497    let elements = schreier_sims(degree, gens).elements(cap)?;
498    Some(elements.iter().map(|g| element_order(g)).collect())
499}
500
501/// The **exponent** of the group — the least common multiple of all element orders, i.e. the smallest `e`
502/// with `gᵉ = id` for every `g`. `None` when `|G| > cap`.
503pub fn exponent(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
504    let elements = schreier_sims(degree, gens).elements(cap)?;
505    Some(elements.iter().fold(1u128, |e, g| lcm(e, element_order(g) as u128)))
506}
507
508/// The orders `[|Z₀|, |Z₁|, …]` of the **upper central series**, up to the hypercentre. `Z₀ = {id}` and
509/// `Z_{i+1} = { g : [g, x] ∈ Z_i for all x }` (the preimage of the centre of `G/Z_i`). The series ascends
510/// to `|G|` iff `G` is nilpotent. `None` when `|G| > cap`.
511pub fn upper_central_series(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
512    let elements = schreier_sims(degree, gens).elements(cap)?;
513    let mut z: BTreeSet<Perm> = BTreeSet::from([identity(degree)]);
514    let mut orders = vec![1u128];
515    loop {
516        let next: BTreeSet<Perm> = elements
517            .iter()
518            .filter(|g| elements.iter().all(|x| z.contains(&commutator(g, x))))
519            .cloned()
520            .collect();
521        if next.len() == z.len() {
522            break; // stabilised at the hypercentre
523        }
524        orders.push(next.len() as u128);
525        z = next;
526    }
527    Some(orders)
528}
529
530/// The length of the upper central series when it reaches `G` (the nilpotency class) — `None` if it stalls
531/// below `G` (the group is not nilpotent) or `|G| > cap`. Equals [`nilpotency_class`] for nilpotent groups,
532/// an independent route to the same number.
533pub fn upper_central_length(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
534    let orders = upper_central_series(degree, gens, cap)?;
535    (orders.last() == Some(&schreier_sims(degree, gens).order())).then_some(orders.len() - 1)
536}
537
538/// The **cycle type** of a permutation — the sorted multiset of its cycle lengths (fixed points are
539/// 1-cycles). Its length is the number of cycles.
540fn cycle_type(g: &[usize]) -> Vec<usize> {
541    let mut seen = vec![false; g.len()];
542    let mut lengths = Vec::new();
543    for start in 0..g.len() {
544        if seen[start] {
545            continue;
546        }
547        let mut len = 0;
548        let mut x = start;
549        while !seen[x] {
550            seen[x] = true;
551            x = g[x];
552            len += 1;
553        }
554        lengths.push(len);
555    }
556    lengths.sort_unstable();
557    lengths
558}
559
560/// The **cycle index** data — the distribution of cycle types over the group, mapping each cycle type to
561/// the number of elements with it. Dividing by `|G|` gives the cycle index polynomial, the engine of Pólya
562/// enumeration. `None` when `|G| > cap`.
563pub fn cycle_index(degree: usize, gens: &[Perm], cap: usize) -> Option<BTreeMap<Vec<usize>, u128>> {
564    let elements = schreier_sims(degree, gens).elements(cap)?;
565    let mut dist: BTreeMap<Vec<usize>, u128> = BTreeMap::new();
566    for g in &elements {
567        *dist.entry(cycle_type(g)).or_insert(0) += 1;
568    }
569    Some(dist)
570}
571
572/// **Pólya / Burnside count** — the number of ways to colour the `degree` points with `m` colours up to the
573/// group action: `(1/|G|) Σ_g m^{#cycles(g)}`. With `m = 2` this is the number of distinct `{0,1}`
574/// assignments to the points modulo symmetry — the symmetry-reduced size of the assignment space. `None`
575/// when `|G| > cap`.
576pub fn polya_count(degree: usize, gens: &[Perm], m: usize, cap: usize) -> Option<u128> {
577    let elements = schreier_sims(degree, gens).elements(cap)?;
578    let order = elements.len() as u128;
579    let total: u128 = elements.iter().map(|g| (m as u128).pow(cycle_type(g).len() as u32)).sum();
580    Some(total / order)
581}
582
583/// The **pattern inventory** (weighted Pólya, two colours) — `coeff[w]` is the number of distinct `{0,1}`
584/// assignments to the points with exactly `w` ones, up to the group. Obtained by substituting
585/// `aₖ → (1 + zᵏ)` into the cycle index: a `k`-cycle is either all-0 or all-1, contributing `1 + zᵏ`. The
586/// coefficients sum to [`polya_count`]`(…, 2, …)`. `None` when `|G| > cap`.
587pub fn pattern_inventory(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
588    let elements = schreier_sims(degree, gens).elements(cap)?;
589    let order = elements.len() as u128;
590    let mut total = vec![0u128; degree + 1];
591    for g in &elements {
592        // Π over cycles of (1 + z^len): a polynomial of total degree = Σ len = degree.
593        let mut poly = vec![0u128; degree + 1];
594        poly[0] = 1;
595        for &len in &cycle_type(g) {
596            let prev = poly.clone();
597            for i in 0..=degree {
598                poly[i] = prev[i] + if i >= len { prev[i - len] } else { 0 };
599            }
600        }
601        for i in 0..=degree {
602            total[i] += poly[i];
603        }
604    }
605    Some(total.iter().map(|&c| c / order).collect())
606}
607
608/// The **abelianisation** `G / [G, G]` — the largest abelian quotient — as `(order, exponent)`. The order
609/// is `|G| / |[G, G]|`; the exponent (lcm of coset orders) and whether it equals the order — i.e. whether
610/// `Gᵃᵇ` is cyclic — are the new structural content. `None` when `|G| > cap`.
611pub fn abelianization(degree: usize, gens: &[Perm], cap: usize) -> Option<(u128, u128)> {
612    let elements = schreier_sims(degree, gens).elements(cap)?;
613    let derived: BTreeSet<Perm> =
614        schreier_sims(degree, &derived_subgroup(degree, gens)).elements(cap)?.into_iter().collect();
615    // Canonical coset representative g·[G,G] = the lexicographically least element of the coset.
616    let coset_rep = |g: &Perm| -> Perm { derived.iter().map(|x| compose(g, x)).min().unwrap() };
617    let cosets: BTreeSet<Perm> = elements.iter().map(|g| coset_rep(g)).collect();
618    let order = cosets.len() as u128;
619    // Order of a coset in the quotient: least k with rᵏ ∈ [G,G].
620    let coset_order = |r: &Perm| -> usize {
621        let mut p = r.clone();
622        let mut k = 1;
623        while !derived.contains(&p) {
624            p = compose(&p, r);
625            k += 1;
626        }
627        k
628    };
629    let exponent = cosets.iter().fold(1u128, |e, r| lcm(e, coset_order(r) as u128));
630    Some((order, exponent))
631}
632
633/// The subgroup generated by `seed` — its closure under composition (with the identity).
634fn subgroup_closure(degree: usize, seed: &BTreeSet<Perm>) -> BTreeSet<Perm> {
635    let mut set = seed.clone();
636    set.insert(identity(degree));
637    loop {
638        let snapshot: Vec<Perm> = set.iter().cloned().collect();
639        let before = set.len();
640        for a in &snapshot {
641            for b in &snapshot {
642                set.insert(compose(a, b));
643            }
644        }
645        if set.len() == before {
646            break;
647        }
648    }
649    set
650}
651
652/// The **number of subgroups** of the group — the size of its subgroup lattice. Found by breadth-first
653/// search: extend each known subgroup by one outside element and close, deduplicating. `None` when
654/// `|G| > cap` (the enumeration, and the lattice walk, are exponential in the worst case). Classic counts:
655/// `C₄ → 3`, `S₃ → 6`, `V₄ → 5`, `S₄ → 30`.
656fn all_subgroups(degree: usize, gens: &[Perm], cap: usize) -> Option<BTreeSet<BTreeSet<Perm>>> {
657    let elements = schreier_sims(degree, gens).elements(cap)?;
658    let trivial: BTreeSet<Perm> = BTreeSet::from([identity(degree)]);
659    let mut subgroups: BTreeSet<BTreeSet<Perm>> = BTreeSet::from([trivial.clone()]);
660    let mut queue = vec![trivial];
661    while let Some(h) = queue.pop() {
662        for g in &elements {
663            if h.contains(g) {
664                continue;
665            }
666            let mut seed = h.clone();
667            seed.insert(g.clone());
668            let sub = subgroup_closure(degree, &seed);
669            if subgroups.insert(sub.clone()) {
670                queue.push(sub);
671            }
672        }
673    }
674    Some(subgroups)
675}
676
677pub fn subgroup_count(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
678    all_subgroups(degree, gens, cap).map(|s| s.len())
679}
680
681/// Is the subgroup (element set) `h` normal — closed under conjugation by the generators?
682fn is_normal_set(h: &BTreeSet<Perm>, gens: &[Perm]) -> bool {
683    gens.iter().all(|g| h.iter().all(|x| h.contains(&compose(&compose(&invert(g), x), g))))
684}
685
686/// A **maximal proper normal subgroup** (the largest-order one) as an element list — so the quotient
687/// `G/N` is simple. `None` if there is none in range.
688fn maximal_normal_subgroup(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Perm>> {
689    let order = schreier_sims(degree, gens).order();
690    let subgroups = all_subgroups(degree, gens, cap)?;
691    subgroups
692        .iter()
693        .filter(|h| (h.len() as u128) < order && is_normal_set(h, gens))
694        .max_by_key(|h| h.len())
695        .map(|h| h.iter().cloned().collect())
696}
697
698/// The **composition factors** of the group as the sorted multiset of their orders — the Jordan–Hölder
699/// decomposition into simple groups (the "prime factorisation" of the group). Their product is `|G|`; for a
700/// solvable group every factor is a prime (cyclic `Cₚ`), and a non-abelian simple factor (e.g. `A₅`, order
701/// 60) marks unsolvability. `None` when the group is out of range.
702pub fn composition_factor_orders(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
703    let order = schreier_sims(degree, gens).order();
704    if order == 1 {
705        return Some(Vec::new());
706    }
707    if is_simple(degree, gens, cap)? {
708        return Some(vec![order]);
709    }
710    let n = maximal_normal_subgroup(degree, gens, cap)?;
711    let n_order = schreier_sims(degree, &n).order();
712    let mut factors = composition_factor_orders(degree, &n, cap)?;
713    factors.push(order / n_order); // |G/N| is simple
714    factors.sort_unstable();
715    Some(factors)
716}
717
718/// The distinct prime divisors of `n`.
719fn distinct_primes(mut n: u128) -> Vec<u128> {
720    let mut primes = Vec::new();
721    let mut p = 2u128;
722    while p * p <= n {
723        if n % p == 0 {
724            primes.push(p);
725            while n % p == 0 {
726                n /= p;
727            }
728        }
729        p += 1;
730    }
731    if n > 1 {
732        primes.push(n);
733    }
734    primes
735}
736
737/// The full `p`-power `pᵃ` with `pᵃ ∥ n` (i.e. `pᵃ | n` but `pᵃ⁺¹ ∤ n`).
738fn prime_power_part(n: u128, p: u128) -> u128 {
739    let mut pa = 1;
740    let mut m = n;
741    while m % p == 0 {
742        pa *= p;
743        m /= p;
744    }
745    pa
746}
747
748/// The **Sylow structure** — for each prime `p ∣ |G|`, the number `n_p` of Sylow `p`-subgroups (the
749/// subgroups of maximal `p`-power order `pᵃ ∥ |G|`; all such subgroups are conjugate, so counting them
750/// counts the Sylow subgroups). Returned as `(p, n_p)` pairs sorted by `p`. Sylow's theorems guarantee
751/// `n_p ≡ 1 (mod p)` and `n_p ∣ |G|/pᵃ`. `None` when the subgroup lattice is out of range.
752pub fn sylow_counts(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<(u128, usize)>> {
753    let order = schreier_sims(degree, gens).order();
754    let subgroups = all_subgroups(degree, gens, cap)?;
755    Some(
756        distinct_primes(order)
757            .into_iter()
758            .map(|p| {
759                let pa = prime_power_part(order, p);
760                let n_p = subgroups.iter().filter(|h| h.len() as u128 == pa).count();
761                (p, n_p)
762            })
763            .collect(),
764    )
765}
766
767/// Map every group element to the index of its conjugacy class.
768fn class_index_map(classes: &[Vec<Perm>]) -> BTreeMap<Perm, usize> {
769    let mut idx = BTreeMap::new();
770    for (i, class) in classes.iter().enumerate() {
771        for g in class {
772            idx.insert(g.clone(), i);
773        }
774    }
775    idx
776}
777
778/// The **class-algebra structure constants** `a[i][j][k] = #{ x ∈ Cᵢ : x⁻¹·z ∈ Cⱼ }` for `z` a fixed
779/// representative of class `Cₖ` (independent of the choice of `z`). These are the multiplication
780/// coefficients of the centre of the group algebra — `Cᵢ·Cⱼ = Σₖ a[i][j][k]·Cₖ` — and the foundation of
781/// the Burnside–Dixon character-table algorithm. They satisfy `Σₖ a[i][j][k]·|Cₖ| = |Cᵢ|·|Cⱼ|`. `None`
782/// when `|G| > cap`.
783pub fn class_multiplication_coefficients(
784    degree: usize,
785    gens: &[Perm],
786    cap: usize,
787) -> Option<Vec<Vec<Vec<u128>>>> {
788    let classes = conjugacy_classes(degree, gens, cap)?;
789    let idx = class_index_map(&classes);
790    let k = classes.len();
791    let mut a = vec![vec![vec![0u128; k]; k]; k];
792    for (kk, class_k) in classes.iter().enumerate() {
793        let z = &class_k[0];
794        for (i, class_i) in classes.iter().enumerate() {
795            for x in class_i {
796                let j = idx[&compose(&invert(x), z)]; // class of x⁻¹·z
797                a[i][j][kk] += 1;
798            }
799        }
800    }
801    Some(a)
802}
803
804/// The number of **real conjugacy classes** — those closed under inversion (`C = C⁻¹`). By Burnside's
805/// theorem this equals the number of real-valued irreducible characters. `None` when `|G| > cap`.
806pub fn real_class_count(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
807    let classes = conjugacy_classes(degree, gens, cap)?;
808    let idx = class_index_map(&classes);
809    Some(classes.iter().enumerate().filter(|(i, c)| idx[&invert(&c[0])] == *i).count())
810}
811
812/// `g^t` under the right-action composition (`(g·h)[x] = h[g[x]]`), by repeated squaring.
813fn perm_pow(g: &[usize], mut t: usize) -> Perm {
814    let mut result = identity(g.len());
815    let mut base = g.to_vec();
816    while t > 0 {
817        if t & 1 == 1 {
818            result = compose(&result, &base);
819        }
820        base = compose(&base, &base);
821        t >>= 1;
822    }
823    result
824}
825
826/// The **Galois orbits on conjugacy classes**. The Galois group `Gal(ℚ(ζ_e)/ℚ) ≅ (ℤ/e)*` (`e` = the group
827/// exponent) acts on classes by `C ↦ C^t` (the class of `g^t`), for every `t` coprime to `e` — this is the
828/// action dual to the Galois action `σ_t(χ)(g) = χ(g^t)` on irreducible characters. Two classes share an
829/// orbit iff they are *algebraically conjugate* (`g ~ g^t` for some coprime `t`). `None` when `|G| > cap`.
830pub fn galois_class_orbits(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Vec<usize>>> {
831    let classes = conjugacy_classes(degree, gens, cap)?;
832    let idx = class_index_map(&classes);
833    let e = exponent(degree, gens, cap)? as usize;
834    let k = classes.len();
835    let mut parent: Vec<usize> = (0..k).collect();
836    fn find(parent: &mut [usize], mut x: usize) -> usize {
837        while parent[x] != x {
838            parent[x] = parent[parent[x]];
839            x = parent[x];
840        }
841        x
842    }
843    for t in 1..e.max(2) {
844        if gcd(t as u128, e as u128) != 1 {
845            continue;
846        }
847        for r in 0..k {
848            let img = idx[&perm_pow(&classes[r][0], t)];
849            let (a, b) = (find(&mut parent, r), find(&mut parent, img));
850            parent[a] = b;
851        }
852    }
853    let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
854    for r in 0..k {
855        let root = find(&mut parent, r);
856        groups.entry(root).or_default().push(r);
857    }
858    Some(groups.into_values().collect())
859}
860
861/// The number of **rational conjugacy classes** — classes `C` fixed by the *whole* Galois group
862/// (`g ~ g^t` for every `t` coprime to `ord(g)`), i.e. the singleton [`galois_class_orbits`]. By Burnside's
863/// rationality theorem this equals the number of rational-valued irreducible characters. Strictly refines
864/// [`real_class_count`] (real = closed under the single element `t = −1`): rational ⟹ real, and the counts
865/// differ exactly when a character is real but irrational (e.g. `A₅`'s golden-ratio degree-3 pair).
866/// `None` when `|G| > cap`.
867pub fn rational_class_count(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
868    Some(galois_class_orbits(degree, gens, cap)?.iter().filter(|o| o.len() == 1).count())
869}
870
871/// The order of the **automorphism group** `Aut(G)` of `G = ⟨gens⟩` — the symmetries of the group itself
872/// (bijections `G → G` preserving multiplication, `φ(xy) = φ(x)φ(y)`). An automorphism is determined by the
873/// images of a generating set, so the search ranges over candidate images (each generator must map to an
874/// element of the same order, a necessary condition) and accepts those that extend to a consistent,
875/// bijective homomorphism. `None` when `|G| > cap` or the candidate search would exceed its budget. Classic:
876/// `|Aut(Cₙ)| = φ(n)`, `|Aut(Sₙ)| = n!` (n≠6), `|Aut(V₄)| = 6`, `|Aut(D₄)| = 8`, `|Aut(Q₈)| = 24`.
877pub fn automorphism_group_order(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
878    let seed: BTreeSet<Perm> = gens.iter().cloned().collect();
879    let elements: Vec<Perm> = subgroup_closure(degree, &seed).into_iter().collect();
880    let n = elements.len();
881    if n > cap {
882        return None;
883    }
884    let idx: BTreeMap<Perm, usize> = elements.iter().enumerate().map(|(i, e)| (e.clone(), i)).collect();
885    let id_idx = idx[&identity(degree)];
886    let mul: Vec<Vec<usize>> =
887        (0..n).map(|i| (0..n).map(|j| idx[&compose(&elements[i], &elements[j])]).collect()).collect();
888    let ord: Vec<usize> = elements.iter().map(|e| element_order(e)).collect();
889    // A generating set: the distinct non-identity input generators (the closure is unchanged).
890    let mut gen_idx: Vec<usize> = Vec::new();
891    for g in gens {
892        let gi = idx[g];
893        if gi != id_idx && !gen_idx.contains(&gi) {
894            gen_idx.push(gi);
895        }
896    }
897    if gen_idx.is_empty() {
898        return Some(1); // the trivial group has only the identity automorphism
899    }
900    // Candidate images per generator: same-order elements. Budget the search-space product.
901    let candidates: Vec<Vec<usize>> =
902        gen_idx.iter().map(|&gi| (0..n).filter(|&e| ord[e] == ord[gi]).collect::<Vec<_>>()).collect();
903    if candidates.iter().map(|c| c.len() as u128).product::<u128>() > 2_000_000 {
904        return None;
905    }
906    let m = gen_idx.len();
907    let mut count = 0u128;
908    let mut choice = vec![0usize; m];
909    loop {
910        let img: Vec<usize> = (0..m).map(|t| candidates[t][choice[t]]).collect();
911        // Extend φ (sending gen_idx[t] ↦ img[t]) by BFS over the Cayley graph from the identity.
912        let mut phi = vec![usize::MAX; n];
913        phi[id_idx] = id_idx;
914        let mut queue = vec![id_idx];
915        let mut head = 0;
916        let mut ok = true;
917        'bfs: while head < queue.len() {
918            let u = queue[head];
919            head += 1;
920            for t in 0..m {
921                let ug = mul[u][gen_idx[t]];
922                let target = mul[phi[u]][img[t]];
923                if phi[ug] == usize::MAX {
924                    phi[ug] = target;
925                    queue.push(ug);
926                } else if phi[ug] != target {
927                    ok = false;
928                    break 'bfs;
929                }
930            }
931        }
932        // A valid automorphism is total and bijective.
933        if ok && phi.iter().all(|&x| x != usize::MAX) {
934            let mut seen = vec![false; n];
935            if phi.iter().all(|&x| !std::mem::replace(&mut seen[x], true)) {
936                count += 1;
937            }
938        }
939        let mut t = 0;
940        while t < m {
941            choice[t] += 1;
942            if choice[t] < candidates[t].len() {
943                break;
944            }
945            choice[t] = 0;
946            t += 1;
947        }
948        if t == m {
949            break;
950        }
951    }
952    Some(count)
953}
954
955/// The order of the **outer automorphism group** `Out(G) = Aut(G)/Inn(G)`, where the inner automorphisms
956/// `Inn(G) ≅ G/Z(G)` are those realised by conjugation. `Out(G)` counts the "exotic" symmetries of the
957/// group not coming from within it. `None` when out of range.
958pub fn outer_automorphism_order(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
959    let aut = automorphism_group_order(degree, gens, cap)?;
960    let order = schreier_sims(degree, gens).order();
961    let center = center_order(degree, gens, cap)?;
962    Some(aut / (order / center)) // |Inn(G)| = |G| / |Z(G)|
963}
964
965/// The **table of marks** of `G = ⟨gens⟩` — the Burnside-ring analogue of the character table. Rows and
966/// columns are the conjugacy classes of subgroups (ordered by increasing order); the `(i,j)` entry is the
967/// **mark** `m(H_i, H_j)` = the number of `H_i`-fixed points in the transitive action of `G` on the cosets
968/// `G/H_j`, computed as `(1/|H_j|)·|{g ∈ G : g⁻¹ H_i g ⊆ H_j}|`. Returns `(subgroup_class_orders, marks)`.
969///
970/// The complete invariant of the category of `G`-sets: every finite `G`-set decomposes uniquely into the
971/// transitive ones `G/H_j`, and the marks record how each subgroup sees each. With this ordering the matrix
972/// is triangular with diagonal `[N_G(H_i):H_i]`, hence invertible. Where [`character_table`] classifies the
973/// LINEAR representations of `G`, the table of marks classifies its PERMUTATION representations. Exact
974/// integer arithmetic. `None` when `|G| > cap`.
975pub fn table_of_marks(degree: usize, gens: &[Perm], cap: usize) -> Option<(Vec<u128>, Vec<Vec<u128>>)> {
976    let elements: Vec<Perm> =
977        subgroup_closure(degree, &gens.iter().cloned().collect()).into_iter().collect();
978    let subs = all_subgroups(degree, gens, cap)?;
979    // Conjugation x ↦ g⁻¹ x g, applied to a whole subgroup.
980    let conjugate = |h: &BTreeSet<Perm>, g: &Perm| -> BTreeSet<Perm> {
981        let gi = invert(g);
982        h.iter().map(|x| compose(&compose(&gi, x), g)).collect()
983    };
984    // One representative per conjugacy class of subgroups.
985    let mut reps: Vec<BTreeSet<Perm>> = Vec::new();
986    let mut seen: BTreeSet<BTreeSet<Perm>> = BTreeSet::new();
987    for h in &subs {
988        if seen.contains(h) {
989            continue;
990        }
991        for g in &elements {
992            seen.insert(conjugate(h, g));
993        }
994        reps.push(h.clone());
995    }
996    // Order classes by subgroup order, then canonically — makes the table triangular and deterministic.
997    reps.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
998    let k = reps.len();
999    let orders: Vec<u128> = reps.iter().map(|h| h.len() as u128).collect();
1000    let mut marks = vec![vec![0u128; k]; k];
1001    for i in 0..k {
1002        for j in 0..k {
1003            if reps[i].len() > reps[j].len() {
1004                continue; // a larger subgroup cannot be conjugated inside a smaller one
1005            }
1006            let count = elements
1007                .iter()
1008                .filter(|g| conjugate(&reps[i], g).iter().all(|x| reps[j].contains(x)))
1009                .count() as u128;
1010            marks[i][j] = count / reps[j].len() as u128;
1011        }
1012    }
1013    Some((orders, marks))
1014}
1015
1016/// The **Burnside ring** multiplication of `G = ⟨gens⟩` — the structure constants `N[a][b][l]` giving the
1017/// decomposition of the product G-set `(G/H_a) × (G/H_b) = ⊔_l N[a][b][l]·(G/H_l)` into transitive G-sets,
1018/// indexed by the conjugacy classes of subgroups (same order as [`table_of_marks`]).
1019///
1020/// Marks are multiplicative — a subgroup fixes a pair iff it fixes each coordinate — so the mark vector of a
1021/// product is the componentwise product of the factors' mark vectors; the (triangular, invertible) table of
1022/// marks is then back-substituted to recover the multiplicities. This is the multiplication of the Burnside
1023/// ring, the G-set analogue of the tensor/fusion ring of the character table ([`tensor_decomposition`]).
1024/// FAIL-CLOSED: `None` if any back-substitution is inexact (it never is for genuine G-sets, so this
1025/// certifies the result). Coefficients are non-negative integers.
1026pub fn burnside_ring_product(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Vec<Vec<i128>>>> {
1027    let (_orders, marks) = table_of_marks(degree, gens, cap)?;
1028    let k = marks.len();
1029    // Solve marks · c = p by back-substitution (indices ordered by increasing subgroup order, so the system
1030    // is upper-triangular with a nonzero diagonal).
1031    let solve = |p: &[u128]| -> Option<Vec<i128>> {
1032        let mut c = vec![0i128; k];
1033        for i in (0..k).rev() {
1034            let mut acc = p[i] as i128;
1035            for l in (i + 1)..k {
1036                acc -= marks[i][l] as i128 * c[l];
1037            }
1038            let diag = marks[i][i] as i128;
1039            if diag == 0 || acc % diag != 0 {
1040                return None; // inexact ⇒ not a genuine G-set decomposition
1041            }
1042            c[i] = acc / diag;
1043        }
1044        Some(c)
1045    };
1046    let mut n = vec![vec![vec![0i128; k]; k]; k];
1047    for a in 0..k {
1048        for b in 0..k {
1049            let p: Vec<u128> = (0..k).map(|i| marks[i][a] * marks[i][b]).collect();
1050            let c = solve(&p)?;
1051            for l in 0..k {
1052                n[a][b][l] = c[l];
1053            }
1054        }
1055    }
1056    Some(n)
1057}
1058
1059/// The **Möbius function to the top** of the subgroup lattice: `μ(H, G)` for every subgroup `H`, returned as
1060/// `(subgroup_orders, mu)` aligned by index. Defined by `μ(G,G)=1` and `μ(H,G) = -Σ_{H ⊊ L} μ(L,G)`. `None`
1061/// when `|G| > cap`.
1062fn lattice_mobius_to_top(degree: usize, gens: &[Perm], cap: usize) -> Option<(Vec<u128>, Vec<i128>)> {
1063    let subs: Vec<BTreeSet<Perm>> = all_subgroups(degree, gens, cap)?.into_iter().collect();
1064    let n = subs.len();
1065    let top_size = subs.iter().map(|h| h.len()).max().unwrap_or(0); // |G|, unique maximum
1066    let mut order: Vec<usize> = (0..n).collect();
1067    order.sort_by_key(|&i| std::cmp::Reverse(subs[i].len())); // descending: compute supergroups first
1068    let mut mu = vec![0i128; n];
1069    for &i in &order {
1070        if subs[i].len() == top_size {
1071            mu[i] = 1; // μ(G, G) = 1
1072        } else {
1073            let s: i128 = (0..n)
1074                .filter(|&j| subs[i].len() < subs[j].len() && subs[i].is_subset(&subs[j]))
1075                .map(|j| mu[j])
1076                .sum();
1077            mu[i] = -s;
1078        }
1079    }
1080    Some((subs.iter().map(|h| h.len() as u128).collect(), mu))
1081}
1082
1083/// The **Möbius number** `μ(1, G)` of the subgroup lattice of `G = ⟨gens⟩` — the value of the lattice's
1084/// Möbius function from the trivial subgroup to the whole group. A classical invariant: for a cyclic group
1085/// `μ(1, Cₙ)` is the number-theoretic Möbius function `μ(n)`, and in general it drives the group's Eulerian
1086/// (probabilistic-zeta) function. `None` when `|G| > cap`.
1087pub fn mobius_number(degree: usize, gens: &[Perm], cap: usize) -> Option<i128> {
1088    let (orders, mu) = lattice_mobius_to_top(degree, gens, cap)?;
1089    (0..orders.len()).find(|&i| orders[i] == 1).map(|i| mu[i])
1090}
1091
1092/// The number of ordered `k`-tuples of group elements that **generate** `G` — the Eulerian function
1093/// `e_k(G) = Σ_{H ≤ G} μ(H, G)·|H|ᵏ` (Hall's formula by Möbius inversion over the subgroup lattice, since
1094/// `|H|ᵏ` counts the `k`-tuples landing in `H`). Dividing by `|G|ᵏ` gives the probability that `k` random
1095/// elements generate `G`. `None` when `|G| > cap`.
1096pub fn generating_tuple_count(degree: usize, gens: &[Perm], cap: usize, k: u32) -> Option<i128> {
1097    let (orders, mu) = lattice_mobius_to_top(degree, gens, cap)?;
1098    Some((0..orders.len()).map(|i| mu[i] * (orders[i] as i128).pow(k)).sum())
1099}
1100
1101/// One representative per conjugacy class of subgroups, ordered by increasing order (the row/column index
1102/// shared by [`table_of_marks`] and the permutation-character decomposition).
1103fn subgroup_class_reps(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<BTreeSet<Perm>>> {
1104    let elements: Vec<Perm> =
1105        subgroup_closure(degree, &gens.iter().cloned().collect()).into_iter().collect();
1106    let subs = all_subgroups(degree, gens, cap)?;
1107    let conjugate = |h: &BTreeSet<Perm>, g: &Perm| -> BTreeSet<Perm> {
1108        let gi = invert(g);
1109        h.iter().map(|x| compose(&compose(&gi, x), g)).collect()
1110    };
1111    let mut reps: Vec<BTreeSet<Perm>> = Vec::new();
1112    let mut seen: BTreeSet<BTreeSet<Perm>> = BTreeSet::new();
1113    for h in &subs {
1114        if seen.contains(h) {
1115            continue;
1116        }
1117        for g in &elements {
1118            seen.insert(conjugate(h, g));
1119        }
1120        reps.push(h.clone());
1121    }
1122    reps.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
1123    Some(reps)
1124}
1125
1126/// The **permutation-character decomposition** — the bridge between the table of marks and the character
1127/// table. `M[i][s]` is the multiplicity of the irreducible `χ_s` in the permutation representation of `G`
1128/// on the cosets `G/H_i`, i.e. `M[i][s] = ⟨Ind_{H_i}^G 1, χ_s⟩ = (1/|H_i|)·Σ_{h ∈ H_i} χ_s(h)` (Frobenius
1129/// reciprocity = the dimension of the `H_i`-fixed subspace of `χ_s`). Rows are subgroup conjugacy classes
1130/// (as in [`table_of_marks`]), columns are irreducibles (as in [`character_table`]). Returns
1131/// `(subgroup_orders, irreducible_degrees, M)`.
1132///
1133/// This is the "linearization" map from the Burnside ring to the representation ring made explicit. The
1134/// character values are read off the `GF(p)` character table; each multiplicity is a small non-negative
1135/// integer (`≤ d_s`), so it decodes uniquely. FAIL-CLOSED: `None` unless `M[1] = ` the degrees (the regular
1136/// representation contains each `χ_s` with multiplicity `d_s`), `M[i][trivial] = 1` (every transitive action
1137/// contains the trivial character once), `M[G] = e_trivial`, and `Σ_s M[i][s]·d_s = [G:H_i]` for every row.
1138pub fn permutation_character_decomposition(
1139    degree: usize,
1140    gens: &[Perm],
1141    cap: usize,
1142) -> Option<(Vec<u128>, Vec<u128>, Vec<Vec<u128>>)> {
1143    let ct = character_table(degree, gens, cap)?;
1144    let p = ct.prime;
1145    let k = ct.degrees.len();
1146    let classes = conjugacy_classes(degree, gens, cap)?;
1147    let idx = class_index_map(&classes);
1148    let reps = subgroup_class_reps(degree, gens, cap)?;
1149    let order: u128 = ct.degrees.iter().map(|d| d * d).sum();
1150
1151    let mut m = vec![vec![0u128; k]; reps.len()];
1152    for (i, h) in reps.iter().enumerate() {
1153        // |H_i ∩ C_r| for each conjugacy class C_r.
1154        let mut inter = vec![0u128; k];
1155        for x in h {
1156            inter[idx[x]] += 1;
1157        }
1158        let inv_h = mod_inv((h.len() as u128 % p as u128) as u64, p);
1159        for s in 0..k {
1160            let mut acc = 0u64;
1161            for r in 0..k {
1162                let term = (inter[r] % p as u128) as u64;
1163                acc = ((acc as u128 + term as u128 * ct.values[s][r] as u128) % p as u128) as u64;
1164            }
1165            m[i][s] = (acc as u128 * inv_h as u128 % p as u128) as u128;
1166        }
1167    }
1168
1169    // FAIL-CLOSED verification against the classical identities.
1170    let trivial_irr = ct.values.iter().position(|row| row.iter().all(|&x| x == 1))?;
1171    for (i, h) in reps.iter().enumerate() {
1172        if m[i][trivial_irr] != 1 {
1173            return None; // a transitive action contains the trivial character exactly once
1174        }
1175        let dim: u128 = (0..k).map(|s| m[i][s] * ct.degrees[s]).sum();
1176        if dim != order / h.len() as u128 {
1177            return None; // Σ_s M·d_s = [G : H_i]
1178        }
1179        for s in 0..k {
1180            if m[i][s] > ct.degrees[s] {
1181                return None; // multiplicity cannot exceed the degree
1182            }
1183        }
1184    }
1185    if m[0] != ct.degrees {
1186        return None; // the regular representation G/1 = Σ_s d_s·χ_s
1187    }
1188    let mut e_triv = vec![0u128; k];
1189    e_triv[trivial_irr] = 1;
1190    if *m.last()? != e_triv {
1191        return None; // G/G is the trivial representation
1192    }
1193    let orders: Vec<u128> = reps.iter().map(|h| h.len() as u128).collect();
1194    Some((orders, ct.degrees, m))
1195}
1196
1197// ---- Character table via the Burnside–Dixon method --------------------------------------------
1198//
1199// The class sums `Ĉ_r = Σ_{g∈C_r} g` span the centre of the group algebra; left-multiplication by `Ĉ_i`
1200// is the matrix `M_i` with `M_i[k][j] = a[i][j][k]` (the structure constants). The `M_i` pairwise commute
1201// (the centre is commutative) and are simultaneously diagonalisable, with exactly `k = #classes` common
1202// eigenvectors — one per irreducible character `χ_s`. The eigenvalue of `M_i` on `χ_s`'s eigenvector is
1203// `ω_i(χ_s) = |C_i|·χ_s(C_i)/d_s` (`d_s = χ_s(1)`). Dixon's insight: work over `GF(p)` for a prime
1204// `p ≡ 1 (mod exponent)` with `p > |G|`, where every `ω` lives (characters are sums of `e`-th roots of
1205// unity, which `GF(p)` contains), so the whole computation is EXACT — no floats, no SDP.
1206
1207/// `aᵇ mod p`. `p` need not be prime here.
1208fn mod_pow(mut base: u64, mut exp: u64, p: u64) -> u64 {
1209    let mut r = 1u128;
1210    let mut b = (base % p) as u128;
1211    base = 0; // silence unused-assignment style; b carries the value
1212    let _ = base;
1213    while exp > 0 {
1214        if exp & 1 == 1 {
1215            r = (r * b) % p as u128;
1216        }
1217        b = (b * b) % p as u128;
1218        exp >>= 1;
1219    }
1220    r as u64
1221}
1222
1223/// Multiplicative inverse in `GF(p)` (`p` prime) via Fermat: `a^(p-2)`. `a` must be `≢ 0`.
1224pub(crate) fn mod_inv(a: u64, p: u64) -> u64 {
1225    mod_pow(a % p, p - 2, p)
1226}
1227
1228/// Trial-division primality (the Dixon primes here are small — `O(√|G|)`-ish).
1229pub(crate) fn is_prime(n: u64) -> bool {
1230    if n < 2 {
1231        return false;
1232    }
1233    if n % 2 == 0 {
1234        return n == 2;
1235    }
1236    let mut d = 3u64;
1237    while d * d <= n {
1238        if n % d == 0 {
1239            return false;
1240        }
1241        d += 2;
1242    }
1243    true
1244}
1245
1246/// Integer floor square root.
1247fn isqrt(n: u128) -> u128 {
1248    if n < 2 {
1249        return n;
1250    }
1251    let mut x = (n as f64).sqrt() as u128;
1252    while x * x > n {
1253        x -= 1;
1254    }
1255    while (x + 1) * (x + 1) <= n {
1256        x += 1;
1257    }
1258    x
1259}
1260
1261/// `M·v` over `GF(p)` (`m` is `k×k` as rows, `v` length `k`).
1262pub(crate) fn gf_mat_vec(m: &[Vec<u64>], v: &[u64], p: u64) -> Vec<u64> {
1263    m.iter()
1264        .map(|row| {
1265            let mut acc = 0u128;
1266            for (a, b) in row.iter().zip(v) {
1267                acc += (*a as u128) * (*b as u128);
1268            }
1269            (acc % p as u128) as u64
1270        })
1271        .collect()
1272}
1273
1274/// A basis for the right null space `{x ∈ GF(p)^ncols : A·x = 0}` of `a` (given as rows), via RREF.
1275pub(crate) fn gf_nullspace(mut a: Vec<Vec<u64>>, ncols: usize, p: u64) -> Vec<Vec<u64>> {
1276    let nrows = a.len();
1277    let mut where_pivot = vec![usize::MAX; ncols]; // pivot row of each column, or MAX
1278    let mut row = 0usize;
1279    for col in 0..ncols {
1280        if row >= nrows {
1281            break;
1282        }
1283        let Some(sel) = (row..nrows).find(|&r| a[r][col] % p != 0) else { continue };
1284        a.swap(row, sel);
1285        let inv = mod_inv(a[row][col], p);
1286        for c in 0..ncols {
1287            a[row][c] = ((a[row][c] as u128 * inv as u128) % p as u128) as u64;
1288        }
1289        for r in 0..nrows {
1290            if r != row && a[r][col] != 0 {
1291                let f = a[r][col] as u128;
1292                for c in 0..ncols {
1293                    let sub = (f * a[row][c] as u128) % p as u128;
1294                    a[r][c] = ((a[r][c] as u128 + p as u128 - sub) % p as u128) as u64;
1295                }
1296            }
1297        }
1298        where_pivot[col] = row;
1299        row += 1;
1300    }
1301    let mut basis = Vec::new();
1302    for fc in 0..ncols {
1303        if where_pivot[fc] != usize::MAX {
1304            continue; // pivot column, not free
1305        }
1306        let mut x = vec![0u64; ncols];
1307        x[fc] = 1;
1308        for (col, &pr) in where_pivot.iter().enumerate() {
1309            if pr != usize::MAX {
1310                x[col] = (p - a[pr][fc] % p) % p; // x[col] = -a[pr][fc]
1311            }
1312        }
1313        basis.push(x);
1314    }
1315    basis
1316}
1317
1318/// The character table of `⟨gens⟩`, computed exactly by Dixon's method.
1319#[derive(Clone, Debug, PartialEq, Eq)]
1320pub struct CharacterTable {
1321    /// The Dixon prime `p` (`p ≡ 1 mod exponent`, `p > |G|`) the values live in.
1322    pub prime: u64,
1323    /// Conjugacy class sizes `|C_r|` (column `r`).
1324    pub class_sizes: Vec<u128>,
1325    /// Inverse-class index `r̄` (`C_{r̄} = C_r⁻¹`); `χ(C_{r̄})` is the `GF(p)` image of `conj χ(C_r)`.
1326    pub inverse_class: Vec<usize>,
1327    /// The squaring power map: `power_map2[r]` is the class index of `g_r²` (`g_r ∈ C_r`) — a class
1328    /// invariant (`(xgx⁻¹)² = xg²x⁻¹`), the input to the Frobenius–Schur indicators.
1329    pub power_map2: Vec<usize>,
1330    /// The index of the identity class (`{e}`).
1331    pub identity_class: usize,
1332    /// A representative permutation of each class (`C_r`'s first element) — lets character-derived
1333    /// quantities that depend on the underlying action (e.g. the permutation character) read off the table.
1334    pub class_reps: Vec<Perm>,
1335    /// Irreducible degrees `χ_s(1)` (row `s`), as exact integers; `Σ d_s² = |G|`.
1336    pub degrees: Vec<u128>,
1337    /// `χ_s(C_r)` as a `GF(p)` element (the image of the algebraic character value). `values[s][r]`.
1338    pub values: Vec<Vec<u64>>,
1339}
1340
1341/// Compute the full character table via the Burnside–Dixon algorithm: build the commuting class-algebra
1342/// matrices `M_i` from the structure constants, choose a Dixon prime `p`, simultaneously diagonalise the
1343/// `M_i` over `GF(p)` by iterated eigenspace refinement (one common eigenvector per irreducible), then
1344/// read off each degree `d_s` (exact integer, via `d² = |G|/Σ_r ω_r ω_{r̄}/|C_r|`) and the character
1345/// values `χ_s(C_r) = d_s ω_r/|C_r|`. Returns `None` when `|G| > cap`, the group is too large for the
1346/// finite-field arithmetic, or — FAIL-CLOSED — the recovered table does not satisfy the row-orthogonality
1347/// and degree relations (so a returned table is always a verified one). Rows are sorted by `(degree,
1348/// values)` for determinism.
1349pub fn character_table(degree: usize, gens: &[Perm], cap: usize) -> Option<CharacterTable> {
1350    let classes = conjugacy_classes(degree, gens, cap)?;
1351    let k = classes.len();
1352    // Bound the finite-field work: the structure tensor is k³ and the diagonalisation is O(p·k⁴).
1353    if k == 0 || k > 64 {
1354        return None;
1355    }
1356    let order = schreier_sims(degree, gens).order();
1357    if order == 0 || order > 100_000 {
1358        return None;
1359    }
1360    let idx = class_index_map(&classes);
1361    let class_sizes: Vec<u128> = classes.iter().map(|c| c.len() as u128).collect();
1362    let inverse_class: Vec<usize> = classes.iter().map(|c| idx[&invert(&c[0])]).collect();
1363    let power_map2: Vec<usize> = classes.iter().map(|c| idx[&compose(&c[0], &c[0])]).collect();
1364    let class_reps: Vec<Perm> = classes.iter().map(|c| c[0].clone()).collect();
1365    let id_perm = identity(degree);
1366    let identity_class = classes.iter().position(|c| c.iter().any(|g| *g == id_perm))?;
1367
1368    // Dixon prime: p ≡ 1 (mod exponent), prime, and p > |G| (so |G| and every |C_r| are invertible and
1369    // degree recovery is unique — p > |G| ⟹ p > 2√|G|).
1370    let e = exponent(degree, gens, cap)? as u64;
1371    let order_u = order as u64;
1372    let p = {
1373        let mut m = order_u / e + 1;
1374        let mut found = None;
1375        for _ in 0..1_000_000 {
1376            let cand = e.checked_mul(m)?.checked_add(1)?;
1377            if cand > order_u && is_prime(cand) {
1378                found = Some(cand);
1379                break;
1380            }
1381            m += 1;
1382        }
1383        found?
1384    };
1385
1386    // Structure constants → the commuting matrices M_i with M_i[k][j] = a[i][j][k] (mod p).
1387    let a = class_multiplication_coefficients(degree, gens, cap)?;
1388    let mmats: Vec<Vec<Vec<u64>>> = (0..k)
1389        .map(|i| {
1390            let mut m = vec![vec![0u64; k]; k];
1391            for j in 0..k {
1392                for kk in 0..k {
1393                    m[kk][j] = (a[i][j][kk] % p as u128) as u64;
1394                }
1395            }
1396            m
1397        })
1398        .collect();
1399
1400    // Simultaneous diagonalisation: refine the whole space into the k common 1-dim eigenspaces.
1401    let mut subspaces: Vec<Vec<Vec<u64>>> = vec![(0..k)
1402        .map(|i| {
1403            let mut ei = vec![0u64; k];
1404            ei[i] = 1;
1405            ei
1406        })
1407        .collect()];
1408    for mi in &mmats {
1409        if subspaces.iter().all(|s| s.len() == 1) {
1410            break;
1411        }
1412        let mut next: Vec<Vec<Vec<u64>>> = Vec::new();
1413        for s in &subspaces {
1414            if s.len() == 1 {
1415                next.push(s.clone());
1416                continue;
1417            }
1418            let bn = s.len();
1419            let mb: Vec<Vec<u64>> = s.iter().map(|b| gf_mat_vec(mi, b, p)).collect();
1420            let mut pieces: Vec<Vec<Vec<u64>>> = Vec::new();
1421            let mut covered = 0usize;
1422            for lam in 0..p {
1423                // Null space of (M_i - λI)|_s in the basis `s`: columns are (M_i - λI)·b_j.
1424                let mut rows = vec![vec![0u64; bn]; k];
1425                for r in 0..k {
1426                    for (j, bj) in s.iter().enumerate() {
1427                        let shifted = (lam as u128 * bj[r] as u128) % p as u128;
1428                        rows[r][j] = ((mb[j][r] as u128 + p as u128 - shifted) % p as u128) as u64;
1429                    }
1430                }
1431                let ns = gf_nullspace(rows, bn, p);
1432                if ns.is_empty() {
1433                    continue;
1434                }
1435                let eig: Vec<Vec<u64>> = ns
1436                    .iter()
1437                    .map(|c| {
1438                        let mut x = vec![0u64; k];
1439                        for (j, &cj) in c.iter().enumerate() {
1440                            if cj != 0 {
1441                                for r in 0..k {
1442                                    x[r] = ((x[r] as u128 + cj as u128 * s[j][r] as u128) % p as u128) as u64;
1443                                }
1444                            }
1445                        }
1446                        x
1447                    })
1448                    .collect();
1449                covered += eig.len();
1450                pieces.push(eig);
1451                if covered == bn {
1452                    break;
1453                }
1454            }
1455            if covered == bn {
1456                next.extend(pieces);
1457            } else {
1458                next.push(s.clone()); // a later M_i may split it; final check rejects if none do
1459            }
1460        }
1461        subspaces = next;
1462    }
1463    if subspaces.iter().any(|s| s.len() != 1) {
1464        return None; // did not fully diagonalise — fail closed
1465    }
1466
1467    // Read off each irreducible from its common eigenvector.
1468    let order_p = (order % p as u128) as u64;
1469    let max_deg = isqrt(order);
1470    let mut rows: Vec<(u128, Vec<u64>)> = Vec::with_capacity(k);
1471    for s in &subspaces {
1472        let v = &s[0];
1473        let t = v.iter().position(|&x| x != 0)?;
1474        let inv_vt = mod_inv(v[t], p);
1475        let omega: Vec<u64> = mmats
1476            .iter()
1477            .map(|mi| {
1478                let mv = gf_mat_vec(mi, v, p);
1479                ((mv[t] as u128 * inv_vt as u128) % p as u128) as u64
1480            })
1481            .collect();
1482        // d² = |G| / Σ_r ω_r·ω_{r̄}/|C_r|  (all over GF(p)).
1483        let mut denom = 0u64;
1484        for r in 0..k {
1485            let hr = (class_sizes[r] % p as u128) as u64;
1486            let term = (omega[r] as u128 * omega[inverse_class[r]] as u128 % p as u128) as u64;
1487            let contrib = (term as u128 * mod_inv(hr, p) as u128 % p as u128) as u64;
1488            denom = (denom + contrib) % p;
1489        }
1490        if denom == 0 {
1491            return None;
1492        }
1493        let d2 = (order_p as u128 * mod_inv(denom, p) as u128 % p as u128) as u64;
1494        // Recover the integer degree: the unique d ≤ √|G| dividing |G| with d² ≡ d2 (mod p).
1495        let mut deg = None;
1496        let mut d = 1u128;
1497        while d <= max_deg {
1498            if order % d == 0 && ((d * d) % p as u128) as u64 == d2 {
1499                deg = Some(d);
1500                break;
1501            }
1502            d += 1;
1503        }
1504        let deg = deg?;
1505        let vals: Vec<u64> = (0..k)
1506            .map(|r| {
1507                let hr = (class_sizes[r] % p as u128) as u64;
1508                let num = (deg % p as u128) as u64 as u128 * omega[r] as u128 % p as u128;
1509                (num * mod_inv(hr, p) as u128 % p as u128) as u64
1510            })
1511            .collect();
1512        rows.push((deg, vals));
1513    }
1514    rows.sort();
1515    let degrees: Vec<u128> = rows.iter().map(|(d, _)| *d).collect();
1516    let values: Vec<Vec<u64>> = rows.into_iter().map(|(_, v)| v).collect();
1517
1518    // FAIL-CLOSED verification: degrees and row-orthogonality must hold, else we discovered nothing real.
1519    if degrees.iter().map(|d| d * d).sum::<u128>() != order {
1520        return None;
1521    }
1522    if !values.iter().any(|row| row.iter().all(|&x| x == 1)) {
1523        return None; // the trivial character must be present
1524    }
1525    for s in 0..k {
1526        for t in 0..k {
1527            let mut acc = 0u64;
1528            for r in 0..k {
1529                let hr = (class_sizes[r] % p as u128) as u64;
1530                let prod = values[s][r] as u128 * values[t][inverse_class[r]] as u128 % p as u128;
1531                acc = ((acc as u128 + hr as u128 * prod) % p as u128) as u64;
1532            }
1533            let want = if s == t { order_p } else { 0 };
1534            if acc != want {
1535                return None;
1536            }
1537        }
1538    }
1539
1540    Some(CharacterTable {
1541        prime: p,
1542        class_sizes,
1543        inverse_class,
1544        power_map2,
1545        identity_class,
1546        class_reps,
1547        degrees,
1548        values,
1549    })
1550}
1551
1552/// The **Frobenius–Schur indicators** `ν(χ_s) = (1/|G|) Σ_{g∈G} χ_s(g²) ∈ {+1, 0, −1}` read off a
1553/// character table: `+1` if the irreducible is **real** (orthogonal), `0` if **complex** (`χ ≠ χ̄`), `−1`
1554/// if **quaternionic** (symplectic). Computed exactly over the table's `GF(p)`:
1555/// `Σ_r |C_r|·χ_s(C_{r²})`, scaled by `1/|G|`, decoded `{0, 1, p−1} → {0, 1, −1}`. FAIL-CLOSED: returns
1556/// `None` if any value decodes outside `{−1,0,1}` or the Frobenius–Schur sum rule
1557/// `Σ_s ν(χ_s)·d_s = #{g : g²=1}` fails. The indicators distinguish groups with identical character
1558/// tables (e.g. `D₄` all `+1` vs `Q₈` with a `−1`).
1559pub fn frobenius_schur_from_table(t: &CharacterTable) -> Option<Vec<i8>> {
1560    let p = t.prime;
1561    let order: u128 = t.degrees.iter().map(|d| d * d).sum();
1562    let inv_order = mod_inv((order % p as u128) as u64, p);
1563    let k = t.degrees.len();
1564    // #{g : g² = e} = Σ over classes whose square is the identity class.
1565    let involutions_plus_id: u128 = (0..k)
1566        .filter(|&r| t.power_map2[r] == t.identity_class)
1567        .map(|r| t.class_sizes[r])
1568        .sum();
1569    let mut nu = Vec::with_capacity(k);
1570    for s in 0..k {
1571        let mut acc = 0u64;
1572        for r in 0..k {
1573            let hr = (t.class_sizes[r] % p as u128) as u64;
1574            let chi_sq = t.values[s][t.power_map2[r]];
1575            acc = ((acc as u128 + hr as u128 * chi_sq as u128) % p as u128) as u64;
1576        }
1577        let val = ((acc as u128 * inv_order as u128) % p as u128) as u64;
1578        let ind: i8 = if val == 0 {
1579            0
1580        } else if val == 1 {
1581            1
1582        } else if val == p - 1 {
1583            -1
1584        } else {
1585            return None; // not an indicator — fail closed
1586        };
1587        nu.push(ind);
1588    }
1589    // The Frobenius–Schur counting theorem: Σ_s ν_s·d_s = #{g : g²=1}.
1590    let sum: i128 = nu.iter().zip(&t.degrees).map(|(&v, &d)| v as i128 * d as i128).sum();
1591    if sum != involutions_plus_id as i128 {
1592        return None;
1593    }
1594    Some(nu)
1595}
1596
1597/// The Frobenius–Schur indicators of `⟨gens⟩`, one per irreducible character (aligned with
1598/// [`character_table`]'s rows). See [`frobenius_schur_from_table`]. `None` when the character table is out
1599/// of range.
1600pub fn frobenius_schur_indicators(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<i8>> {
1601    frobenius_schur_from_table(&character_table(degree, gens, cap)?)
1602}
1603
1604/// The **permutation character** `π(g) = #{points fixed by g}` of the natural action of `⟨gens⟩` on its
1605/// `degree` points, valued per conjugacy class (a class invariant, since conjugate permutations have the
1606/// same cycle type). The character of the permutation representation `ℂ^degree`. Aligned with the conjugacy
1607/// classes (so with [`CharacterTable`]'s columns). `None` when `|G| > cap`.
1608pub fn permutation_character(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
1609    let classes = conjugacy_classes(degree, gens, cap)?;
1610    Some(
1611        classes
1612            .iter()
1613            .map(|c| c[0].iter().enumerate().filter(|(i, &x)| *i == x).count() as u128)
1614            .collect(),
1615    )
1616}
1617
1618/// The **isotypic decomposition** of the permutation representation: the multiplicity `m_s = ⟨π, χ_s⟩` of
1619/// each irreducible `χ_s` in the natural action's character `π`, i.e. `π = Σ_s m_s χ_s`. Computed from a
1620/// character table: `m_s = (1/|G|) Σ_r |C_r|·π(C_r)·χ_s(C_{r̄})` over the table's `GF(p)`. The
1621/// representation-theoretic spectrum of the symmetry — it ties the linear theory back to the action:
1622/// `m_trivial = #orbits` (Burnside), `Σ_s m_s² = rank` (#orbitals), `Σ_s m_s·d_s = degree`. FAIL-CLOSED:
1623/// `None` if `p ≤ degree` (then the small non-negative `m_s ≤ degree` would not decode uniquely) or if any
1624/// of those three identities fails. Aligned with the table's rows.
1625pub fn isotypic_from_table(degree: usize, gens: &[Perm], t: &CharacterTable) -> Option<Vec<u128>> {
1626    let p = t.prime;
1627    if p as u128 <= degree as u128 {
1628        return None; // multiplicities (≤ degree) must fit below p to decode uniquely
1629    }
1630    let order: u128 = t.degrees.iter().map(|d| d * d).sum();
1631    let inv_order = mod_inv((order % p as u128) as u64, p);
1632    let k = t.degrees.len();
1633    // The permutation character per class, from the table's class representatives.
1634    let pi: Vec<u64> = t
1635        .class_reps
1636        .iter()
1637        .map(|g| (g.iter().enumerate().filter(|(i, &x)| *i == x).count() as u64) % p)
1638        .collect();
1639    let mut mult = Vec::with_capacity(k);
1640    for s in 0..k {
1641        let mut acc = 0u64;
1642        for r in 0..k {
1643            let hr = (t.class_sizes[r] % p as u128) as u64;
1644            let term = (hr as u128 * pi[r] as u128 % p as u128) as u64;
1645            let contrib = (term as u128 * t.values[s][t.inverse_class[r]] as u128) % p as u128;
1646            acc = ((acc as u128 + contrib) % p as u128) as u64;
1647        }
1648        let m = ((acc as u128 * inv_order as u128) % p as u128) as u128;
1649        mult.push(m);
1650    }
1651    // FAIL-CLOSED cross-checks against independent computations of the action's invariants.
1652    if mult.iter().zip(&t.degrees).map(|(m, d)| m * d).sum::<u128>() != degree as u128 {
1653        return None; // Σ m_s·d_s = dim of the permutation rep
1654    }
1655    if mult.iter().map(|m| m * m).sum::<u128>() != rank(degree, gens) as u128 {
1656        return None; // ⟨π,π⟩ = #orbitals (rank)
1657    }
1658    let num_orbits = orbits(degree, gens).len() as u128;
1659    let trivial_row = t.values.iter().position(|row| row.iter().all(|&x| x == 1))?;
1660    if mult[trivial_row] != num_orbits {
1661        return None; // ⟨π,1⟩ = #orbits (Burnside)
1662    }
1663    Some(mult)
1664}
1665
1666/// The isotypic multiplicities of `⟨gens⟩`'s permutation representation, one per irreducible (aligned with
1667/// [`character_table`]'s rows). See [`isotypic_from_table`]. `None` when the character table is out of range
1668/// or the multiplicities cannot be decoded/verified.
1669pub fn isotypic_multiplicities(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
1670    isotypic_from_table(degree, gens, &character_table(degree, gens, cap)?)
1671}
1672
1673/// The **tensor (Clebsch–Gordan) decomposition** of the irreducibles: `N[i][j][k] = ⟨χ_i·χ_j, χ_k⟩`, the
1674/// multiplicity of `χ_k` in the tensor product `χ_i ⊗ χ_j`. These are the *fusion coefficients* — the
1675/// structure constants of the representation ring `R(G)` (the multiplication dual to the character table's
1676/// addition). Computed from a character table:
1677/// `N[i][j][k] = (1/|G|) Σ_r |C_r|·χ_i(C_r)·χ_j(C_r)·χ_k(C_{r̄})` over the table's `GF(p)`; each is a small
1678/// non-negative integer `≤ d_i·d_j ≤ |G| < p`, so it decodes uniquely. FAIL-CLOSED: returns `None` unless
1679/// every fusion product has the right dimension (`Σ_k N[i][j][k]·d_k = d_i·d_j`), the trivial character is a
1680/// unit (`χ_triv ⊗ χ_j = χ_j`), and the coefficients are symmetric (`N[i][j][k] = N[j][i][k]`). Indices
1681/// align with [`character_table`]'s rows.
1682pub fn tensor_from_table(t: &CharacterTable) -> Option<Vec<Vec<Vec<u128>>>> {
1683    let p = t.prime;
1684    let order: u128 = t.degrees.iter().map(|d| d * d).sum();
1685    let inv_order = mod_inv((order % p as u128) as u64, p);
1686    let k = t.degrees.len();
1687    let mut n = vec![vec![vec![0u128; k]; k]; k];
1688    for i in 0..k {
1689        for j in 0..k {
1690            for kk in 0..k {
1691                let mut acc = 0u64;
1692                for r in 0..k {
1693                    let hr = (t.class_sizes[r] % p as u128) as u64;
1694                    let mut prod = hr as u128;
1695                    prod = prod * t.values[i][r] as u128 % p as u128;
1696                    prod = prod * t.values[j][r] as u128 % p as u128;
1697                    prod = prod * t.values[kk][t.inverse_class[r]] as u128 % p as u128;
1698                    acc = ((acc as u128 + prod) % p as u128) as u64;
1699                }
1700                n[i][j][kk] = (acc as u128 * inv_order as u128 % p as u128) as u128;
1701            }
1702        }
1703    }
1704    // FAIL-CLOSED structural checks of the representation ring.
1705    let trivial = t.values.iter().position(|row| row.iter().all(|&x| x == 1))?;
1706    for i in 0..k {
1707        for j in 0..k {
1708            // Dimension: dim(χ_i ⊗ χ_j) = d_i·d_j.
1709            if (0..k).map(|kk| n[i][j][kk] * t.degrees[kk]).sum::<u128>() != t.degrees[i] * t.degrees[j] {
1710                return None;
1711            }
1712            // Symmetry of the tensor product.
1713            for kk in 0..k {
1714                if n[i][j][kk] != n[j][i][kk] {
1715                    return None;
1716                }
1717            }
1718        }
1719        // The trivial character is the multiplicative unit: χ_triv ⊗ χ_i = χ_i.
1720        for kk in 0..k {
1721            let expect = u128::from(kk == i);
1722            if n[trivial][i][kk] != expect {
1723                return None;
1724            }
1725        }
1726    }
1727    Some(n)
1728}
1729
1730/// The tensor (fusion) decomposition of `⟨gens⟩`'s irreducibles. See [`tensor_from_table`]. `None` when the
1731/// character table is out of range or the fusion coefficients fail their structural checks.
1732pub fn tensor_decomposition(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Vec<Vec<u128>>>> {
1733    tensor_from_table(&character_table(degree, gens, cap)?)
1734}
1735
1736/// The **irreducible-representation degrees** `χ_s(1)` of `⟨gens⟩`, sorted ascending (`Σ dᵢ² = |G|`) —
1737/// the cheap summary of the [`character_table`]. `None` when the table cannot be computed (`|G| > cap`
1738/// or too large for the finite-field diagonalisation).
1739pub fn irreducible_degrees(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
1740    character_table(degree, gens, cap).map(|t| t.degrees)
1741}
1742
1743/// Is the group **simple** — non-trivial with no normal subgroup but `{id}` and itself? Tested via
1744/// conjugacy: every non-trivial normal subgroup contains the whole conjugacy class of any of its elements,
1745/// hence the normal closure of that element. So `G` is simple iff the normal closure of *every*
1746/// non-identity element is all of `G`. Simple non-abelian groups (e.g. `A₅`) are exactly the unsolvable
1747/// building blocks. `None` when `|G| > cap`.
1748pub fn is_simple(degree: usize, gens: &[Perm], cap: usize) -> Option<bool> {
1749    let order = schreier_sims(degree, gens).order();
1750    if order <= 1 {
1751        return Some(false); // the trivial group is not simple
1752    }
1753    let classes = conjugacy_classes(degree, gens, cap)?;
1754    for class in &classes {
1755        let rep = &class[0];
1756        if is_identity(rep) {
1757            continue;
1758        }
1759        let ncl = normal_closure(degree, std::slice::from_ref(rep), gens);
1760        if schreier_sims(degree, &ncl).order() < order {
1761            return Some(false); // a proper non-trivial normal subgroup exists
1762        }
1763    }
1764    Some(true)
1765}
1766
1767/// A base and strong generating set, with the per-level basic transversals.
1768#[derive(Clone, Debug)]
1769pub struct Bsgs {
1770    pub degree: usize,
1771    pub base: Vec<usize>,
1772    transversals: Vec<HashMap<usize, Perm>>,
1773}
1774
1775impl Bsgs {
1776    /// `|G| = Π |Δᵢ|` — exact for `|G|` up to the `u128` range (any degree below ~33 factorial).
1777    pub fn order(&self) -> u128 {
1778        self.transversals.iter().map(|t| t.len() as u128).product()
1779    }
1780
1781    /// Enumerate **all** `|G|` group elements, as the unique products `u_k·…·u₂·u₁` of one transversal
1782    /// element per level. (`sift` divides `g` by the level-i transversal element on the right, so
1783    /// `g = h·u₁` with `h ∈ G⁽²⁾`, recursing to `g = u_k·…·u₁` — deepest level innermost; we accumulate
1784    /// from the deepest level outward.) Returns `None` if `|G| > cap` — the BSGS knows the order without
1785    /// enumerating, so the caller gates on it. The basis of *complete* symmetry breaking.
1786    pub fn elements(&self, cap: usize) -> Option<Vec<Perm>> {
1787        if self.order() > cap as u128 {
1788            return None;
1789        }
1790        let mut elems = vec![identity(self.degree)];
1791        for trans in self.transversals.iter().rev() {
1792            let reps: Vec<&Perm> = trans.values().collect();
1793            let mut next = Vec::with_capacity(elems.len() * reps.len());
1794            for e in &elems {
1795                for r in &reps {
1796                    next.push(compose(e, r));
1797                }
1798            }
1799            elems = next;
1800        }
1801        Some(elems)
1802    }
1803
1804    /// All coset representatives across the stabilizer chain — the transversal elements at every level.
1805    /// There are `Σ |Δᵢ|` of them (polynomial: at most `degree²`), spread across the group, so a lex-leader
1806    /// symmetry break over them (together with the generators) is stronger than the bare generators yet
1807    /// still polynomial, unlike the complete enumeration (`|G|`). This is the stabilizer chain breaking the
1808    /// symmetry level by level — "symmetry break again" at each base point.
1809    pub fn transversal_elements(&self) -> Vec<Perm> {
1810        self.transversals.iter().flat_map(|t| t.values().cloned()).collect()
1811    }
1812
1813    /// Is `g` a member of the group (i.e., does it sift to the identity through the chain)? This is the
1814    /// coset decision: `g ∈ rep·G` iff `rep⁻¹·g` is a member.
1815    pub fn contains(&self, g: &[usize]) -> bool {
1816        if g.len() != self.degree {
1817            return false;
1818        }
1819        let mut g = g.to_vec();
1820        for (i, &beta) in self.base.iter().enumerate() {
1821            let img = g[beta];
1822            match self.transversals[i].get(&img) {
1823                None => return false,
1824                Some(t) => g = compose(&g, &invert(t)),
1825            }
1826        }
1827        is_identity(&g)
1828    }
1829}
1830
1831/// **Schreier–Sims.** Build a BSGS for the permutation group on `degree` points generated by `generators`
1832/// (each a permutation of `{0,…,degree−1}`). Deterministic incremental construction: seed the chain with
1833/// the generators, then repeatedly sift every Schreier generator (`u·s` divided by its transversal
1834/// element, Schreier's lemma) into the chain, adding any non-trivial residue as a new strong generator
1835/// (extending the base as needed), until every Schreier generator sifts to the identity — the completeness
1836/// condition.
1837pub fn schreier_sims(degree: usize, generators: &[Perm]) -> Bsgs {
1838    let mut base: Vec<usize> = Vec::new();
1839    let mut strong: Vec<Perm> = Vec::new();
1840    for g in generators {
1841        if !is_identity(g) {
1842            extend_with(&mut base, &mut strong, g.clone());
1843        }
1844    }
1845    loop {
1846        let mut changed = false;
1847        'scan: for i in 0..base.len() {
1848            let trans = orbit_transversal(&base, &strong, i);
1849            let stab: Vec<Perm> =
1850                strong.iter().filter(|g| (0..i).all(|j| g[base[j]] == base[j])).cloned().collect();
1851            for u in trans.values() {
1852                for s in &stab {
1853                    let us = compose(u, s);
1854                    let img = us[base[i]];
1855                    let schreier = compose(&us, &invert(&trans[&img])); // fixes base[i] ∈ G⁽ⁱ⁺¹⁾
1856                    if !is_identity(&schreier) && extend_with(&mut base, &mut strong, schreier) {
1857                        changed = true;
1858                        break 'scan; // orbits changed — recompute from the top
1859                    }
1860                }
1861            }
1862        }
1863        if !changed {
1864            break;
1865        }
1866    }
1867    let transversals = (0..base.len()).map(|i| orbit_transversal(&base, &strong, i)).collect();
1868    Bsgs { degree, base, transversals }
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873    use super::*;
1874    use std::collections::BTreeSet;
1875
1876    /// Brute-force the whole group by closing the generators under right multiplication.
1877    fn closure(degree: usize, gens: &[Perm]) -> BTreeSet<Perm> {
1878        let mut set: BTreeSet<Perm> = BTreeSet::new();
1879        set.insert(identity(degree));
1880        for g in gens {
1881            set.insert(g.clone());
1882        }
1883        loop {
1884            let before = set.len();
1885            for a in set.iter().cloned().collect::<Vec<_>>() {
1886                for g in gens {
1887                    set.insert(compose(&a, g));
1888                }
1889            }
1890            if set.len() == before {
1891                break;
1892            }
1893        }
1894        set
1895    }
1896
1897    /// Every permutation of `n` points (lexicographic), for an exhaustive membership oracle.
1898    fn all_perms(n: usize) -> Vec<Perm> {
1899        let mut out = Vec::new();
1900        let mut p: Perm = (0..n).collect();
1901        loop {
1902            out.push(p.clone());
1903            // next_permutation
1904            let Some(i) = (0..n.saturating_sub(1)).rev().find(|&i| p[i] < p[i + 1]) else { break };
1905            let j = (i + 1..n).rev().find(|&j| p[j] > p[i]).unwrap();
1906            p.swap(i, j);
1907            p[i + 1..].reverse();
1908        }
1909        out
1910    }
1911
1912    fn splitmix(s: &mut u64) -> u64 {
1913        *s = s.wrapping_add(0x9E37_79B9_7F4A_7C15);
1914        let mut z = *s;
1915        z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
1916        z ^ (z >> 31)
1917    }
1918
1919    fn random_perm(n: usize, state: &mut u64) -> Perm {
1920        let mut p: Perm = (0..n).collect();
1921        for i in (1..n).rev() {
1922            let j = (splitmix(state) % (i as u64 + 1)) as usize;
1923            p.swap(i, j);
1924        }
1925        p
1926    }
1927
1928    /// **Known orders, exactly.** Symmetric `S_n = n!`, alternating `A_n = n!/2`, cyclic `C_n = n`,
1929    /// dihedral `D_n = 2n` — the textbook anchors the BSGS must reproduce.
1930    #[test]
1931    fn schreier_sims_reproduces_textbook_group_orders() {
1932        let fact = |n: u128| (1..=n).product::<u128>();
1933        // S_n = ⟨(0 1), (0 1 … n−1)⟩
1934        for n in 2..=7usize {
1935            let transposition: Perm = {
1936                let mut p = identity(n);
1937                p.swap(0, 1);
1938                p
1939            };
1940            let cycle: Perm = (0..n).map(|i| (i + 1) % n).collect();
1941            assert_eq!(schreier_sims(n, &[transposition, cycle]).order(), fact(n as u128), "|S_{n}| = n!");
1942        }
1943        // A_n = ⟨3-cycles⟩, generated by (0 1 2) and (0 1 … n−1) for the right parity, but use 3-cycles.
1944        for n in 3..=7usize {
1945            let mut gens = Vec::new();
1946            for k in 2..n {
1947                // 3-cycle (0 1 k)
1948                let mut p = identity(n);
1949                p[0] = 1;
1950                p[1] = k;
1951                p[k] = 0;
1952                gens.push(p);
1953            }
1954            assert_eq!(schreier_sims(n, &gens).order(), fact(n as u128) / 2, "|A_{n}| = n!/2");
1955        }
1956        // C_n: one n-cycle.
1957        for n in 1..=12usize {
1958            let cycle: Perm = (0..n).map(|i| (i + 1) % n).collect();
1959            assert_eq!(schreier_sims(n, &[cycle]).order(), n as u128, "|C_{n}| = n");
1960        }
1961        // D_n on n points: rotation + reflection x ↦ (n−x) mod n.
1962        for n in 3..=10usize {
1963            let rot: Perm = (0..n).map(|i| (i + 1) % n).collect();
1964            let refl: Perm = (0..n).map(|i| (n - i) % n).collect();
1965            assert_eq!(schreier_sims(n, &[rot, refl]).order(), 2 * n as u128, "|D_{n}| = 2n");
1966        }
1967        // The trivial group.
1968        assert_eq!(schreier_sims(5, &[]).order(), 1, "the empty generating set gives the trivial group");
1969    }
1970
1971    /// **Order and membership match brute force, to the point of absurdity.** On a fuzz of random
1972    /// generating sets over small degrees, the BSGS order equals the brute-force closure size, and
1973    /// `contains` agrees with closure membership on *every* permutation of the degree — an exhaustive
1974    /// oracle. Non-members are rejected; members are accepted.
1975    #[test]
1976    fn order_and_membership_match_brute_force_exhaustively() {
1977        let mut state = 0xC0FF_EE42u64;
1978        for _ in 0..120 {
1979            let degree = 3 + (splitmix(&mut state) % 4) as usize; // 3..6
1980            let ngens = 1 + (splitmix(&mut state) % 3) as usize; // 1..3
1981            let gens: Vec<Perm> = (0..ngens).map(|_| random_perm(degree, &mut state)).collect();
1982            let group = closure(degree, &gens);
1983            let bsgs = schreier_sims(degree, &gens);
1984            assert_eq!(
1985                bsgs.order(),
1986                group.len() as u128,
1987                "|G| must equal the brute-force closure size; gens = {gens:?}"
1988            );
1989            for p in all_perms(degree) {
1990                assert_eq!(
1991                    bsgs.contains(&p),
1992                    group.contains(&p),
1993                    "membership must match brute force for {p:?}; gens = {gens:?}"
1994                );
1995            }
1996        }
1997    }
1998
1999    /// **The coset decision** — the rung the abelian linear engines could not reach. `g` lies in the coset
2000    /// `rep·G` iff `rep⁻¹·g` is a member; checked against brute force for random reps and targets.
2001    #[test]
2002    fn coset_membership_decides_non_abelian_cosets() {
2003        let mut state = 0x5EED_0A5Eu64;
2004        let degree = 5;
2005        // A non-abelian group: ⟨(0 1 2 3 4), (0 1)⟩ = S_5.
2006        let cycle: Perm = (0..degree).map(|i| (i + 1) % degree).collect();
2007        let transposition: Perm = {
2008            let mut p = identity(degree);
2009            p.swap(0, 1);
2010            p
2011        };
2012        // Use a proper subgroup so cosets are non-trivial: the stabilizer-ish ⟨(1 2 3 4), (1 2)⟩ = S_4 on {1,2,3,4}.
2013        let sub_cycle: Perm = vec![0, 2, 3, 4, 1];
2014        let sub_swap: Perm = vec![0, 2, 1, 3, 4];
2015        let _ = (&cycle, &transposition);
2016        let group = closure(degree, &[sub_cycle.clone(), sub_swap.clone()]);
2017        let bsgs = schreier_sims(degree, &[sub_cycle, sub_swap]);
2018        assert_eq!(bsgs.order(), group.len() as u128, "the S_4 subgroup order");
2019        for _ in 0..200 {
2020            let rep = random_perm(degree, &mut state);
2021            let g = random_perm(degree, &mut state);
2022            // g ∈ rep·G ⟺ rep⁻¹·g ∈ G
2023            let in_coset = bsgs.contains(&compose(&invert(&rep), &g));
2024            let brute = group.contains(&compose(&invert(&rep), &g));
2025            assert_eq!(in_coset, brute, "coset decision must match brute force: rep={rep:?} g={g:?}");
2026        }
2027    }
2028
2029    /// **Enumeration equals the brute-force closure.** The transversal-product enumeration reproduces the
2030    /// whole group exactly — same size, same elements — and every enumerated element is a member.
2031    #[test]
2032    fn elements_enumerates_the_whole_group() {
2033        let mut state = 0xE1E_0F00Du64;
2034        for _ in 0..40 {
2035            let degree = 3 + (splitmix(&mut state) % 3) as usize; // 3..5
2036            let ngens = 1 + (splitmix(&mut state) % 3) as usize;
2037            let gens: Vec<Perm> = (0..ngens).map(|_| random_perm(degree, &mut state)).collect();
2038            let group = closure(degree, &gens);
2039            let bsgs = schreier_sims(degree, &gens);
2040            let elems = bsgs.elements(100_000).expect("small group enumerates");
2041            let as_set: BTreeSet<Perm> = elems.iter().cloned().collect();
2042            assert_eq!(elems.len(), as_set.len(), "enumeration has no duplicates");
2043            assert_eq!(as_set, group, "enumeration equals the brute-force closure; gens={gens:?}");
2044            assert!(elems.iter().all(|g| bsgs.contains(g)), "every enumerated element is a member");
2045        }
2046        // The order gate declines an oversized group rather than enumerate it.
2047        let n = 8;
2048        let cycle: Perm = (0..n).map(|i| (i + 1) % n).collect();
2049        let swap: Perm = {
2050            let mut p = identity(n);
2051            p.swap(0, 1);
2052            p
2053        };
2054        assert!(schreier_sims(n, &[cycle, swap]).elements(1000).is_none(), "|S_8|=40320 > cap ⟹ None");
2055    }
2056
2057    /// **The transversal elements are genuine group members, polynomial in count.** `Σ |Δᵢ|` of them
2058    /// (the additive analogue of `|G| = Π |Δᵢ|`), every one a member — the stabilizer-chain coset reps the
2059    /// polynomial symmetry break draws on.
2060    #[test]
2061    fn transversal_elements_are_polynomial_members() {
2062        let mut state = 0x7AB_5E70u64;
2063        for _ in 0..30 {
2064            let degree = 3 + (splitmix(&mut state) % 4) as usize;
2065            let ngens = 1 + (splitmix(&mut state) % 3) as usize;
2066            let gens: Vec<Perm> = (0..ngens).map(|_| random_perm(degree, &mut state)).collect();
2067            let bsgs = schreier_sims(degree, &gens);
2068            let reps = bsgs.transversal_elements();
2069            assert!(reps.iter().all(|g| bsgs.contains(g)), "every transversal element is a member");
2070            assert!(reps.len() <= degree * degree, "polynomial count (≤ degree²): {}", reps.len());
2071            // Σ|Δᵢ| ≥ k (one per level) and the product of orbit sizes is |G| — the additive vs multiplicative.
2072            assert!(reps.len() as u128 >= bsgs.base.len() as u128, "at least one rep per base level");
2073        }
2074    }
2075
2076    /// Orbits reflect the group action: a transitive group is one orbit; a point-stabilizer leaves that
2077    /// point a singleton; the trivial group is all singletons.
2078    #[test]
2079    fn orbits_match_the_group_action() {
2080        let cycle: Perm = vec![1, 2, 3, 0]; // (0 1 2 3)
2081        let swap: Perm = vec![1, 0, 2, 3]; // (0 1)
2082        assert_eq!(orbits(4, &[cycle, swap]), vec![vec![0, 1, 2, 3]], "S_4 is transitive");
2083        let three: Perm = vec![0, 2, 3, 1]; // (1 2 3), fixes 0
2084        assert_eq!(orbits(4, &[three]), vec![vec![0], vec![1, 2, 3]], "stabilizer of 0");
2085        assert_eq!(orbits(3, &[]), vec![vec![0], vec![1], vec![2]], "trivial group: all singletons");
2086    }
2087
2088    /// **Primitivity vs imprimitivity.** `S_4`'s natural action and `C_5` (prime) are primitive — no
2089    /// non-trivial block system. `C_6` (composite) is imprimitive: it decomposes into size-2 blocks (the
2090    /// internal structure of the symmetry). Block systems are balanced and partition the points.
2091    #[test]
2092    fn block_systems_detect_primitivity_and_imprimitivity() {
2093        let s4: Vec<Perm> = vec![vec![1, 0, 2, 3], vec![1, 2, 3, 0]]; // (0 1), (0 1 2 3)
2094        assert!(is_primitive(4, &s4), "S_4 natural action is primitive");
2095        assert!(minimal_block_system(4, &s4).is_none());
2096
2097        let c5: Vec<Perm> = vec![vec![1, 2, 3, 4, 0]];
2098        assert!(is_primitive(5, &c5), "C_5 is primitive (5 is prime)");
2099
2100        let c6: Vec<Perm> = vec![vec![1, 2, 3, 4, 5, 0]];
2101        assert!(!is_primitive(6, &c6), "C_6 is imprimitive");
2102        let bs = minimal_block_system(6, &c6).expect("C_6 has a non-trivial block system");
2103        assert!(bs.iter().all(|b| b.len() == 2), "C_6 minimal blocks have size 2: {bs:?}");
2104        assert_eq!(bs.len(), 3, "three blocks");
2105        assert_eq!(bs.iter().map(|b| b.len()).sum::<usize>(), 6, "the blocks partition all points");
2106    }
2107
2108    #[test]
2109    fn orbitals_give_the_rank_and_higmans_primitivity() {
2110        // Adjacent transpositions generate Sₙ; an n-cycle generates Cₙ.
2111        let s_n = |n: usize| -> Vec<Perm> {
2112            (0..n - 1)
2113                .map(|i| {
2114                    let mut p: Perm = (0..n).collect();
2115                    p.swap(i, i + 1);
2116                    p
2117                })
2118                .collect()
2119        };
2120        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2121
2122        // (degree, generators, expected rank, expected primitivity).
2123        let cases: Vec<(usize, Vec<Perm>, usize, bool)> = vec![
2124            (3, s_n(3), 2, true),  // S₃ is 2-transitive ⇒ rank 2, primitive
2125            (4, s_n(4), 2, true),  // S₄ is 2-transitive ⇒ rank 2, primitive
2126            (4, c_n(4), 4, false), // C₄ is regular ⇒ rank 4; 4 composite ⇒ imprimitive (blocks {0,2},{1,3})
2127            (5, c_n(5), 5, true),  // C₅ is regular ⇒ rank 5; 5 prime ⇒ primitive
2128            (6, c_n(6), 6, false), // C₆ is regular ⇒ rank 6; 6 composite ⇒ imprimitive
2129        ];
2130
2131        for (deg, gens, want_rank, want_prim) in cases {
2132            assert_eq!(rank(deg, &gens), want_rank, "rank of the group on {deg} points");
2133            // The diagonal is always exactly one orbital.
2134            let diag = orbitals(deg, &gens).into_iter().filter(|o| o.iter().all(|&(i, j)| i == j)).count();
2135            assert_eq!(diag, 1, "the diagonal is a single orbital");
2136            // Higman's criterion agrees with the block-system primitivity test — two independent routes.
2137            assert_eq!(is_primitive_via_orbitals(deg, &gens), want_prim, "Higman primitivity");
2138            assert_eq!(
2139                is_primitive_via_orbitals(deg, &gens),
2140                is_primitive(deg, &gens),
2141                "orbital (Higman) and block-system primitivity must agree"
2142            );
2143        }
2144    }
2145
2146    #[test]
2147    fn transitivity_ladder_climbs_the_tuple_orbits() {
2148        let s_n = |n: usize| -> Vec<Perm> {
2149            (0..n - 1)
2150                .map(|i| {
2151                    let mut p: Perm = (0..n).collect();
2152                    p.swap(i, i + 1);
2153                    p
2154                })
2155                .collect()
2156        };
2157        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2158
2159        // Sₙ is sharply n-transitive: transitive on every distinct tuple. Capped at max_t.
2160        assert_eq!(transitivity_degree(4, &s_n(4), 3), 3, "S₄ is ≥3-transitive");
2161        assert_eq!(transitivity_degree(3, &s_n(3), 5), 3, "S₃ is exactly 3-transitive on 3 points");
2162        // A regular cyclic group is transitive but never 2-transitive.
2163        assert_eq!(transitivity_degree(4, &c_n(4), 3), 1, "C₄ is 1-transitive only");
2164        assert_eq!(transitivity_degree(5, &c_n(5), 3), 1, "C₅ is 1-transitive only");
2165
2166        // k=1 tuple-orbits coincide with the point-orbits; for transitive groups a single orbit.
2167        assert_eq!(orbits_on_tuples(4, &s_n(4), 1).len(), orbits(4, &s_n(4)).len());
2168        // 2-transitive ⟺ a single orbit on distinct pairs; Sₙ qualifies, Cₙ (n>2) does not.
2169        assert_eq!(orbits_on_tuples(4, &s_n(4), 2).len(), 1, "S₄ is 2-transitive");
2170        assert!(orbits_on_tuples(4, &c_n(4), 2).len() > 1, "C₄ is not 2-transitive");
2171    }
2172
2173    #[test]
2174    fn derived_series_decides_solvability() {
2175        let s_n = |n: usize| -> Vec<Perm> {
2176            (0..n - 1)
2177                .map(|i| {
2178                    let mut p: Perm = (0..n).collect();
2179                    p.swap(i, i + 1);
2180                    p
2181                })
2182                .collect()
2183        };
2184        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2185        let order = |deg: usize, g: &[Perm]| schreier_sims(deg, g).order();
2186
2187        // Cyclic groups are abelian ⇒ trivial derived subgroup, solvable.
2188        assert!(is_abelian(4, &c_n(4)), "C₄ is abelian");
2189        assert!(is_solvable(4, &c_n(4)));
2190        assert_eq!(order(4, &derived_subgroup(4, &c_n(4))), 1, "[C₄,C₄] is trivial");
2191
2192        // [Sₙ, Sₙ] = Aₙ (order n!/2). S₃, S₄ are solvable; S₅ is NOT (it contains the simple A₅).
2193        assert!(!is_abelian(3, &s_n(3)));
2194        assert_eq!(order(3, &derived_subgroup(3, &s_n(3))), 3, "[S₃,S₃] = A₃ (order 3)");
2195        assert!(is_solvable(3, &s_n(3)), "S₃ is solvable");
2196
2197        assert_eq!(order(4, &derived_subgroup(4, &s_n(4))), 12, "[S₄,S₄] = A₄ (order 12)");
2198        assert!(is_solvable(4, &s_n(4)), "S₄ is solvable");
2199
2200        assert_eq!(order(5, &derived_subgroup(5, &s_n(5))), 60, "[S₅,S₅] = A₅ (order 60)");
2201        assert!(!is_solvable(5, &s_n(5)), "S₅ is NOT solvable — A₅ is perfect");
2202    }
2203
2204    #[test]
2205    fn conjugacy_classes_partition_the_group_and_find_the_centre() {
2206        let s_n = |n: usize| -> Vec<Perm> {
2207            (0..n - 1)
2208                .map(|i| {
2209                    let mut p: Perm = (0..n).collect();
2210                    p.swap(i, i + 1);
2211                    p
2212                })
2213                .collect()
2214        };
2215        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2216
2217        // S₃ has 3 conjugacy classes (e · transpositions · 3-cycles), sizes 1+3+2 = 6 = |S₃|; trivial centre.
2218        let s3 = conjugacy_classes(3, &s_n(3), 1000).expect("S₃ is enumerable");
2219        assert_eq!(s3.len(), 3, "S₃ has 3 conjugacy classes (= 3 irreps)");
2220        assert_eq!(s3.iter().map(|c| c.len()).sum::<usize>(), 6, "the classes partition S₃");
2221        assert_eq!(center_order(3, &s_n(3), 1000), Some(1), "S₃ has a trivial centre");
2222
2223        // S₄ has 5 conjugacy classes (1+6+3+8+6 = 24); trivial centre.
2224        let s4 = conjugacy_classes(4, &s_n(4), 1000).expect("S₄ is enumerable");
2225        assert_eq!(s4.len(), 5, "S₄ has 5 conjugacy classes (= 5 irreps)");
2226        assert_eq!(s4.iter().map(|c| c.len()).sum::<usize>(), 24, "the classes partition S₄");
2227        assert_eq!(center_order(4, &s_n(4), 1000), Some(1), "S₄ has a trivial centre");
2228
2229        // An abelian group: every element is its own class, and the whole group is its centre.
2230        assert_eq!(conjugacy_classes(6, &c_n(6), 1000).map(|c| c.len()), Some(6), "C₆ has |C₆| classes");
2231        assert_eq!(center_order(6, &c_n(6), 1000), Some(6), "an abelian group is its own centre");
2232    }
2233
2234    #[test]
2235    fn exponent_and_order_spectrum() {
2236        let s_n = |n: usize| -> Vec<Perm> {
2237            (0..n - 1)
2238                .map(|i| {
2239                    let mut p: Perm = (0..n).collect();
2240                    p.swap(i, i + 1);
2241                    p
2242                })
2243                .collect()
2244        };
2245        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2246        let spec = |s: BTreeSet<usize>| -> Vec<usize> { s.into_iter().collect() };
2247
2248        // C₄: element orders {1,2,4}; exponent 4 (it has an order-4 element).
2249        assert_eq!(spec(element_orders(4, &c_n(4), 1000).unwrap()), vec![1, 2, 4]);
2250        assert_eq!(exponent(4, &c_n(4), 1000), Some(4), "C₄ has exponent 4");
2251        // C₆: orders {1,2,3,6}, exponent 6.
2252        assert_eq!(spec(element_orders(6, &c_n(6), 1000).unwrap()), vec![1, 2, 3, 6]);
2253        assert_eq!(exponent(6, &c_n(6), 1000), Some(6));
2254
2255        // S₃: element orders {1,2,3}, exponent lcm = 6.
2256        assert_eq!(spec(element_orders(3, &s_n(3), 1000).unwrap()), vec![1, 2, 3]);
2257        assert_eq!(exponent(3, &s_n(3), 1000), Some(6), "S₃ has exponent 6");
2258        // S₄: element orders {1,2,3,4}, exponent lcm(1,2,3,4) = 12.
2259        assert_eq!(spec(element_orders(4, &s_n(4), 1000).unwrap()), vec![1, 2, 3, 4]);
2260        assert_eq!(exponent(4, &s_n(4), 1000), Some(12), "S₄ has exponent 12");
2261    }
2262
2263    #[test]
2264    fn cycle_index_drives_polya_counting() {
2265        let s_n = |n: usize| -> Vec<Perm> {
2266            (0..n - 1)
2267                .map(|i| {
2268                    let mut p: Perm = (0..n).collect();
2269                    p.swap(i, i + 1);
2270                    p
2271                })
2272                .collect()
2273        };
2274        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2275
2276        // Cycle-type distribution of S₃: id [1,1,1]×1, transpositions [1,2]×3, 3-cycles [3]×2.
2277        let ci = cycle_index(3, &s_n(3), 1000).unwrap();
2278        assert_eq!(ci.get(&vec![1, 1, 1]), Some(&1));
2279        assert_eq!(ci.get(&vec![1, 2]), Some(&3));
2280        assert_eq!(ci.get(&vec![3]), Some(&2));
2281
2282        // Pólya with m = 2 = the number of distinct {0,1}-assignments up to the group. Classic values:
2283        // C₄ → 6 binary necklaces of length 4; S₃ → 4 (assignments of 3 points by weight).
2284        assert_eq!(polya_count(4, &c_n(4), 2, 1000), Some(6), "6 binary necklaces of length 4");
2285        assert_eq!(polya_count(3, &s_n(3), 2, 1000), Some(4), "4 binary 3-point assignments up to S₃");
2286        assert_eq!(polya_count(5, &c_n(5), 2, 1000), Some(8), "8 binary necklaces of length 5");
2287
2288        // Cross-check Pólya(m=2) against the brute orbit count of the 2^degree assignment space.
2289        let brute_assignment_orbits = |deg: usize, gens: &[Perm]| -> u128 {
2290            let mut seen = std::collections::HashSet::new();
2291            let mut orbits = 0u128;
2292            for x in 0u64..(1u64 << deg) {
2293                let a: Vec<bool> = (0..deg).map(|i| (x >> i) & 1 == 1).collect();
2294                if seen.contains(&a) {
2295                    continue;
2296                }
2297                orbits += 1;
2298                let mut stack = vec![a];
2299                while let Some(cur) = stack.pop() {
2300                    if !seen.insert(cur.clone()) {
2301                        continue;
2302                    }
2303                    for g in gens {
2304                        let mut pm = vec![false; deg];
2305                        for v in 0..deg {
2306                            pm[g[v]] = cur[v];
2307                        }
2308                        if !seen.contains(&pm) {
2309                            stack.push(pm);
2310                        }
2311                    }
2312                }
2313            }
2314            orbits
2315        };
2316        for (deg, gens) in [(4, c_n(4)), (3, s_n(3)), (4, s_n(4))] {
2317            assert_eq!(
2318                polya_count(deg, &gens, 2, 1000),
2319                Some(brute_assignment_orbits(deg, &gens)),
2320                "Pólya(2) equals the brute assignment-orbit count"
2321            );
2322        }
2323
2324        // Pattern inventory — assignment-orbits split by weight.
2325        // C₄: binary necklaces by #black beads → 0:1, 1:1, 2:2, 3:1, 4:1 (sum 6).
2326        assert_eq!(pattern_inventory(4, &c_n(4), 1000), Some(vec![1, 1, 2, 1, 1]));
2327        // S₃: one orbit per weight class on 3 points → [1,1,1,1] (sum 4).
2328        assert_eq!(pattern_inventory(3, &s_n(3), 1000), Some(vec![1, 1, 1, 1]));
2329        // The inventory always sums to Pólya(2).
2330        for (deg, gens) in [(4, c_n(4)), (3, s_n(3)), (4, s_n(4)), (5, c_n(5))] {
2331            let inv = pattern_inventory(deg, &gens, 1000).unwrap();
2332            assert_eq!(inv.iter().sum::<u128>(), polya_count(deg, &gens, 2, 1000).unwrap());
2333        }
2334    }
2335
2336    #[test]
2337    fn abelianisation_is_the_largest_abelian_quotient() {
2338        let s_n = |n: usize| -> Vec<Perm> {
2339            (0..n - 1)
2340                .map(|i| {
2341                    let mut p: Perm = (0..n).collect();
2342                    p.swap(i, i + 1);
2343                    p
2344                })
2345                .collect()
2346        };
2347        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2348        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]]; // Klein four-group
2349        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]];
2350
2351        // Sₙ abelianises to C₂ (Sₙ/Aₙ): order 2, exponent 2, cyclic.
2352        assert_eq!(abelianization(3, &s_n(3), 1000), Some((2, 2)), "S₃ᵃᵇ = C₂");
2353        assert_eq!(abelianization(4, &s_n(4), 1000), Some((2, 2)), "S₄ᵃᵇ = C₂");
2354        // An abelian group is its own abelianisation.
2355        assert_eq!(abelianization(6, &c_n(6), 1000), Some((6, 6)), "C₆ᵃᵇ = C₆ (cyclic)");
2356        assert_eq!(abelianization(4, &v4, 1000), Some((4, 2)), "V₄ᵃᵇ = V₄ (order 4, exponent 2, NOT cyclic)");
2357        // D₄ abelianises to C₂ × C₂.
2358        assert_eq!(abelianization(4, &d4, 1000), Some((4, 2)), "D₄ᵃᵇ = C₂ × C₂");
2359
2360        // Consistency: the abelianisation order is |G| / |[G,G]|.
2361        for (deg, gens) in [(3, s_n(3)), (4, s_n(4)), (6, c_n(6)), (4, v4.clone()), (4, d4.clone())] {
2362            let (ab_order, _) = abelianization(deg, &gens, 1000).unwrap();
2363            let g = schreier_sims(deg, &gens).order();
2364            let d = schreier_sims(deg, &derived_subgroup(deg, &gens)).order();
2365            assert_eq!(ab_order, g / d, "|Gᵃᵇ| = |G| / |[G,G]|");
2366        }
2367    }
2368
2369    #[test]
2370    fn subgroup_lattice_is_counted() {
2371        let s_n = |n: usize| -> Vec<Perm> {
2372            (0..n - 1)
2373                .map(|i| {
2374                    let mut p: Perm = (0..n).collect();
2375                    p.swap(i, i + 1);
2376                    p
2377                })
2378                .collect()
2379        };
2380        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2381        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]];
2382
2383        // Classic subgroup-lattice sizes.
2384        assert_eq!(subgroup_count(4, &c_n(4), 1000), Some(3), "C₄: 1, C₂, C₄");
2385        assert_eq!(subgroup_count(6, &c_n(6), 1000), Some(4), "C₆: 1, C₂, C₃, C₆");
2386        assert_eq!(subgroup_count(3, &s_n(3), 1000), Some(6), "S₃: 1, three C₂, C₃, S₃");
2387        assert_eq!(subgroup_count(4, &v4, 1000), Some(5), "V₄: 1, three C₂, V₄");
2388        assert_eq!(subgroup_count(4, &s_n(4), 1000), Some(30), "S₄ has 30 subgroups");
2389    }
2390
2391    #[test]
2392    fn simplicity_detects_the_building_block_groups() {
2393        let s_n = |n: usize| -> Vec<Perm> {
2394            (0..n - 1)
2395                .map(|i| {
2396                    let mut p: Perm = (0..n).collect();
2397                    p.swap(i, i + 1);
2398                    p
2399                })
2400                .collect()
2401        };
2402        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2403        // A₅ = ⟨(0 1 2), (2 3 4)⟩ on 5 points — the smallest non-abelian simple group, order 60.
2404        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
2405
2406        // Cyclic groups of prime order are simple; composite order is not.
2407        assert_eq!(is_simple(5, &c_n(5), 1000), Some(true), "C₅ is simple (prime order)");
2408        assert_eq!(is_simple(4, &c_n(4), 1000), Some(false), "C₄ is not simple (has C₂)");
2409        assert_eq!(is_simple(6, &c_n(6), 1000), Some(false), "C₆ is not simple");
2410        // S₃ is not simple (A₃ is normal).
2411        assert_eq!(is_simple(3, &s_n(3), 1000), Some(false), "S₃ is not simple");
2412
2413        // A₅: simple, non-abelian, order 60.
2414        assert_eq!(schreier_sims(5, &a5).order(), 60, "A₅ has order 60");
2415        assert_eq!(is_simple(5, &a5, 1000), Some(true), "A₅ is simple");
2416        // The structural link: a simple NON-abelian group is exactly an unsolvable building block.
2417        assert!(!is_abelian(5, &a5) && !is_solvable(5, &a5), "A₅ is non-abelian and unsolvable");
2418    }
2419
2420    #[test]
2421    fn composition_factors_are_the_jordan_holder_decomposition() {
2422        let s_n = |n: usize| -> Vec<Perm> {
2423            (0..n - 1)
2424                .map(|i| {
2425                    let mut p: Perm = (0..n).collect();
2426                    p.swap(i, i + 1);
2427                    p
2428                })
2429                .collect()
2430        };
2431        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2432        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
2433
2434        // Solvable groups decompose into primes (cyclic Cₚ factors).
2435        assert_eq!(composition_factor_orders(4, &c_n(4), 1000), Some(vec![2, 2]), "C₄: C₂, C₂");
2436        assert_eq!(composition_factor_orders(6, &c_n(6), 1000), Some(vec![2, 3]), "C₆: C₂, C₃");
2437        assert_eq!(composition_factor_orders(3, &s_n(3), 1000), Some(vec![2, 3]), "S₃: C₂, C₃");
2438        assert_eq!(composition_factor_orders(4, &s_n(4), 1000), Some(vec![2, 2, 2, 3]), "S₄: C₂³, C₃");
2439        // A₅ is its own composition factor — a non-abelian simple group.
2440        assert_eq!(composition_factor_orders(5, &a5, 1000), Some(vec![60]), "A₅ is simple");
2441
2442        // Jordan–Hölder: the factor orders always multiply back to |G|.
2443        for (deg, gens) in [(4, c_n(4)), (6, c_n(6)), (3, s_n(3)), (4, s_n(4)), (5, a5.clone())] {
2444            let factors = composition_factor_orders(deg, &gens, 1000).unwrap();
2445            assert_eq!(factors.iter().product::<u128>(), schreier_sims(deg, &gens).order(), "Π factors = |G|");
2446        }
2447    }
2448
2449    #[test]
2450    fn sylow_counts_satisfy_sylows_theorem() {
2451        let s_n = |n: usize| -> Vec<Perm> {
2452            (0..n - 1)
2453                .map(|i| {
2454                    let mut p: Perm = (0..n).collect();
2455                    p.swap(i, i + 1);
2456                    p
2457                })
2458                .collect()
2459        };
2460        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2461        let a4 = vec![vec![1, 2, 0, 3], vec![0, 2, 3, 1]]; // ⟨(0 1 2),(1 2 3)⟩, order 12
2462        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
2463
2464        // Classic Sylow counts.
2465        assert_eq!(sylow_counts(3, &s_n(3), 1000), Some(vec![(2, 3), (3, 1)]), "S₃: 3 Sylow-2, 1 Sylow-3");
2466        assert_eq!(sylow_counts(4, &s_n(4), 1000), Some(vec![(2, 3), (3, 4)]), "S₄: 3 Sylow-2 (D₄), 4 Sylow-3");
2467        assert_eq!(schreier_sims(4, &a4).order(), 12, "A₄ has order 12");
2468        assert_eq!(sylow_counts(4, &a4, 1000), Some(vec![(2, 1), (3, 4)]), "A₄: V₄ normal, 4 Sylow-3");
2469        assert_eq!(sylow_counts(5, &a5, 1000), Some(vec![(2, 5), (3, 10), (5, 6)]), "A₅: 5/10/6 Sylow subgroups");
2470        // Cyclic ⇒ a unique (normal) Sylow subgroup for each prime.
2471        assert_eq!(sylow_counts(6, &c_n(6), 1000), Some(vec![(2, 1), (3, 1)]), "C₆: unique Sylow subgroups");
2472
2473        // Sylow's third theorem: n_p ≡ 1 (mod p) for every group.
2474        for (deg, gens) in [(3, s_n(3)), (4, s_n(4)), (4, a4.clone()), (5, a5.clone()), (6, c_n(6))] {
2475            for (p, n_p) in sylow_counts(deg, &gens, 1000).unwrap() {
2476                assert_eq!(n_p as u128 % p, 1, "n_{p} ≡ 1 (mod {p})");
2477            }
2478        }
2479    }
2480
2481    #[test]
2482    fn class_algebra_constants_and_real_classes() {
2483        let s_n = |n: usize| -> Vec<Perm> {
2484            (0..n - 1)
2485                .map(|i| {
2486                    let mut p: Perm = (0..n).collect();
2487                    p.swap(i, i + 1);
2488                    p
2489                })
2490                .collect()
2491        };
2492        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2493
2494        // The class-algebra constants satisfy Cᵢ·Cⱼ = Σ a[i][j][k]·Cₖ, i.e. Σₖ a[i][j][k]·|Cₖ| = |Cᵢ|·|Cⱼ|.
2495        for (deg, gens) in [(3, s_n(3)), (4, s_n(4)), (4, c_n(4)), (6, c_n(6))] {
2496            let classes = conjugacy_classes(deg, &gens, 1000).unwrap();
2497            let a = class_multiplication_coefficients(deg, &gens, 1000).unwrap();
2498            let k = classes.len();
2499            for i in 0..k {
2500                for j in 0..k {
2501                    let lhs: u128 = (0..k).map(|kk| a[i][j][kk] * classes[kk].len() as u128).sum();
2502                    let rhs = classes[i].len() as u128 * classes[j].len() as u128;
2503                    assert_eq!(lhs, rhs, "Σ a[{i}][{j}][k]·|Cₖ| = |Cᵢ|·|Cⱼ|");
2504                }
2505            }
2506        }
2507
2508        // Real conjugacy classes (C = C⁻¹) = number of real irreducible characters.
2509        assert_eq!(real_class_count(3, &s_n(3), 1000), Some(3), "S₃: all 3 classes real");
2510        assert_eq!(real_class_count(4, &s_n(4), 1000), Some(5), "Sₙ: every class is real");
2511        assert_eq!(real_class_count(4, &c_n(4), 1000), Some(2), "C₄: only e and the order-2 class are real");
2512        assert_eq!(real_class_count(6, &c_n(6), 1000), Some(2), "C₆: e and the order-2 class");
2513    }
2514
2515    #[test]
2516    fn character_table_matches_the_classical_tables() {
2517        let s_n = |n: usize| -> Vec<Perm> {
2518            (0..n - 1)
2519                .map(|i| {
2520                    let mut p: Perm = (0..n).collect();
2521                    p.swap(i, i + 1);
2522                    p
2523                })
2524                .collect()
2525        };
2526        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2527        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]]; // Klein four-group
2528        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]]; // ⟨(0123),(13)⟩, order 8
2529        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]]; // smallest non-abelian simple
2530
2531        // (degree, gens, |G|, expected sorted irreducible degrees) — the textbook degree sequences.
2532        let cases: Vec<(usize, Vec<Perm>, u128, Vec<u128>)> = vec![
2533            (4, c_n(4), 4, vec![1, 1, 1, 1]),       // C₄: four linear characters
2534            (6, c_n(6), 6, vec![1, 1, 1, 1, 1, 1]), // C₆: six linear characters
2535            (4, v4.clone(), 4, vec![1, 1, 1, 1]),   // V₄: the sign table
2536            (3, s_n(3), 6, vec![1, 1, 2]),          // S₃: trivial, sign, standard
2537            (4, s_n(4), 24, vec![1, 1, 2, 3, 3]),   // S₄
2538            (4, d4.clone(), 8, vec![1, 1, 1, 1, 2]),// D₄: four linear + one 2-dim
2539            (5, a5.clone(), 60, vec![1, 3, 3, 4, 5]), // A₅
2540        ];
2541
2542        for (deg, gens, order, want_degrees) in cases {
2543            let table = character_table(deg, &gens, 2000)
2544                .unwrap_or_else(|| panic!("character_table failed for |G|={order}"));
2545            let classes = conjugacy_classes(deg, &gens, 2000).unwrap();
2546            let k = classes.len();
2547
2548            // One irreducible per conjugacy class, with the classical degrees, and Σ dᵢ² = |G|.
2549            assert_eq!(table.degrees.len(), k, "#irreducibles = #conjugacy classes (|G|={order})");
2550            assert_eq!(table.degrees, want_degrees, "degree sequence (|G|={order})");
2551            assert_eq!(table.degrees.iter().map(|d| d * d).sum::<u128>(), order, "Σ dᵢ² = |G|");
2552
2553            // The trivial character (all ones) is present.
2554            assert!(
2555                table.values.iter().any(|row| row.iter().all(|&x| x == 1)),
2556                "trivial character present (|G|={order})"
2557            );
2558
2559            // The identity-class column equals the degrees (χ_s(1) = d_s); abelian ⇒ every degree 1.
2560            let id: Perm = (0..deg).collect();
2561            let id_class = classes.iter().position(|c| c.contains(&id)).unwrap();
2562            for s in 0..k {
2563                assert_eq!(
2564                    table.values[s][id_class] as u128, table.degrees[s],
2565                    "χ_{s}(1) must equal its degree (|G|={order})"
2566                );
2567            }
2568            if is_abelian(deg, &gens) {
2569                assert!(table.degrees.iter().all(|&d| d == 1), "abelian ⇒ all degrees 1");
2570                assert_eq!(table.degrees.len() as u128, order, "abelian ⇒ |G| linear characters");
2571            }
2572
2573            let p = table.prime as u128;
2574            // ROW orthogonality: Σ_r |C_r|·χ_s(C_r)·χ_t(C_r⁻¹) = |G|·δ_{st}  (independent re-check, mod p).
2575            for s in 0..k {
2576                for t in 0..k {
2577                    let mut acc = 0u128;
2578                    for r in 0..k {
2579                        let prod = table.values[s][r] as u128
2580                            * table.values[t][table.inverse_class[r]] as u128
2581                            % p;
2582                        acc = (acc + table.class_sizes[r] % p * prod) % p;
2583                    }
2584                    let want = if s == t { order % p } else { 0 };
2585                    assert_eq!(acc, want, "row orthogonality s={s} t={t} (|G|={order})");
2586                }
2587            }
2588            // COLUMN orthogonality: Σ_s χ_s(C_r)·χ_s(C_t⁻¹) = (|G|/|C_r|)·δ_{rt}  (mod p).
2589            for r in 0..k {
2590                for t in 0..k {
2591                    let mut acc = 0u128;
2592                    for s in 0..k {
2593                        acc = (acc
2594                            + table.values[s][r] as u128 * table.values[s][table.inverse_class[t]] as u128)
2595                            % p;
2596                    }
2597                    let want = if r == t { order / table.class_sizes[r] % p } else { 0 };
2598                    assert_eq!(acc, want, "column orthogonality r={r} t={t} (|G|={order})");
2599                }
2600            }
2601        }
2602
2603        // Degenerate: the trivial group has the 1×1 table [[1]].
2604        let triv = character_table(1, &[], 10).unwrap();
2605        assert_eq!(triv.degrees, vec![1]);
2606        assert_eq!(triv.values, vec![vec![1]]);
2607
2608        // The convenience wrapper returns exactly the sorted degrees.
2609        assert_eq!(irreducible_degrees(5, &a5, 2000), Some(vec![1, 3, 3, 4, 5]));
2610    }
2611
2612    #[test]
2613    fn frobenius_schur_indicators_distinguish_d4_from_q8() {
2614        let s_n = |n: usize| -> Vec<Perm> {
2615            (0..n - 1)
2616                .map(|i| {
2617                    let mut p: Perm = (0..n).collect();
2618                    p.swap(i, i + 1);
2619                    p
2620                })
2621                .collect()
2622        };
2623        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2624        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]]; // ⟨(0123),(13)⟩, order 8
2625        // Q₈ in its left-regular representation on its 8 elements 0=1,1=-1,2=i,3=-i,4=j,5=-j,6=k,7=-k:
2626        // left-multiply by i and by j (computed from i²=j²=k²=-1, ij=k, ji=-k).
2627        let q8 = vec![vec![2, 3, 1, 0, 6, 7, 5, 4], vec![4, 5, 7, 6, 1, 0, 2, 3]];
2628        assert_eq!(schreier_sims(8, &q8).order(), 8, "Q₈ has order 8");
2629
2630        let sorted = |mut v: Vec<i8>| {
2631            v.sort();
2632            v
2633        };
2634
2635        // Sₙ: every irreducible is real ⇒ all indicators +1.
2636        assert_eq!(frobenius_schur_indicators(3, &s_n(3), 1000), Some(vec![1, 1, 1]), "S₃ is totally real");
2637        assert_eq!(
2638            frobenius_schur_indicators(4, &s_n(4), 1000),
2639            Some(vec![1, 1, 1, 1, 1]),
2640            "S₄ is totally real"
2641        );
2642
2643        // C₄: trivial + order-2 character are real (+1); the order-4 pair are complex conjugates (0).
2644        let c4 = frobenius_schur_indicators(4, &c_n(4), 1000).unwrap();
2645        assert_eq!(sorted(c4.clone()), vec![0, 0, 1, 1], "C₄: two real, one complex-conjugate pair");
2646        assert_eq!(
2647            c4.iter().filter(|&&v| v != 0).count(),
2648            real_class_count(4, &c_n(4), 1000).unwrap(),
2649            "#real-valued characters = #real classes"
2650        );
2651
2652        // THE HEADLINE: D₄ and Q₈ have the SAME character table (degrees [1,1,1,1,2]) and the SAME number
2653        // of real classes — yet the Frobenius–Schur indicators tell them apart.
2654        assert_eq!(
2655            irreducible_degrees(4, &d4, 1000),
2656            irreducible_degrees(8, &q8, 1000),
2657            "D₄ and Q₈ share a character table"
2658        );
2659        assert_eq!(real_class_count(4, &d4, 1000), real_class_count(8, &q8, 1000), "…and # real classes");
2660
2661        let fs_d4 = frobenius_schur_indicators(4, &d4, 1000).unwrap();
2662        let fs_q8 = frobenius_schur_indicators(8, &q8, 1000).unwrap();
2663        assert_eq!(sorted(fs_d4.clone()), vec![1, 1, 1, 1, 1], "D₄: the 2-dim rep is REAL (+1)");
2664        assert_eq!(sorted(fs_q8.clone()), vec![-1, 1, 1, 1, 1], "Q₈: the 2-dim rep is QUATERNIONIC (−1)");
2665        assert_ne!(sorted(fs_d4.clone()), sorted(fs_q8.clone()), "Frobenius–Schur SEPARATES D₄ from Q₈");
2666
2667        // The counting theorem Σ_s ν_s·d_s = #{g : g²=1}: D₄ has 6 such elements (id + 5 involutions),
2668        // Q₈ has only 2 (id and −1).
2669        let degs_d4 = irreducible_degrees(4, &d4, 1000).unwrap();
2670        let degs_q8 = irreducible_degrees(8, &q8, 1000).unwrap();
2671        let dot = |nu: &[i8], d: &[u128]| -> i128 { nu.iter().zip(d).map(|(&v, &x)| v as i128 * x as i128).sum() };
2672        assert_eq!(dot(&fs_d4, &degs_d4), 6, "D₄: 6 square roots of identity");
2673        assert_eq!(dot(&fs_q8, &degs_q8), 2, "Q₈: only id and −1 square to identity");
2674    }
2675
2676    #[test]
2677    fn isotypic_decomposition_of_the_permutation_character() {
2678        let s_n = |n: usize| -> Vec<Perm> {
2679            (0..n - 1)
2680                .map(|i| {
2681                    let mut p: Perm = (0..n).collect();
2682                    p.swap(i, i + 1);
2683                    p
2684                })
2685                .collect()
2686        };
2687        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2688        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
2689
2690        for (deg, gens) in [(3, s_n(3)), (4, s_n(4)), (5, a5.clone()), (4, c_n(4)), (6, c_n(6))] {
2691            let table = character_table(deg, &gens, 2000).unwrap();
2692            let mult = isotypic_multiplicities(deg, &gens, 2000)
2693                .unwrap_or_else(|| panic!("isotypic decomposition failed for degree {deg}"));
2694            // π = Σ_s m_s χ_s, so the three bridge identities to the ACTION must hold exactly.
2695            assert_eq!(
2696                mult.iter().zip(&table.degrees).map(|(m, d)| m * d).sum::<u128>(),
2697                deg as u128,
2698                "Σ m_s·d_s = dim of the permutation representation (degree {deg})"
2699            );
2700            assert_eq!(
2701                mult.iter().map(|m| m * m).sum::<u128>(),
2702                rank(deg, &gens) as u128,
2703                "⟨π,π⟩ = #orbitals = rank (degree {deg})"
2704            );
2705            let trivial = table.values.iter().position(|row| row.iter().all(|&x| x == 1)).unwrap();
2706            assert_eq!(
2707                mult[trivial],
2708                orbits(deg, &gens).len() as u128,
2709                "⟨π,1⟩ = #orbits (Burnside) (degree {deg})"
2710            );
2711            // The permutation character itself: identity fixes everything, and the average #fixed points
2712            // over the group equals the orbit count (Cauchy–Frobenius).
2713            let pi = permutation_character(deg, &gens, 2000).unwrap();
2714            assert_eq!(pi[table.identity_class], deg as u128, "the identity fixes all {deg} points");
2715            let order: u128 = table.degrees.iter().map(|d| d * d).sum();
2716            let avg_fixed: u128 = table.class_sizes.iter().zip(&pi).map(|(h, f)| h * f).sum();
2717            assert_eq!(avg_fixed, order * orbits(deg, &gens).len() as u128, "Σ|C_r|·π(C_r) = |G|·#orbits");
2718        }
2719
2720        // S₄ on 4 points and A₅ on 5 points are 2-transitive: the permutation rep is trivial ⊕ standard,
2721        // so exactly two irreducibles appear, each once (rank 2).
2722        let m_s4 = isotypic_multiplicities(4, &s_n(4), 2000).unwrap();
2723        assert_eq!(m_s4.iter().filter(|&&m| m > 0).count(), 2, "S₄ on 4 points: trivial ⊕ standard");
2724        assert!(m_s4.iter().all(|&m| m <= 1), "each at most once (2-transitive ⇒ multiplicity-free)");
2725
2726        // C₄ acting regularly on 4 points: the regular representation contains every irreducible d_s times;
2727        // C₄ is abelian (all d_s = 1) so every multiplicity is 1.
2728        assert_eq!(isotypic_multiplicities(4, &c_n(4), 2000).unwrap(), vec![1, 1, 1, 1], "C₄ regular rep");
2729    }
2730
2731    #[test]
2732    fn table_of_marks_classifies_the_g_sets() {
2733        let s_n = |n: usize| -> Vec<Perm> {
2734            (0..n - 1)
2735                .map(|i| {
2736                    let mut p: Perm = (0..n).collect();
2737                    p.swap(i, i + 1);
2738                    p
2739                })
2740                .collect()
2741        };
2742        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2743        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]];
2744        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]];
2745
2746        // C₄: subgroup classes 1 ⊂ C₂ ⊂ C₄. The table of marks is the textbook upper-triangular matrix.
2747        let (orders, m) = table_of_marks(4, &c_n(4), 200).unwrap();
2748        assert_eq!(orders, vec![1, 2, 4], "subgroup orders 1, 2, 4");
2749        assert_eq!(m, vec![vec![4, 2, 1], vec![0, 2, 1], vec![0, 0, 1]], "C₄ table of marks");
2750
2751        // S₃: classes 1 ⊂ C₂ ⊂ C₃ ⊂ S₃ — the classic 4×4 table of marks.
2752        let (so, sm) = table_of_marks(3, &s_n(3), 200).unwrap();
2753        assert_eq!(so, vec![1, 2, 3, 6]);
2754        assert_eq!(
2755            sm,
2756            vec![vec![6, 3, 2, 1], vec![0, 1, 0, 1], vec![0, 0, 2, 1], vec![0, 0, 0, 1]],
2757            "S₃ table of marks"
2758        );
2759
2760        // Structural laws hold for every group: the trivial-subgroup row is the index sequence [G:H_j], the
2761        // full-group column is all ones, the full-group row is e_last, the matrix is triangular, and the
2762        // diagonal [N(H_i):H_i] is nonzero (so the table is invertible — it determines every G-set).
2763        for (deg, gens, order) in [(3, s_n(3), 6u128), (4, c_n(4), 4), (4, v4.clone(), 4), (4, d4.clone(), 8), (4, s_n(4), 24)] {
2764            let (ord, mk) = table_of_marks(deg, &gens, 300).unwrap();
2765            let k = ord.len();
2766            assert_eq!(*ord.last().unwrap(), order, "the largest subgroup is G itself");
2767            for j in 0..k {
2768                assert_eq!(mk[0][j], order / ord[j], "m(1, H_j) = [G : H_j]");
2769                assert_eq!(mk[j][k - 1], 1, "every subgroup fixes the single coset of G");
2770                assert_eq!(mk[k - 1][j], u128::from(j == k - 1), "G fixes a coset of H only when H = G");
2771                assert!(mk[j][j] >= 1, "diagonal [N(H_j):H_j] is nonzero ⇒ invertible");
2772                for i in 0..k {
2773                    if ord[i] > ord[j] {
2774                        assert_eq!(mk[i][j], 0, "triangular: no mark when |H_i| > |H_j|");
2775                    }
2776                }
2777            }
2778        }
2779    }
2780
2781    #[test]
2782    fn burnside_ring_multiplies_g_sets() {
2783        let s_n = |n: usize| -> Vec<Perm> {
2784            (0..n - 1)
2785                .map(|i| {
2786                    let mut p: Perm = (0..n).collect();
2787                    p.swap(i, i + 1);
2788                    p
2789                })
2790                .collect()
2791        };
2792        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2793        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]];
2794
2795        // S₃ (classes 1, C₂, C₃, S₃ at indices 0..3): the natural 3-point set is G/C₂. Its square — the
2796        // action on ordered pairs of points — splits into the diagonal (≅ G/C₂) and the off-diagonal
2797        // (the 6-point regular action ≅ G/1). So (G/C₂) × (G/C₂) = G/1 ⊔ G/C₂.
2798        let n = burnside_ring_product(3, &s_n(3), 200).unwrap();
2799        assert_eq!(n[1][1], vec![1, 1, 0, 0], "G/C₂ × G/C₂ = G/1 ⊔ G/C₂ in S₃");
2800
2801        // The Burnside ring laws, on several groups.
2802        for (deg, gens, order) in [(3, s_n(3), 6u128), (4, c_n(4), 4), (4, v4.clone(), 4), (4, s_n(4), 24)] {
2803            let (_o, marks) = table_of_marks(deg, &gens, 300).unwrap();
2804            let nn = burnside_ring_product(deg, &gens, 300).unwrap();
2805            let k = marks.len();
2806            let idx = marks[0].clone(); // marks[0][l] = [G : H_l] = #points of G/H_l
2807            for a in 0..k {
2808                for b in 0..k {
2809                    // Non-negative integer coefficients (a genuine G-set).
2810                    assert!(nn[a][b].iter().all(|&c| c >= 0), "G-set multiplicities are non-negative");
2811                    // Commutativity of the product.
2812                    assert_eq!(nn[a][b], nn[b][a], "Burnside product is commutative");
2813                    // Point-count: |G/H_a × G/H_b| = Σ_l N·|G/H_l|.
2814                    let lhs: i128 = (0..k).map(|l| nn[a][b][l] * idx[l] as i128).sum();
2815                    assert_eq!(lhs, (idx[a] * idx[b]) as i128, "point counts multiply");
2816                }
2817                // G/G (the one-point set, last class) is the multiplicative identity.
2818                let mut id = vec![0i128; k];
2819                id[a] = 1;
2820                assert_eq!(nn[k - 1][a], id, "G/G is the identity of the Burnside ring");
2821            }
2822            // (G/1)² = |G|·(G/1): the regular set squared is |G| copies of itself.
2823            let mut want0 = vec![0i128; k];
2824            want0[0] = order as i128;
2825            assert_eq!(nn[0][0], want0, "(G/1)² = |G|·(G/1)");
2826        }
2827    }
2828
2829    #[test]
2830    fn permutation_character_decomposition_bridges_marks_and_characters() {
2831        let s_n = |n: usize| -> Vec<Perm> {
2832            (0..n - 1)
2833                .map(|i| {
2834                    let mut p: Perm = (0..n).collect();
2835                    p.swap(i, i + 1);
2836                    p
2837                })
2838                .collect()
2839        };
2840        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2841        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]];
2842        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
2843
2844        // S₃: subgroup classes 1, C₂, C₃, S₃; irreducibles trivial, sign, standard (degrees 1,1,2). The
2845        // permutation reps decompose as: G/1 = regular = 1+sign+2·std; G/C₂ (3 points) = triv ⊕ std;
2846        // G/C₃ (2 points) = triv ⊕ sign; G/S₃ (point) = triv. Each row's Σ M·d equals the coset count.
2847        let (so, sd, sm) = permutation_character_decomposition(3, &s_n(3), 200).unwrap();
2848        assert_eq!(so, vec![1, 2, 3, 6]);
2849        assert_eq!(sd, vec![1, 1, 2], "S₃ irreducible degrees");
2850        // Rows as multisets (column order is the character table's, sorted by (degree, values)).
2851        assert_eq!(sm[0], vec![1, 1, 2], "G/1 is the regular representation");
2852        assert_eq!(sm[3], {
2853            let mut e = vec![0, 0, 0];
2854            // the trivial irreducible is the all-ones character; locate it and set 1.
2855            e[sd.iter().position(|&d| d == 1).unwrap()] = 1; // first degree-1 col is trivial after sort
2856            e
2857        }, "G/G is the trivial representation");
2858        // G/C₂ (3-point natural action) is 2-transitive ⇒ trivial ⊕ standard, so Σ M² = 2 (rank 2).
2859        assert_eq!(sm[1].iter().map(|&x| x * x).sum::<u128>(), 2, "G/C₂ has rank 2 (2-transitive)");
2860
2861        // The classical laws, on several groups: the regular rep is the degree vector, every action contains
2862        // the trivial character once, G/G is trivial, and dimensions add to the coset count.
2863        for (deg, gens, order) in [(3, s_n(3), 6u128), (4, c_n(4), 4), (4, v4.clone(), 4), (4, s_n(4), 24), (5, a5.clone(), 60)] {
2864            let (orders, degrees, m) = permutation_character_decomposition(deg, &gens, 2000).unwrap();
2865            let triv = degrees.iter().position(|&d| {
2866                // the trivial irreducible has degree 1 and appears once in every row at multiplicity 1
2867                d == 1
2868            });
2869            assert!(triv.is_some());
2870            assert_eq!(m[0], degrees, "G/1 = regular representation = Σ d_s·χ_s");
2871            for (i, row) in m.iter().enumerate() {
2872                let dim: u128 = (0..degrees.len()).map(|s| row[s] * degrees[s]).sum();
2873                assert_eq!(dim, order / orders[i], "Σ_s M[i][s]·d_s = [G : H_i]");
2874            }
2875            // A₅: G/A₄ is the natural 5-point action = trivial ⊕ (the 4-dim irreducible) — 2-transitive.
2876            // Its row has Σ M² = 2; check SOME row realizes the 2-transitive 5-point action for A₅.
2877            if order == 60 {
2878                assert!(m.iter().any(|row| row.iter().map(|&x| x * x).sum::<u128>() == 2 && row.iter().any(|&x| x == 1)),
2879                    "A₅ has a 2-transitive action (the natural 5-point one)");
2880            }
2881        }
2882    }
2883
2884    #[test]
2885    fn subgroup_lattice_mobius_and_generating_tuples() {
2886        let s_n = |n: usize| -> Vec<Perm> {
2887            (0..n - 1)
2888                .map(|i| {
2889                    let mut p: Perm = (0..n).collect();
2890                    p.swap(i, i + 1);
2891                    p
2892                })
2893                .collect()
2894        };
2895        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2896        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]];
2897
2898        // For a cyclic group the subgroup lattice is the divisor lattice, so μ(1, Cₙ) is exactly the
2899        // number-theoretic Möbius function μ(n) — an independent cross-check.
2900        let nt_mobius = |mut m: usize| -> i128 {
2901            let mut sign = 1i128;
2902            let mut d = 2;
2903            while d * d <= m {
2904                if m % d == 0 {
2905                    m /= d;
2906                    if m % d == 0 {
2907                        return 0; // a squared prime factor
2908                    }
2909                    sign = -sign;
2910                }
2911                d += 1;
2912            }
2913            if m > 1 {
2914                sign = -sign; // a remaining prime factor
2915            }
2916            sign
2917        };
2918        for n in [2usize, 3, 4, 5, 6, 7, 8, 9, 12] {
2919            assert_eq!(
2920                mobius_number(n, &c_n(n), 400),
2921                Some(nt_mobius(n)),
2922                "μ(1, C_{n}) = number-theoretic μ({n})"
2923            );
2924        }
2925        // Classical Möbius numbers.
2926        assert_eq!(mobius_number(3, &s_n(3), 400), Some(3), "μ(1, S₃) = 3");
2927        assert_eq!(mobius_number(4, &v4, 400), Some(2), "μ(1, V₄) = 2");
2928
2929        // The Eulerian function e_k(G) = #ordered k-tuples generating G — cross-checked against a direct
2930        // brute-force generation count over G^k.
2931        let brute_generating = |deg: usize, gens: &[Perm], k: u32| -> i128 {
2932            let elements: Vec<Perm> =
2933                subgroup_closure(deg, &gens.iter().cloned().collect()).into_iter().collect();
2934            let total = elements.len();
2935            let mut count = 0i128;
2936            // iterate all k-tuples by mixed-radix index
2937            let mut tuple = vec![0usize; k as usize];
2938            'outer: loop {
2939                let seed: Vec<Perm> = tuple.iter().map(|&t| elements[t].clone()).collect();
2940                if subgroup_closure(deg, &seed.into_iter().collect()).len() == total {
2941                    count += 1;
2942                }
2943                // increment the mixed-radix tuple
2944                let mut pos = 0;
2945                loop {
2946                    if pos == k as usize {
2947                        break 'outer;
2948                    }
2949                    tuple[pos] += 1;
2950                    if tuple[pos] < total {
2951                        break;
2952                    }
2953                    tuple[pos] = 0;
2954                    pos += 1;
2955                }
2956            }
2957            count
2958        };
2959        for (deg, gens, k) in [(3, s_n(3), 2u32), (3, s_n(3), 3), (4, c_n(4), 2), (4, v4.clone(), 2), (4, v4.clone(), 3)] {
2960            assert_eq!(
2961                generating_tuple_count(deg, &gens, 400, k),
2962                Some(brute_generating(deg, &gens, k)),
2963                "Hall's e_{k}(G) must equal the brute-force generating-tuple count"
2964            );
2965        }
2966        // A cyclic group is generated by a single element iff that element is a generator; e_1(Cₙ) = φ(n).
2967        assert_eq!(generating_tuple_count(6, &c_n(6), 400, 1), Some(2), "e₁(C₆) = φ(6) = 2");
2968        assert_eq!(generating_tuple_count(5, &c_n(5), 400, 1), Some(4), "e₁(C₅) = φ(5) = 4");
2969    }
2970
2971    #[test]
2972    fn automorphism_group_order_matches_classical_values() {
2973        let s_n = |n: usize| -> Vec<Perm> {
2974            (0..n - 1)
2975                .map(|i| {
2976                    let mut p: Perm = (0..n).collect();
2977                    p.swap(i, i + 1);
2978                    p
2979                })
2980                .collect()
2981        };
2982        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
2983        let v4 = vec![vec![1, 0, 3, 2], vec![2, 3, 0, 1]];
2984        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]];
2985        let q8 = vec![vec![2, 3, 1, 0, 6, 7, 5, 4], vec![4, 5, 7, 6, 1, 0, 2, 3]];
2986
2987        // |Aut(Cₙ)| = Euler totient φ(n); cyclic groups are abelian so Inn is trivial and Out = Aut.
2988        assert_eq!(automorphism_group_order(4, &c_n(4), 500), Some(2), "|Aut(C₄)| = φ(4) = 2");
2989        assert_eq!(automorphism_group_order(5, &c_n(5), 500), Some(4), "|Aut(C₅)| = φ(5) = 4");
2990        assert_eq!(automorphism_group_order(6, &c_n(6), 500), Some(2), "|Aut(C₆)| = φ(6) = 2");
2991        assert_eq!(outer_automorphism_order(5, &c_n(5), 500), Some(4), "C₅ abelian ⇒ Out = Aut");
2992
2993        // Sₙ (n ≠ 6) is complete: Aut = Inn = Sₙ, Out trivial.
2994        assert_eq!(automorphism_group_order(3, &s_n(3), 500), Some(6), "|Aut(S₃)| = 6");
2995        assert_eq!(outer_automorphism_order(3, &s_n(3), 500), Some(1), "S₃ complete ⇒ Out = 1");
2996        assert_eq!(automorphism_group_order(4, &s_n(4), 500), Some(24), "|Aut(S₄)| = 24");
2997        assert_eq!(outer_automorphism_order(4, &s_n(4), 500), Some(1), "S₄ complete ⇒ Out = 1");
2998
2999        // V₄: Aut = GL(2,𝔽₂) = S₃ (order 6); abelian so Inn trivial and Out = Aut.
3000        assert_eq!(automorphism_group_order(4, &v4, 500), Some(6), "|Aut(V₄)| = |GL(2,2)| = 6");
3001        assert_eq!(outer_automorphism_order(4, &v4, 500), Some(6), "V₄ abelian ⇒ Out = Aut = S₃");
3002
3003        // THE HEADLINE: D₄ and Q₈ share an order (8), a character table, AND |Inn| = 4 — yet the
3004        // automorphism group separates them, a THIRD invariant beyond Frobenius–Schur and rationality.
3005        assert_eq!(automorphism_group_order(4, &d4, 500), Some(8), "|Aut(D₄)| = 8");
3006        assert_eq!(automorphism_group_order(8, &q8, 500), Some(24), "|Aut(Q₈)| = 24 = |S₄|");
3007        assert_eq!(outer_automorphism_order(4, &d4, 500), Some(2), "Out(D₄) = C₂");
3008        assert_eq!(outer_automorphism_order(8, &q8, 500), Some(6), "Out(Q₈) = S₃");
3009        assert_ne!(
3010            automorphism_group_order(4, &d4, 500),
3011            automorphism_group_order(8, &q8, 500),
3012            "Aut SEPARATES D₄ from Q₈"
3013        );
3014    }
3015
3016    #[test]
3017    fn galois_action_distinguishes_real_from_rational_classes() {
3018        let s_n = |n: usize| -> Vec<Perm> {
3019            (0..n - 1)
3020                .map(|i| {
3021                    let mut p: Perm = (0..n).collect();
3022                    p.swap(i, i + 1);
3023                    p
3024                })
3025                .collect()
3026        };
3027        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
3028        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
3029
3030        // Sₙ is rational (integer character table): every class is rational.
3031        assert_eq!(rational_class_count(3, &s_n(3), 2000), Some(3), "S₃ is rational");
3032        assert_eq!(rational_class_count(4, &s_n(4), 2000), Some(5), "S₄ is rational");
3033        // Cyclic groups: the generator's coprime powers fuse — only e and the involution stay rational.
3034        assert_eq!(rational_class_count(4, &c_n(4), 2000), Some(2), "C₄: only e, g² rational");
3035        assert_eq!(rational_class_count(6, &c_n(6), 2000), Some(2), "C₆: only e, g³ rational");
3036        assert_eq!(rational_class_count(5, &c_n(5), 2000), Some(1), "C₅: the Galois group fuses g..g⁴");
3037
3038        // THE HEADLINE: A₅ is ambivalent (ALL classes real) yet only 3 are RATIONAL — Galois swaps the two
3039        // 5-cycle classes, exactly the irrationality of the golden-ratio degree-3 character pair.
3040        assert_eq!(real_class_count(5, &a5, 2000), Some(5), "A₅: all 5 classes are real");
3041        assert_eq!(rational_class_count(5, &a5, 2000), Some(3), "A₅: only 3 classes are rational");
3042
3043        // Cross-check Burnside's rationality theorem on every group: #rational classes = #rational-valued
3044        // irreducible characters (= rows of the character table constant on each Galois orbit of classes).
3045        for (deg, gens) in [(3, s_n(3)), (4, s_n(4)), (4, c_n(4)), (6, c_n(6)), (5, c_n(5)), (5, a5.clone())] {
3046            let orbits = galois_class_orbits(deg, &gens, 2000).unwrap();
3047            let t = character_table(deg, &gens, 2000).unwrap();
3048            // Orbits partition the classes.
3049            assert_eq!(orbits.iter().map(|o| o.len()).sum::<usize>(), t.degrees.len(), "orbits partition");
3050            let rational_chars = (0..t.degrees.len())
3051                .filter(|&s| orbits.iter().all(|o| o.iter().all(|&r| t.values[s][r] == t.values[s][o[0]])))
3052                .count();
3053            assert_eq!(
3054                rational_chars,
3055                rational_class_count(deg, &gens, 2000).unwrap(),
3056                "Burnside: #rational characters = #rational classes (degree {deg})"
3057            );
3058            // Rational ⟹ real, always.
3059            assert!(
3060                rational_class_count(deg, &gens, 2000).unwrap() <= real_class_count(deg, &gens, 2000).unwrap(),
3061                "rational classes ⊆ real classes (degree {deg})"
3062            );
3063        }
3064    }
3065
3066    #[test]
3067    fn tensor_decomposition_is_the_representation_ring() {
3068        let s_n = |n: usize| -> Vec<Perm> {
3069            (0..n - 1)
3070                .map(|i| {
3071                    let mut p: Perm = (0..n).collect();
3072                    p.swap(i, i + 1);
3073                    p
3074                })
3075                .collect()
3076        };
3077        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
3078        let a5 = vec![vec![1, 2, 0, 3, 4], vec![0, 1, 3, 4, 2]];
3079
3080        for (deg, gens) in [(3, s_n(3)), (4, s_n(4)), (5, a5.clone()), (4, c_n(4)), (6, c_n(6))] {
3081            let t = character_table(deg, &gens, 2000).unwrap();
3082            let n = tensor_decomposition(deg, &gens, 2000)
3083                .unwrap_or_else(|| panic!("tensor decomposition failed for degree {deg}"));
3084            let k = t.degrees.len();
3085            // χ_i ⊗ χ_i ⊇ trivial  ⟺  χ_i is self-dual (real) — the FROBENIUS–SCHUR link to #35.
3086            let fs = frobenius_schur_indicators(deg, &gens, 2000).unwrap();
3087            let trivial = t.values.iter().position(|row| row.iter().all(|&x| x == 1)).unwrap();
3088            for i in 0..k {
3089                let self_dual = n[i][i][trivial] == 1;
3090                assert_eq!(self_dual, fs[i] != 0, "χ_i⊗χ_i ⊇ 1 iff χ_i is real (degree {deg}, irrep {i})");
3091                // The trivial appears in χ_i ⊗ χ_j for EXACTLY ONE j (the dual χ_i*), with multiplicity 1.
3092                assert_eq!(
3093                    (0..k).filter(|&j| n[i][j][trivial] == 1).count(),
3094                    1,
3095                    "χ_i has a unique dual (degree {deg}, irrep {i})"
3096                );
3097            }
3098        }
3099
3100        // C₄ is the cyclic group ℤ/4: its characters form the dual group ℤ/4, so fusion is ADDITION mod 4,
3101        // χ_a ⊗ χ_b = χ_{a+b mod 4}. Recover the +mod-4 Cayley table from the fusion coefficients.
3102        let t = character_table(4, &c_n(4), 2000).unwrap();
3103        let n = tensor_decomposition(4, &c_n(4), 2000).unwrap();
3104        // Index each linear character by the value it assigns the generator (its "frequency" in GF(p)).
3105        let gen_class = (0..4).find(|&r| t.class_reps[r] == vec![1, 2, 3, 0]).unwrap();
3106        let freq: Vec<u64> = (0..4).map(|s| t.values[s][gen_class]).collect();
3107        for a in 0..4 {
3108            for b in 0..4 {
3109                // χ_a ⊗ χ_b is the unique linear character whose frequency is freq[a]·freq[b].
3110                let prod_freq = (freq[a] as u128 * freq[b] as u128 % t.prime as u128) as u64;
3111                let want = (0..4).find(|&c| freq[c] == prod_freq).unwrap();
3112                for c in 0..4 {
3113                    assert_eq!(
3114                        n[a][b][c],
3115                        u128::from(c == want),
3116                        "C₄ fusion = character-group multiplication"
3117                    );
3118                }
3119            }
3120        }
3121    }
3122
3123    #[test]
3124    fn upper_central_series_agrees_with_the_lower_one() {
3125        let s_n = |n: usize| -> Vec<Perm> {
3126            (0..n - 1)
3127                .map(|i| {
3128                    let mut p: Perm = (0..n).collect();
3129                    p.swap(i, i + 1);
3130                    p
3131                })
3132                .collect()
3133        };
3134        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
3135        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]];
3136
3137        // Abelian: the upper series jumps straight to G (Z₀=1 ⊂ Z₁=G).
3138        assert_eq!(upper_central_series(4, &c_n(4), 1000), Some(vec![1, 4]));
3139        // D₄ (class 2): {id} ⊂ centre (order 2) ⊂ G (order 8).
3140        assert_eq!(upper_central_series(4, &d4, 1000), Some(vec![1, 2, 8]));
3141        // A non-nilpotent group's upper series stalls at a trivial hypercentre.
3142        assert_eq!(upper_central_series(3, &s_n(3), 1000), Some(vec![1]), "S₃ has a trivial hypercentre");
3143
3144        // The deep cross-check: upper- and lower-central series have the SAME length (the nilpotency class)
3145        // for every group, and report non-nilpotency identically.
3146        for (deg, gens) in [(4, c_n(4)), (4, d4.clone()), (3, s_n(3)), (4, s_n(4)), (5, s_n(5))] {
3147            assert_eq!(
3148                upper_central_length(deg, &gens, 1000),
3149                nilpotency_class(deg, &gens),
3150                "upper- and lower-central series agree on the nilpotency class"
3151            );
3152        }
3153    }
3154
3155    #[test]
3156    fn lower_central_series_decides_nilpotency() {
3157        let s_n = |n: usize| -> Vec<Perm> {
3158            (0..n - 1)
3159                .map(|i| {
3160                    let mut p: Perm = (0..n).collect();
3161                    p.swap(i, i + 1);
3162                    p
3163                })
3164                .collect()
3165        };
3166        let c_n = |n: usize| -> Vec<Perm> { vec![(1..n).chain(std::iter::once(0)).collect()] };
3167
3168        // Abelian groups are nilpotent (class ≤ 1).
3169        assert!(is_nilpotent(4, &c_n(4)), "C₄ is abelian ⇒ nilpotent");
3170
3171        // D₄ = ⟨(0 1 2 3), (1 3)⟩, order 8 — a 2-group, hence nilpotent (class 2), and non-abelian.
3172        let d4 = vec![vec![1, 2, 3, 0], vec![0, 3, 2, 1]];
3173        assert_eq!(schreier_sims(4, &d4).order(), 8, "D₄ has order 8");
3174        assert!(!is_abelian(4, &d4), "D₄ is non-abelian");
3175        assert!(is_nilpotent(4, &d4), "D₄ is a 2-group ⇒ nilpotent");
3176
3177        // S₃ is solvable but NOT nilpotent — its lower central series stalls at A₃ — the smallest such group.
3178        assert!(is_solvable(3, &s_n(3)) && !is_nilpotent(3, &s_n(3)), "S₃: solvable but not nilpotent");
3179        // S₄ is solvable but not nilpotent either.
3180        assert!(is_solvable(4, &s_n(4)) && !is_nilpotent(4, &s_n(4)), "S₄: solvable but not nilpotent");
3181
3182        // Series DEPTHS: derived length (solvability class) and nilpotency class.
3183        assert_eq!(derived_length(4, &c_n(4)), Some(1), "C₄ abelian ⇒ derived length 1");
3184        assert_eq!(nilpotency_class(4, &c_n(4)), Some(1), "C₄ abelian ⇒ nilpotency class 1");
3185        assert_eq!(nilpotency_class(4, &d4), Some(2), "D₄ has nilpotency class 2");
3186        assert_eq!(derived_length(3, &s_n(3)), Some(2), "S₃ has derived length 2");
3187        assert_eq!(nilpotency_class(3, &s_n(3)), None, "S₃ is not nilpotent");
3188        assert_eq!(derived_length(4, &s_n(4)), Some(3), "S₄ has derived length 3 (S₄ ⊵ A₄ ⊵ V₄ ⊵ 1)");
3189        assert_eq!(derived_length(5, &s_n(5)), None, "S₅ is not solvable");
3190    }
3191}