Harden the king-of-the-hill subnet: build, state integrity, liveness, weight correctness, and the short-generation exploit - #7
Merged
Conversation
…story) Add a Teutonic-style dashboard snapshot the website can poll: a bounded duel-history log in KingState (each verdict: hotkey, accepted, mu_hat, lcb, distances, metric, block), and a pure build_dashboard() that assembles king + king_chain with equal-weight shares + stats + queue + history + chain/duel params. The validator records every duel and publishes dashboard.json to its own bucket each tick and on crown. Pure builder is unit-tested; suite green (163).
The image could not build (Dockerfile copied leoma.py, deleted in 8d5bd4c) and even fixed would crash on startup: chain.toml is read at import but was never packaged, so a non-editable install resolved it into site-packages and raised FileNotFoundError. Move it to leoma/chain.toml, resolve via importlib.resources, and declare it as package-data. Split the image in two (slim validator, CUDA Dockerfile.eval with the [eval] extra) since compose ran the eval server from an image with no torch. numpy moves to core deps (it is imported on the validator path, not just under [eval]); the retired-stack aiosqlite is dropped. The CI path filter watched the deleted leoma.py instead of leoma/**, and there was no test workflow at all — add one whose three jobs map onto exactly these bug classes (pytest, wheel import-safety outside the source tree, docker build smoke). Verified by installing a real wheel in a clean venv. Suite green (169).
A transient bucket failure was indistinguishable from an empty bucket: JsonBucketStore.get swallowed every exception and returned None, so KingState.load produced BLANK state on an outage — re-seeding genesis, re-dueling every past challenger, wiping history — and the next flush overwrote the good bucket state with the blank one. get now distinguishes a genuine miss (S3 NoSuchKey) from an error (StoreUnavailable/StoreCorrupt) with bounded retries, and load refuses to start on a partial or failed read rather than falling back to blank; main() exits for the supervisor unless LEOMA_FORCE_FRESH_STATE=1. State is now one canonical atomically-PUT object (state/state.json, schema v2) instead of five non-atomic keys that could leave king updated but seen/history stale; the old keys are still mirrored and migrate transparently. Every Minio call moves to a worker thread (they blocked the async loop) and the clients now get explicit urllib3 pools — minio's default is 300s x 5 retries, so a hung socket could stall the validator for ~25 minutes. Suite green (184).
process_challengers returned (not continued) on any duel error, so a single challenger whose repo 404s or whose weights crash the pipeline permanently blocked every later challenger — a free griefing vector. It now continues past a failed challenger, and only breaks on BUSY, which is a property of the eval server rather than of any one model. Because the chain is the queue, failure handling is an attempt ledger over a stateless work list rather than a durable queue: failures.py classifies an error as BUSY / TRANSIENT / PERMANENT (defaulting to TRANSIENT, since a bad transient costs four retries while a bad permanent locks a legitimate miner out), and KingState tracks attempts per hotkey|digest with block-based backoff. Quarantine is artifact-scoped, so a miner who fixes the model gets a clean slate, and only a hotkey with several quarantined digests is dropped at scan time — which finally wires reveal_scan's dead blacklist hook. dispatch_duel now raises EvalBusy vs EvalJobFailed instead of returning None for both, so a broken duel is no longer mistaken for a busy server; a verdictless stream is an explicit transient failure. stats.failed is incremented for the first time and quarantined challengers finally produce the error history row the frontend has been typed for since day one. The unopposed-crown path is deleted: with no king and no seed_digest we burn to UID 0 rather than crowning an unevaluated model. Also drop the validator __init__ re-export that shadowed the main module. Suite green (210).
set_weights: bittensor returns (success, message) and its rate-limit path returns False WITHOUT submitting an extrinsic, but the return value was discarded — so a no-op advanced last_weight_block as if the weights had landed, blocking retries for a full WEIGHT_INTERVAL (~1h) and misreporting the persisted state. We now inspect the result, distinguish a rate-limit no-op from a genuine failure, only advance on real success, back off on failure, and force a weight-set at startup. Since the chain is the queue, it is also the weight clock: ask the chain whether it is too soon rather than trusting our own bookkeeping. Metrics: _align truncated BOTH arrays to the shorter one, so a 1-frame generation was scored against a 1-frame truth — and frame 0 of the truth is exactly the conditioning frame the model was handed. Every metric, including the production default lpips, returned a near-perfect score for a model that emitted one frame; flow/clip/temporal made it worse by returning a literal 0.0. A generation shorter than the truth is now rejected (longer is still truncated), and the guard sits above the lazy imports so it is enforced with no torch/cv2 installed. Two existing tests asserted the vulnerable behavior and have been inverted. Suite green (244).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The first hardening tranche on top of the king-of-the-hill re-architecture (#6): make the package actually build, then fix the correctness bugs that make the subnet untrustworthy. Plus the dashboard publisher, which had not been merged.
Every fix here is Leoma-native — shaped by what Leoma actually is (slow multi-GB diffusers duels, a live corpus, a reference-distance metric), not transplanted from the reference subnet. The organizing principle throughout: the chain is the queue. Leoma has no persistent work queue — the challenger list is re-derived every tick from
get_all_revealed_commitments— so the answer to failure is never a durable queue, it's a decision function over a stateless work list.1. It did not build, and it was not tested (
21e27ca)Dockerfilecopiedleoma.py, deleted in8d5bd4c— the image could not build at all.chain.tomlwas never packaged, yetchain_configopen()s it at import, resolving intosite-packages/in a non-editable install ⇒ both services would have crash-looped on startup even once the build was fixed. It now lives atleoma/chain.toml, resolves viaimportlib.resources, and is declared aspackage-data— it is consensus-critical, so the pin must travel with the code.Dockerfile.eval(CUDA base + the[eval]extra). Both now fail at build time if the package can't import.numpywas only in[eval]but is imported at module scope on the validator path.leoma.pyinstead ofleoma/**, and there was no test workflow at all. Added one whose three jobs map 1:1 onto exactly these bug classes:pytest; wheel import-safety from outside the source tree; docker build smoke.2. Silent state destruction (
60e8ef6)JsonBucketStore.getswallowed every exception and returnedNone, so a transient bucket outage was indistinguishable from an empty bucket:KingState.loadreturned blank state — re-seeding genesis, re-dueling every past challenger, wiping history — and the next flush overwrote the good bucket state with the blank one.getnow distinguishes a genuine miss (S3NoSuchKey) from an error (StoreUnavailable/StoreCorrupt), with bounded retries.loadrefuses to start on a partial or failed read;main()exits for the supervisor unlessLEOMA_FORCE_FRESH_STATE=1. A validator that cannot read its own state must not run.state/state.json, schema v2) instead of five non-atomic keys that could leave the king updated but seen-set and history stale. Legacy keys are mirrored and migrate transparently.urllib3pools — minio's default is 300 s × 5 retries, so one hung socket could stall a validator for ~25 minutes.3. One bad model wedged the whole subnet (
32651d1)process_challengersdidreturn(notcontinue) on any duel error, so a single challenger whose repo 404s permanently blocked every later challenger — a free griefing vector, and nothing was ever recorded as failed.failures.pyclassifies BUSY / TRANSIENT / PERMANENT, defaulting to TRANSIENT — a bad transient costs four retries, a bad permanent locks a legitimate miner out. (Auth errors are deliberately not permanent: our own token misconfig would otherwise quarantine every miner on the subnet.)hotkey|digestwith block-based backoff — the chain re-supplies the work item for free next tick, so we only need to supply the memory. Quarantine is artifact-scoped, so a miner who fixes the model gets a clean slate; it is never a ban on a person. Only a hotkey with several quarantined digests is dropped at scan time — which finally wiresscan_reveals' long-deadblacklist=hook.dispatch_duelnow raisesEvalBusyvsEvalJobFailedinstead of returningNonefor both, so a broken duel is no longer mistaken for a busy server; a verdictless stream is an explicit transient failure rather than silence.stats.failedis incremented for the first time, and quarantined challengers finally produce theerrorhistory row the frontend has been typed for since day one.seed_digest, the first reveal was crowned with no duel and no validation. We now burn to UID 0 instead.4. Weights, and the freeze cheat's root (
cd22b2c)set_weights' return value was discarded. bittensor returns(success, message), and its rate-limit path returnsFalsewithout submitting an extrinsic — so a no-op advancedlast_weight_blockas if the weights had landed, blocking retries for ~1 h and misreporting state. The result is now inspected (a rate-limit no-op is not a genuine failure),last_weight_blockonly advances on real success, failures back off exponentially, and a set is forced at startup. And since the chain is the queue, the chain is also the weight clock (blocks_since_last_update) — which makes "state says the weights landed but they didn't" structurally impossible.The short-generation exploit.
metrics._aligntruncated both arrays to the shorter one, so a 1-frame generation was scored against a 1-frame truth — and frame 0 of the truth is the conditioning frame the model was handed:flow/clip/temporalmse/ssimlpips(the production default)Every metric, including the default, handed a near-perfect score to a model that emitted one frame. A generation shorter than the truth is now rejected (longer is still truncated to the truth, which stays authoritative), and the guard sits above the lazy torch/cv2 imports so it is enforced — and unit-testable — with neither installed. Two existing tests asserted the vulnerable behavior and have been inverted.
5. Public dashboard (
3d1d832)Duel history in
KingState, a purebuild_dashboard(), anddashboard.jsonpublished to the validator's own bucket each tick and on crown: king + 5-deep king chain with equal-weight shares, stats, queue, history, and the chain/duel params. No API, no database — the same bucket the state already lives in.Tests
244 passing, up from 163. Each headline bug has a pinned regression test:
test_transport_error_raises_not_none,test_load_refuses_partial_state— the blank-state overwritetest_one_failing_challenger_does_not_block_the_rest— the head-of-line blocktest_rate_limited_no_op_does_not_advance— the bittensor no-optest_one_frame_generation_is_rejected_by_every_metric— parametrized over all six metricstest_no_king_no_seed_burns_and_crowns_nobody— the unopposed crownVerified end-to-end by building a real wheel and importing it from a clean venv outside the source tree.
Pin
chain.toml [seed].seed_digest. Now that the unopposed-crown path is gone, a subnet with no king and no seed digest burns 100 % to UID 0 rather than crowning an unevaluated model. That is the correct safe behavior, but it is an emission consequence: the seed must be pinned with or before this change.Next in the series
Corpus manifest + chain-pinned consensus surface, eval-server resilience (watchdog, SSE replay, in-flight slot), GPU cache/teardown/eviction, the freeze-baseline gate, the diffusers config-lock, and rate limiting.