Skip to main content

logicaffeine_proof/
groupoid.rs

1//! The **action groupoid** — what symmetry breaking *means*, categorically, as checked code.
2//!
3//! Symmetry breaking is not a heuristic; it is the computation of `π₀` of a groupoid. A symmetry
4//! group `G` acts on the space `X` of assignments (the "possible worlds"). The **action groupoid**
5//! `X ⫽ G` has the assignments as objects and the group elements as the (iso)morphisms `α → g·α`:
6//! two assignments are *isomorphic* iff related by a symmetry — bisimilar worlds. Symmetry breaking
7//! keeps ONE assignment per isomorphism class — it computes the **skeleton**, equivalently
8//! `π₀(X ⫽ G)` (the set of orbits = connected components). The exponential→polynomial collapse is
9//! exactly `|X| → |π₀(X ⫽ G)|`.
10//!
11//! This `X ⫽ G` is a **1-groupoid** (h-level 3 in the homotopy-level table). The ∞-tower above it —
12//! symmetries *between* symmetry-breakings, then between those, … — is the honest open direction. We
13//! build the first rung (this 1-groupoid) solidly and *check* that symmetry breaking is its `π₀`; the
14//! next rung (a groupoid of measures-and-their-morphisms) is sketched in the campaign notes. We do
15//! not claim to have built an ∞-groupoid — only the 1-truncation that the present theory occupies,
16//! and the precise statement of what climbing would mean.
17
18use crate::cdcl::Lit;
19use crate::proof::Perm;
20
21/// The action groupoid `X ⫽ G`: assignments over `nv` variables acted on by the group generated by
22/// `gens` (literal permutations). Small `nv` only — it enumerates all `2^nv` objects, which is the
23/// point: it makes the orbit collapse *visible and checkable*.
24pub struct ActionGroupoid {
25    nv: usize,
26    gens: Vec<Perm>,
27}
28
29impl ActionGroupoid {
30    pub fn new(nv: usize, gens: Vec<Perm>) -> Self {
31        ActionGroupoid { nv, gens }
32    }
33
34    /// Act by a (sign-respecting) symmetry on an assignment encoded as a bitmask: variable `v`'s value
35    /// is carried to variable `σ(+v).var`, flipped iff `σ(+v)` is negative.
36    fn act(&self, g: &Perm, a: u32) -> u32 {
37        let mut b = 0u32;
38        for v in 0..self.nv {
39            let val = (a >> v) & 1;
40            let img = g.apply(Lit::pos(v as u32));
41            let w = img.var() as usize;
42            let new_val = if img.is_positive() { val } else { 1 - val };
43            if new_val == 1 {
44                b |= 1 << w;
45            }
46        }
47        b
48    }
49
50    /// The orbit id of every assignment — `π₀(X ⫽ G)`, computed as the connected components of the
51    /// action via union-find. `orbits()[a]` is the canonical representative (smallest member) of `a`'s
52    /// orbit.
53    pub fn orbits(&self) -> Vec<u32> {
54        let total = 1u32 << self.nv;
55        let mut parent: Vec<u32> = (0..total).collect();
56        fn find(parent: &mut [u32], x: u32) -> u32 {
57            let mut r = x;
58            while parent[r as usize] != r {
59                r = parent[r as usize];
60            }
61            // path compression
62            let mut c = x;
63            while parent[c as usize] != r {
64                let next = parent[c as usize];
65                parent[c as usize] = r;
66                c = next;
67            }
68            r
69        }
70        for a in 0..total {
71            for g in &self.gens {
72                let b = self.act(g, a);
73                let (ra, rb) = (find(&mut parent, a), find(&mut parent, b));
74                if ra != rb {
75                    // union by smaller-root so representatives are canonical (smallest)
76                    if ra < rb {
77                        parent[rb as usize] = ra;
78                    } else {
79                        parent[ra as usize] = rb;
80                    }
81                }
82            }
83        }
84        (0..total).map(|a| find(&mut parent, a)).collect()
85    }
86
87    /// `|π₀(X ⫽ G)|` — the number of essentially-different worlds, i.e. exactly what symmetry breaking
88    /// reduces the search space to.
89    pub fn num_orbits(&self) -> usize {
90        let reps = self.orbits();
91        let mut distinct: Vec<u32> = reps.clone();
92        distinct.sort_unstable();
93        distinct.dedup();
94        distinct.len()
95    }
96
97    /// The full group `G` generated by `gens` (closed under composition, including the identity).
98    /// Bounded to `cap` elements so a pathological generating set can't blow up; the caller's small
99    /// examples are well within it.
100    pub fn group_elements(&self, cap: usize) -> Vec<Perm> {
101        let mut seen: std::collections::HashSet<Perm> = std::collections::HashSet::new();
102        let id = Perm::identity(self.nv);
103        seen.insert(id.clone());
104        let mut elems = vec![id];
105        let mut frontier = elems.clone();
106        while !frontier.is_empty() && elems.len() < cap {
107            let mut next = Vec::new();
108            for e in &frontier {
109                for g in &self.gens {
110                    let prod = g.compose(e); // g ∘ e
111                    if seen.insert(prod.clone()) {
112                        next.push(prod.clone());
113                        elems.push(prod);
114                        if elems.len() >= cap {
115                            return elems;
116                        }
117                    }
118                }
119            }
120            frontier = next;
121        }
122        elems
123    }
124
125    /// `π₁(X ⫽ G)` at the assignment `a` — its **stabilizer**: the group elements that fix `a`
126    /// (`g · a = a`). These are the loops at `a` in the action groupoid; the fundamental-group data of
127    /// the homotopy type at that component.
128    pub fn stabilizer_size(&self, a: u32, group: &[Perm]) -> usize {
129        group.iter().filter(|g| self.act(g, a) == a).count()
130    }
131
132    /// The size of `a`'s orbit (the connected component of `a`).
133    pub fn orbit_size(&self, a: u32) -> usize {
134        let reps = self.orbits();
135        reps.iter().filter(|&&r| r == reps[a as usize]).count()
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    /// The adjacent variable-transpositions that generate the full symmetric group `S_nv` on the bits.
144    fn full_symmetric_gens(nv: usize) -> Vec<Perm> {
145        (0..nv.saturating_sub(1))
146            .map(|i| {
147                let images: Vec<Lit> = (0..nv)
148                    .map(|v| {
149                        let w = if v == i {
150                            i + 1
151                        } else if v == i + 1 {
152                            i
153                        } else {
154                            v
155                        };
156                        Lit::pos(w as u32)
157                    })
158                    .collect();
159                Perm::from_images(images)
160            })
161            .collect()
162    }
163
164    #[test]
165    fn symmetry_breaking_is_pi_zero_of_the_action_groupoid() {
166        // WHAT SYMMETRY BREAKING MEANS, made checkable. Under the full symmetric group acting on the
167        // bits, two assignments are isomorphic iff they have the same Hamming weight — so the `2^n`
168        // worlds collapse to exactly `n+1` orbits (weights `0..=n`). That orbit count IS the search
169        // space symmetry breaking leaves; the exponential→linear collapse is `2^n → n+1`, computed
170        // here as `π₀` of the action groupoid.
171        for nv in 3..=8 {
172            let g = ActionGroupoid::new(nv, full_symmetric_gens(nv));
173            assert_eq!(g.num_orbits(), nv + 1, "S_n on bits ⇒ orbits = Hamming weights = n+1");
174            assert!(
175                g.num_orbits() < (1usize << nv),
176                "exponential collapse: {} orbits vs {} worlds",
177                g.num_orbits(),
178                1usize << nv
179            );
180        }
181    }
182
183    #[test]
184    fn pi_one_of_the_action_groupoid_is_the_stabilizer_and_orbit_stabilizer_holds() {
185        // ONE MORE FINITE STEP up the tower: pin the homotopy type of X ⫽ G. π₀ = orbits (already);
186        // π₁ at a component = the STABILIZER. The two are bound by the fiber sequence Stab → G → Orbit
187        // — the orbit-stabilizer theorem: |G| = |orbit(a)| · |stab(a)| for every assignment a. We check
188        // it exhaustively under the full symmetric group on the bits (a small, fully-enumerable group).
189        for nv in 3..=5 {
190            let g = ActionGroupoid::new(nv, full_symmetric_gens(nv));
191            let group = g.group_elements(100_000);
192            // |S_n| = n!
193            let factorial: usize = (1..=nv).product();
194            assert_eq!(group.len(), factorial, "the generated group is S_{nv} of order {nv}!");
195            for a in 0..(1u32 << nv) {
196                let orbit = g.orbit_size(a);
197                let stab = g.stabilizer_size(a, &group);
198                assert_eq!(orbit * stab, group.len(), "orbit-stabilizer: |orbit|·|stab| = |G| at a={a}");
199            }
200        }
201    }
202
203    #[test]
204    fn the_trivial_group_quotients_nothing_and_a_swap_halves() {
205        // Sanity poles of the quotient. No symmetry ⇒ every world is its own orbit (`2^n`). A single
206        // variable-swap (a `Z/2` action) pairs `α` with its swap, so the orbit count is the number of
207        // swap-symmetric-or-paired worlds — strictly fewer than `2^n` whenever the swap moves anything.
208        let nv = 5;
209        let none = ActionGroupoid::new(nv, vec![Perm::identity(nv)]);
210        assert_eq!(none.num_orbits(), 1 << nv, "trivial action ⇒ no collapse");
211
212        let swap01: Perm = {
213            let images: Vec<Lit> =
214                (0..nv).map(|v| Lit::pos(if v == 0 { 1 } else if v == 1 { 0 } else { v } as u32)).collect();
215            Perm::from_images(images)
216        };
217        let g = ActionGroupoid::new(nv, vec![swap01]);
218        assert!(g.num_orbits() < (1 << nv), "a swap genuinely identifies worlds");
219        // Z/2 acting: orbits = fixed points (a₀=a₁) + paired points/2. #fixed = 2^(n-1), #moved = 2^(n-1),
220        // so orbits = 2^(n-1) + 2^(n-2) = 3·2^(n-2).
221        assert_eq!(g.num_orbits(), 3 * (1 << (nv - 2)), "Z/2 swap ⇒ 3·2^(n-2) orbits");
222    }
223}