ordeal 0.18.0

Ordeal — a specialized, certificate-checked QF_BV SMT solver for the PulseEngine toolchain.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
//! The pure-Rust CDCL SAT core (DES-004) — primary engine on every target.
//!
//! Implements the classic conflict-driven clause-learning loop: two-watched-
//! literal propagation, first-UIP conflict analysis with clause learning,
//! VSIDS-style decision activities with phase saving, and Luby-sequence
//! restarts (conflict-counter based — no wall clock, so the engine runs
//! unchanged on wasm32-wasip2 and is fully deterministic). Learned clauses
//! are never deleted, which also keeps proof-trace indices stable.
//!
//! # Proof trace (the P2 LRAT hook, TR-005)
//!
//! Every learned clause is recorded as a [`LearnedStep`] carrying the
//! antecedent clauses used in its conflict-analysis resolution. Antecedent
//! indices refer to the combined original+learned clause sequence: index
//! `i < n` is `formula.clauses[i]` exactly as passed to [`SatSolver::solve`]
//! (tautologies and duplicate literals included, so original indices line up
//! one-to-one with the input), and index `n + k` is the clause of the `k`-th
//! [`LearnedStep`], where `n = formula.clauses.len()`.
//!
//! The antecedents of each step form a reverse-unit-propagation (RUP) chain:
//! assume the negation of every literal in `clause`, then process the
//! antecedents in order — each becomes unit (propagate its remaining
//! literal) until the last, which becomes falsified. Concretely the order is
//! root-level (level-0) unit reasons in assignment order, then the first-UIP
//! resolution reasons in trail order, then the conflicting clause last. When
//! the result is [`SatResult::Unsat`] the final step's `clause` is empty:
//! that step records the derivation of the empty clause.

use crate::cnf::CnfFormula;
use core::cmp::Ordering;

/// The verdict of a SAT solve.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SatResult {
    /// Satisfiable, with `assignment[v-1]` giving variable `v`'s value.
    Sat(Vec<bool>),
    /// Unsatisfiable.
    Unsat,
}

/// One clause-learning step of the resolution proof (see module docs for
/// the indexing convention and the RUP ordering of `antecedents`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LearnedStep {
    /// The learned clause in DIMACS literals; empty for the final conflict.
    pub clause: Vec<i32>,
    /// Antecedent clause indices into the original+learned sequence.
    pub antecedents: Vec<usize>,
}

/// Internal literal: variable `v` (0-based) is `2v` positive, `2v+1` negated.
type ILit = u32;

/// Sentinel for "assigned by decision / unassigned" in the reason array.
const NO_REASON: usize = usize::MAX;

/// Restart interval unit: restart after `64 * luby(k)` conflicts.
const RESTART_BASE: u64 = 64;

/// VSIDS activity decay factor (activities effectively multiply by this
/// per conflict; implemented by growing the increment).
const VAR_DECAY: f64 = 0.95;

fn ilit(l: i32) -> ILit {
    debug_assert!(l != 0, "DIMACS literals are nonzero");
    ((l.unsigned_abs() - 1) << 1) | u32::from(l < 0)
}

fn ivar(l: ILit) -> usize {
    (l >> 1) as usize
}

fn dimacs(l: ILit) -> i32 {
    let v = (l >> 1) as i32 + 1;
    if l & 1 == 1 { -v } else { v }
}

/// The `i`-th element of the Luby sequence 1,1,2,1,1,2,4,… (0-based).
fn luby(i: u64) -> u64 {
    let (mut size, mut seq, mut x) = (1u64, 0u32, i);
    while size < x + 1 {
        seq += 1;
        size = 2 * size + 1;
    }
    while size - 1 != x {
        size = (size - 1) / 2;
        seq -= 1;
        x %= size;
    }
    1 << seq
}

/// Max-heap over variables ordered by activity, ties broken toward the
/// smaller variable index so decisions are deterministic.
#[derive(Debug, Default)]
struct VarOrder {
    heap: Vec<u32>,
    /// Position of each variable in `heap`, or `usize::MAX` if absent.
    pos: Vec<usize>,
}

