From 600a8658e668825d19eeafb9db28ccde7c77a001 Mon Sep 17 00:00:00 2001 From: Bitzy Date: Mon, 22 Jun 2026 16:38:40 +0000 Subject: [PATCH] feat: burn-to-uid-0 weight fallback (validator + auditor) - validator: set_burn_weights to uid 0 when no scoreable submissions this epoch (no king / all rejected) so it still sets weights + keeps vTrust - auditor: same burn fallback when no clean epoch (empty/404 audit repo) - chain: BittensorChain/LocalChain.set_burn_weights via shared _submit_weight_tensors; target uid via RALPH_BURN_UID (default 0) - on by default; disable with RALPH_BURN_FALLBACK=0 - fixes validators/auditors setting nothing while the subnet has no real accepted submissions yet (standard burn-to-owner) Co-Authored-By: Claude Opus 4.8 --- auditor/main.py | 12 +++++++- auditor/weights.py | 56 ++++++++++++++++++++++++++++++++++ chain_layer/bittensor_chain.py | 24 +++++++++++++++ chain_layer/local.py | 13 ++++++++ tests/test_burn_fallback.py | 56 ++++++++++++++++++++++++++++++++++ validator/service.py | 16 ++++++++++ 6 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 tests/test_burn_fallback.py diff --git a/auditor/main.py b/auditor/main.py index 875132c..439741a 100644 --- a/auditor/main.py +++ b/auditor/main.py @@ -232,7 +232,17 @@ def maybe_counter_weight(chain: ChainClient, api: ReportClient) -> None: epoch_id = _read_str_file(LAST_CLEAN_EPOCH_FILE) if not epoch_id: - logger.info("counter-weight: due but no clean epoch audited yet — skipping") + # No clean epoch to replay (e.g. the audit-reports repo is empty / 404). + # BURN FALLBACK: still set weights to uid 0 so the auditor-validator + # keeps its vTrust alive + burns to the owner, instead of skipping. + from auditor.weights import submit_burn_weights + + logger.info( + "counter-weight: due but no clean epoch (empty/404 audit repo) — " + "BURN fallback to uid 0" + ) + if submit_burn_weights(subtensor_url=chain.subtensor_url, netuid=chain.netuid): + _write_int_file(PUBLISHED_FILE, current) return logger.info( diff --git a/auditor/weights.py b/auditor/weights.py index f0dae5a..08211ef 100644 --- a/auditor/weights.py +++ b/auditor/weights.py @@ -98,6 +98,61 @@ def _load_wallet(): return bt.Wallet(name=name, hotkey=hotkey) +def submit_burn_weights( + subtensor_url: str, + netuid: int, + burn_uid: int | None = None, +) -> bool: + """Set the auditor's own weights to 100% on the burn uid (default 0 = owner). + + Used when there is NO clean audit epoch to replay (e.g. the audit-reports + repo is empty / 404) so the auditor-validator still sets weights every + cadence — keeps its vTrust alive and burns to the owner uid instead of + silently skipping. Signed by the auditor's OWN wallet. Never raises. + """ + if burn_uid is None: + burn_uid = int(os.environ.get("RALPH_BURN_UID", "0")) + wallet = _load_wallet() + if wallet is None: + return False + try: + import bittensor as bt + import torch + + subtensor = bt.Subtensor(network=subtensor_url) + metagraph = subtensor.metagraph(netuid=netuid) + except Exception: + logger.exception("burn: failed to connect subtensor at %s", subtensor_url) + return False + try: + auditor_ss58 = wallet.hotkey.ss58_address + if auditor_ss58 not in list(metagraph.hotkeys): + logger.warning( + "burn: auditor hotkey %s not registered on netuid=%d — cannot set weights", + auditor_ss58, netuid, + ) + return False + result = subtensor.set_weights( + wallet=wallet, + netuid=netuid, + uids=torch.tensor([burn_uid], dtype=torch.int64), + weights=torch.tensor([1.0], dtype=torch.float32), + wait_for_inclusion=True, + wait_for_finalization=False, + ) + success = result.success if hasattr(result, "success") else bool(result) + logger.info("auditor BURN set_weights -> uid %d: success=%s", burn_uid, success) + return bool(success) + except Exception: + logger.exception("auditor burn set_weights failed") + return False + finally: + try: + subtensor.close() + except Exception: + pass + + def submit_weights( subtensor_url: str, netuid: int, @@ -185,6 +240,7 @@ def submit_weights( "auditor_hotkey_ss58", "is_enabled", "is_weight_set_due", + "submit_burn_weights", "submit_weights", "weight_set_interval_blocks", ] diff --git a/chain_layer/bittensor_chain.py b/chain_layer/bittensor_chain.py index 2a9650c..2331bba 100644 --- a/chain_layer/bittensor_chain.py +++ b/chain_layer/bittensor_chain.py @@ -265,7 +265,31 @@ def set_weights(self, hotkey_scores: dict[str, float]) -> bool: if not uids: print("[chain] no valid UIDs to set weights for") return False + return self._submit_weight_tensors(uids, weights) + def set_burn_weights(self) -> bool: + """Fallback: set 100% weight to the burn UID (default 0 = subnet owner). + + Used when there is nothing real to score/audit this epoch so the + validator (and the auditor) STILL sets weights every epoch — this keeps + the validator's vTrust alive and burns the epoch's incentive to the + owner uid (standard "burn to owner" pattern) instead of silently setting + nothing. Override the target via env RALPH_BURN_UID. + """ + import os as _os + + burn_uid = int(_os.environ.get("RALPH_BURN_UID", "0")) + print(f"[chain] BURN fallback: 100% weight -> uid {burn_uid}") + self.sync() + return self._submit_weight_tensors([burn_uid], [1.0]) + + def _submit_weight_tensors(self, uids: list[int], weights: list[float]) -> bool: + """Normalize + submit one set_weights extrinsic for explicit uids/weights. + + Shared by set_weights (hotkey-mapped scores) and set_burn_weights (the + uid-0 burn fallback) so both go through the identical rate-limit guard + + extrinsic + event path. Caller is responsible for self.sync(). + """ total = sum(weights) or 1.0 weights = [w / total for w in weights] diff --git a/chain_layer/local.py b/chain_layer/local.py index d3b113a..f09d832 100644 --- a/chain_layer/local.py +++ b/chain_layer/local.py @@ -71,6 +71,19 @@ def set_weights(self, hotkey_scores: dict[str, float]) -> bool: }) return True + def set_burn_weights(self) -> bool: + """Burn fallback (sim): record a 100%-to-burn-uid weight event.""" + import os as _os + + burn_uid = int(_os.environ.get("RALPH_BURN_UID", "0")) + self.append_event({ + "type": "weights_set", + "timestamp": time.time(), + "weights": {f"uid:{burn_uid}": 1.0}, + "burn": True, + }) + return True + def get_king(self) -> Optional[KingRecord]: path = self.chain_dir / "king.json" if not path.exists(): diff --git a/tests/test_burn_fallback.py b/tests/test_burn_fallback.py new file mode 100644 index 0000000..71db7a6 --- /dev/null +++ b/tests/test_burn_fallback.py @@ -0,0 +1,56 @@ +"""Burn-to-uid-0 fallback: validator + auditor still set weights (keep vTrust + +burn to owner) when there is nothing real to score/audit this epoch.""" +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + + +# --------------------------------------------------------- validator flag +def test_validator_burn_fallback_default_on(monkeypatch): + from validator.service import _burn_fallback_enabled + + monkeypatch.delenv("RALPH_BURN_FALLBACK", raising=False) + assert _burn_fallback_enabled() is True + for off in ("0", "false", "no", "off", "OFF"): + monkeypatch.setenv("RALPH_BURN_FALLBACK", off) + assert _burn_fallback_enabled() is False + for on in ("1", "true", "yes", "on"): + monkeypatch.setenv("RALPH_BURN_FALLBACK", on) + assert _burn_fallback_enabled() is True + + +# --------------------------------------------------------- LocalChain burn +def test_localchain_set_burn_weights_records_uid0(monkeypatch): + from chain_layer.local import LocalChain + + monkeypatch.delenv("RALPH_BURN_UID", raising=False) + d = Path(tempfile.mkdtemp()) + chain = LocalChain(d) + assert chain.set_burn_weights() is True + events = [json.loads(ln) for ln in (d / "events.jsonl").read_text().splitlines() if ln.strip()] + last = events[-1] + assert last["type"] == "weights_set" + assert last.get("burn") is True + assert last["weights"] == {"uid:0": 1.0} + + +def test_localchain_burn_uid_override(monkeypatch): + from chain_layer.local import LocalChain + + monkeypatch.setenv("RALPH_BURN_UID", "7") + d = Path(tempfile.mkdtemp()) + chain = LocalChain(d) + chain.set_burn_weights() + events = [json.loads(ln) for ln in (d / "events.jsonl").read_text().splitlines() if ln.strip()] + assert events[-1]["weights"] == {"uid:7": 1.0} + + +# --------------------------------------------------------- auditor burn +def test_auditor_submit_burn_no_wallet_is_graceful(monkeypatch): + from auditor.weights import submit_burn_weights + + monkeypatch.delenv("AUDITOR_WALLET_NAME", raising=False) + # No wallet configured → returns False without raising (read-only). + assert submit_burn_weights("ws://localhost:9944", 40) is False diff --git a/validator/service.py b/validator/service.py index 143a734..fcf1dd3 100644 --- a/validator/service.py +++ b/validator/service.py @@ -368,6 +368,15 @@ def _save_pending_weights(chain, weights: dict[str, float]) -> None: p.write_text(json.dumps(weights, indent=2, sort_keys=True)) +def _burn_fallback_enabled() -> bool: + """Burn-to-uid-0 when nothing is scoreable this epoch. On by default + (standard 'burn to owner' so the validator always sets weights + keeps + vTrust). Disable with RALPH_BURN_FALLBACK in {0,false,no,off}.""" + return os.environ.get("RALPH_BURN_FALLBACK", "1").strip().lower() not in { + "0", "false", "no", "off", + } + + def _clear_pending_weights(chain) -> None: p = _pending_weights_path(chain) if p is not None and p.exists(): @@ -868,6 +877,13 @@ def run_epoch( "set_weights returned False — pending_weights.json kept for " "next-epoch retry. No credit was lost." ) + elif _burn_fallback_enabled(): + # BURN FALLBACK: nothing scoreable this epoch (no king, all rejected / + # zero submissions). Still set weights every epoch so the validator + # keeps its vTrust alive — burn the epoch's incentive to the owner uid + # (default 0). Disable with RALPH_BURN_FALLBACK=0. + log_info("no scoreable submissions this epoch — setting BURN weights (uid 0)") + weights_set = chain.set_burn_weights() # validation-v2 Phase 1: validator audit report + on-chain anchor. # Build report_json from this epoch's scored results, hash + sign, anchor