Boolean DAG representation and bounded associative flattening#
This note specifies the repair for the frontend memory failure observed on
cpachecker-induction.cs_fib_true-unreach-call.i.smt2. The input is a compact
SMT-LIB DAG of nullary definitions, dominated by definitions of the form
d_n = (and d_(n-1) atom). At commit 4e19d1b, a controlled SLURM probe was
killed after 180 seconds at 154,539,380 KB RSS without reaching one theory
conflict.
The failure is before SAT or theory solving. TermManager::mk_and and
mk_or flatten same-kind children during term construction. Eagerly compiled
nullary definitions retain every flattened prefix. A streaming analysis of the
input counted 361,983,696,912 child slots that complete eager flattening would
retain, or about 1.45 TB for 32-bit TermId values before vector capacity,
hash-consing, parser, and map storage.
Algorithmic basis and scope#
Primo’s existing BooleanClausifier uses a cached Tseitin-style encoding: each
reachable Boolean TermId has at most one SAT literal and nested And/Or
nodes receive local defining clauses. That encoding operates on a DAG and does
not require associative connectives to be globally flat. The representation
repair does not change the clausification algorithm.
Bounded associative flattening is an original preprocessing policy, not a claim to implement an optimized algorithm from a paper. Its purpose is experimental: some later preprocessing may benefit from wider nodes, but term construction must remain safe when the source intentionally shares long prefixes.
Ownership and data structures#
core::TermManagerowns the canonical term DAG.mk_andandmk_orperform only local sort checking, constant elimination, and duplicate-child removal. They do not inspect a same-kind child’s operands.smt::flatten_associative_booleansis an optional preprocessing pass over the reachable assertion DAG. ATermId-indexed memo table stores one rewritten result per visited source node; an explicit post-order worklist avoids input- depth recursion.smt::BooleanClausifierconsumes nested DAGs directly. Top-level conjunction assertion uses an explicit worklist and a per-assertion visited set, so a definition chain cannot consume the C++ call stack and a reconvergent DAG is clausified once per reachableTermId.The SAT backend continues to receive only literals and clauses.
Operations and invariants#
Core construction has these invariants:
Constructing a Boolean node copies only the direct operands supplied by its caller. It is therefore linear in direct arity, independent of descendant size.
Hash-consing preserves shared prefixes by
TermId.Removing
truefromAnd, removingfalsefromOr, absorbing the opposite constants, and removing duplicate direct operands preserve Boolean equivalence.Nested and flat forms are both legal core terms. No consumer may assume global associative flattening.
Top-level conjunction clausification and top-level disjunction-shape collection visit each reachable shared node at most once per root.
The optional pass takes a positive maximum arity K. For each visited And or
Or, it considers each same-kind child in source order. It replaces that child
by its direct operands only when the resulting parent, including all remaining
direct children, would have at most K operands. Otherwise it retains the
child node and records a threshold stop. Rebuilding is bottom-up and memoized.
For fixed K, the pass uses O(V * K) copied child identifiers in the worst
case over V reachable Boolean nodes, and O(V) worklist and memo storage.
With the pass disabled, the core representation of the CPAchecker chain uses
O(V) nodes and child identifiers. The pass deliberately gives no bound below
the source arity of an explicitly wide SMT-LIB and or or; it only bounds
arity growth caused by flattening.
Statistics#
The pass reports:
:preprocessing.boolean-flatten.attempts;:preprocessing.boolean-flatten.accepted;:preprocessing.boolean-flatten.threshold-stops;:preprocessing.boolean-flatten.operands-copied;:preprocessing.boolean-flatten.rewrites;:preprocessing.boolean-flatten.max-arity.
All remain zero when the pass is disabled. The command-line option is
--boolean-flatten-threshold N; 0 disables the pass.
The post-preprocessing threshold also bounds the optional
--boolean-implies-rewrite=clause pass. That pass may widen
p => (q or r) into the three-literal or node but does not distribute a
compound antecedent or consequent. Its all mode only creates the binary
(or (not A) B) equivalence; the ordinary bounded flattening pass decides
whether any resulting nested Or is widened.
Proof obligations and tests#
Core tests must show that nested
And/Orchildren remain shared and that duplicate direct children are removed.Preprocessing tests must cover flattening below the threshold, retaining a nested child at the threshold, deterministic operand order, shared subterms, and deep chains without recursion.
Solver tests must cover equivalent SAT and UNSAT formulas with the pass off and on, and a long nullary
Anddefinition chain whose stored node arity remains bounded before preprocessing. A Fibonacci-shaped reconvergentAndDAG must reach its two sharedOrleaves once and emit two clauses.Existing QF_UF, QF_LRA, and QF_UFLRA focused tests must pass because this is a cross-logic core representation change.
The merge fuzz gate must exercise both the default disabled pass and at least one positive threshold against trusted references.
The CPAchecker witness must be rerun with a hard outer resource guard. The accepted implementation must reach frontend and preprocessing statistics without superlinear resident-memory growth.
Validation evidence#
Commit e9b8c39 passed make lint, make check, make asan-test, and
make docs. A release build was also required because GCC’s
-Wmaybe-uninitialized found an exhaustiveness invariant that the debug build
and local clang-tidy did not prove; ba3aecc makes the invalid sentinel and
post-switch check explicit.
The exact CPAchecker witness now completes preprocessing with flattening
disabled. The source-exact local debug build took 52.37 seconds and 3,071,672
KB peak RSS. The clean SLURM release build took 19 seconds and 92,168 KB peak
RSS; parsing, frontend compilation, and preprocessing took 4.73, 5.09, and
5.52 seconds.
Its initial and final preprocessing term sizes were 1,805,077 and 1,816,291,
and every :preprocessing.boolean-flatten.* counter was zero. This replaces
the earlier same-input release probe that reached 154,539,380 KB and its
180-second hard guard without reaching one theory conflict.
The exact release commit also passed two differential SLURM gates. With the
pass disabled, 3,000 mixed QF_UFLRA cases and 1,000 finite-domain cases all
passed. The same 4,000-case gate passed with
--boolean-flatten-threshold=8. In each mode, all 11 tracked QF_UFLRA seeds
passed and YinYang completed 100 successful mutations without a reported bug.
On the established 45-file QF_UFLRA preprocessing-opportunity sample, the
default-disabled pass solved 45/45 with zero wrong or unsupported results and
PAR2 2.174189 seconds. That sample is a same-commit sanity result, not a
before/after attribution; the oversized witness is the direct memory evidence.
An exact local default A/B against the pre-change c1a8288 release used the 12
tracked files in benchmarks/boolean-dag-default-ab-12.txt. All answers and
return codes agreed across three runs per binary, and every non-time counter was
deterministic. Nine files had identical shared non-time counters. The current
binary additionally reports the six zero-valued flatten counters. One compact
finite-domain seed changed three preprocessing counts by one.
The two MathSAT-derived files expose the known search limitation. On the
decoupled file, preserving the nested Or chain increased SAT variables from
268 to 744 and clauses from 695 to 2,105, but median total time improved from
46.9 to 35.1 milliseconds. On the coupled file, variables increased from 302
to 901, clauses from 758 to 2,556, decisions from 1,104 to 3,840, and median
time increased from 34.6 to 40.9 milliseconds. Enabling a threshold large
enough to flatten both formulas reproduced all parent non-time counters on the
decoupled file and all but three one-count differences on the coupled file.
Therefore the default preserves results on this sample, but it deliberately does not preserve exact encoding or search behavior. Nested disjunctions below the top-level shape can receive additional Tseitin literals. This remains an efficiency target for DAG-aware clausification and is why default readiness cannot be inferred from the memory fix alone.
The second application point, and why it was needed (2026-08-11)#
The repair above is correct and the memory result is real – reproduced here
on a synthetic 20,000-link d_n = (and d_(n-1) atom) chain, which peaks at
1.88 GB on the pre-repair binary and 57 MB after. But the pass as
first landed ran only before the rewriting preprocessing passes, and that
turned out not to be enough.
Those passes build fresh And/Or nodes through TermManager, which – by
this note’s own design – no longer flattens at construction. So they
reintroduce nesting the input never had, after the pre-pass has already gone
past. The symptom is diagnostic: on affected inputs the pre-pass reports
attempts = 0 (there is genuinely nothing nested yet) while the finished
formula carries ~10% more nodes than it did before the repair.
Measured on yPositive-SIscaled50.bpl_Iteration1_Loop_6-phaseTemplate:
:preprocessing.term-size.initial is identical either way (28,011) and
only term-size.final diverges (151,804 -> 162,106), which is what localises
the cause to preprocessing rather than parsing.
Cost of the gap, against the commit before the repair:
instance |
before repair |
after repair |
with post-pass |
|---|---|---|---|
|
9.0s / 485 MB |
22.7s / 605 MB |
8.9s / 485 MB |
|
~4.0s |
9.7s / 76 MB |
4.0s / 55 MB |
|
~13.4s |
21.5s / 258 MB |
12.4s / 237 MB |
Note memory moves the same way as time: flattening removes intermediate Tseitin nodes, so on these inputs it is cheaper on both axes. The pathological chain is the opposite case, which is exactly why this is a bounded, tunable policy rather than a fixed choice.
--boolean-flatten-post-threshold, default 2048, chosen by measurement.
256 restores most QF_LRA inputs but leaves one of 55 short –
simple-scaled200 hits 20,200 threshold stops there and needs 2048 to match
the pre-repair clause and decision counts exactly. Raising the bound costs
nothing where the bound exists to protect: on the 94 MB
cpachecker-induction.cs_fib instance, 2048 peaks at 3,346,944 KB against
3,347,208 KB at 256, a 264 KB saving, with threshold stops falling from 17
to 1.
The bound still matters – it is what stops a pathological input from
unbounded arity growth – but it is not the axis that decides memory on real
inputs, because the pass rewrites each reachable node once instead of
materialising every prefix. 0 disables the pass entirely.
Broader check, 34 QF_LRA instances solved by both settings: 0 answer disagreements, median time ratio 0.76, six instances more than 30% faster and none more than 30% slower.
Are we back to the old behaviour on QF_LRA?#
Effectively yes, and where it differs it is better. Against the pre-regression binary over 55 QF_LRA instances (45 solved by both):
0 answer disagreements, and nothing lost or gained – the same instances solve.
Time median 1.02, with no instance more than 30% faster or slower.
Clause counts: 34 identical, 10 fewer now, 1 more (closed by the 2048 default).
The instances with fewer clauses are not a flattening effect. assert_positive
gained a per-assertion visited set in the same commit, so a subterm reached
through several paths of the top-level And spine is clausified once instead
of repeatedly. Terms and variables are unchanged there; only redundant
duplicate clauses disappear. That part of the DAG work is a straight
improvement and is deliberately kept.
Verified on the motivating instance#
cpachecker-induction.cs_fib_true-unreach-call.i.smt2 – the 94 MB input this
note was written about – is in sandbox/non-incremental/QF_UFLRA, and the
post-pass reduces its peak memory rather than threatening it:
setting |
peak RSS |
flattenings accepted |
|---|---|---|
|
4.85 GB |
0 |
|
3.19 GB |
3,512,924 (17 threshold stops, max arity 243) |
Both still time out at 180s, so this is a memory result, not a solved/unsolved one. The direction is the same as on QF_LRA and for the same reason: flattening removes intermediate Tseitin nodes, so where it applies it costs less, not more. The 17 threshold stops are the bound doing its job – without it those nodes are where unbounded growth would start.
This is the opposite of what the bound was expected to buy. The risk it guards against is real but is a property of flattening at construction, where every prefix of a shared chain is materialised; the post-pass rewrites each reachable node once, so the same policy is cheap when applied to a DAG rather than to a construction stream.
Across 28 sampled QF_UFLRA instances: 0 answer disagreements, all 28 solved
under both settings, and no instance’s peak RSS moved by more than 5% either
way.