Skip to content
Closed
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
4 changes: 3 additions & 1 deletion gittensor/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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


Expand Down
2 changes: 1 addition & 1 deletion gittensor/cli/miner_commands/score.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 5 additions & 7 deletions gittensor/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
108 changes: 108 additions & 0 deletions gittensor/validator/emission_allocation.py
Original file line number Diff line number Diff line change
@@ -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
65 changes: 28 additions & 37 deletions gittensor/validator/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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
Expand All @@ -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
8 changes: 5 additions & 3 deletions gittensor/validator/issue_discovery/scan.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@
LanguageConfig,
RepositoryConfig,
TokenConfig,
resolve_repo_weight,
)


Expand Down Expand Up @@ -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:
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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) | '
Expand Down Expand Up @@ -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),
Expand Down
1 change: 0 additions & 1 deletion gittensor/validator/oss_contributions/mirror/scored_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 2 additions & 5 deletions gittensor/validator/oss_contributions/mirror/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``
Expand Down Expand Up @@ -54,7 +54,6 @@
LanguageConfig,
RepositoryConfig,
TokenConfig,
resolve_repo_weight,
)
from gittensor.validator.utils.tree_sitter_scoring import calculate_token_score_from_file_changes

Expand Down Expand Up @@ -338,16 +337,14 @@ 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.
"""
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
Expand Down
Loading