cdclkit 0.1.2__tar.gz → 0.1.3__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 (44) hide show
  1. {cdclkit-0.1.2 → cdclkit-0.1.3}/PKG-INFO +78 -11
  2. {cdclkit-0.1.2 → cdclkit-0.1.3}/README.md +75 -8
  3. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/__init__.py +1 -1
  4. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/brute.py +5 -0
  5. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/cli.py +50 -9
  6. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/encodings.py +2 -2
  7. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/pipeline.py +53 -10
  8. cdclkit-0.1.3/cdclkit/py.typed +0 -0
  9. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/pyeq.py +49 -6
  10. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/solver.py +18 -7
  11. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit.egg-info/PKG-INFO +78 -11
  12. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit.egg-info/SOURCES.txt +5 -0
  13. cdclkit-0.1.3/cdclkit.egg-info/requires.txt +4 -0
  14. {cdclkit-0.1.2 → cdclkit-0.1.3}/pyproject.toml +8 -3
  15. cdclkit-0.1.3/tests/test_adaptive_proof.py +161 -0
  16. cdclkit-0.1.3/tests/test_agent_docs.py +86 -0
  17. cdclkit-0.1.3/tests/test_brute.py +144 -0
  18. cdclkit-0.1.3/tests/test_cli_defaults.py +67 -0
  19. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_core.py +7 -7
  20. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_encodings.py +4 -2
  21. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_mus.py +9 -5
  22. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_native.py +4 -4
  23. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_native_solver.py +97 -5
  24. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_packaging.py +32 -0
  25. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_pipeline.py +3 -3
  26. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_portfolio.py +5 -5
  27. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_preprocess.py +5 -5
  28. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_proof.py +20 -11
  29. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_pyeq.py +23 -2
  30. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_solver.py +7 -7
  31. cdclkit-0.1.2/cdclkit.egg-info/requires.txt +0 -4
  32. {cdclkit-0.1.2 → cdclkit-0.1.3}/LICENSE +0 -0
  33. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/__main__.py +0 -0
  34. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/heap.py +0 -0
  35. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/model.py +0 -0
  36. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/mus.py +0 -0
  37. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/native.py +0 -0
  38. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/portfolio.py +0 -0
  39. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit/preprocess.py +0 -0
  40. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit.egg-info/dependency_links.txt +0 -0
  41. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit.egg-info/entry_points.txt +0 -0
  42. {cdclkit-0.1.2 → cdclkit-0.1.3}/cdclkit.egg-info/top_level.txt +0 -0
  43. {cdclkit-0.1.2 → cdclkit-0.1.3}/setup.cfg +0 -0
  44. {cdclkit-0.1.2 → cdclkit-0.1.3}/tests/test_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cdclkit
3
- Version: 0.1.2
3
+ Version: 0.1.3
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
@@ -22,9 +22,9 @@ Classifier: Topic :: Scientific/Engineering :: Mathematics
22
22
  Requires-Python: >=3.10
23
23
  Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
- Requires-Dist: dratify>=0.1.1
25
+ Requires-Dist: dratify>=0.1.4
26
26
  Provides-Extra: native
27
- Requires-Dist: cdclkit-native==0.1.2; extra == "native"
27
+ Requires-Dist: cdclkit-native==0.1.3; extra == "native"
28
28
  Dynamic: license-file
29
29
 
30
30
  # cdclkit
@@ -36,7 +36,7 @@ Every answer comes with a certificate, and the certificate gets checked.
36
36
 
37
37
  ```bash
38
38
  pip install cdclkit # pure Python
39
- pip install "cdclkit[native]" # plus the Rust engine, ~18x faster
39
+ pip install "cdclkit[native]" # plus the Rust engine, ~20x faster
40
40
  ```
41
41
 
42
42
  Three packages, and no third-party code:
@@ -59,6 +59,56 @@ A solver that says "unsatisfiable" and offers nothing else is asking to be
59
59
  trusted. This one hands you the proof — and the checker that reads it shares no
60
60
  code with the solver that wrote it.
61
61
 
