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
29 changes: 22 additions & 7 deletions gittensor/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import bittensor as bt

from gittensor.constants import MIN_TOKEN_SCORE_FOR_BASE_SCORE
from gittensor.utils.utils import parse_repo_name
from gittensor.validator.configurations.tier_config import Tier, TierConfig, TierStats

Expand Down Expand Up @@ -156,7 +157,8 @@ class PullRequest:
base_score: float = 0.0
issue_multiplier: float = 1.0
open_pr_spam_multiplier: float = 1.0
repository_uniqueness_multiplier: float = 1.0
pioneer_dividend: float = 0.0 # Additive bonus for pioneering a repo
pioneer_rank: int = 0 # 0 = not eligible, 1 = pioneer, 2+ = follower position
time_decay_multiplier: float = 1.0
credibility_multiplier: float = 1.0
raw_credibility: float = 1.0 # Before applying ^k scalar
Expand Down Expand Up @@ -188,24 +190,37 @@ def set_file_changes(self, file_changes: List[FileChange]) -> None:
"""Set the file changes for this pull request"""
self.file_changes = file_changes

def is_pioneer_eligible(self) -> bool:
"""Check if this PR qualifies for pioneer consideration.

A PR is eligible if it is merged, has a tier configuration,
and meets the minimum token score quality gate.
"""
return (
self.repository_tier_configuration is not None
and self.merged_at is not None
and self.token_score >= MIN_TOKEN_SCORE_FOR_BASE_SCORE
)

def calculate_final_earned_score(self) -> float:
"""Combine base score with all multipliers."""
"""Combine base score with all multipliers. Pioneer dividend is added separately after."""
multipliers = {
'repo': self.repo_weight_multiplier,
'issue': self.issue_multiplier,
'spam': self.open_pr_spam_multiplier,
'unique': self.repository_uniqueness_multiplier,
'decay': self.time_decay_multiplier,
'cred': self.credibility_multiplier,
}

self.earned_score = self.base_score * prod(multipliers.values())

# Log all multipliers (credibility shows ^k format)
mult_str = ' × '.join(
f'cred={self.raw_credibility:.2f}^{self.credibility_scalar}' if k == 'cred' else f'{k}={v:.2f}'
for k, v in multipliers.items()
)
def _format_multiplier(k: str, v: float) -> str:
if k == 'cred':
return f'cred={self.raw_credibility:.2f}^{self.credibility_scalar}'
return f'{k}={v:.2f}'

mult_str = ' × '.join(_format_multiplier(k, v) for k, v in multipliers.items())
bt.logging.info(
f'├─ {self.pr_state.value} PR #{self.number} ({self.repository_full_name}) → {self.earned_score:.2f}'
)
Expand Down
11 changes: 9 additions & 2 deletions gittensor/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,16 @@
DEFAULT_MAX_CONTRIBUTION_SCORE_FOR_FULL_BONUS = 2000

# Boosts
UNIQUE_PR_BOOST = 0.74
MAX_CODE_DENSITY_MULTIPLIER = 3.0

# Pioneer dividend — rewards the first quality contributor to each repository
# Rates applied per follower position (1st follower pays most, diminishing after)
# Dividend capped at PIONEER_DIVIDEND_MAX_RATIO × pioneer's own earned_score
PIONEER_DIVIDEND_RATE_1ST = 0.30 # 1st follower: 30% of their earned_score
PIONEER_DIVIDEND_RATE_2ND = 0.20 # 2nd follower: 20% of their earned_score
PIONEER_DIVIDEND_RATE_REST = 0.10 # 3rd+ followers: 10% of their earned_score
PIONEER_DIVIDEND_MAX_RATIO = 1.0 # Cap dividend at 1× pioneer's own earned_score (max 2× total)

# Issue boosts
MAX_ISSUE_CLOSE_WINDOW_DAYS = 1
MAX_ISSUE_AGE_FOR_MAX_SCORE = 40 # days
Expand Down Expand Up @@ -112,7 +119,7 @@
# =============================================================================
# Spam & Gaming Mitigation
# =============================================================================
MAINTAINER_ASSOCIATIONS = ['OWNER', 'MEMBER', 'COLLABORATOR']
MAINTAINER_ASSOCIATIONS = ['OWNER', 'COLLABORATOR']

