null: satkit -- verification suite; found and fixed two RUP checker defects (unsound satisfied-clause conflict, negative nfalse)

This commit is contained in:
null committed 2026-10-26 06:39:20 +00:00
1 parent 73baa4c204
commit c1cd9d581d
4 files changed
+1346

No files matched your search

@@ -0,0 +1,113 @@
# satkit verification — 11 legs, independent oracle, 4 mutants killed
`satkit.py` is a stdlib-only CDCL SAT solver (watched literals, VSIDS, 1UIP
learning, Luby restarts) with an RUP proof log and a separately written RUP
checker. It was shipped with no test suite. This directory is that suite, plus
the two real defects it found and the fix.
Files
-----
| file | role |
|---|---|
| `satkit.py` | the solver + checker (two checker bugs fixed here, see below) |
| `verify_satkit.py` | the suite: 11 legs, exit 0 iff all pass |
| `show_defects.py` | reproduces both pre-fix defects against the shipped code |
| `README.md` | this record |
Run
---
```
python3 verify_satkit.py # 11/11 legs, ~3.4 s, exit 0
python3 show_defects.py # both defects reproduced pre-fix, absent post-fix, exit 0
```
The suite never grades `satkit` with `satkit`:
* SAT/UNSAT is decided by exhaustive brute force on small instances (a model is
the certificate either way), and by *refutation* on large ones;
* every proof step is re-derived by `oracle_rup()` inside `verify_satkit.py`, a
textbook unit-propagation implementation that shares no code with the checker
it is auditing;
* models are checked by `satisfies()`, not by `satkit.model_satisfies`;
* the suite itself is attacked: four sabotaged copies of `satkit` must each fail
the leg aimed at them, or the run is red.
Defects found (measured, not inferred)
-------------------------------------
Both were in `RupChecker`. Both were found by differential fuzzing against the
textbook oracle — 4 disagreements in the first 18 random proof sequences.
**D1 — unsound: a satisfied clause reported as a conflict.**
`_propagate()` treated "`len(c)-1` literals false and no unassigned literal" as a
conflict, but that state also occurs when the remaining literal is *true*. The
checker therefore accepted proofs of formulas that have models:
```
F = [(-3,),(-2,),(2,1,3),(-1,5,4),(-2,5,3),(-3,-4),(1,),(-2,4,5),(5,),(2,4,-3)]
brute-force model: [1,-2,-3,-4,5] (model_satisfies -> True)
pre-fix check_proof(5, F, [()]) -> ok=True "all 1 steps RUP-checked" <- wrong
shipped check_proof(5, F, [()]) -> ok=False "step 0 rejected: not RUP"
```
**D2 — incomplete: `nfalse[]` driven negative.**
`_undo()` decremented the false-literal counter for every trail literal, including
literals never visited because propagation returned early on a conflict. The
counters went negative and later clauses could never reach "all false", blinding
the checker. Consequence: valid 1UIP refutations were rejected.
```
PHP(4,3) 4 pigeons / 3 holes, solver says UNSAT with a 7-step refutation
pre-fix accepted=False at step 1 (5 clauses at nfalse=-1 after step 0)
shipped accepted=True after 7 steps
```
Fix (two hunks, both in `satkit.py`): the `len(c)-1` branch now `continue`s when
the last non-false literal is true, and `_undo()` decrements only the clause
indices recorded in `self.touched` during propagation.
Post-fix: 6000 random proof sequences, 0 false accepts, 0 false rejects against
the textbook oracle.
Legs and what each one actually measured
---------------------------------------
```
edge_cases PASS 0.00s 7 degenerate cases
exhaustive_n2 PASS 0.01s n=2: 247 formulas (152 unsat), all agreed
exhaustive_n3 PASS 0.16s n=3: 4048 formulas (813 unsat), all agreed
random_sweep PASS 0.58s 1200 random CNFs: 458 sat / 742 unsat, models and refutations verified
php_proofs PASS 1.63s PHP(3,2) 2 steps; PHP(4,3) 7; PHP(5,4) 28; PHP(6,5) 145; PHP(7,6) 765
random3sat_proofs PASS 0.83s 6 instances n=70 ratio 4.3: 2 sat (models ok) / 4 unsat (refutations ok)
oracle_selfcheck PASS 0.05s 535 model-falsified candidates, oracle never claimed RUP
negative_controls PASS 0.00s 5 forged proofs all rejected
cross_process PASS 0.13s child emitted a 7-step proof; parent oracle + checker both accept
determinism PASS 0.00s identical sat/model/proof across repeat runs
mutant_control PASS 0.00s 4/4 mutants killed
11/11 legs passed in 3.40s
```
* `exhaustive_n2/n3` enumerate **every** formula over all non-tautological clauses
of width ≤ 2 up to 6 (resp. 4) clauses — 4295 formulas, not a sample.
* `negative_controls` is the leg that would have caught D1: forged empty clause on
a satisfiable formula, a refutation built from the negation of a known model, a
truncated refutation, a genuine refutation transplanted onto a weaker
satisfiable formula, and a provably non-RUP step spliced into step 0.
* `mutant_control` kills: `flipped_sat` (caught by `exhaustive_n2`),
`all_positive_model` (`random_sweep`), `short_proof` (`php_proofs`),
`lax_checker` (`negative_controls`). A green run means those legs have teeth.
Scope — what this does *not* establish
-------------------------------------
* RUP only. The checker is sound and complete for RUP steps; a valid proof that
needed RAT (clause addition by resolution on a pivot, used by some DRAT
producers) would be rejected. `satkit` emits RUP steps only, so this does not
affect it, but the checker is not a general DRAT checker.
* Exhaustive agreement covers n ≤ 3; n ≤ 10 is sampled (1200 CNFs), and larger
instances are covered only where a refutation or a model certifies the answer.
There is no completeness test on satisfiable large instances beyond the models
returned.
* Refutation checking is a soundness certificate: a checked RUP proof ending in
the empty clause proves UNSAT. A rejected proof proves nothing about the
formula — only that this producer's proof was bad.
* `show_defects.py` reconstructs the pre-fix methods by subclassing, so the
"before" column is a faithful copy of the shipped pre-fix code, not a rerun of
an old checkout.
@@ -0,0 +1,533 @@
#!/usr/bin/env python3
"""satkit -- CDCL SAT solver with RUP/DRAT-style proof logging and an
independent RUP proof checker. Stdlib only.
Literals are nonzero ints: +v / -v for variable v in 1..nvars.
Public API
----------
solve(nvars, clauses, proof_out=None) -> SolveResult
SolveResult(sat, model, proof, stats)
sat : True / False
model : list of ints (1..nvars) when sat, else None
proof : list of clauses (learned, ending with [] for UNSAT) when unsat
stats : dict of counters
check_proof(nvars, clauses, proof) -> ProofResult
ProofResult(ok, steps_checked, message)
The two halves are deliberately independent implementations:
* the solver uses two watched literals, VSIDS activity, 1UIP conflict
analysis, non-chronological backjumping and Luby restarts;
* the checker uses occurrence lists and per-clause false-counters, never
touches solver state, and re-derives every step by unit propagation.
A proof step C is accepted iff F & ~C unit-propagates to a conflict (RUP).
An UNSAT proof must terminate with the empty clause.
"""
from __future__ import annotations
__all__ = ["solve", "check_proof", "SolveResult", "ProofResult", "brute_force_sat"]
# --------------------------------------------------------------------------
# helpers
# --------------------------------------------------------------------------
def normalize(lits):
"""Dedupe, drop tautologies. Returns None for a tautology, else a tuple."""
seen = set()
out = []
for l in lits:
if -l in seen:
return None
if l in seen:
continue
seen.add(l)
out.append(l)
return tuple(out)
class SolveResult:
__slots__ = ("sat", "model", "proof", "stats")
def __init__(self, sat, model, proof, stats):
self.sat = sat
self.model = model
self.proof = proof
self.stats = stats
def __repr__(self):
return "SolveResult(sat=%r, model=%r, stats=%r)" % (
self.sat, self.model, self.stats)
class ProofResult:
__slots__ = ("ok", "steps_checked", "message")
def __init__(self, ok, steps_checked, message):
self.ok = ok
self.steps_checked = steps_checked
self.message = message
def __bool__(self):
return self.ok
def __repr__(self):
return "ProofResult(ok=%r, steps=%d, msg=%r)" % (
self.ok, self.steps_checked, self.message)
# --------------------------------------------------------------------------
# CDCL solver
# --------------------------------------------------------------------------
class Solver:
def __init__(self, nvars, clauses, collect_proof=True):
self.n = nvars
self.collect_proof = collect_proof
self.clauses = [] # watched lits held at positions 0 and 1
self.watches = [[] for _ in range(2 * nvars)]
self.assign = [0] * (nvars + 1) # 0 unassigned / 1 true / -1 false
self.level = [0] * (nvars + 1)
self.reason = [-1] * (nvars + 1)
self.seen = [False] * (nvars + 1)
self.trail = []
self.trail_lim = []
self.qhead = 0
self.activity = [0.0] * (nvars + 1)
self.phase = [-1] * (nvars + 1)
self.var_inc = 1.0
self.var_decay = 0.95
self.ok = True
self.proof = []
self.stats = {"conflicts": 0, "decisions": 0, "propagations": 0,
"restarts": 0, "learned": 0, "max_level": 0}
for c in clauses:
if not self.add_input_clause(c):
break
# -- literal helpers ---------------------------------------------------
@staticmethod
def _widx(lit):
return 2 * (abs(lit) - 1) + (0 if lit > 0 else 1)
def val(self, lit):
a = self.assign[abs(lit)]
return a if lit > 0 else -a
# -- clause database ---------------------------------------------------
def add_input_clause(self, lits):
c = normalize(lits)
if c is None: # tautology: carries no information
return True
if len(c) == 0:
self.ok = False
return False
if len(c) == 1:
if self.enqueue(c[0], -1) is False:
self.ok = False
return False
return True
ci = len(self.clauses)
self.clauses.append(list(c))
self.watches[self._widx(c[0])].append(ci)
self.watches[self._widx(c[1])].append(ci)
return True
def add_learned_clause(self, lits):
"""Watch lits[0], lits[1]; lits[0] must be the asserting literal."""
ci = len(self.clauses)
self.clauses.append(list(lits))
self.watches[self._widx(lits[0])].append(ci)
self.watches[self._widx(lits[1])].append(ci)
return ci
# -- assignment --------------------------------------------------------
def enqueue(self, lit, reason):
v = abs(lit)
cur = self.assign[v]
if cur != 0:
return (cur > 0) == (lit > 0)
self.assign[v] = 1 if lit > 0 else -1
self.level[v] = len(self.trail_lim)
self.reason[v] = reason
self.trail.append(lit)
return True
def new_decision_level(self):
self.trail_lim.append(len(self.trail))
@property
def decision_level(self):
return len(self.trail_lim)
def backtrack(self, lvl):
if self.decision_level <= lvl:
return
cut = self.trail_lim[lvl]
for i in range(len(self.trail) - 1, cut - 1, -1):
lit = self.trail[i]
v = abs(lit)
self.phase[v] = 1 if lit > 0 else -1
self.assign[v] = 0
self.reason[v] = -1
self.level[v] = 0
del self.trail[cut:]
del self.trail_lim[lvl:]
self.qhead = len(self.trail)
# -- unit propagation --------------------------------------------------
def propagate(self):
"""Returns a conflicting clause index, or None."""
assign, level, clauses = self.assign, self.level, self.clauses
watches = self.watches
while self.qhead < len(self.trail):
p = self.trail[self.qhead]
self.qhead += 1
self.stats["propagations"] += 1
false_lit = -p
ws = watches[self._widx(false_lit)]
i = 0
while i < len(ws):
ci = ws[i]
c = clauses[ci]
if c[0] == false_lit:
c[0], c[1] = c[1], c[0]
if c[1] != false_lit:
raise RuntimeError("watch invariant broken on clause %d" % ci)
first = c[0]
if self.val(first) > 0:
i += 1
continue
moved = False
for k in range(2, len(c)):
if self.val(c[k]) >= 0: # unassigned or true
c[1], c[k] = c[k], c[1]
watches[self._widx(c[1])].append(ci)
ws[i] = ws[-1]
ws.pop()
moved = True
break
if moved:
continue
if self.val(first) < 0:
return ci
if not self.enqueue(first, ci):
return ci
i += 1
return None
# -- VSIDS -------------------------------------------------------------
def bump(self, v):
self.activity[v] += self.var_inc
if self.activity[v] > 1e100:
for i in range(1, self.n + 1):
self.activity[i] *= 1e-100
self.var_inc *= 1e-100
def pick_branch(self):
best, best_act = 0, -1.0
for v in range(1, self.n + 1):
if self.assign[v] == 0 and self.activity[v] > best_act:
best, best_act = v, self.activity[v]
return best
# -- 1UIP conflict analysis -------------------------------------------
def analyze(self, confl):
seen = self.seen
for v in range(1, self.n + 1):
seen[v] = False
learned = [0]
pathc = 0
p = 0
idx = len(self.trail) - 1
ci = confl
cur = self.decision_level
while True:
c = self.clauses[ci]
start = 0 if p == 0 else 1 # skip the implied literal (at [0])
for q in c[start:]:
v = abs(q)
if not seen[v] and self.level[v] > 0:
seen[v] = True
self.bump(v)
if self.level[v] == cur:
pathc += 1
else:
learned.append(q)
while idx >= 0 and not seen[abs(self.trail[idx])]:
idx -= 1
if idx < 0:
raise RuntimeError("analyze: ran off the trail")
p = self.trail[idx]
idx -= 1
seen[abs(p)] = False
pathc -= 1
if pathc == 0:
break
ci = self.reason[abs(p)]
if ci < 0:
raise RuntimeError("analyze: no reason for implied literal")
learned[0] = -p
# backtrack level = second highest level in the clause
if len(learned) == 1:
bt = 0
else:
bt = 0
best_i = 1
for i in range(1, len(learned)):
lv = self.level[abs(learned[i])]
if lv > bt:
bt, best_i = lv, i
learned[1], learned[best_i] = learned[best_i], learned[1]
return learned, bt
# -- search ------------------------------------------------------------
def solve(self):
if not self.ok:
if self.collect_proof:
self.proof.append([])
return SolveResult(False, None, self.proof, self.stats)
confl = self.propagate()
if confl is not None:
if self.collect_proof:
self.proof.append([])
return SolveResult(False, None, self.proof, self.stats)
restart_no = 1
budget = 100 * _luby(2, restart_no)
conflicts_here = 0
while True:
confl = self.propagate()
if confl is not None:
self.stats["conflicts"] += 1
conflicts_here += 1
if self.decision_level == 0:
if self.collect_proof:
self.proof.append([])
return SolveResult(False, None, self.proof, self.stats)
learned, bt = self.analyze(confl)
self.backtrack(bt)
if len(learned) == 1:
self.enqueue(learned[0], -1)
else:
ci = self.add_learned_clause(learned)
self.enqueue(learned[0], ci)
self.stats["learned"] += 1
if self.collect_proof:
self.proof.append(list(learned))
self.var_inc /= self.var_decay
if conflicts_here >= budget:
self.backtrack(0)
self.stats["restarts"] += 1
restart_no += 1
conflicts_here = 0
budget = 100 * _luby(2, restart_no)
continue
v = self.pick_branch()
if v == 0:
model = [v2 if self.assign[v2] > 0 else -v2
for v2 in range(1, self.n + 1)]
return SolveResult(True, model, None, self.stats)
self.stats["decisions"] += 1
self.new_decision_level()
lit = v if self.phase[v] > 0 else -v
self.enqueue(lit, -1)
if self.decision_level > self.stats["max_level"]:
self.stats["max_level"] = self.decision_level
def _luby(y, x):
"""MiniSat's luby sequence: 1,1,2,1,1,2,4,... for y=2."""
size, seq = 1, 0
while size < x + 1:
seq += 1
size = 2 * size + 1
while size - 1 != x:
size = (size - 1) >> 1
seq -= 1
x = x % size
return y ** seq
def solve(nvars, clauses, collect_proof=True):
return Solver(nvars, clauses, collect_proof).solve()
# --------------------------------------------------------------------------
# independent RUP proof checker (occurrence lists + false-counters)
# --------------------------------------------------------------------------
class RupChecker:
def __init__(self, nvars, clauses):
self.n = nvars
self.clauses = [] # normalized tuples
self.occ = {} # literal -> [clause index]
self.nfalse = []
self.taut = []
self.units = []
self.has_empty = False
self.val = [0] * (nvars + 1)
self.trail = []
self.touched = [] # clause indices whose nfalse was incremented
self.qhead = 0
for c in clauses:
norm = normalize(c)
if norm is None:
continue
self._db_add(norm)
def _db_add(self, norm):
ci = len(self.clauses)
self.clauses.append(norm)
for l in norm:
self.occ.setdefault(l, []).append(ci)
self.nfalse.append(0)
if len(norm) == 0:
self.has_empty = True
elif len(norm) == 1:
self.units.append(norm[0])
return ci
# -- temporary assignment layer ---------------------------------------
def _assume(self, lit):
"""Returns False on contradiction with the current temporary trail."""
v = abs(lit)
cur = self.val[v]
if cur != 0:
return (cur > 0) == (lit > 0)
self.val[v] = 1 if lit > 0 else -1
self.trail.append(lit)
return True
def _propagate(self):
"""True iff a clause became fully false."""
clauses, occ, nfalse = self.clauses, self.occ, self.nfalse
while self.qhead < len(self.trail):
p = self.trail[self.qhead]
self.qhead += 1
for ci in occ.get(-p, ()):
nfalse[ci] += 1
self.touched.append(ci)
c = clauses[ci]
if nfalse[ci] == len(c):
return True
if nfalse[ci] == len(c) - 1:
unit = 0
for l in c:
if self.val[abs(l)] == 0:
unit = l
break
if unit == 0:
# the single non-false literal is TRUE: clause satisfied
continue
if not self._assume(unit):
return True
return False
def _undo(self):
# Undo exactly the increments that happened. Walking the trail instead
# would decrement clauses never visited when propagation returned early,
# driving nfalse[] negative and silently blinding later steps.
for ci in self.touched:
self.nfalse[ci] -= 1
self.touched = []
for p in self.trail:
self.val[abs(p)] = 0
self.trail = []
self.qhead = 0
# -- one proof step ----------------------------------------------------
def check_step(self, lits):
norm = normalize(lits)
if norm is None:
return True, "tautology (vacuously RUP)"
conflict = self.has_empty
if not conflict:
for u in self.units:
if not self._assume(u):
conflict = True
break
if not conflict:
for l in norm:
if not self._assume(-l):
conflict = True
break
if not conflict:
conflict = self._propagate()
self._undo()
if not conflict:
return False, "not RUP: unit propagation reached a fixpoint with no conflict"
self._db_add(norm)
return True, "rup"
def check_proof(nvars, clauses, proof, require_empty=True):
"""Verify a RUP proof. Every step must be RUP w.r.t. clauses + earlier steps.
require_empty: the proof must end with the empty clause (UNSAT claim).
"""
chk = RupChecker(nvars, clauses)
if not proof:
return ProofResult(False, 0, "empty proof")
for i, step in enumerate(proof):
ok, why = chk.check_step(step)
if not ok:
return ProofResult(False, i, "step %d rejected: %s" % (i, why))
if require_empty:
last = normalize(proof[-1])
if last is None or len(last) != 0:
return ProofResult(False, len(proof),
"proof does not end with the empty clause")
return ProofResult(True, len(proof), "all %d steps RUP-checked" % len(proof))
# --------------------------------------------------------------------------
# reference oracle (exponential; only for tiny instances)
# --------------------------------------------------------------------------
def brute_force_sat(nvars, clauses):
"""Return a model (list of +-v) or None. Exhaustive, no cleverness."""
norm = []
for c in clauses:
n = normalize(c)
if n is None:
continue
norm.append(n)
if len(n) == 0:
return None
for mask in range(1 << nvars):
ok = True
for c in norm:
sat = False
for l in c:
v = abs(l)
bit = (mask >> (v - 1)) & 1
if (bit == 1) == (l > 0):
sat = True
break
if not sat:
ok = False
break
if ok:
return [v if (mask >> (v - 1)) & 1 else -v for v in range(1, nvars + 1)]
return None
def model_satisfies(nvars, clauses, model):
"""Independent model check against the raw clause list."""
val = {}
for lit in model:
val[abs(lit)] = lit > 0
if len(val) != nvars:
return False
for c in clauses:
if not any(val.get(abs(l)) == (l > 0) for l in c):
return False
return True
if __name__ == "__main__":
# tiny demo
cls = [[1, 2], [-1, 2], [1, -2], [-1, -2]]
r = solve(2, cls)
print("demo sat=%r model=%r stats=%r" % (r.sat, r.model, r.stats))
print("proof check:", check_proof(2, cls, r.proof))
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""show_defects.py -- reproduce the two checker defects found in satkit.py, and show
that the shipped checker no longer has them.
The pre-fix behaviour is reconstructed by subclassing satkit.RupChecker with the
two original methods (verbatim from the code before the fix), so this runs against
one copy of satkit.py and needs no second checkout.
D1 (unsound) : a clause that is SATISFIED but has exactly len-1 false literals was
reported as a conflict, so the checker accepted proofs of formulas
that are satisfiable.
D2 (incomplete) : _undo() decremented nfalse[] for every trail literal, including ones
never visited when propagation returned early. nfalse[] went negative,
which blinded later steps, so a valid 1UIP refutation was rejected at
its second step.
Exit 0 iff the old behaviour is reproduced AND the shipped behaviour is correct.
"""
from __future__ import annotations
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)
import satkit
# --------------------------------------------------------------------------
# the two methods exactly as they were before the fix
# --------------------------------------------------------------------------
class PreFixChecker(satkit.RupChecker):
def _propagate(self):
clauses, occ, nfalse = self.clauses, self.occ, self.nfalse
while self.qhead < len(self.trail):
p = self.trail[self.qhead]
self.qhead += 1
for ci in occ.get(-p, ()):
nfalse[ci] += 1
c = clauses[ci]
if nfalse[ci] == len(c):
return True
if nfalse[ci] == len(c) - 1:
unit = 0
for l in c:
if self.val[abs(l)] == 0:
unit = l
break
if unit == 0:
return True # BUG D1: this may be a satisfied clause
if not self._assume(unit):
return True
return False
def _undo(self):
for p in reversed(self.trail):
for ci in self.occ.get(-p, ()):
self.nfalse[ci] -= 1 # BUG D2: decrements clauses never touched
self.val[abs(p)] = 0
self.trail = []
self.qhead = 0
def check_with(cls, nvars, clauses, proof, require_empty=True):
chk = cls(nvars, clauses)
if not proof:
return False, 0, "empty proof"
for i, step in enumerate(proof):
ok, why = chk.check_step(step)
if not ok:
return False, i, "step %d rejected: %s" % (i, why)
if require_empty:
last = satkit.normalize(proof[-1])
if last is None or len(last) != 0:
return False, len(proof), "no empty clause"
return True, len(proof), "accepted"
def php(holes, pigeons):
def v(p, h):
return p * holes + h + 1
cl = [tuple(v(p, h) for h in range(holes)) for p in range(pigeons)]
for h in range(holes):
for p1 in range(pigeons):
for p2 in range(p1 + 1, pigeons):
cl.append((-v(p1, h), -v(p2, h)))
return pigeons * holes, cl
def main():
failures = 0
print("D1 -- forged empty clause on a SATISFIABLE formula")
F = [(-3,), (-2,), (2, 1, 3), (-1, 5, 4), (-2, 5, 3), (-3, -4), (1,), (-2, 4, 5), (5,), (2, 4, -3)]
model = satkit.brute_force_sat(5, F)
print(" F is satisfiable, model %r (verified %r)"
% (model, satkit.model_satisfies(5, F, model)))
old_ok, _, old_msg = check_with(PreFixChecker, 5, F, [()])
new_ok, _, new_msg = check_with(satkit.RupChecker, 5, F, [()])
print(" pre-fix checker : accepted=%r (%s)" % (old_ok, old_msg))
print(" shipped checker : accepted=%r (%s)" % (new_ok, new_msg))
if not old_ok:
print(" !! defect D1 not reproduced -- this record is stale")
failures += 1
if new_ok:
print(" !! shipped checker still unsound")
failures += 1
print("\nD2 -- valid 1UIP refutation of PHP(4,3) (4 pigeons, 3 holes)")
n, cl = php(3, 4)
r = satkit.solve(n, cl)
print(" solver: sat=%r, %d proof steps" % (r.sat, len(r.proof)))
old_ok, old_i, old_msg = check_with(PreFixChecker, n, cl, r.proof)
print(" pre-fix checker : accepted=%r at step %d (%s)" % (old_ok, old_i, old_msg))
chk = PreFixChecker(n, cl)
chk.check_step(r.proof[0])
negatives = sum(1 for x in chk.nfalse if x < 0)
print(" nfalse[] after one step: %d clauses negative (min %d) -> propagation blinded"
% (negatives, min(chk.nfalse)))
new_ok, new_i, new_msg = check_with(satkit.RupChecker, n, cl, r.proof)
print(" shipped checker : accepted=%r after %d steps (%s)" % (new_ok, new_i, new_msg))
if old_ok or negatives == 0:
print(" !! defect D2 not reproduced -- this record is stale")
failures += 1
if not new_ok:
print(" !! shipped checker still rejects valid refutations")
failures += 1
print("\n%s" % ("both defects reproduced pre-fix and absent post-fix"
if failures == 0 else "%d assertion(s) failed" % failures))
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,562 @@
#!/usr/bin/env python3
"""verify_satkit.py -- adversarial verification of satkit.py (CDCL solver + RUP proof checker).
Nothing here trusts satkit for its own grade:
* satisfiability is decided against an exhaustive oracle (brute force) on small
instances and against *proofs* on large ones;
* every proof step is re-checked by a unit-propagation implementation that lives
in this file (``oracle_rup``) and shares no code with satkit's checker;
* the checker is attacked with forged proofs it must reject;
* the suite itself is attacked: deliberately broken copies of satkit must fail
the leg aimed at them, otherwise a green run would mean nothing.
Legs
----
1 edge_cases degenerate inputs (0 vars, empty clause, no clauses)
2 exhaustive_n2 every formula over the 8 clauses of width<=2 on 2 vars, <=6 clauses
3 exhaustive_n3 every formula over the 18 clauses of width<=2 on 3 vars, <=4 clauses
4 random_sweep 1200 random CNFs, n<=10: sat/model/proof vs oracle
5 php_proofs pigeonhole PHP(n+1,n), n=2..6: UNSAT + proof re-checked step by step
6 random3sat_proofs 3-SAT at ratio 4.3, n=70: model or re-checked refutation
7 oracle_selfcheck the harness oracle never calls a non-implied clause RUP
8 negative_controls forged/truncated/transplanted proofs must be REJECTED
9 cross_process proof produced in a child process, checked in the parent
10 determinism identical inputs -> identical sat/model/proof
11 mutant_control 4 sabotaged satkits, each must fail its designated leg
Exit 0 iff every leg passes. stdlib only.
"""
from __future__ import annotations
import itertools
import json
import os
import random
import subprocess
import sys
import tempfile
import time
HERE = os.path.dirname(os.path.abspath(__file__))
if HERE not in sys.path:
sys.path.insert(0, HERE)
import satkit # noqa: E402
# ==========================================================================
# harness-owned primitives (deliberately independent of satkit)
# ==========================================================================
def norm_clause(lits):
"""Dedupe; None for a tautology; () for the empty clause."""
seen = set()
out = []
for l in lits:
if -l in seen:
return None
if l in seen:
continue
seen.add(l)
out.append(l)
return tuple(out)
def oracle_rup(db, cand):
"""Textbook RUP: db + units + the negation of cand must unit-propagate to conflict."""
val = {}
for l in cand:
v = abs(l)
if v in val and val[v] != (l > 0):
return True # negated assumption set is contradictory
val[v] = l < 0 # assume -l
for c in db:
if len(c) == 1:
v = abs(c[0])
if v in val and val[v] != (c[0] > 0):
return True
val[v] = c[0] > 0
changed = True
while changed:
changed = False
for c in db:
sat, un = False, []
for l in c:
v = abs(l)
if v not in val:
un.append(l)
elif val[v] == (l > 0):
sat = True
break
if sat:
continue
if not un:
return True # every literal false -> conflict
if len(un) == 1:
val[abs(un[0])] = un[0] > 0
changed = True
return False
def oracle_check_proof(nvars, clauses, proof, require_empty=True):
"""(ok, index, message) -- step-by-step RUP with the harness's own propagator."""
db = []
for c in clauses:
n = norm_clause(c)
if n is not None:
db.append(n)
if not proof:
return False, 0, "empty proof"
for i, step in enumerate(proof):
n = norm_clause(step)
if n is None:
continue # tautology: vacuously RUP
if not oracle_rup(db, n):
return False, i, "step %d not RUP: %r" % (i, list(step))
db.append(n)
if require_empty:
last = norm_clause(proof[-1])
if last is None or len(last) != 0:
return False, len(proof), "proof does not end with the empty clause"
return True, len(proof), "all %d steps RUP-checked (harness oracle)" % len(proof)
def satisfies(nvars, clauses, model):
"""Harness-owned model check. Rejects malformed or incomplete models."""
if model is None or len(model) != nvars:
return False
val = {}
for lit in model:
if lit == 0 or abs(lit) > nvars or abs(lit) in val:
return False
val[abs(lit)] = lit > 0
if len(val) != nvars:
return False
for c in clauses:
n = norm_clause(c)
if n is None:
continue
if not any(val[abs(l)] == (l > 0) for l in n):
return False
return True
def brute_force(nvars, clauses):
"""Model or None. Exhaustive; the only authority on SAT/UNSAT here."""
cs = []
for c in clauses:
n = norm_clause(c)
if n is None:
continue
cs.append(n)
if len(n) == 0:
return None
for mask in range(1 << nvars):
ok = True
for c in cs:
if not any(((mask >> (abs(l) - 1)) & 1) == (1 if l > 0 else 0) for l in c):
ok = False
break
if ok:
return [v if (mask >> (v - 1)) & 1 else -v for v in range(1, nvars + 1)]
return None
# ==========================================================================
# instance generators
# ==========================================================================
def php(holes, pigeons):
"""pigeons > holes -> UNSAT. var(p,h) = p*holes + h + 1"""
def v(p, h):
return p * holes + h + 1
cl = [tuple(v(p, h) for h in range(holes)) for p in range(pigeons)]
for h in range(holes):
for p1 in range(pigeons):
for p2 in range(p1 + 1, pigeons):
cl.append((-v(p1, h), -v(p2, h)))
return pigeons * holes, cl
def rand_cnf(rng, n, m, kmax=3):
cl = []
for _ in range(m):
k = rng.randint(1, min(kmax, n))
vs = rng.sample(range(1, n + 1), k)
cl.append(tuple(v if rng.random() < 0.5 else -v for v in vs))
return cl
def rand_3sat(rng, n, ratio=4.3):
m = int(ratio * n)
cl = []
for _ in range(m):
vs = rng.sample(range(1, n + 1), 3)
cl.append(tuple(v if rng.random() < 0.5 else -v for v in vs))
return cl
# ==========================================================================
# legs -- every leg takes the module under test and returns (ok, detail)
# ==========================================================================
def leg_edge_cases(mod):
cases = [
(0, []), # no variables, no clauses -> SAT
(0, [()]), # empty clause -> UNSAT
(1, []), # free variable -> SAT
(1, [()]), # empty clause -> UNSAT
(3, [(1, -1)]), # tautology only -> SAT
(2, [(1,), (-1,)]), # direct contradiction -> UNSAT
(2, [(1, 2), (-1,), (-2,)]), # UNSAT by propagation
]
for n, cl in cases:
r = mod.solve(n, list(cl))
want = brute_force(n, cl) is not None
if r.sat != want:
return False, "n=%d %r: solver sat=%r oracle=%r" % (n, cl, r.sat, want)
if r.sat:
if not satisfies(n, cl, r.model):
return False, "n=%d %r: bad model %r" % (n, cl, r.model)
if r.proof is not None:
return False, "n=%d %r: SAT answer carried a proof" % (n, cl)
else:
ok, i, msg = oracle_check_proof(n, cl, r.proof)
if not ok:
return False, "n=%d %r: refutation rejected (%s)" % (n, cl, msg)
return True, "%d degenerate cases" % len(cases)
def _exhaustive(mod, nvars, max_size, label):
lits = [v for v in range(1, nvars + 1)]
pool = []
for l in lits:
pool.append((l,))
pool.append((-l,))
for a, b in itertools.combinations(lits, 2):
for sa in (1, -1):
for sb in (1, -1):
pool.append((sa * a, sb * b))
pool = [c for c in pool if norm_clause(c) is not None]
total = 0
unsat = 0
for size in range(0, max_size + 1):
for combo in itertools.combinations(range(len(pool)), size):
cl = [pool[i] for i in combo]
r = mod.solve(nvars, cl)
want = brute_force(nvars, cl) is not None
total += 1
if r.sat != want:
return False, "%s: %r solver=%r oracle=%r" % (label, cl, r.sat, want)
if r.sat:
if not satisfies(nvars, cl, r.model):
return False, "%s: %r bad model %r" % (label, cl, r.model)
else:
unsat += 1
ok, i, msg = oracle_check_proof(nvars, cl, r.proof)
if not ok:
return False, "%s: %r refutation rejected (%s)" % (label, cl, msg)
return True, "%s: %d formulas (%d unsat), all agreed" % (label, total, unsat)
def leg_exhaustive_n2(mod):
return _exhaustive(mod, 2, 6, "n=2")
def leg_exhaustive_n3(mod):
return _exhaustive(mod, 3, 4, "n=3")
def leg_random_sweep(mod):
rng = random.Random(424242)
sat_n = unsat_n = 0
for t in range(1200):
n = rng.randint(1, 10)
cl = rand_cnf(rng, n, rng.randint(0, 26))
r = mod.solve(n, cl)
want = brute_force(n, cl) is not None
if r.sat != want:
return False, "case %d n=%d %r: solver=%r oracle=%r" % (t, n, cl, r.sat, want)
if r.sat:
sat_n += 1
if not satisfies(n, cl, r.model):
return False, "case %d n=%d %r: bad model %r" % (t, n, cl, r.model)
else:
unsat_n += 1
ok, i, msg = oracle_check_proof(n, cl, r.proof)
if not ok:
return False, "case %d n=%d %r: refutation rejected (%s)" % (t, n, cl, msg)
return True, "1200 random CNFs: %d sat / %d unsat, models and refutations verified" % (sat_n, unsat_n)
def leg_php_proofs(mod):
detail = []
for holes in range(2, 7):
n, cl = php(holes, holes + 1)
t0 = time.time()
r = mod.solve(n, cl)
dt = time.time() - t0
if r.sat:
return False, "PHP(%d,%d) reported SAT" % (holes + 1, holes)
ok, i, msg = oracle_check_proof(n, cl, r.proof)
if not ok:
return False, "PHP(%d,%d): %s" % (holes + 1, holes, msg)
if len(r.proof) < 2:
return False, "PHP(%d,%d): refutation has %d steps" % (holes + 1, holes, len(r.proof))
detail.append("PHP(%d,%d) %d steps %.3fs" % (holes + 1, holes, len(r.proof), dt))
return True, "; ".join(detail)
def leg_random3sat_proofs(mod):
rng = random.Random(90210)
sat_n = unsat_n = 0
for seed in range(6):
n = 70
cl = rand_3sat(rng, n)
r = mod.solve(n, cl)
if r.sat:
sat_n += 1
if not satisfies(n, cl, r.model):
return False, "seed %d: bad model" % seed
else:
unsat_n += 1
ok, i, msg = oracle_check_proof(n, cl, r.proof)
if not ok:
return False, "seed %d: %s" % (seed, msg)
return True, "6 instances n=70 ratio 4.3: %d sat (models ok) / %d unsat (refutations ok)" % (sat_n, unsat_n)
def leg_oracle_selfcheck(mod):
"""RUP implies implication: if a model of F falsifies C, C must not be RUP."""
rng = random.Random(5150)
checked = 0
for _ in range(800):
n = rng.randint(1, 7)
cl = rand_cnf(rng, n, rng.randint(1, 10))
model = brute_force(n, cl)
if model is None:
continue
val = {abs(l): l > 0 for l in model}
cand = tuple(-l for l in model if rng.random() < 0.5) or (model[0],)
cand = tuple(l for l in cand if True)
falsified = all(val[abs(l)] != (l > 0) for l in cand)
db = [norm_clause(c) for c in cl]
db = [c for c in db if c is not None]
if falsified and oracle_rup(db, cand):
return False, "oracle called %r RUP though model %r falsifies it" % (cand, model)
checked += 1
return True, "%d model-falsified candidates, oracle never claimed RUP" % checked
def leg_negative_controls(mod):
"""Forged proofs must be rejected. A checker that accepts these is worthless."""
rejected = []
# (a) forged empty clause on a satisfiable formula
F = [(-3,), (-2,), (2, 1, 3), (-1, 5, 4), (-2, 5, 3), (-3, -4), (1,), (-2, 4, 5), (5,), (2, 4, -3)]
model = brute_force(5, F)
if model is None:
return False, "control (a) built on an unsatisfiable formula"
pr = mod.check_proof(5, F, [()])
if pr.ok:
return False, "(a) checker ACCEPTED the empty clause on satisfiable F (model %r)" % (model,)
rejected.append("(a) forged [] on SAT F")
# (b) forged refutation built from the negation of a known model
forged = [[-l] for l in model] + [()]
pr = mod.check_proof(5, F, forged)
if pr.ok:
return False, "(b) checker ACCEPTED a refutation built from the model's negation"
rejected.append("(b) model-negation refutation")
# (c) genuine refutation, last step dropped
n, cl = php(3, 4)
r = mod.solve(n, cl)
if r.sat or not r.proof:
return False, "control (c) needs an unsat instance with a proof"
pr = mod.check_proof(n, cl, r.proof[:-1])
if pr.ok:
return False, "(c) checker ACCEPTED a refutation with no empty clause"
rejected.append("(c) truncated refutation")
# (d) genuine refutation transplanted onto a weaker (satisfiable) formula
weaker = [c for c in cl if c != cl[0]]
if brute_force(n, weaker) is None:
return False, "control (d): weakened formula is still unsatisfiable"
pr = mod.check_proof(n, weaker, r.proof)
if pr.ok:
return False, "(d) checker ACCEPTED a proof of a formula it does not refute"
rejected.append("(d) proof transplanted onto weaker F")
# (e) a step that is provably not RUP, spliced into a genuine refutation
bad_step = None
for cand in ([1], [-1], [1, 2], [-1, -2], [cl[0][0]]):
if not oracle_rup([norm_clause(c) for c in cl], tuple(cand)):
bad_step = cand
break
if bad_step is None:
return False, "control (e): could not find a non-RUP clause"
spliced = [bad_step] + list(r.proof)
pr = mod.check_proof(n, cl, spliced)
if pr.ok:
return False, "(e) checker ACCEPTED a non-RUP step %r" % (bad_step,)
if pr.steps_checked != 0:
return False, "(e) rejected at step %d, expected step 0" % pr.steps_checked
rejected.append("(e) non-RUP step spliced in")
return True, "%d forged proofs all rejected: %s" % (len(rejected), ", ".join(rejected))
def leg_cross_process(mod):
"""The checker must work from a cold database: proof made in a child, checked here."""
n, cl = php(3, 4) # 4 pigeons, 3 holes -> UNSAT
tmp = tempfile.mkdtemp(prefix="satkit-cross-")
inst = os.path.join(tmp, "instance.json")
out = os.path.join(tmp, "proof.json")
with open(inst, "w") as fh:
json.dump({"nvars": n, "clauses": [list(c) for c in cl]}, fh)
child = [sys.executable, os.path.abspath(__file__), "--emit-proof", inst, out]
proc = subprocess.run(child, capture_output=True, text=True, timeout=120)
if proc.returncode != 0:
return False, "child failed: %s" % (proc.stderr.strip()[:300],)
with open(out) as fh:
payload = json.load(fh)
if payload.get("sat"):
return False, "child reported SAT for PHP(4,3)"
ok, i, msg = oracle_check_proof(n, cl, payload["proof"])
if not ok:
return False, "child proof rejected by harness oracle: %s" % msg
pr = mod.check_proof(n, cl, payload["proof"])
if not pr.ok:
return False, "checker rejected a proof from another process: %s" % pr.message
return True, "child emitted a %d-step proof; parent oracle + checker both accept" % (
len(payload["proof"]),)
def leg_determinism(mod):
rng = random.Random(31337)
for n, cl in ((30, rand_3sat(rng, 30)), php(3, 4)):
a = mod.solve(n, list(cl))
b = mod.solve(n, list(cl))
if (a.sat, a.model, a.proof) != (b.sat, b.model, b.proof):
return False, "two runs on n=%d disagreed" % n
return True, "identical sat/model/proof across repeat runs"
LEGS = [
("edge_cases", leg_edge_cases),
("exhaustive_n2", leg_exhaustive_n2),
("exhaustive_n3", leg_exhaustive_n3),
("random_sweep", leg_random_sweep),
("php_proofs", leg_php_proofs),
("random3sat_proofs", leg_random3sat_proofs),
("oracle_selfcheck", leg_oracle_selfcheck),
("negative_controls", leg_negative_controls),
("cross_process", leg_cross_process),
("determinism", leg_determinism),
]
# ==========================================================================
# mutation control: the suite must have teeth
# ==========================================================================
class Sabotaged:
"""Delegates to satkit except for the named overrides."""
def __init__(self, name, overrides):
self.name = name
self._overrides = overrides
def __getattr__(self, attr):
if attr in self._overrides:
return self._overrides[attr]
return getattr(satkit, attr)
def _flipped_sat(nvars, clauses, collect_proof=True):
r = satkit.solve(nvars, clauses, collect_proof)
return satkit.SolveResult(not r.sat, r.model, r.proof, r.stats)
def _all_positive_model(nvars, clauses, collect_proof=True):
r = satkit.solve(nvars, clauses, collect_proof)
if r.sat:
return satkit.SolveResult(True, [v for v in range(1, nvars + 1)], None, r.stats)
return r
def _short_proof(nvars, clauses, collect_proof=True):
r = satkit.solve(nvars, clauses, collect_proof)
if not r.sat and r.proof:
return satkit.SolveResult(False, None, list(r.proof)[:-1], r.stats)
return r
def _lax_checker(nvars, clauses, proof, require_empty=True):
return satkit.ProofResult(True, len(proof or []), "mutant: accepts everything")
MUTANTS = [
("flipped_sat", {"solve": _flipped_sat}, "exhaustive_n2"),
("all_positive_model", {"solve": _all_positive_model}, "random_sweep"),
("short_proof", {"solve": _short_proof}, "php_proofs"),
("lax_checker", {"check_proof": _lax_checker}, "negative_controls"),
]
def leg_mutant_control(_mod):
by_name = dict(LEGS)
results = []
for name, overrides, target in MUTANTS:
ok, detail = by_name[target](Sabotaged(name, overrides))
if ok:
return False, "mutant %r SURVIVED leg %r -- that leg is vacuous" % (name, target)
results.append("%s caught by %s" % (name, target))
return True, "%d/%d mutants killed (%s)" % (len(MUTANTS), len(MUTANTS), "; ".join(results))
# ==========================================================================
# entry point
# ==========================================================================
def emit_proof(instance_path, out_path):
with open(instance_path) as fh:
inst = json.load(fh)
r = satkit.solve(inst["nvars"], [tuple(c) for c in inst["clauses"]])
with open(out_path, "w") as fh:
json.dump({"sat": r.sat, "model": r.model,
"proof": [list(s) for s in (r.proof or [])]}, fh)
return 0
def main(argv):
if "--emit-proof" in argv:
return emit_proof(argv[argv.index("--emit-proof") + 1],
argv[argv.index("--emit-proof") + 2])
print("verify_satkit: %s" % os.path.abspath(satkit.__file__))
print("python %s\n" % sys.version.split()[0])
failures = 0
t_start = time.time()
for name, leg in LEGS:
t0 = time.time()
try:
ok, detail = leg(satkit)
except Exception as exc: # noqa: BLE001
ok, detail = False, "raised %s: %s" % (type(exc).__name__, exc)
dt = time.time() - t0
print("%-20s %-4s %6.2fs %s" % (name, "PASS" if ok else "FAIL", dt, detail))
if not ok:
failures += 1
t0 = time.time()
ok, detail = leg_mutant_control(satkit)
print("%-20s %-4s %6.2fs %s" % ("mutant_control", "PASS" if ok else "FAIL", time.time() - t0, detail))
if not ok:
failures += 1
total = len(LEGS) + 1
print("\n%d/%d legs passed in %.2fs" % (total - failures, total, time.time() - t_start))
return 0 if failures == 0 else 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))