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/__init__.py +152 -0
- cdclkit/__main__.py +10 -0
- cdclkit/brute.py +210 -0
- cdclkit/cli.py +513 -0
- cdclkit/encodings.py +842 -0
- cdclkit/heap.py +180 -0
- cdclkit/model.py +420 -0
- cdclkit/mus.py +159 -0
- cdclkit/native.py +111 -0
- cdclkit/pipeline.py +212 -0
- cdclkit/portfolio.py +683 -0
- cdclkit/preprocess.py +500 -0
- cdclkit/pyeq.py +824 -0
- cdclkit/solver.py +1377 -0
- cdclkit-0.1.0.dist-info/METADATA +136 -0
- cdclkit-0.1.0.dist-info/RECORD +20 -0
- cdclkit-0.1.0.dist-info/WHEEL +5 -0
- cdclkit-0.1.0.dist-info/entry_points.txt +2 -0
- cdclkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- cdclkit-0.1.0.dist-info/top_level.txt +1 -0
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
|