From 01bc9152eff8b84c442948f2c8f0805725fbcad3 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 01:56:32 +0000 Subject: [PATCH 01/10] fix(config): sync main's chain.toml to the live worker-image pins, billing and packed-source rule main still named worker-v2.0.0-rc11 (v2) and worker-v0.9.0 (v1) while the network has trained on rc15 / v0.13.0 since 2026-10-31 / 2026-09-28, so a validator or miner on main computed a different contract digest than the fleet (v2 34b72ba9 vs live 7bb581e3). Synced, each = the deployed value: - [training] train_image_digest (v1) -> 205ff682 (v0.13.0) - [training] budget_denomination_after(_block) = points+mv20 @ 9165600 - [round] funded_pod_image / worker_code_fingerprint -> rc15 pins - [v2.training] train_image_digest -> rc15 5d41d94b - [static_guard] packed_sources = reject @ 9169200 Resulting digests equal live: v2 7bb581e3, v1 13a588e8. Deliberately NOT synced: vote-resolved blocks ([round] rolling/epoch rollover, [scoring] era_king/tenure/cohort, [v2] from_block). Validators resolve those themselves and at least one external validator holds a different rolling lock-in (9378000 vs ours 9136800); typing ours could refuse it at start. Also not synced: operator-only knobs (funded money limits, host lists, generation providers, calibration) and blocked_hotkeys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- chain.toml | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/chain.toml b/chain.toml index d291050e..1ffc1a47 100644 --- a/chain.toml +++ b/chain.toml @@ -241,8 +241,10 @@ expected_gpu = "" # was "NVIDIA L40S" (DEC-CA-0002) # launch-env injection + the provisioner health gate; digest computed # orchestrator-side). Folded into contract_digest — re-pin ⇒ trainer AND # validators restart together at an epoch boundary, pods pull the new image. -train_image_digest = "sha256:43c07d4207d7693517d74203f6783fdd2c2f91e3d475f38db38e4798ae97785e" -# ^ ghcr cascade-worker:worker-v0.9.0 — built 2026-09-16 09:38 UTC from this +train_image_digest = "sha256:205ff68242ae78fc923c8a5c7d2c4a4eccdd8afc7c7739cae99187475ba96d6f" +# ^ LIVE SYNC 2026-11-13: worker-v0.13.0 (main 4fbed80, built 2026-09-28) = the +# deployed pin since 2026-09-28; main still named v0.9.0 below. +# (was) ghcr cascade-worker:worker-v0.9.0 — built 2026-09-16 09:38 UTC from this # branch (publish-worker run 35080201768): budget_denomination (DEC-CA-0042) # parsed on the pod, PLUS check_loss_finite (840e900, 2026-09-14) which the # v0.8.0 image (main@9346a52) never carried — three NaN legs trained to the @@ -269,6 +271,9 @@ train_image_digest = "sha256:43c07d4207d7693517d74203f6783fdd2c2f91e3d475f38db38 # target_train_hours = 0.0 # per-size budget hours (313M increments ~6h; set # # jointly with the margin re-unitisation from the # # null-LCB noise-floor measurement) +# DEC-CA-0042 billing switch, LIVE since block 9165600 (synced to main 2026-11-13). +budget_denomination_after = "points+mv20" +budget_denomination_after_block = 9165600 [round] @@ -352,14 +357,14 @@ funded_pods = "rent" payer_vault_dir = "payer_vault" # cascade-intake --vault-dir (work_root-relative) funded_pod_sku = "" # unused with funded_pod_skus set (per-round pick) # Worker image funded pods boot — keep = the fleet's pinned digest. -funded_pod_image = "ghcr.io/tensorlink-ai/cascade-worker:worker-v2.0.0-rc11@sha256:2fd193cad6be7bef87f110246a2074de84cde44511e153a21d1d0301d93cba64" +funded_pod_image = "ghcr.io/tensorlink-ai/cascade-worker:worker-v2.0.0-rc15@sha256:5d41d94bc0c0d3ae3dfd042b0cc7e4d900c7d1f48173808436c12bf764a67641" # LIVE since 2026-10-31 (rc15 = main 5e3fade); synced to main 2026-11-13 # sha256 of that image's cascade/**/*.py (scripts/worker_code_fingerprint.py # @). Every rented final/funded pod must hash its files to this # BEFORE a leg is dispatched — the digest env a template injects is echoed # faithfully by a host that started a STALE image under the pinned tag # (2026-09-12: Lium 91.224.44.222/.223 ran worker-v0.7.0 as "v0.8.0"; three # miners' legs stalled and were burned). Re-derive on every image re-pin. -worker_code_fingerprint = "33b0d42fad8fa46b93c09ce1abb3d1d07a349905c171b795671cc68f06d7e907" # 271 files, worker-v2.0.0-rc11 (2fd193ca…); registry walk, rc10 walk reproduced its pin 2026-10-30 +worker_code_fingerprint = "2d8261aa40e86d3821f8fbeac0d60a0ac7528434a686c17e204c2b20198e293c" # 274 files, worker-v2.0.0-rc15 (5d41d94b…); registry walk = local 5e3fade source = on-pod hash, 2026-10-31 funded_ready_timeout_seconds = 900.0 # How a funded leg's checkpoint leaves its payer pod. "harvest" (default): # CREDENTIAL-FREE pods — the worker trains --local-only (nothing at all in the @@ -943,6 +948,10 @@ blocked = [ "cascade.validator", "cascade.shared.chain", ] +# Packed/obfuscated generator sources are REJECTED from block 9169200 — LIVE on the +# deployment since then; synced to main 2026-11-13 so `cascade verify` matches intake. +packed_sources = "reject" +packed_sources_from_block = 9169200 [storage] # Hippius storage layer: Hub OCI registry for models/checkpoints/generators @@ -1348,7 +1357,7 @@ router_lr_scale = 0.1 route_bias_rate_scale = 0.25 # Fit the existing 50B unique-corpus cap; the full reference preset is 27,500. training_steps = 23841 -train_image_digest = "sha256:2fd193cad6be7bef87f110246a2074de84cde44511e153a21d1d0301d93cba64" +train_image_digest = "sha256:5d41d94bc0c0d3ae3dfd042b0cc7e4d900c7d1f48173808436c12bf764a67641" # rc15, LIVE since 2026-10-31 (was rc11 2fd193ca…); synced to main 2026-11-13 expected_gpu = "" long_series = "crop" power_ema_scope = "leg" From b7102fdd24da29f79d2b2ac8341d6e308cc7abc8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 01:59:48 +0000 Subject: [PATCH 02/10] fix(config): keep the billing switch and packed-source rule unarmed in main The repo ships both unarmed by design (test_contract_block_gate pins it); the deployment arms them. This PR now syncs only the worker-image pins. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- chain.toml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/chain.toml b/chain.toml index 1ffc1a47..230765a7 100644 --- a/chain.toml +++ b/chain.toml @@ -271,9 +271,6 @@ train_image_digest = "sha256:205ff68242ae78fc923c8a5c7d2c4a4eccdd8afc7c7739cae99 # target_train_hours = 0.0 # per-size budget hours (313M increments ~6h; set # # jointly with the margin re-unitisation from the # # null-LCB noise-floor measurement) -# DEC-CA-0042 billing switch, LIVE since block 9165600 (synced to main 2026-11-13). -budget_denomination_after = "points+mv20" -budget_denomination_after_block = 9165600 [round] @@ -948,10 +945,6 @@ blocked = [ "cascade.validator", "cascade.shared.chain", ] -# Packed/obfuscated generator sources are REJECTED from block 9169200 — LIVE on the -# deployment since then; synced to main 2026-11-13 so `cascade verify` matches intake. -packed_sources = "reject" -packed_sources_from_block = 9169200 [storage] # Hippius storage layer: Hub OCI registry for models/checkpoints/generators From 9301eab70602677d9a8f879ca64fb0bb7f919fa5 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 02:14:47 +0000 Subject: [PATCH 03/10] fix(config): type the decided v2b switch block (9493200) into main's chain.toml The v2b-69630f92 vote locked in at 9486000 (85.9% of stake) and applied at the era start 9493200. Typing a decided block is behaviour-identical and frees its segment of the 128-byte on-chain readiness note (the same step taken for forfeit / margin-v2 on 2026-11-01). Without it, a validator whose rolling-era-king vote is still signalled (resolved from the chain rather than typed) overflows the note once it runs code that refuses to drop the v2b segment, and crash-loops at start (observed 2026-11-13 00:13 UTC on an external validator). Verified against that validator's note shape and ours; the typed-notice rule passes for a fresh node and a tally-locked node (the switch era has started). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- chain.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/chain.toml b/chain.toml index 230765a7..cc691ce3 100644 --- a/chain.toml +++ b/chain.toml @@ -1221,7 +1221,7 @@ king_ladder = true # so a worker-image repin keeps the carried king and the finished legs. 0 = # decided on chain; a typed era-start block is the owner override. Repin the # image only AFTER the switch era has started. -image_decoupled_from_block = 0 +image_decoupled_from_block = 9493200 # TYPED 2026-11-13 (DEC-CA-0067): = the block the v2b-69630f92 vote resolved (locked in at 9486000, 85.9% of stake, applied at the era start 9493200 = Thu 12 Nov 22:23 UTC) — behaviour-identical; frees its note segment. Without it a validator whose rolling-era-king vote is still signalled overflows the 128-byte note and REFUSES to start (seen 2026-11-13 00:13 UTC). # Bundled with the same block (part of the vote's hash): the v2 private # verdict budget becomes this many windows. Inert until the switch. eval_n_windows_after = 4000 From cb91403a6535d3097523df1a9e3f2323926a3cc8 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 02:19:00 +0000 Subject: [PATCH 04/10] test: reset the typed v2b block in the synthetic 3600-grid seam fixtures The shipped chain.toml now types image_decoupled_from_block = 9493200, an era start on the live 900 grid; the two tests that build a 7200/3600 seam from the shipped file would judge it on that grid instead. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/unit/test_config.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 8d841628..54fc133d 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -288,11 +288,16 @@ def test_activation_block_must_be_on_both_grids(tmp_path): """A seam that is not a boundary of both grids leaves the round spanning it with two different lengths depending on which side you ask from.""" + import re + import pytest from cascade.shared.config import DEFAULT_CHAIN_TOML, load_chain_config - src = DEFAULT_CHAIN_TOML.read_text() + # The shipped template types the decided v2b switch (an era start on the + # live 900 grid); the synthetic 3600-grid seam below is not that grid. + src = re.sub(r"^image_decoupled_from_block\s*=.*$", "image_decoupled_from_block = 0", + DEFAULT_CHAIN_TOML.read_text(), flags=re.M) p = tmp_path / "chain.toml" # The shipped template is in the steady state (0/0); build the scheduled # pair explicitly so the test does not depend on a live seam being armed. @@ -312,11 +317,14 @@ def test_activation_block_must_be_on_both_grids(tmp_path): def test_cadence_keys_must_be_set_together(tmp_path): + import re + import pytest from cascade.shared.config import DEFAULT_CHAIN_TOML, load_chain_config - src = DEFAULT_CHAIN_TOML.read_text() + src = re.sub(r"^image_decoupled_from_block\s*=.*$", "image_decoupled_from_block = 0", + DEFAULT_CHAIN_TOML.read_text(), flags=re.M) p = tmp_path / "chain.toml" steady = ("epoch_blocks_prev = 0\nepoch_activation_block = 0") p.write_text(src.replace(steady, "epoch_blocks_prev = 0\n" From 92593c813612a767430dcdceccbf8c906b34f46e Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 02:25:14 +0000 Subject: [PATCH 05/10] =?UTF-8?q?test:=20the=20release=20types=20the=20dec?= =?UTF-8?q?ided=20v2b=20switch=20=E2=80=94=20fixtures=20keep=20the=20vote?= =?UTF-8?q?=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors how the typed forfeit / margin-v2 blocks are handled (conftest and the per-file fixtures reset them to 0): every fixture that exercises the v2b VOTE (image_decoupled_feature lock-ins, note budget, after-pin schedule binding, pre-lock receipts) now resets image_decoupled_from_block to 0. The two tests that pin the shipped file's state assert mainnet typed 9493200 / testnet 0. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/conftest.py | 6 ++++- tests/unit/test_image_after_repin.py | 8 +++++- tests/unit/test_image_decoupled_contract.py | 30 ++++++++++++++------- tests/unit/test_image_decoupled_merge.py | 7 +++-- tests/unit/test_shared_ladder.py | 10 ++++--- tests/unit/test_v2_golive.py | 5 +++- tests/unit/test_v2b_seed_epoch_switch.py | 6 ++--- 7 files changed, 51 insertions(+), 21 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0d2bf048..58d692b5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -134,12 +134,16 @@ def cfg() -> ChainConfig: raw["netconfig"]["owner_hotkey"] = "" raw["scoring"]["forfeit_from_block"] = 0 # DEC-CA-0067 typed blocks: see scoring= raw["scoring"]["margin_v2_from_block"] = 0 + raw["v2"]["image_decoupled_from_block"] = 0 # the typed v2b switch: see v2= return replace(c, training=replace(c.training, expected_gpu="", train_image_digest=""), raw=raw, netconfig=replace(c.netconfig, owner_hotkey=""), v2=replace(c.v2, launch_at_utc="", commit_open_at_utc="", - commit_from_block=0, prelaunch_burn_readiness=""), + commit_from_block=0, prelaunch_burn_readiness="", + # the release types the decided v2b switch (real- + # chain state); fixtures keep the vote undecided + image_decoupled_from_block=0), # corpus_target_points ships ARMED (DEC-CA-0031): a points- # denominated drain would override every fixture's small # corpus_n_series with a 67M-point draw. Tests that exercise diff --git a/tests/unit/test_image_after_repin.py b/tests/unit/test_image_after_repin.py index 98633677..c1d0f337 100644 --- a/tests/unit/test_image_after_repin.py +++ b/tests/unit/test_image_after_repin.py @@ -80,6 +80,11 @@ def test_parse_is_all_or_none_and_one_digest(): def _with(tmp_path, **kv): text = (REPO / "chain.toml").read_text() + # the release types the decided v2b switch; these tests arm the AFTER pins + # with the vote still open (unbound schedule) + text, n = re.subn(r"(?m)^image_decoupled_from_block\s*=.*$", + "image_decoupled_from_block = 0", text) + assert n == 1 for k, v in kv.items(): text, n = re.subn(rf"(?m)^{k}\s*=.*$", f'{k} = "{v}"', text) assert n == 1, k @@ -119,7 +124,8 @@ def acfg(): funded_sku_wall_measured_image=""), scoring=replace(c.scoring, era_king_from_block=B0, tenure_blocks_from_block=B0, cohort_maxt_increment_from_block=B0), - v2=replace(c.v2, from_block=B0, launch_at_utc="", **AFTER), + v2=replace(c.v2, from_block=B0, launch_at_utc="", image_decoupled_from_block=0, + **AFTER), # the vote still open training=replace(c.training, v2_from_block=B0)) return bind_image_after(c) diff --git a/tests/unit/test_image_decoupled_contract.py b/tests/unit/test_image_decoupled_contract.py index 50f3a27c..55757aa3 100644 --- a/tests/unit/test_image_decoupled_contract.py +++ b/tests/unit/test_image_decoupled_contract.py @@ -124,15 +124,25 @@ def test_prove_numerics_digest_needs_the_leg_image(): # ── the knobs ──────────────────────────────────────────────────────────────── -def test_knobs_ship_off_and_inert(): - for path, after in (("chain.toml", 4000), ("chain.testnet.toml", 300)): - cfg = load_chain_config(REPO / path) - assert cfg.v2.image_decoupled_from_block == 0 - assert cfg.v2.eval_n_windows_after == after - assert cfg.v2.prior_train_image_digests == () - assert cfg.activation.resolved_image_decoupled_block == 0 - assert not image_decoupled_at(cfg, 10**9) - assert v2_eval_windows_at(cfg, 10**9) == int(cfg.v2.eval_n_windows) +def test_knobs_ship_typed_on_mainnet_and_off_on_testnet(): + # mainnet: the v2b-69630f92 vote resolved (locked 9486000, 85.9% of stake, + # applied at the era start 9493200) and the release TYPES that block + # (2026-11-13) to free its note segment; nothing is resolved from chain + cfg = load_chain_config(REPO / "chain.toml") + assert cfg.v2.image_decoupled_from_block == 9493200 + assert cfg.v2.eval_n_windows_after == 4000 + assert cfg.v2.prior_train_image_digests == () + assert cfg.activation.resolved_image_decoupled_block == 0 + assert image_decoupled_at(cfg, 9493200) and not image_decoupled_at(cfg, 9493199) + assert v2_eval_windows_at(cfg, 10**9) == 4000 + # testnet: still decided on chain, inert until then + cfg = load_chain_config(REPO / "chain.testnet.toml") + assert cfg.v2.image_decoupled_from_block == 0 + assert cfg.v2.eval_n_windows_after == 300 + assert cfg.v2.prior_train_image_digests == () + assert cfg.activation.resolved_image_decoupled_block == 0 + assert not image_decoupled_at(cfg, 10**9) + assert v2_eval_windows_at(cfg, 10**9) == int(cfg.v2.eval_n_windows) def _with(tmp_path, **kv): @@ -305,7 +315,7 @@ def vcfg(): forfeit_from_block=0, forfeit_successor_hotkey="", win_margin_start_v2=0.0, win_margin_end_v2=0.0, margin_warmup_blocks_v2=0, margin_v2_from_block=0), - v2=replace(c.v2, prelaunch_burn=0.0), + v2=replace(c.v2, prelaunch_burn=0.0, image_decoupled_from_block=0), # vote open eval=replace(c.eval, shared_ladder_windows=0)) diff --git a/tests/unit/test_image_decoupled_merge.py b/tests/unit/test_image_decoupled_merge.py index 780faca8..a01c2adc 100644 --- a/tests/unit/test_image_decoupled_merge.py +++ b/tests/unit/test_image_decoupled_merge.py @@ -68,7 +68,9 @@ def test_pre_lock_receipts_carry_no_switch_and_match_the_v3_golden(): from .receipt_fixture import make_scored_receipt - runner = ValidatorRunner(cfg=load_chain_config(REPO / "chain.toml")) + release = load_chain_config(REPO / "chain.toml") + untyped = replace(release, v2=replace(release.v2, image_decoupled_from_block=0)) + runner = ValidatorRunner(cfg=untyped) assert runner.image_decoupled_block == 0 # nothing locked, nothing typed receipt, _, _ = make_scored_receipt() stamped = replace(receipt, image_decoupled_block=runner.image_decoupled_block) @@ -100,7 +102,8 @@ def test_option_b_drops_the_prelaunch_segment_and_option_a_still_needed(tmp_path release = load_chain_config(REPO / "chain.toml") assert A.v2b_note_problem(release, rec, head_block=9_420_000) == "" assert A.v2b_note_problem(_retired(release), rec, head_block=9_420_000) == "" - both = A.own_signal_payload(_retired(release), rec, None, None, A.ActivationRecord(), None, + open_vote = replace(release, v2=replace(release.v2, image_decoupled_from_block=0)) + both = A.own_signal_payload(_retired(open_vote), rec, None, None, A.ActivationRecord(), None, None, A.ActivationRecord(feature=V2B, lock_block=9504000, activation_block=9507600, source="tally")) diff --git a/tests/unit/test_shared_ladder.py b/tests/unit/test_shared_ladder.py index 717f6d9a..c294b9f0 100644 --- a/tests/unit/test_shared_ladder.py +++ b/tests/unit/test_shared_ladder.py @@ -120,8 +120,11 @@ def test_shipped_values_parse_and_default_off(tmp_path): assert shared_ladder_configured(shipped.eval) assert not shared_ladder_active(shipped.eval, 9493200) # the old era's last settlement assert shared_ladder_active(shipped.eval, 9494100) - assert shipped.quantile_head_at(9494100) and not shipped.quantile_head_at(9493200) or \ - not shipped.v2.image_decoupled_from_block # aligned when v2b is applied + # the release types both switches at the same era (v2b 9493200 → the + # quantile head from its first settlement; v2 live from 9399600 on chain) + assert shipped.v2.image_decoupled_from_block == 9493200 + live = replace(shipped, v2=replace(shipped.v2, from_block=9399600)) + assert live.quantile_head_at(9494100) and not live.quantile_head_at(9493200) assert not A.shared_ladder_feature(shipped).decided_on_chain # typed: nothing signalled # The vote's name is pinned: a changed count or ladder is a FRESH vote. assert A.shared_ladder_feature_name(4000, HORIZONS) == "lad-556b1b98" @@ -496,7 +499,8 @@ def test_note_carries_the_segment_and_stays_under_the_budget_with_every_mainnet_ shipped = load_chain_config(DEFAULT_CHAIN_TOML) assert A.shared_ladder_feature(shipped).name not in ( A.own_signal_payload(shipped, A.ActivationRecord()) or "").split(":") - shipped = replace(shipped, eval=replace(shipped.eval, shared_ladder_from_block=0)) + shipped = replace(shipped, eval=replace(shipped.eval, shared_ladder_from_block=0), + v2=replace(shipped.v2, image_decoupled_from_block=0)) # both votes open assert shipped.activation.enabled lad = A.shared_ladder_feature(shipped) v2b = A.image_decoupled_feature(shipped) diff --git a/tests/unit/test_v2_golive.py b/tests/unit/test_v2_golive.py index ffe29e24..d3a5351a 100644 --- a/tests/unit/test_v2_golive.py +++ b/tests/unit/test_v2_golive.py @@ -55,7 +55,10 @@ def test_mainnet_ships_inert_with_the_v2_contract_scheduled(): bench_budget_hours=6.0, bench_sweep_hours=2.0, # DEC-CA-0067: bundled budget, inert until the v2b vote eval_n_windows_after=4000, quantile_head_after=True, - image_decoupled_feature="v2b-69630f92") + image_decoupled_feature="v2b-69630f92", + # the vote resolved (locked 9486000, 85.9%): typed + # 2026-11-13 to free its note segment + image_decoupled_from_block=9493200) assert not cfg.v2.active(10**9) assert cfg.lane_b.from_block == 0 and not cfg.lane_b.active(10**9) v2c = cfg.training.v2_contract diff --git a/tests/unit/test_v2b_seed_epoch_switch.py b/tests/unit/test_v2b_seed_epoch_switch.py index 492cc2af..f1defbb8 100644 --- a/tests/unit/test_v2b_seed_epoch_switch.py +++ b/tests/unit/test_v2b_seed_epoch_switch.py @@ -42,8 +42,8 @@ def base(): cfg = load_chain_config(ROOT / "chain.toml") era = era_length_blocks(cfg.round, 0) gate = 10_000 * era - cfg = replace(cfg, v2=replace(cfg.v2, from_block=gate), - training=replace(cfg.training, v2_from_block=gate)) + cfg = replace(cfg, v2=replace(cfg.v2, from_block=gate, image_decoupled_from_block=0), + training=replace(cfg.training, v2_from_block=gate)) # the vote still open return cfg, era @@ -499,7 +499,7 @@ def vcfg(): forfeit_from_block=0, forfeit_successor_hotkey="", win_margin_start_v2=0.0, win_margin_end_v2=0.0, margin_warmup_blocks_v2=0, margin_v2_from_block=0), - v2=replace(c.v2, prelaunch_burn=0.0), + v2=replace(c.v2, prelaunch_burn=0.0, image_decoupled_from_block=0), # vote open eval=replace(c.eval, shared_ladder_windows=0)) From b2e1b62ccda4003bc07d94c40fd05f4e68b00a04 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 02:30:28 +0000 Subject: [PATCH 06/10] test: judge the typed v2b switch on the resolved 900 grid (settlement-at-boundary rule) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/unit/test_image_decoupled_contract.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_image_decoupled_contract.py b/tests/unit/test_image_decoupled_contract.py index 55757aa3..5628be62 100644 --- a/tests/unit/test_image_decoupled_contract.py +++ b/tests/unit/test_image_decoupled_contract.py @@ -133,8 +133,12 @@ def test_knobs_ship_typed_on_mainnet_and_off_on_testnet(): assert cfg.v2.eval_n_windows_after == 4000 assert cfg.v2.prior_train_image_digests == () assert cfg.activation.resolved_image_decoupled_block == 0 - assert image_decoupled_at(cfg, 9493200) and not image_decoupled_at(cfg, 9493199) - assert v2_eval_windows_at(cfg, 10**9) == 4000 + assert image_decoupled_at(cfg, 10**9) and v2_eval_windows_at(cfg, 10**9) == 4000 + # on the live 900 grid (the fleet's resolved rollover) the settlement AT + # the switch block is the previous era's last; the next one is under the rule + live = replace(cfg, round=replace(cfg.round, rolling_from_block=9136800, epoch_blocks=900, + epoch_blocks_prev=3600, epoch_activation_block=9136800)) + assert not image_decoupled_at(live, 9493200) and image_decoupled_at(live, 9494100) # testnet: still decided on chain, inert until then cfg = load_chain_config(REPO / "chain.testnet.toml") assert cfg.v2.image_decoupled_from_block == 0 From 2f4085fa813598851d67c5ed591054ad1010283b Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 02:41:54 +0000 Subject: [PATCH 07/10] test: the remaining shipped-file note tests judge the vote-form config (v2b untyped) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/unit/test_image_decoupled_contract.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_image_decoupled_contract.py b/tests/unit/test_image_decoupled_contract.py index 5628be62..7372e1bb 100644 --- a/tests/unit/test_image_decoupled_contract.py +++ b/tests/unit/test_image_decoupled_contract.py @@ -759,6 +759,7 @@ def test_mainnet_note_carries_v2b_within_the_field(rollover_typed): rollover resolved from signals); True is the main validator's deployed shape (rolling_from_block typed).""" cfg = load_chain_config(REPO / "chain.toml") + cfg = replace(cfg, v2=replace(cfg.v2, image_decoupled_from_block=0)) # the vote still open feature = A.primary_feature(cfg).name if rollover_typed: cfg = A.apply_activation(cfg, LIVE_ROLLOVER[1]) @@ -792,6 +793,7 @@ def test_note_with_a_locked_cfg_segment_and_the_v2b_type_rule(): type its block into chain.toml (behaviour-identical) and its segment leaves the note.""" cfg = load_chain_config(REPO / "chain.toml") + cfg = replace(cfg, v2=replace(cfg.v2, image_decoupled_from_block=0)) # the vote still open v2b = A.image_decoupled_feature(cfg).name for digits, fits in ((7, True), (8, False)): low = 10 ** (digits - 1) @@ -816,9 +818,9 @@ def test_note_with_a_locked_cfg_segment_and_the_v2b_type_rule(): def _untyped_blocks(tmp_path): """The release chain.toml (pins set) WITHOUT the typed forfeit / margin-v2 - blocks — a deployment that applied the pins but not the typed blocks.""" + / v2b blocks — a deployment that applied the pins but not the typed blocks.""" text = (REPO / "chain.toml").read_text() - for k in ("forfeit_from_block", "margin_v2_from_block"): + for k in ("forfeit_from_block", "margin_v2_from_block", "image_decoupled_from_block"): text, n = re.subn(rf"(?m)^{k}\s*=.*$", f"{k} = 0", text) assert n == 1, k p = tmp_path / "untyped.toml" @@ -876,8 +878,10 @@ def test_note_budget_worst_case_scales_to_8_digit_blocks(): """Review 4: the worst case uses the digit count blocks reach within the horizon; 8 digits (head unknown, or within ~30 days of 10,000,000) trims the release config's v2b segment beside a locked cfg- overlay — the - documented rule: type v2b's block once it is decided.""" + documented rule: type v2b's block once it is decided (the release now + does; this judges the vote-form config).""" cfg = load_chain_config(REPO / "chain.toml") + cfg = replace(cfg, v2=replace(cfg.v2, image_decoupled_from_block=0)) rec = _live_records()[0] assert A.v2b_note_problem(cfg, rec, head_block=9_420_000) == "" assert "TRIM" in A.v2b_note_problem(cfg, rec, head_block=9_800_000) From 339128ee35132ef9c51611cda0f6715b820e0b89 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 02:47:34 +0000 Subject: [PATCH 08/10] test: lane-C raw re-parse keeps the v2b vote open beside its far v2 go-live Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/unit/test_lane_c_config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_lane_c_config.py b/tests/unit/test_lane_c_config.py index 6c0dba25..225f9519 100644 --- a/tests/unit/test_lane_c_config.py +++ b/tests/unit/test_lane_c_config.py @@ -151,6 +151,7 @@ def test_loader_arms_lane_c_on_an_era_boundary(tmp_path): v2 = 10_000 * era raw = copy.deepcopy(cfg.raw) raw["v2"]["from_block"] = v2 + raw["v2"]["image_decoupled_from_block"] = 0 # the typed v2b switch: vote open here # DEC-CA-0063 needs [netconfig] owner_hotkey once v2 is typed; lane C only raw["v2"]["bench_source"] = "trainer" # Lane C takes its allocation from B, which now launches independently. From f91ce1a5480ee1734542ef27c004ca84f114759b Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 03:04:57 +0000 Subject: [PATCH 09/10] tests: shared-ladder shipped-values probe arms the resolved 900 grid The repo file ships rolling and v2 undecided (3600 grid), where 9494100 is not a settlement boundary at all; the quantile-head alignment check needs the fleet's resolved grid, like test_knobs_ship_typed_on_mainnet. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/unit/test_shared_ladder.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_shared_ladder.py b/tests/unit/test_shared_ladder.py index c294b9f0..7365f8a4 100644 --- a/tests/unit/test_shared_ladder.py +++ b/tests/unit/test_shared_ladder.py @@ -123,7 +123,11 @@ def test_shipped_values_parse_and_default_off(tmp_path): # the release types both switches at the same era (v2b 9493200 → the # quantile head from its first settlement; v2 live from 9399600 on chain) assert shipped.v2.image_decoupled_from_block == 9493200 - live = replace(shipped, v2=replace(shipped.v2, from_block=9399600)) + # ... on the fleet's resolved 900 grid: the repo file ships rolling and v2 + # undecided (3600 grid, where 9494100 is not a boundary at all) + live = replace(shipped, v2=replace(shipped.v2, from_block=9399600), + round=replace(shipped.round, rolling_from_block=9136800, epoch_blocks=900, + epoch_blocks_prev=3600, epoch_activation_block=9136800)) assert live.quantile_head_at(9494100) and not live.quantile_head_at(9493200) assert not A.shared_ladder_feature(shipped).decided_on_chain # typed: nothing signalled # The vote's name is pinned: a changed count or ladder is a FRESH vote. From 46f0823a0b1ffc945e5827f5b7921cbf93f06009 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 13 Nov 2026 03:23:22 +0000 Subject: [PATCH 10/10] tests: validator-bench go-live loader test keeps the v2b switch open The shipped file now types [v2] image_decoupled_from_block=9493200; a fixture that pushes from_block far out must untype it or the loader's "precedes [v2] from_block" check fires before the one under test. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01BMT4D7eZUKUF2gocYLVFmj --- tests/unit/test_validator_bench.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/test_validator_bench.py b/tests/unit/test_validator_bench.py index 23aac22a..f9f81e9a 100644 --- a/tests/unit/test_validator_bench.py +++ b/tests/unit/test_validator_bench.py @@ -220,6 +220,9 @@ def test_loader_refuses_v2_go_live_without_a_main_validator(tmp_path): text = re.sub(r"(?m)^(\[v2\]\n(?:[^\[][^\n]*\n)*?from_block\s*=\s*)\d+", rf"\g<1>{v2}", (root / "chain.toml").read_text(encoding="utf-8")) assert f"from_block = {v2}" in text + # the shipped file types the v2b switch (9493200); keep it OPEN here so the + # loader's "precedes [v2] from_block" check does not fire before the one under test + text = re.sub(r"(?m)^image_decoupled_from_block\s*=\s*\d+", "image_decoupled_from_block = 0", text) text = text.replace(f'owner_hotkey = "{cfg.netconfig.owner_hotkey}"', 'owner_hotkey = ""', 1) p = tmp_path / "chain.toml" p.write_text(text, encoding="utf-8")