Skip to content

Ask every miner the same question, and stop paying for the easy tier - #3

Open
bitzic wants to merge 9 commits into
mainfrom
fix/scoring-parity-and-incentives
Open

Ask every miner the same question, and stop paying for the easy tier#3
bitzic wants to merge 9 commits into
mainfrom
fix/scoring-parity-and-incentives

Conversation

@bitzic

@bitzic bitzic commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Three defects in the path that ranks and pays miners, all visible in the two scored rounds.

PROMPT PARITY. HFRunner wrapped every prompt as a user turn; GGUFStudentRunner has no chat path at all. So a safetensors submission and a GGUF submission were asked different questions and then ranked against each other, and the parent's reference step was produced the safetensors way — GGUF miners were compared to a prompt they never saw. A trajectory prefix is a transcript to be continued, not a message to reply to, so continuation is also the semantically right reading.

  • new runners.continuation(): asks for a raw continuation from any runner, and is a no-op for the ~15 sim runners and doubles that never had a chat path. Inspects the signature rather than catching TypeError, which would swallow a TypeError raised from inside a runner
  • routed observer_round (both legs), the freeze, ladder_probe and batch_invariance through it; the last two compare regenerated steps against scored ones and would otherwise report drift that was entirely their own

DEGENERACY. Round 2's ternary tier took four entries and three were broken; one emitted subword salad, another collapsed to 0 0 0 0. degeneracy_flags was imported by the live loop and never called, and the floor that did apply was retention_lb > 0.02, so only one miner uploading a working model kept a broken quantiser off the throne.

  • call it, and extend it: the salad defeats the n-gram and length tests completely. Repeated-character runs catch it; script-mixing was the tempting signal and is wrong, since a genuine Chinese step in this corpus switches script more often than the salad does
  • validated against the real recorded steps of all 11 submissions, vendored as a fixture: 3 rejected, 8 kept, no false positives on markdown, tables, code, Hindi, Chinese or arithmetic

EMISSION. Tiers paid equally and Tournament.weights normalised over tiers that HAD a king, so the two empty tiers funded the two occupied ones and sub4 — one llama-quantize invocation — collected half of everything. An equal split predicts exactly the field we drew: 9 sub4, 6 ternary, nothing in binary or sub2.

  • difficulty-weighted shares, binary 0.40 down to sub4 0.10
  • an unclaimed tier's share is no longer redistributed; weights() may sum to less than 1 and unclaimed() reports the remainder
  • carried through the record and burned at set_weights, without which the renormalisation would have handed it back and the change would be cosmetic
  • unclaimed is SPARSE: both anchored records still digest to what they signed

bitzic and others added 9 commits August 11, 2026 14:45
Three defects in the path that ranks and pays miners, all visible in the two
scored rounds.

PROMPT PARITY. HFRunner wrapped every prompt as a user turn; GGUFStudentRunner
has no chat path at all. So a safetensors submission and a GGUF submission were
asked different questions and then ranked against each other, and the parent's
reference step was produced the safetensors way — GGUF miners were compared to a
prompt they never saw. A trajectory prefix is a transcript to be continued, not
a message to reply to, so continuation is also the semantically right reading.

- new runners.continuation(): asks for a raw continuation from any runner, and
  is a no-op for the ~15 sim runners and doubles that never had a chat path.
  Inspects the signature rather than catching TypeError, which would swallow a
  TypeError raised from inside a runner
- routed observer_round (both legs), the freeze, ladder_probe and
  batch_invariance through it; the last two compare regenerated steps against
  scored ones and would otherwise report drift that was entirely their own

DEGENERACY. Round 2's ternary tier took four entries and three were broken; one
emitted subword salad, another collapsed to `0 0 0 0`. degeneracy_flags was
imported by the live loop and never called, and the floor that did apply was
retention_lb > 0.02, so only one miner uploading a working model kept a broken
quantiser off the throne.

- call it, and extend it: the salad defeats the n-gram and length tests
  completely. Repeated-character runs catch it; script-mixing was the tempting
  signal and is wrong, since a genuine Chinese step in this corpus switches
  script more often than the salad does
- validated against the real recorded steps of all 11 submissions, vendored as
  a fixture: 3 rejected, 8 kept, no false positives on markdown, tables, code,
  Hindi, Chinese or arithmetic

