Skip to main content

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}