Skip to content

fix: clause_locked deleted in-use reason clauses → unsound learnt clauses - #74

Merged
InauguralPhysicist merged 2 commits into
mainfrom
fix-clause-locked-soundness
Jul 30, 2026
Merged

fix: clause_locked deleted in-use reason clauses → unsound learnt clauses#74
InauguralPhysicist merged 2 commits into
mainfrom
fix-clause-locked-soundness

Conversation

@InauguralPhysicist

Copy link
Copy Markdown
Contributor

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, and rebuild_watches resets 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

  1. A learnt clause still serving as a reason looks unlocked
  2. reduce_learnt_db deletes it — a flag only, nothing breaks yet
  3. compact_deleted_clauses physically removes it and remaps state.reason[v]-1
  4. analyze_conflict_cdcl hits reason_ci < 0 and treats an implied literal as a decision
  5. The clause it derives need not be implied → unsound

Confirmed, 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 — the default path is affected too, it just produced a valid proof by luck there

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.

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_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.

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 — the compaction machinery was only reachable from bench code, which is why randomized testing never hit it
  • tests/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'd
  • tests/fuzz_policy.py — same oracle across policies; reports path coverage so "we tested compaction" is measured, not assumed
  • Regression: tests/fixtures/pigeonhole_6_5.cnf under eager+luby in run_proof_check.sh — red before (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

Consequence 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

InauguralPhysicist and others added 2 commits July 30, 2026 06:33
…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
InauguralPhysicist merged commit 0a9fe58 into main Jul 30, 2026
1 check passed
@InauguralPhysicist
InauguralPhysicist deleted the fix-clause-locked-soundness branch July 30, 2026 11:35
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant