From 1789fc1eaf3c56a326b7887fd6efb3e026d73ab0 Mon Sep 17 00:00:00 2001 From: Clayton Date: Mon, 6 Jul 2026 10:36:19 -0500 Subject: [PATCH 1/2] feat(miner-foundation): extract scoring preview/model into gittensory-engine Ports the deterministic parts of src/scoring/{preview,model,pending-pr-scenarios}.ts into packages/gittensory-engine/src/scoring/, giving the miner the same score-preview machinery the gittensory_preview_local_pr_score MCP tool already calls server-side, so a miner can preview its own PR score fully locally (#2282). The D1/GitHub-fetching halves of model.ts and pending-pr-scenarios.ts stay in src/, which now imports its pure constants/classifiers back from the engine; those functions have no use to a local miner (no D1/App-token access) and depend on Cloudflare-specific bindings the engine package's isolated tsconfig cannot resolve. src/scoring/preview.ts is now a full re-export shim since the whole file was already side-effect-free. Every existing scoring test suite passes unmodified against the new shims. --- packages/gittensory-engine/package.json | 12 + packages/gittensory-engine/src/index.ts | 5 + .../gittensory-engine/src/scoring/model.ts | 169 ++ .../src/scoring/pending-pr-scenarios.ts | 223 +++ .../gittensory-engine/src/scoring/preview.ts | 1494 +++++++++++++++++ .../gittensory-engine/src/scoring/types.ts | 147 ++ src/scoring/model.ts | 176 +- src/scoring/pending-pr-scenarios.ts | 209 +-- src/scoring/preview.ts | 1466 +--------------- 9 files changed, 2082 insertions(+), 1819 deletions(-) create mode 100644 packages/gittensory-engine/src/scoring/model.ts create mode 100644 packages/gittensory-engine/src/scoring/pending-pr-scenarios.ts create mode 100644 packages/gittensory-engine/src/scoring/preview.ts create mode 100644 packages/gittensory-engine/src/scoring/types.ts diff --git a/packages/gittensory-engine/package.json b/packages/gittensory-engine/package.json index 7ecc5fa153..a336e55202 100644 --- a/packages/gittensory-engine/package.json +++ b/packages/gittensory-engine/package.json @@ -34,6 +34,18 @@ "./miner-goal-spec-parse": { "types": "./dist/miner-goal-spec-parse.d.ts", "default": "./dist/miner-goal-spec-parse.js" + }, + "./scoring/model": { + "types": "./dist/scoring/model.d.ts", + "default": "./dist/scoring/model.js" + }, + "./scoring/preview": { + "types": "./dist/scoring/preview.d.ts", + "default": "./dist/scoring/preview.js" + }, + "./scoring/pending-pr-scenarios": { + "types": "./dist/scoring/pending-pr-scenarios.d.ts", + "default": "./dist/scoring/pending-pr-scenarios.js" } }, "files": [ diff --git a/packages/gittensory-engine/src/index.ts b/packages/gittensory-engine/src/index.ts index 05ec54a5cf..53513e7b95 100644 --- a/packages/gittensory-engine/src/index.ts +++ b/packages/gittensory-engine/src/index.ts @@ -220,3 +220,8 @@ export { rankMetadataOpportunitiesAtOrAboveScore } from "./metadata-min-score.js export { pickTopMetadataOpportunitiesAtOrAboveScore } from "./metadata-top-min-score.js"; export { bestMetadataOpportunity } from "./metadata-best-pick.js"; export { bestMetadataOpportunityAtOrAboveScore } from "./metadata-best-min-score.js"; +// Score-preview machinery (#2282): namespaced (rather than flattened) so the three ported files keep their +// own identity and cannot collide with each other or the ~50 other top-level exports above. +export * as scoringModel from "./scoring/model.js"; +export * as scoringPreview from "./scoring/preview.js"; +export * as scoringPendingPrScenarios from "./scoring/pending-pr-scenarios.js"; diff --git a/packages/gittensory-engine/src/scoring/model.ts b/packages/gittensory-engine/src/scoring/model.ts new file mode 100644 index 0000000000..8c7b853a45 --- /dev/null +++ b/packages/gittensory-engine/src/scoring/model.ts @@ -0,0 +1,169 @@ +import type { ScoringModelSnapshotRecord } from "./types.js"; + +// Deterministic subset of the backend's `src/scoring/model.ts`, extracted so a miner can run the same +// score-preview constants/classification logic locally (#2282). The upstream-fetching, D1-persisting half +// of that file (`refreshScoringModelSnapshot`, `getOrCreateScoringModelSnapshot`, and their private fetch +// helpers) is Cloudflare/D1-bound and stays in `src/scoring/model.ts`, which imports the pure pieces below +// back from this package. + +export const DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5; + +export const DEFAULT_SCORING_CONSTANTS: Record = { + OSS_EMISSION_SHARE: 0.9, + // Upstream name is ISSUES_TREASURY_EMISSION_SHARE (plural). The prior singular spelling never matched + // upstream, freezing this at the local default and showing up as a false "unmodeled" drift warning (#806). + ISSUES_TREASURY_EMISSION_SHARE: 0.1, + // Lookback window used upstream for PR history; stored so it syncs and does not surface as unmodeled drift. + PR_LOOKBACK_DAYS: 30, + MERGED_PR_BASE_SCORE: 25, + // Upstream MAX_CONTRIBUTION_BONUS is 5. This local value is only the fetch-failure fallback; keeping it at + // 25 silently 5x-inflated the contribution bonus whenever the upstream fetch failed (#807). + MAX_CONTRIBUTION_BONUS: 5, + CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, + // Applied in preview.ts when computing totalTokenScore from components (#808). + TEST_FILE_CONTRIBUTION_WEIGHT: 0.05, + // Upstream-enforced eligibility floors for PR and issue-discovery history (#808). + // These gate whether a validator counts a contributor's submissions, not the per-PR/issue score itself. + // Stored here so they sync from upstream and no longer appear as unmodeled drift warnings. + MIN_VALID_MERGED_PRS: 3, + MIN_CREDIBILITY: 0.8, + MIN_VALID_SOLVED_ISSUES: 3, + MIN_ISSUE_CREDIBILITY: 0.8, + MIN_TOKEN_SCORE_FOR_VALID_ISSUE: 5, + // Open-issue spam gate constants — wired into the issue-discovery scoring lane in preview.ts (#808). + OPEN_ISSUE_SPAM_BASE_THRESHOLD: 2, + OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT: 300, + MAX_OPEN_ISSUE_THRESHOLD: 30, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER: 2.0, + MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300, + DEFAULT_ISSUE_DISCOVERY_SHARE, + REVIEW_PENALTY_RATE: 0.15, + STANDARD_ISSUE_MULTIPLIER: 1.33, + MAINTAINER_ISSUE_MULTIPLIER: 1.66, + EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, + OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, + MAX_OPEN_PR_THRESHOLD: 30, + SRC_TOK_SATURATION_SCALE: 58, + // Density-era constants (#812): upstream is on the saturation model, but `current_density_model` is still + // a supported `activeModel` (types.ts union, the public OpenAPI schema, the DB parser, ~20 test fixtures, + // and src/services/score-breakdown.ts). The density branch in preview.ts is therefore NOT dead — it is the + // supported alternate/fallback model. Single-sourcing these fallbacks HERE (instead of as silent hardcoded + // literals at every constant() call site) closes the duplicate-source-of-truth gap without a breaking + // removal of a still-supported model. + MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, + MAX_CODE_DENSITY_MULTIPLIER: 1.15, + // Upstream time-decay (#703): a merged PR's score decays on a sigmoid after a grace period. Modeled here + // so they no longer surface as unmodeled drift (#690); APPLICATION is opt-in + default-off (see preview). + TIME_DECAY_GRACE_PERIOD_HOURS: 12, + TIME_DECAY_SIGMOID_MIDPOINT: 10, + TIME_DECAY_SIGMOID_STEEPNESS_SCALAR: 0.4, + TIME_DECAY_MIN_MULTIPLIER: 0.05, +}; + +export const DEFAULT_GITTENSOR_UPSTREAM_REPO = "entrius/gittensor"; +export const DEFAULT_GITTENSOR_UPSTREAM_REF = "test"; + +// Single source of truth (#812): every recognized upstream constant name is a key of +// DEFAULT_SCORING_CONSTANTS, so the known-only parser, the unmodeled-drift detector, and the preview-side +// fallbacks all derive from one place. The density-era constants are included because the density model is +// still a supported activeModel (see comment above). Exported so the D1-bound refresh in +// `src/scoring/model.ts` can reuse it for its `recognizedCount` sanity floor without duplicating the Set. +export const SCORING_CONSTANT_NAMES = new Set(Object.keys(DEFAULT_SCORING_CONSTANTS)); + +// Mirror Pipeline B's UPSTREAM_STALE_MS (upstream/ruleset.ts): a served scoring snapshot older than this +// window means the last upstream refresh failed or has not run, so previews are quietly using last-good (or +// DEFAULT) constants with no other staleness signal on the scoring side (#810). +export const SCORING_SNAPSHOT_STALE_MS = 2 * 60 * 60 * 1000; + +export function scoringSnapshotStalenessWarning(snapshot: Pick, now: number = Date.now()): string | null { + if (Date.parse(snapshot.fetchedAt) + SCORING_SNAPSHOT_STALE_MS >= now) return null; + return "Scoring constants snapshot is stale: the last upstream refresh is older than the freshness window, so scoring may be using last-good or default constants and be behind upstream."; +} + +export function parsePythonNumberConstants(source: string, options: { knownOnly?: boolean } = { knownOnly: true }): Record { + const constants: Record = {}; + for (const line of source.split("\n")) { + // Match Python numeric literals including underscore separators in integer and fractional parts + // (1_000_000, 0.000_001, 3.14_15), floats, and exponents (1e-9, 5.8e1). The previous regex only + // allowed `_` in the integer part, truncating 0.000_001 -> 0 and 3.14_15 -> 3.14 (#992). + const match = line.match(/^([A-Z][A-Z0-9_]+)\s*=\s*([-+]?(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][-+]?\d+)?)/); + if (!match) continue; + const name = match[1]!; + const raw = match[2]!; + if (options.knownOnly !== false && !SCORING_CONSTANT_NAMES.has(name)) continue; + // Number() rejects underscore separators, so strip them before parsing. + constants[name] = Number(raw.replace(/_/g, "")); + } + return constants; +} + +/** + * Upstream operational/infra constants gittensory intentionally does not model in score previews. + * They are not scoring dimensions — surfacing them as "unmodeled drift" is noise (#809). + */ +const NON_SCORING_UPSTREAM_CONSTANT_NAMES = new Set([ + "SECONDS_PER_DAY", + "SECONDS_PER_HOUR", + "GITHUB_HTTP_TIMEOUT_SECONDS", + "MIRROR_HTTP_TIMEOUT_SECONDS", + "MIRROR_MAX_ATTEMPTS", + "TREE_SITTER_PARSE_TIMEOUT_MICROS", + "SCORING_SUBPROCESS_BUDGET_S", + "MAX_FILE_SIZE_BYTES", + "RECYCLE_UID", + "ISSUES_TREASURY_UID", + "MAX_ISSUE_ID", + // Floating-point epsilon for the registry emission_share-sum validation + // (load_weights.py: `total_share > 1.0 + EMISSION_SHARE_TOLERANCE`), not a scoring dimension — without + // this entry the parser (which reads exponent literals like `1e-9`, #992) flagged it as a permanent + // false-positive unmodeled-scoring-drift warning (#809). + "EMISSION_SHARE_TOLERANCE", + // Fallback weight applied by the validator's load_programming_language_weights() for extensions absent from + // the JSON file; gittensory reads the JSON directly and does not track this loader default (#1692). + "DEFAULT_PROGRAMMING_LANGUAGE_WEIGHT", +]); + +/** + * Numeric constant names upstream gittensor defines that gittensory's scoring engine does NOT model. + * The normal parse is `knownOnly` (it keeps only constants we already encode), which silently hides + * upstream ADDITIONS — e.g. a newly-introduced time-decay constant. Surfacing these makes scoring + * staleness visible: if upstream adds a scoring dimension, an operator sees it instead of the gate + * silently drifting behind. Detection only — it does not change any score. + */ +export function findUnmodeledConstantKeys(allConstants: Record): string[] { + return Object.keys(allConstants) + .filter((name) => !SCORING_CONSTANT_NAMES.has(name) && !NON_SCORING_UPSTREAM_CONSTANT_NAMES.has(name)) + .sort(); +} + +export function findUnmodeledUpstreamConstants(source: string): string[] { + return findUnmodeledConstantKeys(parsePythonNumberConstants(source, { knownOnly: false })); +} + +/** + * Owner-controlled global gate for applying upstream time-decay to score previews (#703). Default OFF: the + * roadmap deferral requires the owner to review a before/after ranking diff before enabling. Even when on, + * a fresh PR is unaffected (decay 1.0), so it only changes aged-PR projections. + */ +export function isTimeDecayEnabled(env: { SCORING_TIME_DECAY_ENABLED?: string | null | undefined }): boolean { + return /^(1|true|yes|on)$/i.test(env.SCORING_TIME_DECAY_ENABLED ?? ""); +} + +export function detectActiveModel(constants: Record): ScoringModelSnapshotRecord["activeModel"] { + if (hasSaturationConstants(constants)) return "pending_saturation_model"; + if (hasDensityConstants(constants)) { + return "current_density_model"; + } + return "unknown"; +} + +// Exported (unlike the backend original, where these stay private) so `src/scoring/model.ts`'s +// D1-bound `refreshScoringModelSnapshot`/`activeModelWarnings` can reuse them without duplication. +export function hasSaturationConstants(constants: Record): boolean { + return Number.isFinite(constants.SRC_TOK_SATURATION_SCALE); +} + +export function hasDensityConstants(constants: Record): boolean { + return Number.isFinite(constants.MAX_CODE_DENSITY_MULTIPLIER) && Number.isFinite(constants.MIN_TOKEN_SCORE_FOR_BASE_SCORE); +} diff --git a/packages/gittensory-engine/src/scoring/pending-pr-scenarios.ts b/packages/gittensory-engine/src/scoring/pending-pr-scenarios.ts new file mode 100644 index 0000000000..c5f8e3f039 --- /dev/null +++ b/packages/gittensory-engine/src/scoring/pending-pr-scenarios.ts @@ -0,0 +1,223 @@ +import type { CheckSummaryRecord, PullRequestRecord, PullRequestReviewRecord, RoleContext } from "./types.js"; +import type { ScorePreviewInput } from "./preview.js"; + +// Deterministic subset of the backend's `src/scoring/pending-pr-scenarios.ts` (#2282): the pure +// classification/detection logic that only needs already-fetched PR/review/check records. The two +// D1-fetching loaders (`loadContributorRepoOpenPrSignalRecords`, `loadContributorRepoOpenPrSignals`) stay in +// `src/scoring/pending-pr-scenarios.ts`, which imports the pure pieces below back from this package. + +export type OpenPrPendingClass = + | "merge_ready" + | "stale_likely_close" + | "draft" + | "blocked" + | "maintainer_lane" + | "open_other"; + +export type ClassifiedOpenPullRequest = { + repoFullName: string; + number: number; + title: string; + classification: OpenPrPendingClass; + reasons: string[]; +}; + +export type PendingPrScenarioDetection = { + source: "github_observed" | "user_supplied"; + pendingMergedPrCount: number; + pendingClosedPrCount: number; + approvedPrCount: number; + expectedOpenPrCountAfterMerge?: number | undefined; + scenarioNotes: string[]; + classified: ClassifiedOpenPullRequest[]; +}; + +export type ContributorRepoOpenPrSignals = { + reviewsByPullNumber: Map; + checksByPullNumber: Map; +}; + +const STALE_DAYS = 14; + +// Real draft markers only — "[draft]", "Draft:", "Draft -"; the delimiter keeps "Drafting" and +// "draft-js" from matching. Trailing \s* lets the same pattern also strip the marker for dedup keys. +export const DRAFT_TITLE_PATTERN = /^(?:\[\s*draft\s*\]|draft(?:\s*:|\s+-))\s*/i; + +// Mirrors `src/github/commands.ts`'s `isMaintainerAssociation`/`MAINTAINER_ASSOCIATIONS` exactly. Duplicated +// here (rather than imported) because this package cannot reach into `src/`; keep the two in sync by hand. +const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); + +function isMaintainerAssociation(association: string | null | undefined): boolean { + return Boolean(association && MAINTAINER_ASSOCIATIONS.has(association)); +} + +export function detectPendingPrScenario(args: { + login: string; + repoFullName: string; + pullRequests: PullRequestRecord[]; + roleContext: RoleContext; + openPrCount?: number | undefined; + reviewsByPullNumber?: Map | undefined; + checksByPullNumber?: Map | undefined; + excludePullNumbers?: number[] | undefined; + userSupplied?: Pick< + ScorePreviewInput, + "pendingMergedPrCount" | "pendingClosedPrCount" | "approvedPrCount" | "expectedOpenPrCountAfterMerge" | "projectedCredibility" | "scenarioNotes" + > | undefined; +}): PendingPrScenarioDetection | null { + const user = args.userSupplied; + const hasUserCounts = + user?.pendingMergedPrCount !== undefined || + user?.pendingClosedPrCount !== undefined || + user?.approvedPrCount !== undefined || + user?.expectedOpenPrCountAfterMerge !== undefined; + if (hasUserCounts) { + return { + source: "user_supplied", + pendingMergedPrCount: nonNegative(user?.pendingMergedPrCount), + pendingClosedPrCount: nonNegative(user?.pendingClosedPrCount), + approvedPrCount: nonNegative(user?.approvedPrCount), + ...(user?.expectedOpenPrCountAfterMerge !== undefined ? { expectedOpenPrCountAfterMerge: nonNegative(user.expectedOpenPrCountAfterMerge) } : {}), + scenarioNotes: user?.scenarioNotes ?? [], + classified: [], + }; + } + + const excluded = new Set(args.excludePullNumbers ?? []); + const contributorOpen = args.pullRequests.filter( + (pr) => + sameRepoFullName(pr.repoFullName, args.repoFullName) && + pr.state === "open" && + sameLogin(pr.authorLogin, args.login) && + !excluded.has(pr.number), + ); + if (contributorOpen.length === 0) return null; + + const classified = contributorOpen.map((pr) => + classifyOpenPullRequest({ + pr, + roleContext: args.roleContext, + reviews: args.reviewsByPullNumber?.get(pr.number) ?? [], + checks: args.checksByPullNumber?.get(pr.number) ?? [], + }), + ); + + const mergeReady = classified.filter((entry) => entry.classification === "merge_ready"); + const staleLikelyClose = classified.filter((entry) => entry.classification === "stale_likely_close"); + const pendingMergedPrCount = mergeReady.length; + const pendingClosedPrCount = staleLikelyClose.length; + if (pendingMergedPrCount === 0 && pendingClosedPrCount === 0) return null; + + const currentOpen = args.openPrCount ?? contributorOpen.length; + const expectedOpenPrCountAfterMerge = Math.max(0, currentOpen - pendingMergedPrCount - pendingClosedPrCount); + const scenarioNotes = [ + "GitHub-observed open PR state from cached reviews, checks, and activity timestamps (estimate only).", + ...(pendingMergedPrCount > 0 + ? [`${pendingMergedPrCount} open PR(s) look merge-ready (approved, no changes requested, no failing checks, not draft/stale).`] + : []), + ...(pendingClosedPrCount > 0 ? [`${pendingClosedPrCount} open PR(s) look stale and may be closed instead of merged.`] : []), + ...classified + .filter((entry) => entry.classification === "draft" || entry.classification === "blocked" || entry.classification === "maintainer_lane") + .map((entry) => `PR #${entry.number} treated as ${entry.classification.replace(/_/g, " ")} for this projection.`), + ]; + + return { + source: "github_observed", + pendingMergedPrCount, + pendingClosedPrCount, + approvedPrCount: mergeReady.length, + expectedOpenPrCountAfterMerge, + scenarioNotes, + classified, + }; +} + +export function classifyOpenPullRequest(args: { + pr: PullRequestRecord; + roleContext: RoleContext; + reviews: PullRequestReviewRecord[]; + checks: CheckSummaryRecord[]; + duplicateProne?: boolean | undefined; + missingTests?: boolean | undefined; +}): ClassifiedOpenPullRequest { + const reasons: string[] = []; + if (args.roleContext.maintainerLane) { + reasons.push("Maintainer-lane context for this repo; not counted as outside-contributor pending reward work."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons }; + } + if (isMaintainerAssociation(args.pr.authorAssociation)) { + reasons.push("Author association indicates maintainer-authored work."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons }; + } + if (isDraftPullRequest(args.pr)) { + reasons.push("Draft PRs are not treated as likely to land."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "draft", reasons }; + } + + const approvalCount = args.reviews.filter((review) => review.state.toUpperCase() === "APPROVED").length; + const changeRequestCount = args.reviews.filter((review) => review.state.toUpperCase() === "CHANGES_REQUESTED").length; + const checkFailureCount = args.checks.filter( + (check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled", + ).length; + const ageDays = daysSince(args.pr.updatedAt ?? args.pr.createdAt); + + if (args.duplicateProne) reasons.push("Overlapping open work detected in the same repo (possible duplicate or WIP collision)."); + if (args.missingTests) reasons.push("Cached file list shows code changes without matching test files."); + if (changeRequestCount > 0) reasons.push(`${changeRequestCount} changes-requested review(s).`); + if (checkFailureCount > 0) reasons.push(`${checkFailureCount} failing or cancelled check(s).`); + if (approvalCount === 0) reasons.push("No approved review in cache."); + + if (changeRequestCount > 0 || checkFailureCount > 0 || approvalCount === 0) { + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "blocked", reasons }; + } + + if (ageDays >= STALE_DAYS) { + reasons.push(`No meaningful update in at least ${STALE_DAYS} days; may be closed instead of merged.`); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "stale_likely_close", reasons }; + } + + reasons.push("Approved with passing checks and recent activity; treated as likely to merge (estimate)."); + return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "merge_ready", reasons }; +} + +export function applyPendingPrDetectionToScoreInput( + input: ScorePreviewInput, + detection: PendingPrScenarioDetection | null, +): ScorePreviewInput { + if (!detection || detection.source === "user_supplied") return input; + return { + ...input, + pendingMergedPrCount: detection.pendingMergedPrCount, + pendingClosedPrCount: detection.pendingClosedPrCount, + approvedPrCount: detection.approvedPrCount, + expectedOpenPrCountAfterMerge: detection.expectedOpenPrCountAfterMerge, + scenarioNotes: [...(input.scenarioNotes ?? []), ...detection.scenarioNotes], + pendingScenarioObserved: true, + }; +} + +function isDraftPullRequest(pr: PullRequestRecord): boolean { + if (pr.isDraft) return true; + if (DRAFT_TITLE_PATTERN.test(pr.title.trim())) return true; + return pr.labels.some((label) => label.toLowerCase() === "draft" || label.toLowerCase() === "wip"); +} + +function sameRepoFullName(left: string, right: string): boolean { + return left.toLowerCase() === right.toLowerCase(); +} + +function sameLogin(value: string | null | undefined, login: string): boolean { + return Boolean(value && value.toLowerCase() === login.toLowerCase()); +} + +function daysSince(value: string | null | undefined): number { + if (!value) return Number.POSITIVE_INFINITY; + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY; + return Math.max(0, (Date.now() - parsed) / 86_400_000); +} + +function nonNegative(value: number | undefined): number { + if (value === undefined || !Number.isFinite(value)) return 0; + return Math.max(0, Math.trunc(value)); +} diff --git a/packages/gittensory-engine/src/scoring/preview.ts b/packages/gittensory-engine/src/scoring/preview.ts new file mode 100644 index 0000000000..c9f93ed0df --- /dev/null +++ b/packages/gittensory-engine/src/scoring/preview.ts @@ -0,0 +1,1494 @@ +import type { ContributorEvidenceRecord, JsonValue, RepositoryRecord, RepoTimeDecayOverrides, ScoringModelSnapshotRecord, ScorePreviewRecord } from "./types.js"; +import { DEFAULT_SCORING_CONSTANTS } from "./model.js"; + +// Deterministic score-preview builder extracted verbatim from the backend's `src/scoring/preview.ts` +// (#2282) — this file has no D1/network/env dependency in the original, so it ports unchanged aside from +// its imports and the two tiny pure helpers (`nowIso`, `hasUnsafeWildcardCount`) inlined below, which the +// backend sources from `src/utils/json.ts` and `src/signals/change-guardrail.ts` respectively. + +// The package's tsconfig sets `types: []` (no ambient DOM/Node globals, keeping the engine's type surface +// independent of any consumer's lib config), so the Web Crypto global needs a minimal local declaration. +// `crypto.randomUUID()` itself is a universal runtime global (Node 19+, browsers, Cloudflare Workers) — +// this only restores its type, not its availability. +declare const crypto: { randomUUID(): string }; + +function nowIso(): string { + return new Date().toISOString(); +} + +// Mirrors `src/signals/change-guardrail.ts`'s `hasUnsafeWildcardCount`/wildcard-group counting exactly — +// see that file for the full ReDoS-safety rationale. Duplicated here (rather than imported) because this +// package cannot reach into `src/`; keep the two in sync by hand. +const MAX_GLOB_WILDCARD_GROUPS = 2; + +function countWildcardGroups(glob: string): number { + let count = 0; + for (let i = 0; i < glob.length; i += 1) { + if (glob.charAt(i) !== "*") continue; + count += 1; + if (glob.charAt(i + 1) === "*") { + i += 1; // consume the second star of the "**" pair — one group, not two + if (glob.charAt(i + 1) === "/") i += 1; // `**/` also matches zero segments, mirroring globToRegExp + } + } + return count; +} + +function hasUnsafeWildcardCount(glob: string): boolean { + return countWildcardGroups(glob) > MAX_GLOB_WILDCARD_GROUPS; +} + +export type ScorePreviewInput = { + repoFullName: string; + targetType?: ScorePreviewRecord["targetType"]; + targetKey?: string | undefined; + contributorLogin?: string | undefined; + labels?: string[] | undefined; + linkedIssueMode?: "none" | "standard" | "maintainer" | undefined; + linkedIssueContext?: LinkedIssueMultiplierContext | undefined; + sourceTokenScore?: number | undefined; + totalTokenScore?: number | undefined; + sourceLines?: number | undefined; + testTokenScore?: number | undefined; + nonCodeTokenScore?: number | undefined; + /** Raw non-code line count before upstream's MAX_LINES_SCORED_FOR_NON_CODE_EXT cap. */ + nonCodeLines?: number | undefined; + existingContributorTokenScore?: number | undefined; + openPrCount?: number | undefined; + /** Contributor's current open-issue count for the repo, used for the open-issue spam gate (#808). */ + openIssueCount?: number | undefined; + /** Repo-level merged PR count for upstream contributor-history eligibility (#808). */ + mergedPullRequests?: number | undefined; + /** Count of valid solved issues for upstream issue-discovery eligibility (#808). */ + validSolvedIssues?: number | undefined; + /** Issue-discovery credibility for upstream issue-discovery eligibility (#808). */ + issueCredibility?: number | undefined; + credibility?: number | undefined; + changesRequestedCount?: number | undefined; + fixedBaseScore?: number | undefined; + metadataOnly?: boolean | undefined; + pendingMergedPrCount?: number | undefined; + pendingClosedPrCount?: number | undefined; + approvedPrCount?: number | undefined; + observedApprovedPrCount?: number | undefined; + observedStalePrCount?: number | undefined; + observedClosedPrCount?: number | undefined; + observedDraftPrCount?: number | undefined; + observedBlockedPrCount?: number | undefined; + observedMaintainerPrCount?: number | undefined; + duplicateRiskCount?: number | undefined; + expectedOpenPrCountAfterMerge?: number | undefined; + projectedCredibility?: number | undefined; + scenarioNotes?: string[] | undefined; + pendingScenarioObserved?: boolean | undefined; + observedScenarioNotes?: string[] | undefined; + branchEligibility?: BranchEligibilityInput | undefined; + /** Hours since the PR merged, for upstream time-decay (#703). Absent / below the grace period = a fresh + * PR (multiplier 1.0). Only consulted when `applyTimeDecay` is on. */ + prAgeHours?: number | undefined; + /** Opt-in upstream time-decay (#703), default OFF and env-gated (SCORING_TIME_DECAY_ENABLED) at the call + * site. Even when on, a fresh PR is unaffected, so it never changes a normal new-PR preview. */ + applyTimeDecay?: boolean | undefined; +}; + +export type BranchEligibilityInput = { + status: "eligible" | "ineligible" | "unknown"; + source?: "github_metadata" | "local_metadata" | "registry" | "user_supplied" | undefined; + reason?: string | undefined; + checkedAt?: string | undefined; + stale?: boolean | undefined; +}; + +export type BranchEligibilityResult = { + required: boolean; + status: "eligible" | "ineligible" | "unknown" | "not_required"; + evidence: "provided" | "missing"; + source: "github_metadata" | "local_metadata" | "registry" | "user_supplied" | "missing"; + reason?: string | undefined; + checkedAt?: string | undefined; + stale: boolean; + warnings: string[]; +}; + +export type LinkedIssueMultiplierStatus = "not_required" | "raw" | "plausible" | "validated" | "invalid" | "unavailable"; + +export type LinkedIssueMultiplierSource = "none" | "user_supplied" | "official_mirror" | "github_cache" | "issue_quality" | "missing"; + +export type LinkedIssueMultiplierContext = { + status?: Exclude | undefined; + source?: Exclude | undefined; + issueNumbers?: number[] | undefined; + solvedByPullRequests?: number[] | undefined; + reason?: string | undefined; + warnings?: string[] | undefined; +}; + +const PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION = Symbol("projectedSolvedByPullRequestValidation"); + +type ProjectedLinkedIssueMultiplierContext = LinkedIssueMultiplierContext & { + [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]?: true; +}; + +export type LinkedIssueMultiplierDecision = { + mode: "none" | "standard" | "maintainer"; + status: LinkedIssueMultiplierStatus; + source: LinkedIssueMultiplierSource; + eligible: boolean; + issueNumbers: number[]; + solvedByPullRequests: number[]; + baseMultiplier: number; + appliedMultiplier: number; + reason: string; + warnings: string[]; +}; + +export type ScoreGateBlocker = { + code: + | "repo_not_registered" + | "inactive_allocation" + | "base_token_gate" + | "open_pr_threshold" + | "open_issue_threshold" + | "merged_pr_history_floor" + | "issue_discovery_validity_floor" + | "credibility_floor" + | "review_penalty" + | "metadata_only" + | "linked_issue_invalid" + | "linked_issue_unvalidated" + | "branch_ineligible" + | "branch_eligibility_missing" + | "duplicate_risk" + | "stale_work"; + severity: "blocker" | "reducer" | "context"; + detail: string; +}; + +export type ScoreGateDelta = { + gate: + | "open_pr_threshold" + | "open_issue_threshold" + | "merged_pr_history_floor" + | "issue_discovery_validity_floor" + | "credibility_floor" + | "linked_issue_multiplier"; + current: string; + projected: string; + explanation: string; +}; + +export type ScoreScenarioPreview = { + name: "current" | "cleanGates" | "afterPendingMerges" | "afterApprovedPrsMerge" | "afterStalePrsClose" | "linkedIssueFixed" | "bestReasonableCase"; + source: "current_data" | "user_supplied" | "github_observed" | "gittensory_projection"; + assumptions: string[]; + scoreEstimate: ScorePreviewResult["scoreEstimate"]; + gates: ScorePreviewResult["gates"]; + effectiveEstimatedScore: number; + underlyingPotentialScore: number; + blockedBy: ScoreGateBlocker[]; + linkedIssueMultiplier: LinkedIssueMultiplierDecision; + deltaExplanation: string; +}; + +export type ScorePreviewResult = { + repoFullName: string; + generatedAt: string; + scoringModelSnapshotId: string; + activeModel: ScoringModelSnapshotRecord["activeModel"]; + privateOnly: true; + laneMath: { + repoEmissionShare: number; + ossEmissionShare: number; + repoSlice: number; + directPrSlice: number; + issueDiscoverySlice: number; + issueDiscoveryShare: number; + }; + scoreEstimate: { + /** Computed base score (the earned foundation before multipliers apply). */ + baseScore: number; + /** The maximum possible baseScore given the active model and snapshot constants; used by the score + * breakdown to surface saturation vs sub-cap status. Undefined when a fixedBaseScore override is in + * effect (the override is not bounded by the model cap). */ + baseScoreCap?: number; + densityMultiplier: number; + contributionBonus: number; + labelMultiplier: number; + issueMultiplier: number; + credibilityMultiplier: number; + reviewPenaltyMultiplier: number; + openPrMultiplier: number; + openIssueMultiplier: number; + /** Upstream merged-PR history floor (#808). 0 when below MIN_VALID_MERGED_PRS; 1 when unknown or eligible. */ + mergedHistoryMultiplier: number; + /** Upstream issue-discovery validity floor (#808). 0 when below MIN_VALID_SOLVED_ISSUES or MIN_ISSUE_CREDIBILITY. */ + issueDiscoveryHistoryMultiplier: number; + /** Upstream sigmoid time-decay multiplier (#703). 1 = no decay (fresh PR, or feature off). */ + timeDecayMultiplier: number; + estimatedMergedScore: number; + pendingSaturationScore: number; + }; + linkedIssueMultiplier: LinkedIssueMultiplierDecision; + gates: { + baseTokenGatePassed: boolean; + openPrThreshold: number; + openPrCount: number; + /** Effective open-PR collateral fraction (OPEN_PR_COLLATERAL_PERCENT × reviewCollateralMultiplier). */ + collateralFraction: number; + /** Upstream open-PR review-collateral multiplier from CHANGES_REQUESTED reviews (≥ 1, capped). */ + reviewCollateralMultiplier: number; + credibilityFloor: number; + credibilityObserved: number; + openIssueThreshold: number; + openIssueCount: number; + mergedPrFloor: number; + /** Observed merged PR count when supplied or inferred from contributor evidence; absent when unknown. */ + mergedPullRequests?: number | undefined; + validSolvedIssuesFloor: number; + /** Observed valid solved-issue count when supplied; absent when unknown. */ + validSolvedIssues?: number | undefined; + issueCredibilityFloor: number; + /** Observed issue-discovery credibility when supplied; absent when unknown. */ + issueCredibility?: number | undefined; + /** Upstream non-code line scoring cap (MAX_LINES_SCORED_FOR_NON_CODE_EXT); non-code token score beyond this + * many changed non-code lines is not scored. */ + nonCodeLineCap: number; + /** Observed raw non-code line count before the cap; absent when no non-code line count was supplied. */ + nonCodeLinesObserved?: number | undefined; + }; + branchEligibility: BranchEligibilityResult; + effectiveEstimatedScore: number; + underlyingPotentialScore: number; + blockedBy: ScoreGateBlocker[]; + gateDeltas: ScoreGateDelta[]; + scenarioPreviews: ScoreScenarioPreview[]; + scoreabilityStatus: "blocked" | "conditionally_scoreable" | "scoreable" | "hold"; + warnings: string[]; + assumptions: string[]; + recommendation: { + level: "strong_fit" | "reasonable_fit" | "needs_work" | "hold"; + actions: string[]; + }; +}; + +export function buildScorePreview(args: { + input: ScorePreviewInput; + repo: RepositoryRecord | null; + snapshot: ScoringModelSnapshotRecord; + contributorEvidence?: ContributorEvidenceRecord | null | undefined; +}): ScorePreviewResult { + const branchEligibility = normalizeBranchEligibility(args.input); + const current = computeScoreCore(args.input, args.repo, args.snapshot, args.contributorEvidence); + const scenarioPreviews = buildScenarioPreviews(args.input, args.repo, args.snapshot, args.contributorEvidence, current); + const blockedBy = blockedByFor(args.input, args.repo, current, branchEligibility); + const gateDeltas = buildGateDeltas(current, scenarioPreviews); + const effectiveEstimatedScore = current.scoreEstimate.estimatedMergedScore; + const underlyingPotentialScore = current.scoreEstimate.pendingSaturationScore; + const scoreabilityStatus = statusFor(args.repo, blockedBy, effectiveEstimatedScore, scenarioPreviews); + const warnings = [...args.snapshot.warnings, ...warningsFor(args.input, args.repo, current, branchEligibility)]; + const actions = [ + ...(!current.gates.baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []), + ...(current.scoreEstimate.openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []), + ...(current.scoreEstimate.openIssueMultiplier === 0 ? ["Close excess open issues to stay within the open-issue spam threshold."] : []), + ...(current.scoreEstimate.mergedHistoryMultiplier === 0 ? ["Build merged PR history on this repo before relying on this preview; upstream requires a minimum merged count."] : []), + ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier === 0 + ? ["Build valid solved-issue history and issue credibility before relying on issue-discovery scoring on this repo."] + : []), + ...(current.scoreEstimate.credibilityMultiplier < 1 ? ["Build or wait for contributor credibility evidence before relying on this preview."] : []), + ...(current.scoreEstimate.reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []), + ...(current.scoreEstimate.labelMultiplier <= 1 && Object.keys(args.repo?.registryConfig?.labelMultipliers ?? {}).length > 0 + ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."] + : []), + ...(branchEligibility.required && branchEligibility.status === "ineligible" ? ["Use an eligible branch or remove linked-issue assumptions before relying on this preview."] : []), + ...(branchEligibility.required && (branchEligibility.evidence === "missing" || branchEligibility.stale) + ? ["Refresh branch/base eligibility metadata before relying on linked-issue assumptions."] + : []), + ...(current.linkedIssueMultiplier.mode === "standard" && !current.linkedIssueMultiplier.eligible + ? ["Validate linked issue context with solved-by-PR evidence before relying on the standard issue multiplier."] + : []), + ]; + + return { + repoFullName: args.input.repoFullName, + generatedAt: nowIso(), + scoringModelSnapshotId: args.snapshot.id, + activeModel: args.snapshot.activeModel, + privateOnly: true, + laneMath: current.laneMath, + scoreEstimate: current.scoreEstimate, + linkedIssueMultiplier: current.linkedIssueMultiplier, + gates: current.gates, + branchEligibility, + effectiveEstimatedScore, + underlyingPotentialScore, + blockedBy, + gateDeltas, + scenarioPreviews, + scoreabilityStatus, + warnings, + assumptions: [ + "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.", + "No future outcome or exact payout is guaranteed.", + "Private API/MCP output only; public comments intentionally omit these details.", + `Linked issue multiplier status: ${current.linkedIssueMultiplier.status}; ${current.linkedIssueMultiplier.reason}`, + ...branchEligibility.warnings, + ...(args.input.scenarioNotes ?? []).map((note) => `User scenario note: ${note}`), + ], + recommendation: { + level: scoreabilityStatus === "hold" || warnings.some((warning) => /not registered|no active|exceeds/i.test(warning)) + ? "hold" + : effectiveEstimatedScore >= 30 && warnings.length === 0 + ? "strong_fit" + : effectiveEstimatedScore >= 15 + ? "reasonable_fit" + : "needs_work", + actions: actions.length > 0 ? actions : ["Keep the PR focused, linked, tested, and easy for maintainers to review."], + }, + }; +} + +export function makeScorePreviewRecord(input: ScorePreviewInput, snapshot: ScoringModelSnapshotRecord, result: ScorePreviewResult): ScorePreviewRecord { + return { + id: crypto.randomUUID(), + scoringModelSnapshotId: snapshot.id, + repoFullName: input.repoFullName, + targetType: input.targetType ?? "planned_pr", + targetKey: input.targetKey ?? `${input.repoFullName}:${input.targetType ?? "planned_pr"}:${Date.now()}`, + contributorLogin: input.contributorLogin, + input: input as unknown as Record, + result: result as unknown as Record, + generatedAt: result.generatedAt, + }; +} + +type ScoreCore = Pick; + +function computeScoreCore( + input: ScorePreviewInput, + repo: RepositoryRecord | null, + snapshot: ScoringModelSnapshotRecord, + contributorEvidence?: ContributorEvidenceRecord | null | undefined, +): ScoreCore { + const constants = { ...snapshot.constants }; + const config = repo?.registryConfig; + const emissionShare = clamp(config?.emissionShare ?? 0, 0, 1); + const issueDiscoveryShare = clamp(config?.issueDiscoveryShare ?? 0, 0, 1); + const ossEmissionShare = constant(constants, "OSS_EMISSION_SHARE"); + const repoSlice = emissionShare * ossEmissionShare; + const directPrSlice = repoSlice * (1 - issueDiscoveryShare); + const issueDiscoverySlice = repoSlice * issueDiscoveryShare; + const sourceTokenScore = nonNegative(input.sourceTokenScore); + // TEST_FILE_CONTRIBUTION_WEIGHT (#808): upstream weights test-file tokens at 0.05× relative to source tokens. + // Applied only when totalTokenScore is not explicitly provided — an explicit caller total is honoured as-is. + const testFileWeight = constant(constants, "TEST_FILE_CONTRIBUTION_WEIGHT"); + const cappedNonCodeTokenScore = applyNonCodeLineCap(input, constants); + const derivedTotalTokenScore = sourceTokenScore + testFileWeight * nonNegative(input.testTokenScore) + cappedNonCodeTokenScore; + const totalTokenScore = + input.totalTokenScore === undefined ? nonNegative(derivedTotalTokenScore) : applyNonCodeCapToTotal(input.totalTokenScore, input, cappedNonCodeTokenScore); + const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore)); + const fixedBaseScore = clampFixedBaseScore(input.fixedBaseScore ?? config?.fixedBaseScore); + const rawDensity = sourceTokenScore / sourceLines; + // Density branch (#812): upstream is on the saturation model, but `current_density_model` is still a + // supported `activeModel` (types.ts union, the public OpenAPI schema, the DB parser, ~20 test fixtures, and + // src/services/score-breakdown.ts which keys off densityMultiplier). The issue's "if density is dead, + // remove the branch" condition is therefore FALSE — the branch is retained as the supported alternate / + // fetch-failure-`unknown` fallback model. Its fallback constants are now single-sourced from + // DEFAULT_SCORING_CONSTANTS (model.ts) instead of silent duplicated literals, closing the drift surface. + const densityMultiplier = clamp(rawDensity || 0, 0, constant(constants, "MAX_CODE_DENSITY_MULTIPLIER")); + const densityTokenGatePassed = sourceTokenScore >= constant(constants, "MIN_TOKEN_SCORE_FOR_BASE_SCORE"); + const baseTokenGatePassed = snapshot.activeModel === "pending_saturation_model" ? sourceTokenScore > 0 : densityTokenGatePassed; + const densityContributionBonus = contributionBonusRamp(totalTokenScore, constants); + const saturationContributionBonusValue = saturationContributionBonus(totalTokenScore, constants); + const saturationBaseScore = saturationScore(sourceTokenScore, totalTokenScore, constants); + const densityBaseScore = + (densityTokenGatePassed ? constant(constants, "MERGED_PR_BASE_SCORE") * densityMultiplier : 0) + densityContributionBonus; + const baseScore = + fixedBaseScore !== undefined + ? fixedBaseScore + : snapshot.activeModel === "pending_saturation_model" + ? saturationBaseScore + : densityBaseScore; + const baseScoreCap = + fixedBaseScore !== undefined + ? undefined + : snapshot.activeModel === "pending_saturation_model" + ? constant(constants, "MERGED_PR_BASE_SCORE") + constant(constants, "MAX_CONTRIBUTION_BONUS") + : constant(constants, "MERGED_PR_BASE_SCORE") * constant(constants, "MAX_CODE_DENSITY_MULTIPLIER") + constant(constants, "MAX_CONTRIBUTION_BONUS"); + const activeContributionBonus = snapshot.activeModel === "pending_saturation_model" ? saturationContributionBonusValue : densityContributionBonus; + const labelMultiplier = selectLabelMultiplier(input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); + const branchEligibility = normalizeBranchEligibility(input); + const linkedIssueMultiplier = decideLinkedIssueMultiplier(input.linkedIssueMode ?? "none", input.linkedIssueContext, constants, branchEligibility); + const issueMultiplier = linkedIssueMultiplier.appliedMultiplier; + const credibilityObserved = clamp(input.credibility ?? inferCredibility(contributorEvidence), 0, 1); + const credibilityFloor = constant(constants, "MIN_CREDIBILITY"); + const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor; + const changesRequestedCount = nonNegative(input.changesRequestedCount); + const reviewPenaltyRate = constant(constants, "REVIEW_PENALTY_RATE"); + const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * reviewPenaltyRate, 0, 1); + const reviewCollateralMultiplier = Math.min( + constant(constants, "MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER"), + 1 + changesRequestedCount * reviewPenaltyRate, + ); + const openPrCollateralPercent = constant(constants, "OPEN_PR_COLLATERAL_PERCENT"); + const openPrCount = nonNegative(input.openPrCount); + // The concurrency allowance is earned from the contributor's established merged-history token + // score; the planned PR's own tokens (totalTokenScore) must not inflate its own open-PR threshold. + const openPrThreshold = Math.min( + constant(constants, "MAX_OPEN_PR_THRESHOLD"), + constant(constants, "EXCESSIVE_PR_PENALTY_BASE_THRESHOLD") + + Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE")), + ); + const openPrMultiplier = openPrCount <= openPrThreshold ? 1 : 0; + // Open-issue spam gate (#808): mirrors the open-PR gate for the issue-discovery channel. + // A contributor earns extra open-issue slots from their existing merged-history token score. + const openIssueCount = nonNegative(input.openIssueCount); + const openIssueThreshold = Math.min( + constant(constants, "MAX_OPEN_ISSUE_THRESHOLD"), + constant(constants, "OPEN_ISSUE_SPAM_BASE_THRESHOLD") + + Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT")), + ); + const openIssueMultiplier = openIssueCount <= openIssueThreshold ? 1 : 0; + const mergedPrFloor = constant(constants, "MIN_VALID_MERGED_PRS"); + const mergedPullRequestsObserved = resolveMergedPullRequests(input, contributorEvidence); + const mergedHistoryMultiplier = + mergedPullRequestsObserved === undefined ? 1 : mergedPullRequestsObserved >= mergedPrFloor ? 1 : 0; + const validSolvedIssuesFloor = constant(constants, "MIN_VALID_SOLVED_ISSUES"); + const issueCredibilityFloor = constant(constants, "MIN_ISSUE_CREDIBILITY"); + const validSolvedIssuesObserved = input.validSolvedIssues !== undefined ? nonNegative(input.validSolvedIssues) : undefined; + const issueCredibilityObserved = input.issueCredibility !== undefined ? clamp(input.issueCredibility, 0, 1) : undefined; + // Issue-discovery validity mirrors upstream's separate issue lane, which is the `standard` linked-issue + // lane only. The `maintainer` lane explicitly does not require solved-by-PR issue linkage (see + // decideLinkedIssueMultiplier), so it must not be gated by the solved-issue history floor — otherwise a + // maintainer preview with sparse issue history is wrongly zeroed and flagged issue_discovery_validity_floor. + const issueDiscoveryRelevant = (input.linkedIssueMode ?? "none") === "standard"; + const issueDiscoveryHistoryKnown = validSolvedIssuesObserved !== undefined && issueCredibilityObserved !== undefined; + const issueDiscoveryHistoryMultiplier = + !issueDiscoveryRelevant || !issueDiscoveryHistoryKnown + ? 1 + : validSolvedIssuesObserved >= validSolvedIssuesFloor && issueCredibilityObserved >= issueCredibilityFloor + ? 1 + : 0; + // Upstream time-decay (#703): mirrors upstream's `scored.time_decay_multiplier` applied to a PR's score. + // Opt-in + env-gated (default off). A fresh PR (prAgeHours below the grace period) yields 1.0, so a normal + // new-PR preview is unchanged even when enabled — only an aged-PR projection decays. + // Per-repo curve (#703): the repo's registry `scoring.time_decay` overrides overlay the snapshot defaults. + const timeDecayMultiplier = input.applyTimeDecay ? calculateTimeDecay(nonNegative(input.prAgeHours), constants, config?.timeDecay) : 1; + const estimatedMergedScore = roundScore( + baseScore * + labelMultiplier * + issueMultiplier * + credibilityMultiplier * + reviewPenaltyMultiplier * + openPrMultiplier * + openIssueMultiplier * + mergedHistoryMultiplier * + issueDiscoveryHistoryMultiplier * + timeDecayMultiplier, + ); + const pendingSaturationScore = roundScore(saturationBaseScore); + return { + laneMath: { + repoEmissionShare: emissionShare, + ossEmissionShare, + repoSlice: roundScore(repoSlice), + directPrSlice: roundScore(directPrSlice), + issueDiscoverySlice: roundScore(issueDiscoverySlice), + issueDiscoveryShare, + }, + scoreEstimate: { + baseScore: roundScore(baseScore), + ...(baseScoreCap !== undefined ? { baseScoreCap: roundScore(baseScoreCap) } : {}), + densityMultiplier: roundScore(densityMultiplier), + contributionBonus: roundScore(activeContributionBonus), + labelMultiplier, + issueMultiplier, + credibilityMultiplier: roundScore(credibilityMultiplier), + reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier), + openPrMultiplier, + openIssueMultiplier, + mergedHistoryMultiplier, + issueDiscoveryHistoryMultiplier, + timeDecayMultiplier: roundScore(timeDecayMultiplier), + estimatedMergedScore, + pendingSaturationScore, + }, + linkedIssueMultiplier, + gates: { + baseTokenGatePassed, + openPrThreshold, + openPrCount, + reviewCollateralMultiplier: roundScore(reviewCollateralMultiplier), + collateralFraction: roundScore(openPrCollateralPercent * reviewCollateralMultiplier), + credibilityFloor, + credibilityObserved, + openIssueThreshold, + openIssueCount, + mergedPrFloor, + ...(mergedPullRequestsObserved !== undefined ? { mergedPullRequests: mergedPullRequestsObserved } : {}), + validSolvedIssuesFloor, + ...(validSolvedIssuesObserved !== undefined ? { validSolvedIssues: validSolvedIssuesObserved } : {}), + issueCredibilityFloor, + ...(issueCredibilityObserved !== undefined ? { issueCredibility: issueCredibilityObserved } : {}), + nonCodeLineCap: constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT"), + ...(input.nonCodeLines !== undefined ? { nonCodeLinesObserved: nonNegative(input.nonCodeLines) } : {}), + }, + }; +} + +function buildScenarioPreviews( + input: ScorePreviewInput, + repo: RepositoryRecord | null, + snapshot: ScoringModelSnapshotRecord, + contributorEvidence: ContributorEvidenceRecord | null | undefined, + current: ScoreCore, +): ScoreScenarioPreview[] { + // Count each pending open PR at most once. `approvedPrCount` and + // `pendingMergedPrCount` are the same merge-ready set — detectPendingPrScenario + // sets both to `mergeReady.length` — so they must be folded with `max`, not + // added, or the merge-ready PRs get double-subtracted from the open-PR + // projection. Closed/likely-close PRs are a disjoint set and add on top. This + // mirrors the canonical reduction in pending-pr-scenarios.ts + // (currentOpen - pendingMergedPrCount - pendingClosedPrCount). + const mergeReadyPending = Math.max(nonNegative(input.pendingMergedPrCount), nonNegative(input.approvedPrCount)); + const userPendingCount = mergeReadyPending + nonNegative(input.pendingClosedPrCount); + const observedApprovedCount = nonNegative(input.observedApprovedPrCount); + const observedStaleCloseCount = nonNegative(input.observedStalePrCount); + const observedClosedCount = nonNegative(input.observedClosedPrCount); + const combinedPendingCount = userPendingCount + observedApprovedCount + observedStaleCloseCount; + const expectedOpenPrCountAfterMerge = + input.expectedOpenPrCountAfterMerge !== undefined ? nonNegative(input.expectedOpenPrCountAfterMerge) : Math.max(0, current.gates.openPrCount - userPendingCount); + const projectedCredibility = + input.projectedCredibility !== undefined + ? clamp(input.projectedCredibility, 0, 1) + : userPendingCount > 0 + ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) + : current.gates.credibilityObserved; + const observedApprovalCredibility = observedApprovedCount > 0 ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) : current.gates.credibilityObserved; + const afterApprovedInput = { + ...input, + openPrCount: Math.max(0, current.gates.openPrCount - observedApprovedCount), + credibility: observedApprovalCredibility, + }; + const afterStaleInput = { + ...input, + openPrCount: Math.max(0, current.gates.openPrCount - observedStaleCloseCount), + credibility: current.gates.credibilityObserved, + }; + const cleanGatesInput = { + ...input, + openPrCount: Math.min(current.gates.openPrCount, current.gates.openPrThreshold), + openIssueCount: Math.min(current.gates.openIssueCount, current.gates.openIssueThreshold), + credibility: Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor), + ...(current.gates.mergedPullRequests !== undefined + ? { mergedPullRequests: Math.max(current.gates.mergedPullRequests, current.gates.mergedPrFloor) } + : {}), + ...(current.gates.validSolvedIssues !== undefined + ? { validSolvedIssues: Math.max(current.gates.validSolvedIssues, current.gates.validSolvedIssuesFloor) } + : {}), + ...(current.gates.issueCredibility !== undefined + ? { issueCredibility: Math.max(current.gates.issueCredibility, current.gates.issueCredibilityFloor) } + : {}), + }; + const afterPendingInput = { + ...input, + openPrCount: expectedOpenPrCountAfterMerge, + credibility: projectedCredibility, + ...(current.gates.mergedPullRequests !== undefined + ? { mergedPullRequests: nonNegative(current.gates.mergedPullRequests) + mergeReadyPending } + : {}), + }; + const linkedIssueInput = withValidatedLinkedIssueScenario(input); + const bestReasonableInput = { + ...linkedIssueInput, + openPrCount: Math.min( + input.expectedOpenPrCountAfterMerge !== undefined ? expectedOpenPrCountAfterMerge : Math.max(0, current.gates.openPrCount - combinedPendingCount), + current.gates.openPrThreshold, + ), + // Project open-issue spam cleanup (#808): mirror the open-PR projection so the + // "best reasonable case" can clear the open-issue gate just like it clears open-PR pressure. + openIssueCount: Math.min(current.gates.openIssueCount, current.gates.openIssueThreshold), + credibility: Math.max(projectedCredibility, observedApprovalCredibility, current.gates.credibilityFloor), + ...(current.gates.mergedPullRequests !== undefined + ? { + mergedPullRequests: Math.max( + nonNegative(current.gates.mergedPullRequests) + mergeReadyPending, + current.gates.mergedPrFloor, + ), + } + : {}), + ...(current.gates.validSolvedIssues !== undefined + ? { validSolvedIssues: Math.max(current.gates.validSolvedIssues, current.gates.validSolvedIssuesFloor) } + : {}), + ...(current.gates.issueCredibility !== undefined + ? { issueCredibility: Math.max(current.gates.issueCredibility, current.gates.issueCredibilityFloor) } + : {}), + }; + return [ + scenario("current", "current_data", input, current, ["Current cached/account state and supplied local diff metadata."], repo), + scenario("cleanGates", "gittensory_projection", cleanGatesInput, computeScoreCore(cleanGatesInput, repo, snapshot, contributorEvidence), [ + "Open PR, open-issue, credibility, and contributor-history gates are projected as cleared; branch metadata is otherwise unchanged.", + ], repo), + scenario( + "afterPendingMerges", + input.pendingScenarioObserved + ? "github_observed" + : userPendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined + ? "user_supplied" + : "gittensory_projection", + afterPendingInput, + computeScoreCore(afterPendingInput, repo, snapshot, contributorEvidence), + [ + userPendingCount > 0 + ? `${userPendingCount} pending merged/closed PR(s) are treated as no longer open for this scenario${input.pendingScenarioObserved ? "" : " (caller-supplied)"}.` + : "No pending merge/close count was supplied; this scenario preserves current open PR pressure.", + ...(input.projectedCredibility !== undefined + ? [`Projected credibility is user-supplied as ${roundScore(projectedCredibility)}.`] + : userPendingCount > 0 + ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} because pending merges are expected to land.`] + : []), + ...(input.scenarioNotes ?? []), + ], + repo, + ), + scenario( + "afterApprovedPrsMerge", + "github_observed", + afterApprovedInput, + computeScoreCore(afterApprovedInput, repo, snapshot, contributorEvidence), + [ + observedApprovedCount > 0 + ? `${observedApprovedCount} GitHub-observed approved or mergeable open PR(s) are treated as no longer open if they merge.` + : "No GitHub-observed approved or mergeable open PRs were available for this scenario.", + ...(observedApprovedCount > 0 ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} after observed mergeable work lands.`] : []), + ...observedScenarioNotes(input), + ], + repo, + ), + scenario( + "afterStalePrsClose", + "github_observed", + afterStaleInput, + computeScoreCore(afterStaleInput, repo, snapshot, contributorEvidence), + [ + observedStaleCloseCount > 0 + ? `${observedStaleCloseCount} GitHub-observed stale open PR(s) are treated as no longer open if they close or withdraw.` + : "No GitHub-observed stale open PRs were available for this scenario.", + ...(observedClosedCount > 0 ? [`${observedClosedCount} GitHub-observed already-closed PR(s) are excluded because they no longer contribute to open PR pressure.`] : []), + "Credibility is not increased in this scenario because stale cleanup is not the same as merged work.", + ...observedScenarioNotes(input), + ], + repo, + ), + scenario("linkedIssueFixed", "gittensory_projection", linkedIssueInput, computeScoreCore(linkedIssueInput, repo, snapshot, contributorEvidence), [ + input.linkedIssueMode === "none" || !input.linkedIssueMode + ? "A standard linked-issue/no-issue rationale multiplier is projected as solved-by-PR validated." + : "Linked issue mode was already supplied; this scenario projects solved-by-PR validation where needed.", + ], repo), + scenario("bestReasonableCase", "gittensory_projection", bestReasonableInput, computeScoreCore(bestReasonableInput, repo, snapshot, contributorEvidence), [ + "Combines plausible near-term gate cleanup: open PR pressure at threshold or below, open-issue spam pressure at threshold or below, credibility at floor or above, contributor merged-history and issue-discovery validity at floor or above, and linked-issue context where applicable.", + ...(input.scenarioNotes ?? []), + ...observedScenarioNotes(input), + ], repo), + ]; +} + +function observedScenarioNotes(input: ScorePreviewInput): string[] { + return [ + ...(nonNegative(input.observedDraftPrCount) > 0 ? [`${nonNegative(input.observedDraftPrCount)} draft PR(s) were excluded from likely-to-land projections.`] : []), + ...(nonNegative(input.observedBlockedPrCount) > 0 ? [`${nonNegative(input.observedBlockedPrCount)} blocked PR(s) were excluded from likely-to-land projections.`] : []), + ...(nonNegative(input.observedMaintainerPrCount) > 0 ? [`${nonNegative(input.observedMaintainerPrCount)} maintainer-lane PR(s) were kept out of outside-contributor projections.`] : []), + ...(input.observedScenarioNotes ?? []), + ]; +} + +function scenario( + name: ScoreScenarioPreview["name"], + source: ScoreScenarioPreview["source"], + input: ScorePreviewInput, + core: ScoreCore, + assumptions: string[], + repo: RepositoryRecord | null, +): ScoreScenarioPreview { + const blockedBy = blockedByFor(input, repo, core); + return { + name, + source, + assumptions, + scoreEstimate: core.scoreEstimate, + linkedIssueMultiplier: core.linkedIssueMultiplier, + gates: core.gates, + effectiveEstimatedScore: core.scoreEstimate.estimatedMergedScore, + underlyingPotentialScore: core.scoreEstimate.pendingSaturationScore, + blockedBy, + deltaExplanation: deltaExplanationFor(core, blockedBy), + }; +} + +function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore, branchEligibility = normalizeBranchEligibility(input)): ScoreGateBlocker[] { + return [ + ...(!repo?.isRegistered + ? [{ code: "repo_not_registered" as const, severity: "blocker" as const, detail: "Repository is not registered in the local Gittensory cache." }] + : []), + ...(core.laneMath.repoEmissionShare <= 0 + ? [{ code: "inactive_allocation" as const, severity: "blocker" as const, detail: "Repository has no active allocation in the current registry snapshot." }] + : []), + ...(input.metadataOnly + ? [{ code: "metadata_only" as const, severity: "context" as const, detail: "Preview used metadata-only inputs, so token and density estimates are rough." }] + : []), + ...(branchEligibility.required && branchEligibility.status === "ineligible" + ? [ + { + code: "branch_ineligible" as const, + severity: "reducer" as const, + detail: "Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled.", + }, + ] + : []), + ...(branchEligibility.required && branchEligibility.status === "unknown" + ? [ + { + code: "branch_eligibility_missing" as const, + severity: "context" as const, + detail: + branchEligibility.evidence === "missing" + ? "Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions." + : "Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions.", + }, + ] + : []), + ...(!core.gates.baseTokenGatePassed + ? [{ code: "base_token_gate" as const, severity: "blocker" as const, detail: "Source token score does not pass the current base-score token gate." }] + : []), + ...(core.scoreEstimate.openPrMultiplier === 0 + ? [ + { + code: "open_pr_threshold" as const, + severity: "blocker" as const, + detail: `Open PR count ${core.gates.openPrCount} exceeds threshold ${core.gates.openPrThreshold}.`, + }, + ] + : []), + ...(core.scoreEstimate.openIssueMultiplier === 0 + ? [ + { + code: "open_issue_threshold" as const, + severity: "blocker" as const, + detail: `Open issue count ${core.gates.openIssueCount} exceeds spam threshold ${core.gates.openIssueThreshold}.`, + }, + ] + : []), + ...(core.scoreEstimate.mergedHistoryMultiplier === 0 + ? [ + { + code: "merged_pr_history_floor" as const, + severity: "blocker" as const, + detail: `Merged PR count ${core.gates.mergedPullRequests} is below upstream floor ${core.gates.mergedPrFloor}.`, + }, + ] + : []), + ...(core.scoreEstimate.issueDiscoveryHistoryMultiplier === 0 + ? [ + { + code: "issue_discovery_validity_floor" as const, + severity: "blocker" as const, + detail: `Issue-discovery history (${core.gates.validSolvedIssues} valid solved, credibility ${roundScore(core.gates.issueCredibility!)}) is below upstream floors (${core.gates.validSolvedIssuesFloor} valid solved, ${core.gates.issueCredibilityFloor} credibility).`, + }, + ] + : []), + ...(core.gates.credibilityObserved < core.gates.credibilityFloor + ? [ + { + code: "credibility_floor" as const, + severity: "reducer" as const, + detail: `Credibility ${roundScore(core.gates.credibilityObserved)} is below floor ${core.gates.credibilityFloor}.`, + }, + ] + : []), + ...(core.scoreEstimate.reviewPenaltyMultiplier < 1 + ? [{ code: "review_penalty" as const, severity: "reducer" as const, detail: "Change-request history reduces the estimate." }] + : []), + ...(core.linkedIssueMultiplier.mode === "standard" && core.linkedIssueMultiplier.status === "invalid" + ? [ + { + code: "linked_issue_invalid" as const, + severity: "reducer" as const, + detail: core.linkedIssueMultiplier.reason, + }, + ] + : []), + ...(core.linkedIssueMultiplier.mode === "standard" && ["raw", "plausible", "unavailable"].includes(core.linkedIssueMultiplier.status) + ? [ + { + code: "linked_issue_unvalidated" as const, + severity: "context" as const, + detail: core.linkedIssueMultiplier.reason, + }, + ] + : []), + ...(nonNegative(input.observedStalePrCount) > 0 + ? [ + { + code: "stale_work" as const, + severity: "reducer" as const, + detail: `${nonNegative(input.observedStalePrCount)} stale open PR(s) detected; consider closing stale work before opening new contributions.`, + }, + ] + : []), + ...(nonNegative(input.duplicateRiskCount) > 0 + ? [ + { + code: "duplicate_risk" as const, + severity: "reducer" as const, + detail: `${nonNegative(input.duplicateRiskCount)} duplicate-risk issue(s) or PR(s) detected; verify there is no conflicting work before proceeding.`, + }, + ] + : []), + ]; +} + +function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]): ScoreGateDelta[] { + const currentScenario = scenarios[0]; + /* v8 ignore next -- buildScenarioPreviews always emits a current scenario; this protects malformed adapters. */ + if (!currentScenario) return []; + const bestMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "bestReasonableCase"); + /* v8 ignore next -- buildScenarioPreviews always emits bestReasonableCase; current is the defensive fallback. */ + const best = bestMatch ?? currentScenario; + const linkedMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "linkedIssueFixed"); + /* v8 ignore next -- buildScenarioPreviews always emits linkedIssueFixed; best is the defensive fallback. */ + const linked = linkedMatch ?? best; + return [ + ...(current.scoreEstimate.openPrMultiplier !== best.scoreEstimate.openPrMultiplier || current.gates.openPrCount !== best.gates.openPrCount + ? [ + { + gate: "open_pr_threshold" as const, + current: `${current.gates.openPrCount}/${current.gates.openPrThreshold} open PRs, multiplier ${current.scoreEstimate.openPrMultiplier}`, + projected: `${best.gates.openPrCount}/${best.gates.openPrThreshold} open PRs, multiplier ${best.scoreEstimate.openPrMultiplier}`, + explanation: `Open PR pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.scoreEstimate.openIssueMultiplier !== best.scoreEstimate.openIssueMultiplier || current.gates.openIssueCount !== best.gates.openIssueCount + ? [ + { + gate: "open_issue_threshold" as const, + current: `${current.gates.openIssueCount}/${current.gates.openIssueThreshold} open issues, multiplier ${current.scoreEstimate.openIssueMultiplier}`, + projected: `${best.gates.openIssueCount}/${best.gates.openIssueThreshold} open issues, multiplier ${best.scoreEstimate.openIssueMultiplier}`, + explanation: `Open issue spam pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.scoreEstimate.mergedHistoryMultiplier !== best.scoreEstimate.mergedHistoryMultiplier + ? [ + { + gate: "merged_pr_history_floor" as const, + current: `${current.gates.mergedPullRequests}/${current.gates.mergedPrFloor} merged PRs, multiplier ${current.scoreEstimate.mergedHistoryMultiplier}`, + projected: `${best.gates.mergedPullRequests}/${best.gates.mergedPrFloor} merged PRs, multiplier ${best.scoreEstimate.mergedHistoryMultiplier}`, + explanation: `Merged PR history changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier !== best.scoreEstimate.issueDiscoveryHistoryMultiplier + ? [ + { + gate: "issue_discovery_validity_floor" as const, + current: `${current.gates.validSolvedIssues} valid solved / ${roundScore(current.gates.issueCredibility!)} credibility, multiplier ${current.scoreEstimate.issueDiscoveryHistoryMultiplier}`, + projected: `${best.gates.validSolvedIssues} valid solved / ${roundScore(best.gates.issueCredibility!)} credibility, multiplier ${best.scoreEstimate.issueDiscoveryHistoryMultiplier}`, + explanation: `Issue-discovery validity changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.gates.credibilityObserved !== best.gates.credibilityObserved || current.scoreEstimate.credibilityMultiplier !== best.scoreEstimate.credibilityMultiplier + ? [ + { + gate: "credibility_floor" as const, + current: `${roundScore(current.gates.credibilityObserved)} observed, multiplier ${current.scoreEstimate.credibilityMultiplier}`, + projected: `${roundScore(best.gates.credibilityObserved)} projected, multiplier ${best.scoreEstimate.credibilityMultiplier}`, + explanation: `Credibility changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ...(current.scoreEstimate.issueMultiplier !== linked.scoreEstimate.issueMultiplier + ? [ + { + gate: "linked_issue_multiplier" as const, + current: `${current.scoreEstimate.issueMultiplier}`, + projected: `${linked.scoreEstimate.issueMultiplier}`, + explanation: `Linked issue/no-issue context changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${linked.scoreEstimate.estimatedMergedScore}.`, + }, + ] + : []), + ]; +} + +function warningsFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore, branchEligibility = normalizeBranchEligibility(input)): string[] { + return [...new Set([...blockedByFor(input, repo, core, branchEligibility).map((blocker) => blocker.detail), ...core.linkedIssueMultiplier.warnings])]; +} + +function statusFor( + repo: RepositoryRecord | null, + blockedBy: ScoreGateBlocker[], + effectiveEstimatedScore: number, + scenarios: ScoreScenarioPreview[], +): ScorePreviewResult["scoreabilityStatus"] { + if (!repo?.isRegistered || blockedBy.some((blocker) => blocker.code === "inactive_allocation")) return "hold"; + if (effectiveEstimatedScore > 0 && !blockedBy.some((blocker) => blocker.severity === "blocker")) return "scoreable"; + if (scenarios.some((scenarioPreview) => scenarioPreview.name !== "current" && scenarioPreview.effectiveEstimatedScore > effectiveEstimatedScore)) { + return "conditionally_scoreable"; + } + return "blocked"; +} + +function deltaExplanationFor(core: ScoreCore, blockedBy: ScoreGateBlocker[]): string { + if (blockedBy.length === 0) return `Currently scoreable at ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}.`; + return `Effective score ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}; blocked or reduced by ${blockedBy.map((blocker) => blocker.code).join(", ")}.`; +} + +// A label multiplier must be a positive, finite number (mirrors the validity rule signals/engine.ts's own +// config-quality check already documents and enforces for its ADVISORY health score: "0, negative, NaN, or +// Infinity are config errors that would silently misweight scoring"). That check only ever adjusted a +// repo's informational config-quality score -- it never stopped an invalid value from reaching the REAL +// scoring formula here, where `labelMultiplier` multiplies directly into `estimatedMergedScore`. A +// registry-sourced label multiplier of 0 or a negative number is valid JSON and passed neither `numberValue` +// (only applied to the scalar overrides, not this map) nor any check in this function, so it would zero out +// or invert any PR/issue score for a matching label. Filtering here closes the gap where it actually matters. +function isValidLabelMultiplier(value: number): boolean { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function selectLabelMultiplier(labels: string[], multipliers: Record, fallback: number): number { + const normalized = labels.map((label) => label.toLowerCase()); + const matched = Object.entries(multipliers).flatMap(([pattern, multiplier]) => { + if (!isValidLabelMultiplier(multiplier)) return []; + const matcher = labelPatternToRegExp(pattern.toLowerCase()); + return normalized.some((label) => matcher.test(label)) ? [multiplier] : []; + }); + return matched.length > 0 ? Math.max(...matched) : isValidLabelMultiplier(fallback) ? fallback : 1; +} + +/** True when `label` matches the configured multiplier `pattern` under the SAME case-insensitive fnmatch glob + * semantics scoring uses to resolve label multipliers (see {@link labelPatternToRegExp}). Exported so the + * signals surfaces that audit configured label keys (config-quality, label-audit) match them as the GLOBS they + * are: a `type:*` key must count `type:bug-fix` as observed/configured, not silently report it missing because + * the literal pattern never appears verbatim on a real issue/PR. A literal key (no glob metacharacter) still + * matches only its exact label, so existing configs behave identically. */ +export function labelMatchesPattern(label: string, pattern: string): boolean { + return labelPatternToRegExp(pattern.toLowerCase()).test(label.toLowerCase()); +} + +// Compiled fnmatch→RegExp matchers are memoized by pattern. The same small, +// config-derived set of label keys is matched on every scored PR/issue, so the +// per-call recompile inside the nested label loops in engine.ts is pure waste. +// Keys come from a repo's registryConfig.labelMultipliers, sourced from the externally-fetched gittensor +// registry (registry/sync.ts + registry/normalize.ts, not a value this repo's own maintainer directly controls +// via .gittensory.yml) — so the pattern SET is small per repo, but individual pattern CONTENT is untrusted, not +// literally attacker-supplied-per-request the way GitHub PR content is. The wildcard-count cap below (#2456) +// bounds a single pattern's compile cost; this cache is additionally bounded to a fixed max entry count and +// evicted LRU, so a long-running isolate that observes many distinct registry snapshots over its life still +// can't grow the cache unboundedly. The compiled RegExp carries only the "i" flag (no global/sticky `lastIndex` +// state), so sharing one instance across calls is safe and byte-identical to recompiling on every call. +export const LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES = 256; +const labelPatternRegExpCache = new Map(); + +// A RegExp that never matches any input — mirrors change-guardrail.ts's identical NEVER_MATCHES fallback for an +// over-complex pattern, so a pathological registry entry degrades to "this label multiplier never applies" +// instead of hanging the scoring path that evaluates it. +const LABEL_PATTERN_NEVER_MATCHES = /^(?!)$/; + +// Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a +// literal string: `fnmatch(label.lower(), pattern.lower())` in +// gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or +// `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The +// preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the +// neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch +// pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in +// change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any +// run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a +// pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically. +function labelPatternToRegExp(pattern: string): RegExp { + const cached = labelPatternRegExpCache.get(pattern); + if (cached !== undefined) { + // Refresh recency on hit so the cache behaves as an LRU: the most-recently-matched patterns + // survive eviction, not just the most-recently-inserted ones. + labelPatternRegExpCache.delete(pattern); + labelPatternRegExpCache.set(pattern, cached); + return cached; + } + // Reuses change-guardrail.ts's wildcard-GROUP counting (a `*` here matches the same "any run of chars" + // semantics as that glob compiler's `*`, so the same catastrophic-backtracking risk and the same empirically- + // safe threshold apply) — an over-complex registry-sourced label_multipliers key degrades to a safe never-match + // instead of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public + // score-preview API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise + // hang scoring for every PR on that repo. + if (hasUnsafeWildcardCount(pattern)) { + setLabelPatternRegExpCacheEntry(pattern, LABEL_PATTERN_NEVER_MATCHES); + return LABEL_PATTERN_NEVER_MATCHES; + } + let regex = ""; + let i = 0; + while (i < pattern.length) { + const char = pattern.charAt(i); + i += 1; + if (char === "*") { + regex += ".*"; + } else if (char === "?") { + regex += "."; + } else if (char === "[") { + const close = pattern.indexOf("]", i); + if (close === -1) { + // No closing bracket: fnmatch treats the `[` as a literal character. + regex += "\\["; + } else { + const rawBody = pattern.slice(i, close); + if (rawBody === "" || rawBody === "!") { + // Empty classes and bare `[!]` stay literal in Python fnmatch instead of compiling as classes. + regex += `\\[${escapeRegExpLiteral(rawBody)}\\]`; + } else if (hasDescendingCharacterRange(rawBody)) { + // Python fnmatch treats invalid ranges like `[z-a]` as a never-match pattern; RegExp throws. + regex += "(?!)"; + } else { + let body = rawBody.replace(/\\/g, "\\\\"); + // `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`. + if (body.startsWith("!")) body = `^${body.slice(1)}`; + else if (body.startsWith("^")) body = `\\${body}`; + regex += `[${body}]`; + } + i = close + 1; + } + } else if (/[.+^${}()|\]\\]/.test(char)) { + regex += `\\${char}`; + } else { + regex += char; + } + } + const compiled = new RegExp(`^${regex}$`, "i"); + setLabelPatternRegExpCacheEntry(pattern, compiled); + return compiled; +} + +// Inserts a new (never-before-cached) entry, evicting the least-recently-used entry first if the +// cache is already at its bound. Callers must only use this for keys not already present — refreshing +// an existing key's recency on a cache hit is handled inline above via delete+set. +function setLabelPatternRegExpCacheEntry(pattern: string, compiled: RegExp): void { + if (labelPatternRegExpCache.size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES) { + // Map iteration order is insertion order, so the first key is always the least-recently-used + // one (recency is refreshed via delete+set on every hit/insert). The map is non-empty here + // because size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES (a positive constant), so the loop body + // always runs exactly once. + for (const oldestPattern of labelPatternRegExpCache.keys()) { + labelPatternRegExpCache.delete(oldestPattern); + break; + } + } + labelPatternRegExpCache.set(pattern, compiled); +} + +export function clearLabelPatternRegExpCacheForTest(): void { + labelPatternRegExpCache.clear(); +} + +export function labelPatternRegExpCacheKeysForTest(): string[] { + return [...labelPatternRegExpCache.keys()]; +} + +function escapeRegExpLiteral(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function hasDescendingCharacterRange(body: string): boolean { + const start = body.startsWith("!") ? 1 : 0; + // Walk the class left-to-right, consuming each `X-Y` range as a unit so a range endpoint can't be + // misread as the start of a spurious second range. Only a genuinely inverted range like `[z-a]` — the + // case JS `RegExp` actually throws on — must degrade the class to never-match; a literal `-` that + // follows a completed range (as in `[a-z-9]`, a valid class) must NOT be suppressed. The prior scan + // flagged any `-` whose left neighbor outranked its right neighbor, so it wrongly killed `[a-z-9]`. + let i = start; + while (i < body.length) { + if (i + 2 < body.length && body.charAt(i + 1) === "-") { + if (body.charCodeAt(i) > body.charCodeAt(i + 2)) return true; + i += 3; + } else { + i += 1; + } + } + return false; +} + +function decideLinkedIssueMultiplier( + mode: "none" | "standard" | "maintainer", + context: LinkedIssueMultiplierContext | undefined, + constants: Record, + branchEligibility: BranchEligibilityResult, +): LinkedIssueMultiplierDecision { + const baseMultiplier = selectIssueMultiplier(mode, constants); + const issueNumbers = uniquePositiveInts(context?.issueNumbers ?? []); + const solvedByPullRequests = uniquePositiveInts(context?.solvedByPullRequests ?? []); + if (mode === "none") { + return { + mode, + status: "not_required", + source: "none", + eligible: false, + issueNumbers, + solvedByPullRequests, + baseMultiplier, + appliedMultiplier: 1, + reason: "No linked-issue multiplier mode was requested.", + warnings: [], + }; + } + if (mode === "maintainer") { + return { + mode, + status: "not_required", + source: context?.source ?? "none", + eligible: true, + issueNumbers, + solvedByPullRequests, + baseMultiplier, + appliedMultiplier: baseMultiplier, + reason: "Maintainer-lane multiplier does not require solved-by-PR issue linkage.", + warnings: context?.warnings ?? [], + }; + } + + const projectedSolvedByPullRequestValidation = (context as ProjectedLinkedIssueMultiplierContext | undefined)?.[PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION] === true; + const requestedStatus = context?.status ?? (solvedByPullRequests.length > 0 ? "validated" : issueNumbers.length > 0 ? "raw" : "unavailable"); + const hasSolvedByPullRequestEvidence = solvedByPullRequests.length > 0 || projectedSolvedByPullRequestValidation; + const status = requestedStatus === "validated" && !hasSolvedByPullRequestEvidence ? (issueNumbers.length > 0 ? "raw" : "unavailable") : requestedStatus; + const source = context?.source ?? (status === "unavailable" ? "missing" : "user_supplied"); + const branchEligible = isConfirmedBranchEligible(branchEligibility); + const eligible = status === "validated" && hasSolvedByPullRequestEvidence && branchEligible; + const reason = + branchEligible || status !== "validated" + ? status === requestedStatus + ? context?.reason ?? linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) + : linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) + : branchEligibilityFailureReason(branchEligibility); + return { + mode, + status, + source, + eligible, + issueNumbers, + solvedByPullRequests, + baseMultiplier, + appliedMultiplier: eligible ? baseMultiplier : 1, + reason, + warnings: [...new Set([...linkedIssueWarnings(status), ...branchEligibility.warnings, ...(context?.warnings ?? [])])], + }; +} + +function isConfirmedBranchEligible(branchEligibility: BranchEligibilityResult): boolean { + return !branchEligibility.required || (branchEligibility.status === "eligible" && branchEligibility.evidence === "provided" && !branchEligibility.stale); +} + +function branchEligibilityFailureReason(branchEligibility: BranchEligibilityResult): string { + if (branchEligibility.status === "ineligible") return "Branch eligibility is confirmed ineligible; standard issue multiplier is not applied."; + if (branchEligibility.evidence === "missing") return "Branch eligibility evidence is missing; standard issue multiplier is not applied."; + if (branchEligibility.status === "unknown") return "Branch eligibility is unknown; standard issue multiplier is not applied."; + if (branchEligibility.stale) return "Branch eligibility evidence is stale; standard issue multiplier is not applied."; + if (branchEligibility.source === "user_supplied") return "Branch eligibility evidence is user-supplied; standard issue multiplier is not applied until verified metadata is available."; + return "Branch eligibility is not confirmed; standard issue multiplier is not applied."; +} + +function withValidatedLinkedIssueScenario(input: ScorePreviewInput): ScorePreviewInput { + const mode = input.linkedIssueMode ?? "none"; + if (mode === "maintainer") return input; + const issueNumbers = uniquePositiveInts(input.linkedIssueContext?.issueNumbers ?? []); + const solvedByPullRequests = uniquePositiveInts(input.linkedIssueContext?.solvedByPullRequests ?? []); + const linkedIssueContext: ProjectedLinkedIssueMultiplierContext = { + ...input.linkedIssueContext, + status: "validated", + source: input.linkedIssueContext?.source ?? "user_supplied", + issueNumbers, + solvedByPullRequests, + warnings: [], + [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true, + }; + return { + ...input, + linkedIssueMode: "standard", + linkedIssueContext, + }; +} + +/** + * Project the standard linked-issue multiplier decision under the assumption that a planned PR + * becomes the merged solver of the given issue(s). Reuses {@link decideLinkedIssueMultiplier} — the + * same eligibility rule used by buildScorePreview — so standalone validators stay consistent with + * the scoring engine. The numeric multiplier on the returned decision is private; callers that are + * public-safe should surface only `eligible`/`status`/`reason`. + */ +export function projectLinkedIssueMultiplierForPlannedSolve(issueNumbers: number[]): LinkedIssueMultiplierDecision { + const branchEligibility: BranchEligibilityResult = { + required: true, + status: "eligible", + evidence: "provided", + source: "user_supplied", + stale: false, + warnings: [], + }; + const context: ProjectedLinkedIssueMultiplierContext = { + status: "validated", + source: "user_supplied", + issueNumbers: uniquePositiveInts(issueNumbers), + solvedByPullRequests: [], + warnings: [], + [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true, + }; + return decideLinkedIssueMultiplier("standard", context, {}, branchEligibility); +} + +function linkedIssueReason( + status: Exclude, + source: LinkedIssueMultiplierSource, + issueNumbers: number[], + solvedByPullRequests: number[], +): string { + const issues = issueNumbers.length > 0 ? ` for issue(s) ${issueNumbers.map((number) => `#${number}`).join(", ")}` : ""; + if (status === "validated") { + const solvers = solvedByPullRequests.length > 0 ? ` via solved-by-PR ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}` : ""; + return `Linked issue context is solved-by-PR validated${issues}${solvers}.`; + } + if (status === "invalid") return `Linked issue context is invalid${issues}; standard issue multiplier is not applied.`; + if (status === "plausible") return `Linked issue context is plausible${issues}, but solved-by-PR validation is not available yet.`; + if (status === "unavailable") return `Linked issue mirror/cache data is unavailable${issues}; standard issue multiplier is not applied until validation is available.`; + return `Raw linked issue reference${issues} has no solved-by-PR validation from ${source}.`; +} + +function linkedIssueWarnings(status: Exclude): string[] { + if (status === "validated") return []; + if (status === "invalid") return ["Linked issue context is invalid; standard issue multiplier is not applied."]; + if (status === "unavailable") return ["Linked issue mirror/cache data is unavailable; standard issue multiplier is not applied until validation is available."]; + if (status === "plausible") return ["Linked issue context is plausible but not solved-by-PR validated; standard issue multiplier is not applied."]; + return ["Raw linked issue reference has no solved-by-PR evidence; standard issue multiplier is not applied."]; +} + +function uniquePositiveInts(values: number[]): number[] { + return [...new Set(values.filter((value) => Number.isInteger(value) && value > 0))].sort((left, right) => left - right); +} + +function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constants: Record): number { + if (mode === "maintainer") return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER"); + if (mode === "standard") return constant(constants, "STANDARD_ISSUE_MULTIPLIER"); + return 1; +} + +function normalizeBranchEligibility(input: ScorePreviewInput): BranchEligibilityResult { + const required = input.linkedIssueMode === "standard"; + const supplied = input.branchEligibility; + if (!required) { + return { + required: false, + status: "not_required", + evidence: supplied ? "provided" : "missing", + source: supplied ? supplied.source ?? "user_supplied" : "missing", + reason: supplied?.reason, + checkedAt: supplied?.checkedAt, + stale: Boolean(supplied?.stale), + warnings: [], + }; + } + if (!supplied) { + return { + required: true, + status: "unknown", + evidence: "missing", + source: "missing", + stale: false, + warnings: ["Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions."], + }; + } + const status = supplied.status ?? "unknown"; + const stale = Boolean(supplied.stale); + const warnings = [ + ...(status === "ineligible" ? ["Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled."] : []), + ...(status === "unknown" ? ["Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions."] : []), + ...(stale ? ["Branch eligibility evidence is stale; refresh branch/base metadata before relying on linked-issue assumptions."] : []), + ]; + return { + required: true, + status, + evidence: "provided", + source: supplied.source ?? "user_supplied", + reason: supplied.reason, + checkedAt: supplied.checkedAt, + stale, + warnings, + }; +} + +function resolveMergedPullRequests( + input: Pick, + contributorEvidence?: ContributorEvidenceRecord | null, +): number | undefined { + if (input.mergedPullRequests !== undefined) return nonNegative(input.mergedPullRequests); + const fromEvidence = Number(contributorEvidence?.payload?.mergedPullRequests); + return Number.isFinite(fromEvidence) ? nonNegative(fromEvidence) : undefined; +} + +function inferCredibility(evidence?: ContributorEvidenceRecord | null): number { + const payload = evidence?.payload; + const merged = Number(payload?.mergedPullRequests ?? 0); + const stale = Number(payload?.stalePullRequests ?? 0); + const unlinked = Number(payload?.unlinkedPullRequests ?? 0); + // The payload is a loosely-typed cache (`Record`), so any count can arrive as a + // non-numeric value. A non-finite `stale`/`unlinked` propagates NaN through the arithmetic below — + // and `clamp` cannot rescue NaN — poisoning the whole credibility multiplier and score, so guard all + // three counts, not just `merged`, falling back to the neutral credibility. + if (!Number.isFinite(merged) || !Number.isFinite(stale) || !Number.isFinite(unlinked)) return 0.8; + return clamp(0.75 + merged * 0.04 - stale * 0.03 - unlinked * 0.02, 0.25, 1); +} + +function applyNonCodeLineCap(input: Pick, constants: Record): number { + const score = nonNegative(input.nonCodeTokenScore); + const lines = nonNegative(input.nonCodeLines); + if (score <= 0 || lines <= 0) return score; + const maxLines = constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT"); + return lines <= maxLines ? score : score * (maxLines / lines); +} + +function applyNonCodeCapToTotal( + totalTokenScore: number, + input: Pick, + cappedNonCodeTokenScore: number, +): number { + const total = nonNegative(totalTokenScore); + const nonCodeTokenScore = nonNegative(input.nonCodeTokenScore); + if (nonCodeTokenScore <= 0 || cappedNonCodeTokenScore >= nonCodeTokenScore) return total; + return Math.max(0, total - (nonCodeTokenScore - cappedNonCodeTokenScore)); +} + +// Single source of truth (#812): the fallback for any constant is ALWAYS DEFAULT_SCORING_CONSTANTS — never +// a duplicated literal at the call site. The live `constants` (snapshot.constants, which already merges +// DEFAULT_SCORING_CONSTANTS with parsed upstream values) wins when present; otherwise the declared default +// is used. This removes the duplicate-source-of-truth drift surface without changing any value (every +// former call-site literal already matched its DEFAULT_SCORING_CONSTANTS entry). +function constant(constants: Record, key: string): number { + const value = constants[key]; + if (typeof value === "number" && Number.isFinite(value)) return value; + const fallback = DEFAULT_SCORING_CONSTANTS[key]; + /* v8 ignore next -- defensive: every recognized key is in DEFAULT_SCORING_CONSTANTS; this guards typos/forward-compat. */ + return typeof fallback === "number" && Number.isFinite(fallback) ? fallback : 0; +} + +/** + * Resolve a repo's time-decay curve: each parameter is the repo's per-repo override (from the registry's + * `scoring.time_decay`) when present, else the global default constant from the live scoring snapshot. + * Mirrors upstream's `resolve_time_decay` (RepoTimeDecayConfig overlaid on the module constants). + * + * Parity note (#1320): upstream coerces ONLY `grace_period_hours` to an integer + * (`grace_period_hours=int(pick(...))`) while the three curve params stay floats. A maintainer may legally + * configure a fractional grace (upstream validates `0 <= grace_period_hours <= 168`), so the resolved grace + * must be truncated toward zero to match the validator — otherwise a PR aged between `trunc(grace)` and + * `grace` is treated as fresh in the preview but already decaying upstream. + */ +export function resolveTimeDecay( + constants: Record, + overrides?: RepoTimeDecayOverrides | null, +): { gracePeriodHours: number; sigmoidMidpointDays: number; sigmoidSteepness: number; minMultiplier: number } { + return { + gracePeriodHours: clampGracePeriodHours(Math.trunc(pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS")))), + sigmoidMidpointDays: pickOverride(overrides?.sigmoidMidpointDays, constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT")), + sigmoidSteepness: pickOverride(overrides?.sigmoidSteepness, constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR")), + minMultiplier: clampMinMultiplier(pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER"))), + }; +} + +// Documented bound (see the parity note above): upstream validates 0 <= grace_period_hours <= 168. The +// override reaches this resolver via a bare Number.isFinite check (registry/normalize.ts's parseTimeDecayOverrides), +// so an out-of-band value (negative, or beyond a week) would otherwise apply verbatim. +const GRACE_PERIOD_HOURS_MIN = 0; +const GRACE_PERIOD_HOURS_MAX = 168; + +function clampGracePeriodHours(value: number): number { + return clamp(value, GRACE_PERIOD_HOURS_MIN, GRACE_PERIOD_HOURS_MAX); +} + +// minMultiplier is the sigmoid's floor (calculateTimeDecay: Math.max(sigmoid, minMultiplier)), and the +// sigmoid itself is always in (0, 1). A per-repo override above 1 would floor every aged PR ABOVE a fresh +// PR's multiplier -- inverting time decay into an age bonus -- and a negative override applied verbatim +// today has no floor semantics at all. Bound to the sigmoid's own range so the floor invariant always holds. +const MIN_MULTIPLIER_MIN = 0; +const MIN_MULTIPLIER_MAX = 1; + +function clampMinMultiplier(value: number): number { + return clamp(value, MIN_MULTIPLIER_MIN, MIN_MULTIPLIER_MAX); +} + +function pickOverride(value: number | null | undefined, fallback: number): number { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +/** + * Upstream gittensor's sigmoid time-decay multiplier (#703), ported verbatim from the validator's + * `calculate_time_decay` (gittensor/validator/utils/datetime_utils.py): for the first grace-period hours the + * multiplier is exactly 1.0 (hard cutoff); after that it follows a logistic on days-since-merge centred at + * the sigmoid midpoint (50% at that point), floored at the minimum multiplier. The curve params are + * resolved PER-REPO (overrides ?? snapshot defaults), so each maintainer's registry hyperparameters apply. + * Pure + deterministic. + */ +export function calculateTimeDecay(prAgeHours: number, constants: Record, overrides?: RepoTimeDecayOverrides | null): number { + const { gracePeriodHours, sigmoidMidpointDays, sigmoidSteepness, minMultiplier } = resolveTimeDecay(constants, overrides); + if (!Number.isFinite(prAgeHours) || prAgeHours < gracePeriodHours) return 1; + const days = prAgeHours / 24; + const sigmoid = 1 / (1 + Math.exp(sigmoidSteepness * (days - sigmoidMidpointDays))); + return Math.max(sigmoid, minMultiplier); +} + +function saturationScore(sourceTokenScore: number, totalTokenScore: number, constants: Record): number { + // SRC_TOK_SATURATION_SCALE is per-repo overridable only within [10, 500] upstream; a snapshot value outside + // that band (a bad override, a parse glitch) would otherwise distort the saturation curve — at scale 1 the + // component saturates almost immediately, well above the documented floor. Clamp to the documented range so + // the curve stays within upstream bounds (the prior Math.max(...,1) only guarded the divide-by-zero edge). + const scale = clampSaturationScale(constant(constants, "SRC_TOK_SATURATION_SCALE")); + return ( + constant(constants, "MERGED_PR_BASE_SCORE") * (1 - Math.exp(-sourceTokenScore / scale)) + + saturationContributionBonus(totalTokenScore, constants) + ); +} + +function saturationContributionBonus(totalTokenScore: number, constants: Record): number { + return contributionBonusRamp(totalTokenScore, constants); +} + +// Shared contribution-bonus ramp used by both scoring models so the saturation +// and density bonuses cannot drift: clamp(totalTokenScore / FULL_BONUS, 0, 1) +// scaled by MAX_CONTRIBUTION_BONUS (upstream default 5; single-sourced in model.ts, see #807/#812). +function contributionBonusRamp(totalTokenScore: number, constants: Record): number { + return ( + clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS"), 0, 1) * + constant(constants, "MAX_CONTRIBUTION_BONUS") + ); +} + +function nonNegative(value: number | undefined): number { + /* v8 ignore next -- API schemas and local scorers normalize numeric preview inputs before this defensive fallback. */ + return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +// Bounds documented by upstream for the two repo-configurable scoring inputs the preview consumes directly. +const FIXED_BASE_SCORE_MIN = 0; +const FIXED_BASE_SCORE_MAX = 100; +const SRC_TOK_SATURATION_SCALE_MIN = 10; +const SRC_TOK_SATURATION_SCALE_MAX = 500; + +// A repo's fixed_base_score override forces base_score to a constant within [0, 100]. The value reaches the +// preview unbounded above — the API schema only enforces `.min(0)` and registry normalization accepts any +// finite number — so a misconfigured 150 would otherwise mint a base score above the model ceiling. Clamp to +// the documented range; a non-finite/absent value falls through to the token-derived base score. +function clampFixedBaseScore(value: number | null | undefined): number | undefined { + return Number.isFinite(value) ? clamp(value as number, FIXED_BASE_SCORE_MIN, FIXED_BASE_SCORE_MAX) : undefined; +} + +function clampSaturationScale(value: number): number { + return clamp(value, SRC_TOK_SATURATION_SCALE_MIN, SRC_TOK_SATURATION_SCALE_MAX); +} + +function roundScore(value: number): number { + return Math.round(value * 10000) / 10000; +} diff --git a/packages/gittensory-engine/src/scoring/types.ts b/packages/gittensory-engine/src/scoring/types.ts new file mode 100644 index 0000000000..b450341d45 --- /dev/null +++ b/packages/gittensory-engine/src/scoring/types.ts @@ -0,0 +1,147 @@ +// Local mirror of the record/type shapes `scoring/preview.ts`, `scoring/model.ts`, and +// `scoring/pending-pr-scenarios.ts` need from the backend's `src/types.ts` and `src/signals/engine.ts`. +// The engine package cannot import across into `src/` (see the package's own tsconfig: `rootDir: "src"`, +// `types: []` — it stays isolated from the Cloudflare Worker/D1 ambient types the backend depends on), so +// these shapes are duplicated here rather than imported. `src/types.ts` and `src/signals/engine.ts` stay +// the canonical source; keep this file in sync with them by hand. + +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }; + +export type RepoTimeDecayOverrides = { + gracePeriodHours?: number | null | undefined; + sigmoidMidpointDays?: number | null | undefined; + sigmoidSteepness?: number | null | undefined; + minMultiplier?: number | null | undefined; +}; + +export type RegistryRepoConfig = { + repo: string; + emissionShare: number; + issueDiscoveryShare: number; + labelMultipliers: Record; + trustedLabelPipeline?: boolean | null; + maintainerCut: number; + defaultLabelMultiplier?: number | null; + fixedBaseScore?: number | null; + eligibilityMode?: string | null; + timeDecay?: RepoTimeDecayOverrides | null; + raw: Record; +}; + +export type RepositoryRecord = { + fullName: string; + owner: string; + name: string; + installationId?: number | null | undefined; + isInstalled: boolean; + isRegistered: boolean; + isPrivate: boolean; + htmlUrl?: string | null | undefined; + defaultBranch?: string | null | undefined; + registryConfig?: RegistryRepoConfig | null | undefined; +}; + +export type PullRequestRecord = { + repoFullName: string; + number: number; + title: string; + state: string; + authorLogin?: string | null | undefined; + authorAssociation?: string | null | undefined; + headSha?: string | null | undefined; + headRef?: string | null | undefined; + baseRef?: string | null | undefined; + htmlUrl?: string | null | undefined; + mergedAt?: string | null | undefined; + isDraft?: boolean | null | undefined; + mergeableState?: string | null | undefined; + reviewDecision?: string | null | undefined; + body?: string | null | undefined; + createdAt?: string | null | undefined; + updatedAt?: string | null | undefined; + closedAt?: string | null | undefined; + linkedIssueClaimedAt?: string | null | undefined; + labels: string[]; + linkedIssues: number[]; + slopRisk?: number | null | undefined; + slopBand?: string | null | undefined; + mergeAttemptCount?: number | null | undefined; + mergeBlockedSha?: string | null | undefined; + mergeBlockedReason?: string | null | undefined; + approvedHeadSha?: string | null | undefined; + lastRegatedAt?: string | null | undefined; + lastPublishedSurfaceSha?: string | null | undefined; + changedFiles?: string[] | undefined; +}; + +export type PullRequestReviewRecord = { + id: string; + repoFullName: string; + pullNumber: number; + reviewerLogin?: string | null | undefined; + state: string; + authorAssociation?: string | null | undefined; + submittedAt?: string | null | undefined; + payload: Record; +}; + +export type CheckSummaryRecord = { + id: string; + repoFullName: string; + pullNumber?: number | null | undefined; + headSha?: string | null | undefined; + name: string; + status: string; + conclusion?: string | null | undefined; + startedAt?: string | null | undefined; + completedAt?: string | null | undefined; + detailsUrl?: string | null | undefined; + payload: Record; +}; + +export type ScoringModelSnapshotRecord = { + id: string; + sourceKind: "raw-github" | "api" | "fallback" | "test"; + sourceUrl: string; + fetchedAt: string; + activeModel: "current_density_model" | "pending_saturation_model" | "exponential_saturation_model" | "unknown"; + constants: Record; + programmingLanguages: Record; + registrySnapshotId?: string | null | undefined; + warnings: string[]; + payload: Record; +}; + +export type ScorePreviewRecord = { + id: string; + scoringModelSnapshotId: string; + repoFullName: string; + targetType: "planned_pr" | "pull_request" | "local_diff" | "variant"; + targetKey: string; + contributorLogin?: string | null | undefined; + input: Record; + result: Record; + generatedAt: string; +}; + +export type ContributorEvidenceRecord = { + login: string; + payload: Record; + generatedAt: string; +}; + +export type ContributorRole = "outside_contributor" | "repo_maintainer" | "org_member" | "collaborator" | "owner" | "unknown"; + +export type RoleContext = { + login: string; + repoFullName: string; + generatedAt: string; + role: ContributorRole; + maintainerLane: boolean; + normalContributorEvidenceAllowed: boolean; + source: "github_association" | "repo_owner_match" | "gittensor_api" | "cache" | "unknown"; + association?: string | null | undefined; + reasons: string[]; + guidance: string; +}; diff --git a/src/scoring/model.ts b/src/scoring/model.ts index 712a93a402..0e135b948f 100644 --- a/src/scoring/model.ts +++ b/src/scoring/model.ts @@ -9,63 +9,24 @@ import { syncUnmodeledScoringConstantDrift } from "../upstream/unmodeled-scoring import type { JsonValue, ScoringModelSnapshotRecord } from "../types"; import { errorMessage, nowIso } from "../utils/json"; -export const DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5; - -export const DEFAULT_SCORING_CONSTANTS: Record = { - OSS_EMISSION_SHARE: 0.9, - // Upstream name is ISSUES_TREASURY_EMISSION_SHARE (plural). The prior singular spelling never matched - // upstream, freezing this at the local default and showing up as a false "unmodeled" drift warning (#806). - ISSUES_TREASURY_EMISSION_SHARE: 0.1, - // Lookback window used upstream for PR history; stored so it syncs and does not surface as unmodeled drift. - PR_LOOKBACK_DAYS: 30, - MERGED_PR_BASE_SCORE: 25, - // Upstream MAX_CONTRIBUTION_BONUS is 5. This local value is only the fetch-failure fallback; keeping it at - // 25 silently 5x-inflated the contribution bonus whenever the upstream fetch failed (#807). - MAX_CONTRIBUTION_BONUS: 5, - CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, - // Applied in preview.ts when computing totalTokenScore from components (#808). - TEST_FILE_CONTRIBUTION_WEIGHT: 0.05, - // Upstream-enforced eligibility floors for PR and issue-discovery history (#808). - // These gate whether a validator counts a contributor's submissions, not the per-PR/issue score itself. - // Stored here so they sync from upstream and no longer appear as unmodeled drift warnings. - MIN_VALID_MERGED_PRS: 3, - MIN_CREDIBILITY: 0.8, - MIN_VALID_SOLVED_ISSUES: 3, - MIN_ISSUE_CREDIBILITY: 0.8, - MIN_TOKEN_SCORE_FOR_VALID_ISSUE: 5, - // Open-issue spam gate constants — wired into the issue-discovery scoring lane in preview.ts (#808). - OPEN_ISSUE_SPAM_BASE_THRESHOLD: 2, - OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT: 300, - MAX_OPEN_ISSUE_THRESHOLD: 30, - OPEN_PR_COLLATERAL_PERCENT: 0.2, - MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER: 2.0, - MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300, - DEFAULT_ISSUE_DISCOVERY_SHARE, - REVIEW_PENALTY_RATE: 0.15, - STANDARD_ISSUE_MULTIPLIER: 1.33, - MAINTAINER_ISSUE_MULTIPLIER: 1.66, - EXCESSIVE_PR_PENALTY_BASE_THRESHOLD: 2, - OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, - MAX_OPEN_PR_THRESHOLD: 30, - SRC_TOK_SATURATION_SCALE: 58, - // Density-era constants (#812): upstream is on the saturation model, but `current_density_model` is still - // a supported `activeModel` (types.ts union, the public OpenAPI schema, the DB parser, ~20 test fixtures, - // and src/services/score-breakdown.ts). The density branch in preview.ts is therefore NOT dead — it is the - // supported alternate/fallback model. Single-sourcing these fallbacks HERE (instead of as silent hardcoded - // literals at every constant() call site) closes the duplicate-source-of-truth gap without a breaking - // removal of a still-supported model. - MIN_TOKEN_SCORE_FOR_BASE_SCORE: 5, - MAX_CODE_DENSITY_MULTIPLIER: 1.15, - // Upstream time-decay (#703): a merged PR's score decays on a sigmoid after a grace period. Modeled here - // so they no longer surface as unmodeled drift (#690); APPLICATION is opt-in + default-off (see preview). - TIME_DECAY_GRACE_PERIOD_HOURS: 12, - TIME_DECAY_SIGMOID_MIDPOINT: 10, - TIME_DECAY_SIGMOID_STEEPNESS_SCALAR: 0.4, - TIME_DECAY_MIN_MULTIPLIER: 0.05, -}; - -export const DEFAULT_GITTENSOR_UPSTREAM_REPO = "entrius/gittensor"; -export const DEFAULT_GITTENSOR_UPSTREAM_REF = "test"; +// Deterministic constants/classifiers, extracted to `@jsonbored/gittensory-engine` (#2282) so the miner can +// run the same scoring-constant logic locally. Re-exported here (via relative source path — see +// src/scoring/preview.ts's shim comment for why) so every existing import of this module keeps working +// unchanged. The upstream-fetching, D1-persisting logic below is Cloudflare/D1-bound and cannot move into +// the engine package, so it stays here, importing its pure dependencies back from the engine. +export * from "../../packages/gittensory-engine/src/scoring/model"; +import { + DEFAULT_GITTENSOR_UPSTREAM_REPO, + DEFAULT_GITTENSOR_UPSTREAM_REF, + DEFAULT_SCORING_CONSTANTS, + SCORING_CONSTANT_NAMES, + parsePythonNumberConstants, + findUnmodeledUpstreamConstants, + detectActiveModel, + hasSaturationConstants, + hasDensityConstants, + scoringSnapshotStalenessWarning, +} from "../../packages/gittensory-engine/src/scoring/model"; function scoringUpstreamConfig(env: Env): { repo: string; ref: string } { return { @@ -78,12 +39,6 @@ function upstreamRawUrl(config: { repo: string; ref: string }, path: string): st return `https://raw.githubusercontent.com/${config.repo}/${encodeURIComponent(config.ref)}/${path}`; } -// Single source of truth (#812): every recognized upstream constant name is a key of -// DEFAULT_SCORING_CONSTANTS, so the known-only parser, the unmodeled-drift detector, and the preview-side -// fallbacks all derive from one place. The density-era constants are included because the density model is -// still a supported activeModel (see comment above). -const SCORING_CONSTANT_NAMES = new Set(Object.keys(DEFAULT_SCORING_CONSTANTS)); - // Sanity floor for a 200 constants.py body. A real upstream file defines ~30 recognized constants; an HTML // interstitial, a Git-LFS pointer, or a truncated body parses to ~0. Below this, treat the body as non-source // and fail closed rather than reverting live scoring to defaults under a "raw-github" label. (#audit-3.6) @@ -188,16 +143,6 @@ export async function refreshScoringModelSnapshot(env: Env): Promise, now: number = Date.now()): string | null { - if (Date.parse(snapshot.fetchedAt) + SCORING_SNAPSHOT_STALE_MS >= now) return null; - return "Scoring constants snapshot is stale: the last upstream refresh is older than the freshness window, so scoring may be using last-good or default constants and be behind upstream."; -} - export async function getOrCreateScoringModelSnapshot(env: Env): Promise { const snapshot = (await getLatestScoringModelSnapshot(env)) ?? (await refreshScoringModelSnapshot(env)); // Surface staleness so previews do not silently use last-good/DEFAULT constants after a failed/old refresh (#810). @@ -205,83 +150,6 @@ export async function getOrCreateScoringModelSnapshot(env: Env): Promise { - const constants: Record = {}; - for (const line of source.split("\n")) { - // Match Python numeric literals including underscore separators in integer and fractional parts - // (1_000_000, 0.000_001, 3.14_15), floats, and exponents (1e-9, 5.8e1). The previous regex only - // allowed `_` in the integer part, truncating 0.000_001 -> 0 and 3.14_15 -> 3.14 (#992). - const match = line.match(/^([A-Z][A-Z0-9_]+)\s*=\s*([-+]?(?:\d[\d_]*\.?[\d_]*|\.\d[\d_]*)(?:[eE][-+]?\d+)?)/); - if (!match) continue; - const name = match[1]!; - const raw = match[2]!; - if (options.knownOnly !== false && !SCORING_CONSTANT_NAMES.has(name)) continue; - // Number() rejects underscore separators, so strip them before parsing. - constants[name] = Number(raw.replace(/_/g, "")); - } - return constants; -} - -/** - * Upstream operational/infra constants gittensory intentionally does not model in score previews. - * They are not scoring dimensions — surfacing them as "unmodeled drift" is noise (#809). - */ -const NON_SCORING_UPSTREAM_CONSTANT_NAMES = new Set([ - "SECONDS_PER_DAY", - "SECONDS_PER_HOUR", - "GITHUB_HTTP_TIMEOUT_SECONDS", - "MIRROR_HTTP_TIMEOUT_SECONDS", - "MIRROR_MAX_ATTEMPTS", - "TREE_SITTER_PARSE_TIMEOUT_MICROS", - "SCORING_SUBPROCESS_BUDGET_S", - "MAX_FILE_SIZE_BYTES", - "RECYCLE_UID", - "ISSUES_TREASURY_UID", - "MAX_ISSUE_ID", - // Floating-point epsilon for the registry emission_share-sum validation - // (load_weights.py: `total_share > 1.0 + EMISSION_SHARE_TOLERANCE`), not a scoring dimension — without - // this entry the parser (which reads exponent literals like `1e-9`, #992) flagged it as a permanent - // false-positive unmodeled-scoring-drift warning (#809). - "EMISSION_SHARE_TOLERANCE", - // Fallback weight applied by the validator's load_programming_language_weights() for extensions absent from - // the JSON file; gittensory reads the JSON directly and does not track this loader default (#1692). - "DEFAULT_PROGRAMMING_LANGUAGE_WEIGHT", -]); - -/** - * Numeric constant names upstream gittensor defines that gittensory's scoring engine does NOT model. - * The normal parse is `knownOnly` (it keeps only constants we already encode), which silently hides - * upstream ADDITIONS — e.g. a newly-introduced time-decay constant. Surfacing these makes scoring - * staleness visible: if upstream adds a scoring dimension, an operator sees it instead of the gate - * silently drifting behind. Detection only — it does not change any score. - */ -export function findUnmodeledConstantKeys(allConstants: Record): string[] { - return Object.keys(allConstants) - .filter((name) => !SCORING_CONSTANT_NAMES.has(name) && !NON_SCORING_UPSTREAM_CONSTANT_NAMES.has(name)) - .sort(); -} - -export function findUnmodeledUpstreamConstants(source: string): string[] { - return findUnmodeledConstantKeys(parsePythonNumberConstants(source, { knownOnly: false })); -} - -/** - * Owner-controlled global gate for applying upstream time-decay to score previews (#703). Default OFF: the - * roadmap deferral requires the owner to review a before/after ranking diff before enabling. Even when on, - * a fresh PR is unaffected (decay 1.0), so it only changes aged-PR projections. - */ -export function isTimeDecayEnabled(env: Env): boolean { - return /^(1|true|yes|on)$/i.test(env.SCORING_TIME_DECAY_ENABLED ?? ""); -} - -export function detectActiveModel(constants: Record): ScoringModelSnapshotRecord["activeModel"] { - if (hasSaturationConstants(constants)) return "pending_saturation_model"; - if (hasDensityConstants(constants)) { - return "current_density_model"; - } - return "unknown"; -} - function activeModelWarnings(constants: Record): string[] { const hasSaturation = hasSaturationConstants(constants); const hasDensity = hasDensityConstants(constants); @@ -292,14 +160,6 @@ function activeModelWarnings(constants: Record): string[] { return []; } -function hasSaturationConstants(constants: Record): boolean { - return Number.isFinite(constants.SRC_TOK_SATURATION_SCALE); -} - -function hasDensityConstants(constants: Record): boolean { - return Number.isFinite(constants.MAX_CODE_DENSITY_MULTIPLIER) && Number.isFinite(constants.MIN_TOKEN_SCORE_FOR_BASE_SCORE); -} - async function fetchText(url: string, token?: string): Promise<{ ok: true; value: string } | { ok: false; error: string }> { try { const response = await timeoutFetch(url, { headers: githubHeaders(token, "text/plain") }); diff --git a/src/scoring/pending-pr-scenarios.ts b/src/scoring/pending-pr-scenarios.ts index 527885fc20..c0909519a5 100644 --- a/src/scoring/pending-pr-scenarios.ts +++ b/src/scoring/pending-pr-scenarios.ts @@ -1,45 +1,13 @@ import { listCheckSummaries, listPullRequestReviews } from "../db/repositories"; -import { isMaintainerAssociation } from "../github/commands"; import type { CheckSummaryRecord, PullRequestRecord, PullRequestReviewRecord } from "../types"; -import type { RoleContext } from "../signals/engine"; -import type { ScorePreviewInput } from "./preview"; -export type OpenPrPendingClass = - | "merge_ready" - | "stale_likely_close" - | "draft" - | "blocked" - | "maintainer_lane" - | "open_other"; - -export type ClassifiedOpenPullRequest = { - repoFullName: string; - number: number; - title: string; - classification: OpenPrPendingClass; - reasons: string[]; -}; - -export type PendingPrScenarioDetection = { - source: "github_observed" | "user_supplied"; - pendingMergedPrCount: number; - pendingClosedPrCount: number; - approvedPrCount: number; - expectedOpenPrCountAfterMerge?: number | undefined; - scenarioNotes: string[]; - classified: ClassifiedOpenPullRequest[]; -}; - -export type ContributorRepoOpenPrSignals = { - reviewsByPullNumber: Map; - checksByPullNumber: Map; -}; - -const STALE_DAYS = 14; - -// Real draft markers only — "[draft]", "Draft:", "Draft -"; the delimiter keeps "Drafting" and -// "draft-js" from matching. Trailing \s* lets the same pattern also strip the marker for dedup keys. -export const DRAFT_TITLE_PATTERN = /^(?:\[\s*draft\s*\]|draft(?:\s*:|\s+-))\s*/i; +// Deterministic open-PR classification/detection, extracted to `@jsonbored/gittensory-engine` (#2282) so +// the miner can run the same pending-PR-scenario logic locally. Re-exported here (via relative source path +// — see src/scoring/preview.ts's shim comment for why) so every existing import of this module keeps +// working unchanged. The two D1-fetching loaders below cannot move into the engine package, so they stay +// here, importing the pure classifier back from the engine. +export * from "../../packages/gittensory-engine/src/scoring/pending-pr-scenarios"; +import type { ContributorRepoOpenPrSignals } from "../../packages/gittensory-engine/src/scoring/pending-pr-scenarios"; export async function loadContributorRepoOpenPrSignalRecords( env: Env, @@ -78,157 +46,6 @@ export async function loadContributorRepoOpenPrSignals( return { reviewsByPullNumber, checksByPullNumber }; } -export function detectPendingPrScenario(args: { - login: string; - repoFullName: string; - pullRequests: PullRequestRecord[]; - roleContext: RoleContext; - openPrCount?: number | undefined; - reviewsByPullNumber?: Map | undefined; - checksByPullNumber?: Map | undefined; - excludePullNumbers?: number[] | undefined; - userSupplied?: Pick< - ScorePreviewInput, - "pendingMergedPrCount" | "pendingClosedPrCount" | "approvedPrCount" | "expectedOpenPrCountAfterMerge" | "projectedCredibility" | "scenarioNotes" - > | undefined; -}): PendingPrScenarioDetection | null { - const user = args.userSupplied; - const hasUserCounts = - user?.pendingMergedPrCount !== undefined || - user?.pendingClosedPrCount !== undefined || - user?.approvedPrCount !== undefined || - user?.expectedOpenPrCountAfterMerge !== undefined; - if (hasUserCounts) { - return { - source: "user_supplied", - pendingMergedPrCount: nonNegative(user?.pendingMergedPrCount), - pendingClosedPrCount: nonNegative(user?.pendingClosedPrCount), - approvedPrCount: nonNegative(user?.approvedPrCount), - ...(user?.expectedOpenPrCountAfterMerge !== undefined ? { expectedOpenPrCountAfterMerge: nonNegative(user.expectedOpenPrCountAfterMerge) } : {}), - scenarioNotes: user?.scenarioNotes ?? [], - classified: [], - }; - } - - const excluded = new Set(args.excludePullNumbers ?? []); - const contributorOpen = args.pullRequests.filter( - (pr) => - sameRepoFullName(pr.repoFullName, args.repoFullName) && - pr.state === "open" && - sameLogin(pr.authorLogin, args.login) && - !excluded.has(pr.number), - ); - if (contributorOpen.length === 0) return null; - - const classified = contributorOpen.map((pr) => - classifyOpenPullRequest({ - pr, - roleContext: args.roleContext, - reviews: args.reviewsByPullNumber?.get(pr.number) ?? [], - checks: args.checksByPullNumber?.get(pr.number) ?? [], - }), - ); - - const mergeReady = classified.filter((entry) => entry.classification === "merge_ready"); - const staleLikelyClose = classified.filter((entry) => entry.classification === "stale_likely_close"); - const pendingMergedPrCount = mergeReady.length; - const pendingClosedPrCount = staleLikelyClose.length; - if (pendingMergedPrCount === 0 && pendingClosedPrCount === 0) return null; - - const currentOpen = args.openPrCount ?? contributorOpen.length; - const expectedOpenPrCountAfterMerge = Math.max(0, currentOpen - pendingMergedPrCount - pendingClosedPrCount); - const scenarioNotes = [ - "GitHub-observed open PR state from cached reviews, checks, and activity timestamps (estimate only).", - ...(pendingMergedPrCount > 0 - ? [`${pendingMergedPrCount} open PR(s) look merge-ready (approved, no changes requested, no failing checks, not draft/stale).`] - : []), - ...(pendingClosedPrCount > 0 ? [`${pendingClosedPrCount} open PR(s) look stale and may be closed instead of merged.`] : []), - ...classified - .filter((entry) => entry.classification === "draft" || entry.classification === "blocked" || entry.classification === "maintainer_lane") - .map((entry) => `PR #${entry.number} treated as ${entry.classification.replace(/_/g, " ")} for this projection.`), - ]; - - return { - source: "github_observed", - pendingMergedPrCount, - pendingClosedPrCount, - approvedPrCount: mergeReady.length, - expectedOpenPrCountAfterMerge, - scenarioNotes, - classified, - }; -} - -export function classifyOpenPullRequest(args: { - pr: PullRequestRecord; - roleContext: RoleContext; - reviews: PullRequestReviewRecord[]; - checks: CheckSummaryRecord[]; - duplicateProne?: boolean | undefined; - missingTests?: boolean | undefined; -}): ClassifiedOpenPullRequest { - const reasons: string[] = []; - if (args.roleContext.maintainerLane) { - reasons.push("Maintainer-lane context for this repo; not counted as outside-contributor pending reward work."); - return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons }; - } - if (isMaintainerAssociation(args.pr.authorAssociation)) { - reasons.push("Author association indicates maintainer-authored work."); - return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "maintainer_lane", reasons }; - } - if (isDraftPullRequest(args.pr)) { - reasons.push("Draft PRs are not treated as likely to land."); - return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "draft", reasons }; - } - - const approvalCount = args.reviews.filter((review) => review.state.toUpperCase() === "APPROVED").length; - const changeRequestCount = args.reviews.filter((review) => review.state.toUpperCase() === "CHANGES_REQUESTED").length; - const checkFailureCount = args.checks.filter( - (check) => check.conclusion === "failure" || check.conclusion === "timed_out" || check.conclusion === "cancelled", - ).length; - const ageDays = daysSince(args.pr.updatedAt ?? args.pr.createdAt); - - if (args.duplicateProne) reasons.push("Overlapping open work detected in the same repo (possible duplicate or WIP collision)."); - if (args.missingTests) reasons.push("Cached file list shows code changes without matching test files."); - if (changeRequestCount > 0) reasons.push(`${changeRequestCount} changes-requested review(s).`); - if (checkFailureCount > 0) reasons.push(`${checkFailureCount} failing or cancelled check(s).`); - if (approvalCount === 0) reasons.push("No approved review in cache."); - - if (changeRequestCount > 0 || checkFailureCount > 0 || approvalCount === 0) { - return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "blocked", reasons }; - } - - if (ageDays >= STALE_DAYS) { - reasons.push(`No meaningful update in at least ${STALE_DAYS} days; may be closed instead of merged.`); - return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "stale_likely_close", reasons }; - } - - reasons.push("Approved with passing checks and recent activity; treated as likely to merge (estimate)."); - return { repoFullName: args.pr.repoFullName, number: args.pr.number, title: args.pr.title, classification: "merge_ready", reasons }; -} - -export function applyPendingPrDetectionToScoreInput( - input: ScorePreviewInput, - detection: PendingPrScenarioDetection | null, -): ScorePreviewInput { - if (!detection || detection.source === "user_supplied") return input; - return { - ...input, - pendingMergedPrCount: detection.pendingMergedPrCount, - pendingClosedPrCount: detection.pendingClosedPrCount, - approvedPrCount: detection.approvedPrCount, - expectedOpenPrCountAfterMerge: detection.expectedOpenPrCountAfterMerge, - scenarioNotes: [...(input.scenarioNotes ?? []), ...detection.scenarioNotes], - pendingScenarioObserved: true, - }; -} - -function isDraftPullRequest(pr: PullRequestRecord): boolean { - if (pr.isDraft) return true; - if (DRAFT_TITLE_PATTERN.test(pr.title.trim())) return true; - return pr.labels.some((label) => label.toLowerCase() === "draft" || label.toLowerCase() === "wip"); -} - function sameRepoFullName(left: string, right: string): boolean { return left.toLowerCase() === right.toLowerCase(); } @@ -236,15 +53,3 @@ function sameRepoFullName(left: string, right: string): boolean { function sameLogin(value: string | null | undefined, login: string): boolean { return Boolean(value && value.toLowerCase() === login.toLowerCase()); } - -function daysSince(value: string | null | undefined): number { - if (!value) return Number.POSITIVE_INFINITY; - const parsed = Date.parse(value); - if (!Number.isFinite(parsed)) return Number.POSITIVE_INFINITY; - return Math.max(0, (Date.now() - parsed) / 86_400_000); -} - -function nonNegative(value: number | undefined): number { - if (value === undefined || !Number.isFinite(value)) return 0; - return Math.max(0, Math.trunc(value)); -} diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 64480a6db0..2d5efd05d3 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -1,1459 +1,7 @@ -import type { ContributorEvidenceRecord, JsonValue, RepositoryRecord, RepoTimeDecayOverrides, ScoringModelSnapshotRecord, ScorePreviewRecord } from "../types"; -import { DEFAULT_SCORING_CONSTANTS } from "./model"; -import { nowIso } from "../utils/json"; -import { hasUnsafeWildcardCount } from "../signals/change-guardrail"; - -export type ScorePreviewInput = { - repoFullName: string; - targetType?: ScorePreviewRecord["targetType"]; - targetKey?: string | undefined; - contributorLogin?: string | undefined; - labels?: string[] | undefined; - linkedIssueMode?: "none" | "standard" | "maintainer" | undefined; - linkedIssueContext?: LinkedIssueMultiplierContext | undefined; - sourceTokenScore?: number | undefined; - totalTokenScore?: number | undefined; - sourceLines?: number | undefined; - testTokenScore?: number | undefined; - nonCodeTokenScore?: number | undefined; - /** Raw non-code line count before upstream's MAX_LINES_SCORED_FOR_NON_CODE_EXT cap. */ - nonCodeLines?: number | undefined; - existingContributorTokenScore?: number | undefined; - openPrCount?: number | undefined; - /** Contributor's current open-issue count for the repo, used for the open-issue spam gate (#808). */ - openIssueCount?: number | undefined; - /** Repo-level merged PR count for upstream contributor-history eligibility (#808). */ - mergedPullRequests?: number | undefined; - /** Count of valid solved issues for upstream issue-discovery eligibility (#808). */ - validSolvedIssues?: number | undefined; - /** Issue-discovery credibility for upstream issue-discovery eligibility (#808). */ - issueCredibility?: number | undefined; - credibility?: number | undefined; - changesRequestedCount?: number | undefined; - fixedBaseScore?: number | undefined; - metadataOnly?: boolean | undefined; - pendingMergedPrCount?: number | undefined; - pendingClosedPrCount?: number | undefined; - approvedPrCount?: number | undefined; - observedApprovedPrCount?: number | undefined; - observedStalePrCount?: number | undefined; - observedClosedPrCount?: number | undefined; - observedDraftPrCount?: number | undefined; - observedBlockedPrCount?: number | undefined; - observedMaintainerPrCount?: number | undefined; - duplicateRiskCount?: number | undefined; - expectedOpenPrCountAfterMerge?: number | undefined; - projectedCredibility?: number | undefined; - scenarioNotes?: string[] | undefined; - pendingScenarioObserved?: boolean | undefined; - observedScenarioNotes?: string[] | undefined; - branchEligibility?: BranchEligibilityInput | undefined; - /** Hours since the PR merged, for upstream time-decay (#703). Absent / below the grace period = a fresh - * PR (multiplier 1.0). Only consulted when `applyTimeDecay` is on. */ - prAgeHours?: number | undefined; - /** Opt-in upstream time-decay (#703), default OFF and env-gated (SCORING_TIME_DECAY_ENABLED) at the call - * site. Even when on, a fresh PR is unaffected, so it never changes a normal new-PR preview. */ - applyTimeDecay?: boolean | undefined; -}; - -export type BranchEligibilityInput = { - status: "eligible" | "ineligible" | "unknown"; - source?: "github_metadata" | "local_metadata" | "registry" | "user_supplied" | undefined; - reason?: string | undefined; - checkedAt?: string | undefined; - stale?: boolean | undefined; -}; - -export type BranchEligibilityResult = { - required: boolean; - status: "eligible" | "ineligible" | "unknown" | "not_required"; - evidence: "provided" | "missing"; - source: "github_metadata" | "local_metadata" | "registry" | "user_supplied" | "missing"; - reason?: string | undefined; - checkedAt?: string | undefined; - stale: boolean; - warnings: string[]; -}; - -export type LinkedIssueMultiplierStatus = "not_required" | "raw" | "plausible" | "validated" | "invalid" | "unavailable"; - -export type LinkedIssueMultiplierSource = "none" | "user_supplied" | "official_mirror" | "github_cache" | "issue_quality" | "missing"; - -export type LinkedIssueMultiplierContext = { - status?: Exclude | undefined; - source?: Exclude | undefined; - issueNumbers?: number[] | undefined; - solvedByPullRequests?: number[] | undefined; - reason?: string | undefined; - warnings?: string[] | undefined; -}; - -const PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION = Symbol("projectedSolvedByPullRequestValidation"); - -type ProjectedLinkedIssueMultiplierContext = LinkedIssueMultiplierContext & { - [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]?: true; -}; - -export type LinkedIssueMultiplierDecision = { - mode: "none" | "standard" | "maintainer"; - status: LinkedIssueMultiplierStatus; - source: LinkedIssueMultiplierSource; - eligible: boolean; - issueNumbers: number[]; - solvedByPullRequests: number[]; - baseMultiplier: number; - appliedMultiplier: number; - reason: string; - warnings: string[]; -}; - -export type ScoreGateBlocker = { - code: - | "repo_not_registered" - | "inactive_allocation" - | "base_token_gate" - | "open_pr_threshold" - | "open_issue_threshold" - | "merged_pr_history_floor" - | "issue_discovery_validity_floor" - | "credibility_floor" - | "review_penalty" - | "metadata_only" - | "linked_issue_invalid" - | "linked_issue_unvalidated" - | "branch_ineligible" - | "branch_eligibility_missing" - | "duplicate_risk" - | "stale_work"; - severity: "blocker" | "reducer" | "context"; - detail: string; -}; - -export type ScoreGateDelta = { - gate: - | "open_pr_threshold" - | "open_issue_threshold" - | "merged_pr_history_floor" - | "issue_discovery_validity_floor" - | "credibility_floor" - | "linked_issue_multiplier"; - current: string; - projected: string; - explanation: string; -}; - -export type ScoreScenarioPreview = { - name: "current" | "cleanGates" | "afterPendingMerges" | "afterApprovedPrsMerge" | "afterStalePrsClose" | "linkedIssueFixed" | "bestReasonableCase"; - source: "current_data" | "user_supplied" | "github_observed" | "gittensory_projection"; - assumptions: string[]; - scoreEstimate: ScorePreviewResult["scoreEstimate"]; - gates: ScorePreviewResult["gates"]; - effectiveEstimatedScore: number; - underlyingPotentialScore: number; - blockedBy: ScoreGateBlocker[]; - linkedIssueMultiplier: LinkedIssueMultiplierDecision; - deltaExplanation: string; -}; - -export type ScorePreviewResult = { - repoFullName: string; - generatedAt: string; - scoringModelSnapshotId: string; - activeModel: ScoringModelSnapshotRecord["activeModel"]; - privateOnly: true; - laneMath: { - repoEmissionShare: number; - ossEmissionShare: number; - repoSlice: number; - directPrSlice: number; - issueDiscoverySlice: number; - issueDiscoveryShare: number; - }; - scoreEstimate: { - /** Computed base score (the earned foundation before multipliers apply). */ - baseScore: number; - /** The maximum possible baseScore given the active model and snapshot constants; used by the score - * breakdown to surface saturation vs sub-cap status. Undefined when a fixedBaseScore override is in - * effect (the override is not bounded by the model cap). */ - baseScoreCap?: number; - densityMultiplier: number; - contributionBonus: number; - labelMultiplier: number; - issueMultiplier: number; - credibilityMultiplier: number; - reviewPenaltyMultiplier: number; - openPrMultiplier: number; - openIssueMultiplier: number; - /** Upstream merged-PR history floor (#808). 0 when below MIN_VALID_MERGED_PRS; 1 when unknown or eligible. */ - mergedHistoryMultiplier: number; - /** Upstream issue-discovery validity floor (#808). 0 when below MIN_VALID_SOLVED_ISSUES or MIN_ISSUE_CREDIBILITY. */ - issueDiscoveryHistoryMultiplier: number; - /** Upstream sigmoid time-decay multiplier (#703). 1 = no decay (fresh PR, or feature off). */ - timeDecayMultiplier: number; - estimatedMergedScore: number; - pendingSaturationScore: number; - }; - linkedIssueMultiplier: LinkedIssueMultiplierDecision; - gates: { - baseTokenGatePassed: boolean; - openPrThreshold: number; - openPrCount: number; - /** Effective open-PR collateral fraction (OPEN_PR_COLLATERAL_PERCENT × reviewCollateralMultiplier). */ - collateralFraction: number; - /** Upstream open-PR review-collateral multiplier from CHANGES_REQUESTED reviews (≥ 1, capped). */ - reviewCollateralMultiplier: number; - credibilityFloor: number; - credibilityObserved: number; - openIssueThreshold: number; - openIssueCount: number; - mergedPrFloor: number; - /** Observed merged PR count when supplied or inferred from contributor evidence; absent when unknown. */ - mergedPullRequests?: number | undefined; - validSolvedIssuesFloor: number; - /** Observed valid solved-issue count when supplied; absent when unknown. */ - validSolvedIssues?: number | undefined; - issueCredibilityFloor: number; - /** Observed issue-discovery credibility when supplied; absent when unknown. */ - issueCredibility?: number | undefined; - /** Upstream non-code line scoring cap (MAX_LINES_SCORED_FOR_NON_CODE_EXT); non-code token score beyond this - * many changed non-code lines is not scored. */ - nonCodeLineCap: number; - /** Observed raw non-code line count before the cap; absent when no non-code line count was supplied. */ - nonCodeLinesObserved?: number | undefined; - }; - branchEligibility: BranchEligibilityResult; - effectiveEstimatedScore: number; - underlyingPotentialScore: number; - blockedBy: ScoreGateBlocker[]; - gateDeltas: ScoreGateDelta[]; - scenarioPreviews: ScoreScenarioPreview[]; - scoreabilityStatus: "blocked" | "conditionally_scoreable" | "scoreable" | "hold"; - warnings: string[]; - assumptions: string[]; - recommendation: { - level: "strong_fit" | "reasonable_fit" | "needs_work" | "hold"; - actions: string[]; - }; -}; - -export function buildScorePreview(args: { - input: ScorePreviewInput; - repo: RepositoryRecord | null; - snapshot: ScoringModelSnapshotRecord; - contributorEvidence?: ContributorEvidenceRecord | null | undefined; -}): ScorePreviewResult { - const branchEligibility = normalizeBranchEligibility(args.input); - const current = computeScoreCore(args.input, args.repo, args.snapshot, args.contributorEvidence); - const scenarioPreviews = buildScenarioPreviews(args.input, args.repo, args.snapshot, args.contributorEvidence, current); - const blockedBy = blockedByFor(args.input, args.repo, current, branchEligibility); - const gateDeltas = buildGateDeltas(current, scenarioPreviews); - const effectiveEstimatedScore = current.scoreEstimate.estimatedMergedScore; - const underlyingPotentialScore = current.scoreEstimate.pendingSaturationScore; - const scoreabilityStatus = statusFor(args.repo, blockedBy, effectiveEstimatedScore, scenarioPreviews); - const warnings = [...args.snapshot.warnings, ...warningsFor(args.input, args.repo, current, branchEligibility)]; - const actions = [ - ...(!current.gates.baseTokenGatePassed ? ["Increase meaningful source change size or scope clarity before relying on this preview."] : []), - ...(current.scoreEstimate.openPrMultiplier === 0 ? ["Land or close existing open PRs before opening more concurrent work."] : []), - ...(current.scoreEstimate.openIssueMultiplier === 0 ? ["Close excess open issues to stay within the open-issue spam threshold."] : []), - ...(current.scoreEstimate.mergedHistoryMultiplier === 0 ? ["Build merged PR history on this repo before relying on this preview; upstream requires a minimum merged count."] : []), - ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier === 0 - ? ["Build valid solved-issue history and issue credibility before relying on issue-discovery scoring on this repo."] - : []), - ...(current.scoreEstimate.credibilityMultiplier < 1 ? ["Build or wait for contributor credibility evidence before relying on this preview."] : []), - ...(current.scoreEstimate.reviewPenaltyMultiplier < 1 ? ["Reduce review churn with tighter tests and clearer evidence."] : []), - ...(current.scoreEstimate.labelMultiplier <= 1 && Object.keys(args.repo?.registryConfig?.labelMultipliers ?? {}).length > 0 - ? ["Check whether the change legitimately matches one of the repo's configured trusted labels."] - : []), - ...(branchEligibility.required && branchEligibility.status === "ineligible" ? ["Use an eligible branch or remove linked-issue assumptions before relying on this preview."] : []), - ...(branchEligibility.required && (branchEligibility.evidence === "missing" || branchEligibility.stale) - ? ["Refresh branch/base eligibility metadata before relying on linked-issue assumptions."] - : []), - ...(current.linkedIssueMultiplier.mode === "standard" && !current.linkedIssueMultiplier.eligible - ? ["Validate linked issue context with solved-by-PR evidence before relying on the standard issue multiplier."] - : []), - ]; - - return { - repoFullName: args.input.repoFullName, - generatedAt: nowIso(), - scoringModelSnapshotId: args.snapshot.id, - activeModel: args.snapshot.activeModel, - privateOnly: true, - laneMath: current.laneMath, - scoreEstimate: current.scoreEstimate, - linkedIssueMultiplier: current.linkedIssueMultiplier, - gates: current.gates, - branchEligibility, - effectiveEstimatedScore, - underlyingPotentialScore, - blockedBy, - gateDeltas, - scenarioPreviews, - scoreabilityStatus, - warnings, - assumptions: [ - "Advisory preview only; tied to the recorded scoring model snapshot and cached Gittensory data.", - "No future outcome or exact payout is guaranteed.", - "Private API/MCP output only; public comments intentionally omit these details.", - `Linked issue multiplier status: ${current.linkedIssueMultiplier.status}; ${current.linkedIssueMultiplier.reason}`, - ...branchEligibility.warnings, - ...(args.input.scenarioNotes ?? []).map((note) => `User scenario note: ${note}`), - ], - recommendation: { - level: scoreabilityStatus === "hold" || warnings.some((warning) => /not registered|no active|exceeds/i.test(warning)) - ? "hold" - : effectiveEstimatedScore >= 30 && warnings.length === 0 - ? "strong_fit" - : effectiveEstimatedScore >= 15 - ? "reasonable_fit" - : "needs_work", - actions: actions.length > 0 ? actions : ["Keep the PR focused, linked, tested, and easy for maintainers to review."], - }, - }; -} - -export function makeScorePreviewRecord(input: ScorePreviewInput, snapshot: ScoringModelSnapshotRecord, result: ScorePreviewResult): ScorePreviewRecord { - return { - id: crypto.randomUUID(), - scoringModelSnapshotId: snapshot.id, - repoFullName: input.repoFullName, - targetType: input.targetType ?? "planned_pr", - targetKey: input.targetKey ?? `${input.repoFullName}:${input.targetType ?? "planned_pr"}:${Date.now()}`, - contributorLogin: input.contributorLogin, - input: input as unknown as Record, - result: result as unknown as Record, - generatedAt: result.generatedAt, - }; -} - -type ScoreCore = Pick; - -function computeScoreCore( - input: ScorePreviewInput, - repo: RepositoryRecord | null, - snapshot: ScoringModelSnapshotRecord, - contributorEvidence?: ContributorEvidenceRecord | null | undefined, -): ScoreCore { - const constants = { ...snapshot.constants }; - const config = repo?.registryConfig; - const emissionShare = clamp(config?.emissionShare ?? 0, 0, 1); - const issueDiscoveryShare = clamp(config?.issueDiscoveryShare ?? 0, 0, 1); - const ossEmissionShare = constant(constants, "OSS_EMISSION_SHARE"); - const repoSlice = emissionShare * ossEmissionShare; - const directPrSlice = repoSlice * (1 - issueDiscoveryShare); - const issueDiscoverySlice = repoSlice * issueDiscoveryShare; - const sourceTokenScore = nonNegative(input.sourceTokenScore); - // TEST_FILE_CONTRIBUTION_WEIGHT (#808): upstream weights test-file tokens at 0.05× relative to source tokens. - // Applied only when totalTokenScore is not explicitly provided — an explicit caller total is honoured as-is. - const testFileWeight = constant(constants, "TEST_FILE_CONTRIBUTION_WEIGHT"); - const cappedNonCodeTokenScore = applyNonCodeLineCap(input, constants); - const derivedTotalTokenScore = sourceTokenScore + testFileWeight * nonNegative(input.testTokenScore) + cappedNonCodeTokenScore; - const totalTokenScore = - input.totalTokenScore === undefined ? nonNegative(derivedTotalTokenScore) : applyNonCodeCapToTotal(input.totalTokenScore, input, cappedNonCodeTokenScore); - const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore)); - const fixedBaseScore = clampFixedBaseScore(input.fixedBaseScore ?? config?.fixedBaseScore); - const rawDensity = sourceTokenScore / sourceLines; - // Density branch (#812): upstream is on the saturation model, but `current_density_model` is still a - // supported `activeModel` (types.ts union, the public OpenAPI schema, the DB parser, ~20 test fixtures, and - // src/services/score-breakdown.ts which keys off densityMultiplier). The issue's "if density is dead, - // remove the branch" condition is therefore FALSE — the branch is retained as the supported alternate / - // fetch-failure-`unknown` fallback model. Its fallback constants are now single-sourced from - // DEFAULT_SCORING_CONSTANTS (model.ts) instead of silent duplicated literals, closing the drift surface. - const densityMultiplier = clamp(rawDensity || 0, 0, constant(constants, "MAX_CODE_DENSITY_MULTIPLIER")); - const densityTokenGatePassed = sourceTokenScore >= constant(constants, "MIN_TOKEN_SCORE_FOR_BASE_SCORE"); - const baseTokenGatePassed = snapshot.activeModel === "pending_saturation_model" ? sourceTokenScore > 0 : densityTokenGatePassed; - const densityContributionBonus = contributionBonusRamp(totalTokenScore, constants); - const saturationContributionBonusValue = saturationContributionBonus(totalTokenScore, constants); - const saturationBaseScore = saturationScore(sourceTokenScore, totalTokenScore, constants); - const densityBaseScore = - (densityTokenGatePassed ? constant(constants, "MERGED_PR_BASE_SCORE") * densityMultiplier : 0) + densityContributionBonus; - const baseScore = - fixedBaseScore !== undefined - ? fixedBaseScore - : snapshot.activeModel === "pending_saturation_model" - ? saturationBaseScore - : densityBaseScore; - const baseScoreCap = - fixedBaseScore !== undefined - ? undefined - : snapshot.activeModel === "pending_saturation_model" - ? constant(constants, "MERGED_PR_BASE_SCORE") + constant(constants, "MAX_CONTRIBUTION_BONUS") - : constant(constants, "MERGED_PR_BASE_SCORE") * constant(constants, "MAX_CODE_DENSITY_MULTIPLIER") + constant(constants, "MAX_CONTRIBUTION_BONUS"); - const activeContributionBonus = snapshot.activeModel === "pending_saturation_model" ? saturationContributionBonusValue : densityContributionBonus; - const labelMultiplier = selectLabelMultiplier(input.labels ?? [], config?.labelMultipliers ?? {}, config?.defaultLabelMultiplier ?? 1); - const branchEligibility = normalizeBranchEligibility(input); - const linkedIssueMultiplier = decideLinkedIssueMultiplier(input.linkedIssueMode ?? "none", input.linkedIssueContext, constants, branchEligibility); - const issueMultiplier = linkedIssueMultiplier.appliedMultiplier; - const credibilityObserved = clamp(input.credibility ?? inferCredibility(contributorEvidence), 0, 1); - const credibilityFloor = constant(constants, "MIN_CREDIBILITY"); - const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor; - const changesRequestedCount = nonNegative(input.changesRequestedCount); - const reviewPenaltyRate = constant(constants, "REVIEW_PENALTY_RATE"); - const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * reviewPenaltyRate, 0, 1); - const reviewCollateralMultiplier = Math.min( - constant(constants, "MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER"), - 1 + changesRequestedCount * reviewPenaltyRate, - ); - const openPrCollateralPercent = constant(constants, "OPEN_PR_COLLATERAL_PERCENT"); - const openPrCount = nonNegative(input.openPrCount); - // The concurrency allowance is earned from the contributor's established merged-history token - // score; the planned PR's own tokens (totalTokenScore) must not inflate its own open-PR threshold. - const openPrThreshold = Math.min( - constant(constants, "MAX_OPEN_PR_THRESHOLD"), - constant(constants, "EXCESSIVE_PR_PENALTY_BASE_THRESHOLD") + - Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE")), - ); - const openPrMultiplier = openPrCount <= openPrThreshold ? 1 : 0; - // Open-issue spam gate (#808): mirrors the open-PR gate for the issue-discovery channel. - // A contributor earns extra open-issue slots from their existing merged-history token score. - const openIssueCount = nonNegative(input.openIssueCount); - const openIssueThreshold = Math.min( - constant(constants, "MAX_OPEN_ISSUE_THRESHOLD"), - constant(constants, "OPEN_ISSUE_SPAM_BASE_THRESHOLD") + - Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT")), - ); - const openIssueMultiplier = openIssueCount <= openIssueThreshold ? 1 : 0; - const mergedPrFloor = constant(constants, "MIN_VALID_MERGED_PRS"); - const mergedPullRequestsObserved = resolveMergedPullRequests(input, contributorEvidence); - const mergedHistoryMultiplier = - mergedPullRequestsObserved === undefined ? 1 : mergedPullRequestsObserved >= mergedPrFloor ? 1 : 0; - const validSolvedIssuesFloor = constant(constants, "MIN_VALID_SOLVED_ISSUES"); - const issueCredibilityFloor = constant(constants, "MIN_ISSUE_CREDIBILITY"); - const validSolvedIssuesObserved = input.validSolvedIssues !== undefined ? nonNegative(input.validSolvedIssues) : undefined; - const issueCredibilityObserved = input.issueCredibility !== undefined ? clamp(input.issueCredibility, 0, 1) : undefined; - // Issue-discovery validity mirrors upstream's separate issue lane, which is the `standard` linked-issue - // lane only. The `maintainer` lane explicitly does not require solved-by-PR issue linkage (see - // decideLinkedIssueMultiplier), so it must not be gated by the solved-issue history floor — otherwise a - // maintainer preview with sparse issue history is wrongly zeroed and flagged issue_discovery_validity_floor. - const issueDiscoveryRelevant = (input.linkedIssueMode ?? "none") === "standard"; - const issueDiscoveryHistoryKnown = validSolvedIssuesObserved !== undefined && issueCredibilityObserved !== undefined; - const issueDiscoveryHistoryMultiplier = - !issueDiscoveryRelevant || !issueDiscoveryHistoryKnown - ? 1 - : validSolvedIssuesObserved >= validSolvedIssuesFloor && issueCredibilityObserved >= issueCredibilityFloor - ? 1 - : 0; - // Upstream time-decay (#703): mirrors upstream's `scored.time_decay_multiplier` applied to a PR's score. - // Opt-in + env-gated (default off). A fresh PR (prAgeHours below the grace period) yields 1.0, so a normal - // new-PR preview is unchanged even when enabled — only an aged-PR projection decays. - // Per-repo curve (#703): the repo's registry `scoring.time_decay` overrides overlay the snapshot defaults. - const timeDecayMultiplier = input.applyTimeDecay ? calculateTimeDecay(nonNegative(input.prAgeHours), constants, config?.timeDecay) : 1; - const estimatedMergedScore = roundScore( - baseScore * - labelMultiplier * - issueMultiplier * - credibilityMultiplier * - reviewPenaltyMultiplier * - openPrMultiplier * - openIssueMultiplier * - mergedHistoryMultiplier * - issueDiscoveryHistoryMultiplier * - timeDecayMultiplier, - ); - const pendingSaturationScore = roundScore(saturationBaseScore); - return { - laneMath: { - repoEmissionShare: emissionShare, - ossEmissionShare, - repoSlice: roundScore(repoSlice), - directPrSlice: roundScore(directPrSlice), - issueDiscoverySlice: roundScore(issueDiscoverySlice), - issueDiscoveryShare, - }, - scoreEstimate: { - baseScore: roundScore(baseScore), - ...(baseScoreCap !== undefined ? { baseScoreCap: roundScore(baseScoreCap) } : {}), - densityMultiplier: roundScore(densityMultiplier), - contributionBonus: roundScore(activeContributionBonus), - labelMultiplier, - issueMultiplier, - credibilityMultiplier: roundScore(credibilityMultiplier), - reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier), - openPrMultiplier, - openIssueMultiplier, - mergedHistoryMultiplier, - issueDiscoveryHistoryMultiplier, - timeDecayMultiplier: roundScore(timeDecayMultiplier), - estimatedMergedScore, - pendingSaturationScore, - }, - linkedIssueMultiplier, - gates: { - baseTokenGatePassed, - openPrThreshold, - openPrCount, - reviewCollateralMultiplier: roundScore(reviewCollateralMultiplier), - collateralFraction: roundScore(openPrCollateralPercent * reviewCollateralMultiplier), - credibilityFloor, - credibilityObserved, - openIssueThreshold, - openIssueCount, - mergedPrFloor, - ...(mergedPullRequestsObserved !== undefined ? { mergedPullRequests: mergedPullRequestsObserved } : {}), - validSolvedIssuesFloor, - ...(validSolvedIssuesObserved !== undefined ? { validSolvedIssues: validSolvedIssuesObserved } : {}), - issueCredibilityFloor, - ...(issueCredibilityObserved !== undefined ? { issueCredibility: issueCredibilityObserved } : {}), - nonCodeLineCap: constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT"), - ...(input.nonCodeLines !== undefined ? { nonCodeLinesObserved: nonNegative(input.nonCodeLines) } : {}), - }, - }; -} - -function buildScenarioPreviews( - input: ScorePreviewInput, - repo: RepositoryRecord | null, - snapshot: ScoringModelSnapshotRecord, - contributorEvidence: ContributorEvidenceRecord | null | undefined, - current: ScoreCore, -): ScoreScenarioPreview[] { - // Count each pending open PR at most once. `approvedPrCount` and - // `pendingMergedPrCount` are the same merge-ready set — detectPendingPrScenario - // sets both to `mergeReady.length` — so they must be folded with `max`, not - // added, or the merge-ready PRs get double-subtracted from the open-PR - // projection. Closed/likely-close PRs are a disjoint set and add on top. This - // mirrors the canonical reduction in pending-pr-scenarios.ts - // (currentOpen - pendingMergedPrCount - pendingClosedPrCount). - const mergeReadyPending = Math.max(nonNegative(input.pendingMergedPrCount), nonNegative(input.approvedPrCount)); - const userPendingCount = mergeReadyPending + nonNegative(input.pendingClosedPrCount); - const observedApprovedCount = nonNegative(input.observedApprovedPrCount); - const observedStaleCloseCount = nonNegative(input.observedStalePrCount); - const observedClosedCount = nonNegative(input.observedClosedPrCount); - const combinedPendingCount = userPendingCount + observedApprovedCount + observedStaleCloseCount; - const expectedOpenPrCountAfterMerge = - input.expectedOpenPrCountAfterMerge !== undefined ? nonNegative(input.expectedOpenPrCountAfterMerge) : Math.max(0, current.gates.openPrCount - userPendingCount); - const projectedCredibility = - input.projectedCredibility !== undefined - ? clamp(input.projectedCredibility, 0, 1) - : userPendingCount > 0 - ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) - : current.gates.credibilityObserved; - const observedApprovalCredibility = observedApprovedCount > 0 ? Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor) : current.gates.credibilityObserved; - const afterApprovedInput = { - ...input, - openPrCount: Math.max(0, current.gates.openPrCount - observedApprovedCount), - credibility: observedApprovalCredibility, - }; - const afterStaleInput = { - ...input, - openPrCount: Math.max(0, current.gates.openPrCount - observedStaleCloseCount), - credibility: current.gates.credibilityObserved, - }; - const cleanGatesInput = { - ...input, - openPrCount: Math.min(current.gates.openPrCount, current.gates.openPrThreshold), - openIssueCount: Math.min(current.gates.openIssueCount, current.gates.openIssueThreshold), - credibility: Math.max(current.gates.credibilityObserved, current.gates.credibilityFloor), - ...(current.gates.mergedPullRequests !== undefined - ? { mergedPullRequests: Math.max(current.gates.mergedPullRequests, current.gates.mergedPrFloor) } - : {}), - ...(current.gates.validSolvedIssues !== undefined - ? { validSolvedIssues: Math.max(current.gates.validSolvedIssues, current.gates.validSolvedIssuesFloor) } - : {}), - ...(current.gates.issueCredibility !== undefined - ? { issueCredibility: Math.max(current.gates.issueCredibility, current.gates.issueCredibilityFloor) } - : {}), - }; - const afterPendingInput = { - ...input, - openPrCount: expectedOpenPrCountAfterMerge, - credibility: projectedCredibility, - ...(current.gates.mergedPullRequests !== undefined - ? { mergedPullRequests: nonNegative(current.gates.mergedPullRequests) + mergeReadyPending } - : {}), - }; - const linkedIssueInput = withValidatedLinkedIssueScenario(input); - const bestReasonableInput = { - ...linkedIssueInput, - openPrCount: Math.min( - input.expectedOpenPrCountAfterMerge !== undefined ? expectedOpenPrCountAfterMerge : Math.max(0, current.gates.openPrCount - combinedPendingCount), - current.gates.openPrThreshold, - ), - // Project open-issue spam cleanup (#808): mirror the open-PR projection so the - // "best reasonable case" can clear the open-issue gate just like it clears open-PR pressure. - openIssueCount: Math.min(current.gates.openIssueCount, current.gates.openIssueThreshold), - credibility: Math.max(projectedCredibility, observedApprovalCredibility, current.gates.credibilityFloor), - ...(current.gates.mergedPullRequests !== undefined - ? { - mergedPullRequests: Math.max( - nonNegative(current.gates.mergedPullRequests) + mergeReadyPending, - current.gates.mergedPrFloor, - ), - } - : {}), - ...(current.gates.validSolvedIssues !== undefined - ? { validSolvedIssues: Math.max(current.gates.validSolvedIssues, current.gates.validSolvedIssuesFloor) } - : {}), - ...(current.gates.issueCredibility !== undefined - ? { issueCredibility: Math.max(current.gates.issueCredibility, current.gates.issueCredibilityFloor) } - : {}), - }; - return [ - scenario("current", "current_data", input, current, ["Current cached/account state and supplied local diff metadata."], repo), - scenario("cleanGates", "gittensory_projection", cleanGatesInput, computeScoreCore(cleanGatesInput, repo, snapshot, contributorEvidence), [ - "Open PR, open-issue, credibility, and contributor-history gates are projected as cleared; branch metadata is otherwise unchanged.", - ], repo), - scenario( - "afterPendingMerges", - input.pendingScenarioObserved - ? "github_observed" - : userPendingCount > 0 || input.expectedOpenPrCountAfterMerge !== undefined || input.projectedCredibility !== undefined - ? "user_supplied" - : "gittensory_projection", - afterPendingInput, - computeScoreCore(afterPendingInput, repo, snapshot, contributorEvidence), - [ - userPendingCount > 0 - ? `${userPendingCount} pending merged/closed PR(s) are treated as no longer open for this scenario${input.pendingScenarioObserved ? "" : " (caller-supplied)"}.` - : "No pending merge/close count was supplied; this scenario preserves current open PR pressure.", - ...(input.projectedCredibility !== undefined - ? [`Projected credibility is user-supplied as ${roundScore(projectedCredibility)}.`] - : userPendingCount > 0 - ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} because pending merges are expected to land.`] - : []), - ...(input.scenarioNotes ?? []), - ], - repo, - ), - scenario( - "afterApprovedPrsMerge", - "github_observed", - afterApprovedInput, - computeScoreCore(afterApprovedInput, repo, snapshot, contributorEvidence), - [ - observedApprovedCount > 0 - ? `${observedApprovedCount} GitHub-observed approved or mergeable open PR(s) are treated as no longer open if they merge.` - : "No GitHub-observed approved or mergeable open PRs were available for this scenario.", - ...(observedApprovedCount > 0 ? [`Projected credibility is raised to the current floor ${current.gates.credibilityFloor} after observed mergeable work lands.`] : []), - ...observedScenarioNotes(input), - ], - repo, - ), - scenario( - "afterStalePrsClose", - "github_observed", - afterStaleInput, - computeScoreCore(afterStaleInput, repo, snapshot, contributorEvidence), - [ - observedStaleCloseCount > 0 - ? `${observedStaleCloseCount} GitHub-observed stale open PR(s) are treated as no longer open if they close or withdraw.` - : "No GitHub-observed stale open PRs were available for this scenario.", - ...(observedClosedCount > 0 ? [`${observedClosedCount} GitHub-observed already-closed PR(s) are excluded because they no longer contribute to open PR pressure.`] : []), - "Credibility is not increased in this scenario because stale cleanup is not the same as merged work.", - ...observedScenarioNotes(input), - ], - repo, - ), - scenario("linkedIssueFixed", "gittensory_projection", linkedIssueInput, computeScoreCore(linkedIssueInput, repo, snapshot, contributorEvidence), [ - input.linkedIssueMode === "none" || !input.linkedIssueMode - ? "A standard linked-issue/no-issue rationale multiplier is projected as solved-by-PR validated." - : "Linked issue mode was already supplied; this scenario projects solved-by-PR validation where needed.", - ], repo), - scenario("bestReasonableCase", "gittensory_projection", bestReasonableInput, computeScoreCore(bestReasonableInput, repo, snapshot, contributorEvidence), [ - "Combines plausible near-term gate cleanup: open PR pressure at threshold or below, open-issue spam pressure at threshold or below, credibility at floor or above, contributor merged-history and issue-discovery validity at floor or above, and linked-issue context where applicable.", - ...(input.scenarioNotes ?? []), - ...observedScenarioNotes(input), - ], repo), - ]; -} - -function observedScenarioNotes(input: ScorePreviewInput): string[] { - return [ - ...(nonNegative(input.observedDraftPrCount) > 0 ? [`${nonNegative(input.observedDraftPrCount)} draft PR(s) were excluded from likely-to-land projections.`] : []), - ...(nonNegative(input.observedBlockedPrCount) > 0 ? [`${nonNegative(input.observedBlockedPrCount)} blocked PR(s) were excluded from likely-to-land projections.`] : []), - ...(nonNegative(input.observedMaintainerPrCount) > 0 ? [`${nonNegative(input.observedMaintainerPrCount)} maintainer-lane PR(s) were kept out of outside-contributor projections.`] : []), - ...(input.observedScenarioNotes ?? []), - ]; -} - -function scenario( - name: ScoreScenarioPreview["name"], - source: ScoreScenarioPreview["source"], - input: ScorePreviewInput, - core: ScoreCore, - assumptions: string[], - repo: RepositoryRecord | null, -): ScoreScenarioPreview { - const blockedBy = blockedByFor(input, repo, core); - return { - name, - source, - assumptions, - scoreEstimate: core.scoreEstimate, - linkedIssueMultiplier: core.linkedIssueMultiplier, - gates: core.gates, - effectiveEstimatedScore: core.scoreEstimate.estimatedMergedScore, - underlyingPotentialScore: core.scoreEstimate.pendingSaturationScore, - blockedBy, - deltaExplanation: deltaExplanationFor(core, blockedBy), - }; -} - -function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore, branchEligibility = normalizeBranchEligibility(input)): ScoreGateBlocker[] { - return [ - ...(!repo?.isRegistered - ? [{ code: "repo_not_registered" as const, severity: "blocker" as const, detail: "Repository is not registered in the local Gittensory cache." }] - : []), - ...(core.laneMath.repoEmissionShare <= 0 - ? [{ code: "inactive_allocation" as const, severity: "blocker" as const, detail: "Repository has no active allocation in the current registry snapshot." }] - : []), - ...(input.metadataOnly - ? [{ code: "metadata_only" as const, severity: "context" as const, detail: "Preview used metadata-only inputs, so token and density estimates are rough." }] - : []), - ...(branchEligibility.required && branchEligibility.status === "ineligible" - ? [ - { - code: "branch_ineligible" as const, - severity: "reducer" as const, - detail: "Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled.", - }, - ] - : []), - ...(branchEligibility.required && branchEligibility.status === "unknown" - ? [ - { - code: "branch_eligibility_missing" as const, - severity: "context" as const, - detail: - branchEligibility.evidence === "missing" - ? "Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions." - : "Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions.", - }, - ] - : []), - ...(!core.gates.baseTokenGatePassed - ? [{ code: "base_token_gate" as const, severity: "blocker" as const, detail: "Source token score does not pass the current base-score token gate." }] - : []), - ...(core.scoreEstimate.openPrMultiplier === 0 - ? [ - { - code: "open_pr_threshold" as const, - severity: "blocker" as const, - detail: `Open PR count ${core.gates.openPrCount} exceeds threshold ${core.gates.openPrThreshold}.`, - }, - ] - : []), - ...(core.scoreEstimate.openIssueMultiplier === 0 - ? [ - { - code: "open_issue_threshold" as const, - severity: "blocker" as const, - detail: `Open issue count ${core.gates.openIssueCount} exceeds spam threshold ${core.gates.openIssueThreshold}.`, - }, - ] - : []), - ...(core.scoreEstimate.mergedHistoryMultiplier === 0 - ? [ - { - code: "merged_pr_history_floor" as const, - severity: "blocker" as const, - detail: `Merged PR count ${core.gates.mergedPullRequests} is below upstream floor ${core.gates.mergedPrFloor}.`, - }, - ] - : []), - ...(core.scoreEstimate.issueDiscoveryHistoryMultiplier === 0 - ? [ - { - code: "issue_discovery_validity_floor" as const, - severity: "blocker" as const, - detail: `Issue-discovery history (${core.gates.validSolvedIssues} valid solved, credibility ${roundScore(core.gates.issueCredibility!)}) is below upstream floors (${core.gates.validSolvedIssuesFloor} valid solved, ${core.gates.issueCredibilityFloor} credibility).`, - }, - ] - : []), - ...(core.gates.credibilityObserved < core.gates.credibilityFloor - ? [ - { - code: "credibility_floor" as const, - severity: "reducer" as const, - detail: `Credibility ${roundScore(core.gates.credibilityObserved)} is below floor ${core.gates.credibilityFloor}.`, - }, - ] - : []), - ...(core.scoreEstimate.reviewPenaltyMultiplier < 1 - ? [{ code: "review_penalty" as const, severity: "reducer" as const, detail: "Change-request history reduces the estimate." }] - : []), - ...(core.linkedIssueMultiplier.mode === "standard" && core.linkedIssueMultiplier.status === "invalid" - ? [ - { - code: "linked_issue_invalid" as const, - severity: "reducer" as const, - detail: core.linkedIssueMultiplier.reason, - }, - ] - : []), - ...(core.linkedIssueMultiplier.mode === "standard" && ["raw", "plausible", "unavailable"].includes(core.linkedIssueMultiplier.status) - ? [ - { - code: "linked_issue_unvalidated" as const, - severity: "context" as const, - detail: core.linkedIssueMultiplier.reason, - }, - ] - : []), - ...(nonNegative(input.observedStalePrCount) > 0 - ? [ - { - code: "stale_work" as const, - severity: "reducer" as const, - detail: `${nonNegative(input.observedStalePrCount)} stale open PR(s) detected; consider closing stale work before opening new contributions.`, - }, - ] - : []), - ...(nonNegative(input.duplicateRiskCount) > 0 - ? [ - { - code: "duplicate_risk" as const, - severity: "reducer" as const, - detail: `${nonNegative(input.duplicateRiskCount)} duplicate-risk issue(s) or PR(s) detected; verify there is no conflicting work before proceeding.`, - }, - ] - : []), - ]; -} - -function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]): ScoreGateDelta[] { - const currentScenario = scenarios[0]; - /* v8 ignore next -- buildScenarioPreviews always emits a current scenario; this protects malformed adapters. */ - if (!currentScenario) return []; - const bestMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "bestReasonableCase"); - /* v8 ignore next -- buildScenarioPreviews always emits bestReasonableCase; current is the defensive fallback. */ - const best = bestMatch ?? currentScenario; - const linkedMatch = scenarios.find((scenarioPreview) => scenarioPreview.name === "linkedIssueFixed"); - /* v8 ignore next -- buildScenarioPreviews always emits linkedIssueFixed; best is the defensive fallback. */ - const linked = linkedMatch ?? best; - return [ - ...(current.scoreEstimate.openPrMultiplier !== best.scoreEstimate.openPrMultiplier || current.gates.openPrCount !== best.gates.openPrCount - ? [ - { - gate: "open_pr_threshold" as const, - current: `${current.gates.openPrCount}/${current.gates.openPrThreshold} open PRs, multiplier ${current.scoreEstimate.openPrMultiplier}`, - projected: `${best.gates.openPrCount}/${best.gates.openPrThreshold} open PRs, multiplier ${best.scoreEstimate.openPrMultiplier}`, - explanation: `Open PR pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, - }, - ] - : []), - ...(current.scoreEstimate.openIssueMultiplier !== best.scoreEstimate.openIssueMultiplier || current.gates.openIssueCount !== best.gates.openIssueCount - ? [ - { - gate: "open_issue_threshold" as const, - current: `${current.gates.openIssueCount}/${current.gates.openIssueThreshold} open issues, multiplier ${current.scoreEstimate.openIssueMultiplier}`, - projected: `${best.gates.openIssueCount}/${best.gates.openIssueThreshold} open issues, multiplier ${best.scoreEstimate.openIssueMultiplier}`, - explanation: `Open issue spam pressure changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, - }, - ] - : []), - ...(current.scoreEstimate.mergedHistoryMultiplier !== best.scoreEstimate.mergedHistoryMultiplier - ? [ - { - gate: "merged_pr_history_floor" as const, - current: `${current.gates.mergedPullRequests}/${current.gates.mergedPrFloor} merged PRs, multiplier ${current.scoreEstimate.mergedHistoryMultiplier}`, - projected: `${best.gates.mergedPullRequests}/${best.gates.mergedPrFloor} merged PRs, multiplier ${best.scoreEstimate.mergedHistoryMultiplier}`, - explanation: `Merged PR history changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, - }, - ] - : []), - ...(current.scoreEstimate.issueDiscoveryHistoryMultiplier !== best.scoreEstimate.issueDiscoveryHistoryMultiplier - ? [ - { - gate: "issue_discovery_validity_floor" as const, - current: `${current.gates.validSolvedIssues} valid solved / ${roundScore(current.gates.issueCredibility!)} credibility, multiplier ${current.scoreEstimate.issueDiscoveryHistoryMultiplier}`, - projected: `${best.gates.validSolvedIssues} valid solved / ${roundScore(best.gates.issueCredibility!)} credibility, multiplier ${best.scoreEstimate.issueDiscoveryHistoryMultiplier}`, - explanation: `Issue-discovery validity changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, - }, - ] - : []), - ...(current.gates.credibilityObserved !== best.gates.credibilityObserved || current.scoreEstimate.credibilityMultiplier !== best.scoreEstimate.credibilityMultiplier - ? [ - { - gate: "credibility_floor" as const, - current: `${roundScore(current.gates.credibilityObserved)} observed, multiplier ${current.scoreEstimate.credibilityMultiplier}`, - projected: `${roundScore(best.gates.credibilityObserved)} projected, multiplier ${best.scoreEstimate.credibilityMultiplier}`, - explanation: `Credibility changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${best.scoreEstimate.estimatedMergedScore}.`, - }, - ] - : []), - ...(current.scoreEstimate.issueMultiplier !== linked.scoreEstimate.issueMultiplier - ? [ - { - gate: "linked_issue_multiplier" as const, - current: `${current.scoreEstimate.issueMultiplier}`, - projected: `${linked.scoreEstimate.issueMultiplier}`, - explanation: `Linked issue/no-issue context changes estimated score ${current.scoreEstimate.estimatedMergedScore} -> ${linked.scoreEstimate.estimatedMergedScore}.`, - }, - ] - : []), - ]; -} - -function warningsFor(input: ScorePreviewInput, repo: RepositoryRecord | null, core: ScoreCore, branchEligibility = normalizeBranchEligibility(input)): string[] { - return [...new Set([...blockedByFor(input, repo, core, branchEligibility).map((blocker) => blocker.detail), ...core.linkedIssueMultiplier.warnings])]; -} - -function statusFor( - repo: RepositoryRecord | null, - blockedBy: ScoreGateBlocker[], - effectiveEstimatedScore: number, - scenarios: ScoreScenarioPreview[], -): ScorePreviewResult["scoreabilityStatus"] { - if (!repo?.isRegistered || blockedBy.some((blocker) => blocker.code === "inactive_allocation")) return "hold"; - if (effectiveEstimatedScore > 0 && !blockedBy.some((blocker) => blocker.severity === "blocker")) return "scoreable"; - if (scenarios.some((scenarioPreview) => scenarioPreview.name !== "current" && scenarioPreview.effectiveEstimatedScore > effectiveEstimatedScore)) { - return "conditionally_scoreable"; - } - return "blocked"; -} - -function deltaExplanationFor(core: ScoreCore, blockedBy: ScoreGateBlocker[]): string { - if (blockedBy.length === 0) return `Currently scoreable at ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}.`; - return `Effective score ${core.scoreEstimate.estimatedMergedScore}; underlying potential ${core.scoreEstimate.pendingSaturationScore}; blocked or reduced by ${blockedBy.map((blocker) => blocker.code).join(", ")}.`; -} - -// A label multiplier must be a positive, finite number (mirrors the validity rule signals/engine.ts's own -// config-quality check already documents and enforces for its ADVISORY health score: "0, negative, NaN, or -// Infinity are config errors that would silently misweight scoring"). That check only ever adjusted a -// repo's informational config-quality score -- it never stopped an invalid value from reaching the REAL -// scoring formula here, where `labelMultiplier` multiplies directly into `estimatedMergedScore`. A -// registry-sourced label multiplier of 0 or a negative number is valid JSON and passed neither `numberValue` -// (only applied to the scalar overrides, not this map) nor any check in this function, so it would zero out -// or invert any PR/issue score for a matching label. Filtering here closes the gap where it actually matters. -function isValidLabelMultiplier(value: number): boolean { - return typeof value === "number" && Number.isFinite(value) && value > 0; -} - -function selectLabelMultiplier(labels: string[], multipliers: Record, fallback: number): number { - const normalized = labels.map((label) => label.toLowerCase()); - const matched = Object.entries(multipliers).flatMap(([pattern, multiplier]) => { - if (!isValidLabelMultiplier(multiplier)) return []; - const matcher = labelPatternToRegExp(pattern.toLowerCase()); - return normalized.some((label) => matcher.test(label)) ? [multiplier] : []; - }); - return matched.length > 0 ? Math.max(...matched) : isValidLabelMultiplier(fallback) ? fallback : 1; -} - -/** True when `label` matches the configured multiplier `pattern` under the SAME case-insensitive fnmatch glob - * semantics scoring uses to resolve label multipliers (see {@link labelPatternToRegExp}). Exported so the - * signals surfaces that audit configured label keys (config-quality, label-audit) match them as the GLOBS they - * are: a `type:*` key must count `type:bug-fix` as observed/configured, not silently report it missing because - * the literal pattern never appears verbatim on a real issue/PR. A literal key (no glob metacharacter) still - * matches only its exact label, so existing configs behave identically. */ -export function labelMatchesPattern(label: string, pattern: string): boolean { - return labelPatternToRegExp(pattern.toLowerCase()).test(label.toLowerCase()); -} - -// Compiled fnmatch→RegExp matchers are memoized by pattern. The same small, -// config-derived set of label keys is matched on every scored PR/issue, so the -// per-call recompile inside the nested label loops in engine.ts is pure waste. -// Keys come from a repo's registryConfig.labelMultipliers, sourced from the externally-fetched gittensor -// registry (registry/sync.ts + registry/normalize.ts, not a value this repo's own maintainer directly controls -// via .gittensory.yml) — so the pattern SET is small per repo, but individual pattern CONTENT is untrusted, not -// literally attacker-supplied-per-request the way GitHub PR content is. The wildcard-count cap below (#2456) -// bounds a single pattern's compile cost; this cache is additionally bounded to a fixed max entry count and -// evicted LRU, so a long-running isolate that observes many distinct registry snapshots over its life still -// can't grow the cache unboundedly. The compiled RegExp carries only the "i" flag (no global/sticky `lastIndex` -// state), so sharing one instance across calls is safe and byte-identical to recompiling on every call. -export const LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES = 256; -const labelPatternRegExpCache = new Map(); - -// A RegExp that never matches any input — mirrors change-guardrail.ts's identical NEVER_MATCHES fallback for an -// over-complex pattern, so a pathological registry entry degrades to "this label multiplier never applies" -// instead of hanging the scoring path that evaluates it. -const LABEL_PATTERN_NEVER_MATCHES = /^(?!)$/; - -// Upstream resolves label multipliers by matching each configured key as a Python `fnmatch` GLOB, not a -// literal string: `fnmatch(label.lower(), pattern.lower())` in -// gittensor/validator/oss_contributions/label_resolution.py, so a repo can configure `type:*`, `kind/*`, or -// `priority:?` and have it match `type:bug-fix`, `kind/bug`, `priority:1` (#1244-class scoring parity). The -// preview previously did exact equality, so it silently scored every wildcard-configured trusted label at the -// neutral default — under-/over-estimating the score for any repo using glob keys. Translate one fnmatch -// pattern to an anchored, case-insensitive RegExp. fnmatch semantics differ from the path-glob in -// change-guardrail.ts (there `*` stops at `/` and `?` is literal): labels are flat strings, so `*` matches any -// run, `?` any single character, and `[seq]`/`[!seq]` a character class. Literal keys are unaffected — for a -// pattern with no glob metacharacter the RegExp is an exact match, so existing configs score identically. -function labelPatternToRegExp(pattern: string): RegExp { - const cached = labelPatternRegExpCache.get(pattern); - if (cached !== undefined) { - // Refresh recency on hit so the cache behaves as an LRU: the most-recently-matched patterns - // survive eviction, not just the most-recently-inserted ones. - labelPatternRegExpCache.delete(pattern); - labelPatternRegExpCache.set(pattern, cached); - return cached; - } - // Reuses change-guardrail.ts's wildcard-GROUP counting (a `*` here matches the same "any run of chars" - // semantics as that glob compiler's `*`, so the same catastrophic-backtracking risk and the same empirically- - // safe threshold apply) — an over-complex registry-sourced label_multipliers key degrades to a safe never-match - // instead of hanging RegExp.test() on an adversarial near-miss label (#2456). Reachable via the public - // score-preview API, the MCP tool, and the per-PR label-audit signal, so one bad registry entry could otherwise - // hang scoring for every PR on that repo. - if (hasUnsafeWildcardCount(pattern)) { - setLabelPatternRegExpCacheEntry(pattern, LABEL_PATTERN_NEVER_MATCHES); - return LABEL_PATTERN_NEVER_MATCHES; - } - let regex = ""; - let i = 0; - while (i < pattern.length) { - const char = pattern.charAt(i); - i += 1; - if (char === "*") { - regex += ".*"; - } else if (char === "?") { - regex += "."; - } else if (char === "[") { - const close = pattern.indexOf("]", i); - if (close === -1) { - // No closing bracket: fnmatch treats the `[` as a literal character. - regex += "\\["; - } else { - const rawBody = pattern.slice(i, close); - if (rawBody === "" || rawBody === "!") { - // Empty classes and bare `[!]` stay literal in Python fnmatch instead of compiling as classes. - regex += `\\[${escapeRegExpLiteral(rawBody)}\\]`; - } else if (hasDescendingCharacterRange(rawBody)) { - // Python fnmatch treats invalid ranges like `[z-a]` as a never-match pattern; RegExp throws. - regex += "(?!)"; - } else { - let body = rawBody.replace(/\\/g, "\\\\"); - // `[!seq]` is fnmatch's negated class; RegExp spells negation as `[^seq]`. - if (body.startsWith("!")) body = `^${body.slice(1)}`; - else if (body.startsWith("^")) body = `\\${body}`; - regex += `[${body}]`; - } - i = close + 1; - } - } else if (/[.+^${}()|\]\\]/.test(char)) { - regex += `\\${char}`; - } else { - regex += char; - } - } - const compiled = new RegExp(`^${regex}$`, "i"); - setLabelPatternRegExpCacheEntry(pattern, compiled); - return compiled; -} - -// Inserts a new (never-before-cached) entry, evicting the least-recently-used entry first if the -// cache is already at its bound. Callers must only use this for keys not already present — refreshing -// an existing key's recency on a cache hit is handled inline above via delete+set. -function setLabelPatternRegExpCacheEntry(pattern: string, compiled: RegExp): void { - if (labelPatternRegExpCache.size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES) { - // Map iteration order is insertion order, so the first key is always the least-recently-used - // one (recency is refreshed via delete+set on every hit/insert). The map is non-empty here - // because size >= LABEL_PATTERN_REGEXP_CACHE_MAX_ENTRIES (a positive constant), so the loop body - // always runs exactly once. - for (const oldestPattern of labelPatternRegExpCache.keys()) { - labelPatternRegExpCache.delete(oldestPattern); - break; - } - } - labelPatternRegExpCache.set(pattern, compiled); -} - -export function clearLabelPatternRegExpCacheForTest(): void { - labelPatternRegExpCache.clear(); -} - -export function labelPatternRegExpCacheKeysForTest(): string[] { - return [...labelPatternRegExpCache.keys()]; -} - -function escapeRegExpLiteral(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} - -function hasDescendingCharacterRange(body: string): boolean { - const start = body.startsWith("!") ? 1 : 0; - // Walk the class left-to-right, consuming each `X-Y` range as a unit so a range endpoint can't be - // misread as the start of a spurious second range. Only a genuinely inverted range like `[z-a]` — the - // case JS `RegExp` actually throws on — must degrade the class to never-match; a literal `-` that - // follows a completed range (as in `[a-z-9]`, a valid class) must NOT be suppressed. The prior scan - // flagged any `-` whose left neighbor outranked its right neighbor, so it wrongly killed `[a-z-9]`. - let i = start; - while (i < body.length) { - if (i + 2 < body.length && body.charAt(i + 1) === "-") { - if (body.charCodeAt(i) > body.charCodeAt(i + 2)) return true; - i += 3; - } else { - i += 1; - } - } - return false; -} - -function decideLinkedIssueMultiplier( - mode: "none" | "standard" | "maintainer", - context: LinkedIssueMultiplierContext | undefined, - constants: Record, - branchEligibility: BranchEligibilityResult, -): LinkedIssueMultiplierDecision { - const baseMultiplier = selectIssueMultiplier(mode, constants); - const issueNumbers = uniquePositiveInts(context?.issueNumbers ?? []); - const solvedByPullRequests = uniquePositiveInts(context?.solvedByPullRequests ?? []); - if (mode === "none") { - return { - mode, - status: "not_required", - source: "none", - eligible: false, - issueNumbers, - solvedByPullRequests, - baseMultiplier, - appliedMultiplier: 1, - reason: "No linked-issue multiplier mode was requested.", - warnings: [], - }; - } - if (mode === "maintainer") { - return { - mode, - status: "not_required", - source: context?.source ?? "none", - eligible: true, - issueNumbers, - solvedByPullRequests, - baseMultiplier, - appliedMultiplier: baseMultiplier, - reason: "Maintainer-lane multiplier does not require solved-by-PR issue linkage.", - warnings: context?.warnings ?? [], - }; - } - - const projectedSolvedByPullRequestValidation = (context as ProjectedLinkedIssueMultiplierContext | undefined)?.[PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION] === true; - const requestedStatus = context?.status ?? (solvedByPullRequests.length > 0 ? "validated" : issueNumbers.length > 0 ? "raw" : "unavailable"); - const hasSolvedByPullRequestEvidence = solvedByPullRequests.length > 0 || projectedSolvedByPullRequestValidation; - const status = requestedStatus === "validated" && !hasSolvedByPullRequestEvidence ? (issueNumbers.length > 0 ? "raw" : "unavailable") : requestedStatus; - const source = context?.source ?? (status === "unavailable" ? "missing" : "user_supplied"); - const branchEligible = isConfirmedBranchEligible(branchEligibility); - const eligible = status === "validated" && hasSolvedByPullRequestEvidence && branchEligible; - const reason = - branchEligible || status !== "validated" - ? status === requestedStatus - ? context?.reason ?? linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) - : linkedIssueReason(status, source, issueNumbers, solvedByPullRequests) - : branchEligibilityFailureReason(branchEligibility); - return { - mode, - status, - source, - eligible, - issueNumbers, - solvedByPullRequests, - baseMultiplier, - appliedMultiplier: eligible ? baseMultiplier : 1, - reason, - warnings: [...new Set([...linkedIssueWarnings(status), ...branchEligibility.warnings, ...(context?.warnings ?? [])])], - }; -} - -function isConfirmedBranchEligible(branchEligibility: BranchEligibilityResult): boolean { - return !branchEligibility.required || (branchEligibility.status === "eligible" && branchEligibility.evidence === "provided" && !branchEligibility.stale); -} - -function branchEligibilityFailureReason(branchEligibility: BranchEligibilityResult): string { - if (branchEligibility.status === "ineligible") return "Branch eligibility is confirmed ineligible; standard issue multiplier is not applied."; - if (branchEligibility.evidence === "missing") return "Branch eligibility evidence is missing; standard issue multiplier is not applied."; - if (branchEligibility.status === "unknown") return "Branch eligibility is unknown; standard issue multiplier is not applied."; - if (branchEligibility.stale) return "Branch eligibility evidence is stale; standard issue multiplier is not applied."; - if (branchEligibility.source === "user_supplied") return "Branch eligibility evidence is user-supplied; standard issue multiplier is not applied until verified metadata is available."; - return "Branch eligibility is not confirmed; standard issue multiplier is not applied."; -} - -function withValidatedLinkedIssueScenario(input: ScorePreviewInput): ScorePreviewInput { - const mode = input.linkedIssueMode ?? "none"; - if (mode === "maintainer") return input; - const issueNumbers = uniquePositiveInts(input.linkedIssueContext?.issueNumbers ?? []); - const solvedByPullRequests = uniquePositiveInts(input.linkedIssueContext?.solvedByPullRequests ?? []); - const linkedIssueContext: ProjectedLinkedIssueMultiplierContext = { - ...input.linkedIssueContext, - status: "validated", - source: input.linkedIssueContext?.source ?? "user_supplied", - issueNumbers, - solvedByPullRequests, - warnings: [], - [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true, - }; - return { - ...input, - linkedIssueMode: "standard", - linkedIssueContext, - }; -} - -/** - * Project the standard linked-issue multiplier decision under the assumption that a planned PR - * becomes the merged solver of the given issue(s). Reuses {@link decideLinkedIssueMultiplier} — the - * same eligibility rule used by buildScorePreview — so standalone validators stay consistent with - * the scoring engine. The numeric multiplier on the returned decision is private; callers that are - * public-safe should surface only `eligible`/`status`/`reason`. - */ -export function projectLinkedIssueMultiplierForPlannedSolve(issueNumbers: number[]): LinkedIssueMultiplierDecision { - const branchEligibility: BranchEligibilityResult = { - required: true, - status: "eligible", - evidence: "provided", - source: "user_supplied", - stale: false, - warnings: [], - }; - const context: ProjectedLinkedIssueMultiplierContext = { - status: "validated", - source: "user_supplied", - issueNumbers: uniquePositiveInts(issueNumbers), - solvedByPullRequests: [], - warnings: [], - [PROJECTED_SOLVED_BY_PULL_REQUEST_VALIDATION]: true, - }; - return decideLinkedIssueMultiplier("standard", context, {}, branchEligibility); -} - -function linkedIssueReason( - status: Exclude, - source: LinkedIssueMultiplierSource, - issueNumbers: number[], - solvedByPullRequests: number[], -): string { - const issues = issueNumbers.length > 0 ? ` for issue(s) ${issueNumbers.map((number) => `#${number}`).join(", ")}` : ""; - if (status === "validated") { - const solvers = solvedByPullRequests.length > 0 ? ` via solved-by-PR ${solvedByPullRequests.map((number) => `#${number}`).join(", ")}` : ""; - return `Linked issue context is solved-by-PR validated${issues}${solvers}.`; - } - if (status === "invalid") return `Linked issue context is invalid${issues}; standard issue multiplier is not applied.`; - if (status === "plausible") return `Linked issue context is plausible${issues}, but solved-by-PR validation is not available yet.`; - if (status === "unavailable") return `Linked issue mirror/cache data is unavailable${issues}; standard issue multiplier is not applied until validation is available.`; - return `Raw linked issue reference${issues} has no solved-by-PR validation from ${source}.`; -} - -function linkedIssueWarnings(status: Exclude): string[] { - if (status === "validated") return []; - if (status === "invalid") return ["Linked issue context is invalid; standard issue multiplier is not applied."]; - if (status === "unavailable") return ["Linked issue mirror/cache data is unavailable; standard issue multiplier is not applied until validation is available."]; - if (status === "plausible") return ["Linked issue context is plausible but not solved-by-PR validated; standard issue multiplier is not applied."]; - return ["Raw linked issue reference has no solved-by-PR evidence; standard issue multiplier is not applied."]; -} - -function uniquePositiveInts(values: number[]): number[] { - return [...new Set(values.filter((value) => Number.isInteger(value) && value > 0))].sort((left, right) => left - right); -} - -function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constants: Record): number { - if (mode === "maintainer") return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER"); - if (mode === "standard") return constant(constants, "STANDARD_ISSUE_MULTIPLIER"); - return 1; -} - -function normalizeBranchEligibility(input: ScorePreviewInput): BranchEligibilityResult { - const required = input.linkedIssueMode === "standard"; - const supplied = input.branchEligibility; - if (!required) { - return { - required: false, - status: "not_required", - evidence: supplied ? "provided" : "missing", - source: supplied ? supplied.source ?? "user_supplied" : "missing", - reason: supplied?.reason, - checkedAt: supplied?.checkedAt, - stale: Boolean(supplied?.stale), - warnings: [], - }; - } - if (!supplied) { - return { - required: true, - status: "unknown", - evidence: "missing", - source: "missing", - stale: false, - warnings: ["Branch eligibility evidence is missing; refresh branch/base metadata before relying on linked-issue assumptions."], - }; - } - const status = supplied.status ?? "unknown"; - const stale = Boolean(supplied.stale); - const warnings = [ - ...(status === "ineligible" ? ["Branch eligibility is confirmed ineligible; linked-issue multiplier assumptions are disabled."] : []), - ...(status === "unknown" ? ["Branch eligibility is unknown; refresh branch/base metadata before relying on linked-issue assumptions."] : []), - ...(stale ? ["Branch eligibility evidence is stale; refresh branch/base metadata before relying on linked-issue assumptions."] : []), - ]; - return { - required: true, - status, - evidence: "provided", - source: supplied.source ?? "user_supplied", - reason: supplied.reason, - checkedAt: supplied.checkedAt, - stale, - warnings, - }; -} - -function resolveMergedPullRequests( - input: Pick, - contributorEvidence?: ContributorEvidenceRecord | null, -): number | undefined { - if (input.mergedPullRequests !== undefined) return nonNegative(input.mergedPullRequests); - const fromEvidence = Number(contributorEvidence?.payload?.mergedPullRequests); - return Number.isFinite(fromEvidence) ? nonNegative(fromEvidence) : undefined; -} - -function inferCredibility(evidence?: ContributorEvidenceRecord | null): number { - const payload = evidence?.payload; - const merged = Number(payload?.mergedPullRequests ?? 0); - const stale = Number(payload?.stalePullRequests ?? 0); - const unlinked = Number(payload?.unlinkedPullRequests ?? 0); - // The payload is a loosely-typed cache (`Record`), so any count can arrive as a - // non-numeric value. A non-finite `stale`/`unlinked` propagates NaN through the arithmetic below — - // and `clamp` cannot rescue NaN — poisoning the whole credibility multiplier and score, so guard all - // three counts, not just `merged`, falling back to the neutral credibility. - if (!Number.isFinite(merged) || !Number.isFinite(stale) || !Number.isFinite(unlinked)) return 0.8; - return clamp(0.75 + merged * 0.04 - stale * 0.03 - unlinked * 0.02, 0.25, 1); -} - -function applyNonCodeLineCap(input: Pick, constants: Record): number { - const score = nonNegative(input.nonCodeTokenScore); - const lines = nonNegative(input.nonCodeLines); - if (score <= 0 || lines <= 0) return score; - const maxLines = constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT"); - return lines <= maxLines ? score : score * (maxLines / lines); -} - -function applyNonCodeCapToTotal( - totalTokenScore: number, - input: Pick, - cappedNonCodeTokenScore: number, -): number { - const total = nonNegative(totalTokenScore); - const nonCodeTokenScore = nonNegative(input.nonCodeTokenScore); - if (nonCodeTokenScore <= 0 || cappedNonCodeTokenScore >= nonCodeTokenScore) return total; - return Math.max(0, total - (nonCodeTokenScore - cappedNonCodeTokenScore)); -} - -// Single source of truth (#812): the fallback for any constant is ALWAYS DEFAULT_SCORING_CONSTANTS — never -// a duplicated literal at the call site. The live `constants` (snapshot.constants, which already merges -// DEFAULT_SCORING_CONSTANTS with parsed upstream values) wins when present; otherwise the declared default -// is used. This removes the duplicate-source-of-truth drift surface without changing any value (every -// former call-site literal already matched its DEFAULT_SCORING_CONSTANTS entry). -function constant(constants: Record, key: string): number { - const value = constants[key]; - if (typeof value === "number" && Number.isFinite(value)) return value; - const fallback = DEFAULT_SCORING_CONSTANTS[key]; - /* v8 ignore next -- defensive: every recognized key is in DEFAULT_SCORING_CONSTANTS; this guards typos/forward-compat. */ - return typeof fallback === "number" && Number.isFinite(fallback) ? fallback : 0; -} - -/** - * Resolve a repo's time-decay curve: each parameter is the repo's per-repo override (from the registry's - * `scoring.time_decay`) when present, else the global default constant from the live scoring snapshot. - * Mirrors upstream's `resolve_time_decay` (RepoTimeDecayConfig overlaid on the module constants). - * - * Parity note (#1320): upstream coerces ONLY `grace_period_hours` to an integer - * (`grace_period_hours=int(pick(...))`) while the three curve params stay floats. A maintainer may legally - * configure a fractional grace (upstream validates `0 <= grace_period_hours <= 168`), so the resolved grace - * must be truncated toward zero to match the validator — otherwise a PR aged between `trunc(grace)` and - * `grace` is treated as fresh in the preview but already decaying upstream. - */ -export function resolveTimeDecay( - constants: Record, - overrides?: RepoTimeDecayOverrides | null, -): { gracePeriodHours: number; sigmoidMidpointDays: number; sigmoidSteepness: number; minMultiplier: number } { - return { - gracePeriodHours: clampGracePeriodHours(Math.trunc(pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS")))), - sigmoidMidpointDays: pickOverride(overrides?.sigmoidMidpointDays, constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT")), - sigmoidSteepness: pickOverride(overrides?.sigmoidSteepness, constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR")), - minMultiplier: clampMinMultiplier(pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER"))), - }; -} - -// Documented bound (see the parity note above): upstream validates 0 <= grace_period_hours <= 168. The -// override reaches this resolver via a bare Number.isFinite check (registry/normalize.ts's parseTimeDecayOverrides), -// so an out-of-band value (negative, or beyond a week) would otherwise apply verbatim. -const GRACE_PERIOD_HOURS_MIN = 0; -const GRACE_PERIOD_HOURS_MAX = 168; - -function clampGracePeriodHours(value: number): number { - return clamp(value, GRACE_PERIOD_HOURS_MIN, GRACE_PERIOD_HOURS_MAX); -} - -// minMultiplier is the sigmoid's floor (calculateTimeDecay: Math.max(sigmoid, minMultiplier)), and the -// sigmoid itself is always in (0, 1). A per-repo override above 1 would floor every aged PR ABOVE a fresh -// PR's multiplier -- inverting time decay into an age bonus -- and a negative override applied verbatim -// today has no floor semantics at all. Bound to the sigmoid's own range so the floor invariant always holds. -const MIN_MULTIPLIER_MIN = 0; -const MIN_MULTIPLIER_MAX = 1; - -function clampMinMultiplier(value: number): number { - return clamp(value, MIN_MULTIPLIER_MIN, MIN_MULTIPLIER_MAX); -} - -function pickOverride(value: number | null | undefined, fallback: number): number { - return typeof value === "number" && Number.isFinite(value) ? value : fallback; -} - -/** - * Upstream gittensor's sigmoid time-decay multiplier (#703), ported verbatim from the validator's - * `calculate_time_decay` (gittensor/validator/utils/datetime_utils.py): for the first grace-period hours the - * multiplier is exactly 1.0 (hard cutoff); after that it follows a logistic on days-since-merge centred at - * the sigmoid midpoint (50% at that point), floored at the minimum multiplier. The curve params are - * resolved PER-REPO (overrides ?? snapshot defaults), so each maintainer's registry hyperparameters apply. - * Pure + deterministic. - */ -export function calculateTimeDecay(prAgeHours: number, constants: Record, overrides?: RepoTimeDecayOverrides | null): number { - const { gracePeriodHours, sigmoidMidpointDays, sigmoidSteepness, minMultiplier } = resolveTimeDecay(constants, overrides); - if (!Number.isFinite(prAgeHours) || prAgeHours < gracePeriodHours) return 1; - const days = prAgeHours / 24; - const sigmoid = 1 / (1 + Math.exp(sigmoidSteepness * (days - sigmoidMidpointDays))); - return Math.max(sigmoid, minMultiplier); -} - -function saturationScore(sourceTokenScore: number, totalTokenScore: number, constants: Record): number { - // SRC_TOK_SATURATION_SCALE is per-repo overridable only within [10, 500] upstream; a snapshot value outside - // that band (a bad override, a parse glitch) would otherwise distort the saturation curve — at scale 1 the - // component saturates almost immediately, well above the documented floor. Clamp to the documented range so - // the curve stays within upstream bounds (the prior Math.max(...,1) only guarded the divide-by-zero edge). - const scale = clampSaturationScale(constant(constants, "SRC_TOK_SATURATION_SCALE")); - return ( - constant(constants, "MERGED_PR_BASE_SCORE") * (1 - Math.exp(-sourceTokenScore / scale)) + - saturationContributionBonus(totalTokenScore, constants) - ); -} - -function saturationContributionBonus(totalTokenScore: number, constants: Record): number { - return contributionBonusRamp(totalTokenScore, constants); -} - -// Shared contribution-bonus ramp used by both scoring models so the saturation -// and density bonuses cannot drift: clamp(totalTokenScore / FULL_BONUS, 0, 1) -// scaled by MAX_CONTRIBUTION_BONUS (upstream default 5; single-sourced in model.ts, see #807/#812). -function contributionBonusRamp(totalTokenScore: number, constants: Record): number { - return ( - clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS"), 0, 1) * - constant(constants, "MAX_CONTRIBUTION_BONUS") - ); -} - -function nonNegative(value: number | undefined): number { - /* v8 ignore next -- API schemas and local scorers normalize numeric preview inputs before this defensive fallback. */ - return Number.isFinite(value) ? Math.max(0, value ?? 0) : 0; -} - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - -// Bounds documented by upstream for the two repo-configurable scoring inputs the preview consumes directly. -const FIXED_BASE_SCORE_MIN = 0; -const FIXED_BASE_SCORE_MAX = 100; -const SRC_TOK_SATURATION_SCALE_MIN = 10; -const SRC_TOK_SATURATION_SCALE_MAX = 500; - -// A repo's fixed_base_score override forces base_score to a constant within [0, 100]. The value reaches the -// preview unbounded above — the API schema only enforces `.min(0)` and registry normalization accepts any -// finite number — so a misconfigured 150 would otherwise mint a base score above the model ceiling. Clamp to -// the documented range; a non-finite/absent value falls through to the token-derived base score. -function clampFixedBaseScore(value: number | null | undefined): number | undefined { - return Number.isFinite(value) ? clamp(value as number, FIXED_BASE_SCORE_MIN, FIXED_BASE_SCORE_MAX) : undefined; -} - -function clampSaturationScale(value: number): number { - return clamp(value, SRC_TOK_SATURATION_SCALE_MIN, SRC_TOK_SATURATION_SCALE_MAX); -} - -function roundScore(value: number): number { - return Math.round(value * 10000) / 10000; -} +// Deterministic score-preview builder, extracted to `@jsonbored/gittensory-engine` (#2282) so the miner can +// run the same preview locally. This file is a thin re-export shim; the implementation lives at +// packages/gittensory-engine/src/scoring/preview.ts (imported via relative source path, not the published +// package, to match this repo's existing engine-consumption convention — see e.g. +// test/unit/ai-policy-map.test.ts — and to avoid depending on the engine package's built `dist/` output, +// which is not guaranteed to exist yet when `typecheck`/`test:coverage` run in CI). +export * from "../../packages/gittensory-engine/src/scoring/preview"; From 0cf9ef306e370cba9157ab6e679e0109e6c4a01d Mon Sep 17 00:00:00 2001 From: jimcody1995 Date: Mon, 6 Jul 2026 18:35:49 +0200 Subject: [PATCH 2/2] test(scoring): cover engine extraction paths for codecov patch (#2282) Add barrel, branch-eligibility, wildcard, draft, and timestamp edge-case tests so the scoring engine extraction meets the 99% patch coverage gate. Co-authored-by: Cursor --- test/unit/pending-pr-scenarios.test.ts | 45 +++++++++++++++++++++++++ test/unit/scoring-engine-barrel.test.ts | 18 ++++++++++ test/unit/scoring.test.ts | 36 +++++++++++++++++++- 3 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 test/unit/scoring-engine-barrel.test.ts diff --git a/test/unit/pending-pr-scenarios.test.ts b/test/unit/pending-pr-scenarios.test.ts index 5aea1593de..7c0f16da0b 100644 --- a/test/unit/pending-pr-scenarios.test.ts +++ b/test/unit/pending-pr-scenarios.test.ts @@ -403,6 +403,51 @@ describe("pending PR scenario detection", () => { ).toMatchObject({ pendingMergedPrCount: 1, pendingScenarioObserved: true, scenarioNotes: ["observed"] }); }); + it("treats GitHub draft flags as draft work even without title/label heuristics", () => { + const classified = classifyOpenPullRequest({ + pr: pr({ number: 92, title: "Ready change", labels: [], isDraft: true }), + roleContext: outsideContributorRole, + reviews: [approvedReview(92)], + checks: [], + }); + expect(classified.classification).toBe("draft"); + }); + + it("classifies PRs when review/check maps are omitted and timestamps are missing or invalid", () => { + const detection = detectPendingPrScenario({ + login: "miner-a", + repoFullName: "entrius/allways-ui", + pullRequests: [pr({ number: 88 })], + roleContext: outsideContributorRole, + openPrCount: 1, + }); + expect(detection).toBeNull(); + expect( + classifyOpenPullRequest({ + pr: pr({ number: 89, updatedAt: undefined, createdAt: daysAgo(2) }), + roleContext: outsideContributorRole, + reviews: [approvedReview(89)], + checks: [], + }).classification, + ).toBe("merge_ready"); + expect( + classifyOpenPullRequest({ + pr: pr({ number: 90, updatedAt: undefined, createdAt: undefined }), + roleContext: outsideContributorRole, + reviews: [approvedReview(90)], + checks: [], + }).classification, + ).toBe("stale_likely_close"); + expect( + classifyOpenPullRequest({ + pr: pr({ number: 91, updatedAt: "not-a-date", createdAt: "also-invalid" }), + roleContext: outsideContributorRole, + reviews: [approvedReview(91)], + checks: [], + }).classification, + ).toBe("stale_likely_close"); + }); + it("loads cached reviews and checks for contributor open PRs", async () => { const env = {} as Env; vi.spyOn(repositories, "listPullRequestReviews").mockResolvedValue([approvedReview(70)]); diff --git a/test/unit/scoring-engine-barrel.test.ts b/test/unit/scoring-engine-barrel.test.ts new file mode 100644 index 0000000000..0a72714154 --- /dev/null +++ b/test/unit/scoring-engine-barrel.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { DEFAULT_SCORING_CONSTANTS, parsePythonNumberConstants } from "../../packages/gittensory-engine/src/scoring/model"; +import { buildScorePreview } from "../../packages/gittensory-engine/src/scoring/preview"; +import { classifyOpenPullRequest } from "../../packages/gittensory-engine/src/scoring/pending-pr-scenarios"; + +describe("gittensory-engine scoring barrel exports (#2282)", () => { + it("re-exports scoring namespaces from the package barrel", async () => { + const barrel = await import("../../packages/gittensory-engine/src/index"); + expect(typeof barrel.scoringPreview.buildScorePreview).toBe("function"); + expect(typeof barrel.scoringModel.parsePythonNumberConstants).toBe("function"); + expect(typeof barrel.scoringPendingPrScenarios.classifyOpenPullRequest).toBe("function"); + expect(barrel.scoringModel.DEFAULT_SCORING_CONSTANTS).toEqual(DEFAULT_SCORING_CONSTANTS); + expect(typeof barrel.scoringPreview.buildScorePreview).toBe(typeof buildScorePreview); + expect(typeof barrel.scoringModel.parsePythonNumberConstants).toBe(typeof parsePythonNumberConstants); + expect(typeof barrel.scoringPendingPrScenarios.classifyOpenPullRequest).toBe(typeof classifyOpenPullRequest); + }); +}); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 24a6d63c7e..41d667a2d4 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -908,6 +908,10 @@ NOVELTY_BONUS_SCALAR = 3 // Regex metacharacters in a literal key stay literal: `.` matches only a dot, not any char. expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1.0"])).toBe(1.1); expect(labelMultiplierFor({ "v1.0": 1.1 }, ["v1x0"])).toBe(1); + // `**/` counts as one wildcard group and matches across path-like label segments. + expect(labelMultiplierFor({ "**/bug": 1.45 }, ["feature/bug"])).toBe(1.45); + expect(labelMultiplierFor({ "public/**/*.json": 1.2 }, ["public/release/config.json"])).toBe(1.2); + expect(labelMultiplierFor({ "**bug": 1.35 }, ["feature-bug"])).toBe(1.35); // When several patterns match, the highest multiplier wins (mirrors upstream `max(...)`). expect(labelMultiplierFor({ "kind/*": 1.1, "*/bug": 1.6 }, ["kind/bug"])).toBe(1.6); // Literal keys are unchanged — exact match, parity-preserving for every existing config. @@ -990,7 +994,37 @@ NOVELTY_BONUS_SCALAR = 3 expect(notRequired.branchEligibility).toMatchObject({ required: false, status: "not_required", evidence: "provided", source: "user_supplied" }); expect(notRequired.scoreEstimate.issueMultiplier).toBe(1); expect(notRequired.blockedBy.map((blocker) => blocker.code)).not.toContain("branch_eligibility_missing"); - expect(JSON.stringify({ eligible, ineligible, missing, unknown, implicitUnknown, notRequired })).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); + const userSuppliedBranch = buildScorePreview({ + repo, + snapshot, + input: { + ...baseInput, + branchEligibility: { status: "not_required", source: "user_supplied" } as never, + }, + }); + const unconfirmedGithubBranch = buildScorePreview({ + repo, + snapshot, + input: { + ...baseInput, + branchEligibility: { status: "not_required", source: "github_metadata" } as never, + }, + }); + expect(userSuppliedBranch.linkedIssueMultiplier.reason).toMatch(/user-supplied/i); + expect(userSuppliedBranch.scoreEstimate.issueMultiplier).toBe(1); + expect(unconfirmedGithubBranch.linkedIssueMultiplier.reason).toMatch(/not confirmed/i); + expect(unconfirmedGithubBranch.scoreEstimate.issueMultiplier).toBe(1); + const staleEligibleBranch = buildScorePreview({ + repo, + snapshot, + input: { + ...baseInput, + branchEligibility: { status: "eligible", source: "github_metadata", stale: true }, + }, + }); + expect(staleEligibleBranch.linkedIssueMultiplier.reason).toMatch(/stale/i); + expect(staleEligibleBranch.scoreEstimate.issueMultiplier).toBe(1); + expect(JSON.stringify({ eligible, ineligible, missing, unknown, implicitUnknown, notRequired, userSuppliedBranch, unconfirmedGithubBranch, staleEligibleBranch })).not.toMatch(/guaranteed payout|wallet|hotkey|farming/i); }); it("requires solved-by-PR validation before applying the standard linked-issue multiplier", () => {