Skip to content

Implement CI: test, check lean, fuzz, bench - #100

Open
imlvts wants to merge 27 commits into
Adam-Vandervorst:masterfrom
imlvts:ci-self-hosted
Open

imlvts wants to merge 27 commits into
Adam-Vandervorst:masterfrom
imlvts:ci-self-hosted

Conversation

@imlvts

@imlvts imlvts commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

Here's how it looks like:

Fuzzing result: imlvts#2 (comment)
Benches result: imlvts#1 (comment)

imlvts and others added 24 commits September 15, 2026 22:39
Workflow runs on the self-hosted runner: a test job (release build,
cargo test with default and arena_compact+random features, docs) and a
bench job that benchmarks the PR head against its base on the same
machine.  bench_ab.sh builds both sides once, runs them in alternating
order pinned to one core, averages the rounds with bench_avg_files.py
and posts the bench_cmp.py table to the step summary and the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
…ning

bench_ab.sh failed on the runner with exit 101 because act_paths declares
required-features (arena_compact, serialization) and the script built
without them.  It now reads each side's Cargo.toml and passes the features
the requested benches need, and prints the cargo error into the step log
when a build fails instead of leaving it in a temp file.

The script also appends to progress.txt after every run and rewrites
compare.txt after every completed round.  The workflow runs it in the
background and, once a minute, posts progress plus the compare table of
the rounds finished so far to one PR comment via pr_comment.py (stdlib
only).  The comment is found by a marker so re-runs and later pushes
reuse it, and its id is cached per run so the periodic updates skip the
lookup.  This replaces the sticky-comment action.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
…th results

The per-minute progress updates duplicated the job log.  The comment now
carries a link to the specific job (looked up from the run's jobs as the
one in progress on this runner, falling back to the run link) and, when
the bench finishes, the compare table.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
… run

The table for a bench is averaged over the rounds finished so far and
saved as cmp-<bench>.txt; compare.txt is the concatenation, rewritten at
the end of every round.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
The crate on master has 44 new divergences from the Lean model on the
seed-7 corpus, so "zero divergences" cannot be the bar.  fuzz_ab.sh runs
head and base on identical inputs (model vs crate, 20000; ACT read side,
5000) and fails only when head diverges on an input base did not.  Both
sides use head's differential/ and lean/, so only the crate under test
differs; if base cannot be built with head's harness its own is tried,
and with no baseline at all the job reports that and does not gate.

Two enabling fixes: the differential harness did not compile since the
ZipperValues/ZipperValuesAt split (ReadSource now requires
ZipperValuesAt<u64>), and differential.py accepts PATHMAP_TRACE /
PATHMAP_ACT_TRACE to find binaries built into another target dir.

The fuzz job runs after the tests, on PRs and manual runs.  Lean is
installed through elan for the runner user; lake's build dir is kept in
the runner's cache since checkout wipes ignored files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
Same inputs, env vars and output files as the bash versions, which were
mostly embedded Python anyway.  bench_ab.py imports the divan helpers
from benches/ instead of shelling out to bench_avg_files.py and
bench_cmp.py.  Standard library only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
bench_ab.py now also writes summary.md and summary.json after every
round: one row per bench (cases, geometric mean, largest gain and loss,
counts beyond 5%) and a collapsed list of the cases that moved more than
5%.  The PR comment and the run summary use it; the full per-bench
tables stay in the job log and the bench-out artifact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
The job stays green; the run shows the annotation and the summary names
the inputs.  FUZZ_STRICT=1 restores the hard failure (with an error
annotation).  A run that does not finish still fails the job.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
For up to FUZZ_REPROS (3) newly diverging inputs with distinct
first-differing operations, fuzz_ab.py runs lean/shrink.py on the saved
input and `pathmap_trace --repro` on the result.  The summary gets a
collapsed block per input with the differing trace lines and the Rust
program; the .min.bin and .rs files go to fuzz-out/repro/ in the
artifact.  shrink.py accepts PATHMAP_ORACLE / PATHMAP_TRACE /
PATHMAP_ACT_TRACE like differential.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
The toolchain step resolves lean/lean-toolchain through elan (which
downloads it when missing) and prints the versions; fuzz_ab.py reports
each lake and cargo build with its duration instead of only on failure.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
A clean runner has neither.  Every job's toolchain step now installs
rustup into $HOME if absent and puts it on the PATH; the fuzz job does
the same for elan and resolves lean/lean-toolchain there, which fetches
the pinned Lean.  Verified from an empty home with a bare PATH: rustup,
stable Rust, elan and Lean 4.33.1 all arrive in about 20 seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
Each bench's base and head runs still share one core back to back, but
different benches run at the same time on different cores from
BENCH_CPUS (default: one SMT thread per physical core, every other core,
at most 16, which spreads them over the L3 complexes).  A round's wall
time is now that of its slowest bench pair rather than the sum.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
An A/A run (same commit on both sides) reported 5-25% differences on
superdense_keys and binary_keys cases, consistent across rounds, with
the two sides pinned to the same core.  The sides were different
binaries: built at different paths into different target dirs, they got
different crate hashes and code layout.  Now one worktree is used for
both: check out base, build, copy the bench executables out; check out
head, build, copy out.  The historical bench run did the same and had a
round-to-round CV under 1% on those benches.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
`lake build` type-checks the theorems in Spec.lean and evaluates every
#guard in Check.lean, so a broken law or regression fixture fails the
job.  Lean only warns on sorry, so the job greps the build log and the
model sources for it (and for axioms) separately.  Lake's build dir is
kept in the runner's cache; a warm run takes seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
pr_comment.py takes --id / --title / --dir so each job owns one comment
per PR (bench keeps its marker and defaults).  The fuzz job posts its
summary after every run: verdict, per-mode tables, the inputs that
diverge on head but not on base, and the shrunk Rust reproducers in
collapsed blocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
Its findings are warnings, and a PR with failing tests is one whose
divergences are worth seeing.  The bench job still needs both to pass.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
One row per first-differing operation (or final-state mismatch) with
the input count and a first example, instead of up to 50 inputs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
fuzz_ab.py writes a findings file when head has new divergences or a
run did not finish; pr_comment.py --create-only-if creates the comment
only then.  An existing comment is still updated by a clean run, so it
does not keep showing stale findings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
The comparison diffs only the unclassified divergences, so inputs that
moved between the known-bug bucket and agreement were invisible: a base
with 826 known hits and a head with 221 showed 44 new on both sides and
nothing fixed.  The summary now has a legend and a table of known
classes whose hit counts differ between base and head.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXEKcXZn55RYJY7BSJuYeS
imlvts and others added 3 commits September 15, 2026 22:51
differential.py derived the agreement count by subtracting from the
requested input count, so when --max-fails stopped the run early every
input that was never executed was scored as agreeing.  The error grows
with the number of divergences, which is backwards: over 3000 inputs at
seed 7, --max-fails 3 classified 846 inputs and reported 2987/3000
agree -- more than the 2959/3000 an exhaustive run finds over the same
set.  --max-fails defaults to 10, so hand-run invocations were all
affected; fuzz_ab.py passes --max-fails 0 and was exact.

