Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 79 additions & 39 deletions gittensor/validator/emission_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,53 +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."""
"""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():
repo_slice = repo_config.emission_share * OSS_EMISSION_SHARE
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
Expand All @@ -136,20 +163,33 @@ 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 _repo_has_scorers(
miner_evaluations: Dict[int, MinerEvaluation],
repo_name: str,
repo_config: RepositoryConfig,
miner_uids: set[int],
) -> bool:
"""True when the repo has a scorer on a side that pays out (mirrors the split gates).

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.
"""
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(
scores: Dict[int, float],
allocation: float,
Expand Down
69 changes: 63 additions & 6 deletions tests/validator/test_blend_emission_pools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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:
Expand Down Expand Up @@ -633,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)
Loading