cdclkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cdclkit/solver.py ADDED
@@ -0,0 +1,1377 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
3
+ """The CDCL core: conflict-driven clause learning with two-watched literals.
4
+
5
+ The architecture is the one established by MiniSat and refined by Glucose and
6
+ CaDiCaL. Every piece is implemented here from scratch; nothing outside the
7
+ Python standard library is used.
8
+
9
+ The main loop is::
10
+
11
+ while True:
12
+ conflict = propagate()
13
+ if conflict:
14
+ if decision_level == 0: return UNSAT
15
+ learnt, backjump_level, lbd = analyze(conflict) # first-UIP
16
+ backtrack(backjump_level)
17
+ record(learnt) # attaches it and emits a DRAT line
18
+ assign(learnt[0], reason=learnt)
19
+ decay_activities()
20
+ else:
21
+ if restart_due(): backtrack(0); continue
22
+ if db_too_large(): reduce_db()
23
+ lit = pick_branch_literal() # VSIDS + saved phase
24
+ if lit is None: return SAT
25
+ new_decision_level(); assign(lit, reason=None)
26
+
27
+ Nontrivial pieces, in the order they matter for performance:
28
+
29
+ *Two-watched literals* (:meth:`Solver._propagate`). A clause is only visited
30
+ when one of its two watched literals becomes false. Watch lists are stored per
31
+ *literal*: ``watches[p]`` holds the clauses that watch ``~p``, so they are
32
+ exactly the clauses that need attention when ``p`` becomes true. Each watcher
33
+ carries a *blocker* -- a second literal of the clause cached inline -- so that a
34
+ clause already satisfied by its blocker is skipped without dereferencing the
35
+ clause object at all.
36
+
37
+ *First-UIP learning* (:meth:`Solver._analyze`). Resolve the conflicting clause
38
+ against the reasons of the current-level literals, newest first, until exactly
39
+ one literal of the current decision level remains. That literal is the first
40
+ unique implication point; its negation becomes the asserting literal of the
41
+ learnt clause. The clause is then minimised by *recursive self-subsumption*
42
+ (:meth:`Solver._lit_redundant`): a literal whose reason's other literals are
43
+ all already in the clause (transitively) is redundant and dropped.
44
+
45
+ *LBD / glue* (:meth:`Solver._lbd`). The number of distinct decision levels in
46
+ a learnt clause predicts its future usefulness far better than its length.
47
+ Clauses with LBD <= 2 are kept forever; the rest are ranked by LBD for
48
+ deletion, and the LBD moving averages drive the restart policy.
49
+
50
+ *Restarts.* Two policies. Luby: the reluctant-doubling sequence, provably
51
+ optimal up to a log factor for heavy-tailed runtimes. Glucose EMA: restart
52
+ when the recent LBD average is much worse than the long-run one, with a
53
+ blocking rule that suppresses the restart when the trail is unusually deep
54
+ (which suggests the current branch is close to a model).
55
+
56
+ Luby is the default, paired with target phases. Glucose EMA was the default
57
+ for most of this project's life on the reasoning that unsatisfiable instances
58
+ dominate the hard cases -- and measurement killed that reasoning. Over 203
59
+ public instances the pair Luby+target went from 1.62x behind kissat to 0.84x
60
+ ahead, and it improved *both* halves: satisfiable 3.25x -> 1.02x, and
61
+ unsatisfiable 0.84x -> 0.72x. The refutation argument was not merely
62
+ outweighed, it was wrong on its own terms.
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import time
68
+ from typing import Iterable, Sequence
69
+
70
+ from dratify.cnf import CNF, Clause
71
+ from .heap import ActivityHeap
72
+ from dratify.lits import F, T, U, from_dimacs, to_dimacs
73
+
74
+ #: probSAT break-count weights, ``(0.9 + brk) ** -2.06``, as literals.
75
+ #:
76
+ #: Written out rather than computed so both engines use *identical bits*.
77
+ #: Python's ``**`` and Rust's ``powf`` each call their platform's ``pow``, and a
78
+ #: one-ULP disagreement would silently flip a comparison in the walk and break
79
+ #: the bit-exactness the two implementations are checked against.
80
+ WALK_WEIGHTS = (
81
+ 1.2423971044693904,
82
+ 0.2665431844564756,
83
+ 0.11154757268716438,
84
+ 0.06059083109242785,
85
+ 0.0378613482436238,
86
+ 0.02582526912626813,
87
+ 0.018705566831429692,
88
+ 0.0141542933420601,
89
+ 0.011072777835612904,
90
+ 0.00889183712824521,
91
+ 0.007292919406638117,
92
+ 0.006086578947728128,
93
+ 0.005154484010820045,
94
+ 0.004419666621745944,
95
+ 0.003830330838618274,
96
+ 0.0033505949060830035,
97
+ 0.0029549721254695433,
98
+ 0.0026249604007938013,
99
+ 0.00234686810854462,
100
+ 0.0021103897194995175,
101
+ 0.001907649796755619,
102
+ 0.001732547393265342,
103
+ 0.001580297694020363,
104
+ 0.0014471059328459816,
105
+ 0.0013299317216792165,
106
+ 0.0012263162579798836,
107
+ 0.0011342539574143936,
108
+ 0.0010520959317115543,
109
+ 0.000978476599632614,
110
+ 0.0009122573099164859,
111
+ 0.0008524826176723598,
112
+ 0.0007983460721352612,
113
+ 0.0007491632244617672,
114
+ )
115
+
116
+ __all__ = ["Solver", "Config", "Stats", "SAT", "UNSAT", "UNKNOWN"]
117
+
118
+ SAT = "SAT"
119
+ UNSAT = "UNSAT"
120
+ UNKNOWN = "UNKNOWN"
121
+
122
+
123
+ # --------------------------------------------------------------------------
124
+ # configuration and statistics
125
+ # --------------------------------------------------------------------------
126
+
127
+
128
+ class Config:
129
+ """Tunable solver parameters. Defaults follow Glucose 4 closely."""
130
+
131
+ __slots__ = (
132
+ "var_decay",
133
+ "var_decay_max",
134
+ "cla_decay",
135
+ "restart",
136
+ "luby_base",
137
+ "ccmin",
138
+ "phase_saving",
139
+ "init_phase",
140
+ "target_phase",
141
+ "target_reset",
142
+ "walk_flips",
143
+ "walk_interval",
144
+ "walk_patience",
145
+ "walk_min_conflicts",
146
+ "first_reduce",
147
+ "reduce_inc",
148
+ "special_inc",
149
+ "glue_keep",
150
+ "block_restart",
151
+ "rnd_freq",
152
+ "rnd_seed",
153
+ "verbosity",
154
+ )
155
+
156
+ def __init__(self, **kw) -> None:
157
+ self.var_decay = 0.8 # ramps up to var_decay_max
158
+ self.var_decay_max = 0.95
159
+ self.cla_decay = 0.999
160
+ self.restart = "luby" # "glucose" | "luby" | "none"
161
+ self.luby_base = 100
162
+ self.ccmin = "deep" # "deep" | "basic" | "none"
163
+ self.phase_saving = True
164
+ self.init_phase = False # first-time polarity for a fresh variable
165
+ # Branch on the assignment from the deepest conflict-free trail ever
166
+ # reached, rather than on the most recently saved one. Saved phases
167
+ # follow wherever the search just was, including into the region a
168
+ # conflict just pushed it out of; the target follows the best place it
169
+ # has ever been.
170
+ #
171
+ # On by default, with `restart="luby"`, because the pair was measured
172
+ # at 1.62x -> 0.84x against kissat over 203 public instances. Neither
173
+ # half is worth much alone (target alone 0.97, luby alone 0.75): a
174
+ # target is only useful if the restart schedule leaves the search long
175
+ # enough to reach it. See docs/ALGORITHMS.md.
176
+ self.target_phase = True
177
+ # Restarts after which the target is forgotten and re-learned. 0 keeps
178
+ # the deepest trail ever seen, for the whole run -- which is CaDiCaL's
179
+ # "best" rather than its "target", and goes stale: once the search has
180
+ # moved region, the assignment being branched from is a memory of
181
+ # somewhere it can no longer get to.
182
+ self.target_reset = 0
183
+ # Local-search rephasing (probSAT). `walk_flips` is the flip budget per
184
+ # invocation, 0 to disable; `walk_interval` is how many restarts apart
185
+ # invocations are.
186
+ #
187
+ # CDCL is weak on satisfiable uniform-random instances and local search
188
+ # is strong on exactly those, which is the measured gap against
189
+ # `kissat --sat`. The walk never decides anything: it proposes an
190
+ # assignment, that assignment becomes the phases the search branches
191
+ # from, and the search still has to find and verify a model itself.
192
+ # So this is proof-neutral like every other phase mechanism here.
193
+ self.walk_flips = 20_000
194
+ self.walk_interval = 1
195
+ # Consecutive walks that fail to reduce the best unsatisfied-clause
196
+ # count before the walk gives up for the rest of the run.
197
+ #
198
+ # On a satisfiable instance probSAT keeps improving until it lands a
199
+ # model. On an unsatisfiable one it plateaus almost at once and every
200
+ # further flip is waste -- measured at 12% slower across uuf250 when
201
+ # walking unconditionally. This is the cheap way to tell the two apart
202
+ # without being told which you have.
203
+ self.walk_patience = 3
204
+ # Conflicts the CDCL search must spend before local search is allowed
205
+ # to start.
206
+ #
207
+ # The walk is a specialised tool -- it is worth 34x on large random
208
+ # satisfiable instances and a loss on everything else, because its
209
+ # fixed cost dominates whenever the instance was going to be solved
210
+ # quickly anyway. Measured against no walk: graph colouring 1.55x
211
+ # slower, planning 1.68x, small random unsatisfiable 5.2x. Gating on
212
+ # effort already spent asks "is CDCL losing?" rather than "does this
213
+ # look like a random instance?", which is the question that generalises.
214
+ self.walk_min_conflicts = 5000
215
+ self.first_reduce = 2000
216
+ self.reduce_inc = 300
217
+ self.special_inc = 1000
218
+ self.glue_keep = 2 # LBD <= glue_keep is never deleted
219
+ self.block_restart = True
220
+ self.rnd_freq = 0.0
221
+ self.rnd_seed = 91648253
222
+ self.verbosity = 0
223
+ for k, v in kw.items():
224
+ if k not in Config.__slots__:
225
+ raise TypeError(f"unknown config option {k!r}")
226
+ setattr(self, k, v)
227
+
228
+ def as_dict(self) -> dict:
229
+ return {k: getattr(self, k) for k in Config.__slots__}
230
+
231
+
232
+ class Stats:
233
+ """Counters. Cheap to maintain, invaluable for judging a heuristic."""
234
+
235
+ __slots__ = (
236
+ "decisions",
237
+ "propagations",
238
+ "conflicts",
239
+ "learned",
240
+ "learned_lits",
241
+ "minimized_lits",
242
+ "restarts",
243
+ "blocked_restarts",
244
+ "walks",
245
+ "walk_flips",
246
+ "reductions",
247
+ "deleted",
248
+ "simplifications",
249
+ "removed_clauses",
250
+ "removed_lits",
251
+ "start_time",
252
+ "solve_time",
253
+ "max_trail",
254
+ )
255
+
256
+ def __init__(self) -> None:
257
+ for k in Stats.__slots__:
258
+ setattr(self, k, 0)
259
+ self.start_time = time.perf_counter()
260
+ self.solve_time = 0.0
261
+
262
+ def as_dict(self) -> dict:
263
+ return {k: getattr(self, k) for k in Stats.__slots__ if k != "start_time"}
264
+
265
+ def report(self) -> str:
266
+ d = self.as_dict()
267
+ t = max(d["solve_time"], 1e-9)
268
+ lines = [
269
+ f"c conflicts : {d['conflicts']:>12} ({d['conflicts']/t:>10.0f} /sec)",
270
+ f"c decisions : {d['decisions']:>12} ({d['decisions']/t:>10.0f} /sec)",
271
+ f"c propagations : {d['propagations']:>12} ({d['propagations']/t:>10.0f} /sec)",
272
+ f"c learned : {d['learned']:>12} "
273
+ f"(avg len {d['learned_lits']/max(d['learned'],1):.1f}, "
274
+ f"{d['minimized_lits']} lits minimised away)",
275
+ f"c restarts : {d['restarts']:>12} ({d['blocked_restarts']} blocked)",
276
+ f"c db reductions: {d['reductions']:>12} ({d['deleted']} clauses deleted)",
277
+ f"c simplify : {d['simplifications']:>12} "
278
+ f"({d['removed_clauses']} clauses, {d['removed_lits']} lits removed)",
279
+ f"c cpu time : {d['solve_time']:>12.3f} s",
280
+ ]
281
+ return "\n".join(lines)
282
+
283
+
284
+ # --------------------------------------------------------------------------
285
+ # restart schedules
286
+ # --------------------------------------------------------------------------
287
+
288
+
289
+ def luby(y: float, x: int) -> float:
290
+ """The Luby reluctant-doubling sequence 1,1,2,1,1,2,4,... scaled by ``y``.
291
+
292
+ Finds the finite subsequence containing index ``x`` and computes its value
293
+ in O(log x) without materialising the sequence.
294
+ """
295
+ size = 1
296
+ seq = 0
297
+ while size < x + 1:
298
+ seq += 1
299
+ size = 2 * size + 1
300
+ while size - 1 != x:
301
+ size = (size - 1) >> 1
302
+ seq -= 1
303
+ x = x % size
304
+ return y**seq
305
+
306
+
307
+ class _EMA:
308
+ """Exponential moving average with bias correction for the warm-up phase."""
309
+
310
+ __slots__ = ("value", "alpha", "beta", "wait", "period")
311
+
312
+ def __init__(self, alpha: float) -> None:
313
+ self.value = 0.0
314
+ self.alpha = alpha
315
+ self.beta = 1.0 # starts fast, decays to alpha (Biere's smoothing)
316
+ self.wait = self.period = 0
317
+
318
+ def update(self, x: float) -> None:
319
+ self.value += self.beta * (x - self.value)
320
+ if self.beta > self.alpha and self.wait == 0:
321
+ self.beta *= 0.5
322
+ self.period = 2 * self.period + 1
323
+ self.wait = self.period
324
+ elif self.wait:
325
+ self.wait -= 1
326
+
327
+
328
+ # --------------------------------------------------------------------------
329
+ # the solver
330
+ # --------------------------------------------------------------------------
331
+
332
+
333
+ class Solver:
334
+ """A conflict-driven clause-learning SAT solver.
335
+
336
+ Typical use::
337
+
338
+ s = Solver()
339
+ a, b = s.new_var(), s.new_var()
340
+ s.add_clause([mk_lit(a), mk_lit(b)])
341
+ if s.solve():
342
+ print(s.model)
343
+
344
+ The solver is *incremental*: after a call to :meth:`solve` more clauses may
345
+ be added and :meth:`solve` called again, optionally under assumptions.
346
+ When solving under assumptions returns UNSAT, :attr:`conflict` holds the
347
+ subset of assumptions responsible -- an unsatisfiable core.
348
+ """
349
+
350
+ def __init__(
351
+ self,
352
+ nvars: int = 0,
353
+ proof=None,
354
+ config: Config | None = None,
355
+ ) -> None:
356
+ self.cfg = config or Config()
357
+ self.stats = Stats()
358
+ self.proof = proof
359
+
360
+ # -- assignment state
361
+ self.nvars = 0
362
+ self.val = bytearray() # per literal: U / T / F
363
+ self.level: list[int] = [] # per variable
364
+ self.reason: list[Clause | None] = []
365
+ self.trail: list[int] = []
366
+ self.trail_lim: list[int] = []
367
+ self.qhead = 0
368
+
369
+ # -- clause database
370
+ self.watches: list[list] = []
371
+ self.clauses: list[Clause] = []
372
+ self.learnts: list[Clause] = []
373
+
374
+ # -- heuristics
375
+ self.act: list[float] = []
376
+ self.var_inc = 1.0
377
+ self.cla_inc = 1.0
378
+ self.order = ActivityHeap(self.act)
379
+ self.polarity = bytearray()
380
+ #: phases from the deepest conflict-free trail seen so far, and that
381
+ #: depth. Only meaningful when cfg.target_phase is on.
382
+ self._target = bytearray()
383
+ self._target_size = 0
384
+ self._walk_best = 1 << 30
385
+ self._walk_stale = 0
386
+ self.frozen = bytearray() # variables excluded from decisions
387
+
388
+ # -- scratch
389
+ self.seen = bytearray()
390
+ self._analyze_toclear: list[int] = []
391
+ self._lbd_stamp: list[int] = []
392
+ self._lbd_gen = 0
393
+
394
+ # -- control
395
+ self.ok = True
396
+ self.assumptions: list[int] = []
397
+ self.conflict: list[int] = [] # final core, in assumption polarity
398
+ self.model: list[bool] = []
399
+ self._rnd = self.cfg.rnd_seed
400
+
401
+ # -- restart / reduce bookkeeping
402
+ self._lbd_fast = _EMA(1.0 / 50)
403
+ self._lbd_slow = _EMA(1.0 / 5000)
404
+ self._trail_ema = _EMA(1.0 / 5000)
405
+ self._next_reduce = self.cfg.first_reduce
406
+ self._reduce_count = 0
407
+ self._restart_index = 0
408
+ self._conflicts_at_restart = 0
409
+ self._simp_props = 0
410
+ self._var_decay = self.cfg.var_decay
411
+
412
+ for _ in range(nvars):
413
+ self.new_var()
414
+
415
+ # ----------------------------------------------------------------- vars
416
+
417
+ def new_var(self, polarity: bool | None = None, decision: bool = True) -> int:
418
+ """Allocate a fresh variable, returning its 0-based index."""
419
+ v = self.nvars
420
+ self.nvars += 1
421
+ self.val.extend((U, U))
422
+ self.level.append(0)
423
+ self.reason.append(None)
424
+ self.watches.append([])
425
+ self.watches.append([])
426
+ self.act.append(0.0)
427
+ self._target.append(0)
428
+ self.polarity.append(
429
+ 1 if (self.cfg.init_phase if polarity is None else polarity) else 0
430
+ )
431
+ self.frozen.append(0 if decision else 1)
432
+ self.seen.append(0)
433
+ self._lbd_stamp.append(0)
434
+ self.order.grow(self.nvars)
435
+ if decision:
436
+ self.order.insert(v)
437
+ return v
438
+
439
+ def ensure_vars(self, n: int) -> None:
440
+ while self.nvars < n:
441
+ self.new_var()
442
+
443
+ def set_decision(self, v: int, on: bool) -> None:
444
+ self.frozen[v] = 0 if on else 1
445
+ if on:
446
+ self.order.insert(v)
447
+ else:
448
+ self.order.remove(v)
449
+
450
+ # ------------------------------------------------------------- accessors
451
+
452
+ def value(self, lit: int) -> int:
453
+ return self.val[lit]
454
+
455
+ def var_value(self, v: int) -> int:
456
+ return self.val[v << 1]
457
+
458
+ @property
459
+ def decision_level(self) -> int:
460
+ return len(self.trail_lim)
461
+
462
+ def n_assigns(self) -> int:
463
+ return len(self.trail)
464
+
465
+ def _lit_level(self, lit: int) -> int:
466
+ return self.level[lit >> 1]
467
+
468
+ # ------------------------------------------------------------ clause add
469
+
470
+ def add_clause(self, lits: Iterable[int]) -> bool:
471
+ """Add a permanent clause. Returns False when the formula became UNSAT.
472
+
473
+ Must be called at decision level 0 (the caller may be mid-incremental
474
+ use; we backtrack for them). Literals are deduplicated, tautologies
475
+ are dropped, and literals already false at level 0 are removed -- the
476
+ latter is a strengthening step, so it is logged to the proof.
477
+ """
478
+ if not self.ok:
479
+ return False
480
+ if self.decision_level != 0:
481
+ self._cancel_until(0)
482
+
483
+ seen: set[int] = set()
484
+ out: list[int] = []
485
+ original = []
486
+ for l in lits:
487
+ original.append(l)
488
+ if l >= len(self.val):
489
+ self.ensure_vars((l >> 1) + 1)
490
+ if l in seen:
491
+ continue
492
+ if (l ^ 1) in seen:
493
+ return True # tautology: nothing to add
494
+ seen.add(l)
495
+ v = self.val[l]
496
+ if v == T and self.level[l >> 1] == 0:
497
+ return True # already satisfied at root
498
+ if v == F and self.level[l >> 1] == 0:
499
+ continue # root-false literal: drop it
500
+ out.append(l)
501
+
502
+ if self.proof is not None and len(out) != len(seen):
503
+ # We are adding a strengthened version of the user's clause. It is
504
+ # RUP (unit propagation on the root units derives it), so a plain
505
+ # addition line is a valid DRAT step.
506
+ self.proof.add(out)
507
+
508
+ if not out:
509
+ self.ok = False
510
+ if self.proof is not None:
511
+ self.proof.add([])
512
+ return False
513
+ if len(out) == 1:
514
+ self._assign(out[0], None)
515
+ if self._propagate() is not None:
516
+ self.ok = False
517
+ if self.proof is not None:
518
+ self.proof.add([])
519
+ return False
520
+ return True
521
+
522
+ c = Clause(out, learnt=False)
523
+ self.clauses.append(c)
524
+ self._attach(c)
525
+ return True
526
+
527
+ def add_clause_dimacs(self, dimacs: Iterable[int]) -> bool:
528
+ return self.add_clause(from_dimacs(d) for d in dimacs)
529
+
530
+ def add_cnf(self, f: CNF) -> bool:
531
+ """Load an entire :class:`~cdclkit.cnf.CNF`."""
532
+ self.ensure_vars(f.nvars)
533
+ for c in f.clauses:
534
+ if not self.add_clause(c):
535
+ return False
536
+ return True
537
+
538
+ # ----------------------------------------------------------- attach/detach
539
+
540
+ def _attach(self, c: Clause) -> None:
541
+ lits = c.lits
542
+ self.watches[lits[0] ^ 1].append([c, lits[1]])
543
+ self.watches[lits[1] ^ 1].append([c, lits[0]])
544
+
545
+ def _detach(self, c: Clause) -> None:
546
+ lits = c.lits
547
+ for a, b in ((lits[0], lits[1]), (lits[1], lits[0])):
548
+ ws = self.watches[a ^ 1]
549
+ for i, w in enumerate(ws):
550
+ if w[0] is c:
551
+ del ws[i]
552
+ break
553
+
554
+ def _remove_clause(self, c: Clause, log: bool = True) -> None:
555
+ self._detach(c)
556
+ if log and self.proof is not None:
557
+ self.proof.delete(c.lits)
558
+ if self._locked(c):
559
+ self.reason[c.lits[0] >> 1] = None
560
+ c.deleted = True
561
+
562
+ def _locked(self, c: Clause) -> bool:
563
+ l0 = c.lits[0]
564
+ return self.val[l0] == T and self.reason[l0 >> 1] is c
565
+
566
+ # ------------------------------------------------------------- assignment
567
+
568
+ def _assign(self, lit: int, reason: Clause | None) -> None:
569
+ v = lit >> 1
570
+ self.val[lit] = T
571
+ self.val[lit ^ 1] = F
572
+ self.level[v] = len(self.trail_lim)
573
+ self.reason[v] = reason
574
+ self.trail.append(lit)
575
+
576
+ def _cancel_until(self, level: int) -> None:
577
+ if len(self.trail_lim) <= level:
578
+ return
579
+ bound = self.trail_lim[level]
580
+ trail = self.trail
581
+ val = self.val
582
+ order = self.order
583
+ save = self.cfg.phase_saving
584
+ for i in range(len(trail) - 1, bound - 1, -1):
585
+ lit = trail[i]
586
+ v = lit >> 1
587
+ val[lit] = U
588
+ val[lit ^ 1] = U
589
+ self.reason[v] = None
590
+ if save:
591
+ self.polarity[v] = 0 if (lit & 1) else 1
592
+ if not self.frozen[v]:
593
+ order.insert(v)
594
+ del trail[bound:]
595
+ del self.trail_lim[level:]
596
+ self.qhead = len(trail)
597
+
598
+ # ------------------------------------------------------------ propagation
599
+
600
+ def _propagate(self) -> Clause | None:
601
+ """Unit-propagate to fixpoint; return a conflicting clause or None."""
602
+ val = self.val
603
+ watches = self.watches
604
+ trail = self.trail
605
+ confl: Clause | None = None
606
+ props = 0
607
+
608
+ while self.qhead < len(trail):
609
+ p = trail[self.qhead]
610
+ self.qhead += 1
611
+ props += 1
612
+ false_lit = p ^ 1
613
+ ws = watches[p]
614
+ i = j = 0
615
+ n = len(ws)
616
+ while i < n:
617
+ w = ws[i]
618
+ blocker = w[1]
619
+ if val[blocker] == T:
620
+ ws[j] = w
621
+ i += 1
622
+ j += 1
623
+ continue
624
+ c = w[0]
625
+ lits = c.lits
626
+ # normalise: the false literal sits at index 1
627
+ if lits[0] == false_lit:
628
+ lits[0] = lits[1]
629
+ lits[1] = false_lit
630
+ first = lits[0]
631
+ if first != blocker and val[first] == T:
632
+ w[1] = first
633
+ ws[j] = w
634
+ i += 1
635
+ j += 1
636
+ continue
637
+ # look for a replacement watch among lits[2:]
638
+ found = False
639
+ for k in range(2, len(lits)):
640
+ lk = lits[k]
641
+ if val[lk] != F:
642
+ lits[1] = lk
643
+ lits[k] = false_lit
644
+ watches[lk ^ 1].append([c, first])
645
+ found = True
646
+ break
647
+ if found:
648
+ i += 1
649
+ continue
650
+ # no replacement: the clause is unit or conflicting
651
+ ws[j] = w
652
+ i += 1
653
+ j += 1
654
+ if val[first] == F:
655
+ confl = c
656
+ self.qhead = len(trail)
657
+ while i < n:
658
+ ws[j] = ws[i]
659
+ i += 1
660
+ j += 1
661
+ break
662
+ self._assign(first, c)
663
+ del ws[j:]
664
+ if confl is not None:
665
+ break
666
+
667
+ self.stats.propagations += props
668
+ if len(trail) > self.stats.max_trail:
669
+ self.stats.max_trail = len(trail)
670
+ return confl
671
+
672
+ # -------------------------------------------------------------- activity
673
+
674
+ def _bump_var(self, v: int) -> None:
675
+ act = self.act
676
+ a = act[v] + self.var_inc
677
+ act[v] = a
678
+ if a > 1e100:
679
+ for i in range(self.nvars):
680
+ act[i] *= 1e-100
681
+ self.var_inc *= 1e-100
682
+ self.order.bump(v)
683
+
684
+ def _decay_var(self) -> None:
685
+ self.var_inc /= self._var_decay
686
+
687
+ def _bump_clause(self, c: Clause) -> None:
688
+ c.act += self.cla_inc
689
+ if c.act > 1e20:
690
+ for d in self.learnts:
691
+ d.act *= 1e-20
692
+ self.cla_inc *= 1e-20
693
+
694
+ def _decay_clause(self) -> None:
695
+ self.cla_inc /= self.cfg.cla_decay
696
+
697
+ # ---------------------------------------------------------------- analyze
698
+
699
+ def _abstract_level(self, v: int) -> int:
700
+ return 1 << (self.level[v] & 31)
701
+
702
+ def _lbd(self, lits: Sequence[int]) -> int:
703
+ """Literal Block Distance: number of distinct decision levels."""
704
+ self._lbd_gen += 1
705
+ gen = self._lbd_gen
706
+ stamp = self._lbd_stamp
707
+ level = self.level
708
+ n = 0
709
+ for l in lits:
710
+ lv = level[l >> 1]
711
+ if stamp[lv] != gen:
712
+ stamp[lv] = gen
713
+ n += 1
714
+ return n
715
+
716
+ def _analyze(self, confl: Clause) -> tuple[list[int], int, int]:
717
+ """First-UIP conflict analysis.
718
+
719
+ Returns ``(learnt, backjump_level, lbd)`` where ``learnt[0]`` is the
720
+ asserting literal and ``learnt[1]`` (if any) sits at the backjump
721
+ level, so the clause propagates immediately after backtracking.
722
+ """
723
+ seen = self.seen
724
+ level = self.level
725
+ trail = self.trail
726
+ cur = len(self.trail_lim)
727
+
728
+ learnt: list[int] = [0] # slot 0 reserved for the asserting literal
729
+ counter = 0
730
+ p = -1
731
+ index = len(trail) - 1
732
+
733
+ while True:
734
+ c = confl
735
+ if c.learnt:
736
+ self._bump_clause(c)
737
+ if c.lbd > 2:
738
+ # Glucose's on-the-fly LBD update: a clause whose LBD drops
739
+ # is more useful than its recorded score suggests.
740
+ nl = self._lbd(c.lits)
741
+ if nl < c.lbd:
742
+ c.lbd = nl
743
+ lits = c.lits
744
+ for k in range(0 if p < 0 else 1, len(lits)):
745
+ q = lits[k]
746
+ v = q >> 1
747
+ if not seen[v] and level[v] > 0:
748
+ seen[v] = 1
749
+ self._bump_var(v)
750
+ if level[v] >= cur:
751
+ counter += 1
752
+ else:
753
+ learnt.append(q)
754
+ while not seen[trail[index] >> 1]:
755
+ index -= 1
756
+ p = trail[index]
757
+ index -= 1
758
+ v = p >> 1
759
+ confl = self.reason[v]
760
+ seen[v] = 0
761
+ counter -= 1
762
+ if counter <= 0:
763
+ break
764
+
765
+ learnt[0] = p ^ 1
766
+ raw_len = len(learnt)
767
+
768
+ # -- clause minimisation ------------------------------------------
769
+ self._analyze_toclear = learnt[:]
770
+ mode = self.cfg.ccmin
771
+ if mode == "deep":
772
+ abstract = 0
773
+ for l in learnt[1:]:
774
+ abstract |= self._abstract_level(l >> 1)
775
+ keep = [learnt[0]]
776
+ for l in learnt[1:]:
777
+ if self.reason[l >> 1] is None or not self._lit_redundant(l, abstract):
778
+ keep.append(l)
779
+ learnt = keep
780
+ elif mode == "basic":
781
+ keep = [learnt[0]]
782
+ for l in learnt[1:]:
783
+ r = self.reason[l >> 1]
784
+ if r is None:
785
+ keep.append(l)
786
+ continue
787
+ for q in r.lits[1:]:
788
+ if not seen[q >> 1] and level[q >> 1] > 0:
789
+ keep.append(l)
790
+ break
791
+ learnt = keep
792
+
793
+ self.stats.minimized_lits += raw_len - len(learnt)
794
+
795
+ # -- backjump level -------------------------------------------------
796
+ if len(learnt) == 1:
797
+ bt = 0
798
+ else:
799
+ best = 1
800
+ best_lvl = level[learnt[1] >> 1]
801
+ for i in range(2, len(learnt)):
802
+ lv = level[learnt[i] >> 1]
803
+ if lv > best_lvl:
804
+ best_lvl = lv
805
+ best = i
806
+ learnt[1], learnt[best] = learnt[best], learnt[1]
807
+ bt = best_lvl
808
+
809
+ lbd = self._lbd(learnt)
810
+ for l in self._analyze_toclear:
811
+ seen[l >> 1] = 0
812
+ self._analyze_toclear = []
813
+ return learnt, bt, lbd
814
+
815
+ def _lit_redundant(self, p: int, abstract_levels: int) -> bool:
816
+ """True when ``p`` is implied by the other literals of the learnt clause.
817
+
818
+ Depth-first walk of the implication graph backwards from ``p``. The
819
+ walk succeeds when every reachable antecedent literal is either already
820
+ in the clause (``seen``) or root-level. ``abstract_levels`` is a
821
+ 64-bit-style bloom filter over decision levels: a literal whose level
822
+ is not represented in the learnt clause can never be redundant, and the
823
+ filter rejects it without touching its reason clause.
824
+ """
825
+ seen = self.seen
826
+ level = self.level
827
+ reason = self.reason
828
+ stack = [p]
829
+ top = len(self._analyze_toclear)
830
+ while stack:
831
+ q = stack.pop()
832
+ c = reason[q >> 1]
833
+ if c is None: # decision literal: not redundant
834
+ for l in self._analyze_toclear[top:]:
835
+ seen[l >> 1] = 0
836
+ del self._analyze_toclear[top:]
837
+ return False
838
+ for l in c.lits[1:]:
839
+ v = l >> 1
840
+ if seen[v] or level[v] == 0:
841
+ continue
842
+ if reason[v] is not None and (self._abstract_level(v) & abstract_levels):
843
+ seen[v] = 1
844
+ stack.append(l)
845
+ self._analyze_toclear.append(l)
846
+ else:
847
+ for m in self._analyze_toclear[top:]:
848
+ seen[m >> 1] = 0
849
+ del self._analyze_toclear[top:]
850
+ return False
851
+ return True
852
+
853
+ def _analyze_final(self, p: int) -> list[int]:
854
+ """Build the assumption core explaining why literal ``p`` cannot hold.
855
+
856
+ Walks the trail backwards from the top, collecting the decision (i.e.
857
+ assumption) literals that reach ``p``. The result is returned in
858
+ *assumption polarity*: the literals the caller passed in.
859
+ """
860
+ out = [p]
861
+ if not self.trail_lim:
862
+ # The assumption is contradicted at root level, so it alone is the
863
+ # core. Note the flip: `out` is accumulated in conflict-clause
864
+ # polarity (negated assumptions) and converted on the way out, so
865
+ # this early return has to convert too.
866
+ return [p ^ 1]
867
+ seen = self.seen
868
+ seen[p >> 1] = 1
869
+ for i in range(len(self.trail) - 1, self.trail_lim[0] - 1, -1):
870
+ v = self.trail[i] >> 1
871
+ if not seen[v]:
872
+ continue
873
+ r = self.reason[v]
874
+ if r is None:
875
+ if self.level[v] > 0:
876
+ out.append(self.trail[i] ^ 1)
877
+ else:
878
+ for l in r.lits[1:]:
879
+ if self.level[l >> 1] > 0:
880
+ seen[l >> 1] = 1
881
+ seen[v] = 0
882
+ seen[p >> 1] = 0
883
+ return [l ^ 1 for l in out]
884
+
885
+ # ------------------------------------------------------------- db control
886
+
887
+ def _record(self, learnt: list[int], lbd: int) -> Clause | None:
888
+ """Attach a learnt clause and log it to the proof."""
889
+ self.stats.learned += 1
890
+ self.stats.learned_lits += len(learnt)
891
+ if self.proof is not None:
892
+ self.proof.add(learnt)
893
+ if len(learnt) == 1:
894
+ return None
895
+ c = Clause(learnt, learnt=True, lbd=lbd)
896
+ c.act = self.cla_inc
897
+ self.learnts.append(c)
898
+ self._attach(c)
899
+ return c
900
+
901
+ def _reduce_db(self) -> None:
902
+ """Delete the least useful half of the learnt clauses.
903
+
904
+ Ranking is LBD first (lower is better), clause activity second. Glue
905
+ clauses (LBD <= ``glue_keep``), binaries and clauses that are currently
906
+ the reason for an assignment are exempt.
907
+ """
908
+ self.stats.reductions += 1
909
+ learnts = self.learnts
910
+ keep_lbd = self.cfg.glue_keep
911
+ candidates = [
912
+ c
913
+ for c in learnts
914
+ if not c.deleted and c.lbd > keep_lbd and len(c.lits) > 2 and not self._locked(c)
915
+ ]
916
+ candidates.sort(key=lambda c: (-c.lbd, c.act))
917
+ limit = len(candidates) // 2
918
+ removed = 0
919
+ for c in candidates[:limit]:
920
+ self._remove_clause(c)
921
+ removed += 1
922
+ self.learnts = [c for c in learnts if not c.deleted]
923
+ self.stats.deleted += removed
924
+
925
+ def _simplify(self) -> bool:
926
+ """Root-level simplification: drop satisfied clauses, shrink the rest.
927
+
928
+ Only worth running when new root-level units exist, hence the
929
+ propagation counter guard. Every strengthened clause is emitted to the
930
+ proof as an addition followed by the deletion of the original, which is
931
+ exactly what a DRAT checker expects.
932
+ """
933
+ assert self.decision_level == 0
934
+ if self._propagate() is not None:
935
+ self.ok = False
936
+ if self.proof is not None:
937
+ self.proof.add([])
938
+ return False
939
+ self.stats.simplifications += 1
940
+ val = self.val
941
+ for lst in (self.learnts, self.clauses):
942
+ out = []
943
+ for c in lst:
944
+ if c.deleted:
945
+ continue
946
+ if self._locked(c):
947
+ out.append(c)
948
+ continue
949
+ sat = False
950
+ nfalse = 0
951
+ for l in c.lits:
952
+ if val[l] == T and self.level[l >> 1] == 0:
953
+ sat = True
954
+ break
955
+ if val[l] == F and self.level[l >> 1] == 0:
956
+ nfalse += 1
957
+ if sat:
958
+ self._remove_clause(c)
959
+ self.stats.removed_clauses += 1
960
+ continue
961
+ if nfalse:
962
+ survivors = [
963
+ l for l in c.lits if not (val[l] == F and self.level[l >> 1] == 0)
964
+ ]
965
+ self.stats.removed_lits += nfalse
966
+ if self.proof is not None:
967
+ self.proof.add(survivors)
968
+ self._detach(c)
969
+ if self.proof is not None:
970
+ self.proof.delete(c.lits)
971
+ if len(survivors) == 1:
972
+ c.deleted = True
973
+ if val[survivors[0]] == U:
974
+ self._assign(survivors[0], None)
975
+ elif val[survivors[0]] == F:
976
+ self.ok = False
977
+ if self.proof is not None:
978
+ self.proof.add([])
979
+ return False
980
+ continue
981
+ c.lits = survivors
982
+ self._attach(c)
983
+ out.append(c)
984
+ lst[:] = out
985
+ if self._propagate() is not None:
986
+ self.ok = False
987
+ if self.proof is not None:
988
+ self.proof.add([])
989
+ return False
990
+ self._simp_props = self.stats.propagations
991
+ return True
992
+
993
+ # ----------------------------------------------------------- decisions
994
+
995
+ def _rand(self) -> float:
996
+ # xorshift32, so that runs are reproducible across platforms and
997
+ # independent of the global `random` module's state.
998
+ x = self._rnd
999
+ x ^= (x << 13) & 0xFFFFFFFF
1000
+ x ^= x >> 17
1001
+ x ^= (x << 5) & 0xFFFFFFFF
1002
+ self._rnd = x & 0xFFFFFFFF
1003
+ return self._rnd / 4294967296.0
1004
+
1005
+ def _pick_branch_lit(self) -> int:
1006
+ """Return a decision literal, or -1 when every variable is assigned."""
1007
+ order = self.order
1008
+ val = self.val
1009
+ # `phase` is whichever array the configuration says to branch from.
1010
+ # Bound once rather than tested per decision: this is the hottest loop
1011
+ # in the solver after propagation.
1012
+ phase = self._target if self.cfg.target_phase else self.polarity
1013
+ if self.cfg.rnd_freq > 0.0 and self._rand() < self.cfg.rnd_freq and len(order):
1014
+ v = order.heap[int(self._rand() * len(order))]
1015
+ if val[v << 1] == U and not self.frozen[v]:
1016
+ self.stats.decisions += 1
1017
+ return (v << 1) | (0 if phase[v] else 1)
1018
+ while True:
1019
+ if order.empty():
1020
+ return -1
1021
+ v = order.pop_max()
1022
+ if val[v << 1] == U and not self.frozen[v]:
1023
+ self.stats.decisions += 1
1024
+ return (v << 1) | (0 if phase[v] else 1)
1025
+
1026
+
1027
+ # ------------------------------------------------------------ local search
1028
+
1029
+ def _walk_occurrences(self):
1030
+ """Occurrence lists over the *original* clauses, rebuilt every call.
1031
+
1032
+ Deliberately not cached. Root simplification deletes and strengthens
1033
+ original clauses while the search runs, so a cached index goes stale --
1034
+ and it went stale *differently* in the two engines, which showed up as
1035
+ the walk diverging on exactly the instances that run long enough to
1036
+ simplify. Rebuilding is O(total literals), which is nothing beside the
1037
+ thousands of flips that follow it.
1038
+
1039
+ Learnt clauses are excluded on purpose. They are implied by the
1040
+ originals, so satisfying the originals satisfies them too, and
1041
+ including them would make every flip cost more while the clause set
1042
+ churns underneath a cached index.
1043
+ """
1044
+ clauses = [c.lits for c in self.clauses if not c.deleted and len(c.lits) > 1]
1045
+ occ: list[list[int]] = [[] for _ in range(2 * self.nvars)]
1046
+ for i, lits in enumerate(clauses):
1047
+ for l in lits:
1048
+ occ[l].append(i)
1049
+ return clauses, occ
1050
+
1051
+ def _walk(self, max_flips: int) -> None:
1052
+ """probSAT over the original clauses; the best assignment becomes phases.
1053
+
1054
+ Balint and Schoening's probSAT: repeatedly pick an unsatisfied clause at
1055
+ random and flip one of its variables, choosing the variable with
1056
+ probability falling off polynomially in its *break count* -- how many
1057
+ currently-satisfied clauses the flip would break. No tabu list, no
1058
+ greedy tie-breaking, no restarts of its own; the whole heuristic is that
1059
+ one probability.
1060
+
1061
+ Deterministic: it draws from the solver's own xorshift32, so a run is
1062
+ reproducible and the Rust port reproduces it flip for flip.
1063
+ """
1064
+ clauses, occ = self._walk_occurrences()
1065
+ if not clauses:
1066
+ return
1067
+
1068
+ # start from the phases the search would otherwise branch on
1069
+ src = self._target if self.cfg.target_phase else self.polarity
1070
+ assign = bytearray(src)
1071
+ # variables fixed at level 0 are not the walk's to move
1072
+ val = self.val
1073
+ for v in range(self.nvars):
1074
+ if val[v << 1] == T and self.level[v] == 0:
1075
+ assign[v] = 1
1076
+ elif val[(v << 1) ^ 1] == T and self.level[v] == 0:
1077
+ assign[v] = 0
1078
+
1079
+ def sat_count(lits):
1080
+ return sum(1 for l in lits if assign[l >> 1] == (0 if (l & 1) else 1))
1081
+
1082
+ ntrue = [sat_count(lits) for lits in clauses]
1083
+ unsat = [i for i, n in enumerate(ntrue) if n == 0]
1084
+ where = {c: k for k, c in enumerate(unsat)}
1085
+
1086
+ best_unsat = len(unsat)
1087
+ best = bytearray(assign)
1088
+
1089
+ for _ in range(max_flips):
1090
+ if not unsat:
1091
+ break
1092
+ lits = clauses[unsat[int(self._rand() * len(unsat))]]
1093
+
1094
+ # break count: clauses this flip would take from 1 true literal to 0
1095
+ weights, total = [], 0.0
1096
+ for l in lits:
1097
+ v = l >> 1
1098
+ cur = (v << 1) | (0 if assign[v] else 1) # literal true now
1099
+ brk = sum(1 for ci in occ[cur] if ntrue[ci] == 1)
1100
+ w = WALK_WEIGHTS[brk if brk < len(WALK_WEIGHTS) else -1]
1101
+ weights.append((v, w))
1102
+ total += w
1103
+
1104
+ r = self._rand() * total
1105
+ flip = weights[-1][0]
1106
+ acc = 0.0
1107
+ for v, w in weights:
1108
+ acc += w
1109
+ if r <= acc:
1110
+ flip = v
1111
+ break
1112
+
1113
+ # apply the flip and repair the counts
1114
+ now_true = (flip << 1) | (0 if assign[flip] else 1)
1115
+ assign[flip] ^= 1
1116
+ new_true = (flip << 1) | (0 if assign[flip] else 1)
1117
+ for ci in occ[now_true]:
1118
+ ntrue[ci] -= 1
1119
+ if ntrue[ci] == 0:
1120
+ where[ci] = len(unsat)
1121
+ unsat.append(ci)
1122
+ for ci in occ[new_true]:
1123
+ if ntrue[ci] == 0:
1124
+ k = where.pop(ci)
1125
+ last = unsat.pop()
1126
+ if k < len(unsat):
1127
+ unsat[k] = last
1128
+ where[last] = k
1129
+ ntrue[ci] += 1
1130
+
1131
+ if len(unsat) < best_unsat:
1132
+ best_unsat = len(unsat)
1133
+ best = bytearray(assign)
1134
+
1135
+ self.stats.walks += 1
1136
+ self.stats.walk_flips += max_flips
1137
+ if best_unsat < self._walk_best:
1138
+ self._walk_best = best_unsat
1139
+ self._walk_stale = 0
1140
+ else:
1141
+ self._walk_stale += 1
1142
+ # the payoff: branch from what the walk found
1143
+ self._target[:] = best
1144
+ self.polarity[:] = best
1145
+ self._target_size = 0 # the target now describes the walk, not a trail
1146
+
1147
+ # -------------------------------------------------------------- restarts
1148
+
1149
+ def _restart_due(self) -> bool:
1150
+ mode = self.cfg.restart
1151
+ if mode == "none":
1152
+ return False
1153
+ if mode == "luby":
1154
+ budget = self.cfg.luby_base * luby(2.0, self._restart_index)
1155
+ return self.stats.conflicts - self._conflicts_at_restart >= budget
1156
+ # Glucose: restart when the recent LBD average is 25% worse than the
1157
+ # long-run average, after a minimum window.
1158
+ if self.stats.conflicts - self._conflicts_at_restart < 50:
1159
+ return False
1160
+ return self._lbd_fast.value * 0.8 > self._lbd_slow.value
1161
+
1162
+ def _block_restart(self) -> bool:
1163
+ """Suppress a restart when the trail is much deeper than usual."""
1164
+ if not self.cfg.block_restart:
1165
+ return False
1166
+ if self.stats.conflicts < 10000:
1167
+ return False
1168
+ return len(self.trail) > 1.4 * self._trail_ema.value
1169
+
1170
+ # ----------------------------------------------------------------- search
1171
+
1172
+ def _search(self, max_conflicts: int | None,
1173
+ deadline: float | None = None) -> str:
1174
+ """`deadline` is a `time.perf_counter()` value; None means unbounded.
1175
+
1176
+ A benchmark harness has to bound its own solver the way it bounds the
1177
+ competitors. Ours did not: external solvers ran as subprocesses with a
1178
+ timeout while cdclkit ran in-process with none, so a SATLIB `par32`
1179
+ instance -- 3176 variables, exponential for CDCL without XOR reasoning,
1180
+ and one kissat also fails to solve -- ran for 77 minutes while kissat
1181
+ would have been killed at 120 seconds.
1182
+
1183
+ Checked every 256 conflicts, which is far below the cost of a conflict
1184
+ and keeps `perf_counter` out of the hot path. Leaving it None changes
1185
+ nothing, so determinism and the bit-exactness tests are unaffected.
1186
+ """
1187
+ conflicts = 0
1188
+ while True:
1189
+ confl = self._propagate()
1190
+ if confl is not None:
1191
+ self.stats.conflicts += 1
1192
+ conflicts += 1
1193
+ if self.decision_level == 0:
1194
+ if self.proof is not None:
1195
+ self.proof.add([])
1196
+ self.ok = False
1197
+ return UNSAT
1198
+ learnt, bt, lbd = self._analyze(confl)
1199
+ self._lbd_fast.update(lbd)
1200
+ self._lbd_slow.update(lbd)
1201
+ self._trail_ema.update(len(self.trail))
1202
+ if self._block_restart():
1203
+ self.stats.blocked_restarts += 1
1204
+ self._conflicts_at_restart = self.stats.conflicts
1205
+ self._cancel_until(bt)
1206
+ c = self._record(learnt, lbd)
1207
+ self._assign(learnt[0], c)
1208
+ self._decay_var()
1209
+ self._decay_clause()
1210
+ if self._var_decay < self.cfg.var_decay_max:
1211
+ self._var_decay = min(self._var_decay + 0.01, self.cfg.var_decay_max)
1212
+ if self.stats.conflicts >= self._next_reduce:
1213
+ self._reduce_count += 1
1214
+ self._next_reduce = (
1215
+ self.cfg.first_reduce
1216
+ + self.cfg.reduce_inc * self._reduce_count * self._reduce_count
1217
+ )
1218
+ self._reduce_db()
1219
+ else:
1220
+ # A new deepest conflict-free trail: remember the assignment
1221
+ # that reached it. Copying costs O(|trail|), but only on a
1222
+ # strict improvement, and improvements become rare quickly.
1223
+ if self.cfg.target_phase and len(self.trail) > self._target_size:
1224
+ self._target_size = len(self.trail)
1225
+ target = self._target
1226
+ for t in self.trail:
1227
+ target[t >> 1] = 0 if (t & 1) else 1
1228
+
1229
+ if max_conflicts is not None and conflicts >= max_conflicts:
1230
+ self._cancel_until(len(self.assumptions_applied))
1231
+ return UNKNOWN
1232
+ if (deadline is not None and (conflicts & 255) == 0
1233
+ and time.perf_counter() >= deadline):
1234
+ self._cancel_until(len(self.assumptions_applied))
1235
+ return UNKNOWN
1236
+ if self._restart_due():
1237
+ self.stats.restarts += 1
1238
+ self._restart_index += 1
1239
+ self._conflicts_at_restart = self.stats.conflicts
1240
+ if (self.cfg.target_reset
1241
+ and self.stats.restarts % self.cfg.target_reset == 0):
1242
+ self._target_size = 0
1243
+ if (self.cfg.walk_flips
1244
+ and self.stats.conflicts >= self.cfg.walk_min_conflicts
1245
+ and self._walk_stale < self.cfg.walk_patience
1246
+ and self.stats.restarts % self.cfg.walk_interval == 0):
1247
+ self._cancel_until(0)
1248
+ self._walk(self.cfg.walk_flips)
1249
+ self._cancel_until(0)
1250
+ continue
1251
+ if self.decision_level == 0 and self.stats.propagations > self._simp_props:
1252
+ if not self._simplify():
1253
+ return UNSAT
1254
+
1255
+ # -- assumptions come before free decisions
1256
+ lit = -1
1257
+ while self.decision_level < len(self.assumptions):
1258
+ a = self.assumptions[self.decision_level]
1259
+ v = self.val[a]
1260
+ if v == T:
1261
+ self.trail_lim.append(len(self.trail)) # dummy level
1262
+ elif v == F:
1263
+ self.conflict = self._analyze_final(a ^ 1)
1264
+ return UNSAT
1265
+ else:
1266
+ lit = a
1267
+ break
1268
+ if lit == -1:
1269
+ lit = self._pick_branch_lit()
1270
+ if lit == -1:
1271
+ self.model = [self.val[v << 1] == T for v in range(self.nvars)]
1272
+ return SAT
1273
+ self.trail_lim.append(len(self.trail))
1274
+ self._assign(lit, None)
1275
+
1276
+ @property
1277
+ def assumptions_applied(self) -> list[int]:
1278
+ return self.assumptions
1279
+
1280
+ # ------------------------------------------------------------------ API
1281
+
1282
+ def solve(
1283
+ self,
1284
+ assumptions: Sequence[int] = (),
1285
+ max_conflicts: int | None = None,
1286
+ deadline: float | None = None,
1287
+ ) -> bool | None:
1288
+ """Solve under ``assumptions``.
1289
+
1290
+ Returns True (satisfiable, :attr:`model` set), False (unsatisfiable,
1291
+ :attr:`conflict` set when assumptions were used) or None when a
1292
+ conflict budget was exhausted.
1293
+ """
1294
+ t0 = time.perf_counter()
1295
+ self.model = []
1296
+ self.conflict = []
1297
+ self.assumptions = list(assumptions)
1298
+ try:
1299
+ if not self.ok:
1300
+ return False
1301
+ self._cancel_until(0)
1302
+ status = self._search(max_conflicts, deadline)
1303
+ if status == SAT:
1304
+ return True
1305
+ if status == UNSAT:
1306
+ return False
1307
+ return None
1308
+ finally:
1309
+ self._cancel_until(0)
1310
+ self.assumptions = []
1311
+ self.stats.solve_time += time.perf_counter() - t0
1312
+
1313
+ def solve_dimacs_assumptions(self, dimacs: Sequence[int]) -> bool | None:
1314
+ return self.solve([from_dimacs(d) for d in dimacs])
1315
+
1316
+ def model_dimacs(self) -> list[int]:
1317
+ return [(v + 1) if b else -(v + 1) for v, b in enumerate(self.model)]
1318
+
1319
+ def core_dimacs(self) -> list[int]:
1320
+ return [to_dimacs(l) for l in self.conflict]
1321
+
1322
+ # ------------------------------------------------------- model enumeration
1323
+
1324
+ def enumerate_models(self, projection: Sequence[int] | None = None, limit: int = 0):
1325
+ """Yield models, blocking each one as it is produced.
1326
+
1327
+ ``projection`` restricts the blocking clause (and therefore the notion
1328
+ of distinctness) to a subset of variables, which is how you count
1329
+ solutions of an encoded problem without also counting the different
1330
+ internal states of its Tseitin variables.
1331
+
1332
+ Mutates the clause database: each yielded model adds a blocking clause,
1333
+ so the solver is left strictly stronger than it started.
1334
+ """
1335
+ vars_ = list(range(self.nvars)) if projection is None else list(projection)
1336
+ n = 0
1337
+ while self.solve():
1338
+ model = self.model
1339
+ yield list(model)
1340
+ n += 1
1341
+ if limit and n >= limit:
1342
+ return
1343
+ block = [(v << 1) | (1 if model[v] else 0) for v in vars_]
1344
+ if not block or not self.add_clause(block):
1345
+ return
1346
+
1347
+ # ------------------------------------------------------------- diagnostics
1348
+
1349
+ def check_watch_invariant(self) -> list[str]:
1350
+ """Verify the two-watched-literal bookkeeping. Test-suite only."""
1351
+ errs: list[str] = []
1352
+ counted: dict[int, int] = {}
1353
+ for p, ws in enumerate(self.watches):
1354
+ for c, blocker in ws:
1355
+ if c.deleted:
1356
+ errs.append(f"deleted clause still watched by {p}")
1357
+ if (p ^ 1) not in (c.lits[0], c.lits[1]):
1358
+ errs.append(f"clause {c} in watches[{p}] but does not watch {p^1}")
1359
+ if blocker not in c.lits:
1360
+ errs.append(f"blocker {blocker} not in {c}")
1361
+ counted[id(c)] = counted.get(id(c), 0) + 1
1362
+ for c in self.clauses + self.learnts:
1363
+ if c.deleted:
1364
+ continue
1365
+ if counted.get(id(c), 0) != 2:
1366
+ errs.append(f"clause {c} has {counted.get(id(c),0)} watchers, want 2")
1367
+ return errs
1368
+
1369
+ def check_trail_invariant(self) -> list[str]:
1370
+ errs = []
1371
+ for i, lit in enumerate(self.trail):
1372
+ if self.val[lit] != T:
1373
+ errs.append(f"trail literal {lit} not true")
1374
+ lvl = self.level[lit >> 1]
1375
+ if lvl > 0 and self.trail_lim[lvl - 1] > i:
1376
+ errs.append(f"trail literal {lit} at position {i} claims level {lvl}")
1377
+ return errs