logicaffeine_proof/cdcl.rs
1//! A conflict-driven clause-learning (CDCL) SAT core — the modern competition-grade
2//! engine, built to the lineage that made SAT practical: two-watched-literal unit
3//! propagation (Moskewicz et al., *Chaff*, 2001), first-UIP conflict analysis with
4//! learned clauses and non-chronological backtracking (Marques-Silva & Sakallah,
5//! *GRASP*, 1996), VSIDS-style activity decisions, and Luby restarts (Luby et al.,
6//! 1993). The clean reference implementation it follows is Eén & Sörensson's *MiniSat*
7//! (2003).
8//!
9//! This module is the propositional substrate of a **DPLL(T)** engine (Nieuwenhuis,
10//! Oliveras & Tinelli, JACM 2006): theory propagators (an AllDifferent GAC filter for
11//! grid categories, EUF, LIA, …) plug in through [`Theory`], and every learned clause is
12//! a resolvent the solver can log as a **DRAT/LRAT** proof step (Wetzler/Heule/Hunt,
13//! 2014; Cruz-Filipe et al., 2017) for a downstream linear checker.
14//!
15//! It is deliberately self-contained and value-typed (`Var = u32`, `Lit` a packed
16//! sign+index) so it can be exercised against a brute-force oracle in isolation before
17//! it is wired to the grid encoder — a SAT core is only as good as its cross-checks.
18
19use std::collections::VecDeque;
20
21/// A propositional variable: an index `0..num_vars`.
22pub type Var = u32;
23
24/// A literal: a variable plus a sign, packed as `var << 1 | negated`. Packing keeps the
25/// watch lists and the trail cache-dense, the way every fast solver stores them.
26#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
27pub struct Lit(u32);
28
29impl Lit {
30 /// The positive literal `+v`.
31 #[inline]
32 pub fn pos(v: Var) -> Lit {
33 Lit(v << 1)
34 }
35 /// The negative literal `¬v`.
36 #[inline]
37 pub fn neg(v: Var) -> Lit {
38 Lit((v << 1) | 1)
39 }
40 /// Build from a variable and a sign (`true` ⇒ positive).
41 #[inline]
42 pub fn new(v: Var, positive: bool) -> Lit {
43 if positive {
44 Lit::pos(v)
45 } else {
46 Lit::neg(v)
47 }
48 }
49 /// The underlying variable.
50 #[inline]
51 pub fn var(self) -> Var {
52 self.0 >> 1
53 }
54 /// Whether this is a positive literal.
55 #[inline]
56 pub fn is_positive(self) -> bool {
57 self.0 & 1 == 0
58 }
59 /// The complementary literal.
60 #[inline]
61 pub fn negated(self) -> Lit {
62 Lit(self.0 ^ 1)
63 }
64 /// The dense index for watch/seen arrays (`2*var + sign`).
65 #[inline]
66 fn index(self) -> usize {
67 self.0 as usize
68 }
69}
70
71/// A three-valued assignment cell.
72#[derive(Clone, Copy, PartialEq, Eq, Debug)]
73enum Val {
74 True,
75 False,
76 Unset,
77}
78
79/// Reason a variable was assigned, for the implication graph that conflict analysis
80/// walks. `Decision` is a branching guess; `Clause(ci)` means clause `ci` became unit
81/// and forced this literal (its other literals are all false, earlier on the trail).
82#[derive(Clone, Copy, Debug)]
83enum Reason {
84 Decision,
85 Clause(usize),
86}
87
88/// A watch-list entry: the clause being watched plus a *blocking literal* — one of the clause's
89/// other literals, cached inline. If the blocker is already true the clause is satisfied, so
90/// propagation skips it without dereferencing the clause at all (the cache-miss saver from
91/// Chaff/MiniSat). Purely an optimization: the verdict is unaffected.
92#[derive(Clone, Copy, Debug)]
93struct Watcher {
94 clause: usize,
95 blocker: Lit,
96 /// Whether the watched clause is binary. For a binary clause the blocker *is* the only other
97 /// literal, so when it is not already true the clause is immediately unit-or-conflict — decided
98 /// with no clause dereference at all (the implicit-binary trick from Kissat/CaDiCaL). Kept in sync
99 /// by watch (re)creation: strengthening always `unwatch`+`rewatch`es, and `reduce_db` rebuilds all
100 /// watches, so a binary flag is never stale against clause contents.
101 binary: bool,
102}
103
104/// The verdict of [`Solver::solve`].
105#[derive(Clone, PartialEq, Eq, Debug)]
106pub enum SolveResult {
107 /// Satisfiable, with a full model over `0..num_vars` (`true`/`false` per variable).
108 Sat(Vec<bool>),
109 /// Unsatisfiable.
110 Unsat,
111}
112
113/// The outcome of a conflict-budgeted solve: a verdict, or budget exhaustion (with the learned
114/// clauses left available for symmetric amplification).
115#[derive(Clone, PartialEq, Eq, Debug)]
116pub enum BudgetedResult {
117 /// Satisfiable, with a full model.
118 Sat(Vec<bool>),
119 /// Unsatisfiable (proven within budget).
120 Unsat,
121 /// The conflict budget was exhausted before a verdict; [`Solver::learned`] holds the clauses
122 /// derived so far.
123 Budget,
124}
125
126/// A learned clause logged for proof reconstruction. Each is a resolvent derivable from
127/// the formula by reverse unit propagation — the unit of a DRAT/LRAT proof.
128#[derive(Clone, Debug)]
129pub struct LearnedClause {
130 pub lits: Vec<Lit>,
131}
132
133/// A theory propagator (the DPLL(T) seam). The SAT core calls [`Theory::propagate`] at
134/// each fixpoint of Boolean propagation; a theory returns a fresh clause to add (an
135/// explanation/conflict) or `None` if it has nothing to say. AllDifferent GAC, EUF, and
136/// LIA all implement this without the core knowing the theory.
137pub trait Theory {
138 /// Given the solver's current `trail` (assigned literals in assignment order), return a clause
139 /// that is theory-entailed and currently unit or falsified (so the core will propagate or
140 /// conflict on it), or `None` at a theory fixpoint. The trail is passed in order — and shrinks
141 /// on backtrack — so an incremental theory can sync forward/undo against it (LIFO). The returned
142 /// clauses MUST each be a sound consequence of the theory. Returning the whole batch of forced
143 /// clauses at once (rather than one per call) lets an incremental theory amortise its work over
144 /// one pass instead of rescanning per implication. An empty vec means "theory fixpoint".
145 fn propagate(&mut self, trail: &[Lit]) -> Vec<Vec<Lit>>;
146}
147
148/// Glucose restart-policy constants (Audemard & Simon, 2012). `LBD_WINDOW` recent conflicts feed
149/// the fast average; `TRAIL_WINDOW` recent trail lengths feed the blocking average. A restart fires
150/// when `fast_lbd_avg * RESTART_K > global_lbd_avg`; it is *blocked* when the trail at conflict
151/// exceeds `BLOCK_R ×` the recent average (and at least `BLOCK_MIN_CONFLICTS` have passed).
152const LBD_WINDOW: usize = 50;
153const TRAIL_WINDOW: usize = 5000;
154const RESTART_K: f64 = 0.8;
155const BLOCK_R: f64 = 1.4;
156const BLOCK_MIN_CONFLICTS: u64 = 10_000;
157
158/// Adaptive restart phase length: the first phase spans `ADAPT_PHASE_BASE` conflicts, and each
159/// switch multiplies it by `PHASE_GROWTH`. The base is large enough that a small instance solves
160/// entirely inside the first (aggressive Glucose) phase — keeping Glucose's wins there — while a
161/// long search alternates into calm Luby phases that help SAT and Luby-favouring instances.
162const ADAPT_PHASE_BASE: u64 = 5000;
163const PHASE_GROWTH: f64 = 2.0;
164
165/// The restart heuristic. Glucose's dynamic LBD policy restarts when recent learned-clause quality
166/// (literal-block distance) degrades relative to the global average, and *blocks* a restart when
167/// the trail is unusually long — a sign the search is closing in on a model. Luby is the classic
168/// universal sequence (Luby et al., 1993). Adaptive alternates the two in geometrically growing
169/// phases (the CaDiCaL/Kissat "stabilizing / non-stabilizing" idea): aggressive Glucose restarts to
170/// EXPLORE, then calm Luby restarts to let phase-saving EXPLOIT and dig toward a model — capturing
171/// the strength of each and avoiding the per-instance losses of either alone. The choice is a pure
172/// search-ORDER heuristic: it never changes a verdict.
173#[derive(Clone, Copy, PartialEq, Eq, Debug)]
174pub enum RestartMode {
175 /// Alternate Glucose (non-stabilizing) and Luby (stabilizing) phases — the default.
176 Adaptive,
177 /// Dynamic LBD restarts with blocking — the Glucose lever.
178 Glucose,
179 /// The Luby universal restart sequence.
180 Luby,
181}
182
183/// The CDCL solver.
184pub struct Solver {
185 num_vars: usize,
186 /// All clauses (original + learned); a clause is a `Vec<Lit>`.
187 clauses: Vec<Vec<Lit>>,
188 /// Watch lists: for each literal, the [`Watcher`]s on it. Two-watched-literal scheme — a
189 /// clause is visited only when one of its two watched literals is falsified, and even then is
190 /// skipped without a dereference when its cached blocking literal is already true.
191 watches: Vec<Vec<Watcher>>,
192 /// Per-variable value.
193 value: Vec<Val>,
194 /// Per-variable decision level.
195 level: Vec<u32>,
196 /// Per-variable reason.
197 reason: Vec<Reason>,
198 /// The assignment trail (literals in assignment order).
199 trail: Vec<Lit>,
200 /// Index into `trail` where each decision level begins.
201 trail_lim: Vec<usize>,
202 /// Head of the propagation queue (index into `trail`).
203 qhead: usize,
204 /// VSIDS activities and the bump/decay.
205 activity: Vec<f64>,
206 var_inc: f64,
207 /// Phase saving: the last value each variable held before being unset by a backjump. A
208 /// decision reuses it, so the search sticks to assignments that previously propagated far —
209 /// the standard ~2-3× win on structured SAT. Purely a heuristic: it changes search ORDER,
210 /// never completeness, so verdicts are unaffected.
211 saved_phase: Vec<bool>,
212 /// Learned clauses, logged for proof output.
213 learned_log: Vec<LearnedClause>,
214 /// Scratch `seen` markers for conflict analysis (indexed by var).
215 seen: Vec<bool>,
216 /// Reusable redundancy memo for learned-clause minimization (indexed by var): `0` unknown, `1`
217 /// redundant, `2` not — solver-resident so `minimize` never allocates a per-conflict `HashMap`.
218 min_cache: Vec<u8>,
219 /// The vars whose `min_cache` entry was set this minimization, so only those are reset (not the
220 /// whole array) — the sparse-clear list that keeps minimization allocation-free.
221 min_touched: Vec<Var>,
222 /// Per-decision-level stamps for computing a learned clause's LBD without a per-conflict `Vec`
223 /// allocation or sort: a level is counted once when its stamp differs from the current generation.
224 lbd_stamp: Vec<u32>,
225 /// The current LBD-stamp generation, bumped once per conflict (wraps only after 2³² conflicts).
226 lbd_gen: u32,
227 /// Set once an empty clause is added — the formula is then trivially unsatisfiable.
228 empty_clause: bool,
229 /// Number of clauses present when the solve began (the original formula); clauses at
230 /// indices `>= n_original` are learned. Lets a downstream checker read the original
231 /// clauses without a separate copy.
232 n_original: usize,
233 /// Total conflicts encountered during the solve — the standard measure of search work,
234 /// the lever symmetry breaking is meant to collapse. Pure observability; never read by the
235 /// search itself, so it cannot change a verdict.
236 conflicts: u64,
237 /// Total decisions (branchings) — search work. Pure observability; never read by the search.
238 decisions: u64,
239 /// Total literals propagated by BCP (one per trail literal processed) — throughput work.
240 propagations: u64,
241 /// Total restarts performed — observability for the restart policy.
242 restarts: u64,
243 /// Per-clause LBD (literal-block distance, Audemard & Simon 2009): the number of distinct
244 /// decision levels among a learned clause's literals. Low LBD ("glue") clauses are the most
245 /// reusable; high-LBD clauses are the deletion candidates. Originals carry `u32::MAX`.
246 lbd: Vec<u32>,
247 /// Whether to periodically delete high-LBD learned clauses, keeping the database compact (the
248 /// Glucose lever). Verdict-invariant — deletion only removes redundant learned clauses; the
249 /// original formula and all locked/glue clauses are kept. Toggleable for A/B benchmarking.
250 reduce_enabled: bool,
251 /// The live-learned-clause count that triggers a reduction; grows after each reduction.
252 reduce_limit: usize,
253 /// If `Some`, decisions are restricted to variables flagged `true` (the rest are left to theory
254 /// propagation). DPLL(XOR) sets this to the non-pivot variables so search ranges only over the
255 /// GF(2) kernel; `None` means branch on any variable (ordinary CDCL).
256 decision_mask: Option<Vec<bool>>,
257 /// VSIDS order heap (MiniSat-style indexed binary max-heap): the next decision is the highest-
258 /// activity unassigned variable in O(log n), replacing the old O(n) activity scan — the dominant
259 /// throughput lever at scale. `heap_pos[v]` is v's index in `heap`, or -1 when absent.
260 heap: Vec<Var>,
261 heap_pos: Vec<i32>,
262 /// The restart heuristic in force (default [`RestartMode::Glucose`]).
263 restart_mode: RestartMode,
264 /// Glucose fast window: the LBDs of the most recent `LBD_WINDOW` conflicts, with their running
265 /// sum, for the dynamic restart trigger.
266 lbd_fast: VecDeque<u32>,
267 lbd_fast_sum: u64,
268 /// Sum of every conflict's LBD over the whole solve — the global average's numerator (its
269 /// denominator is `conflicts`).
270 lbd_global_sum: u64,
271 /// Glucose blocking window: the trail lengths at the most recent `TRAIL_WINDOW` conflicts, with
272 /// their running sum, for the blocking-restart "we look close to a model" test.
273 trail_fast: VecDeque<usize>,
274 trail_fast_sum: u64,
275 /// Restarts suppressed by the blocking heuristic. Pure observability.
276 blocked_restarts: u64,
277 /// Learned clauses strengthened by vivification. Pure observability.
278 vivifications: u64,
279 /// Units derived by failed-literal probing. Pure observability.
280 probes: u64,
281 /// Learned clauses deleted or strengthened by subsumption / self-subsuming resolution. Pure
282 /// observability.
283 subsumptions: u64,
284 /// Whether to run the level-0 inprocessing schedule (probe + vivify + rephase) between
285 /// restarts during a long search. Default on; toggleable for A/B benchmarking. Verdict-invariant
286 /// either way.
287 inprocess_enabled: bool,
288 /// Conflicts between inprocessing rounds (default [`INPROCESS_INTERVAL`]). Tunable for
289 /// benchmarking and tests.
290 inprocess_interval: u64,
291 /// Whether probing is still worth running this solve. Set false after a probing round derives no
292 /// unit — on most instances probing finds nothing, so it would otherwise be pure overhead. Reset
293 /// at each top-level solve.
294 probe_active: bool,
295 /// Restart bookkeeping, solver-resident so the adaptive phase machinery can reset it cleanly:
296 /// conflicts since the last restart, the current Luby limit, and the Luby step index.
297 csr: u64,
298 restart_limit: u64,
299 restart_no: u64,
300 /// Adaptive restart phase: `stabilizing` = the calm (Luby) phase vs the aggressive (Glucose)
301 /// phase; `phase_start` is the conflict count when the current phase began; `phase_len` is its
302 /// length, grown by [`PHASE_GROWTH`] each switch.
303 stabilizing: bool,
304 phase_start: u64,
305 phase_len: u64,
306}
307
308/// Conflicts before the FIRST inprocessing round — high enough that short searches never inprocess
309/// (their churn can cost more than it saves), so the schedule engages only on the genuinely long
310/// solves where it pays off. Measured: at 2000 a couple of short random instances regress; at 6000
311/// every tested instance is regression-free while the long-search wins (PHP, gt18, hard random)
312/// are kept. Subsequent rounds back off by [`INPROCESS_GROWTH`].
313const INPROCESS_INTERVAL: u64 = 6000;
314/// Per-round caps so an inprocessing pass stays cheap relative to search.
315const PROBE_BUDGET: usize = 256;
316const VIVIFY_BUDGET: usize = 400;
317const SUBSUME_BUDGET: usize = 600;
318/// Each inprocessing round multiplies the conflicts-until-next-round by this factor, so a long
319/// search inprocesses often early then rarely — bounding total inprocessing cost (and the churn it
320/// can cost on instances where it does not pay off).
321const INPROCESS_GROWTH: f64 = 1.5;
322
323impl Solver {
324 /// A fresh solver over `num_vars` variables and no clauses.
325 pub fn new(num_vars: usize) -> Self {
326 Solver {
327 num_vars,
328 clauses: Vec::new(),
329 watches: vec![Vec::new(); num_vars * 2],
330 value: vec![Val::Unset; num_vars],
331 level: vec![0; num_vars],
332 reason: vec![Reason::Decision; num_vars],
333 trail: Vec::new(),
334 trail_lim: Vec::new(),
335 qhead: 0,
336 activity: vec![0.0; num_vars],
337 var_inc: 1.0,
338 saved_phase: vec![false; num_vars],
339 learned_log: Vec::new(),
340 seen: vec![false; num_vars],
341 min_cache: vec![0u8; num_vars],
342 min_touched: Vec::new(),
343 lbd_stamp: vec![0u32; num_vars + 1],
344 lbd_gen: 0,
345 empty_clause: false,
346 n_original: 0,
347 conflicts: 0,
348 decisions: 0,
349 propagations: 0,
350 restarts: 0,
351 lbd: Vec::new(),
352 reduce_enabled: true,
353 reduce_limit: 2000,
354 decision_mask: None,
355 heap: (0..num_vars as Var).collect(),
356 heap_pos: (0..num_vars as i32).collect(),
357 restart_mode: RestartMode::Adaptive,
358 lbd_fast: VecDeque::with_capacity(LBD_WINDOW + 1),
359 lbd_fast_sum: 0,
360 lbd_global_sum: 0,
361 trail_fast: VecDeque::with_capacity(TRAIL_WINDOW + 1),
362 trail_fast_sum: 0,
363 blocked_restarts: 0,
364 vivifications: 0,
365 probes: 0,
366 subsumptions: 0,
367 inprocess_enabled: true,
368 inprocess_interval: INPROCESS_INTERVAL,
369 probe_active: true,
370 csr: 0,
371 restart_limit: luby(1) * 100,
372 restart_no: 1,
373 stabilizing: false,
374 phase_start: 0,
375 phase_len: ADAPT_PHASE_BASE,
376 }
377 }
378
379 /// Run one subsumption + self-subsuming-resolution round over the learned clauses (Phase 4
380 /// inprocessing). Returns `false` if it proves the formula UNSAT. Verdict-invariant.
381 pub fn subsume(&mut self) -> bool {
382 self.subsume_round(0)
383 }
384
385 /// Learned clauses deleted or strengthened by subsumption during this solver's life.
386 pub fn subsumptions(&self) -> u64 {
387 self.subsumptions
388 }
389
390 /// Enable or disable the between-restart inprocessing schedule (default on). For A/B
391 /// benchmarking; verdict-invariant either way.
392 pub fn set_inprocess(&mut self, on: bool) {
393 self.inprocess_enabled = on;
394 }
395
396 /// Set the conflict interval between inprocessing rounds (default [`INPROCESS_INTERVAL`]).
397 /// Tuning / test knob; verdict-invariant.
398 pub fn set_inprocess_interval(&mut self, conflicts: u64) {
399 self.inprocess_interval = conflicts.max(1);
400 }
401
402 /// Run one vivification round over the learned clauses, strengthening each shortenable clause
403 /// in place (Phase 2 inprocessing). Returns `false` if vivification proves the formula UNSAT.
404 /// Verdict-invariant; intended to be scheduled at decision level 0 between restarts.
405 pub fn vivify(&mut self) -> bool {
406 self.vivify_round(0)
407 }
408
409 /// Learned clauses strengthened by vivification during this solver's life. Pure observability.
410 pub fn vivifications(&self) -> u64 {
411 self.vivifications
412 }
413
414 /// Run one failed-literal probing round (Phase 3 inprocessing): try each phase of each free
415 /// variable as a level-0 assumption; if propagation conflicts, the opposite literal is a forced
416 /// unit, learned permanently. Returns `false` if probing proves the formula UNSAT.
417 /// Verdict-invariant; scheduled at decision level 0.
418 pub fn probe(&mut self) -> bool {
419 self.probe_round(0)
420 }
421
422 /// Units derived by failed-literal probing during this solver's life. Pure observability.
423 pub fn probes(&self) -> u64 {
424 self.probes
425 }
426
427 /// Select the restart heuristic (default [`RestartMode::Glucose`]). Search-order only — a
428 /// verdict is unaffected, so this is for A/B tuning.
429 pub fn set_restart_mode(&mut self, mode: RestartMode) {
430 self.restart_mode = mode;
431 }
432
433 /// The restart heuristic in force.
434 pub fn restart_mode(&self) -> RestartMode {
435 self.restart_mode
436 }
437
438 /// Restarts suppressed by Glucose blocking during the last solve.
439 pub fn blocked_restarts(&self) -> u64 {
440 self.blocked_restarts
441 }
442
443 /// Record a conflict's LBD and the trail length at the moment of conflict, updating the Glucose
444 /// fast and blocking windows. If the trail was much longer than the recent average, empty the
445 /// fast LBD window — that defers the next dynamic restart (the blocking heuristic: a long trail
446 /// means a promising branch). Pure heuristic state; never affects a verdict. Call once per
447 /// conflict, before `conflicts` is incremented.
448 fn note_conflict(&mut self, lbd: u32, trail_at_conflict: usize) {
449 self.lbd_global_sum += u64::from(lbd);
450 self.lbd_fast.push_back(lbd);
451 self.lbd_fast_sum += u64::from(lbd);
452 if self.lbd_fast.len() > LBD_WINDOW {
453 self.lbd_fast_sum -= u64::from(self.lbd_fast.pop_front().unwrap());
454 }
455 self.trail_fast.push_back(trail_at_conflict);
456 self.trail_fast_sum += trail_at_conflict as u64;
457 if self.trail_fast.len() > TRAIL_WINDOW {
458 self.trail_fast_sum -= self.trail_fast.pop_front().unwrap() as u64;
459 }
460 if self.conflicts >= BLOCK_MIN_CONFLICTS
461 && self.lbd_fast.len() >= LBD_WINDOW
462 && (trail_at_conflict as f64)
463 > BLOCK_R * (self.trail_fast_sum as f64 / self.trail_fast.len() as f64)
464 {
465 self.lbd_fast.clear();
466 self.lbd_fast_sum = 0;
467 self.blocked_restarts += 1;
468 }
469 }
470
471 /// The Glucose dynamic-restart trigger: the fast-window LBD average has degraded past
472 /// `RESTART_K ×` the global average. Fires only once the fast window is full (and is emptied by
473 /// blocking, which is how a block suppresses the next restart).
474 fn glucose_should_restart(&self) -> bool {
475 if self.lbd_fast.len() < LBD_WINDOW || self.conflicts == 0 {
476 return false;
477 }
478 let fast = self.lbd_fast_sum as f64 / self.lbd_fast.len() as f64;
479 let global = self.lbd_global_sum as f64 / self.conflicts as f64;
480 fast * RESTART_K > global
481 }
482
483 /// Reset all restart bookkeeping for a fresh top-level solve (Luby counter, Glucose windows, and
484 /// the adaptive phase, which starts in the aggressive Glucose phase).
485 fn reset_restart_state(&mut self) {
486 self.csr = 0;
487 self.restart_limit = luby(1) * 100;
488 self.restart_no = 1;
489 self.stabilizing = false;
490 self.phase_start = self.conflicts;
491 self.phase_len = ADAPT_PHASE_BASE;
492 self.lbd_fast.clear();
493 self.lbd_fast_sum = 0;
494 }
495
496 /// In Adaptive mode, switch between the aggressive (Glucose) and calm (Luby) phases once the
497 /// current phase's conflict budget is spent, growing the next phase and resetting both policies'
498 /// trigger state so neither carries a stale signal across the boundary. No-op in fixed modes.
499 fn advance_restart_phase(&mut self) {
500 if self.restart_mode != RestartMode::Adaptive {
501 return;
502 }
503 if self.conflicts - self.phase_start >= self.phase_len {
504 self.stabilizing = !self.stabilizing;
505 self.phase_start = self.conflicts;
506 self.phase_len = ((self.phase_len as f64) * PHASE_GROWTH) as u64;
507 self.csr = 0;
508 self.restart_no = 1;
509 self.restart_limit = luby(1) * 100;
510 self.lbd_fast.clear();
511 self.lbd_fast_sum = 0;
512 }
513 }
514
515 /// Whether to restart now, per the active policy. Adaptive consults Glucose in its aggressive
516 /// phase and Luby in its calm (stabilizing) phase.
517 fn want_restart(&self) -> bool {
518 match self.restart_mode {
519 RestartMode::Glucose => self.glucose_should_restart(),
520 RestartMode::Luby => self.csr >= self.restart_limit,
521 RestartMode::Adaptive => {
522 if self.stabilizing {
523 self.csr >= self.restart_limit
524 } else {
525 self.glucose_should_restart()
526 }
527 }
528 }
529 }
530
531 /// Perform a restart: jump to level 0, advance the Luby counter, and reset the Glucose fast
532 /// window so the next trigger measures freshly.
533 fn do_restart(&mut self) {
534 self.backtrack_to(0);
535 self.csr = 0;
536 self.restarts += 1;
537 self.restart_no += 1;
538 self.restart_limit = luby(self.restart_no) * 100;
539 self.lbd_fast.clear();
540 self.lbd_fast_sum = 0;
541 }
542
543 /// Restrict branching to `vars` (DPLL(XOR): the engine's non-pivot variables; the theory forces
544 /// the rest). A model is still complete because theory propagation assigns every excluded
545 /// variable before all decision variables are exhausted.
546 pub fn set_decision_vars(&mut self, vars: &[usize]) {
547 let mut mask = vec![false; self.num_vars];
548 for &v in vars {
549 if v < self.num_vars {
550 mask[v] = true;
551 }
552 }
553 self.decision_mask = Some(mask);
554 // Rebuild the order heap to hold only the decision candidates.
555 self.heap.clear();
556 self.heap_pos = vec![-1; self.num_vars];
557 for &v in vars {
558 if v < self.num_vars && self.heap_pos[v] < 0 {
559 self.heap_pos[v] = self.heap.len() as i32;
560 self.heap.push(v as Var);
561 }
562 }
563 for i in (0..self.heap.len() / 2).rev() {
564 self.sift_down(i);
565 }
566 }
567
568 // --- VSIDS order heap (indexed binary max-heap on `activity`) ---
569
570 fn heap_swap(&mut self, i: usize, j: usize) {
571 self.heap.swap(i, j);
572 self.heap_pos[self.heap[i] as usize] = i as i32;
573 self.heap_pos[self.heap[j] as usize] = j as i32;
574 }
575
576 fn sift_up(&mut self, mut i: usize) {
577 while i > 0 {
578 let p = (i - 1) / 2;
579 if self.activity[self.heap[i] as usize] > self.activity[self.heap[p] as usize] {
580 self.heap_swap(i, p);
581 i = p;
582 } else {
583 break;
584 }
585 }
586 }
587
588 fn sift_down(&mut self, mut i: usize) {
589 let n = self.heap.len();
590 loop {
591 let (l, r) = (2 * i + 1, 2 * i + 2);
592 let mut m = i;
593 if l < n && self.activity[self.heap[l] as usize] > self.activity[self.heap[m] as usize] {
594 m = l;
595 }
596 if r < n && self.activity[self.heap[r] as usize] > self.activity[self.heap[m] as usize] {
597 m = r;
598 }
599 if m == i {
600 break;
601 }
602 self.heap_swap(i, m);
603 i = m;
604 }
605 }
606
607 /// Insert `v` (idempotent: a no-op if already present).
608 fn heap_insert(&mut self, v: Var) {
609 if self.heap_pos[v as usize] >= 0 {
610 return;
611 }
612 let i = self.heap.len();
613 self.heap.push(v);
614 self.heap_pos[v as usize] = i as i32;
615 self.sift_up(i);
616 }
617
618 /// Remove and return the highest-activity variable, or `None` if empty.
619 fn heap_pop(&mut self) -> Option<Var> {
620 if self.heap.is_empty() {
621 return None;
622 }
623 let top = self.heap[0];
624 self.heap_pos[top as usize] = -1;
625 let last = self.heap.pop().unwrap();
626 if !self.heap.is_empty() {
627 self.heap[0] = last;
628 self.heap_pos[last as usize] = 0;
629 self.sift_down(0);
630 }
631 Some(top)
632 }
633
634 /// Restore the heap after `v`'s activity rose (it can only move toward the root).
635 fn heap_increase(&mut self, v: Var) {
636 let i = self.heap_pos[v as usize];
637 if i >= 0 {
638 self.sift_up(i as usize);
639 }
640 }
641
642 /// Total conflicts during the last solve — the search-work metric.
643 pub fn conflicts(&self) -> u64 {
644 self.conflicts
645 }
646
647 /// Total decisions during the last solve.
648 pub fn decisions(&self) -> u64 {
649 self.decisions
650 }
651
652 /// Total BCP propagations during the last solve — the throughput metric.
653 pub fn propagations(&self) -> u64 {
654 self.propagations
655 }
656
657 /// Total restarts during the last solve.
658 pub fn restarts(&self) -> u64 {
659 self.restarts
660 }
661
662 /// Enable or disable LBD-based learned-clause deletion (default on). For A/B benchmarking.
663 pub fn set_reduce(&mut self, on: bool) {
664 self.reduce_enabled = on;
665 }
666
667 /// Set the live-learned-clause count that triggers a reduction (tuning / stress-testing).
668 pub fn set_reduce_limit(&mut self, limit: usize) {
669 self.reduce_limit = limit.max(1);
670 }
671
672 /// Seed the saved-phase polarities (the order decisions try first) from an external assignment.
673 /// Purely a search-order hint — it never changes which models exist, so it cannot affect the
674 /// verdict — but starting decisions on, e.g., a GF(2)-consistent assignment lets the hybrid XOR
675 /// route begin on the linear system's solution manifold and only repair the residual clauses.
676 pub fn set_initial_phase(&mut self, phases: &[bool]) {
677 for (v, &p) in phases.iter().enumerate().take(self.num_vars) {
678 self.saved_phase[v] = p;
679 }
680 }
681
682 /// The number of learned clauses currently live in the database (originals excluded).
683 pub fn live_learned(&self) -> usize {
684 self.clauses.len().saturating_sub(self.n_original)
685 }
686
687 /// The learned clauses produced during the last solve (the DRAT/LRAT proof skeleton).
688 pub fn learned(&self) -> &[LearnedClause] {
689 &self.learned_log
690 }
691
692 /// The ORIGINAL clauses (those present before solving), borrowed in place — so a RUP
693 /// checker can replay over them without copying the clause set.
694 pub fn original_clauses(&self) -> &[Vec<Lit>] {
695 &self.clauses[..self.n_original]
696 }
697
698 /// Add a clause. An empty clause makes the formula trivially unsatisfiable; a unit
699 /// clause is enqueued as a top-level fact.
700 pub fn add_clause(&mut self, mut lits: Vec<Lit>) {
701 // Dedup and drop a clause that is already a tautology (`v ∨ ¬v`).
702 lits.sort_by_key(|l| l.0);
703 lits.dedup();
704 for w in lits.windows(2) {
705 if w[0].var() == w[1].var() {
706 // contains both polarities of some var → tautology, skip
707 return;
708 }
709 }
710 self.add_clause_raw(lits, false);
711 }
712
713 /// Internal: register a clause and set up its two watches. `learned` clauses are also
714 /// logged. Returns the clause index.
715 fn add_clause_raw(&mut self, lits: Vec<Lit>, learned: bool) -> usize {
716 let ci = self.clauses.len();
717 // Keep the per-clause LBD vector aligned with `clauses` (grows by exactly one here).
718 // Originals/units default to `u32::MAX` (never deleted); the caller overwrites a learned
719 // clause's slot with its real LBD.
720 self.lbd.push(u32::MAX);
721 if learned {
722 self.learned_log.push(LearnedClause { lits: lits.clone() });
723 }
724 if lits.is_empty() {
725 self.empty_clause = true;
726 self.clauses.push(lits);
727 return ci;
728 }
729 if lits.len() == 1 {
730 let l = lits[0];
731 self.clauses.push(lits);
732 // A unit clause forces its literal. If it is already FALSE on the trail, the
733 // formula is unsatisfiable (a top-level conflict); if already true, nothing to
734 // do; else enqueue it.
735 match self.val_of(l) {
736 Val::True => {}
737 Val::False => self.empty_clause = true,
738 Val::Unset => self.enqueue(l, Reason::Clause(ci)),
739 }
740 return ci;
741 }
742 // Watch the first two literals, each blocked by the other.
743 let (w0, w1) = (lits[0], lits[1]);
744 let bin = lits.len() == 2;
745 self.watches[w0.index()].push(Watcher { clause: ci, blocker: w1, binary: bin });
746 self.watches[w1.index()].push(Watcher { clause: ci, blocker: w0, binary: bin });
747 self.clauses.push(lits);
748 ci
749 }
750
751 #[inline]
752 fn val_of(&self, l: Lit) -> Val {
753 match self.value[l.var() as usize] {
754 Val::Unset => Val::Unset,
755 v => {
756 if l.is_positive() {
757 v
758 } else {
759 match v {
760 Val::True => Val::False,
761 Val::False => Val::True,
762 Val::Unset => Val::Unset,
763 }
764 }
765 }
766 }
767 }
768
769 #[inline]
770 fn lit_true(&self, l: Lit) -> bool {
771 self.val_of(l) == Val::True
772 }
773 #[inline]
774 fn lit_false(&self, l: Lit) -> bool {
775 self.val_of(l) == Val::False
776 }
777
778 /// Assign `l` true with the given reason, push it on the trail. Assumes `l` is currently
779 /// unset (callers check).
780 fn enqueue(&mut self, l: Lit, r: Reason) {
781 let v = l.var() as usize;
782 self.value[v] = if l.is_positive() { Val::True } else { Val::False };
783 self.level[v] = self.trail_lim.len() as u32;
784 self.reason[v] = r;
785 self.trail.push(l);
786 }
787
788 /// Two-watched-literal unit propagation. Returns the index of a conflicting clause, or
789 /// `None` at a Boolean fixpoint.
790 fn propagate(&mut self) -> Option<usize> {
791 while self.qhead < self.trail.len() {
792 let p = self.trail[self.qhead];
793 self.qhead += 1;
794 self.propagations += 1;
795 // Clauses watching ¬p may have become unit/false.
796 let false_lit = p.negated();
797 let mut wi = 0;
798 // Take the watch list out to satisfy the borrow checker; we rebuild it.
799 let mut watchers = std::mem::take(&mut self.watches[false_lit.index()]);
800 'next_clause: while wi < watchers.len() {
801 // Blocking literal: if it is already true, the clause is satisfied — skip it
802 // without dereferencing the clause (the cache-miss saver).
803 if self.lit_true(watchers[wi].blocker) {
804 wi += 1;
805 continue;
806 }
807 // Binary clause: the blocker is the *only* other literal and it is not true, so the
808 // clause is immediately unit (blocker unassigned) or a conflict (blocker false) — with
809 // no clause dereference at all. The bulk of a mature learned database is binary, so this
810 // is a real BCP win, and it is exact.
811 if watchers[wi].binary {
812 let other = watchers[wi].blocker;
813 let ci = watchers[wi].clause;
814 if self.lit_false(other) {
815 self.watches[false_lit.index()] = watchers;
816 return Some(ci);
817 }
818 self.enqueue(other, Reason::Clause(ci));
819 wi += 1;
820 continue;
821 }
822 let ci = watchers[wi].clause;
823 let clause_len = self.clauses[ci].len();
824 // Ensure the watched literal we keep is in slot 0/1; make slot 1 = false_lit.
825 if self.clauses[ci][0] == false_lit {
826 self.clauses[ci].swap(0, 1);
827 }
828 // Slot 0 is the other watched literal; refresh it as this watch's blocker.
829 let other = self.clauses[ci][0];
830 watchers[wi].blocker = other;
831 // If it is already satisfied, the clause is fine — keep watching false_lit.
832 if self.lit_true(other) {
833 wi += 1;
834 continue;
835 }
836 // Look for a new, non-false literal to watch (slots 2..).
837 for k in 2..clause_len {
838 let lk = self.clauses[ci][k];
839 if !self.lit_false(lk) {
840 // Move lk into slot 1, watch it (blocked by `other`), drop this watch.
841 self.clauses[ci].swap(1, k);
842 // This path only fires for clauses with a third literal to move to, so the
843 // clause is never binary here (`clause_len >= 3`).
844 self.watches[lk.index()].push(Watcher { clause: ci, blocker: other, binary: clause_len == 2 });
845 watchers.swap_remove(wi);
846 continue 'next_clause;
847 }
848 }
849 // No new watch: clause is unit on `other` or conflicting.
850 if self.lit_false(other) {
851 // Conflict: restore the rest of this watch list and report.
852 self.watches[false_lit.index()] = watchers;
853 return Some(ci);
854 }
855 // Unit: propagate `other`.
856 self.enqueue(other, Reason::Clause(ci));
857 wi += 1;
858 }
859 self.watches[false_lit.index()] = watchers;
860 }
861 None
862 }
863
864 /// First-UIP conflict analysis (GRASP/MiniSat). Walks the implication graph back from
865 /// the conflicting clause to the first unique implication point at the current level,
866 /// producing a learned clause and the level to backjump to.
867 fn analyze(&mut self, conflict: usize) -> (Vec<Lit>, u32, u32) {
868 let decision_level = self.trail_lim.len() as u32;
869 let mut learned: Vec<Lit> = vec![Lit::pos(0)]; // slot 0 reserved for the UIP
870 let mut counter = 0usize; // unresolved literals at the current level
871 let mut p: Option<Lit> = None;
872 let mut trail_idx = self.trail.len();
873 let mut clause = conflict;
874
875 loop {
876 // Resolve with `clause` (its literals are all false; for the conflict clause, and for
877 // each antecedent reason clause thereafter). Index rather than clone the clause: copying
878 // each `Lit` out ends the immutable borrow before we `bump`/mark, so the hot 1UIP loop
879 // never allocates — the way a production solver walks reason clauses.
880 for k in 0..self.clauses[clause].len() {
881 let q = self.clauses[clause][k];
882 if Some(q) == p {
883 continue; // skip the pivot we just resolved on
884 }
885 let v = q.var() as usize;
886 if !self.seen[v] && self.level[v] > 0 {
887 self.bump(q.var());
888 self.seen[v] = true;
889 if self.level[v] == decision_level {
890 counter += 1;
891 } else {
892 learned.push(q);
893 }
894 }
895 }
896 // Pick the next literal to resolve: the most recent seen literal on the trail.
897 loop {
898 trail_idx -= 1;
899 let l = self.trail[trail_idx];
900 if self.seen[l.var() as usize] {
901 p = Some(l);
902 break;
903 }
904 }
905 let pv = p.unwrap().var() as usize;
906 self.seen[pv] = false;
907 counter -= 1;
908 if counter == 0 {
909 break;
910 }
911 clause = match self.reason[pv] {
912 Reason::Clause(ci) => ci,
913 Reason::Decision => unreachable!("UIP reached a decision before counter hit 0"),
914 };
915 }
916 // The asserting literal is ¬p (p is the UIP, currently true).
917 learned[0] = p.unwrap().negated();
918 // Clear the `seen` marks left on the non-UIP learned literals.
919 for &l in &learned[1..] {
920 self.seen[l.var() as usize] = false;
921 }
922 // Recursive minimization: drop any learned literal whose reason is already covered by the
923 // rest of the clause. Strictly strengthens the (still-implied) clause, shrinking its size
924 // and LBD — verdict-invariant.
925 self.minimize(&mut learned);
926 // Move the highest-decision-level literal into slot 1. The two-watched-literal invariant
927 // requires the second watch to be the most recently falsified literal (the backjump
928 // level); otherwise the learned clause can be falsified later without the watch firing.
929 let mut backjump = 0u32;
930 let mut max_idx = 1usize;
931 for i in 1..learned.len() {
932 let lv = self.level[learned[i].var() as usize];
933 if lv > backjump {
934 backjump = lv;
935 max_idx = i;
936 }
937 }
938 if learned.len() >= 2 {
939 learned.swap(1, max_idx);
940 }
941 // LBD (distinct decision levels among the learned literals) via generation-stamped counting —
942 // no per-conflict Vec allocation or sort. Each level is counted once per conflict.
943 self.lbd_gen = self.lbd_gen.wrapping_add(1);
944 if self.lbd_gen == 0 {
945 // Generation wrapped after 2³² conflicts — reset the stamps once and restart at 1.
946 for s in self.lbd_stamp.iter_mut() {
947 *s = 0;
948 }
949 self.lbd_gen = 1;
950 }
951 let mut lbd = 0u32;
952 for &l in learned.iter() {
953 let lev = self.level[l.var() as usize] as usize;
954 if self.lbd_stamp[lev] != self.lbd_gen {
955 self.lbd_stamp[lev] = self.lbd_gen;
956 lbd += 1;
957 }
958 }
959 (learned, backjump, lbd)
960 }
961
962 /// Recursive learned-clause minimization (Sörensson & Biere, 2009). A non-asserting literal is
963 /// dropped if its reason clause's other literals are all already in the learned clause, are
964 /// level-0 facts, or are themselves removable — meaning the literal is redundant. The result is
965 /// a sub-clause still implied by the formula, so it is verdict-invariant; it just yields
966 /// shorter, lower-LBD clauses that propagate and delete better.
967 fn minimize(&mut self, learned: &mut Vec<Lit>) {
968 // Mark every learned literal in the shared `seen` array — the in-clause test, no per-conflict
969 // HashSet — and borrow the reusable memo/touch buffers out of `self` so `lit_redundant` can
970 // read `self` immutably while writing them (no per-conflict HashMap).
971 for &l in learned.iter() {
972 self.seen[l.var() as usize] = true;
973 }
974 let mut cache = std::mem::take(&mut self.min_cache);
975 let mut touched = std::mem::take(&mut self.min_touched);
976 // Pass 1: fill the memo (`cache[var] == 1` ⟺ that literal is redundant), with `seen` intact.
977 for i in 1..learned.len() {
978 let _ = self.lit_redundant(learned[i], &mut cache, &mut touched, 0);
979 }
980 // Clear `seen` off the full (not-yet-compacted) learned clause — the next analyze starts clean.
981 for &l in learned.iter() {
982 self.seen[l.var() as usize] = false;
983 }
984 // Pass 2: drop the redundant literals.
985 let mut j = 1;
986 for i in 1..learned.len() {
987 let l = learned[i];
988 if cache[l.var() as usize] != 1 {
989 learned[j] = l;
990 j += 1;
991 }
992 }
993 learned.truncate(j);
994 // Sparse-reset the memo and hand the buffers back to `self` for the next conflict.
995 for &v in &touched {
996 cache[v as usize] = 0;
997 }
998 touched.clear();
999 self.min_cache = cache;
1000 self.min_touched = touched;
1001 }
1002
1003 fn lit_redundant(&self, lit: Lit, cache: &mut [u8], touched: &mut Vec<Var>, depth: u32) -> bool {
1004 let v = lit.var();
1005 match cache[v as usize] {
1006 1 => return true,
1007 2 => return false,
1008 _ => {}
1009 }
1010 // Bound the recursion: a deep implication chain could otherwise overflow the stack. Not
1011 // minimizing a literal is always sound, so we conservatively report "not redundant" past the
1012 // limit — and do NOT cache that cutoff, which is not the true answer.
1013 if depth >= 48 {
1014 return false;
1015 }
1016 let result = match self.reason[v as usize] {
1017 // A decision literal anchors its level; it is never redundant.
1018 Reason::Decision => false,
1019 // No clone: the reason clause and the recursive call are both immutable borrows of `self`.
1020 // `seen[qv]` is the in-learned-clause test (marked by `minimize`).
1021 Reason::Clause(ci) => self.clauses[ci].iter().all(|&q| {
1022 let qv = q.var();
1023 qv == v
1024 || self.level[qv as usize] == 0
1025 || self.seen[qv as usize]
1026 || self.lit_redundant(q, cache, touched, depth + 1)
1027 }),
1028 };
1029 cache[v as usize] = if result { 1 } else { 2 };
1030 touched.push(v);
1031 result
1032 }
1033
1034 fn bump(&mut self, v: Var) {
1035 self.activity[v as usize] += self.var_inc;
1036 if self.activity[v as usize] > 1e100 {
1037 // Rescale all activities; relative order (hence the heap) is preserved.
1038 for a in self.activity.iter_mut() {
1039 *a *= 1e-100;
1040 }
1041 self.var_inc *= 1e-100;
1042 }
1043 self.heap_increase(v);
1044 }
1045
1046 fn decay(&mut self) {
1047 self.var_inc /= 0.95;
1048 }
1049
1050 /// Undo assignments down to (but not including) `level`.
1051 fn backtrack_to(&mut self, level: u32) {
1052 if self.trail_lim.len() as u32 <= level {
1053 return;
1054 }
1055 let target = self.trail_lim[level as usize];
1056 while self.trail.len() > target {
1057 let l = self.trail.pop().unwrap();
1058 let v = l.var() as usize;
1059 // Remember the polarity this variable held, so the next decision on it reuses it.
1060 self.saved_phase[v] = self.value[v] == Val::True;
1061 self.value[v] = Val::Unset;
1062 // Return the now-unassigned variable to the order heap (if it's a decision candidate).
1063 if self.heap_pos[v] < 0 && self.decision_mask.as_ref().is_none_or(|m| m[v]) {
1064 self.heap_insert(v as Var);
1065 }
1066 }
1067 self.qhead = target;
1068 self.trail_lim.truncate(level as usize);
1069 }
1070
1071 /// Delete the high-LBD half of the (deletable) learned clauses and compact the database.
1072 ///
1073 /// Soundness/verdict-invariance: only learned clauses are ever removed, and never a **locked**
1074 /// clause (a current reason on the trail), a **glue** clause (LBD ≤ 2), or a unit/binary. A
1075 /// learned clause is a resolvent already implied by the formula, so dropping it cannot change
1076 /// satisfiability. Surviving clauses keep their two watched literals (slots 0/1) unchanged, so
1077 /// the two-watched-literal invariant is preserved — we only renumber and rebuild the watch
1078 /// lists. The full proof trace (`learned_log`) is untouched, so a downstream RUP/PR check still
1079 /// replays every learned clause.
1080 fn reduce_db(&mut self) {
1081 let n = self.clauses.len();
1082 // A clause currently justifying a trail literal must not be deleted.
1083 let mut locked = vec![false; n];
1084 for &l in &self.trail {
1085 if let Reason::Clause(ci) = self.reason[l.var() as usize] {
1086 locked[ci] = true;
1087 }
1088 }
1089 // Deletion candidates: learned, unlocked, non-glue, length > 2.
1090 let mut cand: Vec<usize> = (self.n_original..n)
1091 .filter(|&ci| !locked[ci] && self.lbd[ci] > 2 && self.clauses[ci].len() > 2)
1092 .collect();
1093 if cand.is_empty() {
1094 return;
1095 }
1096 cand.sort_by(|&a, &b| self.lbd[b].cmp(&self.lbd[a])); // worst (highest LBD) first
1097 let drop_n = cand.len() / 2;
1098 let delete: std::collections::HashSet<usize> = cand.into_iter().take(drop_n).collect();
1099 self.compact(&delete);
1100 }
1101
1102 /// Rebuild the clause database keeping every clause NOT in `delete`, renumbering reasons and
1103 /// rebuilding watch lists from each survivor's (unchanged) first two literals. Originals stay
1104 /// first — so `n_original` / `original_clauses()` remain valid — provided `delete` holds only
1105 /// learned indices, and no deleted clause is a current reason (locked). Shared by LBD reduction
1106 /// and subsumption.
1107 fn compact(&mut self, delete: &std::collections::HashSet<usize>) {
1108 if delete.is_empty() {
1109 return;
1110 }
1111 let n = self.clauses.len();
1112 let survivors: Vec<usize> = (0..n).filter(|ci| !delete.contains(ci)).collect();
1113 let mut remap = vec![usize::MAX; n];
1114 for (new, &old) in survivors.iter().enumerate() {
1115 remap[old] = new;
1116 }
1117 let new_clauses: Vec<Vec<Lit>> = survivors.iter().map(|&ci| self.clauses[ci].clone()).collect();
1118 let new_lbd: Vec<u32> = survivors.iter().map(|&ci| self.lbd[ci]).collect();
1119 for v in 0..self.num_vars {
1120 if let Reason::Clause(ci) = self.reason[v] {
1121 if remap[ci] != usize::MAX {
1122 self.reason[v] = Reason::Clause(remap[ci]);
1123 }
1124 }
1125 }
1126 self.clauses = new_clauses;
1127 self.lbd = new_lbd;
1128 for w in self.watches.iter_mut() {
1129 w.clear();
1130 }
1131 for (ci, c) in self.clauses.iter().enumerate() {
1132 if c.len() >= 2 {
1133 let (l0, l1) = (c[0], c[1]);
1134 let bin = c.len() == 2;
1135 self.watches[l0.index()].push(Watcher { clause: ci, blocker: l1, binary: bin });
1136 self.watches[l1.index()].push(Watcher { clause: ci, blocker: l0, binary: bin });
1137 }
1138 }
1139 }
1140
1141 /// Lift the two watchers of clause `ci` from the watch lists (its watched literals are, by
1142 /// invariant, `clauses[ci][0..2]`). Used to EXCLUDE a clause from propagation while it is being
1143 /// vivified, so the strengthened clause is implied by `F \ {C}` — the soundness key.
1144 fn unwatch(&mut self, ci: usize) {
1145 if self.clauses[ci].len() < 2 {
1146 return;
1147 }
1148 let (a, b) = (self.clauses[ci][0].index(), self.clauses[ci][1].index());
1149 if let Some(p) = self.watches[a].iter().position(|w| w.clause == ci) {
1150 self.watches[a].swap_remove(p);
1151 }
1152 if let Some(p) = self.watches[b].iter().position(|w| w.clause == ci) {
1153 self.watches[b].swap_remove(p);
1154 }
1155 }
1156
1157 /// Re-establish watches on `clauses[ci]`'s current first two literals (the inverse of
1158 /// [`Self::unwatch`], after a possible literal rewrite).
1159 fn rewatch(&mut self, ci: usize) {
1160 if self.clauses[ci].len() < 2 {
1161 return;
1162 }
1163 let (l0, l1) = (self.clauses[ci][0], self.clauses[ci][1]);
1164 let bin = self.clauses[ci].len() == 2;
1165 self.watches[l0.index()].push(Watcher { clause: ci, blocker: l1, binary: bin });
1166 self.watches[l1.index()].push(Watcher { clause: ci, blocker: l0, binary: bin });
1167 }
1168
1169 /// Vivify the clause at `ci` (asymmetric branching; Piette, Hamadi & Saïs 2008). Must be called
1170 /// at decision level 0 with the top-level fixpoint reached and `ci` already un-watched. Pushes
1171 /// trial decisions `¬lᵢ` for the clause's literals in order, propagating against `F \ {C}`:
1172 ///
1173 /// - `lᵢ` already FALSE ⇒ `F\{C} ⊨ ¬lᵢ` under the assumed prefix ⇒ `lᵢ` is redundant — drop it.
1174 /// - `lᵢ` already TRUE ⇒ `F\{C} ⊨ (kept ∨ lᵢ)` ⇒ that prefix subsumes C — strengthen to it.
1175 /// - propagating `¬lᵢ` CONFLICTS ⇒ `F\{C} ⊨ kept` ⇒ strengthen to the prefix `kept`.
1176 ///
1177 /// Returns the strengthened literal set if C can be shortened (a strict, still-implied subset),
1178 /// else `None`. Leaves the trail back at level 0; the clause is left un-watched for the caller to
1179 /// rewatch or replace. Because the result is implied by `F \ {C}` and is `⊆ C`, replacing C with
1180 /// it preserves every model and the new clause is a valid RUP/DRAT addition.
1181 fn vivify_clause(&mut self, ci: usize) -> Option<Vec<Lit>> {
1182 let c = self.clauses[ci].clone();
1183 let mut kept: Vec<Lit> = Vec::with_capacity(c.len());
1184 let mut shortened = false;
1185 for &l in &c {
1186 match self.val_of(l) {
1187 Val::False => shortened = true, // redundant literal — drop it
1188 Val::True => {
1189 kept.push(l);
1190 shortened = kept.len() < c.len();
1191 break;
1192 }
1193 Val::Unset => {
1194 kept.push(l);
1195 self.trail_lim.push(self.trail.len());
1196 self.enqueue(l.negated(), Reason::Decision);
1197 if self.propagate().is_some() {
1198 shortened = kept.len() < c.len();
1199 break;
1200 }
1201 }
1202 }
1203 }
1204 self.backtrack_to(0);
1205 if shortened && !kept.is_empty() {
1206 Some(kept)
1207 } else {
1208 None
1209 }
1210 }
1211
1212 /// One vivification round over the learned clauses (Phase 2 inprocessing). Strengthens each
1213 /// shortenable learned clause in place, appending the result to the proof log as a valid RUP
1214 /// addition. Must be invoked at decision level 0. Returns `false` if a clause vivifies down to a
1215 /// unit that conflicts (or to empty) — i.e. the formula is proven UNSAT. Verdict-invariant: only
1216 /// replaces a learned clause with an equally- or more-constraining clause implied by the rest of
1217 /// the formula. A `budget` of 0 means "every learned clause".
1218 fn vivify_round(&mut self, budget: usize) -> bool {
1219 self.backtrack_to(0);
1220 if self.propagate().is_some() {
1221 return false; // level-0 conflict ⇒ UNSAT
1222 }
1223 let n = self.clauses.len();
1224 let cap = if budget == 0 { usize::MAX } else { budget };
1225 let mut done = 0usize;
1226 for ci in self.n_original..n {
1227 if done >= cap {
1228 break;
1229 }
1230 if self.clauses[ci].len() < 2 {
1231 continue; // units/empties: nothing to strengthen
1232 }
1233 done += 1;
1234 self.unwatch(ci);
1235 match self.vivify_clause(ci) {
1236 None => self.rewatch(ci),
1237 Some(kept) => {
1238 self.vivifications += 1;
1239 self.learned_log.push(LearnedClause { lits: kept.clone() });
1240 if kept.is_empty() {
1241 self.empty_clause = true;
1242 self.clauses[ci] = kept;
1243 return false;
1244 }
1245 if kept.len() == 1 {
1246 let u = kept[0];
1247 self.clauses[ci] = kept;
1248 match self.val_of(u) {
1249 Val::False => {
1250 self.empty_clause = true;
1251 return false;
1252 }
1253 Val::Unset => self.enqueue(u, Reason::Clause(ci)),
1254 Val::True => {}
1255 }
1256 if self.propagate().is_some() {
1257 return false;
1258 }
1259 continue;
1260 }
1261 self.lbd[ci] = self.clause_lbd(&kept);
1262 self.clauses[ci] = kept;
1263 self.rewatch(ci);
1264 }
1265 }
1266 }
1267 true
1268 }
1269
1270 /// One failed-literal probing round. For each free variable `v` (a decision candidate), assume
1271 /// each phase as a fresh decision and propagate against the whole formula: if `F ∧ probe`
1272 /// conflicts at level 0, then `F ⊨ ¬probe`, so `¬probe` is added as a permanent unit (a valid
1273 /// RUP step) and propagated. Returns `false` if a derived unit conflicts — the formula is UNSAT.
1274 /// Verdict-invariant. A `budget` of 0 means "every variable".
1275 fn probe_round(&mut self, budget: usize) -> bool {
1276 self.backtrack_to(0);
1277 if self.propagate().is_some() {
1278 return false; // level-0 conflict ⇒ UNSAT
1279 }
1280 let cap = if budget == 0 { usize::MAX } else { budget };
1281 let mut done = 0usize;
1282 for v in 0..self.num_vars as Var {
1283 if done >= cap {
1284 break;
1285 }
1286 if self.value[v as usize] != Val::Unset {
1287 continue; // already a level-0 fact
1288 }
1289 if self.decision_mask.as_ref().is_some_and(|m| !m[v as usize]) {
1290 continue; // not a decision candidate
1291 }
1292 done += 1;
1293 for probe in [Lit::pos(v), Lit::neg(v)] {
1294 if self.value[v as usize] != Val::Unset {
1295 break; // fixed by the other phase's probe
1296 }
1297 self.trail_lim.push(self.trail.len());
1298 self.enqueue(probe, Reason::Decision);
1299 let conflict = self.propagate().is_some();
1300 self.backtrack_to(0);
1301 if conflict {
1302 // F ∧ probe is UNSAT ⇒ ¬probe is forced; learn it as a unit (RUP) and apply it.
1303 self.probes += 1;
1304 self.add_clause_raw(vec![probe.negated()], true);
1305 if self.empty_clause || self.propagate().is_some() {
1306 return false;
1307 }
1308 }
1309 }
1310 }
1311 true
1312 }
1313
1314 /// One subsumption + self-subsuming-resolution round over the LEARNED clauses. A learned clause
1315 /// subsumed by any clause is deleted; a learned clause that self-subsumes against another
1316 /// (exactly one literal resolves away) is strengthened. Only learned, unlocked clauses are
1317 /// removed/strengthened — originals (and the RUP certificate, and `n_original`) stay valid.
1318 /// Verdict-invariant: a deleted clause is entailed by its subsumer (and subsumption is
1319 /// transitive, so soundness survives even if the subsumer is itself later removed), and a
1320 /// strengthened clause is a sound resolvent (logged as RUP). Returns `false` if a strengthening
1321 /// derives the empty clause. `budget` of 0 means "every learned clause".
1322 fn subsume_round(&mut self, budget: usize) -> bool {
1323 self.backtrack_to(0);
1324 if self.propagate().is_some() {
1325 return false;
1326 }
1327 let n = self.clauses.len();
1328 if n <= self.n_original {
1329 return true;
1330 }
1331 let code = |l: Lit| l.var() * 2 + u32::from(!l.is_positive());
1332 let mut coded: Vec<Vec<u32>> = Vec::with_capacity(n);
1333 let mut sigs: Vec<u64> = Vec::with_capacity(n);
1334 for c in &self.clauses {
1335 let mut v: Vec<u32> = c.iter().map(|&l| code(l)).collect();
1336 v.sort_unstable();
1337 v.dedup();
1338 sigs.push(c.iter().fold(0u64, |s, l| s | (1u64 << (l.var() % 64))));
1339 coded.push(v);
1340 }
1341 let mut occ: Vec<Vec<usize>> = vec![Vec::new(); self.num_vars];
1342 for (ci, cc) in coded.iter().enumerate() {
1343 for &x in cc {
1344 occ[(x / 2) as usize].push(ci);
1345 }
1346 }
1347 let mut locked = vec![false; n];
1348 for &l in &self.trail {
1349 if let Reason::Clause(ci) = self.reason[l.var() as usize] {
1350 if ci < n {
1351 locked[ci] = true;
1352 }
1353 }
1354 }
1355 let mut delete: std::collections::HashSet<usize> = std::collections::HashSet::new();
1356 let mut strengthen: Vec<(usize, u32)> = Vec::new();
1357 let cap = if budget == 0 { usize::MAX } else { budget };
1358 let mut done = 0usize;
1359 for di in self.n_original..n {
1360 if done >= cap {
1361 break;
1362 }
1363 if coded[di].len() < 2 || delete.contains(&di) {
1364 continue;
1365 }
1366 done += 1;
1367 let rare = *coded[di]
1368 .iter()
1369 .min_by_key(|&&x| occ[(x / 2) as usize].len())
1370 .unwrap();
1371 let mut tried = 0;
1372 for &ci in &occ[(rare / 2) as usize] {
1373 if ci == di || delete.contains(&ci) {
1374 continue;
1375 }
1376 tried += 1;
1377 if tried > 96 {
1378 break;
1379 }
1380 if coded[ci].len() > coded[di].len() || (sigs[ci] & !sigs[di]) != 0 {
1381 continue;
1382 }
1383 match self_subsumes(&coded[ci], &coded[di]) {
1384 Sub::Subsumes => {
1385 if !locked[di] {
1386 delete.insert(di);
1387 }
1388 break;
1389 }
1390 Sub::Strengthen(pivot) => {
1391 strengthen.push((di, pivot));
1392 break;
1393 }
1394 Sub::No => {}
1395 }
1396 }
1397 }
1398 for (di, drop_code) in strengthen {
1399 if delete.contains(&di) {
1400 continue;
1401 }
1402 self.unwatch(di);
1403 let new: Vec<Lit> = self.clauses[di]
1404 .iter()
1405 .copied()
1406 .filter(|&l| code(l) != drop_code)
1407 .collect();
1408 self.subsumptions += 1;
1409 self.learned_log.push(LearnedClause { lits: new.clone() });
1410 if new.is_empty() {
1411 self.empty_clause = true;
1412 self.clauses[di] = new;
1413 return false;
1414 }
1415 if new.len() == 1 {
1416 let u = new[0];
1417 self.clauses[di] = new;
1418 match self.val_of(u) {
1419 Val::False => {
1420 self.empty_clause = true;
1421 return false;
1422 }
1423 Val::Unset => self.enqueue(u, Reason::Clause(di)),
1424 Val::True => {}
1425 }
1426 if self.propagate().is_some() {
1427 return false;
1428 }
1429 } else {
1430 self.lbd[di] = self.clause_lbd(&new);
1431 self.clauses[di] = new;
1432 self.rewatch(di);
1433 }
1434 }
1435 self.subsumptions += delete.len() as u64;
1436 self.compact(&delete);
1437 true
1438 }
1439
1440 /// Rotate the saved-phase strategy (rephasing). Decisions reuse `saved_phase`; periodically
1441 /// overwriting it — invert, all-false, all-true, then leave the search's own saved phases — kicks
1442 /// the search out of a basin without changing completeness. Pure heuristic; verdict-invariant.
1443 fn rephase(&mut self, round: u64) {
1444 match round % 4 {
1445 0 => {
1446 for p in self.saved_phase.iter_mut() {
1447 *p = !*p;
1448 }
1449 }
1450 1 => self.saved_phase.iter_mut().for_each(|p| *p = false),
1451 2 => self.saved_phase.iter_mut().for_each(|p| *p = true),
1452 _ => {}
1453 }
1454 }
1455
1456 /// Run one level-0 inprocessing round: failed-literal probing (only while it keeps paying off),
1457 /// then subsumption + self-subsuming resolution, then learned-clause vivification, then a
1458 /// rephase. Returns `false` if it proves the formula UNSAT. Must be called at decision level 0
1459 /// (the scheduler calls it right after a restart). Verdict-invariant.
1460 fn inprocess(&mut self, round: u64) -> bool {
1461 if self.probe_active {
1462 let before = self.probes;
1463 if !self.probe_round(PROBE_BUDGET) {
1464 return false;
1465 }
1466 // Most instances yield no failed literals; once a round finds none, stop probing this
1467 // solve so it is not pure overhead.
1468 if self.probes == before {
1469 self.probe_active = false;
1470 }
1471 }
1472 if !self.subsume_round(SUBSUME_BUDGET) {
1473 return false;
1474 }
1475 if !self.vivify_round(VIVIFY_BUDGET) {
1476 return false;
1477 }
1478 self.rephase(round);
1479 true
1480 }
1481
1482 /// The decision literal for `v`: its saved phase (false-first on the first ever decision).
1483 fn decision_lit(&self, v: Var) -> Lit {
1484 if self.saved_phase[v as usize] {
1485 Lit::pos(v)
1486 } else {
1487 Lit::neg(v)
1488 }
1489 }
1490
1491 /// Pick the highest-activity unassigned decision variable in O(log n) via the order heap,
1492 /// discarding popped variables that are already assigned (lazy deletion). `None` once every
1493 /// decision candidate is assigned. Backtracking re-inserts unassigned candidates, so the heap is
1494 /// never missing a candidate when a decision is actually needed.
1495 fn pick_branch(&mut self) -> Option<Var> {
1496 loop {
1497 let v = self.heap_pop()?;
1498 if self.value[v as usize] == Val::Unset {
1499 return Some(v);
1500 }
1501 }
1502 }
1503
1504 /// The Literal-Block-Distance of a clause: the number of distinct decision levels among its
1505 /// literals (Audemard & Simon, 2009). Low LBD ⇒ "glue" ⇒ kept across reductions.
1506 fn clause_lbd(&self, lits: &[Lit]) -> u32 {
1507 let mut levels: Vec<u32> = lits.iter().map(|l| self.level[l.var() as usize]).collect();
1508 levels.sort_unstable();
1509 levels.dedup();
1510 levels.len() as u32
1511 }
1512
1513 /// The conflict aftermath shared by Boolean and theory conflicts: derive the 1-UIP asserting
1514 /// clause from conflicting clause `ci`, backjump, learn + enqueue it, then run the decay /
1515 /// reduce-DB / restart bookkeeping. Returns `true` when the conflict is at decision level 0 — the
1516 /// formula is UNSAT.
1517 fn after_conflict(&mut self, ci: usize) -> bool {
1518 if self.trail_lim.is_empty() {
1519 return true; // conflict at level 0
1520 }
1521 let trail_at_conflict = self.trail.len();
1522 let (learned, backjump, lbd) = self.analyze(ci);
1523 self.note_conflict(lbd, trail_at_conflict);
1524 self.backtrack_to(backjump);
1525 let asserting = learned[0];
1526 let unit = learned.len() == 1;
1527 let new_ci = self.add_clause_raw(learned, true);
1528 self.lbd[new_ci] = lbd;
1529 if !unit {
1530 self.enqueue(asserting, Reason::Clause(new_ci));
1531 }
1532 self.decay();
1533 self.conflicts += 1;
1534 self.csr += 1;
1535 if self.reduce_enabled && self.live_learned() >= self.reduce_limit {
1536 self.reduce_db();
1537 self.reduce_limit += 500;
1538 }
1539 self.advance_restart_phase();
1540 if self.want_restart() {
1541 self.do_restart();
1542 }
1543 false
1544 }
1545
1546 /// Solve, optionally under a list of theory propagators (DPLL(T)). Returns a model or
1547 /// `Unsat`. The learned-clause log is available afterwards via [`Solver::learned`].
1548 pub fn solve(&mut self) -> SolveResult {
1549 self.solve_with(&mut [])
1550 }
1551
1552 /// Solve but give up after `max_conflicts` conflicts, returning [`BudgetedResult::Budget`] with
1553 /// the learned clauses ([`Self::learned`]) intact — the hook dynamic symmetry breaking needs to
1554 /// interleave bounded search with symmetric clause amplification. A `max_conflicts` of 0 means
1555 /// unlimited (equivalent to [`Self::solve`]).
1556 pub fn solve_budgeted(&mut self, max_conflicts: u64) -> BudgetedResult {
1557 // Mark which clauses are original so DB reduction never deletes them (the omission that
1558 // would otherwise let reduction drop the formula itself and report a bogus SAT).
1559 self.n_original = self.clauses.len();
1560 if self.empty_clause {
1561 return BudgetedResult::Unsat;
1562 }
1563 if self.propagate().is_some() {
1564 return BudgetedResult::Unsat;
1565 }
1566 self.reset_restart_state();
1567 let start = self.conflicts;
1568 loop {
1569 if let Some(ci) = self.propagate() {
1570 if self.trail_lim.is_empty() {
1571 return BudgetedResult::Unsat;
1572 }
1573 let trail_at_conflict = self.trail.len();
1574 let (learned, backjump, lbd) = self.analyze(ci);
1575 self.note_conflict(lbd, trail_at_conflict);
1576 self.backtrack_to(backjump);
1577 let asserting = learned[0];
1578 let unit = learned.len() == 1;
1579 let new_ci = self.add_clause_raw(learned, true);
1580 self.lbd[new_ci] = lbd;
1581 if !unit {
1582 self.enqueue(asserting, Reason::Clause(new_ci));
1583 }
1584 self.decay();
1585 self.conflicts += 1;
1586 self.csr += 1;
1587 if max_conflicts != 0 && self.conflicts - start >= max_conflicts {
1588 self.backtrack_to(0);
1589 return BudgetedResult::Budget;
1590 }
1591 if self.reduce_enabled && self.live_learned() >= self.reduce_limit {
1592 self.reduce_db();
1593 self.reduce_limit += 500;
1594 }
1595 self.advance_restart_phase();
1596 if self.want_restart() {
1597 self.do_restart();
1598 }
1599 continue;
1600 }
1601 match self.pick_branch() {
1602 None => {
1603 let model = (0..self.num_vars).map(|v| self.value[v] == Val::True).collect();
1604 return BudgetedResult::Sat(model);
1605 }
1606 Some(v) => {
1607 self.trail_lim.push(self.trail.len());
1608 self.decisions += 1;
1609 self.enqueue(self.decision_lit(v), Reason::Decision);
1610 }
1611 }
1612 }
1613 }
1614
1615 /// Solve with theory propagators. Each is consulted at every Boolean fixpoint; a
1616 /// returned clause is added to the formula (and may immediately propagate or conflict).
1617 pub fn solve_with(&mut self, theories: &mut [Box<dyn Theory>]) -> SolveResult {
1618 self.n_original = self.clauses.len();
1619 if self.empty_clause {
1620 return SolveResult::Unsat;
1621 }
1622 // Top-level propagation of any unit clauses already enqueued.
1623 if self.propagate().is_some() {
1624 return SolveResult::Unsat;
1625 }
1626 self.reset_restart_state();
1627 let mut last_inprocess = 0u64;
1628 let mut inprocess_rounds = 0u64;
1629 let mut inprocess_gap = self.inprocess_interval;
1630 self.probe_active = true; // re-arm probing for this solve
1631
1632 loop {
1633 let conflict = self.propagate();
1634 if let Some(ci) = conflict {
1635 let restarts_before = self.restarts;
1636 if self.after_conflict(ci) {
1637 return SolveResult::Unsat;
1638 }
1639 // A restart just returned us to level 0 — the safe point to inprocess. Gated by a
1640 // GROWING gap so only long searches pay, and less and less as the search runs on
1641 // (bounding churn). probe + subsume + vivify + rephase, all verdict-invariant; a
1642 // `false` return means inprocessing proved UNSAT.
1643 if self.inprocess_enabled
1644 && theories.is_empty()
1645 && self.restarts > restarts_before
1646 && self.conflicts - last_inprocess >= inprocess_gap
1647 {
1648 last_inprocess = self.conflicts;
1649 if !self.inprocess(inprocess_rounds) {
1650 return SolveResult::Unsat;
1651 }
1652 inprocess_rounds += 1;
1653 inprocess_gap = ((inprocess_gap as f64) * INPROCESS_GROWTH) as u64;
1654 }
1655 continue;
1656 }
1657 // Boolean fixpoint — consult theories before branching. Every clause a theory hands back
1658 // is a globally-valid no-good (a logical consequence of the formula), so we CARRY it into
1659 // the learned database: a contradiction found by one strategy holds in every model, and a
1660 // carried clause prunes that branch for the rest of the search. A theory conflict (all
1661 // literals false) drives a 1-UIP backjump exactly like a Boolean conflict — sound because
1662 // we consult at EVERY fixpoint, so the inconsistency is caught at the level of its last
1663 // contributing assignment and the carried clause has a current-level literal. A theory
1664 // propagation (one literal still free) is enqueued with the carried clause as its reason,
1665 // so later conflict analysis resolves through the theory's reasoning.
1666 let mut theory_acted = false;
1667 for ti in 0..theories.len() {
1668 let implied = theories[ti].propagate(&self.trail);
1669 if implied.is_empty() {
1670 continue;
1671 }
1672 // A theory conflict takes priority: carry it and run conflict analysis.
1673 if let Some(conf) = implied.iter().find(|c| c.iter().all(|&l| self.lit_false(l))) {
1674 if conf.is_empty() {
1675 return SolveResult::Unsat; // an unconditional contradiction (0 = 1)
1676 }
1677 let lbd = self.clause_lbd(conf);
1678 let ci = self.add_clause_raw(conf.clone(), true);
1679 self.lbd[ci] = lbd;
1680 if self.after_conflict(ci) {
1681 return SolveResult::Unsat;
1682 }
1683 theory_acted = true;
1684 break;
1685 }
1686 // Otherwise carry every implied unit, enqueueing its one free literal.
1687 for c in &implied {
1688 if c.iter().any(|&l| self.lit_true(l)) {
1689 continue; // already satisfied — stale
1690 }
1691 let free: Vec<usize> = (0..c.len()).filter(|&i| self.val_of(c[i]) == Val::Unset).collect();
1692 if free.len() != 1 {
1693 continue; // not a clean unit propagation — skip defensively
1694 }
1695 let mut lits = c.clone();
1696 lits.swap(0, free[0]);
1697 if lits.len() > 1 {
1698 // Watch the highest-level false literal alongside the implied one.
1699 let mut best = 1;
1700 let mut best_lv = 0u32;
1701 for i in 1..lits.len() {
1702 let lv = self.level[lits[i].var() as usize];
1703 if lv >= best_lv {
1704 best_lv = lv;
1705 best = i;
1706 }
1707 }
1708 lits.swap(1, best);
1709 }
1710 let implied_lit = lits[0];
1711 let multi = lits.len() > 1;
1712 let lbd = self.clause_lbd(&lits);
1713 let ci = self.add_clause_raw(lits, true);
1714 self.lbd[ci] = lbd;
1715 if multi {
1716 // A unit clause was already enqueued by add_clause_raw's unit path.
1717 self.enqueue(implied_lit, Reason::Clause(ci));
1718 }
1719 theory_acted = true;
1720 }
1721 if theory_acted {
1722 break;
1723 }
1724 }
1725 if theory_acted {
1726 continue;
1727 }
1728 // Decide.
1729 match self.pick_branch() {
1730 None => {
1731 // Full assignment → SAT.
1732 let model = (0..self.num_vars)
1733 .map(|v| self.value[v] == Val::True)
1734 .collect();
1735 return SolveResult::Sat(model);
1736 }
1737 Some(v) => {
1738 self.trail_lim.push(self.trail.len());
1739 // Phase saving: reuse the variable's last polarity (false-first initially).
1740 self.decisions += 1;
1741 self.enqueue(self.decision_lit(v), Reason::Decision);
1742 }
1743 }
1744 }
1745 }
1746
1747 /// Solve under temporary `assumptions` (literals forced true for THIS query only),
1748 /// reusing every clause learned so far. The permanent clause database is untouched, so a
1749 /// later call with different assumptions may well be satisfiable — successive queries on
1750 /// the same solver (e.g. BMC at increasing depths) amortise learning. This is the
1751 /// incremental-SAT (IPASIR) pattern. `Unsat` here means "unsatisfiable UNDER these
1752 /// assumptions".
1753 ///
1754 /// Soundness of reuse: conflict analysis keeps decision-level literals (assumptions are
1755 /// decisions) and drops level-0 facts, so each learned clause is a consequence of the
1756 /// PERMANENT clauses alone — valid no matter which assumptions a future query makes.
1757 ///
1758 /// Restarts are disabled in this path: the assumptions occupy the bottom decision levels,
1759 /// and skipping restarts keeps them pinned without a restart-floor dance. The small,
1760 /// bounded queries this serves do not need restarts; correctness beats the heuristic.
1761 /// (Does not touch `n_original`; do not mix with [`Solver::original_clauses`]/RUP on the
1762 /// same solver.)
1763 pub fn solve_under_assumptions(&mut self, assumptions: &[Lit]) -> SolveResult {
1764 // Drop any prior search state, keeping level-0 facts and all learned clauses.
1765 self.backtrack_to(0);
1766 if self.empty_clause {
1767 return SolveResult::Unsat;
1768 }
1769 // Level-0 propagation: if the permanent formula is already unsatisfiable, no
1770 // assumption can rescue it — and it stays unsat for every future query, so latch the
1771 // permanent-unsat flag (this also guarantees a clean state on the next call, which a
1772 // no-op `backtrack_to(0)` over an empty `trail_lim` would otherwise inherit dirty).
1773 if self.propagate().is_some() {
1774 self.empty_clause = true;
1775 return SolveResult::Unsat;
1776 }
1777 loop {
1778 if let Some(ci) = self.propagate() {
1779 if self.trail_lim.is_empty() {
1780 self.empty_clause = true; // conflict with no decisions ⇒ unconditionally unsat
1781 return SolveResult::Unsat;
1782 }
1783 let (learned, backjump, lbd) = self.analyze(ci);
1784 self.backtrack_to(backjump);
1785 let asserting = learned[0];
1786 let unit = learned.len() == 1;
1787 let new_ci = self.add_clause_raw(learned, true);
1788 self.lbd[new_ci] = lbd;
1789 if !unit {
1790 self.enqueue(asserting, Reason::Clause(new_ci));
1791 }
1792 self.decay();
1793 continue;
1794 }
1795 // Decide: place the first not-yet-satisfied assumption (so the search always
1796 // explores under the full assumption set, even after a backjump unset some).
1797 let mut decided = false;
1798 for &a in assumptions {
1799 match self.val_of(a) {
1800 // The assumption is forced false ⇒ no model under the assumptions.
1801 Val::False => return SolveResult::Unsat,
1802 Val::True => {}
1803 Val::Unset => {
1804 self.trail_lim.push(self.trail.len());
1805 self.enqueue(a, Reason::Decision);
1806 decided = true;
1807 break;
1808 }
1809 }
1810 }
1811 if decided {
1812 continue;
1813 }
1814 // All assumptions hold — branch on the remaining variables.
1815 match self.pick_branch() {
1816 None => {
1817 let model = (0..self.num_vars)
1818 .map(|v| self.value[v] == Val::True)
1819 .collect();
1820 return SolveResult::Sat(model);
1821 }
1822 Some(v) => {
1823 self.trail_lim.push(self.trail.len());
1824 self.decisions += 1;
1825 self.enqueue(self.decision_lit(v), Reason::Decision);
1826 }
1827 }
1828 }
1829 }
1830}
1831
1832/// The Luby restart sequence `1,1,2,1,1,2,4,1,…` (Luby, Sinclair & Zuckerman, 1993) —
1833/// the optimal universal restart schedule.
1834/// The outcome of testing clause `C` (as sorted `(var,sign)` codes) against clause `D`.
1835enum Sub {
1836 /// `C ⊆ D` — `C` subsumes `D`, so `D` is redundant.
1837 Subsumes,
1838 /// `C` self-subsumes `D` on one literal: all of `C` is in `D` except a single literal whose
1839 /// negation is in `D`. `D` can drop that negated literal (the carried code). Resolution + the
1840 /// resulting subsumption.
1841 Strengthen(u32),
1842 /// No subsumption relationship.
1843 No,
1844}
1845
1846/// Classify `c` against `d` (both sorted, deduped `(var,sign)` code vectors). A code's sign bit is
1847/// the low bit, so `code ^ 1` is the opposite-polarity literal.
1848fn self_subsumes(c: &[u32], d: &[u32]) -> Sub {
1849 let mut pivot: Option<u32> = None;
1850 for &x in c {
1851 if d.binary_search(&x).is_ok() {
1852 continue; // x ∈ D
1853 }
1854 if d.binary_search(&(x ^ 1)).is_ok() {
1855 if pivot.is_some() {
1856 return Sub::No; // a second flipped literal ⇒ not (self-)subsuming
1857 }
1858 pivot = Some(x ^ 1);
1859 } else {
1860 return Sub::No; // x neither in D nor its negation ⇒ C ⊄ D
1861 }
1862 }
1863 match pivot {
1864 None => Sub::Subsumes,
1865 Some(p) => Sub::Strengthen(p),
1866 }
1867}
1868
1869fn luby(mut i: u64) -> u64 {
1870 // 1-indexed Luby.
1871 let mut k = 1u32;
1872 loop {
1873 let span = (1u64 << k) - 1;
1874 if i == span {
1875 return 1u64 << (k - 1);
1876 }
1877 if i < span {
1878 i -= (1u64 << (k - 1)) - 1;
1879 k = 1;
1880 continue;
1881 }
1882 k += 1;
1883 }
1884}
1885
1886#[cfg(test)]
1887mod tests {
1888 use super::*;
1889
1890 #[test]
1891 #[ignore = "bench: random-3SAT solve throughput — before/after engine tuning"]
1892 fn bench_random_3sat_throughput() {
1893 use std::time::Instant;
1894 let mut total_ms = 0.0;
1895 let mut conflicts = 0u64;
1896 let mut props = 0u64;
1897 for seed in 0..24u64 {
1898 let n = 150;
1899 let m = (n as f64 * 4.26) as usize;
1900 let cnf = crate::families::random_3sat(n, m, 0xBEEFu64 ^ seed);
1901 let mut s = Solver::new(cnf.num_vars);
1902 for c in &cnf.clauses {
1903 s.add_clause(c.clone());
1904 }
1905 let t = Instant::now();
1906 let _ = s.solve();
1907 total_ms += t.elapsed().as_secs_f64() * 1e3;
1908 conflicts += s.conflicts();
1909 props += s.propagations();
1910 }
1911 eprintln!(
1912 "[bench] random-3SAT ×24 @ n=150: {total_ms:.1}ms total ({:.3}ms/inst), {conflicts} conflicts, {props} propagations",
1913 total_ms / 24.0
1914 );
1915 }
1916
1917 #[test]
1918 fn counters_track_search_work() {
1919 // All 8 clauses over 3 vars (every assignment blocked) → UNSAT, forcing real search:
1920 // decisions, propagations, and conflicts must all register, and propagations (one per
1921 // trail literal processed) must dominate conflicts.
1922 let mut s = Solver::new(3);
1923 for mask in 0..8u32 {
1924 let c: Vec<Lit> = (0..3).map(|v| Lit::new(v, (mask >> v) & 1 == 0)).collect();
1925 s.add_clause(c);
1926 }
1927 assert_eq!(s.solve(), SolveResult::Unsat);
1928 assert!(s.conflicts() > 0, "expected conflicts, got 0");
1929 assert!(s.decisions() > 0, "expected decisions, got 0");
1930 assert!(s.propagations() >= s.conflicts(), "propagations should dominate conflicts");
1931 }
1932
1933 #[test]
1934 fn order_heap_pops_in_descending_activity() {
1935 let mut s = Solver::new(5);
1936 for _ in 0..5 {
1937 s.bump(4); // activity[4] = 5
1938 }
1939 for _ in 0..3 {
1940 s.bump(2); // activity[2] = 3
1941 }
1942 s.bump(0); // activity[0] = 1; vars 1,3 stay at 0
1943 let mut order = Vec::new();
1944 while let Some(v) = s.heap_pop() {
1945 order.push(v);
1946 }
1947 assert_eq!(&order[..3], &[4, 2, 0], "highest activity first");
1948 let mut rest = order[3..].to_vec();
1949 rest.sort();
1950 assert_eq!(rest, vec![1, 3], "zero-activity vars come last");
1951 }
1952
1953 fn sat_brute(num_vars: usize, clauses: &[Vec<Lit>]) -> bool {
1954 // Enumerate all 2^n assignments; true iff some assignment satisfies every clause.
1955 for mask in 0u64..(1u64 << num_vars) {
1956 let val = |v: Var| (mask >> v) & 1 == 1;
1957 let ok = clauses.iter().all(|c| {
1958 c.iter().any(|l| {
1959 let b = val(l.var());
1960 if l.is_positive() {
1961 b
1962 } else {
1963 !b
1964 }
1965 })
1966 });
1967 if ok {
1968 return true;
1969 }
1970 }
1971 false
1972 }
1973
1974 fn check_model(clauses: &[Vec<Lit>], model: &[bool]) -> bool {
1975 clauses.iter().all(|c| {
1976 c.iter().any(|l| {
1977 let b = model[l.var() as usize];
1978 if l.is_positive() {
1979 b
1980 } else {
1981 !b
1982 }
1983 })
1984 })
1985 }
1986
1987 #[test]
1988 fn unit_and_empty() {
1989 // Empty clause ⇒ Unsat.
1990 let mut s = Solver::new(1);
1991 s.add_clause(vec![]);
1992 assert_eq!(s.solve(), SolveResult::Unsat);
1993
1994 // x ∧ ¬x ⇒ Unsat.
1995 let mut s = Solver::new(1);
1996 s.add_clause(vec![Lit::pos(0)]);
1997 s.add_clause(vec![Lit::neg(0)]);
1998 assert_eq!(s.solve(), SolveResult::Unsat);
1999 }
2000
2001 #[test]
2002 fn tiny_sat() {
2003 // (x ∨ y) ∧ (¬x ∨ y) ∧ (¬y ∨ z): forces y, then z; x free.
2004 let mut s = Solver::new(3);
2005 s.add_clause(vec![Lit::pos(0), Lit::pos(1)]);
2006 s.add_clause(vec![Lit::neg(0), Lit::pos(1)]);
2007 s.add_clause(vec![Lit::neg(1), Lit::pos(2)]);
2008 match s.solve() {
2009 SolveResult::Sat(m) => {
2010 assert!(m[1] && m[2], "y and z forced true");
2011 }
2012 SolveResult::Unsat => panic!("should be SAT"),
2013 }
2014 }
2015
2016 #[test]
2017 fn pigeonhole_3_into_2_unsat() {
2018 // 3 pigeons, 2 holes: p_{i,h} = pigeon i in hole h. Each pigeon in some hole; no
2019 // two pigeons share a hole. Classic UNSAT (PHP) — exercises conflict learning.
2020 let var = |i: usize, h: usize| (i * 2 + h) as Var;
2021 let mut s = Solver::new(6);
2022 for i in 0..3 {
2023 s.add_clause(vec![Lit::pos(var(i, 0)), Lit::pos(var(i, 1))]);
2024 }
2025 for h in 0..2 {
2026 for i in 0..3 {
2027 for j in (i + 1)..3 {
2028 s.add_clause(vec![Lit::neg(var(i, h)), Lit::neg(var(j, h))]);
2029 }
2030 }
2031 }
2032 assert_eq!(s.solve(), SolveResult::Unsat);
2033 }
2034
2035 #[test]
2036 fn random_against_brute_force() {
2037 // Deterministic pseudo-random 3-CNFs over up to 6 vars; cross-check SAT/UNSAT and
2038 // validate every returned model. The only honest way to trust a SAT core.
2039 let mut state = 0x9e3779b97f4a7c15u64;
2040 let mut next = || {
2041 state ^= state << 13;
2042 state ^= state >> 7;
2043 state ^= state << 17;
2044 state
2045 };
2046 for _trial in 0..400 {
2047 let num_vars = 3 + (next() % 4) as usize; // 3..6
2048 let num_clauses = 3 + (next() % 12) as usize;
2049 let mut clauses = Vec::new();
2050 for _ in 0..num_clauses {
2051 let mut c = Vec::new();
2052 for _ in 0..3 {
2053 let v = (next() % num_vars as u64) as Var;
2054 let positive = next() & 1 == 0;
2055 c.push(Lit::new(v, positive));
2056 }
2057 clauses.push(c);
2058 }
2059 let expected = sat_brute(num_vars, &clauses);
2060 let mut s = Solver::new(num_vars);
2061 for c in &clauses {
2062 s.add_clause(c.clone());
2063 }
2064 match s.solve() {
2065 SolveResult::Sat(m) => {
2066 assert!(expected, "solver said SAT but brute force says UNSAT");
2067 assert!(check_model(&clauses, &m), "returned model does not satisfy the formula");
2068 }
2069 SolveResult::Unsat => {
2070 assert!(!expected, "solver said UNSAT but brute force found a model");
2071 }
2072 }
2073 }
2074 }
2075
2076 #[test]
2077 fn reduction_preserves_verdicts_and_models() {
2078 // LBD clause deletion must be verdict-invariant. With reduction forced after every few
2079 // learned clauses — so the delete + reason-remap + watch-rebuild path runs constantly —
2080 // every verdict must still match brute force and every model must satisfy the formula.
2081 // The strongest guard against a reduceDB bug.
2082 let mut state = 0x1234_5678_9abc_def0u64;
2083 let mut next = || {
2084 state ^= state << 13;
2085 state ^= state >> 7;
2086 state ^= state << 17;
2087 state
2088 };
2089 for _trial in 0..500 {
2090 let num_vars = 3 + (next() % 5) as usize; // 3..7
2091 let num_clauses = 10 + (next() % 25) as usize; // over-constrained → conflicts → reductions
2092 let mut clauses = Vec::new();
2093 for _ in 0..num_clauses {
2094 let mut c = Vec::new();
2095 for _ in 0..3 {
2096 c.push(Lit::new((next() % num_vars as u64) as Var, next() & 1 == 0));
2097 }
2098 clauses.push(c);
2099 }
2100 let expected = sat_brute(num_vars, &clauses);
2101 let mut s = Solver::new(num_vars);
2102 for c in &clauses {
2103 s.add_clause(c.clone());
2104 }
2105 s.set_reduce_limit(4); // hammer the reduction path
2106 match s.solve() {
2107 SolveResult::Sat(m) => {
2108 assert!(expected, "reduce-on solver said SAT but brute force says UNSAT");
2109 assert!(check_model(&clauses, &m), "model invalid under reduction");
2110 }
2111 SolveResult::Unsat => {
2112 assert!(!expected, "reduce-on solver said UNSAT but a model exists");
2113 }
2114 }
2115 }
2116 }
2117
2118 fn rng(seed: u64) -> impl FnMut() -> u64 {
2119 let mut state = seed;
2120 move || {
2121 state ^= state << 13;
2122 state ^= state >> 7;
2123 state ^= state << 17;
2124 state
2125 }
2126 }
2127
2128 #[test]
2129 fn restart_modes_are_all_verdict_invariant_default_adaptive() {
2130 // The adaptive (alternating Glucose/Luby phase) policy is the default, and switching restart
2131 // heuristics changes only search ORDER — every verdict and model must still agree with brute
2132 // force under ALL three policies.
2133 assert_eq!(
2134 Solver::new(1).restart_mode(),
2135 RestartMode::Adaptive,
2136 "adaptive restarts are the default"
2137 );
2138 let mut next = rng(0x51ed_2701_a1b2_c3d4);
2139 for _ in 0..400 {
2140 let num_vars = 3 + (next() % 5) as usize; // 3..7
2141 let num_clauses = 8 + (next() % 22) as usize;
2142 let mut clauses = Vec::new();
2143 for _ in 0..num_clauses {
2144 let mut c = Vec::new();
2145 for _ in 0..3 {
2146 c.push(Lit::new((next() % num_vars as u64) as Var, next() & 1 == 0));
2147 }
2148 clauses.push(c);
2149 }
2150 let expected = sat_brute(num_vars, &clauses);
2151 for mode in [RestartMode::Adaptive, RestartMode::Glucose, RestartMode::Luby] {
2152 let mut s = Solver::new(num_vars);
2153 s.set_restart_mode(mode);
2154 for c in &clauses {
2155 s.add_clause(c.clone());
2156 }
2157 match s.solve() {
2158 SolveResult::Sat(m) => {
2159 assert!(expected, "{mode:?}: SAT but brute force UNSAT");
2160 assert!(check_model(&clauses, &m), "{mode:?}: invalid model");
2161 }
2162 SolveResult::Unsat => assert!(!expected, "{mode:?}: UNSAT but SAT"),
2163 }
2164 }
2165 }
2166 }
2167
2168 fn php_solver(n: usize, holes: usize, mode: RestartMode) -> Solver {
2169 let var = |p: usize, h: usize| (p * holes + h) as Var;
2170 let mut s = Solver::new(n * holes);
2171 s.set_restart_mode(mode);
2172 for p in 0..n {
2173 s.add_clause((0..holes).map(|h| Lit::pos(var(p, h))).collect());
2174 }
2175 for h in 0..holes {
2176 for i in 0..n {
2177 for j in (i + 1)..n {
2178 s.add_clause(vec![Lit::neg(var(i, h)), Lit::neg(var(j, h))]);
2179 }
2180 }
2181 }
2182 s
2183 }
2184
2185 #[test]
2186 fn glucose_restarts_fire_and_beat_luby_on_pigeonhole() {
2187 // PHP(8→7) does real exponential-resolution work, and on it the dynamic LBD policy must
2188 // (a) actually restart — proving the mechanism is wired, not dormant — and (b) need no
2189 // more conflicts than the Luby baseline (here it roughly halves them). The blocking
2190 // counter is read to exercise its accounting. Verdicts are checked in the differential
2191 // test above; this one is about the restart *heuristic* paying off.
2192 let mut g = php_solver(8, 7, RestartMode::Glucose);
2193 assert_eq!(g.solve(), SolveResult::Unsat);
2194 assert!(g.restarts() > 0, "Glucose must restart on PHP(8); got {}", g.restarts());
2195 let _ = g.blocked_restarts();
2196
2197 let mut l = php_solver(8, 7, RestartMode::Luby);
2198 assert_eq!(l.solve(), SolveResult::Unsat);
2199 assert!(
2200 g.conflicts() <= l.conflicts(),
2201 "Glucose ({} conflicts) should not exceed Luby ({} conflicts) on PHP(8)",
2202 g.conflicts(),
2203 l.conflicts(),
2204 );
2205 }
2206
2207 #[test]
2208 fn vivify_preserves_verdicts() {
2209 // Interleave a vivification round into solving: learn a few clauses with a tiny budget,
2210 // strengthen them, then solve to completion — the verdict and any model must still match
2211 // brute force on the ORIGINAL formula. Vivify replaces a learned clause with an implied
2212 // sub-clause, so it can never change satisfiability. (That it *fires* is proven on
2213 // pigeonhole below, where instances are big enough to learn strengthenable clauses.)
2214 let mut next = rng(0x7654_3210_fedc_ba98);
2215 for _ in 0..600 {
2216 let num_vars = 3 + (next() % 5) as usize; // 3..7
2217 let num_clauses = 10 + (next() % 22) as usize; // over-constrained → conflicts
2218 let mut clauses = Vec::new();
2219 for _ in 0..num_clauses {
2220 let width = 2 + (next() % 2) as usize;
2221 let mut c = Vec::new();
2222 for _ in 0..width {
2223 c.push(Lit::new((next() % num_vars as u64) as Var, next() & 1 == 0));
2224 }
2225 clauses.push(c);
2226 }
2227 let expected = sat_brute(num_vars, &clauses);
2228 let mut s = Solver::new(num_vars);
2229 for c in &clauses {
2230 s.add_clause(c.clone());
2231 }
2232 // Vivify only from a clean budget-exhausted state (the supported inprocessing point,
2233 // mirroring the scheduler). A terminal verdict from the budgeted call is just checked.
2234 match s.solve_budgeted(12) {
2235 BudgetedResult::Sat(m) => {
2236 assert!(expected, "budgeted SAT but brute force UNSAT");
2237 assert!(check_model(&clauses, &m), "budgeted model invalid");
2238 }
2239 BudgetedResult::Unsat => assert!(!expected, "budgeted UNSAT but a model exists"),
2240 BudgetedResult::Budget => {
2241 if !s.vivify() {
2242 assert!(!expected, "vivify reported UNSAT but a model exists");
2243 continue;
2244 }
2245 match s.solve() {
2246 SolveResult::Sat(m) => {
2247 assert!(expected, "post-vivify SAT but brute force UNSAT");
2248 assert!(check_model(&clauses, &m), "post-vivify model invalid");
2249 }
2250 SolveResult::Unsat => {
2251 assert!(!expected, "post-vivify UNSAT but a model exists")
2252 }
2253 }
2254 }
2255 }
2256 }
2257 }
2258
2259 #[test]
2260 fn vivify_fires_and_preserves_verdict_on_pigeonhole() {
2261 // PHP is big enough to learn strengthenable clauses, so vivification must actually fire
2262 // (not be a dormant no-op) AND keep the UNSAT verdict. PHP(6→5) yields ~16 strengthenings.
2263 let mut s = php_solver(6, 5, RestartMode::Glucose);
2264 assert_eq!(s.solve_budgeted(30), BudgetedResult::Budget, "budget should exhaust mid-search");
2265 assert!(s.vivify(), "vivify should not yet prove UNSAT");
2266 assert!(s.vivifications() > 0, "vivification must fire on PHP — the pass is dormant");
2267 assert_eq!(s.solve(), SolveResult::Unsat, "UNSAT preserved through vivification");
2268 }
2269
2270 #[test]
2271 fn vivify_on_a_budgeted_prefix_preserves_pigeonhole_unsat() {
2272 // PHP(5→4) needs real search, so a small conflict budget exhausts cleanly — the supported
2273 // inprocessing state. Vivifying the learned clauses and finishing the solve must still
2274 // prove UNSAT (and the budgeted prefix must not regress to a bogus SAT).
2275 let mut s = php_solver(5, 4, RestartMode::Glucose);
2276 match s.solve_budgeted(5) {
2277 BudgetedResult::Unsat => {} // already proven within budget — still correct
2278 BudgetedResult::Sat(_) => panic!("PHP(5) is UNSAT"),
2279 BudgetedResult::Budget => {
2280 assert!(s.vivify(), "vivify should not yet prove UNSAT");
2281 assert_eq!(s.solve(), SolveResult::Unsat, "UNSAT preserved through vivification");
2282 }
2283 }
2284 }
2285
2286 #[test]
2287 fn probe_preserves_verdicts() {
2288 // Failed-literal probing derives only units that the formula entails, so running a probing
2289 // round before the solve must never change satisfiability or invalidate a model. Checked
2290 // against brute force on many random formulas.
2291 let mut next = rng(0x1c1c_2d2d_3e3e_4f4f);
2292 for _ in 0..600 {
2293 let num_vars = 3 + (next() % 5) as usize; // 3..7
2294 let num_clauses = 6 + (next() % 18) as usize;
2295 let mut clauses = Vec::new();
2296 for _ in 0..num_clauses {
2297 let width = 2 + (next() % 2) as usize;
2298 let mut c = Vec::new();
2299 for _ in 0..width {
2300 c.push(Lit::new((next() % num_vars as u64) as Var, next() & 1 == 0));
2301 }
2302 clauses.push(c);
2303 }
2304 let expected = sat_brute(num_vars, &clauses);
2305 let mut s = Solver::new(num_vars);
2306 for c in &clauses {
2307 s.add_clause(c.clone());
2308 }
2309 if !s.probe() {
2310 assert!(!expected, "probe reported UNSAT but a model exists");
2311 continue;
2312 }
2313 match s.solve() {
2314 SolveResult::Sat(m) => {
2315 assert!(expected, "post-probe SAT but brute force UNSAT");
2316 assert!(check_model(&clauses, &m), "post-probe model invalid");
2317 }
2318 SolveResult::Unsat => assert!(!expected, "post-probe UNSAT but a model exists"),
2319 }
2320 }
2321 }
2322
2323 #[test]
2324 fn probe_derives_a_failed_literal_unit() {
2325 // (¬v ∨ a) ∧ (¬v ∨ ¬a): assuming v=true forces a AND ¬a — a conflict — so probing must
2326 // derive the unit ¬v. The formula is SAT (v=false), and the post-probe model must reflect
2327 // the forced ¬v.
2328 let (v, a) = (0u32, 1u32);
2329 let clauses = vec![
2330 vec![Lit::neg(v), Lit::pos(a)],
2331 vec![Lit::neg(v), Lit::neg(a)],
2332 ];
2333 let mut s = Solver::new(2);
2334 for c in &clauses {
2335 s.add_clause(c.clone());
2336 }
2337 assert!(s.probe(), "probing should not prove this SAT formula UNSAT");
2338 assert!(s.probes() > 0, "probing must derive the failed-literal unit ¬v");
2339 match s.solve() {
2340 SolveResult::Sat(m) => {
2341 assert!(!m[v as usize], "¬v was forced by probing");
2342 assert!(check_model(&clauses, &m));
2343 }
2344 SolveResult::Unsat => panic!("formula is satisfiable (v = false)"),
2345 }
2346 }
2347
2348 #[test]
2349 fn inprocessing_engages_inside_solve_and_improves_search() {
2350 // PHP(9→8) crosses the inprocessing interval many times (at the tuned-down test interval),
2351 // so the scheduler must (a) actually fire inside solve() — observable via the counters —
2352 // and (b) cut the conflict count versus the same solve with inprocessing disabled. The
2353 // verdict is preserved both ways. (On much larger PHP instances the win grows to ~2×.)
2354 let mut on = php_solver(9, 8, RestartMode::Glucose);
2355 on.set_inprocess_interval(400);
2356 assert_eq!(on.solve(), SolveResult::Unsat);
2357 assert!(
2358 on.vivifications() > 0,
2359 "inprocessing must engage on a long solve; vivifications={}",
2360 on.vivifications(),
2361 );
2362
2363 let mut off = php_solver(9, 8, RestartMode::Glucose);
2364 off.set_inprocess(false);
2365 assert_eq!(off.solve(), SolveResult::Unsat);
2366 assert_eq!(off.vivifications() + off.probes(), 0, "toggle must suppress inprocessing");
2367 assert!(
2368 on.conflicts() < off.conflicts(),
2369 "inprocessing should cut conflicts: on={} off={}",
2370 on.conflicts(),
2371 off.conflicts(),
2372 );
2373 }
2374
2375 #[test]
2376 fn self_subsumes_classifies_subsumption_and_ssr() {
2377 // Direct test of the subsumption classifier on (var,sign) codes: 2v = +v, 2v+1 = ¬v.
2378 let pos = |v: u32| 2 * v;
2379 let neg = |v: u32| 2 * v + 1;
2380 let sorted = |mut x: Vec<u32>| {
2381 x.sort_unstable();
2382 x
2383 };
2384 let c = sorted(vec![pos(0), pos(1)]); // {a, b}
2385 // {a,b} ⊆ {a,b,c} → subsumes.
2386 assert!(matches!(
2387 self_subsumes(&c, &sorted(vec![pos(0), pos(1), pos(2)])),
2388 Sub::Subsumes
2389 ));
2390 // {a,b} vs {¬a,b,c}: a flips → strengthen, dropping ¬a from D.
2391 assert!(matches!(
2392 self_subsumes(&c, &sorted(vec![neg(0), pos(1), pos(2)])),
2393 Sub::Strengthen(p) if p == neg(0)
2394 ));
2395 // {a,d} vs {¬a,b,c}: d absent in either polarity → no relation.
2396 assert!(matches!(
2397 self_subsumes(&sorted(vec![pos(0), pos(3)]), &sorted(vec![neg(0), pos(1), pos(2)])),
2398 Sub::No
2399 ));
2400 // {a,b} vs {¬a,¬b,c}: two flips → no relation (resolution would not subsume).
2401 assert!(matches!(
2402 self_subsumes(&c, &sorted(vec![neg(0), neg(1), pos(2)])),
2403 Sub::No
2404 ));
2405 }
2406
2407 #[test]
2408 fn subsume_preserves_verdicts() {
2409 // Subsumption deletes only entailed learned clauses and SSR strengthens to sound resolvents,
2410 // so a subsumption round before finishing the solve must never change the verdict or
2411 // invalidate a model. Checked against brute force. (Firing at scale is covered by the
2412 // arena measurement; here soundness is the point.)
2413 let mut next = rng(0xa5a5_5a5a_c3c3_3c3c);
2414 for _ in 0..600 {
2415 let num_vars = 3 + (next() % 5) as usize;
2416 let num_clauses = 10 + (next() % 22) as usize;
2417 let mut clauses = Vec::new();
2418 for _ in 0..num_clauses {
2419 let width = 2 + (next() % 2) as usize;
2420 let mut c = Vec::new();
2421 for _ in 0..width {
2422 c.push(Lit::new((next() % num_vars as u64) as Var, next() & 1 == 0));
2423 }
2424 clauses.push(c);
2425 }
2426 let expected = sat_brute(num_vars, &clauses);
2427 let mut s = Solver::new(num_vars);
2428 for c in &clauses {
2429 s.add_clause(c.clone());
2430 }
2431 match s.solve_budgeted(12) {
2432 BudgetedResult::Sat(m) => {
2433 assert!(expected);
2434 assert!(check_model(&clauses, &m));
2435 }
2436 BudgetedResult::Unsat => assert!(!expected),
2437 BudgetedResult::Budget => {
2438 if !s.subsume() {
2439 assert!(!expected, "subsume reported UNSAT but a model exists");
2440 continue;
2441 }
2442 match s.solve() {
2443 SolveResult::Sat(m) => {
2444 assert!(expected, "post-subsume SAT but brute force UNSAT");
2445 assert!(check_model(&clauses, &m), "post-subsume model invalid");
2446 }
2447 SolveResult::Unsat => {
2448 assert!(!expected, "post-subsume UNSAT but a model exists")
2449 }
2450 }
2451 }
2452 }
2453 }
2454 }
2455
2456 #[test]
2457 fn solve_under_assumptions_matches_brute_force() {
2458 // Incremental SAT, validated against the oracle: for each random formula, fire MANY
2459 // assumption queries at the SAME solver (so it accumulates learned clauses), and
2460 // demand every verdict + model agree with brute force on `clauses ∧ assumptions`.
2461 // Reusing the solver is the whole point — it proves learned-clause reuse across
2462 // different assumption sets stays sound.
2463 let mut state = 0x243f_6a88_85a3_08d3u64;
2464 let mut next = || {
2465 state ^= state << 13;
2466 state ^= state >> 7;
2467 state ^= state << 17;
2468 state
2469 };
2470 for _trial in 0..300 {
2471 let num_vars = 3 + (next() % 4) as usize; // 3..6
2472 let num_clauses = 3 + (next() % 10) as usize;
2473 let mut clauses = Vec::new();
2474 for _ in 0..num_clauses {
2475 let width = 2 + (next() % 2) as usize; // 2- or 3-literal
2476 let mut c = Vec::new();
2477 for _ in 0..width {
2478 let v = (next() % num_vars as u64) as Var;
2479 let positive = next() & 1 == 0;
2480 c.push(Lit::new(v, positive));
2481 }
2482 clauses.push(c);
2483 }
2484 let mut s = Solver::new(num_vars);
2485 for c in &clauses {
2486 s.add_clause(c.clone());
2487 }
2488 // Several assumption queries on this one (clause-accumulating) solver.
2489 for _ in 0..8 {
2490 let a_count = (next() % 3) as usize; // 0..2 assumptions (may contradict)
2491 let mut asm = Vec::new();
2492 for _ in 0..a_count {
2493 let v = (next() % num_vars as u64) as Var;
2494 let positive = next() & 1 == 0;
2495 asm.push(Lit::new(v, positive));
2496 }
2497 let mut full = clauses.clone();
2498 for &a in &asm {
2499 full.push(vec![a]);
2500 }
2501 let expected = sat_brute(num_vars, &full);
2502 match s.solve_under_assumptions(&asm) {
2503 SolveResult::Sat(m) => {
2504 assert!(expected, "under {asm:?}: solver SAT but brute force UNSAT");
2505 assert!(
2506 check_model(&full, &m),
2507 "under {asm:?}: model violates clauses or assumptions"
2508 );
2509 }
2510 SolveResult::Unsat => {
2511 assert!(!expected, "under {asm:?}: solver UNSAT but brute force SAT");
2512 }
2513 }
2514 }
2515 }
2516 }
2517}