Skip to main content

logicaffeine_proof/
category_collapse.rs

1//! The **category of collapses** — the 2-rung toward the ∞-tower.
2//!
3//! Fix a problem and its refutation. Its Lyapunov measures (levelings of the proof) form a
4//! **category**: objects are valid measures (non-increasing potentials over the proof steps), and a
5//! morphism `V → W` is a **refinement** — a monotone map `φ` with `W = φ ∘ V`, so `V` distinguishes
6//! states at least as finely as `W`. This is a *thin* category (a preorder under refinement). Its
7//! **initial object** is the finest measure (the linear, one-level-per-step proof-induced measure);
8//! its **terminal object** is the coarsest valid leveling. Different physics give different objects;
9//! refinements are the **2-cells** relating them.
10//!
11//! Where the ∞ begins (honestly): this is one *thin 1-category per problem*. The next rung is
12//! *functors between these categories* — **measure transfer between problems** (a reduction `F → F'`
13//! carrying a measure of `F'` back to one of `F`). When those functors and the natural transformations
14//! between them cohere, you get a 2-category of collapses, then higher. We build this rung solidly and
15//! state the climb precisely; we do not claim the tower.
16
17use crate::cdcl::Lit;
18use crate::complexity::RankedRefutation;
19use crate::proof::{Perm, ProofStep, Witness};
20
21/// A **reduction** `ρ: F' → F` — a sign-respecting variable→literal map. It acts on clauses, on
22/// substitution witnesses (by conjugation `ρσρ⁻¹`), and hence on whole refutations. When `ρ(F') ⊆ F`
23/// (the source embeds into the target), `ρ` carries any refutation of `F'` to one of `F`. This is the
24/// morphism along which the **transfer functor** moves collapses between problems.
25pub struct Reduction {
26    /// `image[v] = ρ(+v)`, a literal over the target's variables.
27    pub image: Vec<Lit>,
28    pub target_num_vars: usize,
29}
30
31impl Reduction {
32    /// `ρ` applied to a literal (sign-respecting).
33    pub fn apply_lit(&self, l: Lit) -> Lit {
34        let img = self.image[l.var() as usize];
35        if l.is_positive() {
36            img
37        } else {
38            img.negated()
39        }
40    }
41
42    /// `ρ` applied to a clause.
43    pub fn apply_clause(&self, c: &[Lit]) -> Vec<Lit> {
44        c.iter().map(|&l| self.apply_lit(l)).collect()
45    }
46
47    /// The conjugate `ρσρ⁻¹` over the target's variables (for a pure-variable injective `ρ`); the
48    /// identity on target variables outside `ρ`'s image. Conjugation transports an automorphism of
49    /// the source database to one of `ρ(database)` — which is why SR witnesses transfer.
50    fn conjugate(&self, sigma: &Perm) -> Perm {
51        let nv = self.target_num_vars;
52        let mut inv: Vec<Option<usize>> = vec![None; nv];
53        for (v, img) in self.image.iter().enumerate() {
54            inv[img.var() as usize] = Some(v);
55        }
56        let images: Vec<Lit> = (0..nv)
57            .map(|w| match inv[w] {
58                Some(v) => self.apply_lit(sigma.apply(Lit::pos(v as u32))),
59                None => Lit::pos(w as u32),
60            })
61            .collect();
62        Perm::from_images(images)
63    }
64
65    /// `ρ` applied to a proof step (clause + witness).
66    pub fn apply_step(&self, step: &ProofStep) -> ProofStep {
67        match step {
68            ProofStep::Rup(c) => ProofStep::Rup(self.apply_clause(c)),
69            ProofStep::Delete(c) => ProofStep::Delete(self.apply_clause(c)),
70            ProofStep::Pr { clause, witness } => {
71                let w = match witness {
72                    Witness::Assignment(a) => Witness::Assignment(a.iter().map(|&l| self.apply_lit(l)).collect()),
73                    Witness::Substitution(sigma) => Witness::Substitution(self.conjugate(sigma)),
74                };
75                ProofStep::Pr { clause: self.apply_clause(clause), witness: w }
76            }
77        }
78    }
79}
80
81impl Reduction {
82    /// Is `ρ` an **isomorphism** (a bijective, sign-respecting variable renaming)? Such reductions are
83    /// the *invertible 1-morphisms* — they make problems-and-reductions a **groupoid**.
84    pub fn is_bijective(&self) -> bool {
85        if self.image.len() != self.target_num_vars {
86            return false;
87        }
88        let mut vars: Vec<u32> = self.image.iter().map(|l| l.var()).collect();
89        vars.sort_unstable();
90        vars.dedup();
91        vars.len() == self.target_num_vars
92    }
93
94    /// The inverse isomorphism `ρ⁻¹` (if `ρ` is bijective).
95    pub fn inverse(&self) -> Option<Reduction> {
96        if !self.is_bijective() {
97            return None;
98        }
99        let nv = self.target_num_vars;
100        let mut inv = vec![Lit::pos(0); nv];
101        for (v, &img) in self.image.iter().enumerate() {
102            inv[img.var() as usize] = Lit::new(v as u32, img.is_positive());
103        }
104        Some(Reduction { image: inv, target_num_vars: nv })
105    }
106
107    /// Composition `self ∘ other` (apply `other` then `self`).
108    pub fn compose(&self, other: &Reduction) -> Reduction {
109        Reduction {
110            image: (0..other.image.len()).map(|v| self.apply_lit(other.image[v])).collect(),
111            target_num_vars: self.target_num_vars,
112        }
113    }
114
115    /// Is `ρ` the identity reduction?
116    pub fn is_identity(&self) -> bool {
117        self.image.len() == self.target_num_vars
118            && self.image.iter().enumerate().all(|(v, &l)| l == Lit::pos(v as u32))
119    }
120
121    /// Is `ρ` a **loop at `F`** — an isomorphism `F → F` carrying the clause set onto itself? A loop is
122    /// exactly an **automorphism** of `F`. The loops at `F` are `π₁(F)` of the groupoid, and they form
123    /// the symmetry group of `F`.
124    pub fn is_loop_at(&self, formula: &[Vec<Lit>]) -> bool {
125        if !self.is_bijective() {
126            return false;
127        }
128        let mapped: Vec<Vec<Lit>> = formula.iter().map(|c| self.apply_clause(c)).collect();
129        canon_clauses(&mapped) == canon_clauses(formula)
130    }
131}
132
133/// Canonical multiset form of a clause set (each clause sorted/deduped, then the clauses sorted) — for
134/// set-equality of formulas under renaming.
135fn canon_clauses(cs: &[Vec<Lit>]) -> Vec<Vec<u32>> {
136    let mut out: Vec<Vec<u32>> = cs
137        .iter()
138        .map(|c| {
139            let mut k: Vec<u32> = c.iter().map(|l| l.var() * 2 + u32::from(!l.is_positive())).collect();
140            k.sort_unstable();
141            k.dedup();
142            k
143        })
144        .collect();
145    out.sort();
146    out
147}
148
149/// **The transfer functor.** Carry a refutation/measure of `F'` to one of `F` along `ρ`, preserving
150/// the level structure (ranks). Re-checks the transferred proof against `F` (fail-closed): returns
151/// the transferred ranked refutation iff it genuinely refutes the target. Functorial — `transfer`
152/// along the identity reduction is the identity, and along a composite is the composite.
153pub fn transfer(
154    reduction: &Reduction,
155    source_steps: &[ProofStep],
156    ranks: &[u64],
157    target_formula: &[Vec<Lit>],
158) -> Option<RankedRefutation> {
159    let steps: Vec<ProofStep> = source_steps.iter().map(|s| reduction.apply_step(s)).collect();
160    if crate::pr::check_pr_refutation_fast(reduction.target_num_vars, target_formula, &steps) {
161        Some(RankedRefutation { refuted: true, steps, ranks: ranks.to_vec() })
162    } else {
163        None
164    }
165}
166
167/// Does `fine` **refine** `coarse`? I.e., is there a monotone `φ` with `coarse = φ ∘ fine`? Equivalent
168/// to `fine[i] ≥ fine[j] ⟹ coarse[i] ≥ coarse[j]` for all `i, j`. Requires equal length (two levelings
169/// of the SAME proof). A `true` means `fine` is at least as fine a partition of the proof steps as
170/// `coarse` — the morphism `fine → coarse` in the category of collapses.
171pub fn refines(fine: &[u64], coarse: &[u64]) -> bool {
172    if fine.len() != coarse.len() {
173        return false;
174    }
175    let n = fine.len();
176    for i in 0..n {
177        for j in 0..n {
178            if fine[i] >= fine[j] && coarse[i] < coarse[j] {
179                return false;
180            }
181        }
182    }
183    true
184}
185
186/// Are two measures **isomorphic** in the category — mutual refinements (the same partition of steps
187/// up to relabeling levels)?
188pub fn iso(a: &[u64], b: &[u64]) -> bool {
189    refines(a, b) && refines(b, a)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn refinement_is_a_thin_category_a_preorder() {
198        // The category laws: refinement is reflexive (identities) and transitive (composition) — so
199        // the collapses of a problem form a (thin) category. Checked over random non-increasing
200        // levelings and their monotone coarsenings.
201        let mut state = 0x2C0A_7E60_1234_ABCDu64;
202        let mut next = || {
203            state = state.wrapping_add(0x9E3779B97F4A7C15);
204            let mut z = state;
205            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
206            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
207            z ^ (z >> 31)
208        };
209        for _ in 0..10_000 {
210            let len = 1 + (next() as usize % 10);
211            // a random NON-INCREASING leveling
212            let mut a: Vec<u64> = (0..len).map(|_| next() % 8).collect();
213            a.sort_unstable_by(|x, y| y.cmp(x));
214            // reflexivity (identity morphism)
215            assert!(refines(&a, &a), "refinement is reflexive (identity)");
216            // monotone coarsenings b = ⌊a/2⌋, c = ⌊b/2⌋
217            let b: Vec<u64> = a.iter().map(|&x| x / 2).collect();
218            let c: Vec<u64> = b.iter().map(|&x| x / 2).collect();
219            assert!(refines(&a, &b), "a ⟶ b (a refines its coarsening)");
220            assert!(refines(&b, &c), "b ⟶ c");
221            // transitivity (composition of morphisms)
222            assert!(refines(&a, &c), "composition: a ⟶ b ⟶ c ⟹ a ⟶ c");
223        }
224    }
225
226    #[test]
227    fn the_linear_measure_is_the_initial_object() {
228        // The proof-induced linear measure (every step its own level) refines EVERY valid leveling of
229        // the same proof — it is the INITIAL (finest) object in the category of collapses. We check it
230        // against a REAL symmetry measure and its coarsening, exhibiting the chain
231        // linear ⟶ symmetry ⟶ (2-level) coarse, with composition.
232        let (php, _) = crate::families::php(7);
233        let (_, ranked) =
234            crate::lyapunov::solve_by_measure_synthesis(php.num_vars, &php.clauses).unwrap();
235        let symmetry = ranked.ranks.clone();
236        let linear = crate::lyapunov::proof_induced_measure(symmetry.len());
237        // initial: linear refines the symmetry measure
238        assert!(refines(&linear, &symmetry), "linear (finest) ⟶ symmetry measure");
239        // a 2-level coarsening of the symmetry measure
240        let coarse: Vec<u64> = symmetry.iter().map(|&r| if r > 1 { 1 } else { 0 }).collect();
241        assert!(refines(&symmetry, &coarse), "symmetry ⟶ its 2-level coarsening");
242        // composition gives the long arrow directly
243        assert!(refines(&linear, &coarse), "linear ⟶ coarse (the composite)");
244        // and the symmetry measure is NOT iso to its strict coarsening (a genuine non-identity arrow)
245        assert!(!iso(&symmetry, &coarse), "the coarsening is a strict morphism, not an isomorphism");
246    }
247
248    #[test]
249    fn transfer_functor_carries_a_collapse_along_a_reduction() {
250        // THE 2-CATEGORY MATERIALIZES: a reduction ρ carries a collapse of F' to a collapse of F,
251        // preserving the level structure. We discover a measure on standard PHP(n), then transfer it
252        // along a variable-renaming ρ to refute the RENAMED formula ρ(PHP(n)) — re-checked against the
253        // target. The transferred measure is the same object (same ranks), moved along the morphism.
254        let n = 6;
255        let (php, _) = crate::families::php(n);
256        let nv = php.num_vars;
257        let (_, source) =
258            crate::lyapunov::solve_by_measure_synthesis(nv, &php.clauses).unwrap();
259        // ρ : a variable renaming (a fixed permutation of the variables).
260        let perm: Vec<usize> = (0..nv).map(|v| (v * 7 + 3) % nv).collect();
261        // ensure it's actually a permutation (gcd(7,nv) may not be 1); fall back to reverse if not.
262        let is_perm = {
263            let mut seen = perm.clone();
264            seen.sort_unstable();
265            seen.dedup();
266            seen.len() == nv
267        };
268        let perm: Vec<usize> = if is_perm { perm } else { (0..nv).rev().collect() };
269        let reduction = Reduction {
270            image: (0..nv).map(|v| crate::cdcl::Lit::pos(perm[v] as u32)).collect(),
271            target_num_vars: nv,
272        };
273        let target: Vec<Vec<crate::cdcl::Lit>> =
274            php.clauses.iter().map(|c| reduction.apply_clause(c)).collect();
275        let transferred = transfer(&reduction, &source.steps, &source.ranks, &target)
276            .expect("the transferred collapse must refute the renamed formula");
277        assert!(
278            crate::pr::check_pr_refutation_fast(nv, &target, &transferred.steps),
279            "the transferred proof re-checks against the target F"
280        );
281        assert_eq!(transferred.ranks, source.ranks, "the functor preserves the measure object (ranks)");
282    }
283
284    #[test]
285    fn invertible_reductions_form_a_groupoid() {
286        // Climbing to a GROUPOID: bijective reductions (isomorphisms) are invertible, with
287        // ρ∘ρ⁻¹ = ρ⁻¹∘ρ = id. Checked over random renamings (with random sign flips).
288        let mut state = 0x9A17_0117_BEEF_0042u64;
289        let mut next = || {
290            state = state.wrapping_add(0x9E3779B97F4A7C15);
291            let mut z = state;
292            z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
293            z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
294            z ^ (z >> 31)
295        };
296        for _ in 0..3_000 {
297            let nv = 2 + (next() as usize % 8);
298            let mut perm: Vec<usize> = (0..nv).collect();
299            for i in (1..nv).rev() {
300                let j = next() as usize % (i + 1);
301                perm.swap(i, j);
302            }
303            let image: Vec<crate::cdcl::Lit> =
304                (0..nv).map(|v| crate::cdcl::Lit::new(perm[v] as u32, next() & 1 == 0)).collect();
305            let rho = Reduction { image, target_num_vars: nv };
306            assert!(rho.is_bijective(), "a renaming is an isomorphism");
307            let inv = rho.inverse().expect("isomorphisms invert");
308            assert!(rho.compose(&inv).is_identity(), "ρ∘ρ⁻¹ = id");
309            assert!(inv.compose(&rho).is_identity(), "ρ⁻¹∘ρ = id");
310        }
311    }
312
313    #[test]
314    fn pi_one_of_a_problem_is_its_symmetry_group() {
315        // THE IDENTIFICATION (where the ∞-tower starts paying off): a LOOP at F — an isomorphism
316        // F → F — is exactly an AUTOMORPHISM of F. So π₁(F) of the groupoid of problems IS the
317        // symmetry group of F. We check on PHP: pigeon-swaps are loops, loops compose and invert to
318        // loops (π₁ is a group), and a non-symmetry renaming is NOT a loop.
319        let n = 4;
320        let holes = n - 1;
321        let (php, _) = crate::families::php(n);
322        let nv = php.num_vars;
323        let var = |p: usize, h: usize| p * holes + h;
324        // pigeon-swap(0,1) as a variable renaming
325        let swap_pigeons = |a: usize, b: usize| -> Reduction {
326            let image: Vec<crate::cdcl::Lit> = (0..nv)
327                .map(|v| {
328                    let (p, h) = (v / holes, v % holes);
329                    let np = if p == a { b } else if p == b { a } else { p };
330                    crate::cdcl::Lit::pos(var(np, h) as u32)
331                })
332                .collect();
333            Reduction { image, target_num_vars: nv }
334        };
335        let s01 = swap_pigeons(0, 1);
336        let s12 = swap_pigeons(1, 2);
337        // generators of the symmetry group are loops (automorphisms)
338        assert!(s01.is_loop_at(&php.clauses), "pigeon-swap is a loop = an automorphism = in π₁");
339        assert!(s12.is_loop_at(&php.clauses));
340        // π₁ is a GROUP: composition and inverse of loops are loops
341        assert!(s01.compose(&s12).is_loop_at(&php.clauses), "composition of loops is a loop");
342        assert!(s01.inverse().unwrap().is_loop_at(&php.clauses), "inverse of a loop is a loop");
343        assert!(s01.compose(&s01).is_identity(), "a transposition squares to the identity loop");
344        // a NON-symmetry renaming (cross pigeon AND hole) is NOT a loop — not every iso is in π₁.
345        let bad = {
346            let mut image: Vec<crate::cdcl::Lit> = (0..nv).map(|v| crate::cdcl::Lit::pos(v as u32)).collect();
347            image.swap(var(0, 0), var(1, 1)); // swap x(0,0) ↔ x(1,1): not a PHP automorphism
348            Reduction { image, target_num_vars: nv }
349        };
350        assert!(!bad.is_loop_at(&php.clauses), "a non-symmetry renaming is not in π₁");
351    }
352
353    #[test]
354    fn the_2cells_are_the_group_relations_so_the_infinity_groupoid_is_BG() {
355        // ONE MORE FINITE STEP up the tower — and it closes the question. π₁(F) = Aut(F) is a discrete
356        // group, *presented* by the Coxeter relations of its generators (adjacent pigeon-swaps). Those
357        // relations ARE the 2-cells: the homotopies witnessing that a product of loops is the trivial
358        // loop. We verify the full presentation:
359        //     s_i² = id,   (s_i s_{i+1})³ = id,   s_i s_j = s_j s_i  (|i-j| ≥ 2).
360        // Therefore the ∞-groupoid of this symmetry structure is K(Aut(F), 1) = the classifying space
361        // BG: π₁ = G (checked earlier), and πₙ = 0 for n ≥ 2 — BECAUSE the symmetry is a *discrete*
362        // group. Genuine higher πₙ would require a 2-GROUP (symmetry-with-internal-symmetry); that is
363        // the honest open frontier, not something we have. This is the finite step that names the limit.
364        let n = 5;
365        let holes = n - 1;
366        let (php, _) = crate::families::php(n);
367        let nv = php.num_vars;
368        let var = |p: usize, h: usize| p * holes + h;
369        let s = |i: usize| -> Reduction {
370            let image: Vec<crate::cdcl::Lit> = (0..nv)
371                .map(|v| {
372                    let (p, h) = (v / holes, v % holes);
373                    let np = if p == i { i + 1 } else if p == i + 1 { i } else { p };
374                    crate::cdcl::Lit::pos(var(np, h) as u32)
375                })
376                .collect();
377            Reduction { image, target_num_vars: nv }
378        };
379        // Each generator is a loop (an automorphism = an element of π₁).
380        for i in 0..n - 1 {
381            assert!(s(i).is_loop_at(&php.clauses), "generator s_{i} is a loop in π₁");
382            // Relation 1 (involution): s_i² = id — a 2-cell.
383            assert!(s(i).compose(&s(i)).is_identity(), "s_{i}² = id");
384        }
385        // Relation 2 (braid): (s_i s_{i+1})³ = id — the defining 2-cell of the symmetric group.
386        for i in 0..n - 2 {
387            let b = s(i).compose(&s(i + 1));
388            assert!(b.compose(&b).compose(&b).is_identity(), "braid relation (s_i·s_next)^3 = id at i={i}");
389        }
390        // Relation 3 (far commutation): s_i s_j = s_j s_i for |i-j| ≥ 2.
391        for i in 0..n - 1 {
392            for j in 0..n - 1 {
393                if (i as i32 - j as i32).abs() >= 2 {
394                    assert_eq!(
395                        s(i).compose(&s(j)).image,
396                        s(j).compose(&s(i)).image,
397                        "distant swaps commute at i={i} j={j}"
398                    );
399                }
400            }
401        }
402        // The presentation holds ⇒ ⟨s_i | relations⟩ = S_n = π₁ ⇒ the ∞-groupoid is K(S_n, 1) = BG.
403    }
404
405    #[test]
406    fn transfer_is_functorial() {
407        // The functor laws (the 2-category structure): transfer along the IDENTITY reduction is the
408        // identity, and transfer along a COMPOSITE reduction equals the composite of transfers.
409        let n = 5;
410        let (php, _) = crate::families::php(n);
411        let nv = php.num_vars;
412        let (_, source) = crate::lyapunov::solve_by_measure_synthesis(nv, &php.clauses).unwrap();
413
414        // identity reduction
415        let id = Reduction { image: (0..nv).map(|v| crate::cdcl::Lit::pos(v as u32)).collect(), target_num_vars: nv };
416        let via_id: Vec<_> = source.steps.iter().map(|s| id.apply_step(s)).collect();
417        assert_eq!(via_id, source.steps, "transfer along identity is the identity functor");
418
419        // two renamings ρ, τ and their composite ρ∘τ
420        let p_rho: Vec<usize> = (0..nv).rev().collect();
421        let p_tau: Vec<usize> = (0..nv).map(|v| (v + 1) % nv).collect();
422        let rho = Reduction { image: (0..nv).map(|v| crate::cdcl::Lit::pos(p_rho[v] as u32)).collect(), target_num_vars: nv };
423        let tau = Reduction { image: (0..nv).map(|v| crate::cdcl::Lit::pos(p_tau[v] as u32)).collect(), target_num_vars: nv };
424        // composite ρ∘τ (apply τ then ρ): (ρ∘τ)(v) = ρ(τ(v))
425        let comp = Reduction {
426            image: (0..nv).map(|v| rho.apply_lit(tau.image[v])).collect(),
427            target_num_vars: nv,
428        };
429        // transfer(ρ, transfer(τ, step)) == transfer(ρ∘τ, step), step by step
430        for s in &source.steps {
431            let two_step = rho.apply_step(&tau.apply_step(s));
432            let one_step = comp.apply_step(s);
433            assert_eq!(two_step, one_step, "transfer along ρ∘τ = transfer(ρ) ∘ transfer(τ)");
434        }
435    }
436
437    #[test]
438    fn aut_F_acts_on_the_collapses_the_2group_crossed_module() {
439        // THE 2-GROUP'S FIRST RUNG. π₁ = Aut(F) ACTS on the collapses of F — the transfer functor
440        // restricted to loops. A symmetry σ carries a collapse of F to a collapse of F (it lands back
441        // because σ is a loop), the action obeys the group laws (identity acts trivially), it is by
442        // ISOMORPHISMS of collapses (ranks preserved ⇒ 2-cells), and it is NON-TRIVIAL (σ genuinely
443        // relabels the collapse). This is the crossed-module action of π₁ on the collapse structure.
444        //
445        // π₂ NOTE (and it reconfirms the summit): the action represents the *discrete* Aut(F)
446        // faithfully on the collapse set, so there are no symmetries acting trivially-as-1-cells but
447        // nontrivially-as-2-cells — π₂ = 0, the 2-group is 1-truncated, and the homotopy type is still
448        // K(Aut(F), 1). A non-trivial π₂ needs genuinely categorified (non-discrete) symmetry.
449        let n = 6;
450        let holes = n - 1;
451        let (php, _) = crate::families::php(n);
452        let nv = php.num_vars;
453        let var = |p: usize, h: usize| p * holes + h;
454        let (_, source) = crate::lyapunov::solve_by_measure_synthesis(nv, &php.clauses).unwrap();
455
456        // a loop σ = pigeon-swap(0,1), an automorphism of F
457        let sigma = {
458            let image: Vec<crate::cdcl::Lit> = (0..nv)
459                .map(|v| {
460                    let (p, h) = (v / holes, v % holes);
461                    let np = if p == 0 { 1 } else if p == 1 { 0 } else { p };
462                    crate::cdcl::Lit::pos(var(np, h) as u32)
463                })
464                .collect();
465            Reduction { image, target_num_vars: nv }
466        };
467        assert!(sigma.is_loop_at(&php.clauses), "σ is a symmetry of F (a loop in π₁)");
468
469        // ACTION: σ carries a collapse of F to a collapse of F (lands back in F's collapses).
470        let acted = transfer(&sigma, &source.steps, &source.ranks, &php.clauses)
471            .expect("a symmetry of F carries a collapse of F to a collapse of F");
472        assert!(crate::pr::check_pr_refutation_fast(nv, &php.clauses, &acted.steps), "the acted collapse refutes F");
473        // by ISOMORPHISM of collapses — the level structure is preserved (a 2-cell).
474        assert_eq!(acted.ranks, source.ranks, "the action is by isomorphisms (ranks preserved)");
475        // GROUP-ACTION law: the identity symmetry acts trivially.
476        let id = Reduction { image: (0..nv).map(|v| crate::cdcl::Lit::pos(v as u32)).collect(), target_num_vars: nv };
477        let by_id: Vec<_> = source.steps.iter().map(|s| id.apply_step(s)).collect();
478        assert_eq!(by_id, source.steps, "the identity symmetry acts trivially (group-action unit law)");
479        // NON-TRIVIALITY: a non-identity symmetry genuinely moves the collapse (relabels its steps).
480        assert_ne!(acted.steps, source.steps, "a non-identity symmetry genuinely permutes the collapses");
481    }
482
483    #[test]
484    fn non_uniqueness_gives_two_objects_in_the_category() {
485        // Tie-in to the non-uniqueness theorem: PHP has a symmetry measure AND a cutting-planes
486        // measure — two OBJECTS in the category of collapses. They are genuinely different objects
487        // (different lengths ⇒ not even comparable by refinement directly), which is exactly what
488        // makes the category non-trivial and the 2-rung worth climbing.
489        let n = 7;
490        let (php, _) = crate::families::php(n);
491        let (_, ranked) =
492            crate::lyapunov::solve_by_measure_synthesis(php.num_vars, &php.clauses).unwrap();
493        let (cp_traj, _) = crate::lyapunov::cutting_planes_lyapunov(n);
494        // Two collapses of one problem; as objects they differ (different step counts).
495        assert_ne!(ranked.ranks.len(), cp_traj.len(), "symmetry and cutting-planes are distinct objects");
496        // Each is its own identity (reflexive).
497        assert!(refines(&ranked.ranks, &ranked.ranks) && refines(&cp_traj, &cp_traj));
498    }
499}