diff --git a/gittensor/classes.py b/gittensor/classes.py index 4856d4744..7916b8cfc 100644 --- a/gittensor/classes.py +++ b/gittensor/classes.py @@ -291,6 +291,21 @@ class MinerEvaluation: # The top-level scalars above are round-level rollups of this map. repo_evaluations: Dict[str, RepoEvaluation] = field(default_factory=dict) + def get_or_create_repo_evaluation( + self, repo_name: str, repository_full_name: Optional[str] = None + ) -> RepoEvaluation: + """Return the repo evaluation stored under ``repo_name``, creating one if absent. + + ``repo_name`` is the map key (a lowercased repository_full_name). When a + new entry is created, ``repository_full_name`` seeds it, defaulting to + ``repo_name`` when not supplied. + """ + repo_eval = self.repo_evaluations.get(repo_name) + if repo_eval is None: + repo_eval = RepoEvaluation(repository_full_name=repository_full_name or repo_name) + self.repo_evaluations[repo_name] = repo_eval + return repo_eval + @property def total_prs(self) -> int: return self.total_merged_prs + self.total_closed_prs + self.total_open_prs @@ -578,10 +593,7 @@ def store(self, evaluation: 'MinerEvaluation') -> None: value = getattr(existing.evaluation, name) setattr(cached_eval, name, _copy_issue_discovery_value(name, value)) for repo_name, prior_repo in existing.evaluation.repo_evaluations.items(): - target = cached_eval.repo_evaluations.get(repo_name) - if target is None: - target = RepoEvaluation(repository_full_name=prior_repo.repository_full_name) - cached_eval.repo_evaluations[repo_name] = target + target = cached_eval.get_or_create_repo_evaluation(repo_name, prior_repo.repository_full_name) target.copy_issue_discovery_from(prior_repo) self._cache[evaluation.uid] = CachedEvaluation( @@ -621,10 +633,7 @@ def update_issue_discovery(self, evaluation: 'MinerEvaluation') -> None: setattr(existing.evaluation, name, _copy_issue_discovery_value(name, value)) for repo_name, repo_eval in evaluation.repo_evaluations.items(): - target = existing.evaluation.repo_evaluations.get(repo_name) - if target is None: - target = RepoEvaluation(repository_full_name=repo_eval.repository_full_name) - existing.evaluation.repo_evaluations[repo_name] = target + target = existing.evaluation.get_or_create_repo_evaluation(repo_name, repo_eval.repository_full_name) target.copy_issue_discovery_from(repo_eval) bt.logging.debug(f'Refreshed cached issue discovery for UID {evaluation.uid}') diff --git a/gittensor/constants.py b/gittensor/constants.py index e513dbec3..3e315ae79 100644 --- a/gittensor/constants.py +++ b/gittensor/constants.py @@ -1,4 +1,5 @@ # Entrius 2025 +import os import re from typing import Dict @@ -34,6 +35,21 @@ MIRROR_HTTP_TIMEOUT_SECONDS = 30 MIRROR_MAX_ATTEMPTS = 3 +# ============================================================================= +# das-gittensor API (https://api.gittensor.io) — repository hyperparameter registry +# ============================================================================= +# GET /repos returns the full master-repository registry (full_name -> raw config), +# the sole source of truth for repository hyperparameters. On every successful +# fetch the validator writes the registry to an on-disk last-good cache; when the +# API is unreachable it falls back to that cache (and to built-in knob defaults if +# no cache exists). There is no bundled master_repositories.json. +GITTENSOR_API_DEFAULT_URL = 'https://api.gittensor.io' +REPOS_API_TIMEOUT_SECONDS = 15 +REPOS_API_MAX_ATTEMPTS = 3 +# On-disk last-good cache for the repos registry. Env-configurable; defaults under +# the user's home so it survives validator restarts. '~' is expanded at use. +REPOS_CACHE_PATH = os.getenv('GITTENSOR_REPOS_CACHE_PATH', '~/.gittensor/cache/repos_registry.json') + # ============================================================================= # Language & File Scoring # ============================================================================= @@ -123,14 +139,14 @@ # ============================================================================= # Eligibility Gate (OSS Contributions) # ============================================================================= -# Per-repo defaults — each repo may override these in master_repositories.json. +# Per-repo defaults — each repo may override these via its registry config (GET /repos). MIN_VALID_MERGED_PRS = 3 # minimum merged PRs (per repo) to receive score MIN_CREDIBILITY = 0.80 # minimum credibility ratio to receive score # ============================================================================= # Issue Discovery # ============================================================================= -# Eligibility gate — per-repo defaults, overridable in master_repositories.json. +# Eligibility gate — per-repo defaults, overridable via the registry config (GET /repos). MIN_VALID_SOLVED_ISSUES = 3 # minimum solved issues where solving PR has token_score >= MIN_TOKEN_SCORE_FOR_VALID_ISSUE MIN_ISSUE_CREDIBILITY = 0.80 # minimum issue credibility ratio MIN_TOKEN_SCORE_FOR_VALID_ISSUE = 5 # solving-PR token_score for a solved issue to count as "valid" diff --git a/gittensor/validator/emission_allocation.py b/gittensor/validator/emission_allocation.py index aadff2ebe..6bf532bf9 100644 --- a/gittensor/validator/emission_allocation.py +++ b/gittensor/validator/emission_allocation.py @@ -58,7 +58,7 @@ def blend_emission_pools( # Issue treasury (10% flat to UID 111) if ISSUES_TREASURY_UID > 0 and ISSUES_TREASURY_UID in miner_uids: - treasury_idx = sorted_uids.index(ISSUES_TREASURY_UID) + treasury_idx = uid_index[ISSUES_TREASURY_UID] rewards[treasury_idx] += ISSUES_TREASURY_EMISSION_SHARE bt.logging.info( f'Treasury allocation: UID {ISSUES_TREASURY_UID} receives ' @@ -67,7 +67,7 @@ def blend_emission_pools( # Recycle receives registry slack and empty repo slices. if RECYCLE_UID in miner_uids: - recycle_idx = sorted_uids.index(RECYCLE_UID) + recycle_idx = uid_index[RECYCLE_UID] rewards[recycle_idx] += recycle_share if recycle_share > EMISSION_SHARE_TOLERANCE: bt.logging.info(f'Recycling {recycle_share * 100:.0f}% unclaimed emissions from repo allocation') diff --git a/gittensor/validator/issue_competitions/contract_client.py b/gittensor/validator/issue_competitions/contract_client.py index 72a755b73..a67fa04aa 100644 --- a/gittensor/validator/issue_competitions/contract_client.py +++ b/gittensor/validator/issue_competitions/contract_client.py @@ -49,6 +49,22 @@ def load_contract_metadata() -> Tuple[Dict[str, bytes], Dict[str, List]]: CONTRACT_SELECTORS, CONTRACT_ARG_TYPES = load_contract_metadata() +def _scale_compact_length(n: int) -> bytes: + """SCALE-encode a non-negative integer as a compact length prefix. + + Used to prefix variable-length SCALE payloads (Vec, String). + """ + if n < 0: + raise ValueError(f'Length must be non-negative: {n}') + if n < 1 << 6: + return bytes([n << 2]) + if n < 1 << 14: + return ((n << 2) | 1).to_bytes(2, 'little') + if n < 1 << 30: + return ((n << 2) | 2).to_bytes(4, 'little') + raise ValueError(f'Length too large for compact encoding: {n}') + + class IssueStatus(Enum): """Status of an issue in its lifecycle""" @@ -568,6 +584,11 @@ def _encode_args(self, method_name: str, args: dict) -> bytes: encoded += struct.pack('> 64) + elif type_def == 'str': + if not isinstance(value, str): + raise ValueError(f'Expected str for {arg_name}, got {type(value).__name__}') + data = value.encode('utf-8') + encoded += _scale_compact_length(len(data)) + data elif type_def == 'AccountId': if isinstance(value, str): encoded += bytes.fromhex(self.subtensor.substrate.ss58_decode(value)) diff --git a/gittensor/validator/issue_discovery/scan.py b/gittensor/validator/issue_discovery/scan.py index 2365b57a4..4f6310663 100644 --- a/gittensor/validator/issue_discovery/scan.py +++ b/gittensor/validator/issue_discovery/scan.py @@ -40,7 +40,7 @@ import bittensor as bt -from gittensor.classes import Issue, MinerEvaluation, MinerEvaluationCache, RepoEvaluation +from gittensor.classes import Issue, MinerEvaluation, MinerEvaluationCache from gittensor.constants import ( MAINTAINER_ASSOCIATIONS, ) @@ -278,10 +278,7 @@ def _apply_open_issue_counts(evaluation: MinerEvaluation, open_counts: Dict[str, """Record per-repo open-issue counts (and the round-level total) for a miner with no in-window issues to score.""" for repo_name, count in open_counts.items(): - repo_eval = evaluation.repo_evaluations.get(repo_name) - if repo_eval is None: - repo_eval = RepoEvaluation(repository_full_name=repo_name) - evaluation.repo_evaluations[repo_name] = repo_eval + repo_eval = evaluation.get_or_create_repo_evaluation(repo_name) repo_eval.total_open_issues = count evaluation.total_open_issues = sum(open_counts.values()) @@ -297,10 +294,7 @@ def _copy_issue_discovery_fields(target: MinerEvaluation, source: MinerEvaluatio target.total_open_issues = source.total_open_issues target.issue_discovery_issues = list(source.issue_discovery_issues) for repo_name, source_repo in source.repo_evaluations.items(): - target_repo = target.repo_evaluations.get(repo_name) - if target_repo is None: - target_repo = RepoEvaluation(repository_full_name=source_repo.repository_full_name) - target.repo_evaluations[repo_name] = target_repo + target_repo = target.get_or_create_repo_evaluation(repo_name, source_repo.repository_full_name) target_repo.copy_issue_discovery_from(source_repo) @@ -560,10 +554,7 @@ def _finalize_repo_issue_scores( acc = repo_acc.get(repo_name) or _RepoIssueAcc() open_count = open_counts.get(repo_name, 0) - repo_eval = evaluation.repo_evaluations.get(repo_name) - if repo_eval is None: - repo_eval = RepoEvaluation(repository_full_name=repo_name) - evaluation.repo_evaluations[repo_name] = repo_eval + repo_eval = evaluation.get_or_create_repo_evaluation(repo_name) repo_eval.total_solved_issues = acc.solved repo_eval.total_valid_solved_issues = acc.valid_solved diff --git a/gittensor/validator/utils/load_weights.py b/gittensor/validator/utils/load_weights.py index 7b7719615..4bf5a8df9 100644 --- a/gittensor/validator/utils/load_weights.py +++ b/gittensor/validator/utils/load_weights.py @@ -1,16 +1,19 @@ # The MIT License (MIT) # Copyright © 2025 Entrius import json +import time from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional import bittensor as bt +import requests from gittensor.constants import ( DEFAULT_ISSUE_DISCOVERY_SHARE, EMISSION_SHARE_TOLERANCE, EXCESSIVE_PR_PENALTY_BASE_THRESHOLD, + GITTENSOR_API_DEFAULT_URL, MAINTAINER_ISSUE_MULTIPLIER, MAX_OPEN_ISSUE_THRESHOLD, MAX_OPEN_PR_THRESHOLD, @@ -25,6 +28,9 @@ OPEN_PR_COLLATERAL_PERCENT, OPEN_PR_THRESHOLD_TOKEN_SCORE, PR_LOOKBACK_DAYS, + REPOS_API_MAX_ATTEMPTS, + REPOS_API_TIMEOUT_SECONDS, + REPOS_CACHE_PATH, REVIEW_PENALTY_RATE, SRC_TOK_SATURATION_SCALE, STANDARD_ISSUE_MULTIPLIER, @@ -512,72 +518,167 @@ def _validate_scoring_configs(configs: Dict[str, RepositoryConfig]) -> None: ) -def load_master_repo_weights() -> Dict[str, RepositoryConfig]: +def _parse_registry(data: Any) -> Dict[str, RepositoryConfig]: + """Parse + validate a raw registry map (full_name -> metadata) into + RepositoryConfig objects, keyed by lowercased full_name. + + Raises RepositoryRegistryError / ValueError on malformed entries or + emission-share / eligibility / scoring contract violations. """ - Load repository emission shares from the local JSON file. - Normalizes repository names to lowercase for case-insensitive matching. + if not isinstance(data, dict): + raise RepositoryRegistryError(f'Expected dict registry, got {type(data)}') + + normalized_data: Dict[str, RepositoryConfig] = {} + for repo_name, metadata in data.items(): + try: + if not isinstance(metadata, dict): + raise TypeError(f'expected object metadata, got {type(metadata)}') + config = RepositoryConfig( + emission_share=_coerce_share(repo_name, 'emission_share', metadata['emission_share']), + issue_discovery_share=_coerce_share( + repo_name, + 'issue_discovery_share', + metadata.get('issue_discovery_share', DEFAULT_ISSUE_DISCOVERY_SHARE), + ), + additional_acceptable_branches=metadata.get('additional_acceptable_branches'), + trusted_label_pipeline=bool(metadata.get('trusted_label_pipeline', False)), + label_multipliers=( + {str(label): float(multiplier) for label, multiplier in metadata['label_multipliers'].items()} + if metadata.get('label_multipliers') is not None + else None + ), + default_label_multiplier=float(metadata.get('default_label_multiplier', 1.0)), + fixed_base_score=metadata.get('fixed_base_score'), + eligibility=_parse_eligibility(repo_name, metadata.get('eligibility')), + scoring=_parse_scoring(repo_name, metadata.get('scoring')), + maintainer_cut=_coerce_share(repo_name, 'maintainer_cut', metadata.get('maintainer_cut', 0.0)), + ) + normalized_data[repo_name.lower()] = config + except RepositoryRegistryError: + raise + except (KeyError, ValueError, TypeError) as e: + raise ValueError(f'Could not parse config for {repo_name}: {e}') from e - Returns: - Dictionary mapping normalized (lowercase) fullName (str) to RepositoryConfig object. - Returns empty dict when the file is missing or invalid JSON. Raises - RepositoryRegistryError or ValueError when registry entries violate the - emission-share contract. + _validate_emission_shares(normalized_data) + _validate_eligibility_configs(normalized_data) + _validate_scoring_configs(normalized_data) + return normalized_data + + +def _fetch_registry_from_api() -> Any: + """GET the repository registry from the das-gittensor API, with retries. + + Returns the decoded JSON (full_name -> raw config map). Raises + RepositoryRegistryError if the endpoint is unreachable or never returns + valid JSON within the retry budget. """ - weights_file = _get_weights_dir() / 'master_repositories.json' + from gittensor.utils.utils import backoff_seconds + + url = f'{GITTENSOR_API_DEFAULT_URL.rstrip("/")}/repos' + last_error: Optional[str] = None + + for attempt in range(REPOS_API_MAX_ATTEMPTS): + try: + response = requests.get(url, timeout=REPOS_API_TIMEOUT_SECONDS) + except requests.RequestException as e: + last_error = f'request exception: {e}' + else: + if 200 <= response.status_code < 300: + try: + return response.json() + except ValueError as e: + last_error = f'invalid JSON: {e}' + else: + last_error = f'status {response.status_code}' + + if attempt < REPOS_API_MAX_ATTEMPTS - 1: + backoff = backoff_seconds(attempt) + bt.logging.warning( + f'repos API GET {url} failed ({last_error}) ' + f'(attempt {attempt + 1}/{REPOS_API_MAX_ATTEMPTS}), retrying in {backoff}s...' + ) + time.sleep(backoff) + + raise RepositoryRegistryError(f'repos API GET {url} failed after {REPOS_API_MAX_ATTEMPTS} attempts: {last_error}') + +def _get_repos_cache_path() -> Path: + """Filesystem path of the on-disk last-good registry cache (env-configurable).""" + return Path(REPOS_CACHE_PATH).expanduser() + + +def _write_registry_to_cache(data: Any) -> None: + """Atomically persist the last-good registry response to the disk cache. + + Best-effort: a cache-write failure must never break the scoring cycle, so any + error is logged and swallowed. Written only after the data has parsed cleanly, + so the cache always holds a valid registry. + """ + cache_path = _get_repos_cache_path() try: - with open(weights_file, 'r') as f: - data = json.load(f) + cache_path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = cache_path.with_suffix(cache_path.suffix + '.tmp') + with open(tmp_path, 'w') as f: + json.dump(data, f) + tmp_path.replace(cache_path) + except OSError as e: + bt.logging.warning(f'Failed to write repos registry cache to {cache_path}: {e}') - if not isinstance(data, dict): - raise RepositoryRegistryError(f'Expected dict from {weights_file}, got {type(data)}') - # Parse JSON data into RepositoryConfig objects - normalized_data: Dict[str, RepositoryConfig] = {} - for repo_name, metadata in data.items(): - try: - if not isinstance(metadata, dict): - raise TypeError(f'expected object metadata, got {type(metadata)}') - config = RepositoryConfig( - emission_share=_coerce_share(repo_name, 'emission_share', metadata['emission_share']), - issue_discovery_share=_coerce_share( - repo_name, - 'issue_discovery_share', - metadata.get('issue_discovery_share', DEFAULT_ISSUE_DISCOVERY_SHARE), - ), - additional_acceptable_branches=metadata.get('additional_acceptable_branches'), - trusted_label_pipeline=bool(metadata.get('trusted_label_pipeline', False)), - label_multipliers=( - {str(label): float(multiplier) for label, multiplier in metadata['label_multipliers'].items()} - if metadata.get('label_multipliers') is not None - else None - ), - default_label_multiplier=float(metadata.get('default_label_multiplier', 1.0)), - fixed_base_score=metadata.get('fixed_base_score'), - eligibility=_parse_eligibility(repo_name, metadata.get('eligibility')), - scoring=_parse_scoring(repo_name, metadata.get('scoring')), - maintainer_cut=_coerce_share(repo_name, 'maintainer_cut', metadata.get('maintainer_cut', 0.0)), - ) - normalized_data[repo_name.lower()] = config - except RepositoryRegistryError: - raise - except (KeyError, ValueError, TypeError) as e: - raise ValueError(f'Could not parse config for {repo_name}: {e}') from e +def _load_registry_from_cache() -> Any: + """Read the on-disk last-good registry cache. - _validate_emission_shares(normalized_data) - _validate_eligibility_configs(normalized_data) - _validate_scoring_configs(normalized_data) + Raises FileNotFoundError when no cache exists yet, json.JSONDecodeError when + the cache file is corrupt. + """ + with open(_get_repos_cache_path(), 'r') as f: + return json.load(f) - bt.logging.debug(f'Successfully loaded {len(normalized_data)} repository entries from {weights_file}') - return normalized_data +def load_master_repo_weights() -> Dict[str, RepositoryConfig]: + """ + Load the repository hyperparameter registry, normalizing repo names to + lowercase for case-insensitive matching. + + Source of truth is the das-gittensor API (GET /repos). On a successful fetch + the registry is written to an on-disk last-good cache. If the endpoint is + unreachable or serves data that fails the registry contract, falls back to + that cache so a transient API outage or a bad push can't brick scoring. + + Returns: + Dictionary mapping normalized (lowercase) fullName -> RepositoryConfig. + Returns an empty dict when the API is down and no cache exists (the + registry is empty that cycle; per-field knob defaults still apply once + repos are known). Raises RepositoryRegistryError / ValueError when the + cached registry itself violates the registry contract. + """ + try: + data = _fetch_registry_from_api() + normalized = _parse_registry(data) + _write_registry_to_cache(data) + bt.logging.debug(f'Loaded {len(normalized)} repository entries from the repos API') + return normalized + except Exception as api_error: + bt.logging.warning( + f'Repository registry API unavailable or invalid ({api_error}); falling back to the on-disk last-good cache' + ) + + try: + data = _load_registry_from_cache() except FileNotFoundError: - bt.logging.error(f'Weights file not found: {weights_file}') + bt.logging.error( + 'Repos API unavailable and no on-disk registry cache present; ' + 'returning empty registry (no repos scored this cycle)' + ) return {} except json.JSONDecodeError as e: - bt.logging.error(f'Failed to parse JSON from {weights_file}: {e}') + bt.logging.error(f'On-disk registry cache is invalid JSON: {e}; returning empty registry') return {} + normalized = _parse_registry(data) + bt.logging.debug(f'Loaded {len(normalized)} repository entries from the on-disk cache (fallback)') + return normalized + def load_programming_language_weights() -> Dict[str, LanguageConfig]: """ diff --git a/gittensor/validator/weights/master_repositories.json b/gittensor/validator/weights/master_repositories.json deleted file mode 100644 index 14a19544d..000000000 --- a/gittensor/validator/weights/master_repositories.json +++ /dev/null @@ -1,373 +0,0 @@ -{ - "anderdc/social-media-manager": { - "default_label_multiplier": 0.0, - "eligibility": { - "min_credibility": 0.0, - "min_issue_credibility": 0.0, - "min_valid_merged_prs": 0, - "min_valid_solved_issues": 0 - }, - "emission_share": 0.005, - "fixed_base_score": 1.0, - "issue_discovery_share": 0.0, - "label_multipliers": { - "crown": 1.0 - }, - "maintainer_cut": 0.0, - "scoring": { - "pr_lookback_days": 7 - }, - "trusted_label_pipeline": true - }, - "cogniax/tao-pulse-app": { - "emission_share": 0.008, - "issue_discovery_share": 0.5, - "maintainer_cut": 0.4 - }, - "DPBG/Engram.AI": { - "emission_share": 0.005, - "issue_discovery_share": 0.0 - }, - "e35ventura/taopedia": { - "emission_share": 0.025, - "issue_discovery_share": 0.0, - "maintainer_cut": 0.0, - "additional_acceptable_branches": ["test"], - "trusted_label_pipeline": true, - "default_label_multiplier": 0.0, - "label_multipliers": { - "ui-ux": 5.0, - "feature": 2.0, - "bug": 0.35, - "security": 0.35, - "other": 0.05 - }, - "eligibility": { - "min_credibility": 0.6, - "max_open_pr_threshold": 4 - }, - "scoring": { - "pr_lookback_days": 7, - "maintainer_issue_multiplier": 5.0, - "time_decay": { - "grace_period_hours": 6, - "sigmoid_midpoint_days": 2, - "sigmoid_steepness": 1.0, - "min_multiplier": 0.02 - } - } - }, - "e35ventura/taopedia-articles": { - "emission_share": 0.025, - "issue_discovery_share": 0.0, - "maintainer_cut": 0.0, - "additional_acceptable_branches": ["test"], - "trusted_label_pipeline": true, - "default_label_multiplier": 0.0, - "label_multipliers": { - "article": 1.0, - "correction": 1.25, - "image": 0.75, - "category": 0.5, - "other": 0.1 - }, - "eligibility": { - "min_credibility": 0.5, - "min_token_score_for_valid_issue": 0.0 - }, - "scoring": { - "pr_lookback_days": 7, - "time_decay": { - "grace_period_hours": 3, - "sigmoid_midpoint_days": 1, - "sigmoid_steepness": 1.25, - "min_multiplier": 0.01 - } - } - }, - "entrius/allways": { - "emission_share": 0.04, - "issue_discovery_share": 1.0, - "label_multipliers": { - "bug": 1.25, - "enhancement": 1.0, - "refactor": 0.25 - }, - "maintainer_cut": 0.0, - "trusted_label_pipeline": true - }, - "entrius/das-github-mirror": { - "emission_share": 0.005, - "issue_discovery_share": 0.5, - "label_multipliers": { - "bug": 1.25, - "enhancement": 1.1, - "refactor": 0.1 - }, - "maintainer_cut": 0.0, - "trusted_label_pipeline": true - }, - "entrius/gittensor": { - "emission_share": 0.07, - "issue_discovery_share": 0.0, - "label_multipliers": { - "bug": 1.1, - "enhancement": 1.25, - "feature": 1.5, - "refactor": 0.25 - }, - "maintainer_cut": 0.0, - "trusted_label_pipeline": true - }, - "entrius/gittensor-ui": { - "emission_share": 0.0, - "issue_discovery_share": 0.0, - "label_multipliers": { - "bug": 1.1, - "enhancement": 1.0, - "feature": 1.25, - "refactor": 0.5 - }, - "maintainer_cut": 0.0, - "trusted_label_pipeline": true - }, - "entrius/oc-1": { - "default_label_multiplier": 0.0, - "eligibility": { - "min_credibility": 0.0, - "min_issue_credibility": 0.0, - "min_valid_merged_prs": 0, - "min_valid_solved_issues": 0 - }, - "emission_share": 0.0, - "fixed_base_score": 1.0, - "issue_discovery_share": 0.0, - "label_multipliers": { - "benchmark-improvement": 1.0 - }, - "maintainer_cut": 0.0, - "trusted_label_pipeline": true - }, - "Geniepod/genie-claw": { - "default_label_multiplier": 0.2, - "eligibility": { - "max_open_pr_threshold": 2 - }, - "emission_share": 0.025, - "issue_discovery_share": 0.0, - "label_multipliers": { - "community-contribution": 0.2, - "optimization": 0.8 - }, - "maintainer_cut": 0.3 - }, - "infiniflow/ragflow": { - "emission_share": 0.055, - "issue_discovery_share": 0.0 - }, - "JSONbored/awesome-claude": { - "default_label_multiplier": 0.0, - "trusted_label_pipeline": true, - "emission_share": 0.005, - "issue_discovery_share": 0.0, - "label_multipliers": { - "gittensor:bug": 0.5, - "gittensor:feature": 1.25, - "gittensor:priority": 1.75 - }, - "eligibility": { - "min_credibility": 0.5 - }, - "maintainer_cut": 0.5, - "scoring": { - "pr_lookback_days": 7, - "open_pr_collateral_percent": 0.2, - "review_penalty_rate": 0.2, - "standard_issue_multiplier": 1.1, - "maintainer_issue_multiplier": 1.5, - "time_decay": { - "grace_period_hours": 24, - "sigmoid_midpoint_days": 3, - "sigmoid_steepness": 1.0, - "min_multiplier": 0.05 - } - } - }, - "JSONbored/gittensory": { - "default_label_multiplier": 0.0, - "trusted_label_pipeline": true, - "emission_share": 0.013, - "issue_discovery_share": 0.0, - "label_multipliers": { - "gittensor:bug": 0.5, - "gittensor:feature": 1.25, - "gittensor:priority": 1.75 - }, - "eligibility": { - "min_credibility": 0.6 - }, - "maintainer_cut": 0.5, - "scoring": { - "pr_lookback_days": 7, - "open_pr_collateral_percent": 0.2, - "review_penalty_rate": 0.2, - "standard_issue_multiplier": 1.1, - "maintainer_issue_multiplier": 1.5, - "time_decay": { - "grace_period_hours": 24, - "sigmoid_midpoint_days": 3, - "sigmoid_steepness": 1.0, - "min_multiplier": 0.05 - } - } - }, - "JSONbored/metagraphed": { - "default_label_multiplier": 0.0, - "trusted_label_pipeline": true, - "emission_share": 0.0075, - "issue_discovery_share": 0.0, - "label_multipliers": { - "gittensor:bug": 0.5, - "gittensor:feature": 1.5, - "gittensor:priority": 2.5 - }, - "eligibility": { - "min_credibility": 0.5 - }, - "maintainer_cut": 0.25, - "scoring": { - "pr_lookback_days": 7, - "open_pr_collateral_percent": 0.2, - "review_penalty_rate": 0.2, - "standard_issue_multiplier": 1.1, - "maintainer_issue_multiplier": 1.5, - "time_decay": { - "grace_period_hours": 24, - "sigmoid_midpoint_days": 3, - "sigmoid_steepness": 1.0, - "min_multiplier": 0.05 - } - } - }, - "MkDev11/gittensor-hub": { - "emission_share": 0.008, - "issue_discovery_share": 0.0, - "label_multipliers": { - "feature": 2, - "whale": 20, - "shark": 5, - "bug": 0.3, - "refactor": 0.3 - }, - "scoring": { - "open_pr_collateral_percent": 0.2, - "review_penalty_rate": 0.2, - "maintainer_issue_multiplier": 2, - "time_decay": { - "grace_period_hours": 24, - "sigmoid_midpoint_days": 14, - "min_multiplier": 0.1 - } - }, - "maintainer_cut": 0.3 - }, - "vouchdev/vouch": { - "emission_share": 0.01, - "issue_discovery_share": 0.0, - "label_multipliers": { - "feature": 1.5, - "bug": 1.1 - }, - "scoring": { - "pr_lookback_days": 30, - "open_pr_collateral_percent": 0.1, - "review_penalty_rate": 0.2, - "maintainer_issue_multiplier": 1.5, - "standard_issue_multiplier": 1, - "time_decay": { - "grace_period_hours": 24, - "sigmoid_midpoint_days": 14, - "min_multiplier": 0.1 - } - }, - "eligibility": { - "excessive_pr_penalty_base_threshold": 3 - }, - "additional_acceptable_branches": ["test"], - "maintainer_cut": 0.5 - }, - "phase-rs/phase": { - "emission_share": 0.018, - "issue_discovery_share": 0.0, - "label_multipliers": { - "bug": 1.2, - "enhancement": 1.3, - "feature": 1.0, - "refactor": 0.25, - "test": 1.0, - "quality": 3 - }, - "scoring": { - "standard_issue_multiplier": 1, - "review_penalty_rate": 0.01, - "pr_lookback_days": 15 - }, - "maintainer_cut": 0.4, - "eligibility": { - "excessive_pr_penalty_base_threshold": 10 - } - }, - "seroperson/jvm-live-reload": { - "emission_share": 0.005, - "issue_discovery_share": 0.0 - }, - "touchpilot/touchpilot": { - "emission_share": 0.01, - "issue_discovery_share": 0.0, - "label_multipliers": { - "type: bug": 1.1, - "type: enhancement": 1.25, - "type: feature": 1.5, - "type: refactor": 0.25, - "type: test": 1.2 - }, - "maintainer_cut": 0.3 - }, - "we-promise/sure": { - "emission_share": 0.03, - "issue_discovery_share": 0.0, - "label_multipliers": { - "performance": 1.5, - "mobile/Flutter": 1.2 - }, - "maintainer_cut": 0.3 - }, - "e35dev/live-bittensor-emissions-leaderboard": { - "emission_share": 0.005, - "issue_discovery_share": 0, - "additional_acceptable_branches": [ - "test" - ], - "trusted_label_pipeline": true, - "default_label_multiplier": 0, - "label_multipliers": { - "feature": 3, - "ui-ux": 2, - "bug": 0.625, - "security": 0.625, - "other": 0.1 - }, - "eligibility": { - "min_credibility": 0.6 - }, - "scoring": { - "pr_lookback_days": 7, - "time_decay": { - "grace_period_hours": 6, - "sigmoid_midpoint_days": 2, - "sigmoid_steepness": 1, - "min_multiplier": 0.02 - } - } - } -} diff --git a/tests/validator/conftest.py b/tests/validator/conftest.py index 654446d67..ecb630e4e 100644 --- a/tests/validator/conftest.py +++ b/tests/validator/conftest.py @@ -9,9 +9,31 @@ from datetime import datetime, timezone from typing import Optional +import pytest + from gittensor.classes import PRState, PullRequest +@pytest.fixture(autouse=True) +def _isolate_repo_registry(monkeypatch, tmp_path): + """Keep validator tests offline and deterministic: + + - the repos API fetch always fails, so loads exercise the fallback path + (tests covering the API re-patch ``_fetch_registry_from_api`` themselves; + their setattr runs after this fixture and wins); + - the on-disk last-good cache points at an empty per-test tmp path, so the + default load yields an empty registry. Tests that want a warm-cache + fallback write their registry JSON to ``lw._get_repos_cache_path()``. + """ + from gittensor.validator.utils import load_weights as lw + + def _api_disabled(): + raise lw.RepositoryRegistryError('repos API disabled in tests') + + monkeypatch.setattr(lw, '_fetch_registry_from_api', _api_disabled) + monkeypatch.setattr(lw, '_get_repos_cache_path', lambda: tmp_path / 'repos_cache.json') + + @dataclass class PRBuilder: """Builder for creating mock PullRequests with sensible defaults. diff --git a/tests/validator/test_contract_client_transactions.py b/tests/validator/test_contract_client_transactions.py index 1e23da894..b71f680d8 100644 --- a/tests/validator/test_contract_client_transactions.py +++ b/tests/validator/test_contract_client_transactions.py @@ -3,6 +3,7 @@ """Tests for IssueCompetitionContractClient transaction methods.""" import hashlib +import struct from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -11,6 +12,7 @@ from gittensor.validator.issue_competitions.contract_client import ( DEFAULT_GAS_LIMIT, IssueCompetitionContractClient, + _scale_compact_length, ) # (method, call_kwargs, expected_contract_method, expected_args, uses_hotkey, explicit_gas) @@ -140,3 +142,127 @@ def test_get_treasury_stake_returns_zero_for_empty_alpha_result(client): return_value=_packed_treasury_storage(), ): assert client.get_treasury_stake() == 0 + + +class TestScaleCompactLength: + """Boundary coverage for the SCALE compact-length encoder.""" + + @pytest.mark.parametrize( + 'n, expected', + [ + (0, b'\x00'), + (1, b'\x04'), + (63, bytes([63 << 2])), + ], + ) + def test_mode_0_single_byte(self, n, expected): + assert _scale_compact_length(n) == expected + + @pytest.mark.parametrize('n', [64, 100, 16383]) + def test_mode_1_two_bytes(self, n): + encoded = _scale_compact_length(n) + assert len(encoded) == 2 + assert encoded == ((n << 2) | 1).to_bytes(2, 'little') + + @pytest.mark.parametrize('n', [16384, 100_000, (1 << 30) - 1]) + def test_mode_2_four_bytes(self, n): + encoded = _scale_compact_length(n) + assert len(encoded) == 4 + assert encoded == ((n << 2) | 2).to_bytes(4, 'little') + + def test_rejects_negative(self): + with pytest.raises(ValueError, match='non-negative'): + _scale_compact_length(-1) + + def test_rejects_oversize(self): + with pytest.raises(ValueError, match='too large'): + _scale_compact_length(1 << 30) + + +class TestEncodeArgsStr: + """SCALE encoding of `str` arguments via _encode_args (regression for #1374).""" + + def test_register_issue_short_url_encodes(self, client): + url = 'https://github.com/owner/repo/issues/1' + repo = 'owner/repo' + url_bytes = url.encode('utf-8') + repo_bytes = repo.encode('utf-8') + assert len(url_bytes) < 64 + + encoded = client._encode_args( + 'register_issue', + { + 'github_url': url, + 'repository_full_name': repo, + 'issue_number': 1, + 'target_bounty': 10_000_000_000, + }, + ) + + offset = 0 + assert encoded[offset] == len(url_bytes) << 2 + offset += 1 + assert encoded[offset : offset + len(url_bytes)] == url_bytes + offset += len(url_bytes) + + assert encoded[offset] == len(repo_bytes) << 2 + offset += 1 + assert encoded[offset : offset + len(repo_bytes)] == repo_bytes + offset += len(repo_bytes) + + assert struct.unpack_from(' 0, 'Should have at least one repository' - def test_repo_configs_are_repository_config_objects(self): """Each entry should be a RepositoryConfig object.""" repos = load_master_repo_weights() @@ -127,25 +114,6 @@ def test_repo_names_are_lowercase(self): for repo_name in repos.keys(): assert repo_name == repo_name.lower(), f'{repo_name} should be lowercase' - def test_trusted_label_pipeline_field_present_on_live_configs(self): - """Live master_repositories.json entries load with a bool trusted_label_pipeline.""" - repos = load_master_repo_weights() - for repo_name, config in repos.items(): - assert isinstance(config.trusted_label_pipeline, bool), ( - f'{repo_name} trusted_label_pipeline should be bool, got {type(config.trusted_label_pipeline)}' - ) - - def test_entrius_repos_have_trusted_label_pipeline(self): - """All entrius/* entries opt into trusted_label_pipeline (issue #911).""" - repos = load_master_repo_weights() - entrius_repos = {name: cfg for name, cfg in repos.items() if name.startswith('entrius/')} - assert entrius_repos, 'expected entrius/* entries in master_repositories.json' - for repo_name, config in entrius_repos.items(): - assert config.trusted_label_pipeline is True, ( - f'{repo_name} must have trusted_label_pipeline=true so the agentic-maintainer ' - f'labeling worker is honored at scoring time' - ) - class TestRepositoryConfigTrustedLabelPipeline: """Dataclass + JSON-parsing tests for trusted_label_pipeline (issue #911).""" @@ -167,7 +135,7 @@ def test_loader_parses_trusted_label_pipeline_true(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text( + (fake_weights_dir / 'repos_cache.json').write_text( json.dumps( { 'foo/trusted': {'emission_share': 0.5, 'trusted_label_pipeline': True}, @@ -176,8 +144,6 @@ def test_loader_parses_trusted_label_pipeline_true(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - repos = lw.load_master_repo_weights() assert repos['foo/trusted'].trusted_label_pipeline is True @@ -198,7 +164,7 @@ def test_loader_parses_label_multiplier_config(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text( + (fake_weights_dir / 'repos_cache.json').write_text( json.dumps( { 'foo/labeled': { @@ -210,8 +176,6 @@ def test_loader_parses_label_multiplier_config(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - repos = lw.load_master_repo_weights() assert repos['foo/labeled'].label_multipliers == {'kind/*': 1.5, 'type:bug': 1.25} @@ -220,32 +184,6 @@ def test_loader_parses_label_multiplier_config(self, tmp_path, monkeypatch): assert repos['foo/defaults'].label_multipliers is None assert repos['foo/defaults'].default_label_multiplier == pytest.approx(1.0) - @pytest.mark.parametrize('repo_name,metadata', _live_master_repo_metadata()) - def test_live_label_multiplier_maps_are_bounded(self, repo_name, metadata): - label_multipliers = metadata.get('label_multipliers') - if label_multipliers is None: - return - - assert isinstance(label_multipliers, dict), f'{repo_name} label_multipliers must be a dict' - assert len(label_multipliers) <= 10, f'{repo_name} label_multipliers has too many entries' - - @pytest.mark.parametrize('repo_name,metadata', _live_master_repo_metadata()) - def test_live_label_multiplier_values_are_in_range(self, repo_name, metadata): - for pattern, multiplier in (metadata.get('label_multipliers') or {}).items(): - assert isinstance(pattern, str), f'{repo_name} label_multipliers keys must be strings' - assert 0.0 <= float(multiplier) <= 20.0, ( - f'{repo_name} label_multipliers[{pattern!r}] must be within [0.0, 20.0]' - ) - - @pytest.mark.parametrize('repo_name,metadata', _live_master_repo_metadata()) - def test_live_default_label_multiplier_values_are_in_range(self, repo_name, metadata): - if 'default_label_multiplier' not in metadata: - return - - assert 0.0 <= float(metadata['default_label_multiplier']) <= 20.0, ( - f'{repo_name} default_label_multiplier must be within [0.0, 20.0]' - ) - class TestRepositoryConfigMirrorScoringFields: """Dataclass + JSON-parsing tests for mirror scoring + per-repo eligibility fields.""" @@ -259,7 +197,7 @@ def test_mirror_scoring_field_defaults(self): def test_loader_parses_fixed_base_score(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps( { 'foo/fixed': {'emission_share': 0.5, 'fixed_base_score': 12.5}, @@ -267,8 +205,6 @@ def test_loader_parses_fixed_base_score(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - repos = lw.load_master_repo_weights() assert repos['foo/fixed'].fixed_base_score == pytest.approx(12.5) @@ -277,7 +213,7 @@ def test_loader_parses_fixed_base_score(self, tmp_path, monkeypatch): def test_loader_parses_eligibility_overrides(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps( { 'foo/custom': { @@ -288,8 +224,6 @@ def test_loader_parses_eligibility_overrides(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - repos = lw.load_master_repo_weights() assert repos['foo/custom'].eligibility.min_valid_merged_prs == 1 @@ -301,77 +235,21 @@ def test_loader_parses_eligibility_overrides(self, tmp_path, monkeypatch): def test_loader_rejects_unknown_eligibility_key(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'eligibility': {'min_valid_prs': 1}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_rejects_out_of_range_credibility(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'eligibility': {'min_credibility': 1.5}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() - def test_live_mirror_scoring_fields_have_valid_shape(self): - """Loader passes scoring + eligibility fields through unchanged — CI fails - when a bad value is committed to master_repositories.json.""" - repos = load_master_repo_weights() - for repo_name, config in repos.items(): - if config.fixed_base_score is not None: - assert isinstance(config.fixed_base_score, (int, float)) and not isinstance( - config.fixed_base_score, bool - ), f'{repo_name} fixed_base_score must be numeric, got {type(config.fixed_base_score)}' - assert 0.0 <= float(config.fixed_base_score) <= 100.0, ( - f'{repo_name} fixed_base_score must be within [0.0, 100.0]' - ) - resolved = resolve_eligibility(config.eligibility) - assert 0.0 <= resolved.min_credibility <= 1.0, f'{repo_name} min_credibility out of range' - assert 0.0 <= resolved.min_issue_credibility <= 1.0, f'{repo_name} min_issue_credibility out of range' - assert resolved.min_valid_merged_prs >= 0, f'{repo_name} min_valid_merged_prs negative' - resolved_scoring = resolve_scoring(config.scoring) - assert 1 <= resolved_scoring.pr_lookback_days <= 90, f'{repo_name} pr_lookback_days out of range' - assert 0.0 <= resolved_scoring.open_pr_collateral_percent <= 1.0, ( - f'{repo_name} open_pr_collateral_percent out of range' - ) - assert 0.0 < resolved_scoring.review_penalty_rate <= 1.0, f'{repo_name} review_penalty_rate out of range' - assert 1.0 <= resolved_scoring.standard_issue_multiplier <= 5.0, ( - f'{repo_name} standard_issue_multiplier out of range' - ) - assert 1.0 <= resolved_scoring.maintainer_issue_multiplier <= 5.0, ( - f'{repo_name} maintainer_issue_multiplier out of range' - ) - assert 10.0 <= resolved_scoring.src_tok_saturation_scale <= 500.0, ( - f'{repo_name} src_tok_saturation_scale out of range' - ) - assert 0 <= resolved_scoring.time_decay.grace_period_hours <= 168, ( - f'{repo_name} time_decay.grace_period_hours out of range' - ) - assert 1.0 <= resolved_scoring.time_decay.sigmoid_midpoint_days <= 90.0, ( - f'{repo_name} time_decay.sigmoid_midpoint_days out of range' - ) - assert 0.01 <= resolved_scoring.time_decay.sigmoid_steepness <= 5.0, ( - f'{repo_name} time_decay.sigmoid_steepness out of range' - ) - assert 0.0 <= resolved_scoring.time_decay.min_multiplier <= 1.0, ( - f'{repo_name} time_decay.min_multiplier out of range' - ) - - def test_oc_1_runs_ungated(self): - """The oc-1 benchmark repo opts out of the gate via zeroed thresholds.""" - repos = load_master_repo_weights() - resolved = resolve_eligibility(repos['entrius/oc-1'].eligibility) - assert resolved.min_valid_merged_prs == 0 - assert resolved.min_credibility == 0.0 - assert resolved.min_valid_solved_issues == 0 - class TestRepositoryConfigScoringBlock: """Dataclass + JSON-parsing tests for the per-repo scoring block.""" @@ -383,7 +261,7 @@ def test_scoring_field_defaults(self): def test_loader_parses_scoring_overrides(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps( { 'foo/custom': { @@ -398,8 +276,6 @@ def test_loader_parses_scoring_overrides(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - repos = lw.load_master_repo_weights() assert repos['foo/custom'].scoring.pr_lookback_days == 45 @@ -410,55 +286,45 @@ def test_loader_parses_scoring_overrides(self, tmp_path, monkeypatch): def test_loader_rejects_unknown_scoring_key(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'bogus': 1}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_rejects_out_of_range_collateral(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'open_pr_collateral_percent': 1.5}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_rejects_zero_review_penalty_rate(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'review_penalty_rate': 0.0}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_rejects_out_of_range_issue_multiplier(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'standard_issue_multiplier': 0.5}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_parses_time_decay_overrides(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/custom': {'emission_share': 0.5, 'scoring': {'time_decay': {'grace_period_hours': 24}}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - repos = lw.load_master_repo_weights() assert repos['foo/custom'].scoring.time_decay.grace_period_hours == 24 @@ -466,22 +332,18 @@ def test_loader_parses_time_decay_overrides(self, tmp_path, monkeypatch): def test_loader_rejects_unknown_time_decay_key(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'time_decay': {'bogus': 1}}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_rejects_out_of_range_lookback(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'pr_lookback_days': 200}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() @@ -489,18 +351,16 @@ def test_loader_rejects_out_of_range_lookback(self, tmp_path, monkeypatch): def test_loader_rejects_out_of_range_saturation_scale(self, tmp_path, monkeypatch, scale): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'src_tok_saturation_scale': scale}}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() def test_loader_accepts_saturation_scale_at_bounds(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw - (tmp_path / 'master_repositories.json').write_text( + (tmp_path / 'repos_cache.json').write_text( json.dumps( { 'foo/low': {'emission_share': 0.4, 'scoring': {'src_tok_saturation_scale': 10.0}}, @@ -508,8 +368,6 @@ def test_loader_accepts_saturation_scale_at_bounds(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) - repos = lw.load_master_repo_weights() assert resolve_scoring(repos['foo/low'].scoring).src_tok_saturation_scale == pytest.approx(10.0) assert resolve_scoring(repos['foo/high'].scoring).src_tok_saturation_scale == pytest.approx(500.0) @@ -526,7 +384,7 @@ def test_loader_parses_maintainer_cut(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text( + (fake_weights_dir / 'repos_cache.json').write_text( json.dumps( { 'foo/with-cut': {'emission_share': 0.5, 'maintainer_cut': 0.3}, @@ -534,20 +392,11 @@ def test_loader_parses_maintainer_cut(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - repos = lw.load_master_repo_weights() assert repos['foo/with-cut'].maintainer_cut == pytest.approx(0.3) assert repos['foo/defaults'].maintainer_cut == pytest.approx(0.0) - @pytest.mark.parametrize('repo_name,metadata', _live_master_repo_metadata()) - def test_live_maintainer_cut_is_in_range(self, repo_name, metadata): - if 'maintainer_cut' not in metadata: - return - - assert 0.0 <= float(metadata['maintainer_cut']) <= 1.0, f'{repo_name} maintainer_cut must be within [0.0, 1.0]' - class TestRepositoryEmissionShare: """Tests for bounded repo emission_share loading.""" @@ -568,7 +417,7 @@ def test_loader_parses_issue_discovery_share(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text( + (fake_weights_dir / 'repos_cache.json').write_text( json.dumps( { 'foo/pr-only': {'emission_share': 0.4, 'issue_discovery_share': 0.0}, @@ -576,8 +425,6 @@ def test_loader_parses_issue_discovery_share(self, tmp_path, monkeypatch): } ) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - repos = lw.load_master_repo_weights() assert repos['foo/pr-only'].issue_discovery_share == pytest.approx(0.0) @@ -587,11 +434,9 @@ def test_loader_accepts_sum_less_than_one(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text( + (fake_weights_dir / 'repos_cache.json').write_text( json.dumps({'foo/a': {'emission_share': 0.2}, 'foo/b': {'emission_share': 0.3}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - repos = lw.load_master_repo_weights() assert set(repos) == {'foo/a', 'foo/b'} @@ -612,9 +457,7 @@ def test_loader_rejects_out_of_range_values(self, tmp_path, monkeypatch, metadat from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text(json.dumps({'foo/bad': metadata})) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - + (fake_weights_dir / 'repos_cache.json').write_text(json.dumps({'foo/bad': metadata})) with pytest.raises(RepositoryRegistryError): lw.load_master_repo_weights() @@ -630,9 +473,7 @@ def test_loader_rejects_boolean_share_values(self, tmp_path, monkeypatch, metada from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text(json.dumps({'foo/bad': metadata})) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - + (fake_weights_dir / 'repos_cache.json').write_text(json.dumps({'foo/bad': metadata})) with pytest.raises(RepositoryRegistryError, match='must be a float'): lw.load_master_repo_weights() @@ -640,22 +481,75 @@ def test_loader_rejects_sum_greater_than_one(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw fake_weights_dir = tmp_path - (fake_weights_dir / 'master_repositories.json').write_text( + (fake_weights_dir / 'repos_cache.json').write_text( json.dumps({'foo/a': {'emission_share': 0.6}, 'foo/b': {'emission_share': 0.5}}) ) - monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) - with pytest.raises(RepositoryRegistryError, match='total emission_share must be <= 1.0'): lw.load_master_repo_weights() - def test_live_master_repo_emission_shares_are_valid(self): - repos = load_master_repo_weights() - total = sum(config.emission_share for config in repos.values()) - assert 0.0 <= total <= 1.0 - for repo_name, config in repos.items(): - assert 0.0 <= config.emission_share <= 1.0, f'{repo_name} emission_share out of range' - assert 0.0 <= config.issue_discovery_share <= 1.0, f'{repo_name} issue_discovery_share out of range' +class TestRegistryApiLoading: + """Tests for the API-first loader with on-disk last-good cache fallback.""" + + def test_loads_from_api_when_available(self, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + payload = {'Owner/Repo': {'emission_share': 0.1, 'label_multipliers': {'feature': 2.0}}} + monkeypatch.setattr(lw, '_fetch_registry_from_api', lambda: payload) + + repos = lw.load_master_repo_weights() + + assert 'owner/repo' in repos # normalized to lowercase + assert repos['owner/repo'].emission_share == 0.1 + assert repos['owner/repo'].label_multipliers == {'feature': 2.0} + + def test_successful_fetch_writes_disk_cache(self, monkeypatch): + # A successful fetch persists the last-good registry for outage resilience. + from gittensor.validator.utils import load_weights as lw + + payload = {'owner/repo': {'emission_share': 0.1}} + monkeypatch.setattr(lw, '_fetch_registry_from_api', lambda: payload) + + lw.load_master_repo_weights() + + cache_path = lw._get_repos_cache_path() + assert cache_path.exists(), 'successful API fetch should write the last-good cache' + assert json.loads(cache_path.read_text()) == payload + + def test_falls_back_to_disk_cache_when_api_unavailable(self, tmp_path): + # autouse fixture fails the API and points the cache at tmp_path; warm it. + from gittensor.validator.utils import load_weights as lw + + lw._get_repos_cache_path().write_text(json.dumps({'a/b': {'emission_share': 0.2}})) + repos = lw.load_master_repo_weights() + + assert repos['a/b'].emission_share == 0.2 + + def test_api_invalid_content_falls_back_to_cache(self, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + # API serves data violating the emission contract -> fall back to the cache. + monkeypatch.setattr(lw, '_fetch_registry_from_api', lambda: {'a/b': {'emission_share': 5.0}}) + lw._get_repos_cache_path().write_text(json.dumps({'a/b': {'emission_share': 0.3}})) + repos = lw.load_master_repo_weights() + + assert repos['a/b'].emission_share == 0.3 + + def test_returns_empty_when_api_down_and_no_cache(self): + # autouse fixture disables the API and points the cache at an empty tmp path. + from gittensor.validator.utils import load_weights as lw + + assert lw.load_master_repo_weights() == {} + + def test_invalid_cache_still_raises(self): + # A cache that violates the registry contract is a real problem and must surface. + from gittensor.validator.utils import load_weights as lw + + lw._get_repos_cache_path().write_text( + json.dumps({'foo/a': {'emission_share': 0.6}, 'foo/b': {'emission_share': 0.5}}) + ) + with pytest.raises(RepositoryRegistryError, match='total emission_share must be <= 1.0'): + lw.load_master_repo_weights() if __name__ == '__main__':