diff --git a/gittensor/cli/miner_commands/score.py b/gittensor/cli/miner_commands/score.py index 64cb6b8a..9ff06591 100644 --- a/gittensor/cli/miner_commands/score.py +++ b/gittensor/cli/miner_commands/score.py @@ -48,6 +48,10 @@ def _round(x: float) -> float: return round(x, 4) +def _round6(x: float) -> float: + return round(x, 6) + + class _StubValidator: """Stub `self` with no tensor, wallet, axon, wandb, and DB connection.""" @@ -111,6 +115,40 @@ def _serialize_evaluation(miner_eval) -> Dict[str, Any]: return payload +def _serialize_allocation_breakdown(allocations, uid: int) -> list[Dict[str, Any]]: + rows = [] + for allocation in allocations: + pr_score = allocation.pr_scores.get(uid, 0.0) + issue_score = allocation.issue_discovery_scores.get(uid, 0.0) + pr_reward = allocation.pr_rewards.get(uid, 0.0) + issue_reward = allocation.issue_discovery_rewards.get(uid, 0.0) + maintainer_reward = allocation.maintainer_rewards.get(uid, 0.0) + if pr_score <= 0 and issue_score <= 0 and pr_reward <= 0 and issue_reward <= 0 and maintainer_reward <= 0: + continue + + rows.append( + { + 'repository_full_name': allocation.repository_full_name, + 'emission_share': _round6(allocation.emission_share), + 'issue_discovery_share': _round6(allocation.issue_discovery_share), + 'maintainer_cut': _round6(allocation.maintainer_cut), + 'repo_slice': _round6(allocation.repo_slice), + 'maintainer_carve_out': _round6(allocation.maintainer_carve_out), + 'pr_slice': _round6(allocation.pr_slice), + 'issue_discovery_slice': _round6(allocation.issue_discovery_slice), + 'pr_score': _round(pr_score), + 'issue_discovery_score': _round(issue_score), + 'pr_reward': _round6(pr_reward), + 'issue_discovery_reward': _round6(issue_reward), + 'maintainer_reward': _round6(maintainer_reward), + 'total_reward': _round6(pr_reward + issue_reward + maintainer_reward), + 'recycled_amount': _round6(allocation.recycled_amount), + 'recycled': allocation.recycled_amount > 0, + } + ) + return rows + + def _render_table(payload: Dict[str, Any]) -> None: miner = payload['miner_evaluation'] rewards = payload['rewards'] @@ -170,6 +208,34 @@ def _render_table(payload: Dict[str, Any]) -> None: ) console.print(repo_table) + allocation_rows = payload.get('allocation_breakdown', []) + if allocation_rows: + show_maintainer = any(row.get('maintainer_reward', 0) > 0 for row in allocation_rows) + allocation_table = Table(title='Repo allocation breakdown', show_lines=False) + allocation_table.add_column('Repo', style='cyan') + allocation_table.add_column('Slice', justify='right') + allocation_table.add_column('PR score', justify='right') + allocation_table.add_column('Issue score', justify='right') + allocation_table.add_column('PR reward', justify='right') + allocation_table.add_column('Issue reward', justify='right') + if show_maintainer: + allocation_table.add_column('Maintainer reward', justify='right') + allocation_table.add_column('Recycled', justify='right') + for row in allocation_rows: + values = [ + row['repository_full_name'], + f'{row["repo_slice"]:.6f}', + f'{row["pr_score"]:.2f}', + f'{row["issue_discovery_score"]:.2f}', + f'{row["pr_reward"]:.6f}', + f'{row["issue_discovery_reward"]:.6f}', + ] + if show_maintainer: + values.append(f'{row.get("maintainer_reward", 0):.6f}') + values.append(f'{row["recycled_amount"]:.6f}' if row['recycled'] else '-') + allocation_table.add_row(*values) + console.print(allocation_table) + pr_table = Table(title='Per-PR breakdown', show_lines=False) pr_table.add_column('Repo#PR', style='cyan') pr_table.add_column('State', style='magenta') @@ -272,8 +338,9 @@ def score_command(pat: Optional[str], log_level: str, json_mode: bool) -> None: resolved_pat = _resolve_pat(pat, json_mode) # Deferred imports: keeps --help fast (these pull bittensor + the validator graph). - from gittensor.validator.emission_allocation import blend_emission_pools + from gittensor.validator.emission_allocation import blend_emission_pools, calculate_repo_emission_breakdown from gittensor.validator.forward import ( + _build_maintainer_uids_by_repo, issue_discovery, oss_contributions, ) @@ -308,7 +375,13 @@ async def _run() -> Dict[str, Any]: stub, miner_uids, master_repositories, programming_languages, token_config ) await issue_discovery(miner_evaluations, master_repositories, programming_languages, token_config) - rewards = blend_emission_pools(miner_evaluations, master_repositories, miner_uids) + maintainer_uids_by_repo = _build_maintainer_uids_by_repo(miner_evaluations, master_repositories, miner_uids) + allocation_breakdown = list( + calculate_repo_emission_breakdown( + miner_evaluations, master_repositories, miner_uids, maintainer_uids_by_repo + ) + ) + rewards = blend_emission_pools(miner_evaluations, master_repositories, miner_uids, maintainer_uids_by_repo) return { 'success': True, @@ -316,6 +389,7 @@ async def _run() -> Dict[str, Any]: 'rewards': { 'blended_final': _round(float(rewards[0])), }, + 'allocation_breakdown': _serialize_allocation_breakdown(allocation_breakdown, _DEV_UID), } if not json_mode: diff --git a/gittensor/validator/emission_allocation.py b/gittensor/validator/emission_allocation.py index 59dbb83c..f806ffda 100644 --- a/gittensor/validator/emission_allocation.py +++ b/gittensor/validator/emission_allocation.py @@ -3,7 +3,8 @@ """Round-level emission allocation by repository emission shares.""" -from typing import Dict, Optional +from dataclasses import dataclass, field +from typing import Dict, Iterator, Optional import bittensor as bt import numpy as np @@ -19,6 +20,26 @@ from gittensor.validator.utils.load_weights import RepositoryConfig +@dataclass +class RepoEmissionAllocation: + """Per-repository allocation details for one scoring round.""" + + repository_full_name: str + emission_share: float + issue_discovery_share: float + repo_slice: float + maintainer_cut: float = 0.0 + maintainer_carve_out: float = 0.0 + maintainer_rewards: Dict[int, float] = field(default_factory=dict) + pr_slice: float = 0.0 + issue_discovery_slice: float = 0.0 + pr_scores: Dict[int, float] = field(default_factory=dict) + issue_discovery_scores: Dict[int, float] = field(default_factory=dict) + pr_rewards: Dict[int, float] = field(default_factory=dict) + issue_discovery_rewards: Dict[int, float] = field(default_factory=dict) + recycled_amount: float = 0.0 + + def blend_emission_pools( miner_evaluations: Dict[int, MinerEvaluation], master_repositories: Dict[str, RepositoryConfig], @@ -45,67 +66,133 @@ def blend_emission_pools( total_configured_share = sum(config.emission_share for config in master_repositories.values()) recycle_share = max(0.0, 1.0 - total_configured_share) * OSS_EMISSION_SHARE + for allocation in calculate_repo_emission_breakdown( + miner_evaluations, master_repositories, miner_uids, maintainer_uids_by_repo + ): + recycle_share += allocation.recycled_amount + for uid, reward in allocation.maintainer_rewards.items(): + rewards[uid_index[uid]] += reward + for uid, reward in allocation.pr_rewards.items(): + rewards[uid_index[uid]] += reward + for uid, reward in allocation.issue_discovery_rewards.items(): + rewards[uid_index[uid]] += reward + + # 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 + bt.logging.info( + f'Treasury allocation: UID {ISSUES_TREASURY_UID} receives ' + f'{ISSUES_TREASURY_EMISSION_SHARE * 100:.0f}% of emissions' + ) + + # Recycle receives registry slack and empty repo slices. + if RECYCLE_UID in miner_uids: + recycle_idx = sorted_uids.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') + + return rewards + + +def calculate_repo_emission_breakdown( + miner_evaluations: Dict[int, MinerEvaluation], + master_repositories: Dict[str, RepositoryConfig], + miner_uids: set[int], + maintainer_uids_by_repo: Optional[Dict[str, list[int]]] = None, +) -> Iterator[RepoEmissionAllocation]: + """Return per-repository reward allocation details without adding treasury/slack.""" for repo_name, repo_config in master_repositories.items(): repo_slice = repo_config.emission_share * OSS_EMISSION_SHARE if repo_slice <= 0: continue + allocation = RepoEmissionAllocation( + repository_full_name=repo_name, + emission_share=repo_config.emission_share, + issue_discovery_share=repo_config.issue_discovery_share, + repo_slice=repo_slice, + maintainer_cut=repo_config.maintainer_cut, + ) + # Maintainer carve-out: route maintainer_cut of the repo slice evenly to # the repo's registered maintainer miners, off the top before the # PR/issue split. Skipped when no maintainer miner is present. maintainer_uids = (maintainer_uids_by_repo or {}).get(repo_name) or [] + scoring_slice = repo_slice if repo_config.maintainer_cut > 0.0 and maintainer_uids: carve_out = repo_config.maintainer_cut * repo_slice - per_maintainer = carve_out / len(maintainer_uids) - for uid in maintainer_uids: - idx = uid_index.get(uid) - if idx is not None: - rewards[idx] += per_maintainer - repo_slice -= carve_out + eligible_maintainers = [uid for uid in maintainer_uids if uid in miner_uids] + if eligible_maintainers: + per_maintainer = carve_out / len(eligible_maintainers) + allocation.maintainer_carve_out = carve_out + allocation.maintainer_rewards = {uid: per_maintainer for uid in eligible_maintainers} + else: + allocation.recycled_amount += carve_out + scoring_slice -= carve_out issue_share = repo_config.issue_discovery_share - pr_scores = _collect_repo_pr_scores(miner_evaluations, repo_name, miner_uids) if issue_share < 1.0 else {} - issue_scores = ( - _collect_repo_issue_discovery_scores(miner_evaluations, repo_name, miner_uids) if issue_share > 0.0 else {} - ) + raw_pr_scores = _collect_repo_pr_scores(miner_evaluations, repo_name, miner_uids) + raw_issue_scores = _collect_repo_issue_discovery_scores(miner_evaluations, repo_name, miner_uids) + pr_scores = raw_pr_scores if issue_share < 1.0 else {} + issue_scores = raw_issue_scores if issue_share > 0.0 else {} + + allocation.pr_scores = raw_pr_scores + allocation.issue_discovery_scores = raw_issue_scores pr_total = sum(pr_scores.values()) issue_total = sum(issue_scores.values()) if pr_total <= 0 and issue_total <= 0: - recycle_share += repo_slice + allocation.recycled_amount += scoring_slice + yield allocation continue if pr_total > 0 and issue_total > 0: - recycle_share += _allocate_scores_to_rewards( - rewards, - uid_index, - pr_scores, - repo_slice * (1.0 - issue_share), - ) - recycle_share += _allocate_scores_to_rewards(rewards, uid_index, issue_scores, repo_slice * issue_share) + allocation.pr_slice = scoring_slice * (1.0 - issue_share) + allocation.issue_discovery_slice = scoring_slice * issue_share elif pr_total > 0: - recycle_share += _allocate_scores_to_rewards(rewards, uid_index, pr_scores, repo_slice) + allocation.pr_slice = scoring_slice else: - recycle_share += _allocate_scores_to_rewards(rewards, uid_index, issue_scores, repo_slice) + allocation.issue_discovery_slice = scoring_slice - # 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 - bt.logging.info( - f'Treasury allocation: UID {ISSUES_TREASURY_UID} receives ' - f'{ISSUES_TREASURY_EMISSION_SHARE * 100:.0f}% of emissions' + allocation.pr_rewards, pr_unallocated = _calculate_score_rewards( + pr_scores, + allocation.pr_slice, + miner_uids, ) + allocation.issue_discovery_rewards, issue_unallocated = _calculate_score_rewards( + issue_scores, + allocation.issue_discovery_slice, + miner_uids, + ) + allocation.recycled_amount += pr_unallocated + issue_unallocated + yield allocation - # Recycle receives registry slack and empty repo slices. - if RECYCLE_UID in miner_uids: - recycle_idx = sorted_uids.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') - return rewards +def _calculate_score_rewards( + scores: Dict[int, float], + allocation: float, + miner_uids: set[int], +) -> tuple[Dict[int, float], float]: + if allocation <= 0: + return {}, 0.0 + + total = sum(scores.values()) + if total <= 0: + return {}, allocation + + rewards: Dict[int, float] = {} + unallocated = 0.0 + for uid, score in scores.items(): + share = allocation * (score / total) + if uid in miner_uids: + rewards[uid] = share + else: + unallocated += share + + return rewards, unallocated def _collect_repo_pr_scores( @@ -148,28 +235,3 @@ def _collect_repo_issue_discovery_scores( scores[uid] = score return scores - - -def _allocate_scores_to_rewards( - rewards: np.ndarray, - uid_index: Dict[int, int], - scores: Dict[int, float], - allocation: float, -) -> float: - if allocation <= 0: - return 0.0 - - total = sum(scores.values()) - if total <= 0: - return allocation - - unallocated = 0.0 - for uid, score in scores.items(): - share = allocation * (score / total) - idx = uid_index.get(uid) - if idx is None: - unallocated += share - else: - rewards[idx] += share - - return unallocated diff --git a/tests/cli/test_miner_score.py b/tests/cli/test_miner_score.py index 1a3757c8..36dbf9f6 100644 --- a/tests/cli/test_miner_score.py +++ b/tests/cli/test_miner_score.py @@ -41,6 +41,7 @@ def _patch_pipeline( blended: float = 0.3, oss_side_effect=None, master_repos: Optional[Dict] = None, + maintainer_uids_by_repo: Optional[Dict] = None, ): """Mock the three forward entry points; `oss_side_effect` captures call args.""" miner_evaluations = {uid: miner_evaluation} @@ -59,6 +60,10 @@ def _patch_pipeline( oss_patch, patch('gittensor.validator.forward.issue_discovery', new=AsyncMock(return_value=None)), patch('gittensor.validator.emission_allocation.blend_emission_pools', return_value=final_rewards), + patch( + 'gittensor.validator.forward._build_maintainer_uids_by_repo', + return_value=maintainer_uids_by_repo or {}, + ), patch('gittensor.validator.utils.load_weights.load_master_repo_weights', return_value=master_repos or {}), patch('gittensor.validator.utils.load_weights.load_programming_language_weights', return_value={}), patch('gittensor.validator.utils.load_weights.load_token_config', return_value=_stub_token_config()), @@ -123,6 +128,18 @@ def _make_scored_mirror_pr( ) +def _make_discovered_issue(repo_full_name: str = 'octo/repo', number: int = 7, earned_score: float = 8.0): + from gittensor.classes import Issue + + return Issue( + number=number, + pr_number=number + 1000, + repository_full_name=repo_full_name, + title='discovered', + discovery_earned_score=earned_score, + ) + + class TestScoreCommand: def test_help_text(self, runner): result = runner.invoke(cli, ['miner', 'score', '--help']) @@ -177,6 +194,48 @@ def test_e2e_json_output(self, runner, miner_eval_factory): assert payload['miner_evaluation']['is_eligible'] is True assert payload['miner_evaluation']['credibility'] == 0.85 assert payload['rewards']['blended_final'] == 0.3 + assert payload['allocation_breakdown'] == [] + + def test_json_output_includes_repo_allocation_breakdown(self, runner, miner_eval_factory): + from gittensor.validator.utils.load_weights import RepositoryConfig + + evaluation = miner_eval_factory( + uid=_DEV_UID, + merged_prs=[_make_scored_mirror_pr(repo_full_name='octo/repo', earned_score=42.5)], + issue_discovery_issues=[_make_discovered_issue(repo_full_name='octo/repo', earned_score=8.0)], + ) + master_repos = {'octo/repo': RepositoryConfig(emission_share=0.2, issue_discovery_share=0.25)} + with _multi_patch( + _patch_pipeline( + uid=_DEV_UID, + miner_evaluation=evaluation, + blended=0.3, + master_repos=master_repos, + ) + ): + result = runner.invoke( + cli, + ['miner', 'score', '--json'], + env={'GITTENSOR_MINER_PAT': 'ghp_dummy'}, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + rows = payload['allocation_breakdown'] + assert len(rows) == 1 + row = rows[0] + assert row['repository_full_name'] == 'octo/repo' + assert row['emission_share'] == 0.2 + assert row['issue_discovery_share'] == 0.25 + assert row['repo_slice'] == 0.18 + assert row['pr_slice'] == 0.135 + assert row['issue_discovery_slice'] == 0.045 + assert row['pr_score'] == 42.5 + assert row['issue_discovery_score'] == 8.0 + assert row['pr_reward'] == 0.135 + assert row['issue_discovery_reward'] == 0.045 + assert row['total_reward'] == 0.18 + assert row['recycled'] is False def test_pat_never_appears_in_json(self, runner, miner_eval_factory): """The introspection-based serializer relies on _EVAL_SKIP to redact secrets; @@ -306,6 +365,81 @@ def test_populated_mirror_prs_render_in_table(self, runner, miner_eval_factory): assert 'octo/repo#42' in result.output assert 'Per-PR breakdown' in result.output + def test_allocation_breakdown_renders_in_table(self, runner, miner_eval_factory): + from gittensor.validator.utils.load_weights import RepositoryConfig + + evaluation = miner_eval_factory( + uid=_DEV_UID, + merged_prs=[_make_scored_mirror_pr(repo_full_name='octo/repo', earned_score=42.5)], + issue_discovery_issues=[_make_discovered_issue(repo_full_name='octo/repo', earned_score=8.0)], + ) + master_repos = {'octo/repo': RepositoryConfig(emission_share=0.2, issue_discovery_share=0.25)} + with _multi_patch( + _patch_pipeline( + uid=_DEV_UID, + miner_evaluation=evaluation, + master_repos=master_repos, + ) + ): + result = runner.invoke( + cli, + ['miner', 'score'], + env={'GITTENSOR_MINER_PAT': 'ghp_dummy'}, + ) + + assert result.exit_code == 0, result.output + assert 'Repo allocation breakdown' in result.output + assert 'octo/repo' in result.output + assert '0.135000' in result.output + assert '0.045000' in result.output + + def test_allocation_breakdown_includes_maintainer_carve_out(self, runner, miner_eval_factory): + """When the dev UID is a registered maintainer of a maintainer_cut repo, + the breakdown must surface the carve-out and roll it into total_reward.""" + from gittensor.validator.utils.load_weights import RepositoryConfig + + evaluation = miner_eval_factory( + uid=_DEV_UID, + merged_prs=[_make_scored_mirror_pr(repo_full_name='octo/repo', earned_score=42.5)], + ) + master_repos = { + 'octo/repo': RepositoryConfig( + emission_share=0.2, + issue_discovery_share=0.0, + maintainer_cut=0.5, + ) + } + with _multi_patch( + _patch_pipeline( + uid=_DEV_UID, + miner_evaluation=evaluation, + master_repos=master_repos, + maintainer_uids_by_repo={'octo/repo': [_DEV_UID]}, + ) + ): + result = runner.invoke( + cli, + ['miner', 'score', '--json'], + env={'GITTENSOR_MINER_PAT': 'ghp_dummy'}, + ) + + assert result.exit_code == 0, result.output + payload = json.loads(result.output) + rows = payload['allocation_breakdown'] + assert len(rows) == 1 + row = rows[0] + assert row['repository_full_name'] == 'octo/repo' + assert row['maintainer_cut'] == 0.5 + assert row['repo_slice'] == 0.18 + assert row['maintainer_carve_out'] == 0.09 + assert row['maintainer_reward'] == 0.09 + # PR scoring receives the remaining 0.09 after the carve-out + assert row['pr_slice'] == 0.09 + assert row['pr_reward'] == 0.09 + assert row['issue_discovery_reward'] == 0 + assert row['total_reward'] == 0.18 + assert row['recycled'] is False + def test_pat_storage_load_all_pats_is_patched(self, runner, miner_eval_factory): """The injected PAT snapshot must override pat_storage.load_all_pats().""" evaluation = miner_eval_factory(uid=_DEV_UID) diff --git a/tests/validator/test_blend_emission_pools.py b/tests/validator/test_blend_emission_pools.py index ddb05330..d2c9014b 100644 --- a/tests/validator/test_blend_emission_pools.py +++ b/tests/validator/test_blend_emission_pools.py @@ -21,7 +21,7 @@ RECYCLE_UID, ) from gittensor.utils.mirror.models import MirrorPullRequest, MirrorReviewSummary -from gittensor.validator.emission_allocation import blend_emission_pools +from gittensor.validator.emission_allocation import blend_emission_pools, calculate_repo_emission_breakdown from gittensor.validator.oss_contributions.mirror.scored_pr import ScoredPR from gittensor.validator.utils.load_weights import RepositoryConfig, load_master_repo_weights @@ -319,6 +319,48 @@ def test_share_one_with_only_pr_data_recycles_not_spills(self): assert rewards[_idx(miner_uids, RECYCLE_UID)] == pytest.approx(OSS_EMISSION_SHARE) +class TestAllocationBreakdown: + def test_breakdown_exposes_repo_pr_and_issue_rewards(self): + repos = {'r/both': _config(emission_share=0.1, issue_discovery_share=0.4)} + miner_uids = _uids(1, 2) + evaluations = { + 1: _evaluation(1, prs=[_scored_pr('r/both', 100, earned_score=10.0)]), + 2: _evaluation(2, issues=[_discovered_issue('r/both', 10, earned_score=20.0)]), + } + + rows = list(calculate_repo_emission_breakdown(evaluations, repos, miner_uids)) + + assert len(rows) == 1 + row = rows[0] + repo_slice = 0.1 * OSS_EMISSION_SHARE + assert row.repository_full_name == 'r/both' + assert row.emission_share == pytest.approx(0.1) + assert row.issue_discovery_share == pytest.approx(0.4) + assert row.repo_slice == pytest.approx(repo_slice) + assert row.pr_slice == pytest.approx(repo_slice * 0.6) + assert row.issue_discovery_slice == pytest.approx(repo_slice * 0.4) + assert row.pr_scores[1] == pytest.approx(10.0) + assert row.issue_discovery_scores[2] == pytest.approx(20.0) + assert row.pr_rewards[1] == pytest.approx(repo_slice * 0.6) + assert row.issue_discovery_rewards[2] == pytest.approx(repo_slice * 0.4) + assert row.recycled_amount == pytest.approx(0.0) + + def test_breakdown_keeps_raw_score_when_configured_side_recycles(self): + repos = {'r/issue-only': _config(emission_share=0.2, issue_discovery_share=1.0)} + miner_uids = _uids(1) + evaluations = {1: _evaluation(1, prs=[_scored_pr('r/issue-only', 100, earned_score=50.0)])} + + rows = list(calculate_repo_emission_breakdown(evaluations, repos, miner_uids)) + + assert len(rows) == 1 + row = rows[0] + assert row.pr_scores[1] == pytest.approx(50.0) + assert row.issue_discovery_scores == {} + assert row.pr_rewards == {} + assert row.issue_discovery_rewards == {} + assert row.recycled_amount == pytest.approx(0.2 * OSS_EMISSION_SHARE) + + class TestPreservedCompatibility: def test_live_registry_load_then_allocate(self): repos = load_master_repo_weights()