diff --git a/VERSION b/VERSION index 2da43162..0062ac97 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.10.0 +5.0.0 diff --git a/docs/Incentive.md b/docs/Incentive.md index c7bc19d1..b83ac87e 100644 --- a/docs/Incentive.md +++ b/docs/Incentive.md @@ -140,17 +140,19 @@ The normative implementation details and complete metric field glossary live in ### King of the Hill -Discriminator emission is King of the Hill. Each modality has one reigning model. Validators set that lane's weight on the king's registered hotkey every tempo — not on an escrow wallet. +Discriminator emission is King of the Hill. Each modality has one reigning model. Validators set that lane's weight on registered hotkeys every tempo — not on an escrow wallet. Current split: -- Image king: 40% -- Video king: 40% -- Audio king: 4% +- Image lane: 40% +- Video lane: 40% +- Audio lane: 4% - Generators: 16% -A challenger takes the crown when it posts a higher `sn34_score` on the **same** `CURRENT_BENCHMARK_VERSION`. The same `file_hash` can refresh its stored score without resetting the reign. +Each discriminator lane is split **85 / 10 / 5** across the current king and the previous two **distinct** crowned hotkeys. If a lane has no previous king, that residual rolls up to the current king (a first king receives the full lane). An unresolvable current king burns its share; an unresolvable previous king rolls to the current king when that UID is registered. -When a new benchmark version is released the current king keeps receiving weights. The throne is marked `defending` until that exact model completes a full re-eval on the new version. Dethroning is frozen during defense. If the re-eval fails or times out (48 hours), the crown goes to the best successful new-version model, or that lane's share burns until one exists. +A challenger takes the crown when it posts an `sn34_score` at least **0.01** higher than the sitting king on the **same** `CURRENT_BENCHMARK_VERSION`. Empty-lane seeding and failed-defense replacement do not use the margin. The same `file_hash` can refresh its stored score without resetting the reign. -Alpha accrues on the king's hotkey while they hold the lane. There is no end-of-round escrow transfer and no pot. +When a new benchmark version is released the current king keeps receiving weights. The throne is marked `defending` until that exact model completes a full re-eval on the new version. Dethroning is frozen during defense. After a successful defense, deferred challengers still need the 0.01 margin. If the re-eval fails or times out (48 hours), the crown goes to the best successful new-version model, or that lane's share burns until one exists. + +Alpha accrues on the chain hotkeys while they hold those residual shares. There is no end-of-round escrow transfer and no pot. diff --git a/gas/__init__.py b/gas/__init__.py index 3492292f..1d041248 100644 --- a/gas/__init__.py +++ b/gas/__init__.py @@ -1,4 +1,4 @@ -__version__ = "4.10.0" +__version__ = "5.0.0" version_split = __version__.split(".") __spec_version__ = ( diff --git a/gas/cli.py b/gas/cli.py index e71fd759..33af9fa7 100644 --- a/gas/cli.py +++ b/gas/cli.py @@ -454,8 +454,11 @@ def discriminator(): @click.option("--chain-endpoint", help="Subtensor network endpoint") @click.option("--retry-delay", default=60, help="Retry delay in seconds") @click.option("--vertical", type=click.Choice(["general", "human"]), default="general", help="Competition vertical (default: general)") +@click.option("--upload-endpoint", default=None, help="Model upload URL (default: production https://upload.bitmind.ai/upload)") +@click.option("--skip-chain", is_flag=True, help="Exit after a successful upload without writing on-chain metadata") +@click.option("--max-retries", default=3, type=int, help="Max blockchain registration attempts per modality (0 = retry forever)") def push_discriminator( - image_model, video_model, audio_model, wallet_name, wallet_hotkey, netuid, chain_endpoint, retry_delay, vertical + image_model, video_model, audio_model, wallet_name, wallet_hotkey, netuid, chain_endpoint, retry_delay, vertical, upload_endpoint, skip_chain, max_retries ): """Push discriminator model(s) and register on blockchain. At least one model zip file (image, video, or audio) must be provided.""" if not image_model and not video_model and not audio_model: @@ -488,14 +491,19 @@ def push_discriminator( cmd.extend(["--retry-delay", str(retry_delay)]) cmd.extend(["--vertical", vertical]) - - # Execute the push_model script + cmd.extend(["--max-retries", str(max_retries)]) + if upload_endpoint: + cmd.extend(["--upload-endpoint", upload_endpoint]) + if skip_chain: + cmd.append("--skip-chain") + + # Execute the push_model script. Always propagate its exit code so a + # successful upload does not leave this click process hanging. try: - result = subprocess.run(cmd, check=True) - except subprocess.CalledProcessError as e: - sys.exit(e.returncode) - except Exception as e: + result = subprocess.run(cmd) + except Exception: sys.exit(1) + sys.exit(result.returncode) discriminator.add_command(push_discriminator, name="push") diff --git a/gas/koth_weights.py b/gas/koth_weights.py index 92081163..0203509a 100644 --- a/gas/koth_weights.py +++ b/gas/koth_weights.py @@ -1,6 +1,6 @@ """Build on-chain weights for King-of-the-Hill discriminator lanes.""" -from typing import Callable, Dict, Iterable, Optional +from typing import Callable, Dict, Iterable, List, Optional import numpy as np @@ -10,6 +10,10 @@ "audio": 0.04, "generator": 0.16, } +# Per-lane residual after the current king: previous, then two-back. +# Unused slots (no prior distinct king) roll up to the current king. +KOTH_LANE_RESIDUAL = (0.85, 0.10, 0.05) +KOTH_CHAIN_ROLES = ("current", "previous", "two_back") def kings_by_modality(payload: Optional[dict]) -> Dict[str, str]: @@ -25,6 +29,54 @@ def kings_by_modality(payload: Optional[dict]) -> Dict[str, str]: return out +def assign_residual_shares(hotkeys: Iterable[str]) -> List[Dict[str, object]]: + """Map last-N distinct hotkeys to 85/10/5, rolling unused slots to current.""" + keys = [] + seen = set() + for key in hotkeys: + if not key or key in seen: + continue + keys.append(key) + seen.add(key) + if len(keys) >= len(KOTH_LANE_RESIDUAL): + break + if not keys: + return [] + shares = list(KOTH_LANE_RESIDUAL[: len(keys)]) + shares[0] += sum(KOTH_LANE_RESIDUAL[len(keys) :]) + return [ + { + "ss58_address": key, + "share": shares[index], + "role": KOTH_CHAIN_ROLES[index], + } + for index, key in enumerate(keys) + ] + + +def chains_by_modality(payload: Optional[dict]) -> Dict[str, List[Dict[str, object]]]: + """Last-3 distinct kings per lane, with residual shares recomputed locally.""" + if not payload: + return {} + raw = payload.get("chain") or {} + kings = kings_by_modality(payload) + out: Dict[str, List[Dict[str, object]]] = {} + for modality in ("image", "video", "audio"): + members = raw.get(modality) or [] + hotkeys = [] + for member in members: + if isinstance(member, str): + hotkeys.append(member) + elif isinstance(member, dict): + hotkeys.append(member.get("ss58_address")) + assigned = assign_residual_shares(hotkeys) + if not assigned and kings.get(modality): + assigned = assign_residual_shares([kings[modality]]) + if assigned: + out[modality] = assigned + return out + + def build_koth_weights( n: int, scores: np.ndarray, @@ -33,13 +85,25 @@ def build_koth_weights( uid_for_hotkey: Callable[[str], Optional[int]], burn_uid: Optional[int] = None, split: Optional[Dict[str, float]] = None, + chains: Optional[Dict[str, List[Dict[str, object]]]] = None, ) -> np.ndarray: """Return a length-n weight vector. Missing kings go to burn_uid. `uid_for_hotkey` must resolve at current chain head. Escrow addresses are - never used. + never used. Each discriminator lane is 85/10/5 across the current king and + the previous two distinct kings. Unused residual slots roll to the current + king. An unresolvable current king burns its share; unresolvable previous + kings roll to the current king when that UID resolved. """ - split = dict(split or KOTH_SPLIT) + split = dict(KOTH_SPLIT if split is None else split) + try: + split = {key: float(split[key]) for key in KOTH_SPLIT} + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("Invalid KOTH split") from exc + if any( + not np.isfinite(value) or value < 0 for value in split.values() + ) or not np.isclose(sum(split.values()), 1.0): + raise ValueError("Invalid KOTH split") weights = np.zeros(n, dtype=np.float64) if len(scores) < n: scores = np.append(scores, np.zeros(n - len(scores))) @@ -49,19 +113,43 @@ def build_koth_weights( king_uids = set() burned = 0.0 for modality in ("image", "video", "audio"): - pct = float(split.get(modality, 0.0)) - hotkey = kings.get(modality) - if not hotkey: - burned += pct - continue - uid = uid_for_hotkey(hotkey) - if uid is None or uid < 0 or uid >= n: + pct = split[modality] + members = list((chains or {}).get(modality) or []) + if not members: + hotkey = kings.get(modality) + members = assign_residual_shares([hotkey] if hotkey else []) + if not members: burned += pct continue - weights[uid] += pct - king_uids.add(uid) - generator_pct = float(split.get("generator", 0.16)) + current_uid = None + leftover = 0.0 + current_resolved = False + for index, member in enumerate(members): + hotkey = member.get("ss58_address") + share = float(member.get("share") or 0.0) + if share <= 0: + continue + uid = uid_for_hotkey(hotkey) if hotkey else None + if uid is None or uid < 0 or uid >= n: + if index == 0: + burned += pct * share + else: + leftover += share + continue + weights[uid] += pct * share + king_uids.add(uid) + if index == 0: + current_uid = uid + current_resolved = True + + if leftover > 0: + if current_resolved and current_uid is not None: + weights[current_uid] += pct * leftover + else: + burned += pct * leftover + + generator_pct = split["generator"] active = [uid for uid in generator_uids if 0 <= uid < n and uid not in king_uids] if active and generator_pct > 0: gen_scores = np.array([max(float(scores[uid]), 0.0) for uid in active]) @@ -74,11 +162,11 @@ def build_koth_weights( else: burned += generator_pct - if ( - burned > 0 - and burn_uid is not None - and 0 <= burn_uid < n - ): + if burned > 0: + if burn_uid is None or not 0 <= burn_uid < n: + raise ValueError( + f"Cannot allocate {burned:.4f} burn weight: burn UID is unavailable" + ) weights[burn_uid] += burned return weights diff --git a/neurons/discriminator/push_model.py b/neurons/discriminator/push_model.py index 481dfbba..9486bc39 100644 --- a/neurons/discriminator/push_model.py +++ b/neurons/discriminator/push_model.py @@ -54,6 +54,17 @@ class Style: MODEL_UPLOAD_ENDPOINT = "https://upload.bitmind.ai/upload" +DEFAULT_MAX_RETRIES = 3 + + +def should_retry_register(attempt: int, max_retries: int) -> bool: + """True if another chain-register try should run after this failed attempt. + + attempt is 1-based. max_retries <= 0 keeps the old unlimited loop. + """ + if max_retries <= 0: + return True + return attempt < max_retries def print_success(message: str): @@ -90,6 +101,29 @@ def _print_submission_count(result: dict, modality: str, hotkey: str): print_info(f"{count_after}/{submissions_max} {modality} models submitted for {hotkey}") +def _accept_already_uploaded(modality: str, result: dict, skip_chain: bool) -> bool: + """Handle a 409 / already-accepted hash. True means this modality is done.""" + if result.get("already_uploaded"): + print_warning( + f"{modality.capitalize()} model already uploaded — server already has this hash." + ) + if skip_chain: + print_success( + f"{modality.capitalize()} upload already accepted; nothing more to do (--skip-chain)." + ) + return True + print_error( + f"Cannot retrieve r2_key for already-uploaded {modality} model. " + "Re-run with the original r2_key, or pass --skip-chain if you only needed the upload." + ) + return False + print_error(f"{modality.capitalize()} model upload failed at step: {result.get('step', 'unknown')}") + print_error(f"Error: {result.get('error', 'Unknown error')}") + if result.get("response"): + print_error(f"Server response: {result['response']}") + return False + + async def push_separate_models( image_model_path: Optional[str] = None, video_model_path: Optional[str] = None, @@ -99,6 +133,9 @@ async def push_separate_models( netuid: int = 34, chain_endpoint: Optional[str] = None, vertical: str = "general", + upload_endpoint: Optional[str] = None, + skip_chain: bool = False, + max_retries: int = DEFAULT_MAX_RETRIES, ): """Pushes separate image, video, and/or audio detector models and registers on the Bittensor blockchain. @@ -114,6 +151,7 @@ async def push_separate_models( if audio_model_path and not os.path.exists(audio_model_path): raise FileNotFoundError(f"Audio model file not found: {audio_model_path}") + endpoint = upload_endpoint or MODEL_UPLOAD_ENDPOINT results = {} upload_count = 0 total_uploads = (1 if image_model_path else 0) + \ @@ -129,26 +167,17 @@ async def push_separate_models( wallet, image_model_path, 'image', - MODEL_UPLOAD_ENDPOINT, + endpoint, vertical=vertical, ) results['image'] = image_result if not image_result['success']: - if image_result.get('already_uploaded'): - print_warning("Image model already uploaded — skipping (model is already accepted by the server)") - # Still need r2_key for blockchain registration; server returned 409 so we - # don't have it here. The miner must use the r2_key from the original upload. - print_error("Cannot retrieve r2_key for already-uploaded image model. Re-run with the original r2_key or wait for the current upload to be processed.") + if not _accept_already_uploaded('image', image_result, skip_chain): return False - print_error(f"Image model upload failed at step: {image_result.get('step', 'unknown')}") - print_error(f"Error: {image_result.get('error', 'Unknown error')}") - if image_result.get('response'): - print_error(f"Server response: {image_result['response']}") - return False - - print_success("Image model uploaded successfully!") - _print_submission_count(image_result, 'image', wallet.hotkey.ss58_address) + else: + print_success("Image model uploaded successfully!") + _print_submission_count(image_result, 'image', wallet.hotkey.ss58_address) except Exception as e: print_error(f"Image model upload failed with exception: {e}") return False @@ -162,24 +191,17 @@ async def push_separate_models( wallet, video_model_path, 'video', - MODEL_UPLOAD_ENDPOINT, + endpoint, vertical=vertical, ) results['video'] = video_result if not video_result['success']: - if video_result.get('already_uploaded'): - print_warning("Video model already uploaded — skipping (model is already accepted by the server)") - print_error("Cannot retrieve r2_key for already-uploaded video model. Re-run with the original r2_key or wait for the current upload to be processed.") + if not _accept_already_uploaded('video', video_result, skip_chain): return False - print_error(f"Video model upload failed at step: {video_result.get('step', 'unknown')}") - print_error(f"Error: {video_result.get('error', 'Unknown error')}") - if video_result.get('response'): - print_error(f"Server response: {video_result['response']}") - return False - - print_success("Video model uploaded successfully!") - _print_submission_count(video_result, 'video', wallet.hotkey.ss58_address) + else: + print_success("Video model uploaded successfully!") + _print_submission_count(video_result, 'video', wallet.hotkey.ss58_address) except Exception as e: print_error(f"Video model upload failed with exception: {e}") return False @@ -193,28 +215,26 @@ async def push_separate_models( wallet, audio_model_path, 'audio', - MODEL_UPLOAD_ENDPOINT, + endpoint, vertical=vertical, ) results['audio'] = audio_result if not audio_result['success']: - if audio_result.get('already_uploaded'): - print_warning("Audio model already uploaded — skipping (model is already accepted by the server)") - print_error("Cannot retrieve r2_key for already-uploaded audio model. Re-run with the original r2_key or wait for the current upload to be processed.") + if not _accept_already_uploaded('audio', audio_result, skip_chain): return False - print_error(f"Audio model upload failed at step: {audio_result.get('step', 'unknown')}") - print_error(f"Error: {audio_result.get('error', 'Unknown error')}") - if audio_result.get('response'): - print_error(f"Server response: {audio_result['response']}") - return False - - print_success("Audio model uploaded successfully!") - _print_submission_count(audio_result, 'audio', wallet.hotkey.ss58_address) + else: + print_success("Audio model uploaded successfully!") + _print_submission_count(audio_result, 'audio', wallet.hotkey.ss58_address) except Exception as e: print_error(f"Audio model upload failed with exception: {e}") return False + if skip_chain: + print() + print_success("Upload complete. Skipping blockchain registration (--skip-chain).") + return True + print() print_step(upload_count + 1, total_uploads + 1, "Registering model metadata on blockchain...") @@ -226,6 +246,7 @@ async def push_separate_models( metadata_store = ChainModelMetadataStore(subtensor, netuid) # Register each model separately on the blockchain + chain_ok = True for modality, result in results.items(): model_key = result.get("r2_key", "") if not model_key: @@ -243,7 +264,10 @@ async def push_separate_models( model_id = ModelId(key=model_key, hash=hash_value) print_info(f"Registering {modality} model (ID: {model_id.key})...") + attempt = 0 + registered = False while True: + attempt += 1 try: await metadata_store.store_model_metadata(wallet, model_id) @@ -265,15 +289,33 @@ async def push_separate_models( raise ValueError(f"{modality.capitalize()} metadata verification failed") print_success(f"{modality.capitalize()} model registered on blockchain!") - break # Success, move to next model + registered = True + break except Exception as e: print_error(f"Failed to register {modality} model on blockchain: {e}") - print_warning(f"Retrying in {retry_delay_secs} seconds...") - time.sleep(retry_delay_secs) + if should_retry_register(attempt, max_retries): + print_warning( + f"Retrying in {retry_delay_secs} seconds " + f"(attempt {attempt}/{max_retries if max_retries > 0 else 'unlimited'})..." + ) + time.sleep(retry_delay_secs) + continue + print_warning( + f"Giving up chain registration after {attempt} attempt(s). " + "The upload already succeeded and the model will still be examined." + ) + chain_ok = False + break + + if not registered: + chain_ok = False - print_success("All models registered successfully!") - return True + if chain_ok: + print_success("All models registered successfully!") + else: + print_warning("Upload succeeded; one or more chain registrations did not complete.") + return chain_ok def main(): @@ -327,6 +369,25 @@ def main(): choices=["general", "human"], help="Competition vertical (default: general)" ) + parser.add_argument( + "--upload-endpoint", + default=MODEL_UPLOAD_ENDPOINT, + help=f"Model upload URL (default: {MODEL_UPLOAD_ENDPOINT})", + ) + parser.add_argument( + "--skip-chain", + action="store_true", + help="Exit after a successful upload without writing on-chain metadata", + ) + parser.add_argument( + "--max-retries", + type=int, + default=DEFAULT_MAX_RETRIES, + help=( + "Max blockchain registration attempts per modality after a failed try " + f"(default: {DEFAULT_MAX_RETRIES}; 0 = retry forever)" + ), + ) args = parser.parse_args() @@ -351,6 +412,11 @@ def main(): print(f"Subnet UID: {args.netuid}") print(f"Chain Endpoint: {args.chain_endpoint}") print(f"Vertical: {args.vertical}") + print(f"Upload endpoint: {args.upload_endpoint}") + if args.skip_chain: + print("Chain registration: skipped") + else: + print(f"Chain register retries: {args.max_retries}") print() print(f"{Fore.CYAN}{Style.BRIGHT}=== Starting Model Push ==={Style.RESET_ALL}") @@ -363,14 +429,17 @@ def main(): print_error(f"Failed to initialize wallet: {e}") sys.exit(1) - # Check if hotkey is registered + # Opening a Metagraph websocket keeps this process alive after success. + # Only do it when we are about to write on-chain metadata. netuid = args.netuid - mg = bt.Metagraph(netuid=netuid, network="finney" if netuid == 34 else "test") - hotkey = wallet.hotkey.ss58_address - if hotkey not in mg.hotkeys: - print_warning(f"Hotkey {hotkey} not registered on netuid {netuid}") - print_warning("This may cause issues with model registration") - + if not args.skip_chain: + mg = bt.Metagraph(netuid=netuid, network="finney" if netuid == 34 else "test") + hotkey = wallet.hotkey.ss58_address + if hotkey not in mg.hotkeys: + print_warning(f"Hotkey {hotkey} not registered on netuid {netuid}") + print_warning("This may cause issues with model registration") + + exit_code = 0 try: success = asyncio.run( push_separate_models( @@ -382,6 +451,9 @@ def main(): netuid=netuid, chain_endpoint=args.chain_endpoint, vertical=args.vertical, + upload_endpoint=args.upload_endpoint, + skip_chain=args.skip_chain, + max_retries=args.max_retries, ) ) @@ -390,14 +462,17 @@ def main(): print_success("🎉 Model push completed successfully!") else: print_error("💥 Model push failed!") - sys.exit(1) + exit_code = 1 except Exception as e: print() print_error(f"💥 Model push failed with error: {e}") print_info("Full traceback:") print(traceback.format_exc()) - sys.exit(1) + exit_code = 1 + + # Bittensor leaves non-daemon websocket threads; SystemExit will not end the process. + os._exit(exit_code) if __name__ == "__main__": diff --git a/neurons/validator/validator.py b/neurons/validator/validator.py index db45bd41..55184ec7 100644 --- a/neurons/validator/validator.py +++ b/neurons/validator/validator.py @@ -11,7 +11,7 @@ from gas import __spec_version__ as spec_version from gas.protocol.validator_requests import get_benchmark_results, get_current_kings -from gas.koth_weights import build_koth_weights, kings_by_modality +from gas.koth_weights import build_koth_weights, chains_by_modality, kings_by_modality from gas.utils.autoupdater import autoupdate from gas.cache import ContentManager from gas.utils.metagraph import create_set_weights @@ -213,6 +213,7 @@ async def set_weights(self, block): kings_payload = {"kings": []} kings = kings_by_modality(kings_payload) + chains = chains_by_modality(kings_payload) split = (kings_payload or {}).get("split") async with self._state_lock: @@ -249,11 +250,13 @@ def uid_for_hotkey(hotkey_ss58: str): uid_for_hotkey=uid_for_hotkey, burn_uid=burn_uid, split=split, + chains=chains, ) total_weight = float(np.sum(normed_weights)) bt.logging.info( f"KOTH weights sum={total_weight:.4f} kings={list(kings.keys())} " + f"chain={ {mod: [m.get('role') for m in members] for mod, members in chains.items()} } " f"generators={len(generator_uids)}" ) diff --git a/tests/test_koth_weights.py b/tests/test_koth_weights.py index 6f7421ef..1b2cb3c1 100644 --- a/tests/test_koth_weights.py +++ b/tests/test_koth_weights.py @@ -1,8 +1,14 @@ """Unit tests for KOTH validator weight vectors.""" import numpy as np +import pytest -from gas.koth_weights import build_koth_weights, kings_by_modality +from gas.koth_weights import ( + assign_residual_shares, + build_koth_weights, + chains_by_modality, + kings_by_modality, +) ESCROW = { "image": "5EUJFyH4ZSSiD3C8sM698nsVE26Tq98LoBwkmopmWZqaZqCA", @@ -71,3 +77,95 @@ def test_api_down_empty_kings_burns_discriminator_shares(): ) assert abs(weights[0] - 0.84) < 1e-9 assert abs(weights[1] - 0.16) < 1e-9 + + +def test_required_burn_fails_when_burn_uid_is_unavailable(): + with pytest.raises(ValueError, match="burn UID is unavailable"): + build_koth_weights( + n=3, + scores=np.array([0.0, 1.0, 0.0]), + generator_uids=[1], + kings={}, + uid_for_hotkey=lambda hk: None, + burn_uid=None, + ) + + +@pytest.mark.parametrize( + "split", + [ + {"image": 0.4, "video": 0.4, "audio": -0.01, "generator": 0.21}, + {"image": 0.4, "video": 0.4, "audio": 0.04}, + ], +) +def test_invalid_split_is_rejected(split): + with pytest.raises(ValueError, match="Invalid KOTH split"): + build_koth_weights( + n=1, + scores=np.zeros(1), + generator_uids=[], + kings={}, + uid_for_hotkey=lambda hk: None, + burn_uid=0, + split=split, + ) + + +def test_residual_rolls_unused_slots_to_current(): + assert assign_residual_shares(["5A"]) == [ + {"ss58_address": "5A", "share": 1.0, "role": "current"} + ] + assert assign_residual_shares(["5A", "5B"]) == [ + {"ss58_address": "5A", "share": 0.90, "role": "current"}, + {"ss58_address": "5B", "share": 0.10, "role": "previous"}, + ] + + +def test_chains_by_modality_recomputes_shares_and_falls_back(): + payload = { + "kings": [{"modality": "audio", "ss58_address": "5Aud"}], + "chain": { + "image": [ + {"ss58_address": "5Img", "share": 0.5, "role": "current"}, + {"ss58_address": "5Prev", "share": 0.5, "role": "previous"}, + ] + }, + } + chains = chains_by_modality(payload) + assert [member["share"] for member in chains["image"]] == [0.90, 0.10] + assert chains["audio"][0]["ss58_address"] == "5Aud" + assert chains["audio"][0]["share"] == 1.0 + + +def test_lane_residual_splits_across_last_three_kings(): + hotkeys = {"5Img": 1, "5Prev": 2, "5Two": 3, "5Burn": 0} + weights = build_koth_weights( + n=5, + scores=np.zeros(5), + generator_uids=[], + kings={"image": "5Img"}, + uid_for_hotkey=hotkeys.get, + burn_uid=0, + chains={ + "image": assign_residual_shares(["5Img", "5Prev", "5Two"]), + }, + ) + assert abs(weights[1] - 0.40 * 0.85) < 1e-9 + assert abs(weights[2] - 0.40 * 0.10) < 1e-9 + assert abs(weights[3] - 0.40 * 0.05) < 1e-9 + assert abs(weights[0] - 0.60) < 1e-9 # video + audio + generator + + +def test_unresolvable_previous_king_rolls_to_current(): + hotkeys = {"5Img": 1, "5Burn": 0} + weights = build_koth_weights( + n=3, + scores=np.zeros(3), + generator_uids=[], + kings={"image": "5Img"}, + uid_for_hotkey=hotkeys.get, + burn_uid=0, + chains={"image": assign_residual_shares(["5Img", "5Gone"])}, + ) + assert abs(weights[1] - 0.40) < 1e-9 + assert abs(weights[0] - 0.60) < 1e-9 diff --git a/tests/test_push_model_retries.py b/tests/test_push_model_retries.py new file mode 100644 index 00000000..264df53b --- /dev/null +++ b/tests/test_push_model_retries.py @@ -0,0 +1,68 @@ +import asyncio +from types import SimpleNamespace + +import neurons.discriminator.push_model as push_model +from neurons.discriminator.push_model import ( + _accept_already_uploaded, + push_separate_models, + should_retry_register, +) + + +def test_default_retries_stop_after_three(): + assert should_retry_register(1, 3) is True + assert should_retry_register(2, 3) is True + assert should_retry_register(3, 3) is False + + +def test_zero_retries_is_unlimited(): + assert should_retry_register(1, 0) is True + assert should_retry_register(99, 0) is True + + +def test_already_uploaded_is_success_when_skipping_chain(): + result = {"already_uploaded": True, "success": False} + assert _accept_already_uploaded("image", result, skip_chain=True) is True + + +def test_already_uploaded_fails_when_chain_register_needs_r2_key(): + result = {"already_uploaded": True, "success": False} + assert _accept_already_uploaded("image", result, skip_chain=False) is False + + +def test_chain_registration_failure_is_reported(monkeypatch, tmp_path): + model_path = tmp_path / "model.zip" + model_path.touch() + wallet = SimpleNamespace(hotkey=SimpleNamespace(ss58_address="5Miner")) + + monkeypatch.setattr( + push_model, + "upload_single_modality", + lambda *args, **kwargs: { + "success": True, + "r2_key": "model-key", + "file_hash": "file-hash", + }, + ) + monkeypatch.setattr(push_model.bt, "Subtensor", lambda **kwargs: object()) + + class FailingMetadataStore: + async def store_model_metadata(self, wallet, model_id): + raise RuntimeError("chain unavailable") + + monkeypatch.setattr( + push_model, + "ChainModelMetadataStore", + lambda subtensor, netuid: FailingMetadataStore(), + ) + + success = asyncio.run( + push_separate_models( + image_model_path=str(model_path), + wallet=wallet, + retry_delay_secs=0, + max_retries=1, + ) + ) + + assert success is False