Skip to main content

logicaffeine_proof/
symmetry.rs

1//! Symmetry breaking for the general solver — the second pillar (with [`crate::pigeonhole`]
2//! cardinality reasoning) for winning on pigeonhole.
3//!
4//! CDCL refutes a *symmetric* UNSAT formula by re-deriving the same conflict once per symmetric
5//! copy of the problem — for the pigeonhole principle the pigeons are interchangeable, so the
6//! refutation cost is multiplied by `n!`. Adding sound **symmetry-breaking predicates** (SBPs)
7//! collapses each symmetry orbit to a single lexicographically-least representative, so the solver
8//! searches the quotient instead of the whole symmetric space.
9//!
10//! We detect a cheap, *sound* class: ROW-INTERCHANGE symmetries. The at-least-one rows of a
11//! pigeonhole-shaped formula (positive clauses over disjoint variable blocks) are candidate
12//! interchangeable units; for each adjacent pair we **verify** the column-aligned row swap is a
13//! genuine automorphism of the entire clause set (apply the swap, check the clause multiset is
14//! invariant) before adding the lex-leader "row i ≤ₗₑₓ row i+1" SBP.
15//!
16//! **Soundness.** A verified automorphism σ leaves the formula F invariant, so the lex-leader SBP_σ
17//! preserves satisfiability (it keeps the lex-least model of each orbit). Hence `F ∧ SBP_σ` is UNSAT
18//! **iff** F is — refuting the augmented formula refutes the original. The lex-leader encoding is
19//! pinned exhaustively against a brute-force oracle, and we add an SBP *only* for a swap we have
20//! proven is an automorphism; a wrong symmetry could delete the only model, so verified-or-nothing.
21
22use crate::ProofExpr;
23use std::collections::BTreeSet;
24
25fn atom(s: String) -> ProofExpr {
26    ProofExpr::Atom(s)
27}
28fn not(a: ProofExpr) -> ProofExpr {
29    ProofExpr::Not(Box::new(a))
30}
31fn or(a: ProofExpr, b: ProofExpr) -> ProofExpr {
32    ProofExpr::Or(Box::new(a), Box::new(b))
33}
34fn and(a: ProofExpr, b: ProofExpr) -> ProofExpr {
35    ProofExpr::And(Box::new(a), Box::new(b))
36}
37fn implies(a: ProofExpr, b: ProofExpr) -> ProofExpr {
38    ProofExpr::Implies(Box::new(a), Box::new(b))
39}
40fn iff(a: ProofExpr, b: ProofExpr) -> ProofExpr {
41    ProofExpr::Iff(Box::new(a), Box::new(b))
42}
43
44/// "X ≤ₗₑₓ Y" over equal-length boolean vectors (false < true), MSB-first: at the first index where
45/// they differ, X has `false` and Y has `true`. Encoded with a chain of "equal-so-far" auxiliaries
46/// `{aux}_eq_{k}` (distinct constraints must use distinct `aux`). Returns a tautology for empty/
47/// length-1-or-shorter vectors handled position-by-position.
48///
49/// Correctness is pinned **exhaustively** against a brute-force oracle (every assignment, small m).
50pub fn lex_le(x: &[ProofExpr], y: &[ProofExpr], aux: &str) -> ProofExpr {
51    assert_eq!(x.len(), y.len(), "lex_le needs equal-length vectors");
52    let m = x.len();
53    if m == 0 {
54        let t = atom(format!("{aux}_taut"));
55        return or(t.clone(), not(t));
56    }
57    // eq_k ≙ "positions 0..k are all equal". eq_0 ↔ (x_0 ↔ y_0); eq_k ↔ eq_{k-1} ∧ (x_k ↔ y_k).
58    let eq = |k: usize| atom(format!("{aux}_eq_{k}"));
59    let mut clauses: Vec<ProofExpr> = Vec::new();
60    // Position 0: x_0 ≤ y_0 unconditionally (no prefix to be equal).
61    clauses.push(or(not(x[0].clone()), y[0].clone()));
62    if m >= 2 {
63        clauses.push(iff(eq(0), iff(x[0].clone(), y[0].clone())));
64        for k in 1..m {
65            // If the prefix 0..k-1 is equal, then x_k ≤ y_k.
66            clauses.push(implies(eq(k - 1), or(not(x[k].clone()), y[k].clone())));
67            if k < m - 1 {
68                clauses.push(iff(eq(k), and(eq(k - 1), iff(x[k].clone(), y[k].clone()))));
69            }
70        }
71    }
72    clauses.into_iter().reduce(and).unwrap()
73}
74
75/// A literal: `(atom-name, is_positive)`.
76type Lit = (String, bool);
77/// A clause as a canonical set of literals (sorted, duplicate-free).
78type Clause = BTreeSet<Lit>;
79
80/// Convert `e` into a flat list of literal-clauses, or `None` if any top-level conjunct is not a
81/// clause (a disjunction of literals, including the binary `a→b` and `¬(a∧b)` forms). Conservative:
82/// a formula we cannot read as a clause set gets no symmetry breaking.
83fn to_clauses(e: &ProofExpr) -> Option<Vec<Clause>> {
84    let mut conjuncts: Vec<&ProofExpr> = Vec::new();
85    fn flatten<'a>(e: &'a ProofExpr, out: &mut Vec<&'a ProofExpr>) {
86        match e {
87            ProofExpr::And(l, r) => {
88                flatten(l, out);
89                flatten(r, out);
90            }
91            other => out.push(other),
92        }
93    }
94    flatten(e, &mut conjuncts);
95    let mut clauses = Vec::with_capacity(conjuncts.len());
96    for c in conjuncts {
97        clauses.push(clause_of(c)?);
98    }
99    Some(clauses)
100}
101
102/// Read a single clause (disjunction of literals) out of `e`, normalizing `a→b` to `¬a∨b` and the
103/// binary `¬(a∧b)` to `¬a∨¬b`. `None` if `e` is not such a clause.
104fn clause_of(e: &ProofExpr) -> Option<Clause> {
105    let mut lits = BTreeSet::new();
106    if collect_lits(e, true, &mut lits) {
107        Some(lits)
108    } else {
109        None
110    }
111}
112
113fn collect_lits(e: &ProofExpr, pos: bool, out: &mut Clause) -> bool {
114    match e {
115        ProofExpr::Atom(a) => {
116            out.insert((a.clone(), pos));
117            true
118        }
119        ProofExpr::Not(inner) => match inner.as_ref() {
120            ProofExpr::Atom(a) => {
121                out.insert((a.clone(), !pos));
122                true
123            }
124            // ¬(a ∧ b) = ¬a ∨ ¬b ; ¬(a ∨ b) = ¬a ∧ ¬b (not a clause unless pos flips it back).
125            ProofExpr::And(l, r) if pos => collect_lits(l, false, out) && collect_lits(r, false, out),
126            ProofExpr::Or(l, r) if !pos => collect_lits(l, false, out) && collect_lits(r, false, out),
127            ProofExpr::Not(x) => collect_lits(x, pos, out),
128            _ => false,
129        },
130        ProofExpr::Or(l, r) if pos => collect_lits(l, pos, out) && collect_lits(r, pos, out),
131        ProofExpr::And(l, r) if !pos => collect_lits(l, pos, out) && collect_lits(r, pos, out),
132        ProofExpr::Implies(l, r) if pos => collect_lits(l, false, out) && collect_lits(r, true, out),
133        _ => false,
134    }
135}
136
137/// The at-least-one ROWS of `clauses`: all-positive clauses, returned as ordered variable vectors.
138/// Order within a row follows `BTreeSet` (name order) — stable and identical for every row, which is
139/// the column alignment we verify below.
140fn positive_rows(clauses: &[Clause]) -> Vec<Vec<String>> {
141    clauses
142        .iter()
143        .filter(|c| c.len() >= 2 && c.iter().all(|(_, p)| *p))
144        .map(|c| c.iter().map(|(a, _)| a.clone()).collect())
145        .collect()
146}
147
148/// Apply the variable renaming `map` to a clause, returning its canonical (re-sorted) form.
149fn rename(clause: &Clause, map: &std::collections::HashMap<&str, &str>) -> Clause {
150    clause
151        .iter()
152        .map(|(a, p)| (map.get(a.as_str()).map(|s| s.to_string()).unwrap_or_else(|| a.clone()), *p))
153        .collect()
154}
155
156/// Is the column-aligned swap of `row_a`↔`row_b` (position k ↔ position k) a genuine automorphism
157/// of the whole clause set? Apply the swap to every clause and check the multiset is invariant.
158fn swap_is_automorphism(clause_set: &BTreeSet<Clause>, clauses: &[Clause], row_a: &[String], row_b: &[String]) -> bool {
159    if row_a.len() != row_b.len() {
160        return false;
161    }
162    let mut map = std::collections::HashMap::new();
163    for (a, b) in row_a.iter().zip(row_b.iter()) {
164        map.insert(a.as_str(), b.as_str());
165        map.insert(b.as_str(), a.as_str());
166    }
167    // Image multiset must equal the original set (clauses are de-duplicated into a set; an
168    // automorphism is a bijection on a clause SET, so set-equality of the image is exactly right).
169    let image: BTreeSet<Clause> = clauses.iter().map(|c| rename(c, &map)).collect();
170    &image == clause_set
171}
172
173/// Augment `e` with lex-leader SBPs for every adjacent row-interchange symmetry we can *verify*.
174/// Returns `e` unchanged when the formula is not a readable clause set, has no rows, or no verified
175/// symmetry — symmetry breaking is purely additive and never changes the SAT/UNSAT verdict.
176pub fn break_symmetries(e: &ProofExpr) -> ProofExpr {
177    let Some(clauses) = to_clauses(e) else {
178        return e.clone();
179    };
180    let rows = positive_rows(&clauses);
181    if rows.len() < 2 {
182        return e.clone();
183    }
184    let clause_set: BTreeSet<Clause> = clauses.iter().cloned().collect();
185    let mut sbps: Vec<ProofExpr> = Vec::new();
186    for i in 0..rows.len() - 1 {
187        let (ra, rb) = (&rows[i], &rows[i + 1]);
188        if ra.len() != rb.len() || ra.len() < 2 {
189            continue;
190        }
191        if swap_is_automorphism(&clause_set, &clauses, ra, rb) {
192            let x: Vec<ProofExpr> = ra.iter().map(|a| atom(a.clone())).collect();
193            let y: Vec<ProofExpr> = rb.iter().map(|a| atom(a.clone())).collect();
194            sbps.push(lex_le(&x, &y, &format!("__sym_{i}")));
195        }
196    }
197    if sbps.is_empty() {
198        return e.clone();
199    }
200    let sbp = sbps.into_iter().reduce(and).unwrap();
201    and(e.clone(), sbp)
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::sat::{find_model, prove_unsat, ModelOutcome, UnsatOutcome};
208
209    fn a(s: &str) -> ProofExpr {
210        atom(s.to_string())
211    }
212
213    fn bits(mask: u32, n: usize) -> Vec<bool> {
214        (0..n).map(|i| (mask >> i) & 1 == 1).collect()
215    }
216    fn lex_le_oracle(x: &[bool], y: &[bool]) -> bool {
217        for k in 0..x.len() {
218            if x[k] != y[k] {
219                return !x[k] && y[k];
220            }
221        }
222        true
223    }
224
225    #[test]
226    fn lex_le_matches_brute_force() {
227        // Soundness foundation: the encoded `X ≤ₗₑₓ Y` must agree with the oracle on EVERY pinned
228        // assignment (auxiliaries free) — a wrong SBP would delete models and forge UNSAT.
229        for m in 1..=5 {
230            let xs: Vec<ProofExpr> = (0..m).map(|i| a(&format!("x{i}"))).collect();
231            let ys: Vec<ProofExpr> = (0..m).map(|i| a(&format!("y{i}"))).collect();
232            let f = lex_le(&xs, &ys, "L");
233            for xm in 0..(1u32 << m) {
234                for ym in 0..(1u32 << m) {
235                    let (xa, ya) = (bits(xm, m), bits(ym, m));
236                    let mut pinned = f.clone();
237                    for (v, &b) in xs.iter().zip(&xa).chain(ys.iter().zip(&ya)) {
238                        pinned = and(pinned, if b { v.clone() } else { not(v.clone()) });
239                    }
240                    let sat = matches!(find_model(&pinned), ModelOutcome::Sat(_));
241                    assert_eq!(
242                        sat,
243                        lex_le_oracle(&xa, &ya),
244                        "lex_le m={m} x={xa:?} y={ya:?} encoded={sat} oracle={}",
245                        lex_le_oracle(&xa, &ya)
246                    );
247                }
248            }
249        }
250    }
251
252    /// Pairwise PHP(n) — used to confirm symmetry breaking PRESERVES the UNSAT verdict.
253    fn php(n: usize) -> ProofExpr {
254        let holes = n - 1;
255        let p = |i: usize, h: usize| a(&format!("p_{i}_{h}"));
256        let mut clauses = Vec::new();
257        for i in 0..n {
258            clauses.push((0..holes).map(|h| p(i, h)).reduce(or).unwrap());
259        }
260        for h in 0..holes {
261            for i in 0..n {
262                for j in (i + 1)..n {
263                    clauses.push(not(and(p(i, h), p(j, h))));
264                }
265            }
266        }
267        clauses.into_iter().reduce(and).unwrap()
268    }
269
270    #[test]
271    fn breaking_preserves_unsat_on_php() {
272        // Adding verified SBPs to an UNSAT formula keeps it UNSAT (and stays refutable).
273        for n in 3..=6 {
274            let broken = break_symmetries(&php(n));
275            assert!(
276                matches!(prove_unsat(&broken), UnsatOutcome::Refuted),
277                "PHP({n}) with symmetry breaking must stay Refuted"
278            );
279        }
280    }
281
282    #[test]
283    fn breaking_detects_the_pigeon_symmetry() {
284        // PHP rows (pigeons) are interchangeable → at least one verified row-swap SBP is added, so
285        // the formula grows. (If detection silently found nothing, the formula would be unchanged.)
286        let before = php(4);
287        let after = break_symmetries(&before);
288        assert_ne!(before, after, "PHP(4) pigeon symmetry must be detected and broken");
289    }
290
291    #[test]
292    fn breaking_preserves_sat_models() {
293        // Soundness-critical: on a SATISFIABLE symmetric formula, breaking must keep ≥1 model.
294        // "each of 3 items takes one of 3 distinct slots" (a feasible bipartite matching) is SAT and
295        // row-symmetric; after breaking it must remain SAT.
296        let p = |i: usize, h: usize| a(&format!("q_{i}_{h}"));
297        let mut clauses = Vec::new();
298        for i in 0..3 {
299            clauses.push((0..3).map(|h| p(i, h)).reduce(or).unwrap());
300        }
301        for h in 0..3 {
302            for i in 0..3 {
303                for j in (i + 1)..3 {
304                    clauses.push(not(and(p(i, h), p(j, h))));
305                }
306            }
307        }
308        let f = clauses.into_iter().reduce(and).unwrap();
309        assert!(matches!(find_model(&f), ModelOutcome::Sat(_)), "feasible must be SAT to begin");
310        let broken = break_symmetries(&f);
311        assert!(
312            matches!(find_model(&broken), ModelOutcome::Sat(_)),
313            "symmetry breaking must NOT delete every model of a SAT formula"
314        );
315    }
316
317    fn raw_cdcl_is_unsat(e: &ProofExpr) -> bool {
318        // Drive the bare CDCL core — NO matching fast-path, NO symmetry breaking inside — so this
319        // measures the effect of the SBPs we pass in, isolated from `prove_unsat`'s shortcuts.
320        use crate::cdcl::SolveResult;
321        use crate::cnf::Cnf;
322        let mut cnf = Cnf::new();
323        cnf.assert(e).expect("clausifiable");
324        let (mut solver, _) = cnf.into_solver_with_atoms();
325        matches!(solver.solve(), SolveResult::Unsat)
326    }
327
328    #[test]
329    fn symmetry_breaking_tames_raw_cdcl_on_pigeonhole() {
330        // The PROOF symmetry breaking earns its place: pairwise PHP is syntactically symmetric, so
331        // raw CDCL re-derives the same conflict once per pigeon permutation and bogs down. The
332        // verified lex-leader SBPs collapse those orbits — the broken formula is decided much faster.
333        // (`prove_unsat` would short-circuit PHP via the matching reasoner; here we bypass it to
334        // isolate the symmetry-breaking effect on the general CDCL engine.)
335        use std::time::Instant;
336        let n = 8;
337        let plain = php(n);
338        let broken = break_symmetries(&plain);
339        assert_ne!(plain, broken, "PHP({n}) must have a verified pigeon symmetry to break");
340
341        let t = Instant::now();
342        assert!(raw_cdcl_is_unsat(&broken), "broken PHP({n}) must be UNSAT");
343        let with = t.elapsed();
344        let t = Instant::now();
345        assert!(raw_cdcl_is_unsat(&plain), "plain PHP({n}) must be UNSAT");
346        let without = t.elapsed();
347
348        eprintln!(
349            "raw CDCL PHP({n}): plain={without:?} broken={with:?} speedup={:.1}x",
350            without.as_secs_f64() / with.as_secs_f64().max(f64::MIN_POSITIVE)
351        );
352        assert!(
353            with < without,
354            "symmetry breaking must speed up raw CDCL on pigeonhole: broken={with:?} plain={without:?}"
355        );
356    }
357
358    /// THE POWER, pulled out and measured to its bound. `PHP(n)` is the canonical exponential-hard SAT
359    /// instance: every resolution/CDCL refutation has size `2^Ω(n)` (Haken, 1985) — the wall that stops raw
360    /// CDCL *and* Z3 cold. We measure the **conflict count** (machine-independent search work) for plain vs
361    /// symmetry-broken CDCL as `n` grows: plain explodes, the broken count stays small, and crucially the
362    /// **collapse ratio widens with `n`** — the hallmark of an exponential-vs-polynomial separation, made
363    /// empirical. Then the matching reasoner decides the SAME family with **zero search** at a scale where
364    /// CDCL is exponentially dead — the polynomial bound, realized and certified.
365    #[test]
366    fn pigeonhole_exponential_collapses_to_polynomial_certified() {
367        use crate::cdcl::SolveResult;
368        use crate::cnf::Cnf;
369        let conflicts_of = |e: &ProofExpr| -> u64 {
370            let mut cnf = Cnf::new();
371            cnf.assert(e).expect("clausifiable");
372            let (mut solver, _) = cnf.into_solver_with_atoms();
373            assert!(matches!(solver.solve(), SolveResult::Unsat), "PHP must be UNSAT");
374            solver.conflicts()
375        };
376
377        let mut plains = Vec::new();
378        let mut ratios = Vec::new();
379        for n in 4..=8 {
380            let plain = conflicts_of(&php(n));
381            let broken = conflicts_of(&break_symmetries(&php(n)));
382            eprintln!("PHP({n}): plain conflicts={plain}  broken={broken}  collapse={:.1}x", plain as f64 / broken.max(1) as f64);
383            plains.push(plain);
384            ratios.push(plain as f64 / broken.max(1) as f64);
385        }
386
387        // Plain CDCL conflicts explode super-linearly with n — the Haken wall, empirically.
388        assert!(*plains.last().unwrap() >= plains[0].saturating_mul(plains.len() as u64), "plain CDCL conflicts must explode with n: {plains:?}");
389        // The symmetry-breaking collapse WIDENS with n: the win grows (polynomial pulling away from exponential).
390        assert!(*ratios.last().unwrap() > ratios[0] * 1.5, "the symmetry-breaking collapse must widen with n: {ratios:?}");
391
392        // The matching reasoner decides the WHOLE family in polynomial time with a re-verified Hall witness —
393        // certified UNSAT, zero search, at sizes where every CDCL/resolution refutation is astronomically large.
394        for n in [10usize, 16, 24, 32] {
395            assert!(crate::pigeonhole::decide_pigeonhole_unsat(&php(n)), "matching: PHP({n}) certified UNSAT with no search");
396        }
397        eprintln!("matching reasoner: PHP(10..32) all certified UNSAT, polynomial, zero search — CDCL is exponentially dead here");
398    }
399
400    #[test]
401    fn non_symmetric_formula_is_left_alone() {
402        // A formula whose "rows" are NOT interchangeable (asymmetric extra clause) gets no SBP for
403        // that pair — the automorphism check must reject it. Here row 0 and row 1 differ because an
404        // extra unit clause pins one of row 0's variables, so the swap is not an automorphism.
405        let f = and(
406            and(or(a("p_0_0"), a("p_0_1")), or(a("p_1_0"), a("p_1_1"))),
407            a("p_0_0"), // breaks the row-0/row-1 symmetry
408        );
409        // With no verified symmetry the formula is returned unchanged.
410        assert_eq!(break_symmetries(&f), f, "asymmetric formula must be left unchanged");
411    }
412
413    /// The collapse curve, charted out to `PHP(10)`. Heavy because plain CDCL grows `~×5` per pigeon, so
414    /// `PHP(10)` costs ~`10⁵` conflicts — the very point: it is the exponential wall, rendered. We measure
415    /// the conflict count plain vs symmetry-broken for `n = 4..10`, draw a `log₂` bar chart of both curves
416    /// (plain a rising staircase, broken nearly flat), and bank it. `#[ignore]` by default; run on demand.
417    #[test]
418    #[ignore = "heavy (~seconds): plain CDCL on PHP(9..10) is exponential — that's the point. Charts the curve."]
419    fn pigeonhole_collapse_curve_to_php10() {
420        use crate::cdcl::SolveResult;
421        use crate::cnf::Cnf;
422        let conflicts_of = |e: &ProofExpr| -> u64 {
423            let mut cnf = Cnf::new();
424            cnf.assert(e).expect("clausifiable");
425            let (mut solver, _) = cnf.into_solver_with_atoms();
426            assert!(matches!(solver.solve(), SolveResult::Unsat));
427            solver.conflicts()
428        };
429        let bar = |v: u64| "█".repeat((64.0 * (v.max(1) as f64).log2() / (200000f64).log2()) as usize);
430        let mut rows = Vec::new();
431        rows.push("  n |   plain |  broken |  collapse | log₂ plain (█) vs broken (░)".to_string());
432        rows.push("----+---------+---------+-----------+-----------------------------".to_string());
433        for n in 4..=10 {
434            let plain = conflicts_of(&php(n));
435            let broken = conflicts_of(&break_symmetries(&php(n)));
436            rows.push(format!(
437                "{n:3} | {plain:7} | {broken:7} | {:7.1}× | {}\n    |         |         |           | {}",
438                plain as f64 / broken.max(1) as f64,
439                bar(plain),
440                "░".repeat(bar(broken).chars().count())
441            ));
442        }
443        let chart = rows.join("\n");
444        eprintln!("\nPIGEONHOLE COLLAPSE — exponential plain vs polynomial broken\n{chart}\n");
445        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../logs/derived_facts");
446        if std::fs::create_dir_all(&dir).is_ok() {
447            let _ = std::fs::write(dir.join("pigeonhole_collapse_curve.txt"), format!("PIGEONHOLE COLLAPSE — exponential plain CDCL vs polynomial certified symmetry breaking\n\n{chart}\n"));
448        }
449        // The wall is real: PHP(10) plain conflicts dwarf PHP(4), while broken barely moved.
450        let p10 = conflicts_of(&php(10));
451        let b10 = conflicts_of(&break_symmetries(&php(10)));
452        assert!(p10 > 50_000, "PHP(10) plain CDCL hits the exponential wall: {p10} conflicts");
453        assert!(b10 < 100, "broken PHP(10) stays polynomial: {b10} conflicts");
454    }
455}
456