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