Count the inputs actually classified and report against that, naming
the shortfall on a line of its own so the summary line's format, which
fuzz_ab.py parses with SUMMARY_RE, is unchanged.

A denominator below the requested count now counts as an unfinished run
in fuzz_ab.py, which fails the job.  A truncated run previously still
printed a summary line and passed as OK, even though the two sides stop
after their own scheduling-dependent prefix of the inputs: comparing
failure sets drawn from different input populations is not a gate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every skip rendered as a bare `skip`, so a trace said that something
declined the operation but not what: the ACT read source refusing to be
a merge source, a degenerate `k = 0`, an empty focus and a quarantined
op were all the same token.  Each site now emits `skip:<reason>` from a
vocabulary defined once on each side -- `SKIP_*` in harness.rs, `skip*`
in Fuzz.lean -- and the two must agree exactly, since a token that
differs makes every input that skips diverge.

Naming them turned up an ordering bug the bare token had hidden.  For
`restricting` the model tested ACT mode first and the harness tested the
empty focus first, so in ACT mode with an empty focus the two took
different branches and agreed only because both printed `skip`.  The
model now checks the guards in the harness's order; the harness reaches
its ACT skip only by calling `do_restricting`, which it does not do once
the empty-focus guard has fired, so that is the order that can be
matched.  `meet_k_path_into` splits `meetKPathUnspecified` into its two
disjuncts on both sides for the same reason.

Behaviour is otherwise unchanged: 20000 crate inputs and 5000 ACT inputs
at seed 7 give the same agree, known and new counts as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
At seed 7 the run reported 44 unclassified divergences on 20000 crate
inputs and 3 on 5000 ACT inputs.  All of them are instances of five
defects the 2026-09-11 triage had already shrunk into lean/corpus/ but
that were never added to KNOWN, so every run rediscovered them and a
genuinely new defect would have had to be spotted among them.

They resisted the existing op-name keys because the op that reports one
is not the op that caused it: value bias is introduced by a merge but
observed by whichever of `dump`, `val_at` or the final map dump next
reads the location, and it turned up under six different op names.  So
`divergence_shape` keys them on which fields of the trace line moved --
status only, a child count that grew, a value count that shrank, a value
that changed, the focus value returned for a path the model says is
empty -- and the KNOWN entries match those tags.  They are tested last,
so every op-specific entry above still wins.

Returning "no familiar shape" is the case that matters, and the shapes
are deliberately narrow: a value appearing or vanishing is not value
bias, a location moving in a dump is not, and a bool return flipping is
not, which is why the value-bias rule for `ret` is confined to the ops
whose return is a value rather than a flag.  A compound divergence on
seed 10 (`meet_k_path_into` flips its bool, drops a value and gains a
child at once) is still reported as new, which is correct.

Also fixes a misattribution: the ACT val_count shape entry sat below the
op-name entries, so a val_count-only difference landing on a
`to_next_val` line was blamed on the `to_next_val` class -- 131 of 5000
ACT inputs at seed 7.  It is tested first now.

Seed 7 goes from 44 and 3 new to 0 and 0 with the agree counts
unchanged, seeds 0-3, 11 and 99 from 1-2 new to 0, and all 14 corpus
reproducers classify as the class their filenames name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant