dratify 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.
dratify/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Check a DRAT/DRUP proof of unsatisfiability, inside your Python process.
3
+
4
+ A SAT solver that answers "unsatisfiable" is asking to be trusted. A DRAT proof
5
+ is how it stops asking: the solver logs every clause it derives, and a checker
6
+ that shares no code with it replays the log and confirms the empty clause
7
+ really follows.
8
+
9
+ Emitting those proofs is common -- PySAT exposes them from six solver families
10
+ via ``with_proof=True`` / ``get_proof()``. *Checking* them from Python has not
11
+ been possible: ``drat-trim`` is C you have to compile and shell out to, and the
12
+ one checker on PyPI installs only on Linux x86-64 under Python <= 3.10.
13
+
14
+ >>> from dratify import parse_dimacs, check_proof
15
+ >>> formula = parse_dimacs(open("problem.cnf").read())
16
+ >>> result = check_proof(formula, open("proof.drat").read())
17
+ >>> result.ok
18
+ True
19
+
20
+ Two engines, and that is the point
21
+ ----------------------------------
22
+ The pure-Python checker has zero dependencies and runs anywhere Python does.
23
+ The optional Rust accelerator (``pip install dratify[native]``) is roughly 18x
24
+ faster. Neither is the "real" one: proof checking is the one domain where two
25
+ independent implementations agreeing *is* the evidence, and these two have been
26
+ differentially tested against each other, on acceptances and on rejections.
27
+
28
+ Pass ``engine="python"``, ``engine="native"``, or ``engine="auto"`` (the
29
+ default: native when installed).
30
+
31
+ RUP and RAT are both checked. Checking is forward -- every step is verified,
32
+ rather than working backwards from the empty clause.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from .cnf import CNF
38
+ from .lits import from_dimacs, to_dimacs
39
+ from .proof import (
40
+ DRATChecker,
41
+ MemoryProof,
42
+ ProofWriter,
43
+ check_proof,
44
+ )
45
+ from .cnf import parse_dimacs, parse_dimacs_file, write_dimacs
46
+ from . import _nativeinfo
47
+
48
+ __version__ = "0.1.0"
49
+
50
+ __all__ = [
51
+ "CNF",
52
+ "DRATChecker",
53
+ "MemoryProof",
54
+ "ProofWriter",
55
+ "check_proof",
56
+ "parse_dimacs",
57
+ "parse_dimacs_file",
58
+ "write_dimacs",
59
+ "from_dimacs",
60
+ "to_dimacs",
61
+ "native_available",
62
+ "__version__",
63
+ ]
64
+
65
+
66
+ def native_available() -> bool:
67
+ """True when the optional Rust checker is installed."""
68
+ return _nativeinfo.available()
dratify/_nativeinfo.py ADDED
@@ -0,0 +1,21 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Availability of the optional Rust checker."""
3
+
4
+ from __future__ import annotations
5
+
6
+ BUILD_HINT = (
7
+ "engine='native' was requested but the Rust checker is not installed. "
8
+ "The Rust accelerator is not yet published as a Python wheel; use "
9
+ "engine='python' or engine='auto' "
10
+ "(the pure-Python checker is complete and is never going away -- it is "
11
+ "the independent implementation that makes agreement meaningful)."
12
+ )
13
+
14
+
15
+ def available() -> bool:
16
+ """True when the Rust checker can be imported."""
17
+ try:
18
+ import sable_native
19
+ except ImportError:
20
+ return False
21
+ return hasattr(sable_native, "check_proof")
dratify/cnf.py ADDED
@@ -0,0 +1,340 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
3
+ """Clause and formula containers, plus DIMACS CNF input/output.
4
+
5
+ Two distinct objects live here and they are deliberately different:
6
+
7
+ ``Clause``
8
+ The solver's runtime clause. Mutable, carries the two watched literals in
9
+ slots 0 and 1, and holds the learnt-clause bookkeeping (LBD, activity,
10
+ deletion mark). Never used for storage or I/O.
11
+
12
+ ``CNF``
13
+ An immutable-ish *formula*: a variable count plus a list of clauses, each a
14
+ tuple of internal literals. This is what the parser produces, what the
15
+ encoders build, what the proof checker consumes and what the brute-force
16
+ reference solver evaluates. It has no solver state at all.
17
+
18
+ Keeping them apart means the encoders and the checker can be tested without
19
+ instantiating a solver, and the solver never has to worry about I/O concerns
20
+ such as tautology filtering or duplicate literals -- ``CNF.add`` normalises on
21
+ the way in.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import io
27
+ from typing import Iterable, Iterator, Sequence
28
+
29
+ from .lits import from_dimacs, to_dimacs
30
+
31
+ __all__ = ["Clause", "CNF", "parse_dimacs", "parse_dimacs_file", "write_dimacs"]
32
+
33
+
34
+ # --------------------------------------------------------------------------
35
+ # runtime clause
36
+ # --------------------------------------------------------------------------
37
+
38
+
39
+ class Clause:
40
+ """A runtime clause with two watched literals in ``lits[0]`` and ``lits[1]``.
41
+
42
+ Invariant maintained by the solver: if the clause is not satisfied at a
43
+ lower level, ``lits[0]`` and ``lits[1]`` are either unassigned or assigned
44
+ true, or the clause is conflicting / unit. See
45
+ :meth:`sable.solver.Solver._propagate` for the exact restoration logic.
46
+ """
47
+
48
+ __slots__ = ("lits", "learnt", "lbd", "act", "deleted", "seen")
49
+
50
+ def __init__(self, lits: Sequence[int], learnt: bool = False, lbd: int = 0) -> None:
51
+ self.lits: list[int] = list(lits)
52
+ self.learnt = learnt
53
+ self.lbd = lbd
54
+ self.act = 0.0
55
+ self.deleted = False
56
+ self.seen = False # scratch flag for subsumption / DB reduction
57
+
58
+ def __len__(self) -> int:
59
+ return len(self.lits)
60
+
61
+ def __iter__(self) -> Iterator[int]:
62
+ return iter(self.lits)
63
+
64
+ def __getitem__(self, i: int) -> int:
65
+ return self.lits[i]
66
+
67
+ def __setitem__(self, i: int, v: int) -> None:
68
+ self.lits[i] = v
69
+
70
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
71
+ kind = "learnt" if self.learnt else "orig"
72
+ body = " ".join(str(to_dimacs(l)) for l in self.lits)
73
+ return f"<Clause {kind} lbd={self.lbd} [{body}]>"
74
+
75
+
76
+ # --------------------------------------------------------------------------
77
+ # formula
78
+ # --------------------------------------------------------------------------
79
+
80
+
81
+ class CNF:
82
+ """A CNF formula over variables ``0 .. nvars-1``.
83
+
84
+ ``add`` performs three normalisations, all of them satisfiability
85
+ preserving and all of them required by the rest of the toolkit:
86
+
87
+ * duplicate literals are collapsed (``x v x = x``);
88
+ * tautologies (``x v ~x``) are dropped entirely;
89
+ * the variable count grows to cover every literal seen.
90
+
91
+ The empty clause is kept: it is the canonical certificate of
92
+ unsatisfiability and both the checker and the solver rely on being able to
93
+ represent it.
94
+ """
95
+
96
+ __slots__ = ("nvars", "clauses", "comments", "_names", "header_mismatch")
97
+
98
+ def __init__(self, nvars: int = 0) -> None:
99
+ self.nvars = nvars
100
+ self.clauses: list[tuple[int, ...]] = []
101
+ self.comments: list[str] = []
102
+ #: (declared_clauses, parsed_clauses) when a `p cnf` header disagreed
103
+ #: with what was actually read, else None.
104
+ #:
105
+ #: Tolerating a stray `%` line or a missing final `0` is being liberal
106
+ #: in what you accept. A clause count that disagrees with the header is
107
+ #: not that -- it means the formula in memory is not the formula in the
108
+ #: file. SATLIB's `dubois100.cnf` declares 800 clauses and parses to
109
+ #: 396 here, which flipped it from unsatisfiable to satisfiable, and
110
+ #: the benchmark compared two solvers on it for weeks without noticing
111
+ #: because both were fed the *re-serialised* formula and so agreed.
112
+ self.header_mismatch: tuple[int, int] | None = None
113
+ self._names: dict[int, str] = {}
114
+
115
+ # -- construction -------------------------------------------------------
116
+
117
+ def new_var(self, name: str | None = None) -> int:
118
+ """Allocate a fresh variable and return its 0-based index."""
119
+ v = self.nvars
120
+ self.nvars += 1
121
+ if name is not None:
122
+ self._names[v] = name
123
+ return v
124
+
125
+ def new_vars(self, n: int) -> list[int]:
126
+ return [self.new_var() for _ in range(n)]
127
+
128
+ def name(self, v: int) -> str:
129
+ return self._names.get(v, f"x{v}")
130
+
131
+ def set_name(self, v: int, name: str) -> None:
132
+ self._names[v] = name
133
+
134
+ def add(self, lits: Iterable[int]) -> bool:
135
+ """Add a clause of internal literals. Returns False if it was a tautology."""
136
+ seen: set[int] = set()
137
+ out: list[int] = []
138
+ for l in lits:
139
+ if l < 0:
140
+ raise ValueError(
141
+ f"internal literals are non-negative; got {l} "
142
+ "(use lits.from_dimacs for signed input)"
143
+ )
144
+ if l in seen:
145
+ continue
146
+ if (l ^ 1) in seen:
147
+ return False # tautology
148
+ seen.add(l)
149
+ out.append(l)
150
+ v = l >> 1
151
+ if v >= self.nvars:
152
+ self.nvars = v + 1
153
+ self.clauses.append(tuple(out))
154
+ return True
155
+
156
+ def add_dimacs(self, dimacs_lits: Iterable[int]) -> bool:
157
+ """Add a clause given as signed 1-based DIMACS literals."""
158
+ return self.add(from_dimacs(d) for d in dimacs_lits)
159
+
160
+ def extend(self, clauses: Iterable[Iterable[int]]) -> None:
161
+ for c in clauses:
162
+ self.add(c)
163
+
164
+ # -- queries ------------------------------------------------------------
165
+
166
+ @property
167
+ def nclauses(self) -> int:
168
+ return len(self.clauses)
169
+
170
+ def __len__(self) -> int:
171
+ return len(self.clauses)
172
+
173
+ def __iter__(self):
174
+ return iter(self.clauses)
175
+
176
+ def literals(self) -> Iterator[int]:
177
+ for c in self.clauses:
178
+ yield from c
179
+
180
+ def occurrence_counts(self) -> list[int]:
181
+ counts = [0] * (2 * self.nvars)
182
+ for c in self.clauses:
183
+ for l in c:
184
+ counts[l] += 1
185
+ return counts
186
+
187
+ def stats(self) -> dict[str, int | float]:
188
+ sizes = [len(c) for c in self.clauses]
189
+ total = sum(sizes)
190
+ return {
191
+ "vars": self.nvars,
192
+ "clauses": len(self.clauses),
193
+ "literals": total,
194
+ "max_len": max(sizes) if sizes else 0,
195
+ "min_len": min(sizes) if sizes else 0,
196
+ "avg_len": (total / len(sizes)) if sizes else 0.0,
197
+ "unit": sum(1 for s in sizes if s == 1),
198
+ "binary": sum(1 for s in sizes if s == 2),
199
+ "ternary": sum(1 for s in sizes if s == 3),
200
+ }
201
+
202
+ # -- evaluation ---------------------------------------------------------
203
+
204
+ def is_satisfied_by(self, model: Sequence[bool]) -> bool:
205
+ """True iff every clause has a true literal under ``model``."""
206
+ for c in self.clauses:
207
+ for l in c:
208
+ v = l >> 1
209
+ if v < len(model) and (model[v] != bool(l & 1)):
210
+ break
211
+ else:
212
+ return False
213
+ return True
214
+
215
+ def falsified_clauses(self, model: Sequence[bool]) -> list[tuple[int, ...]]:
216
+ """All clauses with no true literal (used for error messages)."""
217
+ bad = []
218
+ for c in self.clauses:
219
+ for l in c:
220
+ v = l >> 1
221
+ if v < len(model) and (model[v] != bool(l & 1)):
222
+ break
223
+ else:
224
+ bad.append(c)
225
+ return bad
226
+
227
+ def copy(self) -> "CNF":
228
+ f = CNF(self.nvars)
229
+ f.clauses = list(self.clauses)
230
+ f.comments = list(self.comments)
231
+ f._names = dict(self._names)
232
+ return f
233
+
234
+ # -- serialisation ------------------------------------------------------
235
+
236
+ def to_dimacs(self) -> str:
237
+ buf = io.StringIO()
238
+ write_dimacs(self, buf)
239
+ return buf.getvalue()
240
+
241
+ def save(self, path: str) -> None:
242
+ with open(path, "w", encoding="ascii") as fh:
243
+ write_dimacs(self, fh)
244
+
245
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
246
+ return f"<CNF vars={self.nvars} clauses={len(self.clauses)}>"
247
+
248
+
249
+ # --------------------------------------------------------------------------
250
+ # DIMACS I/O
251
+ # --------------------------------------------------------------------------
252
+
253
+
254
+ def parse_dimacs(text: str, strict: bool = False) -> CNF:
255
+ """Parse DIMACS CNF text.
256
+
257
+ Tolerates the real-world deviations that appear in benchmark archives:
258
+ clauses split across lines, a missing or wrong header, ``%`` / trailing
259
+ ``0`` terminators from SATLIB files, and blank lines. With ``strict=True``
260
+ a header mismatch raises instead of being repaired.
261
+ """
262
+ f = CNF()
263
+ declared_vars = declared_clauses = None
264
+ pending: list[int] = []
265
+ for lineno, raw in enumerate(text.splitlines(), 1):
266
+ line = raw.strip()
267
+ if not line:
268
+ continue
269
+ head = line[0]
270
+ if head == "c":
271
+ f.comments.append(line[1:].strip())
272
+ continue
273
+ if head == "%":
274
+ break # SATLIB terminator
275
+ if head == "p":
276
+ parts = line.split()
277
+ if len(parts) < 4 or parts[1] != "cnf":
278
+ raise ValueError(f"line {lineno}: malformed header {line!r}")
279
+ declared_vars = int(parts[2])
280
+ declared_clauses = int(parts[3])
281
+ if declared_vars > f.nvars:
282
+ f.nvars = declared_vars
283
+ continue
284
+ for tok in line.split():
285
+ try:
286
+ d = int(tok)
287
+ except ValueError:
288
+ raise ValueError(f"line {lineno}: bad token {tok!r}") from None
289
+ if d == 0:
290
+ f.add_dimacs(pending)
291
+ pending = []
292
+ else:
293
+ pending.append(d)
294
+ if pending:
295
+ if strict:
296
+ raise ValueError("input ends with an unterminated clause")
297
+ f.add_dimacs(pending)
298
+ if declared_vars is not None and declared_clauses is not None:
299
+ if declared_vars != f.nvars or declared_clauses != len(f.clauses):
300
+ if strict:
301
+ raise ValueError(
302
+ f"header says {declared_vars} vars / {declared_clauses} "
303
+ f"clauses, found {f.nvars} / {len(f.clauses)}"
304
+ )
305
+ f.header_mismatch = (declared_clauses, len(f.clauses))
306
+ return f
307
+
308
+
309
+ def parse_dimacs_file(path: str, strict: bool = False) -> CNF:
310
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
311
+ return parse_dimacs(fh.read(), strict=strict)
312
+
313
+
314
+ def write_dimacs(f: CNF, out) -> None:
315
+ """Write ``f`` in DIMACS CNF format to a text stream."""
316
+ for c in f.comments:
317
+ out.write(f"c {c}\n")
318
+ out.write(f"p cnf {f.nvars} {len(f.clauses)}\n")
319
+ for c in f.clauses:
320
+ out.write(" ".join(str(to_dimacs(l)) for l in c))
321
+ out.write(" 0\n")
322
+
323
+
324
+ # --------------------------------------------------------------------------
325
+ # model helpers
326
+ # --------------------------------------------------------------------------
327
+
328
+
329
+ def model_to_dimacs(model: Sequence[bool]) -> list[int]:
330
+ """Convert a boolean model into the signed literal list DIMACS expects."""
331
+ return [(i + 1) if b else -(i + 1) for i, b in enumerate(model)]
332
+
333
+
334
+ def model_from_dimacs(lits: Sequence[int], nvars: int) -> list[bool]:
335
+ model = [False] * nvars
336
+ for d in lits:
337
+ if d == 0:
338
+ continue
339
+ model[abs(d) - 1] = d > 0
340
+ return model
dratify/lits.py ADDED
@@ -0,0 +1,114 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright (c) 2026 Carlo Perassi. Licensed under the Apache License 2.0.
3
+ """Literal and truth-value representation.
4
+
5
+ SABLE uses the standard "doubled index" literal encoding found in MiniSat,
6
+ Glucose and CaDiCaL:
7
+
8
+ variable v 0-based integer in [0, nvars)
9
+ positive literal 2*v
10
+ negative literal 2*v + 1
11
+
12
+ The encoding makes negation a single XOR (``lit ^ 1``), makes the variable a
13
+ single shift (``lit >> 1``) and lets every per-literal table be a flat array of
14
+ length ``2 * nvars`` indexed directly by the literal. This is why the solver
15
+ never has to branch on the sign of a literal in the propagation inner loop,
16
+ which is where roughly 80% of a CDCL solver's time is spent.
17
+
18
+ Truth values are represented by the three-valued domain
19
+
20
+ U = 0 undefined
21
+ T = 1 true
22
+ F = 2 false
23
+
24
+ stored in a ``bytearray`` of length ``2 * nvars``. Both a literal and its
25
+ negation get an entry, kept complementary at all times by
26
+ :meth:`sable.solver.Solver._assign`. Reading ``val[lit]`` is then a single
27
+ array index with no sign test. ``flip(x)`` maps T<->F and leaves U alone.
28
+
29
+ DIMACS literals (the signed non-zero integers used by the standard file
30
+ format, where variables are 1-based) are converted at the I/O boundary only;
31
+ nothing inside the solver ever sees a signed literal.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ __all__ = [
37
+ "U",
38
+ "T",
39
+ "F",
40
+ "flip",
41
+ "neg",
42
+ "var_of",
43
+ "is_neg",
44
+ "mk_lit",
45
+ "from_dimacs",
46
+ "to_dimacs",
47
+ "lit_str",
48
+ "clause_str",
49
+ ]
50
+
51
+ # --------------------------------------------------------------------------
52
+ # three-valued logic
53
+ # --------------------------------------------------------------------------
54
+
55
+ U = 0 # undefined
56
+ T = 1 # true
57
+ F = 2 # false
58
+
59
+ #: ``_FLIP[x]`` is the negation of truth value ``x`` (U stays U).
60
+ _FLIP = (U, F, T)
61
+
62
+
63
+ def flip(x: int) -> int:
64
+ """Negate a three-valued truth value: T<->F, U->U."""
65
+ return _FLIP[x]
66
+
67
+
68
+ # --------------------------------------------------------------------------
69
+ # literals
70
+ # --------------------------------------------------------------------------
71
+
72
+
73
+ def neg(lit: int) -> int:
74
+ """Return the complement of ``lit``."""
75
+ return lit ^ 1
76
+
77
+
78
+ def var_of(lit: int) -> int:
79
+ """Return the 0-based variable underlying ``lit``."""
80
+ return lit >> 1
81
+
82
+
83
+ def is_neg(lit: int) -> bool:
84
+ """True when ``lit`` is a negative (complemented) literal."""
85
+ return bool(lit & 1)
86
+
87
+
88
+ def mk_lit(var: int, negated: bool = False) -> int:
89
+ """Build the literal for 0-based ``var``, complemented iff ``negated``."""
90
+ return (var << 1) | (1 if negated else 0)
91
+
92
+
93
+ def from_dimacs(d: int) -> int:
94
+ """Convert a signed 1-based DIMACS literal into internal encoding."""
95
+ if d == 0:
96
+ raise ValueError("0 is not a DIMACS literal (it is the clause terminator)")
97
+ v = abs(d) - 1
98
+ return (v << 1) | (1 if d < 0 else 0)
99
+
100
+
101
+ def to_dimacs(lit: int) -> int:
102
+ """Convert an internal literal back into signed 1-based DIMACS form."""
103
+ v = (lit >> 1) + 1
104
+ return -v if (lit & 1) else v
105
+
106
+
107
+ def lit_str(lit: int) -> str:
108
+ """Human-readable form of a literal, e.g. ``x3`` or ``~x3``."""
109
+ return ("~x" if (lit & 1) else "x") + str(lit >> 1)
110
+
111
+
112
+ def clause_str(lits) -> str:
113
+ """Human-readable form of a clause given as internal literals."""
114
+ return "(" + " v ".join(lit_str(l) for l in lits) + ")"