Experiments#
Experiment and benchmark reports belong here.
Use MyST Markdown for written reports. Use notebooks only for executable benchmark analysis or plotted reports.
Record every nontrivial benchmark, fuzzing, profiling, tuning, dependency-profile,
or reference-comparison run in docs/run-log.json; see Run Log for the
schema and evidence policy.
For the latest accepted comparisons, start with Current implementation and qualification. The family studies and older command examples below retain their own revisions and timeouts; do not combine their solve counts into a new headline comparison. The N-component migration’s five-logic result and the later QF_UFLIA-only overhead fix have distinct evidence scopes.
Separate model validation from speed measurements#
The repository’s primo-model-validation skill owns the operational procedure
for real-corpus correctness checks and post-benchmark SAT replay. Its canonical
source is skills/primo-model-validation/SKILL.md, exposed for repository skill
discovery through .agents/skills/primo-model-validation. Benchmarking and
testing skills route to it; AGENTS.md contains only the obligation and routing.
Detailed benchmark execution rules now live in
skills/benchmarking/references/execution.md rather than the global guide.
Preserve timing rows unchanged. A separate correctness run replays successful
SAT rows with the same binary/profile and validates printed models independently
with Dolmen. Record the parent run, binary/input/manifest identities, arguments,
seeds, validator configuration and coverage in docs/run-log.json. A model pass
is not an UNSAT certificate. Timeouts, unsupported validation and missing rows
remain visible as incomplete coverage; an invalid model or changed answer fails
the correctness stage. Model-production and validation costs are not part of the
original runtime/PAR2. A standalone family sample cannot qualify a full evaluation.
scripts/benchmark_model_replay.py implements two separate phases: benchmark
records binary/input hashes and exact arguments before calling the existing
timing harness; replay verifies that provenance, selects every successful SAT
row and calls the validator with --expect-sat. The latter turns an UNSAT replay
into answer-changed, not not-sat. Coverage is joined into a separate
model-validation/coverage.json; timing rows are not modified. The SLURM wrapper
scripts/slurm_real_corpus_validation.sbatch exposes both phases for dependent
jobs. Campaign scheduling and reference-answer joins remain explicit operations.
scripts/summarize_model_campaign.py ROOT --expected QF_UF=7503 ... joins
LOGIC/{primo,z3,cvc5}/shard-N outputs, checks shard/manifest coverage, compares
decisive answers, and reports family-level timing and independent model coverage.
It can read durable journals for progress, but a partial journal cannot complete
a shard. Unknown answers and failed processes are not solved or credited in PAR2.
Model-coverage errors are reported separately and do not rewrite timing completeness.
Do not automatically schedule new reference jobs for a Primo-only paired run. Join recorded Z3/cvc5 answers by input content hash and preserve their binary, options and timeout provenance. Missing or nondecisive reference rows remain explicit gaps; declared SMT-LIB status and agreement with an older Primo are weaker evidence, especially for UNSAT. Timing comparisons require compatible execution conditions, not merely matching answers. A fresh reference run needs a concrete coverage or comparison purpose.
Nondefault correctness profiles must reach both answer comparison and model
validation. In particular, the QF_UFLIA mutation wrapper forwards
PRIMO_FUZZ_SOLVER_ARGS to validate_model.py as repeated --solver-arg
arguments. Validating the default profile after timing/fuzzing a different one
does not qualify the requested configuration.
Live reports use journals until provenance marks a shard complete; an in-flight
CSV is not authoritative. Unreadable journal records are explicit coverage issues,
not silent passes or grounds for discarding every other shard’s progress.
An unknown response emitted during timeout termination remains a timeout,
not a second independent unknown result.
The flat-CSV summarize_benchmark_shards.py helper uses the same decisive-answer
rule for solve counts, family summaries, PAR2 and paired recoveries/losses.
Its older status == ok accounting incorrectly credited unknown; recompute
affected historical summaries from their raw rows instead of assuming those
aggregates are comparable.
The model command adapter scans top-level boundaries without building or
expanding term trees. It preserves assertion text and comments, understands
quoted symbols and doubled-quote strings, replaces existing model/print-success
options, and inserts get-model after the query but before exit. Multi-query,
assumption and incremental scripts are rejected as input-unsupported, not
silently flattened. Supported scope is one non-incremental query.
The replay driver verifies binary and selected input identities before and after
validation. It preserves partial attempts; --attempt NAME creates a separate
retry directory. It reuses only matching complete shards, not arbitrary old
passes or individual rows. Invalid/changed/malformed results fail; resource limits,
unsupported inputs and missing rows leave coverage incomplete. The independent
validator records solver and validator durations separately. No new solver
algorithm is introduced by this workflow.
The workflow uses streaming SHA-256 from the standard library and supports the
cluster’s Python 3.9; it does not require Python 3.11’s hashlib.file_digest.
Validator memory-limit investigation#
The 2026-09-07-dolmen-memory-and-internal-checker-investigation run-log entry
resolves 14 apparent failures from the full real-corpus replay. The installed
Dolmen 0.10 defaults to an internal --size=1G limit; allocating more memory in
SLURM does not change that limit. All 14 unchanged saved models pass with
--strict=false --size=8G --check-model=true --response MODEL PROBLEM.
Representative checks at 1G reproduce resource failures under the same timeout;
a deliberately false control model instead produces model-verification exit 6.
This joins the original 11,662 passes to cover all 11,676 selected SAT rows.
Original timing and failed-attempt artifacts remain immutable. The aggregate
helper does not yet automatically join this separately recorded diagnostic.
The validation harness now classifies the installed Dolmen 0.10 exit contract
explicitly: exit 2 and the observed Dolmen_loop.Alarm.Out_of_space escape are
dolmen-resource-limit; unrecognized exits and internal bugs are
validator-error; exit 6 is model-error. Only parse/type/header/flow rejection
of the input-only probe is input-nonconforming. That probe uses the same memory
budget and retains both streams alongside the response-check diagnostics.
Unavailable or failed fallback validation is not itself a model refutation.
Strict --require-dolmen-pass gates cannot pass input-nonconforming rows.
validate_model.py --dolmen-size SIZE records and forwards an explicit budget
(standalone default 1G). benchmark_model_replay.py replay defaults to 8G;
the real-corpus SLURM wrapper uses PRIMO_DOLMEN_SIZE (default 8G) with a 12G
allocation, leaving headroom beyond Dolmen’s internal budget. Override the job
allocation consistently when changing this value. Resource/validator errors
leave replay coverage incomplete, separate from confirmed model failures.
Python regressions cover the documented exit classes, the escaped memory alarm,
input-probe exhaustion, same-budget forwarding and retained diagnostics.
Primo’s internal checker had approved all 14 models against the original typed assertions before serialization. Its evaluator tests and an additional diagnostic with deliberately false interpretations pass, including rejection of the earlier inconsistent UF witness. However, internal checking does not reparse the printed model, and no end-to-end session test injecting a corrupted typed model was found. Independent printed-model validation remains necessary; these testing gaps are not closed by a successful resource-limit retry.
Analyzing benchmark shape: use the analyzer, not grep#
scripts/analyze_smt2.py reports the structure of an SMT-LIB file: what is
declared, the atom census classified by shape (unary bound / difference /
two-variable / general linear), per-atom Boolean polarity computed through
not/=>/ite/xor, per-variable one-sidedness, and guard protection (the
“this value is a don’t-care under that flag” shape from
The tropical-matrix family: what the formulas actually say).
python3 scripts/analyze_smt2.py FILE... # human-readable
python3 scripts/analyze_smt2.py --json FILE # machine-readable
python3 scripts/analyze_smt2.py --selftest # verify the analyzer itself
Prefer it over grep for any structural claim. Two real errors motivated
writing it, and both are now regression cases in --selftest:
grep -o "(*" filereported ~12,000 multiplications in a file containing none – in a basic regular expression*is a quantifier, so the pattern matches “zero or more open parens” everywhere.A regex matching only flat atoms silently skipped every nested constraint such as
(<= (+ (* 2 x) y) 5), which is most of whatmiplibandlatendressecontain. It reported 456 of 996danoint-166reals as one-sided; the analyzer reports 0, which is correct.
The design rule that follows: the analyzer never silently ignores what it
cannot parse. Unsupported constructs are counted as opaque and their
variables are forced to the conservative classification (two-sided, mixed
polarity), so an unhandled construct weakens a claim instead of falsifying
it. A nonzero OPAQUE atoms line in a report is a signal to look, not noise.
Validation: on tropical-matrix/constraint-199552.smt2 it independently
reproduces both the previously hand-derived 774 guard-protected variables and
the separately recorded “70-72% of arithmetic atoms are unary or
two-variable” census figure (1,611 of 2,250, 71.6%).
Non-standard unsolved benchmarks#
A benchmark left unsolved because the solver ran out of time is an honest result. One left unsolved because we could not parse it, or refused input the logic permits, is a defect wearing a timeout’s clothes: it costs a benchmark exactly as much, and a solved-count table cannot tell the two apart.
SMT-LIB inputs are well-formed by construction, so any such failure is ours until shown otherwise. Two found in August 2026, both by reading code rather than by any benchmark report:
(/ x 2.0)was rejected although it is linear –(* (1/2) x)– and both z3 and Yices accept it.(set-option :verbosity (- 1))was rejected although SMT-LIB 2.6 allows an s-expression attribute value.
scripts/compare_solver_answers.py now prints an
=== unsolved for NON-STANDARD reasons === section: per solver, grouped by
cause with example files, plus a warning when the count is non-zero. A row
counts as non-standard when its status is neither ok nor timeout and its
answer is not an honest unknown (a solver giving up inside its budget is
legitimate).
Check it before quoting solved counts or PAR2. If a cause is genuinely out of scope – an unimplemented theory, say – record that in writing rather than letting it sit in an aggregate.
Solver-process results are answer plus exit status#
Differential and mutation harnesses must classify the process outcome before
accepting a parsed sat or unsat line. A solver that prints an answer and
then exits nonzero has crashed or otherwise failed; the answer is not a pass.
This applies equally to Primo and reference solvers. Fuzz gates preserve the
input and fail the campaign for such candidate failures or unusable reference
results. tests/python/test_fuzz_solver_exit_classification.py locks this
ordering with fake candidate and reference processes that print sat before
exiting with status 9.
Interesting QF_UF Exact Set#
benchmarks/interesting-qf-uf.txt is the source of truth for the 118-row
interesting.html QF_UF set. Use the manifest workflow for this target:
python3 scripts/bench_interesting_qf_uf.py \
--solver ./build/primo \
--solver-arg=--quiet \
--solver-arg=--qf-uf-profile=tuned-interesting \
--solver-arg=--euf-prop=model \
--solver-arg=--stats \
--timeout 30 \
--manifest benchmarks/interesting-qf-uf.txt \
--out results/interesting-qf-uf.csv \
--summary results/interesting-qf-uf-summary.csv \
--log-dir results/interesting-qf-uf-logs
The script defaults to that manifest unless --discover is passed. Each output
row records the manifest row number, absolute and repository-relative file path,
family, solver arguments, status, answer, elapsed seconds, exit code, per-file
stdout/stderr logs, and any --stats counters emitted before completion. The
runner sends SIGTERM at the timeout and waits briefly before SIGKILL.
Primo’s signal handler only records a cancellation request; the normal solver
control flow observes it through the SAT terminator and returns unknown.
Output and final statistics are therefore produced outside signal context. A
timeout may still be killed before a long non-SAT phase reaches a cancellation
boundary, so timeout rows must not be assumed to contain statistics. The same
parser preserves LLM2SMT [stats] lines as llm2smt.* columns for reference
comparisons.
script uses SMT-LIB :status sat|unsat metadata as the expected answer by
default; pass --no-expected-from-status to disable that and rely only on
--expected. The summary reports total solved, status and answer counts, wrong
answers, PAR2, solved counts by family, and totals for every emitted --stats
counter.
For cluster runs, submit scripts/slurm_interesting_qf_uf.sbatch from a built
remote checkout. It uses a 32-way SLURM array over the exact manifest. Monitor
progress with squeue, shard CSV counts in the result directory, and sacct
after completion; do not run a local sleep loop on the login node.
Before submitting a SLURM benchmark, prepare and validate the remote binary with the repository preflight script:
python3 scripts/slurm_prepare_benchmark_workspace.py \
--ssh-config /tmp/llm2smt_slurm_ssh_config \
--remote-dir <remote-home>/primo-cadical-profile
The script refuses a dirty local tree, installs a git archive snapshot of the
committed source by default, provisions the required cluster-local dependencies
with make contrib-required, rebuilds build-slurm-rel/primo, and checks that
primo --version reports the local commit and a CaDiCaL version. If the cluster
does not provide CMake on PATH, the helper installs CMake into .venv-build
and prepends that environment for dependency and build commands. The archive
sync deliberately avoids copying the local .git directory, build trees, result
trees, fuzz output, and machine-local dependency installations. The remote build
receives the commit through PRIMO_GIT_REVISION_OVERRIDE, so stale generated
outputs in a reused workspace cannot make benchmark binaries report a spurious
-dirty suffix. Use --sync-mode rsync only for a deliberate legacy full-tree
sync.
When heavy compilation must run under SLURM, add --build-via-slurm. The helper
performs its usual clean-source synchronization, then executes dependency/build
commands through a synchronous srun allocation (requested build CPU count,
8G, 30 minutes). It still performs the binary/version preflight after the build;
benchmark submission requires a fresh --check-only as usual. The login host
does not perform compilation in this mode.
If compute nodes lack development headers, --cxx-header-manifest ABSOLUTE_PATH
can reuse a pre-provisioned include-directory snapshot matching the selected
compiler. The helper validates every directory, records the manifest hash,
and supplies system-include flags before CMake configuration, so the resulting
compile commands retain the header provenance. It does not download or mix
headers from a different compiler version.
When a workspace already has a verified cluster-local dependency installation,
pass --reuse-dependencies. The helper preserves contrib/local, installs the
clean tracked source snapshot, removes build-slurm-rel, and rebuilds Primo from
scratch. This avoids repeated CaDiCaL, GMP, and CLI11 provisioning without
risking stale project objects from preserved source timestamps. Before reusing the
installation, the helper checks the concrete CLI11, IPASIR, CaDiCaL, and
GMP headers/libraries required by CMake. If any are absent, it automatically runs
contrib-required instead of starting a doomed build. Explicit
--contrib-target requests can be combined with reuse mode for independent,
idempotent tools such as contrib-yinyang; those targets run after the core
artifact check without rebuilding valid native dependencies. The IPASIR check
accepts either ipasir_up.h or the compatible ipasir.h, matching the
alternatives in CMake; requiring only the former would falsely reprovision a
valid CaDiCaL installation on every synchronization.
When compute nodes lack network access, explicitly request a binary-download
target with --login-contrib-target contrib-opensmt. The preparation helper runs
that target through SSH after source synchronization and before entering the
build allocation. Use this with --reuse-dependencies --build-via-slurm to keep
compilation on compute nodes. Login targets are separate from --contrib-target;
do not request the same download in both lists. A failed download stops preparation,
and --check-only never runs either target list. This option is intended for
small dependency downloads, not compilation on the login host.
scripts/slurm_opensmt_incremental.sbatch adds an external OpenSMT reference to
the native UF archive and the two established LRA BMW sessions. It uses the
unchanged input streams through native_uf_sessions.py timing, with 30-second
UF and 600-second LRA session limits. benchmarks/native-lra-opensmt.json selects
the original first two LRA files and retains their hash-qualified per-query
status oracles. summarize_opensmt_incremental.py joins these runs to preserved
Primo/Z3/cvc5 results, checks overlapping answers and rejects incomplete coverage.
The reference batch explicitly requests --continue-after-error: process errors
remain error rows with PAR2 penalties, but subsequent files are still attempted.
The driver returns failure after collecting such rows. Wrong answers always stop
the shard. This option is limited to external-reference timing (no
--expected-git), and the default remains stop-on-error. Solver version capture
includes stderr because OpenSMT prints its version there. Separate model
validation remains necessary for candidate correctness qualification.
For example, a fuzz workspace that already has the native build dependencies and Z3 but lacks the pinned YinYang environment can be prepared with:
python3 scripts/slurm_prepare_benchmark_workspace.py \
--reuse-dependencies \
--contrib-target contrib-yinyang \
--require-tool .venv-yinyang/bin/typefuzz \
--require-tool contrib/local/bin/z3
contrib-required is only the dependency set needed to build and run Primo. A
benchmark that names reference solvers under contrib/local/bin must either
reuse an existing matching reference result, or prepare the workspace with the
matching reference tool and require it in the preflight check. For Z3:
python3 scripts/slurm_prepare_benchmark_workspace.py \
--ssh-config /tmp/llm2smt_slurm_ssh_config \
--remote-dir <remote-home>/primo-cadical-profile \
--with-z3
python3 scripts/slurm_prepare_benchmark_workspace.py \
--ssh-config /tmp/llm2smt_slurm_ssh_config \
--remote-dir <remote-home>/primo-cadical-profile \
--with-z3 \
--check-only
If the SMT-LIB corpus, timeout, and reference binary are unchanged, prefer the standing practice of reusing the existing reference CSV instead of rerunning Z3 or cvc5 just because a Primo branch changed.
scripts/bench_qf_uf_full.py writes an append-only JSONL journal after every
completed instance, defaulting to <out>.jsonl, in addition to its final CSV
and summary. SLURM result collection should retain this journal: it preserves
all completed rows if a task is cancelled, times out at the scheduler level, or
loses its node before the final aggregate files are written. --journal can
select a different path when a wrapper has a fixed artifact layout.
The local CMake build refreshes a generated git-revision header on every primo
build; uncommitted source changes are reported with a -dirty suffix. Use
--check-only immediately before sbatch when the workspace was already
prepared. Destructive sync/rebuild is refused while active SLURM jobs with Primo
job names are present, because those jobs may be using the same build-slurm-rel
binary. Use a separate remote workdir for overlapping campaigns; reserve
--allow-active-jobs for cases where the active jobs are known to use another
workspace.
Archive-mode preparation intentionally excludes tmp/, so benchmark manifests
that refer to locally extracted SMT-LIB archives need a separate staging step.
Before transferring a large corpus, check for a complete copy in another remote
workspace and verify its file count, total bytes, and path/size manifest hash; a
server-side copy is preferable to another network transfer. Otherwise, use the
staging helper to copy only the listed files into the matching remote root,
without copying .git or unrelated archive content. The helper streams a gzip
archive, extracts into a staging directory before replacing the target, and
reports the path count without printing the full manifest:
python3 scripts/slurm_stage_manifest_files.py \
--ssh-config /tmp/llm2smt_slurm_ssh_config \
--remote llm2smt-slurm \
--remote-dir <remote-home>/primo-cadical-profile \
--root tmp/QF_UFLRA \
--manifest benchmarks/qf_uflra-smtlib-sample-5-per-family.txt
QF_UFLIA representative sample#
benchmarks/qf_uflia-smtlib-representative-20.txt is the fixed development
sample from sandbox/QF_UFLIA.tar.zst. It covers all four top-level corpus
families, the Hash, Wisa, and EufLaArithmetic MathSAT subfamilies, known
satisfiable and unsatisfiable cases where the corpus metadata provides that
classification, two size points, and two Certora inputs whose filename does not
state an expected answer. The Certora contribution is distributed under CC BY
4.0; the remaining files retain the archive’s accompanying metadata and
licenses. The manifest records paths only: external benchmark inputs are not
copied into the repository.
Extract the archive into the ignored benchmark workspace and run the sample with the generic benchmark driver:
mkdir -p tmp/QF_UFLIA
tar --zstd -xf sandbox/QF_UFLIA.tar.zst \
-C tmp/QF_UFLIA --strip-components=2
python3 scripts/bench_qf_uf_full.py \
--root tmp/QF_UFLIA \
--manifest benchmarks/qf_uflia-smtlib-representative-20.txt \
--solver build/primo --timeout 10 \
--out results/qf_uflia-representative-20.csv
The default now combines sparse model guidance, trail-synchronized EUF and
rational arithmetic, and a 512-choice bound. Pair materialization remains
cumulative, care pairs only prioritize decisions, and every typed component
limit restarts the online complete-arrangement backstop. Use
--no-qf-uflia-model-combination --no-qf-uflia-online-components for the
complete control, disable only online components for sparse model guidance, or
disable only model combination for online complete arrangement. Satisfiable
Primo rows must always be replayed with scripts/validate_model.py and Dolmen;
the archive requires --dolmen-arg=--strict=false because several inputs use a
linear coefficient directly on a UF application.
For the eight-way reference comparison, use
scripts/slurm_qf_uflia_sample_compare.sbatch. It runs the current default,
complete control, model-only, online-complete, and explicit sparse-online Primo
routes, plus Z3, cvc5, and OpenSMT, with a 30-second per-file cutoff. On the
pre-promotion exact benchmark commit c9dedcf, the equivalent sparse-online
arm solved 18/20 on the contended SLURM node (8 sat and 10 unsat, PAR2 131.626
seconds); an exact-revision local replay solved 19/20 and Dolmen directly
accepted all 9 models. The complete and model-only controls each solved 11/20,
while online-complete solved 16/20. Z3, cvc5, and OpenSMT each solved 19/20.
All arms had zero wrong answers or unsupported results. A 3,000-case SLURM
differential campaign passed every case and Dolmen directly accepted all 1,765
satisfiable models before promotion.
The exact promoted default at f38446a independently passed 3,000/3,000
generated QF_UFLIA comparisons, with all 1,773 satisfiable models accepted
directly by Dolmen, and solved 19/20 sample rows in an isolated 30-second run.
A real-seed YinYang/typefuzz complement on 66250a4 processed all 10 selected
MathSAT QF_UFLIA seeds with 20 successful mutations per seed, made 388
Primo/Z3 calls, and produced zero bug triggers or bug files. This mutation run
complements rather than replaces the generated campaign; its 20,000-byte seed
limit intentionally selected the MathSAT portion of the representative corpus.
Historically, on exact benchmark commit f1f2525, Z3, cvc5, and OpenSMT each
solved 19/20 files (9 sat and 10 unsat); each timed out only on the larger Certora
case. Default and model-guided Primo each solved 11/20 (4 sat and 7 unsat) and
timed out on the same nine files. There were no decisive-answer disagreements,
errors, or unsupported results. Default/model-guided solved-row time was
65.586/46.284 seconds and PAR2 was 605.586/586.284 seconds. Most of the guided
time gain came from two already-solved MathSAT cases, so this sample shows no
coverage gain and is not evidence for enabling the experimental route by
default.
Direct Dolmen replay of the four satisfiable Primo results exposed a compound
definition reconstruction defect in one MathSAT Wisa case. Commit 71e5e11
fixes that model-only path; Dolmen then accepted all four satisfiable models
from each Primo arm. Two independent 1,000-case generated gates on that exact
fix commit agreed with Z3 and cvc5, and Dolmen directly accepted every one of
the 592 default and 596 model-guided satisfiable models. The benchmark timing
remains evidence for f1f2525: the fix runs only when a model is requested,
after the final accepted assignment, and does not change the search path.
benchmarks/qf_uflia-reference-gap-watchlist.txt retains the eight sample
cases that both Primo routes timed out on while all three references agreed and
finished within five seconds. It is a focused profiling queue, not a
correctness corpus or a claim that the eight cases share one root cause.
The merge-grade generated gate is
scripts/slurm_qf_uflia_model_fuzz_gate.sbatch. Its four default shards produce
3,000 QF_UFLIA cases, compare answers with Z3, and require direct Dolmen
validation for every satisfiable Primo result. PRIMO_FUZZ_SOLVER_ARGS may be
used to exercise either ablation profile.
Use scripts/slurm_qf_uflia_full_solver_compare.sbatch for the complete
SMT-LIB archive. Its 32 tasks run Primo, Z3, cvc5, and OpenSMT over the same
eight deterministic shards with a 60-second per-instance cutoff. Stage every
file from sandbox/QF_UFLIA.tar.zst under tmp/QF_UFLIA first. Each task writes
an append-only JSONL journal as well as its final CSV, so completed rows survive
a task-level interruption. Keep the array size synchronized with the number of
solvers times PRIMO_COMPARE_SHARDS when overriding either setting.
The first full run, on exact solver commit 61cd9ee with a 60-second cutoff,
covered all 659 archive files. Primo solved 543 (379 sat, 164 unsat), versus 618
for Z3, 605 for cvc5, and 601 for OpenSMT. Primo had 116 timeouts, zero wrong
declared-status answers, and no unsupported or error row; all 379 satisfiable
models passed direct Dolmen validation. All four solvers completed TwoSquares.
Primo solved 3/76 Certora, 437/454 MathSAT, and 82/108 wisas files. The solvers
had zero terminal decisive-answer disagreement; all three references agreed on
54 files that Primo timed out on. This is the first Primo full-QF_UFLIA
baseline, so it has no earlier full-corpus Primo result to compare against.
The later fcf5018 comparison separated incremental component growth from
integer-safe preprocessing. With preprocessing still off, Primo solved 549
files and reduced PAR2 from 15,758.31 to 15,146.01. Enabling the basic
integer-safe profile solved 571 files (403 sat, 168 unsat) with 88 timeouts and
PAR2 11,735.74: 22 additional solves and 22.5% lower PAR2 than the same binary
without the profile. The gains were concentrated in MathSAT (441/454) and
wisas (105/108); the stronger numeral-domain AMO profile remains explicit
because the focused ablation contained a marked regression. One wisas row
crossed the 60-second cutoff in the opposite direction, but both configurations
solved it in a paired 120-second rerun. These results promoted the basic profile
only for QF_UFLIA auto; pure QF_LIA keeps its restricted substitution-only
profile.
The exact promoted commit 6bb70aa reproduced all 659 classifications: 571
solved, 88 timeouts, no wrong/error/unsupported row, and PAR2 11,717.76. Its
403 satisfiable archive models all passed Dolmen. The retained reference runs
remain ahead—Z3 solved 618, cvc5 605, and OpenSMT 601—but the promotion closes
22 of the 110 timeouts left by incremental component growth alone. A separate
11-shape Boolean campaign passed 11,000/11,000 answer comparisons and all 6,840
satisfiable models, covering the model-printer boundary that the
mixed-arithmetic generator does not stress.
These corpus cases are an evaluation gate, not part of make check: several
are intentionally beyond Primo’s current short-timeout performance. For cases
without a declared status, an evaluation must establish the expected answer by
agreement between independent reference solvers. Every satisfiable Primo result
must also be validated directly with Dolmen. For remote work, stage the same
manifest with scripts/slurm_stage_manifest_files.py, using
--root tmp/QF_UFLIA.
To stage a complete extracted corpus without first creating a temporary
manifest, replace --manifest ... with --all-smt2. Paths are discovered below
--root and sorted before archiving.
When the corpus already exists in another workspace on the same remote host,
add --remote-source-dir. The helper computes the selected local path/size
fingerprint, sends only the path list over SSH, verifies the remote source has
the same count, total bytes, and fingerprint, and then performs the tar copy
between remote directories. It still builds a staging tree and replaces the
target only after every selected file has been copied:
python3 scripts/slurm_stage_manifest_files.py \
--ssh-config /tmp/llm2smt_slurm_ssh_config \
--remote llm2smt-slurm \
--remote-source-dir <existing-remote-workspace> \
--remote-dir <new-remote-workspace> \
--root tmp/QF_UFLRA \
--all-smt2
If the published interesting.html table is available, pass
--reference-html path/to/interesting.html. The script extracts table text with
standard-library HTML parsing and writes results/interesting-qf-uf-reference.csv
by default, preserving columns for solvers such as llm2smt_sym2, Z3, Yices,
and OpenSMT when those columns are present.
Headline performance reports must separate Goel, NEQ, PEQ, QG, and SEQ solved
counts. --euf-prop=online is excluded from headline numbers until it has zero
errors/crashes and zero wrong answers on the exact set and the QF_UF fuzz gate.
Full QF_UF Archive#
scripts/bench_qf_uf_full.py runs the full extracted sandbox/QF_UF.tar.zst
archive. The expected layout is tmp/QF_UF/<family>/**/*.smt2, produced by
extracting the archive’s non-incremental/QF_UF subtree into tmp/QF_UF.
Use the full archive harness for headline QF_UF runs:
python3 scripts/bench_qf_uf_full.py \
--root tmp/QF_UF \
--solver ./build/primo \
--solver-arg=--quiet \
--timeout 2 \
--shards 32 \
--shard-index 0 \
--out results/qf-uf-full/shard-0.csv \
--summary results/qf-uf-full/shard-0-summary.csv
Pass --manifest path/to/files.txt for a reproducible subset. Manifest rows may
be absolute paths or paths relative to --root; a missing .smt2 suffix is
added automatically. Blank lines and text after # are ignored.
On SLURM, run one shard per array task and merge shard CSVs after all tasks
complete. The CSV records file, family, solver status, answer, SMT-LIB
:status expectation, wrong-answer flag, elapsed seconds, timeout flag, exit
code, a short stderr prefix, and any --stats counters emitted before
completion or during the timeout SIGTERM grace period. The summary records solved, unsupported,
wrong-answer, status, answer, family, and PAR2 counts. Only non-wrong sat and
unsat answers count as solved or as elapsed-time PAR2 successes; unknown,
unsupported, timeouts, and errors contribute 2 * timeout. Any performance
report based on this archive must include the SLURM job id, timeout, commit,
merged CSV path, family summary, unsupported count, wrong-answer count, and
comparison against the previous full-archive Primo run. Because timeout status
takes precedence over child exit status, reports must also inspect
timed_out_with_stats, exit_code, and stderr_head to distinguish a graceful
cancellation from the subsequent forced kill.
Harness status is stricter than recognized stdout. An explicit unsupported
answer has status unsupported, including Primo’s normal exit code 2 for an
unsupported fragment. A nonzero exit with any other answer, or a zero exit
without a recognized answer, has status error. Only a recognized answer from
a normal exit has status ok. The same classification is used by
scripts/bench_interesting_qf_uf.py and is covered by the Python tests run by
make check.
Use scripts/render_benchmark_matrix_html.py to turn one or more merged
benchmark CSVs into the compact detailed-results table used for solver/config
comparisons:
python3 scripts/render_benchmark_matrix_html.py \
--title "QF_UF default vs disequality Ackermann" \
--timeout 30 \
--metadata commit=546738e \
--metadata slurm_job=117301 \
--variant default=results/qfuf-drop-diseqack-117301/default-merged.csv \
--variant diseq_ack=results/qfuf-drop-diseqack-117301/diseq_ack-merged.csv \
--strip-prefix tmp/QF_UF/ \
--strip-suffix .smt2 \
--out results/qfuf-drop-diseqack-117301/matrix.html \
--summary-json results/qfuf-drop-diseqack-117301/matrix-summary.json
The renderer accepts any benchmark CSV with file, status, answer,
seconds, and exit_code columns. It computes solved, timeout/error, wrong,
win, unique, suboptimal, not-run, elapsed-time, and PAR2 summaries when
--timeout is provided. For Primo --stats CSVs, it also prints selected SAT
and EUF counters in each detailed cell and totals them below the table. Add
extra counters with repeated --stat :counter.name arguments when an experiment
needs a narrower mechanism-specific view.
Generated SLURM scripts are part of the benchmark scaffold. Write them to a local file, validate them, then copy and submit:
python3 scripts/slurm_validate_sbatch.py /tmp/primo-qfuf.sbatch
python3 scripts/slurm_prepare_benchmark_workspace.py \
--ssh-config /tmp/llm2smt_slurm_ssh_config \
--remote-dir <remote-home>/primo-cadical-profile \
--check-only
scp -F /tmp/llm2smt_slurm_ssh_config /tmp/primo-qfuf.sbatch \
llm2smt-slurm:<remote-home>/primo-cadical-profile/
ssh -F /tmp/llm2smt_slurm_ssh_config llm2smt-slurm \
'cd <remote-home>/primo-cadical-profile && sbatch primo-qfuf.sbatch'
Use quoted delimiters for embedded interpreter heredocs, such as
python3 - <<'PY'. Do not rely on nested shell quoting for embedded Python in
SLURM campaigns; that has produced immediate array failures before.
After each native full-archive run, rank fast-reference/slow-Primo gaps before selecting a target instance:
python3 scripts/rank_qfuf_gaps.py \
--native results/qf-uf-full/merged.csv \
--ref results/slurm-qfuf-full-ref-z3-109856/z3-qfuf-full-2s-merged.csv \
--ref results/slurm-qfuf-full-ref-cvc5-109857/cvc5-qfuf-full-2s-merged.csv \
--timeout 2 \
--fast-ref 1 \
--top 50 \
--out results/qf-uf-full/fastref-slowprimo.csv
Use the ranked CSV as the triage queue. Prefer clusters of high-ratio rows from the same family over isolated one-offs, and carry the listed solver stats into the local diagnosis.
CaDiCaL Profile Check#
scripts/check_cadical_profile.py is a campaign guard for dependency/version
sensitivity in the SAT backend. It compares Primo’s default CaDiCaL settings
against an incremental profile:
python3 scripts/check_cadical_profile.py \
--solver ./build/primo \
--timeout 10 \
--out results/cadical-profile/current.csv \
--summary results/cadical-profile/current.md \
tmp/QF_UF/QG-classification/qg7/iso_brn_repgen_sk033.smt2 \
tmp/QF_UF/2018-Goel-hwbench/QF_UF_peg_solitaire.5.prop1_ab_reg_max.smt2 \
tmp/QF_UF/NEQ/NEQ041_size6.smt2
The incremental profile sets ilb=2 and disables sweep, congruence,
backbone, condition, elim, vivify, probe, subsume, transred, and
walk. This mirrors the CaDiCaL-version debugging lesson documented in
https://mikolasjanota.github.io/just_count/investigation.html: ilb changed
defaults between CaDiCaL versions, and short-timeout throughput checks exposed
the regression quickly. For Primo this script is not a headline benchmark and
does not change CLI defaults. Use it to decide whether full QF_UF SLURM
campaigns should include a CaDiCaL-profiled variant.
To compare CaDiCaL releases rather than only option profiles, build one Primo binary per CaDiCaL tag:
python3 scripts/check_cadical_versions.py \
--tags rel-1.9.5 rel-2.2.1 rel-3.0.0 \
--timeout 10 \
--out results/cadical-version-profile/profile.csv \
--summary results/cadical-version-profile/profile.md \
tmp/QF_UF/QG-classification/qg7/iso_brn_repgen_sk033.smt2 \
tmp/QF_UF/2018-Goel-hwbench/QF_UF_firewire_tree.3.prop2_ab_reg_max.smt2
This is a dependency check, not a solver algorithm. Record the resulting job,
commands, tags, and artifact paths in docs/run-log.json before citing the
comparison.
QF_UF Parameter Tuning With irace#
scripts/irace/ contains a reproducible irace setup for QF_UF profile tuning.
It is meant for SLURM runs, not workstation benchmarking. The training manifest
is generated from existing benchmark artifacts:
python3 scripts/irace/make_qfuf_tuning_manifests.py
This writes benchmarks/qfuf-irace-train.txt and
benchmarks/qfuf-irace-test.txt. The training set combines current
NEQ/PEQ/SEQ timeouts, solved-but-slow cases against LLM2SMT, and easy guard
instances. The generator interleaves guard, slow-gap, and timeout instances so
early irace races see discriminating costs instead of only uniform timeout
penalties. qfuf_initial.txt seeds the race with the current CLI default as
the first configuration, plus conservative nearby variants; any tuned elite
must beat that seeded default on the holdout before it can be considered. The
target runner uses SMT-LIB :status metadata when available: correct solved
runs cost elapsed seconds, timeouts/errors cost PAR2, and wrong answers receive
a large penalty.
On the cluster, install irace in a user-local environment, then run from the repo root:
export MAMBA_ROOT_PREFIX=$PWD/.micromamba
export PRIMO_TUNE_SOLVER=$PWD/build-slurm-vp/primo
export PRIMO_TUNE_TIMEOUT=30
export PRIMO_TUNE_WRONG_PENALTY=30000
tools/micromamba/bin/micromamba run -n irace \
Rscript -e 'args <- commandArgs(TRUE); args <- args[args != "--args"]; do.call(irace::irace_cmdline, list(args))' --args \
--scenario scripts/irace/qfuf_scenario.txt \
--parallel 4 \
--max-experiments 600
Do not promote an irace elite directly to a default. First validate it on the holdout manifest, the full NEQ/PEQ/SEQ set, and then the full QF_UF archive. Report wrong answers, PAR2, solved counts by family, and the exact generated Primo command line.
QF_UF Gap Tuning With SMAC#
scripts/smac/qfuf_gap_tune.py runs focused SMAC campaigns for current QF_UF
gaps against reference-solver evidence. It uses SMAC’s instance support: each
training manifest row is a separate SMAC instance, the default configuration is
included in the race, and the target function runs one solver invocation per
configuration-instance trial. The validation manifest contains held-out rows
from the same benchmark shape; optional guard manifests record benchmark shapes
that must not regress.
Run it on SLURM, not on a workstation:
python3 -m venv .venv-smac
. .venv-smac/bin/activate
python -m pip install 'smac>=2,<3'
python scripts/smac/qfuf_gap_tune.py \
--repo <remote-home>/llm2smt-fuzz \
--solver <remote-home>/llm2smt-fuzz/build-slurm-rel/primo \
--train benchmarks/qfuf-smac-gap-train.txt \
--validate benchmarks/qfuf-smac-gap-validate.txt \
--out-dir results/smac/qfuf-gap-c2c7e0e \
--timeout 45 \
--validation-timeout 120 \
--trials 80
The same invocation can be submitted through scripts/slurm_qfuf_smac.sbatch
by setting PRIMO_SMAC_TRAIN, PRIMO_SMAC_VALIDATE, PRIMO_SMAC_LABEL,
PRIMO_SMAC_TRIALS, PRIMO_SMAC_TIMEOUT, and
PRIMO_SMAC_VALIDATION_TIMEOUT. Set PRIMO_SMAC_GUARD and
PRIMO_SMAC_GUARD_TIMEOUT when tuning a narrow family while preserving another
family. For QG iso_* tuning, use
PRIMO_SMAC_GUARD=benchmarks/qfuf-smac-equality-guard.txt so the diamond
equality/finite-domain families reject profiles that lose equality-bridge
behavior.
The objective is per-instance PAR2, with wrong answers penalized far above
timeouts. After the SMAC budget is exhausted, the script validates the SMAC
incumbent and the current default on the held-out instances and writes
result.json, incumbent.json, trial-summary.csv, target-runs.csv, and
validation-runs.csv. If a guard manifest is provided, result.json sets
rejected_by_guard when the incumbent has more wrong answers, fewer solved
rows, or higher guard PAR2 than the default.
For profile search, prefer the mixed manifests
benchmarks/qfuf-smac-mixed-train.txt and
benchmarks/qfuf-smac-mixed-validate.txt. They combine QG iso_* gaps,
NEQ/PEQ/SEQ finite-domain equality gaps, and Goel hardware sentinels, so a
candidate cannot optimize only one family while ignoring known regressions.
The narrower benchmarks/qfuf-smac-neq-train.txt and
benchmarks/qfuf-smac-neq-validate.txt manifests are diagnostic for the
NEQ016_size8 and nearby NEQ reference gaps. They deliberately keep
NEQ016_size7 in validation because local evidence shows
--pure-theory-filter=off helps NEQ016_size8 but regresses the size-7
predecessor. Use benchmarks/qfuf-smac-neq-guard.txt as the guard manifest; it
contains PEQ/SEQ equality rows, QG iso_* rows, and Goel sentinels. Do not
promote an NEQ-only incumbent unless it also passes the guard and then validates
on the mixed manifests and broader QF_UF subsets.
Use scripts/slurm_qfuf_phase_compare.sbatch for a narrower default-versus
--theory-phase=auto validation on the mixed train/validation/equality-guard
union. It runs the current default and --theory-phase=auto over the same
de-duplicated manifest, writes one result directory per variant, and is intended
as the fast guard before considering whether the auto phase heuristic is ready
for a broader 334-row or full-QF_UF campaign. Each SLURM array task writes its
own manifest through a temporary file and atomic rename; the manifest must not
be shared across tasks because concurrent truncation can make valid paths appear
missing.
Use scripts/slurm_qfuf_portfolio_compare.sbatch for the matching
default-versus---qf-uf-portfolio=basic-polarity validation. It uses the same
manifest handling and output layout as the phase comparison script, with
PRIMO_PORTFOLIO_* environment variables replacing PRIMO_PHASE_*.
Use scripts/slurm_qfuf_option_compare.sbatch for ad hoc option comparisons
over the same sharded QF_UF harness. PRIMO_OPTION_VARIANTS is a
space-separated list of name=arg1,arg2 records. The default compares
default, --preprocessing-mode=basic, and
--preprocessing-mode=basic,--theory-phase=polarity. This script is intended for
evidence collection before promoting a preprocessing or phase heuristic to the
default profile.
The narrower benchmarks/qfuf-smac-iso-train.txt and
benchmarks/qfuf-smac-iso-validate.txt manifests are diagnostic only. They
include the user-visible QG-classification/qg7/iso_brn_repgen033.smt2 case,
ranked slow iso_brn_nogen_sk* siblings, and iso_icl_repgen006.smt2. Do not
promote an iso-only incumbent; rerun with the mixed manifests, the equality
guard, and then the broader 334-row/full-QF_UF validation. The search space
includes preprocessing strength, finite-domain symmetry/value-precedence and
equality-definition toggles, post-simplification finite-domain AMO, bounded
ground-congruence, EUF engine/propagation settings, EUF explanation search,
theory phase, and the existing CaDiCaL incremental profile.
The first completed campaign for commit c2c7e0e found an incumbent that solves
NEQ015_size6 in 25.36s under the 120s validation cap, while the default timed
out on all seven validation instances. The incumbent did not solve the remaining
six validation cases. Treat this as a diagnostic signal, not a default change:
the winning profile uses aggressive online EUF propagation and the incremental
CaDiCaL profile, so the engineering target is a cheaper, principled propagation
queue rather than exposing another broad default profile.
Baseline worktrees and the sync (learned 2026-08-01)#
slurm_prepare_benchmark_workspace.py rsyncs with --delete, which
includes the remote tree’s .git. That destroys any git worktree
registered under it: .git/worktrees/<name>/ is removed, the worktree’s
own .git file becomes a dangling pointer, and git worktree repair
cannot fix it because the registration is gone rather than stale.
The failure is quiet in the worst way. A git checkout <commit> in the
broken worktree fails, but a build there still succeeds against whatever
source is on disk, and --version reports the commit baked in at the last
configure (the git-revision header is generated at configure time, not
build time). So an A/B can silently compare against the wrong baseline
while its banner claims otherwise – which happened once: a baseline
intended to be pre-H10 was actually pre-H9, so the comparison would have
measured two items and attributed them to one.
What to do:
After any sync, recreate the baseline worktree rather than assuming it survived:
git worktree prune && git worktree add -f <dir> <commit>.Re-run
cmakeconfigure, not just--build, so the version banner matches the checkout.Verify the baseline by content, not only by banner – grep for a marker of the change under test and confirm it is absent from the baseline and present in the new tree. That is the check that caught this.