Skip to content

Prune the boolean search instead of walking the whole truth table - #864

Merged
Rafael-SOWNet merged 1 commit into
masterfrom
fix/boolean-dpll
Aug 10, 2026
Merged

Prune the boolean search instead of walking the whole truth table#864
Rafael-SOWNet merged 1 commit into
masterfrom
fix/boolean-dpll

Conversation

@Rafael-SOWNet

Copy link
Copy Markdown
Collaborator

Closes #858.

SolveTable enumerated all 2^n assignments and, for each, did a full symbolic Substitute followed by EvalBoolean. Twenty-two variables did not finish in 30 s, which put a hard ceiling on the public API at around twenty.

It now assigns variables one at a time and asks after each what the expression already is, under a three-valued reading. A prefix that makes it false rules out every completion of itself at once; a prefix that makes it true admits all of them, and they are written out directly rather than tested. Only a prefix that settles nothing gets branched on.

Walking the whole table is what happens when no prefix ever settles anything — so it went from being the algorithm to being the worst case.

Measured

was now
p_0 & p_1 & ..., one solution 8 vars 12 ms 5 ms
18 vars 3.1 s 0 ms
22 vars > 30 s 0 ms
200 vars 7 ms
exactly one of k true 40 vars 2⁴⁰, hopeless 208 ms, 40 solutions
an xor chain — nothing prunable 20 vars 2²⁰ substitutions 943 ms, 524 288 solutions

Even the adversarial case is faster. The old per-row cost was a symbolic substitution into the tree; the new one is a walk that stops early plus a row written straight out.

What stays exponential is the output. A tautology over n variables has 2ⁿ solutions and they all have to be written down. That is the method's contract — it enumerates models, not satisfiability — and changing it would change the signature, which is the separate question raised in #858 and worth settling while 2.0 is in preview. This PR does not touch it.

No behavioural change

  • Same rows, same order. Assigning false before true with the last variable moving fastest is exactly the order counting through the table produced. RowsKeepTruthTableOrder pins that against an independently computed expectation.
  • Unrecognised nodes cost pruning, never correctness. Anything the three-valued reading does not know reads as Unknown, and once every variable is assigned the search falls through to precisely the old substitute-and-evaluate. A non-boolean expression therefore still throws exactly where it threw before.
  • BuildTruthTable is untouched — a truth table is all 2ⁿ rows by definition, so there is nothing there to prune.

No BREAKING-CHANGES.md entry, because nothing observable changed.

Tests

Three of the new tests cannot pass by enumeration — 60 variables is 2⁶⁰:

  • AConjunctionIsSolvedWithoutEnumeratingTheTable (60 vars, 1 solution)
  • ExactlyOneTrueIsFoundWithoutEnumeratingTheTable (40 vars, 40 solutions)
  • AContradictionHasNoSolutions (30 vars, null per the existing contract)
  • RowsKeepTruthTableOrder (order preservation)

Verification

  • 6065 C# tests pass, 0 failed
  • 130 F# wrapper tests pass
  • the existing BooleanSolver.Test theory, which asserts exact solution counts and re-checks every returned row by substitution, is unchanged and green

🤖 Generated with Claude Code

Closes #858.

SolveTable enumerated all 2^n assignments and, for each, did a full symbolic
Substitute followed by EvalBoolean. Twenty-two variables did not finish in 30s, which
put a hard ceiling on the public API at around twenty.

It now assigns variables one at a time and asks after each what the expression already
is, under a three-valued reading. A prefix that makes it false rules out every
completion of itself at once; a prefix that makes it true admits all of them and they
are written out directly. Only a prefix that settles nothing gets branched on.
Enumerating the whole table is what happens when no prefix ever settles anything, so it
went from being the algorithm to being the worst case.

    p_0 & p_1 & ... , one solution        was  8 vars  12 ms
                                               18 vars 3.1 s
                                               22 vars >30 s
                                          now 22 vars   0 ms
                                              200 vars  7 ms

    exactly one of k true                 now  40 vars 208 ms   (40 solutions of 2^40)
    an xor chain, nothing prunable        now  20 vars 943 ms   (524288 solutions)

Even the adversarial case is faster: the old per-row cost was a symbolic substitution
into the tree, and the new one is a walk that stops early plus a row written straight
out. What remains exponential is the output -- a tautology over n variables has 2^n
solutions and they all have to be written down. That is the contract, not the search,
and changing it would change the signature.

No behavioural change. The same rows come back in the same order: assigning false
before true with the last variable moving fastest is the order counting through the
table produced, and RowsKeepTruthTableOrder pins it. An expression the three-valued
reading does not recognise falls through to exactly the old substitute-and-evaluate
once everything is assigned, so unhandled node shapes cost pruning and never
correctness. BuildTruthTable is untouched, since a truth table is all 2^n rows by
definition.

The three new tests cannot pass by enumeration: 60 variables is 2^60.

Verified: 6065 C# tests and 130 F# tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Rafael-SOWNet
Rafael-SOWNet merged commit 95d9a14 into master Aug 10, 2026
25 checks passed
@Rafael-SOWNet
Rafael-SOWNet deleted the fix/boolean-dpll branch August 10, 2026 02:20
Rafael-SOWNet added a commit that referenced this pull request Aug 10, 2026
Towards #858.

SolveBooleanTable and BuildTruthTable never consulted the cancellation token, so the
escape hatch that works everywhere else in the library did not exist here. Measured before
the change: a token cancelled after three seconds was still being ignored twenty seconds
later.

That matters because of what the shape of these methods costs. #864 made the search cheap,
which leaves writing every model down as the wall, and how tall it is depends on the answer
rather than the question:

    tautology, 18 variables      262 144 rows     575 ms     124 MB
    tautology, 20 variables    1 048 576 rows    1724 ms     544 MB
    tautology, 22 variables    4 194 304 rows    7979 ms    2368 MB

A caller cannot know in advance which of those they asked for, and there is no pruning
available for a formula every assignment satisfies. Being able to stop is the whole of the
remedy available without changing the signature.

MultithreadingFunctional.ExitIfCancelled is what PolynomialGcd, Simplificator, Minimiser
and ExponentialSolver already use; this adds it at the branch nodes of the search, in the
loop that writes out completions, and in the truth-table loop, which is all 2^n rows by
definition and so has nothing but stopping to offer.

It costs nothing measurable -- 22 variables went 7979 ms to 7943 ms, inside the noise --
because the check is one async-local read against a per-row allocation.

Verified: cancelling now returns at the moment it is asked rather than not at all, 6087 C#
tests and 130 F# tests pass.

Co-authored-by: Claude Opus 5 (1M context) <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.

SolveBooleanTable enumerates all 2^n assignments

1 participant