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
17 changes: 13 additions & 4 deletions gittensor/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime, timezone
from enum import Enum
from math import prod
from typing import TYPE_CHECKING, DefaultDict, Dict, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Any, DefaultDict, Dict, List, Optional, Set, Tuple

import bittensor as bt

Expand Down 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 # current mirror-tracked open issues (set by issue_discovery.scan)
discovered_issues: List[Issue] = field(default_factory=list)

@property
def total_prs(self) -> int:
Expand Down Expand Up @@ -505,6 +505,7 @@ class CachedEvaluation:
'total_valid_solved_issues',
'total_closed_issues',
'total_open_issues',
'discovered_issues',
)


Expand Down Expand Up @@ -547,7 +548,7 @@ def store(self, evaluation: 'MinerEvaluation') -> None:
existing = self._cache.get(evaluation.uid)
if existing is not None and existing.hotkey == evaluation.hotkey and existing.github_id == evaluation.github_id:
for name in _ISSUE_DISCOVERY_FIELDS:
setattr(cached_eval, name, getattr(existing.evaluation, name))
setattr(cached_eval, name, _copy_issue_discovery_field(name, getattr(existing.evaluation, name)))

self._cache[evaluation.uid] = CachedEvaluation(
hotkey=evaluation.hotkey,
Expand Down Expand Up @@ -581,7 +582,7 @@ def update_issue_discovery(self, evaluation: 'MinerEvaluation') -> None:
return

for name in _ISSUE_DISCOVERY_FIELDS:
setattr(existing.evaluation, name, getattr(evaluation, name))
setattr(existing.evaluation, name, _copy_issue_discovery_field(name, getattr(evaluation, name)))

bt.logging.debug(f'Refreshed cached issue discovery for UID {evaluation.uid}')

Expand Down Expand Up @@ -627,6 +628,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.copy(issue) for issue in evaluation.discovered_issues]
return cached

@staticmethod
Expand All @@ -636,9 +638,16 @@ 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.copy(issue) for issue in cached_eval.discovered_issues]
return copy_eval


def _copy_issue_discovery_field(name: str, value: Any) -> Any:
if name == 'discovered_issues':
return [copy.copy(issue) for issue in value]
return value


def _scored_mirror_pr_for_cache(scored: 'ScoredPR') -> 'ScoredPR':
scored_copy = copy.copy(scored)
scored_copy.files = None
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 @@ -283,7 +283,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(miner_uids, miner_evaluations, master_repositories)

return {
'success': True,
Expand Down
11 changes: 4 additions & 7 deletions gittensor/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
# =============================================================================
# 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
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 +154,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 splits.
OSS_EMISSION_SHARE = 0.90 # 90% combined scoring pool allocated by repo emission_share

# =============================================================================
# Spam & Gaming Mitigation
Expand Down Expand Up @@ -187,5 +184,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
148 changes: 100 additions & 48 deletions gittensor/validator/forward.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,7 @@
import numpy as np

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.constants import ISSUES_TREASURY_EMISSION_SHARE, ISSUES_TREASURY_UID, OSS_EMISSION_SHARE, RECYCLE_UID
from gittensor.utils.uids import get_all_uids
from gittensor.validator.issue_competitions.forward import issue_competitions
from gittensor.validator.issue_discovery.normalize import (
Expand Down Expand Up @@ -46,13 +39,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 All @@ -62,12 +54,12 @@ async def forward(self: 'Validator') -> None:
token_config = load_token_config()

# 1. Score OSS contributions
oss_rewards, miner_evaluations, cached_uids, penalized_uids = await oss_contributions(
_, miner_evaluations, cached_uids, penalized_uids = await oss_contributions(
self, miner_uids, master_repositories, programming_languages, token_config
)

# 2. Score issue discovery
issue_rewards = await issue_discovery(
await issue_discovery(
miner_evaluations,
master_repositories,
programming_languages,
Expand All @@ -85,8 +77,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(miner_uids, miner_evaluations, master_repositories)

self.update_scores(rewards, miner_uids, blacklisted_uids=sorted(penalized_uids))

Expand Down Expand Up @@ -150,36 +142,20 @@ async def issue_discovery(


def blend_emission_pools(
oss_rewards: np.ndarray,
issue_rewards: np.ndarray,
miner_uids: set[int],
miner_evaluations: Dict[int, MinerEvaluation],
master_repositories: Dict[str, RepositoryConfig],
) -> np.ndarray:
"""Blend 4 emission pools into a single rewards array.
"""Allocate the combined scoring pool by repo emission_share.

- OSS contributions: 30%
- Issue discovery: 30%
- Issue treasury: 15% (flat to UID 111)
- Recycle: 25% (flat to UID 0)
Allocation is repo-first: each active repo receives exactly its configured
slice of ``OSS_EMISSION_SHARE`` and distributes it within the repo by raw score.
"""
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
else:
recycle_extra += OSS_EMISSION_SHARE

# 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)

rewards += _allocate_repo_scoring_pool(sorted_uids, miner_evaluations, master_repositories)

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 +164,87 @@ 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 _allocate_repo_scoring_pool(
sorted_uids: list[int],
miner_evaluations: Dict[int, MinerEvaluation],
master_repositories: Dict[str, RepositoryConfig],
) -> np.ndarray:
rewards = np.zeros(len(sorted_uids))
uid_index = {uid: idx for idx, uid in enumerate(sorted_uids)}
recycle_idx = uid_index.get(RECYCLE_UID)
allocated_share = 0.0

for repo_name, repo_config in master_repositories.items():
repo_key = repo_name.lower()
repo_share = repo_config.emission_share
allocated_share += repo_share
repo_slice = OSS_EMISSION_SHARE * repo_share
if repo_slice <= 0:
continue

pr_scores: dict[int, float] = {}
issue_scores: dict[int, float] = {}
issue_share = repo_config.issue_discovery_share
pr_share = 1.0 - issue_share
for uid, evaluation in miner_evaluations.items():
if uid not in uid_index:
continue
if pr_share > 0:
pr_score = _repo_pr_score(evaluation, repo_key)
if pr_score > 0:
pr_scores[uid] = pr_score
if issue_share > 0:
for issue in evaluation.discovered_issues:
if issue.repository_full_name.lower() == repo_key and issue.discovery_earned_score > 0:
issue_scores[uid] = issue_scores.get(uid, 0.0) + float(issue.discovery_earned_score)

pr_total = sum(pr_scores.values())
issue_total = sum(issue_scores.values())
if pr_total <= 0 and issue_total <= 0:
if recycle_idx is not None:
rewards[recycle_idx] += repo_slice
continue

pr_slice = repo_slice * pr_share
issue_slice = repo_slice * issue_share
if pr_total <= 0:
issue_slice += pr_slice
pr_slice = 0.0
elif issue_total <= 0:
pr_slice += issue_slice
issue_slice = 0.0

_add_proportional_rewards(rewards, uid_index, pr_scores, pr_total, pr_slice)
_add_proportional_rewards(rewards, uid_index, issue_scores, issue_total, issue_slice)

slack_share = max(0.0, 1.0 - allocated_share)
if slack_share > 0 and recycle_idx is not None:
rewards[recycle_idx] += OSS_EMISSION_SHARE * slack_share

return rewards


def _repo_pr_score(evaluation: MinerEvaluation, repo_name: str) -> float:
return sum(
float(pr.earned_score)
for pr in evaluation.merged_prs
if pr.repository_full_name.lower() == repo_name and pr.earned_score > 0
)


def _add_proportional_rewards(
rewards: np.ndarray,
uid_index: dict[int, int],
scores: dict[int, float],
total: float,
amount: float,
) -> None:
if total <= 0 or amount <= 0:
return
for uid, score in scores.items():
idx = uid_index.get(uid)
if idx is not None:
rewards[idx] += amount * score / total
7 changes: 4 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 @@ -450,6 +449,8 @@ async def _score_miner_issues(
evaluation.issue_credibility = credibility

if not is_eligible:
evaluation.discovered_issues = []
evaluation.issue_discovery_score = 0.0
bt.logging.info(
f'├─ UID {evaluation.uid}: ineligible ({reason}) | '
f'{solved_count} solved ({valid_solved_count} valid) | {closed_count} closed | '
Expand All @@ -465,7 +466,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 @@ -474,6 +474,7 @@ async def _score_miner_issues(
)
total_discovery_score += issue.discovery_earned_score

evaluation.discovered_issues = scored_issues
evaluation.issue_discovery_score = round(total_discovery_score, 2)

bt.logging.info(
Expand Down Expand Up @@ -629,7 +630,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: 3 additions & 4 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,15 +337,15 @@ 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)
scored.repo_weight_multiplier = 1.0

chosen_label, label_multiplier = _resolve_trusted_scoring_label(pr, repo_config)
scored.label = chosen_label
Expand Down
Loading