Skip to content

Commit da04e39

Browse files
committed
feat(scoring): gate PR scoring by PAT first-registration date
1 parent 7a3fbfb commit da04e39

9 files changed

Lines changed: 400 additions & 10 deletions

File tree

‎gittensor/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
# =============================================================================
7676
DEFAULT_REPO_WEIGHT = 0.01 # fallback weight for repos not in master_repositories.json
7777
PR_LOOKBACK_DAYS = 35 # rolling window for scoring
78+
REGISTRATION_GRACE_DAYS = 7
7879
MERGED_PR_BASE_SCORE = 25
7980
MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5 # PRs below this get 0 base score
8081
MAX_CONTRIBUTION_BONUS = 25

‎gittensor/utils/github_api_tools.py‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,7 @@ def try_add_open_or_closed_pr(
728728
pr_raw: Dict,
729729
pr_state: str,
730730
lookback_date_filter: datetime,
731+
registration_cutoff: Optional[datetime] = None,
731732
) -> None:
732733
"""
733734
Attempts to add an OPEN or CLOSED PR to miner_eval if eligible.
@@ -737,6 +738,7 @@ def try_add_open_or_closed_pr(
737738
pr_raw: Raw PR data from GraphQL
738739
pr_state: GitHub PR state (OPEN, CLOSED, MERGED)
739740
lookback_date_filter: Date filter for lookback period
741+
registration_cutoff: Skip closed PRs whose closed_at is earlier than this cutoff
740742
"""
741743
# Ignore all maintainer contributions
742744
if not os.environ.get('DEV_MODE') and pr_raw.get('authorAssociation') in MAINTAINER_ASSOCIATIONS:
@@ -764,6 +766,9 @@ def try_add_open_or_closed_pr(
764766
if created_dt < lookback_date_filter:
765767
return
766768

769+
if registration_cutoff is not None and closed_dt < registration_cutoff:
770+
return
771+
767772
if closed_dt >= lookback_date_filter:
768773
miner_eval.add_closed_pull_request(pr_raw)
769774

@@ -773,6 +778,7 @@ def should_skip_merged_pr(
773778
repository_full_name: str,
774779
repo_config: RepositoryConfig,
775780
lookback_date_filter: datetime,
781+
registration_cutoff: Optional[datetime] = None,
776782
) -> tuple[bool, Optional[str]]:
777783
"""
778784
Validate a merged PR against all eligibility criteria.
@@ -782,6 +788,7 @@ def should_skip_merged_pr(
782788
repository_full_name (str): Full repository name (owner/repo)
783789
repo_config (RepositoryConfig): Repository configuration
784790
lookback_date_filter (datetime): Date filter for lookback period
791+
registration_cutoff (Optional[datetime]): Skip merged PRs whose merged_at is earlier than this cutoff
785792
786793
Returns:
787794
tuple[bool, Optional[str]]: (should_skip, skip_reason) - True if PR should be skipped with reason
@@ -799,6 +806,12 @@ def should_skip_merged_pr(
799806
f'Skipping PR #{pr_raw["number"]} in {repository_full_name} - merged before {PR_LOOKBACK_DAYS}-day lookback window',
800807
)
801808

809+
if registration_cutoff is not None and merged_dt < registration_cutoff:
810+
return (
811+
True,
812+
f'Skipping PR #{pr_raw["number"]} in {repository_full_name} - merged before miner registration on this validator',
813+
)
814+
802815
# Skip if PR author is a maintainer
803816
author_association = pr_raw.get('authorAssociation')
804817
if not os.environ.get('DEV_MODE') and author_association in MAINTAINER_ASSOCIATIONS:
@@ -854,7 +867,10 @@ def should_skip_merged_pr(
854867

855868

856869
def load_miners_prs(
857-
miner_eval: MinerEvaluation, master_repositories: Dict[str, RepositoryConfig], max_prs: int = 1000
870+
miner_eval: MinerEvaluation,
871+
master_repositories: Dict[str, RepositoryConfig],
872+
max_prs: int = 1000,
873+
registration_cutoff: Optional[datetime] = None,
858874
) -> None:
859875
"""
860876
Fetches user PRs via GraphQL API and categorize them by state.
@@ -864,6 +880,7 @@ def load_miners_prs(
864880
miner_eval: The MinerEvaluation object containing github details + more
865881
master_repositories: Repository metadata (name -> RepositoryConfig)
866882
max_prs: Maximum merged PRs to fetch
883+
registration_cutoff: Skip PRs (merged or closed) whose effective timestamp predates this cutoff
867884
"""
868885
bt.logging.info('*****Fetching PRs*****')
869886
miner_eval.github_pr_fetch_failed = False
@@ -945,11 +962,17 @@ def load_miners_prs(
945962
continue
946963

947964
if pr_state in (PRState.OPEN.value, PRState.CLOSED.value):
948-
try_add_open_or_closed_pr(miner_eval, pr_raw, pr_state, lookback_date_filter)
965+
try_add_open_or_closed_pr(
966+
miner_eval, pr_raw, pr_state, lookback_date_filter, registration_cutoff=registration_cutoff
967+
)
949968
continue
950969

951970
should_skip, skip_reason = should_skip_merged_pr(
952-
pr_raw, repository_full_name, repo_config, lookback_date_filter
971+
pr_raw,
972+
repository_full_name,
973+
repo_config,
974+
lookback_date_filter,
975+
registration_cutoff=registration_cutoff,
953976
)
954977

955978
if should_skip:

‎gittensor/validator/oss_contributions/mirror/load.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,15 @@ def load_mirror_miner_prs(
3434
mirror_eval: MirrorMinerEvaluation,
3535
mirror_repos: Dict[str, RepositoryConfig],
3636
client: Optional[MirrorClient] = None,
37+
registration_cutoff: Optional[datetime] = None,
3738
) -> None:
3839
"""Populate mirror_eval with PRs fetched from the mirror service.
3940
4041
Args:
4142
mirror_eval: container to populate; must already have github_id set
4243
mirror_repos: repo configs to filter against (only mirror_enabled entries)
4344
client: optional MirrorClient for dependency injection in tests
45+
registration_cutoff: skip merged/closed PRs whose effective timestamp predates this cutoff
4446
"""
4547

4648
bt.logging.info('*****Fetching PRs from mirror*****')
@@ -65,7 +67,7 @@ def load_mirror_miner_prs(
6567

6668
for pr in response.pull_requests:
6769
try:
68-
_maybe_add_pr(mirror_eval, pr, mirror_repos, lookback_date)
70+
_maybe_add_pr(mirror_eval, pr, mirror_repos, lookback_date, registration_cutoff=registration_cutoff)
6971
except Exception as e:
7072
bt.logging.warning(f'Error processing mirror PR #{pr.pr_number} ({pr.repo_full_name}): {e}')
7173

@@ -80,6 +82,7 @@ def _maybe_add_pr(
8082
pr: MirrorPullRequest,
8183
mirror_repos: Dict[str, RepositoryConfig],
8284
lookback_date: datetime,
85+
registration_cutoff: Optional[datetime] = None,
8386
) -> None:
8487
"""Apply load-time filters and bucket pr by state if it passes."""
8588

@@ -112,12 +115,19 @@ def _maybe_add_pr(
112115
# closing an old PR shouldn't trigger a fresh credibility penalty).
113116
if pr.created_at < lookback_date:
114117
return
118+
if registration_cutoff is not None and pr.closed_at is not None and pr.closed_at < registration_cutoff:
119+
return
115120
mirror_eval.closed_prs.append(ScoredMirrorPR(pr=pr))
116121
elif pr.state == 'MERGED':
117122
# Apply the merge-eligibility gate at LOAD time (matches legacy parity —
118123
# should_skip_merged_pr runs inside load_miners_prs before adding). If we
119124
# deferred to scoring, rejected PRs would remain in mirror_merged_prs and
120125
# inflate the merged_count used in check_eligibility, distorting credibility.
126+
if registration_cutoff is not None and pr.merged_at is not None and pr.merged_at < registration_cutoff:
127+
bt.logging.debug(
128+
f'Skipping mirror PR #{pr.pr_number} in {pr.repo_full_name} - merged before miner registration on this validator'
129+
)
130+
return
121131
candidate = ScoredMirrorPR(pr=pr)
122132
should_skip, reason = _should_skip_merged_mirror_pr(candidate, repo_config)
123133
if should_skip:

‎gittensor/validator/oss_contributions/reward.py‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Copyright © 2025 Entrius
33
from __future__ import annotations
44

5+
from datetime import datetime
56
from typing import TYPE_CHECKING, Dict, Optional, Set, Tuple
67

78
import bittensor as bt
@@ -39,6 +40,7 @@ async def evaluate_miners_pull_requests(
3940
programming_languages: Dict[str, LanguageConfig],
4041
token_config: TokenConfig,
4142
stale_hotkey: Optional[str] = None,
43+
registration_cutoff: Optional[datetime] = None,
4244
) -> MinerEvaluation:
4345
"""
4446
Entry point from taking a miners response -> Get PRs -> Score PRs
@@ -51,6 +53,7 @@ async def evaluate_miners_pull_requests(
5153
programming_languages: The programming languages and their weights
5254
token_config: Token-based scoring weights configuration
5355
stale_hotkey: If set, the UID has a stored PAT from this old hotkey (re-registration detected)
56+
registration_cutoff: If set, skip merged/closed PRs whose effective timestamp predates this cutoff
5457
5558
Returns:
5659
MinerEvaluation: The object containing scores, valid_prs, etc.
@@ -75,7 +78,7 @@ async def evaluate_miners_pull_requests(
7578

7679
if legacy_repos:
7780
with session_scope():
78-
load_miners_prs(miner_eval, legacy_repos)
81+
load_miners_prs(miner_eval, legacy_repos, registration_cutoff=registration_cutoff)
7982
score_miner_prs(miner_eval, legacy_repos, programming_languages, token_config)
8083

8184
if mirror_repos:
@@ -85,7 +88,9 @@ async def evaluate_miners_pull_requests(
8588
github_id=miner_eval.github_id,
8689
)
8790
with MirrorClient() as mirror_client:
88-
load_mirror_miner_prs(mirror_eval, mirror_repos, client=mirror_client)
91+
load_mirror_miner_prs(
92+
mirror_eval, mirror_repos, client=mirror_client, registration_cutoff=registration_cutoff
93+
)
8994
score_mirror_miner_prs(mirror_eval, mirror_repos, programming_languages, token_config, client=mirror_client)
9095
combine(miner_eval, mirror_eval)
9196

@@ -133,6 +138,8 @@ async def get_rewards(
133138
else:
134139
stale_hotkey = pat_entry.get('hotkey')
135140

141+
registration_cutoff = pat_storage.get_registration_cutoff(uid, hotkey)
142+
136143
# Calculate score
137144
miner_evaluation = await evaluate_miners_pull_requests(
138145
uid,
@@ -142,6 +149,7 @@ async def get_rewards(
142149
programming_languages,
143150
token_config,
144151
stale_hotkey=stale_hotkey,
152+
registration_cutoff=registration_cutoff,
145153
)
146154
miner_evaluations[uid] = miner_evaluation
147155

‎gittensor/validator/pat_storage.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@
1111
import os
1212
import tempfile
1313
import threading
14-
from datetime import datetime, timezone
14+
from datetime import datetime, timedelta, timezone
1515
from pathlib import Path
1616
from typing import Optional
1717

18+
from gittensor.constants import REGISTRATION_GRACE_DAYS
19+
1820
PATS_FILE = Path(__file__).resolve().parents[2] / 'data' / 'miner_pats.json'
1921

2022
_lock = threading.Lock()
@@ -37,14 +39,25 @@ def save_pat(uid: int, hotkey: str, pat: str, github_id: str) -> None:
3739
"""Upsert a PAT entry by UID. Creates the file if needed."""
3840
with _lock:
3941
entries = _read_file()
42+
now_iso = datetime.now(timezone.utc).isoformat()
43+
44+
prior = next((e for e in entries if e.get('uid') == uid), None)
45+
46+
first_registered_at: Optional[str]
47+
if prior is not None and prior.get('hotkey') == hotkey:
48+
first_registered_at = prior.get('first_registered_at') if 'first_registered_at' in prior else None
49+
else:
50+
first_registered_at = now_iso
4051

4152
entry = {
4253
'uid': uid,
4354
'hotkey': hotkey,
4455
'pat': pat,
4556
'github_id': github_id,
46-
'stored_at': datetime.now(timezone.utc).isoformat(),
57+
'stored_at': now_iso,
4758
}
59+
if first_registered_at is not None:
60+
entry['first_registered_at'] = first_registered_at
4861

4962
for i, existing in enumerate(entries):
5063
if existing.get('uid') == uid:
@@ -56,6 +69,21 @@ def save_pat(uid: int, hotkey: str, pat: str, github_id: str) -> None:
5669
_write_file(entries)
5770

5871

72+
def get_registration_cutoff(uid: int, hotkey: str) -> Optional[datetime]:
73+
"""Return the registration scoring cutoff for this miner, or None when no gate applies."""
74+
entry = get_pat_by_uid(uid)
75+
if entry is None or entry.get('hotkey') != hotkey:
76+
return None
77+
raw = entry.get('first_registered_at')
78+
if not raw:
79+
return None
80+
try:
81+
first_registered_at = datetime.fromisoformat(raw)
82+
except ValueError:
83+
return None
84+
return first_registered_at - timedelta(days=REGISTRATION_GRACE_DAYS)
85+
86+
5987
def get_pat_by_uid(uid: int) -> Optional[dict]:
6088
"""Look up a single PAT entry by UID. Returns None if not found."""
6189
with _lock:

0 commit comments

Comments
 (0)