EMISSION. Tiers paid equally and Tournament.weights normalised over tiers that
HAD a king, so the two empty tiers funded the two occupied ones and sub4 — one
llama-quantize invocation — collected half of everything. An equal split
predicts exactly the field we drew: 9 sub4, 6 ternary, nothing in binary or
sub2.

- difficulty-weighted shares, binary 0.40 down to sub4 0.10
- an unclaimed tier's share is no longer redistributed; weights() may sum to
  less than 1 and unclaimed() reports the remainder
- carried through the record and burned at set_weights, without which the
  renormalisation would have handed it back and the change would be cosmetic
- `unclaimed` is SPARSE: both anchored records still digest to what they signed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The crowns live in miner repos with 116 and 119 downloads, named after a miner,
with nothing saying they are netuid 40's champions. `main` happens to point at
the scored commits today; the miner can push over it whenever they like.

- eval/publish_champions.py mirrors every occupied throne into one repo, one
  GGUF per tier, the same shape as any multi-quant repo so PocketPal and Enclave
  list both and a user picks by size
- it VERIFIES rather than copies: each artifact is fetched at the pinned
  revision from the record, re-hashed with identity.content_hash, and published
  only if it matches the record's model_id. Confirmed end-to-end against the
  live ternary crown — 2.43 GB fetched, hash reproduced exactly
- refuses an artifact_uri with no pinned revision; skips a tier already
  published at the same model_id; dry-run by default, --push to upload
- champions.json records source repo, revision, round, retention and miner, so
  a reader can fetch the original and check it themselves
- generated README, because a repo holding one file per tier needs a page about
  the choice between them rather than a card about one artifact

koth.kings_from_events: `hold` counts as an occupied throne. model_card
.from_record scanned only crown/dethrone and kept the last it saw, so a round
that held both crowns raised "no crowned challenger", and with several tiers
live it wrote one model's card for every tier. Now per-tier.

publish_champions prefers the incumbent row when a crown was held: the same
bytes scored 0.3030 as challenger and 0.2875 as incumbent in round 2, and the
re-score is the number the crown decision used.

test_a_sparse_field_cannot_move_an_already_published_digest is now generic over
_SPARSE — it reconstructed the legacy payload by deleting one hard-coded key, so
the second sparse field broke it while the property still held.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Option B: weights follow product value, not difficulty. The goal is a model on
people's phones, and sub2 is the only tier that produces one.

- no burn. A subnet writing part of its weight vector to a burn uid reads as
  taxing its own miners. What fixed the original problem was the weights being
  unequal, not withholding emission: the same field that paid sub4 50% under an
  equal split now pays it 37%, and taking sub2 moves a miner to 50%
- unclaimed() is reporting only, so a record can say "a four-tier schedule paid
  two tiers" instead of leaving a concentrated payout looking accidental
- sub2 cap 2.0 -> 2.3. Q2_0 group-64 is 2.25 bpw, so at 2.0 the one
  phone-native format could enter no tier but sub4, where models carrying twice
  the bits beat it. A tier built for a format that could not enter it
- weights: sub2 .40, ternary .25, binary .20, sub4 .15. Binary is the hardest
  problem and pays third because its winner needs a private fork to run

GGML type table had no Q1_0 or Q2_0, so read_gguf returned None and refused the
artifact — sub2 could not have accepted a submission in the format it exists
for. Ids and block layouts read from llama.cpp's ggml.h / ggml-common.h:
Q1_0 = 41, 128 elems / 18 bytes = 1.125 bpw; Q2_0 = 42, 64 / 18 = 2.25 bpw.

bitrate: detect the group size instead of assuming 128. A genuinely ternary
group-64 artifact, truly 1.835 bpw, measured 2.447 and was thrown out of ternary
into sub4 — normalising a 128-wide window holding two 64-wide groups turns 3
levels into 5. Minimising over candidate groups is the right selector: scale
overhead is scale_bits/group_size, so claiming a smaller group than you used
makes your number worse. Affects safetensors only; GGUF reads its type table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Corrects the previous commit, which weighted emission on the belief that only
Q2_0 reached a phone and that sub-2-bit needed PrismML's private fork. Checked
against mainline llama.cpp's Metal sources: ggml-metal.metal carries
get_rows/dequantize kernels for q1_0, q2_0, tq2_0, iq1_s, iq1_m, iq2_xxs and
q4_K. Q1_0 — PrismML's 1.125 bpw format — has been upstreamed. Every tier here
produces something a phone can run.

With that correction three orderings coincide, so the ladder is steepest at the
bottom: fewer bits is a smaller file, a smaller file fits more phones and
decodes faster (decode is bandwidth-bound, so halving the weights roughly
doubles tokens/sec), and fewer bits is the harder research problem.

- emission: binary .40, ternary .25, sub2 .20, sub4 .15
- UNRUNNABLE_FORMATS gate. TQ1_0 fits the binary and ternary budgets at 1.6875
  bpw and has NO Metal kernels in mainline, so a crown in it would be a champion
  that crashes on every iPhone. Refused by every tier; its sibling TQ2_0 passes,
  so this cannot be waved through as "the ternary formats are broken"
- BitReport.formats carries the GGUF type histogram, kept separate from
  per_tensor, which is keyed by tensor name on the safetensors path and by type
  name on the GGUF one

The moving-up test compared a two-tier field against a three-tier one and read
the extra dilution as a lost incentive. Renormalisation moves every share when a
tier is added, so the comparison now holds the live tiers fixed. Both emission
tests derive from TIER_EMISSION_WEIGHT instead of hard-coding it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page reported `rounds published: 2` and, separately, how many miners had
committed. A count is not a history: it could not say what happened in round 2,
how often rounds run, which of them paid anyone, or whether anybody has
submitted since the last one closed. Both halves were on the page and nothing
joined them.

- trail.rounds: per-round summary from the signed records — scored, rejected
  with reasons, by tier, crowns, exam size, parent, observer, weights. Newest
  first, capped at 12, and the cap travels with the list because a bounded
  history that does not say so reads as the whole history
- chain.cohort.pending: what is committed that the newest round did NOT score,
  splitting NEW ENTRANTS from RESUBMISSIONS. Different miners, different
  meaning — this field iterates on recipe far more often than it grows
- a miner rejected last round is not reported as a new entrant; they were seen
  and told why, and calling them new would erase the rejection
- records fetched through the same verifier as the headline crowns, and a round
  that fails to verify is dropped AND NAMED: a gap in a timeline is
  indistinguishable from a round that never happened
- an unreadable trail yields no timeline rather than an empty one, same rule
  that already governs every other number on the page

RoundRecord gains started_at/published_at, sparse, so rounds 1-2 still digest to
what they signed and render as "not recorded" rather than as instant. Phase is
declared via RALPH_FIRST_LIVE_ROUND, not inferred: a shakedown round computes a
weight vector exactly like a live one, so nothing in a record distinguishes
them, and the default classifies everything as shakedown.

Incumbents are counted separately from challengers throughout. A held crown
appears twice in a record, so len(submissions) says 13 for a round that scored
11 miners.

UI: RoundTimeline + PendingPanel, 8 new status tests, next build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The SDK was documented in requirements.txt as a version range and never pinned
anywhere installable, so the only working copy on the dev box belonged to
another project and whatever happened to be there was what ran.

- requirements-chain.txt (bittensor>=11.1,<12) and requirements-gpu.txt, both
  real `pip install -r` targets. The base file stays wallet-free and GPU-free so
  the crown-path suite remains installable on a box with neither

11.x is the ground-up rewrite the old docstring warned about: Subtensor has no
get_current_block, metagraph, get_commitment, set_commitment, set_weights or
.substrate. Ported and verified against finney:

- block -> st.block; block hash -> st.block_info(n).hash
- storage via typed items: st.query_map(item, params). uid<->hotkey from
  SubtensorModule.Keys, coldkey from SubtensorModule.Owner
- mg() builds its own {uid: hotkey} rather than bt.metagraph.fetch, which is a
  COROUTINE in 11.x while the whole validator is synchronous
- writes build a call and submit it: bt.SetWeights / Commitments.set_commitment
  through one _submit(), so there is a single line to audit for what can sign
- _item() isolates the typed-storage lookup. 10.x addressed storage by strings,
  so this module was stubbable with no SDK installed — a property requirements
  .txt states out loud; one indirection keeps it true

Live against netuid 40: 256 uids, 153 commitment slots, 16 v2 envelopes
(sub4 9, ternary 6, sub2 1).

