diff --git a/gittensor/constants.py b/gittensor/constants.py index 66936f79..01f2d39b 100644 --- a/gittensor/constants.py +++ b/gittensor/constants.py @@ -74,7 +74,7 @@ # ============================================================================= # Repository & PR Scoring # ============================================================================= -PR_LOOKBACK_DAYS = 35 # rolling window for scoring +PR_LOOKBACK_DAYS = 30 # rolling window for scoring (per-repo default, overridable in the scoring config) MERGED_PR_BASE_SCORE = 25 MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5 # PRs below this get 0 base score MAX_CONTRIBUTION_BONUS = 25 @@ -129,13 +129,9 @@ # ============================================================================= # Eligibility gate — per-repo defaults, overridable in master_repositories.json. MIN_VALID_SOLVED_ISSUES = 3 # minimum solved issues where solving PR has token_score >= MIN_TOKEN_SCORE_FOR_VALID_ISSUE -MIN_ISSUE_CREDIBILITY = 0.70 # minimum issue credibility ratio +MIN_ISSUE_CREDIBILITY = 0.80 # minimum issue credibility ratio MIN_TOKEN_SCORE_FOR_VALID_ISSUE = 5 # solving-PR token_score for a solved issue to count as "valid" -# Review quality cliff model (different from OSS: has clean bonus + steeper penalty) -ISSUE_REVIEW_CLEAN_BONUS = 1.1 # multiplier when 0 CHANGES_REQUESTED rounds -ISSUE_REVIEW_PENALTY_RATE = 0.15 # per CHANGES_REQUESTED round after cliff - # Open issue spam threshold (per-repo: counts a repo's own open issues) OPEN_ISSUE_SPAM_BASE_THRESHOLD = 2 OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT = 300.0 # +1 allowed open issue per this much token score diff --git a/gittensor/utils/github_api_tools.py b/gittensor/utils/github_api_tools.py index 19e95167..7dc86284 100644 --- a/gittensor/utils/github_api_tools.py +++ b/gittensor/utils/github_api_tools.py @@ -3,7 +3,6 @@ import time from dataclasses import dataclass from enum import Enum -from math import ceil from typing import Any, Dict, List, Optional import bittensor as bt @@ -12,14 +11,10 @@ from gittensor.constants import ( BASE_GITHUB_API_URL, GITHUB_HTTP_TIMEOUT_SECONDS, - REVIEW_PENALTY_RATE, ) from gittensor.utils.models import PRInfo from gittensor.utils.utils import backoff_seconds -# Beyond this many CHANGES_REQUESTED reviews the quality multiplier is already 0 -_MAX_CHANGES_REQUESTED_REVIEWS = ceil(1 / REVIEW_PENALTY_RATE) - class GitHubIdentityStatus(Enum): VALID = 'VALID' diff --git a/gittensor/utils/mirror/client.py b/gittensor/utils/mirror/client.py index 91318ae6..d365ef8c 100644 --- a/gittensor/utils/mirror/client.py +++ b/gittensor/utils/mirror/client.py @@ -7,7 +7,7 @@ import time from datetime import datetime, timezone -from typing import Optional +from typing import Dict, Optional import bittensor as bt import requests @@ -64,35 +64,40 @@ def __exit__(self, exc_type, exc, tb) -> None: def get_miner_pulls( self, github_id: str, - since: Optional[datetime] = None, + since_by_repo: Optional[Dict[str, datetime]] = None, ) -> MirrorPullRequestsResponse: - """Fetch every tracked PR authored by ``github_id`` since the given - datetime. If ``since`` is omitted the mirror defaults to 35 days back. - Response contains all mirror-tracked repos; caller must filter to the - scoring config's registered subset. + """Fetch tracked PRs authored by ``github_id``. + + With ``since_by_repo`` (repo full name -> cutoff datetime), POSTs the + per-repo window map; the response is restricted to those repos, each + windowed to its own cutoff. Without it, GETs the mirror's default + window across all tracked repos. """ path = f'/api/v1/miners/{github_id}/pulls' - params = {'since': since.astimezone(timezone.utc).isoformat()} if since else None - data = self._get(path, params=params) + data = self._fetch_windowed(path, since_by_repo) try: return MirrorPullRequestsResponse.from_dict(data) except Exception as e: - raise MirrorRequestError(f'Mirror GET {path} returned invalid mirror response: {e}') from e + raise MirrorRequestError(f'Mirror response from {path} was invalid: {e}') from e def get_miner_issues( self, github_id: str, - since: Optional[datetime] = None, + since_by_repo: Optional[Dict[str, datetime]] = None, ) -> MirrorIssuesResponse: - """Fetch issues authored by ``github_id`` since the given datetime, - each with an inline ``solving_pr`` when ``solved_by_pr`` is populated.""" + """Fetch issues authored by ``github_id``, each with an inline + ``solving_pr`` when ``solved_by_pr`` is populated. + + With ``since_by_repo``, POSTs the per-repo window map (the scoring + window). Without it, GETs all currently-open issues unbounded — the + open-issue-count path. + """ path = f'/api/v1/miners/{github_id}/issues' - params = {'since': since.astimezone(timezone.utc).isoformat()} if since else None - data = self._get(path, params=params) + data = self._fetch_windowed(path, since_by_repo) try: return MirrorIssuesResponse.from_dict(data) except Exception as e: - raise MirrorRequestError(f'Mirror GET {path} returned invalid mirror response: {e}') from e + raise MirrorRequestError(f'Mirror response from {path} was invalid: {e}') from e def get_pr_files( self, @@ -109,7 +114,7 @@ def get_pr_files( try: return MirrorPullRequestFilesResponse.from_dict(data) except Exception as e: - raise MirrorRequestError(f'Mirror GET {path} returned invalid mirror response: {e}') from e + raise MirrorRequestError(f'Mirror response from {path} was invalid: {e}') from e def get_repo_maintainers(self, repo_full_name: str) -> MirrorRepoMaintainersResponse: """Fetch users whose latest known GitHub association for @@ -123,21 +128,46 @@ def get_repo_maintainers(self, repo_full_name: str) -> MirrorRepoMaintainersResp try: return MirrorRepoMaintainersResponse.from_dict(data) except Exception as e: - raise MirrorRequestError(f'Mirror GET {path} returned invalid mirror response: {e}') from e + raise MirrorRequestError(f'Mirror response from {path} was invalid: {e}') from e + + def _fetch_windowed(self, path: str, since_by_repo: Optional[Dict[str, datetime]]) -> dict: + """POST a per-repo ``since`` map when one is given, else GET the + mirror's default window.""" + if since_by_repo: + body = { + 'since_by_repo': {repo: dt.astimezone(timezone.utc).isoformat() for repo, dt in since_by_repo.items()} + } + return self._post(path, body) + return self._get(path) def _get(self, path: str, params: Optional[dict] = None) -> dict: + return self._request('GET', path, params=params) + + def _post(self, path: str, json_body: dict) -> dict: + return self._request('POST', path, json_body=json_body) + + def _request( + self, + method: str, + path: str, + params: Optional[dict] = None, + json_body: Optional[dict] = None, + ) -> dict: url = f'{self.base_url}{path}' last_error: Optional[str] = None for attempt in range(self.max_attempts): try: - response = self.session.get(url, params=params, timeout=self.timeout) + if method == 'POST': + response = self.session.post(url, json=json_body, timeout=self.timeout) + else: + response = self.session.get(url, params=params, timeout=self.timeout) except requests.RequestException as e: last_error = f'request exception: {e}' if attempt < self.max_attempts - 1: backoff = backoff_seconds(attempt) bt.logging.warning( - f'Mirror GET {path} raised {e} ' + f'Mirror {method} {path} raised {e} ' f'(attempt {attempt + 1}/{self.max_attempts}), retrying in {backoff}s...' ) time.sleep(backoff) @@ -151,7 +181,7 @@ def _get(self, path: str, params: Optional[dict] = None) -> dict: if attempt < self.max_attempts - 1: backoff = backoff_seconds(attempt) bt.logging.warning( - f'Mirror GET {path} failed ({last_error}) ' + f'Mirror {method} {path} failed ({last_error}) ' f'(attempt {attempt + 1}/{self.max_attempts}), retrying in {backoff}s...' ) time.sleep(backoff) @@ -160,16 +190,16 @@ def _get(self, path: str, params: Optional[dict] = None) -> dict: # 4xx except 429 are not retryable — fail fast so callers see the real error. if 400 <= response.status_code < 500 and response.status_code != 429: raise MirrorRequestError( - f'Mirror GET {path} returned {response.status_code}: {_body_preview(response)}' + f'Mirror {method} {path} returned {response.status_code}: {_body_preview(response)}' ) last_error = f'status {response.status_code}: {_body_preview(response)}' if attempt < self.max_attempts - 1: backoff = backoff_seconds(attempt) bt.logging.warning( - f'Mirror GET {path} failed ({last_error}) ' + f'Mirror {method} {path} failed ({last_error}) ' f'(attempt {attempt + 1}/{self.max_attempts}), retrying in {backoff}s...' ) time.sleep(backoff) - raise MirrorRequestError(f'Mirror GET {path} failed after {self.max_attempts} attempts: {last_error}') + raise MirrorRequestError(f'Mirror {method} {path} failed after {self.max_attempts} attempts: {last_error}') diff --git a/gittensor/validator/issue_discovery/scan.py b/gittensor/validator/issue_discovery/scan.py index 8c0062ad..c30a12e3 100644 --- a/gittensor/validator/issue_discovery/scan.py +++ b/gittensor/validator/issue_discovery/scan.py @@ -44,7 +44,6 @@ from gittensor.constants import ( MAINTAINER_ASSOCIATIONS, MIN_TOKEN_SCORE_FOR_BASE_SCORE, - PR_LOOKBACK_DAYS, ) from gittensor.utils.mirror.client import MirrorClient, MirrorRequestError from gittensor.utils.mirror.models import MirrorIssue, MirrorSolvingPR @@ -63,6 +62,7 @@ RepositoryConfig, TokenConfig, resolve_eligibility, + resolve_scoring, ) @@ -137,7 +137,7 @@ async def run_issue_discovery( For each miner, fetches their authored issues via the mirror and classifies each. Issues in repos not present in ``mirror_repos`` are filtered out - client-side (mirror returns all tracked repos; the master list may be narrower). + client-side. Depends on OSS scoring (``score_miner_prs``) having already run for this cycle — the cross-miner solving-PR cache is built by walking every @@ -153,7 +153,12 @@ async def run_issue_discovery( return client = client or MirrorClient() - lookback_date = datetime.now(timezone.utc) - timedelta(days=PR_LOOKBACK_DAYS) + now = datetime.now(timezone.utc) + # Each repo is windowed by its own pr_lookback_days; the mirror applies the + # per-repo cutoffs server-side for the scoring fetch. + since_by_repo = { + name: now - timedelta(days=resolve_scoring(rc.scoring).pr_lookback_days) for name, rc in mirror_repos.items() + } enabled_names: Set[str] = set(mirror_repos.keys()) solving_pr_cache: Dict[Tuple[str, int], CachedSolvingPR] = _build_solving_pr_cache(miner_evaluations) @@ -181,7 +186,9 @@ async def run_issue_discovery( continue try: - response = await asyncio.to_thread(client.get_miner_issues, evaluation.github_id, since=lookback_date) + response = await asyncio.to_thread( + client.get_miner_issues, evaluation.github_id, since_by_repo=since_by_repo + ) except MirrorRequestError as e: bt.logging.warning(f'├─ UID {uid}: issue fetch failed ({e}) — skipped this miner') _restore_issue_discovery_from_cache(evaluation, evaluation_cache) @@ -733,10 +740,16 @@ def _mirror_issue_for_scoring( body_or_title_edited_at=None, ) + scoring_cfg = resolve_scoring(repo_config.scoring) adapted.discovery_base_score = base_score - adapted.discovery_time_decay_multiplier = round(calculate_time_decay(solving_pr.merged_at), 2) + adapted.discovery_time_decay_multiplier = round( + calculate_time_decay(solving_pr.merged_at, scoring_cfg.time_decay), 2 + ) adapted.discovery_review_quality_multiplier = round( - calculate_issue_review_quality_multiplier(solving_pr.review_summary.maintainer_changes_requested_count), + calculate_issue_review_quality_multiplier( + solving_pr.review_summary.maintainer_changes_requested_count, + scoring_cfg.review_penalty_rate, + ), 2, ) diff --git a/gittensor/validator/issue_discovery/scoring.py b/gittensor/validator/issue_discovery/scoring.py index 8b2cadd7..57e40876 100644 --- a/gittensor/validator/issue_discovery/scoring.py +++ b/gittensor/validator/issue_discovery/scoring.py @@ -15,27 +15,18 @@ import bittensor as bt -from gittensor.constants import ( - ISSUE_REVIEW_CLEAN_BONUS, - ISSUE_REVIEW_PENALTY_RATE, -) - if TYPE_CHECKING: from gittensor.validator.utils.load_weights import ResolvedEligibility -def calculate_issue_review_quality_multiplier(changes_requested_count: int) -> float: - """Cliff model: clean bonus when 0 changes requested, then linear penalty. +def calculate_issue_review_quality_multiplier(changes_requested_count: int, review_penalty_rate: float) -> float: + """Linear penalty on the solving PR's maintainer CHANGES_REQUESTED rounds. - 0 rounds → 1.1 (clean bonus) + 0 rounds → 1.0 1 round → 0.85 - 2 rounds → 0.70 7+ rounds → 0.0 """ - if changes_requested_count == 0: - multiplier = ISSUE_REVIEW_CLEAN_BONUS - else: - multiplier = max(0.0, 1.0 - ISSUE_REVIEW_PENALTY_RATE * changes_requested_count) + multiplier = max(0.0, 1.0 - review_penalty_rate * changes_requested_count) bt.logging.info( f'{changes_requested_count} solving-PR CHANGES_REQUESTED review(s) → ' f'issue_review_quality_multiplier={multiplier:.2f}' diff --git a/gittensor/validator/oss_contributions/mirror/load.py b/gittensor/validator/oss_contributions/mirror/load.py index fb891e79..029a77ee 100644 --- a/gittensor/validator/oss_contributions/mirror/load.py +++ b/gittensor/validator/oss_contributions/mirror/load.py @@ -2,13 +2,13 @@ The mirror returns one bundle per PR (with all scoring inputs inlined), so loading is a single HTTP call regardless of how many repos the miner has -touched. +touched. The call sends each repo's ``pr_lookback_days`` window, so the mirror +applies the per-repo time cutoffs server-side and returns only in-window PRs. Filtering applied at load time: -- Repo not in master_repositories: dropped (mirror returns all tracked repos). +- Repo not in master_repositories: dropped (defensive — the per-repo request + already scopes the response to the registered repos). - PR author is a maintainer (OWNER/MEMBER/COLLABORATOR): silently dropped. -- CLOSED PRs created before the lookback window: dropped — closing an old PR - shouldn't trigger a fresh credibility penalty. - MERGED PRs that fail ``_should_skip_merged_mirror_pr`` (base_ref, head_ref, self-merge w/o approval, etc.): dropped. Applied at LOAD time so the merged_count used by ``check_eligibility`` isn't inflated by ineligible PRs. @@ -21,12 +21,12 @@ import bittensor as bt from gittensor.classes import MinerEvaluation -from gittensor.constants import MAINTAINER_ASSOCIATIONS, PR_LOOKBACK_DAYS +from gittensor.constants import MAINTAINER_ASSOCIATIONS from gittensor.utils.mirror.client import MirrorClient, MirrorRequestError from gittensor.utils.mirror.models import MirrorPullRequest from gittensor.validator.oss_contributions.mirror.scored_pr import ScoredPR from gittensor.validator.oss_contributions.mirror.scoring import _should_skip_merged_mirror_pr -from gittensor.validator.utils.load_weights import RepositoryConfig +from gittensor.validator.utils.load_weights import RepositoryConfig, resolve_scoring def load_miner_prs( @@ -56,10 +56,16 @@ def load_miner_prs( return client = client or MirrorClient() - lookback_date = datetime.now(timezone.utc) - timedelta(days=PR_LOOKBACK_DAYS) + now = datetime.now(timezone.utc) + # Each repo is windowed by its own pr_lookback_days; the mirror applies the + # per-repo cutoffs server-side and returns only in-window PRs. + since_by_repo = { + name: now - timedelta(days=resolve_scoring(rc.scoring).pr_lookback_days) + for name, rc in master_repositories.items() + } try: - response = client.get_miner_pulls(eval_.github_id, since=lookback_date) + response = client.get_miner_pulls(eval_.github_id, since_by_repo=since_by_repo) except MirrorRequestError as e: bt.logging.error(f'PR fetch failed for UID {eval_.uid}: {e}') eval_.mirror_pr_fetch_failed = True @@ -68,7 +74,7 @@ def load_miner_prs( for pr in response.pull_requests: try: - _maybe_add_pr(eval_, pr, master_repositories, lookback_date) + _maybe_add_pr(eval_, pr, master_repositories) except Exception as e: bt.logging.warning(f'Error processing PR #{pr.pr_number} ({pr.repo_full_name}): {e}') @@ -81,14 +87,17 @@ def _maybe_add_pr( eval_: MinerEvaluation, pr: MirrorPullRequest, master_repositories: Dict[str, RepositoryConfig], - lookback_date: datetime, ) -> None: - """Apply load-time filters and bucket pr by state if it passes.""" + """Apply load-time filters and bucket pr by state if it passes. + + Time-windowing (each repo's ``pr_lookback_days``) is applied by the mirror, + so every PR here is already inside its repo's window. + """ repo_config = master_repositories.get(pr.repo_full_name) if repo_config is None: - # Mirror tracks more repos than the scoring set; skip-noise dominates the - # log at info level when master_repositories is small. Demoted to debug. + # Defensive: the per-repo request already scopes the response, but a + # stray repo would otherwise have no config to score against. bt.logging.debug(f'Skipping PR #{pr.pr_number} in {pr.repo_full_name} - not in master_repositories') return @@ -100,10 +109,6 @@ def _maybe_add_pr( if pr.state == 'OPEN': eval_.open_prs.append(ScoredPR(pr=pr)) elif pr.state == 'CLOSED': - # Skip stale CLOSED PRs created before the lookback window — closing an - # old PR shouldn't trigger a fresh credibility penalty. - if pr.created_at < lookback_date: - return eval_.closed_prs.append(ScoredPR(pr=pr)) elif pr.state == 'MERGED': # Apply the merge-eligibility gate at LOAD time so the merged_count used diff --git a/gittensor/validator/oss_contributions/mirror/scoring.py b/gittensor/validator/oss_contributions/mirror/scoring.py index 9e4c97b2..c079c595 100644 --- a/gittensor/validator/oss_contributions/mirror/scoring.py +++ b/gittensor/validator/oss_contributions/mirror/scoring.py @@ -32,13 +32,11 @@ from gittensor.constants import ( CONTRIBUTION_SCORE_FOR_FULL_BONUS, MAINTAINER_ASSOCIATIONS, - MAINTAINER_ISSUE_MULTIPLIER, MAX_CONTRIBUTION_BONUS, MAX_ISSUE_CLOSE_WINDOW_DAYS, MERGED_PR_BASE_SCORE, MIN_TOKEN_SCORE_FOR_BASE_SCORE, SECONDS_PER_DAY, - STANDARD_ISSUE_MULTIPLIER, ) from gittensor.utils.github_api_tools import FileContentPair, branch_matches_pattern from gittensor.utils.mirror.client import MirrorClient, MirrorRequestError @@ -53,7 +51,9 @@ from gittensor.validator.utils.load_weights import ( LanguageConfig, RepositoryConfig, + ResolvedScoring, TokenConfig, + resolve_scoring, ) from gittensor.validator.utils.tree_sitter_scoring import calculate_token_score_from_file_changes @@ -349,19 +349,24 @@ def _calculate_pr_multipliers(scored: ScoredPR, repo_config: RepositoryConfig) - """ pr = scored.pr is_merged = pr.state == 'MERGED' + scoring_cfg = resolve_scoring(repo_config.scoring) chosen_label, label_multiplier = _resolve_trusted_scoring_label(pr, repo_config) scored.label = chosen_label scored.label_multiplier = label_multiplier - scored.issue_multiplier = round(_calculate_issue_multiplier(scored), 2) + scored.issue_multiplier = round(_calculate_issue_multiplier(scored, scoring_cfg), 2) if is_merged: assert pr.merged_at is not None, f'MERGED PR #{pr.pr_number} missing merged_at' scored.open_pr_spam_multiplier = 1.0 # finalized later with combined open-PR count - scored.time_decay_multiplier = round(calculate_time_decay(pr.merged_at), 2) + scored.time_decay_multiplier = round(calculate_time_decay(pr.merged_at, scoring_cfg.time_decay), 2) scored.review_quality_multiplier = round( - calculate_review_quality_multiplier(pr.review_summary.maintainer_changes_requested_count, pr.pr_number), + calculate_review_quality_multiplier( + pr.review_summary.maintainer_changes_requested_count, + scoring_cfg.review_penalty_rate, + pr.pr_number, + ), 2, ) else: @@ -398,11 +403,11 @@ def _resolve_trusted_scoring_label(pr: MirrorPullRequest, repo_config: Repositor # ============================================================================ -def _calculate_issue_multiplier(scored: ScoredPR) -> float: +def _calculate_issue_multiplier(scored: ScoredPR, scoring: ResolvedScoring) -> float: """Return the multiplier earned from valid linked issues on a PR. Maintainer-authored valid issues bump the multiplier higher - (``MAINTAINER_ISSUE_MULTIPLIER`` vs ``STANDARD_ISSUE_MULTIPLIER``). + (``maintainer_issue_multiplier`` vs ``standard_issue_multiplier``). Returns 1.0 if no linked issues pass the anti-gaming gates. """ pr = scored.pr @@ -422,7 +427,7 @@ def _calculate_issue_multiplier(scored: ScoredPR) -> float: valid[0], ) is_maintainer = issue.author_association in MAINTAINER_ASSOCIATIONS if issue.author_association else False - multiplier = MAINTAINER_ISSUE_MULTIPLIER if is_maintainer else STANDARD_ISSUE_MULTIPLIER + multiplier = scoring.maintainer_issue_multiplier if is_maintainer else scoring.standard_issue_multiplier label = 'maintainer' if is_maintainer else 'standard' bt.logging.info(f'Linked issue #{issue.number} - {label} | multiplier: {multiplier}') return multiplier diff --git a/gittensor/validator/oss_contributions/scoring.py b/gittensor/validator/oss_contributions/scoring.py index 25320ab8..81a3d15f 100644 --- a/gittensor/validator/oss_contributions/scoring.py +++ b/gittensor/validator/oss_contributions/scoring.py @@ -9,21 +9,25 @@ if TYPE_CHECKING: from gittensor.validator.oss_contributions.mirror.scored_pr import ScoredPR -from gittensor.constants import ( - MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER, - OPEN_PR_COLLATERAL_PERCENT, - REVIEW_PENALTY_RATE, -) +from gittensor.constants import MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER from gittensor.validator.oss_contributions.credibility import check_eligibility -from gittensor.validator.utils.load_weights import RepositoryConfig, ResolvedEligibility, resolve_eligibility +from gittensor.validator.utils.load_weights import ( + RepositoryConfig, + ResolvedEligibility, + ResolvedScoring, + resolve_eligibility, + resolve_scoring, +) -def calculate_review_quality_multiplier(changes_requested_count: int, pr_number: Optional[int] = None) -> float: +def calculate_review_quality_multiplier( + changes_requested_count: int, review_penalty_rate: float, pr_number: Optional[int] = None +) -> float: """Calculate the review quality multiplier based on maintainer CHANGES_REQUESTED reviews. - Formula: max(0.0, 1.0 - REVIEW_PENALTY_RATE × N) + Formula: max(0.0, 1.0 - review_penalty_rate × N) """ - multiplier = max(0.0, 1.0 - REVIEW_PENALTY_RATE * changes_requested_count) + multiplier = max(0.0, 1.0 - review_penalty_rate * changes_requested_count) if changes_requested_count > 0: ctx = f' (PR #{pr_number})' if pr_number else '' bt.logging.info( @@ -33,16 +37,18 @@ def calculate_review_quality_multiplier(changes_requested_count: int, pr_number: return multiplier -def calculate_review_collateral_multiplier(changes_requested_count: int, pr_number: Optional[int] = None) -> float: +def calculate_review_collateral_multiplier( + changes_requested_count: int, review_penalty_rate: float, pr_number: Optional[int] = None +) -> float: """Calculate the open-PR collateral multiplier from maintainer CHANGES_REQUESTED reviews. Unlike ``review_quality_multiplier`` for earned scores, this increases collateral so non-merge-ready open PRs reserve more score instead of less. - Formula: min(MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER, 1.0 + REVIEW_PENALTY_RATE × N) + Formula: min(MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER, 1.0 + review_penalty_rate × N) """ multiplier = min( MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER, - 1.0 + REVIEW_PENALTY_RATE * changes_requested_count, + 1.0 + review_penalty_rate * changes_requested_count, ) if changes_requested_count > 0: ctx = f' (PR #{pr_number})' if pr_number else '' @@ -97,7 +103,9 @@ def finalize_miner_scores( bt.logging.info('=' * 50) for pr in evaluation.open_prs: - pr.collateral_score = calculate_open_pr_collateral_score(pr) + repo_config = master_repositories.get(pr.repository_full_name.lower()) + scoring_cfg = resolve_scoring(repo_config.scoring if repo_config else None) + pr.collateral_score = calculate_open_pr_collateral_score(pr, scoring_cfg) _score_miner_repos(evaluation, master_repositories) _roll_up_miner_totals(evaluation) @@ -209,11 +217,11 @@ def _roll_up_miner_totals(evaluation: MinerEvaluation) -> None: bt.logging.info(f'└─ Eligible in {eligible_repos}/{len(repo_evals)} repo(s)') -def calculate_open_pr_collateral_score(pr: 'ScoredPR') -> float: +def calculate_open_pr_collateral_score(pr: 'ScoredPR', scoring: ResolvedScoring) -> float: """ Calculate collateral score for an open PR. - Collateral = base_score * applicable_multipliers * OPEN_PR_COLLATERAL_PERCENT + Collateral = base_score * applicable_multipliers * open_pr_collateral_percent Applicable multipliers: issue, label, review_collateral NOT applicable: time_decay (merge-based), credibility_multiplier (merge-based), @@ -224,16 +232,19 @@ def calculate_open_pr_collateral_score(pr: 'ScoredPR') -> float: multipliers = { 'issue': pr.issue_multiplier, 'label': pr.label_multiplier, - 'review_collateral': calculate_review_collateral_multiplier(pr.changes_requested_count, pr.number), + 'review_collateral': calculate_review_collateral_multiplier( + pr.changes_requested_count, scoring.review_penalty_rate, pr.number + ), } potential_score = pr.base_score * prod(multipliers.values()) - collateral_score = potential_score * OPEN_PR_COLLATERAL_PERCENT + collateral_score = potential_score * scoring.open_pr_collateral_percent mult_str = ' | '.join([f'{k}: {v:.2f}' for k, v in multipliers.items()]) bt.logging.info( f'OPEN PR #{pr.number} | base: {pr.base_score:.2f} | {mult_str} | ' - f'potential: {potential_score:.2f} | collateral ({OPEN_PR_COLLATERAL_PERCENT * 100:.0f}%): {collateral_score:.2f}' + f'potential: {potential_score:.2f} ' + f'| collateral ({scoring.open_pr_collateral_percent * 100:.0f}%): {collateral_score:.2f}' ) return collateral_score diff --git a/gittensor/validator/utils/datetime_utils.py b/gittensor/validator/utils/datetime_utils.py index a7254446..6d8ef6c5 100644 --- a/gittensor/validator/utils/datetime_utils.py +++ b/gittensor/validator/utils/datetime_utils.py @@ -4,13 +4,8 @@ import pytz -from gittensor.constants import ( - SECONDS_PER_HOUR, - TIME_DECAY_GRACE_PERIOD_HOURS, - TIME_DECAY_MIN_MULTIPLIER, - TIME_DECAY_SIGMOID_MIDPOINT, - TIME_DECAY_SIGMOID_STEEPNESS_SCALAR, -) +from gittensor.constants import SECONDS_PER_HOUR +from gittensor.validator.utils.load_weights import ResolvedTimeDecay CHICAGO_TZ = pytz.timezone('America/Chicago') @@ -47,14 +42,14 @@ def parse_github_timestamp_to_cst(timestamp_str: str) -> datetime: return parse_github_iso_to_utc(timestamp_str).astimezone(CHICAGO_TZ) -def calculate_time_decay(merged_at: datetime) -> float: +def calculate_time_decay(merged_at: datetime, time_decay: ResolvedTimeDecay) -> float: """Calculate sigmoid-based time decay multiplier from a merge timestamp.""" now = datetime.now(timezone.utc) hours_since_merge = (now - merged_at).total_seconds() / SECONDS_PER_HOUR - if hours_since_merge < TIME_DECAY_GRACE_PERIOD_HOURS: + if hours_since_merge < time_decay.grace_period_hours: return 1.0 days_since_merge = hours_since_merge / 24 - sigmoid = 1 / (1 + math.exp(TIME_DECAY_SIGMOID_STEEPNESS_SCALAR * (days_since_merge - TIME_DECAY_SIGMOID_MIDPOINT))) - return max(sigmoid, TIME_DECAY_MIN_MULTIPLIER) + sigmoid = 1 / (1 + math.exp(time_decay.sigmoid_steepness * (days_since_merge - time_decay.sigmoid_midpoint_days))) + return max(sigmoid, time_decay.min_multiplier) diff --git a/gittensor/validator/utils/load_weights.py b/gittensor/validator/utils/load_weights.py index 8c8c87c4..8a2f7ec1 100644 --- a/gittensor/validator/utils/load_weights.py +++ b/gittensor/validator/utils/load_weights.py @@ -11,6 +11,7 @@ DEFAULT_ISSUE_DISCOVERY_SHARE, EMISSION_SHARE_TOLERANCE, EXCESSIVE_PR_PENALTY_BASE_THRESHOLD, + MAINTAINER_ISSUE_MULTIPLIER, MAX_OPEN_ISSUE_THRESHOLD, MAX_OPEN_PR_THRESHOLD, MIN_CREDIBILITY, @@ -22,7 +23,15 @@ NON_CODE_EXTENSIONS, OPEN_ISSUE_SPAM_BASE_THRESHOLD, OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT, + OPEN_PR_COLLATERAL_PERCENT, OPEN_PR_THRESHOLD_TOKEN_SCORE, + PR_LOOKBACK_DAYS, + REVIEW_PENALTY_RATE, + STANDARD_ISSUE_MULTIPLIER, + TIME_DECAY_GRACE_PERIOD_HOURS, + TIME_DECAY_MIN_MULTIPLIER, + TIME_DECAY_SIGMOID_MIDPOINT, + TIME_DECAY_SIGMOID_STEEPNESS_SCALAR, ) @@ -79,6 +88,54 @@ class ResolvedEligibility: max_open_issue_threshold: int +@dataclass +class RepoTimeDecayConfig: + """Per-repo overrides for the time-decay curve. Every field optional.""" + + grace_period_hours: Optional[int] = None + sigmoid_midpoint_days: Optional[float] = None + sigmoid_steepness: Optional[float] = None + min_multiplier: Optional[float] = None + + +@dataclass(frozen=True) +class ResolvedTimeDecay: + """A ``RepoTimeDecayConfig`` with every override resolved to a concrete value.""" + + grace_period_hours: int + sigmoid_midpoint_days: float + sigmoid_steepness: float + min_multiplier: float + + +@dataclass +class RepoScoringConfig: + """Per-repo overrides for the scoring knobs. + + Every field is optional; ``None`` means "use the global default constant". + Resolve a config into concrete values with ``resolve_scoring``. + """ + + pr_lookback_days: Optional[int] = None + open_pr_collateral_percent: Optional[float] = None + review_penalty_rate: Optional[float] = None + standard_issue_multiplier: Optional[float] = None + maintainer_issue_multiplier: Optional[float] = None + time_decay: RepoTimeDecayConfig = field(default_factory=RepoTimeDecayConfig) + + +@dataclass(frozen=True) +class ResolvedScoring: + """A ``RepoScoringConfig`` with every override resolved to a concrete value.""" + + pr_lookback_days: int + open_pr_collateral_percent: float + review_penalty_rate: float + standard_issue_multiplier: float + maintainer_issue_multiplier: float + time_decay: ResolvedTimeDecay + + @dataclass class RepositoryConfig: """Configuration for a repository in the master_repositories list. @@ -100,6 +157,8 @@ class RepositoryConfig: eligibility: Per-repo overrides for the eligibility / spam knobs. Unset fields fall back to the global default constants — see ``resolve_eligibility``. + scoring: Per-repo overrides for the scoring knobs. Unset fields fall + back to the global default constants — see ``resolve_scoring``. maintainer_cut: Fraction [0.0, 1.0] of this repo's emission slice routed directly to its maintainer miner neurons, split evenly, before normal scoring. Defaults to 0.0 (no carve-out). @@ -114,6 +173,7 @@ class RepositoryConfig: default_label_multiplier: float = 1.0 fixed_base_score: Optional[float] = None eligibility: RepoEligibilityConfig = field(default_factory=RepoEligibilityConfig) + scoring: RepoScoringConfig = field(default_factory=RepoScoringConfig) maintainer_cut: float = 0.0 @@ -146,6 +206,38 @@ def pick(value: Any, default: Any) -> Any: ) +def resolve_time_decay(cfg: Optional[RepoTimeDecayConfig]) -> ResolvedTimeDecay: + """Overlay a repo's time-decay overrides onto the global default constants.""" + cfg = cfg or RepoTimeDecayConfig() + + def pick(value: Any, default: Any) -> Any: + return default if value is None else value + + return ResolvedTimeDecay( + grace_period_hours=int(pick(cfg.grace_period_hours, TIME_DECAY_GRACE_PERIOD_HOURS)), + sigmoid_midpoint_days=float(pick(cfg.sigmoid_midpoint_days, TIME_DECAY_SIGMOID_MIDPOINT)), + sigmoid_steepness=float(pick(cfg.sigmoid_steepness, TIME_DECAY_SIGMOID_STEEPNESS_SCALAR)), + min_multiplier=float(pick(cfg.min_multiplier, TIME_DECAY_MIN_MULTIPLIER)), + ) + + +def resolve_scoring(cfg: Optional[RepoScoringConfig]) -> ResolvedScoring: + """Overlay a repo's scoring overrides onto the global default constants.""" + cfg = cfg or RepoScoringConfig() + + def pick(value: Any, default: Any) -> Any: + return default if value is None else value + + return ResolvedScoring( + pr_lookback_days=int(pick(cfg.pr_lookback_days, PR_LOOKBACK_DAYS)), + open_pr_collateral_percent=float(pick(cfg.open_pr_collateral_percent, OPEN_PR_COLLATERAL_PERCENT)), + review_penalty_rate=float(pick(cfg.review_penalty_rate, REVIEW_PENALTY_RATE)), + standard_issue_multiplier=float(pick(cfg.standard_issue_multiplier, STANDARD_ISSUE_MULTIPLIER)), + maintainer_issue_multiplier=float(pick(cfg.maintainer_issue_multiplier, MAINTAINER_ISSUE_MULTIPLIER)), + time_decay=resolve_time_decay(cfg.time_decay), + ) + + @dataclass class TokenConfig: """Configuration for token-based scoring weights. @@ -249,6 +341,70 @@ def _parse_eligibility(repo_name: str, raw: Any) -> RepoEligibilityConfig: return RepoEligibilityConfig(**kwargs) +_SCORING_INT_FIELDS = ('pr_lookback_days',) +_SCORING_FLOAT_FIELDS = ( + 'open_pr_collateral_percent', + 'review_penalty_rate', + 'standard_issue_multiplier', + 'maintainer_issue_multiplier', +) + + +def _coerce_scoring_value(repo_name: str, field_name: str, raw_value: Any, caster: Any) -> Any: + if raw_value is None: + return None + if isinstance(raw_value, bool): + raise RepositoryRegistryError(f'{repo_name} scoring.{field_name} must be a number, got bool') + try: + return caster(raw_value) + except (TypeError, ValueError) as e: + raise RepositoryRegistryError(f'{repo_name} scoring.{field_name} must be a number: {e}') from e + + +_TIME_DECAY_INT_FIELDS = ('grace_period_hours',) +_TIME_DECAY_FLOAT_FIELDS = ('sigmoid_midpoint_days', 'sigmoid_steepness', 'min_multiplier') + + +def _parse_time_decay(repo_name: str, raw: Any) -> RepoTimeDecayConfig: + """Parse the optional nested ``scoring.time_decay`` object.""" + if raw is None: + return RepoTimeDecayConfig() + if not isinstance(raw, dict): + raise RepositoryRegistryError(f'{repo_name} scoring.time_decay must be an object, got {type(raw)}') + + known = set(_TIME_DECAY_INT_FIELDS) | set(_TIME_DECAY_FLOAT_FIELDS) + unknown = sorted(set(raw) - known) + if unknown: + raise RepositoryRegistryError(f'{repo_name} scoring.time_decay has unknown keys: {unknown}') + + kwargs: Dict[str, Any] = {} + for field_name in _TIME_DECAY_INT_FIELDS: + kwargs[field_name] = _coerce_scoring_value(repo_name, f'time_decay.{field_name}', raw.get(field_name), int) + for field_name in _TIME_DECAY_FLOAT_FIELDS: + kwargs[field_name] = _coerce_scoring_value(repo_name, f'time_decay.{field_name}', raw.get(field_name), float) + return RepoTimeDecayConfig(**kwargs) + + +def _parse_scoring(repo_name: str, raw: Any) -> RepoScoringConfig: + """Parse the optional ``scoring`` object from a master_repositories.json entry.""" + if raw is None: + return RepoScoringConfig() + if not isinstance(raw, dict): + raise RepositoryRegistryError(f'{repo_name} scoring must be an object, got {type(raw)}') + + unknown = sorted(set(raw) - set(_SCORING_INT_FIELDS) - set(_SCORING_FLOAT_FIELDS) - {'time_decay'}) + if unknown: + raise RepositoryRegistryError(f'{repo_name} scoring has unknown keys: {unknown}') + + kwargs: Dict[str, Any] = {} + for field_name in _SCORING_INT_FIELDS: + kwargs[field_name] = _coerce_scoring_value(repo_name, field_name, raw.get(field_name), int) + for field_name in _SCORING_FLOAT_FIELDS: + kwargs[field_name] = _coerce_scoring_value(repo_name, field_name, raw.get(field_name), float) + kwargs['time_decay'] = _parse_time_decay(repo_name, raw.get('time_decay')) + return RepoScoringConfig(**kwargs) + + def _validate_emission_shares(configs: Dict[str, RepositoryConfig]) -> None: total_share = 0.0 for repo_name, config in configs.items(): @@ -303,6 +459,55 @@ def _validate_eligibility_configs(configs: Dict[str, RepositoryConfig]) -> None: ) +def _validate_scoring_configs(configs: Dict[str, RepositoryConfig]) -> None: + """Range-check every repo's resolved scoring config.""" + for repo_name, config in configs.items(): + resolved = resolve_scoring(config.scoring) + if not 1 <= resolved.pr_lookback_days <= 90: + raise RepositoryRegistryError( + f'{repo_name} scoring.pr_lookback_days must be within [1, 90], got {resolved.pr_lookback_days}' + ) + if not 0.0 <= resolved.open_pr_collateral_percent <= 1.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.open_pr_collateral_percent must be within [0, 1], ' + f'got {resolved.open_pr_collateral_percent}' + ) + if not 0.0 < resolved.review_penalty_rate <= 1.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.review_penalty_rate must be within (0, 1], got {resolved.review_penalty_rate}' + ) + if not 1.0 <= resolved.standard_issue_multiplier <= 5.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.standard_issue_multiplier must be within [1, 5], ' + f'got {resolved.standard_issue_multiplier}' + ) + if not 1.0 <= resolved.maintainer_issue_multiplier <= 5.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.maintainer_issue_multiplier must be within [1, 5], ' + f'got {resolved.maintainer_issue_multiplier}' + ) + if not 0 <= resolved.time_decay.grace_period_hours <= 168: + raise RepositoryRegistryError( + f'{repo_name} scoring.time_decay.grace_period_hours must be within [0, 168], ' + f'got {resolved.time_decay.grace_period_hours}' + ) + if not 1.0 <= resolved.time_decay.sigmoid_midpoint_days <= 90.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.time_decay.sigmoid_midpoint_days must be within [1, 90], ' + f'got {resolved.time_decay.sigmoid_midpoint_days}' + ) + if not 0.01 <= resolved.time_decay.sigmoid_steepness <= 5.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.time_decay.sigmoid_steepness must be within [0.01, 5], ' + f'got {resolved.time_decay.sigmoid_steepness}' + ) + if not 0.0 <= resolved.time_decay.min_multiplier <= 1.0: + raise RepositoryRegistryError( + f'{repo_name} scoring.time_decay.min_multiplier must be within [0, 1], ' + f'got {resolved.time_decay.min_multiplier}' + ) + + def load_master_repo_weights() -> Dict[str, RepositoryConfig]: """ Load repository emission shares from the local JSON file. @@ -346,6 +551,7 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: default_label_multiplier=float(metadata.get('default_label_multiplier', 1.0)), fixed_base_score=metadata.get('fixed_base_score'), eligibility=_parse_eligibility(repo_name, metadata.get('eligibility')), + scoring=_parse_scoring(repo_name, metadata.get('scoring')), maintainer_cut=_coerce_share(repo_name, 'maintainer_cut', metadata.get('maintainer_cut', 0.0)), ) normalized_data[repo_name.lower()] = config @@ -356,6 +562,7 @@ def load_master_repo_weights() -> Dict[str, RepositoryConfig]: _validate_emission_shares(normalized_data) _validate_eligibility_configs(normalized_data) + _validate_scoring_configs(normalized_data) bt.logging.debug(f'Successfully loaded {len(normalized_data)} repository entries from {weights_file}') return normalized_data diff --git a/tests/utils/test_mirror_client.py b/tests/utils/test_mirror_client.py index ee574ddc..a6103c0d 100644 --- a/tests/utils/test_mirror_client.py +++ b/tests/utils/test_mirror_client.py @@ -5,8 +5,8 @@ """Unit tests for gittensor.utils.mirror.client. Covers the MirrorClient HTTP wrapper: -- URL + query-param construction for each endpoint -- since datetime → ISO UTC formatting; aware-input requirement +- URL construction for each endpoint +- since_by_repo map → POST body (per-repo windows); omitted map → GET - Response parsing into the right dataclass - Retry behavior: 5xx / 429 / connection errors retry with exponential backoff - Fail-fast on non-429 4xx @@ -125,44 +125,77 @@ def test_get_pr_files_interpolates_owner_repo_and_number(self): url = session.get.call_args.args[0] assert url == 'https://mirror.gittensor.io/api/v1/pulls/entrius/gittensor-ui/518/files' - def test_since_param_formatted_as_iso_utc(self): + +class TestSinceByRepoPost: + """A since_by_repo map switches the miner endpoints from GET to a POST + carrying the per-repo window map; an empty/omitted map stays a GET.""" + + def test_get_miner_pulls_posts_since_by_repo(self): session = Mock() - session.get.return_value = _ok(_minimal_pulls_payload()) + session.post.return_value = _ok(_minimal_pulls_payload()) client = _make_client(session) since = datetime(2026, 3, 15, 12, 30, 45, tzinfo=timezone.utc) - client.get_miner_pulls('218712309', since=since) + client.get_miner_pulls('218712309', since_by_repo={'entrius/gittensor': since}) - params = session.get.call_args.kwargs['params'] - # ISO string with explicit UTC offset - assert params['since'].startswith('2026-03-15T12:30:45') - assert '+00:00' in params['since'] or params['since'].endswith('Z') + session.post.assert_called_once() + session.get.assert_not_called() + url = session.post.call_args.args[0] + assert url == 'https://mirror.gittensor.io/api/v1/miners/218712309/pulls' + body = session.post.call_args.kwargs['json'] + assert set(body) == {'since_by_repo'} + iso = body['since_by_repo']['entrius/gittensor'] + assert iso.startswith('2026-03-15T12:30:45') + assert iso.endswith('+00:00') or iso.endswith('Z') - def test_since_param_omitted_when_none(self): + def test_get_miner_issues_posts_since_by_repo(self): session = Mock() - session.get.return_value = _ok(_minimal_pulls_payload()) + session.post.return_value = _ok(_minimal_issues_payload()) client = _make_client(session) - client.get_miner_pulls('218712309') + client.get_miner_issues( + '218712309', since_by_repo={'entrius/gittensor': datetime(2026, 3, 1, tzinfo=timezone.utc)} + ) - # params should be None or not contain 'since' - params = session.get.call_args.kwargs.get('params') - assert params is None or 'since' not in params + session.post.assert_called_once() + url = session.post.call_args.args[0] + assert url == 'https://mirror.gittensor.io/api/v1/miners/218712309/issues' + assert 'entrius/gittensor' in session.post.call_args.kwargs['json']['since_by_repo'] - def test_non_utc_aware_since_converted_to_utc(self): - """A datetime in another tz should serialize as UTC equivalent.""" + def test_non_utc_since_converted_to_utc_in_body(self): + """A datetime in another tz serializes as its UTC equivalent.""" from datetime import timedelta session = Mock() - session.get.return_value = _ok(_minimal_pulls_payload()) + session.post.return_value = _ok(_minimal_pulls_payload()) client = _make_client(session) # 2026-03-15 06:30 in UTC-6 == 2026-03-15 12:30 UTC non_utc = datetime(2026, 3, 15, 6, 30, 0, tzinfo=timezone(timedelta(hours=-6))) - client.get_miner_pulls('218712309', since=non_utc) + client.get_miner_pulls('218712309', since_by_repo={'o/r': non_utc}) + + iso = session.post.call_args.kwargs['json']['since_by_repo']['o/r'] + assert iso.startswith('2026-03-15T12:30:00') + + def test_empty_since_by_repo_falls_back_to_get(self): + session = Mock() + session.get.return_value = _ok(_minimal_pulls_payload()) + client = _make_client(session) + + client.get_miner_pulls('218712309', since_by_repo={}) + + session.get.assert_called_once() + session.post.assert_not_called() - params = session.get.call_args.kwargs['params'] - assert params['since'].startswith('2026-03-15T12:30:00') + def test_omitted_since_by_repo_uses_get(self): + session = Mock() + session.get.return_value = _ok(_minimal_issues_payload()) + client = _make_client(session) + + client.get_miner_issues('218712309') + + session.get.assert_called_once() + session.post.assert_not_called() # ============================================================================ @@ -213,7 +246,7 @@ def test_top_level_schema_parse_error_wrapped_as_mirror_request_error(self, meth session.get.return_value = _ok({'error': 'upstream unavailable'}) client = _make_client(session) - with pytest.raises(MirrorRequestError, match='invalid mirror response'): + with pytest.raises(MirrorRequestError, match='was invalid'): getattr(client, method_name)(*args) @@ -330,6 +363,20 @@ def test_max_attempts_exhausted_on_invalid_2xx_json(self, _log, mock_sleep): assert session.get.call_count == 3 assert mock_sleep.call_count == 2 + def test_post_path_retries_on_500(self, _log, mock_sleep): + """The POST path shares the retry loop — a 5xx retries like GET.""" + session = Mock() + session.post.side_effect = [ + _err(500, 'oops'), + _ok(_minimal_pulls_payload()), + ] + client = _make_client(session) + + client.get_miner_pulls('218712309', since_by_repo={'o/r': datetime(2026, 3, 1, tzinfo=timezone.utc)}) + + assert session.post.call_count == 2 + mock_sleep.assert_called_once_with(5) + @patch('gittensor.utils.mirror.client.time.sleep') @patch('gittensor.utils.mirror.client.bt.logging') @@ -368,6 +415,18 @@ def test_403_fails_fast_no_retry(self, _log, mock_sleep): assert session.get.call_count == 1 + def test_post_404_fails_fast_no_retry(self, _log, mock_sleep): + """A 404 on the POST path (e.g. an un-upgraded mirror) fails fast.""" + session = Mock() + session.post.return_value = _err(404, 'not found') + client = _make_client(session, max_attempts=3) + + with pytest.raises(MirrorRequestError, match='404'): + client.get_miner_pulls('218712309', since_by_repo={'o/r': datetime(2026, 3, 1, tzinfo=timezone.utc)}) + + assert session.post.call_count == 1 + mock_sleep.assert_not_called() + # ============================================================================ # Constructor defaults diff --git a/tests/validator/issue_discovery/test_scan.py b/tests/validator/issue_discovery/test_scan.py index be7efc1c..bc994c67 100644 --- a/tests/validator/issue_discovery/test_scan.py +++ b/tests/validator/issue_discovery/test_scan.py @@ -372,7 +372,7 @@ def test_non_mirror_enabled_repo_filtered_out(self): def test_mirror_request_error_does_not_abort_other_miners(self): client = Mock() - def _per_miner(github_id, since=None): + def _per_miner(github_id, since_by_repo=None): if github_id == 'fails': raise MirrorRequestError('boom') return _response([_issue_dict()]) @@ -412,7 +412,7 @@ def test_mirror_request_error_restores_cached_issue_discovery_fields(self): _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): + def _per_miner(github_id, since_by_repo=None): if github_id == 'fails': raise MirrorRequestError('boom') return _response(working_issues) @@ -968,10 +968,10 @@ def test_old_open_issues_outside_scoring_window_still_trip_spam(self): assert eval_.total_open_issues == 6 assert eval_.issue_discovery_score == 0 assert client.get_miner_issues.call_count == 2 - scoring_since = client.get_miner_issues.call_args_list[0].kwargs['since'] + scoring_call = client.get_miner_issues.call_args_list[0] open_count_call = client.get_miner_issues.call_args_list[1] - assert scoring_since is not None - assert open_count_call.kwargs.get('since') is None + assert scoring_call.kwargs.get('since_by_repo') # windowed scoring fetch + assert open_count_call.kwargs.get('since_by_repo') is None # unbounded open-issue count def test_all_mirror_miner_with_many_open_issues_trips_spam(self): """6 open issues in mirror response trips the spam multiplier.""" @@ -1178,7 +1178,7 @@ def test_two_miners_shared_pr_only_earliest_scores(self): ) ) - def _per_miner(github_id, since=None): + def _per_miner(github_id, since_by_repo=None): return _response(a_issues if github_id == 'A' else b_issues) client.get_miner_issues.side_effect = _per_miner @@ -1289,7 +1289,7 @@ def test_different_solving_prs_both_miners_score(self): 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): + def _per_miner(github_id, since_by_repo=None): return _response(a_issues if github_id == 'A' else b_issues) client.get_miner_issues.side_effect = _per_miner @@ -1357,7 +1357,7 @@ def _issues_by_github_id(mapping: dict): issue list, others get none. Keeps a seed miner from re-discovering the target miner's issues and competing for the same solving PR.""" - def _side_effect(github_id, since=None): + def _side_effect(github_id, since_by_repo=None): return _response(mapping.get(github_id, [])) return _side_effect diff --git a/tests/validator/oss_contributions/mirror/test_load.py b/tests/validator/oss_contributions/mirror/test_load.py index 214fa52c..6bc1003c 100644 --- a/tests/validator/oss_contributions/mirror/test_load.py +++ b/tests/validator/oss_contributions/mirror/test_load.py @@ -28,14 +28,18 @@ MirrorRequestError = mirror_client_mod.MirrorRequestError RepositoryConfig = load_weights.RepositoryConfig +# Recent, valid timestamps for the PR fixtures. +_RECENT_CREATED = (datetime.now(timezone.utc) - timedelta(days=10)).isoformat().replace('+00:00', 'Z') +_RECENT_MERGED = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat().replace('+00:00', 'Z') + def _pr_dict( pr_number: int, repo: str = 'entrius/gittensor-ui', state: str = 'MERGED', author_association: str = 'CONTRIBUTOR', - created_at: str = '2026-04-15T00:00:00Z', - merged_at: str | None = '2026-04-18T10:00:00Z', + created_at: str = _RECENT_CREATED, + merged_at: str | None = _RECENT_MERGED, author_login: str = 'bittoby', merged_by_login: str | None = 'anderdc', approved_count: int = 1, @@ -200,28 +204,30 @@ def test_dev_mode_bypasses_maintainer_skip(self, monkeypatch): # ============================================================================ -# Stale closed PR +# Lookback window (sent to the mirror; windowing happens server-side) # ============================================================================ -class TestStaleClosedPR: - def test_closed_pr_created_before_lookback_dropped(self): - # Lookback is 35 days before "now"; a CLOSED PR created 50 days ago should drop - old = (datetime.now(timezone.utc) - timedelta(days=50)).isoformat().replace('+00:00', 'Z') - recent = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') - +class TestLookbackWindow: + def test_since_by_repo_carries_each_repos_lookback(self): + """Each repo's pr_lookback_days becomes its own ``since`` cutoff in the + map sent to the mirror — the mirror applies the window server-side.""" + RepoScoringConfig = load_weights.RepoScoringConfig + repos = { + 'entrius/gittensor-ui': RepositoryConfig(emission_share=0.3), # default 30d + 'entrius/gittensor': RepositoryConfig(emission_share=0.3, scoring=RepoScoringConfig(pr_lookback_days=60)), + } client = Mock() - client.get_miner_pulls.return_value = _build_response( - [ - _pr_dict(1, state='CLOSED', merged_at=None, created_at=old), - _pr_dict(2, state='CLOSED', merged_at=None, created_at=recent), - ] - ) + client.get_miner_pulls.return_value = _build_response([]) eval_ = _eval() - load_miner_prs(eval_, _mirror_repos('entrius/gittensor-ui'), client=client) - assert len(eval_.closed_prs) == 1 - assert eval_.closed_prs[0].pr.pr_number == 2 + before = datetime.now(timezone.utc) + load_miner_prs(eval_, repos, client=client) + after = datetime.now(timezone.utc) + + since_by_repo = client.get_miner_pulls.call_args.kwargs['since_by_repo'] + assert before - timedelta(days=30) <= since_by_repo['entrius/gittensor-ui'] <= after - timedelta(days=30) + assert before - timedelta(days=60) <= since_by_repo['entrius/gittensor'] <= after - timedelta(days=60) # ============================================================================ @@ -305,7 +311,8 @@ def test_malformed_2xx_json_sets_fetch_failed(self): response = Mock(status_code=200, text='bad gateway') response.json.side_effect = ValueError('Expecting value') session = Mock() - session.get.return_value = response + # load_miner_prs sends a per-repo since map, so the client POSTs. + session.post.return_value = response client = MirrorClient(session=session, max_attempts=1) eval_ = _eval() @@ -321,11 +328,11 @@ def test_per_pr_exception_does_not_abort_loop(self, monkeypatch): call_count = {'n': 0} original = load_mod._maybe_add_pr - def flaky(eval_, pr, repos, lookback): + def flaky(eval_, pr, repos): call_count['n'] += 1 if call_count['n'] == 1: raise RuntimeError('synthetic failure on first PR') - original(eval_, pr, repos, lookback) + original(eval_, pr, repos) monkeypatch.setattr(load_mod, '_maybe_add_pr', flaky) diff --git a/tests/validator/oss_contributions/mirror/test_scoring.py b/tests/validator/oss_contributions/mirror/test_scoring.py index 382a27c0..ec1c9c62 100644 --- a/tests/validator/oss_contributions/mirror/test_scoring.py +++ b/tests/validator/oss_contributions/mirror/test_scoring.py @@ -39,6 +39,7 @@ _calculate_issue_multiplier = scoring_module._calculate_issue_multiplier _is_valid_linked_issue = scoring_module._is_valid_linked_issue score_pr = scoring_module.score_pr +resolve_scoring = load_weights.resolve_scoring ScoredPR = scored_pr_module.ScoredPR MirrorPullRequest = mirror_models.MirrorPullRequest @@ -722,19 +723,19 @@ def _linked_issue( class TestIssueMultiplier: def test_no_linked_issues_returns_neutral(self): scored = ScoredPR(pr=_pr(linked_issues=[])) - assert _calculate_issue_multiplier(scored) == 1.0 + assert _calculate_issue_multiplier(scored, resolve_scoring(None)) == 1.0 def test_valid_standard_issue(self): from gittensor.constants import STANDARD_ISSUE_MULTIPLIER scored = ScoredPR(pr=_pr(linked_issues=[_linked_issue()])) - assert _calculate_issue_multiplier(scored) == STANDARD_ISSUE_MULTIPLIER + assert _calculate_issue_multiplier(scored, resolve_scoring(None)) == STANDARD_ISSUE_MULTIPLIER def test_maintainer_authored_issue_gets_maintainer_multiplier(self): from gittensor.constants import MAINTAINER_ISSUE_MULTIPLIER scored = ScoredPR(pr=_pr(linked_issues=[_linked_issue(author_association='OWNER')])) - assert _calculate_issue_multiplier(scored) == MAINTAINER_ISSUE_MULTIPLIER + assert _calculate_issue_multiplier(scored, resolve_scoring(None)) == MAINTAINER_ISSUE_MULTIPLIER def test_first_valid_issue_chosen(self): # Even if the first issue is invalid, valid second one should be chosen @@ -743,7 +744,7 @@ def test_first_valid_issue_chosen(self): scored = ScoredPR(pr=_pr(linked_issues=[invalid, valid])) from gittensor.constants import STANDARD_ISSUE_MULTIPLIER - assert _calculate_issue_multiplier(scored) == STANDARD_ISSUE_MULTIPLIER + assert _calculate_issue_multiplier(scored, resolve_scoring(None)) == STANDARD_ISSUE_MULTIPLIER class TestLinkedIssueValidity: @@ -829,7 +830,7 @@ def test_prefer_maintainer_authored_when_multiple_valid(self): non_maint = _linked_issue(number=1, author_association='CONTRIBUTOR', author_github_id='111') maint = _linked_issue(number=2, author_association='OWNER', author_github_id='222') scored = ScoredPR(pr=_pr(linked_issues=[non_maint, maint])) - assert _calculate_issue_multiplier(scored) == MAINTAINER_ISSUE_MULTIPLIER + assert _calculate_issue_multiplier(scored, resolve_scoring(None)) == MAINTAINER_ISSUE_MULTIPLIER def test_falls_back_to_first_when_no_maintainer_authored(self): from gittensor.constants import STANDARD_ISSUE_MULTIPLIER @@ -837,7 +838,7 @@ def test_falls_back_to_first_when_no_maintainer_authored(self): issue_a = _linked_issue(number=1, author_association='CONTRIBUTOR', author_github_id='111') issue_b = _linked_issue(number=2, author_association='CONTRIBUTOR', author_github_id='222') scored = ScoredPR(pr=_pr(linked_issues=[issue_a, issue_b])) - assert _calculate_issue_multiplier(scored) == STANDARD_ISSUE_MULTIPLIER + assert _calculate_issue_multiplier(scored, resolve_scoring(None)) == STANDARD_ISSUE_MULTIPLIER class TestCollateralScoreAcceptsScoredPR: @@ -847,6 +848,7 @@ class TestCollateralScoreAcceptsScoredPR: def test_collateral_computed_without_crash(self): from gittensor.validator.oss_contributions.scoring import calculate_open_pr_collateral_score + from gittensor.validator.utils.load_weights import resolve_scoring scored = ScoredPR(pr=_pr(state='OPEN')) scored.base_score = 25.0 @@ -854,7 +856,7 @@ def test_collateral_computed_without_crash(self): scored.label_multiplier = 1.0 # Must not raise AttributeError on .number - result = calculate_open_pr_collateral_score(scored) + result = calculate_open_pr_collateral_score(scored, resolve_scoring(None)) assert result >= 0.0 def test_number_property_proxies_to_pr_pr_number(self): @@ -875,6 +877,7 @@ def test_open_pr_skips_merge_only_gates(self): def test_open_mirror_pr_review_iterations_increase_collateral(self): from gittensor.validator.oss_contributions.scoring import calculate_open_pr_collateral_score + from gittensor.validator.utils.load_weights import resolve_scoring clean = ScoredPR(pr=_pr(state='OPEN', maintainer_changes_requested_count=0)) clean.base_score = 100.0 @@ -886,8 +889,9 @@ def test_open_mirror_pr_review_iterations_increase_collateral(self): reviewed.issue_multiplier = 1.0 reviewed.label_multiplier = 1.0 - assert calculate_open_pr_collateral_score(reviewed) == pytest.approx( - calculate_open_pr_collateral_score(clean) * 1.45 + default_scoring = resolve_scoring(None) + assert calculate_open_pr_collateral_score(reviewed, default_scoring) == pytest.approx( + calculate_open_pr_collateral_score(clean, default_scoring) * 1.45 ) diff --git a/tests/validator/test_issue_eligibility.py b/tests/validator/test_issue_eligibility.py index 9043ff1b..08675c3f 100644 --- a/tests/validator/test_issue_eligibility.py +++ b/tests/validator/test_issue_eligibility.py @@ -9,7 +9,7 @@ from gittensor.validator.issue_discovery.scoring import check_issue_eligibility from gittensor.validator.utils.load_weights import RepoEligibilityConfig, resolve_eligibility -_CFG = resolve_eligibility(None) # defaults: 3 valid solved issues, 0.70 issue credibility +_CFG = resolve_eligibility(None) # defaults: 3 valid solved issues, 0.80 issue credibility @pytest.mark.parametrize( @@ -21,7 +21,7 @@ (10, 2, 2, pytest.approx(10 / 12, abs=1e-3), False), # no solved issues -> credibility 0, ineligible (0, 0, 2, 0.0, False), - # credibility below 0.70 -> ineligible even with enough valid solves + # credibility below 0.80 -> ineligible even with enough valid solves (4, 4, 3, pytest.approx(4 / 7, abs=1e-3), False), # exactly at both gates (3, 3, 0, 1.0, True), diff --git a/tests/validator/test_load_weights.py b/tests/validator/test_load_weights.py index 290f6902..05b576a7 100644 --- a/tests/validator/test_load_weights.py +++ b/tests/validator/test_load_weights.py @@ -15,6 +15,7 @@ from gittensor.validator.utils.load_weights import ( LanguageConfig, RepoEligibilityConfig, + RepoScoringConfig, RepositoryConfig, RepositoryRegistryError, TokenConfig, @@ -22,6 +23,7 @@ load_programming_language_weights, load_token_config, resolve_eligibility, + resolve_scoring, ) @@ -334,6 +336,30 @@ def test_live_mirror_scoring_fields_have_valid_shape(self): assert 0.0 <= resolved.min_credibility <= 1.0, f'{repo_name} min_credibility out of range' assert 0.0 <= resolved.min_issue_credibility <= 1.0, f'{repo_name} min_issue_credibility out of range' assert resolved.min_valid_merged_prs >= 0, f'{repo_name} min_valid_merged_prs negative' + resolved_scoring = resolve_scoring(config.scoring) + assert 1 <= resolved_scoring.pr_lookback_days <= 90, f'{repo_name} pr_lookback_days out of range' + assert 0.0 <= resolved_scoring.open_pr_collateral_percent <= 1.0, ( + f'{repo_name} open_pr_collateral_percent out of range' + ) + assert 0.0 < resolved_scoring.review_penalty_rate <= 1.0, f'{repo_name} review_penalty_rate out of range' + assert 1.0 <= resolved_scoring.standard_issue_multiplier <= 5.0, ( + f'{repo_name} standard_issue_multiplier out of range' + ) + assert 1.0 <= resolved_scoring.maintainer_issue_multiplier <= 5.0, ( + f'{repo_name} maintainer_issue_multiplier out of range' + ) + assert 0 <= resolved_scoring.time_decay.grace_period_hours <= 168, ( + f'{repo_name} time_decay.grace_period_hours out of range' + ) + assert 1.0 <= resolved_scoring.time_decay.sigmoid_midpoint_days <= 90.0, ( + f'{repo_name} time_decay.sigmoid_midpoint_days out of range' + ) + assert 0.01 <= resolved_scoring.time_decay.sigmoid_steepness <= 5.0, ( + f'{repo_name} time_decay.sigmoid_steepness out of range' + ) + assert 0.0 <= resolved_scoring.time_decay.min_multiplier <= 1.0, ( + f'{repo_name} time_decay.min_multiplier out of range' + ) def test_oc_1_runs_ungated(self): """The oc-1 benchmark repo opts out of the gate via zeroed thresholds.""" @@ -344,6 +370,119 @@ def test_oc_1_runs_ungated(self): assert resolved.min_valid_solved_issues == 0 +class TestRepositoryConfigScoringBlock: + """Dataclass + JSON-parsing tests for the per-repo scoring block.""" + + def test_scoring_field_defaults(self): + config = RepositoryConfig(emission_share=0.5) + assert config.scoring == RepoScoringConfig() + + def test_loader_parses_scoring_overrides(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps( + { + 'foo/custom': { + 'emission_share': 0.5, + 'scoring': { + 'pr_lookback_days': 45, + 'open_pr_collateral_percent': 0.4, + 'review_penalty_rate': 0.25, + }, + }, + 'foo/defaults': {'emission_share': 0.3}, + } + ) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + repos = lw.load_master_repo_weights() + + assert repos['foo/custom'].scoring.pr_lookback_days == 45 + assert repos['foo/custom'].scoring.open_pr_collateral_percent == pytest.approx(0.4) + assert repos['foo/custom'].scoring.review_penalty_rate == pytest.approx(0.25) + assert repos['foo/defaults'].scoring == RepoScoringConfig() + + def test_loader_rejects_unknown_scoring_key(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'bogus': 1}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + with pytest.raises(RepositoryRegistryError): + lw.load_master_repo_weights() + + def test_loader_rejects_out_of_range_collateral(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'open_pr_collateral_percent': 1.5}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + with pytest.raises(RepositoryRegistryError): + lw.load_master_repo_weights() + + def test_loader_rejects_zero_review_penalty_rate(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'review_penalty_rate': 0.0}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + with pytest.raises(RepositoryRegistryError): + lw.load_master_repo_weights() + + def test_loader_rejects_out_of_range_issue_multiplier(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'standard_issue_multiplier': 0.5}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + with pytest.raises(RepositoryRegistryError): + lw.load_master_repo_weights() + + def test_loader_parses_time_decay_overrides(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/custom': {'emission_share': 0.5, 'scoring': {'time_decay': {'grace_period_hours': 24}}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + repos = lw.load_master_repo_weights() + + assert repos['foo/custom'].scoring.time_decay.grace_period_hours == 24 + + def test_loader_rejects_unknown_time_decay_key(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'time_decay': {'bogus': 1}}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + with pytest.raises(RepositoryRegistryError): + lw.load_master_repo_weights() + + def test_loader_rejects_out_of_range_lookback(self, tmp_path, monkeypatch): + from gittensor.validator.utils import load_weights as lw + + (tmp_path / 'master_repositories.json').write_text( + json.dumps({'foo/bad': {'emission_share': 0.5, 'scoring': {'pr_lookback_days': 200}}}) + ) + monkeypatch.setattr(lw, '_get_weights_dir', lambda: tmp_path) + + with pytest.raises(RepositoryRegistryError): + lw.load_master_repo_weights() + + class TestRepositoryConfigMaintainerCut: """Dataclass + JSON-parsing tests for the maintainer_cut emission carve-out.""" diff --git a/tests/validator/test_review_quality_multiplier.py b/tests/validator/test_review_quality_multiplier.py index 43105d5b..32248507 100644 --- a/tests/validator/test_review_quality_multiplier.py +++ b/tests/validator/test_review_quality_multiplier.py @@ -4,15 +4,9 @@ """Tests for PR review quality multiplier (issue #303).""" -from math import ceil - import pytest -from gittensor.constants import ( - MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER, - REVIEW_PENALTY_RATE, -) -from gittensor.utils.github_api_tools import _MAX_CHANGES_REQUESTED_REVIEWS +from gittensor.constants import REVIEW_PENALTY_RATE from gittensor.validator.oss_contributions.scoring import ( calculate_review_collateral_multiplier, calculate_review_quality_multiplier, @@ -23,74 +17,63 @@ class TestCalculateReviewQualityMultiplier: """Tests for the standalone calculate_review_quality_multiplier function.""" def test_no_reviews_returns_one(self): - assert calculate_review_quality_multiplier(0) == 1.0 + assert calculate_review_quality_multiplier(0, REVIEW_PENALTY_RATE) == 1.0 def test_one_review_applies_single_penalty(self): - result = calculate_review_quality_multiplier(1) + result = calculate_review_quality_multiplier(1, REVIEW_PENALTY_RATE) assert result == pytest.approx(1.0 - REVIEW_PENALTY_RATE) def test_two_reviews_cumulative(self): - result = calculate_review_quality_multiplier(2) + result = calculate_review_quality_multiplier(2, REVIEW_PENALTY_RATE) assert result == pytest.approx(1.0 - 2 * REVIEW_PENALTY_RATE) def test_table_values(self): - """Verify expected values across the penalty range.""" - expected = { - 0: 1.00, - 1: 0.85, - 2: 0.70, - 3: 0.55, - 4: 0.40, - 5: 0.25, - 6: 0.10, - } + """Verify expected values across the penalty range at the default rate.""" + expected = {0: 1.00, 1: 0.85, 2: 0.70, 3: 0.55, 4: 0.40, 5: 0.25, 6: 0.10} for n, mult in expected.items(): - assert calculate_review_quality_multiplier(n) == pytest.approx(mult, abs=1e-9), f'n={n}' + assert calculate_review_quality_multiplier(n, REVIEW_PENALTY_RATE) == pytest.approx(mult, abs=1e-9), ( + f'n={n}' + ) def test_floor_at_zero(self): - assert calculate_review_quality_multiplier(7) == 0.0 + assert calculate_review_quality_multiplier(7, REVIEW_PENALTY_RATE) == 0.0 def test_large_count_stays_at_zero(self): - assert calculate_review_quality_multiplier(100) == 0.0 + assert calculate_review_quality_multiplier(100, REVIEW_PENALTY_RATE) == 0.0 def test_returns_float(self): - assert isinstance(calculate_review_quality_multiplier(0), float) + assert isinstance(calculate_review_quality_multiplier(0, REVIEW_PENALTY_RATE), float) + + def test_per_repo_rate_overrides_default(self): + # A repo-configured rate replaces the global default. + assert calculate_review_quality_multiplier(1, 0.25) == pytest.approx(0.75) + assert calculate_review_quality_multiplier(2, 0.25) == pytest.approx(0.50) class TestCalculateReviewCollateralMultiplier: """Tests for the collateral-only review multiplier for OPEN PRs.""" def test_no_reviews_returns_one(self): - assert calculate_review_collateral_multiplier(0) == 1.0 + assert calculate_review_collateral_multiplier(0, REVIEW_PENALTY_RATE) == 1.0 def test_one_review_increases_collateral_multiplier(self): - assert calculate_review_collateral_multiplier(1) == pytest.approx(1.0 + REVIEW_PENALTY_RATE) + assert calculate_review_collateral_multiplier(1, REVIEW_PENALTY_RATE) == pytest.approx( + 1.0 + REVIEW_PENALTY_RATE + ) def test_table_values(self): - expected = { - 0: 1.00, - 1: 1.15, - 2: 1.30, - 3: 1.45, - } + expected = {0: 1.00, 1: 1.15, 2: 1.30, 3: 1.45} for n, mult in expected.items(): - assert calculate_review_collateral_multiplier(n) == pytest.approx(mult, abs=1e-9), f'n={n}' + assert calculate_review_collateral_multiplier(n, REVIEW_PENALTY_RATE) == pytest.approx(mult, abs=1e-9), ( + f'n={n}' + ) def test_caps_at_two(self): - assert calculate_review_collateral_multiplier(7) == pytest.approx(2.0) - assert calculate_review_collateral_multiplier(100) == pytest.approx(2.0) - - -def test_max_changes_requested_reviews_covers_review_multipliers(): - # Tripwire: the GraphQL fetch cap must stay aligned with every review-count-based multiplier. - penalty_cap = ceil(1 / REVIEW_PENALTY_RATE) - collateral_cap = ceil((MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER - 1.0) / REVIEW_PENALTY_RATE) + assert calculate_review_collateral_multiplier(7, REVIEW_PENALTY_RATE) == pytest.approx(2.0) + assert calculate_review_collateral_multiplier(100, REVIEW_PENALTY_RATE) == pytest.approx(2.0) - assert _MAX_CHANGES_REQUESTED_REVIEWS == max(penalty_cap, collateral_cap) - assert calculate_review_quality_multiplier(_MAX_CHANGES_REQUESTED_REVIEWS) == 0.0 - assert calculate_review_collateral_multiplier(_MAX_CHANGES_REQUESTED_REVIEWS) == pytest.approx( - MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER - ) + def test_per_repo_rate_overrides_default(self): + assert calculate_review_collateral_multiplier(1, 0.25) == pytest.approx(1.25) if __name__ == '__main__': diff --git a/tests/validator/test_scoring_resolver.py b/tests/validator/test_scoring_resolver.py new file mode 100644 index 00000000..84075488 --- /dev/null +++ b/tests/validator/test_scoring_resolver.py @@ -0,0 +1,72 @@ +"""Tests for resolve_scoring — per-repo override resolution against the +global default constants.""" + +from gittensor.constants import ( + MAINTAINER_ISSUE_MULTIPLIER, + OPEN_PR_COLLATERAL_PERCENT, + PR_LOOKBACK_DAYS, + REVIEW_PENALTY_RATE, + STANDARD_ISSUE_MULTIPLIER, + TIME_DECAY_GRACE_PERIOD_HOURS, + TIME_DECAY_MIN_MULTIPLIER, + TIME_DECAY_SIGMOID_MIDPOINT, + TIME_DECAY_SIGMOID_STEEPNESS_SCALAR, +) +from gittensor.validator.utils.load_weights import RepoScoringConfig, RepoTimeDecayConfig, resolve_scoring + + +def test_none_resolves_entirely_to_global_defaults(): + resolved = resolve_scoring(None) + assert resolved.pr_lookback_days == PR_LOOKBACK_DAYS + assert resolved.open_pr_collateral_percent == OPEN_PR_COLLATERAL_PERCENT + assert resolved.review_penalty_rate == REVIEW_PENALTY_RATE + assert resolved.standard_issue_multiplier == STANDARD_ISSUE_MULTIPLIER + assert resolved.maintainer_issue_multiplier == MAINTAINER_ISSUE_MULTIPLIER + assert resolved.time_decay.grace_period_hours == TIME_DECAY_GRACE_PERIOD_HOURS + assert resolved.time_decay.sigmoid_midpoint_days == TIME_DECAY_SIGMOID_MIDPOINT + assert resolved.time_decay.sigmoid_steepness == TIME_DECAY_SIGMOID_STEEPNESS_SCALAR + assert resolved.time_decay.min_multiplier == TIME_DECAY_MIN_MULTIPLIER + + +def test_empty_config_resolves_to_global_defaults(): + assert resolve_scoring(RepoScoringConfig()) == resolve_scoring(None) + + +def test_overrides_take_precedence_over_defaults(): + resolved = resolve_scoring( + RepoScoringConfig( + pr_lookback_days=60, + open_pr_collateral_percent=0.5, + review_penalty_rate=0.3, + standard_issue_multiplier=2.0, + maintainer_issue_multiplier=3.0, + ) + ) + assert resolved.pr_lookback_days == 60 + assert resolved.open_pr_collateral_percent == 0.5 + assert resolved.review_penalty_rate == 0.3 + assert resolved.standard_issue_multiplier == 2.0 + assert resolved.maintainer_issue_multiplier == 3.0 + + +def test_zero_override_is_respected_not_treated_as_unset(): + """0 is a real value (a repo opting out of collateral), not 'use the default'.""" + resolved = resolve_scoring(RepoScoringConfig(open_pr_collateral_percent=0.0)) + assert resolved.open_pr_collateral_percent == 0.0 + + +def test_time_decay_overrides_resolve(): + resolved = resolve_scoring( + RepoScoringConfig( + time_decay=RepoTimeDecayConfig( + grace_period_hours=24, + sigmoid_midpoint_days=15.0, + sigmoid_steepness=0.3, + min_multiplier=1.0, + ) + ) + ) + assert resolved.time_decay.grace_period_hours == 24 + assert resolved.time_decay.sigmoid_midpoint_days == 15.0 + assert resolved.time_decay.sigmoid_steepness == 0.3 + assert resolved.time_decay.min_multiplier == 1.0