From 44c74bcd675464ec89218612871917857ddd2349 Mon Sep 17 00:00:00 2001 From: CaptainTimon Date: Wed, 13 May 2026 02:36:09 +0200 Subject: [PATCH] feat: allocate emissions by repository share --- gittensor/classes.py | 4 +- gittensor/cli/miner_commands/score.py | 2 +- gittensor/constants.py | 12 +- gittensor/validator/emission_allocation.py | 108 ++++++++++++++++++ gittensor/validator/forward.py | 65 +++++------ gittensor/validator/issue_discovery/scan.py | 8 +- .../oss_contributions/mirror/scored_pr.py | 1 - .../oss_contributions/mirror/scoring.py | 7 +- .../validator/oss_contributions/scoring.py | 3 +- gittensor/validator/utils/load_weights.py | 103 ++++++++++++++--- .../weights/master_repositories.json | 13 ++- .../mirror/test_scored_pr.py | 5 +- .../oss_contributions/mirror/test_scoring.py | 9 +- .../test_emission_share_allocation.py | 91 +++++++++++++++ tests/validator/test_load_weights.py | 96 +++++++++++++++- 15 files changed, 437 insertions(+), 90 deletions(-) create mode 100644 gittensor/validator/emission_allocation.py create mode 100644 tests/validator/test_emission_share_allocation.py diff --git a/gittensor/classes.py b/gittensor/classes.py index c944e1d09..224585b0e 100644 --- a/gittensor/classes.py +++ b/gittensor/classes.py @@ -234,7 +234,6 @@ def is_pioneer_eligible(self) -> bool: def calculate_final_earned_score(self) -> float: """Combine base score with all multipliers. Pioneer dividend is added separately after.""" multipliers = { - 'repo': self.repo_weight_multiplier, 'issue': self.issue_multiplier, 'label': self.label_multiplier, 'spam': self.open_pr_spam_multiplier, @@ -289,6 +288,7 @@ class MinerEvaluation: total_valid_solved_issues: int = 0 # solved issues where solving PR has token_score >= 5 total_closed_issues: int = 0 total_open_issues: int = 0 # mirror-tracked open issues in lookback window (set by issue_discovery.scan) + discovered_issues: List[Issue] = field(default_factory=list) @property def total_prs(self) -> int: @@ -627,6 +627,7 @@ def _build_cache_entry(evaluation: 'MinerEvaluation') -> 'MinerEvaluation': cached.merged_prs = [_scored_mirror_pr_for_cache(pr) for pr in evaluation.merged_prs] cached.open_prs = [_scored_mirror_pr_for_cache(pr) for pr in evaluation.open_prs] cached.closed_prs = [_scored_mirror_pr_for_cache(pr) for pr in evaluation.closed_prs] + cached.discovered_issues = copy.deepcopy(evaluation.discovered_issues) return cached @staticmethod @@ -636,6 +637,7 @@ def _isolate_for_downstream(cached_eval: 'MinerEvaluation') -> 'MinerEvaluation' # adapters produce fresh Issue objects per call via get_all_issues(). copy_eval = copy.copy(cached_eval) copy_eval.unique_repos_contributed_to = set(cached_eval.unique_repos_contributed_to) + copy_eval.discovered_issues = copy.deepcopy(cached_eval.discovered_issues) return copy_eval diff --git a/gittensor/cli/miner_commands/score.py b/gittensor/cli/miner_commands/score.py index bc5fdf5c3..4cfbb957e 100644 --- a/gittensor/cli/miner_commands/score.py +++ b/gittensor/cli/miner_commands/score.py @@ -280,7 +280,7 @@ async def _run() -> Dict[str, Any]: issue_rewards = await issue_discovery( miner_evaluations, master_repositories, programming_languages, token_config, miner_uids ) - rewards = blend_emission_pools(oss_rewards, issue_rewards, miner_uids) + rewards = blend_emission_pools(oss_rewards, issue_rewards, miner_uids, miner_evaluations, master_repositories) return { 'success': True, diff --git a/gittensor/constants.py b/gittensor/constants.py index 6daa940fd..3f72bb455 100644 --- a/gittensor/constants.py +++ b/gittensor/constants.py @@ -74,7 +74,8 @@ # ============================================================================= # Repository & PR Scoring # ============================================================================= -DEFAULT_REPO_WEIGHT = 0.01 # fallback weight for repos not in master_repositories.json +DEFAULT_REPO_EMISSION_SHARE = 0.01 # fallback share for repos not in master_repositories.json +DEFAULT_REPO_WEIGHT = DEFAULT_REPO_EMISSION_SHARE # backward-compatible alias PR_LOOKBACK_DAYS = 35 # rolling window for scoring MERGED_PR_BASE_SCORE = 25 MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5 # PRs below this get 0 base score @@ -154,11 +155,8 @@ # ============================================================================= RECYCLE_UID = 0 -# Hardcoded emission splits per competition (replaces dynamic emissions) -OSS_EMISSION_SHARE = 0.30 # 30% to OSS contributions (PR scoring) -ISSUE_DISCOVERY_EMISSION_SHARE = 0.10 # 10% to issue discovery -RECYCLE_EMISSION_SHARE = 0.45 # 45% to recycle UID 0 -# ISSUES_TREASURY_EMISSION_SHARE = 0.15 defined below (15% to smart contract treasury) +# Hardcoded emission split. Recycle receives only unclaimed scoring-pool slack. +OSS_EMISSION_SHARE = 0.90 # 90% combined OSS scoring pool # ============================================================================= # Spam & Gaming Mitigation @@ -187,5 +185,5 @@ # ============================================================================= CONTRACT_ADDRESS = '5FWNdk8YNtNcHKrAx2krqenFrFAZG7vmsd2XN2isJSew3MrD' ISSUES_TREASURY_UID = 111 # UID of the smart contract neuron, if set to RECYCLE_UID then it's disabled -ISSUES_TREASURY_EMISSION_SHARE = 0.15 # % of emissions allocated to funding issues treasury +ISSUES_TREASURY_EMISSION_SHARE = 0.10 # % of emissions allocated to funding issues treasury MAX_ISSUE_ID = 1_000_000 # sanity-check upper bound for any real deployment diff --git a/gittensor/validator/emission_allocation.py b/gittensor/validator/emission_allocation.py new file mode 100644 index 000000000..020f5ca4e --- /dev/null +++ b/gittensor/validator/emission_allocation.py @@ -0,0 +1,108 @@ +from collections.abc import Mapping +from typing import Dict + +import numpy as np + +from gittensor.classes import MinerEvaluation +from gittensor.constants import OSS_EMISSION_SHARE, RECYCLE_UID +from gittensor.validator.utils.load_weights import RepositoryConfig + + +def allocate_repo_scoring_pool( + sorted_uids: list[int], + miner_evaluations: Dict[int, MinerEvaluation], + master_repositories: Mapping[str, RepositoryConfig], + pool_share: float = OSS_EMISSION_SHARE, +) -> np.ndarray: + """Allocate the scoring pool by bounded repository emission shares. + + Each configured repo receives at most ``emission_share * pool_share``. + Within that repo slice, PR and issue-discovery sub-slices split by + ``issue_discovery_share`` and spill only to the active side of the same + repo. Fully inactive repo slices and registry slack go to ``RECYCLE_UID``. + """ + rewards = np.zeros(len(sorted_uids)) + uid_to_index = {uid: idx for idx, uid in enumerate(sorted_uids)} + recycle_idx = uid_to_index.get(RECYCLE_UID) + allocated_share = 0.0 + + for repo_name, repo_config in master_repositories.items(): + repo_name = repo_name.lower() + repo_share = float(repo_config.emission_share) + allocated_share += repo_share + repo_slice = pool_share * repo_share + if repo_slice <= 0.0: + continue + + pr_scores = _collect_pr_scores(repo_name, miner_evaluations) + issue_scores = _collect_issue_scores(repo_name, miner_evaluations) + pr_total = sum(pr_scores.values()) + issue_total = sum(issue_scores.values()) + + if pr_total <= 0.0 and issue_total <= 0.0: + _add_recycle(rewards, recycle_idx, repo_slice) + continue + + issue_slice = repo_slice * float(repo_config.issue_discovery_share) + pr_slice = repo_slice - issue_slice + + if pr_total <= 0.0: + issue_slice += pr_slice + pr_slice = 0.0 + elif issue_total <= 0.0: + pr_slice += issue_slice + issue_slice = 0.0 + + _add_proportional_rewards(rewards, uid_to_index, pr_scores, pr_total, pr_slice) + _add_proportional_rewards(rewards, uid_to_index, issue_scores, issue_total, issue_slice) + + slack_share = max(0.0, 1.0 - allocated_share) + _add_recycle(rewards, recycle_idx, pool_share * slack_share) + + return rewards + + +def _collect_pr_scores(repo_name: str, miner_evaluations: Dict[int, MinerEvaluation]) -> dict[int, float]: + scores: dict[int, float] = {} + for uid, evaluation in miner_evaluations.items(): + repo_score = sum( + float(pr.earned_score) + for pr in evaluation.merged_prs + if pr.repository_full_name.lower() == repo_name and pr.earned_score > 0.0 + ) + if repo_score > 0.0: + scores[uid] = repo_score + return scores + + +def _collect_issue_scores(repo_name: str, miner_evaluations: Dict[int, MinerEvaluation]) -> dict[int, float]: + scores: dict[int, float] = {} + for uid, evaluation in miner_evaluations.items(): + repo_score = sum( + float(issue.discovery_earned_score) + for issue in evaluation.discovered_issues + if issue.repository_full_name.lower() == repo_name and issue.discovery_earned_score > 0.0 + ) + if repo_score > 0.0: + scores[uid] = repo_score + return scores + + +def _add_proportional_rewards( + rewards: np.ndarray, + uid_to_index: dict[int, int], + scores: dict[int, float], + total: float, + amount: float, +) -> None: + if total <= 0.0 or amount <= 0.0: + return + for uid, score in scores.items(): + idx = uid_to_index.get(uid) + if idx is not None: + rewards[idx] += amount * (score / total) + + +def _add_recycle(rewards: np.ndarray, recycle_idx: int | None, amount: float) -> None: + if recycle_idx is not None and amount > 0.0: + rewards[recycle_idx] += amount diff --git a/gittensor/validator/forward.py b/gittensor/validator/forward.py index 33a01de7b..7c159c61a 100644 --- a/gittensor/validator/forward.py +++ b/gittensor/validator/forward.py @@ -9,14 +9,12 @@ from gittensor.classes import MinerEvaluation, MinerEvaluationCache from gittensor.constants import ( - ISSUE_DISCOVERY_EMISSION_SHARE, ISSUES_TREASURY_EMISSION_SHARE, ISSUES_TREASURY_UID, OSS_EMISSION_SHARE, - RECYCLE_EMISSION_SHARE, - RECYCLE_UID, ) from gittensor.utils.uids import get_all_uids +from gittensor.validator.emission_allocation import allocate_repo_scoring_pool from gittensor.validator.issue_competitions.forward import issue_competitions from gittensor.validator.issue_discovery.normalize import ( normalize_issue_discovery_rewards, @@ -46,13 +44,12 @@ async def forward(self: 'Validator') -> None: 2. Score issue discovery 3. Run issue bounties verification 4. Store all evaluations to DB - 5. Blend emission pools and update scores + 5. Allocate repo emission slices and update scores - Emission blending (hardcoded per-competition): - - OSS contributions: 30% - - Issue discovery: 30% - - Issue treasury: 15% (flat to UID 111) - - Recycle: 25% (flat to UID 0) + Emission allocation: + - Combined scoring pool: 90%, allocated by repo emission_share + - Issue treasury: 10% (flat to UID 111) + - Recycle: unclaimed repo slices and registry slack to UID 0 """ if self.step % VALIDATOR_STEPS_INTERVAL == 0: @@ -85,8 +82,8 @@ async def forward(self: 'Validator') -> None: # 4. Store all evaluations to DB (includes issue discovery fields) await self.bulk_store_evaluation(miner_evaluations, skip_uids=cached_uids) - # 5. Blend 4 emission pools into final rewards - rewards = blend_emission_pools(oss_rewards, issue_rewards, miner_uids) + # 5. Allocate repo emission slices into final rewards + rewards = blend_emission_pools(oss_rewards, issue_rewards, miner_uids, miner_evaluations, master_repositories) self.update_scores(rewards, miner_uids, blacklisted_uids=sorted(penalized_uids)) @@ -153,33 +150,26 @@ def blend_emission_pools( oss_rewards: np.ndarray, issue_rewards: np.ndarray, miner_uids: set[int], + miner_evaluations: Optional[Dict[int, MinerEvaluation]] = None, + master_repositories: Optional[Dict[str, RepositoryConfig]] = None, ) -> np.ndarray: - """Blend 4 emission pools into a single rewards array. + """Blend scoring and treasury pools into a single rewards array. - - OSS contributions: 30% - - Issue discovery: 30% - - Issue treasury: 15% (flat to UID 111) - - Recycle: 25% (flat to UID 0) + When miner evaluations and repo config are provided, allocation is + repo-first: PR and issue-discovery scores only distribute each repo's + configured ``emission_share``. The normalized arrays are retained for + legacy CLI/tests and are used only when raw per-repo score evidence is not + available. """ sorted_uids = sorted(miner_uids) rewards = np.zeros(len(sorted_uids)) - recycle_extra = 0.0 - # Pool 1: OSS contributions (30%) - oss_total = float(oss_rewards.sum()) - if oss_total > 0: - rewards += oss_rewards * OSS_EMISSION_SHARE + if miner_evaluations is not None and master_repositories is not None: + rewards += allocate_repo_scoring_pool(sorted_uids, miner_evaluations, master_repositories) else: - recycle_extra += OSS_EMISSION_SHARE + rewards += _legacy_blend_scoring_pool(oss_rewards, issue_rewards) - # Pool 2: Issue discovery (30%) - issue_total = float(issue_rewards.sum()) - if issue_total > 0: - rewards += issue_rewards * ISSUE_DISCOVERY_EMISSION_SHARE - else: - recycle_extra += ISSUE_DISCOVERY_EMISSION_SHARE - - # Pool 3: Issue treasury (15% flat to UID 111) + # 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) rewards[treasury_idx] += ISSUES_TREASURY_EMISSION_SHARE @@ -188,11 +178,12 @@ def blend_emission_pools( f'{ISSUES_TREASURY_EMISSION_SHARE * 100:.0f}% of emissions' ) - # Pool 4: Recycle (25% + unclaimed from empty pools) - if RECYCLE_UID in miner_uids: - recycle_idx = sorted_uids.index(RECYCLE_UID) - rewards[recycle_idx] += RECYCLE_EMISSION_SHARE + recycle_extra - if recycle_extra > 0: - bt.logging.info(f'Recycling {recycle_extra * 100:.0f}% unclaimed emissions from empty pools') - return rewards + + +def _legacy_blend_scoring_pool(oss_rewards: np.ndarray, issue_rewards: np.ndarray) -> np.ndarray: + combined = np.asarray(oss_rewards, dtype=float) + np.asarray(issue_rewards, dtype=float) + total = float(combined.sum()) + if total <= 0.0: + return np.zeros(len(combined)) + return combined / total * OSS_EMISSION_SHARE diff --git a/gittensor/validator/issue_discovery/scan.py b/gittensor/validator/issue_discovery/scan.py index ad771fe2e..4df71dbcc 100644 --- a/gittensor/validator/issue_discovery/scan.py +++ b/gittensor/validator/issue_discovery/scan.py @@ -60,7 +60,6 @@ LanguageConfig, RepositoryConfig, TokenConfig, - resolve_repo_weight, ) @@ -224,6 +223,7 @@ def _clear_issue_discovery_fields(evaluation: MinerEvaluation) -> None: evaluation.total_valid_solved_issues = 0 evaluation.total_closed_issues = 0 evaluation.total_open_issues = 0 + evaluation.discovered_issues = [] def _copy_issue_discovery_fields(target: MinerEvaluation, source: MinerEvaluation) -> None: @@ -235,6 +235,7 @@ def _copy_issue_discovery_fields(target: MinerEvaluation, source: MinerEvaluatio target.total_valid_solved_issues = source.total_valid_solved_issues target.total_closed_issues = source.total_closed_issues target.total_open_issues = source.total_open_issues + target.discovered_issues = list(source.discovered_issues) def _restore_issue_discovery_from_cache( @@ -341,6 +342,7 @@ async def _score_miner_issues( issue_token_score = 0.0 score_fetch_failed = False scored_issues: List[Issue] = [] + evaluation.discovered_issues = [] issues_sorted = sorted( issues, @@ -456,7 +458,6 @@ async def _score_miner_issues( issue.discovery_open_issue_spam_multiplier = spam_mult issue.discovery_earned_score = round( issue.discovery_base_score - * issue.discovery_repo_weight_multiplier * issue.discovery_time_decay_multiplier * issue.discovery_review_quality_multiplier * issue.discovery_credibility_multiplier @@ -466,6 +467,7 @@ async def _score_miner_issues( total_discovery_score += issue.discovery_earned_score evaluation.issue_discovery_score = round(total_discovery_score, 2) + evaluation.discovered_issues = scored_issues bt.logging.info( f'├─ UID {evaluation.uid}: {solved_count} solved ({valid_solved_count} valid) | ' @@ -620,7 +622,7 @@ def _mirror_issue_for_scoring( ) adapted.discovery_base_score = base_score - adapted.discovery_repo_weight_multiplier = resolve_repo_weight(repo_config) + adapted.discovery_repo_weight_multiplier = 1.0 adapted.discovery_time_decay_multiplier = round(calculate_time_decay(solving_pr.merged_at), 2) adapted.discovery_review_quality_multiplier = round( calculate_issue_review_quality_multiplier(solving_pr.review_summary.maintainer_changes_requested_count), diff --git a/gittensor/validator/oss_contributions/mirror/scored_pr.py b/gittensor/validator/oss_contributions/mirror/scored_pr.py index 882377beb..0f2a7f52d 100644 --- a/gittensor/validator/oss_contributions/mirror/scored_pr.py +++ b/gittensor/validator/oss_contributions/mirror/scored_pr.py @@ -88,7 +88,6 @@ def is_pioneer_eligible(self) -> bool: def calculate_final_earned_score(self) -> float: """Combine base score with all multipliers. Pioneer dividend is added separately after.""" multipliers = { - 'repo': self.repo_weight_multiplier, 'issue': self.issue_multiplier, 'label': self.label_multiplier, 'spam': self.open_pr_spam_multiplier, diff --git a/gittensor/validator/oss_contributions/mirror/scoring.py b/gittensor/validator/oss_contributions/mirror/scoring.py index bda651e01..089b1dc25 100644 --- a/gittensor/validator/oss_contributions/mirror/scoring.py +++ b/gittensor/validator/oss_contributions/mirror/scoring.py @@ -2,7 +2,7 @@ Scope: - Compute base_score for each PR via the existing token-scoring infra. -- Compute per-PR multipliers: repo_weight, time_decay, review_quality, label, issue. +- Compute per-PR multipliers: time_decay, review_quality, label, issue. - The merge-eligibility gate (``_should_skip_merged_mirror_pr``) is exported and applied at LOAD time by ``mirror.load._maybe_add_pr`` — rejected PRs never enter ``merged_prs``, so the merged_count used by ``check_eligibility`` @@ -54,7 +54,6 @@ LanguageConfig, RepositoryConfig, TokenConfig, - resolve_repo_weight, ) from gittensor.validator.utils.tree_sitter_scoring import calculate_token_score_from_file_changes @@ -338,7 +337,7 @@ def calculate_base_score_for_pr_files( def _calculate_pr_multipliers(scored: ScoredPR, repo_config: RepositoryConfig) -> None: - """Compute repo_weight, time_decay, review_quality, label, issue multipliers. + """Compute time_decay, review_quality, label, issue multipliers. Spam and credibility multipliers are deferred to ``finalize_miner_scores`` — they depend on per-miner aggregate counts. @@ -346,8 +345,6 @@ def _calculate_pr_multipliers(scored: ScoredPR, repo_config: RepositoryConfig) - pr = scored.pr is_merged = pr.state == 'MERGED' - scored.repo_weight_multiplier = resolve_repo_weight(repo_config) - chosen_label, label_multiplier = _resolve_trusted_scoring_label(pr, repo_config) scored.label = chosen_label scored.label_multiplier = label_multiplier diff --git a/gittensor/validator/oss_contributions/scoring.py b/gittensor/validator/oss_contributions/scoring.py index 4336d2481..7398e2743 100644 --- a/gittensor/validator/oss_contributions/scoring.py +++ b/gittensor/validator/oss_contributions/scoring.py @@ -283,14 +283,13 @@ def calculate_open_pr_collateral_score(pr: 'ScoredPR') -> float: Collateral = base_score * applicable_multipliers * OPEN_PR_COLLATERAL_PERCENT - Applicable multipliers: repo_weight, issue, label, review_collateral + Applicable multipliers: issue, label, review_collateral NOT applicable: time_decay (merge-based), credibility_multiplier (merge-based), open_pr_spam (not for collateral) """ from math import prod multipliers = { - 'repo_weight': pr.repo_weight_multiplier, 'issue': pr.issue_multiplier, 'label': pr.label_multiplier, 'review_collateral': calculate_review_collateral_multiplier(pr.changes_requested_count, pr.number), diff --git a/gittensor/validator/utils/load_weights.py b/gittensor/validator/utils/load_weights.py index b838529a1..bc342351f 100644 --- a/gittensor/validator/utils/load_weights.py +++ b/gittensor/validator/utils/load_weights.py @@ -1,13 +1,16 @@ # The MIT License (MIT) # Copyright © 2025 Entrius import json +import math from dataclasses import dataclass, field from pathlib import Path from typing import Dict, List, Optional import bittensor as bt -from gittensor.constants import DEFAULT_REPO_WEIGHT, NON_CODE_EXTENSIONS +from gittensor.constants import DEFAULT_REPO_EMISSION_SHARE, NON_CODE_EXTENSIONS + +_SHARE_TOLERANCE = 1e-9 @dataclass @@ -23,12 +26,13 @@ class LanguageConfig: language: Optional[str] = None -@dataclass +@dataclass(init=False) class RepositoryConfig: """Configuration for a repository in the master_repositories list. Attributes: - weight: Repository weight for scoring + emission_share: Repository share of the combined scoring emission pool + issue_discovery_share: Fraction of this repo slice reserved for issue discovery inactive_at: ISO timestamp when repository became inactive (None if active) additional_acceptable_branches: List of additional branch patterns to accept (None if only default branch) trusted_label_pipeline: When True, scoring labels count regardless of @@ -46,7 +50,8 @@ class RepositoryConfig: """ - weight: float + emission_share: float + issue_discovery_share: float inactive_at: Optional[str] = None additional_acceptable_branches: Optional[List[str]] = None trusted_label_pipeline: bool = False @@ -55,12 +60,49 @@ class RepositoryConfig: fixed_base_score: Optional[float] = None eligibility_mode: bool = True + def __init__( + self, + emission_share: Optional[float] = None, + *, + weight: Optional[float] = None, + issue_discovery_share: float = 0.5, + inactive_at: Optional[str] = None, + additional_acceptable_branches: Optional[List[str]] = None, + trusted_label_pipeline: bool = False, + label_multipliers: Optional[Dict[str, float]] = None, + default_label_multiplier: float = 1.0, + fixed_base_score: Optional[float] = None, + eligibility_mode: bool = True, + ) -> None: + if emission_share is None: + emission_share = DEFAULT_REPO_EMISSION_SHARE if weight is None else weight + + self.emission_share = float(emission_share) + self.issue_discovery_share = float(issue_discovery_share) + self.inactive_at = inactive_at + self.additional_acceptable_branches = additional_acceptable_branches + self.trusted_label_pipeline = trusted_label_pipeline + self.label_multipliers = label_multipliers + self.default_label_multiplier = default_label_multiplier + self.fixed_base_score = fixed_base_score + self.eligibility_mode = eligibility_mode + + @property + def weight(self) -> float: + """Backward-compatible alias while callers migrate to emission_share.""" + return self.emission_share + + +def resolve_repo_emission_share(repo_config: Optional[RepositoryConfig]) -> float: + """Return the configured repo emission share, or the default for unknown repos.""" + if repo_config is None: + return DEFAULT_REPO_EMISSION_SHARE + return repo_config.emission_share + def resolve_repo_weight(repo_config: Optional[RepositoryConfig]) -> float: - """Return the repo weight preserving full JSON precision, or the default for unknown repos.""" - if repo_config is None: - return DEFAULT_REPO_WEIGHT - return repo_config.weight + """Backward-compatible alias for the configured repo emission share.""" + return resolve_repo_emission_share(repo_config) @dataclass @@ -107,14 +149,31 @@ def _get_weights_dir() -> Path: return Path(__file__).parent.parent / 'weights' +def _coerce_share(repo_name: str, field_name: str, value: object) -> float: + if isinstance(value, bool): + raise ValueError(f'{repo_name} {field_name} must be numeric, got bool') + share = float(value) + if not math.isfinite(share) or not 0.0 <= share <= 1.0: + raise ValueError(f'{repo_name} {field_name} must be within [0, 1], got {share}') + return share + + +def _validate_repository_emission_shares(repos: Dict[str, RepositoryConfig]) -> None: + total_share = sum(config.emission_share for config in repos.values()) + if total_share < -_SHARE_TOLERANCE or total_share > 1.0 + _SHARE_TOLERANCE: + raise ValueError(f'total repository emission_share must be <= 1.0, got {total_share}') + + def load_master_repo_weights() -> Dict[str, RepositoryConfig]: """ - Load repository weights from the local JSON file. + Load repository emission shares from the local JSON file. Normalizes repository names to lowercase for case-insensitive matching. Returns: Dictionary mapping normalized (lowercase) fullName (str) to RepositoryConfig object. - Returns empty dict on error. + Returns empty dict when the file is missing or malformed JSON. Raises + ValueError for invalid share config so validators fail closed on bad + monetary-policy input. """ weights_file = _get_weights_dir() / 'master_repositories.json' @@ -126,12 +185,25 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: bt.logging.error(f'Expected dict from {weights_file}, got {type(data)}') return {} - # Parse JSON data into RepositoryConfig objects normalized_data: Dict[str, RepositoryConfig] = {} for repo_name, metadata in data.items(): + if not isinstance(metadata, dict): + raise ValueError(f'Expected dict metadata for {repo_name}, got {type(metadata)}') + + emission_share = _coerce_share( + repo_name, + 'emission_share', + metadata.get('emission_share', metadata.get('weight', DEFAULT_REPO_EMISSION_SHARE)), + ) + issue_discovery_share = _coerce_share( + repo_name, + 'issue_discovery_share', + metadata.get('issue_discovery_share', 0.5), + ) try: config = RepositoryConfig( - weight=float(metadata.get('weight', 0.01)), + emission_share=emission_share, + issue_discovery_share=issue_discovery_share, inactive_at=metadata.get('inactive_at'), additional_acceptable_branches=metadata.get('additional_acceptable_branches'), trusted_label_pipeline=bool(metadata.get('trusted_label_pipeline', False)), @@ -146,10 +218,9 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: ) normalized_data[repo_name.lower()] = config except (ValueError, TypeError) as e: - bt.logging.warning(f'Could not parse config for {repo_name}: {e}, using defaults') - # Create config with defaults if parsing fails - normalized_data[repo_name.lower()] = RepositoryConfig(weight=float(metadata.get('weight', 0.01))) + raise ValueError(f'Could not parse config for {repo_name}: {e}') from e + _validate_repository_emission_shares(normalized_data) bt.logging.debug(f'Successfully loaded {len(normalized_data)} repository entries from {weights_file}') return normalized_data @@ -159,6 +230,8 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: except json.JSONDecodeError as e: bt.logging.error(f'Failed to parse JSON from {weights_file}: {e}') return {} + except ValueError: + raise except Exception as e: bt.logging.error(f'Unexpected error loading repository weights: {e}') return {} diff --git a/gittensor/validator/weights/master_repositories.json b/gittensor/validator/weights/master_repositories.json index 3d0254d73..380038ce9 100644 --- a/gittensor/validator/weights/master_repositories.json +++ b/gittensor/validator/weights/master_repositories.json @@ -1,6 +1,6 @@ { "entrius/allways": { - "weight": 0.05, + "emission_share": 0.05, "trusted_label_pipeline": true, "label_multipliers": { "bug": 1.25, @@ -9,7 +9,7 @@ } }, "entrius/allways-ui": { - "weight": 0.01, + "emission_share": 0.01, "trusted_label_pipeline": true, "label_multipliers": { "feature": 1.25, @@ -19,7 +19,7 @@ } }, "entrius/das-github-mirror": { - "weight": 0.02, + "emission_share": 0.02, "trusted_label_pipeline": true, "label_multipliers": { "bug": 1.25, @@ -28,7 +28,7 @@ } }, "entrius/gittensor": { - "weight": 0.1, + "emission_share": 0.1, "trusted_label_pipeline": true, "label_multipliers": { "feature": 1.5, @@ -38,7 +38,7 @@ } }, "entrius/gittensor-ui": { - "weight": 0.03, + "emission_share": 0.03, "trusted_label_pipeline": true, "label_multipliers": { "feature": 1.25, @@ -48,7 +48,8 @@ } }, "entrius/oc-1": { - "weight": 0.5, + "emission_share": 0.5, + "issue_discovery_share": 0.0, "trusted_label_pipeline": true, "fixed_base_score": 1.0, "eligibility_mode": false, diff --git a/tests/validator/oss_contributions/mirror/test_scored_pr.py b/tests/validator/oss_contributions/mirror/test_scored_pr.py index b3c138f05..e76d8095b 100644 --- a/tests/validator/oss_contributions/mirror/test_scored_pr.py +++ b/tests/validator/oss_contributions/mirror/test_scored_pr.py @@ -115,8 +115,9 @@ def test_multipliers_compose(self): scored.base_score = 100.0 scored.repo_weight_multiplier = 0.5 scored.review_quality_multiplier = 0.5 - # 100 * 0.5 * 0.5 (others 1.0) = 25 - assert scored.calculate_final_earned_score() == 25.0 + # repo_weight_multiplier is persisted for compatibility but no longer + # participates in per-PR score composition. + assert scored.calculate_final_earned_score() == 50.0 def test_zero_multiplier_zeros_score(self): scored = ScoredPR(pr=_make_pr()) diff --git a/tests/validator/oss_contributions/mirror/test_scoring.py b/tests/validator/oss_contributions/mirror/test_scoring.py index c14b792ec..dc6b267d7 100644 --- a/tests/validator/oss_contributions/mirror/test_scoring.py +++ b/tests/validator/oss_contributions/mirror/test_scoring.py @@ -378,7 +378,7 @@ def test_fixed_base_score_scores_without_stored_files(self): client.get_pr_files.assert_not_called() assert scored.base_score == pytest.approx(7.5) - assert scored.repo_weight_multiplier == pytest.approx(0.5) + assert scored.repo_weight_multiplier == pytest.approx(1.0) class TestFixedBaseScore: @@ -439,7 +439,6 @@ def test_fixed_base_replaces_token_base_but_keeps_token_breakdown_and_multiplier assert scored.label_multiplier == pytest.approx(2.0) assert scored.calculate_final_earned_score() == pytest.approx( scored.base_score - * scored.repo_weight_multiplier * scored.issue_multiplier * scored.label_multiplier * scored.open_pr_spam_multiplier @@ -860,7 +859,7 @@ def test_collateral_computed_without_crash(self): # Must not raise AttributeError on .number result = calculate_open_pr_collateral_score(scored) - assert result >= 0.0 + assert result == pytest.approx(5.0) def test_number_property_proxies_to_pr_pr_number(self): scored = ScoredPR(pr=_pr()) @@ -914,7 +913,7 @@ def test_merged_pr_populates_all_multipliers(self): _config(weight=0.7, additional_branches=['test'], label_multipliers={'feature': 1.5}), ) - assert scored.repo_weight_multiplier == 0.7 + assert scored.repo_weight_multiplier == 1.0 assert scored.label == 'feature' assert scored.label_multiplier == pytest.approx(1.5) assert 0.0 <= scored.time_decay_multiplier <= 1.0 @@ -926,7 +925,7 @@ def test_open_pr_only_neutral_multipliers(self): scored = ScoredPR(pr=_pr(state='OPEN')) _calculate_pr_multipliers(scored, _config(weight=0.5)) - assert scored.repo_weight_multiplier == 0.5 + assert scored.repo_weight_multiplier == 1.0 # Time decay / review quality / credibility are merge-only — kept neutral here. assert scored.time_decay_multiplier == 1.0 assert scored.credibility_multiplier == 1.0 diff --git a/tests/validator/test_emission_share_allocation.py b/tests/validator/test_emission_share_allocation.py new file mode 100644 index 000000000..af0870bec --- /dev/null +++ b/tests/validator/test_emission_share_allocation.py @@ -0,0 +1,91 @@ +from types import SimpleNamespace +from typing import Any, cast + +import numpy as np +import pytest + +from gittensor.classes import Issue, MinerEvaluation +from gittensor.validator.forward import blend_emission_pools +from gittensor.validator.utils.load_weights import RepositoryConfig + + +def _eval(uid: int, repo_scores=None, issue_scores=None) -> MinerEvaluation: + evaluation = MinerEvaluation(uid=uid, hotkey=f'hotkey-{uid}', github_id=f'github-{uid}') + evaluation.merged_prs = cast( + Any, + [SimpleNamespace(repository_full_name=repo, earned_score=score) for repo, score in (repo_scores or [])], + ) + evaluation.discovered_issues = [ + Issue( + number=idx + 1, + pr_number=idx + 10, + repository_full_name=repo, + title='issue', + discovery_earned_score=score, + ) + for idx, (repo, score) in enumerate(issue_scores or []) + ] + return evaluation + + +def test_active_repo_receives_fixed_slice_regardless_of_pr_count(): + repos = { + 'repo/a': RepositoryConfig(emission_share=0.05, issue_discovery_share=0.0), + 'repo/b': RepositoryConfig(emission_share=0.05, issue_discovery_share=0.0), + } + evaluations = { + 0: _eval(0), + 1: _eval(1, [('repo/a', 10.0)]), + 2: _eval(2, [('repo/b', 1.0) for _ in range(50)]), + } + + rewards = blend_emission_pools(np.zeros(3), np.zeros(3), {0, 1, 2}, evaluations, repos) + + assert rewards[0] == pytest.approx(0.90 * 0.90) + assert rewards[1] == pytest.approx(0.90 * 0.05) + assert rewards[2] == pytest.approx(0.90 * 0.05) + + +def test_pr_and_issue_sides_split_and_spill_within_same_repo(): + repos = { + 'repo/split': RepositoryConfig(emission_share=0.4, issue_discovery_share=0.25), + 'repo/pr-only': RepositoryConfig(emission_share=0.2, issue_discovery_share=0.5), + 'repo/empty': RepositoryConfig(emission_share=0.1, issue_discovery_share=0.5), + } + evaluations = { + 0: _eval(0), + 1: _eval(1, [('repo/split', 3.0)]), + 2: _eval(2, [('repo/split', 1.0)], [('repo/split', 4.0)]), + 3: _eval(3, [('repo/pr-only', 9.0)]), + } + + rewards = blend_emission_pools(np.zeros(4), np.zeros(4), {0, 1, 2, 3}, evaluations, repos) + + assert rewards[0] == pytest.approx(0.90 * 0.4) # registry slack + empty repo + assert rewards[1] == pytest.approx(0.90 * 0.4 * 0.75 * 0.75) + assert rewards[2] == pytest.approx((0.90 * 0.4 * 0.75 * 0.25) + (0.90 * 0.4 * 0.25)) + assert rewards[3] == pytest.approx(0.90 * 0.2) # issue side spills to PR side + + +def test_no_repo_activity_recycles_scoring_pool_and_preserves_treasury(): + repos = {'repo/a': RepositoryConfig(emission_share=1.0, issue_discovery_share=0.5)} + evaluations = {0: _eval(0), 1: _eval(1), 111: _eval(111)} + + rewards = blend_emission_pools(np.zeros(3), np.zeros(3), {0, 1, 111}, evaluations, repos) + + assert rewards[0] == pytest.approx(0.90) + assert rewards[1] == pytest.approx(0.0) + assert rewards[2] == pytest.approx(0.10) + assert rewards.sum() == pytest.approx(1.0) + + +def test_fallback_blend_combines_legacy_normalized_arrays_when_raw_scores_unavailable(): + rewards = blend_emission_pools( + np.array([0.0, 1.0, 0.0]), + np.array([0.0, 0.0, 1.0]), + {0, 1, 2}, + ) + + assert rewards[0] == pytest.approx(0.0) + assert rewards[1] == pytest.approx(0.45) + assert rewards[2] == pytest.approx(0.45) diff --git a/tests/validator/test_load_weights.py b/tests/validator/test_load_weights.py index bb179784d..64f0d5332 100644 --- a/tests/validator/test_load_weights.py +++ b/tests/validator/test_load_weights.py @@ -19,6 +19,7 @@ load_master_repo_weights, load_programming_language_weights, load_token_config, + resolve_repo_emission_share, resolve_repo_weight, ) @@ -142,6 +143,86 @@ def test_entrius_repos_have_trusted_label_pipeline(self): f'labeling worker is honored at scoring time' ) + def test_live_master_repository_entries_use_emission_share(self): + """Live registry entries use bounded emission_share, not legacy weight.""" + for repo_name, metadata in _live_master_repo_metadata(): + assert 'emission_share' in metadata, f'{repo_name} must declare emission_share' + assert 'weight' not in metadata, f'{repo_name} must not use legacy weight' + + def test_live_emission_shares_are_in_range_and_total_not_above_one(self): + repos = load_master_repo_weights() + total_share = sum(config.emission_share for config in repos.values()) + assert 0.0 <= total_share <= 1.0 + for repo_name, config in repos.items(): + assert 0.0 <= config.emission_share <= 1.0, f'{repo_name} emission_share must be within [0, 1]' + assert 0.0 <= config.issue_discovery_share <= 1.0, ( + f'{repo_name} issue_discovery_share must be within [0, 1]' + ) + + def test_oc_1_opts_out_of_issue_discovery_rewards(self): + repos = load_master_repo_weights() + + assert repos['entrius/oc-1'].issue_discovery_share == pytest.approx(0.0) + + 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( + json.dumps( + { + 'foo/one': {'emission_share': 0.2}, + 'foo/two': {'emission_share': 0.3}, + } + ) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) + + repos = lw.load_master_repo_weights() + + assert repos['foo/one'].emission_share == pytest.approx(0.2) + assert repos['foo/two'].emission_share == pytest.approx(0.3) + + @pytest.mark.parametrize( + 'field,value', + [ + ('emission_share', -0.001), + ('emission_share', 1.001), + ('emission_share', True), + ('issue_discovery_share', -0.001), + ('issue_discovery_share', 1.001), + ('issue_discovery_share', False), + ], + ) + def test_loader_rejects_share_outside_range_or_bool(self, tmp_path, monkeypatch, field, value): + from gittensor.validator.utils import load_weights as lw + + fake_weights_dir = tmp_path + metadata = {'emission_share': 0.5} + metadata[field] = value + (fake_weights_dir / 'master_repositories.json').write_text(json.dumps({'foo/bad': metadata})) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) + + with pytest.raises(ValueError, match=field): + lw.load_master_repo_weights() + + def test_loader_rejects_total_emission_share_above_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( + json.dumps( + { + 'foo/one': {'emission_share': 0.6}, + 'foo/two': {'emission_share': 0.5}, + } + ) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: fake_weights_dir) + + with pytest.raises(ValueError, match='total repository emission_share'): + lw.load_master_repo_weights() + class TestRepositoryConfigTrustedLabelPipeline: """Dataclass + JSON-parsing tests for trusted_label_pipeline (issue #911).""" @@ -341,10 +422,10 @@ def test_no_active_banned_org_repos(self): class TestResolveRepoWeight: - """Tests for resolve_repo_weight — full-precision repo weight lookup.""" + """Tests for repo emission-share resolution — full-precision lookup.""" def test_none_returns_default(self): - assert resolve_repo_weight(None) == 0.01 + assert resolve_repo_emission_share(None) == 0.01 @pytest.mark.parametrize( 'weight', @@ -352,15 +433,20 @@ def test_none_returns_default(self): ) def test_preserves_full_precision(self, weight): config = RepositoryConfig(weight=weight) - assert resolve_repo_weight(config) == weight + assert resolve_repo_emission_share(config) == weight def test_live_master_repo_precision(self): """cronboard (0.0349) and fzf (0.0351) must not collapse to 0.03/0.04.""" repos = load_master_repo_weights() if 'antoniorodr/cronboard' in repos: - assert resolve_repo_weight(repos['antoniorodr/cronboard']) == pytest.approx(0.0349, abs=1e-9) + assert resolve_repo_emission_share(repos['antoniorodr/cronboard']) == pytest.approx(0.0349, abs=1e-9) if 'junegunn/fzf' in repos: - assert resolve_repo_weight(repos['junegunn/fzf']) == pytest.approx(0.0351, abs=1e-9) + assert resolve_repo_emission_share(repos['junegunn/fzf']) == pytest.approx(0.0351, abs=1e-9) + + def test_legacy_weight_resolver_aliases_emission_share_resolver(self): + config = RepositoryConfig(emission_share=0.1234) + + assert resolve_repo_weight(config) == pytest.approx(resolve_repo_emission_share(config)) if __name__ == '__main__':