From 0db8db94279f8e2ffc315337334dc527f9c0ac65 Mon Sep 17 00:00:00 2001 From: Landyn Date: Thu, 25 Jun 2026 14:00:45 -0500 Subject: [PATCH 1/2] feat(validator): redistribute ineligible repo emissions to eligible repos Ineligible repo slices (no maintainer, no scorers, no contributions) recycled to UID 0, burning emissions. Renormalize configured emission_share across only repos that pay a miner this round so the full configured pool is always released; registry slack (configured shares < 1.0) still recycles. --- gittensor/validator/emission_allocation.py | 46 +++++++++++++++++++- tests/validator/test_blend_emission_pools.py | 32 +++++++++++--- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/gittensor/validator/emission_allocation.py b/gittensor/validator/emission_allocation.py index 6bf532bf..fa0a4559 100644 --- a/gittensor/validator/emission_allocation.py +++ b/gittensor/validator/emission_allocation.py @@ -82,8 +82,18 @@ def calculate_repo_emission_breakdown( maintainer_uids_by_repo: Optional[Dict[str, list[int]]] = None, ) -> Iterator[RepoEmissionAllocation]: """Return per-repository reward allocation details without adding treasury/slack.""" + eligible_shares = _eligible_repo_shares(miner_evaluations, master_repositories, miner_uids, maintainer_uids_by_repo) + configured = sum(c.emission_share for c in master_repositories.values() if c.emission_share > 0) + eligible_total = sum(eligible_shares.values()) + # Redistribute whole ineligible repo slices across eligible repos by weight so the + # configured pool is always released. Registry slack (configured < 1.0) is untouched. + redistribute = 0 < eligible_total < configured + multiplier = configured / eligible_total if redistribute else 1.0 + for repo_name, repo_config in master_repositories.items(): - repo_slice = repo_config.emission_share * OSS_EMISSION_SHARE + if redistribute and repo_name not in eligible_shares: + continue + repo_slice = repo_config.emission_share * OSS_EMISSION_SHARE * multiplier if repo_slice <= 0: continue @@ -150,6 +160,40 @@ def calculate_repo_emission_breakdown( yield allocation +def _eligible_repo_shares( + miner_evaluations: Dict[int, MinerEvaluation], + master_repositories: Dict[str, RepositoryConfig], + miner_uids: set[int], + maintainer_uids_by_repo: Optional[Dict[str, list[int]]] = None, +) -> Dict[str, float]: + """Map of repo -> emission_share for repos that pay at least one miner this round. + + Mirrors the distribution gates below: PR scorers count only when ``issue_discovery_share + < 1.0``, issue scorers only when it is ``> 0.0``, and a maintainer carve-out counts only + when ``maintainer_cut > 0`` and a listed maintainer is registered. + """ + maintainer_map = maintainer_uids_by_repo or {} + eligible: Dict[str, float] = {} + for repo_name, repo_config in master_repositories.items(): + if repo_config.emission_share <= 0: + continue + issue_share = repo_config.issue_discovery_share + pays = ( + (issue_share < 1.0 and bool(_collect_repo_pr_scores(miner_evaluations, repo_name, miner_uids))) + or ( + issue_share > 0.0 + and bool(_collect_repo_issue_discovery_scores(miner_evaluations, repo_name, miner_uids)) + ) + or ( + repo_config.maintainer_cut > 0.0 + and any(uid in miner_uids for uid in (maintainer_map.get(repo_name) or [])) + ) + ) + if pays: + eligible[repo_name] = repo_config.emission_share + return eligible + + def _calculate_score_rewards( scores: Dict[int, float], allocation: float, diff --git a/tests/validator/test_blend_emission_pools.py b/tests/validator/test_blend_emission_pools.py index 06f0f405..ba5b56b4 100644 --- a/tests/validator/test_blend_emission_pools.py +++ b/tests/validator/test_blend_emission_pools.py @@ -4,9 +4,9 @@ """Regression tests for repo-bounded round allocation. These cover the allocation scenarios required by issue #1215: repo slices cap -PR throughput, empty slices recycle instead of redistributing across repos, -PR/issue sub-slices spill only within a repo, registry slack recycles, and the -fixed recycle baseline is gone. +PR throughput, ineligible/empty slices redistribute across eligible repos by +weight to fill the configured pool, PR/issue sub-slices spill only within a repo, +registry slack recycles, and the fixed recycle baseline is gone. """ from datetime import datetime, timezone @@ -216,7 +216,7 @@ def test_repo_pr_allocation_clamps_collateral_adjusted_score_at_zero(self): class TestCrossRepoIsolation: - def test_empty_repo_slice_recycles_without_redistribution(self): + def test_empty_repo_slice_redistributes_to_eligible_repo(self): repos = { 'r/active': _config(emission_share=0.4, issue_discovery_share=0.0), 'r/empty': _config(emission_share=0.6, issue_discovery_share=0.0), @@ -226,8 +226,28 @@ def test_empty_repo_slice_recycles_without_redistribution(self): rewards = blend_emission_pools(evaluations, repos, miner_uids) - assert rewards[_idx(miner_uids, 1)] == pytest.approx(0.4 * OSS_EMISSION_SHARE) - assert rewards[_idx(miner_uids, RECYCLE_UID)] == pytest.approx(0.6 * OSS_EMISSION_SHARE) + assert rewards[_idx(miner_uids, 1)] == pytest.approx(OSS_EMISSION_SHARE) + assert rewards[_idx(miner_uids, RECYCLE_UID)] == pytest.approx(0.0) + + def test_empty_repo_slice_redistributes_by_eligible_weight(self): + repos = { + 'r/a': _config(emission_share=0.3, issue_discovery_share=0.0), + 'r/b': _config(emission_share=0.1, issue_discovery_share=0.0), + 'r/empty': _config(emission_share=0.6, issue_discovery_share=0.0), + } + miner_uids = _uids(1, 2) + evaluations = { + 1: _evaluation(1, prs=[_scored_pr('r/a', 100, earned_score=10.0)]), + 2: _evaluation(2, prs=[_scored_pr('r/b', 200, earned_score=10.0)]), + } + + rewards = blend_emission_pools(evaluations, repos, miner_uids) + + # r/empty's 0.6 is split across the eligible repos by their relative weight + # (0.3 : 0.1), preserving the 3:1 ratio while filling the full OSS pool. + assert rewards[_idx(miner_uids, 1)] == pytest.approx(0.75 * OSS_EMISSION_SHARE) + assert rewards[_idx(miner_uids, 2)] == pytest.approx(0.25 * OSS_EMISSION_SHARE) + assert rewards[_idx(miner_uids, RECYCLE_UID)] == pytest.approx(0.0) class TestFailedEvaluationFiltering: From c249c6241587078736dc8976ba7b3ebfd9aa45da Mon Sep 17 00:00:00 2001 From: Landyn Date: Thu, 25 Jun 2026 17:03:50 -0500 Subject: [PATCH 2/2] fix(validator): split maintainer cut from scoring pool in redistribution Address review: pay the maintainer cut at the repo's base rate (never scaled by redistribution) and redistribute only the scoring pool across repos with actual PR/issue scorers, weighted by post-cut scoring share. A maintainer-only repo's miner half now flows to active scorers instead of re-burning, and a maintainer's take no longer inflates when other repos go dead. Maintainer presence no longer marks a repo active. With no active repos the scoring pool recycles as before. --- gittensor/validator/emission_allocation.py | 148 +++++++++---------- tests/validator/test_blend_emission_pools.py | 37 +++++ 2 files changed, 109 insertions(+), 76 deletions(-) diff --git a/gittensor/validator/emission_allocation.py b/gittensor/validator/emission_allocation.py index fa0a4559..4df83c2e 100644 --- a/gittensor/validator/emission_allocation.py +++ b/gittensor/validator/emission_allocation.py @@ -81,63 +81,80 @@ def calculate_repo_emission_breakdown( 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.""" - eligible_shares = _eligible_repo_shares(miner_evaluations, master_repositories, miner_uids, maintainer_uids_by_repo) - configured = sum(c.emission_share for c in master_repositories.values() if c.emission_share > 0) - eligible_total = sum(eligible_shares.values()) - # Redistribute whole ineligible repo slices across eligible repos by weight so the - # configured pool is always released. Registry slack (configured < 1.0) is untouched. - redistribute = 0 < eligible_total < configured - multiplier = configured / eligible_total if redistribute else 1.0 + """Return per-repository reward allocation details without adding treasury/slack. + + Two independent piles: the maintainer cut is paid at the repo's *base* rate + (``maintainer_cut * emission_share * OSS``) and is never scaled, so a maintainer's + take cannot be inflated by other repos going dead. Everything else forms a + subnet-wide scoring pool released only to repos with PR/issue scorers this round, + weighted by their post-cut scoring share. Ineligible/empty scoring shares thus flow + to active scorers instead of recycling; with no active repos the scoring pool + recycles. Registry slack (configured shares < 1.0) recycles either way. + """ + maintainer_map = maintainer_uids_by_repo or {} + # Pass 1: classify each repo and size the scoring pool vs the active scoring share. + plans: list[tuple[str, RepositoryConfig, list[int], float, bool]] = [] + total_scoring_share = 0.0 + active_scoring_share = 0.0 for repo_name, repo_config in master_repositories.items(): - if redistribute and repo_name not in eligible_shares: - continue - repo_slice = repo_config.emission_share * OSS_EMISSION_SHARE * multiplier - if repo_slice <= 0: + if repo_config.emission_share <= 0: continue + eligible_maintainers = ( + [uid for uid in (maintainer_map.get(repo_name) or []) if uid in miner_uids] + if repo_config.maintainer_cut > 0.0 + else [] + ) + cut_fraction = repo_config.maintainer_cut if eligible_maintainers else 0.0 + scoring_share = repo_config.emission_share * (1.0 - cut_fraction) + is_active = _repo_has_scorers(miner_evaluations, repo_name, repo_config, miner_uids) + + total_scoring_share += scoring_share + if is_active: + active_scoring_share += scoring_share + plans.append((repo_name, repo_config, eligible_maintainers, scoring_share, is_active)) + + # The scoring pool is released to active repos pro-rata by scoring share; with none + # active the pool recycles (multiplier 0 routes each repo's scoring share to recycle). + scoring_multiplier = total_scoring_share / active_scoring_share if active_scoring_share > 0 else 0.0 + # Pass 2: emit per-repo allocations. + for repo_name, repo_config, eligible_maintainers, scoring_share, is_active in plans: 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, + repo_slice=repo_config.emission_share * OSS_EMISSION_SHARE, 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 - 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 + # Maintainer pile: base-rate carve-out split evenly among registered maintainers. + if eligible_maintainers: + carve_out = repo_config.maintainer_cut * repo_config.emission_share * OSS_EMISSION_SHARE + per_maintainer = carve_out / len(eligible_maintainers) + allocation.maintainer_carve_out = carve_out + allocation.maintainer_rewards = {uid: per_maintainer for uid in eligible_maintainers} - issue_share = repo_config.issue_discovery_share - 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 = _collect_repo_pr_scores(miner_evaluations, repo_name, miner_uids) + allocation.issue_discovery_scores = _collect_repo_issue_discovery_scores( + miner_evaluations, repo_name, miner_uids + ) - allocation.pr_scores = raw_pr_scores - allocation.issue_discovery_scores = raw_issue_scores + if not is_active: + # Inactive repo's scoring share is redistributed to active repos; it only + # recycles when nothing is active anywhere. + if active_scoring_share <= 0: + allocation.recycled_amount += scoring_share * OSS_EMISSION_SHARE + yield allocation + continue + scoring_slice = scoring_share * OSS_EMISSION_SHARE * scoring_multiplier + issue_share = repo_config.issue_discovery_share + pr_scores = allocation.pr_scores if issue_share < 1.0 else {} + issue_scores = allocation.issue_discovery_scores if issue_share > 0.0 else {} pr_total = sum(pr_scores.values()) issue_total = sum(issue_scores.values()) - if pr_total <= 0 and issue_total <= 0: - allocation.recycled_amount += scoring_slice - yield allocation - continue - if pr_total > 0 and issue_total > 0: allocation.pr_slice = scoring_slice * (1.0 - issue_share) allocation.issue_discovery_slice = scoring_slice * issue_share @@ -146,52 +163,31 @@ def calculate_repo_emission_breakdown( else: allocation.issue_discovery_slice = scoring_slice - allocation.pr_rewards, pr_unallocated = _calculate_score_rewards( - pr_scores, - allocation.pr_slice, - miner_uids, - ) + 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, + issue_scores, allocation.issue_discovery_slice, miner_uids ) allocation.recycled_amount += pr_unallocated + issue_unallocated yield allocation -def _eligible_repo_shares( +def _repo_has_scorers( miner_evaluations: Dict[int, MinerEvaluation], - master_repositories: Dict[str, RepositoryConfig], + repo_name: str, + repo_config: RepositoryConfig, miner_uids: set[int], - maintainer_uids_by_repo: Optional[Dict[str, list[int]]] = None, -) -> Dict[str, float]: - """Map of repo -> emission_share for repos that pay at least one miner this round. +) -> bool: + """True when the repo has a scorer on a side that pays out (mirrors the split gates). - Mirrors the distribution gates below: PR scorers count only when ``issue_discovery_share - < 1.0``, issue scorers only when it is ``> 0.0``, and a maintainer carve-out counts only - when ``maintainer_cut > 0`` and a listed maintainer is registered. + Maintainer presence alone does NOT make a repo active: a maintainer-only repo pays + its base-rate cut but its scoring share is redistributed to repos that did work. """ - maintainer_map = maintainer_uids_by_repo or {} - eligible: Dict[str, float] = {} - for repo_name, repo_config in master_repositories.items(): - if repo_config.emission_share <= 0: - continue - issue_share = repo_config.issue_discovery_share - pays = ( - (issue_share < 1.0 and bool(_collect_repo_pr_scores(miner_evaluations, repo_name, miner_uids))) - or ( - issue_share > 0.0 - and bool(_collect_repo_issue_discovery_scores(miner_evaluations, repo_name, miner_uids)) - ) - or ( - repo_config.maintainer_cut > 0.0 - and any(uid in miner_uids for uid in (maintainer_map.get(repo_name) or [])) - ) - ) - if pays: - eligible[repo_name] = repo_config.emission_share - return eligible + issue_share = repo_config.issue_discovery_share + if issue_share < 1.0 and _collect_repo_pr_scores(miner_evaluations, repo_name, miner_uids): + return True + if issue_share > 0.0 and _collect_repo_issue_discovery_scores(miner_evaluations, repo_name, miner_uids): + return True + return False def _calculate_score_rewards( diff --git a/tests/validator/test_blend_emission_pools.py b/tests/validator/test_blend_emission_pools.py index ba5b56b4..258b2614 100644 --- a/tests/validator/test_blend_emission_pools.py +++ b/tests/validator/test_blend_emission_pools.py @@ -653,3 +653,40 @@ def test_round_total_sums_to_one_with_carve_out(self): rewards = blend_emission_pools(evaluations, repos, miner_uids, {'r/a': [3]}) assert float(rewards.sum()) == pytest.approx(1.0) + + def test_maintainer_cut_not_inflated_by_redistribution(self): + # Maintainer-only repo (no scorers) coexisting with an active repo and a dead + # repo: the maintainer is paid the base-rate cut and nothing more, while the + # maintainer-only repo's miner half flows to the active scorer. Zero burn. + repos = { + 'r/maint': _config(emission_share=0.4, issue_discovery_share=0.0, maintainer_cut=0.5), + 'r/active': _config(emission_share=0.4, issue_discovery_share=0.0), + 'r/dead': _config(emission_share=0.2, issue_discovery_share=0.0), + } + miner_uids = _uids(1, 2) + evaluations = { + 1: _evaluation(1), + 2: _evaluation(2, prs=[_scored_pr('r/active', 100, earned_score=10.0)]), + } + + rewards = blend_emission_pools(evaluations, repos, miner_uids, {'r/maint': [1]}) + + # Maintainer: base rate 0.5 * 0.4, NOT scaled by the 2x scoring multiplier. + assert rewards[_idx(miner_uids, 1)] == pytest.approx(0.5 * 0.4 * OSS_EMISSION_SHARE) + # Active scorer absorbs the entire scoring pool (its own + maint half + dead). + assert rewards[_idx(miner_uids, 2)] == pytest.approx(0.8 * OSS_EMISSION_SHARE) + assert rewards[_idx(miner_uids, RECYCLE_UID)] == pytest.approx(0.0) + + def test_maintainer_only_repo_recycles_scoring_when_nothing_active(self): + # No active scorers anywhere: maintainer still paid base rate, scoring recycles. + repos = { + 'r/maint': _config(emission_share=0.4, issue_discovery_share=0.0, maintainer_cut=0.5), + 'r/dead': _config(emission_share=0.6, issue_discovery_share=0.0), + } + miner_uids = _uids(1) + evaluations = {1: _evaluation(1)} + + rewards = blend_emission_pools(evaluations, repos, miner_uids, {'r/maint': [1]}) + + assert rewards[_idx(miner_uids, 1)] == pytest.approx(0.5 * 0.4 * OSS_EMISSION_SHARE) + assert rewards[_idx(miner_uids, RECYCLE_UID)] == pytest.approx(0.8 * OSS_EMISSION_SHARE)