cdclkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cdclkit/__init__.py +152 -0
- cdclkit/__main__.py +10 -0
- cdclkit/brute.py +210 -0
- cdclkit/cli.py +513 -0
- cdclkit/encodings.py +842 -0
- cdclkit/heap.py +180 -0
- cdclkit/model.py +420 -0
- cdclkit/mus.py +159 -0
- cdclkit/native.py +111 -0
- cdclkit/pipeline.py +212 -0
- cdclkit/portfolio.py +683 -0
- cdclkit/preprocess.py +500 -0
- cdclkit/pyeq.py +824 -0
- cdclkit/solver.py +1377 -0
- cdclkit-0.1.0.dist-info/METADATA +136 -0
- cdclkit-0.1.0.dist-info/RECORD +20 -0
- cdclkit-0.1.0.dist-info/WHEEL +5 -0
- cdclkit-0.1.0.dist-info/entry_points.txt +2 -0
- cdclkit-0.1.0.dist-info/licenses/LICENSE +202 -0
- cdclkit-0.1.0.dist-info/top_level.txt +1 -0
cdclkit/__init__.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
|
|
3
|
+
"""cdclkit -- a CDCL SAT solving toolkit with checkable proofs, in pure Python.
|
|
4
|
+
|
|
5
|
+
Everything here is written from scratch against the standard library only.
|
|
6
|
+
The public surface:
|
|
7
|
+
|
|
8
|
+
from cdclkit import Solver, CNF, Encoder, solve
|
|
9
|
+
|
|
10
|
+
f = CNF()
|
|
11
|
+
a, b, c = f.new_var(), f.new_var(), f.new_var()
|
|
12
|
+
f.add([lit(a), lit(b)]) # a v b
|
|
13
|
+
f.add([lit(a, True), lit(c)]) # ~a v c
|
|
14
|
+
status, model = solve(f)
|
|
15
|
+
|
|
16
|
+
Modules:
|
|
17
|
+
|
|
18
|
+
``lits`` literal encoding and three-valued logic
|
|
19
|
+
``cnf`` clause/formula containers and DIMACS I/O
|
|
20
|
+
``heap`` indexed activity heap for VSIDS
|
|
21
|
+
``solver`` the CDCL core
|
|
22
|
+
``proof`` DRAT emission and an independent DRAT checker
|
|
23
|
+
``preprocess`` subsumption, strengthening, variable elimination
|
|
24
|
+
``mus`` minimal unsatisfiable subsets (deletion, QuickXplain)
|
|
25
|
+
``encodings`` Tseitin, cardinality, pseudo-boolean, totalizer, optimisation
|
|
26
|
+
``model`` a small typed modelling layer over the encoder
|
|
27
|
+
``pyeq`` prove two Python functions equivalent, or find an input where
|
|
28
|
+
they are not
|
|
29
|
+
``brute`` reference solvers used to cross-check everything else
|
|
30
|
+
``cli`` the ``python -m cdclkit`` command line
|
|
31
|
+
|
|
32
|
+
Public API
|
|
33
|
+
----------
|
|
34
|
+
Everything in ``__all__`` below is public and follows semantic versioning: it
|
|
35
|
+
will not change incompatibly without a major version bump, and anything due to
|
|
36
|
+
be removed gets a ``DeprecationWarning`` for one minor release first.
|
|
37
|
+
|
|
38
|
+
Everything else is internal, including ``cdclkit.pipeline``, ``cdclkit.portfolio``
|
|
39
|
+
and ``cdclkit.native``. They are importable because Python has no way to stop
|
|
40
|
+
you, not because they are stable. If you need something from them, say so and
|
|
41
|
+
it can be promoted -- that is a smaller problem than finding out from a broken
|
|
42
|
+
build that someone depended on it.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
from dratify.cnf import CNF, Clause, parse_dimacs, parse_dimacs_file, write_dimacs
|
|
48
|
+
from .encodings import Encoder, Totalizer, optimise
|
|
49
|
+
from dratify.lits import from_dimacs, mk_lit, neg, to_dimacs
|
|
50
|
+
from .mus import MUSExtractor, mus
|
|
51
|
+
from .preprocess import Preprocessor, preprocess
|
|
52
|
+
from dratify.proof import DRATChecker, MemoryProof, ProofWriter, check_proof
|
|
53
|
+
from .solver import Config, SAT, Solver, Stats, UNKNOWN, UNSAT
|
|
54
|
+
|
|
55
|
+
__version__ = "0.1.0"
|
|
56
|
+
|
|
57
|
+
__all__ = [
|
|
58
|
+
"CNF",
|
|
59
|
+
"Clause",
|
|
60
|
+
"Config",
|
|
61
|
+
"DRATChecker",
|
|
62
|
+
"Encoder",
|
|
63
|
+
"EncodingDisagreement",
|
|
64
|
+
"EquivalenceResult",
|
|
65
|
+
"MUSExtractor",
|
|
66
|
+
"MemoryProof",
|
|
67
|
+
"Preprocessor",
|
|
68
|
+
"ProofRejected",
|
|
69
|
+
"ProofWriter",
|
|
70
|
+
"SAT",
|
|
71
|
+
"UNKNOWN",
|
|
72
|
+
"UNSAT",
|
|
73
|
+
"Solver",
|
|
74
|
+
"Stats",
|
|
75
|
+
"Totalizer",
|
|
76
|
+
"UnsupportedConstruct",
|
|
77
|
+
"check_proof",
|
|
78
|
+
"differential_solve",
|
|
79
|
+
"equivalent",
|
|
80
|
+
"from_dimacs",
|
|
81
|
+
"lit",
|
|
82
|
+
"mk_lit",
|
|
83
|
+
"mus",
|
|
84
|
+
"neg",
|
|
85
|
+
"optimise",
|
|
86
|
+
"parse_dimacs",
|
|
87
|
+
"parse_dimacs_file",
|
|
88
|
+
"preprocess",
|
|
89
|
+
"solve",
|
|
90
|
+
"to_dimacs",
|
|
91
|
+
"write_dimacs",
|
|
92
|
+
"__version__",
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
#: Public names that live in `cdclkit.pyeq`, resolved on first access rather than
|
|
97
|
+
#: at import. `pyeq` needs `inspect` to read a function's source, and `inspect`
|
|
98
|
+
#: drags in `re` and `functools`: importing it eagerly was 5.5 ms of an 8.8 ms
|
|
99
|
+
#: `import cdclkit`, paid by everyone including the CLI solving a DIMACS file.
|
|
100
|
+
#: Startup is not a rounding error here -- the benchmark harness discards
|
|
101
|
+
#: instances a competitor finishes in under 50 ms because process startup
|
|
102
|
+
#: dominates them.
|
|
103
|
+
_LAZY = {
|
|
104
|
+
"equivalent": "pyeq",
|
|
105
|
+
"EquivalenceResult": "pyeq",
|
|
106
|
+
"differential_solve": "model",
|
|
107
|
+
"EncodingDisagreement": "model",
|
|
108
|
+
"UnsupportedConstruct": "pyeq",
|
|
109
|
+
"ProofRejected": "pyeq",
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# Imported eagerly, not lazily: importing it is what registers the native
|
|
114
|
+
# checker with `dratify`, and a checker that is present but unregistered would
|
|
115
|
+
# silently cost ~18x on proof checking. The import is a single guarded attempt
|
|
116
|
+
# at a compiled module and costs nothing when it is absent.
|
|
117
|
+
from . import native as _native_loader # noqa: F401,E402
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def __getattr__(name: str):
|
|
121
|
+
"""PEP 562 lazy attribute access for the heavier public names."""
|
|
122
|
+
mod = _LAZY.get(name)
|
|
123
|
+
if mod is None:
|
|
124
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
125
|
+
import importlib
|
|
126
|
+
|
|
127
|
+
value = getattr(importlib.import_module(f".{mod}", __name__), name)
|
|
128
|
+
globals()[name] = value # resolve once; subsequent lookups skip __getattr__
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def __dir__() -> list[str]:
|
|
133
|
+
return sorted(__all__)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def lit(var: int, negated: bool = False) -> int:
|
|
137
|
+
"""Alias for :func:`cdclkit.lits.mk_lit`, the common spelling in user code."""
|
|
138
|
+
return mk_lit(var, negated)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def solve(formula: CNF, proof=None, config: Config | None = None):
|
|
142
|
+
"""Solve a formula in one call.
|
|
143
|
+
|
|
144
|
+
Returns ``(True, model)`` or ``(False, None)``. ``model`` is a list of
|
|
145
|
+
booleans indexed by variable.
|
|
146
|
+
"""
|
|
147
|
+
s = Solver(formula.nvars, proof=proof, config=config)
|
|
148
|
+
if not s.add_cnf(formula):
|
|
149
|
+
return False, None
|
|
150
|
+
if s.solve():
|
|
151
|
+
return True, s.model
|
|
152
|
+
return False, None
|
cdclkit/__main__.py
ADDED
cdclkit/brute.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
|
|
3
|
+
"""Reference solvers: slow, obviously correct, and written independently.
|
|
4
|
+
|
|
5
|
+
Every nontrivial claim cdclkit makes is cross-checked against something in this
|
|
6
|
+
file. The rule I hold myself to: a reference implementation must be simple
|
|
7
|
+
enough that its correctness is apparent by reading, even at the cost of being
|
|
8
|
+
exponentially slower. No watched literals, no learning, no clever data
|
|
9
|
+
structures. If the CDCL solver and the reference disagree, the reference is
|
|
10
|
+
right until proven otherwise.
|
|
11
|
+
|
|
12
|
+
Three of them, at increasing strength:
|
|
13
|
+
|
|
14
|
+
``exhaustive``
|
|
15
|
+
Enumerate all 2^n assignments. Correct by definition of satisfiability.
|
|
16
|
+
``dpll``
|
|
17
|
+
Classic Davis-Putnam-Logemann-Loveland: unit propagation, pure literal
|
|
18
|
+
elimination, splitting. Independent of the CDCL code path.
|
|
19
|
+
``resolution_refute``
|
|
20
|
+
Bounded ordered resolution -- the proof system CDCL simulates. Used to
|
|
21
|
+
show, on small instances, that a formula the solver calls UNSAT really has
|
|
22
|
+
a resolution refutation, and to generate ground truth for proof tests.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import itertools
|
|
28
|
+
from typing import Sequence
|
|
29
|
+
|
|
30
|
+
from dratify.cnf import CNF
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"exhaustive_solve",
|
|
34
|
+
"count_models",
|
|
35
|
+
"all_models",
|
|
36
|
+
"dpll",
|
|
37
|
+
"resolution_refute",
|
|
38
|
+
"implied_literals",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def exhaustive_solve(f: CNF) -> list[bool] | None:
|
|
43
|
+
"""Return the lexicographically first model, or None if there is none."""
|
|
44
|
+
for bits in itertools.product((False, True), repeat=f.nvars):
|
|
45
|
+
if f.is_satisfied_by(bits):
|
|
46
|
+
return list(bits)
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def all_models(f: CNF, projection: Sequence[int] | None = None) -> list[tuple[bool, ...]]:
|
|
51
|
+
"""Every model, optionally projected onto a subset of variables."""
|
|
52
|
+
seen = set()
|
|
53
|
+
out = []
|
|
54
|
+
for bits in itertools.product((False, True), repeat=f.nvars):
|
|
55
|
+
if not f.is_satisfied_by(bits):
|
|
56
|
+
continue
|
|
57
|
+
key = tuple(bits) if projection is None else tuple(bits[v] for v in projection)
|
|
58
|
+
if key not in seen:
|
|
59
|
+
seen.add(key)
|
|
60
|
+
out.append(key)
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def count_models(f: CNF, projection: Sequence[int] | None = None) -> int:
|
|
65
|
+
return len(all_models(f, projection))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def implied_literals(f: CNF) -> set[int]:
|
|
69
|
+
"""Internal literals true in *every* model (the formula's backbone).
|
|
70
|
+
|
|
71
|
+
Exponential. Used to test that the solver's root-level propagation and
|
|
72
|
+
simplification never assert something that is not actually implied.
|
|
73
|
+
"""
|
|
74
|
+
models = [m for m in itertools.product((False, True), repeat=f.nvars) if f.is_satisfied_by(m)]
|
|
75
|
+
if not models:
|
|
76
|
+
return set()
|
|
77
|
+
out = set()
|
|
78
|
+
for v in range(f.nvars):
|
|
79
|
+
vals = {m[v] for m in models}
|
|
80
|
+
if len(vals) == 1:
|
|
81
|
+
out.add((v << 1) | (0 if vals.pop() else 1))
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------
|
|
86
|
+
# DPLL
|
|
87
|
+
# --------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def dpll(f: CNF, max_steps: int = 2_000_000) -> list[bool] | None:
|
|
91
|
+
"""Textbook DPLL. Returns a model or None; raises on step exhaustion."""
|
|
92
|
+
clauses = [list(c) for c in f.clauses]
|
|
93
|
+
assign: dict[int, bool] = {}
|
|
94
|
+
steps = [0]
|
|
95
|
+
|
|
96
|
+
def simplify(cs: list[list[int]], lit: int) -> list[list[int]] | None:
|
|
97
|
+
"""Apply ``lit = true``; None signals the empty clause."""
|
|
98
|
+
out = []
|
|
99
|
+
for c in cs:
|
|
100
|
+
if lit in c:
|
|
101
|
+
continue
|
|
102
|
+
if (lit ^ 1) in c:
|
|
103
|
+
rest = [l for l in c if l != (lit ^ 1)]
|
|
104
|
+
if not rest:
|
|
105
|
+
return None
|
|
106
|
+
out.append(rest)
|
|
107
|
+
else:
|
|
108
|
+
out.append(c)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
def rec(cs: list[list[int]], a: dict[int, bool]) -> dict[int, bool] | None:
|
|
112
|
+
steps[0] += 1
|
|
113
|
+
if steps[0] > max_steps:
|
|
114
|
+
raise RuntimeError("DPLL step budget exhausted")
|
|
115
|
+
# unit propagation
|
|
116
|
+
while True:
|
|
117
|
+
unit = next((c[0] for c in cs if len(c) == 1), None)
|
|
118
|
+
if unit is None:
|
|
119
|
+
break
|
|
120
|
+
a = dict(a)
|
|
121
|
+
a[unit >> 1] = not (unit & 1)
|
|
122
|
+
cs2 = simplify(cs, unit)
|
|
123
|
+
if cs2 is None:
|
|
124
|
+
return None
|
|
125
|
+
cs = cs2
|
|
126
|
+
if not cs:
|
|
127
|
+
return a
|
|
128
|
+
# pure literal elimination
|
|
129
|
+
present = {l for c in cs for l in c}
|
|
130
|
+
pure = next((l for l in present if (l ^ 1) not in present), None)
|
|
131
|
+
if pure is not None:
|
|
132
|
+
a = dict(a)
|
|
133
|
+
a[pure >> 1] = not (pure & 1)
|
|
134
|
+
cs2 = simplify(cs, pure)
|
|
135
|
+
return rec(cs2, a) if cs2 is not None else None
|
|
136
|
+
# split on the first literal of the first clause
|
|
137
|
+
lit = cs[0][0]
|
|
138
|
+
for choice in (lit, lit ^ 1):
|
|
139
|
+
a2 = dict(a)
|
|
140
|
+
a2[choice >> 1] = not (choice & 1)
|
|
141
|
+
cs2 = simplify(cs, choice)
|
|
142
|
+
if cs2 is None:
|
|
143
|
+
continue
|
|
144
|
+
r = rec(cs2, a2)
|
|
145
|
+
if r is not None:
|
|
146
|
+
return r
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
res = rec(clauses, assign)
|
|
150
|
+
if res is None:
|
|
151
|
+
return None
|
|
152
|
+
return [res.get(v, False) for v in range(f.nvars)]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# resolution
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def resolution_refute(f: CNF, max_clauses: int = 200_000) -> list[tuple] | None:
|
|
161
|
+
"""Saturating unrestricted resolution. Returns a derivation of the empty
|
|
162
|
+
clause as a list of ``(resolvent, parent_a, parent_b, pivot_var)``, or None
|
|
163
|
+
if the formula is satisfiable (saturation closed without deriving it).
|
|
164
|
+
|
|
165
|
+
This is the ground truth for "UNSAT means refutable": CDCL learning is
|
|
166
|
+
exactly a restricted form of this rule, so anything the solver refutes must
|
|
167
|
+
be refutable here. Exponential in the worst case, hence the cap.
|
|
168
|
+
"""
|
|
169
|
+
frontier = {frozenset(c) for c in f.clauses}
|
|
170
|
+
if frozenset() in frontier:
|
|
171
|
+
return []
|
|
172
|
+
known = set(frontier)
|
|
173
|
+
derivation: list[tuple] = []
|
|
174
|
+
parents: dict[frozenset, tuple] = {}
|
|
175
|
+
while True:
|
|
176
|
+
new = set()
|
|
177
|
+
items = list(known)
|
|
178
|
+
for i, a in enumerate(items):
|
|
179
|
+
for b in items[i + 1 :]:
|
|
180
|
+
for l in a:
|
|
181
|
+
if (l ^ 1) not in b:
|
|
182
|
+
continue
|
|
183
|
+
r = (a - {l}) | (b - {l ^ 1})
|
|
184
|
+
if any((x ^ 1) in r for x in r):
|
|
185
|
+
continue # tautology
|
|
186
|
+
r = frozenset(r)
|
|
187
|
+
if r in known or r in new:
|
|
188
|
+
continue
|
|
189
|
+
new.add(r)
|
|
190
|
+
parents[r] = (a, b, l >> 1)
|
|
191
|
+
if not r:
|
|
192
|
+
# unwind the derivation
|
|
193
|
+
order: list[tuple] = []
|
|
194
|
+
stack = [r]
|
|
195
|
+
seen = set()
|
|
196
|
+
while stack:
|
|
197
|
+
cur = stack.pop()
|
|
198
|
+
if cur in seen or cur not in parents:
|
|
199
|
+
continue
|
|
200
|
+
seen.add(cur)
|
|
201
|
+
pa, pb, piv = parents[cur]
|
|
202
|
+
order.append((tuple(sorted(cur)), tuple(sorted(pa)), tuple(sorted(pb)), piv))
|
|
203
|
+
stack.extend((pa, pb))
|
|
204
|
+
order.reverse()
|
|
205
|
+
return order
|
|
206
|
+
if not new:
|
|
207
|
+
return None
|
|
208
|
+
if len(known) + len(new) > max_clauses:
|
|
209
|
+
raise RuntimeError("resolution saturation exceeded the clause cap")
|
|
210
|
+
known |= new
|