62
+ ## Why you would want this
63
+
64
+ **Use this if:**
65
+
66
+ - ✅ You have constraints and need any assignment satisfying all of them
67
+ - ✅ You need to know **why** something is infeasible, not just that it is
68
+ - ✅ You need to *verify* an "impossible" answer rather than trust it
69
+ - ✅ You want to read the solver, or teach how CDCL works
70
+ - ✅ You want zero third-party dependencies
71
+
72
+ **Use something else if:**
73
+
74
+ - ❌ You need raw speed on industrial instances → [PySAT](https://pypi.org/project/python-sat/) ships kissat and CaDiCaL
75
+ - ❌ Your problem is numeric optimisation over real numbers → an LP or MIP solver
76
+ - ❌ You need Windows → never tested here, and the classifiers say so
77
+
78
+ ---
79
+
80
+ You have a pile of constraints and need any assignment satisfying all of them:
81
+
82
+ - **Rostering and scheduling** — six nurses, three shifts, nobody works two
83
+ nights running, everyone gets a weekend off a month.
84
+ - **Configuration** — which package versions can coexist, which hardware
85
+ options are compatible.
86
+ - **Assignment** — exams to rooms and slots with no clashes, frequencies to
87
+ transmitters that must not interfere.
88
+ - **Puzzles** — Sudoku, nonograms, the zebra puzzle. Genuinely the same shape
89
+ as the three above.
90
+
91
+ The alternative is writing backtracking search yourself. That is slow to write,
92
+ and what you produce in an afternoon will be far slower than a solver with
93
+ thirty years of engineering in the same loop.
94
+
95
+ Two things you get that a hand-rolled search does not:
96
+
97
+ - **When there is no solution, you learn why.** A minimal unsatisfiable subset
98
+ names the handful of constraints that actually conflict, instead of reporting
99
+ "infeasible" and leaving you to bisect a thousand of them.
100
+ - **When it says "no", you can check it.** A "yes" verifies itself in linear
101
+ time; a "no" is an assertion about every one of 2^n assignments. This one
102
+ hands you a proof.
103
+
104
+ **When not to.** Numeric optimisation over real numbers wants an LP or MIP
105
+ solver, not this. Raw throughput on millions of clauses wants
106
+ [PySAT](https://pypi.org/project/python-sat/), which ships kissat and CaDiCaL
107
+ as binary wheels and will be much faster.
108
+
109
+ New to this? [docs/tutorial/](docs/tutorial/) starts from boolean logic and
110
+ assumes nothing else.
111
+
62
112
  ## Quick start
63
113
 
64
114
  ```python
@@ -73,7 +123,8 @@ Note the shape: `solve()` returns a **tuple**, so `if solve(f):` is always
73
123
  true. Unpack it.
74
124
 
75
125
  Worked examples live in `examples/` — Sudoku with a uniqueness proof, the zebra
76
- puzzle, graph colouring, bounded model checking, circuit equivalence.
126
+ puzzle, graph colouring, bounded model checking, circuit equivalence, and
127
+ checking a refactored Python function against the original.
77
128
 
78
129
  ## What's in it
79
130
 
@@ -88,9 +139,12 @@ puzzle, graph colouring, bounded model checking, circuit equivalence.
88
139
  | `pyeq` | **experimental** — bounded equivalence of two Python integer functions |
89
140
 
90
141
  An optional Rust engine ([`cdclkit-native`](https://pypi.org/project/cdclkit-native/))
91
- is roughly 18x faster
92
- and **bit-exact** with the Python one: identical conflicts, decisions and
93
- propagations on every instance. The pure-Python path has zero third-party
142
+ is **bit-exact** with the Python one — identical conflicts, decisions and
143
+ propagations on every instance — and about **20x faster**: the geometric mean
144
+ of per-instance ratios over the 17 benchmark instances is 20.1x, ranging from
145
+ 11x to 51x. Regenerate it with `make history` (the `python` and `native`
146
+ checkpoints in `bench/history.jsonl`); see [BENCHMARKS.md](BENCHMARKS.md) for
147
+ the method. The pure-Python path has zero third-party
94
148
  dependencies and is the one that must never break.
95
149
 
96
150
  ## Relationship to dratify
@@ -122,13 +176,18 @@ of what is being measured.
122
176
 
123
177
  If you need raw speed, install [PySAT](https://pypi.org/project/python-sat/):
124
178
  it ships kissat, CaDiCaL and Glucose as binary wheels on every platform. This
125
- project is not trying to beat them. It is trying to be a complete, readable,
126
- self-checking implementation you can audit.
179
+ project is not trying to beat them, and will lose badly on large instances.
180
+
181
+ The difference worth naming is narrower than "readable": PySAT can emit DRUP
182
+ proofs and ships nothing to check them, and its solvers are third-party C++ in
183
+ a compiled extension. Here the proof gets checked by default, by a package you
184
+ can read. That matters if you are about to act on "no solution exists", and not
185
+ otherwise.
127
186
 
128
187
  ## Honest limitations
129
188
 
130
189
  - **No Windows.** Never tested; the classifiers say so rather than implying support.
131
- - Pure Python is ~18x slower than its own Rust port, which is itself far from
190
+ - Pure Python is ~20x slower than its own Rust port, which is itself far from
132
191
  kissat. Not a tool for competition-scale instances.
133
192
  - No inprocessing, no XOR/Gaussian reasoning. Parity families are a known
134
193
  weakness and `bench/` includes one to keep that visible.
@@ -140,12 +199,20 @@ self-checking implementation you can audit.
140
199
 
141
200
  ## Documentation
142
201
 
202
+ - [docs/tutorial/](docs/tutorial/) — **start here if you are new to SAT.** A
203
+ tutorial for a working programmer who knows boolean logic and has never used
204
+ a solver: the theory you need, then Python, then Rust. Every example runs.
143
205
  - [docs/ALGORITHMS.md](docs/ALGORITHMS.md) — the mathematics, from resolution
144
206
  through first-UIP, LBD, DRAT, encodings and preprocessing, including a
145
207
  section on what is deliberately absent.
146
208
  - [docs/ROADMAP.md](docs/ROADMAP.md) — what is planned, in sprints.
147
209
  - [docs/RELEASING.md](docs/RELEASING.md) — the release checklist.
148
210
 
211
+ ## Generating code against this?
212
+
213
+ [AGENTS.md](AGENTS.md) lists the API's sharp edges — the mistakes that have
214
+ actually been made, not hypothetical ones. Worth reading before writing a line.
215
+
149
216
  ## Licence
150
217
 
151
218
  Apache-2.0. See [LICENSE](LICENSE).
@@ -7,7 +7,7 @@ Every answer comes with a certificate, and the certificate gets checked.
7
7
 
8
8
  ```bash
9
9
  pip install cdclkit # pure Python
10
- pip install "cdclkit[native]" # plus the Rust engine, ~18x faster
10
+ pip install "cdclkit[native]" # plus the Rust engine, ~20x faster
11
11
  ```
12
12
 
13
13
  Three packages, and no third-party code:
@@ -30,6 +30,56 @@ A solver that says "unsatisfiable" and offers nothing else is asking to be
30
30
  trusted. This one hands you the proof — and the checker that reads it shares no
31
31
  code with the solver that wrote it.
32
32
 
33
+ ## Why you would want this
34
+
35
+ **Use this if:**
36
+
37
+ - ✅ You have constraints and need any assignment satisfying all of them
38
+ - ✅ You need to know **why** something is infeasible, not just that it is
39
+ - ✅ You need to *verify* an "impossible" answer rather than trust it
40
+ - ✅ You want to read the solver, or teach how CDCL works
41
+ - ✅ You want zero third-party dependencies
42
+
43
+ **Use something else if:**
44
+
45
+ - ❌ You need raw speed on industrial instances → [PySAT](https://pypi.org/project/python-sat/) ships kissat and CaDiCaL
46
+ - ❌ Your problem is numeric optimisation over real numbers → an LP or MIP solver
47
+ - ❌ You need Windows → never tested here, and the classifiers say so
48
+
49
+ ---
50
+
51
+ You have a pile of constraints and need any assignment satisfying all of them:
52
+
53
+ - **Rostering and scheduling** — six nurses, three shifts, nobody works two
54
+ nights running, everyone gets a weekend off a month.
55
+ - **Configuration** — which package versions can coexist, which hardware
56
+ options are compatible.
57
+ - **Assignment** — exams to rooms and slots with no clashes, frequencies to
58
+ transmitters that must not interfere.
59
+ - **Puzzles** — Sudoku, nonograms, the zebra puzzle. Genuinely the same shape
60
+ as the three above.
61
+
62
+ The alternative is writing backtracking search yourself. That is slow to write,
63
+ and what you produce in an afternoon will be far slower than a solver with
64
+ thirty years of engineering in the same loop.
65
+
66
+ Two things you get that a hand-rolled search does not:
67
+
68
+ - **When there is no solution, you learn why.** A minimal unsatisfiable subset
69
+ names the handful of constraints that actually conflict, instead of reporting
70
+ "infeasible" and leaving you to bisect a thousand of them.
71
+ - **When it says "no", you can check it.** A "yes" verifies itself in linear
72
+ time; a "no" is an assertion about every one of 2^n assignments. This one
73
+ hands you a proof.
74
+
75
+ **When not to.** Numeric optimisation over real numbers wants an LP or MIP
76
+ solver, not this. Raw throughput on millions of clauses wants
77
+ [PySAT](https://pypi.org/project/python-sat/), which ships kissat and CaDiCaL
78
+ as binary wheels and will be much faster.
79
+
80
+ New to this? [docs/tutorial/](docs/tutorial/) starts from boolean logic and
81
+ assumes nothing else.
82
+
33
83
  ## Quick start
34
84
 
35
85
  ```python
@@ -44,7 +94,8 @@ Note the shape: `solve()` returns a **tuple**, so `if solve(f):` is always
44
94
  true. Unpack it.
45
95
 
46
96
  Worked examples live in `examples/` — Sudoku with a uniqueness proof, the zebra
47
- puzzle, graph colouring, bounded model checking, circuit equivalence.
97
+ puzzle, graph colouring, bounded model checking, circuit equivalence, and
98
+ checking a refactored Python function against the original.
48
99
 
49
100
  ## What's in it
50
101
 
@@ -59,9 +110,12 @@ puzzle, graph colouring, bounded model checking, circuit equivalence.
59
110
  | `pyeq` | **experimental** — bounded equivalence of two Python integer functions |
60
111
 
61
112
  An optional Rust engine ([`cdclkit-native`](https://pypi.org/project/cdclkit-native/))
62
- is roughly 18x faster
63
- and **bit-exact** with the Python one: identical conflicts, decisions and
64
- propagations on every instance. The pure-Python path has zero third-party
113
+ is **bit-exact** with the Python one — identical conflicts, decisions and
114
+ propagations on every instance — and about **20x faster**: the geometric mean
115
+ of per-instance ratios over the 17 benchmark instances is 20.1x, ranging from
116
+ 11x to 51x. Regenerate it with `make history` (the `python` and `native`
117
+ checkpoints in `bench/history.jsonl`); see [BENCHMARKS.md](BENCHMARKS.md) for
118
+ the method. The pure-Python path has zero third-party
65
119
  dependencies and is the one that must never break.
66
120
 
67
121
  ## Relationship to dratify
@@ -93,13 +147,18 @@ of what is being measured.
93
147
 
94
148
  If you need raw speed, install [PySAT](https://pypi.org/project/python-sat/):
95
149
  it ships kissat, CaDiCaL and Glucose as binary wheels on every platform. This
96
- project is not trying to beat them. It is trying to be a complete, readable,
97
- self-checking implementation you can audit.
150
+ project is not trying to beat them, and will lose badly on large instances.
151
+
152
+ The difference worth naming is narrower than "readable": PySAT can emit DRUP
153
+ proofs and ships nothing to check them, and its solvers are third-party C++ in
154
+ a compiled extension. Here the proof gets checked by default, by a package you
155
+ can read. That matters if you are about to act on "no solution exists", and not
156
+ otherwise.
98
157
 
99
158
  ## Honest limitations
100
159
 
101
160
  - **No Windows.** Never tested; the classifiers say so rather than implying support.
102
- - Pure Python is ~18x slower than its own Rust port, which is itself far from
161
+ - Pure Python is ~20x slower than its own Rust port, which is itself far from
103
162
  kissat. Not a tool for competition-scale instances.
104
163
  - No inprocessing, no XOR/Gaussian reasoning. Parity families are a known
105
164
  weakness and `bench/` includes one to keep that visible.
@@ -111,12 +170,20 @@ self-checking implementation you can audit.
111
170
 
112
171
  ## Documentation
113
172
 
173
+ - [docs/tutorial/](docs/tutorial/) — **start here if you are new to SAT.** A
174
+ tutorial for a working programmer who knows boolean logic and has never used
175
+ a solver: the theory you need, then Python, then Rust. Every example runs.
114
176
  - [docs/ALGORITHMS.md](docs/ALGORITHMS.md) — the mathematics, from resolution
115
177
  through first-UIP, LBD, DRAT, encodings and preprocessing, including a
116
178
  section on what is deliberately absent.
117
179
  - [docs/ROADMAP.md](docs/ROADMAP.md) — what is planned, in sprints.
118
180
  - [docs/RELEASING.md](docs/RELEASING.md) — the release checklist.
119
181
 
182
+ ## Generating code against this?
183
+
184
+ [AGENTS.md](AGENTS.md) lists the API's sharp edges — the mistakes that have
185
+ actually been made, not hypothetical ones. Worth reading before writing a line.
186
+
120
187
  ## Licence
121
188
 
122
189
  Apache-2.0. See [LICENSE](LICENSE).
@@ -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.2"
55
+ __version__ = "0.1.3"
56
56
 
57
57
  __all__ = [
58
58
  "CNF",
@@ -112,6 +112,11 @@ def dpll(f: CNF, max_steps: int = 2_000_000) -> list[bool] | None:
112
112
  steps[0] += 1
113
113
  if steps[0] > max_steps:
114
114
  raise RuntimeError("DPLL step budget exhausted")
115
+ # An empty clause cannot be satisfied by anything. `simplify` catches
116
+ # the ones this search *creates*, but one present in the input reached
117
+ # the split below and indexed cs[0][0] on an empty list.
118
+ if any(not c for c in cs):
119
+ return None
115
120
  # unit propagation
116
121
  while True:
117
122
  unit = next((c[0] for c in cs if len(c) == 1), None)
@@ -105,7 +105,23 @@ def cmd_solve(args) -> int:
105
105
  if getattr(args, "adaptive", False):
106
106
  from .pipeline import solve_adaptive
107
107
 
108
- pr = solve_adaptive(f, engine="native" if _native_ok() else "python")
108
+ # Everything below used to be dropped here: cfg, the proof sink and the
109
+ # conflict budget were all built above and none reached the pipeline.
110
+ # `--adaptive --self-check` printed "s UNSATISFIABLE" and checked
111
+ # nothing, which is the one thing this project claims not to do.
112
+ jobs = getattr(args, "jobs", None) or 1
113
+ if proof_sink is not None and jobs > 1:
114
+ print("c error: a proof cannot be logged from a parallel portfolio "
115
+ "(workers race and their steps interleave); drop -j or drop "
116
+ "--proof/--self-check")
117
+ return EXIT_ERROR
118
+ pr = solve_adaptive(
119
+ f,
120
+ engine="native" if _native_ok() else "python",
121
+ config=cfg,
122
+ jobs=jobs,
123
+ proof=proof_sink,
124
+ )
109
125
  print(pr.report())
110
126
  print(f"c {pr.conflicts} conflicts in {pr.seconds:.3f}s")
111
127
  if pr.sat:
@@ -120,7 +136,10 @@ def cmd_solve(args) -> int:
120
136
  _emit_model(pr.model)
121
137
  return EXIT_SAT
122
138
  print("s UNSATISFIABLE")
123
- return EXIT_UNSAT
139
+ # The proof covers preprocessing *and* the search, so it is checked
140
+ # against the original formula, not the reduced one.
141
+ return _finish_unsat(args, original if original is not None else f,
142
+ mem_proof, proof_sink)
124
143
 
125
144
  jobs = args.jobs
126
145
  if jobs is not None and jobs > 1:
@@ -167,9 +186,23 @@ def cmd_solve(args) -> int:
167
186
  return EXIT_SAT
168
187
 
169
188
  print("s UNSATISFIABLE")
189
+ return _finish_unsat(args, original, mem_proof, proof_sink)
190
+
191
+
192
+ def _finish_unsat(args, formula, mem_proof, proof_sink) -> int:
193
+ """Self-check or write out the proof, then report.
194
+
195
+ Shared by the ordinary and the adaptive paths. It was inline in one of
196
+ them, which is how the adaptive path came to skip it entirely: the flag
197
+ was accepted, the branch returned early, and nothing said so.
198
+
199
+ `formula` is the *original* input. When the adaptive pipeline
200
+ preprocesses, its steps are logged first, so the combined proof is a
201
+ refutation of what the user handed in rather than of the reduced formula.
202
+ """
170
203
  if mem_proof is not None:
171
204
  t0 = time.perf_counter()
172
- res = check_proof(original, mem_proof)
205
+ res = check_proof(formula, mem_proof)
173
206
  print(f"c proof self-check took {time.perf_counter()-t0:.3f}s "
174
207
  f"({len(mem_proof.steps)} steps)")
175
208
  for line in res.report().splitlines():
@@ -405,12 +438,20 @@ def build_parser() -> argparse.ArgumentParser:
405
438
  "turns out to be hard enough to repay it")
406
439
  s.add_argument("--prep-rounds", type=int, default=3)
407
440
  s.add_argument("--conflicts", type=int, default=None, help="conflict budget")
408
- s.add_argument("--restart", default="glucose", choices=["glucose", "luby", "none"])
409
- s.add_argument("--var-decay", type=float, default=0.8)
410
- s.add_argument("--ccmin", default="deep", choices=["deep", "basic", "none"])
411
- s.add_argument("--no-phase-saving", action="store_true")
412
- s.add_argument("--rnd-freq", type=float, default=0.0)
413
- s.add_argument("--seed", type=int, default=91648253)
441
+ # Every search default comes from Config() rather than a literal. They were
442
+ # literals, and --restart drifted to "glucose" while the library moved to
443
+ # "luby" -- so the CLI quietly ran the policy the CHANGELOG says was
444
+ # replaced, and the headline measurement did not describe `cdclkit solve`.
445
+ _d = Config()
446
+ s.add_argument("--restart", default=_d.restart,
447
+ choices=["glucose", "luby", "none"])
448
+ s.add_argument("--var-decay", type=float, default=_d.var_decay)
449
+ s.add_argument("--ccmin", default=_d.ccmin, choices=["deep", "basic", "none"])
450
+ s.add_argument("--no-phase-saving", action="store_true",
451
+ help="phase saving is on by default"
452
+ if _d.phase_saving else "phase saving is off by default")
453
+ s.add_argument("--rnd-freq", type=float, default=_d.rnd_freq)
454
+ s.add_argument("--seed", type=int, default=_d.rnd_seed)
414
455
  s.add_argument("--no-model", action="store_true", help="suppress the v lines")
415
456
  s.add_argument("--jobs", "-j", type=int, default=None,
416
457
  metavar="N",
@@ -103,12 +103,12 @@ class Encoder:
103
103
  are also used to *define* variables the caller may reference either way.
104
104
  """
105
105
 
106
- #: Cap on :meth:`xor_direct`, which emits 2^(k-1) clauses. 16 inputs is
106
+ #: Cap on direct XOR expansion, which emits 2^(k-1) clauses. 16 inputs is
107
107
  #: 32768 clauses -- large but survivable, and the point of the direct form
108
108
  #: is to cross-check the chain on small arities, not to replace it.
109
109
  MAX_DIRECT_XOR_ARITY = 16
110
110
 
111
- #: Cap on :meth:`assert_expr_expanded`, which enumerates 2^v rows over the
111
+ #: Cap on expanded truth-table assertion, which enumerates 2^v rows over the
112
112
  #: v variables an expression mentions. 12 is 4096 rows per conjunct.
113
113
  MAX_EXPAND_ARITY = 12
114
114
 
@@ -31,6 +31,7 @@ import time
31
31
  from typing import Sequence
32
32
 
33
33
  from dratify.cnf import CNF
34
+ from dratify.lits import from_dimacs
34
35
  from .solver import Config, Solver
35
36
 
36
37
  __all__ = ["solve_adaptive", "PipelineResult"]
@@ -74,8 +75,27 @@ def _native_available() -> bool:
74
75
  return native.available()
75
76
 
76
77
 
78
+ def _drain_native_proof(obj, proof) -> None:
79
+ """Copy a native stage's logged steps into `proof`, in order.
80
+
81
+ Both the native Solver and the native Preprocessor accumulate steps and
82
+ hand them over at the end, where the Python versions write to a sink as
83
+ they go. Concatenating them preserves DRAT's only ordering requirement:
84
+ a clause must be justified by what precedes it, and preprocessing precedes
85
+ the search.
86
+ """
87
+ if proof is None:
88
+ return
89
+ for kind, lits in obj.proof_steps():
90
+ internal = [from_dimacs(d) for d in lits]
91
+ if kind == "d":
92
+ proof.delete(internal)
93
+ else:
94
+ proof.add(internal)
95
+
96
+
77
97
  def _solve(f: CNF, cfg: Config, budget: int | None, engine: str,
78
- seconds: float | None = None):
98
+ seconds: float | None = None, proof=None):
79
99
  """Returns (status, model, conflicts). status None means budget exhausted."""
80
100
  if engine == "native":
81
101
  from . import native
@@ -94,15 +114,20 @@ def _solve(f: CNF, cfg: Config, budget: int | None, engine: str,
94
114
  glue_keep=cfg.glue_keep, block_restart=cfg.block_restart,
95
115
  rnd_freq=cfg.rnd_freq, rnd_seed=cfg.rnd_seed,
96
116
  )
117
+ if proof is not None:
118
+ s.enable_proof() # must precede the first clause
97
119
  for c in f.clauses:
98
120
  if not s.add_clause(list(c)):
121
+ _drain_native_proof(s, proof)
99
122
  return False, None, s.conflicts
100
123
  res = s.solve(budget, seconds)
101
124
  if res is None:
102
125
  return None, None, s.conflicts
126
+ if not res:
127
+ _drain_native_proof(s, proof)
103
128
  return res, (list(s.model) if res else None), s.conflicts
104
129
 
105
- s = Solver(f.nvars, config=cfg)
130
+ s = Solver(f.nvars, config=cfg, proof=proof)
106
131
  if not s.add_cnf(f):
107
132
  return False, None, s.stats.conflicts
108
133
  res = s.solve(max_conflicts=budget, deadline=(
@@ -112,17 +137,22 @@ def _solve(f: CNF, cfg: Config, budget: int | None, engine: str,
112
137
  return res, (list(s.model) if res else None), s.stats.conflicts
113
138
 
114
139
 
115
- def _preprocess(f: CNF, engine: str):
140
+ def _preprocess(f: CNF, engine: str, proof=None):
116
141
  """Returns (reduced_formula, unsat, reconstruct_fn, seconds)."""
117
142
  t0 = time.perf_counter()
118
143
  if engine == "native" and _native_available():
119
144
  from . import native
120
145
 
121
146
  n = native.require()
122
- p = n.Preprocessor(f.nvars)
147
+ # with_proof is off by default; without it the native
148
+ # preprocessor logs nothing and the solver's proof ends up
149
+ # being about the *reduced* formula, which does not check
150
+ # against the original.
151
+ p = n.Preprocessor(f.nvars, with_proof=proof is not None)
123
152
  for c in f.clauses:
124
153
  p.add_clause(list(c))
125
154
  p.run(3)
155
+ _drain_native_proof(p, proof)
126
156
  red = CNF(f.nvars)
127
157
  for c in p.reduced():
128
158
  red.add(c)
@@ -131,7 +161,7 @@ def _preprocess(f: CNF, engine: str):
131
161
 
132
162
  from .preprocess import Preprocessor
133
163
 
134
- p = Preprocessor(f)
164
+ p = Preprocessor(f, proof=proof)
135
165
  red = p.run()
136
166
  return red, p.unsat, p.reconstruct, time.perf_counter() - t0
137
167
 
@@ -145,6 +175,7 @@ def solve_adaptive(
145
175
  never_preprocess: bool = False,
146
176
  jobs: int = 1,
147
177
  seconds: float | None = None,
178
+ proof=None,
148
179
  ) -> PipelineResult:
149
180
  """Solve `f`, preprocessing only when a short probe says it is worth it.
150
181
 
@@ -162,13 +193,22 @@ def solve_adaptive(
162
193
  cfg = config or Config()
163
194
  if engine == "native" and not _native_available():
164
195
  engine = "python"
196
+ if proof is not None and jobs > 1:
197
+ # Portfolio workers race; whichever finishes first is the one whose
198
+ # refutation is returned, and the others' steps would interleave into
199
+ # a stream that justifies nothing. Refuse rather than emit a proof
200
+ # that will not check.
201
+ raise ValueError(
202
+ "a proof cannot be logged from a parallel portfolio: workers race "
203
+ "and their steps would interleave. Use jobs=1 when proof is set.")
165
204
 
166
205
  r = PipelineResult()
167
206
  r.clauses_before = f.nclauses
168
207
  t_start = time.perf_counter()
169
208
 
170
209
  if not never_preprocess and not always_preprocess:
171
- status, model, conflicts = _solve(f, cfg, probe, engine)
210
+ # The probe is a real solve and may refute outright, so it logs too.
211
+ status, model, conflicts = _solve(f, cfg, probe, engine, proof=proof)
172
212
  r.probe_conflicts = conflicts
173
213
  if status is not None:
174
214
  r.sat, r.model, r.conflicts = status, model, conflicts
@@ -177,13 +217,14 @@ def solve_adaptive(
177
217
  return r
178
218
 
179
219
  if never_preprocess:
180
- status, model, conflicts = _solve(f, cfg, None, engine, seconds)
220
+ status, model, conflicts = _solve(f, cfg, None, engine, seconds,
221
+ proof=proof)
181
222
  r.sat, r.model, r.conflicts = status, model, conflicts
182
223
  r.seconds = time.perf_counter() - t_start
183
224
  r.clauses_after = f.nclauses
184
225
  return r
185
226
 
186
- red, unsat, reconstruct, prep_s = _preprocess(f, engine)
227
+ red, unsat, reconstruct, prep_s = _preprocess(f, engine, proof=proof)
187
228
  r.preprocessed = True
188
229
  r.prep_seconds = prep_s
189
230
  r.clauses_after = red.nclauses
@@ -199,12 +240,14 @@ def solve_adaptive(
199
240
  # preprocess_workers=0 because this formula is *already* preprocessed;
200
241
  # asking for preprocessing workers here would both redo the work and
201
242
  # force the process-based path, paying ~60 ms of startup for nothing
202
- pr = solve_portfolio(red, jobs=jobs, engine=engine, preprocess_workers=0)
243
+ pr = solve_portfolio(red, jobs=jobs, engine=engine,
244
+ preprocess_workers=0, timeout=seconds)
203
245
  status = pr.sat
204
246
  model = pr.model
205
247
  conflicts = pr.stats.get("conflicts", 0)
206
248
  else:
207
- status, model, conflicts = _solve(red, cfg, None, engine, seconds)
249
+ status, model, conflicts = _solve(red, cfg, None, engine, seconds,
250
+ proof=proof)
208
251
  r.conflicts = conflicts + r.probe_conflicts
209
252
  r.sat = status
210
253
  if status:
File without changes