The Nelson-Oppen QF_LRA regression: evidence, mechanisms, and how to fix it safely#
Written 2026-08-07. Main has lost 59 net files on the full SMT-LIB QF_LRA division against a commit from four days earlier, and the loss traces to the merge that brought the Nelson-Oppen / theory-combination work into main. This note collects the evidence, the two mechanisms identified so far, the one still unexplained, and – the part that matters most – why the obvious fix is riskier than it looks.
It is deliberately separate from Arithmetic, which describes what is implemented. This is a defect report about main.
Outcome: fixed, and past where it started#
Measured 2026-08-07 on the full division (job 120784, both arms in one job on the same nodes, 1,753 files at 300s), after landing all three mechanisms:
solver |
solved / 1753 |
wrong |
PAR2 |
|---|---|---|---|
opensmt 2.9.2 |
1670 |
0 |
68,068 |
primo, fixed ( |
1657 |
0 |
79,646 |
yices 2.7.0 |
1645 |
0 |
80,585 |
primo |
1631 |
0 |
95,091 |
z3 5.0.0 |
1584 |
0 |
128,079 |
primo |
1572 |
0 |
135,691 |
cvc5 1.3.4 |
1511 |
0 |
181,248 |
vs the merge base
0c0843d: gained 87, lost 2, PAR2 nearly halved. Gains are LassoRanker 71 (the interface-equality gate), 2019-ezsmt 12 (the dispatch fix), miplib 2, Heizmann 1, tropical-matrix 1. Paired median on files over a second is 0.56, p10 0.18, p90 0.96 – faster nearly everywhere.vs the pre-regression
b6b8d55: gained 26, lost 0. Nothing that commit solved was given up, and 25 of the 26 are LassoRanker.
The two losses are both miplib (pp08a-4000 129s and fixnet-1000 90s, each to
timeout). Those are real rather than boundary churn, and miplib is the known
trade for the cross-link change on long-row instances – see
Open leads register‘s lead 3b.
Zero wrong answers in every arm, on the one QF_LRA corpus that carries
(set-info :status) and therefore checks answers rather than only cross-solver
agreement.
How the regression was measured#
Full division, 1,753 files, 300s, 40 shards, --cpus-per-task=3, all three
runs on the same three nodes with the same split (jobs 118904, 120122, 120072).
solved / 1753 |
wrong |
PAR2 |
|
|---|---|---|---|
|
1631 |
0 |
95,091 |
main |
1572 |
0 |
136,330 |
branch |
1588 |
0 |
127,101 |
main vs
b6b8d55: gained 2, lost 61. LassoRanker 46, 2019-ezsmt 12, miplib 2, tropical-matrix 1.branch vs main: gained 16, lost 0, PAR2 -9,229, median paired 0.94. All 16 gains are LassoRanker.
Zero wrong answers in all three runs. This corpus carries
(set-info :status), so that is a real soundness check, not just cross-solver
agreement. The regression is performance-only.
Bisected (first-parent) to 84336a2 “Merge remote-tracking branch
‘origin/main’” – 46 files, 14,392 insertions, introducing
src/smt/theory_combination.{cpp,hpp}. Its first parent 38be7fd is good.
Mechanism A: a propositional variable is treated as an EUF predicate#
summarize_formula_features (src/smt/check_sat.cpp):
summary.has_euf_predicate =
(term.kind == core::TermKind::Application || term.kind == core::TermKind::Constant) &&
term.sort == terms.symbols().bool_sort();
and the dispatch:
if ((has_equality || has_euf_predicate) && has_arithmetic) { ... solve_qf_uf_lra ... }
A Constant of sort Bool is a plain propositional variable. It has no
arguments, so it can never participate in a congruence rule – there is nothing
for EUF to do with it. But it sets has_euf_predicate, and any QF_LRA formula
containing one is routed to the mixed solver.
Introduced by ffaf9eb “Add separable QF_UFLRA final checks” (2026-07-30),
which added contains_euf_predicate_atom alongside this.
Measured, on 2019-ezsmt/travellingSalesperson/tsp_rand_70_300_1155482584_7
– a file with zero sorts and zero functions with arguments:
commit |
|
|
result |
|---|---|---|---|
|
1 |
– |
sat, 0.93s, 11,735 pivots |
|
0 |
1 |
timeout, deterministic |
A second over-trigger was suspected here and does not exist. Reading the
code suggests one: has_equality is refined so a plain Real equality does
not set it, but the !real_equality branch appears to fire for a Bool
equality –
if (!real_equality || summary.has_application_term) { summary.has_equality = true; }
Measured, it does not. A formula whose only candidate trigger is
(= p q) between propositional variables dispatches to :solver.boolean, and
with arithmetic added it moves to :solver.qf-lra as soon as the
has_euf_predicate trigger is removed. The reason is that Bool-sorted
equalities are rewritten during preprocessing (preprocessing.cpp, the
bool-sorted equality branch) before summarize_formula_features runs, so no
Equality node over Bool ever reaches the dispatch.
This matters for scoping the fix: for these files has_euf_predicate on a
Bool Constant is the single trigger, so narrowing it to Application is
sufficient rather than partial. Verify this rather than trusting it if the
preprocessing pipeline changes – the code still reads as though the second
trigger were live.
Mechanism B: those variables are then handed to congruence closure#
Routing to the mixed path would be merely wasteful if the mixed path treated
propositional variables as propositional. It does not. is_euf_theory_atom
repeats the same predicate:
return (term.kind == core::TermKind::Application || term.kind == core::TermKind::Constant) &&
term.sort == terms.symbols().bool_sort();
and solve_qf_uf_lra splits every clausifier theory atom into exactly two
buckets on it:
if (is_euf_theory_atom(terms, atom.term)) { euf_atoms.push_back(atom); }
else if (is_lra_theory_atom(terms, atom.term)) { lra_atoms.push_back(atom); }
else { throw UnsupportedInput("unsupported mixed-theory atom"); }
Consequence, measured on the same file:
counter |
pure path |
mixed path |
|---|---|---|
|
0 |
5,364 |
|
0 |
39,436,128 |
|
0 |
39,446,856 |
|
– |
7,352 |
|
– |
0 |
|
26,117 |
31,481 |
The file declares 7,352 Bool-sorted nullary declare-funs – and
:no.mixed.euf-atoms is exactly 7,352. Every propositional variable goes to
congruence closure, producing 39.4 million merges to decide nothing the SAT
solver does not already own.
This is the important framing. Nelson-Oppen itself is not slow here and is
not at fault: :no.interface.conflicts is 0 and every purification counter is
zero. NO correctly no-ops, exactly as it should on a single-theory problem. The
cost comes from starting an EUF engine for a formula with no EUF content.
The pure QF_LRA path faces the same atoms and is fine, because it marks them
is_arithmetic = false and skips them. The mixed path has no equivalent
“neither theory owns this” bucket.
Are the merges really on Booleans, and is p = q the cause? Yes to the
first; the second is only part of it. is_euf_theory_atom returns true for
any equality whose children are not Real – so (= p q) between propositional
variables is an EUF atom – but the dominant contribution is the bare
Boolean atoms. Each one’s truth assignment is realised as a merge with true
or false, so 7,352 propositional variables mean 7,352 merges per check
regardless of whether any equality is involved.
Mechanism B2: the EUF theory is rebuilt from scratch on every check#
This is the part that matters beyond misclassified Booleans, and it was found by noticing the counters are exactly divisible:
:euf.incremental.merges 39,436,128 = 5,364 checks x 7,352 atoms (exact)
:euf.incremental.terms 39,446,856 = 5,364 checks x 7,354 atoms (exact, +2 = true/false)
Not approximately – exactly. Every check merges every atom. The code says why
(solve_qf_uf_lra, inside the check loop):
std::vector<BooleanClausifier::TheoryAtom> euf_atoms_for_check = euf_atoms; // full copy
...
prepared_flat_theory.emplace(terms, euf_atoms_for_check, true_term, false_term, ...);
euf_check = check_incremental_euf_closure(prepared_flat_theory.value(), ...);
check_incremental_euf_closure is incremental within one closure, but the
PreparedFlatTheory it works on is constructed fresh on every check, so
nothing is reused across checks. The cost is O(checks x euf_atoms) by
construction.
Verified as general, not specific to one instance. On four further mixed-path files the identity holds exactly:
file |
checks |
euf atoms |
merges |
|
|---|---|---|---|---|
|
1 |
7,332 |
7,332 |
yes |
|
1 |
7,352 |
7,352 |
yes |
|
1 |
8,262 |
8,262 |
yes |
|
1 |
288 |
288 |
yes |
Those files are cheap only because they need one check. The blow-up needs
both many atoms and many checks, which tsp_rand_70_300_1155482584_7 has
(5,364).
Why this is the more important finding. Mechanisms A and B are about formulas that should never have reached the mixed path. B2 is not: a genuine QF_UFLRA problem pays O(checks x atoms) too. Fixing A and B removes the Boolean atoms from the multiplication but leaves the multiplication. Any mixed-theory problem with many EUF atoms and a search that needs many final checks will hit this, and no amount of classifier tightening helps.
Mechanism C: LassoRanker – NO bookkeeping on the pure QF_LRA path (SOLVED)#
Do not assume A and B explain the regression. They explain the 12 2019-ezsmt losses. The largest family does not fit them at all.
On LassoRanker/CooperatingT2/dropbuf.t2.c_Iteration3_Loop_4-pieceTemplate
(3.4 MB):
answer |
dispatch |
pivots |
decisions |
|
|---|---|---|---|---|
|
sat, 13.0s |
|
10,217 |
256,688 |
main |
timeout 150s |
|
3,793 |
190,089 |
branch HEAD |
timeout 150s |
|
4,183 |
198,695 |
The dispatch never flips – both sides run the pure LRA solver, so Mechanism A is not involved and Mechanism B cannot be either.
Not preprocessing:
--preprocess-onlyis 1.35s vs 1.42s.Not the tableau: at a 45s cap main does 1,472 pivots and 1.66M pivot-term-ops against
b6b8d55’s 10,217 and 76.1M in 13s. The online check-call rate falls from 13,976/s to 2,143/s while the LRA work per call shrinks. Something outside the simplex is paid per check call.
Solved 2026-08-07#
Method, which is the reusable part. Both commits were run under
--max-theory-conflicts 2000 so each stopped after the same logical work:
both answered sat with 884 conflicts and 10,217 pivots – byte-identical
search. That makes the flat profiles directly comparable as pure constant
factor, with no trajectory noise to argue about. Under gprof the good commit
took 43.2s and main 329.3s: a 7.6x slowdown on identical work.
|
main |
|
|---|---|---|
top |
|
|
2nd |
|
|
1.4 billion hash lookups – about 150,000 per backtrack – into the Nelson-Oppen interface-equality registry, on a pure QF_LRA problem.
notify_backtrack calls rebuild_true_interface_bounds() unconditionally,
and that rescans every theory atom, hash-inserting each true one into
true_lra_le_reasons_. Meanwhile pending_interface_equalities_ is drained
only by MixedTheoryPropagationDispatcher. So on the pure QF_LRA path
nothing consumes any of it: filled on every assignment, rebuilt on every
backtrack, discarded.
Fix: interface_equalities_enabled_, defaulting to false, turned on by
the dispatcher – the one path that consumes it. It guards both
rebuild_true_interface_bounds and observe_true_interface_bound.
answer |
wall |
pivots |
conflicts |
|
|---|---|---|---|---|
|
sat |
13.44s |
10,217 |
884 |
main |
timeout 200s |
– |
7,776 |
716 |
main + fix |
sat |
12.16s |
10,217 |
884 |
Identical search counters to the pre-regression commit, and marginally faster.
The NO side is verified unaffected, which was the risk of gating it: with
--nelson-oppen-prop online on a formula where x <= y and y <= x must
publish x = y to EUF, both builds answer unsat with
:no.online.lra-published-equalities 1 and precreated 2. A regression test
asserts >= 1, so disabling the gate for the mixed path fails loudly rather
than silently losing LRA-to-EUF propagation.
120-file QF_LRA differential at 20s: 0 disagreements, gained 6, lost 0, paired median 0.79 with max 1.01 – faster nearly everywhere and never slower. All six gains are LassoRanker.
Not yet measured: a full-division re-run on the fixed tree. The corpus-level recovery is projected from a sample.
Interesting and unexplained: the qflra-tropical-t1-propagation branch
recovers 16 LassoRanker files relative to main without targeting any of
this. Whatever the mechanism is, it interacts with tableau cost.
How to fix this safely#
The obvious fix – “stop treating a Bool Constant as an EUF predicate” –
is correct in substance and wrong to apply as a single edit in both places at
once. The two layers have different risk profiles.
The asymmetry that decides the order#
A (dispatch) |
B (atom split) |
B2 (rebuild per check) |
|
|---|---|---|---|
what it decides |
which solver runs |
which theory owns an atom |
how often EUF state is rebuilt |
if too narrow |
unsoundness |
none: SAT owns it anyway |
n/a |
if left as is |
slow |
slow |
slow on genuine QF_UFLRA too |
perturbs search elsewhere |
yes, massively |
only already-mixed files |
no – pure performance |
fixes the regression |
12 of 61 files |
with A |
none of it directly |
So fix B first, and treat B2 as the one that matters for the NO work going forward even though it does not by itself recover a single lost file: it is the only one of the three that a correctly-classified QF_UFLRA problem still pays. It is where the 39.4M merges are, it cannot introduce unsoundness, and it does not change which solver runs on any file – which means the change is measurable against a stable baseline. Fixing A first changes the dispatch for a large fraction of the corpus and makes every before/after comparison a comparison of two different solvers.
Fixing B: add the missing third bucket#
The trap is the else branch: it throws. Removing propositional variables
from euf_atoms without adding somewhere for them to go turns a performance
bug into UnsupportedInput on every affected file. The change is therefore:
Recognise atoms that neither theory owns – Bool-sorted
Constants with no arguments, and Bool-sorted equalities over Bool – as propositional.Give them a third bucket that is neither
euf_atomsnorlra_atoms, and confirm every downstream consumer tolerates an atom in neither list.shared_real_constants, the interface-equality registry and the model builder all iterate these vectors; each needs checking, not assuming.Mirror what the pure path already does (
is_arithmetic = falseand skip) rather than inventing a new convention.
Falsifier, stated in advance: on
tsp_rand_70_300_1155482584_7, :euf.incremental.merges must drop from 39.4M
to ~0 and :no.mixed.euf-atoms from 7,352 to ~0, with the answer unchanged. If
merges stay high, the classification is not where the atoms are entering EUF
and this analysis is wrong.
The routing fix has a real cost, and --mixed-dispatch wide recovers it#
Measured 2026-08-07 after landing the fix, on tropical-matrix/constraint-199552
under --guarded-real-equality-lowering:
dispatch |
answer |
wall |
path |
pivots |
|---|---|---|---|---|
|
sat |
2.2s |
|
7,679 |
|
unknown |
>90s |
|
306,333 |
A >40x regression on one instance, isolated to this commit. Scope, checked rather than assumed:
The division result is unaffected. Without that flag both paths time out, and the division runs plain
primo --quiet;constraint-199552times out in both arms there. The +87/-2 and 1657 solved stand.It is this file plus that flag. The other tropical instances time out either way.
Why it matters anyway. constraint-199552 is the reference instance for
essentially all the tropical work in Open leads register‘s lead 3b and
The tropical-matrix family: what the formulas actually say – the 4.13s baseline, the -41% cross-link
measurement, job 119577, the 1.4-billion-lookup profile. Every one of those
numbers was taken on the mixed path. With narrow as the default they no
longer reproduce; add --mixed-dispatch wide to reproduce them.
--mixed-dispatch {narrow,wide} exists for exactly this. It is deliberately
exotic, and it earns its place three ways: it keeps those measurements
reproducible, it is a portfolio dimension on a corpus where neither path
dominates (lead 2b), and it lets differential fuzzing reach the mixed path on
inputs that would otherwise never dispatch there.
Prototype measured 2026-08-07: one line, sound, and two-tailed#
The A-side fix was built and measured, so the difficulty question is answered
rather than estimated. The change is one line – restrict
has_euf_predicate to a Bool-sorted Application:
summary.has_euf_predicate =
term.kind == core::TermKind::Application && term.sort == terms.symbols().bool_sort();
Baseline and prototype built from the same commit, differing only in that predicate. Results over 150 random division files at a 10s cap:
value |
|
|---|---|
answer disagreements |
0 of 150 |
files changing path |
65 (43%), every one |
solved |
114 -> 115 |
gained / lost |
2 / 1 |
paired ratio (n=26, >0.5s) |
p10 0.46, median 0.96, p90 1.04 |
And on the instance the regression was bisected on: timeout -> sat in 0.28s.
It is not free, and the shape of the cost is the same one lead 2b found.
The single “lost” file, 2019-ezsmt/routingMax/SCC_strong/p50.56, is not a
capability loss – it still solves, in 13.0s against 0.23s, a 56x
slowdown on the pure path. So the mixed path is genuinely better on some
instances, and narrowing the dispatch gives those up. The distribution is
two-tailed in exactly the way a solver swap is: p10 0.46 against p90 1.04.
What this means for the decision. The fix is safe (zero disagreements, and the Boolean-shape cases in Booleans have more shapes than “a propositional variable” – test all of them all behave), and it removes a pathological case worth 39.4M merges. It is not an unambiguous speedup, and a 150-file random sample at 10s is the wrong instrument to settle that – the files it targets are a specific 12 on the division. Evaluate it on the full division before promoting it, reporting gained/lost per family rather than PAR2 alone.
Fixing A: only after B, and measure it as a dispatch change#
Restrict has_euf_predicate to Application (arity >= 1) of sort Bool, and
handle the Bool-equality trigger noted in Mechanism A: a propositional variable is treated as an EUF predicate at the same
time – fixing one and leaving the other achieves little.
Note first that the dispatch is a known nondeterminism trigger. Measured
separately: a semantically irrelevant edit – adding a single
(declare-fun p () Bool) to an otherwise pure QF_LRA problem – already flips
which solver runs, and the flip is worth up to an order of magnitude in either
direction. Across the 130 stratified-sample files where the dispatch differs,
neither path dominates at a 15s cap (80 comparable, timeout wins 2-2), which is
why this was originally filed as a nondeterminism hazard rather than a
performance bug; the division at 300s is what showed it also costs solved
files. Consequences for the fix:
The A/B is not a normal A/B. Files that change path are being solved by a different program, not a faster one. Report gained/lost per family, not just PAR2.
Err toward the mixed path when unsure. A missed EUF obligation is a wrong answer; an unnecessary mixed dispatch is only slow. If B is fixed first, the cost of erring wide is much lower, which is another reason for that order.
Fuzz it as a soundness change, not a performance change. The QF_LRA model fuzz gate on Slurm is the minimum; mutation fuzzers add and remove declarations, so they exercise exactly this classifier.
Fixing B2: reuse the prepared theory across checks#
The atom set does not change between checks – euf_atoms is built once
before the loop, and euf_atoms_for_check is a copy of it plus whatever
interface equalities the round appends. So the rebuild is not required by
anything; it is incidental.
The shape of the fix is to construct PreparedFlatTheory once outside the
loop and push/pop the per-check assignment, mirroring what the LRA side
already does (OnlineLraPropagator copies base_tableau once and uses
push()/pop(), see Arithmetic).
The complication to respect: euf_atoms_for_check is not always identical
to euf_atoms – append_true_interface_equalities and
append_interface_equalities extend it during the round. So the reusable part
is the base theory over euf_atoms, with the round’s interface equalities
layered on top and retracted afterwards. A first version that reuses the base
and rebuilds only when the interface set changes would capture most of the win
with far less risk than a full incremental rewrite.
Falsifier: :euf.incremental.merges must stop being exactly
checks * atoms. If it still divides exactly, the base theory is still being
rebuilt.
Booleans have more shapes than “a propositional variable” – test all of them#
Any fix here is a change to how Booleans are classified, and Booleans appear
in SMT in several forms that behave differently. A test suite that only covers
(declare-fun p () Bool) will pass while the fix is broken. The cases, with
what each one must do:
shape |
|
must route / classify as |
why |
|---|---|---|---|
|
|
not EUF |
nullary: no congruence rule can apply |
|
|
not EUF |
a literal, distinct kind – verified, never matched |
|
|
EUF |
congruence applies: |
|
|
EUF |
same, and note |
|
rewritten before dispatch |
not a trigger (measured) |
preprocessing rewrites Bool equalities, so no |
|
|
EUF |
genuine congruence content |
|
|
LRA, unless it contains an application |
already handled correctly |
The trap that matters most, and it is on the Mechanism B side. The truth
value of a Bool-sorted term is represented in the EUF engine by merging it
with true or false – that is why PreparedFlatTheory is constructed with
true_term and false_term, and why the terms count came to
checks x (atoms + 2). So the true/false machinery is load-bearing for
genuine predicates: (f x) still needs to be merged with true/false to
have a truth value at all.
The fix must therefore remove propositional variables from the EUF atom set without removing the true/false handling that Bool-sorted applications depend on. “Stop merging Booleans with true/false” would be a correctness bug, not an optimisation. Concretely, the discriminator is arity, not sort: a Bool-sorted term with zero arguments carries no congruence obligation, and one with arguments does.
Minimum regression tests to add alongside the fix, each asserting both the answer and the dispatch/classification counter:
Pure QF_LRA plus propositional variables – must stay on
:solver.qf-lra.The same plus one
(declare-fun f (Real) Bool)application – must go mixed.Pure QF_LRA plus
(= p q)over Bool – states the current behaviour, and is the test that changes if the second over-trigger is also fixed.A Bool-sorted predicate whose truth value is forced both ways, checking the true/false merge still happens (guards against over-pruning the EUF atoms).
Uninterpreted-sort equalities with arithmetic – must stay mixed (the unsoundness direction).
true/falseappearing literally in an otherwise pure QF_LRA formula – must not flip the dispatch.
What not to do#
Do not “fix” this by disabling NO on single-theory problems. NO is already inert here (
:no.interface.conflicts 0). That would treat the symptom’s neighbour and leave the EUF engine running.Do not declare the regression fixed when A and B are done. They account for 12 of 61 lost files. Mechanism C: LassoRanker – NO bookkeeping on the pure QF_LRA path (SOLVED) is the larger half and is independent.
Do not stop at A and B because the regression numbers recover. Mechanism B2: the EUF theory is rebuilt from scratch on every check recovers no lost file on this corpus and is still the defect most likely to bite genuine QF_UFLRA work, because it is the only one that survives a correct classifier.
Do not benchmark a fix against
b6b8d55. That is a pre-regression commit, not the merge base. Compare against the commit the fix is built on; this note exists partly because that mistake was made once already and made a main-side loss look branch-side.Do not merge a fix on one instance’s evidence. Both mechanisms here were found on single files and confirmed on the corpus afterwards. The corpus step is what turned “1588 vs 1631, we regressed” into “+16 / -0 against the merge base”.
Measurement traps hit while producing this note#
Recorded because each cost real time and each would recur.
Task density on the cluster. The first division run put 67 tasks on one 56-core node alongside another user’s array, giving a 1.31x median slowdown that looked like a code regression. Re-running spread over three nodes with
--cpus-per-task=3moved the result by 4 files – so contention was not the cause, but the run was uninterpretable until it was excluded. Match task density to the baseline run.Bisecting across a branch merge. Main contains merge commits from the Nelson-Oppen branch whose own history predates QF_LRA support; those commits answer
unsupported logic: QF_LRA. A bisect script that treats “no answer” as bad converges on nonsense – twice, here, both times onto a docs-only commit. Usegit bisect --first-parentand exit 125 on unsupported / no-answer.Reading the version banner as the answer. Without
--quietthe first line of output isprimo git <sha> ..., notsat. A test script usinghead -1marks every commit bad.Comparing against the wrong baseline. See “what not to do” above.