Skip to content
Open
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
78 changes: 76 additions & 2 deletions gittensor/cli/miner_commands/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -308,14 +375,21 @@ 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,
'miner_evaluation': _serialize_evaluation(miner_evaluations[_DEV_UID]),
'rewards': {
'blended_final': _round(float(rewards[0])),
},
'allocation_breakdown': _serialize_allocation_breakdown(allocation_breakdown, _DEV_UID),
}

if not json_mode:
Expand Down
182 changes: 122 additions & 60 deletions gittensor/validator/emission_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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],
Expand All @@ -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(
Expand Down Expand Up @@ -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
Loading
Loading