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> = strong
46        .iter()
47        .filter(|g| (0..level).all(|j| g[base[j]] == base[j]))
48        .collect();
49    let mut trans: HashMap<usize, Perm> = HashMap::new();
50    trans.insert(base[level], identity(degree));
51    let mut queue = vec![base[level]];
52    while let Some(p) = queue.pop() {
53        let up = trans[&p].clone();
54        for s in &stab {
55            let q = s[p];
56            if !trans.contains_key(&q) {
57                trans.insert(q, compose(&up, s)); // base[level] → p → q
58                queue.push(q);
59            }
60        }
61    }
62    trans
63}
64
65/// Sift `g` through the chain: at each level send `g` into the stabilizer of `base[i]` by right-dividing
66/// the transversal element. Returns the residue and the level reached. `g ∈ ⟨strong⟩` iff the residue is
67/// the identity (and the full depth was reached).
68fn sift(base: &[usize], strong: &[Perm], mut g: Perm) -> (Perm, usize) {
69    for (i, &beta) in base.iter().enumerate() {
70        let trans = orbit_transversal(base, strong, i);
71        let img = g[beta];
72        match trans.get(&img) {
73            None => return (g, i),
74            Some(t) => g = compose(&g, &invert(t)), // now fixes base[i]
75        }
76    }
77    (g, base.len())
78}
79
80/// Add `g ∈ G` to the (base, strong) data: sift it, and if the residue is non-trivial it is a new strong
81/// generator — extend the base if the residue fixes the whole current base. Returns whether it grew.
82fn extend_with(base: &mut Vec<usize>, strong: &mut Vec<Perm>, g: Perm) -> bool {
83    let (res, lvl) = sift(base, strong, g);
84    if is_identity(&res) {
85        return false;
86    }
87    if lvl == base.len() {
88        let moved = (0..res.len())
89            .find(|&x| res[x] != x)
90            .expect("a non-identity moves a point");
91        base.push(moved);
92    }
93    strong.push(res);
94    true
95}
96
97/// The orbits of `{0,…,degree−1}` under `⟨generators⟩`, as a partition (each orbit sorted ascending,
98/// orbits ordered by least element). Needs only the generators — a BFS, independent of the BSGS.
99pub fn orbits(degree: usize, generators: &[Perm]) -> Vec<Vec<usize>> {
100    let mut seen = vec![false; degree];
101    let mut out = Vec::new();
102    for start in 0..degree {
103        if seen[start] {
104            continue;
105        }
106        seen[start] = true;
107        let mut orbit = vec![start];
108        let mut i = 0;
109        while i < orbit.len() {
110            let p = orbit[i];
111            i += 1;
112            for g in generators {
113                let q = g[p];
114                if !seen[q] {
115                    seen[q] = true;
116                    orbit.push(q);
117                }
118            }
119        }
120        orbit.sort_unstable();
121        out.push(orbit);
122    }
123    out
124}
125
126fn uf_find(parent: &mut [usize], mut x: usize) -> usize {
127    while parent[x] != x {
128        parent[x] = parent[parent[x]];
129        x = parent[x];
130    }
131    x
132}
133
134/// The minimal block (G-congruence class) containing both `alpha` and `beta`, returned as a block-id per
135/// point (Atkinson's algorithm): merge `α,β`, then whenever two points share a block so must their images
136/// under every generator — close under the generators with union–find. The class of `alpha` is the
137/// minimal block containing the pair.
138fn block_containing(degree: usize, gens: &[Perm], alpha: usize, beta: usize) -> Vec<usize> {
139    let mut parent: Vec<usize> = (0..degree).collect();
140    let mut queue: Vec<(usize, usize)> = Vec::new();
141    let (ra, rb) = (uf_find(&mut parent, alpha), uf_find(&mut parent, beta));
142    if ra != rb {
143        parent[ra] = rb;
144        queue.push((alpha, beta));
145    }
146    while let Some((x, y)) = queue.pop() {
147        for g in gens {
148            let (gx, gy) = (g[x], g[y]);
149            let (rx, ry) = (uf_find(&mut parent, gx), uf_find(&mut parent, gy));
150            if rx != ry {
151                parent[rx] = ry;
152                queue.push((gx, gy));
153            }
154        }
155    }
156    (0..degree).map(|x| uf_find(&mut parent, x)).collect()
157}
158
159/// The minimal non-trivial **block system** of a TRANSITIVE permutation group — the finest `G`-invariant
160/// partition into equal-size blocks bigger than a point and smaller than the whole set — or `None` if the
161/// group is **primitive** (only the trivial partitions are invariant) or not transitive. Imprimitivity is
162/// the symmetry's internal structure: a grid symmetry decomposes into its rows, a cyclic group of
163/// composite order into cosets. (Atkinson's algorithm over each pair `{0, β}`.)
164pub fn minimal_block_system(degree: usize, gens: &[Perm]) -> Option<Vec<Vec<usize>>> {
165    if degree < 2 || orbits(degree, gens).len() != 1 {
166        return None; // primitivity is a property of transitive groups only
167    }
168    let mut best: Option<Vec<usize>> = None;
169    let mut best_size = degree;
170    for beta in 1..degree {
171        let ids = block_containing(degree, gens, 0, beta);
172        let size = ids.iter().filter(|&&b| b == ids[0]).count();
173        if 1 < size && size < degree && size < best_size {
174            best_size = size;
175            best = Some(ids);
176        }
177    }
178    best.map(|ids| {
179        let mut by_block: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
180        for (x, &b) in ids.iter().enumerate() {
181            by_block.entry(b).or_default().push(x);
182        }
183        by_block.into_values().collect()
184    })
185}
186
187/// Is the group **primitive** — transitive with no non-trivial block system? Primitive groups are the
188/// indecomposable "atoms" of permutation-group structure; imprimitive ones split into blocks
189/// ([`minimal_block_system`]).
190pub fn is_primitive(degree: usize, gens: &[Perm]) -> bool {
191    degree >= 2 && orbits(degree, gens).len() == 1 && minimal_block_system(degree, gens).is_none()
192}
193
194/// The **orbitals** — the orbits of the group on ORDERED PAIRS `(i, j)` under the action
195/// `g·(i,j) = (g[i], g[j])`. The diagonal `{(i,i)}` is always one orbital; for a transitive group the
196/// non-diagonal orbitals are its "relation classes" (its association scheme). The count is the group's
197/// [`rank`]. One level finer than the point-orbits ([`orbits`]).
198pub fn orbitals(degree: usize, gens: &[Perm]) -> Vec<Vec<(usize, usize)>> {
199    let mut seen = vec![false; degree * degree];
200    let idx = |i: usize, j: usize| i * degree + j;
201    let mut out = Vec::new();
202    for i in 0..degree {
203        for j in 0..degree {
204            if seen[idx(i, j)] {
205                continue;
206            }
207            seen[idx(i, j)] = true;
208            let mut orbit = vec![(i, j)];
209            let mut k = 0;
210            while k < orbit.len() {
211                let (a, b) = orbit[k];
212                k += 1;
213                for g in gens {
214                    let (ga, gb) = (g[a], g[b]);
215                    if !seen[idx(ga, gb)] {
216                        seen[idx(ga, gb)] = true;
217                        orbit.push((ga, gb));
218                    }
219                }
220            }
221            out.push(orbit);
222        }
223    }
224    out
225}
226
227/// The **rank** of the group: the number of orbitals (orbits on ordered pairs). A transitive group has
228/// rank `2` iff it is 2-transitive; a regular group has rank equal to its degree.
229pub fn rank(degree: usize, gens: &[Perm]) -> usize {
230    orbitals(degree, gens).len()
231}
232
233/// Are all `degree` points connected by the undirected graph whose edges are `orbital`'s pairs?
234fn orbital_graph_connected(degree: usize, orbital: &[(usize, usize)]) -> bool {
235    let mut parent: Vec<usize> = (0..degree).collect();
236    for &(i, j) in orbital {
237        let (ri, rj) = (uf_find(&mut parent, i), uf_find(&mut parent, j));
238        parent[ri] = rj;
239    }
240    let r0 = uf_find(&mut parent, 0);
241    (0..degree).all(|v| uf_find(&mut parent, v) == r0)
242}
243
244/// Primitivity via **Higman's theorem**: a transitive group is primitive iff every non-diagonal orbital
245/// graph is connected. An independent route to [`is_primitive`]; when a non-diagonal orbital graph is
246/// disconnected, its connected components are a block system. (Returns `false` for an intransitive group.)
247pub fn is_primitive_via_orbitals(degree: usize, gens: &[Perm]) -> bool {
248    if degree < 2 || orbits(degree, gens).len() != 1 {
249        return false;
250    }
251    orbitals(degree, gens)
252        .iter()
253        .filter(|orb| orb.iter().any(|&(i, j)| i != j)) // skip the diagonal
254        .all(|orb| orbital_graph_connected(degree, orb))
255}
256
257/// Every ordered `k`-tuple of DISTINCT points from `0..degree`.
258fn distinct_tuples(degree: usize, k: usize) -> Vec<Vec<usize>> {
259    let mut out = Vec::new();
260    let mut cur = Vec::with_capacity(k);
261    let mut used = vec![false; degree];
262    fn rec(
263        degree: usize,
264        k: usize,
265        cur: &mut Vec<usize>,
266        used: &mut [bool],
267        out: &mut Vec<Vec<usize>>,
268    ) {
269        if cur.len() == k {
270            out.push(cur.clone());
271            return;
272        }
273        for x in 0..degree {
274            if !used[x] {
275                used[x] = true;
276                cur.push(x);
277                rec(degree, k, cur, used, out);
278                cur.pop();
279                used[x] = false;
280            }
281        }
282    }
283    rec(degree, k, &mut cur, &mut used, &mut out);
284    out
285}
286
287/// The orbits of the group on ordered `k`-tuples of distinct points (`g·(t₁,…,t_k) = (g[t₁],…,g[t_k])`).
288/// `k = 1` is the point-orbits ([`orbits`]); `k = 2` (on distinct pairs) refines [`orbitals`]; in general
289/// the group is `k`-transitive iff this is a single orbit — the rungs of the transitivity ladder.
290pub fn orbits_on_tuples(degree: usize, gens: &[Perm], k: usize) -> Vec<Vec<Vec<usize>>> {
291    if k == 0 || k > degree {
292        return Vec::new();
293    }
294    let tuples = distinct_tuples(degree, k);
295    let index: HashMap<Vec<usize>, usize> = tuples
296        .iter()
297        .enumerate()
298        .map(|(i, t)| (t.clone(), i))
299        .collect();
300    let mut seen = vec![false; tuples.len()];
301    let mut out = Vec::new();
302    for start in 0..tuples.len() {
303        if seen[start] {
304            continue;
305        }
306        seen[start] = true;
307        let mut orbit = vec![tuples[start].clone()];
308        let mut i = 0;
309        while i < orbit.len() {
310            let cur = orbit[i].clone();
311            i += 1;
312            for g in gens {
313                let img: Vec<usize> = cur.iter().map(|&x| g[x]).collect();
314                let idx = index[&img];
315                if !seen[idx] {
316                    seen[idx] = true;
317                    orbit.push(img);
318                }
319            }
320        }
321        out.push(orbit);
322    }
323    out
324}
325
326/// The **transitivity degree**: the largest `t ≤ max_t` for which the group is transitive on ordered
327/// `t`-tuples of distinct points (`1` = transitive, `2` = 2-transitive, …). `0` if intransitive. Capped at
328/// `max_t` because the `t`-tuple space grows as `degree^t`. `Sₙ` is `n`-transitive; a regular group is only
329/// `1`-transitive.
330pub fn transitivity_degree(degree: usize, gens: &[Perm], max_t: usize) -> usize {
331    let mut t = 0;
332    for k in 1..=max_t.min(degree) {
333        if orbits_on_tuples(degree, gens, k).len() == 1 {
334            t = k;
335        } else {
336            break; // k-transitive ⟹ (k-1)-transitive, so the first failure is the ceiling
337        }
338    }
339    t
340}
341
342/// The commutator `[g, h] = g⁻¹ h⁻¹ g h`.
343fn commutator(g: &[usize], h: &[usize]) -> Perm {
344    compose(&compose(&invert(g), &invert(h)), &compose(g, h))
345}
346
347/// Generators of the **normal closure** of `⟨sub⟩` inside `⟨gens⟩`: close `sub` under conjugation by the
348/// generators and their inverses (which generates conjugation by the whole group) until the generated
349/// group stops growing.
350fn normal_closure(degree: usize, sub: &[Perm], gens: &[Perm]) -> Vec<Perm> {
351    let mut closure: Vec<Perm> = sub.iter().filter(|p| !is_identity(p)).cloned().collect();
352    if closure.is_empty() {
353        return closure;
354    }
355    let mut bsgs = schreier_sims(degree, &closure);
356    let mut i = 0;
357    while i < closure.len() {
358        let s = closure[i].clone();
359        i += 1;
360        for g in gens {
361            for conj in [
362                compose(&compose(&invert(g), &s), g),
363                compose(&compose(g, &s), &invert(g)),
364            ] {
365                if !is_identity(&conj) && !bsgs.contains(&conj) {
366                    closure.push(conj);
367                    bsgs = schreier_sims(degree, &closure);
368                }
369            }
370        }
371    }
372    closure
373}
374
375/// Generators of the **commutator subgroup** `[A, B]` — the normal closure (in `⟨gens_a⟩`) of the
376/// commutators `[a, b]` for generators `a` of `A`, `b` of `B`. `[G, G]` is the derived subgroup; `[G, γ]`
377/// is a step of the lower central series.
378fn commutator_subgroup(degree: usize, gens_a: &[Perm], gens_b: &[Perm]) -> Vec<Perm> {
379    let mut comms = Vec::new();
380    for a in gens_a {
381        for b in gens_b {
382            let c = commutator(a, b);
383            if !is_identity(&c) {
384                comms.push(c);
385            }
386        }
387    }
388    normal_closure(degree, &comms, gens_a)
389}
390
391/// Generators of the **derived (commutator) subgroup** `[G, G]` — the normal closure of the commutators of
392/// the generators. Always normal; `G / [G, G]` is the abelianisation, and `[G, G]` is trivial iff `G` is
393/// abelian.
394pub fn derived_subgroup(degree: usize, gens: &[Perm]) -> Vec<Perm> {
395    commutator_subgroup(degree, gens, gens)
396}
397
398/// Is the group **nilpotent**? (Its lower central series reaches the trivial group.) Strictly stronger than
399/// solvability — every `p`-group is nilpotent, but `S₃` (solvable) is not.
400pub fn is_nilpotent(degree: usize, gens: &[Perm]) -> bool {
401    nilpotency_class(degree, gens).is_some()
402}
403
404/// Is the group **abelian**? (Its generators pairwise commute.)
405pub fn is_abelian(_degree: usize, gens: &[Perm]) -> bool {
406    gens.iter()
407        .all(|g| gens.iter().all(|h| compose(g, h) == compose(h, g)))
408}
409
410/// The **derived length** (solvability class): the number of steps the derived series `G ⊵ G' ⊵ G'' ⊵ …`
411/// takes to reach the trivial group, or `None` if it never does (`G` is unsolvable). `0` is the trivial
412/// group, `1` a non-trivial abelian group, `2` for `S₃`, `3` for `S₄`.
413pub fn derived_length(degree: usize, gens: &[Perm]) -> Option<usize> {
414    let mut cur: Vec<Perm> = gens.to_vec();
415    let mut len = 0;
416    loop {
417        let order = schreier_sims(degree, &cur).order();
418        if order == 1 {
419            return Some(len);
420        }
421        let d = derived_subgroup(degree, &cur);
422        if schreier_sims(degree, &d).order() == order {
423            return None; // G' = G ⇒ the series never descends to 1
424        }
425        cur = d;
426        len += 1;
427    }
428}
429
430/// Is the group **solvable**? (Its derived series reaches the trivial group.)
431pub fn is_solvable(degree: usize, gens: &[Perm]) -> bool {
432    derived_length(degree, gens).is_some()
433}
434
435/// The **nilpotency class**: the number of steps the lower central series `γ₁ = G`, `γ_{k+1} = [G, γ_k]`
436/// takes to reach the trivial group, or `None` if it never does (`G` is not nilpotent). `0` is trivial,
437/// `1` abelian, `2` for `D₄`.
438pub fn nilpotency_class(degree: usize, gens: &[Perm]) -> Option<usize> {
439    let mut gamma: Vec<Perm> = gens.to_vec();
440    let mut class = 0;
441    loop {
442        let order = schreier_sims(degree, &gamma).order();
443        if order == 1 {
444            return Some(class);
445        }
446        let next = commutator_subgroup(degree, gens, &gamma);
447        if schreier_sims(degree, &next).order() == order {
448            return None; // the series stalls above the identity
449        }
450        gamma = next;
451        class += 1;
452    }
453}
454
455/// The **conjugacy classes** of the group — the partition of its elements by `g ~ x⁻¹gx`. The number of
456/// classes equals the number of irreducible representations (the bridge to character theory); the
457/// singleton classes are exactly the centre `Z(G)`. Requires enumerating the group, so it returns `None`
458/// when `|G| > cap`.
459pub fn conjugacy_classes(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Vec<Perm>>> {
460    let elements = schreier_sims(degree, gens).elements(cap)?;
461    let mut remaining: BTreeSet<Perm> = elements.iter().cloned().collect();
462    let mut classes = Vec::new();
463    while let Some(g) = remaining.iter().next().cloned() {
464        let mut class: BTreeSet<Perm> = BTreeSet::new();
465        for x in &elements {
466            class.insert(compose(&compose(&invert(x), &g), x)); // x⁻¹ g x
467        }
468        for c in &class {
469            remaining.remove(c);
470        }
471        classes.push(class.into_iter().collect::<Vec<_>>());
472    }
473    Some(classes)
474}
475
476/// The order of the **centre** `Z(G)` — the elements commuting with all of `G`, which are exactly those in
477/// singleton conjugacy classes. `None` when `|G| > cap`.
478pub fn center_order(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
479    conjugacy_classes(degree, gens, cap)
480        .map(|classes| classes.iter().filter(|c| c.len() == 1).count() as u128)
481}
482
483/// The **order of a single permutation**: the least `k ≥ 1` with `gᵏ = id`.
484fn element_order(g: &[usize]) -> usize {
485    if is_identity(g) {
486        return 1;
487    }
488    let mut p = compose(g, g);
489    let mut k = 2;
490    while !is_identity(&p) {
491        p = compose(&p, g);
492        k += 1;
493    }
494    k
495}
496
497fn gcd(mut a: u128, mut b: u128) -> u128 {
498    while b != 0 {
499        (a, b) = (b, a % b);
500    }
501    a
502}
503
504fn lcm(a: u128, b: u128) -> u128 {
505    if a == 0 || b == 0 {
506        0
507    } else {
508        a / gcd(a, b) * b
509    }
510}
511
512/// The **order spectrum** — the set of distinct orders of the group's elements. `None` when `|G| > cap`.
513pub fn element_orders(degree: usize, gens: &[Perm], cap: usize) -> Option<BTreeSet<usize>> {
514    let elements = schreier_sims(degree, gens).elements(cap)?;
515    Some(elements.iter().map(|g| element_order(g)).collect())
516}
517
518/// The **exponent** of the group — the least common multiple of all element orders, i.e. the smallest `e`
519/// with `gᵉ = id` for every `g`. `None` when `|G| > cap`.
520pub fn exponent(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
521    let elements = schreier_sims(degree, gens).elements(cap)?;
522    Some(
523        elements
524            .iter()
525            .fold(1u128, |e, g| lcm(e, element_order(g) as u128)),
526    )
527}
528
529/// The orders `[|Z₀|, |Z₁|, …]` of the **upper central series**, up to the hypercentre. `Z₀ = {id}` and
530/// `Z_{i+1} = { g : [g, x] ∈ Z_i for all x }` (the preimage of the centre of `G/Z_i`). The series ascends
531/// to `|G|` iff `G` is nilpotent. `None` when `|G| > cap`.
532pub fn upper_central_series(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
533    let elements = schreier_sims(degree, gens).elements(cap)?;
534    let mut z: BTreeSet<Perm> = BTreeSet::from([identity(degree)]);
535    let mut orders = vec![1u128];
536    loop {
537        let next: BTreeSet<Perm> = elements
538            .iter()
539            .filter(|g| elements.iter().all(|x| z.contains(&commutator(g, x))))
540            .cloned()
541            .collect();
542        if next.len() == z.len() {
543            break; // stabilised at the hypercentre
544        }
545        orders.push(next.len() as u128);
546        z = next;
547    }
548    Some(orders)
549}
550
551/// The length of the upper central series when it reaches `G` (the nilpotency class) — `None` if it stalls
552/// below `G` (the group is not nilpotent) or `|G| > cap`. Equals [`nilpotency_class`] for nilpotent groups,
553/// an independent route to the same number.
554pub fn upper_central_length(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
555    let orders = upper_central_series(degree, gens, cap)?;
556    (orders.last() == Some(&schreier_sims(degree, gens).order())).then_some(orders.len() - 1)
557}
558
559/// The **cycle type** of a permutation — the sorted multiset of its cycle lengths (fixed points are
560/// 1-cycles). Its length is the number of cycles.
561fn cycle_type(g: &[usize]) -> Vec<usize> {
562    let mut seen = vec![false; g.len()];
563    let mut lengths = Vec::new();
564    for start in 0..g.len() {
565        if seen[start] {
566            continue;
567        }
568        let mut len = 0;
569        let mut x = start;
570        while !seen[x] {
571            seen[x] = true;
572            x = g[x];
573            len += 1;
574        }
575        lengths.push(len);
576    }
577    lengths.sort_unstable();
578    lengths
579}
580
581/// The **cycle index** data — the distribution of cycle types over the group, mapping each cycle type to
582/// the number of elements with it. Dividing by `|G|` gives the cycle index polynomial, the engine of Pólya
583/// enumeration. `None` when `|G| > cap`.
584pub fn cycle_index(degree: usize, gens: &[Perm], cap: usize) -> Option<BTreeMap<Vec<usize>, u128>> {
585    let elements = schreier_sims(degree, gens).elements(cap)?;
586    let mut dist: BTreeMap<Vec<usize>, u128> = BTreeMap::new();
587    for g in &elements {
588        *dist.entry(cycle_type(g)).or_insert(0) += 1;
589    }
590    Some(dist)
591}
592
593/// **Pólya / Burnside count** — the number of ways to colour the `degree` points with `m` colours up to the
594/// group action: `(1/|G|) Σ_g m^{#cycles(g)}`. With `m = 2` this is the number of distinct `{0,1}`
595/// assignments to the points modulo symmetry — the symmetry-reduced size of the assignment space. `None`
596/// when `|G| > cap`.
597pub fn polya_count(degree: usize, gens: &[Perm], m: usize, cap: usize) -> Option<u128> {
598    let elements = schreier_sims(degree, gens).elements(cap)?;
599    let order = elements.len() as u128;
600    let total: u128 = elements
601        .iter()
602        .map(|g| (m as u128).pow(cycle_type(g).len() as u32))
603        .sum();
604    Some(total / order)
605}
606
607/// The **pattern inventory** (weighted Pólya, two colours) — `coeff[w]` is the number of distinct `{0,1}`
608/// assignments to the points with exactly `w` ones, up to the group. Obtained by substituting
609/// `aₖ → (1 + zᵏ)` into the cycle index: a `k`-cycle is either all-0 or all-1, contributing `1 + zᵏ`. The
610/// coefficients sum to [`polya_count`]`(…, 2, …)`. `None` when `|G| > cap`.
611pub fn pattern_inventory(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
612    let elements = schreier_sims(degree, gens).elements(cap)?;
613    let order = elements.len() as u128;
614    let mut total = vec![0u128; degree + 1];
615    for g in &elements {
616        // Π over cycles of (1 + z^len): a polynomial of total degree = Σ len = degree.
617        let mut poly = vec![0u128; degree + 1];
618        poly[0] = 1;
619        for &len in &cycle_type(g) {
620            let prev = poly.clone();
621            for i in 0..=degree {
622                poly[i] = prev[i] + if i >= len { prev[i - len] } else { 0 };
623            }
624        }
625        for i in 0..=degree {
626            total[i] += poly[i];
627        }
628    }
629    Some(total.iter().map(|&c| c / order).collect())
630}
631
632/// The **abelianisation** `G / [G, G]` — the largest abelian quotient — as `(order, exponent)`. The order
633/// is `|G| / |[G, G]|`; the exponent (lcm of coset orders) and whether it equals the order — i.e. whether
634/// `Gᵃᵇ` is cyclic — are the new structural content. `None` when `|G| > cap`.
635pub fn abelianization(degree: usize, gens: &[Perm], cap: usize) -> Option<(u128, u128)> {
636    let elements = schreier_sims(degree, gens).elements(cap)?;
637    let derived: BTreeSet<Perm> = schreier_sims(degree, &derived_subgroup(degree, gens))
638        .elements(cap)?
639        .into_iter()
640        .collect();
641    // Canonical coset representative g·[G,G] = the lexicographically least element of the coset.
642    let coset_rep = |g: &Perm| -> Perm { derived.iter().map(|x| compose(g, x)).min().unwrap() };
643    let cosets: BTreeSet<Perm> = elements.iter().map(|g| coset_rep(g)).collect();
644    let order = cosets.len() as u128;
645    // Order of a coset in the quotient: least k with rᵏ ∈ [G,G].
646    let coset_order = |r: &Perm| -> usize {
647        let mut p = r.clone();
648        let mut k = 1;
649        while !derived.contains(&p) {
650            p = compose(&p, r);
651            k += 1;
652        }
653        k
654    };
655    let exponent = cosets
656        .iter()
657        .fold(1u128, |e, r| lcm(e, coset_order(r) as u128));
658    Some((order, exponent))
659}
660
661/// The subgroup generated by `seed` — its closure under composition (with the identity).
662fn subgroup_closure(degree: usize, seed: &BTreeSet<Perm>) -> BTreeSet<Perm> {
663    let mut set = seed.clone();
664    set.insert(identity(degree));
665    loop {
666        let snapshot: Vec<Perm> = set.iter().cloned().collect();
667        let before = set.len();
668        for a in &snapshot {
669            for b in &snapshot {
670                set.insert(compose(a, b));
671            }
672        }
673        if set.len() == before {
674            break;
675        }
676    }
677    set
678}
679
680/// The **number of subgroups** of the group — the size of its subgroup lattice. Found by breadth-first
681/// search: extend each known subgroup by one outside element and close, deduplicating. `None` when
682/// `|G| > cap` (the enumeration, and the lattice walk, are exponential in the worst case). Classic counts:
683/// `C₄ → 3`, `S₃ → 6`, `V₄ → 5`, `S₄ → 30`.
684fn all_subgroups(degree: usize, gens: &[Perm], cap: usize) -> Option<BTreeSet<BTreeSet<Perm>>> {
685    let elements = schreier_sims(degree, gens).elements(cap)?;
686    let trivial: BTreeSet<Perm> = BTreeSet::from([identity(degree)]);
687    let mut subgroups: BTreeSet<BTreeSet<Perm>> = BTreeSet::from([trivial.clone()]);
688    let mut queue = vec![trivial];
689    while let Some(h) = queue.pop() {
690        for g in &elements {
691            if h.contains(g) {
692                continue;
693            }
694            let mut seed = h.clone();
695            seed.insert(g.clone());
696            let sub = subgroup_closure(degree, &seed);
697            if subgroups.insert(sub.clone()) {
698                queue.push(sub);
699            }
700        }
701    }
702    Some(subgroups)
703}
704
705pub fn subgroup_count(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
706    all_subgroups(degree, gens, cap).map(|s| s.len())
707}
708
709/// Is the subgroup (element set) `h` normal — closed under conjugation by the generators?
710fn is_normal_set(h: &BTreeSet<Perm>, gens: &[Perm]) -> bool {
711    gens.iter().all(|g| {
712        h.iter()
713            .all(|x| h.contains(&compose(&compose(&invert(g), x), g)))
714    })
715}
716
717/// A **maximal proper normal subgroup** (the largest-order one) as an element list — so the quotient
718/// `G/N` is simple. `None` if there is none in range.
719fn maximal_normal_subgroup(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Perm>> {
720    let order = schreier_sims(degree, gens).order();
721    let subgroups = all_subgroups(degree, gens, cap)?;
722    subgroups
723        .iter()
724        .filter(|h| (h.len() as u128) < order && is_normal_set(h, gens))
725        .max_by_key(|h| h.len())
726        .map(|h| h.iter().cloned().collect())
727}
728
729/// The **composition factors** of the group as the sorted multiset of their orders — the Jordan–Hölder
730/// decomposition into simple groups (the "prime factorisation" of the group). Their product is `|G|`; for a
731/// solvable group every factor is a prime (cyclic `Cₚ`), and a non-abelian simple factor (e.g. `A₅`, order
732/// 60) marks unsolvability. `None` when the group is out of range.
733pub fn composition_factor_orders(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
734    let order = schreier_sims(degree, gens).order();
735    if order == 1 {
736        return Some(Vec::new());
737    }
738    if is_simple(degree, gens, cap)? {
739        return Some(vec![order]);
740    }
741    let n = maximal_normal_subgroup(degree, gens, cap)?;
742    let n_order = schreier_sims(degree, &n).order();
743    let mut factors = composition_factor_orders(degree, &n, cap)?;
744    factors.push(order / n_order); // |G/N| is simple
745    factors.sort_unstable();
746    Some(factors)
747}
748
749/// The distinct prime divisors of `n`.
750fn distinct_primes(mut n: u128) -> Vec<u128> {
751    let mut primes = Vec::new();
752    let mut p = 2u128;
753    while p * p <= n {
754        if n % p == 0 {
755            primes.push(p);
756            while n % p == 0 {
757                n /= p;
758            }
759        }
760        p += 1;
761    }
762    if n > 1 {
763        primes.push(n);
764    }
765    primes
766}
767
768/// The full `p`-power `pᵃ` with `pᵃ ∥ n` (i.e. `pᵃ | n` but `pᵃ⁺¹ ∤ n`).
769fn prime_power_part(n: u128, p: u128) -> u128 {
770    let mut pa = 1;
771    let mut m = n;
772    while m % p == 0 {
773        pa *= p;
774        m /= p;
775    }
776    pa
777}
778
779/// The **Sylow structure** — for each prime `p ∣ |G|`, the number `n_p` of Sylow `p`-subgroups (the
780/// subgroups of maximal `p`-power order `pᵃ ∥ |G|`; all such subgroups are conjugate, so counting them
781/// counts the Sylow subgroups). Returned as `(p, n_p)` pairs sorted by `p`. Sylow's theorems guarantee
782/// `n_p ≡ 1 (mod p)` and `n_p ∣ |G|/pᵃ`. `None` when the subgroup lattice is out of range.
783pub fn sylow_counts(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<(u128, usize)>> {
784    let order = schreier_sims(degree, gens).order();
785    let subgroups = all_subgroups(degree, gens, cap)?;
786    Some(
787        distinct_primes(order)
788            .into_iter()
789            .map(|p| {
790                let pa = prime_power_part(order, p);
791                let n_p = subgroups.iter().filter(|h| h.len() as u128 == pa).count();
792                (p, n_p)
793            })
794            .collect(),
795    )
796}
797
798/// Map every group element to the index of its conjugacy class.
799fn class_index_map(classes: &[Vec<Perm>]) -> BTreeMap<Perm, usize> {
800    let mut idx = BTreeMap::new();
801    for (i, class) in classes.iter().enumerate() {
802        for g in class {
803            idx.insert(g.clone(), i);
804        }
805    }
806    idx
807}
808
809/// The **class-algebra structure constants** `a[i][j][k] = #{ x ∈ Cᵢ : x⁻¹·z ∈ Cⱼ }` for `z` a fixed
810/// representative of class `Cₖ` (independent of the choice of `z`). These are the multiplication
811/// coefficients of the centre of the group algebra — `Cᵢ·Cⱼ = Σₖ a[i][j][k]·Cₖ` — and the foundation of
812/// the Burnside–Dixon character-table algorithm. They satisfy `Σₖ a[i][j][k]·|Cₖ| = |Cᵢ|·|Cⱼ|`. `None`
813/// when `|G| > cap`.
814pub fn class_multiplication_coefficients(
815    degree: usize,
816    gens: &[Perm],
817    cap: usize,
818) -> Option<Vec<Vec<Vec<u128>>>> {
819    let classes = conjugacy_classes(degree, gens, cap)?;
820    let idx = class_index_map(&classes);
821    let k = classes.len();
822    let mut a = vec![vec![vec![0u128; k]; k]; k];
823    for (kk, class_k) in classes.iter().enumerate() {
824        let z = &class_k[0];
825        for (i, class_i) in classes.iter().enumerate() {
826            for x in class_i {
827                let j = idx[&compose(&invert(x), z)]; // class of x⁻¹·z
828                a[i][j][kk] += 1;
829            }
830        }
831    }
832    Some(a)
833}
834
835/// The number of **real conjugacy classes** — those closed under inversion (`C = C⁻¹`). By Burnside's
836/// theorem this equals the number of real-valued irreducible characters. `None` when `|G| > cap`.
837pub fn real_class_count(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
838    let classes = conjugacy_classes(degree, gens, cap)?;
839    let idx = class_index_map(&classes);
840    Some(
841        classes
842            .iter()
843            .enumerate()
844            .filter(|(i, c)| idx[&invert(&c[0])] == *i)
845            .count(),
846    )
847}
848
849/// `g^t` under the right-action composition (`(g·h)[x] = h[g[x]]`), by repeated squaring.
850fn perm_pow(g: &[usize], mut t: usize) -> Perm {
851    let mut result = identity(g.len());
852    let mut base = g.to_vec();
853    while t > 0 {
854        if t & 1 == 1 {
855            result = compose(&result, &base);
856        }
857        base = compose(&base, &base);
858        t >>= 1;
859    }
860    result
861}
862
863/// The **Galois orbits on conjugacy classes**. The Galois group `Gal(ℚ(ζ_e)/ℚ) ≅ (ℤ/e)*` (`e` = the group
864/// exponent) acts on classes by `C ↦ C^t` (the class of `g^t`), for every `t` coprime to `e` — this is the
865/// action dual to the Galois action `σ_t(χ)(g) = χ(g^t)` on irreducible characters. Two classes share an
866/// orbit iff they are *algebraically conjugate* (`g ~ g^t` for some coprime `t`). `None` when `|G| > cap`.
867pub fn galois_class_orbits(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<Vec<usize>>> {
868    let classes = conjugacy_classes(degree, gens, cap)?;
869    let idx = class_index_map(&classes);
870    let e = exponent(degree, gens, cap)? as usize;
871    let k = classes.len();
872    let mut parent: Vec<usize> = (0..k).collect();
873    fn find(parent: &mut [usize], mut x: usize) -> usize {
874        while parent[x] != x {
875            parent[x] = parent[parent[x]];
876            x = parent[x];
877        }
878        x
879    }
880    for t in 1..e.max(2) {
881        if gcd(t as u128, e as u128) != 1 {
882            continue;
883        }
884        for r in 0..k {
885            let img = idx[&perm_pow(&classes[r][0], t)];
886            let (a, b) = (find(&mut parent, r), find(&mut parent, img));
887            parent[a] = b;
888        }
889    }
890    let mut groups: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
891    for r in 0..k {
892        let root = find(&mut parent, r);
893        groups.entry(root).or_default().push(r);
894    }
895    Some(groups.into_values().collect())
896}
897
898/// The number of **rational conjugacy classes** — classes `C` fixed by the *whole* Galois group
899/// (`g ~ g^t` for every `t` coprime to `ord(g)`), i.e. the singleton [`galois_class_orbits`]. By Burnside's
900/// rationality theorem this equals the number of rational-valued irreducible characters. Strictly refines
901/// [`real_class_count`] (real = closed under the single element `t = −1`): rational ⟹ real, and the counts
902/// differ exactly when a character is real but irrational (e.g. `A₅`'s golden-ratio degree-3 pair).
903/// `None` when `|G| > cap`.
904pub fn rational_class_count(degree: usize, gens: &[Perm], cap: usize) -> Option<usize> {
905    Some(
906        galois_class_orbits(degree, gens, cap)?
907            .iter()
908            .filter(|o| o.len() == 1)
909            .count(),
910    )
911}
912
913/// The order of the **automorphism group** `Aut(G)` of `G = ⟨gens⟩` — the symmetries of the group itself
914/// (bijections `G → G` preserving multiplication, `φ(xy) = φ(x)φ(y)`). An automorphism is determined by the
915/// images of a generating set, so the search ranges over candidate images (each generator must map to an
916/// element of the same order, a necessary condition) and accepts those that extend to a consistent,
917/// bijective homomorphism. `None` when `|G| > cap` or the candidate search would exceed its budget. Classic:
918/// `|Aut(Cₙ)| = φ(n)`, `|Aut(Sₙ)| = n!` (n≠6), `|Aut(V₄)| = 6`, `|Aut(D₄)| = 8`, `|Aut(Q₈)| = 24`.
919pub fn automorphism_group_order(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
920    let seed: BTreeSet<Perm> = gens.iter().cloned().collect();
921    let elements: Vec<Perm> = subgroup_closure(degree, &seed).into_iter().collect();
922    let n = elements.len();
923    if n > cap {
924        return None;
925    }
926    let idx: BTreeMap<Perm, usize> = elements
927        .iter()
928        .enumerate()
929        .map(|(i, e)| (e.clone(), i))
930        .collect();
931    let id_idx = idx[&identity(degree)];
932    let mul: Vec<Vec<usize>> = (0..n)
933        .map(|i| {
934            (0..n)
935                .map(|j| idx[&compose(&elements[i], &elements[j])])
936                .collect()
937        })
938        .collect();
939    let ord: Vec<usize> = elements.iter().map(|e| element_order(e)).collect();
940    // A generating set: the distinct non-identity input generators (the closure is unchanged).
941    let mut gen_idx: Vec<usize> = Vec::new();
942    for g in gens {
943        let gi = idx[g];
944        if gi != id_idx && !gen_idx.contains(&gi) {
945            gen_idx.push(gi);
946        }
947    }
948    if gen_idx.is_empty() {
949        return Some(1); // the trivial group has only the identity automorphism
950    }
951    // Candidate images per generator: same-order elements. Budget the search-space product.
952    let candidates: Vec<Vec<usize>> = gen_idx
953        .iter()
954        .map(|&gi| (0..n).filter(|&e| ord[e] == ord[gi]).collect::<Vec<_>>())
955        .collect();
956    if candidates.iter().map(|c| c.len() as u128).product::<u128>() > 2_000_000 {
957        return None;
958    }
959    let m = gen_idx.len();
960    let mut count = 0u128;
961    let mut choice = vec![0usize; m];
962    loop {
963        let img: Vec<usize> = (0..m).map(|t| candidates[t][choice[t]]).collect();
964        // Extend φ (sending gen_idx[t] ↦ img[t]) by BFS over the Cayley graph from the identity.
965        let mut phi = vec![usize::MAX; n];
966        phi[id_idx] = id_idx;
967        let mut queue = vec![id_idx];
968        let mut head = 0;
969        let mut ok = true;
970        'bfs: while head < queue.len() {
971            let u = queue[head];
972            head += 1;
973            for t in 0..m {
974                let ug = mul[u][gen_idx[t]];
975                let target = mul[phi[u]][img[t]];
976                if phi[ug] == usize::MAX {
977                    phi[ug] = target;
978                    queue.push(ug);
979                } else if phi[ug] != target {
980                    ok = false;
981                    break 'bfs;
982                }
983            }
984        }
985        // A valid automorphism is total and bijective.
986        if ok && phi.iter().all(|&x| x != usize::MAX) {
987            let mut seen = vec![false; n];
988            if phi.iter().all(|&x| !std::mem::replace(&mut seen[x], true)) {
989                count += 1;
990            }
991        }
992        let mut t = 0;
993        while t < m {
994            choice[t] += 1;
995            if choice[t] < candidates[t].len() {
996                break;
997            }
998            choice[t] = 0;
999            t += 1;
1000        }
1001        if t == m {
1002            break;
1003        }
1004    }
1005    Some(count)
1006}
1007
1008/// The order of the **outer automorphism group** `Out(G) = Aut(G)/Inn(G)`, where the inner automorphisms
1009/// `Inn(G) ≅ G/Z(G)` are those realised by conjugation. `Out(G)` counts the "exotic" symmetries of the
1010/// group not coming from within it. `None` when out of range.
1011pub fn outer_automorphism_order(degree: usize, gens: &[Perm], cap: usize) -> Option<u128> {
1012    let aut = automorphism_group_order(degree, gens, cap)?;
1013    let order = schreier_sims(degree, gens).order();
1014    let center = center_order(degree, gens, cap)?;
1015    Some(aut / (order / center)) // |Inn(G)| = |G| / |Z(G)|
1016}
1017
1018/// The **table of marks** of `G = ⟨gens⟩` — the Burnside-ring analogue of the character table. Rows and
1019/// columns are the conjugacy classes of subgroups (ordered by increasing order); the `(i,j)` entry is the
1020/// **mark** `m(H_i, H_j)` = the number of `H_i`-fixed points in the transitive action of `G` on the cosets
1021/// `G/H_j`, computed as `(1/|H_j|)·|{g ∈ G : g⁻¹ H_i g ⊆ H_j}|`. Returns `(subgroup_class_orders, marks)`.
1022///
1023/// The complete invariant of the category of `G`-sets: every finite `G`-set decomposes uniquely into the
1024/// transitive ones `G/H_j`, and the marks record how each subgroup sees each. With this ordering the matrix
1025/// is triangular with diagonal `[N_G(H_i):H_i]`, hence invertible. Where [`character_table`] classifies the
1026/// LINEAR representations of `G`, the table of marks classifies its PERMUTATION representations. Exact
1027/// integer arithmetic. `None` when `|G| > cap`.
1028pub fn table_of_marks(
1029    degree: usize,
1030    gens: &[Perm],
1031    cap: usize,
1032) -> Option<(Vec<u128>, Vec<Vec<u128>>)> {
1033    let elements: Vec<Perm> = subgroup_closure(degree, &gens.iter().cloned().collect())
1034        .into_iter()
1035        .collect();
1036    let subs = all_subgroups(degree, gens, cap)?;
1037    // Conjugation x ↦ g⁻¹ x g, applied to a whole subgroup.
1038    let conjugate = |h: &BTreeSet<Perm>, g: &Perm| -> BTreeSet<Perm> {
1039        let gi = invert(g);
1040        h.iter().map(|x| compose(&compose(&gi, x), g)).collect()
1041    };
1042    // One representative per conjugacy class of subgroups.
1043    let mut reps: Vec<BTreeSet<Perm>> = Vec::new();
1044    let mut seen: BTreeSet<BTreeSet<Perm>> = BTreeSet::new();
1045    for h in &subs {
1046        if seen.contains(h) {
1047            continue;
1048        }
1049        for g in &elements {
1050            seen.insert(conjugate(h, g));
1051        }
1052        reps.push(h.clone());
1053    }
1054    // Order classes by subgroup order, then canonically — makes the table triangular and deterministic.
1055    reps.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
1056    let k = reps.len();
1057    let orders: Vec<u128> = reps.iter().map(|h| h.len() as u128).collect();
1058    let mut marks = vec![vec![0u128; k]; k];
1059    for i in 0..k {
1060        for j in 0..k {
1061            if reps[i].len() > reps[j].len() {
1062                continue; // a larger subgroup cannot be conjugated inside a smaller one
1063            }
1064            let count = elements
1065                .iter()
1066                .filter(|g| conjugate(&reps[i], g).iter().all(|x| reps[j].contains(x)))
1067                .count() as u128;
1068            marks[i][j] = count / reps[j].len() as u128;
1069        }
1070    }
1071    Some((orders, marks))
1072}
1073
1074/// The **Burnside ring** multiplication of `G = ⟨gens⟩` — the structure constants `N[a][b][l]` giving the
1075/// decomposition of the product G-set `(G/H_a) × (G/H_b) = ⊔_l N[a][b][l]·(G/H_l)` into transitive G-sets,
1076/// indexed by the conjugacy classes of subgroups (same order as [`table_of_marks`]).
1077///
1078/// Marks are multiplicative — a subgroup fixes a pair iff it fixes each coordinate — so the mark vector of a
1079/// product is the componentwise product of the factors' mark vectors; the (triangular, invertible) table of
1080/// marks is then back-substituted to recover the multiplicities. This is the multiplication of the Burnside
1081/// ring, the G-set analogue of the tensor/fusion ring of the character table ([`tensor_decomposition`]).
1082/// FAIL-CLOSED: `None` if any back-substitution is inexact (it never is for genuine G-sets, so this
1083/// certifies the result). Coefficients are non-negative integers.
1084pub fn burnside_ring_product(
1085    degree: usize,
1086    gens: &[Perm],
1087    cap: usize,
1088) -> Option<Vec<Vec<Vec<i128>>>> {
1089    let (_orders, marks) = table_of_marks(degree, gens, cap)?;
1090    let k = marks.len();
1091    // Solve marks · c = p by back-substitution (indices ordered by increasing subgroup order, so the system
1092    // is upper-triangular with a nonzero diagonal).
1093    let solve = |p: &[u128]| -> Option<Vec<i128>> {
1094        let mut c = vec![0i128; k];
1095        for i in (0..k).rev() {
1096            let mut acc = p[i] as i128;
1097            for l in (i + 1)..k {
1098                acc -= marks[i][l] as i128 * c[l];
1099            }
1100            let diag = marks[i][i] as i128;
1101            if diag == 0 || acc % diag != 0 {
1102                return None; // inexact ⇒ not a genuine G-set decomposition
1103            }
1104            c[i] = acc / diag;
1105        }
1106        Some(c)
1107    };
1108    let mut n = vec![vec![vec![0i128; k]; k]; k];
1109    for a in 0..k {
1110        for b in 0..k {
1111            let p: Vec<u128> = (0..k).map(|i| marks[i][a] * marks[i][b]).collect();
1112            let c = solve(&p)?;
1113            for l in 0..k {
1114                n[a][b][l] = c[l];
1115            }
1116        }
1117    }
1118    Some(n)
1119}
1120
1121/// The **Möbius function to the top** of the subgroup lattice: `μ(H, G)` for every subgroup `H`, returned as
1122/// `(subgroup_orders, mu)` aligned by index. Defined by `μ(G,G)=1` and `μ(H,G) = -Σ_{H ⊊ L} μ(L,G)`. `None`
1123/// when `|G| > cap`.
1124fn lattice_mobius_to_top(
1125    degree: usize,
1126    gens: &[Perm],
1127    cap: usize,
1128) -> Option<(Vec<u128>, Vec<i128>)> {
1129    let subs: Vec<BTreeSet<Perm>> = all_subgroups(degree, gens, cap)?.into_iter().collect();
1130    let n = subs.len();
1131    let top_size = subs.iter().map(|h| h.len()).max().unwrap_or(0); // |G|, unique maximum
1132    let mut order: Vec<usize> = (0..n).collect();
1133    order.sort_by_key(|&i| std::cmp::Reverse(subs[i].len())); // descending: compute supergroups first
1134    let mut mu = vec![0i128; n];
1135    for &i in &order {
1136        if subs[i].len() == top_size {
1137            mu[i] = 1; // μ(G, G) = 1
1138        } else {
1139            let s: i128 = (0..n)
1140                .filter(|&j| subs[i].len() < subs[j].len() && subs[i].is_subset(&subs[j]))
1141                .map(|j| mu[j])
1142                .sum();
1143            mu[i] = -s;
1144        }
1145    }
1146    Some((subs.iter().map(|h| h.len() as u128).collect(), mu))
1147}
1148
1149/// The **Möbius number** `μ(1, G)` of the subgroup lattice of `G = ⟨gens⟩` — the value of the lattice's
1150/// Möbius function from the trivial subgroup to the whole group. A classical invariant: for a cyclic group
1151/// `μ(1, Cₙ)` is the number-theoretic Möbius function `μ(n)`, and in general it drives the group's Eulerian
1152/// (probabilistic-zeta) function. `None` when `|G| > cap`.
1153pub fn mobius_number(degree: usize, gens: &[Perm], cap: usize) -> Option<i128> {
1154    let (orders, mu) = lattice_mobius_to_top(degree, gens, cap)?;
1155    (0..orders.len()).find(|&i| orders[i] == 1).map(|i| mu[i])
1156}
1157
1158/// The number of ordered `k`-tuples of group elements that **generate** `G` — the Eulerian function
1159/// `e_k(G) = Σ_{H ≤ G} μ(H, G)·|H|ᵏ` (Hall's formula by Möbius inversion over the subgroup lattice, since
1160/// `|H|ᵏ` counts the `k`-tuples landing in `H`). Dividing by `|G|ᵏ` gives the probability that `k` random
1161/// elements generate `G`. `None` when `|G| > cap`.
1162pub fn generating_tuple_count(degree: usize, gens: &[Perm], cap: usize, k: u32) -> Option<i128> {
1163    let (orders, mu) = lattice_mobius_to_top(degree, gens, cap)?;
1164    Some(
1165        (0..orders.len())
1166            .map(|i| mu[i] * (orders[i] as i128).pow(k))
1167            .sum(),
1168    )
1169}
1170
1171/// One representative per conjugacy class of subgroups, ordered by increasing order (the row/column index
1172/// shared by [`table_of_marks`] and the permutation-character decomposition).
1173fn subgroup_class_reps(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<BTreeSet<Perm>>> {
1174    let elements: Vec<Perm> = subgroup_closure(degree, &gens.iter().cloned().collect())
1175        .into_iter()
1176        .collect();
1177    let subs = all_subgroups(degree, gens, cap)?;
1178    let conjugate = |h: &BTreeSet<Perm>, g: &Perm| -> BTreeSet<Perm> {
1179        let gi = invert(g);
1180        h.iter().map(|x| compose(&compose(&gi, x), g)).collect()
1181    };
1182    let mut reps: Vec<BTreeSet<Perm>> = Vec::new();
1183    let mut seen: BTreeSet<BTreeSet<Perm>> = BTreeSet::new();
1184    for h in &subs {
1185        if seen.contains(h) {
1186            continue;
1187        }
1188        for g in &elements {
1189            seen.insert(conjugate(h, g));
1190        }
1191        reps.push(h.clone());
1192    }
1193    reps.sort_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
1194    Some(reps)
1195}
1196
1197/// The **permutation-character decomposition** — the bridge between the table of marks and the character
1198/// table. `M[i][s]` is the multiplicity of the irreducible `χ_s` in the permutation representation of `G`
1199/// 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
1200/// reciprocity = the dimension of the `H_i`-fixed subspace of `χ_s`). Rows are subgroup conjugacy classes
1201/// (as in [`table_of_marks`]), columns are irreducibles (as in [`character_table`]). Returns
1202/// `(subgroup_orders, irreducible_degrees, M)`.
1203///
1204/// This is the "linearization" map from the Burnside ring to the representation ring made explicit. The
1205/// character values are read off the `GF(p)` character table; each multiplicity is a small non-negative
1206/// integer (`≤ d_s`), so it decodes uniquely. FAIL-CLOSED: `None` unless `M[1] = ` the degrees (the regular
1207/// representation contains each `χ_s` with multiplicity `d_s`), `M[i][trivial] = 1` (every transitive action
1208/// contains the trivial character once), `M[G] = e_trivial`, and `Σ_s M[i][s]·d_s = [G:H_i]` for every row.
1209pub fn permutation_character_decomposition(
1210    degree: usize,
1211    gens: &[Perm],
1212    cap: usize,
1213) -> Option<(Vec<u128>, Vec<u128>, Vec<Vec<u128>>)> {
1214    let ct = character_table(degree, gens, cap)?;
1215    let p = ct.prime;
1216    let k = ct.degrees.len();
1217    let classes = conjugacy_classes(degree, gens, cap)?;
1218    let idx = class_index_map(&classes);
1219    let reps = subgroup_class_reps(degree, gens, cap)?;
1220    let order: u128 = ct.degrees.iter().map(|d| d * d).sum();
1221
1222    let mut m = vec![vec![0u128; k]; reps.len()];
1223    for (i, h) in reps.iter().enumerate() {
1224        // |H_i ∩ C_r| for each conjugacy class C_r.
1225        let mut inter = vec![0u128; k];
1226        for x in h {
1227            inter[idx[x]] += 1;
1228        }
1229        let inv_h = mod_inv((h.len() as u128 % p as u128) as u64, p);
1230        for s in 0..k {
1231            let mut acc = 0u64;
1232            for r in 0..k {
1233                let term = (inter[r] % p as u128) as u64;
1234                acc = ((acc as u128 + term as u128 * ct.values[s][r] as u128) % p as u128) as u64;
1235            }
1236            m[i][s] = (acc as u128 * inv_h as u128 % p as u128) as u128;
1237        }
1238    }
1239
1240    // FAIL-CLOSED verification against the classical identities.
1241    let trivial_irr = ct
1242        .values
1243        .iter()
1244        .position(|row| row.iter().all(|&x| x == 1))?;
1245    for (i, h) in reps.iter().enumerate() {
1246        if m[i][trivial_irr] != 1 {
1247            return None; // a transitive action contains the trivial character exactly once
1248        }
1249        let dim: u128 = (0..k).map(|s| m[i][s] * ct.degrees[s]).sum();
1250        if dim != order / h.len() as u128 {
1251            return None; // Σ_s M·d_s = [G : H_i]
1252        }
1253        for s in 0..k {
1254            if m[i][s] > ct.degrees[s] {
1255                return None; // multiplicity cannot exceed the degree
1256            }
1257        }
1258    }
1259    if m[0] != ct.degrees {
1260        return None; // the regular representation G/1 = Σ_s d_s·χ_s
1261    }
1262    let mut e_triv = vec![0u128; k];
1263    e_triv[trivial_irr] = 1;
1264    if *m.last()? != e_triv {
1265        return None; // G/G is the trivial representation
1266    }
1267    let orders: Vec<u128> = reps.iter().map(|h| h.len() as u128).collect();
1268    Some((orders, ct.degrees, m))
1269}
1270
1271// ---- Character table via the Burnside–Dixon method --------------------------------------------
1272//
1273// The class sums `Ĉ_r = Σ_{g∈C_r} g` span the centre of the group algebra; left-multiplication by `Ĉ_i`
1274// is the matrix `M_i` with `M_i[k][j] = a[i][j][k]` (the structure constants). The `M_i` pairwise commute
1275// (the centre is commutative) and are simultaneously diagonalisable, with exactly `k = #classes` common
1276// eigenvectors — one per irreducible character `χ_s`. The eigenvalue of `M_i` on `χ_s`'s eigenvector is
1277// `ω_i(χ_s) = |C_i|·χ_s(C_i)/d_s` (`d_s = χ_s(1)`). Dixon's insight: work over `GF(p)` for a prime
1278// `p ≡ 1 (mod exponent)` with `p > |G|`, where every `ω` lives (characters are sums of `e`-th roots of
1279// unity, which `GF(p)` contains), so the whole computation is EXACT — no floats, no SDP.
1280
1281/// `aᵇ mod p`. `p` need not be prime here.
1282fn mod_pow(mut base: u64, mut exp: u64, p: u64) -> u64 {
1283    let mut r = 1u128;
1284    let mut b = (base % p) as u128;
1285    base = 0; // silence unused-assignment style; b carries the value
1286    let _ = base;
1287    while exp > 0 {
1288        if exp & 1 == 1 {
1289            r = (r * b) % p as u128;
1290        }
1291        b = (b * b) % p as u128;
1292        exp >>= 1;
1293    }
1294    r as u64
1295}
1296
1297/// Multiplicative inverse in `GF(p)` (`p` prime) via Fermat: `a^(p-2)`. `a` must be `≢ 0`.
1298pub(crate) fn mod_inv(a: u64, p: u64) -> u64 {
1299    mod_pow(a % p, p - 2, p)
1300}
1301
1302/// Trial-division primality (the Dixon primes here are small — `O(√|G|)`-ish).
1303pub(crate) fn is_prime(n: u64) -> bool {
1304    if n < 2 {
1305        return false;
1306    }
1307    if n % 2 == 0 {
1308        return n == 2;
1309    }
1310    let mut d = 3u64;
1311    while d * d <= n {
1312        if n % d == 0 {
1313            return false;
1314        }
1315        d += 2;
1316    }
1317    true
1318}
1319
1320/// Integer floor square root.
1321fn isqrt(n: u128) -> u128 {
1322    if n < 2 {
1323        return n;
1324    }
1325    let mut x = (n as f64).sqrt() as u128;
1326    while x * x > n {
1327        x -= 1;
1328    }
1329    while (x + 1) * (x + 1) <= n {
1330        x += 1;
1331    }
1332    x
1333}
1334
1335/// `M·v` over `GF(p)` (`m` is `k×k` as rows, `v` length `k`).
1336pub(crate) fn gf_mat_vec(m: &[Vec<u64>], v: &[u64], p: u64) -> Vec<u64> {
1337    m.iter()
1338        .map(|row| {
1339            let mut acc = 0u128;
1340            for (a, b) in row.iter().zip(v) {
1341                acc += (*a as u128) * (*b as u128);
1342            }
1343            (acc % p as u128) as u64
1344        })
1345        .collect()
1346}
1347
1348/// A basis for the right null space `{x ∈ GF(p)^ncols : A·x = 0}` of `a` (given as rows), via RREF.
1349pub(crate) fn gf_nullspace(mut a: Vec<Vec<u64>>, ncols: usize, p: u64) -> Vec<Vec<u64>> {
1350    let nrows = a.len();
1351    let mut where_pivot = vec![usize::MAX; ncols]; // pivot row of each column, or MAX
1352    let mut row = 0usize;
1353    for col in 0..ncols {
1354        if row >= nrows {
1355            break;
1356        }
1357        let Some(sel) = (row..nrows).find(|&r| a[r][col] % p != 0) else {
1358            continue;
1359        };
1360        a.swap(row, sel);
1361        let inv = mod_inv(a[row][col], p);
1362        for c in 0..ncols {
1363            a[row][c] = ((a[row][c] as u128 * inv as u128) % p as u128) as u64;
1364        }
1365        for r in 0..nrows {
1366            if r != row && a[r][col] != 0 {
1367                let f = a[r][col] as u128;
1368                for c in 0..ncols {
1369                    let sub = (f * a[row][c] as u128) % p as u128;
1370                    a[r][c] = ((a[r][c] as u128 + p as u128 - sub) % p as u128) as u64;
1371                }
1372            }
1373        }
1374        where_pivot[col] = row;
1375        row += 1;
1376    }
1377    let mut basis = Vec::new();
1378    for fc in 0..ncols {
1379        if where_pivot[fc] != usize::MAX {
1380            continue; // pivot column, not free
1381        }
1382        let mut x = vec![0u64; ncols];
1383        x[fc] = 1;
1384        for (col, &pr) in where_pivot.iter().enumerate() {
1385            if pr != usize::MAX {
1386                x[col] = (p - a[pr][fc] % p) % p; // x[col] = -a[pr][fc]
1387            }
1388        }
1389        basis.push(x);
1390    }
1391    basis
1392}
1393
1394/// The character table of `⟨gens⟩`, computed exactly by Dixon's method.
1395#[derive(Clone, Debug, PartialEq, Eq)]
1396pub struct CharacterTable {
1397    /// The Dixon prime `p` (`p ≡ 1 mod exponent`, `p > |G|`) the values live in.
1398    pub prime: u64,
1399    /// Conjugacy class sizes `|C_r|` (column `r`).
1400    pub class_sizes: Vec<u128>,
1401    /// Inverse-class index `r̄` (`C_{r̄} = C_r⁻¹`); `χ(C_{r̄})` is the `GF(p)` image of `conj χ(C_r)`.
1402    pub inverse_class: Vec<usize>,
1403    /// The squaring power map: `power_map2[r]` is the class index of `g_r²` (`g_r ∈ C_r`) — a class
1404    /// invariant (`(xgx⁻¹)² = xg²x⁻¹`), the input to the Frobenius–Schur indicators.
1405    pub power_map2: Vec<usize>,
1406    /// The index of the identity class (`{e}`).
1407    pub identity_class: usize,
1408    /// A representative permutation of each class (`C_r`'s first element) — lets character-derived
1409    /// quantities that depend on the underlying action (e.g. the permutation character) read off the table.
1410    pub class_reps: Vec<Perm>,
1411    /// Irreducible degrees `χ_s(1)` (row `s`), as exact integers; `Σ d_s² = |G|`.
1412    pub degrees: Vec<u128>,
1413    /// `χ_s(C_r)` as a `GF(p)` element (the image of the algebraic character value). `values[s][r]`.
1414    pub values: Vec<Vec<u64>>,
1415}
1416
1417/// Compute the full character table via the Burnside–Dixon algorithm: build the commuting class-algebra
1418/// matrices `M_i` from the structure constants, choose a Dixon prime `p`, simultaneously diagonalise the
1419/// `M_i` over `GF(p)` by iterated eigenspace refinement (one common eigenvector per irreducible), then
1420/// read off each degree `d_s` (exact integer, via `d² = |G|/Σ_r ω_r ω_{r̄}/|C_r|`) and the character
1421/// values `χ_s(C_r) = d_s ω_r/|C_r|`. Returns `None` when `|G| > cap`, the group is too large for the
1422/// finite-field arithmetic, or — FAIL-CLOSED — the recovered table does not satisfy the row-orthogonality
1423/// and degree relations (so a returned table is always a verified one). Rows are sorted by `(degree,
1424/// values)` for determinism.
1425pub fn character_table(degree: usize, gens: &[Perm], cap: usize) -> Option<CharacterTable> {
1426    let classes = conjugacy_classes(degree, gens, cap)?;
1427    let k = classes.len();
1428    // Bound the finite-field work: the structure tensor is k³ and the diagonalisation is O(p·k⁴).
1429    if k == 0 || k > 64 {
1430        return None;
1431    }
1432    let order = schreier_sims(degree, gens).order();
1433    if order == 0 || order > 100_000 {
1434        return None;
1435    }
1436    let idx = class_index_map(&classes);
1437    let class_sizes: Vec<u128> = classes.iter().map(|c| c.len() as u128).collect();
1438    let inverse_class: Vec<usize> = classes.iter().map(|c| idx[&invert(&c[0])]).collect();
1439    let power_map2: Vec<usize> = classes
1440        .iter()
1441        .map(|c| idx[&compose(&c[0], &c[0])])
1442        .collect();
1443    let class_reps: Vec<Perm> = classes.iter().map(|c| c[0].clone()).collect();
1444    let id_perm = identity(degree);
1445    let identity_class = classes
1446        .iter()
1447        .position(|c| c.iter().any(|g| *g == id_perm))?;
1448
1449    // Dixon prime: p ≡ 1 (mod exponent), prime, and p > |G| (so |G| and every |C_r| are invertible and
1450    // degree recovery is unique — p > |G| ⟹ p > 2√|G|).
1451    let e = exponent(degree, gens, cap)? as u64;
1452    let order_u = order as u64;
1453    let p = {
1454        let mut m = order_u / e + 1;
1455        let mut found = None;
1456        for _ in 0..1_000_000 {
1457            let cand = e.checked_mul(m)?.checked_add(1)?;
1458            if cand > order_u && is_prime(cand) {
1459                found = Some(cand);
1460                break;
1461            }
1462            m += 1;
1463        }
1464        found?
1465    };
1466
1467    // Structure constants → the commuting matrices M_i with M_i[k][j] = a[i][j][k] (mod p).
1468    let a = class_multiplication_coefficients(degree, gens, cap)?;
1469    let mmats: Vec<Vec<Vec<u64>>> = (0..k)
1470        .map(|i| {
1471            let mut m = vec![vec![0u64; k]; k];
1472            for j in 0..k {
1473                for kk in 0..k {
1474                    m[kk][j] = (a[i][j][kk] % p as u128) as u64;
1475                }
1476            }
1477            m
1478        })
1479        .collect();
1480
1481    // Simultaneous diagonalisation: refine the whole space into the k common 1-dim eigenspaces.
1482    let mut subspaces: Vec<Vec<Vec<u64>>> = vec![(0..k)
1483        .map(|i| {
1484            let mut ei = vec![0u64; k];
1485            ei[i] = 1;
1486            ei
1487        })
1488        .collect()];
1489    for mi in &mmats {
1490        if subspaces.iter().all(|s| s.len() == 1) {
1491            break;
1492        }
1493        let mut next: Vec<Vec<Vec<u64>>> = Vec::new();
1494        for s in &subspaces {
1495            if s.len() == 1 {
1496                next.push(s.clone());
1497                continue;
1498            }
1499            let bn = s.len();
1500            let mb: Vec<Vec<u64>> = s.iter().map(|b| gf_mat_vec(mi, b, p)).collect();
1501            let mut pieces: Vec<Vec<Vec<u64>>> = Vec::new();
1502            let mut covered = 0usize;
1503            for lam in 0..p {
1504                // Null space of (M_i - λI)|_s in the basis `s`: columns are (M_i - λI)·b_j.
1505                let mut rows = vec![vec![0u64; bn]; k];
1506                for r in 0..k {
1507                    for (j, bj) in s.iter().enumerate() {
1508                        let shifted = (lam as u128 * bj[r] as u128) % p as u128;
1509                        rows[r][j] = ((mb[j][r] as u128 + p as u128 - shifted) % p as u128) as u64;
1510                    }
1511                }
1512                let ns = gf_nullspace(rows, bn, p);
1513                if ns.is_empty() {
1514                    continue;
1515                }
1516                let eig: Vec<Vec<u64>> = ns
1517                    .iter()
1518                    .map(|c| {
1519                        let mut x = vec![0u64; k];
1520                        for (j, &cj) in c.iter().enumerate() {
1521                            if cj != 0 {
1522                                for r in 0..k {
1523                                    x[r] = ((x[r] as u128 + cj as u128 * s[j][r] as u128)
1524                                        % p as u128)
1525                                        as u64;
1526                                }
1527                            }
1528                        }
1529                        x
1530                    })
1531                    .collect();
1532                covered += eig.len();
1533                pieces.push(eig);
1534                if covered == bn {
1535                    break;
1536                }
1537            }
1538            if covered == bn {
1539                next.extend(pieces);
1540            } else {
1541                next.push(s.clone()); // a later M_i may split it; final check rejects if none do
1542            }
1543        }
1544        subspaces = next;
1545    }
1546    if subspaces.iter().any(|s| s.len() != 1) {
1547        return None; // did not fully diagonalise — fail closed
1548    }
1549
1550    // Read off each irreducible from its common eigenvector.
1551    let order_p = (order % p as u128) as u64;
1552    let max_deg = isqrt(order);
1553    let mut rows: Vec<(u128, Vec<u64>)> = Vec::with_capacity(k);
1554    for s in &subspaces {
1555        let v = &s[0];
1556        let t = v.iter().position(|&x| x != 0)?;
1557        let inv_vt = mod_inv(v[t], p);
1558        let omega: Vec<u64> = mmats
1559            .iter()
1560            .map(|mi| {
1561                let mv = gf_mat_vec(mi, v, p);
1562                ((mv[t] as u128 * inv_vt as u128) % p as u128) as u64
1563            })
1564            .collect();
1565        // d² = |G| / Σ_r ω_r·ω_{r̄}/|C_r|  (all over GF(p)).
1566        let mut denom = 0u64;
1567        for r in 0..k {
1568            let hr = (class_sizes[r] % p as u128) as u64;
1569            let term = (omega[r] as u128 * omega[inverse_class[r]] as u128 % p as u128) as u64;
1570            let contrib = (term as u128 * mod_inv(hr, p) as u128 % p as u128) as u64;
1571            denom = (denom + contrib) % p;
1572        }
1573        if denom == 0 {
1574            return None;
1575        }
1576        let d2 = (order_p as u128 * mod_inv(denom, p) as u128 % p as u128) as u64;
1577        // Recover the integer degree: the unique d ≤ √|G| dividing |G| with d² ≡ d2 (mod p).
1578        let mut deg = None;
1579        let mut d = 1u128;
1580        while d <= max_deg {
1581            if order % d == 0 && ((d * d) % p as u128) as u64 == d2 {
1582                deg = Some(d);
1583                break;
1584            }
1585            d += 1;
1586        }
1587        let deg = deg?;
1588        let vals: Vec<u64> = (0..k)
1589            .map(|r| {
1590                let hr = (class_sizes[r] % p as u128) as u64;
1591                let num = (deg % p as u128) as u64 as u128 * omega[r] as u128 % p as u128;
1592                (num * mod_inv(hr, p) as u128 % p as u128) as u64
1593            })
1594            .collect();
1595        rows.push((deg, vals));
1596    }
1597    rows.sort();
1598    let degrees: Vec<u128> = rows.iter().map(|(d, _)| *d).collect();
1599    let values: Vec<Vec<u64>> = rows.into_iter().map(|(_, v)| v).collect();
1600
1601    // FAIL-CLOSED verification: degrees and row-orthogonality must hold, else we discovered nothing real.
1602    if degrees.iter().map(|d| d * d).sum::<u128>() != order {
1603        return None;
1604    }
1605    if !values.iter().any(|row| row.iter().all(|&x| x == 1)) {
1606        return None; // the trivial character must be present
1607    }
1608    for s in 0..k {
1609        for t in 0..k {
1610            let mut acc = 0u64;
1611            for r in 0..k {
1612                let hr = (class_sizes[r] % p as u128) as u64;
1613                let prod = values[s][r] as u128 * values[t][inverse_class[r]] as u128 % p as u128;
1614                acc = ((acc as u128 + hr as u128 * prod) % p as u128) as u64;
1615            }
1616            let want = if s == t { order_p } else { 0 };
1617            if acc != want {
1618                return None;
1619            }
1620        }
1621    }
1622
1623    Some(CharacterTable {
1624        prime: p,
1625        class_sizes,
1626        inverse_class,
1627        power_map2,
1628        identity_class,
1629        class_reps,
1630        degrees,
1631        values,
1632    })
1633}
1634
1635/// The **Frobenius–Schur indicators** `ν(χ_s) = (1/|G|) Σ_{g∈G} χ_s(g²) ∈ {+1, 0, −1}` read off a
1636/// character table: `+1` if the irreducible is **real** (orthogonal), `0` if **complex** (`χ ≠ χ̄`), `−1`
1637/// if **quaternionic** (symplectic). Computed exactly over the table's `GF(p)`:
1638/// `Σ_r |C_r|·χ_s(C_{r²})`, scaled by `1/|G|`, decoded `{0, 1, p−1} → {0, 1, −1}`. FAIL-CLOSED: returns
1639/// `None` if any value decodes outside `{−1,0,1}` or the Frobenius–Schur sum rule
1640/// `Σ_s ν(χ_s)·d_s = #{g : g²=1}` fails. The indicators distinguish groups with identical character
1641/// tables (e.g. `D₄` all `+1` vs `Q₈` with a `−1`).
1642pub fn frobenius_schur_from_table(t: &CharacterTable) -> Option<Vec<i8>> {
1643    let p = t.prime;
1644    let order: u128 = t.degrees.iter().map(|d| d * d).sum();
1645    let inv_order = mod_inv((order % p as u128) as u64, p);
1646    let k = t.degrees.len();
1647    // #{g : g² = e} = Σ over classes whose square is the identity class.
1648    let involutions_plus_id: u128 = (0..k)
1649        .filter(|&r| t.power_map2[r] == t.identity_class)
1650        .map(|r| t.class_sizes[r])
1651        .sum();
1652    let mut nu = Vec::with_capacity(k);
1653    for s in 0..k {
1654        let mut acc = 0u64;
1655        for r in 0..k {
1656            let hr = (t.class_sizes[r] % p as u128) as u64;
1657            let chi_sq = t.values[s][t.power_map2[r]];
1658            acc = ((acc as u128 + hr as u128 * chi_sq as u128) % p as u128) as u64;
1659        }
1660        let val = ((acc as u128 * inv_order as u128) % p as u128) as u64;
1661        let ind: i8 = if val == 0 {
1662            0
1663        } else if val == 1 {
1664            1
1665        } else if val == p - 1 {
1666            -1
1667        } else {
1668            return None; // not an indicator — fail closed
1669        };
1670        nu.push(ind);
1671    }
1672    // The Frobenius–Schur counting theorem: Σ_s ν_s·d_s = #{g : g²=1}.
1673    let sum: i128 = nu
1674        .iter()
1675        .zip(&t.degrees)
1676        .map(|(&v, &d)| v as i128 * d as i128)
1677        .sum();
1678    if sum != involutions_plus_id as i128 {
1679        return None;
1680    }
1681    Some(nu)
1682}
1683
1684/// The Frobenius–Schur indicators of `⟨gens⟩`, one per irreducible character (aligned with
1685/// [`character_table`]'s rows). See [`frobenius_schur_from_table`]. `None` when the character table is out
1686/// of range.
1687pub fn frobenius_schur_indicators(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<i8>> {
1688    frobenius_schur_from_table(&character_table(degree, gens, cap)?)
1689}
1690
1691/// The **permutation character** `π(g) = #{points fixed by g}` of the natural action of `⟨gens⟩` on its
1692/// `degree` points, valued per conjugacy class (a class invariant, since conjugate permutations have the
1693/// same cycle type). The character of the permutation representation `ℂ^degree`. Aligned with the conjugacy
1694/// classes (so with [`CharacterTable`]'s columns). `None` when `|G| > cap`.
1695pub fn permutation_character(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
1696    let classes = conjugacy_classes(degree, gens, cap)?;
1697    Some(
1698        classes
1699            .iter()
1700            .map(|c| c[0].iter().enumerate().filter(|(i, &x)| *i == x).count() as u128)
1701            .collect(),
1702    )
1703}
1704
1705/// The **isotypic decomposition** of the permutation representation: the multiplicity `m_s = ⟨π, χ_s⟩` of
1706/// each irreducible `χ_s` in the natural action's character `π`, i.e. `π = Σ_s m_s χ_s`. Computed from a
1707/// character table: `m_s = (1/|G|) Σ_r |C_r|·π(C_r)·χ_s(C_{r̄})` over the table's `GF(p)`. The
1708/// representation-theoretic spectrum of the symmetry — it ties the linear theory back to the action:
1709/// `m_trivial = #orbits` (Burnside), `Σ_s m_s² = rank` (#orbitals), `Σ_s m_s·d_s = degree`. FAIL-CLOSED:
1710/// `None` if `p ≤ degree` (then the small non-negative `m_s ≤ degree` would not decode uniquely) or if any
1711/// of those three identities fails. Aligned with the table's rows.
1712pub fn isotypic_from_table(degree: usize, gens: &[Perm], t: &CharacterTable) -> Option<Vec<u128>> {
1713    let p = t.prime;
1714    if p as u128 <= degree as u128 {
1715        return None; // multiplicities (≤ degree) must fit below p to decode uniquely
1716    }
1717    let order: u128 = t.degrees.iter().map(|d| d * d).sum();
1718    let inv_order = mod_inv((order % p as u128) as u64, p);
1719    let k = t.degrees.len();
1720    // The permutation character per class, from the table's class representatives.
1721    let pi: Vec<u64> = t
1722        .class_reps
1723        .iter()
1724        .map(|g| (g.iter().enumerate().filter(|(i, &x)| *i == x).count() as u64) % p)
1725        .collect();
1726    let mut mult = Vec::with_capacity(k);
1727    for s in 0..k {
1728        let mut acc = 0u64;
1729        for r in 0..k {
1730            let hr = (t.class_sizes[r] % p as u128) as u64;
1731            let term = (hr as u128 * pi[r] as u128 % p as u128) as u64;
1732            let contrib = (term as u128 * t.values[s][t.inverse_class[r]] as u128) % p as u128;
1733            acc = ((acc as u128 + contrib) % p as u128) as u64;
1734        }
1735        let m = ((acc as u128 * inv_order as u128) % p as u128) as u128;
1736        mult.push(m);
1737    }
1738    // FAIL-CLOSED cross-checks against independent computations of the action's invariants.
1739    if mult
1740        .iter()
1741        .zip(&t.degrees)
1742        .map(|(m, d)| m * d)
1743        .sum::<u128>()
1744        != degree as u128
1745    {
1746        return None; // Σ m_s·d_s = dim of the permutation rep
1747    }
1748    if mult.iter().map(|m| m * m).sum::<u128>() != rank(degree, gens) as u128 {
1749        return None; // ⟨π,π⟩ = #orbitals (rank)
1750    }
1751    let num_orbits = orbits(degree, gens).len() as u128;
1752    let trivial_row = t
1753        .values
1754        .iter()
1755        .position(|row| row.iter().all(|&x| x == 1))?;
1756    if mult[trivial_row] != num_orbits {
1757        return None; // ⟨π,1⟩ = #orbits (Burnside)
1758    }
1759    Some(mult)
1760}
1761
1762/// The isotypic multiplicities of `⟨gens⟩`'s permutation representation, one per irreducible (aligned with
1763/// [`character_table`]'s rows). See [`isotypic_from_table`]. `None` when the character table is out of range
1764/// or the multiplicities cannot be decoded/verified.
1765pub fn isotypic_multiplicities(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
1766    isotypic_from_table(degree, gens, &character_table(degree, gens, cap)?)
1767}
1768
1769/// The **tensor (Clebsch–Gordan) decomposition** of the irreducibles: `N[i][j][k] = ⟨χ_i·χ_j, χ_k⟩`, the
1770/// multiplicity of `χ_k` in the tensor product `χ_i ⊗ χ_j`. These are the *fusion coefficients* — the
1771/// structure constants of the representation ring `R(G)` (the multiplication dual to the character table's
1772/// addition). Computed from a character table:
1773/// `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
1774/// non-negative integer `≤ d_i·d_j ≤ |G| < p`, so it decodes uniquely. FAIL-CLOSED: returns `None` unless
1775/// every fusion product has the right dimension (`Σ_k N[i][j][k]·d_k = d_i·d_j`), the trivial character is a
1776/// unit (`χ_triv ⊗ χ_j = χ_j`), and the coefficients are symmetric (`N[i][j][k] = N[j][i][k]`). Indices
1777/// align with [`character_table`]'s rows.
1778pub fn tensor_from_table(t: &CharacterTable) -> Option<Vec<Vec<Vec<u128>>>> {
1779    let p = t.prime;
1780    let order: u128 = t.degrees.iter().map(|d| d * d).sum();
1781    let inv_order = mod_inv((order % p as u128) as u64, p);
1782    let k = t.degrees.len();
1783    let mut n = vec![vec![vec![0u128; k]; k]; k];
1784    for i in 0..k {
1785        for j in 0..k {
1786            for kk in 0..k {
1787                let mut acc = 0u64;
1788                for r in 0..k {
1789                    let hr = (t.class_sizes[r] % p as u128) as u64;
1790                    let mut prod = hr as u128;
1791                    prod = prod * t.values[i][r] as u128 % p as u128;
1792                    prod = prod * t.values[j][r] as u128 % p as u128;
1793                    prod = prod * t.values[kk][t.inverse_class[r]] as u128 % p as u128;
1794                    acc = ((acc as u128 + prod) % p as u128) as u64;
1795                }
1796                n[i][j][kk] = (acc as u128 * inv_order as u128 % p as u128) as u128;
1797            }
1798        }
1799    }
1800    // FAIL-CLOSED structural checks of the representation ring.
1801    let trivial = t
1802        .values
1803        .iter()
1804        .position(|row| row.iter().all(|&x| x == 1))?;
1805    for i in 0..k {
1806        for j in 0..k {
1807            // Dimension: dim(χ_i ⊗ χ_j) = d_i·d_j.
1808            if (0..k).map(|kk| n[i][j][kk] * t.degrees[kk]).sum::<u128>()
1809                != t.degrees[i] * t.degrees[j]
1810            {
1811                return None;
1812            }
1813            // Symmetry of the tensor product.
1814            for kk in 0..k {
1815                if n[i][j][kk] != n[j][i][kk] {
1816                    return None;
1817                }
1818            }
1819        }
1820        // The trivial character is the multiplicative unit: χ_triv ⊗ χ_i = χ_i.
1821        for kk in 0..k {
1822            let expect = u128::from(kk == i);
1823            if n[trivial][i][kk] != expect {
1824                return None;
1825            }
1826        }
1827    }
1828    Some(n)
1829}
1830
1831/// The tensor (fusion) decomposition of `⟨gens⟩`'s irreducibles. See [`tensor_from_table`]. `None` when the
1832/// character table is out of range or the fusion coefficients fail their structural checks.
1833pub fn tensor_decomposition(
1834    degree: usize,
1835    gens: &[Perm],
1836    cap: usize,
1837) -> Option<Vec<Vec<Vec<u128>>>> {
1838    tensor_from_table(&character_table(degree, gens, cap)?)
1839}
1840
1841/// The **irreducible-representation degrees** `χ_s(1)` of `⟨gens⟩`, sorted ascending (`Σ dᵢ² = |G|`) —
1842/// the cheap summary of the [`character_table`]. `None` when the table cannot be computed (`|G| > cap`
1843/// or too large for the finite-field diagonalisation).
1844pub fn irreducible_degrees(degree: usize, gens: &[Perm], cap: usize) -> Option<Vec<u128>> {
1845    character_table(degree, gens, cap).map(|t| t.degrees)
1846}
1847
1848/// Is the group **simple** — non-trivial with no normal subgroup but `{id}` and itself? Tested via
1849/// conjugacy: every non-trivial normal subgroup contains the whole conjugacy class of any of its elements,
1850/// hence the normal closure of that element. So `G` is simple iff the normal closure of *every*
1851/// non-identity element is all of `G`. Simple non-abelian groups (e.g. `A₅`) are exactly the unsolvable
1852/// building blocks. `None` when `|G| > cap`.
1853pub fn is_simple(degree: usize, gens: &[Perm], cap: usize) -> Option<bool> {
1854    let order = schreier_sims(degree, gens).order();
1855    if order <= 1 {
1856        return Some(false); // the trivial group is not simple
1857    }
1858    let classes = conjugacy_classes(degree, gens, cap)?;
1859    for class in &classes {
1860        let rep = &class[0];
1861        if is_identity(rep) {
1862            continue;
1863        }
1864        let ncl = normal_closure(degree, std::slice::from_ref(rep), gens);
1865        if schreier_sims(degree, &ncl).order() < order {
1866            return Some(false); // a proper non-trivial normal subgroup exists
1867        }
1868    }
1869    Some(true)
1870}
1871
1872/// A base and strong generating set, with the per-level basic transversals.
1873#[derive(Clone, Debug)]
1874pub struct Bsgs {
1875    pub degree: usize,
1876    pub base: Vec<usize>,
1877    transversals: Vec<HashMap<usize, Perm>>,
1878}
1879
1880impl Bsgs {
1881    /// `|G| = Π |Δᵢ|` — exact for `|G|` up to the `u128` range (any degree below ~33 factorial).
1882    pub fn order(&self) -> u128 {
1883        self.transversals.iter().map(|t| t.len() as u128).product()
1884    }
1885
1886    /// Enumerate **all** `|G|` group elements, as the unique products `u_k·…·u₂·u₁` of one transversal
1887    /// element per level. (`sift` divides `g` by the level-i transversal element on the right, so
1888    /// `g = h·u₁` with `h ∈ G⁽²⁾`, recursing to `g = u_k·…·u₁` — deepest level innermost; we accumulate
1889    /// from the deepest level outward.) Returns `None` if `|G| > cap` — the BSGS knows the order without
1890    /// enumerating, so the caller gates on it. The basis of *complete* symmetry breaking.
1891    pub fn elements(&self, cap: usize) -> Option<Vec<Perm>> {
1892        if self.order() > cap as u128 {
1893            return None;
1894        }
1895        let mut elems = vec![identity(self.degree)];
1896        for trans in self.transversals.iter().rev() {
1897            let reps: Vec<&Perm> = trans.values().collect();
1898            let mut next = Vec::with_capacity(elems.len() * reps.len());
1899            for e in &elems {
1900                for r in &reps {
1901                    next.push(compose(e, r));
1902                }
1903            }
1904            elems = next;
1905        }
1906        Some(elems)
1907    }
1908
1909    /// All coset representatives across the stabilizer chain — the transversal elements at every level.
1910    /// There are `Σ |Δᵢ|` of them (polynomial: at most `degree²`), spread across the group, so a lex-leader
1911    /// symmetry break over them (together with the generators) is stronger than the bare generators yet
1912    /// still polynomial, unlike the complete enumeration (`|G|`). This is the stabilizer chain breaking the
1913    /// symmetry level by level — "symmetry break again" at each base point.
1914    pub fn transversal_elements(&self) -> Vec<Perm> {
1915        self.transversals
1916            .iter()
1917            .flat_map(|t| t.values().cloned())
1918            .collect()
1919    }
1920
1921    /// Is `g` a member of the group (i.e., does it sift to the identity through the chain)? This is the
1922    /// coset decision: `g ∈ rep·G` iff `rep⁻¹·g` is a member.
1923    pub fn contains(&self, g: &[usize]) -> bool {
1924        if g.len() != self.degree {
1925            return false;
1926        }
1927        let mut g = g.to_vec();
1928        for (i, &beta) in self.base.iter().enumerate() {
1929            let img = g[beta];
1930            match self.transversals[i].get(&img) {
1931                None => return false,
1932                Some(t) => g = compose(&g, &invert(t)),
1933            }
1934        }
1935        is_identity(&g)
1936    }
1937}
1938
1939/// **Schreier–Sims.** Build a BSGS for the permutation group on `degree` points generated by `generators`
1940/// (each a permutation of `{0,…,degree−1}`). Deterministic incremental construction: seed the chain with
1941/// the generators, then repeatedly sift every Schreier generator (`u·s` divided by its transversal
1942/// element, Schreier's lemma) into the chain, adding any non-trivial residue as a new strong generator
1943/// (extending the base as needed), until every Schreier generator sifts to the identity — the completeness
1944/// condition.
1945pub fn schreier_sims(degree: usize, generators: &[Perm]) -> Bsgs {
1946    let mut base: Vec<usize> = Vec::new();
1947    let mut strong: Vec<Perm> = Vec::new();
1948    for g in generators {
1949        if !is_identity(g) {
1950            extend_with(&mut base, &mut strong, g.clone());
1951        }
1952    }
1953    loop {
1954        let mut changed = false;
1955        'scan: for i in 0..base.len() {
1956            let trans = orbit_transversal(&base, &strong, i);
1957            let stab: Vec<Perm> = strong
1958                .iter()
1959                .filter(|g| (0..i).all(|j| g[base[j]] == base[j]))
1960                .cloned()
1961                .collect();
1962            for u in trans.values() {
1963                for s in &stab {
1964                    let us = compose(u, s);
1965                    let img = us[base[i]];
1966                    let schreier = compose(&us, &invert(&trans[&img])); // fixes base[i] ∈ G⁽ⁱ⁺¹⁾
1967                    if !is_identity(&schreier) && extend_with(&mut base, &mut strong, schreier) {
1968                        changed = true;
1969                        break 'scan; // orbits changed — recompute from the top
1970                    }
1971                }
1972            }
1973        }
1974        if !changed {
1975            break;
1976        }
1977    }
1978    let transversals = (0..base.len())
1979        .map(|i| orbit_transversal(&base, &strong, i))
1980        .collect();
1981    Bsgs {
1982        degree,
1983        base,
1984        transversals,
1985    }
1986}
1987
1988#[cfg(test)]
1989#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/permgroup/tests.rs"]
1990mod tests;