QF_LRA fuzz coverage#
This page preserves the dated coverage investigations and their failure cases. It is not fresh qualification of later solver changes. Current accepted gates are indexed in Current implementation and qualification; Experiments describes the separate all-SAT-row model replay, and repository testing/fuzzing/model-validation skills own execution procedures. Old source line references remain historical.
Two fuzzers guard QF_LRA, and they fail in opposite directions. FuzzSMT
generates formulas from a grammar, so it covers whatever its parameters say
and nothing else. YinYang’s opfuzz and typefuzz mutate a seed directory, so
they cover whatever is in that directory and nothing else. Both defaults were
narrower than anyone had checked, and this page records what was found on
2026-08-01, what was changed, and what was measured rather than assumed.
The mutation seed corpus#
benchmarks/seeds/qf_lra_real held 14 files drawn from three of the
non-incremental division’s 17 families – check, DTP-Scheduling,
keymaera – plus gasburner, which is not in the division at all. Fourteen
families had never been mutated. The omissions included the two that matter
most to recent work:
latendresse– pure LP, zero decisions, very large pivot counts. This is the shape items H8, H9 and H10 each rewrote.LassoRanker– 419 files, the largest single source of Primo’s lead over the reference solvers.
A benchmark run does exercise every family, but it only checks the final answer. Mutation fuzzing perturbs the formula, which is what turns a family’s shape into coverage.
Seeds are committed, so the fix is not to add a megabyte of benchmarks.
scripts/build_fuzz_seed_corpus.py builds the corpus from a benchmark root
instead:
python3 scripts/build_fuzz_seed_corpus.py --root tmp/QF_LRA \
--out benchmarks/seeds/qf_lra_division --per-family 2 --max-bytes 200000
It takes the smallest files per family, so mutation stays cheap while every family is represented, and selection is deterministic (smallest first, ties by path) so a given root always yields the same corpus. On the full division that is 17 families, 34 seeds, 1.2 MB.
One deliberate exception: a family whose smallest file exceeds --max-bytes
still contributes that file. latendresse’s smallest is 122 KB, and skipping
it would recreate the exact blind spot the script exists to remove.
The size limit that undid the fix#
The first campaign over the family-complete corpus (job 118543) reported, in every one of its five tasks:
34 seeds processed, 22 valid, 12 invalid
0 bug triggers found
That “12 invalid” reads like a well-sortedness result. It is not. typefuzz’s
-L flag is a seed file-size limit, default 20000 bytes in
scripts/yinyang_fuzz.sh, and it silently drops every seed above it. The 12
dropped seeds were exactly the 12 files over 20 KB:
family |
seed size |
|---|---|
|
160 KB, 180 KB |
|
125 KB |
|
77 KB, 81 KB |
|
73 KB, 78 KB |
|
70 KB |
|
45 KB |
So the corpus that had just been widened from 3 families to 17 was truncated
back to 11 – discarding latendresse and LassoRanker again, the two
families the whole exercise existed to cover. The campaign was clean, and it
was clean over almost the old blind spot.
Two changes, since one alone would leave the trap armed:
scripts/slurm_qfuf_fuzz_gate.sbatchpasses--file-size-limit 200000, matching the--max-bytesthatbuild_fuzz_seed_corpus.pyselects under, so the corpus builder decides the corpus and the fuzzer’s limit no longer does.scripts/yinyang_fuzz.shcounts oversized seeds up front and names them on stderr, so a truncated corpus appears in the campaign log as a warning rather than as an arithmetic discrepancy nobody reads.
There is a second, independent effect worth knowing when reading mutation
results on these families: at a 20-second reference timeout, z3 5.0.0 does not
answer the unmutated seed from LassoRanker, latendresse, miplib, or
tropical-matrix at all – 8 of the 34 seeds. Those iterations run but cannot
yield a verdict. That is what Yices as a second reference addresses.
Yices as a second reference#
A reference solver that cannot answer a seed is not a reference for it. Since the seeds without an oracle were concentrated in the families the corpus had just been widened to include, it was worth asking whether a different reference does better. Measured 2026-08-01 on the corpus seeds:
seed |
primo |
z3 @60s |
yices @60s |
|---|---|---|---|
|
sat, 3.2s |
timeout |
sat, 15.9s |
|
unsat, 2.7s |
timeout |
unsat, 15.1s |
|
sat, 54.2s |
timeout |
sat, 4.7s |
|
unknown |
timeout |
timeout |
|
unknown |
timeout |
timeout |
|
unsat, ~3s |
timeout |
unsat, 109s |
|
fast |
fast |
fast |
At the 20s mutation timeout yices oracles 29 of 34 seeds against z3’s 26,
and its failures are a strict subset of z3’s, so the union is also 29. The
three it adds are both latendresse seeds – the family the widening existed
to cover – and one miplib seed.
Yices does not subsume z3: both time out on LassoRanker at 20s and on
tropical-matrix outright. So it is added as a second reference, not a
replacement, which also buys reference cross-checking: classify() in
scripts/fuzz_external.py reports REF_DISAGREEMENT when two references
disagree with each other.
It is also the cheaper reference. On 150 wide-coefficient generator cases it agreed with z3 on all 150 and ran 6x faster (0.3s against 1.9s).
Two asides the measurements forced out, both worth remembering rather than
re-deriving. miplib_pp08a-9000: yices 4.7s against primo’s 54.2s – miplib
was already the one division family z3 leads on, and yices looks stronger there
still. Conversely LassoRanker: primo 3s against yices 109s, about 36x the
other way.
The five seeds nobody oracles are covered by the differential benchmark runs
instead, where the division’s :status fields are an oracle needing no
reference solver at all.
What adding a reference broke#
Two defects in scripts/fuzz_external.py were latent under one reference and
live under two:
classify()returnedUNKNOWN_INVOLVEDwhenever any reference answered unknown. With two references, one timing out while the other answers concretely is the ordinary case on hard inputs, so a genuine disagreement with the concrete reference would have been filed as inconclusive. Now only our own unknown, or no concrete reference at all, is inconclusive.A reference missing from
PATHwas dropped silently, so a run requested with two references and given one read as a complete result – the same shape as the “fuzzsmt is not installed; skipping” no-op gate. It now reports the missing reference and exits non-zero if none is left, and the gate’s required-tool check covers every configured reference rather than just z3.
How much mutation fuzzing actually tests#
Chasing why yices looked useless as a third target turned up something more important than the answer. Across 25 mutants from each of 8 seed groups, 200 in total, Primo can answer 15 of them – 7.5%:
seed group |
usable |
|---|---|
|
0 / 25 |
|
0 / 25 |
|
4 / 25 |
|
3 / 25 |
|
2 / 25 |
|
1 / 25 |
Two independent causes, and the second is the one that matters.
Declaration order. typefuzz emits declare-fun after the first use. In one
Heizmann mutant motzkin_2059_0 is used at line 17 and declared at line 49,
where the parent seed declares it at line 77 and first uses it at line 86.
Primo and yices both reject with unknown symbol; z3 drops the offending
assertions and answers anyway – so on these inputs z3 answers a strictly
weaker formula than the one it was given. Mechanically fixable by hoisting.
Nonlinearity, which dominates. Hoisting is not enough: the same mutant then
fails with * requires a literal rational coefficient operand (QF_LRA).
opfuzz and typefuzz mutate operators in a sort-aware way, and QF_LRA’s
linearity is a syntactic restriction beyond sorts, so operator mutation
routinely yields products of non-constant terms such as
(* (+ (* motzkin_2059_0 10.0) ...) ...). Those are no longer QF_LRA. Primo
correctly rejects them; z3 solves them as nonlinear.
So mutation fuzzing has a low intrinsic yield against a linear-only solver: most mutants leave the logic. That is a property of the tool/logic pairing, not a misconfiguration, and it explains why mutation fuzzing has never found anything here. It is not unsound – in opfuzz mode Primo is the oracle, and a mutant it cannot answer is counted invalid and skipped, so no false bug is reported. The cost is budget: roughly 13 mutants generated per usable one.
The corpus widening in The mutation seed corpus is still worth having, because it broadens the shapes of the 7.5% that do stay linear. It just does not multiply the yield the way the seed count suggests. Generator fuzzing – the ladder in The FuzzSMT parameter ladder – is the higher-yield path for QF_LRA and should stay the primary gate.
The clean-room line#
Yices is installed by contrib/install_yices.py (make contrib-yices) into
contrib/local, which is gitignored and excluded from the cluster rsync. It is
a reference solver only: run as a separate process, never linked, source never
read.
That boundary is sharper here than for z3 or cvc5. Yices is the solver Dutertre
and de Moura wrote alongside “Integrating Simplex with DPLL(T)”, the paper
Primo’s QF_LRA theory follows. Reading the paper is the whole point; reading
the implementation would be a clean-room violation. Yices 2 is GPLv3 – running
a binary imposes no obligation, linking would, so the installer deliberately
installs neither lib/ nor include/.
The FuzzSMT parameter ladder#
Every QF_LRA gate recorded in docs/run-log.json before 2026-08-01 ran:
-ms 1 -Ms 3 -mv 1 -Mv 3 -mf 1 -mp 1 -ma 1 -Ma 2
For QF_LRA that string is FuzzSMT’s stock default. -ms/-Ms are sort
counts and -mf/-mp/-ma/-Ma are uninterpreted-function, predicate and
arity knobs; FuzzSMT applies all of them only to the QF_UF* logics and
silently ignores them here, so nothing ever complained. What actually applied
was -Mv 3, and the untouched defaults -Mc 3 and -Mbw 4.
-Mbw bounds the bit-width of the integer literals FuzzSMT emits, which become
coefficients and, via (/ c1 c2), the rational constants. At the default
-Mbw 4 every coefficient in this project’s entire generator-fuzz history
lay in [1,15]. The GMP bignum paths in DeltaRational and in Farkas
coefficient combination were never reached by the generator.
scripts/slurm_qfuf_fuzz_gate.sbatch now assigns a ladder of profiles to
shards round-robin when PRIMO_FUZZ_FUZZSMT_LOGIC=QF_LRA:
# |
profile |
what it buys |
|---|---|---|
0 |
|
the historical setting, kept for continuity |
1 |
|
max literal 15 → 54430, at unchanged cost |
2 |
|
~9x the rows; 18s/100 cases, 1% timeouts |
3 |
profile 2 |
100% unsat: conflicts and Farkas certificates |
4 |
profile 2 |
100% sat: model construction |
5 |
|
very wide coefficients at the small size |
Profile 3 earns its shard. The random Boolean layer yields only ~11% unsat, and unsat is the discriminating outcome, since it is the one that exercises conflict detection and certificate construction rather than model search. Profile 4 is its complement and costs about the same.
Setting PRIMO_FUZZ_FUZZSMT_ARGS still forces a single profile, so every
historical docs/run-log.json command line stays reproducible. The QF_UF
default is unchanged.
Knobs that do not pay, with the measurements#
Cranking FuzzSMT indiscriminately does not work – output size grows superlinearly and throughput collapses. Measured at 10-150 cases per setting on 2026-08-01:
setting |
effect |
|---|---|
|
free: no size or time cost, 4000x wider coefficients |
|
3x file size |
|
9x file size |
|
7x file size |
|
20x file size; generation itself starts hitting the wall |
|
~2 MB per case – the knobs compose |
-bool-cnf is unusable for a different reason: FuzzSMT 0.3 emits a malformed
let block for it, and z3 rejects the output too (invalid let declaration).
That is a generator bug, not something to work around here. All 150 cases at
-bool-cnf 3 were rejected by Primo’s parser, correctly.
The one caution on reading these numbers: FuzzSMT’s output is seed-driven, and
changing a parameter changes the random stream, so per-setting file sizes
wander. A first measurement suggested -Mbw 16 was cheaper than -Mbw 4
(4.2s versus 22.7s per 150 cases) and doubled the unsat rate; a second seed
showed 3.4s versus 3.7s and no meaningful unsat difference. The defensible
claim is that -Mbw 16 costs nothing, not that it wins.
Status#
Job 118735 (2026-08-02) is the first campaign with everything here active
at once: the ladder, both QF_LRA-restricted configs, --keep-logic and
--hoist-declarations, Yices as a second reference, and the family-complete
corpus. No defect was found in roughly 4,700 generator and mutation cases.
The result worth keeping is what the configs did to opfuzz’s effective-call rate – the fraction of solver invocations that return a usable answer:
campaign |
configuration |
opfuzz efficiency |
|---|---|---|
118548 |
stock configs, z3 only |
65.1% |
118611 |
+ Yices, which could not read mutants |
42.8% |
118735 |
QF_LRA configs + |
99.7% |
typefuzz improved less – 41.7%, 65.0% and 51.3% against a 27-43% baseline – which is the expected shape: it generates new terms rather than substituting operators in place, so it produces harder mutants and a residue that no config can prevent (see How much mutation fuzzing actually tests).
Generator side, 5 of 6 shards at time of writing: 2500 cases, 2455 PASS, 45
TIMEOUT, zero wrong answers and zero reference disagreements. Every case
consulted both references – checked from the summary CSVs rather than
assumed, because fuzz_external.py does not echo its arguments.
The 45 files under bugs/ are exactly those 45 timeouts, all in FuzzSMT shard
directories and none in any mutation directory. A non-empty bugs/ is not a
bug count – fuzz_external.py copies every non-PASS case there.
Two operational findings from the same job, both now in
scripts/slurm_qfuf_fuzz_gate.sbatch:
FuzzSMT generation cost varies ~200x by seed at identical parameters (seed 218735: 20.9s and 34.7 MB per 100 files, against 0.1s and 1.9 MB for its neighbour). A shard drawing such a seed looks stuck while being merely slow, which had previously been attributed to cluster load.
One mutation shard was SIGKILLed and the cause was not established. Memory is ruled out – two sibling tasks reported higher MaxRSS on the same request and completed. Primo is not reproducibly implicated: 800 mutants with it capped at 4 GB produced no kill, and the largest mutant solves in 0.01s using 9 MB. Recorded as unexplained rather than attributed to anything.
Job 118548 ran the full ladder: 6 shards x 500 cases, all six shard CSVs present. 2959 PASS, 41 TIMEOUT, zero wrong answers and zero crashes. Timeouts by profile were 2, 7, 19, 4, 5, 4 – concentrated in profile 2, the widest tableau, as intended.
Those 41 are throughput artifacts, not defects: sampling one, it is a 330 KB
8,928-line formula that Primo answers sat in under 60s, agreeing with z3.
fuzz_external.py copies every non-PASS case into bugs/, so a non-empty
bugs/ directory is not a bug count – check the outcome column before
reading it as one.
The earlier job 118543 reported 0 bug triggers, but over 22 of 34 seeds, for the reason in The size limit that undid the fix; it is superseded, not evidence for the widened corpus. Job 118548’s own mutation tasks still used z3 as the only reference, predating Yices as a second reference.
A FAILED task state is not a wrong answer either#
Same trap as the bugs/ directory above, one level up, and it cost real time
on jobs 119949/119950 (the tuning-knob gate, 2026-08-06). Tasks 0-5 came back
FAILED 1:0 in both arrays while 6-8 passed, which reads as a correctness
failure and is not one: 0 wrong answers and 0 invalid models across 6,000
FuzzSMT cases.
The mechanism:
validate_qfuf_models_with_dolmen.pycounts its own 10-second solver timeout as a failure (failures += 1) and endsreturn 1 if failures else 0.slurm_qf_lra_model_fuzz_gate.sbatchruns underset -euo pipefail, and the validator is the last command in the FuzzSMT branch, so the validator’s exit code becomes the task’s.Tasks 6-8 are the opfuzz/YinYang branch, which never invokes the validator. Their passing is not independent corroboration; it is the absence of the same check.
So on this gate, read the outcome tallies rather than the Slurm state:
outcome in fuzzsmt/shard-*/summary.csv (PASS/TIMEOUT, plus anything
else) and in model-validation/shard-*/summary.csv (pass, not-sat,
solver-timeout). Note not-sat is not a failure – it means the instance
was unsat so no model was expected.
Why this needs fixing rather than just documenting. A configuration that
is correct but slower cannot pass this gate. That was tolerable while every
gated change was meant to be neutral-or-faster, but
Numerical parameters: what exists, what is hardcoded, what to add deliberately exposes knobs whose whole purpose is to
explore speed trade-offs – --lra-sparse-pricing-candidates 1 is known to
turn a 4.13s solve into a 300s timeout. Gating those means gating slow
configurations by construction. The validator should separate “could not
check within the budget” from “checked and wrong”, and only the latter should
fail the run.
See docs/run-log.json for the per-run entries.
A free-Boolean-mixed-with-arithmetic blind spot, and the bug it hid#
Found 2026-08-17, while hand-testing shapes for a new
--monotone-elimination generator. build_qf_lra_model (check_sat.cpp)
silently dropped every Boolean atom the SAT search decided (rather than
preprocessing) from the printed model – see Arithmetic‘s “Model
construction for Boolean atoms in QF_LRA” for the fix. The verdict was
always right; the model was wrong, for any sat QF_LRA problem with
propositional structure the solver couldn’t fully resolve up front.
Three independent reasons this session’s own hand-testing found it first, all QF_LRA-specific:
fuzz_external.py’sclassify()– the mechanism behind the great majority ofdocs/run-log.json’s QF_LRA entries – only ever compares thesat/unsatverdict against a reference solver. It never requests(get-model), so a right-answer/wrong-model bug is invisible to it by construction.The one gate that does check models,
slurm_qf_lra_model_fuzz_gate.sbatch(dolmen-based; see A FAILED task state is not a wrong answer either‘s job 119949/119950, “0 wrong answers and 0 invalid models across 6,000 FuzzSMT cases”), uses the same FuzzSMT/opfuzz/typefuzz generators as the answer-only gates. FuzzSMT’s own predicate/UF-arity flags (-mf/-mp/-ma/-Ma, The FuzzSMT parameter ladder) apply “only to theQF_UF*logics” and are silently ignored for QF_LRA – so no FuzzSMT-generated QF_LRA case has ever contained a free Boolean atom mixed with arithmetic. The gate ran clean 6,000 times not because the bug didn’t exist, but because its generator structurally cannot produce the shape that triggers it.The mutation seed corpus has the identical gap:
benchmarks/seeds/qf_lra/’s three committed files declare zero Boolean symbols, and real SMT-LIB QF_LRA-division benchmarks are almost entirely pure arithmetic, so opfuzz/typefuzz mutation (which perturbs within a seed’s existing symbol/sort inventory) never introduces one either.
Even the closest hand-written unit test,
monotone_elimination_rejects_atom_shared_across_polarities
(tests/unit/qf_lra_solver_tests.cpp), had exactly this shape –
(and (or (<= x 5) p) (or (not (<= x 5)) q)) – and asserted only the sat
verdict and the disqualification stat, never the model. Strengthened as
part of the fix.
The durable fix is scripts/qf_lra_boolean_arithmetic_fuzz.py, a small
in-repo generator (not dependent on FuzzSMT’s grammar) purpose-built to mix
free Booleans with LRA chains/fans – see its own module docstring for the
shape catalogue. It requires every satisfiable model to pass Dolmen directly;
a Z3 assignment fallback is not accepted as model-validation evidence. The
four-shard scripts/slurm_qf_lra_boolean_arithmetic_fuzz_gate.sbatch wrapper
runs 1,000 cases by default and doubles as the
--monotone-elimination reconstruction stress test the session originally
set out to build.
Not pursued here: repointing slurm_qf_lra_model_fuzz_gate.sbatch’s
PRIMO_FUZZ_OPFUZZ_SEEDS/PRIMO_FUZZ_YINYANG_SEEDS at committed seeds
containing free Booleans. benchmarks/seeds/qf_lra_division/ (the gate’s
actual seed source) is gitignored and rebuilt per job from an external
tmp/QF_LRA tree, so a durable version of that fix means either changing
shared sbatch defaults other campaigns also use, or growing the external
family corpus – a separate, larger-blast-radius change.