# Issue multiplier bonuses
MAX_ISSUE_AGE_BONUS = 0.75 # Max bonus for issue age (scales with sqrt of days open)
Expand Down
37 changes: 30 additions & 7 deletions gittensor/utils/github_api_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,18 +268,41 @@ def get_pull_request_file_changes(repository: str, pr_number: int, token: str) -
last_error = None
for attempt in range(max_attempts):
try:
response = requests.get(
f'{BASE_GITHUB_API_URL}/repos/{repository}/pulls/{pr_number}/files', headers=headers, timeout=15
)
if response.status_code == 200:
all_file_diffs: List[Dict[str, Any]] = []
page = 1
per_page = 100

while True:
response = requests.get(
f'{BASE_GITHUB_API_URL}/repos/{repository}/pulls/{pr_number}/files',
headers=headers,
params={'per_page': str(per_page), 'page': str(page)},
timeout=15,
)
if response.status_code != 200:
last_error = f'status {response.status_code}'
break

file_diffs = response.json()
return [FileChange.from_github_response(pr_number, repository, file_diff) for file_diff in file_diffs]
if not isinstance(file_diffs, list):
last_error = 'invalid JSON payload for files list'
break

all_file_diffs.extend(file_diffs)

# No more pages.
if len(file_diffs) < per_page:
return [
FileChange.from_github_response(pr_number, repository, file_diff)
for file_diff in all_file_diffs
]

page += 1

