diff --git a/gittensor/validator/issue_discovery/mirror_scan.py b/gittensor/validator/issue_discovery/mirror_scan.py index 04fd6c8c7..f2916ecce 100644 --- a/gittensor/validator/issue_discovery/mirror_scan.py +++ b/gittensor/validator/issue_discovery/mirror_scan.py @@ -19,9 +19,10 @@ - issue.author_github_id != solving_pr.author_github_id (anti-self-issue) Same-account ("solver is also discoverer") gives credibility only — no -discovery score. One-issue-per-PR rule prevents a single solving PR from -generating multiple discovery scores when it closes more than one issue; -the earliest-created qualifying issue wins. +discovery score. One-issue-per-PR rule is round-global: a single solving PR +awards at most one discovery score across the entire validator round, even +when it closes issues authored by different miners. The earliest-created +qualifying issue across all miners wins; the rest are credibility only. Base-score resolution uses a per-cycle cross-miner cache. Most solving PRs on mirror-enabled repos will be miners' own PRs that OSS scoring already tokenized; @@ -96,6 +97,9 @@ class _CacheStats: fetch_failures: int = 0 +_FAR_FUTURE = datetime.max.replace(tzinfo=timezone.utc) + + async def run_mirror_issue_discovery( miner_evaluations: Dict[int, MinerEvaluation], mirror_repos: Dict[str, RepositoryConfig], @@ -134,12 +138,14 @@ async def run_mirror_issue_discovery( f'{sum(len(ev.mirror_merged_prs) for ev in miner_evaluations.values())} scored mirror PRs' ) - processed = 0 skipped_no_gh = 0 skipped_failed = 0 fetch_errors = 0 no_issues = 0 + # Phase 1: fetch each miner's issues. The one-issue-per-PR rule is round- + # global, so scoring is deferred until every miner's batch is in hand. + pending: List[Tuple[MinerEvaluation, List[MirrorIssue], int]] = [] for uid, evaluation in miner_evaluations.items(): if not evaluation.github_id or evaluation.github_id == '0': skipped_no_gh += 1 @@ -166,8 +172,10 @@ async def run_mirror_issue_discovery( # mirror-scoped state (the legacy GraphQL global open-issue count was # never the right signal for the gate). open_issue_count = sum(1 for i in filtered if i.state == 'OPEN') + pending.append((evaluation, filtered, open_issue_count)) - processed += 1 + canonical_pr_owners = _build_canonical_pr_owners(pending) + for evaluation, filtered, open_issue_count in pending: _score_miner_mirror_issues( evaluation, filtered, @@ -178,11 +186,12 @@ async def run_mirror_issue_discovery( programming_languages, token_config, open_issue_count=open_issue_count, + canonical_pr_owners=canonical_pr_owners, ) bt.logging.info('') bt.logging.info( - f'Issue discovery complete | {processed} processed | {no_issues} no mirror issues | ' + f'Issue discovery complete | {len(pending)} processed | {no_issues} no mirror issues | ' f'{fetch_errors} fetch errors | {skipped_no_gh} no github_id | {skipped_failed} prior OSS failure' ) bt.logging.info( @@ -192,6 +201,34 @@ async def run_mirror_issue_discovery( ) +def _build_canonical_pr_owners( + pending: List[Tuple[MinerEvaluation, List[MirrorIssue], int]], +) -> Dict[Tuple[str, int], Tuple[datetime, int, int]]: + """Cross-miner one-issue-per-PR resolution (legacy parity, pre-#796). + + Returns ``(repo, pr_number) -> (created_at, issue_number, uid)`` for the + earliest-created qualifying issue across all miners. Same-account issues + (discoverer == solver) are excluded — they never claim the slot, mirroring + legacy ``pr_scored.add`` ordering. ``_score_miner_mirror_issues`` matches + issue markers against this map to gate scoring vs. credibility-only. + """ + canonical: Dict[Tuple[str, int], Tuple[datetime, int, int]] = {} + for evaluation, issues, _ in pending: + for issue in issues: + if _classify_issue(issue) != 'solved': + continue + sp = issue.solving_pr + assert sp is not None # _classify_issue guarantees a solving_pr + if issue.author_github_id == sp.author_github_id: + continue + key = (issue.repo_full_name, sp.pr_number) + marker = (issue.created_at or _FAR_FUTURE, issue.issue_number, evaluation.uid) + existing = canonical.get(key) + if existing is None or marker < existing: + canonical[key] = marker + return canonical + + def _build_solving_pr_cache( miner_evaluations: Dict[int, MinerEvaluation], ) -> Dict[Tuple[str, int], CachedSolvingPR]: @@ -226,12 +263,16 @@ def _score_miner_mirror_issues( programming_languages: Dict[str, LanguageConfig], token_config: TokenConfig, open_issue_count: int, + canonical_pr_owners: Dict[Tuple[str, int], Tuple[datetime, int, int]], ) -> None: """Classify + score one miner's mirror issues, populate MinerEvaluation fields. ``open_issue_count`` is the miner's currently-OPEN issue count across mirror-enabled repos within the lookback window — the source-of-truth for the open-issue spam multiplier on the mirror path. + + ``canonical_pr_owners`` enforces the cross-miner one-issue-per-PR rule: + only the marker-matching issue scores, siblings count for credibility. """ solved_count = 0 valid_solved_count = 0 @@ -239,16 +280,12 @@ def _score_miner_mirror_issues( issue_token_score = 0.0 scored_issues: List[Issue] = [] - # One-issue-per-PR: the earliest-created issue a PR closes gets the score; - # later issues closed by the same PR add credibility only. - pr_scored_keys: Set[Tuple[str, int]] = set() - issues_sorted = sorted( issues, key=lambda i: ( i.repo_full_name, i.solved_by_pr or 0, - i.created_at or datetime.max.replace(tzinfo=timezone.utc), + i.created_at or _FAR_FUTURE, ), ) @@ -299,13 +336,13 @@ def _score_miner_mirror_issues( continue pr_key = (issue.repo_full_name, solving_pr.pr_number) - if pr_key in pr_scored_keys: + own_marker = (issue.created_at or _FAR_FUTURE, issue.issue_number, evaluation.uid) + if canonical_pr_owners.get(pr_key) != own_marker: bt.logging.debug( f' issue #{issue.issue_number} ({issue.repo_full_name}): one-issue-per-PR ' - f'(PR #{solving_pr.pr_number} already scored an earlier issue) — credibility only' + f'(PR #{solving_pr.pr_number} canonical owner is a different issue) — credibility only' ) continue - pr_scored_keys.add(pr_key) repo_config = mirror_repos.get(issue.repo_full_name) if repo_config is None: diff --git a/tests/validator/issue_discovery/test_mirror_scan.py b/tests/validator/issue_discovery/test_mirror_scan.py index 5d4cee08f..384793acf 100644 --- a/tests/validator/issue_discovery/test_mirror_scan.py +++ b/tests/validator/issue_discovery/test_mirror_scan.py @@ -111,6 +111,7 @@ def _issue_dict( solving_pr_edited_after_merge: bool = False, last_edited_at: Optional[str] = None, repo: str = 'entrius/gittensor-ui', + created_at: str = '2026-04-01T00:00:00Z', ) -> dict: sp = None if solved_by_pr: @@ -136,7 +137,7 @@ def _issue_dict( 'author_github_id': author_github_id, 'author_login': 'discoverer', 'author_association': 'CONTRIBUTOR', - 'created_at': '2026-04-01T00:00:00Z', + 'created_at': created_at, 'closed_at': '2026-04-18T10:00:00Z' if state == 'CLOSED' else None, 'updated_at': '2026-04-18T10:00:00Z', 'last_edited_at': last_edited_at, @@ -700,3 +701,284 @@ def test_all_mirror_miner_below_threshold_passes_spam(self): # Below threshold → spam_mult=1.0 → discovery score is non-zero assert eval_.issue_discovery_score > 0 assert eval_.total_open_issues == 2 + + +class TestCrossMinerOneIssuePerPr: + """Regression tests for the cross-miner one-issue-per-PR rule. + + A single solving PR closing issues authored by multiple miners must award + discovery score to at most one of them — the earliest-created qualifying + issue across all miners — with the rest counted as credibility-only. This + matches the rule documented at the top of ``mirror_scan`` and the legacy + pre-#796 behavior in ``_collect_issues_from_prs``. + """ + + def test_canonical_picks_earliest_created_across_miners(self): + """``_build_canonical_pr_owners`` keys (repo, pr_number) to the + earliest-created qualifying issue across all miners' fetches.""" + from gittensor.validator.issue_discovery.mirror_scan import _build_canonical_pr_owners + + e_a = _eval(uid=1, github_id='A') + e_b = _eval(uid=2, github_id='B') + + a_issue = MirrorIssue.from_dict( + _issue_dict( + issue_number=50, + author_github_id='A', + solving_pr_author='SOLVER', + created_at='2026-04-01T00:00:00Z', + ) + ) + b_issue = MirrorIssue.from_dict( + _issue_dict( + issue_number=51, + author_github_id='B', + solving_pr_author='SOLVER', + created_at='2026-04-05T00:00:00Z', + ) + ) + + canonical = _build_canonical_pr_owners([(e_a, [a_issue], 0), (e_b, [b_issue], 0)]) + + # Earlier-created issue (#50, uid 1) wins canonical for PR 100 + owner = canonical[('entrius/gittensor-ui', 100)] + assert owner[1] == 50 + assert owner[2] == 1 + + def test_canonical_tie_break_lower_issue_number(self): + """Identical ``created_at`` across miners → lower issue_number wins.""" + from gittensor.validator.issue_discovery.mirror_scan import _build_canonical_pr_owners + + # uid 2 first in iteration order, but uid 1's lower issue_number must win. + e_a = _eval(uid=2, github_id='A') + e_b = _eval(uid=1, github_id='B') + + a_issue = MirrorIssue.from_dict( + _issue_dict( + issue_number=51, + author_github_id='A', + solving_pr_author='SOLVER', + created_at='2026-04-01T00:00:00Z', + ) + ) + b_issue = MirrorIssue.from_dict( + _issue_dict( + issue_number=50, + author_github_id='B', + solving_pr_author='SOLVER', + created_at='2026-04-01T00:00:00Z', + ) + ) + + canonical = _build_canonical_pr_owners([(e_a, [a_issue], 0), (e_b, [b_issue], 0)]) + + owner = canonical[('entrius/gittensor-ui', 100)] + assert owner[1] == 50 # lower issue_number wins + assert owner[2] == 1 # ... which is uid 1 (e_b) + + def test_canonical_excludes_same_account(self): + """Same-account issues never claim canonical ownership of a PR slot, + leaving non-same-account siblings on the same PR free to score.""" + from gittensor.validator.issue_discovery.mirror_scan import _build_canonical_pr_owners + + e_a = _eval(uid=1, github_id='A') + e_b = _eval(uid=2, github_id='B') + + # A's issue is same-account (author == solver) and earlier — must be excluded. + a_issue = MirrorIssue.from_dict( + _issue_dict( + issue_number=50, + author_github_id='A', + solving_pr_author='A', + created_at='2026-04-01T00:00:00Z', + ) + ) + b_issue = MirrorIssue.from_dict( + _issue_dict( + issue_number=51, + author_github_id='B', + solving_pr_author='SOLVER', + created_at='2026-04-05T00:00:00Z', + ) + ) + + canonical = _build_canonical_pr_owners([(e_a, [a_issue], 0), (e_b, [b_issue], 0)]) + + owner = canonical[('entrius/gittensor-ui', 100)] + assert owner[1] == 51 # B's issue claims canonical + assert owner[2] == 2 + + def test_two_miners_shared_pr_only_earliest_scores(self): + """End-to-end: two miners each with 7 valid solved issues clearing + the eligibility gate, one solving PR shared between them. The + earlier-created issue's miner pockets the shared PR's contribution; + the later one gets credibility only. + """ + client = Mock() + + # 6 unique-PR issues + 1 shared-PR issue per miner. A's #50 is earlier + # (April 1) than B's #51 (April 5), so A is canonical for PR 100. + a_issues = [_issue_dict(issue_number=10 + i, author_github_id='A', solved_by_pr=200 + i) for i in range(6)] + a_issues.append( + _issue_dict( + issue_number=50, + author_github_id='A', + solved_by_pr=100, + solving_pr_author='SOLVER', + created_at='2026-04-01T00:00:00Z', + ) + ) + b_issues = [_issue_dict(issue_number=20 + i, author_github_id='B', solved_by_pr=300 + i) for i in range(6)] + b_issues.append( + _issue_dict( + issue_number=51, + author_github_id='B', + solved_by_pr=100, + solving_pr_author='SOLVER', + created_at='2026-04-05T00:00:00Z', + ) + ) + + def _per_miner(github_id, since=None): + return _response(a_issues if github_id == 'A' else b_issues) + + client.get_miner_issues.side_effect = _per_miner + + e_a = _eval(uid=1, github_id='A') + e_b = _eval(uid=2, github_id='B') + + # Pre-seed cross-miner solving-PR cache so no fetches are needed. + seed = MinerEvaluation(uid=99, hotkey='hkS', github_id='SEED') + seed.mirror_merged_prs = [ + _scored_mirror_pr('entrius/gittensor-ui', pr_number) + for pr_number in [100] + list(range(200, 206)) + list(range(300, 306)) + ] + + _run( + run_mirror_issue_discovery( + {1: e_a, 2: e_b, 99: seed}, + _mirror_repos('entrius/gittensor-ui'), + _EMPTY_LANGS, + _EMPTY_TOKEN_CONFIG, + client=client, + ) + ) + + # Both miners count the shared-PR issue toward credibility. + assert e_a.total_solved_issues == 7 + assert e_b.total_solved_issues == 7 + assert e_a.total_valid_solved_issues == 7 + assert e_b.total_valid_solved_issues == 7 + assert e_a.is_issue_eligible + assert e_b.is_issue_eligible + + # ``issue_token_score`` accumulates only over SCORED PRs (default + # ``_scored_mirror_pr`` token_score is 100.0), so this is the + # deterministic, time-decay-independent check: A has 7 scored, B has + # 6 (shared PR 100 is canonical for A only and credibility-only for B). + assert e_a.issue_token_score == 700.0 + assert e_b.issue_token_score == 600.0 + # All solving PRs share identical scoring inputs at this issue mix, so + # the discovery_score ratio collapses to 7:6. + assert e_a.issue_discovery_score > e_b.issue_discovery_score > 0 + assert e_a.issue_discovery_score / e_b.issue_discovery_score == pytest.approx(7 / 6, rel=1e-2) + + def test_within_miner_one_issue_per_pr_still_holds(self): + """One miner authoring two issues both closed by the same PR — the + earlier-created issue scores; the later one is credibility-only. + Preserves the original within-miner one-issue-per-PR semantics now + that the rule is enforced via the cross-miner canonical map.""" + client = Mock() + + miner_issues = [ + _issue_dict(issue_number=10 + i, author_github_id='999', solved_by_pr=200 + i) for i in range(6) + ] + miner_issues.extend( + [ + _issue_dict( + issue_number=50, + author_github_id='999', + solved_by_pr=100, + solving_pr_author='SOLVER', + created_at='2026-04-01T00:00:00Z', + ), + _issue_dict( + issue_number=51, + author_github_id='999', + solved_by_pr=100, + solving_pr_author='SOLVER', + created_at='2026-04-05T00:00:00Z', + ), + ] + ) + + client.get_miner_issues.return_value = _response(miner_issues) + eval_ = _eval(uid=1, github_id='999') + + seed = MinerEvaluation(uid=99, hotkey='hkS', github_id='SEED') + seed.mirror_merged_prs = [ + _scored_mirror_pr('entrius/gittensor-ui', pr_number) for pr_number in [100] + list(range(200, 206)) + ] + + _run( + run_mirror_issue_discovery( + {1: eval_, 99: seed}, + _mirror_repos('entrius/gittensor-ui'), + _EMPTY_LANGS, + _EMPTY_TOKEN_CONFIG, + client=client, + ) + ) + + # 8 solved (both shared-PR issues counted for credibility), eligible. + assert eval_.total_solved_issues == 8 + assert eval_.total_valid_solved_issues == 8 + assert eval_.is_issue_eligible + # ``issue_token_score`` only accumulates over SCORED PRs (default + # ``_scored_mirror_pr`` token_score is 100.0). 7 distinct scoring PRs + # ⇒ 700.0; the later PR-100 issue is credibility-only and contributes + # no token_score, so this would be 800.0 if the within-miner rule had + # broken alongside the cross-miner one. + assert eval_.issue_token_score == 700.0 + assert eval_.issue_discovery_score > 0 + + def test_different_solving_prs_both_miners_score(self): + """Two miners' issues closed by completely disjoint solving PRs — + no cross-miner canonical contention; both miners score normally.""" + client = Mock() + + a_issues = [_issue_dict(issue_number=10 + i, author_github_id='A', solved_by_pr=200 + i) for i in range(7)] + b_issues = [_issue_dict(issue_number=20 + i, author_github_id='B', solved_by_pr=300 + i) for i in range(7)] + + def _per_miner(github_id, since=None): + return _response(a_issues if github_id == 'A' else b_issues) + + client.get_miner_issues.side_effect = _per_miner + + e_a = _eval(uid=1, github_id='A') + e_b = _eval(uid=2, github_id='B') + + seed = MinerEvaluation(uid=99, hotkey='hkS', github_id='SEED') + seed.mirror_merged_prs = [ + _scored_mirror_pr('entrius/gittensor-ui', pr_number) + for pr_number in list(range(200, 207)) + list(range(300, 307)) + ] + + _run( + run_mirror_issue_discovery( + {1: e_a, 2: e_b, 99: seed}, + _mirror_repos('entrius/gittensor-ui'), + _EMPTY_LANGS, + _EMPTY_TOKEN_CONFIG, + client=client, + ) + ) + + # Identical issue mix and disjoint PRs → identical scores. Both miners + # score all 7 of their issues (no canonical contention). + assert e_a.total_solved_issues == 7 + assert e_b.total_solved_issues == 7 + assert e_a.issue_token_score == 700.0 + assert e_b.issue_token_score == 700.0 + assert e_a.issue_discovery_score == e_b.issue_discovery_score + assert e_a.issue_discovery_score > 0