Numerical parameters: what exists, what is hardcoded, what to add#
This is a dated tuning/design inventory, not the current CLI reference or a
fresh ranking of HEAD. Engine-local check_sat.cpp references below predate the
split into frontend.cpp, solver_engines.cpp and strategy modules. Confirm
option defaults and reachability in src/smt/check_sat.hpp, src/main.cpp
and the selected strategy before reusing a search space. Current routes and
accepted evidence are summarized in Current implementation and qualification.
Written 2026-08-06, ahead of a dedicated QF_LRA parameter-tuning effort, and in the framing of Open leads register‘s complementarity criterion: a configuration earns a portfolio slot by solving something the others do not, not by winning on average. Numerical knobs are the cheapest source of such configurations, because each one is a dial rather than a new mechanism to validate.
This note is a code review of where Primo’s behaviour is currently fixed by a constant, split by how much work exposing that constant would be. It does not tune anything; the campaign that follows does.
Where the tuning infrastructure already is#
scripts/irace/ and scripts/smac/ (documented in Experiments) are
set up and have been used, but both are QF_UF only –
scripts/irace/qfuf_parameters.txt is the sole search-space file in the
repository. The first deliverable of the QF_LRA effort is a
qflra_parameters.txt alongside it; everything in
Tier 0 – already exposed, never tuned (zero code change) can go into it with no code change at all.
A knob is only a dimension where it is reachable#
Reviewed 2026-08-10, and it changed what belongs in the search space. Most options here are guarded: they do nothing unless some other option is set, and several of those guards are off by default. A guarded knob handed to irace is worse than useless — it consumes a dimension, doubles the configurations to evaluate, and contributes only noise, while looking like a tuned parameter in the report.
Two of the guards are structural rather than a matter of taste:
Pivoting-rule scoping.
least_violated_leaving_andsparse_leaving_are both read asflag && rule_ == PivotingRule::Sparse(lra_tableau.cpp:1299,:1310), andsparse_pricing_candidatesis only reached throughselect_sparse_entering_variable, called underSparsealone (:913). SOI minimization is likewiseSoi-only.Propagation nesting.
scan_row_based_propagationsis called only whenpropagation_enabled_ && row_propagation_enabled_(check_sat.cpp:6786), and the bidirectional check sits inside that function (:7107). So the chain is--lra-propagation→--lra-row-propagation→--lra-bidirectional-row-propagation, and the last two are off by default.
How to check a knob before spending a dimension on it: run it at two extreme values and diff a counter that it should move. If nothing changes, it is not reachable in that configuration. That is how the dead options below were found.
Tier 0 – already exposed, never tuned (zero code change)#
Live CLI options that no search space contains. The requires column is the precondition without which the option does nothing.
option |
default |
requires |
note |
|---|---|---|---|
|
|
— |
4 values ( |
|
64 |
|
dead until 2026-08-10, see Four options that parse and do nothing |
|
64 |
|
|
|
32 |
|
|
|
32 |
|
|
|
off |
|
MathSAT’s rule; measured 167 vs 174 overall but beats most-violated by 5, i.e. a complementarity candidate, not a loss. Pairing it with any other rule wastes the dimension |
|
on |
|
ablation of the leaving-side half |
|
off |
— |
nets to nothing overall, doubles the >5MB band – textbook portfolio arm. Note it also forces |
|
mixed elimination on |
mixed QF_UFLRA |
ablation of the mixed-only default that exposes |
|
|
mixed QF_UFLRA |
|
|
reconciliation on |
mixed QF_UFLRA |
ablation of the default convex shared-model path; restores explained exact equality sharing |
|
off |
mixed QF_UFLRA model reconciliation |
routes checked EUF/LRA projections and model-choice batches through the backend-neutral sequential coordinator; keeps the established specialized scheduler as the default during parity evaluation |
|
on |
QF_UFLIA |
starts with input-visible shared equalities, uses EUF/LIA model partitions for reversible equality choices, and retains the complete arrangement as a restart fallback; the historical |
|
on |
QF_UFLIA with the incremental EUF engine |
reuses trail-synchronized EUF and rational-LIA state during SAT search; with model combination it rebuilds on cumulative interface growth, and integer completion still runs after an accepted rational model; the historical |
|
512 |
QF_UFLIA model combination |
maximum model-choice batches before the complete QF_UFLIA arrangement restart; |
|
on |
— |
the root of the propagation chain |
|
off |
|
|
|
off |
|
|
|
off/off |
— |
independent flags inside the propagator; see 10. Theory-decision share – turning a cliff into a dial before enabling |
|
auto |
— |
|
|
— |
already integer-ranged |
|
|
– |
varies |
arbitrary CaDiCaL integers, but several are disabled while an external propagator is attached – see The CaDiCaL sub-space |
Four options that parse and do nothing#
CheckSatOptions::lra_row_propagation_limits (check_sat.hpp:224) is written
by all four --lra-*-max-row-size / -max-fanout options in main.cpp:232-252
and then never read anywhere in src/. The propagation code uses
hardcoded constants instead: kRowBasedPropagationMaxRowSize /
...MaxFanout (check_sat.cpp:4500-4501) and their bidirectional
counterparts (:4510-4511).
Confirmed empirically, not just by reading: with row propagation switched on,
--lra-row-propagation-max-row-size 1 and 100000 both give
:lra.row-propagations 2694 on sc/sc-9.base.cvc.smt2 — byte-identical
counters at extremes three orders of magnitude apart.
This matters more than a typical dead flag because the table above previously
advertised all four as free dimensions requiring zero code change. Putting
them in qflra_parameters.txt would have spent four dimensions on noise.
Fixed 2026-08-10. The struct is threaded through OnlineLraPropagator and
the two compute_implied_*_bound helpers, and the four constants are gone.
The defaults were already 64/64/32/32, identical to the constants, so this is
behaviour-preserving where it matters — verified against the pre-change binary
on 24 completed instances at default settings and 10 more with row propagation
explicitly enabled, 0 answer differences and 0 :lra.* counter differences in
both.
The knobs now bind, and one of them was binding all along without anyone being
able to see it: on sc/sc-9.base.cvc.smt2, --lra-row-propagation-max-fanout 100000 yields 3177 row propagations against 2694 at the default 64.
So the forward fanout cap is not a formality on real input — it is discarding
about 15% of available propagations at its default, which is now a tunable
decision rather than a constant nobody could reach.
row_propagation_limits_are_actually_enforced in
tests/unit/qf_lra_solver_tests.cpp pins it. The test asserts the caps
bind rather than that propagation happens, deliberately: a test of the
latter shape would have passed throughout the entire period the options did
nothing.
The CaDiCaL sub-space#
--cadical-option already forwards any integer option, so the entire SAT
search is tunable without touching Primo. The options worth putting in the
space first, given what the QF_LRA measurements show:
restartint (2), stabilize (1), stabilizeinit (1000), target (1),
rephase/rephaseint, reduceint (25), walk (1) and walkeffort (80),
bumpreason/bumpreasondepth, chrono/chronolevelim.
The specific reason to expect leverage: on constraint-199552 the solved run
reports 15,766 decisions against 93 conflicts
(The tropical-matrix family: what the formulas actually say). A nearly conflict-free walk is exactly the
regime where restart/stabilization/local-search policy dominates and clause
management is irrelevant. Caveat to check first: CaDiCaL disables several
inprocessing options while an external propagator is attached, so confirm an
option actually takes effect before spending a search dimension on it.
Warning
Verified 2026-08-12 for seed: it is inert. --cadical-option seed=1, =7
and =99 give byte-identical :sat.decisions (8420) and :sat.conflicts
(726) on sc/sc-9.base.cvc.smt2. So the caveat above is not hypothetical –
at least one obvious dimension in the list is already dead under the external
propagator, and it cost a wrong conclusion once: a perturbation experiment
built on seed looked like strong evidence until the identical timings gave
it away. Apply the two-extreme-values check from A knob is only a dimension where it is reachable to every
CaDiCaL option before it enters the search space.
Implemented: the Check/Pivot knobs and the SOI minimization arms#
LraTableau::Tuning now carries what were hardcoded constants, wired through
CheckSatOptions::lra_tuning and exposed on the CLI. Defaults are the former
constants, so an untuned run is unchanged – verified on constraint-199552,
which still reports exactly the recorded 7,679 pivots and 128,402,240 pivot
term ops.
Every one of these is guarded by --lra-pivoting-rule, which is the
single most important thing to know before putting them in a search space:
knob |
requires |
|---|---|
|
|
|
any rule other than |
|
|
|
|
Since sparse is the default and soi is not, the SOI arms are unreachable
unless the rule is set explicitly. Treat the rule as a parent dimension and
these as conditional on its value; irace supports exactly that, and a flat
space would spend most of its budget on combinations that cannot differ.
A unit test (tuning_knobs_never_change_a_verdict) pins the property the
whole campaign rests on: these are cost levers, and no value of them may
change a verdict.
--lra-sparse-pricing-candidates (default 16, 0 = unbounded)#
How many eligible entering candidates select_sparse_entering_variable
scores before taking the best so far. 1 is the cheapest possible pricing;
0 scores every eligible candidate in the row.
Safety: the limit only ever restricts scoring, never the search for whether
an eligible candidate exists – “no eligible candidate” is what check()
reports as a conflict, so an early exit before finding one would manufacture
an unsound conflict. Any value is sound.
Measured range on constraint-199552 (local, diagnostic, 60s budget,
--guarded-real-equality-lowering):
candidates |
result |
pivots |
pivot term ops |
time |
|---|---|---|---|---|
1 |
timeout (300s budget) |
180,958 |
16,534,507,811 |
>300s |
4 |
timeout |
61,588 |
3,321,322,628 |
>60s |
8 |
sat |
92,386 |
1,873,731,376 |
38.2s |
16 (default) |
sat |
7,679 |
128,402,240 |
2.8s |
32 |
timeout |
179,984 |
2,872,889,041 |
>60s |
64 |
timeout |
163,719 |
2,862,388,347 |
>60s |
0 (unbounded) |
timeout |
184,633 |
2,926,390,362 |
>60s |
Two things follow, and the second matters more than the first. The knob has a >100x dynamic range in pivot term ops on a single file, so it is a real dimension rather than a rounding-error one. But the profile is a spike, not a plateau – 16 wins and both neighbours lose badly, non-monotonically. Since this value was never tuned, that is not evidence that 16 is right; it is evidence that the surface is chaotic, which is the same “search perturbation changes everything” effect already noted for the leaving rule. A single global default is the wrong shape of answer here; per-family or portfolio selection is.
--lra-bland-fallback-factor / --lra-bland-fallback-offset (4 / 64)#
check()’s per-call fallback point, factor * variables + offset pivots.
The comment on it was explicit that it was never tuned. Small factor means
switching to Bland’s rule early: anti-cycling-safe, slower, a robustness arm.
Termination only needs the fallback to happen after finitely many pivots, not after this particular count, so every value including 0 is sound – 0/0 falls back to Bland selection from the second pivot of each call.
A counter bug found while checking whether this knob is inert.
:lra.bland-fallbacks incremented only under PivotingRule::Dantzig, so
under Sparse – the solver’s default – it read 0 whether or not the
fallback fired. That made the counter useless for the one question it is
consulted for. Fixed the same day, with a regression test
(bland_fallback_is_counted_under_the_sparse_rule). With the fix,
constraint-199552 reports 0 fallbacks at the defaults and 622 when forced
with 0 0 – so the default zero is now a meaningful zero, and on this file
the threshold genuinely never binds.
And with the counter fixed, the threshold turns out to bind on real
input. An earlier draft of this section guessed the knob might be inert
because constraint-199552 reports 0. It is not: across the
2017-Heizmann-UltimateInvariantSynthesis files in the stratified sample, the
default configuration reports fallback counts of 1, 2, 4, 7, 8 and 12 on
different instances. So check() really does exhaust 4 * variables + 64
pivots within a single call on ordinary QF_LRA benchmarks, and the rule in
force for the remainder of those calls is Bland’s – not the Sparse rule the
solver is nominally configured with.
That makes both coefficients a live tuning dimension rather than a robustness
arm, and it raises a question worth answering before tuning them: on
instances where the fallback fires, how much of the pivoting is happening
under Bland? If it is a large fraction, the measured performance of
--lra-pivoting-rule sparse on those files is partly the performance of
Bland, which would matter for interpreting every pivoting-rule comparison in
QF_LRA Speedup Heuristics: Design Note.
Tier 1 – hardcoded constants still to expose (small, local diffs)#
Each of these is a constexpr or a literal in an expression. Exposing means
routing it through CheckSatOptions and adding one add_option – no new
mechanism, no new invariant.
3. Gaussian-elimination size caps#
src/smt/preprocessing.cpp:375-376
constexpr std::size_t kGaussianEliminationMaxEquations = 128;
constexpr std::size_t kGaussianEliminationMaxVariables = 256;
The comment already asks for this: “Not precisely derived; tunable against real evidence later.” The cost is O(equations² · variables), so these trade preprocessing time against tableau size directly.
Local spot check (13 stratified-sample files, --gaussian-elimination,
diagnostic only): :preprocessing.solve-eqs.gaussian-candidates ranged 0-42,
non-zero on 11 of 13 – so the pass does real work and the caps are plausibly
binding on the larger instances. The two zeros were not distinguished between
“no equalities” and “cap hit”; that distinction is worth a counter before
tuning, otherwise a search cannot tell an inert dimension from a blocked one.
4. Solved-constant substitution blow-up budget#
src/smt/preprocessing.cpp:186-187
constexpr std::size_t kSolvedConstantMaxDefinitionSize = 64;
constexpr std::size_t kSolvedConstantMaxAddedNodes = 200000;
Good tuning candidates because bindingness is already observable:
:preprocessing.solve-eqs.rejected-blowups counts exactly the
rejections these cause. Tune only where that counter is non-zero.
5. QF_UF-side caps the existing irace space only toggles on and off#
src/smt/preprocessing.cpp:181-185: kMaxBridgeBranches (64),
kMaxBridgePairChecks (200000), kMaxCongruenceDefinitionClauses (10000),
kMaxFullCongruenceApplicationsPerSymbol (128),
kMaxFiniteDomainEvaluationAssignments (250000).
qfuf_parameters.txt currently offers eq_bridge, ground_congruence etc.
as on/off only. Each of these constants turns one binary dimension into a
sized one, which is usually where the gain is – “off” and “unlimited” are
rarely both optimal.
6. Rational::kSmallMax – a build parameter, not a search dimension#
src/util/rational.hpp:145, INT64_MAX. The class comment states the intent
outright: “kSmallMax below is the single knob, so the two widths can be
compared empirically rather than argued about” (Monniaux uses 32-bit; Primo
deviated to 64-bit). This is compile-time, so it belongs in a one-off A/B and
possibly a second portfolio binary, not in an irace space.
Tier 2 – parameters that need a small mechanism first#
7. LRA check frequency – the biggest gap, and the one with a precedent#
run_deferred_check_if_needed() (src/smt/check_sat.cpp:6715) runs the full
Check/Pivot repair at every quiescence point. Measured on
constraint-199552: :lra.online-check-calls 6528 against
:lra.theory-checks 1.
There is nothing to tune here today, and yet the EUF side already has
exactly this idea parameterized – --prop-interval and
--prop-assign-threshold, both already in qfuf_parameters.txt. The
arithmetic analogues:
--lra-check-interval k– run the repair every k-th quiescence point.--lra-check-assign-threshold f– skip while fewer than fractionfof arithmetic atoms are assigned.
Requires: nothing – the repair runs on every path, which is exactly why this is the biggest gap. Unlike most of the table it would be a knob with no guard above it.
Both are completeness-preserving provided the check is forced at
cb_check_found_model, which is where the verdict actually matters. This is
the closest thing on the list to a cheap approximation of relevancy
propagation (lead L2 in The tropical-matrix family: what the formulas actually say) – deferring
arithmetic on assignments that later turn out irrelevant is precisely the
waste L2 targets, and a working interval knob is evidence about whether the
much larger L2 mechanism is worth building.
8. Same-slack propagation fanout cap#
Row propagation has four caps; the default-on same-slack propagation
(scan_for_propagations) has none. It walks occurrence lists that average
679 entries on the tropical family – the same measurement that motivated
the cross-link change. A --lra-propagation-max-fanout mirroring the row
version’s cap is a two-line addition and directly bounded work.
9. SOI conflict-minimization budget#
Superseded 2026-08-10 — the original suggestion here was aimed the wrong way, and the counter it cited does not measure what it claimed.
The suggestion was --lra-soi-minimize-max-vars, skipping minimization above
a given error-set size on the theory that large sets cost repeated
soi_subset_still_conflicts evaluations, citing
:lra.soi-quickxplain-calls as already measuring the volume. Both halves were
wrong:
That counter measured successes, not calls. It only incremented when minimization actually shrank the set (a 6x under-report: 345 against 2253), which made a high success rate look like a low one. It is now split into
:lra.soi-minimize-calls,:lra.soi-minimize-shrunkand:lra.soi-conflict-lits.There are no large sets to skip. Instrumented over 2253 calls: 82% have a single element, the rest average 2.83, and the largest seen was 9. A size-threshold knob would never usefully fire, and minimization succeeds 88% of the time on the sets that have more than one element — so a threshold would be disabling the case that pays.
What replaced it is --lra-soi-minimize {quickxplain,deletion,none} with
--lra-soi-minimize-order {id,row-support,violation}. Since minimization
reduces the set to 1.23 elements on average, there are usually several equally
minimal answers and the algorithm silently picks one; deletion makes that pick
orderable, where QuickXplain’s recursion cannot be steered. The quantity to
watch is :lra.soi-conflict-lits, not the set size — build_soi_conflict
emits one literal per nonzero entry of the restricted SOI row plus one per
error variable, and the first term usually dominates.
Separate, non-tuning finding from the same code: quickxplain
(src/theory/arithmetic/lra_tableau.cpp:770-797) is recursive, with a GCC
diagnostic pragma wrapped around it. That is against this project’s standing
rule to use an explicit stack instead. Worth converting independently of any
tuning work – the error set is bounded by the number of violated rows, so the
depth is data-dependent.
11. Model-phase refresh interval#
Requires --lra-model-phase (off by default).
--lra-model-phase refreshes at every quiescence point whenever dirty. A
--lra-model-phase-interval bounds that cost. Lower priority: the option is
off by default and its cost has not been shown to bind. Note also that
--lra-pivoting-rule soi and --lra-fixed-elimination both set
model_phase_backtrack_may_change_beta, so what a refresh costs depends on
those too.
What not to turn into a parameter#
--max-theory-conflictsalready exists but returnsunknown. A portfolio wants restart under a different configuration, which is scheduling infrastructure, not a solver parameter. Do not overload it.Anything whose counter reads zero across the corpus. A search dimension that cannot change behaviour still costs configurations. Every suggestion above names the counter that settles this; check it before including the dimension.
Anything guarded by an option the space holds at its default. The strongest form of the point above: see A knob is only a dimension where it is reachable for the guards that actually exist, and Four options that parse and do nothing for four options that were advertised here as free dimensions while reading nothing at all.
Correctness-relevant limits. None of the above changes an answer, only how long it takes to reach one. Keep it that way: the fuzz gate (QF_LRA fuzz coverage) validates models, and a parameter that can change
sat/unsatwould make a tuning campaign a correctness risk rather than a performance one.
Suggested order#
qflra_parameters.txtover Tier 0 plus the three knobs in Implemented: the Check/Pivot knobs and the SOI minimization arms – no further code change, and it calibrates how much a search can move QF_LRA at all.7. LRA check frequency – the biggest gap, and the one with a precedent – the remaining item with measured evidence that the current fixed behaviour binds.
The remaining Tier 1 constants, each gated on its counter being non-zero.
Tier 2 items 8-11.
A warning the pricing sweep has already earned: the response surface is non-monotone and spiky, so a search that reports a single best value has probably fitted noise on whichever instances dominated its training set. Validate on a held-out set, and prefer reporting a set of complementary configurations over a new default – which is the framing Open leads register already argues for.