1use 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
44pub 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 let eq = |k: usize| atom(format!("{aux}_eq_{k}"));
59 let mut clauses: Vec<ProofExpr> = Vec::new();
60 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 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
75type Lit = (String, bool);
77type Clause = BTreeSet<Lit>;
79
80fn 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
102fn 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 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
137fn 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
148fn 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
156fn 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 let image: BTreeSet<Clause> = clauses.iter().map(|c| rename(c, &map)).collect();
170 &image == clause_set
171}
172
173pub 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 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 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 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 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 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 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 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 #[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 assert!(*plains.last().unwrap() >= plains[0].saturating_mul(plains.len() as u64), "plain CDCL conflicts must explode with n: {plains:?}");
389 assert!(*ratios.last().unwrap() > ratios[0] * 1.5, "the symmetry-breaking collapse must widen with n: {ratios:?}");
391
392 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 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"), );
409 assert_eq!(break_symmetries(&f), f, "asymmetric formula must be left unchanged");
411 }
412
413 #[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 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