fn better(act: &[f64], a: u32, b: u32) -> bool {
    match act[a as usize].partial_cmp(&act[b as usize]) {
        Some(Ordering::Greater) => true,
        Some(Ordering::Equal) => a < b,
        _ => false,
    }
}

impl VarOrder {
    fn reset(&mut self, n: usize) {
        // `heap[i] = i` with all-zero activities already satisfies the heap
        // property (ties break toward the smaller index).
        self.heap = (0..n as u32).collect();
        self.pos = (0..n).collect();
    }

    fn insert(&mut self, act: &[f64], v: u32) {
        if self.pos[v as usize] != usize::MAX {
            return;
        }
        self.pos[v as usize] = self.heap.len();
        self.heap.push(v);
        self.sift_up(act, self.heap.len() - 1);
    }

    /// Restore the heap after `v`'s activity increased.
    fn update(&mut self, act: &[f64], v: u32) {
        let i = self.pos[v as usize];
        if i != usize::MAX {
            self.sift_up(act, i);
        }
    }

    fn pop(&mut self, act: &[f64]) -> Option<u32> {
        let top = *self.heap.first()?;
        self.pos[top as usize] = usize::MAX;
        let last = self.heap.pop().expect("heap is non-empty");
        if !self.heap.is_empty() {
            self.heap[0] = last;
            self.pos[last as usize] = 0;
            self.sift_down(act, 0);
        }
        Some(top)
    }

    fn sift_up(&mut self, act: &[f64], mut i: usize) {
        while i > 0 {
            let parent = (i - 1) / 2;
            if !better(act, self.heap[i], self.heap[parent]) {
                break;
            }
            self.swap(i, parent);
            i = parent;
        }
    }

    fn sift_down(&mut self, act: &[f64], mut i: usize) {
        loop {
            let (l, r) = (2 * i + 1, 2 * i + 2);
            let mut best = i;
            if l < self.heap.len() && better(act, self.heap[l], self.heap[best]) {
                best = l;
            }
            if r < self.heap.len() && better(act, self.heap[r], self.heap[best]) {
                best = r;
            }
            if best == i {
                break;
            }
            self.swap(i, best);
            i = best;
        }
    }

    fn swap(&mut self, i: usize, j: usize) {
        self.heap.swap(i, j);
        self.pos[self.heap[i] as usize] = i;
        self.pos[self.heap[j] as usize] = j;
    }
}

/// The CDCL solver.
#[derive(Debug, Default)]
pub struct SatSolver {
    /// Clause database: input clauses (sorted, deduped) then learned ones.
    /// Index `i` matches the proof-trace convention in the module docs.
    clauses: Vec<Vec<ILit>>,
    /// Number of original clauses (`formula.clauses.len()`).
    n_orig: usize,
    /// For each literal, the clauses currently watching it. A watched
    /// clause keeps its two watched literals at positions 0 and 1.
    watches: Vec<Vec<usize>>,
    assign: Vec<Option<bool>>,
    /// Decision level at which each variable was assigned.
    level: Vec<u32>,
    /// Clause that propagated each variable, or `NO_REASON` for decisions.
    reason: Vec<usize>,
    trail: Vec<ILit>,
    /// Trail length at each decision; `trail_lim.len()` is the current level.
    trail_lim: Vec<usize>,
    /// Propagation queue head (index into `trail`).
    qhead: usize,
    activity: Vec<f64>,
    var_inc: f64,
    order: VarOrder,
    saved_phase: Vec<bool>,
    /// Conflict-analysis scratch: variables already visited.
    seen: Vec<bool>,
    /// Root-support scratch: level-0 variables whose reasons are needed.
    mark: Vec<bool>,
    trace: Vec<LearnedStep>,
    restarts: u64,
    conflicts_since_restart: u64,
}

impl SatSolver {
    /// Create a solver.
    pub fn new() -> Self {
        Self::default()
    }

