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/heap.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
|
|
3
|
+
"""Indexed binary max-heap over an external activity array.
|
|
4
|
+
|
|
5
|
+
The VSIDS decision heuristic needs three operations that a plain ``heapq``
|
|
6
|
+
cannot provide:
|
|
7
|
+
|
|
8
|
+
1. ``pop_max`` -- take the unassigned variable with the highest activity;
|
|
9
|
+
2. ``bump`` -- increase the key of a variable that may already be inside the
|
|
10
|
+
heap and restore the invariant in O(log n);
|
|
11
|
+
3. ``insert`` -- put a variable back when it is unassigned during backtracking,
|
|
12
|
+
without inserting duplicates.
|
|
13
|
+
|
|
14
|
+
So the heap keeps a position index (``pos[v]`` = slot of ``v`` in the array, or
|
|
15
|
+
-1 when absent) alongside the array itself. Keys are *not* stored in the heap:
|
|
16
|
+
they live in the caller's ``act`` list and are read through it, so a bump is
|
|
17
|
+
"write ``act[v]``, then percolate up". That is exactly the MiniSat design.
|
|
18
|
+
|
|
19
|
+
Ties are broken by variable index (lower index wins) so that solver runs are
|
|
20
|
+
deterministic even when many activities are equal, which matters for
|
|
21
|
+
reproducible proofs and regression tests.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
__all__ = ["ActivityHeap"]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActivityHeap:
|
|
30
|
+
"""Max-heap of variables ordered by ``act[v]``, ties broken by index."""
|
|
31
|
+
|
|
32
|
+
__slots__ = ("act", "heap", "pos")
|
|
33
|
+
|
|
34
|
+
def __init__(self, act: list[float], capacity: int = 0) -> None:
|
|
35
|
+
self.act = act
|
|
36
|
+
self.heap: list[int] = []
|
|
37
|
+
self.pos: list[int] = [-1] * capacity
|
|
38
|
+
|
|
39
|
+
# -- capacity -----------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def grow(self, n: int) -> None:
|
|
42
|
+
"""Make room for variables ``[0, n)``."""
|
|
43
|
+
while len(self.pos) < n:
|
|
44
|
+
self.pos.append(-1)
|
|
45
|
+
|
|
46
|
+
# -- predicates ---------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def __len__(self) -> int:
|
|
49
|
+
return len(self.heap)
|
|
50
|
+
|
|
51
|
+
def __contains__(self, v: int) -> bool:
|
|
52
|
+
return v < len(self.pos) and self.pos[v] >= 0
|
|
53
|
+
|
|
54
|
+
def empty(self) -> bool:
|
|
55
|
+
return not self.heap
|
|
56
|
+
|
|
57
|
+
# -- ordering -----------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _better(self, a: int, b: int) -> bool:
|
|
60
|
+
"""True when ``a`` must sit above ``b``."""
|
|
61
|
+
act = self.act
|
|
62
|
+
aa = act[a]
|
|
63
|
+
ab = act[b]
|
|
64
|
+
if aa != ab:
|
|
65
|
+
return aa > ab
|
|
66
|
+
return a < b
|
|
67
|
+
|
|
68
|
+
def _up(self, i: int) -> None:
|
|
69
|
+
heap = self.heap
|
|
70
|
+
pos = self.pos
|
|
71
|
+
v = heap[i]
|
|
72
|
+
while i > 0:
|
|
73
|
+
parent = (i - 1) >> 1
|
|
74
|
+
pv = heap[parent]
|
|
75
|
+
if not self._better(v, pv):
|
|
76
|
+
break
|
|
77
|
+
heap[i] = pv
|
|
78
|
+
pos[pv] = i
|
|
79
|
+
i = parent
|
|
80
|
+
heap[i] = v
|
|
81
|
+
pos[v] = i
|
|
82
|
+
|
|
83
|
+
def _down(self, i: int) -> None:
|
|
84
|
+
heap = self.heap
|
|
85
|
+
pos = self.pos
|
|
86
|
+
n = len(heap)
|
|
87
|
+
v = heap[i]
|
|
88
|
+
while True:
|
|
89
|
+
left = 2 * i + 1
|
|
90
|
+
if left >= n:
|
|
91
|
+
break
|
|
92
|
+
right = left + 1
|
|
93
|
+
child = left
|
|
94
|
+
if right < n and self._better(heap[right], heap[left]):
|
|
95
|
+
child = right
|
|
96
|
+
cv = heap[child]
|
|
97
|
+
if not self._better(cv, v):
|
|
98
|
+
break
|
|
99
|
+
heap[i] = cv
|
|
100
|
+
pos[cv] = i
|
|
101
|
+
i = child
|
|
102
|
+
heap[i] = v
|
|
103
|
+
pos[v] = i
|
|
104
|
+
|
|
105
|
+
# -- mutation -----------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
def insert(self, v: int) -> None:
|
|
108
|
+
"""Insert ``v`` if it is not already present."""
|
|
109
|
+
if v >= len(self.pos):
|
|
110
|
+
self.grow(v + 1)
|
|
111
|
+
if self.pos[v] >= 0:
|
|
112
|
+
return
|
|
113
|
+
self.heap.append(v)
|
|
114
|
+
self.pos[v] = len(self.heap) - 1
|
|
115
|
+
self._up(len(self.heap) - 1)
|
|
116
|
+
|
|
117
|
+
def bump(self, v: int) -> None:
|
|
118
|
+
"""Restore the invariant after ``act[v]`` was increased."""
|
|
119
|
+
if v < len(self.pos) and self.pos[v] >= 0:
|
|
120
|
+
self._up(self.pos[v])
|
|
121
|
+
|
|
122
|
+
def pop_max(self) -> int:
|
|
123
|
+
"""Remove and return the variable with the largest activity."""
|
|
124
|
+
heap = self.heap
|
|
125
|
+
pos = self.pos
|
|
126
|
+
top = heap[0]
|
|
127
|
+
last = heap.pop()
|
|
128
|
+
pos[top] = -1
|
|
129
|
+
if heap:
|
|
130
|
+
heap[0] = last
|
|
131
|
+
pos[last] = 0
|
|
132
|
+
self._down(0)
|
|
133
|
+
return top
|
|
134
|
+
|
|
135
|
+
def peek_max(self) -> int:
|
|
136
|
+
return self.heap[0]
|
|
137
|
+
|
|
138
|
+
def remove(self, v: int) -> None:
|
|
139
|
+
"""Remove ``v`` from the heap if present."""
|
|
140
|
+
i = self.pos[v] if v < len(self.pos) else -1
|
|
141
|
+
if i < 0:
|
|
142
|
+
return
|
|
143
|
+
heap = self.heap
|
|
144
|
+
pos = self.pos
|
|
145
|
+
pos[v] = -1
|
|
146
|
+
last = heap.pop()
|
|
147
|
+
if i < len(heap):
|
|
148
|
+
heap[i] = last
|
|
149
|
+
pos[last] = i
|
|
150
|
+
self._up(i)
|
|
151
|
+
self._down(i)
|
|
152
|
+
|
|
153
|
+
def rebuild(self, variables) -> None:
|
|
154
|
+
"""Discard the contents and heapify ``variables`` from scratch."""
|
|
155
|
+
for v in self.heap:
|
|
156
|
+
self.pos[v] = -1
|
|
157
|
+
self.heap = list(variables)
|
|
158
|
+
for i, v in enumerate(self.heap):
|
|
159
|
+
if v >= len(self.pos):
|
|
160
|
+
self.grow(v + 1)
|
|
161
|
+
self.pos[v] = i
|
|
162
|
+
for i in range(len(self.heap) // 2 - 1, -1, -1):
|
|
163
|
+
self._down(i)
|
|
164
|
+
|
|
165
|
+
# -- debugging ----------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def check_invariant(self) -> bool:
|
|
168
|
+
"""Verify heap order and index consistency (used by the test suite)."""
|
|
169
|
+
for i, v in enumerate(self.heap):
|
|
170
|
+
if self.pos[v] != i:
|
|
171
|
+
return False
|
|
172
|
+
left, right = 2 * i + 1, 2 * i + 2
|
|
173
|
+
if left < len(self.heap) and self._better(self.heap[left], v):
|
|
174
|
+
return False
|
|
175
|
+
if right < len(self.heap) and self._better(self.heap[right], v):
|
|
176
|
+
return False
|
|
177
|
+
for v, p in enumerate(self.pos):
|
|
178
|
+
if p >= 0 and (p >= len(self.heap) or self.heap[p] != v):
|
|
179
|
+
return False
|
|
180
|
+
return True
|
cdclkit/model.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
|
|
3
|
+
"""A small modelling layer: boolean variables with operators, finite-domain
|
|
4
|
+
integers, and the global constraints that make combinatorial problems readable.
|
|
5
|
+
|
|
6
|
+
The point is to write the *problem*, not the CNF::
|
|
7
|
+
|
|
8
|
+
m = Model()
|
|
9
|
+
x = m.int_var(range(1, 10), "x")
|
|
10
|
+
y = m.int_var(range(1, 10), "y")
|
|
11
|
+
m.add(x != y)
|
|
12
|
+
m.all_different([x, y])
|
|
13
|
+
sol = m.solve()
|
|
14
|
+
print(sol[x], sol[y])
|
|
15
|
+
|
|
16
|
+
Integers are **one-hot** (direct) encoded: one boolean per value, exactly one
|
|
17
|
+
true. The alternatives and why they lost here:
|
|
18
|
+
|
|
19
|
+
* *order encoding* (``x >= v`` booleans) propagates inequalities better and is
|
|
20
|
+
the right choice for scheduling, but makes equality and all-different clumsy;
|
|
21
|
+
* *binary encoding* (log bits) is compact but propagates almost nothing --
|
|
22
|
+
fixing one bit rules out half the domain and unit propagation notices very
|
|
23
|
+
little;
|
|
24
|
+
* *one-hot* makes equality, membership and all-different into direct
|
|
25
|
+
cardinality constraints with arc-consistent encodings, which is what the
|
|
26
|
+
puzzle-shaped problems in ``examples/`` need.
|
|
27
|
+
|
|
28
|
+
Order-encoding channelling is provided by :meth:`Model.int_var(order=True)` for
|
|
29
|
+
the cases where inequality reasoning dominates: it adds the ``x >= v`` ladder
|
|
30
|
+
alongside the one-hot booleans and links them, giving both kinds of
|
|
31
|
+
propagation at the cost of n extra variables.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from typing import Iterable, Sequence
|
|
37
|
+
|
|
38
|
+
from dratify.cnf import CNF
|
|
39
|
+
from .encodings import Encoder
|
|
40
|
+
from dratify.lits import mk_lit, neg
|
|
41
|
+
from .solver import Config, Solver
|
|
42
|
+
|
|
43
|
+
__all__ = ["Model", "BoolVar", "IntVar", "Solution"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BoolVar:
|
|
47
|
+
"""A boolean, with operators that build expression trees for Tseitin."""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("lit", "model", "name")
|
|
50
|
+
|
|
51
|
+
def __init__(self, model: "Model", lit: int, name: str = "") -> None:
|
|
52
|
+
self.model = model
|
|
53
|
+
self.lit = lit
|
|
54
|
+
self.name = name
|
|
55
|
+
|
|
56
|
+
# expression building -- the results are plain tuples the Encoder speaks
|
|
57
|
+
def __invert__(self):
|
|
58
|
+
return BoolVar(self.model, neg(self.lit), f"~{self.name}")
|
|
59
|
+
|
|
60
|
+
def __and__(self, other):
|
|
61
|
+
return ("and", self.lit, _as_expr(other))
|
|
62
|
+
|
|
63
|
+
def __or__(self, other):
|
|
64
|
+
return ("or", self.lit, _as_expr(other))
|
|
65
|
+
|
|
66
|
+
def __xor__(self, other):
|
|
67
|
+
return ("xor", self.lit, _as_expr(other))
|
|
68
|
+
|
|
69
|
+
def __rshift__(self, other): # implication: a >> b
|
|
70
|
+
return ("imp", self.lit, _as_expr(other))
|
|
71
|
+
|
|
72
|
+
def iff(self, other):
|
|
73
|
+
return ("iff", self.lit, _as_expr(other))
|
|
74
|
+
|
|
75
|
+
def __repr__(self) -> str:
|
|
76
|
+
return f"BoolVar({self.name or self.lit})"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _as_expr(x):
|
|
80
|
+
if isinstance(x, BoolVar):
|
|
81
|
+
return x.lit
|
|
82
|
+
if isinstance(x, bool):
|
|
83
|
+
return x
|
|
84
|
+
return x
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class IntVar:
|
|
88
|
+
"""A finite-domain integer, one-hot encoded over ``domain``."""
|
|
89
|
+
|
|
90
|
+
__slots__ = ("model", "domain", "lits", "name", "ge_lits")
|
|
91
|
+
|
|
92
|
+
def __init__(self, model: "Model", domain: Sequence[int], name: str = "") -> None:
|
|
93
|
+
self.model = model
|
|
94
|
+
self.domain = list(domain)
|
|
95
|
+
self.name = name
|
|
96
|
+
self.lits = [model.new_lit(f"{name}={v}") for v in self.domain]
|
|
97
|
+
self.ge_lits: list[int] | None = None
|
|
98
|
+
model.enc.exactly_one(self.lits)
|
|
99
|
+
|
|
100
|
+
def is_(self, value: int) -> int:
|
|
101
|
+
"""Literal for ``self == value`` (a false constant if out of domain)."""
|
|
102
|
+
try:
|
|
103
|
+
return self.lits[self.domain.index(value)]
|
|
104
|
+
except ValueError:
|
|
105
|
+
return self.model.enc.false_lit
|
|
106
|
+
|
|
107
|
+
def __eq__(self, other): # type: ignore[override]
|
|
108
|
+
if isinstance(other, IntVar):
|
|
109
|
+
return ("and", *[
|
|
110
|
+
("iff", self.is_(v), other.is_(v)) for v in set(self.domain) | set(other.domain)
|
|
111
|
+
])
|
|
112
|
+
return self.is_(other)
|
|
113
|
+
|
|
114
|
+
def __ne__(self, other): # type: ignore[override]
|
|
115
|
+
if isinstance(other, IntVar):
|
|
116
|
+
return ("and", *[
|
|
117
|
+
("not", ("and", self.is_(v), other.is_(v)))
|
|
118
|
+
for v in set(self.domain) & set(other.domain)
|
|
119
|
+
])
|
|
120
|
+
return ("not", self.is_(other))
|
|
121
|
+
|
|
122
|
+
def in_(self, values: Iterable[int]) -> tuple:
|
|
123
|
+
vals = set(values)
|
|
124
|
+
return ("or", *[self.is_(v) for v in self.domain if v in vals])
|
|
125
|
+
|
|
126
|
+
# -- order encoding ----------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def build_order(self) -> list[int]:
|
|
129
|
+
"""Add the ``x >= v`` ladder and channel it to the one-hot booleans."""
|
|
130
|
+
if self.ge_lits is not None:
|
|
131
|
+
return self.ge_lits
|
|
132
|
+
enc = self.model.enc
|
|
133
|
+
ge = [self.model.new_lit(f"{self.name}>={v}") for v in self.domain]
|
|
134
|
+
for i in range(len(ge) - 1):
|
|
135
|
+
enc.add([neg(ge[i + 1]), ge[i]]) # x >= v+1 -> x >= v
|
|
136
|
+
enc.add([ge[0]]) # always >= min(domain)
|
|
137
|
+
for i, l in enumerate(self.lits):
|
|
138
|
+
enc.add([neg(l), ge[i]])
|
|
139
|
+
if i + 1 < len(ge):
|
|
140
|
+
enc.add([neg(l), neg(ge[i + 1])])
|
|
141
|
+
# channel back: (x >= v) and not (x >= v+1) -> x = v
|
|
142
|
+
body = [neg(ge[i]), l]
|
|
143
|
+
if i + 1 < len(ge):
|
|
144
|
+
body.append(ge[i + 1])
|
|
145
|
+
enc.add(body)
|
|
146
|
+
self.ge_lits = ge
|
|
147
|
+
return ge
|
|
148
|
+
|
|
149
|
+
def ge(self, value: int) -> int:
|
|
150
|
+
"""Literal for ``self >= value``."""
|
|
151
|
+
ge = self.build_order()
|
|
152
|
+
for i, v in enumerate(self.domain):
|
|
153
|
+
if v >= value:
|
|
154
|
+
return ge[i]
|
|
155
|
+
return self.model.enc.false_lit
|
|
156
|
+
|
|
157
|
+
def le(self, value: int) -> int:
|
|
158
|
+
return neg(self.ge(value + 1)) if value + 1 <= max(self.domain) else self.model.enc.true_lit
|
|
159
|
+
|
|
160
|
+
def __repr__(self) -> str:
|
|
161
|
+
return f"IntVar({self.name}, {self.domain[0]}..{self.domain[-1]})"
|
|
162
|
+
|
|
163
|
+
def __hash__(self):
|
|
164
|
+
return id(self)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class Solution:
|
|
168
|
+
"""Read values back out of a model."""
|
|
169
|
+
|
|
170
|
+
__slots__ = ("bits",)
|
|
171
|
+
|
|
172
|
+
def __init__(self, bits: Sequence[bool]) -> None:
|
|
173
|
+
self.bits = list(bits)
|
|
174
|
+
|
|
175
|
+
def _lit(self, l: int) -> bool:
|
|
176
|
+
return self.bits[l >> 1] != bool(l & 1)
|
|
177
|
+
|
|
178
|
+
def __getitem__(self, var):
|
|
179
|
+
if isinstance(var, BoolVar):
|
|
180
|
+
return self._lit(var.lit)
|
|
181
|
+
if isinstance(var, IntVar):
|
|
182
|
+
for v, l in zip(var.domain, var.lits):
|
|
183
|
+
if self._lit(l):
|
|
184
|
+
return v
|
|
185
|
+
raise KeyError(f"{var} has no value in this solution")
|
|
186
|
+
if isinstance(var, int):
|
|
187
|
+
return self._lit(var)
|
|
188
|
+
raise TypeError(type(var))
|
|
189
|
+
|
|
190
|
+
def value(self, var):
|
|
191
|
+
return self[var]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Model:
|
|
195
|
+
"""A problem being built. Owns the CNF, the encoder and the solver."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, config: Config | None = None,
|
|
198
|
+
encoding_method: str | None = None) -> None:
|
|
199
|
+
self.cnf = CNF()
|
|
200
|
+
self.enc = Encoder(self.cnf)
|
|
201
|
+
self.config = config
|
|
202
|
+
self.solver: Solver | None = None
|
|
203
|
+
self._names: dict[int, str] = {}
|
|
204
|
+
#: Overrides the `method` argument of every cardinality constraint.
|
|
205
|
+
#: Set by :func:`differential_solve` to build the same problem twice
|
|
206
|
+
#: with different encodings; None leaves each call's own choice alone.
|
|
207
|
+
self.encoding_method = encoding_method
|
|
208
|
+
|
|
209
|
+
#: methods each constraint kind accepts, mirroring cdclkit/encodings.py
|
|
210
|
+
AMO_METHODS = ("pairwise", "binary", "commander", "sequential")
|
|
211
|
+
AMK_METHODS = ("sequential", "totalizer")
|
|
212
|
+
|
|
213
|
+
def _method(self, method: str, valid: tuple[str, ...]) -> str:
|
|
214
|
+
"""Apply the model-wide override, but only where it means something.
|
|
215
|
+
|
|
216
|
+
`pairwise` is an at-most-one encoding and `totalizer` an at-most-k one,
|
|
217
|
+
so a single global override cannot apply to both. Constraints the
|
|
218
|
+
override does not fit keep the caller's choice rather than raising --
|
|
219
|
+
a differential run then varies the constraints the method applies to
|
|
220
|
+
and leaves the rest identical, which is still a valid comparison.
|
|
221
|
+
"""
|
|
222
|
+
if self.encoding_method and self.encoding_method in valid:
|
|
223
|
+
return self.encoding_method
|
|
224
|
+
return method
|
|
225
|
+
|
|
226
|
+
# -- variables ----------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def new_lit(self, name: str = "") -> int:
|
|
229
|
+
v = self.cnf.new_var(name or None)
|
|
230
|
+
return mk_lit(v)
|
|
231
|
+
|
|
232
|
+
def bool_var(self, name: str = "") -> BoolVar:
|
|
233
|
+
return BoolVar(self, self.new_lit(name), name)
|
|
234
|
+
|
|
235
|
+
def bool_vars(self, n: int, prefix: str = "b") -> list[BoolVar]:
|
|
236
|
+
return [self.bool_var(f"{prefix}{i}") for i in range(n)]
|
|
237
|
+
|
|
238
|
+
def int_var(self, domain: Iterable[int], name: str = "", order: bool = False) -> IntVar:
|
|
239
|
+
iv = IntVar(self, list(domain), name)
|
|
240
|
+
if order:
|
|
241
|
+
iv.build_order()
|
|
242
|
+
return iv
|
|
243
|
+
|
|
244
|
+
def int_vars(self, n: int, domain: Iterable[int], prefix: str = "n") -> list[IntVar]:
|
|
245
|
+
dom = list(domain)
|
|
246
|
+
return [self.int_var(dom, f"{prefix}{i}") for i in range(n)]
|
|
247
|
+
|
|
248
|
+
# -- constraints --------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def add(self, expr) -> None:
|
|
251
|
+
"""Assert an expression (a tree, a literal, or a BoolVar)."""
|
|
252
|
+
self.enc.assert_expr(_as_expr(expr))
|
|
253
|
+
|
|
254
|
+
def add_clause(self, lits: Iterable[int]) -> None:
|
|
255
|
+
self.enc.add([_as_expr(l) for l in lits])
|
|
256
|
+
|
|
257
|
+
def all_different(self, variables: Sequence[IntVar]) -> None:
|
|
258
|
+
"""Pairwise-distinct, encoded value by value.
|
|
259
|
+
|
|
260
|
+
For each value, at most one variable takes it -- which is exactly an
|
|
261
|
+
at-most-one constraint over the one-hot literals for that value, and so
|
|
262
|
+
inherits the arc consistency of the chosen at-most-one encoding. This
|
|
263
|
+
is strictly stronger propagation than pairwise ``x != y`` clauses and
|
|
264
|
+
uses fewer clauses once the domain is larger than a handful.
|
|
265
|
+
"""
|
|
266
|
+
values = sorted({v for x in variables for v in x.domain})
|
|
267
|
+
for val in values:
|
|
268
|
+
lits = [x.is_(val) for x in variables if val in x.domain]
|
|
269
|
+
if len(lits) > 1:
|
|
270
|
+
self.enc.at_most_one(lits)
|
|
271
|
+
|
|
272
|
+
def all_different_permutation(self, variables: Sequence[IntVar]) -> None:
|
|
273
|
+
"""All-different where #variables == #values: adds the exactly-one
|
|
274
|
+
constraint in the other direction too, a redundant constraint that
|
|
275
|
+
cuts search dramatically on Latin-square-shaped problems."""
|
|
276
|
+
self.all_different(variables)
|
|
277
|
+
values = sorted({v for x in variables for v in x.domain})
|
|
278
|
+
if len(values) == len(variables):
|
|
279
|
+
for val in values:
|
|
280
|
+
self.enc.at_least_one([x.is_(val) for x in variables if val in x.domain])
|
|
281
|
+
|
|
282
|
+
def at_most_one(self, items, method: str = "auto") -> None:
|
|
283
|
+
self.enc.at_most_one([_lit_of(i) for i in items],
|
|
284
|
+
self._method(method, self.AMO_METHODS))
|
|
285
|
+
|
|
286
|
+
def exactly_one(self, items, method: str = "auto") -> None:
|
|
287
|
+
self.enc.exactly_one([_lit_of(i) for i in items],
|
|
288
|
+
self._method(method, self.AMO_METHODS))
|
|
289
|
+
|
|
290
|
+
def at_most_k(self, items, k: int, method: str = "auto") -> None:
|
|
291
|
+
self.enc.at_most_k([_lit_of(i) for i in items], k,
|
|
292
|
+
self._method(method, self.AMK_METHODS))
|
|
293
|
+
|
|
294
|
+
def at_least_k(self, items, k: int, method: str = "auto") -> None:
|
|
295
|
+
self.enc.at_least_k([_lit_of(i) for i in items], k,
|
|
296
|
+
self._method(method, self.AMK_METHODS))
|
|
297
|
+
|
|
298
|
+
def exactly_k(self, items, k: int, method: str = "auto") -> None:
|
|
299
|
+
self.enc.exactly_k([_lit_of(i) for i in items], k,
|
|
300
|
+
self._method(method, self.AMK_METHODS))
|
|
301
|
+
|
|
302
|
+
def sum_leq(self, weights: Sequence[int], items, bound: int) -> None:
|
|
303
|
+
self.enc.assert_pb_leq(weights, [_lit_of(i) for i in items], bound)
|
|
304
|
+
|
|
305
|
+
def sum_geq(self, weights: Sequence[int], items, bound: int) -> None:
|
|
306
|
+
self.enc.assert_pb_geq(weights, [_lit_of(i) for i in items], bound)
|
|
307
|
+
|
|
308
|
+
def parity(self, items, odd: bool = True) -> None:
|
|
309
|
+
self.enc.xor_chain([_lit_of(i) for i in items], value=odd)
|
|
310
|
+
|
|
311
|
+
# -- solving ------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
def build_solver(self, proof=None) -> Solver:
|
|
314
|
+
s = Solver(self.cnf.nvars, proof=proof, config=self.config)
|
|
315
|
+
s.add_cnf(self.cnf)
|
|
316
|
+
self.solver = s
|
|
317
|
+
return s
|
|
318
|
+
|
|
319
|
+
def solve(self, proof=None, assumptions: Sequence[int] = ()) -> Solution | None:
|
|
320
|
+
s = self.solver if self.solver is not None else self.build_solver(proof)
|
|
321
|
+
if s.nvars < self.cnf.nvars: # new constraints since the last build
|
|
322
|
+
s = self.build_solver(proof)
|
|
323
|
+
return Solution(s.model) if s.solve(assumptions) else None
|
|
324
|
+
|
|
325
|
+
def solutions(self, project: Sequence[IntVar | BoolVar] | None = None, limit: int = 0):
|
|
326
|
+
"""Iterate over distinct solutions, optionally projected onto variables."""
|
|
327
|
+
s = self.build_solver()
|
|
328
|
+
proj = None
|
|
329
|
+
if project is not None:
|
|
330
|
+
proj = []
|
|
331
|
+
for v in project:
|
|
332
|
+
if isinstance(v, IntVar):
|
|
333
|
+
proj.extend(l >> 1 for l in v.lits)
|
|
334
|
+
else:
|
|
335
|
+
proj.append(v.lit >> 1)
|
|
336
|
+
for bits in s.enumerate_models(projection=proj, limit=limit):
|
|
337
|
+
yield Solution(bits)
|
|
338
|
+
|
|
339
|
+
def stats(self) -> dict:
|
|
340
|
+
return self.cnf.stats()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _lit_of(x) -> int:
|
|
344
|
+
if isinstance(x, BoolVar):
|
|
345
|
+
return x.lit
|
|
346
|
+
if isinstance(x, int):
|
|
347
|
+
return x
|
|
348
|
+
raise TypeError(f"expected a literal or BoolVar, got {type(x)}")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# --------------------------------------------------------------------------
|
|
352
|
+
# differential encoding
|
|
353
|
+
# --------------------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
class EncodingDisagreement(AssertionError):
|
|
357
|
+
"""Two encodings of the same problem reached different verdicts.
|
|
358
|
+
|
|
359
|
+
One of them is wrong, and neither the solver nor its proof can tell you
|
|
360
|
+
which: a DRAT refutation certifies *the CNF it was given*, not that the CNF
|
|
361
|
+
says what you meant. That translation is the last unchecked step in every
|
|
362
|
+
verification pipeline, this one included, and it is the step nobody
|
|
363
|
+
verifies -- the formally verified solvers and checkers all take CNF as
|
|
364
|
+
their input and start from there.
|
|
365
|
+
"""
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def differential_solve(build, methods=("pairwise", "commander"),
|
|
369
|
+
config: Config | None = None, verify: bool = True):
|
|
370
|
+
"""Build the same problem under two encodings; require the same verdict.
|
|
371
|
+
|
|
372
|
+
`build(model)` constructs the problem. It is called once per method, on a
|
|
373
|
+
fresh :class:`Model` whose `encoding_method` is set, so the *constraints*
|
|
374
|
+
are identical and only their translation to clauses differs.
|
|
375
|
+
|
|
376
|
+
This is the two-checker discipline applied one level up. The solver already
|
|
377
|
+
has two independent implementations that must agree, and every UNSAT answer
|
|
378
|
+
already carries a proof replayed by a checker sharing no code with it. None
|
|
379
|
+
of that touches the encoder. Encoding a cardinality constraint two ways and
|
|
380
|
+
requiring the same answer is the cheapest available check on the one
|
|
381
|
+
remaining unverified translation.
|
|
382
|
+
|
|
383
|
+
Returns the :class:`Solution` (or None for unsatisfiable) from the first
|
|
384
|
+
method. Raises :class:`EncodingDisagreement` when the methods disagree.
|
|
385
|
+
|
|
386
|
+
>>> def build(m):
|
|
387
|
+
... xs = m.bool_vars(5)
|
|
388
|
+
... m.at_most_k(xs, 2)
|
|
389
|
+
... m.add_clause([_lit_of(x) for x in xs])
|
|
390
|
+
>>> differential_solve(build, methods=("sequential", "totalizer"))
|
|
391
|
+
... # doctest: +ELLIPSIS
|
|
392
|
+
<...Solution...>
|
|
393
|
+
"""
|
|
394
|
+
verdicts, first = [], None
|
|
395
|
+
for method in methods:
|
|
396
|
+
m = Model(config=config, encoding_method=method)
|
|
397
|
+
build(m)
|
|
398
|
+
sol = m.solve()
|
|
399
|
+
# A satisfying assignment must satisfy the formula that produced it.
|
|
400
|
+
# Checking here means a disagreement is attributed to the encoding
|
|
401
|
+
# rather than to the solver, which is the whole point of the exercise.
|
|
402
|
+
if verify and sol is not None and not m.cnf.is_satisfied_by(sol.bits):
|
|
403
|
+
raise EncodingDisagreement(
|
|
404
|
+
f"the {method!r} encoding produced a model that does not "
|
|
405
|
+
f"satisfy its own CNF -- that is a solver bug, not an "
|
|
406
|
+
f"encoding one"
|
|
407
|
+
)
|
|
408
|
+
verdicts.append((method, sol is not None))
|
|
409
|
+
if first is None:
|
|
410
|
+
first = sol
|
|
411
|
+
|
|
412
|
+
answers = {sat for _, sat in verdicts}
|
|
413
|
+
if len(answers) > 1:
|
|
414
|
+
detail = ", ".join(f"{m}={'SAT' if v else 'UNSAT'}" for m, v in verdicts)
|
|
415
|
+
raise EncodingDisagreement(
|
|
416
|
+
f"encodings disagree on the same problem: {detail}. One of these "
|
|
417
|
+
f"translations is wrong. A proof would not have caught this: it "
|
|
418
|
+
f"certifies the clauses, not that the clauses mean the constraint."
|
|
419
|
+
)
|
|
420
|
+
return first
|