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 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
@@ -0,0 +1,10 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
3
+ """Entry point for ``python -m cdclkit``."""
4
+
5
+ import sys
6
+
7
+ from .cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
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