cdclkit 0.1.6__tar.gz → 0.2.0__tar.gz

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.
Files changed (47) hide show
  1. {cdclkit-0.1.6 → cdclkit-0.2.0}/PKG-INFO +14 -5
  2. {cdclkit-0.1.6 → cdclkit-0.2.0}/README.md +11 -2
  3. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/__init__.py +1 -1
  4. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/cli.py +45 -28
  5. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/encodings.py +92 -27
  6. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/model.py +111 -15
  7. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/mus.py +48 -19
  8. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/native.py +5 -3
  9. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/pipeline.py +25 -5
  10. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/portfolio.py +48 -20
  11. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/preprocess.py +12 -2
  12. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/pyeq.py +158 -27
  13. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/solver.py +99 -15
  14. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit.egg-info/PKG-INFO +14 -5
  15. cdclkit-0.2.0/cdclkit.egg-info/requires.txt +4 -0
  16. {cdclkit-0.1.6 → cdclkit-0.2.0}/pyproject.toml +3 -3
  17. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_adaptive_proof.py +160 -0
  18. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_encodings.py +568 -0
  19. cdclkit-0.2.0/tests/test_mus.py +364 -0
  20. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_native_solver.py +88 -1
  21. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_portfolio.py +26 -0
  22. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_preprocess.py +109 -0
  23. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_pyeq.py +388 -1
  24. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_target_scaling.py +21 -12
  25. cdclkit-0.1.6/cdclkit.egg-info/requires.txt +0 -4
  26. cdclkit-0.1.6/tests/test_mus.py +0 -151
  27. {cdclkit-0.1.6 → cdclkit-0.2.0}/LICENSE +0 -0
  28. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/__main__.py +0 -0
  29. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/brute.py +0 -0
  30. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/heap.py +0 -0
  31. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit/py.typed +0 -0
  32. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit.egg-info/SOURCES.txt +0 -0
  33. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit.egg-info/dependency_links.txt +0 -0
  34. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit.egg-info/entry_points.txt +0 -0
  35. {cdclkit-0.1.6 → cdclkit-0.2.0}/cdclkit.egg-info/top_level.txt +0 -0
  36. {cdclkit-0.1.6 → cdclkit-0.2.0}/setup.cfg +0 -0
  37. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_agent_docs.py +0 -0
  38. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_brute.py +0 -0
  39. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_cli_defaults.py +0 -0
  40. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_core.py +0 -0
  41. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_integration.py +0 -0
  42. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_native.py +0 -0
  43. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_packaging.py +0 -0
  44. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_pipeline.py +0 -0
  45. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_proof.py +0 -0
  46. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_readme.py +0 -0
  47. {cdclkit-0.1.6 → cdclkit-0.2.0}/tests/test_solver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cdclkit
3
- Version: 0.1.6
3
+ Version: 0.2.0
4
4
  Summary: A CDCL SAT solver, preprocessor, encoding library and modelling layer, written from scratch in readable Python. Every answer comes with a certificate.
5
5
  Author: Carlo Perassi
6
6
  License-Expression: Apache-2.0
@@ -23,9 +23,9 @@ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
23
  Requires-Python: >=3.10
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
- Requires-Dist: dratify>=0.1.7
26
+ Requires-Dist: dratify>=0.1.11
27
27
  Provides-Extra: native
28
- Requires-Dist: cdclkit-native==0.1.6; extra == "native"
28
+ Requires-Dist: cdclkit-native==0.2.0; extra == "native"
29
29
  Dynamic: license-file
30
30
 
31
31
  # cdclkit
@@ -140,8 +140,8 @@ checking a refactored Python function against the original.
140
140
  | `pyeq` | **experimental** — bounded equivalence of two Python integer functions |
141
141
 
