Skip to content

Commit cb28084

Browse files
committed
fix(rewards): allocate emissions by repo share
1 parent 73c9d03 commit cb28084

15 files changed

Lines changed: 469 additions & 91 deletions

File tree

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ Incentivize open source contributions.
1818

1919
## How it Works
2020

21-
Miners register with a fine-grained [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) (PAT) and contribute to whitelisted open source repositories. When their pull requests get merged, validators authenticate account ownership via the PAT, verify the merged contributions, and score them based on code quality, repository weight, and programming language factors. Rewards are distributed proportionally to contribution scores.
21+
Miners register with a fine-grained [GitHub personal access token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) (PAT) and contribute to whitelisted open source repositories. When their pull requests get merged, validators authenticate account ownership via the PAT, verify the merged contributions, and score them based on code quality and programming language factors. Rewards are allocated by each repository's bounded `emission_share`, then distributed proportionally to contribution scores within that repository.
2222

2323
## Why Gittensor
2424

@@ -75,11 +75,11 @@ See full guide **[here](https://docs.gittensor.io/validator.html)**
7575

7676
### Important Structures
7777

78-
- Master Repositories & Weights
78+
- Master Repositories & Emission Shares
7979

80-
A list of repositories pulled from GitHub that have been deemed valid for scoring. They each have an associated weight based on factors like: forks, commits, contributors, stars, etc.
80+
A list of repositories pulled from GitHub that have been deemed valid for scoring. They each have an associated `emission_share` that caps how much of the scoring pool that repository can receive in a round.
8181

82-
_NOTE: this list will be dynamic. It will see various audits, additions, deletions, weight changes, and shuffles as the subnet matures._
82+
_NOTE: this list will be dynamic. It will see various audits, additions, deletions, emission share changes, and shuffles as the subnet matures._
8383

8484
_NOTE: don’t be afraid to provide recommendations for your favorite open source repositories and the team will review it as a possible addition. A repo is more likely to be included if: they provide contributing guidelines, are active/community driven, provide value/have users_
8585

gittensor/classes.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,6 @@ def is_pioneer_eligible(self) -> bool:
234234
def calculate_final_earned_score(self) -> float:
235235
"""Combine base score with all multipliers. Pioneer dividend is added separately after."""
236236
multipliers = {
237-
'repo': self.repo_weight_multiplier,
238237
'issue': self.issue_multiplier,
239238
'label': self.label_multiplier,
240239
'spam': self.open_pr_spam_multiplier,
@@ -289,6 +288,7 @@ class MinerEvaluation:
289288
total_valid_solved_issues: int = 0 # solved issues where solving PR has token_score >= 5
290289
total_closed_issues: int = 0
291290
total_open_issues: int = 0 # mirror-tracked open issues in lookback window (set by issue_discovery.scan)
291+
discovered_issues: List[Issue] = field(default_factory=list)
292292

293293
@property
294294
def total_prs(self) -> int:
@@ -505,6 +505,7 @@ class CachedEvaluation:
505505
'total_valid_solved_issues',
506506
'total_closed_issues',
507507
'total_open_issues',
508+
'discovered_issues',
508509
)
509510

510511

@@ -627,6 +628,7 @@ def _build_cache_entry(evaluation: 'MinerEvaluation') -> 'MinerEvaluation':
627628
cached.merged_prs = [_scored_mirror_pr_for_cache(pr) for pr in evaluation.merged_prs]
628629
cached.open_prs = [_scored_mirror_pr_for_cache(pr) for pr in evaluation.open_prs]
629630
cached.closed_prs = [_scored_mirror_pr_for_cache(pr) for pr in evaluation.closed_prs]
631+
cached.discovered_issues = list(evaluation.discovered_issues)
630632
return cached
631633

632634
@staticmethod
@@ -636,6 +638,7 @@ def _isolate_for_downstream(cached_eval: 'MinerEvaluation') -> 'MinerEvaluation'
636638
# adapters produce fresh Issue objects per call via get_all_issues().
637639
copy_eval = copy.copy(cached_eval)
638640
copy_eval.unique_repos_contributed_to = set(cached_eval.unique_repos_contributed_to)
641+
copy_eval.discovered_issues = list(cached_eval.discovered_issues)
639642
return copy_eval
640643

641644

gittensor/cli/miner_commands/score.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ def store_or_use_cached_evaluation(self, miner_evaluations: Dict) -> Set[int]:
6666
'merged_prs',
6767
'open_prs',
6868
'closed_prs',
69+
'discovered_issues',
6970
'unique_repos_contributed_to',
7071
}
7172
)
@@ -280,7 +281,7 @@ async def _run() -> Dict[str, Any]:
280281
issue_rewards = await issue_discovery(
281282
miner_evaluations, master_repositories, programming_languages, token_config, miner_uids
282283
)
283-
rewards = blend_emission_pools(oss_rewards, issue_rewards, miner_uids)
284+
rewards = blend_emission_pools(miner_evaluations, master_repositories, miner_uids)
284285

