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/encodings.py
ADDED
|
@@ -0,0 +1,842 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
|
|
3
|
+
"""Turning things that are not CNF into CNF.
|
|
4
|
+
|
|
5
|
+
Almost nothing anyone wants to solve is naturally a conjunction of clauses.
|
|
6
|
+
This module is the bridge: boolean circuits via Tseitin, "at most k of these",
|
|
7
|
+
weighted sums, parity. The choice of encoding matters more than almost any
|
|
8
|
+
solver tuning, so each one here documents its size and, crucially, its
|
|
9
|
+
*propagation strength*.
|
|
10
|
+
|
|
11
|
+
Propagation strength, precisely
|
|
12
|
+
-------------------------------
|
|
13
|
+
An encoding of a constraint ``C`` is **arc consistent under unit propagation**
|
|
14
|
+
(often "generalised arc consistent", GAC) when, for every partial assignment
|
|
15
|
+
to the constrained variables, unit propagation on the encoding fixes every
|
|
16
|
+
literal that ``C`` itself would fix. Example: with ``x1+...+x5 <= 2`` and
|
|
17
|
+
``x1=x2=1`` already set, a GAC encoding propagates ``x3=x4=x5=0`` immediately.
|
|
18
|
+
A non-GAC encoding may need the solver to *search* and hit a conflict first.
|
|
19
|
+
That difference is worth orders of magnitude on constraint-heavy instances.
|
|
20
|
+
|
|
21
|
+
Summary of what is here
|
|
22
|
+
-----------------------
|
|
23
|
+
|
|
24
|
+
====================== ========== ================ =========================
|
|
25
|
+
encoding aux vars clauses propagation
|
|
26
|
+
====================== ========== ================ =========================
|
|
27
|
+
pairwise AMO 0 n(n-1)/2 GAC
|
|
28
|
+
binary (bimander) AMO log n n log n GAC on inputs, aux vars
|
|
29
|
+
commander AMO ~n/2 ~3.5 n GAC
|
|
30
|
+
sequential AMK (Sinz) n*k ~2nk GAC
|
|
31
|
+
totalizer AMK ~n log n O(n^2) (O(nk) cut) GAC, and incremental
|
|
32
|
+
BDD PB |BDD| 4*|BDD| GAC
|
|
33
|
+
XOR chain n-2 4(n-2) GAC on the chain
|
|
34
|
+
====================== ========== ================ =========================
|
|
35
|
+
|
|
36
|
+
"Incremental" for the totalizer means the bound can be *tightened* later by
|
|
37
|
+
adding one unit clause, without re-encoding anything -- which is what makes it
|
|
38
|
+
the right structure for MaxSAT-style optimisation loops. :func:`optimise` in
|
|
39
|
+
this module uses exactly that.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from itertools import product
|
|
45
|
+
from typing import Iterable, Sequence
|
|
46
|
+
|
|
47
|
+
from dratify.cnf import CNF
|
|
48
|
+
from dratify.lits import mk_lit, neg
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"Encoder",
|
|
52
|
+
"SolverSink",
|
|
53
|
+
"at_most_one",
|
|
54
|
+
"at_least_one",
|
|
55
|
+
"exactly_one",
|
|
56
|
+
"at_most_k",
|
|
57
|
+
"at_least_k",
|
|
58
|
+
"exactly_k",
|
|
59
|
+
"Totalizer",
|
|
60
|
+
"Optimiser",
|
|
61
|
+
"optimise",
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SolverSink:
|
|
66
|
+
"""Adapts a :class:`~cdclkit.solver.Solver` to the encoder's sink protocol."""
|
|
67
|
+
|
|
68
|
+
__slots__ = ("solver",)
|
|
69
|
+
|
|
70
|
+
def __init__(self, solver) -> None:
|
|
71
|
+
self.solver = solver
|
|
72
|
+
|
|
73
|
+
def new_var(self, name: str | None = None) -> int:
|
|
74
|
+
return self.solver.new_var()
|
|
75
|
+
|
|
76
|
+
def add(self, lits) -> bool:
|
|
77
|
+
return self.solver.add_clause(lits)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def nvars(self) -> int:
|
|
81
|
+
return self.solver.nvars
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _as_sink(target):
|
|
85
|
+
if hasattr(target, "add_clause"):
|
|
86
|
+
return SolverSink(target)
|
|
87
|
+
return target
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# --------------------------------------------------------------------------
|
|
91
|
+
# the encoder
|
|
92
|
+
# --------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class Encoder:
|
|
96
|
+
"""Builds CNF into a sink (a :class:`CNF` or a :class:`Solver`).
|
|
97
|
+
|
|
98
|
+
All gate constructors return a *literal* standing for the gate output and
|
|
99
|
+
assert the full equivalence (both implication directions). Polarity-aware
|
|
100
|
+
Tseitin -- emitting only the direction that the surrounding formula needs --
|
|
101
|
+
halves the clause count, and :meth:`tseitin` does apply it when you tell it
|
|
102
|
+
the polarity; the individual ``*_gate`` helpers stay complete because they
|
|
103
|
+
are also used to *define* variables the caller may reference either way.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
#: Cap on :meth:`xor_direct`, which emits 2^(k-1) clauses. 16 inputs is
|
|
107
|
+
#: 32768 clauses -- large but survivable, and the point of the direct form
|
|
108
|
+
#: is to cross-check the chain on small arities, not to replace it.
|
|
109
|
+
MAX_DIRECT_XOR_ARITY = 16
|
|
110
|
+
|
|
111
|
+
#: Cap on :meth:`assert_expr_expanded`, which enumerates 2^v rows over the
|
|
112
|
+
#: v variables an expression mentions. 12 is 4096 rows per conjunct.
|
|
113
|
+
MAX_EXPAND_ARITY = 12
|
|
114
|
+
|
|
115
|
+
def __init__(self, target=None) -> None:
|
|
116
|
+
self.sink = _as_sink(target if target is not None else CNF())
|
|
117
|
+
self._true: int | None = None
|
|
118
|
+
self._cache: dict[tuple, int] = {}
|
|
119
|
+
self.n_clauses_emitted = 0
|
|
120
|
+
self.n_aux = 0
|
|
121
|
+
|
|
122
|
+
# -- plumbing -----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def formula(self):
|
|
126
|
+
return self.sink.solver if isinstance(self.sink, SolverSink) else self.sink
|
|
127
|
+
|
|
128
|
+
def new_var(self, name: str | None = None) -> int:
|
|
129
|
+
self.n_aux += 1
|
|
130
|
+
try:
|
|
131
|
+
return self.sink.new_var(name)
|
|
132
|
+
except TypeError:
|
|
133
|
+
return self.sink.new_var()
|
|
134
|
+
|
|
135
|
+
def new_lit(self, name: str | None = None) -> int:
|
|
136
|
+
return mk_lit(self.new_var(name))
|
|
137
|
+
|
|
138
|
+
def add(self, lits: Iterable[int]) -> None:
|
|
139
|
+
self.n_clauses_emitted += 1
|
|
140
|
+
self.sink.add(list(lits))
|
|
141
|
+
|
|
142
|
+
def add_all(self, clauses: Iterable[Iterable[int]]) -> None:
|
|
143
|
+
for c in clauses:
|
|
144
|
+
self.add(c)
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def true_lit(self) -> int:
|
|
148
|
+
"""A literal that is forced true, created on first use."""
|
|
149
|
+
if self._true is None:
|
|
150
|
+
v = self.new_var("__true")
|
|
151
|
+
self._true = mk_lit(v)
|
|
152
|
+
self.add([self._true])
|
|
153
|
+
return self._true
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def false_lit(self) -> int:
|
|
157
|
+
return neg(self.true_lit)
|
|
158
|
+
|
|
159
|
+
# -- gates --------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def and_gate(self, lits: Sequence[int], out: int | None = None) -> int:
|
|
162
|
+
"""``out <-> AND(lits)``. Returns ``out``."""
|
|
163
|
+
lits = self._simplify_and(lits)
|
|
164
|
+
if lits is None:
|
|
165
|
+
return self.false_lit
|
|
166
|
+
if not lits:
|
|
167
|
+
return self.true_lit
|
|
168
|
+
if len(lits) == 1 and out is None:
|
|
169
|
+
return lits[0]
|
|
170
|
+
key = ("and", tuple(sorted(lits)))
|
|
171
|
+
if out is None and key in self._cache:
|
|
172
|
+
return self._cache[key]
|
|
173
|
+
if out is None:
|
|
174
|
+
out = self.new_lit()
|
|
175
|
+
self._cache[key] = out
|
|
176
|
+
for l in lits: # out -> l
|
|
177
|
+
self.add([neg(out), l])
|
|
178
|
+
self.add([out] + [neg(l) for l in lits]) # AND(lits) -> out
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
def or_gate(self, lits: Sequence[int], out: int | None = None) -> int:
|
|
182
|
+
"""``out <-> OR(lits)``."""
|
|
183
|
+
inner = self.and_gate([neg(l) for l in lits], None if out is None else neg(out))
|
|
184
|
+
return neg(inner)
|
|
185
|
+
|
|
186
|
+
def xor_gate(self, a: int, b: int, out: int | None = None) -> int:
|
|
187
|
+
"""``out <-> a XOR b``."""
|
|
188
|
+
key = ("xor", min(a, b), max(a, b))
|
|
189
|
+
if out is None and key in self._cache:
|
|
190
|
+
return self._cache[key]
|
|
191
|
+
if out is None:
|
|
192
|
+
out = self.new_lit()
|
|
193
|
+
self._cache[key] = out
|
|
194
|
+
self.add([neg(out), a, b])
|
|
195
|
+
self.add([neg(out), neg(a), neg(b)])
|
|
196
|
+
self.add([out, neg(a), b])
|
|
197
|
+
self.add([out, a, neg(b)])
|
|
198
|
+
return out
|
|
199
|
+
|
|
200
|
+
def xor_chain(self, lits: Sequence[int], value: bool = True) -> None:
|
|
201
|
+
"""Assert ``XOR(lits) == value`` with a chain of 3-variable XOR gates.
|
|
202
|
+
|
|
203
|
+
A direct CNF encoding of a k-way XOR needs 2^(k-1) clauses; the chain
|
|
204
|
+
needs 4(k-2) clauses and k-2 auxiliary variables, and unit propagation
|
|
205
|
+
on the chain is as strong as on the direct form as long as literals are
|
|
206
|
+
fixed from the ends inwards.
|
|
207
|
+
"""
|
|
208
|
+
if not lits:
|
|
209
|
+
if value:
|
|
210
|
+
self.add([]) # empty XOR is false; asserting true is a contradiction
|
|
211
|
+
return
|
|
212
|
+
if len(lits) == 1:
|
|
213
|
+
self.add([lits[0] if value else neg(lits[0])])
|
|
214
|
+
return
|
|
215
|
+
acc = lits[0]
|
|
216
|
+
for l in lits[1:-1]:
|
|
217
|
+
acc = self.xor_gate(acc, l)
|
|
218
|
+
a, b = acc, lits[-1]
|
|
219
|
+
if value:
|
|
220
|
+
self.add([a, b])
|
|
221
|
+
self.add([neg(a), neg(b)])
|
|
222
|
+
else:
|
|
223
|
+
self.add([a, neg(b)])
|
|
224
|
+
self.add([neg(a), b])
|
|
225
|
+
|
|
226
|
+
def ite(self, c: int, t: int, e: int, out: int | None = None) -> int:
|
|
227
|
+
"""``out <-> (c ? t : e)``, the primitive every BDD encoding needs."""
|
|
228
|
+
if t == e:
|
|
229
|
+
return t
|
|
230
|
+
key = ("ite", c, t, e)
|
|
231
|
+
if out is None and key in self._cache:
|
|
232
|
+
return self._cache[key]
|
|
233
|
+
if out is None:
|
|
234
|
+
out = self.new_lit()
|
|
235
|
+
self._cache[key] = out
|
|
236
|
+
self.add([neg(out), neg(c), t])
|
|
237
|
+
self.add([neg(out), c, e])
|
|
238
|
+
self.add([out, neg(c), neg(t)])
|
|
239
|
+
self.add([out, c, neg(e)])
|
|
240
|
+
# redundant but propagation-strengthening: t & e -> out, ~t & ~e -> ~out
|
|
241
|
+
self.add([neg(t), neg(e), out])
|
|
242
|
+
self.add([t, e, neg(out)])
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
def implies(self, a: int, b: int) -> None:
|
|
246
|
+
self.add([neg(a), b])
|
|
247
|
+
|
|
248
|
+
def equiv(self, a: int, b: int) -> None:
|
|
249
|
+
self.add([neg(a), b])
|
|
250
|
+
self.add([a, neg(b)])
|
|
251
|
+
|
|
252
|
+
def _simplify_and(self, lits: Sequence[int]) -> list[int] | None:
|
|
253
|
+
"""Deduplicate; return None when the conjunction is trivially false."""
|
|
254
|
+
seen: set[int] = set()
|
|
255
|
+
out: list[int] = []
|
|
256
|
+
for l in lits:
|
|
257
|
+
if l == self._true:
|
|
258
|
+
continue
|
|
259
|
+
if self._true is not None and l == neg(self._true):
|
|
260
|
+
return None
|
|
261
|
+
if l in seen:
|
|
262
|
+
continue
|
|
263
|
+
if neg(l) in seen:
|
|
264
|
+
return None
|
|
265
|
+
seen.add(l)
|
|
266
|
+
out.append(l)
|
|
267
|
+
return out
|
|
268
|
+
|
|
269
|
+
# -- expression trees ---------------------------------------------------
|
|
270
|
+
|
|
271
|
+
def tseitin(self, expr, polarity: int = 0) -> int:
|
|
272
|
+
"""Encode a nested expression tree, returning its output literal.
|
|
273
|
+
|
|
274
|
+
The tree is built from plain tuples::
|
|
275
|
+
|
|
276
|
+
("and", e1, e2, ...) ("or", ...) ("xor", e1, e2)
|
|
277
|
+
("not", e) ("ite", c, t, e) ("imp", a, b)
|
|
278
|
+
("iff", a, b) <int literal>
|
|
279
|
+
|
|
280
|
+
``polarity`` is +1 when the expression only ever needs to be *forced
|
|
281
|
+
true* (so only the ``inputs -> out`` direction is required), -1 for the
|
|
282
|
+
mirror case, 0 for both. Using the polarity halves the clauses on
|
|
283
|
+
large monotone circuits; it is safe exactly because a variable that
|
|
284
|
+
occurs with one polarity can always be set to the value its definition
|
|
285
|
+
prescribes.
|
|
286
|
+
"""
|
|
287
|
+
if isinstance(expr, int):
|
|
288
|
+
return expr
|
|
289
|
+
op = expr[0]
|
|
290
|
+
if op == "not":
|
|
291
|
+
return neg(self.tseitin(expr[1], -polarity))
|
|
292
|
+
if op == "and":
|
|
293
|
+
kids = [self.tseitin(e, polarity) for e in expr[1:]]
|
|
294
|
+
return self._and_pol(kids, polarity)
|
|
295
|
+
if op == "or":
|
|
296
|
+
kids = [self.tseitin(e, polarity) for e in expr[1:]]
|
|
297
|
+
return neg(self._and_pol([neg(k) for k in kids], -polarity))
|
|
298
|
+
if op == "imp":
|
|
299
|
+
a = self.tseitin(expr[1], -polarity)
|
|
300
|
+
b = self.tseitin(expr[2], polarity)
|
|
301
|
+
return neg(self._and_pol([a, neg(b)], -polarity))
|
|
302
|
+
if op == "iff":
|
|
303
|
+
a = self.tseitin(expr[1], 0)
|
|
304
|
+
b = self.tseitin(expr[2], 0)
|
|
305
|
+
return neg(self.xor_gate(a, b))
|
|
306
|
+
if op == "xor":
|
|
307
|
+
a = self.tseitin(expr[1], 0)
|
|
308
|
+
b = self.tseitin(expr[2], 0)
|
|
309
|
+
return self.xor_gate(a, b)
|
|
310
|
+
if op == "ite":
|
|
311
|
+
c = self.tseitin(expr[1], 0)
|
|
312
|
+
t = self.tseitin(expr[2], polarity)
|
|
313
|
+
e = self.tseitin(expr[3], polarity)
|
|
314
|
+
return self.ite(c, t, e)
|
|
315
|
+
raise ValueError(f"unknown operator {op!r}")
|
|
316
|
+
|
|
317
|
+
def _and_pol(self, lits: Sequence[int], polarity: int) -> int:
|
|
318
|
+
lits = self._simplify_and(lits)
|
|
319
|
+
if lits is None:
|
|
320
|
+
return self.false_lit
|
|
321
|
+
if not lits:
|
|
322
|
+
return self.true_lit
|
|
323
|
+
if len(lits) == 1:
|
|
324
|
+
return lits[0]
|
|
325
|
+
if polarity == 0:
|
|
326
|
+
return self.and_gate(lits)
|
|
327
|
+
out = self.new_lit()
|
|
328
|
+
if polarity > 0: # out -> AND(lits)
|
|
329
|
+
for l in lits:
|
|
330
|
+
self.add([neg(out), l])
|
|
331
|
+
else: # AND(lits) -> out
|
|
332
|
+
self.add([out] + [neg(l) for l in lits])
|
|
333
|
+
return out
|
|
334
|
+
|
|
335
|
+
def assert_expr(self, expr) -> None:
|
|
336
|
+
"""Force an expression tree to be true, with polarity optimisation."""
|
|
337
|
+
if isinstance(expr, tuple) and expr[0] == "and":
|
|
338
|
+
for e in expr[1:]:
|
|
339
|
+
self.assert_expr(e)
|
|
340
|
+
return
|
|
341
|
+
if isinstance(expr, tuple) and expr[0] == "or":
|
|
342
|
+
self.add([self.tseitin(e, +1) for e in expr[1:]])
|
|
343
|
+
return
|
|
344
|
+
self.add([self.tseitin(expr, +1)])
|
|
345
|
+
|
|
346
|
+
# ------------------------------------------------------------ at-most-one
|
|
347
|
+
|
|
348
|
+
def amo_pairwise(self, lits: Sequence[int]) -> None:
|
|
349
|
+
"""n(n-1)/2 clauses, no auxiliary variables. Best for n <= 6."""
|
|
350
|
+
n = len(lits)
|
|
351
|
+
for i in range(n):
|
|
352
|
+
li = neg(lits[i])
|
|
353
|
+
for j in range(i + 1, n):
|
|
354
|
+
self.add([li, neg(lits[j])])
|
|
355
|
+
|
|
356
|
+
def amo_binary(self, lits: Sequence[int]) -> None:
|
|
357
|
+
"""Bimander/binary encoding: ceil(log2 n) aux vars, n*log n clauses.
|
|
358
|
+
|
|
359
|
+
Each input is assigned a distinct bit pattern and forced to agree with
|
|
360
|
+
it; two inputs cannot both be true because their patterns differ in
|
|
361
|
+
some bit. Fixing one input true fixes every code bit, and every other
|
|
362
|
+
input then has a clause with a false code literal, so propagation is as
|
|
363
|
+
strong as pairwise on the inputs themselves. What you pay for the size
|
|
364
|
+
reduction is structural: log2(n) auxiliary variables enter the decision
|
|
365
|
+
heuristic, and the encoding is silent until some input is set true.
|
|
366
|
+
"""
|
|
367
|
+
n = len(lits)
|
|
368
|
+
if n <= 1:
|
|
369
|
+
return
|
|
370
|
+
bits = max(1, (n - 1).bit_length())
|
|
371
|
+
code = [self.new_lit() for _ in range(bits)]
|
|
372
|
+
for i, x in enumerate(lits):
|
|
373
|
+
nx = neg(x)
|
|
374
|
+
for b in range(bits):
|
|
375
|
+
self.add([nx, code[b] if (i >> b) & 1 else neg(code[b])])
|
|
376
|
+
|
|
377
|
+
def amo_commander(self, lits: Sequence[int], group: int = 3) -> None:
|
|
378
|
+
"""Klieber-Kwon commander encoding: linear size *and* arc consistent.
|
|
379
|
+
|
|
380
|
+
Split the inputs into groups. Each group gets a commander variable
|
|
381
|
+
equivalent to the disjunction of the group, the group itself gets a
|
|
382
|
+
pairwise at-most-one, and the commanders are recursively constrained by
|
|
383
|
+
the same encoding. Roughly 3.5n clauses and n/2 variables, with unit
|
|
384
|
+
propagation as strong as pairwise.
|
|
385
|
+
"""
|
|
386
|
+
lits = list(lits)
|
|
387
|
+
if len(lits) <= group:
|
|
388
|
+
self.amo_pairwise(lits)
|
|
389
|
+
return
|
|
390
|
+
commanders: list[int] = []
|
|
391
|
+
for i in range(0, len(lits), group):
|
|
392
|
+
g = lits[i : i + group]
|
|
393
|
+
if len(g) == 1:
|
|
394
|
+
commanders.append(g[0])
|
|
395
|
+
continue
|
|
396
|
+
c = self.new_lit()
|
|
397
|
+
self.amo_pairwise(g)
|
|
398
|
+
for x in g: # x -> c
|
|
399
|
+
self.add([neg(x), c])
|
|
400
|
+
self.add([neg(c)] + list(g)) # c -> OR(g)
|
|
401
|
+
commanders.append(c)
|
|
402
|
+
self.amo_commander(commanders, group)
|
|
403
|
+
|
|
404
|
+
def at_most_one(self, lits: Sequence[int], method: str = "auto") -> None:
|
|
405
|
+
if method == "auto":
|
|
406
|
+
method = "pairwise" if len(lits) <= 6 else "commander"
|
|
407
|
+
if method == "pairwise":
|
|
408
|
+
self.amo_pairwise(lits)
|
|
409
|
+
elif method == "binary":
|
|
410
|
+
self.amo_binary(lits)
|
|
411
|
+
elif method == "commander":
|
|
412
|
+
self.amo_commander(lits)
|
|
413
|
+
elif method == "sequential":
|
|
414
|
+
self.amk_sequential(lits, 1)
|
|
415
|
+
elif method == "totalizer":
|
|
416
|
+
self.amk_totalizer(lits, 1)
|
|
417
|
+
else:
|
|
418
|
+
raise ValueError(f"unknown at-most-one method {method!r}")
|
|
419
|
+
|
|
420
|
+
def at_least_one(self, lits: Sequence[int]) -> None:
|
|
421
|
+
self.add(list(lits))
|
|
422
|
+
|
|
423
|
+
def exactly_one(self, lits: Sequence[int], method: str = "auto") -> None:
|
|
424
|
+
self.at_least_one(lits)
|
|
425
|
+
self.at_most_one(lits, method)
|
|
426
|
+
|
|
427
|
+
# -------------------------------------------------------------- at-most-k
|
|
428
|
+
|
|
429
|
+
def amk_sequential(self, lits: Sequence[int], k: int) -> None:
|
|
430
|
+
"""Sinz's sequential counter: n*k aux vars, ~2nk clauses, arc consistent.
|
|
431
|
+
|
|
432
|
+
``s[i][j]`` means "at least j of the first i inputs are true". The
|
|
433
|
+
clauses are just the recurrence
|
|
434
|
+
``s[i][j] <- s[i-1][j] or (x_i and s[i-1][j-1])`` in implication form,
|
|
435
|
+
plus the blocking clause ``~x_i or ~s[i-1][k]``.
|
|
436
|
+
"""
|
|
437
|
+
n = len(lits)
|
|
438
|
+
if k >= n:
|
|
439
|
+
return
|
|
440
|
+
if k < 0:
|
|
441
|
+
self.add([])
|
|
442
|
+
return
|
|
443
|
+
if k == 0:
|
|
444
|
+
for x in lits:
|
|
445
|
+
self.add([neg(x)])
|
|
446
|
+
return
|
|
447
|
+
s = [[self.new_lit() for _ in range(k)] for _ in range(n - 1)]
|
|
448
|
+
self.add([neg(lits[0]), s[0][0]])
|
|
449
|
+
for j in range(1, k):
|
|
450
|
+
self.add([neg(s[0][j])])
|
|
451
|
+
for i in range(1, n - 1):
|
|
452
|
+
self.add([neg(lits[i]), s[i][0]])
|
|
453
|
+
self.add([neg(s[i - 1][0]), s[i][0]])
|
|
454
|
+
for j in range(1, k):
|
|
455
|
+
self.add([neg(lits[i]), neg(s[i - 1][j - 1]), s[i][j]])
|
|
456
|
+
self.add([neg(s[i - 1][j]), s[i][j]])
|
|
457
|
+
self.add([neg(lits[i]), neg(s[i - 1][k - 1])])
|
|
458
|
+
self.add([neg(lits[n - 1]), neg(s[n - 2][k - 1])])
|
|
459
|
+
|
|
460
|
+
def amk_totalizer(self, lits: Sequence[int], k: int) -> "Totalizer":
|
|
461
|
+
"""Build a totalizer and assert ``sum <= k``. Returns the totalizer."""
|
|
462
|
+
t = Totalizer(self, lits, max_count=k + 1)
|
|
463
|
+
t.assert_at_most(k)
|
|
464
|
+
return t
|
|
465
|
+
|
|
466
|
+
def at_most_k(self, lits: Sequence[int], k: int, method: str = "auto") -> None:
|
|
467
|
+
n = len(lits)
|
|
468
|
+
if k >= n:
|
|
469
|
+
return
|
|
470
|
+
if k <= 0:
|
|
471
|
+
for x in lits:
|
|
472
|
+
self.add([neg(x)])
|
|
473
|
+
return
|
|
474
|
+
if k == 1 and method in ("auto", "commander", "pairwise", "binary"):
|
|
475
|
+
self.at_most_one(lits, "auto" if method == "auto" else method)
|
|
476
|
+
return
|
|
477
|
+
if method == "auto":
|
|
478
|
+
method = "sequential" if k * n <= 20000 else "totalizer"
|
|
479
|
+
if method == "sequential":
|
|
480
|
+
self.amk_sequential(lits, k)
|
|
481
|
+
elif method == "totalizer":
|
|
482
|
+
self.amk_totalizer(lits, k)
|
|
483
|
+
else:
|
|
484
|
+
raise ValueError(f"unknown at-most-k method {method!r}")
|
|
485
|
+
|
|
486
|
+
def at_least_k(self, lits: Sequence[int], k: int, method: str = "auto") -> None:
|
|
487
|
+
"""``sum(lits) >= k`` <=> ``sum(~lits) <= n - k``."""
|
|
488
|
+
n = len(lits)
|
|
489
|
+
if k <= 0:
|
|
490
|
+
return
|
|
491
|
+
if k > n:
|
|
492
|
+
self.add([])
|
|
493
|
+
return
|
|
494
|
+
if k == 1:
|
|
495
|
+
self.at_least_one(lits)
|
|
496
|
+
return
|
|
497
|
+
self.at_most_k([neg(l) for l in lits], n - k, method)
|
|
498
|
+
|
|
499
|
+
def exactly_k(self, lits: Sequence[int], k: int, method: str = "auto") -> None:
|
|
500
|
+
self.at_most_k(lits, k, method)
|
|
501
|
+
self.at_least_k(lits, k, method)
|
|
502
|
+
|
|
503
|
+
# ------------------------------------------------------- pseudo-boolean
|
|
504
|
+
|
|
505
|
+
def pb_leq(self, weights: Sequence[int], lits: Sequence[int], bound: int) -> int:
|
|
506
|
+
"""Encode ``sum(w_i * l_i) <= bound`` as a BDD; returns the root literal.
|
|
507
|
+
|
|
508
|
+
The BDD is the reduced decision diagram of the constraint under the
|
|
509
|
+
input order given, built top-down with memoisation on
|
|
510
|
+
``(index, remaining_slack)``. Each node becomes one ITE gate, so the
|
|
511
|
+
encoding is GAC and its size is the BDD's size -- which for a single PB
|
|
512
|
+
constraint is O(n * bound) nodes in the worst case and usually far
|
|
513
|
+
smaller after reduction.
|
|
514
|
+
|
|
515
|
+
Negative weights are handled by the standard transformation
|
|
516
|
+
``w * l = w - w * ~l``, which is applied automatically.
|
|
517
|
+
"""
|
|
518
|
+
if len(weights) != len(lits):
|
|
519
|
+
raise ValueError("weights and literals must have equal length")
|
|
520
|
+
ws: list[int] = []
|
|
521
|
+
ls: list[int] = []
|
|
522
|
+
b = bound
|
|
523
|
+
for w, l in zip(weights, lits):
|
|
524
|
+
if w == 0:
|
|
525
|
+
continue
|
|
526
|
+
if w < 0:
|
|
527
|
+
b -= w # sum += -w * ~l after moving the constant across
|
|
528
|
+
ws.append(-w)
|
|
529
|
+
ls.append(neg(l))
|
|
530
|
+
else:
|
|
531
|
+
ws.append(w)
|
|
532
|
+
ls.append(l)
|
|
533
|
+
order = sorted(range(len(ws)), key=lambda i: -ws[i])
|
|
534
|
+
ws = [ws[i] for i in order]
|
|
535
|
+
ls = [ls[i] for i in order]
|
|
536
|
+
suffix = [0] * (len(ws) + 1)
|
|
537
|
+
for i in range(len(ws) - 1, -1, -1):
|
|
538
|
+
suffix[i] = suffix[i + 1] + ws[i]
|
|
539
|
+
memo: dict[tuple[int, int], int] = {}
|
|
540
|
+
|
|
541
|
+
def build(i: int, slack: int) -> int:
|
|
542
|
+
if slack < 0:
|
|
543
|
+
return self.false_lit
|
|
544
|
+
if slack >= suffix[i]:
|
|
545
|
+
return self.true_lit
|
|
546
|
+
key = (i, slack)
|
|
547
|
+
hit = memo.get(key)
|
|
548
|
+
if hit is not None:
|
|
549
|
+
return hit
|
|
550
|
+
hi = build(i + 1, slack - ws[i])
|
|
551
|
+
lo = build(i + 1, slack)
|
|
552
|
+
out = self.ite(ls[i], hi, lo)
|
|
553
|
+
memo[key] = out
|
|
554
|
+
return out
|
|
555
|
+
|
|
556
|
+
root = build(0, b)
|
|
557
|
+
return root
|
|
558
|
+
|
|
559
|
+
def pb_geq(self, weights: Sequence[int], lits: Sequence[int], bound: int) -> int:
|
|
560
|
+
"""``sum(w_i l_i) >= bound``, by negating every literal."""
|
|
561
|
+
total = sum(weights)
|
|
562
|
+
return self.pb_leq(weights, [neg(l) for l in lits], total - bound)
|
|
563
|
+
|
|
564
|
+
def assert_pb_leq(self, weights, lits, bound: int) -> None:
|
|
565
|
+
self.add([self.pb_leq(weights, lits, bound)])
|
|
566
|
+
|
|
567
|
+
def assert_pb_geq(self, weights, lits, bound: int) -> None:
|
|
568
|
+
self.add([self.pb_geq(weights, lits, bound)])
|
|
569
|
+
|
|
570
|
+
def assert_pb_eq(self, weights, lits, value: int) -> None:
|
|
571
|
+
self.assert_pb_leq(weights, lits, value)
|
|
572
|
+
self.assert_pb_geq(weights, lits, value)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# --------------------------------------------------------------------------
|
|
576
|
+
# totalizer
|
|
577
|
+
# --------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
class Totalizer:
|
|
581
|
+
"""Bailleux-Boufkhad totalizer: a unary counter tree over input literals.
|
|
582
|
+
|
|
583
|
+
``out[i]`` (0-based) is true iff **at least i+1** inputs are true. The
|
|
584
|
+
encoding is arc consistent, and both bounds are enforced by unit clauses on
|
|
585
|
+
the outputs:
|
|
586
|
+
|
|
587
|
+
* ``sum <= k`` -- assert ``~out[k]``
|
|
588
|
+
* ``sum >= k`` -- assert ``out[k-1]``
|
|
589
|
+
|
|
590
|
+
Because the bound is a *unit clause on an existing variable*, it can be
|
|
591
|
+
tightened at any later point without touching the encoding -- add another
|
|
592
|
+
unit, or pass it as an assumption to keep it retractable. That is the
|
|
593
|
+
property optimisation loops are built on, and it is why the totalizer is
|
|
594
|
+
the workhorse of modern MaxSAT solvers.
|
|
595
|
+
|
|
596
|
+
``max_count`` truncates the counter: outputs above the cut are never
|
|
597
|
+
created, since a bound of k never needs to distinguish k+2 from k+3. The
|
|
598
|
+
truncated version is still arc consistent for the bound it was built for.
|
|
599
|
+
"""
|
|
600
|
+
|
|
601
|
+
def __init__(self, enc: Encoder, lits: Sequence[int], max_count: int | None = None):
|
|
602
|
+
self.enc = enc
|
|
603
|
+
self.inputs = list(lits)
|
|
604
|
+
self.max_count = len(self.inputs) if max_count is None else min(
|
|
605
|
+
max_count, len(self.inputs)
|
|
606
|
+
)
|
|
607
|
+
self.outputs = self._build(self.inputs)
|
|
608
|
+
|
|
609
|
+
def _build(self, lits: Sequence[int]) -> list[int]:
|
|
610
|
+
if len(lits) == 1:
|
|
611
|
+
return [lits[0]]
|
|
612
|
+
mid = len(lits) // 2
|
|
613
|
+
a = self._build(lits[:mid])
|
|
614
|
+
b = self._build(lits[mid:])
|
|
615
|
+
return self._merge(a, b)
|
|
616
|
+
|
|
617
|
+
def _merge(self, a: list[int], b: list[int]) -> list[int]:
|
|
618
|
+
enc = self.enc
|
|
619
|
+
m, n = len(a), len(b)
|
|
620
|
+
size = min(m + n, self.max_count)
|
|
621
|
+
out = [enc.new_lit() for _ in range(size)]
|
|
622
|
+
# "at least" direction: alpha from a and beta from b imply alpha+beta
|
|
623
|
+
for alpha in range(m + 1):
|
|
624
|
+
for beta in range(n + 1):
|
|
625
|
+
sigma = alpha + beta
|
|
626
|
+
if sigma < 1 or sigma > size:
|
|
627
|
+
continue
|
|
628
|
+
clause = [out[sigma - 1]]
|
|
629
|
+
if alpha > 0:
|
|
630
|
+
clause.append(neg(a[alpha - 1]))
|
|
631
|
+
if beta > 0:
|
|
632
|
+
clause.append(neg(b[beta - 1]))
|
|
633
|
+
enc.add(clause)
|
|
634
|
+
# "at most" direction: not alpha and not beta imply not alpha+beta+1
|
|
635
|
+
for alpha in range(m + 1):
|
|
636
|
+
for beta in range(n + 1):
|
|
637
|
+
sigma = alpha + beta
|
|
638
|
+
if sigma >= size:
|
|
639
|
+
continue
|
|
640
|
+
clause = [neg(out[sigma])]
|
|
641
|
+
if alpha < m:
|
|
642
|
+
clause.append(a[alpha])
|
|
643
|
+
if beta < n:
|
|
644
|
+
clause.append(b[beta])
|
|
645
|
+
enc.add(clause)
|
|
646
|
+
return out
|
|
647
|
+
|
|
648
|
+
# -- bounds -------------------------------------------------------------
|
|
649
|
+
|
|
650
|
+
def at_most_lit(self, k: int) -> int | None:
|
|
651
|
+
"""Literal asserting ``sum <= k``; None when the bound is vacuous."""
|
|
652
|
+
if k >= len(self.inputs):
|
|
653
|
+
return None
|
|
654
|
+
if k < 0:
|
|
655
|
+
return self.enc.false_lit
|
|
656
|
+
if k >= len(self.outputs):
|
|
657
|
+
return None
|
|
658
|
+
return neg(self.outputs[k])
|
|
659
|
+
|
|
660
|
+
def at_least_lit(self, k: int) -> int | None:
|
|
661
|
+
if k <= 0:
|
|
662
|
+
return None
|
|
663
|
+
if k > len(self.inputs) or k > len(self.outputs):
|
|
664
|
+
return self.enc.false_lit
|
|
665
|
+
return self.outputs[k - 1]
|
|
666
|
+
|
|
667
|
+
def assert_at_most(self, k: int) -> None:
|
|
668
|
+
l = self.at_most_lit(k)
|
|
669
|
+
if l is not None:
|
|
670
|
+
self.enc.add([l])
|
|
671
|
+
|
|
672
|
+
def assert_at_least(self, k: int) -> None:
|
|
673
|
+
l = self.at_least_lit(k)
|
|
674
|
+
if l is not None:
|
|
675
|
+
self.enc.add([l])
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
# --------------------------------------------------------------------------
|
|
679
|
+
# free functions over a fresh CNF (convenience)
|
|
680
|
+
# --------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _wrap(target):
|
|
684
|
+
return target if isinstance(target, Encoder) else Encoder(target)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def at_most_one(target, lits, method="auto"):
|
|
688
|
+
_wrap(target).at_most_one(lits, method)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def at_least_one(target, lits):
|
|
692
|
+
_wrap(target).at_least_one(lits)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def exactly_one(target, lits, method="auto"):
|
|
696
|
+
_wrap(target).exactly_one(lits, method)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def at_most_k(target, lits, k, method="auto"):
|
|
700
|
+
_wrap(target).at_most_k(lits, k, method)
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def at_least_k(target, lits, k, method="auto"):
|
|
704
|
+
_wrap(target).at_least_k(lits, k, method)
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def exactly_k(target, lits, k, method="auto"):
|
|
708
|
+
_wrap(target).exactly_k(lits, k, method)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
# --------------------------------------------------------------------------
|
|
712
|
+
# optimisation on top of the totalizer
|
|
713
|
+
# --------------------------------------------------------------------------
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class Optimiser:
|
|
717
|
+
"""Incremental optimisation over a fixed set of soft literals.
|
|
718
|
+
|
|
719
|
+
Owns the totalizer, so the bound can be tightened repeatedly without
|
|
720
|
+
re-encoding anything and without the solver losing a single learnt clause.
|
|
721
|
+
That matters: the free function :func:`optimise` builds a totalizer per
|
|
722
|
+
call, so calling it twice on the same solver would encode the counter
|
|
723
|
+
twice. Use this class when you want to optimise more than once, resume
|
|
724
|
+
after a budget, or drive the search yourself.
|
|
725
|
+
|
|
726
|
+
Usage::
|
|
727
|
+
|
|
728
|
+
opt = Optimiser(solver, soft_lits)
|
|
729
|
+
best = opt.run() # linear SAT-UNSAT search
|
|
730
|
+
...
|
|
731
|
+
best = opt.run(target=best[0] - 5) # resume with a stronger demand
|
|
732
|
+
"""
|
|
733
|
+
|
|
734
|
+
def __init__(self, solver, soft_lits: Sequence[int], minimise: bool = True) -> None:
|
|
735
|
+
from .solver import Solver # local import: avoids a cycle at module load
|
|
736
|
+
|
|
737
|
+
assert isinstance(solver, Solver)
|
|
738
|
+
self.solver = solver
|
|
739
|
+
self.minimise = minimise
|
|
740
|
+
self.soft = list(soft_lits)
|
|
741
|
+
# counting "true among soft" for minimisation, "false among soft" for
|
|
742
|
+
# maximisation, which is the same counter over negated literals
|
|
743
|
+
self.counted = self.soft if minimise else [neg(l) for l in self.soft]
|
|
744
|
+
self.enc = Encoder(solver)
|
|
745
|
+
self.totalizer = Totalizer(self.enc, self.counted) if self.counted else None
|
|
746
|
+
self.best: tuple[int, list[bool]] | None = None
|
|
747
|
+
self.iterations = 0
|
|
748
|
+
|
|
749
|
+
# -- one step -----------------------------------------------------------
|
|
750
|
+
|
|
751
|
+
def count(self, model: Sequence[bool]) -> int:
|
|
752
|
+
return sum(1 for l in self.counted if model[l >> 1] != bool(l & 1))
|
|
753
|
+
|
|
754
|
+
def solve_with_bound(self, bound: int | None, permanent: bool = False):
|
|
755
|
+
"""Solve demanding ``count <= bound``. Returns a model or None."""
|
|
756
|
+
assumptions: list[int] = []
|
|
757
|
+
if bound is not None and self.totalizer is not None:
|
|
758
|
+
lit = self.totalizer.at_most_lit(bound)
|
|
759
|
+
if lit is not None:
|
|
760
|
+
if permanent:
|
|
761
|
+
self.solver.add_clause([lit])
|
|
762
|
+
else:
|
|
763
|
+
assumptions = [lit]
|
|
764
|
+
if not self.solver.solve(assumptions):
|
|
765
|
+
return None
|
|
766
|
+
return list(self.solver.model)
|
|
767
|
+
|
|
768
|
+
# -- the loop -----------------------------------------------------------
|
|
769
|
+
|
|
770
|
+
def run(self, target: int | None = None, max_iterations: int = 0, on_improve=None):
|
|
771
|
+
"""Linear SAT-UNSAT search from the current best.
|
|
772
|
+
|
|
773
|
+
Every iteration produces a real model, so stopping early still leaves
|
|
774
|
+
:attr:`best` holding the best solution found -- the property that
|
|
775
|
+
matters when there is a time budget.
|
|
776
|
+
"""
|
|
777
|
+
bound = target
|
|
778
|
+
while True:
|
|
779
|
+
model = self.solve_with_bound(bound)
|
|
780
|
+
if model is None:
|
|
781
|
+
break
|
|
782
|
+
self.iterations += 1
|
|
783
|
+
n = self.count(model)
|
|
784
|
+
self.best = (n, model)
|
|
785
|
+
if on_improve is not None:
|
|
786
|
+
on_improve(n, model)
|
|
787
|
+
if n == 0:
|
|
788
|
+
break
|
|
789
|
+
if max_iterations and self.iterations >= max_iterations:
|
|
790
|
+
break
|
|
791
|
+
bound = n - 1
|
|
792
|
+
return self.result()
|
|
793
|
+
|
|
794
|
+
def result(self):
|
|
795
|
+
if self.best is None:
|
|
796
|
+
return None
|
|
797
|
+
n, model = self.best
|
|
798
|
+
return (n if self.minimise else len(self.soft) - n, model)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def optimise(
|
|
802
|
+
|
|
803
|
+
solver,
|
|
804
|
+
soft_lits: Sequence[int],
|
|
805
|
+
minimise: bool = True,
|
|
806
|
+
assumption_based: bool = True,
|
|
807
|
+
on_improve=None,
|
|
808
|
+
) -> tuple[int, list[bool]] | None:
|
|
809
|
+
"""Minimise (or maximise) the number of true literals among ``soft_lits``.
|
|
810
|
+
|
|
811
|
+
Linear search from the top (SAT-UNSAT direction): solve, count, then demand
|
|
812
|
+
strictly better, repeat until UNSAT. Every intermediate result is a real
|
|
813
|
+
model, so the search can be stopped at any point and still return the best
|
|
814
|
+
solution found -- the property that matters when there is a time budget.
|
|
815
|
+
|
|
816
|
+
With ``assumption_based`` the bound is imposed as an *assumption* rather
|
|
817
|
+
than a permanent clause, so the totalizer never has to be rebuilt and the
|
|
818
|
+
solver keeps every clause it learned across iterations.
|
|
819
|
+
|
|
820
|
+
Returns ``(best_count, best_model)`` or None when the instance is UNSAT.
|
|
821
|
+
|
|
822
|
+
This builds a fresh totalizer, so calling it twice on the same solver
|
|
823
|
+
encodes the counter twice. Use :class:`Optimiser` when you need more than
|
|
824
|
+
one optimisation run against one solver.
|
|
825
|
+
"""
|
|
826
|
+
opt = Optimiser(solver, soft_lits, minimise=minimise)
|
|
827
|
+
if not assumption_based and opt.totalizer is not None:
|
|
828
|
+
# permanent-bound variant: each bound becomes a unit clause
|
|
829
|
+
bound = None
|
|
830
|
+
while True:
|
|
831
|
+
model = opt.solve_with_bound(bound, permanent=True)
|
|
832
|
+
if model is None:
|
|
833
|
+
break
|
|
834
|
+
n = opt.count(model)
|
|
835
|
+
opt.best = (n, model)
|
|
836
|
+
if on_improve is not None:
|
|
837
|
+
on_improve(n, model)
|
|
838
|
+
if n == 0:
|
|
839
|
+
break
|
|
840
|
+
bound = n - 1
|
|
841
|
+
return opt.result()
|
|
842
|
+
return opt.run(on_improve=on_improve)
|