142
142
  An optional Rust engine ([`cdclkit-native`](https://pypi.org/project/cdclkit-native/))
143
- is **bit-exact** with the Python one — identical conflicts, decisions and
144
- propagations on every instance — and about **20x faster**: the geometric mean
143
+ runs a search **bit-exact** with the Python one — identical conflicts, decisions
144
+ and propagations on every instance — and about **20x faster**: the geometric mean
145
145
  of per-instance ratios over the 17 benchmark instances is 20.1x, ranging from
146
146
  11x to 51x. That comes from two checkpoints recorded 34 seconds apart at the
147
147
  same commit, one sample per instance — good enough for the mean, not for any
@@ -199,6 +199,15 @@ otherwise.
199
199
  (0 of 105). See `experiments/pyeq-llm-refactor/report.md` for the full
200
200
  negative result.
201
201
  - Every performance figure comes from one machine.
202
+ - Bit-exactness stops at the search. The two preprocessors differ: native has no
203
+ blocked clause elimination and visits clauses in another order. So once
204
+ preprocessing runs, on `--adaptive` past its probe for example, an engine can
205
+ give a different model, conflict count and proof. No verdict has differed, and
206
+ every model satisfied its formula. Proof streams differ on search-only routes
207
+ too, and each one replays.
208
+ - Preprocessed proofs delete a unit clause and then rely on it. They check under
209
+ the rule dratify documents, which ignores such deletions, and which drat-trim
210
+ applies by default. A checker honouring every deletion to the letter rejects them.
202
211
 
203
212
  ## Documentation
204
213
 
@@ -110,8 +110,8 @@ checking a refactored Python function against the original.
110
110
  | `pyeq` | **experimental** — bounded equivalence of two Python integer functions |
111
111
 
112
112
  An optional Rust engine ([`cdclkit-native`](https://pypi.org/project/cdclkit-native/))
113
- is **bit-exact** with the Python one — identical conflicts, decisions and
114
- propagations on every instance — and about **20x faster**: the geometric mean
113
+ runs a search **bit-exact** with the Python one — identical conflicts, decisions
114
+ and propagations on every instance — and about **20x faster**: the geometric mean
115
115
  of per-instance ratios over the 17 benchmark instances is 20.1x, ranging from
116
116
  11x to 51x. That comes from two checkpoints recorded 34 seconds apart at the
117
117
  same commit, one sample per instance — good enough for the mean, not for any
@@ -169,6 +169,15 @@ otherwise.
169
169
  (0 of 105). See `experiments/pyeq-llm-refactor/report.md` for the full
170
170
  negative result.
171
171
  - Every performance figure comes from one machine.
172
+ - Bit-exactness stops at the search. The two preprocessors differ: native has no
173
+ blocked clause elimination and visits clauses in another order. So once
174
+ preprocessing runs, on `--adaptive` past its probe for example, an engine can
175
+ give a different model, conflict count and proof. No verdict has differed, and
176
+ every model satisfied its formula. Proof streams differ on search-only routes
177
+ too, and each one replays.
178
+ - Preprocessed proofs delete a unit clause and then rely on it. They check under
179
+ the rule dratify documents, which ignores such deletions, and which drat-trim
180
+ applies by default. A checker honouring every deletion to the letter rejects them.
172
181
 
173
182
  ## Documentation
174
183
 
@@ -52,7 +52,7 @@ from .preprocess import Preprocessor, preprocess
52
52
  from dratify.proof import DRATChecker, MemoryProof, ProofWriter, check_proof
53
53
  from .solver import Config, SAT, Solver, Stats, UNKNOWN, UNSAT
54
54
 
55
- __version__ = "0.1.6"
55
+ __version__ = "0.2.0"
56
56
 
57
57
  __all__ = [
58
58
  "CNF",
@@ -90,6 +90,8 @@ def _emit_model(model: Sequence[bool], out=None, per_line: int = 20) -> None:
90
90
 
91
91
 
92
92
  def cmd_solve(args) -> int:
93
+ if args.conflicts is not None and args.conflicts < 0:
94
+ raise ValueError(f"--conflicts must not be negative, got {args.conflicts}")
93
95
  f = parse_dimacs_file(args.file)
94
96
  print(f"c cdclkit :: {args.file}")
95
97
  print(f"c formula: {f.nvars} vars, {f.nclauses} clauses")
@@ -144,20 +146,15 @@ def cmd_solve(args) -> int:
144
146
  config=cfg,
145
147
  jobs=jobs,
146
148
  proof=proof_sink,
149
+ max_conflicts=args.conflicts,
147
150
  )
148
151
  print(pr.report())
149
152
  print(f"c {pr.conflicts} conflicts in {pr.seconds:.3f}s")
153
+ if pr.sat is None:
154
+ print("s UNKNOWN")
155
+ return EXIT_UNKNOWN
150
156
  if pr.sat:
151
- if args.check_model:
152
- base = original if original is not None else f
153
- if base.falsified_clauses(pr.model):
154
- print("c MODEL CHECK FAILED")
155
- return EXIT_ERROR
156
- print("c model verified against the input formula")
157
- print("s SATISFIABLE")
158
- if not args.no_model:
159
- _emit_model(pr.model)
160
- return EXIT_SAT
157
+ return _finish_sat(args, pr.model, pre, original)
161
158
  print("s UNSATISFIABLE")
162
159
  # The proof covers preprocessing *and* the search, so it is checked
163
160
  # against the original formula, not the reduced one.
@@ -173,7 +170,8 @@ def cmd_solve(args) -> int:
173
170
  if engine is None:
174
171
  return EXIT_ERROR
175
172
  pr = solve_portfolio(f, jobs=jobs, engine=engine,
176
- want_proof=proof_sink is not None)
173
+ want_proof=proof_sink is not None,
174
+ max_conflicts=args.conflicts)
177
175
  if not pr.finished:
178
176
  print("s UNKNOWN")
179
177
  return EXIT_UNKNOWN
@@ -197,25 +195,39 @@ def cmd_solve(args) -> int:
197
195
  print("s UNKNOWN")
198
196
  return EXIT_UNKNOWN
199
197
  if result:
200
- model = s.model
201
- if pre is not None:
202
- model = pre.reconstruct(model)
203
- if args.check_model:
204
- base = original if original is not None else f
205
- bad = base.falsified_clauses(model)
206
- if bad:
207
- print(f"c MODEL CHECK FAILED: {len(bad)} clauses unsatisfied")
208
- return EXIT_ERROR
209
- print("c model verified against the input formula")
210
- print("s SATISFIABLE")
211
- if not args.no_model:
212
- _emit_model(model)
213
- return EXIT_SAT
198
+ return _finish_sat(args, s.model, pre, original)
214
199
 
215
200
  print("s UNSATISFIABLE")
216
201
  return _finish_unsat(args, original, mem_proof, proof_sink)
217
202
 
218
203
 
204
+ def _finish_sat(args, model, pre, original) -> int:
205
+ """Reconstruct, check if asked, then print the model.
206
+
207
+ Shared by every route, for the reason `_finish_unsat` is. The adaptive
208
+ branch had its own copy that returned before the only `pre.reconstruct`, so
209
+ `--preprocess --adaptive` printed a model of the reduced formula as the
210
+ answer: `v -1` for `(x1)`, exit 10.
211
+
212
+ `--self-check` checks a model as well as a proof. Evaluating the clauses
213
+ costs one pass over the input, far less than the replay an UNSAT answer
214
+ gets, and a self-check that only looks at one of the two verdicts leaves
215
+ the other on trust.
216
+ """
217
+ if pre is not None:
218
+ model = pre.reconstruct(model)
219
+ if original is not None: # kept for exactly --check-model and --self-check
220
+ bad = original.falsified_clauses(model)
221
+ if bad:
222
+ print(f"c MODEL CHECK FAILED: {len(bad)} clauses unsatisfied")
223
+ return EXIT_ERROR
224
+ print("c model verified against the input formula")
225
+ print("s SATISFIABLE")
226
+ if not args.no_model:
227
+ _emit_model(model)
228
+ return EXIT_SAT
229
+
230
+
219
231
  def _finish_unsat(args, formula, mem_proof, proof_sink) -> int:
220
232
  """Self-check or write out the proof, then report.
221
233
 
@@ -456,7 +468,9 @@ def build_parser() -> argparse.ArgumentParser:
456
468
  s.add_argument("file")
457
469
  s.add_argument("--proof", help="write a DRAT proof here (UNSAT only)")
458
470
  s.add_argument("--self-check", action="store_true",
459
- help="keep the proof in memory and verify it before reporting UNSAT")
471
+ help="keep the proof in memory and verify it before reporting "
472
+ "UNSAT; verify the model against the input before "
473
+ "reporting SAT")
460
474
  s.add_argument("--check-model", action="store_true",
461
475
  help="verify the model against the input before reporting SAT")
462
476
  s.add_argument("--preprocess", action="store_true")
@@ -464,7 +478,9 @@ def build_parser() -> argparse.ArgumentParser:
464
478
  help="probe briefly, then preprocess only if the instance "
465
479
  "turns out to be hard enough to repay it")
466
480
  s.add_argument("--prep-rounds", type=int, default=3)
467
- s.add_argument("--conflicts", type=int, default=None, help="conflict budget")
481
+ s.add_argument("--conflicts", type=int, default=None,
482
+ help="conflict budget; under -j, per worker. Out of budget is "
483
+ "s UNKNOWN, exit 0")
468
484
  # Every search default comes from Config() rather than a literal. They were
469
485
  # literals, and --restart drifted to "glucose" while the library moved to
470
486
  # "luby" -- so the CLI quietly ran the policy the CHANGELOG says was
@@ -521,7 +537,8 @@ def build_parser() -> argparse.ArgumentParser:
521
537
  mu.add_argument("--method", default="deletion",
522
538
  choices=["deletion", "quickxplain", "core"])
523
539
  mu.add_argument("--verify", action="store_true",
524
- help="check that the result is unsatisfiable and minimal")
540
+ help="check that the result is unsatisfiable and minimal; a "
541
+ "core need not be, so --method core can exit 1 here")
525
542
  mu.set_defaults(func=cmd_mus)
526
543
 
527
544
  st = sub.add_parser("stats", help="formula statistics")
@@ -101,6 +101,12 @@ class Encoder:
101
101
  halves the clause count, and :meth:`tseitin` does apply it when you tell it
102
102
  the polarity; the individual ``*_gate`` helpers stay complete because they
103
103
  are also used to *define* variables the caller may reference either way.
104
+
105
+ A constraint may name variables the sink has not seen, as ``from_dimacs``
106
+ makes easy: anything that needs an auxiliary variable first grows the sink to
107
+ cover its own inputs. It cannot know about inputs of a *later* constraint, so
108
+ a program that numbers its own variables should declare them before the first
109
+ constraint rather than leave them to be taken.
104
110
  """
105
111
 
106
112
  #: Cap on direct XOR expansion, which emits 2^(k-1) clauses. 16 inputs is
@@ -143,6 +149,23 @@ class Encoder:
143
149
  for c in clauses:
144
150
  self.add(c)
145
151
 
152
+ def _declare(self, lits: Iterable[int]) -> None:
153
+ """Grow the sink to cover `lits`, before asking it for a fresh variable.
154
+
155
+ Its next variable is otherwise whatever it has not seen, which may be one
156
+ of these: the binary encoding of two undeclared inputs read `x1 -> ~x1`.
157
+ """
158
+ top = max(lits, default=-1) >> 1
159
+ while self.sink.nvars <= top:
160
+ self.sink.new_var()
161
+
162
+ def _declare_expr(self, expr) -> None:
163
+ if isinstance(expr, tuple):
164
+ for e in expr[1:]:
165
+ self._declare_expr(e)
166
+ elif not isinstance(expr, bool):
167
+ self._declare((expr,))
168
+
146
169
  @property
147
170
  def true_lit(self) -> int:
148
171
  """A literal that is forced true, created on first use."""
@@ -160,6 +183,7 @@ class Encoder:
160
183
 
161
184
  def and_gate(self, lits: Sequence[int], out: int | None = None) -> int:
162
185
  """``out <-> AND(lits)``. Returns ``out``."""
186
+ self._declare(lits if out is None else [*lits, out])
163
187
  lits = self._simplify_and(lits)
164
188
  if lits is None:
165
189
  return self.false_lit
@@ -185,6 +209,7 @@ class Encoder:
185
209
 
186
210
  def xor_gate(self, a: int, b: int, out: int | None = None) -> int:
187
211
  """``out <-> a XOR b``."""
212
+ self._declare((a, b) if out is None else (a, b, out))
188
213
  key = ("xor", min(a, b), max(a, b))
189
214
  if out is None and key in self._cache:
190
215
  return self._cache[key]
@@ -205,6 +230,7 @@ class Encoder:
205
230
  on the chain is as strong as on the direct form as long as literals are
206
231
  fixed from the ends inwards.
207
232
  """
233
+ self._declare(lits)
208
234
  if not lits:
209
235
  if value:
210
236
  self.add([]) # empty XOR is false; asserting true is a contradiction
@@ -225,6 +251,7 @@ class Encoder:
225
251
 
226
252
  def ite(self, c: int, t: int, e: int, out: int | None = None) -> int:
227
253
  """``out <-> (c ? t : e)``, the primitive every BDD encoding needs."""
254
+ self._declare((c, t, e) if out is None else (c, t, e, out))
228
255
  if t == e:
229
256
  return t
230
257
  key = ("ite", c, t, e)
@@ -275,7 +302,7 @@ class Encoder:
275
302
 
276
303
  ("and", e1, e2, ...) ("or", ...) ("xor", e1, e2)
277
304
  ("not", e) ("ite", c, t, e) ("imp", a, b)
278
- ("iff", a, b) <int literal>
305
+ ("iff", a, b) <int literal> True False
279
306
 
280
307
  ``polarity`` is +1 when the expression only ever needs to be *forced
281
308
  true* (so only the ``inputs -> out`` direction is required), -1 for the
@@ -284,33 +311,40 @@ class Encoder:
284
311
  occurs with one polarity can always be set to the value its definition
285
312
  prescribes.
286
313
  """
314
+ self._declare_expr(expr)
315
+ return self._tseitin(expr, polarity)
316
+
317
+ def _tseitin(self, expr, polarity: int) -> int:
318
+ # before the int test, because True is the int 1 and 1 is a literal
319
+ if isinstance(expr, bool):
320
+ return self.true_lit if expr else self.false_lit
287
321
  if isinstance(expr, int):
288
322
  return expr
289
323
  op = expr[0]
290
324
  if op == "not":
291
- return neg(self.tseitin(expr[1], -polarity))
325
+ return neg(self._tseitin(expr[1], -polarity))
292
326
  if op == "and":
293
- kids = [self.tseitin(e, polarity) for e in expr[1:]]
327
+ kids = [self._tseitin(e, polarity) for e in expr[1:]]
294
328
  return self._and_pol(kids, polarity)
295
329
  if op == "or":
296
- kids = [self.tseitin(e, polarity) for e in expr[1:]]
330
+ kids = [self._tseitin(e, polarity) for e in expr[1:]]
297
331
  return neg(self._and_pol([neg(k) for k in kids], -polarity))
298
332
  if op == "imp":
299
- a = self.tseitin(expr[1], -polarity)
300
- b = self.tseitin(expr[2], polarity)
333
+ a = self._tseitin(expr[1], -polarity)
334
+ b = self._tseitin(expr[2], polarity)
301
335
  return neg(self._and_pol([a, neg(b)], -polarity))
302
336
  if op == "iff":
303
- a = self.tseitin(expr[1], 0)
304
- b = self.tseitin(expr[2], 0)
337
+ a = self._tseitin(expr[1], 0)
338
+ b = self._tseitin(expr[2], 0)
305
339
  return neg(self.xor_gate(a, b))
306
340
  if op == "xor":
307
- a = self.tseitin(expr[1], 0)
308
- b = self.tseitin(expr[2], 0)
341
+ a = self._tseitin(expr[1], 0)
342
+ b = self._tseitin(expr[2], 0)
309
343
  return self.xor_gate(a, b)
310
344
  if op == "ite":
311
- c = self.tseitin(expr[1], 0)
312
- t = self.tseitin(expr[2], polarity)
313
- e = self.tseitin(expr[3], polarity)
345
+ c = self._tseitin(expr[1], 0)
346
+ t = self._tseitin(expr[2], polarity)
347
+ e = self._tseitin(expr[3], polarity)
314
348
  return self.ite(c, t, e)
315
349
  raise ValueError(f"unknown operator {op!r}")
316
350
 
@@ -334,14 +368,15 @@ class Encoder:
334
368
 
335
369
  def assert_expr(self, expr) -> None:
336
370
  """Force an expression tree to be true, with polarity optimisation."""
371
+ self._declare_expr(expr)
337
372
  if isinstance(expr, tuple) and expr[0] == "and":
338
373
  for e in expr[1:]:
339
374
  self.assert_expr(e)
340
375
  return
341
376
  if isinstance(expr, tuple) and expr[0] == "or":
342
- self.add([self.tseitin(e, +1) for e in expr[1:]])
377
+ self.add([self._tseitin(e, +1) for e in expr[1:]])
343
378
  return
344
- self.add([self.tseitin(expr, +1)])
379
+ self.add([self._tseitin(expr, +1)])
345
380
 
346
381
  # ------------------------------------------------------------ at-most-one
347
382
 
@@ -367,6 +402,7 @@ class Encoder:
367
402
  n = len(lits)
368
403
  if n <= 1:
369
404
  return
405
+ self._declare(lits)
370
406
  bits = max(1, (n - 1).bit_length())
371
407
  code = [self.new_lit() for _ in range(bits)]
372
408
  for i, x in enumerate(lits):
@@ -387,6 +423,7 @@ class Encoder:
387
423
  if len(lits) <= group:
388
424
  self.amo_pairwise(lits)
389
425
  return
426
+ self._declare(lits)
390
427
  commanders: list[int] = []
391
428
  for i in range(0, len(lits), group):
392
429
  g = lits[i : i + group]
@@ -444,6 +481,7 @@ class Encoder:
444
481
  for x in lits:
445
482
  self.add([neg(x)])
446
483
  return
484
+ self._declare(lits)
447
485
  s = [[self.new_lit() for _ in range(k)] for _ in range(n - 1)]
448
486
  self.add([neg(lits[0]), s[0][0]])
449
487
  for j in range(1, k):
@@ -465,9 +503,12 @@ class Encoder:
465
503
 
466
504
  def at_most_k(self, lits: Sequence[int], k: int, method: str = "auto") -> None:
467
505
  n = len(lits)
506
+ if k < 0: # before `k >= n`, which would call -1 of nothing vacuous
507
+ self.add([])
508
+ return
468
509
  if k >= n:
469
510
  return
470
- if k <= 0:
511
+ if k == 0:
471
512
  for x in lits:
472
513
  self.add([neg(x)])
473
514
  return
@@ -517,6 +558,7 @@ class Encoder:
517
558
  """
518
559
  if len(weights) != len(lits):
519
560
  raise ValueError("weights and literals must have equal length")
561
+ self._declare(lits)
520
562
  ws: list[int] = []
521
563
  ls: list[int] = []
522
564
  b = bound
@@ -595,20 +637,24 @@ class Totalizer:
595
637
 
596
638
  ``max_count`` truncates the counter: outputs above the cut are never
597
639
  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.
640
+ truncated version is still arc consistent for the bound it was built for,
641
+ and it cannot express anything beyond: ``sum <= k`` for k >= max_count and
642
+ ``sum >= k`` for k > max_count raise ``ValueError``, unless the inputs alone
643
+ decide them.
599
644
  """
600
645
 
601
646
  def __init__(self, enc: Encoder, lits: Sequence[int], max_count: int | None = None):
602
647
  self.enc = enc
603
648
  self.inputs = list(lits)
649
+ enc._declare(self.inputs)
604
650
  self.max_count = len(self.inputs) if max_count is None else min(
605
651
  max_count, len(self.inputs)
606
652
  )
607
653
  self.outputs = self._build(self.inputs)
608
654
 
609
655
  def _build(self, lits: Sequence[int]) -> list[int]:
610
- if len(lits) == 1:
611
- return [lits[0]]
656
+ if len(lits) <= 1:
657
+ return list(lits)
612
658
  mid = len(lits) // 2
613
659
  a = self._build(lits[:mid])
614
660
  b = self._build(lits[mid:])
@@ -653,17 +699,26 @@ class Totalizer:
653
699
  return None
654
700
  if k < 0:
655
701
  return self.enc.false_lit
656
- if k >= len(self.outputs):
657
- return None
702
+ self._require_output(k)
658
703
  return neg(self.outputs[k])
659
704
 
660
705
  def at_least_lit(self, k: int) -> int | None:
661
706
  if k <= 0:
662
707
  return None
663
- if k > len(self.inputs) or k > len(self.outputs):
708
+ if k > len(self.inputs):
664
709
  return self.enc.false_lit
710
+ self._require_output(k - 1)
665
711
  return self.outputs[k - 1]
666
712
 
713
+ def _require_output(self, i: int) -> None:
714
+ # Missing here means this counter stopped short, and says nothing about
715
+ # the bound. Reading it as "vacuous" dropped a constraint, and as
716
+ # "impossible" made a feasible problem unsatisfiable, with a valid proof.
717
+ if i >= len(self.outputs):
718
+ raise ValueError(
719
+ f"this counter was truncated with max_count={self.max_count}, so "
720
+ f"it cannot count to {i + 1} of its {len(self.inputs)} inputs")
721
+
667
722
  def assert_at_most(self, k: int) -> None:
668
723
  l = self.at_most_lit(k)
669
724
  if l is not None:
@@ -754,6 +809,8 @@ class Optimiser:
754
809
  def solve_with_bound(self, bound: int | None, permanent: bool = False):
755
810
  """Solve demanding ``count <= bound``. Returns a model or None."""
756
811
  assumptions: list[int] = []
812
+ if bound is not None and bound < 0 and self.totalizer is None:
813
+ return None # nothing to count, so a count of 0, which is not <= -1
757
814
  if bound is not None and self.totalizer is not None:
758
815
  lit = self.totalizer.at_most_lit(bound)
759
816
  if lit is not None:
@@ -772,21 +829,29 @@ class Optimiser:
772
829
 
773
830
  Every iteration produces a real model, so stopping early still leaves
774
831
  :attr:`best` holding the best solution found -- the property that
775
- matters when there is a time budget.
832
+ matters when there is a time budget. It only ever improves, whatever a
833
+ later call asks for. `max_iterations` applies to this call, so a loop
834
+ can resume with the same budget each time; :attr:`iterations` is the
835
+ total so far.
776
836
  """
777
837
  bound = target
838
+ if bound is None and self.best is not None:
839
+ bound = self.best[0] - 1
840
+ done = 0
778
841
  while True:
779
842
  model = self.solve_with_bound(bound)
780
843
  if model is None:
781
844
  break
782
845
  self.iterations += 1
846
+ done += 1
783
847
  n = self.count(model)
784
- self.best = (n, model)
785
- if on_improve is not None:
786
- on_improve(n, model)
848
+ if self.best is None or n < self.best[0]:
849
+ self.best = (n, model)
850
+ if on_improve is not None:
851
+ on_improve(n, model)
787
852
  if n == 0:
788
853
  break
789
- if max_iterations and self.iterations >= max_iterations:
854
+ if max_iterations and done >= max_iterations:
790
855
  break
791
856
  bound = n - 1
792
857
  return self.result()