last_error = f'status {response.status_code}'
if attempt < max_attempts - 1:
backoff_delay = min(5 * (2**attempt), 30)
bt.logging.warning(
f'File changes request for PR #{pr_number} in {repository} failed with status {response.status_code} '
f'File changes request for PR #{pr_number} in {repository} failed ({last_error}) '
f'(attempt {attempt + 1}/{max_attempts}), retrying in {backoff_delay}s...'
)
time.sleep(backoff_delay)
Expand Down
2 changes: 1 addition & 1 deletion gittensor/validator/evaluation/reward.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ async def get_rewards(
# Adjust scores for duplicate accounts
detect_and_penalize_miners_sharing_github(miner_evaluations)

# Finalize scores: apply unique contribution multiplier, credibility, sum totals, deduct collateral
# Finalize scores: apply pioneer dividends, credibility, sum totals, deduct collateral
finalize_miner_scores(miner_evaluations)

# Allocate emissions by tier: replace total_score with tier-weighted allocations
Expand Down
145 changes: 100 additions & 45 deletions gittensor/validator/evaluation/scoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import math
from datetime import datetime, timezone
from typing import Dict, Optional
from typing import Dict, Optional, Tuple

import bittensor as bt

Expand All @@ -20,13 +20,16 @@
MAX_OPEN_PR_THRESHOLD,
MIN_TOKEN_SCORE_FOR_BASE_SCORE,
OPEN_PR_THRESHOLD_TOKEN_SCORE,
PIONEER_DIVIDEND_MAX_RATIO,
PIONEER_DIVIDEND_RATE_1ST,
PIONEER_DIVIDEND_RATE_2ND,
PIONEER_DIVIDEND_RATE_REST,
SECONDS_PER_DAY,
SECONDS_PER_HOUR,
TIME_DECAY_GRACE_PERIOD_HOURS,
TIME_DECAY_MIN_MULTIPLIER,
TIME_DECAY_SIGMOID_MIDPOINT,
TIME_DECAY_SIGMOID_STEEPNESS_SCALAR,
UNIQUE_PR_BOOST,
)
from gittensor.utils.github_api_tools import (
FileContentPair,
Expand Down Expand Up @@ -226,27 +229,6 @@ def calculate_pr_multipliers(
pr.credibility_multiplier = 1.0


def count_repository_contributors(miner_evaluations: Dict[int, MinerEvaluation]) -> Dict[str, int]:
"""
Count how many miners contribute to each repository and log statistics.

Returns:
Dict[str, int]: Dictionary mapping repository names to contributor counts
"""
repo_counts: Dict[str, int] = {}

for evaluation in miner_evaluations.values():
for repo in evaluation.unique_repos_contributed_to:
repo_counts[repo] = repo_counts.get(repo, 0) + 1

if repo_counts:
bt.logging.info(f'Repository contribution counts: {len(repo_counts)} total repositories')
for repo, count in sorted(repo_counts.items(), key=lambda x: -x[1]):
bt.logging.info(f'{repo}: {count}')

return repo_counts


def calculate_open_pr_threshold(
tier_stats: Dict[Tier, TierStats] = None,
) -> int:
Expand Down Expand Up @@ -304,13 +286,85 @@ def calculate_time_decay_multiplier(pr: PullRequest) -> float:
return max(sigmoid, TIME_DECAY_MIN_MULTIPLIER)


def calculate_pioneer_dividends(
miner_evaluations: Dict[int, MinerEvaluation],
) -> None:
"""Determine pioneers and set pioneer_rank + pioneer_dividend on each PR.

For each repo, the pioneer is the miner with the earliest merged PR that
passes the quality gate (is_pioneer_eligible). The pioneer's earliest PR
on that repo earns a dividend based on ALL followers' earned_scores (post-
multiplier), using per-position rates (30%/20%/10%). The dividend uses the
follower's multipliers, not the pioneer's — so it reflects follower quality.

Must be called AFTER all earned_scores have been computed.
"""
# Build index: (repo, uid) -> eligible PRs, and per-repo aggregates for ordering
pr_index: Dict[str, Dict[int, list]] = {} # repo -> {uid: [eligible PRs]}
repo_contributions: Dict[str, Dict[int, Tuple[datetime, int, float]]] = {}

for evaluation in miner_evaluations.values():
for pr in evaluation.merged_pull_requests:
if not pr.is_pioneer_eligible():
continue
repo = pr.repository_full_name
pr_index.setdefault(repo, {}).setdefault(pr.uid, []).append(pr)

current = repo_contributions.setdefault(repo, {}).get(pr.uid)
if current is None:
repo_contributions[repo][pr.uid] = (pr.merged_at, pr.number, pr.earned_score)
else:
earliest_at, earliest_num, total_score = current
new_total = total_score + pr.earned_score
if pr.merged_at < earliest_at or (pr.merged_at == earliest_at and pr.number < earliest_num):
repo_contributions[repo][pr.uid] = (pr.merged_at, pr.number, new_total)
else:
repo_contributions[repo][pr.uid] = (earliest_at, earliest_num, new_total)

# For each repo: rank contributors, calculate dividend, apply to pioneer PR
for repo, uid_entries in repo_contributions.items():
sorted_uids = sorted(uid_entries.items(), key=lambda x: (x[1][0], x[1][1]))

# Set pioneer_rank via index lookup (no full evaluation scan)
for rank_pos, (uid, _) in enumerate(sorted_uids):
for pr in pr_index[repo][uid]:
pr.pioneer_rank = rank_pos + 1

# Calculate dividend from followers' earned_scores
dividend = 0.0
for pos, (_, entry) in enumerate(sorted_uids[1:]):
follower_earned = entry[2]
if pos == 0:
dividend += follower_earned * PIONEER_DIVIDEND_RATE_1ST
elif pos == 1:
dividend += follower_earned * PIONEER_DIVIDEND_RATE_2ND
else:
dividend += follower_earned * PIONEER_DIVIDEND_RATE_REST

if dividend <= 0:
continue

# Find pioneer's earliest PR via index and apply capped dividend
pioneer_uid = sorted_uids[0][0]
pioneer_pr_number = sorted_uids[0][1][1]
pioneer_pr = next(pr for pr in pr_index[repo][pioneer_uid] if pr.number == pioneer_pr_number)
max_dividend = pioneer_pr.earned_score * PIONEER_DIVIDEND_MAX_RATIO
capped = min(dividend, max_dividend)
pioneer_pr.pioneer_dividend = round(capped, 2)
pioneer_pr.earned_score += pioneer_pr.pioneer_dividend

cap_note = f' (capped from {dividend:.2f})' if capped < dividend else ''
bt.logging.info(
f'Pioneer dividend | repo={repo} pioneer=uid {pioneer_uid} '
f'followers={len(sorted_uids) - 1} dividend={capped:.2f}{cap_note}'
)


def finalize_miner_scores(miner_evaluations: Dict[int, MinerEvaluation]) -> None:
"""Finalize all miner scores: apply uniqueness multipliers, calculate totals, and deduct collateral."""
"""Finalize all miner scores: compute earned_scores, then apply pioneer dividends, then collateral."""
bt.logging.info('**Finalizing miner scores**')

repo_counts = count_repository_contributors(miner_evaluations)
total_contributing_miners = sum(1 for ev in miner_evaluations.values() if ev.unique_repos_contributed_to)

# Phase 1: Compute all earned_scores (base × multipliers) for every miner
for uid, evaluation in miner_evaluations.items():
if not evaluation:
continue
Expand Down Expand Up @@ -348,10 +402,6 @@ def finalize_miner_scores(miner_evaluations: Dict[int, MinerEvaluation]) -> None

# Process merged PRs
for pr in evaluation.merged_pull_requests:
pr.repository_uniqueness_multiplier = calculate_uniqueness_multiplier(
pr.repository_full_name, repo_counts, total_contributing_miners
)

# Apply spam multiplier (calculated once per miner based on unlocked tiers)
pr.open_pr_spam_multiplier = spam_multiplier

Expand All @@ -364,9 +414,6 @@ def finalize_miner_scores(miner_evaluations: Dict[int, MinerEvaluation]) -> None
pr.credibility_multiplier = round(credibility**tier_config.credibility_scalar, 2)

pr.calculate_final_earned_score()
evaluation.base_total_score += pr.base_score
evaluation.total_score += pr.earned_score
evaluation.total_nodes_scored += pr.total_nodes_scored

# Aggregate token scoring breakdown
evaluation.total_token_score += pr.token_score
Expand All @@ -375,6 +422,25 @@ def finalize_miner_scores(miner_evaluations: Dict[int, MinerEvaluation]) -> None
evaluation.total_leaf_count += pr.leaf_count
evaluation.total_leaf_score += pr.leaf_score

# Phase 2: Calculate pioneer dividends from follower earned_scores
# Must happen after Phase 1 so all earned_scores are available
calculate_pioneer_dividends(miner_evaluations)

# Phase 3: Aggregate totals (including dividends), collateral, tier stats, logging
for uid, evaluation in miner_evaluations.items():
if not evaluation:
continue

has_contributions = len(evaluation.merged_pull_requests) > 0 or len(evaluation.closed_pull_requests) > 0
if not has_contributions:
continue

# Aggregate scores (earned_score now includes pioneer_dividend from Phase 2)
for pr in evaluation.merged_pull_requests:
evaluation.base_total_score += pr.base_score
evaluation.total_score += pr.earned_score
evaluation.total_nodes_scored += pr.total_nodes_scored

# Apply collateral deduction (0 - 0 = 0 for empty miners)
earned_score = evaluation.total_score
evaluation.total_score = max(0.0, earned_score - evaluation.total_collateral_score)
Expand Down Expand Up @@ -431,17 +497,6 @@ def finalize_miner_scores(miner_evaluations: Dict[int, MinerEvaluation]) -> None
bt.logging.info('Finalization complete.')


def calculate_uniqueness_multiplier(
repo_full_name: str, repo_counts: Dict[str, int], total_contributing_miners: int
) -> float:
"""Calculate repository uniqueness multiplier based on how many miners contribute to a repo."""
if total_contributing_miners == 0:
return 1.0
repo_count = repo_counts.get(repo_full_name, 0)
uniqueness_score = (total_contributing_miners - repo_count + 1) / total_contributing_miners
return 1.0 + (uniqueness_score * UNIQUE_PR_BOOST)


def calculate_issue_multiplier(pr: PullRequest) -> float:
"""
Calculate PR score multiplier based on the first valid linked issue's age.
Expand Down
5 changes: 3 additions & 2 deletions gittensor/validator/storage/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
number, repository_full_name, uid, hotkey, github_id, title, author_login,
merged_at, pr_created_at, pr_state,
repo_weight_multiplier, base_score, issue_multiplier,
open_pr_spam_multiplier, repository_uniqueness_multiplier, time_decay_multiplier,
open_pr_spam_multiplier, pioneer_dividend, pioneer_rank, time_decay_multiplier,
credibility_multiplier, raw_credibility, credibility_scalar,
earned_score, collateral_score,
additions, deletions, commits, total_nodes_scored,
Expand All @@ -56,7 +56,8 @@
base_score = EXCLUDED.base_score,
issue_multiplier = EXCLUDED.issue_multiplier,
open_pr_spam_multiplier = EXCLUDED.open_pr_spam_multiplier,
repository_uniqueness_multiplier = EXCLUDED.repository_uniqueness_multiplier,
pioneer_dividend = EXCLUDED.pioneer_dividend,
pioneer_rank = EXCLUDED.pioneer_rank,
time_decay_multiplier = EXCLUDED.time_decay_multiplier,
credibility_multiplier = EXCLUDED.credibility_multiplier,
raw_credibility = EXCLUDED.raw_credibility,
Expand Down
3 changes: 2 additions & 1 deletion gittensor/validator/storage/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ def store_pull_requests_bulk(self, pull_requests: List[PullRequest]) -> int:
pr.base_score,
pr.issue_multiplier,
pr.open_pr_spam_multiplier,
pr.repository_uniqueness_multiplier,
pr.pioneer_dividend,
pr.pioneer_rank,
pr.time_decay_multiplier,
pr.credibility_multiplier,
pr.raw_credibility,
Expand Down
Loading