From 5537d6ba946e39798e5749d0f5b2791eba5a3f95 Mon Sep 17 00:00:00 2001 From: Landyn Date: Tue, 28 Jul 2026 14:25:40 -0500 Subject: [PATCH] Load master repo weights from on-chain registry --- gittensor/constants.py | 2 +- gittensor/validator/forward.py | 3 +- gittensor/validator/repo_registry/loader.py | 147 +++++++++++++ gittensor/validator/utils/load_weights.py | 78 +++---- .../validator/weight_consensus/manager.py | 27 ++- neurons/validator.py | 5 +- tests/validator/test_consensus_apply.py | 1 + tests/validator/test_consensus_manager.py | 47 ++++- tests/validator/test_load_weights.py | 5 +- tests/validator/test_registry_loader.py | 195 ++++++++++++++++++ 10 files changed, 463 insertions(+), 47 deletions(-) create mode 100644 gittensor/validator/repo_registry/loader.py create mode 100644 tests/validator/test_registry_loader.py diff --git a/gittensor/constants.py b/gittensor/constants.py index e513dbec3..a8172ebac 100644 --- a/gittensor/constants.py +++ b/gittensor/constants.py @@ -152,7 +152,7 @@ # Combined scoring pool distributed by repository emission_share, then by per-repo PR/issue split. OSS_EMISSION_SHARE = 0.90 -DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5 +DEFAULT_ISSUE_DISCOVERY_SHARE = 0.0 EMISSION_SHARE_TOLERANCE = 1e-9 # ============================================================================= diff --git a/gittensor/validator/forward.py b/gittensor/validator/forward.py index 53d2b4dc7..a3ed8ccb6 100644 --- a/gittensor/validator/forward.py +++ b/gittensor/validator/forward.py @@ -41,7 +41,8 @@ async def forward(self: 'Validator') -> None: Emission blending: - Combined scoring pool: 90%, allocated by repository emission_share - (validator-voted consensus aggregate when active, baked-in weights otherwise) + (on-chain registry + validator-voted consensus aggregate when active, + baked-in weights otherwise) - Maintainer cut: per-repo carve-out routed to maintainer miner neurons - Issue treasury: 10%, flat to UID 111 - Recycle: registry slack and inactive repo slices to UID 0 diff --git a/gittensor/validator/repo_registry/loader.py b/gittensor/validator/repo_registry/loader.py new file mode 100644 index 000000000..5f5af4c66 --- /dev/null +++ b/gittensor/validator/repo_registry/loader.py @@ -0,0 +1,147 @@ +# The MIT License (MIT) +# Copyright 2025 Entrius +"""On-chain registry -> master repository weights (spec swap R1). + +The contract keys repos by GitHub numeric id; this loader resolves them to +lowercase ``owner/name`` dict keys at the load_weights boundary so downstream +scoring sees zero changes (identity shim — renames map forward only). Every +read pins to the snapshot block hash, so all validators load byte-identical +state. Share vectors derive only from contract state at the snapshot block: +the loader never filters or renormalizes on GitHub App install status. + +Fallback ladder: contract @ snapshot -> last-good disk cache -> None, which +sends the caller to the baked JSON. A paused or unseeded contract skips the +cache and lands straight on baked (worst case = today's behavior). +""" + +import json +import os +from pathlib import Path +from typing import Any, Callable, Dict, Optional, Tuple + +import bittensor as bt + +from gittensor.validator.repo_registry.contract_client import RepoRegistryContractClient +from gittensor.validator.utils.load_weights import RepositoryConfig, parse_master_repositories + +_FP6 = 1_000_000 + + +def _fp6(value: int) -> float: + return value / _FP6 + + +def _fp6_or_none(value: int) -> Optional[float]: + return None if value == 0 else value / _FP6 + + +# Contract param key -> (nested metadata path, field name, decoder). +# Known key -> RepositoryConfig field; missing key -> constants.py default; +# unknown key -> ignored (forward compat). +_PARAM_FIELDS: Dict[int, Tuple[Tuple[str, ...], str, Callable[[int], Any]]] = { + 1: ((), 'issue_discovery_share', _fp6), + 2: ((), 'default_label_multiplier', _fp6), + 3: ((), 'fixed_base_score', _fp6_or_none), # 0 = unset + 4: ((), 'maintainer_cut', _fp6), + 5: ((), 'trusted_label_pipeline', bool), + 6: (('eligibility',), 'min_valid_merged_prs', int), + 7: (('eligibility',), 'min_credibility', _fp6), + 8: (('eligibility',), 'excessive_pr_penalty_base_threshold', int), + 9: (('eligibility',), 'open_pr_threshold_token_score', _fp6), + 10: (('eligibility',), 'max_open_pr_threshold', int), + 11: (('eligibility',), 'min_valid_solved_issues', int), + 12: (('eligibility',), 'min_issue_credibility', _fp6), + 13: (('eligibility',), 'min_token_score_for_valid_issue', _fp6), + 14: (('eligibility',), 'open_issue_spam_base_threshold', int), + 15: (('eligibility',), 'open_issue_spam_token_score_per_slot', _fp6), + 16: (('eligibility',), 'max_open_issue_threshold', int), + 17: (('scoring',), 'pr_lookback_days', int), + 18: (('scoring',), 'open_pr_collateral_percent', _fp6), + 19: (('scoring',), 'review_penalty_rate', _fp6), + 20: (('scoring',), 'standard_issue_multiplier', _fp6), + 21: (('scoring',), 'maintainer_issue_multiplier', _fp6), + 22: (('scoring',), 'src_tok_saturation_scale', _fp6), + 23: (('scoring', 'time_decay'), 'grace_period_hours', int), + 24: (('scoring', 'time_decay'), 'sigmoid_midpoint_days', _fp6), + 25: (('scoring', 'time_decay'), 'sigmoid_steepness', _fp6), + 26: (('scoring', 'time_decay'), 'min_multiplier', _fp6), +} + + +class RegistryLoader: + """Loads the contract registry once per snapshot, pinned to its block hash.""" + + def __init__(self, client: RepoRegistryContractClient, cache_dir: Path): + self.client = client + self.cache_path = Path(cache_dir) / 'repo_registry_cache.json' + self._memo: Tuple[Optional[int], Optional[Dict[str, RepositoryConfig]]] = (None, None) + + def load(self, snapshot: int) -> Optional[Dict[str, RepositoryConfig]]: + """Registry configs at the snapshot block; None -> caller uses baked JSON.""" + memo_snapshot, memo_configs = self._memo + if memo_snapshot == snapshot: + return memo_configs + try: + at = self.client.subtensor.substrate.get_block_hash(snapshot) + if not at: + raise RuntimeError(f'no block hash for snapshot block {snapshot}') + packed = self.client.get_registry(at) + if packed is None: + raise RuntimeError('registry root cell unreadable') + if packed.paused: + bt.logging.warning('repo_registry: contract paused; using baked-in repository weights') + return None + data = {repo.full_name: self._repo_metadata(repo.github_id, at) for repo in self.client.get_all_repos(at)} + if not data: + bt.logging.info('repo_registry: contract registry empty; using baked-in repository weights') + return None + configs = parse_master_repositories(data) + self._save_cache(snapshot, data) + self._memo = (snapshot, configs) + bt.logging.info(f'repo_registry: loaded {len(configs)} repos from contract at snapshot {snapshot}') + return configs + except Exception as e: + bt.logging.warning(f'repo_registry: snapshot {snapshot} load failed ({e}); trying last-good cache') + return self._load_cache() + + def _repo_metadata(self, github_id: int, at: str) -> Dict[str, Any]: + """Decode one repo's on-chain params into master_repositories.json shape.""" + meta: Dict[str, Any] = {'emission_share': 0.0} # consensus-voted, overlaid by apply_consensus + for key, value in sorted(self.client.get_params(github_id, at).items()): + spec = _PARAM_FIELDS.get(key) + if spec is None: + continue + path, field, decode = spec + section = meta + for part in path: + section = section.setdefault(part, {}) + decoded = decode(value) + if decoded is not None: + section[field] = decoded + labels = self.client.get_label_multipliers(github_id, at) + if labels: + meta['label_multipliers'] = {label: _fp6(value) for label, value in sorted(labels.items())} + patterns = self.client.get_branch_patterns(github_id, at) + if patterns: + meta['additional_acceptable_branches'] = patterns + return meta + + def _save_cache(self, snapshot: int, data: Dict[str, Any]) -> None: + try: + tmp_path = self.cache_path.with_suffix('.tmp') + tmp_path.write_text(json.dumps({'snapshot': snapshot, 'repositories': data})) + os.replace(tmp_path, self.cache_path) + except OSError as e: + bt.logging.warning(f'repo_registry: cache write failed ({e})') + + def _load_cache(self) -> Optional[Dict[str, RepositoryConfig]]: + try: + payload = json.loads(self.cache_path.read_text()) + configs = parse_master_repositories(payload['repositories']) + bt.logging.warning(f'repo_registry: using last-good registry from snapshot {payload["snapshot"]}') + return configs or None + except FileNotFoundError: + return None + except (OSError, ValueError, KeyError, TypeError) as e: + bt.logging.warning(f'repo_registry: corrupt registry cache ({e}); using baked-in repository weights') + return None diff --git a/gittensor/validator/utils/load_weights.py b/gittensor/validator/utils/load_weights.py index 7b7719615..fbc375a7e 100644 --- a/gittensor/validator/utils/load_weights.py +++ b/gittensor/validator/utils/load_weights.py @@ -512,6 +512,47 @@ def _validate_scoring_configs(configs: Dict[str, RepositoryConfig]) -> None: ) +def parse_master_repositories(data: Dict[str, Any]) -> Dict[str, RepositoryConfig]: + """Parse and validate master_repositories-shaped data into RepositoryConfig + objects keyed by lowercase full name. Raises RepositoryRegistryError or + ValueError when entries violate the registry contract.""" + 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 + + _validate_emission_shares(normalized_data) + _validate_eligibility_configs(normalized_data) + _validate_scoring_configs(normalized_data) + return normalized_data + + def load_master_repo_weights() -> Dict[str, RepositoryConfig]: """ Load repository emission shares from the local JSON file. @@ -532,42 +573,7 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: 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 - - _validate_emission_shares(normalized_data) - _validate_eligibility_configs(normalized_data) - _validate_scoring_configs(normalized_data) - + normalized_data = parse_master_repositories(data) bt.logging.debug(f'Successfully loaded {len(normalized_data)} repository entries from {weights_file}') return normalized_data diff --git a/gittensor/validator/weight_consensus/manager.py b/gittensor/validator/weight_consensus/manager.py index 017e04271..7534a832a 100644 --- a/gittensor/validator/weight_consensus/manager.py +++ b/gittensor/validator/weight_consensus/manager.py @@ -11,7 +11,7 @@ import json import os from pathlib import Path -from typing import Any, Callable, Dict, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, Optional import bittensor as bt @@ -27,16 +27,26 @@ ) from gittensor.validator.weight_consensus.publisher import maybe_publish_prefs, resolve_local_prefs +if TYPE_CHECKING: + from gittensor.validator.repo_registry.loader import RegistryLoader + StoreHook = Callable[[int, Dict[str, Dict[str, int]], Dict[str, int], Dict[str, bool], AggregateResult], None] class ConsensusManager: """Computes, caches, and serves the per-snapshot aggregate.""" - def __init__(self, backend: ConsensusBackend, cache_dir: Path, store_hook: Optional[StoreHook] = None): + def __init__( + self, + backend: ConsensusBackend, + cache_dir: Path, + store_hook: Optional[StoreHook] = None, + registry_loader: Optional['RegistryLoader'] = None, + ): self.backend = backend self.cache_path = Path(cache_dir) / 'weight_consensus_cache.json' self.store_hook = store_hook + self.registry_loader = registry_loader self._failed_snapshots: set = set() self._cache: Dict[str, Any] = self._load_cache() @@ -120,8 +130,11 @@ def _save_cache(self) -> None: def run_weight_consensus(validator, master: Dict[str, RepositoryConfig]) -> Dict[str, RepositoryConfig]: - """Forward-seam orchestrator: publish own vote, fetch the aggregate, overlay - it on the baked registry. Any failure returns the registry untouched.""" + """Forward-seam orchestrator: publish own vote, load the on-chain registry + at the snapshot block, fetch the aggregate, and overlay the shares. The + contract registry replaces the baked weights only when an aggregate is + active — contract repos carry no emission_share of their own. Any failure + returns the baked registry untouched.""" manager: Optional[ConsensusManager] = getattr(validator, 'consensus_manager', None) if getattr(validator.config.neuron, 'disable_weight_consensus', False) or manager is None: return master @@ -141,6 +154,12 @@ def run_weight_consensus(validator, master: Dict[str, RepositoryConfig]) -> Dict shares = manager.get_shares(validator.block) if shares is None: bt.logging.info('weight_consensus: no active aggregate; using baked-in repository weights') + return master + + if manager.registry_loader is not None: + registry = manager.registry_loader.load(compute_snapshot_block(validator.block)) + if registry is not None: + master = registry return apply_consensus(master, shares) except Exception as e: bt.logging.error(f'weight_consensus: unexpected failure ({e}); using baked-in repository weights') diff --git a/neurons/validator.py b/neurons/validator.py index 3d99761de..6f03aeb5f 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -38,6 +38,7 @@ priority_pat_check, ) from gittensor.validator.repo_registry.contract_client import RepoRegistryContractClient +from gittensor.validator.repo_registry.loader import RegistryLoader from gittensor.validator.utils.config import ( CONSENSUS_MIN_VALIDATOR_STAKE_RAO, REPO_REGISTRY_CONTRACT_ADDRESS, @@ -121,10 +122,12 @@ def _init_consensus_manager(self) -> Optional[ConsensusManager]: try: client = RepoRegistryContractClient(REPO_REGISTRY_CONTRACT_ADDRESS, self.subtensor) backend = ContractBackend(client, self.subtensor, self.wallet, self.config.netuid) + cache_dir = Path(self.config.neuron.full_path) return ConsensusManager( backend=backend, - cache_dir=Path(self.config.neuron.full_path), + cache_dir=cache_dir, store_hook=self._store_weight_consensus if self.db_storage else None, + registry_loader=RegistryLoader(client, cache_dir), ) except Exception as e: bt.logging.warning(f'weight_consensus: backend init failed ({e}); using baked-in weights') diff --git a/tests/validator/test_consensus_apply.py b/tests/validator/test_consensus_apply.py index e6103c74e..aeec6ec41 100644 --- a/tests/validator/test_consensus_apply.py +++ b/tests/validator/test_consensus_apply.py @@ -30,6 +30,7 @@ def test_adds_novel_repo_with_defaults(self): assert novel.emission_share == 0.5 assert novel.maintainer_cut == 0.0 assert novel.default_label_multiplier == 1.0 + assert novel.issue_discovery_share == 0.0 # regression: default was 0.5 def test_none_shares_returns_master_unchanged(self): master = _master() diff --git a/tests/validator/test_consensus_manager.py b/tests/validator/test_consensus_manager.py index b486dac2a..84318197a 100644 --- a/tests/validator/test_consensus_manager.py +++ b/tests/validator/test_consensus_manager.py @@ -140,9 +140,21 @@ def hook(snapshot, baskets, stakes_rao, permits, result): assert seen['result'].voter_count == 2 +class FakeRegistryLoader: + """Serves a canned contract registry; records load calls.""" + + def __init__(self, registry): + self.registry = registry + self.calls = [] + + def load(self, snapshot): + self.calls.append(snapshot) + return self.registry + + class TestRunWeightConsensus: - def _validator(self, tmp_path, backend): - manager = ConsensusManager(backend=backend, cache_dir=tmp_path) + def _validator(self, tmp_path, backend, registry_loader=None): + manager = ConsensusManager(backend=backend, cache_dir=tmp_path, registry_loader=registry_loader) return SimpleNamespace( config=SimpleNamespace( netuid=74, @@ -179,3 +191,34 @@ def test_missing_manager_bypasses_everything(self, tmp_path): validator = self._validator(tmp_path, FakeBackend(_voters())) validator.consensus_manager = None assert run_weight_consensus(validator, master) is master + + def test_contract_registry_replaces_baked_when_aggregate_active(self, tmp_path): + loader = FakeRegistryLoader( + { + 'a/b': RepositoryConfig(emission_share=0.0, maintainer_cut=0.2), + 'c/d': RepositoryConfig(emission_share=0.0), + } + ) + baked = {'a/b': RepositoryConfig(emission_share=0.9), 'x/y': RepositoryConfig(emission_share=0.1)} + result = run_weight_consensus(self._validator(tmp_path, FakeBackend(_voters()), loader), baked) + assert loader.calls == [INTERVAL] # single registry load, at the snapshot block + assert result['a/b'].emission_share == 0.75 + assert result['a/b'].maintainer_cut == 0.2 # contract config kept + assert result['c/d'].emission_share == 0.25 + assert 'x/y' not in result # baked-only repo dropped with the baked registry + + def test_no_aggregate_returns_baked_without_registry_load(self, tmp_path): + gate_fails = [('hk1', 3 * STAKE, True, None), ('hk2', STAKE, True, {'a/b': 65535})] + loader = FakeRegistryLoader({'a/b': RepositoryConfig(emission_share=0.0)}) + baked = {'a/b': RepositoryConfig(emission_share=1.0)} + validator = self._validator(tmp_path, FakeBackend(gate_fails), loader) + assert run_weight_consensus(validator, baked) is baked + assert loader.calls == [] + + def test_registry_unavailable_overlays_aggregate_on_baked(self, tmp_path): + loader = FakeRegistryLoader(None) + baked = {'a/b': RepositoryConfig(emission_share=0.9), 'x/y': RepositoryConfig(emission_share=0.1)} + result = run_weight_consensus(self._validator(tmp_path, FakeBackend(_voters()), loader), baked) + assert loader.calls == [INTERVAL] + assert result['a/b'].emission_share == 0.75 + assert result['x/y'].emission_share == 0.0 # baked repos kept, share zeroed diff --git a/tests/validator/test_load_weights.py b/tests/validator/test_load_weights.py index 64fee4d2d..edb675468 100644 --- a/tests/validator/test_load_weights.py +++ b/tests/validator/test_load_weights.py @@ -552,9 +552,10 @@ def test_preserves_full_precision(self, emission_share): config = RepositoryConfig(emission_share=emission_share) assert config.emission_share == emission_share - def test_issue_discovery_share_defaults_even_split(self): + def test_issue_discovery_share_defaults_zero(self): + # Novel/unset repos score PRs only — 17/18 live repos use 0.0. config = RepositoryConfig(emission_share=0.2) - assert config.issue_discovery_share == pytest.approx(0.5) + assert config.issue_discovery_share == pytest.approx(0.0) def test_loader_parses_issue_discovery_share(self, tmp_path, monkeypatch): from gittensor.validator.utils import load_weights as lw diff --git a/tests/validator/test_registry_loader.py b/tests/validator/test_registry_loader.py new file mode 100644 index 000000000..36d49444d --- /dev/null +++ b/tests/validator/test_registry_loader.py @@ -0,0 +1,195 @@ +# The MIT License (MIT) +# Copyright © 2025 Entrius +"""Tests for the on-chain registry loader: identity shim, param decoding, +fallback ladder, and snapshot atomicity.""" + +import json +from types import SimpleNamespace + +from gittensor.validator.repo_registry.loader import RegistryLoader + +SNAPSHOT = 3600 +SNAPSHOT_2 = 7200 +HASH_1 = '0xsnap1' +HASH_2 = '0xsnap2' + + +def _repo(github_id=101, full_name='Owner/Repo', active=True): + return SimpleNamespace(github_id=github_id, full_name=full_name, active=active) + + +def _state(repos, paused=False, params=None, labels=None, patterns=None): + return { + 'repos': repos, + 'paused': paused, + 'params': params or {}, + 'labels': labels or {}, + 'patterns': patterns or {}, + } + + +class FakeRegistryClient: + """Canned contract state keyed by block hash; missing hash = unreachable.""" + + def __init__(self, states, hashes=None): + self.states = states + hashes = hashes or {SNAPSHOT: HASH_1, SNAPSHOT_2: HASH_2} + self.subtensor = SimpleNamespace(substrate=SimpleNamespace(get_block_hash=hashes.get)) + self.registry_reads = 0 + self.read_ats = [] + + def _state(self, at): + self.read_ats.append(at) + return self.states.get(at) + + def get_registry(self, at=None): + self.registry_reads += 1 + state = self._state(at) + if state is None: + return None + return SimpleNamespace(paused=state['paused']) + + def get_all_repos(self, at=None): + return [repo for repo in self._state(at)['repos'] if repo.active] + + def get_params(self, github_id, at=None): + return self._state(at)['params'].get(github_id, {}) + + def get_label_multipliers(self, github_id, at=None): + return self._state(at)['labels'].get(github_id, {}) + + def get_branch_patterns(self, github_id, at=None): + return self._state(at)['patterns'].get(github_id, []) + + +def _loader(client, tmp_path): + return RegistryLoader(client, tmp_path) + + +class TestIdentityShim: + def test_maps_github_ids_to_lowercase_name_keys(self, tmp_path): + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'Owner/Repo'), _repo(202, 'other/lib')])}) + configs = _loader(client, tmp_path).load(SNAPSHOT) + assert set(configs) == {'owner/repo', 'other/lib'} + assert all(config.emission_share == 0.0 for config in configs.values()) # consensus-voted only + + def test_inactive_repos_excluded(self, tmp_path): + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b'), _repo(202, 'c/d', active=False)])}) + assert set(_loader(client, tmp_path).load(SNAPSHOT)) == {'a/b'} + + def test_rename_maps_forward_only(self, tmp_path): + client = FakeRegistryClient( + {HASH_1: _state([_repo(101, 'old/name')]), HASH_2: _state([_repo(101, 'new/name')])} + ) + loader = _loader(client, tmp_path) + assert set(loader.load(SNAPSHOT)) == {'old/name'} + assert set(loader.load(SNAPSHOT_2)) == {'new/name'} + + +class TestParamDecoding: + def test_decodes_fp6_and_integer_params_into_config_fields(self, tmp_path): + params = { + 1: 250_000, # issue_discovery_share 0.25 + 3: 12_500_000, # fixed_base_score 12.5 + 4: 100_000, # maintainer_cut 0.1 + 5: 1, # trusted_label_pipeline + 6: 3, # min_valid_merged_prs + 9: 1_000_000_000, # open_pr_threshold_token_score 1000.0 + 17: 30, # pr_lookback_days + 24: 14_000_000, # sigmoid_midpoint_days 14.0 + } + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b')], params={101: params})}) + config = _loader(client, tmp_path).load(SNAPSHOT)['a/b'] + assert config.issue_discovery_share == 0.25 + assert config.fixed_base_score == 12.5 + assert config.maintainer_cut == 0.1 + assert config.trusted_label_pipeline is True + assert config.eligibility.min_valid_merged_prs == 3 + assert config.eligibility.open_pr_threshold_token_score == 1000.0 + assert config.scoring.pr_lookback_days == 30 + assert config.scoring.time_decay.sigmoid_midpoint_days == 14.0 + + def test_missing_and_unknown_keys_fall_back_to_defaults(self, tmp_path): + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b')], params={101: {3: 0, 99: 7}})}) + config = _loader(client, tmp_path).load(SNAPSHOT)['a/b'] + assert config.fixed_base_score is None # 0 = unset + assert config.issue_discovery_share == 0.0 # missing key -> constants default + assert config.default_label_multiplier == 1.0 + + def test_decodes_label_multipliers_and_branch_patterns(self, tmp_path): + client = FakeRegistryClient( + { + HASH_1: _state( + [_repo(101, 'a/b')], + labels={101: {'bug': 1_500_000, 'king': 2_000_000}}, + patterns={101: ['release/*']}, + ) + } + ) + config = _loader(client, tmp_path).load(SNAPSHOT)['a/b'] + assert config.label_multipliers == {'bug': 1.5, 'king': 2.0} + assert config.additional_acceptable_branches == ['release/*'] + + +class TestFallbackLadder: + def test_paused_returns_none_even_with_cache(self, tmp_path): + client = FakeRegistryClient( + {HASH_1: _state([_repo(101, 'a/b')]), HASH_2: _state([_repo(101, 'a/b')], paused=True)} + ) + assert _loader(client, tmp_path).load(SNAPSHOT) is not None # seeds the disk cache + assert _loader(client, tmp_path).load(SNAPSHOT_2) is None # paused skips the cache -> baked + + def test_empty_registry_returns_none(self, tmp_path): + client = FakeRegistryClient({HASH_1: _state([])}) + assert _loader(client, tmp_path).load(SNAPSHOT) is None + + def test_unreachable_falls_back_to_last_good_cache(self, tmp_path): + good = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b')], params={101: {4: 100_000}})}) + assert _loader(good, tmp_path).load(SNAPSHOT) is not None + unreachable = FakeRegistryClient({}) + configs = _loader(unreachable, tmp_path).load(SNAPSHOT_2) + assert set(configs) == {'a/b'} + assert configs['a/b'].maintainer_cut == 0.1 + + def test_unreachable_without_cache_returns_none(self, tmp_path): + assert _loader(FakeRegistryClient({}), tmp_path).load(SNAPSHOT) is None + + def test_corrupt_cache_returns_none(self, tmp_path): + (tmp_path / 'repo_registry_cache.json').write_text('{corrupt') + assert _loader(FakeRegistryClient({}), tmp_path).load(SNAPSHOT) is None + + def test_out_of_bounds_contract_param_degrades_instead_of_raising(self, tmp_path): + # review_penalty_rate 0 violates the python-side (0, 1] validation. + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b')], params={101: {19: 0}})}) + assert _loader(client, tmp_path).load(SNAPSHOT) is None + + def test_cache_round_trips_through_json(self, tmp_path): + client = FakeRegistryClient( + {HASH_1: _state([_repo(101, 'a/b')], labels={101: {'bug': 1_500_000}}, patterns={101: ['dev*']})} + ) + fresh = _loader(client, tmp_path).load(SNAPSHOT) + payload = json.loads((tmp_path / 'repo_registry_cache.json').read_text()) + assert payload['snapshot'] == SNAPSHOT + cached = _loader(FakeRegistryClient({}), tmp_path).load(SNAPSHOT_2) + assert cached == fresh + + +class TestSnapshotAtomicity: + def test_single_fetch_per_snapshot(self, tmp_path): + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b')])}) + loader = _loader(client, tmp_path) + first = loader.load(SNAPSHOT) + assert loader.load(SNAPSHOT) is first # memoized, no refetch + assert client.registry_reads == 1 + + def test_all_reads_pinned_to_snapshot_hash(self, tmp_path): + client = FakeRegistryClient({HASH_1: _state([_repo(101, 'a/b'), _repo(202, 'c/d')], params={101: {17: 30}})}) + _loader(client, tmp_path).load(SNAPSHOT) + assert set(client.read_ats) == {HASH_1} + + def test_failed_load_is_not_memoized(self, tmp_path): + client = FakeRegistryClient({}) + loader = _loader(client, tmp_path) + assert loader.load(SNAPSHOT) is None + client.states[HASH_1] = _state([_repo(101, 'a/b')]) + assert set(loader.load(SNAPSHOT)) == {'a/b'} # recovers on retry