285286
return {
286287
'success': True,

gittensor/constants.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
# =============================================================================
7575
# Repository & PR Scoring
7676
# =============================================================================
77-
DEFAULT_REPO_WEIGHT = 0.01 # fallback weight for repos not in master_repositories.json
77+
DEFAULT_REPO_EMISSION_SHARE = 0.01 # fallback share for repos not in master_repositories.json
7878
PR_LOOKBACK_DAYS = 35 # rolling window for scoring
7979
MERGED_PR_BASE_SCORE = 25
8080
MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5 # PRs below this get 0 base score
@@ -154,11 +154,9 @@
154154
# =============================================================================
155155
RECYCLE_UID = 0
156156

157-
# Hardcoded emission splits per competition (replaces dynamic emissions)
158-
OSS_EMISSION_SHARE = 0.30 # 30% to OSS contributions (PR scoring)
159-
ISSUE_DISCOVERY_EMISSION_SHARE = 0.10 # 10% to issue discovery
160-
RECYCLE_EMISSION_SHARE = 0.45 # 45% to recycle UID 0
161-
# ISSUES_TREASURY_EMISSION_SHARE = 0.15 defined below (15% to smart contract treasury)
157+
# Scoring pool is allocated by per-repo emission_share, then split within each
158+
# repo between PR scoring and issue discovery.
159+
OSS_EMISSION_SHARE = 0.90
162160

163161
# =============================================================================
164162
# Spam & Gaming Mitigation
@@ -187,5 +185,5 @@
187185
# =============================================================================
188186
CONTRACT_ADDRESS = '5FWNdk8YNtNcHKrAx2krqenFrFAZG7vmsd2XN2isJSew3MrD'
189187
ISSUES_TREASURY_UID = 111 # UID of the smart contract neuron, if set to RECYCLE_UID then it's disabled
190-
ISSUES_TREASURY_EMISSION_SHARE = 0.15 # % of emissions allocated to funding issues treasury
188+
ISSUES_TREASURY_EMISSION_SHARE = 0.10 # % of emissions allocated to funding issues treasury
191189
MAX_ISSUE_ID = 1_000_000 # sanity-check upper bound for any real deployment

gittensor/validator/forward.py

Lines changed: 120 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,17 @@
22
# Copyright © 2025 Entrius
33

44
import asyncio
5-
from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
5+
from collections import defaultdict
6+
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Set, Tuple
67

78
import bittensor as bt
89
import numpy as np
910

1011
from gittensor.classes import MinerEvaluation, MinerEvaluationCache
1112
from gittensor.constants import (
12-
ISSUE_DISCOVERY_EMISSION_SHARE,
1313
ISSUES_TREASURY_EMISSION_SHARE,
1414
ISSUES_TREASURY_UID,
1515
OSS_EMISSION_SHARE,
16-
RECYCLE_EMISSION_SHARE,
1716
RECYCLE_UID,
1817
)
1918
from gittensor.utils.uids import get_all_uids
@@ -48,11 +47,10 @@ async def forward(self: 'Validator') -> None:
4847
4. Store all evaluations to DB
4948
5. Blend emission pools and update scores
5049
51-
Emission blending (hardcoded per-competition):
52-
- OSS contributions: 30%
53-
- Issue discovery: 30%
54-
- Issue treasury: 15% (flat to UID 111)
55-
- Recycle: 25% (flat to UID 0)
50+
Emission blending:
51+
- OSS scoring pool: 90%, allocated by repository emission_share
52+
- Issue treasury: 10% (flat to UID 111)
53+
- Recycle: registry slack and inactive repo slices
5654
"""
5755

5856
if self.step % VALIDATOR_STEPS_INTERVAL == 0:
@@ -62,12 +60,12 @@ async def forward(self: 'Validator') -> None:
6260
token_config = load_token_config()
6361

6462
# 1. Score OSS contributions
65-
oss_rewards, miner_evaluations, cached_uids, penalized_uids = await oss_contributions(
63+
_oss_rewards, miner_evaluations, cached_uids, penalized_uids = await oss_contributions(
6664
self, miner_uids, master_repositories, programming_languages, token_config
6765
)
6866

6967
# 2. Score issue discovery
70-
issue_rewards = await issue_discovery(
68+
_issue_rewards = await issue_discovery(
7169
miner_evaluations,
7270
master_repositories,
7371
programming_languages,
@@ -85,8 +83,8 @@ async def forward(self: 'Validator') -> None:
8583
# 4. Store all evaluations to DB (includes issue discovery fields)
8684
await self.bulk_store_evaluation(miner_evaluations, skip_uids=cached_uids)
8785

88-
# 5. Blend 4 emission pools into final rewards
89-
rewards = blend_emission_pools(oss_rewards, issue_rewards, miner_uids)
86+
# 5. Allocate the scoring pool by per-repo emission_share
87+
rewards = blend_emission_pools(miner_evaluations, master_repositories, miner_uids)
9088

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

@@ -150,49 +148,129 @@ async def issue_discovery(
150148

151149

152150
def blend_emission_pools(
153-
oss_rewards: np.ndarray,
154-
issue_rewards: np.ndarray,
151+
miner_evaluations: Dict[int, MinerEvaluation],
152+
master_repositories: Dict[str, RepositoryConfig],
155153
miner_uids: set[int],
156154
) -> np.ndarray:
157-
"""Blend 4 emission pools into a single rewards array.
155+
"""Allocate emissions by configured repo slices and route slack to recycle.
158156
159-
- OSS contributions: 30%
160-
- Issue discovery: 30%
161-
- Issue treasury: 15% (flat to UID 111)
162-
- Recycle: 25% (flat to UID 0)
157+
Each repository receives at most ``emission_share * OSS_EMISSION_SHARE``.
158+
That repo slice is divided proportionally by raw PR and issue-discovery
159+
scores inside the repo. Registry slack and repo slices with no enabled
160+
nonzero scorers route to the recycle UID.
163161
"""
164162
sorted_uids = sorted(miner_uids)
165163
rewards = np.zeros(len(sorted_uids))
166-
recycle_extra = 0.0
167-
168-
# Pool 1: OSS contributions (30%)
169-
oss_total = float(oss_rewards.sum())
170-
if oss_total > 0:
171-
rewards += oss_rewards * OSS_EMISSION_SHARE
172-
else:
173-
recycle_extra += OSS_EMISSION_SHARE
174-
175-
# Pool 2: Issue discovery (30%)
176-
issue_total = float(issue_rewards.sum())
177-
if issue_total > 0:
178-
rewards += issue_rewards * ISSUE_DISCOVERY_EMISSION_SHARE
179-
else:
180-
recycle_extra += ISSUE_DISCOVERY_EMISSION_SHARE
181-
182-
# Pool 3: Issue treasury (15% flat to UID 111)
164+
uid_index = {uid: idx for idx, uid in enumerate(sorted_uids)}
165+
166+
recycle_amount = allocate_repo_scoring_pool(rewards, uid_index, miner_evaluations, master_repositories)
167+
168+
# Issue treasury (10% flat to UID 111)
183169
if ISSUES_TREASURY_UID > 0 and ISSUES_TREASURY_UID in miner_uids:
184-
treasury_idx = sorted_uids.index(ISSUES_TREASURY_UID)
170+
treasury_idx = uid_index[ISSUES_TREASURY_UID]
185171
rewards[treasury_idx] += ISSUES_TREASURY_EMISSION_SHARE
186172
bt.logging.info(
187173
f'Treasury allocation: UID {ISSUES_TREASURY_UID} receives '
188174
f'{ISSUES_TREASURY_EMISSION_SHARE * 100:.0f}% of emissions'
189175
)
190176

191-
# Pool 4: Recycle (25% + unclaimed from empty pools)
177+
# Recycle receives registry slack plus unclaimed repo slices. There is no
178+
# fixed recycle baseline under the emission_share allocation model.
192179
if RECYCLE_UID in miner_uids:
193-
recycle_idx = sorted_uids.index(RECYCLE_UID)
194-
rewards[recycle_idx] += RECYCLE_EMISSION_SHARE + recycle_extra
195-
if recycle_extra > 0:
196-
bt.logging.info(f'Recycling {recycle_extra * 100:.0f}% unclaimed emissions from empty pools')
180+
recycle_idx = uid_index[RECYCLE_UID]
181+
rewards[recycle_idx] += recycle_amount
182+
if recycle_amount > 0:
183+
bt.logging.info(f'Recycling {recycle_amount * 100:.2f}% unclaimed scoring-pool emissions')
197184

198185
return rewards
186+
187+
188+
def allocate_repo_scoring_pool(
189+
rewards: np.ndarray,
190+
uid_index: Dict[int, int],
191+
miner_evaluations: Dict[int, MinerEvaluation],
192+
master_repositories: Dict[str, RepositoryConfig],
193+
) -> float:
194+
"""Distribute the OSS scoring pool by repository emission shares.
195+
196+
Returns the amount that should be paid to the recycle UID.
197+
"""
198+
pr_scores, issue_scores = _collect_repo_scores(miner_evaluations)
199+
configured_share = sum(config.emission_share for config in master_repositories.values())
200+
recycle_amount = max(0.0, 1.0 - configured_share) * OSS_EMISSION_SHARE
201+
202+
if recycle_amount > 0:
203+
bt.logging.info(f'Registry emission_share slack: {recycle_amount * 100:.2f}% routed to recycle')
204+
205+
for repo_name, config in master_repositories.items():
206+
repo_key = repo_name.lower()
207+
repo_slice = config.emission_share * OSS_EMISSION_SHARE
208+
if repo_slice <= 0:
209+
continue
210+
211+
pr_entries = pr_scores.get(repo_key, [])
212+
issue_entries = issue_scores.get(repo_key, [])
213+
pr_total = sum(score for _, score in pr_entries)
214+
issue_total = sum(score for _, score in issue_entries)
215+
216+
issue_share = config.issue_discovery_share
217+
pr_share = 1.0 - issue_share
218+
pr_active = pr_share > 0 and pr_total > 0
219+
issue_active = issue_share > 0 and issue_total > 0
220+
221+
if not pr_active and not issue_active:
222+
recycle_amount += repo_slice
223+
continue
224+
225+
if pr_active and issue_active:
226+
_distribute_entries(rewards, uid_index, pr_entries, repo_slice * pr_share, pr_total)
227+
_distribute_entries(rewards, uid_index, issue_entries, repo_slice * issue_share, issue_total)
228+
elif pr_active:
229+
_distribute_entries(rewards, uid_index, pr_entries, repo_slice, pr_total)
230+
else:
231+
_distribute_entries(rewards, uid_index, issue_entries, repo_slice, issue_total)
232+
233+
return recycle_amount
234+
235+
236+
def _collect_repo_scores(
237+
miner_evaluations: Dict[int, MinerEvaluation],
238+
) -> Tuple[Dict[str, list[Tuple[int, float]]], Dict[str, list[Tuple[int, float]]]]:
239+
pr_scores: Dict[str, list[Tuple[int, float]]] = defaultdict(list)
240+
issue_scores: Dict[str, list[Tuple[int, float]]] = defaultdict(list)
241+
242+
for uid, evaluation in miner_evaluations.items():
243+
for pr in _positive_pr_scores(evaluation):
244+
pr_scores[pr.repository_full_name.lower()].append((uid, float(pr.earned_score)))
245+
for issue in _positive_issue_scores(evaluation):
246+
issue_scores[issue.repository_full_name.lower()].append((uid, float(issue.discovery_earned_score)))
247+
248+
return pr_scores, issue_scores
249+
250+
251+
def _positive_pr_scores(evaluation: MinerEvaluation) -> Iterable:
252+
return (pr for pr in evaluation.merged_prs if getattr(pr, 'earned_score', 0.0) > 0)
253+
254+
255+
def _positive_issue_scores(evaluation: MinerEvaluation) -> Iterable:
256+
return (
257+
issue
258+
for issue in getattr(evaluation, 'discovered_issues', [])
259+
if getattr(issue, 'discovery_earned_score', 0.0) > 0
260+
)
261+
262+
263+
def _distribute_entries(
264+
rewards: np.ndarray,
265+
uid_index: Dict[int, int],
266+
entries: list[Tuple[int, float]],
267+
allocation: float,
268+
total_score: float,
269+
) -> None:
270+
if allocation <= 0 or total_score <= 0:
271+
return
272+
for uid, score in entries:
273+
idx = uid_index.get(uid)
274+
if idx is None:
275+
continue
276+
rewards[idx] += allocation * score / total_score

gittensor/validator/issue_discovery/scan.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@
6060
LanguageConfig,
6161
RepositoryConfig,
6262
TokenConfig,
63-
resolve_repo_weight,
6463
)
6564

