Skip to content

feat(cybergym): v3 redirects the CyberGym lane to compute when idle (N=0) - #147

Open
ai-hpc wants to merge 3 commits into
mainfrom
ai-hpc/cybergym-v3-idle-redirect
Open

ai-hpc wants to merge 3 commits into
mainfrom
ai-hpc/cybergym-v3-idle-redirect

Conversation

@ai-hpc

@ai-hpc ai-hpc commented Aug 25, 2026

Copy link
Copy Markdown
Member

Implements the N=0 → compute redirect (STAGE B of the v3 CyberGym-lane redesign, Option A), removing the fail-closed HALT: a v3 tick with zero CyberGym winners no longer stops the whole subnet from paying.

What changed

When CyberGym has no winners this tick, v3 signs an honest 100% Intel TDX / 0% CyberGym split with an empty lane, instead of raising:

  • Publisher (weights.py): _compose_cybergym_lane_v3 emits an empty lane (fraction 0.0, same V3_CYBERGYM_LANE_FIELDS) on no_contribution; build_signed_vector then sets intel_tdx_allocation=1.0 / cybergym_allocation=0.0. Genuine failures (disabled / burn-destination-unresolved / recipient-identity-unresolved) still fail closed.
  • Validator (validator_thin.py): the allocation check accepts 0.70/0.30 (CyberGym active) or 1.0/0.0 (idle → redirected to compute).

Safety

The two shapes are mutually exclusive and self-consistent: the existing lane_mass == cybergym_allocation cross-check (validator_thin.py:3414) means 1.0/0.0 forces an empty lane and 0.70/0.30 forces a full one. A publisher cannot claim idle while paying CyberGym miners, nor vice versa. Two rejection tests cover this.

Tests

test_validated_supply_v334 passed (idle metadata accepted; idle maps to 100% compute; both safety rejections; publisher composes an empty lane on idle). The 45 chain-submission failures seen locally are pre-existing (bittensor absent in this env) and unrelated — they fail identically on origin/main.

Independent of the STAGE A curve PR (#146 / distill #161 — different files). Reward-path / v3 contract shape; not self-merged.

🤖 Generated with Claude Code

ai-hpc and others added 3 commits August 25, 2026 04:48
…idle (N=0)

A v3 tick with zero CyberGym winners previously RAISED in
_compose_cybergym_lane_v3, halting the whole signed vector -- so a quiet
CyberGym epoch stopped the compute lane paying too. This adds an honest
"CyberGym-idle" shape to the v3 contract:

- Publisher: on cybergym_allocation status "no_contribution" the composer now
  emits an EMPTY lane (fraction 0.0, same V3_CYBERGYM_LANE_FIELDS) instead of
  raising; build_signed_vector then signs the honest 100% Intel TDX / 0% CyberGym
  split. Genuine failures (disabled / burn-destination-unresolved / recipient-
  identity-unresolved) still fail closed.
- Validator: _validated_supply_v3_meta accepts 0.70/0.30 (CyberGym active) OR
  1.0/0.0 (idle -> redirected to compute). Safety is preserved by the existing
  cybergym_lane mass cross-check (lane_mass == cybergym_allocation): 1.0/0.0
  forces an empty lane and 0.70/0.30 forces a full one, so a publisher cannot
  claim idle while paying CyberGym miners, nor vice versa.

Tests: idle metadata accepted, idle maps to 100% compute, both safety
rejections, publisher composes an empty lane on idle -- test_validated_supply_v3
34 passed (45 chain-submission failures are pre-existing bittensor-absent-locally,
unrelated). Removes the fail-closed HALT flagged pre-launch.

Reward-path / v3 contract shape; for wallscaler review, not self-merged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…idator contract

Same-repo coherence guard (BOUNDARY.md fork hazard): if the publisher's
V3_TDX_ALLOCATION/V3_CYBERGYM_ALLOCATION and the validator's accepted split ever
diverge, this fails loudly instead of rejecting every signed vector in prod.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adversarial review of the v3 lane PRs (no exploitable or correctness defect
found) surfaced three latent items, fixed here:

- policy_hash was computed over the pre-redirect 0.70/0.30 supply_policy, so an
  idle vector's SIGNED hash committed to a split the signed values no longer
  carry (unconsumed today, but a latent trap). Recompute it after the idle
  mutation so the signed hash matches the signed values.
- The public reproducer _assert_current_dry_run_v3 hard-required 0.70/0.30 and
  would report an idle (1.0/0.0) tick as non-reproducible once wired to live
  events. Teach it the idle shape (mirrors the validator's own allocation gate).
- Add an end-to-end seam test: the empty lane the PUBLISHER actually composes on
  idle is fed straight into the validator and maps to 100% compute (closes the
  emitted-==-accepted coverage gap the review noted).

test_validated_supply_v3: 37 passed. Reward-path; for wallscaler review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@wallscaler wallscaler left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed from fresh clones in Python 3.11 venvs with bittensor importable, so the chain-submission tests ran here. Line numbers are on the PR heads unless marked.

PR 3: cathedral-validator #147 (idle lane redirects to compute)

Artifact. scaffold/publisher/weights.py:2155-2176 (idle lane composer), :2388-2405 (allocation mutation and policy_hash recompute), scaffold/validator_thin.py:3195-3211 (allocation gate), scaffold/sn39_public_reproduction.py:2439-2462, tests in test_validated_supply_v3.py.
Question. Is the validator's two-shape acceptance safe, does the publisher actually produce the idle shape on N=0, and what happens across version skew.
Out of scope. Whether idle-to-compute is the right policy versus idle-to-burn (that is a design call for jared and me); the curve PRs.

Verdict

  • Do not merge as written. The publisher branch that emits the idle shape is unreachable: with the CyberGym mechanism enabled at 0.30, the real bridge never returns no_contribution, so a zero-winner tick keeps burning 30% exactly as it does today and the stated HALT does not exist on that path.

Findings

F1. The no_contribution branch cannot fire from the real bridge, so the redirect never happens.

  • Code path: weights.py:2153 calls cybergym_bridge.cybergym_allocation(store, now=now). In the bridge (cybergym_bridge.py:378-396) an empty score vector is not added to scores, so mechanism_router.compose (mechanism_router.py:163-174) books the spec as fallback missing and adds its whole fraction to forfeited_fraction. The bridge then takes the forfeited > 0 branch (:398-424) and returns status ok with weights == {burn_uid: 0.30}. Status no_contribution needs forfeited <= 0 and no weights (:396), which an enabled positive-fraction spec cannot produce: every branch in compose either forfeits the fraction or contributes it.
  • Evidence: ran mechanism_router.compose with an enabled 0.30 spec and no scores, got forfeited 0.3, reason missing; with an all-zero vector, forfeited 0.3, reason empty_after_filter. The repo's own test_cybergym_bridge_e2e.py::test_empty_state_burns_the_share (:201-208) asserts the no-report case returns weights == {BURN_UID: FRACTION}. The only producers of no_contribution in the tree are the PR's monkeypatched lambdas.
  • Behaviour: on a zero-winner tick the old and new publishers both sign 0.70/0.30 with the lane forfeited to burn, both old and new validators accept it, and 30% of emission burns. The PR description's "previously RAISED, halting the whole signed vector" describes burn_destination_unresolved / recipient_identity_unresolved / disabled, all of which still raise after this PR.
  • Relevance: the PR's purpose (stop burning the lane when CyberGym is idle) is not achieved, and the validator gains an accepted shape with no producer. Its tests pass because they never exercise the real bridge.

F2. The redirect, if made reachable, contradicts the exported lane contract and the design doc.

  • Code path: cathedral_thin/integration.py:190 exports "unproven_lane_behavior": "forfeit share to burn (never renormalized)" on the --lanes surface; docs/THIN_SUBNET_DESIGN.md:119-120 says a class that cannot produce a valid distribution holds the round and the missing budget is never renormalized into another class; mechanism_router.py:108-116 exists specifically to prevent a forfeited share moving to another lane.
  • Behaviour: moving the 30% to the TDX lane is a renormalization of the lane budget. Today the adapter reports zero winners as no_uid_mapping (mechanism_cybergym_adapter.py:448), the same bucket as feed failures (no_report, stale, unauthenticated, empty_report). Any implementation of idle-to-compute has to split "fresh authenticated report, zero winners" from "feed failure" or a stale feed would pay compute instead of burning.
  • Relevance: this is the design decision the PR should surface, not paper over with a status that cannot occur.

Refuted

  • "The two shapes are not mutually exclusive." Refuted by adversarial vectors against the #147 validator (script in scratch adv_v3.py): claim idle with a 0.30 lane paying UIDs 50/51 rejects on fraction 0.3 != 0.0 (validator_thin.py:3363); claim idle with fraction 0.0 but weights {50: 0.30} rejects on mass 0.3 != 0.0 (:3414); claim idle with forfeited 0.30 to burn rejects on weights sum 1.3 (:3448); claim active with an empty lane rejects on fraction 0.0 != 0.3; 0.85/0.15 rejects at the gate. Honest idle maps to {10: 0.6, 11: 0.4}; idle plus degraded TDX maps to {burn: 1.0}.
  • "The publisher mutates a shared policy dict and the idle split leaks into the next tick." Refuted: validated_supply_metadata() builds a fresh dict per call (weights.py:394-400), and build_signed_vector calls it once per build (:2247).
  • "policy_hash and signature disagree with the signed values." Refuted by building an idle vector end to end through build_signed_vector with the bridge stubbed to no_contribution: validated_supply is 1.0/0.0, the lane is empty, policy_hash differs from the active build, wire_vector.verify_signature passes, and the #147 validator maps it to {10: 0.6, 11: 0.4}. Nothing in validator_thin.py reads policy_hash, so the recompute is hygiene, not a gate.
  • "Main has moved so far the hunk no longer applies." Refuted: main's validator_thin.py still carries the exact must equal 0.70 / must equal 0.30 checks (:3366, :3370) and the mass check (:3574); GitHub reports MERGEABLE.

Temporal mismatch, verified by running both validators

  • Pre-#147 validator receiving 1.0/0.0: _validated_supply_v3_meta raises Intel TDX allocation must equal 0.70 (ran main's validator_thin against the idle payload). The tick logs VECTOR rejected stage=map, emits VECTOR_REJECTED, submits nothing (validator_thin.py:7101-7112); on-chain weights stay at the last accepted set. So validators must be on #147 before any publisher emits the idle shape.
  • #147 validator with a pre-#147 publisher: the old publisher never emits 1.0/0.0 on N=0 (it burns, per F1), so the new validator only ever sees 0.70/0.30. No new failure mode. When the publisher does raise (burn destination or recipient identity unresolved), _bg_refresh_loop (weights.py:2581-2593) logs and retries; the feed keeps serving the last persisted vector until expires_at (default 1800 s, :2298), then reports it expired (app.py:243-249). Unchanged by this PR.

Tests run

  • test_validated_supply_v3.py: 37 passed on #147 (29 on main). The author's "45 chain-submission failures" do not occur with bittensor importable.
  • Thin v3 files (test_sn39_v3_public_reproduction, test_v3_full_compose_proof, test_integration_cybergym_lane, test_v3_cutover_ready, test_dry_run_contract_stamp, test_require_policy_empty_pin, test_sn39_v3_trust_profile): 194 passed, 1 skipped.
  • tests/boundary/test_v3_allocation_single_owner.py: 22 passed.

Observations

  • The mass tolerance of 1e-9 lets a claim-idle vector carry a dust weight (5e-10) to a CyberGym UID and be accepted. On-chain u16 quantization rounds that to zero, and the same tolerance already applies to the active shape. Not exploitable.
  • contributing_fraction is never cross-checked by the validator (an idle lane declaring 0.30 is accepted). Pre-existing.
  • v3 is not the live contract: CATHEDRAL_ALLOCATION_CONTRACT defaults to v2 (weights.py:341) and is commented out in every deploy env example. The urgency of the idle case is pre-cutover, not production.
  • Authority/FULL provenance mode refuses v3 (validator_thin.py:1642), so the v3 cutover itself needs thin/shadow mode regardless of this PR.
  • tests/thin/test_sn39_v3_public_reproduction.py:155,166 match the substring 70/30/0, which the new reproducer message still contains, so they pass by string luck rather than by intent.

Questions for the author

  • Which real bridge status was observed to halt the vector on a zero-winner tick? The tree's own e2e test shows that case burning, not raising.
  • Is the intended semantics "fresh, authenticated report with zero winners pays compute" while no_report / stale / unauthenticated keep burning? If so, the change belongs in mechanism_cybergym_adapter.py:447-448 and cybergym_bridge.py:396, with a distinct status, and describe_lanes and THIN_SUBNET_DESIGN.md need an explicit carve-out.
  • If jared's Option A means all idle ticks pay compute, who signs off on retiring the "never renormalized" lane contract?

Deploy order if a reachable version lands later: validator release first on every host, then the publisher, because a pre-#147 validator rejects the idle shape at the map stage and stops submitting.

ai-hpc added a commit that referenced this pull request Sep 10, 2026
An empty CyberGym vector had one name for two different events. A fresh, authenticated, complete
report in which nobody scored above zero -- the lane worked, there was nothing to pay -- burned as
`no_uid_mapping`, the same reason a registration problem produces, and a sibling of `no_report` /
`stale` / `unauthenticated`. So nothing downstream could tell an idle lane from a broken one.

That distinction is the prerequisite for any N=0 policy (wallscaler's review of #147 asked for
exactly this): "pay compute when CyberGym is idle" implemented on today's reasons would also pay
compute when the CyberGym feed is merely DOWN, which is the case that must keep burning.

`idle_no_winners` is now returned when the tournament produced no winners at all; a winner that
exists but maps to no UID stays `no_uid_mapping`, because that is a fault. The bridge records
`lane_idle` alongside. No weights change: the share still forfeits to burn exactly as before.
@ai-hpc

ai-hpc commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

Holding this one, with the prerequisite landed.

wallscaler's F1 stands and I reproduced the reasoning against current main. The no_contribution branch this PR keys off cannot fire from the real bridge: an enabled positive-fraction spec either contributes or forfeits, so a zero-winner tick takes the forfeited > 0 path and returns ok with {burn_uid: 0.30}. Merging this would give the validator an accepted shape that no publisher produces, while the HALT it describes stays exactly where it is.

F2 stands too, and it is not mine to wave through. Moving a forfeited lane share to another lane contradicts integration.py's exported unproven_lane_behavior ("forfeit share to burn, never renormalized") and THIN_SUBNET_DESIGN.md. That contract is shared with you; retiring it is a call for you and jared together, not something to slip in behind a merge.

Landed instead: #240, the piece needed either way. An empty vector used to have one name for two different events — a fresh authenticated report where nobody scored above zero, and a registration/mapping failure — both no_uid_mapping. So "pay compute when CyberGym is idle", built on today's reasons, would have paid compute when the feed was merely down. The adapter now returns idle_no_winners only for a genuinely idle lane, no_uid_mapping stays a fault, and the bridge records lane_idle. No weights change.

To make this PR real on top of that: have the bridge act on lane_idle (a distinct status, not no_contribution), decide and document the carve-out to the never-renormalized rule, and keep the deploy order you specified — validators first on every host, publisher second, since a pre-#147 validator rejects the idle shape at the map stage and stops submitting.

Also worth knowing while this sits open: the publisher was not paying the decided curve at all. Fixed in #239 — distill moved to the KING model on 2026-09-04 and the vendored copy still renormalized the old table, so two winners paid 0.82/0.18 where the spec says 0.93/0.07.

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.

2 participants