Arithmetic#
The replacement chapter is docs-next/implementation/lra.md, available with
make docs-next-serve. It explains the current representations and operations
and maps them to code, tests and primary sources. This page retains the
development history, detailed experiments and original anchors during review;
its dated milestone limitations can have been superseded by later work.
The rewrite does not discard the arithmetic implementation or its evidence.
OnlineLraPropagator and ComponentLraExecution now read a shared SAT assignment
view. They retain tableau checkpoints, bound/explanation caches, and pending
consequences; they own no Boolean assignment array or literal trail. Backtracking
receives the exact removed literals and preserves reasons for retained
propagations. See the ownership protocol
for lifetime, model-scope deferral, and qualification status.
This is the Real-arithmetic reference and historical development record. Integer completion is implemented in QF_LIA: Branch-and-Bound, GCD-Consistency, and Gomory Cuts on the Existing Simplex, and mixed integer combination in N-component combination migration. For the current five-logic qualification and limitations, use Current implementation and qualification; the dated samples below are not measurements of the current branch.
QF_LRA (quantifier-free linear real arithmetic) is implemented through
Milestone 4 of the plan described below: a full SMT-LIB QF_LRA frontend
plus a bounded-variable Simplex theory solver integrated into DPLL(T) via a
trail-synced IPASIR-UP external propagator, with both conflict detection and
same-variable bound propagation, following Dutertre & de Moura’s “A Fast
Linear-Arithmetic Solver for DPLL(T)”. Supported, not just
experimental, as of the 2026-07-24 campaign-tier fuzz gate (3000 fuzzsmt
cases plus opfuzz/yinyang mutation seeded from real benchmarks, all clean
– see docs/run-log.json’s Milestone 4b entries and Milestone 4b below).
Historical sample (2026-07-29). On the 237-file stratified sample
(benchmarks/qf-lra-stratified-sample.txt, 10s timeout) Primo solves
168/237 with PAR2 1538.9s, against z3’s 167/1531.6s and cvc5’s
150/1887.1s – up from 108 at the start of the 2026-07-27 heuristics round.
The four items that got it there are H2 (two-tier rationals), H1a (direct
bounds for single-variable atoms), H1b (scale-canonical polynomial keys) and
H3 (fill-in-aware pivot selection, now the default rule); each is described
in its own section below, and QF_LRA Speedup Heuristics: Design Note has the design
rationale and the per-step progression.
Difference logic (QF_IDL/QF_RDL) remains unimplemented; difference_logic.hpp
is an empty placeholder reserved for that, separate future work.
See A Fast Linear-Arithmetic Solver for DPLL(T) for the base algorithm and LRA Related Work for a literature survey of extensions and alternatives considered before/while implementing it.
Theory-literal language#
The ordinary arithmetic theory boundary is narrower than the surface QF_LRA grammar. After preprocessing and Boolean clausification, the LRA integration accepts SAT-assigned atoms of these two forms:
t1 <= t2
t1 < t2
Here t1 and t2 are Real-sorted linear terms built from rational constants,
Real constants, addition, and multiplication or division by a rational
constant. The frontend canonicalizes t1 >= t2 to t2 <= t1 and t1 > t2
to t2 < t1; chainable comparisons become conjunctions of adjacent binary
atoms. With --arithmetic-atom-canonicalization, an early arithmetic-atom pass
scales every linear direction to primitive integer coefficients and a
deterministic sign. Thus proportional source predicates are hash-consed before
clausification; the option can be combined with --preprocessing-mode=off but
defaults off pending stronger performance evidence. Nonlinear multiplication
and division by a nonliteral term are outside the accepted fragment.
The integer operators div, mod, and abs are accepted as a deliberate
extension, not as conformance. They belong to the SMT-LIB Ints theory, but
both QF_LIA and QF_UFLIA exclude them by name – QF_LIA requires Int terms to
have “no occurrences of the function symbols **, /, div, mod, and
abs”, and its :extensions clause readmits only * with concrete
coefficients. So no logic Primo supports permits them, and rejecting them was
correct on the standard’s terms; Primo accepts them because the solvers it is
compared against do, and because rejecting them leaves the operators
unreachable everywhere. Dolmen enforces the logic and will refuse to
model-check such a file.
frontend.cpp builds IntDiv, IntMod and Abs terms, which the
lower-extended-terms leaf encodes; a non-constant divisor is reported as
nonlinear and a literal zero divisor is rejected outright. The QF_UFLIA real-seed mutation gate is where these turned up: of 165
unsupported mutants in job 131463, 44 were div, 40 abs and 29 mod. Those
mutants are themselves outside their declared logic – typefuzz mutates
operators under --keep-logic and does not respect the :extensions clause –
so rejecting them was defensible.
Supporting the operators did not unblock that gate, which was the other
argument for the work. Job 131564, the same gate after the change, still
reports 157 unsupported mutants of 200 and an effective yield of 22.8% against
the roughly 25% before. The reason is that typefuzz substitutes operators
without regard to linearity: of the 157, 68 are mod and 43 are div with a
non-constant divisor, and 46 are nonlinear *. All are genuinely outside
the linear fragment and correctly rejected, so the failure simply moved from
“unknown function symbol” to “nonlinear divisor”. Only about 8 of the original
165 became answerable. The remaining justification for div/mod/abs is
usability, and that is all. The remaining unsupported mutants
are correct rejections: nonlinear multiplication, and files Z3 rejects too
because a mutation left a let variable unbound.
Integer division, modulo, and absolute value surveys what the standard requires
and what the literature does and does not say. In
QF_UFLRA, mixed purification first replaces each
Real-valued UF application occurring in arithmetic with a fresh Real proxy, so
the prepared LRA atom contains no UF application node.
BooleanClausifier owns the mapping from each Le or Lt term to a SAT
literal. The LRA side receives the term together with the SAT assignment’s
truth value and blocking literal; it does not receive a syntactic not.
normalize_atom() converts the comparison to a polynomial bound p <= c or
p < c, and PreparedLraTheory installs either a direct bound on one Real
variable or a bound on a slack variable for a compound polynomial. The
assignment polarity then selects exactly one tableau bound:
Core atom |
SAT value |
Bound asserted to LRA |
|---|---|---|
|
true |
|
|
false |
|
|
true |
|
|
false |
|
Reversing a comparison during polynomial scale canonicalization reverses the
upper/lower direction in the same way. Strict bounds use the exact
DeltaRational representation from Dutertre and de Moura, Section 3.1: for
example, false p <= c is the lower bound c + delta, while true p < c is
the upper bound c - delta.
Equality and disequality#
Real equality is deliberately not part of the SAT-visible arithmetic-atom
language. It remains a genuine TermKind::Equality through the equality-aware
preprocessing passes. At the final equality-producing preprocessing boundary,
the mandatory lower_real_equalities() pass rewrites
t1 = t2 ==> (t1 <= t2) and (t2 <= t1).
This happens before mixed purification, Boolean clausification, and theory dispatch, not during parsing. The ordering lets solve-eqs and related passes consume Real equalities and also ensures that equalities generated by those passes are lowered before LRA sees them.
SMT-LIB disequality is not (= t1 t2); distinct is expanded to pairwise
negated equalities. After equality lowering, a Real disequality therefore has
the Boolean form
not ((t1 <= t2) and (t2 <= t1)).
SAT performs the required case split. Whichever non-strict inequality is
assigned false reaches LRA as a strict separating bound, so the tableau needs
no native != relation.
There is one separate, internal combination input. In QF_UFLRA, the
TheoryCombinationManager may register an equality between shared Real terms
after the ordinary lowering pass. When SAT assigns such an interface equality
true, the mixed final check layers the native Equality atom onto
PreparedLraTheory; LraRelation::Eq then asserts both the upper and lower
bound at the same value. A false interface equality is not sent to LRA as a
native false-Eq: that input would be the non-convex disjunction t1 < t2 or t2 < t1, which is not a single tableau bound. Thus the complete prepared-LRA
input contract is SAT-assigned Le/Lt atoms plus combination-internal,
known-true shared equalities. Native false equality is rejected defensively.
The optional --guarded-real-equality-lowering changes only the Boolean shape
of one guarded case to avoid an extra Tseitin variable; it does not change this
contract. See Real-Equality Lowering for the precise pass ordering and
guarded rewrite.
Tableau sparse-row and occurrence representation#
LraTableau owns two views of the current sparse tableau. For every current
basic variable b, rows_[b] is a contiguous Row containing the nonzero
right-hand-side coefficients in
[ x_b = \sum_j a_{bj}x_j. ]
The entries are strictly ordered by var, with at most one entry per variable:
struct RowEntry {
LraVarId var;
util::Rational coeff;
std::uint32_t occ_slot;
};
The reverse view, occurs_in_[v], is an unordered vector of the basic variable
IDs whose rows currently contain v. Therefore, for every entry e in
rows_[b], the cross-link invariant is
occurs_in_[e.var][e.occ_slot] == b
occ_slot is thus an index into the column list occurs_in_[e.var]. It is
not the entry’s position inside rows_[b], a variable ID, or an occurrence
count. Conversely, because each row contains a variable at most once, every
element b of occurs_in_[v] identifies exactly one entry for v in
rows_[b].
This is Primo’s array-backed, one-direction subset of the doubly cross-linked sparse tableau in King, Section 2.2.7 (King, “Effective Algorithms for the Satisfiability of Quantifier-Free Formulas Over Linear Real and Integer Arithmetic” (NYU, 2014)). King links each nonzero into both its row and column and leaves both lists unsorted. Primo keeps rows sorted and contiguous for linear two-way row merging, so only the row entry points into the column; the column entry stores a basic-variable ID rather than a direct row position.
Maintenance follows the ownership of the column entry:
inserting a new row occurrence appends
btooccurs_in_[v]and stores the old column size as the new entry’socc_slot;rewriting or moving a surviving entry within its row preserves
occ_slot, because its element inoccurs_in_[v]did not move;removing an occurrence swap-pops its column element, then updates the one row entry whose basic-variable ID moved into the vacated column slot;
rebuilding the occurrence index, including after restoration from the trail, rewrites every
occ_slotwhile repopulating the columns.
Rows remain sorted, but column lists deliberately do not: swap-pop removal
destroys column order. occurs_in_index_matches_full_rebuild() independently
reconstructs the columns and also checks the cross-link equation above. The
tests call it after pivots, exact coefficient cancellation, and backtracking,
because a stale slot normally produces a later wrong row update rather than an
immediate failure. occ_slot is 32-bit to limit row-entry growth; the current
representation consequently assumes that one variable occurs in fewer than
\(2^{32}\) basic rows.
Fig. 1 The current sparse-row and reverse-occurrence representation. The paired gold
markers instantiate the cross-link invariant for x0; the lower inset shows why
swap-pop removal must repair the surviving entry that moved within the column.
The diagram abbreviates the implementation field occurs_in_ as occ.
The editable TikZ source is docs/figures/lra-tableau-occurrence-index.tex.#
Implemented (Milestones 1-4, supported)#
Grammar/frontend (
src/smt/frontend.cpp):(set-logic QF_LRA),Real-sorted declarations,+ - * <= < >= > =per the SMT-LIB QF_LRA grammar (verified against the spec, not assumed) – n-ary+/chainable comparisons, arity-disambiguated-,*//requiring a literal operand (with the literal recognized afterbuild_term, so let-bound aliases and constant-folded sub-expressions work, not just raw numeral tokens). Before mandatory lowering, a surfaceReal-sorted=is classified as a QF_LRA equality rather than an EUF atom –contains_equality/contains_arithmetic_atomclassify by operand sort.Term representation (
src/core/term.hpp,term_manager.{hpp,cpp}):SortKind::Real;TermKind::{Rational, Plus, Times, Le, Lt}.Timesis a unary scale node (coefficient * one child), not general multiplication – see the TermKind comment for why (a coefficient has to attach to some node; repeated addition can’t represent non-integer coefficients).Exact rationals (
src/util/rational.{hpp,cpp}): a two-tier representation – numerator/denominator held inline asstd::int64_twhen they fit, falling back to a heapmpq_class(GMP, vendored viacontrib/install_gmp.py,make contrib-gmp) when they don’t. See “Two-tier rational representation” below.Normalization (
src/theory/arithmetic/linear_polynomial.{hpp,cpp},lra_atom.{hpp,cpp}):LinearPolynomial(sum of coeff*var + constant, Section 2’s normal form),LraAtom(canonical “t ~ c” form, Section 2).Decision procedure (
src/theory/arithmetic/lra_tableau.{hpp,cpp}):LraTableauimplements Assert-Bound, Check/Pivot (Bland’s rule, for termination), and Farkas-certificate conflict explanation (Section 3, 3.2). Strict inequalities viaDeltaRational(q + k*delta, exact, no floating point). Incremental:push()/pop(checkpoint)are genuinely trail-based. The default non-SOI path trails bounds only:poprelaxes bounds while retaining the current assignment and basis. The equations and nonbasic bounds remain satisfied;check()establishes basic feasibility. This adapts the technical report’s Section 3.2.4; the distinction from its saved successful assignment is recorded in A Fast Linear-Arithmetic Solver for DPLL(T). SOI is the exception: its basis-dependent objective retains full bound/assignment/pivot rollback. Do not mix assignment rollback with a retained basis. The original full-trail prototype is historical, not the current default.DPLL(T) wiring (
solver_engines.cpp):PreparedLraTheoryprecomputes the tableau’s structural part exactly once per preparedcheck-satproblem, before any theory call and independently of any SAT assignment. It sees the complete theory-atom list and allocates one stable slack variable and row per scale-canonical compound polynomial, not per atom. Every correspondingPreparedAtomthen stores the samebound_var, so an atom encountered in any later callback refers to the same tableau variable. Slacks are therefore global to the prepared problem, not allocated afresh for each theory check. The online path copies this base tableau once and trails bounds (also assignments and pivots in SOI mode); the fallback may copy it for a check, but preserves the prepared variable numbering and atom-to-variable map. A single-variable atom gets no slack and no row at all – it is a bound on that variable itself (see “Direct bounds for single-variable atoms” below). Theatoms_by_bound_varindex, also built once, records every atom that can bound each stable tableau variable.OnlineLraPropagator(Milestone 3a) is a trail-syncedsat::ExternalPropagator, owning oneLraTableau(copied once from the prepared base) kept in sync vianotify_new_decision_level/notify_backtrackcallingLraTableau::push()/pop()– this is what actually exercises push/pop for cross-round reuse.solve_qf_lra_engine_implalways prefers this when the backend reports external-propagator support (#if PRIMO_HAVE_IPASIR);find_incremental_lra_theory_conflict(copy the base tableau and reassert every round, no push/pop reuse) survives only as the fallback when it isn’t.Typed arithmetic models (
solver_engines.cpp,typed_model_builder.cpp, andsmtlib/printer.cpp): when a model is requested, exact values are read from the finalLraTableauassignment and strictDeltaRationalvalues are concretized by choosing a positive rational delta small enough to preserve the asserted strict bounds. The model builder also replays preprocessing reconstruction equalities from solve-eqs/Gaussian elimination, so a constant eliminated before theory solving is assigned from the final values of the terms it was rewritten to. The mixed QF_UFLRA path supplies compatible numeric assignments and EUF facts to the same typed adapter, not concatenated model strings. It supports UF symbols with Real arguments and Real-valued applications after purification. Its coordinator additionally preserves the ordering of all distinct sharedDeltaRationalvalues while choosing the concrete delta, using the adjacent-value form of Dutertre and de Moura Section 3.3 inO(S log S)time. This prevents a concrete model from collapsing Real arguments that the accepted EUF model distinguishes. One frozencore::Modelowns constants and evaluated-argument function tables; the session checks original assertions and prints it separately. See Finalized model interpretations. Arrays, nonlinear arithmetic and the complete paperDPLL(T^M)transition system remain unsupported; model-guided combination itself is implemented for the documented mixed fragments.Model-based SAT phase hints (heuristics item H5,
solver_engines.cpp, automatic for declared QF_LRA rebuild queries with auto preprocessing, with explicit overrides viaCheckSatOptions::lra_model_phase/--lra-model-phase):OnlineLraPropagatorkeeps the SAT backend’s variable choice but refreshes arithmetic atom polarities from the current tableau assignment β at quiescence points. For each arithmetic atom, it evaluates the atom’s TRUE bound using the sameDeltaRationalbound_assertion()representation used by Assert-Bound and same-variable propagation, then callsIpasirUpBackend::phase()only when the preferred literal changed since the previous refresh. This is a search hint only; it does not add clauses, propagations, or theory decisions. Attribution: designed independently here (2026-08-04). The published description is SPASS-SATT’s decision recommendation – Bromberger, Fleury, Schwarz and Weidenbach, SPASS-SATT: A CDCL(LA) Solver, CADE 2019, §3 and footnote 3; DOI 10.1007/978-3-030-29436-6_7. Their rule is a disjunction, and its second disjunct is unreachable whenever unate propagation is on, so this is their technique, not half of it, and their +129 on QF_LIA is a measurement of this mechanism. The flag is off by default and has only been evaluated on QF_LRA; see lead 20. Full attribution chain, including what not to cite, in What to cite for --lra-model-phase.QF_LRA fragment boundary (
Frontend::declare_fun): QF_LRA accepts free constants, but rejects non-nullarydeclare-fundeclarations. Uninterpreted functions or predicates over arithmetic terms are QF_UFLRA/NO work, not pure QF_LRA.Same-variable bound propagation (Milestone 3b,
solver_engines.cpp; called same-slack propagation before item H1a, when a slack was the only thing a bound could live on): after each successful bound assertion,OnlineLraPropagatorscans the other atoms bounding that same tableau variable (viaatoms_by_bound_var) and eagerly propagates any now entailed true/false, usingLraTableau::upper_bound()/lower_bound()(new read accessors) compared against theDeltaRationalbound_assertion()says that atom’s own TRUE/FALSE case would assert – Dutertre & de Moura Section 4.2’s “deriving satisfied atoms”. Each propagation’s reason is exactly the one bound’s own asserting literal (a 2-literal Horn clause), computed eagerly at scan time rather than lazily re-derived, since the check is exact and cheap (no candidate/budget/backoff machinery like EUF’s propagation needs). Gated behindCheckSatOptions::lra_propagation(default on),--lra-propagation/--no-lra-propagationCLI flags. Also known as unate propagation, which is the name Dutertre and de Moura use for it in §3.2.5 of the CSL-06-01 technical report (“if boundxi >= cihas been asserted then any unassigned atom of the formxi >= c'withc' < ciis immediately implied. Similarly, the negation of any atomxi <= uwithu < ciis implied”), and the name it goes by in SPASS-SATT and cvc5. The alias is recorded here because searching the tree for “unate” otherwise returns nothing and invites the false conclusion that Primo lacks the technique – which it did once, on 2026-08-29; see What to cite for --lra-model-phase. Calibration, for anyone tempted to tune it: SPASS-SATT measures unate propagation at exactly zero on both divisions (1607 vs. 1608 QF_LRA, 6806 vs. 6806 QF_LIA) and keeps it on by default anyway, on a stated assumption about theory combinations rather than a measurement (Decision Procedures for Linear Arithmetic (Bromberger’s thesis) §8.1). Against that, cvc5’s own counters on a Primo dramatic-gap file showunatePropagateCalls = 15,595againstpivots = 129(below). Primo has never A/B’d--no-lra-propagation; it is cheap to.Row-based/interval bound propagation (competitiveness-roadmap item 2, 2026-07-25,
check_sat.cpp): generalizes same-slack propagation to a derived rather than only directly-asserted bound. For a rowbasic_var = sum(coeff_j * var_j),compute_implied_boundsums each term’s own maximum/minimum contribution (var_j’s upper bound ifcoeff_jis positive, lower if negative, for the implied-upper case; symmetric for implied-lower) into an implied interval forbasic_var, then checks it againstatoms_by_bound_var[basic_var]exactly like same-variable propagation does. Triggered from the samenotify_assignment_lithook, right after the same-variable scan, on whatever variable was just tightened. (Originally, bounds could only ever live on slacks, so a row’s term variables needed an extrasingle_variable_slackindirection to find their current bound at all, and the trigger only fired for slacks wrapping exactly one coefficient-1 variable. Item H1 removed both: a row term’s bound is now read directly, which is simpler and strictly more reaching.) Reasons are genuinely multi-literal (one antecedent per row term used, unlike same-slack propagation’s single-literal reason) –OnlineLraPropagator’spending_/reasons_/cb_add_reason_clause_litwere widened from a singlesat::Littovector<sat::Lit>to carry this (same-slack entries become singleton vectors, unchanged behavior). Two defensive, non-adaptive caps bound the O(row size) per-affected-row cost this technique has that same-slack propagation’s O(atoms sharing a slack) doesn’t:--lra-row-propagation-max-row-size(default 64) skips deriving a bound for any row longer than this, and--lra-row-propagation-max-fanout(default 64) caps how many rows a single bound change may trigger a scan over – a variable occurring in more rows than this propagates nothing that round. Both live inCheckSatOptions::lra_row_propagation_limitsand actually bind as of 2026-08-10; before that the two CLI options parsed but were silently ignored in favor of now-removed hardcoded constants of the same values (docs/tuning-parameters.md’s “dead knobs” finding). Reported by:lra.row-propagations, alongside the existing aggregate:lra.theory-propagations. Opt-in (CheckSatOptions::lra_row_propagation = false,--lra-row-propagation), only takes effect whenlra_propagationis also enabled. Cleared merge-gate-tier fuzz smoke (500 fuzzsmt QF_LRA cases, 2026-07-25; 500 more with bidirectional also on, 2026-07-26) and the 2026-07-31 raised-cap sweep’s 2,000-case fuzz gate (job 117767, 0 wrong answers), plus a ~4,000-case mixed QF_UFLRA campaign with it enabled (2026-08) – seedocs/run-log.json. What is still missing is a dedicated pure-QF_LRA, default-cap, campaign-tier (>=3000-case) run of the kind that gates promotion to default-on; see What QF_LRA owes Nelson-Oppen: convexity and equality sharing. Only derives a row’s own implied bound and checks its own slack’s atoms – rearranging a row to tighten one of its children’s bounds from the basic variable’s own bound (Section 4’s fuller bidirectional treatment) is the separate, since-implemented competitiveness-roadmap item 7 below,compute_implied_child_bound/--lra-bidirectional-row-propagation.Selectable pivoting rule (Milestone 4a,
lra_tableau.{hpp,cpp}):LraTableau::PivotingRule::{Bland, Dantzig}. Dantzig picks the most-violated basic variable and largest-|coefficient|entering variable incheck()’s Check/Pivot loop – cheaper per pivot and faster per-instance than Bland’s smallest-index selection (perdocs/papers/lra-related-work.md’s survey), but with no anti-cycling guarantee on its own, socheck()falls back to Bland’s-rule selection once a heuristic pivot-count threshold is exceeded within that call. A finite Dantzig prefix followed by a Bland’s-rule suffix still terminates (the paper’s termination argument applies to the suffix on its own). Opt-in viaCheckSatOptions::lra_pivoting_rule(--lra-pivoting-rule {bland,dantzig,soi,sparse}) – default was Bland at the time, the already campaign-fuzzed behavior (superseded 2026-07-29: the default is nowsparse, see “Fill-in-aware pivot selection” above);kStatLraBlandFallbacksreports how often the fallback engages.SOI-Simplex pivoting (competitiveness-roadmap item 4, 2026-07-25, King/Barrett/Dutertre FMCAD 2013 “Simplex with Sum of Infeasibilities for SMT”,
lra_tableau.{hpp,cpp}):PivotingRule::Soireplaces Bland/Dantzig’s single-row objective with a global one – minimize the sum of infeasibilities across every out-of-bound basic variable at once, addressing Dutertre-de Moura’s own documented weak point (no global progress criterion). The implemented mechanisms are listed below. Primo differs from the paper in degenerate entering-variable tie-breaking, uses a finite Bland fallback, and does not perform Figure 5’s preliminary all-row conflict scan. Its recursive minimizer uses the restricted-row conflict test, rather than the row-selection procedure in King’s thesis Figure 3.6. The endpoint correction distinguishes inward and outward motion at a starting bound, avoiding an unnecessary zero-step pivot. Implemented mechanisms:An incrementally-maintained “f-row” (
f_var_, a real extraLraVarId/row sopivot()’s existing row-rewrite logic keeps it correct across basis changes for free) trackssum(d_i * row_i)over every currently-violated basic variable, updated viaset_soi_direction()whenever a basic variable’s direction (d_i, eq 3) flips – hooked intoassert_upper/assert_lower(bound tightened while already basic),update()(assignment changed via propagation), andpivot()(leaving variable resets to the nonbasic baseline). This trail/incremental integration is genuinely new design beyond the paper (which only describes one non-incrementalCheckcall, not DPLL(T) backtracking) – a newFRowTrailEntrytrail variant undoes it onpop().soi_select()(Section V-A) picks the entering variable/leaving row/step size via the paper’s own breakpoint-sortedΔVio(j,δ)walk, not a cheaper approximation.build_soi_conflict()/minimize_soi_conflict()(Section V-B) handle the case where several rows are simultaneously stuck: a multi-row conflict generalizing the existing single-rowbuild_conflict, then minimization viasoi_subset_still_conflicts()’s restricted-objective oracle. The algorithm is selectable (--lra-soi-minimize {quickxplain,deletion,none}, default the original QuickXplain of Junker 2001), and deletion takes an ordering (--lra-soi-minimize-order {id,row-support,violation}).The reason for a choice here is not speed. Instrumented over 2253 calls: 82% of error sets have one element, the rest average 2.83, and the largest seen was 9 – far too small for the asymptotics separating these algorithms to matter. What matters is that minimization reduces the set to 1.23 on average, so there are usually several equally minimal answers and the algorithm silently picks one. QuickXplain’s divide-and-conquer decides which elements are tested together and so cannot be steered; deletion drops what it visits first, which makes the pick an ordering decision.
row-supportsheds the longest rows first becausebuild_soi_conflictemits one literal per nonzero entry of the restricted row plus one per error variable, and the first term usually dominates – so which row survives, not how many, is what sets clause length.Measured 2026-08-10 (18 instances where all five arms complete, 0 answer disagreements;
:lra.soi-conflict-lits, median ratio against quickxplain):arm
median
geomean
better/worse
deletion/id1.000
0.957
6 / 7
deletion/row-support1.000
0.917
8 / 5
deletion/violation1.001
0.917
5 / 9
none1.218
1.186
3 / 15
Two conclusions, and the second is the awkward one. Minimization clearly pays –
nonecosts ~19% more literals, 3 better against 15 worse. But no deletion arm dominates QuickXplain: all three sit at a median of ~1.000, and the better geomeans forrow-supportandviolationcome from a handful of instances with large swings while their win/loss records point in opposite directions. So therow-supportrationale above is not confirmed as a general effect. It is not worse either, which is what makes these portfolio arms rather than default candidates – they are complementary, cheap, and cannot reach a default run. The default staysquickxplain/id.Clause length is a proxy: a shorter clause is usually stronger, but a longer one over lower-level literals can backjump further, and the tableau cannot see decision levels. PAR2 would be authoritative and was not measured.
Defensive pivot-count fallback to plain Bland selection, belt-and- suspenders beyond the paper’s own tie-breaking-reduces-to-Bland termination argument (Section V-C), reusing the existing
bland_fallback_count_machinery.Directly verified against the paper: a white-box test (
lra_tableau_tests.cpp) reproduces the paper’s own Fig 6 worked example exactly (entering=x2, δ=1, leaving=x1,Vio(V)reaching 0 in one pivot) viacheck()’s final state.A real heap-use-after-free was found (not just theorized) during manual smoke testing before any fuzzing:
update()’s dependent- basic-row loop iteratedoccurs_in_[nonbasic_var]by reference whileset_soi_direction()(called inside that same loop) could mutate that exact vector, whennonbasic_varwas itself a term inf_var_’s row – fixed by copying the affected-rows list before iterating.Reported by
:lra.soi-multi-row-conflicts,:lra.soi-minimize-calls,:lra.soi-minimize-shrunkand:lra.soi-conflict-lits, alongside the existing:lra.bland-fallbacks. Opt-in (--lra-pivoting-rule soi), same rollout precedent as Dantzig.Merge-gate fuzz result (job 113439, 2026-07-25): correctness-clean (500/500, 0 crashes/wrong-answers) but a notably higher timeout rate (176/500, ~35%) than the analogous Gaussian-elimination/row- propagation runs (~5-6%), and ~4.5x the wall-clock time for the same 500 cases. Consistent with the paper’s own framing – SOI’s breakpoint-sorted
ΔViowalk over everyFlex(f)candidate each pivot is genuinely more expensive per-pivot than Bland/Dantzig’s O(row size) selection, trading that cost for robustness on hard instances specifically, not average-case speed on typical ones. Not campaign-tier confirmed and not currently planned for default-on promotion given this; seedocs/run-log.json.
Two-tier rational representation (heuristics-note item H2, 2026-07-28,
src/util/rational.{hpp,cpp}, unconditional):util::Rationalstores small values inline asstd::int64_tnumerator/denominator and only falls back to a heapmpq_classwhen a value genuinely needs more than 64 bits. Following Monniaux (CAV 2009, Section 4 – see LRA Related Work), whose stated reason is exactly the one profiling found here: “GMP rationals inherently involve some inefficiencies, including dynamic memory allocation, even for small numerator and denominator”.Canonical-form invariant: a
Rationalis in big form if and only if its value does not fit the small form. The representation is a function of the value, never of how the value was reached – which is what letsoperator==reject a form mismatch outright and letsRationalHashhash the small pair directly. Without it, a value reachable both directly and through an overflowing intermediate would have two representations and hash two ways, silently breakingPolynomialKeyhash-consing (atoms that should share a slack quietly wouldn’t) rather than producing a visibly wrong answer. Exactly two private factories (from_int128_reduced,from_mpq) establish it; nothing else assigns the representation fields. A debug/ASAN-onlycheck_invariant()asserts it at both.Arithmetic: Knuth (TAOCP Vol. 2, Sec. 4.5.1) two-gcd rational addition and cross-reduced multiplication, entirely in machine words with
__int128intermediates. Both operands being canonical with magnitudes<= INT64_MAXbounds every product by2^126and every sum by2^127, so no intermediate can overflow__int128and the only range check needed is on the reduced result. Comparisons cross-multiply in__int128and never allocate or change representation.Deviation from the paper: the small form is 64-bit, not Monniaux’s 32-bit.
kSmallMaxis the single knob, and the two widths were compared empirically rather than argued about;__int128is a GCC/Clang extension, introduced under__extension__because the build is-Wpedantic -Werror.Width ablation (2026-07-28, local, two alternating rounds; the same commit built twice, changing only
kSmallMax). 64-bit is kept, and the trade-off is exactly the predicted one:instance
32-bit
64-bit
64/32
promotions (32 / 64)
TM/p5-zenonumeric_s50.13s
0.13s
1.00x
0 / 0
sc-21.induction31.56s
1.55s
1.01x
0 / 0
uart-18.induction0.57s
0.53s
1.08x
0 / 0
clock_synchro/clocksynchro_2clocks2.44s
1.10s
2.22x
668k / 359k
miplib/pp08a-120001.48s
1.59s
0.93x
0 / 0
DTP-Scheduling/constraints-tempo-width-307.59s
8.77s
0.87x
0 / 0
On every instance whose coefficients never leave 32 bits, the two are within noise or 32-bit is up to ~13% faster –
__int128operations are multi-instruction where 64-bit ones are not, and that cost is paid on every add and multiply. On the one instance that does overflow, 64-bit is 2.2x faster because it cuts big-form operations 12-fold (4.44M -> 365k). A few percent on the common case buys a factor of two on the hard one, so 64-bit stays. The ablation also earned its keep by finding a real latent bug:Rational(std::int64_t)range-checked only one side, which is vacuous atkSmallMax == INT64_MAXand wrong at any narrower width.Observability:
:rational.promotionsand:rational.big-operationsreport how often the fast path was left. Measured: zero onTM/p5-zenonumeric_s5,sc-21.induction3,uart-18.inductionandmiplib/pp08a-12000(the fast path is taken 100% of the time), but 1.22M promotions / 1.27M big operations onclock_synchro/clocksynchro_2clocks.main_invar.induct, whose coefficients genuinely blow up through pivoting – which is itself the explanation for that instance’s ~1.5 ms/pivot cost.Effect (paired local A/B: three alternating before/after rounds on one idle host, reporting each round so cluster/host drift is visible rather than averaged away;
:sat.decisions/:sat.conflicts/:lra.pivots/:lra.online-check-calls/:lra.theory-propagationsbit-identical throughout, confirming a pure representation change):instance
before (3 runs, s)
after (3 runs, s)
best-of-3
median
TM/p5-zenonumeric_s53.66 / 4.65 / 5.77
0.75 / 0.79 / 0.94
4.9x
5.9x
sc-21.induction327.73 / 32.17 / 41.76
6.79 / 7.52 / 8.14
4.1x
4.3x
uart-18.induction7.31 / 7.36 / 7.77
1.40 / 1.78 / 2.70
5.2x
4.1x
clocksynchro_2clocks11.77 / 14.13 / 14.76
5.56 / 6.27 / 6.73
2.1x
2.3x
latendresse/ecoliFBAyicesTest-500timeout (90s)
timeout (90s)
–
–
clocksynchro_2clocksgains least, consistent with its 1.22M promotions: it is the one instance here whose coefficients really do outgrow 64 bits, so it keeps paying GMP’s cost.ecoliFBAyicesTest-500does not move at all – it is a singlecheck()call over one large LP, which is H1/H3 territory, not H2’s.Promotion bar (Slurm job 114900, the 237-file stratified sample at a 10s timeout, diffed against the unchanged z3/cvc5 results from job 114200): solved 108 -> 124/237 (45.6% -> 52.3%), PAR2 2675.6s -> 2380.9s (-11.0%), zero wrong answers, and 16 files newly solved against ZERO regressions. Per family: TM 6->10, miplib 1->4, uart 4->7, 2017-Heizmann 0->2, tta_startup 9->11, clock_synchro 6->7, sc 5->6. The gap to z3 (167) narrows from 59 files to 43 and to cvc5 (150) from 42 to 26. Unlike roadmap item 8, whose dramatic narrow-sample win did not survive this bar, this one does.
Verified at merge-gate and campaign-gate fuzz tiers on both pivoting paths (jobs 114896/114897 merge, 114898/114899 campaign): 7,000 fuzzsmt cases, two opfuzz runs and four yinyang typefuzz shards over real QF_LRA seeds – 0 crashes, 0 wrong answers throughout. Incidentally, the gates’ own timeout rates fell sharply (Bland campaign 118-143 -> 28, SOI campaign 965-976 -> 324), the same speedup showing up in the fuzz corpus.
Direct bounds for single-variable atoms (heuristics-note item H1a,
2026-07-28,
check_sat.cpp, unconditional): an atom whose normalized polynomial is a single termc*xis a bound onxitself. The coefficient is divided out (rhs' = rhs / c) and no slack variable and no row are created at all. Dividing by a negativecreverses the relation, recorded asPreparedAtom::asserts_lower_when_trueand interpreted in exactly one place,bound_assertion(), whose comment tabulates all four relation/orientation rows and which is what the newsingle_variable_atom_*tests pin.Monniaux (CAV 2009, Section 2.1) states this as ordinary practice – “for each left hand side that is not a variable, a new variable is introduced” – and Dutertre & de Moura’s Section 2 does the same. Primo previously introduced a slack for every distinct polynomial.
Two things follow beyond the row saving. A variable’s lower and upper bound now share a tableau variable:
(<= 0 x)normalizes to coefficient -1 and(<= x 10)to +1, so under exact-content polynomial keys they used to land on two different slacks and the tableau could not see thatxwas bounded on both sides without pivoting. And row-based propagation reads a row term’s bound directly instead of viasingle_variable_slack, which both simplifies it and removes the quirk item 7 had to work around (that index only ever registered a bare variable’s positive-coefficient form, so>=atoms never got an entry).Row counts, measured via the new
:lra.rowsstat, against the pre-H1 shape measurements in QF_LRA Speedup Heuristics: Design Note Section 1.3 – every one matches that section’s predicted single-variable share exactly:latendresse/ecoliFBAyicesTest-5004,555 -> 1,394 (-69%),TM/p6-zenonumeric_s51,378 -> 67 (-95%),miplib/pp08a-12000400 -> 138 (-66%),sc-17.induction31,185 -> 723 (-39%),uart-17.induction1,446 -> 920 (-36%),tta_startup/simple_startup_4nodes584 -> 500 (-14%),clock_synchro/clocksynchro_2clocks264 -> 259 (-2%),DTP-Scheduling/constraints-tempo-width-303,932 -> 3,932 (unchanged – it is 100% difference constraints, so it has no single-variable atoms to remove).Effect (paired local A/B against the H2-only build, two alternating rounds):
TM/p5-zenonumeric_s50.63s -> 0.12s (5.3x; decisions 13,230 -> 1,435, pivots 18,097 -> 230),sc-21.induction36.33s -> 1.98s (3.2x; decisions -47%, pivots -58%),uart-18.induction1.18s -> 0.94s (1.3x; pivots -63%),miplib/pp08a-120001.97s -> 1.72s (1.2x),clock_synchroandDTP-Schedulingflat within noise (their counters are unchanged or nearly so, as their row counts predict).latendresse/ecoliFBAyicesTest-500still times out despite losing 69% of its rows – its cost is the remaining 1,394-row LP with a 691-term dense row, i.e. H3’s fill-in problem rather than H1’s row-count one. Unlike H2 this is not a pure representation change: it renumbers tableau variables and strengthens propagation, so search counters legitimately move.Promotion bar (Slurm job 115172): solved 124 -> 136/237, PAR2 2380.9s -> 2149.8s (-9.7%), 12 files newly solved, ZERO regressions, zero wrong answers. Per family: TM 10 -> 15 (now matching z3’s 15/15), 2019-ezsmt 3 -> 7, sc 6 -> 8, tta_startup 11 -> 12. Stacked with H2: 108 -> 136 (+28 files, 45.6% -> 57.4%), PAR2 -19.6%, and the gap to z3 (167) narrows from 59 files to 31, to cvc5 (150) from 42 to 14.
Verified at merge-gate and campaign-gate fuzz tiers on both pivoting paths (jobs 115168/115169 merge, 115170/115171 campaign): 7,000 fuzzsmt cases, two opfuzz runs and four yinyang typefuzz shards over real QF_LRA seeds – 0 crashes, 0 wrong answers throughout, with the Bland merge gate coming back 500/500 PASS with no timeouts at all.
Scale-canonical polynomial keys (heuristics-note item H1b, the
long-deferred competitiveness-roadmap item 3, 2026-07-28,
check_sat.cpp, unconditional):PolynomialKeydivides every coefficient by the coefficient of the lowest-indexed variable, so proportional polynomials collapse to one key.How sharing is detected. During
PreparedLraTheoryconstruction, each arithmetic atom is processed as follows:normalize_atommoves all variable terms to the left and the constant to the right, producing \(p \mathrel{\sim} c\), where \(p\) has no constant term.The terms of \(p\) are sorted by
TermId. Let \(a\) be the nonzero coefficient of the first term. Divide every coefficient and \(c\) by \(a\). If \(a<0\), record that the bound orientation is reversed.The resulting sorted vector of exact
(TermId, Rational)coefficients is thePolynomialKey. The right-hand side, relation, strictness and orientation are deliberately not part of this key: they describe bounds on the polynomial, not the polynomial whose value the slack represents.If the key has one term, its normalized polynomial is just one problem variable and no slack is needed. Otherwise, look up the key in
slack_by_polynomial. Reuse the existing slack when found; on the first occurrence allocate one slack, add its defining row, and insert the key.
The optional early atom canonicalizer, introduced on 2026-08-17 and now default-off, can precede this with a global GCD rewrite of the core arithmetic atom. When enabled, the pre-clausification pass clears coefficient denominators, divides by integer content, fixes the sign, and rebuilds the predicate through
TermManager.PreparedLraTheorystill divides by the first nonzero coefficient as a defensive theory-boundary invariant. For example, the core pass maps both2x + 3y <= 5and4x + 6y <= 10to2x + 3y <= 5; theory preparation may represent that same direction internally asx + 3/2 y <= 5/2. The conventions identify the same rational scale class, while the early primitive-integer form also lets Boolean preprocessing and clausification share the atom.Because the map is populated while walking the complete theory-atom list, this lookup finds proportional forms regardless of which SAT assignment first activates them. For example, it allocates one global slack
s = x + yfor all of these atoms:source atom
normalized bound on
sx + y <= 5s <= 52x + 2y <= 10s <= 5(an equivalent atom)2x + 2y <= 12s <= 6(a distinct, weaker upper bound)-x - y <= -5s >= 5(the opposite orientation)With early atom canonicalization enabled, the first two source bounds become the same hash-consed core atom before clausification. Without that pass, tableau sharing alone does not merge their SAT atoms. The
s <= 6atom cannot be discarded from the prepared problem, however: under arbitrary Boolean structure it may be assigned withouts <= 5, or assigned false and therefore asserts > 6. What is shared globally is the structural slack; each distinct predicate still has its own relation, scaled right-hand side, polarity, and explanation reason. In particular,s <= 5ands <= 6cannot be merged.The scale factor is divided out of the atom’s right-hand side and a negative factor reverses the relation, reusing H1a’s
asserts_lower_when_trueflag and its single interpretation point. Monniaux (CAV 2009, Section 2.1) is the basis for the early coprime-integer form. Prepared theory state retains the leading-coefficient-one form as a defensive internal representation. See Early LRA atom canonicalization for the ownership and invariants.Row counts fall further on top of H1a: ecoliFBA 1,394 -> 695, TM/p6 67 -> 38, sc-17 723 -> 433, uart-17 920 -> 511, clock_synchro 259 -> 179, tta_startup 500 -> 284, and DTP-Scheduling 3,932 -> 2,882 – the first time that family moves at all, since
x-yandy-xare proportional.Correctness-verified, performance verdict deferred. Merge and campaign gates on both pivoting paths (jobs 116545-116548): 7,000 fuzzsmt cases, 0 crashes, 0 wrong answers, plus clean opfuzz and yinyang runs. Local wall-clock is genuinely mixed (clock_synchro 3.1x faster, uart-18 1.5x, but DTP-Scheduling 0.74x slower despite 8.6x fewer decisions – merging proportional rows concentrates fill-in into fewer, denser rows), so its 237-file verdict comes from item H3’s four-way run, whose
blandcolumn measures exactly this item’s effect on the default.
Fill-in-aware pivot selection (heuristics-note item H3, 2026-07-28,
lra_tableau.{hpp,cpp}, the solver default since 2026-07-29):PivotingRule::Sparse, selected by--lra-pivoting-rule sparse. The exact definition follows; this is the authoritative description of what Primo calls Sparse.In plain terms, Sparse aims to limit fill-in (the creation of new nonzero tableau coefficients during pivoting). It first chooses a violated basic variable whose defining row has the fewest nonzero coefficients, breaking ties in favor of the largest bound violation. Among the nonbasic variables that can repair this violation, it then chooses one occurring in the fewest tableau rows, preferring a coefficient of \(+1\) or \(-1\) on ties. This favors pivots that substitute a short row into as few other rows as possible, following the sparsity-oriented heuristics described by Monniaux and King; after a finite number of heuristic pivots, Primo switches to Bland’s rule to guarantee termination. The citations and precise deviations from those sources are given below and in LRA Related Work.
Let the current tableau basis be \(B\), its nonbasic variables be \(N\), and write each basic row as
[ x_b = \sum_{j\in N} a_{bj}x_j. ]
Rowstores exactly the nonzero right-hand-side entries, so define[ r(b) = |{j\in N : a_{bj}\ne 0}| = \texttt{rows_[b].size()}, \qquad c(j) = |{b\in B : a_{bj}\ne 0}| = \texttt{occurs_in_[j].size()}. ]
Thus row length is a nonzero count, not the number of tableau variables and not a byte size. Column length is the number of current basic rows containing the variable; coefficient magnitudes are not stored in
occurs_in_.LraTableauowns both dense outer vectors, andpivot()maintains the cross-linked occurrence index as the basis changes.Before the Bland fallback engages, one Sparse repair step is:
Form the sorted set \(V\) of violated basic variables. For \(b\in V\), let \(d(b)\) be the exact
DeltaRationaldistance from its assignment to the violated bound.Under the shipped default leaving policy, choose the lexicographic minimum of \((r(b),-d(b),b)\): shortest row first, then largest violation, then smallest variable id.
--no-lra-sparse-leavinginstead chooses the lexicographic minimum of \((-d(b),b)\), the Dantzig-style policy. The separate experimental--lra-least-violated-leavingpolicy takes precedence when enabled and minimizes \((d(b),r(b),b)\), following Griggio’s MathSAT 4 description.An entry \(j\) in the chosen row is eligible exactly when \(x_j\) can move in the direction required to bring \(x_b\) to its violated bound without crossing \(x_j\)’s own bound. Concretely, when \(x_b\) is too low, \(a_{bj}>0\) requires \(x_j<u_j\) and \(a_{bj}<0\) requires \(x_j>l_j\); when \(x_b\) is too high, those directions reverse. A missing relevant bound makes the corresponding movement eligible.
Scan the chosen row cyclically from
sparse_pricing_cursor_. Among the first \(k\) eligible entries, choose the lexicographic minimum of \((c(j),\mathit{nonunit}(a_{bj}),j)\), wherenonunitis 0 for coefficient \(+1\) or \(-1\), and 1 otherwise. The option--lra-sparse-pricing-candidatessupplies \(k\), default 16; zero means all eligible entries. The cursor advances after a successful selection. If fewer than \(k\) entries are eligible, the entire row is inspected. In particular, the scan never reports that no repair exists without examining the entire row.Pivot \(j\) into the basis, pivot \(b\) out, and assign \(x_b\) its violated bound.
pivot()substitutes the leaving row into the rows listed by the entering column, so \(c(j)r(b)\) is Primo’s upper-level proxy for row-combination work. Classical Markowitz notation instead estimates possible fill with \((c(j)-1)(r(b)-1)\). For a fixed row with \(r(b)>1\), both rank entering variables by \(c(j)\); when \(r(b)=1\), the classical fill estimate is zero for every candidate and provides no ranking. Primo does not compute that classical product or minimize either product globally over every feasible \((b,j)\) pair: it chooses the leaving row first, then prices only a bounded prefix of its eligible entering variables. “Markowitz-style” therefore describes the cost intuition, not an implementation of global Markowitz pivot search.The minimum-column-length rule and finite Bland fallback are described in King’s thesis, Section 2.2.12; Monniaux (CAV 2009), p. 8, motivates sorting by row and occurrence counts. Primo takes only those sparsity criteria from those sources. The unit-coefficient tie-break, rotating bounded scan, exact lexicographic combination, and its defaults are Primo-specific engineering choices. See LRA Related Work.
After more than
--lra-bland-fallback-factor * variables + --lra-bland-fallback-offsetpivots in onecheck()call (defaults 4 and 64), selection remains Bland for the rest of that call. Consequently, bounded Sparse pricing changes only the heuristic prefix; the finite Bland suffix retains the termination argument.New
:lra.pivot-row-updates/:lra.pivot-term-opscount what the pivots cost, not just how many there were; trading fewer-but-costlier pivots for more-but-cheaper ones is this rule’s whole risk, so both are reported.Promoted to default 2026-07-29, together with the leaving-side half. The four-way 237-file run (job 117222), all four configurations on one binary against the unchanged z3/cvc5 of job 114200:
config
solved / 237
PAR2
wrong
pre-H1b/H3
136
2149.8s
0
bland (former default)
144
1962.4s
0
dantzig
148
1931.3s
0
sparse
154
1798.9s
0
sparse + leaving (new default)
168
1538.9s
0
z3
167
1531.6s
–
cvc5
150
1887.1s
–
+24 files over the former default with zero regressions (+32 over the pre-H1b/H3 run, also zero). Per family: latendresse 0 -> 6 (matching z3; cvc5 solves 0), uart 10 -> 13, clock_synchro 7 -> 12 (z3 9), 2019-ezsmt 7 -> 12 (z3 10), sc 11 -> 13, LassoRanker 1 -> 3 (z3 2).
The latendresse gain is entirely the leaving-side half, the knob that was gated separately because it is not progress-neutral. That objection is right for the Boolean-heavy families and simply does not apply to a pure-LP instance: one
check()call with no Boolean structure has no search trajectory to steer wrong. Entering-side alone reaches 154.Dantzig, finally measured on this bar after three roadmap cycles of only 17-file quick-evals: 148, four ahead of the Bland default it never replaced. Including it is what makes the sparse win attributable rather than confounded with the leaving-rule change the two share.
Cost counters explain the win rather than just recording it. Term-ops, bland -> new default: sc-21 20.4M -> 2.8M, clock_synchro 11.2M -> 1.2M, DTP-Scheduling 54.6M -> 11.0M. On
latendresse/ecoliFBAyicesTest-500the default finishes in 0.58s with 8.7M term-ops, where Dantzig needs 130.9M and ~19.5s and Bland does not finish.clock_synchro’s:rational.promotionsfalls 358,801 -> 1,994, the|coefficient| = 1tie-break keeping values inside H2’s machine-word fast path.Ablation:
--lra-pivoting-rule blandand--no-lra-sparse-leaving, both verified to change behaviour.Gated at both tiers on both pivoting paths. Merge: 117221 (bland, 499/500 PASS – confirming the shared-loop refactor left the then-default alone), 117219 (sparse, 499/1), 117220 (sparse+leaving, 496/4). Campaign: 117227 (new default, fuzzsmt 3000: 2990 PASS/10 TIMEOUT) and 117228 (SOI, 3000: 2908/92), each with opfuzz 14/14 seeds valid and two yinyang shards, 0 bug triggers throughout. No crashes, no wrong answers anywhere.
Delta-maintained
occurs_in_(heuristics-note item H10, 2026-08-01,lra_tableau.{hpp,cpp}, unconditional):pivot()no longer removes and re-adds every affected row’s occurrence entries.occurs_in_records only which rows mention a variable, never a coefficient, so a term carried through a pivot keeps the entry it had; only cancellations remove and only genuinely new terms add. Callgrind had put the round trip at ~91% of instructions – 40.1Mstd::removecalls scanning 37.6 billion elements onscrambled228965.Scrambled competition set 398 -> 439 solved (PAR2 26723 -> 22207), stratified 197 -> 202, churn +41/-0 and +5/-0 – zero regressions on either bar. Median paired 1.51x. That is 439 against z3’s 429 on the same files. 1.15-4.85x locally on four mid-band instances, answers identical.
pivot’s gprof self time falls 10.04s -> 1.26s at an identical call count, and the profile becomes arithmetic-dominated rather than bookkeeping-dominated.Guarded by
occurs_in_index_matches_full_rebuild(), checked after every pivot across all four pivoting rules – a wrong index does not crash, it makesupdate()skip a dependent row and yields a wrong answer later.
Bounds-only backtracking (heuristics-note item H9, 2026-07-31,
lra_tableau.{hpp,cpp}, unconditional except under SOI):pop()restores bounds and nothing else. It used to restore bounds, assignments and every pivot’s row rewrite. This is the paper’s own scheme – technical report SRI-CSL-06-01 Section 3.2.4, “since no pivoting or other expensive operation is used, backtracking is very cheap” – and Primo had diverged from it on a justification that turned out circular: reverting the assignments is what forced reverting the pivots. Sound because the assignment satisfiesAx = 0independently of the basis, andpop()only ever relaxes bounds.PivotingRule::Soikeeps the old scheme; its objective row is a sum over the currently-violated rows, not a linear combination ofA. The argument is onLraTableau’s class comment; the line-by-line comparison that found it is in A Fast Linear-Arithmetic Solver for DPLL(T).The largest single gain measured on this project. Scrambled competition set (595 files, 60s): 378 -> 399 solved, PAR2 28691 -> 26661. Stratified sample (237, 60s): 192 -> 197, PAR2 6069 -> 5575. Zero cross-configuration disagreements, zero wrong answers.
The mechanism is that not undoing pivots lets the search keep the repair work it already paid for: pivot counts fall 2.3-5.3x on the profiled instances, and
rebuild_occurs_in_indexleaves the profile entirely because nothing structural changes on a pop.Unlike H8 it is not regression-free, and that is the part to read. Churn is +32/-11 on the 595 set and +7/-2 on the stratified sample, and among the 145 commonly-solved files over a second, 62 are more than 10% slower against 64 more than 10% faster – median 1.01x, p25 0.63, p75 1.30, tails 0.10x to 24.9x. It nets +21 because the wins land near the timeout: 10 of the 11 lost files already took 24-60s of a 60s budget. The variance is inherent – keeping the basis across a backtrack keeps one that suited the previous subproblem.
Gated campaign-tier on the default path (3000 fuzzsmt QF_LRA cases plus opfuzz and two yinyang shards, 0 wrong) and merge-tier on SOI (2000, 0 wrong), plus local ASAN across five configurations.
Merged row combination in
pivot(heuristics-note item H8, 2026-07-31,lra_tableau.{hpp,cpp}, unconditional):pivot’s affected-row loop combines the affected row with the scaled entering row by a linear two-way merge instead of concatenating both into a scratch buffer and sorting it. This rests on a newRowinvariant – strictly ascending by var id, never a zero coefficient – whichadd_rownow establishes by sorting andpivotpreserves by splicingbasic_varintonew_entering_rowat its ordered position. The affected row is also taken by reference and moved into the trail rather than copied twice.Found by a flat profile rather than by design argument: a gprof run put 11.6% of runtime on
scrambled339450in that onestd::sort, with 100M inner comparisons. See QF_LRA Speedup Heuristics: Design Note section 6.2a.Promoted on every bar, with zero regressions on any of them. Scrambled competition set (595 files, 60s): 369 -> 378 solved, PAR2 29561 -> 28654, median paired 1.19x, +9/-0 – five of the nine in the 500KB-5MB gap band. Mid-band gap (59 files, 300s): 37 -> 38, PAR2 19475 -> 18309, median 1.16x, +1/-0. Stratified sample (237 files, 60s): 189 -> 192, PAR2 6473 -> 6067, median 1.23x, +3/-0. All three same-node paired against the parent commit; 0 cross-configuration disagreements and 0 wrong answers throughout.
Calibration, so this is not over-read: the payoff curve in QF_LRA Speedup Heuristics: Design Note section 6.1a says the gap band needs 2x for 15 files. H8 is 1.19x and buys 5 there – on the curve, not above it.
Gated: campaign-tier QF_LRA fuzz on the default path (3000 fuzzsmt cases, 0 wrong) plus merge-tier on the SOI path (1000, 0 wrong).
Sanitizer coverage is local, not from the cluster. The remote ASAN toolchain is still broken, so H8 was covered with a local
build-asaninstead:make asan-test(12/12), 45 real instances and 48 runs across four configurations with zero sanitizer reports and zero answer differences against Release, and 250 fuzzsmt cases against z3 (247 PASS, 3 timeouts at 20s under Debug+ASAN, 0 wrong). That is smoke-to-merge tier, not campaign tier – but it matters here specifically because H8 rewrites the row loop and moves rows into the trail, which is where this class previously had a real heap-use-after-free.
Cross-linked
occurs_in_index (open-leads lead 3b, 2026-08-06,lra_tableau.{hpp,cpp}, unconditional): each row entry carries a back-index into the column list it appears in, sopivot()removes a dead occurrence without searching for something it is already holding.Representation. See Tableau sparse-row and occurrence representation for the authoritative row/column invariant and its maintenance rules. The important optimization here is that removal uses the row entry’s back-index to swap-pop the column in O(1), then repairs the one moved entry with a
lower_boundover its sorted row in O(log row).What it replaced: a linear scan of
occurs_in_[var]. Measured at 12.0M removals scanning 8.17B elements onconstraint-199552, where occurrence lists average 679.This is half a cross-linked sparse matrix, deliberately. Rows point into columns; columns do not point back into rows. King’s thesis §2.2.7 specifies both directions via doubly linked lists, which would make the repair step O(1) – but his rows are unsorted, so adopting his form wholesale means giving up the two-way merge and item H8. See Paper summaries for the source and open-leads for the fork.
Correctness gate: QF_LRA model fuzz gate on Slurm (job 119566, 9-task array) – 0 wrong answers, 0 invalid models.
occurs_in_index_matches_full_rebuild()checks that every back-index round-trips (occurs_in_[var][entry.occ_slot] == basic_var), which is what makes the scheme falsifiable in a debug build.Evidence, mixed and worth reading before relying on it. Stratified sample (237 files, 10s, job 119555): neutral – gained 0, lost 0, and the PAR2 gain of 8.00 is below the 12.22 run-to-run noise floor measured from an identical baseline binary. Tropical-matrix (10 files, 1200s, job 119577): 1/10 -> 2/10 solved, PAR2 21611 -> 19998, gained 1 / lost 0, with
constraint-199552at 10.65s -> 6.80s.It cannot change the search, which is unusual and is what makes the above interpretable: on 25 solved stratified files the counters are bit-identical to the parent commit (
:lra.pivots,:lra.pivot-term-ops,:sat.decisions,:sat.conflicts). Every per-file time difference is therefore pure constant factor.Known regression, with a known cause. Long-row instances lose, the mirror of the long-column instances that win: the O(log row) repair step replaces a short column scan, and
RowEntrygrew 32 -> 40 bytes. Worst measured isecoliFBAyicesTest-500at 1.58x (rows there reach 691 terms); on files the baseline takes over a second, no regression exceeds 1.10x. Two open follow-ups: narrowLraVarIdto 32 bits (restores 32 bytes, and separates the two causes), and store the row slot inoccurs_in_too, which makes the repair O(1) and removes the mechanism.
Fixed-variable elimination (heuristics-note item H7, 2026-07-30,
lra_tableau.{hpp,cpp}, opt-in,--lra-fixed-elimination): a basic variable whose lower and upper bounds coincide is pivoted out of the basis as soon as it becomes fixed.can_enter()already rejects a fixed variable on every branch, so a fixed non-basic variable is completely inert; only a fixed basic one churns, demanding a repair pivot whenever any term in its row moves. The entering candidate is preferred unbounded (an unbounded basic variable’s row can never be violated), then by Markowitz occurrence count, then by a unit coefficient – and never one that is itself already fixed, which the other criteria actively steer into.Not promoted, and the reason is worth reading. Per-instance the mechanism is decisive:
latendresse/ecoliFBAyicesTest-5000.8s -> 0.1s with:lra.pivot-term-ops8.66M -> 0.13M,scrambled33532651.0s -> 14.9s. On the 595-file scrambled bar it is +1 file (373 -> 374), PAR2 flat, median 0.94x over commonly-solved files.It is bimodal. The entire 56-file deficit against z3 is the 500KB-5MB band (45 against 101), and H7 nets exactly zero there: +8/-8, with a median 0.64x over the 32 files both configurations solve – the typical gap instance gets 1.6x slower. The one real gain is >5MB, 7 -> 13 of 50 (+8/-2), closing that band’s deficit against z3’s 14 to one file.
No size threshold was added to keep the large-instance win and dodge the mid-range cost: that is tuning a heuristic that lost its own bar.
An earlier version also froze qualifying rows out of
occurs_in_. That half was strictly negative and carried all of the item’s wrong-answer risk, so it was deleted rather than kept behind a flag. What remains has no wrong-answer mode – it is a pivot, which is whatcheck()already does on every repair.New
:lra.fixed-variables/:lra.elimination-pivots. Note that:lra.pivotsis NOT comparable across this ablation: it is sampled as a delta aroundcheck(), while elimination pivots happen insideassert_upper/assert_lower. Use:lra.pivot-row-updates/:lra.pivot-term-ops.Ablation: default off;
--lra-fixed-eliminationverified to change behaviour, and the default build reproduces pre-H7 counters exactly.
Disequality: SAT-visible
Real-sorted=is lowered to(t1 <= t2) AND (t2 <= t1)(since Real is a total order), not handled as a special relation in the tableau at all.NOT(t1=t2)is then justNOT(AND(Le,Le)), already correctly handled byBooleanClausifier’s existing Tseitin/CNF machinery for arbitrary Boolean structure – ordinary CDCL branching over the two underlyingLeatoms explorest1<t2vst1>t2for free. No case-splitting or lemma-learning code exists anywhere inLraTableau/OnlineLraPropagator.LraAtom’sEqrelation is unreachable through the ordinary frontend-to-LRA path, but the mixed final check uses it for combination-internal interface equalities already assigned true; falseEqremains unsupported. The lowering itself (lower_real_equalitiesinpreprocessing.cpp) runs once, as the last step of the preprocessing pipeline, immediately before thehas_equality/has_arithmetictheory dispatch – not at parse time as originally implemented (changed 2026-07-25) – so solve-eqs and the other Equality-aware preprocessing passes see genuine RealEqualitynodes first. Seedocs/preprocessing.md’s “Real-Equality Lowering” section.
Implemented subset vs. deviations vs. limitations#
QF_LIA, stages 1-3.
(set-logic QF_LIA)is supported: anIntsort, linear atoms (+ - *(literal coefficient)< <= > >= = distinct), a plain branch-and-bound decision procedure layered entirely on top of the existingLraTableau, a read-only GCD-consistency pre-check, and a Gomory-cut pre-check (King’s thesis’s mixed-integer cut, eq. 4.9) that adds rows to a private per-round tableau copy viaLraTableau’s existing public API – no change toLraTableauorOnlineLraPropagatorthemselves in any stage. Nodiv/mod, and no mixingIntwithRealin one problem (that would be QF_LIRA). The three stages together still have no completeness argument of their own – some integer-infeasible systems can branch forever regardless (a concrete example is documented and tested) – so the search is capped by--lia-branch-limit/--lia-gomory-cut-limitand reportsunknownif a cap is hit rather than looping. Seedocs/qf-lia-branch-and-bound.mdfor the full design.Real-only Real/UF combination. No
Intcounterpart to the QF_UFLRA coordinator below: QF_LIA cannot currently be combined with EUF or mixed withReal. A partial QF_UFLRA coordinator exists for separable formulas and for arithmetic atoms containing Real-valued UF applications. The SMT layer purifies each such application to an internal Real proxy, first replacing any non-constant Real arguments by their own LRA-defined proxies so the EUF-side definition remains a flat UF application. The purified arithmetic atom is sent to LRA, and EUF-implied proxy equalities are checked against LRA under the current SAT assignment. EUF atoms with non-constant Real arguments are purified in the other direction: the EUF atom receives an internal Real proxy, while LRA receives the two defining boundsproxy <= termandterm <= proxy. The reverse interface direction is checked for shared Real constants, including those internal Real-argument proxies: if LRA makes bothx < yandy < xinconsistent under the current assignment, the coordinator assertsx = yinto an augmented EUF check. Mixedget-modelreconstructs concrete Real values for both Real arguments and Real-valued UF application results: reconstruction augments the LRA model with EUF-implied equalities and strict separations for EUF-distinct Real constants before printing concrete Real values in UF table guards and results. This is not full Nelson-Oppen combination: fine-grained cross-theory explanations, full model-equality propagation, native false-EqLRA atoms, and unbounded arrangement enumeration are still not implemented. The optional--model-equality-branchingscaffold creates default-off equality assumptions for LRA-model-equal shared Real pairs, but those guesses are not a complete arrangement-enumeration procedure.~~Disequality (negated
=) is rejected~~ – fixed 2026-07-24, see above. Was the dominant cause ofUnsupportedInputin every fuzz campaign (394-418/500 cases); after the fix, zeroUnsupportedInputin the same 500-case campaign.*is more permissive than strict QF_LRA syntax: the non-literal operand may be a compound linear term ((* 2 (+ x y))), not only a bare variable – harmless (still linear) and simpler to implement than enforcing the stricter grammar.~~No pivoting-rule choice yet~~ – added 2026-07-24 (Milestone 4a):
LraTableau::PivotingRule::Dantzig, opt-in, default stayed Bland at the time (superseded 2026-07-29 bysparse; Dantzig itself was finally measured on the 237-file bar in job 117222, scoring 148 against Bland’s 144). Devex and Steepest-Edge (docs/papers/lra-related-work.md’s survey) remain unimplemented – Dantzig alone already closes most of the practical gap per the cited comparative benchmark (fastest per-instance wall time), and adding either of the other two is a bigger, separate undertaking.~~Polynomial dedup is exact-match only, not scale-invariant:
x<=5and2x<=10still get separate slacks~~ – fixed 2026-07-28. Item H1a made both single-variable forms direct bounds onx, with no slack at all; item H1b made compoundPolynomialKeys scale-canonical, sox+y<=5and2x+2y<=10share the one slack forx+y. The early primitive-integer atom pass, introduced on 2026-08-17 and now default-off, also maps equivalent source predicates to one hash-consed SAT atom when enabled;PreparedLraTheoryretains scale-canonical keys as a defensive boundary. See Early LRA atom canonicalization.Propagation only relates atoms sharing the exact same slack, not Dutertre & de Moura’s stronger row-based/interval bound propagation across different variables via a basic row’s children. Also deliberately out of scope for Milestone 3b (a materially bigger technique), not a bug.
Doesn’t reuse
--pure-theory-filter: that optimization’s soundness was established for EUF specifically; reusing it for LRA without independent verification was judged too risky for a first milestone.~~A pre-existing, theory-agnostic robustness gap: deeply-nested S-expressions can stack-overflow
smtlib::SExpr’s recursive copy constructor~~ – fixed 2026-07-24, theory-agnostic (not an arithmetic-specific change):SExprparsing/copying/destruction andFrontend::build_term’s term-building (both shared by QF_UF and QF_LRA) are now iterative, not recursive descent. Seedocs/papers/fast-linear-arithmetic-dpllt.md’s note and theparser.cpp/ast.cpp/check_sat.cpp(TermBuilder) class comments.
Milestone roadmap#
See /home/user/.claude/plans/piped-wondering-fairy.md for the full plan.
Milestones 1-4 (above) are all done, plus disequality support and the
recursion-removal pass – QF_LRA is supported, not experimental.
QF_LRA options and statistics reference#
Every QF_LRA option, its default, and where its evidence lives. Verified
against CheckSatOptions and --help on 2026-08-06 by diffing the table
against --help output rather than by eye – five options were missing.
The automatic policy at f0fbe67 was qualified and accepted on 2026-10-10; see
QF_LRA Speedup Heuristics: Design Note. Model phase and monotone elimination resolve to on
only for declared QF_LRA queries in rebuild mode with
preprocessing-mode=auto. Other declared logics, explicit preprocessing profiles,
and assumptions sessions retain their previous defaults.
This changes option selection, not either algorithm.
CheckSatOptions owns two std::optional<bool> policies: unset requests the
automatic profile, true explicitly enables a mechanism, and false disables
it. prepare_and_execute_preprocessing_strategy resolves the query’s owned
options copy from Frontend::logic() before normalization can remove the last
Real atom. The session’s original policies survive later queries and reset.
Manual strategy construction and low-level engines without declared-logic
resolution treat unset policies as off. Assumptions engines likewise retain
their safe automatic profile; explicit assertion-dependent elimination is
still rejected. No term or model ownership changes.
Use --no-lra-model-phase --no-monotone-elimination to reproduce the former
QF_LRA default. Each opt-out is independent; positive flags still explicitly
enable the corresponding mechanism outside its automatic scope. False-valued
positive flags also disable it. If both enabling and disabling flags are given,
the disabling flag takes precedence. The CLI reports the automatic default;
unit and command-line tests cover activation, explicit opt-outs, declared-logic
scope, model reconstruction, reset, and assumptions-session safety.
option |
default |
what it does |
|---|---|---|
|
|
Check/Pivot variable selection. |
|
leaving rule on |
Ablates the leaving-side half of |
|
off |
MathSAT’s leaving rule (Griggio thesis 2.5.3 p.53): least-violated first, shorter row as tie-break. Only under |
|
|
Which formulas reach the mixed EUF/LRA solver. |
|
auto: QF_LRA rebuild, auto preprocessing |
One-sided variable elimination with model reconstruction. Historical measurements are in the heuristics note; current qualification is recorded separately. |
|
opt-out off |
Disable monotone elimination explicitly. |
|
propagation on |
Ablates same-slack propagation (Dutertre & de Moura 4.2). |
|
off |
Row/interval implied bounds (roadmap item 2). |
|
off |
Reverse direction: tighten a row term from the row’s own bound (item 7). Requires |
|
off |
Theory-supplied decision literals via |
|
auto: QF_LRA rebuild, auto preprocessing |
H5 beta-based phase hints for arithmetic atoms; keeps VSIDS variable choice. |
|
opt-out off |
Disable model-based phase hints explicitly. |
|
off |
Item H7 (LP presolve’s fixed-variable removal, Andersen & Andersen 1995). Opt-in; see its falsifier outcome. |
|
off |
Opt-in guarded equality distribution inside Real-equality lowering; useful local tropical result but mixed 10-file evidence. |
|
64 |
Longest row row-propagation will derive a bound for. |
|
64 |
Most rows one bound change may scan. |
|
32 |
Reverse direction’s row-length cap. |
|
32 |
Reverse direction’s fanout cap. |
|
16 |
How many eligible entering candidates |
|
4 |
Per-variable coefficient of |
|
64 |
Constant term of the same threshold. |
The three Check/Pivot knobs were hardcoded constants until 2026-08-06 and
became options for the same reason the propagation caps did: so a tuning
campaign can vary them per run rather than per build. None can change an
answer, only how long it takes to reach one, and
tuning_knobs_never_change_a_verdict pins that. Full rationale and the
measured response surface are in Numerical parameters: what exists, what is hardcoded, what to add; the short version
is that --lra-sparse-pricing-candidates has a >100x range in pivot term ops
on a single file but a spiky, non-monotone profile, so it wants per-family
or portfolio selection rather than a new global default.
Related fix: :lra.bland-fallbacks incremented only under PivotingRule:: Dantzig, so it read 0 under sparse – the default – whether or not the
fallback fired. With that corrected the threshold turns out to bind on real
input (counts of 1-12 across the Heizmann files), which means some pivoting
attributed to sparse is actually running under Bland.
The four propagation caps bound the per-bound-change cost of the two
row-propagation directions. Their defaults are documented in the source as
“not precisely derived”, and on the mid-band gap instances they gate the
bidirectional path off almost entirely: occurs_in reaches 521 and rows
reach 91 terms there, so it fires 11-68 times against a 7k-190k opportunity
count. They were compile-time constants until 2026-07-31 and became options
so that direction D1’s sweep could vary them per run rather than per build.
That sweep came back negative (job 117726, 59 mid-band files at 300s): raising all four to 128, 256 or 1024 turns propagation on by two to three orders of magnitude and still does not beat the baseline’s solved count, so the defaults stand. The options remain because they cost nothing to keep and are what made the question answerable. Full outcome in QF_LRA Speedup Heuristics: Design Note‘s D1.
The reverse direction’s two caps default smaller than the forward pair because it runs an O(row size) child-bound computation for each other term of each affected row, not once per row.
Statistics. All are cumulative per solve.
statistic |
meaning |
|---|---|
|
full theory checks run (non-incremental path) |
|
theory conflicts reported to the SAT solver |
|
literals propagated by the theory |
|
decisions supplied via |
|
H5 phase-refresh scans and changed hints sent to CaDiCaL |
|
|
|
repair pivots inside |
|
rows rewritten by pivots (Markowitz column factor) |
|
terms merged while rewriting them (row factor) |
|
|
|
prepared tableau shape |
|
propagations by direction |
|
SOI-only |
|
item H7 |
|
item H2’s small-to-GMP transitions |
Caveat on :lra.pivots: it is sampled as a delta around check(), so
item H7’s elimination pivots – which happen inside
assert_upper/assert_lower – are not counted in it. Comparing
:lra.pivots across an H7 ablation is therefore misleading; use
:lra.pivot-row-updates / :lra.pivot-term-ops, which count every pivot’s
work wherever it was triggered.
Model construction for Boolean atoms in QF_LRA (fixed 2026-08-17)#
build_qf_lra_model (check_sat.cpp) previously dropped every
non-arithmetic (plain Boolean) entry of assigned_atoms – the SAT search’s
own decided values – before printing the model. Only Booleans resolved
during preprocessing (preprocessing_model_assignments) ever reached the
printed model; a Boolean atom the main SAT search alone decided defaulted
silently to false in model_builder.cpp’s congruence closure, regardless
of what was actually assigned. The search itself was sound throughout –
confirmed with runtime instrumentation showing the correct internal
assignment (e.g. x=true, q=true) discarded before printing – this was
purely a model-printing defect.
Trigger: any sat QF_LRA problem containing a Real-sorted declaration
(used or not, anywhere in the problem) plus a Boolean atom the search
decides rather than preprocessing. Minimal repro:
(declare-fun v () Real) (declare-const x Bool) (declare-fun q () Bool) (assert (or x q)). Fixed by folding assigned_atoms’s non-arithmetic
entries into the assignments passed to build_composed_uf_lra_model,
mirroring how the mixed QF_UFLRA path already folds in euf_assignments.
See QF_LRA fuzz coverage for why the existing QF_LRA fuzzing
infrastructure never exercised this shape, and
tests/unit/qf_lra_solver_tests.cpp’s
prints_boolean_atoms_decided_by_search_in_qf_lra_model and the
strengthened monotone_elimination_rejects_atom_shared_across_polarities
for the regression tests.
Where QF_LRA work stands, and what is open (2026-08-07)#
The complete non-incremental QF_LRA division, 1,753 files, 300s
(primo re-run 2026-08-26 at 16d0d96, SLURM job 130137; references not
re-run – z3 and cvc5 from 118382, Yices from 118904, OpenSMT from 119045,
and their PAR2 figures below are byte-identical to the 2026-08-07 reading,
which is the check that the two primo runs are comparable):
solver |
solved |
PAR2 |
wrong |
|---|---|---|---|
OpenSMT 2.9.2 |
1670 (95.3%) |
68068 |
0 |
primo |
1656 (94.5%) |
80616 |
0 |
Yices 2.7.0 |
1645 (93.8%) |
80585 |
0 |
z3 5.0.0 |
1584 (90.4%) |
128079 |
0 |
cvc5 1.3.4 |
1511 (86.2%) |
181248 |
0 |
The 2026-08-26 re-runs change nothing here. primo scores 1656 against job 120784’s 1657 – one file, inside the timeout band – and holds second place, 14 behind OpenSMT and 11 ahead of Yices. That is the expected outcome rather than a disappointment: everything merged since 120784 is QF_LIA machinery that QF_LRA never enters, plus a word-GCD change measured flat on this fragment. The run exists to confirm that, and it does. PAR2 moved 79646 -> 81218 (2%), which is run-to-run variation at a 300s timeout, not a regression anyone should chase.
primo moved from third to second on 2026-08-07, overtaking Yices on both solved count and PAR2. The previous figure was 1631; the change is the three Nelson-Oppen regression fixes plus the merged QF_LRA arithmetic work, measured in The Nelson-Oppen QF_LRA regression: evidence, mechanisms, and how to fix it safely. Against the pre-regression commit it gained 26 files and lost none.
Fig. 2 Instances solved against the per-instance time budget, over the 654
instances that at least one solver did not finish within 10s. The 1,099
instances every solver finished quickly are omitted: each contributed one
point to all five curves, so removing them subtracts the same constant from
every series and leaves every gap between curves intact, while the vertical
range stops being dominated by the shared easy majority. The counts in the
legend are therefore of 656, not of 1,753 – the table above has the full
numbers. Regenerate with --drop-solved-by-all-under 10; omit that flag for
the unfiltered version. Legend entries carry each solver’s sat/unsat split
below the total. primo is job 130137 at 16d0d96 (2026-08-26); the
references are jobs 119045, 118904 and 118382, not re-run.#
The plot shows two crossovers the table cannot. Yices is the fastest solver at every budget up to 10s and OpenSMT is the strongest by 300s; they trade places between 10s and 30s. primo is slowest of the three at short budgets and overtakes Yices only between 60s and 300s:
solved within |
0.1s |
1s |
10s |
30s |
60s |
300s |
|---|---|---|---|---|---|---|
OpenSMT |
868 |
1103 |
1370 |
1527 |
1595 |
1670 |
Yices |
1003 |
1173 |
1428 |
1525 |
1575 |
1645 |
primo |
800 |
1066 |
1303 |
1463 |
1537 |
1656 |
z3 |
861 |
1028 |
1227 |
1346 |
1430 |
1584 |
cvc5 |
711 |
945 |
1118 |
1209 |
1296 |
1511 |
(primo’s row is the 2026-08-26 re-run; the reference rows are unchanged data. Job 120784’s primo read 768 / 1063 / 1305 / 1470 / 1543 / 1657 – the same shape, within a handful of files at every budget.)
At a 10s budget Yices leads this division by 127 files over primo, OpenSMT is second and primo is third. Which solver is “strongest” is a function of the timeout, which is worth remembering whenever the 300s standing is quoted – primo’s second place exists at 300s and nowhere below 60s.
Worth noting against the previous run: primo improved at every budget from
10s up (1286 -> 1305, 1440 -> 1470, 1515 -> 1543, 1631 -> 1657) and
regressed at both short budgets – 0.1s from 832 to 768, and 1s slightly, 1066
-> 1063. The fixes removed per-backtrack
and per-dispatch overhead that dominates long searches; on instances that
finish in under a tenth of a second there is nothing to recover and the
cross-link change’s larger RowEntry is the visible term.
OpenSMT still leads at 300s; primo is second, 14 behind it and 11 ahead of Yices (13 and 12 at the 2026-08-07 reading). Two separate lessons sit in that sentence, and they are worth keeping apart.
The first is about the reference set. It was built incrementally – z3 and cvc5 first, Yices on 2026-08-03, OpenSMT the same day – and primo’s apparent standing fell at each addition: ahead by 47, then second, then third, with its own score unchanged throughout. That is the honest reading of a three-reference comparison extended to five, and it is why “primo leads QF_LRA” was never a safe claim to make from three. It is also why the recovery to second on 2026-08-07 is a real result and the earlier slide was not a real regression: this time the score moved, 1631 -> 1657.
One family carries most of OpenSMT’s remaining margin. LassoRanker, the
division’s largest at 419, is where the gap lives; tropical-matrix is the
sharpest single result on the board, OpenSMT 5 of 10 where every other solver
manages 1 or 0. Primo takes 2019-ezsmt, latendresse and tta_startup, and
eleven of seventeen families tie exactly, ten of them saturated.
Warning
The per-family counts previously given here (LassoRanker 385 to 347,
2017-Heizmann 48 to 41, 2019-ezsmt 103 to 95) were measured when primo
scored 1631 and are superseded. The run-log entry
2026-08-07-qf-lra-full-division-recovery-measured records the deltas that
supersede them – LassoRanker +25 and 2017-Heizmann +1 against that
same 1631 baseline, with nothing lost – so primo’s current LassoRanker figure
is 372 rather than 347. That is derived from two recorded numbers, not
measured directly, which is why it is flagged here instead of being written
into the prose above: a fresh per-family breakdown of job 120784 has not been
computed.
They are still complementary, not ranked. 29 instances Primo solves and OpenSMT does not, against 68 the other way; 23 fall only to OpenSMT, 8 only to Primo, 8 only to Yices, 1 only to z3, none only to cvc5, and 40 defeat all five. A portfolio of Primo and OpenSMT would solve more than either.
primo scores 1631 at b6b8d55 against 1631 at aa44320, PAR2 95091 against
94992: independent confirmation that the model-production merge and the
frontend changes of 2026-08-03 are behaviour-neutral here.
Zero wrong answers and zero cross-solver disagreements. Every file in
this set carries (set-info :status), so unlike the scrambled set the
harness’s own wrong-answer column is live: 1,629 of the 1,753 carry a
checkable status, all five solvers agree with it on every file they answer,
and no file is unsolved for a non-standard reason. This is the strongest
correctness evidence QF_LRA has, and adding a fifth independent
implementation strengthened it rather than finding a crack.
77 files only Primo solves against 30 only z3 solves; against cvc5, 130 to 10.
Per family, every solver, sorted by size:
family |
n |
opensmt |
yices |
primo |
z3 |
cvc5 |
|---|---|---|---|---|---|---|
meti-tarski |
488 |
488 |
488 |
488 |
488 |
488 |
LassoRanker |
419 |
385 |
381 |
347 |
331 |
266 |
sc |
144 |
144 |
144 |
144 |
144 |
144 |
sal |
107 |
107 |
107 |
107 |
107 |
107 |
2019-ezsmt |
105 |
95 |
99 |
103 |
100 |
99 |
DTP-Scheduling |
91 |
91 |
91 |
91 |
91 |
91 |
uart |
73 |
73 |
73 |
73 |
73 |
73 |
tta_startup |
72 |
72 |
70 |
72 |
68 |
70 |
2017-Heizmann-UltimateInvariantSynthesis |
58 |
48 |
25 |
41 |
22 |
26 |
miplib |
42 |
25 |
27 |
23 |
25 |
21 |
spider_benchmarks |
42 |
42 |
42 |
42 |
42 |
42 |
clock_synchro |
36 |
36 |
36 |
36 |
36 |
36 |
TM |
25 |
25 |
25 |
25 |
25 |
25 |
keymaera |
21 |
21 |
21 |
21 |
21 |
21 |
latendresse |
18 |
11 |
13 |
15 |
8 |
0 |
tropical-matrix |
10 |
5 |
1 |
1 |
1 |
0 |
check |
2 |
2 |
2 |
2 |
2 |
2 |
Ten families are saturated – every solver takes all of them – so the
division is decided by seven. miplib is the only family where z3 beats
Primo, 25 to 23, consistent with King, Barrett & Tinelli finding that
CVC4’s LP/MIP integration paid off on exactly that family and essentially
nowhere else in QF_LRA.
latendresse is worth noting: 15 of 18, ahead of every reference
including OpenSMT’s 11 and cvc5’s 0. It was 0/15 before H3, and it is
the family the SOI paper was written for.
tropical-matrix is the open question OpenSMT raises. 10 instances, on
which the other four solvers score 1, 1, 1 and 0 – a pattern that read like
an intrinsically hard family. OpenSMT’s 5 says it is not.
Whatever it does there is worth understanding, and it is small enough to
profile.
The 595-file scrambled subset is not representative. It gave a +8 lead; the division gives +47. The scrambled sample under-weights precisely the families Primo is strongest on – Heizmann, LassoRanker and latendresse alone account for +42 of the +47. Comparisons quoted from the scrambled set should be read as a hard sample rather than as a division result.
What this is not. Not a competition result. SMT-COMP uses a 1200s
timeout, competition-entrant binaries and its own scoring; these are
contrib builds at 300s, and no official SMT-COMP logs are available here.
Nothing about placement follows.
How it got here. On the scrambled subset, 369 solved at 60s on
2026-07-31 to 439, via H8 (merge instead of
sort, +9/-0), H9 (bounds-only backtracking,
+32/-11) and H10 (delta-maintained
occurs_in_, +41/-0). Five other directions were measured and rejected in
the same period.
The 595-file scrambled subset at 300s, for comparison#
Comparison at 300s, all 595 scrambled competition files (job 118310, plus OpenSMT from job 119085; z3 and cvc5 were re-run for 118310 because the timeout differs from every stored result, and primo/z3/cvc5 were not re-run for OpenSMT’s addition):
solver |
solved |
PAR2 |
|---|---|---|
OpenSMT 2.9.2 |
552 (92.8%) |
33907 |
primo |
515 (86.6%) |
61303 |
z3 5.0.0 |
507 (85.2%) |
66718 |
cvc5 1.3.4 |
465 (78.2%) |
96616 |
Zero cross-solver disagreements over 595 files and four solvers – the
strongest soundness signal this set can give, since it carries no
:status.
Primo and z3 are complementary rather than one dominating: 30 files only Primo solves, 22 only z3 solves. Against cvc5 it is 59 to 9. Against OpenSMT the gap is real rather than complementary: 11 files only Primo solves against 48 only OpenSMT solves, and 29 of the 595 fall to OpenSMT alone.
The band this campaign was about is narrowed against z3, and not against OpenSMT:
file size |
n |
opensmt |
primo |
z3 |
cvc5 |
|---|---|---|---|---|---|
0-50 KB |
179 |
179 |
179 |
179 |
179 |
50-500 KB |
192 |
178 |
173 |
157 |
154 |
500 KB-5 MB |
174 |
166 |
134 |
141 |
118 |
>5 MB |
50 |
29 |
29 |
30 |
14 |
The 500KB-5MB band was -56 against z3 on 2026-07-29 and is -7 now. Against OpenSMT the same band is -32, on a set where Primo and z3 are within seven files of each other overall. z3 was flattering as a yardstick here. The campaign’s diagnosis was not wrong – that band is where the work was needed, and 56 files of it were recovered – but “most of the deficit removed” was measured against the wrong reference, and the honest statement is that a third of the original gap remains against the best solver now measured. The >5MB band ties at 29-30 across all three, so nothing in that band is specific to Primo.
What this is not. Not a competition result. SMT-COMP uses a 1200s
timeout, competition-entrant binaries and its own scoring; these are
contrib builds on one 595-file scrambled set, and no official SMT-COMP
logs are available here. It says Primo is competitive with z3 and behind
OpenSMT on this set at this budget, nothing about placement.
How it got here. 369 solved at 60s on 2026-07-31 to 439 at 60s and 515
at 300s, via H8 (merge instead of sort, +9/-0),
H9 (bounds-only backtracking, +32/-11) and
H10 (delta-maintained occurs_in_, +41/-0).
Five other directions were measured and rejected in the same period.
It is a constant factor, and a small one. Re-run at 600s (job 117631), Primo solves 45 of those 59, median 167s. Against a 60s budget that is a median 2.8x. The payoff is near-linear, with no threshold to clear:
speedup |
files recovered at 60s (of 59) |
|---|---|
2x |
15 |
3x |
24 |
5x |
37 |
10x |
45 |
The cost is fill-in. Rows holding 4.1-5.8 nonzeros when prepared merge 21.7-91.1 terms during search, while entering columns reach up to 521 rows, so a single pivot can cost ~47,000 rational operations.
The open list is QF_LRA Speedup Heuristics: Design Note‘s section 6.5, and after 2026-07-31 it is short: D2 floating-point guided basis selection, and D4 revised simplex with an LU-factored basis. Periodic refactorization was considered and rejected – for an explicit tableau the density is a property of the basis reached, so there is nothing for it to discard.
Ruled out already, so as not to be re-proposed: the sparse-pricing candidate
cap (flat across a 60,000x range), fixed-equality slacks (item H7, ships
opt-in, nets zero on this band), preprocessing size (tableau dimensions
match z3’s within ~5%), and D1/D3, bound propagation across offset
equalities – swept over the whole band on 2026-07-31 and rejected: no cap
setting beats the baseline’s solved count even with propagation firing 380x
harder, and the single instance that motivated it turned out to be
workstation noise at a timeout boundary. D1 and D3’s cheap design are the
same code (compute_implied_child_bound), so that one sweep closed both;
D3’s expensive column-merge design had already been rejected on its own
evidence.
Milestone 3b’s real-benchmark impact: a 500-case fuzz merge-gate re-run found zero wrong-answer disagreements against z3 (a real bug –
OnlineLraPropagatorclearing valid propagation reasons on backtrack – was found and fixed via this same gate, see the run-log). A quick 82-file diverse-sample re-run againsttmp/QF_LRAshowed no material aggregate change on that particular sample (44/82 solved vs. 45/82 before, within noise) – same-slack propagation alone did not move the previously-0% families (invariant synthesis, lasso ranking, tropical-matrix), which suggests they need either scale-invariant polynomial matching or row-based/interval propagation (both explicitly out of scope here, see above) rather than same-slack propagation alone. Not a negative result for the milestone – seedocs/run-log.jsonfor the full picture, including unit-test-level evidence (decision-count reduction on hand-built cases) that the mechanism itself works correctly.Milestone 4b (done, 2026-07-24): the campaign-tier fuzz gate (
AGENTS.md: >=3000 cases plus a seed-mutation campaign) that promotes QF_LRA from “experimental” to “supported”/default. Ran on thellm2smt-slurmcluster after fixing two remote-infrastructure gaps (missing fuzzsmt/z3/yinyang tooling; a workspace-sync rsync clobbering provisioned tooling with host-specific local paths – seedocs/run-log.jsonand thefeedback_fuzzing_on_slurmmemory). Result: 3000/3000 fuzzsmt cases (0 crash, 0 wrong, 149 timeout) plus opfuzz and two yinyang typefuzz mutation shards (14/14 real seeds each, 0 bug triggers) – fully clean. Covered the default (Bland) pivoting rule at campaign scale; Dantzig has only merge-gate-tier (500-case) local confirmation, consistent with it being opt-in rather than the behavior being promoted.Competitiveness roadmap item 1 (2026-07-25): LRA-aware solve-eqs. Real-equality lowering moved from parse time to a dedicated
lower_real_equalitiespass at the end of preprocessing (see “Disequality” above), sosubstitute_solved_constants(“solve-eqs”) now fires on genuine RealEqualitynodes instead of silently no-op’ing for QF_LRA.rewrite_with_solved_constantsandrewrite_with_ground_unit_equalitieswere both extended to recurse intoPlus/Times/Le/Ltso a substitution actually propagates through arithmetic subterms. Full suite (Release + ASAN/UBSan) and new unit tests pass; a local 500-case merge-gate fuzz smoke test (417 PASS, 83 TIMEOUT, 0 wrong-answers/crashes) and a fresh campaign-tier Slurm run (job 113357, 2026-07-25: 3000/3000 fuzzsmt cases – 2854 PASS, 146 TIMEOUT, 0 CRASH, 0 WRONG – plus opfuzz and two yinyang typefuzz mutation shards, 14/14 real seeds each, 0 bug triggers) both came back fully clean. This item is done and as trusted as Milestone 4b’s disequality behavior – seedocs/run-log.json.Competitiveness roadmap item 1 follow-up (2026-07-25, done): Gaussian elimination over asserted linear equality systems. The plain recognizer above only catches
x = twith a bare variable on one side;--gaussian-elimination(default on,--no-gaussian-eliminationfor ablation) additionally row-reduces (Gauss-Jordan, exact rationals) the whole system of asserted Real equalities, catching single equations with a coefficient (2x = 5) and genuine multi-equation systems (2x+3y=5andx-y=1jointly solving for bothxandy) that the plain recognizer can’t. Merges into the same candidate list/accept-reject pipeline the plain recognizer uses – no new correctness logic there. Landed opt-in first (materially new algorithmic machinery, not just a recognizer extension), then promoted to default-on the same day once a merge-gate-tier Slurm smoke test (job 113406: 500 fuzzsmt cases, 0 crashes/wrong-answers) and a campaign-tier Slurm run (job 113407: 3000 fuzzsmt cases plus opfuzz + 2 yinyang mutation shards, 0 crashes/wrong- answers/bug triggers) both came back fully clean – seedocs/preprocessing.md’s “Gaussian Elimination” section for the design anddocs/run-log.jsonfor the full verification trail.Competitiveness roadmap item 2 (2026-07-25): row-based/interval bound propagation. See “Row-based/interval bound propagation” above for the design. Full suite (Release + ASAN/UBSan) and new unit tests pass. Opt-in (
--lra-row-propagation), same staged-rollout reasoning as item 1’s Gaussian elimination. A quick 17-file real-benchmark re-run (the same sample as this roadmap’s original quick-eval) showed the mechanism does fire on several real files (:lra.row-propagationsnonzero, up to thousands of propagations on some, confirmed via--stats, including on some of the still-0%-solved families) but did not move the sat/unsat outcome on that narrow 17-file/5s-timeout sample – 6/17 solved either way, same set, no wrong answers. This mirrors Milestone 3b’s own same-slack-propagation finding exactly (real work happening, timeout cases stay timeouts at this sample size/budget) – not a negative result for the mechanism, just evidence that propagation reach alone isn’t what’s gating these particular hard families; seedocs/run-log.json.Competitiveness roadmap item 4 (2026-07-25): SOI-Simplex pivoting. See “SOI-Simplex pivoting” above for the full design – the complete paper algorithm (King/Barrett/Dutertre FMCAD 2013), not a scoped-down version, per explicit direction. Full suite (Release + ASAN/UBSan) and new unit tests pass, including a direct reproduction of the paper’s own Fig 6 worked example. Opt-in (
--lra-pivoting-rule soi). A real heap- use-after-free was found and fixed during manual smoke testing before any fuzzing (see “SOI-Simplex pivoting” above) – a reminder that this item’s trail/incremental-integration piece (genuinely new beyond the paper) was exactly the highest-risk area it was flagged as during design. A quick 17-file real-benchmark re-run (same sample as items 1-2’s) showed identical results to Bland: 7/17 solved, the exact same files, no wrong answers either way – SOI’s global objective didn’t move this particular narrow sample, matching items 1-2’s own honest findings that none of solve-eqs/row-based propagation/SOI- Simplex individually closes this sample’s timeouts. Seedocs/run-log.json.Competitiveness roadmap item 0 (2026-07-25, done last, out of priority order): empirical baseline vs. z3/cvc5. A new reusable stratified sample,
benchmarks/qf-lra-stratified-sample.txt(up to 15 files per family, all 17 corpus families, fixed seed, 237 files – much broader than items 1/2/4’s own 17-file one-per-family quick-eval sample), compared against z3 and cvc5 at a 10s timeout via the newscripts/slurm_qf_lra_solver_compare.sbatchtemplate. Solved: primo 102/237 (43%), z3 167/237 (70%), cvc5 150/237 (63%); PAR2 primo 2799, z3 1531, cvc5 1885. Zero 3-way wrong-answer disagreements. Crucially, the gap is not uniform: Primo fully matches both reference solvers on keymaera/meti-tarski/sal/spider_benchmarks/check, and all three struggle roughly equally on 2017-Heizmann-UltimateInvariantSynthesis/ LassoRanker/tropical-matrix (confirming those are genuinely hard for everyone, not Primo-specific) – but on TM, sc, uart, 2019-ezsmt, tta_startup, and miplib, Primo is specifically and substantially behind while z3/cvc5 mostly solve them within the same budget. Spot-checked TM/uart’s failures: pure timeouts (not wrong/error/ unsupported), with a threshold effect (solved cases finish in 1.9-8.8s, failed ones hit the wall exactly at 10s) suggesting some specific size/structure crossing point, not uniform slowness. This is the first genuine evidence of where Primo is behind – none of items 1/2/4 were evaluated against this broader, more representative family set (their own quick-evals used the narrow 17-file sample, dominated by the “hard for everyone” families) – making the TM/sc/ uart/2019-ezsmt/tta_startup/miplib gap a concrete, prioritized target for follow-up profiling before further engineering investment. Two real sbatch-script bugs (a delimiter collision, an argparsenargs='+'gotcha) and a broken cvc5 install (missing libc++ shared libraries, fixed viacontrib/install_cvc5.pypreferring static release assets) were found and fixed along the way. Seedocs/run-log.json.Item 0 follow-up (2026-07-25): pivot() allocation profiling. Profiled (
perf record/perf report, not just--statsguesswork) the 3 most dramatic timeout-vs-instant cases item 0 surfaced (tmp/QF_LRA/sc/sc-21.induction3.cvc.smt2,tmp/QF_LRA/uart/uart-18.induction.cvc.smt2,tmp/QF_LRA/TM/p5-zenonumeric_s5.smt2). Finding: 50-60% of runtime was in malloc/free/GMP-allocation machinery, withLraTableau::pivot()’s per-affected-row combine step (a freshstd::unordered_map<LraVarId, Rational>built and torn down for every row touched by every pivot) as the dominant contributor;set_soi_direction()had the identical pattern. Fixed (commit04a6c73) by replacing both with a reused member vector, sort-merged instead of hashed – a first attempt that just reused anunordered_mapmember as-is did not help (re-profiling showed the same signature unchanged), sinceunordered_mapis node-based: every element is its own heap allocation no matter how the container itself is reused. Full suite (Release + ASAN/UBSan) green, no behavior change; merge-gate Slurm fuzz smoke (job 113938: 500 fuzzsmt cases, 471 PASS/29 TIMEOUT/0 CRASH/0 WRONG) came back clean. Re-timing showed a real but mixed effect: TM/p5-zenonumeric_s5 dropped from 20.58s to 11.05s (~47% faster); sc-21 and uart-18 improved more modestly/noisily. Follow-up (2026-07-26, commit2222b8f): re-profiling after the above still showedstd::__detail::_Map_basehot –rows_/occurs_in_were themselvesstd::unordered_map<LraVarId, ...>keyed by a dense small integer (LraVarIdis a plain index intovars_, already astd::vector<VarState>), so every pivot’srows_.erase+emplaceandoccurs_in_insert/erase was its own per-call heap-allocated map-node churn, independent of the combine step already fixed. Convertedrows_/occurs_in_/soi_directions_to plain vectors sized in lockstep withvars_(valid to do sinceadd_variable()/add_row()are only ever called before the firstpush(), a documented class contract – so their size never changes once solving starts); a nonbasic slot’s stale content is harmless becauserebuild_occurs_in_index()(the only place that ever iterated all ofrows_) now gates byis_basicinstead of by map-key presence. Full suite green again,:sat.decisions/:sat.conflictsbit-identical to the pre-vectorization commit (pure perf change). Merge-gate Slurm fuzz smoke on both pivoting paths came back clean (job 113939, Bland: 480 PASS/20 TIMEOUT/0 CRASH/0 WRONG; job 113940,--lra- pivoting-rule soi: 338 PASS/162 TIMEOUT/0 CRASH/0 WRONG – SOI’s higher timeout rate matches its already-known characteristic, not a regression). Re-profiling confirmed theunordered_mapoverhead is now gone entirely from the hot path; what remains is GMP’s own –mpq_class’s arithmetic operators canonicalize (GCD-reduce) on every add/multiply by design, an inherent cost of exact rational arithmetic no container-level fix can remove. Re-timing: TM/p5-zenonumeric_s5 down further to 8.62s (~58% faster than the original pre-fix baseline); sc-21 43.24s, uart-18 18.04s. None of the 3 files come close to z3/cvc5’s sub-1s times either way – these two fixes close two real constant-factor costs, not the competitiveness gap itself. Campaign-tier confirmation (2026-07-26, job 113941): clean. 3000/3000 fuzzsmt cases (2857 PASS, 143 TIMEOUT, 0 CRASH, 0 WRONG) plus opfuzz and two yinyang typefuzz mutation shards (14/14 real QF_LRA seeds valid each, 0 bug triggers across all three) on the default Bland path – both fixes are now trusted at the same level as any other default-on QF_LRA behavior, closing the campaign-tier gap the merge-gate-only entries above had left open. Seedocs/run-log.json.Competitiveness roadmap item 5 (2026-07-26, done): violated-variable worklist in
LraTableau::check(). New:lra.pivots/:lra.online-check-callsstats (added while investigating the two allocation fixes above) showedcheck()’s Bland/Dantzig violated-variable scan and SOI’s end-of-check error-var collection both doing a fullO(vars_.size())linear scan over every variable, once per pivot-loop iteration, on everycheck()call – 25-49% of which find nothing violated at all (e.g. TM/p5-zenonumeric_s5: 64,350check()calls for only 8,617 decisions, since propagated literals trigger the same eager repair as decisions do). Replaced both scans with iteration over a new incrementally-maintainedstd::set<LraVarId> violated_vars_–soi_direction(var)!=0is exactly “var is basic and violated” by construction, so one set serves Bland, Dantzig, and SOI’s error-var scan with no separate bookkeeping. Kept in sync going forward viaupdate_violation_status()(called fromassert_upper/assert_lower’s is_basic branch andupdate()’s two assignment-change sites);pop()rebuilds it from scratch rather than patching every undo case individually, mirroringrebuild_occurs_in_index()’s existing precedent. Commit80b617b.:sat.decisions/:sat.conflicts/:lra.pivots/:lra.online-check-callsbit-identical to the pre-item-5 commit on all 3 profiled files (pure perf change, confirmed no selection-order change); wall-clock further improved: uart-18 18.04s->12.45s (~31% faster), TM/p5-zenonumeric_s5 8.62s->6.54s (~24% faster), sc-21 slightly faster. Stacked with the two prior allocation fixes, TM/p5 is now roughly 2.5x faster than the original pre-optimization baseline (20.58s->6.54s). Full suite (Release + ASAN/UBSan) green, including a new push/pop regression test targeting the set’s rebuild-after-pop invariant. Merge-gate (jobs 113950 Bland, 113951 SOI) and campaign-gate (job 113952, Bland only, matching the SOI-stays-opt-in precedent) both clean: 0 crash/wrong-answer across all three. Seedocs/run-log.json.Competitiveness roadmap item 6 (2026-07-26, done): defer
LraTableau::check()to CaDiCaL’scb_has_external_clause.OnlineLraPropagator::notify_assignment_litused to run the full Check/Pivot repair eagerly after every assigned literal (decisions and propagated ones) – the code’s own comment already flagged this as a known cost. Verified against CaDiCaL’s own source (contrib/src/cadical/src/{internal,external_propagate}.cpp): in eager external-propagator mode (unchanged,is_lazystill defaults tofalse),cb_has_external_clause()is asked exactly once per quiescence point (once per decision plus once per conflict/backjump, not once per literal), andcb_propagate()is always fully drained first – so same-slack/row-based propagation (which reads only bound state, never the current assignment) is completely unaffected by moving the repair there. Commit28f573d. A newneeds_check_flag replaces the inlinetableau_.check()call;run_deferred_check_if_ needed()(called from bothcb_has_external_clauseoverloads) resolves it lazily. A real bug was found and fixed during local testing (the existing black-box testatoms_sharing_identical_polynomial_solve_correctlycaught it, not fuzzing): a direct same-variable bound conflict found on a later literal in a batch could leave an earlier literal’sneeds_check_=trueflag dangling, so the deferred-check helper would later overwrite the genuine direct conflict with a stale (wrong)tableau_.check()result – the rejected bound is never actually stored byassert_upper/assert_lower’s own direct-conflict branch, socheck()alone wouldn’t even rediscover it. Fixed by clearingneeds_check_in that branch, plus a defensive guard in the deferred-check helper itself. Full suite (Release + ASAN/UBSan) green, including a new black-box test asserting:lra.online-check-callsstays bounded bydecisions+conflicts+1regardless of how many extra atoms propagation resolves alongside. Measured on the 3 profiled files::lra.online-check-callsdropped 57-95% (largest on TM/p5, the most propagation-heavy file); wall-clock further improved: sc-21 40.93s->23.11s, uart-18 12.45s->6.24s, TM/p5-zenonumeric_s5 6.54s->4.21s. Unlike item 5,:sat.decisions/:sat.conflicts/:lra.pivotsare not bit-identical to the pre-item-6 commit – expected, since deferring conflict discovery legitimately reshapes the search trajectory (still fully correct, just a different valid path, matching the same reordering effect the two allocation fixes showed earlier). Given this touches CaDiCaL protocol integration rather than an internal-only data structure, verified at the highest bar of any QF_LRA change this cycle: merge-gate (jobs 114174 Bland, 114175 SOI) and campaign-gate (jobs 114182 Bland, 114183 SOI) all clean on both pivoting paths – 0 crash/wrong-answer throughout. Seedocs/run-log.json.Stacked across items 0-6, the 3 originally-profiled files went from the pre-optimization baseline (sc-21 56.28s, uart-18 21.27s, TM/p5-zenonumeric_s5 20.58s) to sc-21 23.11s, uart-18 6.24s, TM/p5-zenonumeric_s5 4.21s – roughly 2.4x-4.9x faster depending on the file. None of the 3 come close to z3/cvc5’s sub-1s times on these same files either way: these items closed real constant-factor and call-volume costs, not the underlying competitiveness gap item 0 measured.
Broader-sample check (2026-07-26, job 114200): re-ran item 0’s exact 237-file comparison against the post-item-6 code. Solved: primo 108/237 (45.6%, up from 102/43.0%), z3 167/237 (unchanged), cvc5 150/237 (unchanged). PAR2: primo 2676s (down from 2799s, ~4.4%), z3/cvc5 unchanged within jitter. Zero 3-way disagreements, same as before. Per previously-lagging family (primo solved/15, prior count in parens): TM 6 (4), sc 5 (4), uart 4 (3), 2019-ezsmt 3 (3, unchanged), tta_startup 9 (8), miplib 1 (1, unchanged). A real but modest aggregate gain – exactly what the 3-file profiling result predicts, since a per-file speedup only flips a timeout to solved within this sample’s fixed 10s budget if the post-fix time actually lands under 10s (uart-18’s case would; sc-21’s 23.11s still wouldn’t). 2019-ezsmt and miplib not moving at all are the clearest candidates for a fresh profiling pass if this gap is revisited. See
docs/run-log.json. Rendered as a colored per-instance HTML table via the newscripts/render_solver_compare_html.py(styled afterpeople.ciirc.cvut.cz/~janotmik/llm2smt/primo.html) – now the standard way to report a solver-comparison result.Competitiveness roadmap item 7 (2026-07-26, done, opt-in, not promoted): bidirectional row-based propagation. Profiling one of item 0’s 9 dramatic-gap files (
TM/p6-zenonumeric_s5.smt2: 22.56s vs. z3 0.18s/cvc5 0.56s) and checking the reference solvers’ own stats found the real story: z3’s final LP tableau is tiny (36 rows/728 columns) and cvc5 spent only 8ms of its 645ms runtime pivoting (theory::arith::pivots = 129,unatePropagateCalls = 15,595) – both win via far more complete propagation, not cheaper pivoting. A full Bromberger/Weidenbach-style implied-equality-basis computation was considered and set aside (paper-sized undertaking, and its own experiments don’t measure SMT search speedup); instead this item implements item 2’s own explicitly-flagged-as-deferred “reverse direction” –compute_implied_child_boundderives a bound on one of a row’s other terms from the row’s own bound plus its remaining terms, instead of only deriving the row’s own bound. Opt-in via--lra-bidirectional-row-propagation(requires--lra-row-propagationalso on), its own smaller size caps, new stat:lra.bidirectional-row-propagations. Found and worked around a real pre-existing quirk while writing the tests (not introduced by this item):single_variable_slackonly registers a bare variable’s positive-coefficient normalized form, so>=atoms (which normalize to a negated, coefficient-(-1) polynomial) never get an entry – asserting both<=and>=on the same variable splits its bound across two slacks with only one direction visible on each. Item H1a (2026-07-28) removed this quirk at the root:single_variable_slackis gone entirely, and both directions now land on the variable itself.Empirical result on the 9 dramatic-gap files: genuinely mixed, not a clear win. Item 2 alone (already-shipped) turned out to help far more on this file set than its original narrow quick-eval suggested – pivot reductions on 7/9 files, sometimes dramatically (TM/p6: 113,806->58,736 pivots, ~48% fewer). This item’s own addition only ever fires on TM/uart’s Boolean-guarded-equality structure (never on sc’s min-max comparator chains) and is net-negative as often as positive: wall-clock wins on 3/9 (TM/p6 -44%, uart-16 -11%, uart-17 -25%), losses on 4/9 (TM/p-DepotsNum, sc-17, sc-21, uart-18), roughly flat on 2/9 – and the extra per-assertion scanning overhead measurably slows sc-17 down even on the zero occasions it fires there. Not recommended for default-on promotion on this evidence – a genuinely file-shape-dependent lever. Merge-gate Slurm fuzz (job 114203, both new flags explicitly enabled: 500 cases, 483 PASS/17 TIMEOUT/0 CRASH/0 WRONG) clean; campaign-tier not pursued given the promotion decision, matching the precedent for other opt-in, not-promoted features (e.g. SOI-Simplex pivoting). See
docs/run-log.json.Item 7 closed out items 0-7 of the roadmap; item 8 below picks the underlying TM/sc/uart-vs-z3/cvc5 gap back up from a different angle.
Competitiveness roadmap item 8 (2026-07-26/27, done, opt-in, pending a promotion decision): theory-guided decisions via CaDiCaL’s
cb_decide. A follow-up literature pass (“are we missing something?”, prompted by item 7’s own “gap remains substantially open” framing) first chased and explicitly retracted a dead end: CaDiCaL’s IPASIR-UP interface freezes every observed variable, disabling CaDiCaL’s own inprocessing on them (Fazekas et al., “IPASIR-UP: User Propagators for CDCL”, SAT 2023, Section 2.4, calls this an open problem) – but that same paper’s own Table 2 shows cvc5-ipasirup (the identical frozen-variable architecture) solving moreQF_LinearRealArithbenchmarks in less aggregate time than native cvc5 (1,766 vs. 1,721 solved, 113,919s vs. 130,408s), refuting the freezing theory for this division. The real lead, from the same paper plus a companion talk (Preiner, “CaDiCaL(T): CaDiCaL as CDCL(T) Engine in cvc5”, Shonan 2023): CaDiCaL’scb_decidecallback lets an external theory supply its own decision literal instead of leaving every decision to generic VSIDS – cvc5-ipasirup uses it for “theory decisions (required): decision strategies used by theory solvers”.grep -rn cb_decide srcfound zero overrides in eitherOnlineEufPropagatororOnlineLraPropagator– every one of Primo’s 119,793 decisions onTM/p6-zenonumeric_s5.smt2(vs. z3’s 322 and cvc5’s 498 on the same file) was, and until this item remains by default, pure VSIDS.First attempt (commit
17f8faf, retired): a per-candidate cost ranking. Implementing the “required” scope explicitly chosen by the user over a smaller “opportunistic, free-wins-only” cut,cb_decide()scanned a bounded, rotating window of not-yet-decided arithmetic atoms and ranked each candidate polarity via a newestimate_assertion_costprobe (counting how many rows referencing the candidate’s variable would have their live-assignment-based implied value pushed outside their own existing bound), supplying the cheapest candidate’s literal. On the 9 dramatic-gap files this was genuinely mixed with real regressions: two wins (TM/p6-zenonumeric_s5-76%,TM/p-DepotsNum_s8-13%) plus one small win, but three files flipped from solved to timed-out at the same 60s budget (sc-17/sc-19/sc-21) and two got markedly slower while still solving (uart-16~3x,uart-18~1.6x).Re-investigated after being asked to check for a missed performance issue – the ranking itself was the bug, not just its overhead. A debug build that bypassed
estimate_assertion_costentirely (supply the next unassigned arithmetic atom’s literal, no ranking at all – “cheap mode”) beat both plain VSIDS and the cost-ranked design on 8/9 files, sometimes by 40-96%, and was the only one of the three that solvedsc-27at all within the shared budget. The cost ranking wasn’t merely expensive – it was actively steering search into worse territory (the three solved-to-timeout regressions were VSIDS beating the smarter-looking heuristic outright). Commitfc28dbcreplacescb_decide()with exactly that “cheap mode”: supply the next not-yet-decided arithmetic atom’s TRUE literal each call, advancing a persistent round-robin cursor, no per-candidate arithmetic at all. Removedestimate_assertion_cost, its fanout/scan-budget caps, and the now-unmeasurable:lra.theory-decision-freestat.Final empirical result on the 9 dramatic-gap files (corrected design): a near-uniform win, zero net regressions.
file
baseline
round-robin
delta
TM/p-DepotsNum_s8
53.3s
11.1s
-79%
TM/p6-zenonumeric_s5
59.1s
2.0s
-97%
sc-17
26.3s
11.2s
-57%
sc-19
42.9s
18.8s
-56%
sc-21
50.9s
29.7s
-42%
sc-27
timeout (60s)
22.3s, solved
unsolved -> solved
uart-16
14.5s
14.5s
flat (dec -31%, piv +21%)
uart-17
19.3s
18.3s
-5%
uart-18
13.0s
13.2s
flat (dec -59%, piv -31%)
7/9 clear wins (often 40-97%), 2/9 flat (both still show substantially fewer decisions,
uart-18-59%), andsc-27– unsolved by both plain VSIDS and the original cost-ranked design at this budget – is now solved. No file regresses. Merge-gate Slurm fuzz (job 114234: 500 cases, 482 PASS/18 TIMEOUT/0 CRASH/0 WRONG) and campaign-gate Slurm fuzz (job 114235: fuzzsmt 3000/3000 – 2911 PASS/89 TIMEOUT/0 CRASH/0 WRONG; opfuzz 14/14 real seeds valid/0 bug triggers; yinyang 2 shards/0 bug triggers) both clean on the corrected design. Two black-box tests rewritten for the round-robin’s actual semantics (decision order and conflict-driven correctness, not cost ranking). Full suite (Release + ASAN/UBSan) green throughout. Seedocs/run-log.jsonfor the full trail, including the earlier (retired) cost-ranked design’s own fuzz confirmation (jobs 114204/114205/114214) kept on record for the historical trail even though that design was replaced.Broader 237-file re-verification (job 114244, per the user’s explicit request before any promotion decision): the dramatic 9-file win does NOT generalize. Re-ran item 0’s stratified sample (only the primo task, diffed against the unchanged z3.csv/cvc5.csv from job 114200 – per the standing “don’t rerun unchanged reference solvers” practice). Aggregate result is roughly flat, slightly negative: solved 108->106/237 (-2), PAR2 2675.6s->2682.0s (+0.2%), 0 wrong-answer disagreements, but 4 files regress from solved to unsolved (
LassoRanker/CooperatingT2/ p-43...,sal/gasburner-prop3-19,tta_startup/simple_startup_8nodes...,uart-10.base) against only 2 that newly solve. The round-robin order that dominates on the TM/sc/uart “dramatic-gap” family (Boolean-guarded chains of many similar arithmetic atoms, where any stable decision order beats VSIDS’s churn) is a wash-to-net-negative once families with different structure (LassoRanker, sal, tta_startup) are included – those 9 files were never a representative sample, they were specifically the ones z3/cvc5 found easy and Primo didn’t, which turns out to correlate with exactly the file shape this heuristic helps. Rendered as a colored per-instance HTML table viascripts/render_solver_compare_ html.py, matching standard practice.This closes out all of the QF_LRA competitiveness roadmap’s currently-planned items (0-8). Staying opt-in, not recommended for default-on promotion on the broader evidence – despite being the most dramatic narrow-sample win in the roadmap, it doesn’t clear the bar item 0-6’s unconditional/promoted changes did (broad-sample improvement, not narrow-sample-only). A useful lever for exactly the TM/sc/uart Boolean-guarded-chain file shape (worth keeping available via the flag), not a general-purpose default. This is also a concrete lesson for how this roadmap evaluates future items: the 9-file dramatic-gap sample is useful for finding techniques worth trying, but not sufficient on its own for a promotion decision – the 237-file broader sample is the real bar, as it was for items 0-6.
Item 0 follow-up (2026-07-27, done, unconditional): deferred
occurs_in_rebuild inLraTableau::pop(). Asked to gprof-profileTM/p5-zenonumeric_s5.smt2(one of the 3 files item 0’s follow-up originally profiled) turned up a real, previously-missed bug:rebuild_occurs_in_index()showed up at ~9% self-time in the flat profile, second only topivot()itself, with a call count (17,665) almost exactly matching the file’s total pivot count (18,097). Root cause:undo()called it once perPivotTrailEntry/FRowTrailEntryit undid, not once perpop()call – so a singlepop()unwinding N pivots at once (a full multi-decision-level backtrack) did N redundant fullO(vars_.size())rebuilds instead of one. The function only ever readsrows_/is_basic(neveroccurs_in_itself), so there’s no ordering dependency across the undo loop – deferring it to run once at the end ofpop(), mirroring the existingrebuild_violated_vars()precedent, is sound. Re-profiling confirms the call count drops to 535 (-97%) on the same file.Verified with a paired same-node, same-time A/B on the Slurm remote (alternating before/after binary runs to control for cluster-load drift, which had made a naive historical comparison misleading – the cluster was running noticeably slower than when items 0-6 were first measured, confounding a straight before/after against old documented numbers):
TM/p5-zenonumeric_s5-10.6% (9.92s->8.87s avg of 3 runs),sc-21.induction3-4.2% (50.51s->48.40s),uart-18.induction-9.4% (13.18s->11.95s).:sat.decisions/:lra.pivotsbit-identical before/after on all three (pure perf change, no selection-order change, matching every other item-0-follow-up fix’s own invariant). Full suite (Release + ASAN/UBSan) green. Since this touches unconditional core Check/Pivot code, verified at both merge-gate (jobs 114707 Bland: 500 cases, 472 PASS/28 TIMEOUT/0 CRASH/0 WRONG; 114708 SOI: 500 cases, 341 PASS/159 TIMEOUT/0 CRASH/0 WRONG) and campaign-gate (jobs 114709 Bland: fuzzsmt 3000/3000 – 2882 PASS/118 TIMEOUT/0 CRASH/0 WRONG, opfuzz 14/14 valid/0 bugs, yinyang 0 bugs; 114710 SOI: fuzzsmt 3000/3000 – 2035 PASS/965 TIMEOUT/0 CRASH/0 WRONG, opfuzz 14/14 valid/0 bugs, yinyang 0 bugs) tiers, on both pivoting paths, matching the precedent set by the originaloccurs_in_/rows_/soi_directions_vectorization fix and theviolated_vars_worklist item. Seedocs/run-log.jsonfor the full trail.
Next round of heuristics (2026-07-27, designed, not implemented): see
QF_LRA Speedup Heuristics: Design Note for a prioritized design note picking the
remaining 61-file gap back up from fresh profiling and tableau-shape
instrumentation at commit 35878c4. Its headline findings: ~54% of runtime
is allocator/GMP-copy overhead on tiny numbers (correcting item 0’s
follow-up conclusion that the remaining cost was inherent GCD
canonicalization – measured at under 2%); 36-95% of tableau rows on the
lagging families are trivial single-variable slack rows that Dutertre & de
Moura’s own Section 2 would not create at all; and rows densify 3-5x during
search (one variable reaching 2,127 occurs_in_ entries) while none of
Bland/Dantzig/SOI prices a pivot by its cost. The note also reclassifies
the gap into three distinct bottleneck classes (pure-LP single-check(),
pivot-cost-dominated, and assignment-volume-dominated) rather than one.
Not yet scheduled (natural next candidates, not designed in detail):
Devex or Steepest-Edge pivoting (Milestone 4a covered only Dantzig, and
SOI-Simplex, item 4, is now done without closing the quick-eval sample’s gap –
neither pricing rule choice nor a global objective alone seems to be the
bottleneck for the “hard for everyone” families in that narrow sample); a
campaign-tier confirmation specifically for --lra-pivoting-rule dantzig/soi
if either is ever considered for becoming the default. Scale-invariant
polynomial matching, formerly roadmap item 3, is implemented by H1b; see
QF_LRA Speedup Heuristics: Design Note.
SOI endpoint correction#
This correction follows King–Barrett–Dutertre, Simplex with Sum of Infeasibilities for SMT, FMCAD 2013, §V-A: between breakpoints, integrate the current slope; at a crossing, change it by `(new_direction - old_direction)
coefficient`. King’s thesis §3.3.1 gives the corresponding breakpoint walk. The special case is its starting point, where a variable exactly on a bound has direction zero, rather than the direction just before crossing that bound.
Implementation checklist:
LraTableau::soi_selectowns a temporary vector ofSoiBreakPointrecords. Retain whether each record denotes a lower or upper bound. This distinguishes two coincident bounds of a fixed variable without a separate lookup set.The slope initially comes from the current objective row. At a zero step, an increasing basic value contributes
+coefficientonly for an upper bound; a decreasing value contributes-coefficientonly for a lower bound. An inward move has no direction change. Both bounds of a fixed variable therefore contribute only once at the starting point.At a nonzero crossing, keep the existing
+1or-1direction change for each crossed bound. Passing through both bounds of a fixed variable changes direction from-1to+1, or conversely, and must still count both.Score all candidates at a coincident breakpoint before updating the slope for the next segment. Keep zero-step pivot candidates, the entering variable’s own bound cap, and the existing tie-breaking and Bland fallback.
Use exact
DeltaRationalequality to identify step zero. A positive or negative infinitesimal step is nonzero; there is no numeric tolerance.Selection remains read-only over the tableau. There is no change to row mutation, bound explanations, the objective trail, or public interfaces. The record gains one bound-kind flag; time complexity and the number of row scans remain unchanged.
The proof obligation is that the slope after a starting-bound event matches the one-sided slope of the violation function in the chosen walk direction. Regression coverage must distinguish inward and outward motion on either bound, both entering directions and coefficient signs, fixed variables at zero and at nonzero crossings, coincident events, infinitesimal steps, and rollback. The original diagnostic must repair its feasible assignment with one pivot.
The implemented correction is covered by
dedicated tableau regressions
and the two inputs under benchmarks/regressions/soi/. Fresh SLURM qualification
passed all 43 normal and AddressSanitizer test groups, 201 Python tests, and
36 comparisons against Z3 over arithmetic seeds and the endpoint regressions.
The SOI-forced merge gate generated 1,000 distinct QF_LRA inputs: 958 agreed
with Z3 and 42 timed out at five seconds, including two mutual timeouts;
there were no wrong answers, crashes, input rejections or reference
disagreements. The retained component example now takes one pivot.
The run-log entry 2026-09-29-soi-endpoint-correction records exact provenance.
SOI remains opt-in; these results do not establish an overall speed gain.