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 => {
126                collect_lits(l, false, out) && collect_lits(r, false, out)
127            }
128            ProofExpr::Or(l, r) if !pos => {
129                collect_lits(l, false, out) && collect_lits(r, false, out)
130            }
131            ProofExpr::Not(x) => collect_lits(x, pos, out),
132            _ => false,
133        },
134        ProofExpr::Or(l, r) if pos => collect_lits(l, pos, out) && collect_lits(r, pos, out),
135        ProofExpr::And(l, r) if !pos => collect_lits(l, pos, out) && collect_lits(r, pos, out),
136        ProofExpr::Implies(l, r) if pos => {
137            collect_lits(l, false, out) && collect_lits(r, true, out)
138        }
139        _ => false,
140    }
141}
142
143/// The at-least-one ROWS of `clauses`: all-positive clauses, returned as ordered variable vectors.
144/// Order within a row follows `BTreeSet` (name order) — stable and identical for every row, which is
145/// the column alignment we verify below.
146fn positive_rows(clauses: &[Clause]) -> Vec<Vec<String>> {
147    clauses
148        .iter()
149        .filter(|c| c.len() >= 2 && c.iter().all(|(_, p)| *p))
150        .map(|c| c.iter().map(|(a, _)| a.clone()).collect())
151        .collect()
152}
153
154/// Apply the variable renaming `map` to a clause, returning its canonical (re-sorted) form.
155fn rename(clause: &Clause, map: &std::collections::HashMap<&str, &str>) -> Clause {
156    clause
157        .iter()
158        .map(|(a, p)| {
159            (
160                map.get(a.as_str())
161                    .map(|s| s.to_string())
162                    .unwrap_or_else(|| a.clone()),
163                *p,
164            )
165        })
166        .collect()
167}
168
169/// Is the column-aligned swap of `row_a`↔`row_b` (position k ↔ position k) a genuine automorphism
170/// of the whole clause set? Apply the swap to every clause and check the multiset is invariant.
171fn swap_is_automorphism(
172    clause_set: &BTreeSet<Clause>,
173    clauses: &[Clause],
174    row_a: &[String],
175    row_b: &[String],
176) -> bool {
177    if row_a.len() != row_b.len() {
178        return false;
179    }
180    let mut map = std::collections::HashMap::new();
181    for (a, b) in row_a.iter().zip(row_b.iter()) {
182        map.insert(a.as_str(), b.as_str());
183        map.insert(b.as_str(), a.as_str());
184    }
185    // Image multiset must equal the original set (clauses are de-duplicated into a set; an
186    // automorphism is a bijection on a clause SET, so set-equality of the image is exactly right).
187    let image: BTreeSet<Clause> = clauses.iter().map(|c| rename(c, &map)).collect();
188    &image == clause_set
189}
190
191/// Augment `e` with lex-leader SBPs for every adjacent row-interchange symmetry we can *verify*.
192/// Returns `e` unchanged when the formula is not a readable clause set, has no rows, or no verified
193/// symmetry — symmetry breaking is purely additive and never changes the SAT/UNSAT verdict.
194pub fn break_symmetries(e: &ProofExpr) -> ProofExpr {
195    let Some(clauses) = to_clauses(e) else {
196        return e.clone();
197    };
198    let rows = positive_rows(&clauses);
199    if rows.len() < 2 {
200        return e.clone();
201    }
202    let clause_set: BTreeSet<Clause> = clauses.iter().cloned().collect();
203    let mut sbps: Vec<ProofExpr> = Vec::new();
204    for i in 0..rows.len() - 1 {
205        let (ra, rb) = (&rows[i], &rows[i + 1]);
206        if ra.len() != rb.len() || ra.len() < 2 {
207            continue;
208        }
209        if swap_is_automorphism(&clause_set, &clauses, ra, rb) {
210            let x: Vec<ProofExpr> = ra.iter().map(|a| atom(a.clone())).collect();
211            let y: Vec<ProofExpr> = rb.iter().map(|a| atom(a.clone())).collect();
212            sbps.push(lex_le(&x, &y, &format!("__sym_{i}")));
213        }
214    }
215    if sbps.is_empty() {
216        return e.clone();
217    }
218    let sbp = sbps.into_iter().reduce(and).unwrap();
219    and(e.clone(), sbp)
220}
221
222#[cfg(test)]
223#[path = "../../logicaffeine_tests/unit/logicaffeine_proof/symmetry/tests.rs"]
224mod tests;