Skip to content

fix(round): three failures that each cost a whole round's weights without looking broken - #237

Merged
ai-hpc merged 1 commit into
mainfrom
ai-hpc/f4-f6-round-robustness
Sep 9, 2026
Merged

ai-hpc merged 1 commit into
mainfrom
ai-hpc/f4-f6-round-robustness

Conversation

@ai-hpc

@ai-hpc ai-hpc commented Sep 9, 2026

Copy link
Copy Markdown
Member

Audit findings F4, F5, F6. None is reachable by an outsider; each silently loses a round's payout while everything looks healthy.

F4 — the compose window was one block wide

The compose required block_offset == weight_set_offset exactly. A tick landing on 6601 instead of 6600 meant the round was never composed at all — the validator re-asserted the previous round's weights for a full day while fresh scores sat unused. A GC pause, a slow fetch, a restart, or a benchmark run that overruns all cost a round.

offset 6599 -> reassert
offset 6600 -> compose_and_set
offset 6601 -> compose_and_set     (was: reassert, and never composed again)
offset 7199 -> compose_and_set

Widening is safe because the compose was already idempotent per round via last_composed_round — the first qualifying tick composes, every later one falls through to the keep-alive. There's a test for that, since it's the property the widening depends on.

F5 — a backend outage took down the keep-alive

Benchmarking ran before the weight action and its failure raised out of step(). So a backend the validator merely reads from could stop it discharging a chain obligation that has nothing to do with the backend — and the chain zeroes a validator that goes quiet.

The module's own comment already said "a failed report must not stop the weight obligation". Only the control flow disagreed.

Now the failure is carried on the state, the weights still go out, and reported_round is not advanced so the next tick retries. The existing test asserted the old behaviour — it had encoded the bug — and is corrected with the reasoning in place.

F6 — infrastructure failure was reported as real zeros

Two failure modes, opposite treatment: a validator out of time abstained, while a validator with a dead Docker daemon scored every miner zero, reported as evaluated. One broken validator dragged the whole field down for a fault that says nothing about any miner.

BenchmarkUnavailable now separates "I could not run the differential" from "the PoC did not reproduce":

situation before after
daemon dead / image never pulled / disk full / no docker binary — reads as a failed PoC abstain
PoC genuinely doesn't reproduce — still the miner's result
malformed proof or PoC zero, evaluated zero, evaluated

A missing image matters especially now that the corpus is pulled per round: an image that never finished downloading is our failure, and used to score the whole field zero on that task.

One unrunnable task marks that miner unevaluated even when its others succeeded — a partial score reported as evaluated is a number we know to be too low.

141 v2 round tests pass; ruff clean.

…hout looking broken

Audit findings F4, F5 and F6. None is exploitable by an outsider; each silently loses a round.

**F4 — the compose window was one block wide.** The compose required
`block_offset == weight_set_offset` EXACTLY, so a validator whose tick landed on 6601 instead of
6600 never composed that round at all: it kept re-asserting the previous round's weights for a
full day while a fresh set of scores sat unused. Anything that shifts a tick — a GC pause, a slow
fetch, a restart, a benchmark run that overruns — cost a round's payout.

It now composes at the first block AT OR AFTER the offset. Widening is safe because the compose
was already idempotent per round through `last_composed_round`: the first qualifying tick composes
and every later one falls through to the keep-alive. `is_weight_set_block` stays for callers that
genuinely mean "is this THE block", like a dashboard marking the point.

**F5 — a backend outage took down the keep-alive.** Benchmarking ran before the weight action and
its failure raised out of `step()`, so a backend the validator merely READS from could stop it
discharging a chain obligation that has nothing to do with the backend — and the chain zeroes a
validator that goes quiet. The module's own comment already said this must not happen; only the
control flow disagreed.

The failure is now carried on `RuntimeState.last_benchmark_error`, the weight action still runs,
and `reported_round` is deliberately not advanced so the next tick retries. The test that asserted
the old behaviour asserted the bug, and has been corrected with the reason written down.

**F6 — infrastructure failure was reported as real zeros.** A validator that ran out of time
abstained; a validator whose Docker daemon was dead scored every miner zero and reported those
zeros as EVALUATED, so one broken validator dragged the whole field down for a fault that says
nothing about any miner.

`BenchmarkUnavailable` now separates "I could not run the differential" from "the PoC did not
reproduce", and `evaluate_round` turns the first into an abstention exactly as it already does for
the deadline. The seam raises it for a dead daemon, a missing image (one that never finished
pulling is OUR failure), a full disk, and a missing docker binary — all of which previously
returned False and read exactly like a PoC that did not work. A malformed proof or a broken PoC is
still the miner's result and still scores zero.

One unrunnable task marks that miner unevaluated even when its other tasks succeeded: a partial
score reported as evaluated is a number we know to be too low.

141 v2 round tests pass; ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ai-hpc

ai-hpc commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

Self-review before merge (owner-directed; posted for post-merge audit)

All seven checks pass on 3.11 and 3.12.

What I checked hardest, per finding:

F4 — widening the window is only safe because the compose is idempotent per round, so that property now has its own test rather than being assumed. is_weight_set_block is kept for callers that genuinely mean "is this THE block" (a dashboard marking the point), and past_weight_set_offset is the one the decision uses; keeping both named apart stops the next reader collapsing them.

F5reported_round is deliberately NOT advanced on failure, so a transient outage costs the round its verdicts for one tick rather than permanently. A test pins that, because advancing it would look like the obvious tidy-up and would silently drop a round's benchmarking.

F6 — the discrimination is the whole fix, so both directions are tested: a dead daemon, a missing image, a full disk and a missing binary all abstain; a PoC that genuinely does not reproduce, and a malformed proof, still score zero as the miner's result. One unrunnable task marks that miner unevaluated even when its others succeeded — a partial score reported as evaluated is a number we know to be too low.

Worth flagging: the pre-existing test test_a_failing_report_raises_rather_than_silently_skipping asserted the F5 behaviour as correct — the bug was written down as the specification. That is the second such case in this lane (the burn test asserted calls == [{}]). I corrected it with the reasoning in the docstring rather than flipping the assertion silently, so the next reader sees why it changed.

Not closed here: F7 (restart reopens the duplicate-agent gate), F8 (handle positions do not rotate), F10 (submit accepts any round_id), the fee's chain_check, and on-chain set_weights.

141 v2 round tests pass; ruff clean.

@ai-hpc
ai-hpc merged commit 8f38e82 into main Sep 9, 2026
9 checks passed
@ai-hpc
ai-hpc deleted the ai-hpc/f4-f6-round-robustness branch September 9, 2026 12:02
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