    /// Decide satisfiability of the formula (unbounded — always a verdict).
    pub fn solve(&mut self, formula: &CnfFormula) -> SatResult {
        self.run(formula, None, None)
            .expect("an unbounded solve always reaches a verdict")
    }

    /// Decide satisfiability under a conflict budget (DES-019).
    ///
    /// Runs the same deterministic CDCL search as [`SatSolver::solve`] but
    /// abandons it once search conflicts exceed `max_conflicts`, returning
    /// `None` — no verdict reached within budget. Any decided verdict (SAT,
    /// or an UNSAT refuted by propagation/root conflict without exceeding the
    /// budget) is returned as `Some`, identical to `solve`, with the proof
    /// trace intact. A `max_conflicts` of 0 admits only queries decided
    /// before the first search conflict.
    pub fn solve_with_budget(
        &mut self,
        formula: &CnfFormula,
        max_conflicts: u64,
    ) -> Option<SatResult> {
        self.run(formula, Some(max_conflicts), None)
    }

    /// Decide satisfiability under a wall-clock deadline (TR-029, issue #71).
    ///
    /// The consumer-facing twin of [`solve_with_budget`]: a translation
    /// validator degrades a slow solve to a fast, safe revert on a
    /// *millisecond* budget, which a conflict count does not map to. Search is
    /// abandoned once `deadline` passes, returning `None` — no verdict. Any
    /// decided verdict is identical to [`solve`], proof trace intact. The
    /// deadline is checked at the same per-conflict site as the budget, so a
    /// query decided by pure propagation is never abandoned.
    ///
    /// [`solve_with_budget`]: SatSolver::solve_with_budget
    /// [`solve`]: SatSolver::solve
    pub fn solve_with_deadline(
        &mut self,
        formula: &CnfFormula,
        deadline: std::time::Instant,
    ) -> Option<SatResult> {
        self.run(formula, None, Some(deadline))
    }

    /// Shared solve entry: `budget` of `None` is unbounded (never `None`
    /// return), `Some(max)` bounds search conflicts and returns `None` on
    /// exhaustion. On any decided path the behaviour is bit-for-bit that of
    /// the original `solve`, so determinism and the proof-trace hook are
    /// unaffected.
    fn run(
        &mut self,
        formula: &CnfFormula,
        budget: Option<u64>,
        deadline: Option<std::time::Instant>,
    ) -> Option<SatResult> {
        // Tolerate literals above `num_vars` by sizing to whichever is larger.
        let n = formula
            .clauses
            .iter()
            .flatten()
            .map(|l| l.unsigned_abs())
            .max()
            .unwrap_or(0)
            .max(formula.num_vars) as usize;
        self.reset(n);
        if !self.load(formula) {
            return Some(SatResult::Unsat);
        }
        if let Some(confl) = self.propagate() {
            self.record_root_conflict(confl);
            return Some(SatResult::Unsat);
        }
        self.search(budget, deadline)
    }

    /// The resolution proof recorded so far: one step per learned clause,
    /// in derivation order. Non-empty exactly when the last solve returned
    /// `Unsat` after at least one conflict; its final step derives the
    /// empty clause. See the module docs for the index convention.
    pub fn proof_trace(&self) -> &[LearnedStep] {
        &self.trace
    }

    fn reset(&mut self, n: usize) {
        self.clauses.clear();
        self.n_orig = 0;
        self.watches = vec![Vec::new(); 2 * n];
        self.assign = vec![None; n];
        self.level = vec![0; n];
        self.reason = vec![NO_REASON; n];
        self.trail.clear();
        self.trail_lim.clear();
        self.qhead = 0;
        self.activity = vec![0.0; n];
        self.var_inc = 1.0;
        self.order.reset(n);
        self.saved_phase = vec![false; n];
        self.seen = vec![false; n];
        self.mark = vec![false; n];
        self.trace.clear();
        self.restarts = 0;
        self.conflicts_since_restart = 0;
    }

    /// Load the clause database, applying input hygiene. Every input clause
    /// is stored (so DB indices match input indices); tautologies are kept
    /// but never watched, duplicate literals are removed, units are enqueued
    /// at level 0. Returns `false` on an immediate root-level contradiction.
    fn load(&mut self, formula: &CnfFormula) -> bool {
        self.n_orig = formula.clauses.len();
        for input in &formula.clauses {
            let mut lits: Vec<ILit> = input.iter().map(|&l| ilit(l)).collect();
            lits.sort_unstable();
            lits.dedup();
            // After sorting, complementary literals of a variable are adjacent.
            let tautology = lits.windows(2).any(|w| w[0] ^ 1 == w[1]);
            let idx = self.clauses.len();
            self.clauses.push(lits);
            if tautology {
                continue;
            }
            match self.clauses[idx].len() {
                0 => {
                    // The input contains the empty clause: trivially Unsat,
                    // certified by the clause itself.
                    self.trace.push(LearnedStep {
                        clause: Vec::new(),
                        antecedents: vec![idx],
                    });
                    return false;
                }
                1 => {
                    let l = self.clauses[idx][0];
                    match self.lit_value(l) {
                        Some(true) => {}
                        Some(false) => {
                            self.record_root_conflict(idx);
                            return false;
                        }
                        None => self.enqueue(l, idx),
                    }
                }
                _ => {
                    let (w0, w1) = (self.clauses[idx][0], self.clauses[idx][1]);
                    self.watches[w0 as usize].push(idx);
                    self.watches[w1 as usize].push(idx);
                }
            }
        }
        true
    }

    /// The CDCL search loop. `budget` bounds the number of search conflicts:
    /// once it is exceeded the search yields `None` (no verdict), otherwise a
    /// verdict is always returned as `Some`. `None` budget is unbounded.
    fn search(
        &mut self,
        budget: Option<u64>,
        deadline: Option<std::time::Instant>,
    ) -> Option<SatResult> {
        let mut conflicts = 0u64;
        loop {
            if let Some(confl) = self.propagate() {
                if self.trail_lim.is_empty() {
                    self.record_root_conflict(confl);
                    return Some(SatResult::Unsat);
                }
                self.conflicts_since_restart += 1;
                conflicts += 1;
                // Budget check before conflict analysis: on exhaustion we
                // abandon search without touching the proof trace.
                if budget.is_some_and(|max| conflicts > max) {
                    return None;
                }
                // Deadline check at the same site: cheap (vDSO clock read)
                // relative to conflict analysis, and never abandons a query
                // decided by propagation alone.
                if deadline.is_some_and(|d| std::time::Instant::now() >= d) {
                    return None;
                }
                let (learnt, bt_level, antecedents) = self.analyze(confl);
                self.trace.push(LearnedStep {
                    clause: learnt.iter().map(|&l| dimacs(l)).collect(),
                    antecedents,
                });
                self.backtrack(bt_level);
                let ci = self.clauses.len();
                if learnt.len() >= 2 {
                    self.watches[learnt[0] as usize].push(ci);
                    self.watches[learnt[1] as usize].push(ci);
                }
                let asserting = learnt[0];
                self.clauses.push(learnt);
                // The learned clause is asserting after backtracking.
                self.enqueue(asserting, ci);
                self.var_inc /= VAR_DECAY;
            } else if self.conflicts_since_restart >= RESTART_BASE * luby(self.restarts) {
                self.restarts += 1;
                self.conflicts_since_restart = 0;
                self.backtrack(0);
            } else {
                match self.pick_branch() {
                    None => return Some(SatResult::Sat(self.model())),
                    Some(l) => {
                        self.trail_lim.push(self.trail.len());
                        self.enqueue(l, NO_REASON);
                    }
                }
            }
        }
    }

    fn lit_value(&self, l: ILit) -> Option<bool> {
        self.assign[ivar(l)].map(|b| b == (l & 1 == 0))
    }

    fn enqueue(&mut self, l: ILit, reason: usize) {
        let v = ivar(l);
        debug_assert!(self.assign[v].is_none());
        self.assign[v] = Some(l & 1 == 0);
        self.level[v] = self.trail_lim.len() as u32;
        self.reason[v] = reason;
        self.trail.push(l);
    }

    /// Two-watched-literal propagation to fixpoint; returns a conflicting
    /// clause index if one is found.
    fn propagate(&mut self) -> Option<usize> {
        while self.qhead < self.trail.len() {
            let p = self.trail[self.qhead];
            self.qhead += 1;
            let fl = p ^ 1; // the literal that just became false
            let mut ws = core::mem::take(&mut self.watches[fl as usize]);
            let mut i = 0;
            'clauses: while i < ws.len() {
                let ci = ws[i];
                if self.clauses[ci][0] == fl {
                    self.clauses[ci].swap(0, 1);
                }
                debug_assert_eq!(self.clauses[ci][1], fl);
                let first = self.clauses[ci][0];
                if self.lit_value(first) == Some(true) {
                    i += 1;
                    continue;
                }
                // Look for a replacement watch among the tail literals.
                for k in 2..self.clauses[ci].len() {
                    let q = self.clauses[ci][k];
                    if self.lit_value(q) != Some(false) {
                        self.clauses[ci].swap(1, k);
                        self.watches[q as usize].push(ci);
                        ws.swap_remove(i);
                        continue 'clauses;
                    }
                }
                // No replacement: the clause is unit or conflicting.
                if self.lit_value(first) == Some(false) {
                    self.watches[fl as usize] = ws;
                    return Some(ci);
                }
                self.enqueue(first, ci);
                i += 1;
            }
            self.watches[fl as usize] = ws;
        }
        None
    }

    /// First-UIP conflict analysis. Returns the learned clause (asserting
    /// literal at position 0, a deepest remaining literal at position 1),
    /// the backtrack level, and the RUP-ordered antecedents.
    fn analyze(&mut self, confl: usize) -> (Vec<ILit>, usize, Vec<usize>) {
        let cur_level = self.trail_lim.len() as u32;
        let mut learnt: Vec<ILit> = vec![0]; // slot 0 reserved for the UIP
        let mut chain: Vec<usize> = Vec::new(); // reasons, resolution order
        let mut level0_seeds: Vec<usize> = Vec::new();
        let mut to_clear: Vec<usize> = Vec::new();
        let mut counter = 0usize; // unresolved current-level literals
        let mut idx = self.trail.len();
        let mut c = confl;
        let mut skip_first = false;
        let uip;
        loop {
            // Skip position 0 of reason clauses: it is the propagated
            // (pivot) literal, already accounted for.
            let mut k = usize::from(skip_first);
            while k < self.clauses[c].len() {
                let q = self.clauses[c][k];
                k += 1;
                let v = ivar(q);
                if self.seen[v] {
                    continue;
                }
                if self.level[v] == 0 {
                    // Resolved away against a root-level unit; remember it
                    // so the proof chain can include its reason.
                    if !self.mark[v] {
                        self.mark[v] = true;
                        level0_seeds.push(v);
                    }
                    continue;
                }
                self.seen[v] = true;
                to_clear.push(v);
                self.bump(v);
                if self.level[v] == cur_level {
                    counter += 1;
                } else {
                    learnt.push(q);
                }
            }
            // Next pivot: the most recently assigned marked literal.
            loop {
                idx -= 1;
                if self.seen[ivar(self.trail[idx])] {
                    break;
                }
            }
            let pivot = self.trail[idx];
            counter -= 1;
            if counter == 0 {
                uip = pivot;
                break;
            }
            c = self.reason[ivar(pivot)];
            chain.push(c);
            skip_first = true;
        }
        learnt[0] = uip ^ 1;
        for v in to_clear {
            self.seen[v] = false;
        }
        // Backtrack to the second-highest level; keep one literal of that
        // level at position 1 so it can be watched.
        let bt_level = if learnt.len() == 1 {
            0
        } else {
            let mut deepest = 1;
            for k in 2..learnt.len() {
                if self.level[ivar(learnt[k])] > self.level[ivar(learnt[deepest])] {
                    deepest = k;
                }
            }
            learnt.swap(1, deepest);
            self.level[ivar(learnt[1])] as usize
        };
        // RUP order: level-0 support first, then the resolution reasons in
        // trail (reverse-resolution) order, then the conflicting clause.
        let mut antecedents = self.root_support(&level0_seeds);
        chain.reverse();
        antecedents.extend(chain);
        antecedents.push(confl);
        (learnt, bt_level, antecedents)
    }

    /// Reasons of the given level-0 variables plus, transitively, of the
    /// level-0 variables those reasons mention, in assignment order.
    /// Expects `self.mark` set for the seeds; clears it.
    fn root_support(&mut self, seeds: &[usize]) -> Vec<usize> {
        if seeds.is_empty() {
            return Vec::new();
        }
        let root_end = self.trail_lim.first().copied().unwrap_or(self.trail.len());
        let mut support = Vec::new();
        for i in (0..root_end).rev() {
            let v = ivar(self.trail[i]);
            if self.mark[v] {
                self.mark[v] = false;
                // Every level-0 assignment has a clause reason (there are
                // no decisions below level 1).
                let r = self.reason[v];
                debug_assert_ne!(r, NO_REASON);
                support.push(r);
                for k in 1..self.clauses[r].len() {
                    self.mark[ivar(self.clauses[r][k])] = true;
                }
            }
        }
        support.reverse();
        support
    }

    /// A conflict at decision level 0 refutes the formula: record the
    /// derivation of the empty clause (level-0 unit reasons in assignment
    /// order, then the conflicting clause).
    fn record_root_conflict(&mut self, confl: usize) {
        debug_assert!(self.trail_lim.is_empty());
        let mut seeds = Vec::new();
        for k in 0..self.clauses[confl].len() {
            let v = ivar(self.clauses[confl][k]);
            if !self.mark[v] {
                self.mark[v] = true;
                seeds.push(v);
            }
        }
        let mut antecedents = self.root_support(&seeds);
        antecedents.push(confl);
        self.trace.push(LearnedStep {
            clause: Vec::new(),
            antecedents,
        });
    }

    fn backtrack(&mut self, target_level: usize) {
        if self.trail_lim.len() <= target_level {
            return;
        }
        let end = self.trail_lim[target_level];
        for i in (end..self.trail.len()).rev() {
            let l = self.trail[i];
            let v = ivar(l);
            self.saved_phase[v] = l & 1 == 0;
            self.assign[v] = None;
            self.order.insert(&self.activity, v as u32);
        }
        self.trail.truncate(end);
        self.trail_lim.truncate(target_level);
        self.qhead = end;
    }

    fn bump(&mut self, v: usize) {
        self.activity[v] += self.var_inc;
        if self.activity[v] > 1e100 {
            for a in &mut self.activity {
                *a *= 1e-100;
            }
            self.var_inc *= 1e-100;
        }
        self.order.update(&self.activity, v as u32);
    }

    fn pick_branch(&mut self) -> Option<ILit> {
        while let Some(v) = self.order.pop(&self.activity) {
            let v = v as usize;
            if self.assign[v].is_none() {
                return Some(((v as u32) << 1) | u32::from(!self.saved_phase[v]));
            }
        }
        None
    }

    fn model(&self) -> Vec<bool> {
        // `pick_branch` returned `None`, so every variable is assigned.
        self.assign
            .iter()
            .map(|a| a.expect("full assignment at Sat"))
            .collect()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn formula(num_vars: u32, clauses: &[&[i32]]) -> CnfFormula {
        CnfFormula {
            num_vars,
            clauses: clauses.iter().map(|c| c.to_vec()).collect(),
        }
    }

    /// Brute-force satisfiability of a CNF (≤ 20 vars) — test-only oracle.
    fn brute_sat(f: &CnfFormula) -> bool {
        let n = f.num_vars as usize;
        assert!(n <= 20);
        (0u64..1 << n).any(|bits| {
            let assignment: Vec<bool> = (0..n).map(|i| bits >> i & 1 == 1).collect();
            f.eval(&assignment)
        })
    }

    fn assert_sat_with_model(f: &CnfFormula) -> Vec<bool> {
        match SatSolver::new().solve(f) {
            SatResult::Sat(model) => {
                assert!(model.len() >= f.num_vars as usize);
                assert!(f.eval(&model), "returned model must satisfy the formula");
                model
            }
            SatResult::Unsat => panic!("expected Sat"),
        }
    }

    #[test]
    fn empty_formula_is_sat() {
        assert_sat_with_model(&formula(0, &[]));
        // Unconstrained variables still get values.
        let model = assert_sat_with_model(&formula(3, &[]));
        assert_eq!(model.len(), 3);
    }

    #[test]
    fn empty_clause_is_unsat() {
        let f = formula(2, &[&[1, 2], &[], &[-1]]);
        let mut solver = SatSolver::new();
        assert_eq!(solver.solve(&f), SatResult::Unsat);
        // The trace certifies via the empty input clause itself (index 1).
        assert_eq!(solver.proof_trace().len(), 1);
        assert!(solver.proof_trace()[0].clause.is_empty());
        assert_eq!(solver.proof_trace()[0].antecedents, vec![1]);
    }

    #[test]
    fn chained_unit_propagation() {
        // 1, 1→2, 2→3, …, 9→10 forces every variable true.
        let mut clauses: Vec<Vec<i32>> = vec![vec![1]];
        for v in 1..10 {
            clauses.push(vec![-v, v + 1]);
        }
        let f = CnfFormula {
            num_vars: 10,
            clauses,
        };
        let model = assert_sat_with_model(&f);
        assert!(model.iter().all(|&b| b), "the chain forces all-true");

        // Adding ¬10 closes the chain into a root-level contradiction.
        let mut clauses = f.clauses.clone();
        clauses.push(vec![-10]);
        let g = CnfFormula {
            num_vars: 10,
            clauses,
        };
        let mut solver = SatSolver::new();
        assert_eq!(solver.solve(&g), SatResult::Unsat);
        let trace = solver.proof_trace();
        assert!(!trace.is_empty());
        assert!(trace.last().unwrap().clause.is_empty());
    }

    #[test]
    fn tautology_only_formula_is_sat() {
        let f = formula(3, &[&[1, -1], &[2, 3, -3, 2], &[-2, 1, 2, -1]]);
        assert_sat_with_model(&f);
    }

    struct XorShift(u64);

    impl XorShift {
        fn next(&mut self) -> u64 {
            self.0 ^= self.0 << 13;
            self.0 ^= self.0 >> 7;
            self.0 ^= self.0 << 17;
            self.0
        }
    }

    #[test]
    fn random_3sat_agrees_with_brute_force() {
        let mut rng = XorShift(0x9E3779B97F4A7C15);
        let (mut sats, mut unsats) = (0u32, 0u32);
        for round in 0..150 {
            let n = 8 + (rng.next() % 9) as u32; // 8..=16 variables
            let ratio = 3.0 + (rng.next() % 21) as f64 / 10.0; // 3.0..=5.0
            let m = (ratio * f64::from(n)).round() as usize;
            let mut clauses = Vec::with_capacity(m);
            for _ in 0..m {
                let mut lits: Vec<i32> = Vec::with_capacity(3);
                while lits.len() < 3 {
                    let v = 1 + (rng.next() % u64::from(n)) as i32;
                    if lits.iter().any(|l| l.abs() == v) {
                        continue;
                    }
                    lits.push(if rng.next().is_multiple_of(2) { v } else { -v });
                }
                clauses.push(lits);
            }
            let f = CnfFormula {
                num_vars: n,
                clauses,
            };
            let mut solver = SatSolver::new();
            match solver.solve(&f) {
                SatResult::Sat(model) => {
                    assert!(
                        brute_sat(&f),
                        "round {round}: solver said Sat, oracle Unsat"
                    );
                    assert!(f.eval(&model), "round {round}: model must satisfy");
                    sats += 1;
                }
                SatResult::Unsat => {
                    assert!(
                        !brute_sat(&f),
                        "round {round}: solver said Unsat, oracle Sat"
                    );
                    let trace = solver.proof_trace();
                    assert!(!trace.is_empty(), "round {round}: Unsat needs a proof");
                    assert!(trace.last().unwrap().clause.is_empty());
                    unsats += 1;
                }
            }
        }
        // The ratio range straddles the phase transition; both verdicts occur.
        assert!(sats > 0 && unsats > 0, "sats={sats} unsats={unsats}");
    }

    /// PHP(pigeons, holes): every pigeon in some hole, no two share one.
    fn pigeonhole(pigeons: u32, holes: u32) -> CnfFormula {
        let var = |p: u32, h: u32| (p * holes + h + 1) as i32;
        let mut clauses: Vec<Vec<i32>> = Vec::new();
        for p in 0..pigeons {
            clauses.push((0..holes).map(|h| var(p, h)).collect());
        }
        for h in 0..holes {
            for p1 in 0..pigeons {
                for p2 in p1 + 1..pigeons {
                    clauses.push(vec![-var(p1, h), -var(p2, h)]);
                }
            }
        }
        CnfFormula {
            num_vars: pigeons * holes,
            clauses,
        }
    }

    #[test]
    fn pigeonhole_4_3_is_unsat() {
        let f = pigeonhole(4, 3);
        assert_eq!(SatSolver::new().solve(&f), SatResult::Unsat);
        // Sanity: PHP(3,3) is satisfiable.
        assert_sat_with_model(&pigeonhole(3, 3));
    }

    #[test]
    fn budget_matches_unbounded_on_trivially_decidable() {
        // A generous budget must not change a verdict decided within it.
        let sat = formula(3, &[&[1], &[-1, 2], &[-2, 3]]);
        assert_eq!(
            SatSolver::new().solve_with_budget(&sat, 1_000_000),
            Some(SatSolver::new().solve(&sat)),
        );
        let unsat = pigeonhole(4, 3);
        assert_eq!(
            SatSolver::new().solve_with_budget(&unsat, 1_000_000),
            Some(SatResult::Unsat),
        );
    }

    #[test]
    fn budget_exhaustion_returns_none() {
        // PHP(4,3) is UNSAT and needs conflicts; budget 0 admits nothing that
        // requires a search conflict, so it yields None (no verdict).
        let f = pigeonhole(4, 3);
        assert_eq!(SatSolver::new().solve_with_budget(&f, 0), None);
    }

    #[test]
    fn budget_zero_still_decides_pure_propagation() {
        // Decided entirely by unit propagation — no search conflict — so even
        // a zero budget returns a verdict.
        let mut clauses: Vec<Vec<i32>> = vec![vec![1]];
        for v in 1..10 {
            clauses.push(vec![-v, v + 1]);
        }
        let f = CnfFormula {
            num_vars: 10,
            clauses,
        };
        match SatSolver::new().solve_with_budget(&f, 0) {
            Some(SatResult::Sat(model)) => assert!(model.iter().all(|&b| b)),
            other => panic!("expected Sat within a zero budget, got {other:?}"),
        }
    }

    #[test]
    fn unsat_proof_trace_derives_the_empty_clause() {
        let f = pigeonhole(4, 3);
        let mut solver = SatSolver::new();
        assert_eq!(solver.solve(&f), SatResult::Unsat);
        let trace = solver.proof_trace();
        assert!(!trace.is_empty());
        assert!(
            trace.last().unwrap().clause.is_empty(),
            "the final step derives the empty clause"
        );
        // Every step only references clauses that precede it in the
        // combined original+learned sequence (step k has index n_orig + k).
        let n_orig = f.clauses.len();
        for (k, step) in trace.iter().enumerate() {
            assert!(!step.antecedents.is_empty());
            for &a in &step.antecedents {
                assert!(a < n_orig + k, "step {k} references future clause {a}");
            }
        }
    }
}