_decode_commitment is unchanged and still right — it WALKS the RawN fields
rather than indexing them. A throwaway decoder that read only the first field
reported ZERO v2 commitments on a subnet that has sixteen.

_coldkey_of used getattr(v, "value", "") and returned empty for every hotkey:
11.x hands back a plain str for that item, so the default won. Falls back to the
object itself now.

Test stubs model the SDK boundary, so they move with it. The empty-subnet stub
had to be emptied on BOTH read paths — an empty map makes read_commitments fall
back to the per-uid read, and a stub that empties only the map keeps serving
commitments through the fallback and stops being an empty subnet at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Confirmed on chain, not inferred: uid 0 on netuid 40 is
5HijSRHd9wUmk51UE8Kia7vmx6kD2jwqJLn9bY1frQ4aiTUs — the validator's own hotkey.
Bittensor has no designated burn address; uid 0 is whoever registered first, and
here that is us.

`set_burn_weights` defaults to burn_uid=0 and is called by the AUDITOR, which
third parties are meant to run. With the shipped default an independent auditor
would have written 100% of its weight to us and called it a burn. That is a
self-vote wearing the word "burn", and it would be found.

Refuses and says why, pointing at RALPH_BURN_UID. The round path never calls
this — emission now splits across occupied tiers and nothing is withheld — so
this only affects an auditor with no verified round to pay.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
publish_champions -> publish_crowns, and the round calls it. A crown nobody can
find is not a product: the artifacts live in miner-named repos with ~116
downloads, and `main` there can be pushed over at any time.

- eval/publish_crowns.py, default RALPH_CROWNS_REPO=RalphLabsAI/ralph-crowns,
  manifest crowns.json
- run_orchestrated calls it LAST, after publish, anchor and weights, so a
  mirroring failure leaves a round that is still scored, signed, anchored and
  paid. publish() cannot raise — a bug here must not convert a successful round
  into a failed one at the final step
- RALPH_PUBLISH_CROWNS=0 turns it off; on by default
- per-tier isolation: a deleted repo, a missing revision or an absent dependency
  skips ONE tier and says so. It also exits non-zero when nothing was staged but
  something failed, or "nothing new to publish" would look healthy
- idempotent, so an unchanged crown costs one small read instead of re-uploading
  gigabytes every round

A HELD CROWN LEAVES TWO ROWS AND NEITHER IS PUBLISHABLE ALONE. The incumbent
carries the re-score the crown decision used (0.2875, not the challenger's
luckier 0.3030 — the 0.0155 gap is the measured run-to-run floor); the
challenger carries the bit measurement, because an incumbent is re-scored rather
than re-ingested and its code_bits/container_bits/params are zero. Taking the
incumbent wholesale rendered a 4.61 GB model as "0.0 bits, 0.00 GB" — caught in
a dry run on the box. Now merged, and pinned by a test.

Verified against the live trail: sub4 4.61 GB / 4.0 bits / 0.2875, ternary
2.42 GB / 1.714 / 0.2406.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Submissions table shows the CURRENT cohort — who is committed now and what
the running round is doing with them. It cannot answer "who was in round 1": a
commitment slot holds only its latest value, so once a miner resubmits, nothing
anywhere still says who that round scored. The signed record is the only durable
answer, so the field belongs with the round.

- trail.rounds[].submissions: miner, tier, role, retention, retention_lb,
  code_bits, container_bits, artifact_uri, crowned, gates_ok, reasons. Best
  retention first
- PROJECTED HARD. The record's submissions carry steps, effects, slices and
  per_point — megabytes that exist so an auditor can recompute a score and have
  no business in a snapshot a browser polls. Asserted absent, along with the
  exam fields that must never appear mid-round. 10.5 KB for two rounds, ~63 KB
  at the 12-round cap
- incumbents are shown and LABELLED rather than hidden: the re-score is half of
  the paired dethrone test, so the margin is unexplainable without it. It
  carries no bit measurement (re-scored, not re-ingested) and renders as a dash,
  never 0.00
- gated submissions show the reason. Scored-but-gated and simply-low-scoring
  look identical from a number alone, and only the first is actionable
- rejections listed under the round they were rejected from

UI: collapsible <details> per round, no client state, inside the same table so
the summary columns stay aligned across rounds.

Co-Authored-By: Claude Opus 5 <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