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/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