fix: clause_locked deleted in-use reason clauses → unsound learnt clauses - #74
Merged
Merged
Conversation
…auses
SOUNDNESS BUG. clause_locked checked only literal position 0. That is
MiniSat's invariant, but this solver never swaps literals into slots 0/1 --
it tracks watch POSITIONS in watch_a/watch_b, propagation moves them to
arbitrary indices (run_watched_queue_cdcl), and rebuild_watches resets them
to 0/1 without regard to which literal is asserting. So position 0 is not
reliably the asserting literal of a reason clause.
Consequence chain: a learnt clause still serving as a reason looks unlocked
-> reduce_learnt_db deletes it (a flag only, so nothing breaks yet) ->
compact_deleted_clauses physically removes it and remaps state.reason[v] to
-1 -> analyze_conflict_cdcl hits `reason_ci < 0` and treats an IMPLIED
literal as a decision -> the learnt clause it derives need not be implied.
Confirmed unsound, not theoretical: drat-trim REJECTED refutations of
pigeonhole-7-6 under --compact-policy eager, and of three random 3-SAT
instances found by the new policy fuzzer. Instrumenting the remap showed 7
dangling-reason events on pigeonhole-7-6 (eager) and 1 on pigeonhole-8-7
under the DEFAULT deferred policy, so the default path is affected too --
there it happened to produce a valid proof by luck.
Fix: clause_locked scans the clause for a literal whose variable has this
clause as its reason. O(width) instead of O(1), no measurable cost at these
sizes and the only check that is correct in a non-swapping design.
Why the suite missed it: the answer stayed correct. Pigeonhole is
unsatisfiable whether or not the reasoning is sound, so every status
assertion passed. Only the DRAT oracle could see it -- and the proof-check
corpus was all small instances, none of which compact.
Also in this change:
* --model emits DIMACS v-lines, so a SAT answer is externally checkable
(previously only UNSAT had an oracle)
* --compact-policy / --restart-policy / --phase-policy on the CLI, so the
reduction/compaction machinery is reachable without writing bench code.
It was unreachable before, which is why randomized testing never hit it:
0 of 180 random instances reached compaction, 4 reached reduction.
* tests/fuzz_differential.py -- 6 shape families (tautologies, duplicate
literals, unit-heavy, unused vars, dup clauses), all four solver paths
must agree, SAT models checked, UNSAT proofs drat-trim'd
* tests/fuzz_policy.py -- same oracle across compaction/restart/phase
policies on conflict-rich instances; reports path coverage so "we tested
compaction" is a measured claim, not an assumption
* tests/fixtures/pigeonhole_6_5.cnf + a run_proof_check.sh case under
eager+luby: red before this fix (drat-trim exit 1), green after, ~1.3s
* both fuzzers planted-fault validated -- a corrupted model is caught by
the model check, a dropped learnt literal by drat-trim and by path
disagreement
Counters banked before today came from the buggy solver and shift under the
fix (more clauses correctly stay locked, so the search differs): Tseitin 3x3
goes 673 -> 619 learnt clauses. Re-measure before comparing to a pre-fix
number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment claimed the exit code is 0/1. A corrupted refutation exits 255 (observed while planted-fault testing the conflict-analysis path), so a `-eq 1` style test would let a rejected proof through. The script already tests for zero; only the comment was wrong, and the wrong comment is exactly what would talk someone into a narrower check. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
InauguralPhysicist
added a commit
that referenced
this pull request
Jul 31, 2026
…ounters annotated All ladder counters predated the clause_locked soundness fix (#74, 0a9fe58). Re-measured post-fix: 3x3 = 1,850 res / 619 conflicts (was 1,974/673), 4x4 = 96,733 / 27,421 (was 95,516/27,388); the 3x3->4x4 step is x52.3. 4x5/5x5 stay pre-fix-caveated, blocked on a faster tier: the AOT probe the doc recommended was run and failed on missing dot_assign emission — filed as ouroboros#86, now the critical path for 5x5 and the 4x6 control. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Soundness bug.
clause_lockedchecked only literal position 0. That is MiniSat's invariant, but this solver never swaps literals into slots 0/1 — it tracks watch positions inwatch_a/watch_b, propagation moves them to arbitrary indices, andrebuild_watchesresets them to 0/1 regardless of which literal is asserting. So position 0 is not reliably the asserting literal of a reason clause.The chain
reduce_learnt_dbdeletes it — a flag only, nothing breaks yetcompact_deleted_clausesphysically removes it and remapsstate.reason[v]→ -1analyze_conflict_cdclhitsreason_ci < 0and treats an implied literal as a decisionConfirmed, not theoretical
--compact-policy eagerWhy the suite missed it
The answer stayed correct. Pigeonhole is unsatisfiable whether or not the reasoning is sound, so every status assertion passed. Only the DRAT oracle could see it — and the proof-check corpus was all small instances, none of which compact.
That is the deeper finding: 0 of 180 random instances reached compaction, 4 reached clause-DB reduction. The most intricate code had no oracle on it.
Fix
clause_lockedscans the clause for a literal whose variable has this clause as its reason. O(width) instead of O(1) — no measurable cost at these sizes, and the only check that is correct in a non-swapping design.Also in this change
--modelemits DIMACS v-lines, so a SAT answer is externally checkable (previously only UNSAT had an oracle)--compact-policy/--restart-policy/--phase-policyon the CLI — the compaction machinery was only reachable from bench code, which is why randomized testing never hit ittests/fuzz_differential.py— 6 shape families (tautologies, duplicate literals, unit-heavy, unused vars, dup clauses), all four paths must agree, SAT models checked, UNSAT proofs drat-trim'dtests/fuzz_policy.py— same oracle across policies; reports path coverage so "we tested compaction" is measured, not assumedtests/fixtures/pigeonhole_6_5.cnfunder eager+luby inrun_proof_check.sh— red before (drat-trim exit 1), green after, ~1.3sConsequence for banked numbers
Counters banked before today came from the buggy solver and shift under the fix (more clauses correctly stay locked → different search). Tseitin 3×3 goes 673 → 619 learnt clauses. Re-measure before comparing to any pre-fix number.
🤖 Generated with Claude Code