diff --git a/gittensor/classes.py b/gittensor/classes.py index 1c5001546..8b35fcf8c 100644 --- a/gittensor/classes.py +++ b/gittensor/classes.py @@ -15,7 +15,6 @@ from gittensor.validator.oss_contributions.mirror.scored_pr import ScoredMirrorPR from gittensor.constants import ( - LABEL_MULTIPLIERS, MAINTAINER_ASSOCIATIONS, MAX_CODE_DENSITY_MULTIPLIER, MIN_TOKEN_SCORE_FOR_BASE_SCORE, @@ -183,8 +182,13 @@ class PullRequest: time_decay_multiplier: float = 1.0 credibility_multiplier: float = 1.0 review_quality_multiplier: float = 1.0 # Penalty for CHANGES_REQUESTED reviews from maintainers - label_multiplier: float = 1.0 # Multiplier based on PR label (exact match against known labels) - label: Optional[str] = None # Last label set on the PR + label_multiplier: float = 1.0 # Multiplier resolved from per-repo label_multipliers config + label: Optional[str] = None # Resolved scoring label (set during scoring, stored in DB) + current_labels: frozenset[str] = field(default_factory=frozenset) # All currently-applied labels (lowercased) + # Current labels ordered by last application (most recent first) from timeline scan. + # Subset of current_labels that appeared in timelineItems; labels absent from the timeline + # (truncated) are not included here and fall back to highest-multiplier selection. + label_timeline_order: tuple[str, ...] = field(default_factory=tuple) changes_requested_count: int = 0 # Number of maintainer CHANGES_REQUESTED reviews earned_score: float = 0.0 collateral_score: float = 0.0 # For OPEN PRs: potential_score * collateral_percent @@ -310,18 +314,19 @@ def from_graphql_response(cls, pr_data: dict, uid: int, hotkey: str, github_id: cr_reviews = (pr_data.get('changesRequestedReviews') or {}).get('nodes') or [] changes_requested_count = sum(1 for r in cr_reviews if r.get('authorAssociation') in MAINTAINER_ASSOCIATIONS) - current = {(n.get('name') or '').lower() for n in (pr_data.get('labels') or {}).get('nodes') or [] if n} - label: Optional[str] = None - scoring_labels = current & LABEL_MULTIPLIERS.keys() - if scoring_labels: + current: frozenset = frozenset( + (n.get('name') or '').lower() for n in (pr_data.get('labels') or {}).get('nodes') or [] if n + ) + # Collect all currently-applied labels in reverse-timeline order (most recently applied first). + # Per-repo multiplier resolution happens later in calculate_pr_multipliers which has repo config. + timeline_ordered: list = [] + if current: + seen: set = set() for event in reversed((pr_data.get('timelineItems') or {}).get('nodes') or []): name = ((event or {}).get('label') or {}).get('name', '').lower() - if name in scoring_labels: - label = name - break - if label is None: - # Timeline truncated — fall back to highest-multiplier currently-applied label - label = max(scoring_labels, key=lambda n: (LABEL_MULTIPLIERS[n], n)) + if name and name in current and name not in seen: + seen.add(name) + timeline_ordered.append(name) return cls( number=pr_data['number'], @@ -343,7 +348,8 @@ def from_graphql_response(cls, pr_data: dict, uid: int, hotkey: str, github_id: last_edited_at=last_edited_at, head_ref_oid=pr_data.get('headRefOid'), base_ref_oid=pr_data.get('baseRefOid'), - label=label, + current_labels=current, + label_timeline_order=tuple(timeline_ordered), changes_requested_count=changes_requested_count, ) diff --git a/gittensor/constants.py b/gittensor/constants.py index b2acbadd3..da2d699e8 100644 --- a/gittensor/constants.py +++ b/gittensor/constants.py @@ -83,29 +83,6 @@ # Boosts MAX_CODE_DENSITY_MULTIPLIER = 1.15 -# Label multipliers - applied based on the last label set on the PR (requires triage+ access) -LABEL_MULTIPLIERS: dict[str, float] = { - # features - 'feature': 1.50, - 'feat': 1.50, - # bug fixes - 'bug': 1.25, - 'fix': 1.25, - 'crash': 1.25, - 'regression': 1.25, - 'security': 1.25, - # enhancements - 'enhancement': 1.10, - 'improve': 1.10, - 'perf': 1.10, - # refactors - 'refactor': 0.5, - 'cleanup': 0.5, - 'polish': 0.5, - 'debt': 0.5, - 'chore': 0.5, -} - # 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 diff --git a/gittensor/validator/oss_contributions/mirror/scoring.py b/gittensor/validator/oss_contributions/mirror/scoring.py index e6ca01d4b..4d8df1313 100644 --- a/gittensor/validator/oss_contributions/mirror/scoring.py +++ b/gittensor/validator/oss_contributions/mirror/scoring.py @@ -29,7 +29,6 @@ from gittensor.classes import FileChange, PrScoringResult, ScoringCategory from gittensor.constants import ( CONTRIBUTION_SCORE_FOR_FULL_BONUS, - LABEL_MULTIPLIERS, MAINTAINER_ASSOCIATIONS, MAINTAINER_ISSUE_MULTIPLIER, MAX_CONTRIBUTION_BONUS, @@ -53,6 +52,7 @@ LanguageConfig, RepositoryConfig, TokenConfig, + resolve_label_multiplier, resolve_repo_weight, ) from gittensor.validator.utils.tree_sitter_scoring import calculate_token_score_from_file_changes @@ -351,9 +351,9 @@ def _calculate_pr_multipliers(scored: ScoredMirrorPR, repo_config: RepositoryCon scored.repo_weight_multiplier = resolve_repo_weight(repo_config) - chosen_label = _resolve_trusted_scoring_label(pr, repo_config) + chosen_label, label_multiplier = _resolve_trusted_scoring_label(pr, repo_config) scored.label = chosen_label - scored.label_multiplier = LABEL_MULTIPLIERS.get(chosen_label, 1.0) if chosen_label else 1.0 + scored.label_multiplier = label_multiplier scored.issue_multiplier = round(_calculate_issue_multiplier(scored), 2) @@ -372,9 +372,12 @@ def _calculate_pr_multipliers(scored: ScoredMirrorPR, repo_config: RepositoryCon scored.review_quality_multiplier = 1.0 -def _resolve_trusted_scoring_label(pr: MirrorPullRequest, repo_config: RepositoryConfig) -> Optional[str]: +def _resolve_trusted_scoring_label(pr: MirrorPullRequest, repo_config: RepositoryConfig) -> tuple[Optional[str], float]: """Pick the highest-multiplier currently-applied scoring label whose actor is trusted. + Returns ``(label_name, multiplier)``. Returns ``(None, default_label_multiplier)`` + when no trusted scoring label is present. + By default the actor must be in ``MAINTAINER_ASSOCIATIONS``. Repos opted into ``trusted_label_pipeline`` accept any actor — including GitHub-App actors that surface as ``actor_association=NULL`` because they lack a row in @@ -383,17 +386,17 @@ def _resolve_trusted_scoring_label(pr: MirrorPullRequest, repo_config: Repositor auto-labelers (release-drafter, actions/labeler) and must keep the gate. """ trusted = repo_config.trusted_label_pipeline - candidates = [ - label - for label in pr.labels - if (label.name or '').lower() in LABEL_MULTIPLIERS - and (trusted or label.actor_association in MAINTAINER_ASSOCIATIONS) - ] + candidates: list[tuple[str, float]] = [] + for label in pr.labels: + if not (trusted or label.actor_association in MAINTAINER_ASSOCIATIONS): + continue + mult = resolve_label_multiplier((label.name or '').lower(), repo_config) + if mult is not None: + candidates.append(((label.name or '').lower(), mult)) if not candidates: - return None + return None, repo_config.default_label_multiplier # Highest multiplier wins; tie-broken by label name for deterministic output - best = max(candidates, key=lambda label: (LABEL_MULTIPLIERS[label.name.lower()], label.name.lower())) - return best.name.lower() + return max(candidates, key=lambda t: (t[1], t[0])) # ============================================================================ diff --git a/gittensor/validator/oss_contributions/scoring.py b/gittensor/validator/oss_contributions/scoring.py index 2b7586fd5..25a538b4e 100644 --- a/gittensor/validator/oss_contributions/scoring.py +++ b/gittensor/validator/oss_contributions/scoring.py @@ -17,7 +17,6 @@ from gittensor.validator.oss_contributions.mirror.scored_pr import ScoredMirrorPR from gittensor.constants import ( EXCESSIVE_PR_PENALTY_BASE_THRESHOLD, - LABEL_MULTIPLIERS, MAINTAINER_ASSOCIATIONS, MAINTAINER_ISSUE_MULTIPLIER, MAX_ISSUE_CLOSE_WINDOW_DAYS, @@ -41,7 +40,13 @@ ) from gittensor.validator.oss_contributions.credibility import check_eligibility from gittensor.validator.utils.datetime_utils import calculate_time_decay -from gittensor.validator.utils.load_weights import LanguageConfig, RepositoryConfig, TokenConfig, resolve_repo_weight +from gittensor.validator.utils.load_weights import ( + LanguageConfig, + RepositoryConfig, + TokenConfig, + resolve_label_multiplier, + resolve_repo_weight, +) def score_miner_prs( @@ -202,6 +207,41 @@ def calculate_review_collateral_multiplier(changes_requested_count: int, pr_numb return multiplier +def _resolve_label( + timeline_order: tuple[str, ...], + current_labels: frozenset[str], + repo_config: Optional[RepositoryConfig], +) -> tuple[Optional[str], float]: + """Resolve the scoring label and its multiplier from per-repo config. + + Tries labels in *timeline_order* (most recently applied first) and returns + the first that matches a per-repo pattern — preserving the last-applied- + scoring-label semantics of the original global-table approach. + + Falls back to the highest-multiplier matching label from *current_labels* + for labels absent from the timeline (truncated history). Returns + ``(None, default_label_multiplier)`` when nothing matches at all. + """ + default_mult = repo_config.default_label_multiplier if repo_config else 1.0 + + for lbl in timeline_order: + mult = resolve_label_multiplier(lbl, repo_config) + if mult is not None: + return lbl, mult + + best: Optional[tuple[str, float]] = None + for lbl in current_labels: + mult = resolve_label_multiplier(lbl, repo_config) + if mult is not None: + if best is None or mult > best[1] or (mult == best[1] and lbl < best[0]): + best = (lbl, mult) + + if best is not None: + return best + + return None, default_mult + + def calculate_pr_multipliers( pr: PullRequest, miner_eval: MinerEvaluation, master_repositories: Dict[str, RepositoryConfig] ) -> None: @@ -211,7 +251,7 @@ def calculate_pr_multipliers( pr.repo_weight_multiplier = resolve_repo_weight(repo_config) pr.issue_multiplier = round(calculate_issue_multiplier(pr), 2) - pr.label_multiplier = LABEL_MULTIPLIERS.get(pr.label, 1.0) if pr.label else 1.0 + pr.label, pr.label_multiplier = _resolve_label(pr.label_timeline_order, pr.current_labels, repo_config) if is_merged: # Spam multiplier is recalculated in finalize_miner_scores with total token score diff --git a/gittensor/validator/utils/load_weights.py b/gittensor/validator/utils/load_weights.py index 2bc255653..640b2e187 100644 --- a/gittensor/validator/utils/load_weights.py +++ b/gittensor/validator/utils/load_weights.py @@ -2,6 +2,7 @@ # Copyright © 2025 Entrius import json from dataclasses import dataclass, field +from fnmatch import fnmatch from pathlib import Path from typing import Dict, List, Optional @@ -9,6 +10,10 @@ from gittensor.constants import DEFAULT_REPO_WEIGHT, NON_CODE_EXTENSIONS +_LABEL_MULTIPLIER_MIN = 0.0 +_LABEL_MULTIPLIER_MAX = 20.0 +_LABEL_MULTIPLIERS_MAX_ENTRIES = 10 + @dataclass class LanguageConfig: @@ -38,7 +43,13 @@ class RepositoryConfig: actor — including GitHub Apps that surface as ``actor_association=NULL``. Defaults to False; only enable on repos with an authoritative label pipeline. See ``_resolve_trusted_scoring_label`` for the threat model. - + label_multipliers: Per-repo label-to-multiplier mapping. Keys support + fnmatch wildcards (e.g. ``"type/*"``, ``"*-dev"``). Each value must + be in [0.0, 20.0]; at most 10 entries. When None no label scoring + applies and ``default_label_multiplier`` is used for all PRs. + default_label_multiplier: Multiplier applied when no label on the PR + matches any pattern in ``label_multipliers``. Must be in [0.0, 20.0]. + Defaults to 1.0 (neutral). """ weight: float @@ -46,6 +57,8 @@ class RepositoryConfig: additional_acceptable_branches: Optional[List[str]] = None mirror_enabled: bool = False trusted_label_pipeline: bool = False + label_multipliers: Optional[Dict[str, float]] = None + default_label_multiplier: float = 1.0 def resolve_repo_weight(repo_config: Optional[RepositoryConfig]) -> float: @@ -55,6 +68,22 @@ def resolve_repo_weight(repo_config: Optional[RepositoryConfig]) -> float: return repo_config.weight +def resolve_label_multiplier(label: str, repo_config: Optional[RepositoryConfig]) -> Optional[float]: + """Return the per-repo multiplier for *label* using fnmatch pattern matching. + + Patterns and label are compared in lowercase. Returns ``None`` when no + pattern matches — callers should then fall back to + ``repo_config.default_label_multiplier`` (or 1.0 if config is absent). + """ + if repo_config is None or not repo_config.label_multipliers: + return None + label_lower = label.lower() + for pattern, multiplier in repo_config.label_multipliers.items(): + if fnmatch(label_lower, pattern.lower()): + return multiplier + return None + + @dataclass class TokenConfig: """Configuration for token-based scoring weights. @@ -122,12 +151,46 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: normalized_data: Dict[str, RepositoryConfig] = {} for repo_name, metadata in data.items(): try: + raw_lm = metadata.get('label_multipliers') + label_multipliers: Optional[Dict[str, float]] = None + if raw_lm is not None: + if not isinstance(raw_lm, dict): + bt.logging.warning(f'{repo_name}: label_multipliers must be a dict, ignoring') + elif len(raw_lm) > _LABEL_MULTIPLIERS_MAX_ENTRIES: + bt.logging.warning( + f'{repo_name}: label_multipliers has {len(raw_lm)} entries ' + f'(max {_LABEL_MULTIPLIERS_MAX_ENTRIES}), ignoring' + ) + else: + validated: Dict[str, float] = {} + for k, v in raw_lm.items(): + fv = float(v) + if not (_LABEL_MULTIPLIER_MIN <= fv <= _LABEL_MULTIPLIER_MAX): + bt.logging.warning( + f'{repo_name}: label_multipliers["{k}"]={fv} out of ' + f'[{_LABEL_MULTIPLIER_MIN}, {_LABEL_MULTIPLIER_MAX}], skipping entry' + ) + else: + validated[k] = fv + label_multipliers = validated or None + + raw_default = metadata.get('default_label_multiplier', 1.0) + default_label_multiplier = float(raw_default) + if not (_LABEL_MULTIPLIER_MIN <= default_label_multiplier <= _LABEL_MULTIPLIER_MAX): + bt.logging.warning( + f'{repo_name}: default_label_multiplier={default_label_multiplier} out of ' + f'[{_LABEL_MULTIPLIER_MIN}, {_LABEL_MULTIPLIER_MAX}], resetting to 1.0' + ) + default_label_multiplier = 1.0 + config = RepositoryConfig( weight=float(metadata.get('weight', 0.01)), inactive_at=metadata.get('inactive_at'), additional_acceptable_branches=metadata.get('additional_acceptable_branches'), mirror_enabled=bool(metadata.get('mirror_enabled', False)), trusted_label_pipeline=bool(metadata.get('trusted_label_pipeline', False)), + label_multipliers=label_multipliers, + default_label_multiplier=default_label_multiplier, ) normalized_data[repo_name.lower()] = config except (ValueError, TypeError) as e: diff --git a/tests/validator/oss_contributions/mirror/test_scoring.py b/tests/validator/oss_contributions/mirror/test_scoring.py index 00d784207..b97f308e8 100644 --- a/tests/validator/oss_contributions/mirror/test_scoring.py +++ b/tests/validator/oss_contributions/mirror/test_scoring.py @@ -101,16 +101,23 @@ def _pr( ) +_SCORING_LABEL = 'feature' +_SCORING_MULT = 1.5 +_DEFAULT_LABEL_MULTIPLIERS = {_SCORING_LABEL: _SCORING_MULT, 'bug': 1.25} + + def _config( weight: float = 0.5, additional_branches: list | None = None, trusted_label_pipeline: bool = False, + label_multipliers: dict | None = None, ) -> RepositoryConfig: return RepositoryConfig( weight=weight, mirror_enabled=True, additional_acceptable_branches=additional_branches, trusted_label_pipeline=trusted_label_pipeline, + label_multipliers=_DEFAULT_LABEL_MULTIPLIERS if label_multipliers is None else label_multipliers, ) @@ -414,67 +421,51 @@ def test_renamed_file_carries_previous_filename(self): class TestLabelResolution: def test_no_labels_returns_none(self): scored = ScoredMirrorPR(pr=_pr(labels=[])) - assert _resolve_trusted_scoring_label(scored.pr, _config()) is None + label, _ = _resolve_trusted_scoring_label(scored.pr, _config()) + assert label is None def test_non_scoring_labels_ignored(self): labels = [{'name': 'random', 'actor_github_id': '1', 'actor_association': 'OWNER'}] scored = ScoredMirrorPR(pr=_pr(labels=labels)) - assert _resolve_trusted_scoring_label(scored.pr, _config()) is None + label, _ = _resolve_trusted_scoring_label(scored.pr, _config()) + assert label is None def test_non_maintainer_label_ignored(self): - from gittensor.constants import LABEL_MULTIPLIERS - - scoring_label = next(iter(LABEL_MULTIPLIERS.keys())) - labels = [{'name': scoring_label, 'actor_github_id': '1', 'actor_association': 'CONTRIBUTOR'}] + labels = [{'name': _SCORING_LABEL, 'actor_github_id': '1', 'actor_association': 'CONTRIBUTOR'}] scored = ScoredMirrorPR(pr=_pr(labels=labels)) - assert _resolve_trusted_scoring_label(scored.pr, _config()) is None + label, _ = _resolve_trusted_scoring_label(scored.pr, _config()) + assert label is None def test_null_actor_association_ignored_on_untrusted_repo(self): - from gittensor.constants import LABEL_MULTIPLIERS - - scoring_label = next(iter(LABEL_MULTIPLIERS.keys())) - labels = [{'name': scoring_label, 'actor_github_id': None, 'actor_association': None}] + labels = [{'name': _SCORING_LABEL, 'actor_github_id': None, 'actor_association': None}] scored = ScoredMirrorPR(pr=_pr(labels=labels)) - assert _resolve_trusted_scoring_label(scored.pr, _config()) is None + label, _ = _resolve_trusted_scoring_label(scored.pr, _config()) + assert label is None def test_maintainer_set_scoring_label_returned(self): - from gittensor.constants import LABEL_MULTIPLIERS - - scoring_label = next(iter(LABEL_MULTIPLIERS.keys())) - labels = [{'name': scoring_label, 'actor_github_id': '1', 'actor_association': 'COLLABORATOR'}] + labels = [{'name': _SCORING_LABEL, 'actor_github_id': '1', 'actor_association': 'COLLABORATOR'}] scored = ScoredMirrorPR(pr=_pr(labels=labels)) - assert _resolve_trusted_scoring_label(scored.pr, _config()) == scoring_label.lower() + label, _ = _resolve_trusted_scoring_label(scored.pr, _config()) + assert label == _SCORING_LABEL.lower() def test_highest_multiplier_wins(self): - from gittensor.constants import LABEL_MULTIPLIERS - - scoring_labels = list(LABEL_MULTIPLIERS.keys()) - if len(scoring_labels) < 2: - pytest.skip('Need at least 2 scoring labels for this test') - - a, b = scoring_labels[0], scoring_labels[1] + a, b = 'feature', 'bug' + config = _config(label_multipliers={a: 1.5, b: 1.25}) labels = [ {'name': a, 'actor_github_id': '1', 'actor_association': 'OWNER'}, {'name': b, 'actor_github_id': '1', 'actor_association': 'OWNER'}, ] scored = ScoredMirrorPR(pr=_pr(labels=labels)) - chosen = _resolve_trusted_scoring_label(scored.pr, _config()) - expected = max([a, b], key=lambda n: (LABEL_MULTIPLIERS[n], n)).lower() - assert chosen == expected + chosen, _ = _resolve_trusted_scoring_label(scored.pr, config) + assert chosen == 'feature' def test_calculate_multipliers_threads_trusted_flag(self): """End-to-end issue #911 path: _calculate_pr_multipliers honors trusted_label_pipeline.""" - from gittensor.constants import LABEL_MULTIPLIERS - - scoring_label = next((name for name, mult in LABEL_MULTIPLIERS.items() if mult != 1.0), None) - if scoring_label is None: - pytest.skip('Need at least one non-1.0x label for this test') - - labels = [{'name': scoring_label, 'actor_github_id': '99', 'actor_association': None}] + labels = [{'name': _SCORING_LABEL, 'actor_github_id': '99', 'actor_association': None}] scored_trusted = ScoredMirrorPR(pr=_pr(labels=labels)) _calculate_pr_multipliers(scored_trusted, _config(trusted_label_pipeline=True)) - assert scored_trusted.label_multiplier == LABEL_MULTIPLIERS[scoring_label] + assert scored_trusted.label_multiplier == _SCORING_MULT scored_untrusted = ScoredMirrorPR(pr=_pr(labels=labels)) _calculate_pr_multipliers(scored_untrusted, _config(trusted_label_pipeline=False)) @@ -694,18 +685,15 @@ def test_open_mirror_pr_review_iterations_increase_collateral(self): class TestPrMultipliers: def test_merged_pr_populates_all_multipliers(self): - from gittensor.constants import LABEL_MULTIPLIERS - - scoring_label = next(iter(LABEL_MULTIPLIERS.keys())) - labels = [{'name': scoring_label, 'actor_github_id': '1', 'actor_association': 'OWNER'}] + labels = [{'name': _SCORING_LABEL, 'actor_github_id': '1', 'actor_association': 'OWNER'}] scored = ScoredMirrorPR(pr=_pr(labels=labels)) scored.token_score = 100.0 # for completeness _calculate_pr_multipliers(scored, _config(weight=0.7, additional_branches=['test'])) assert scored.repo_weight_multiplier == 0.7 - assert scored.label == scoring_label.lower() - assert scored.label_multiplier == LABEL_MULTIPLIERS[scoring_label.lower()] + assert scored.label == _SCORING_LABEL.lower() + assert scored.label_multiplier == _SCORING_MULT assert 0.0 <= scored.time_decay_multiplier <= 1.0 assert scored.review_quality_multiplier == 1.0 # 0 maintainer changes_requested assert scored.issue_multiplier == 1.0 # no linked_issues diff --git a/tests/validator/test_label_multiplier.py b/tests/validator/test_label_multiplier.py index b3898b79a..73f78f382 100644 --- a/tests/validator/test_label_multiplier.py +++ b/tests/validator/test_label_multiplier.py @@ -1,17 +1,31 @@ import pytest from gittensor.classes import PullRequest -from gittensor.constants import LABEL_MULTIPLIERS +from gittensor.validator.oss_contributions.scoring import _resolve_label +from gittensor.validator.utils.load_weights import RepositoryConfig, resolve_label_multiplier - -@pytest.mark.parametrize('label,expected', list(LABEL_MULTIPLIERS.items())) -def test_known_labels(label, expected): - assert LABEL_MULTIPLIERS.get(label, 1.0) == expected - - -@pytest.mark.parametrize('label', ['docs', 'question', 'wontfix', 'kind/feature']) -def test_unknown_labels_return_default(label): - assert LABEL_MULTIPLIERS.get(label, 1.0) == 1.0 +# Repo config that mirrors the old global LABEL_MULTIPLIERS table, used to keep +# existing extraction test expectations unchanged. +_LEGACY_CONFIG = RepositoryConfig( + weight=1.0, + label_multipliers={ + 'feature': 1.50, + 'feat': 1.50, + 'bug': 1.25, + 'fix': 1.25, + 'crash': 1.25, + 'regression': 1.25, + 'security': 1.25, + 'enhancement': 1.10, + 'improve': 1.10, + 'perf': 1.10, + 'refactor': 0.5, + 'cleanup': 0.5, + 'polish': 0.5, + 'debt': 0.5, + 'chore': 0.5, + }, +) def _pr_payload(current, timeline): @@ -39,8 +53,11 @@ def _pr_payload(current, timeline): } -def _parse(current, timeline): - return PullRequest.from_graphql_response(_pr_payload(current, timeline), uid=0, hotkey='hk', github_id='gh').label +def _parse(current, timeline, config=_LEGACY_CONFIG): + """Construct a PR and run label resolution with *config*, return resolved label.""" + pr = PullRequest.from_graphql_response(_pr_payload(current, timeline), uid=0, hotkey='hk', github_id='gh') + resolved_label, _ = _resolve_label(pr.label_timeline_order, pr.current_labels, config) + return resolved_label @pytest.mark.parametrize( @@ -90,8 +107,263 @@ def test_label_extraction(current, timeline, expected): def test_missing_labels_key_does_not_crash(): - """Backward-compat: payloads without the new 'labels' key yield None.""" + """Backward-compat: payloads without the 'labels' key yield no resolved label.""" payload = _pr_payload([], ['feature']) del payload['labels'] pr = PullRequest.from_graphql_response(payload, uid=0, hotkey='hk', github_id='gh') - assert pr.label is None + resolved, _ = _resolve_label(pr.label_timeline_order, pr.current_labels, _LEGACY_CONFIG) + assert resolved is None + + +# ============================================================================ +# resolve_label_multiplier — fnmatch pattern matching +# ============================================================================ + + +@pytest.mark.parametrize( + 'pattern,label,expected', + [ + # Exact matches + ('bug', 'bug', 1.25), + ('feature', 'feature', 1.50), + # Wildcard: prefix glob + ('kind/*', 'kind/feature', 1.5), + ('kind/*', 'kind/bug-fix', 1.5), + ('kind/*', 'feature', None), + # Wildcard: suffix glob + ('*-dev', 'backend-dev', 0.5), + ('*-dev', 'frontend-dev', 0.5), + ('*-dev', 'backend-prod', None), + # Wildcard: type: prefix + ('type:*', 'type:bug-fix', 1.1), + ('type:*', 'type:feature', 1.1), + ('type:*', 'bug', None), + # Case insensitive + ('Bug', 'bug', 1.25), + ('BUG', 'Bug', 1.25), + # Release-prefixed labels + ('3.0/*', '3.0/feature', 2.0), + ('3.0/*', '4.0/feature', None), + # No config → always None + (None, 'bug', None), + ], +) +def test_resolve_label_multiplier_patterns(pattern, label, expected): + if pattern is None: + config = RepositoryConfig(weight=1.0) + else: + config = RepositoryConfig(weight=1.0, label_multipliers={pattern: expected or 1.25}) + result = resolve_label_multiplier(label, config) + if expected is None: + assert result is None + else: + assert result == (expected or 1.25) + + +def test_resolve_label_multiplier_no_config(): + assert resolve_label_multiplier('feature', None) is None + + +def test_resolve_label_multiplier_empty_map(): + config = RepositoryConfig(weight=1.0, label_multipliers={}) + assert resolve_label_multiplier('feature', config) is None + + +def test_resolve_label_multiplier_first_match_wins(): + """When multiple patterns match, first wins (dict ordering).""" + config = RepositoryConfig(weight=1.0, label_multipliers={'kind/*': 1.5, 'kind/bug': 0.5}) + assert resolve_label_multiplier('kind/bug', config) == 1.5 + + +# ============================================================================ +# _resolve_label — label + multiplier resolution from PR context +# ============================================================================ + + +def test_resolve_label_uses_timeline_order_first(): + config = RepositoryConfig(weight=1.0, label_multipliers={'bug': 1.25, 'feature': 1.5}) + # 'bug' was applied last in timeline even though 'feature' has higher multiplier + label, mult = _resolve_label(('bug', 'feature'), frozenset({'bug', 'feature'}), config) + assert label == 'bug' + assert mult == 1.25 + + +def test_resolve_label_skips_unmatched_timeline_entries(): + config = RepositoryConfig(weight=1.0, label_multipliers={'bug': 1.25}) + # 'lgtm' is first in timeline order but doesn't match; 'bug' is next and does + label, mult = _resolve_label(('lgtm', 'bug'), frozenset({'lgtm', 'bug'}), config) + assert label == 'bug' + assert mult == 1.25 + + +def test_resolve_label_non_scoring_last_then_multiple_scoring_picks_last_applied(): + """Key gap test: non-scoring label last + multiple scoring labels — last-applied scoring wins.""" + config = RepositoryConfig(weight=1.0, label_multipliers={'feature': 1.5, 'bug': 1.25}) + # Timeline (most recent first): lgtm, bug, feature — bug was applied after feature + label, mult = _resolve_label(('lgtm', 'bug', 'feature'), frozenset({'lgtm', 'bug', 'feature'}), config) + assert label == 'bug' # not 'feature' (higher mult) — last-applied scoring label wins + assert mult == 1.25 + + +def test_resolve_label_highest_multiplier_wins_from_truncated_current(): + config = RepositoryConfig(weight=1.0, label_multipliers={'bug': 1.25, 'feature': 1.5}) + # Empty timeline (all truncated) — fall back to highest multiplier + label, mult = _resolve_label((), frozenset({'bug', 'feature'}), config) + assert label == 'feature' + assert mult == 1.5 + + +def test_resolve_label_returns_default_when_no_match(): + config = RepositoryConfig(weight=1.0, label_multipliers={'bug': 1.25}, default_label_multiplier=0.8) + label, mult = _resolve_label((), frozenset({'lgtm'}), config) + assert label is None + assert mult == 0.8 + + +def test_resolve_label_default_mult_when_no_config(): + label, mult = _resolve_label((), frozenset({'feature'}), None) + assert label is None + assert mult == 1.0 + + +# ============================================================================ +# RepositoryConfig JSON data constraints +# ============================================================================ + + +@pytest.mark.parametrize('value', [0.0, 0.01, 1.0, 10.0, 20.0]) +def test_label_multiplier_value_valid_range(value): + """Values within [0.0, 20.0] are accepted.""" + config = RepositoryConfig(weight=1.0, label_multipliers={'bug': value}) + assert resolve_label_multiplier('bug', config) == value + + +@pytest.mark.parametrize('value', [-0.01, 20.01, 100.0, -1.0]) +def test_label_multiplier_value_out_of_range_not_enforced_at_config_level(value): + """RepositoryConfig itself does not clamp; validation is in load_master_repo_weights.""" + config = RepositoryConfig(weight=1.0, label_multipliers={'bug': value}) + # The dataclass accepts any float; range enforcement happens during JSON loading. + assert resolve_label_multiplier('bug', config) == value + + +@pytest.mark.parametrize('count', [1, 5, 10]) +def test_label_multipliers_within_entry_limit(count): + lm = {f'label-{i}': 1.0 for i in range(count)} + config = RepositoryConfig(weight=1.0, label_multipliers=lm) + assert config.label_multipliers is not None + assert len(config.label_multipliers) == count + + +@pytest.mark.parametrize('default', [0.0, 1.0, 5.0, 20.0]) +def test_default_label_multiplier_valid(default): + config = RepositoryConfig(weight=1.0, default_label_multiplier=default) + _, mult = _resolve_label((), frozenset(), config) + assert mult == default + + +# ============================================================================ +# load_master_repo_weights constraint enforcement +# ============================================================================ + + +def _load_from_dict(repo_dict: dict): + """Exercise load_master_repo_weights via a temp JSON file.""" + import json + import tempfile + from pathlib import Path + from unittest.mock import patch + + from gittensor.validator.utils.load_weights import load_master_repo_weights + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(repo_dict, f) + tmp_path = Path(f.name) + + weights_dir = tmp_path.parent + with patch('gittensor.validator.utils.load_weights._get_weights_dir', return_value=weights_dir): + # Rename to match the expected filename + target = weights_dir / 'master_repositories.json' + tmp_path.rename(target) + result = load_master_repo_weights() + target.unlink(missing_ok=True) + return result + + +def test_load_rejects_more_than_10_entries(): + lm = {f'label-{i}': 1.0 for i in range(11)} + repos = _load_from_dict({'owner/repo': {'weight': 1.0, 'label_multipliers': lm}}) + config = repos.get('owner/repo') + assert config is not None + assert config.label_multipliers is None + + +def test_load_skips_out_of_range_entry(): + repos = _load_from_dict({'owner/repo': {'weight': 1.0, 'label_multipliers': {'bug': 25.0, 'feature': 1.5}}}) + config = repos.get('owner/repo') + assert config is not None + assert config.label_multipliers is not None + assert 'bug' not in config.label_multipliers + assert config.label_multipliers.get('feature') == 1.5 + + +def test_load_resets_out_of_range_default(): + repos = _load_from_dict({'owner/repo': {'weight': 1.0, 'default_label_multiplier': 99.0}}) + config = repos.get('owner/repo') + assert config is not None + assert config.default_label_multiplier == 1.0 + + +def test_load_accepts_valid_config(): + repos = _load_from_dict( + { + 'owner/repo': { + 'weight': 1.0, + 'label_multipliers': {'kind/*': 1.5, 'type:*': 1.1}, + 'default_label_multiplier': 0.8, + } + } + ) + config = repos.get('owner/repo') + assert config is not None + assert config.label_multipliers == {'kind/*': 1.5, 'type:*': 1.1} + assert config.default_label_multiplier == 0.8 + + +# ============================================================================ +# End-to-end wildcard matching tests +# ============================================================================ + + +@pytest.mark.parametrize( + 'label_multipliers,current,timeline,expected_label,expected_mult', + [ + # kind/* pattern + ({'kind/*': 1.5}, ['kind/feature'], ['kind/feature'], 'kind/feature', 1.5), + ({'kind/*': 1.5}, ['kind/bug-fix', 'kind/feature'], ['kind/bug-fix', 'kind/feature'], 'kind/feature', 1.5), + ({'kind/*': 1.5}, ['size:m', 'kind/feature'], ['size:m', 'kind/feature'], 'kind/feature', 1.5), + # type:* pattern + ({'type:*': 1.1}, ['type:bug-fix'], ['type:bug-fix'], 'type:bug-fix', 1.1), + # *-dev suffix pattern + ({'*-dev': 0.5}, ['backend-dev'], ['backend-dev'], 'backend-dev', 0.5), + ({'*-dev': 0.5}, ['frontend-prod'], ['frontend-prod'], None, 1.0), + # Release-prefixed labels + ({'3.0/*': 2.0}, ['3.0/feature'], ['3.0/feature'], '3.0/feature', 2.0), + ({'3.0/*': 2.0}, ['4.0/feature'], ['4.0/feature'], None, 1.0), + # Multiple patterns: last applied wins if it matches + ( + {'kind/*': 1.5, 'type:*': 1.1}, + ['kind/feature', 'type:bug-fix'], + ['kind/feature', 'type:bug-fix'], + 'type:bug-fix', + 1.1, + ), + # No match → default multiplier applied + ({'kind/*': 1.5}, ['lgtm'], ['lgtm'], None, 1.0), + ], +) +def test_e2e_wildcard_matching(label_multipliers, current, timeline, expected_label, expected_mult): + config = RepositoryConfig(weight=1.0, label_multipliers=label_multipliers) + pr = PullRequest.from_graphql_response(_pr_payload(current, timeline), uid=0, hotkey='hk', github_id='gh') + resolved_label, resolved_mult = _resolve_label(pr.label_timeline_order, pr.current_labels, config) + assert resolved_label == expected_label + assert resolved_mult == pytest.approx(expected_mult) diff --git a/tests/validator/test_review_quality_multiplier.py b/tests/validator/test_review_quality_multiplier.py index 43d89b9ea..beaf022a6 100644 --- a/tests/validator/test_review_quality_multiplier.py +++ b/tests/validator/test_review_quality_multiplier.py @@ -249,7 +249,7 @@ def test_open_pr_review_collateral_multiplier_caps_at_two(self, builder): def _make_repo_config() -> dict: - return {'test/repo': RepositoryConfig(weight=1.0)} + return {'test/repo': RepositoryConfig(weight=1.0, label_multipliers={'fix': 1.25})} def _make_eval() -> MinerEvaluation: @@ -260,7 +260,8 @@ class TestReviewCollateralThroughScoringPipeline: def test_open_pr_collateral_uses_collateral_review_multiplier(self, builder): pr = builder.create(state=PRState.OPEN, repo='test/repo') pr.base_score = 80.0 - pr.label = 'fix' + pr.label_timeline_order = ('fix',) + pr.current_labels = frozenset({'fix'}) pr.changes_requested_count = 3 calculate_pr_multipliers(pr, _make_eval(), _make_repo_config())