6665

@@ -224,6 +223,7 @@ def _clear_issue_discovery_fields(evaluation: MinerEvaluation) -> None:
224223
evaluation.total_valid_solved_issues = 0
225224
evaluation.total_closed_issues = 0
226225
evaluation.total_open_issues = 0
226+
evaluation.discovered_issues = []
227227

228228

229229
def _copy_issue_discovery_fields(target: MinerEvaluation, source: MinerEvaluation) -> None:
@@ -235,6 +235,7 @@ def _copy_issue_discovery_fields(target: MinerEvaluation, source: MinerEvaluatio
235235
target.total_valid_solved_issues = source.total_valid_solved_issues
236236
target.total_closed_issues = source.total_closed_issues
237237
target.total_open_issues = source.total_open_issues
238+
target.discovered_issues = list(source.discovered_issues)
238239

239240

240241
def _restore_issue_discovery_from_cache(
@@ -435,6 +436,7 @@ async def _score_miner_issues(
435436
evaluation.total_closed_issues = closed_count
436437
evaluation.total_open_issues = open_issue_count
437438
evaluation.issue_token_score = round(issue_token_score, 2)
439+
evaluation.discovered_issues = []
438440

439441
is_eligible, credibility, reason = check_issue_eligibility(solved_count, valid_solved_count, closed_count)
440442
evaluation.is_issue_eligible = is_eligible
@@ -456,7 +458,6 @@ async def _score_miner_issues(
456458
issue.discovery_open_issue_spam_multiplier = spam_mult
457459
issue.discovery_earned_score = round(
458460
issue.discovery_base_score
459-
* issue.discovery_repo_weight_multiplier
460461
* issue.discovery_time_decay_multiplier
461462
* issue.discovery_review_quality_multiplier
462463
* issue.discovery_credibility_multiplier
@@ -465,6 +466,7 @@ async def _score_miner_issues(
465466
)
466467
total_discovery_score += issue.discovery_earned_score
467468

469+
evaluation.discovered_issues = scored_issues
468470
evaluation.issue_discovery_score = round(total_discovery_score, 2)
469471

470472
bt.logging.info(
@@ -620,7 +622,7 @@ def _mirror_issue_for_scoring(
620622
)
621623

622624
adapted.discovery_base_score = base_score
623-
adapted.discovery_repo_weight_multiplier = resolve_repo_weight(repo_config)
625+
adapted.discovery_repo_weight_multiplier = 1.0
624626
adapted.discovery_time_decay_multiplier = round(calculate_time_decay(solving_pr.merged_at), 2)
625627
adapted.discovery_review_quality_multiplier = round(
626628
calculate_issue_review_quality_multiplier(solving_pr.review_summary.maintainer_changes_requested_count),

0 commit comments

Comments
 (0)