diff --git a/src/scoring/model.ts b/src/scoring/model.ts index a6297a9970..2d48c84ab1 100644 --- a/src/scoring/model.ts +++ b/src/scoring/model.ts @@ -46,6 +46,14 @@ export const DEFAULT_SCORING_CONSTANTS: Record = { 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, @@ -81,7 +89,11 @@ async function fetchUpstreamRefSha(upstream: { repo: string; ref: string }, toke } } -const SCORING_CONSTANT_NAMES = new Set([...Object.keys(DEFAULT_SCORING_CONSTANTS), "MIN_TOKEN_SCORE_FOR_BASE_SCORE", "MAX_CODE_DENSITY_MULTIPLIER"]); +// 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 diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index 94e5fa2c49..7e495c32f2 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -1,4 +1,5 @@ import type { ContributorEvidenceRecord, JsonValue, RepositoryRecord, RepoTimeDecayOverrides, ScoringModelSnapshotRecord, ScorePreviewRecord } from "../types"; +import { DEFAULT_SCORING_CONSTANTS } from "./model"; import { nowIso } from "../utils/json"; export type ScorePreviewInput = { @@ -295,13 +296,14 @@ function computeScoreCore( 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", 0.9); + 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. - const testFileWeight = constant(constants, "TEST_FILE_CONTRIBUTION_WEIGHT", 0.05); + // 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 = @@ -309,14 +311,20 @@ function computeScoreCore( const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore)); const fixedBaseScore = input.fixedBaseScore ?? config?.fixedBaseScore ?? undefined; const rawDensity = sourceTokenScore / sourceLines; - const densityMultiplier = clamp(rawDensity || 0, 0, constant(constants, "MAX_CODE_DENSITY_MULTIPLIER", 1.15)); - const densityTokenGatePassed = sourceTokenScore >= constant(constants, "MIN_TOKEN_SCORE_FOR_BASE_SCORE", 5); + // 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", 25) * densityMultiplier : 0) + densityContributionBonus; + (densityTokenGatePassed ? constant(constants, "MERGED_PR_BASE_SCORE") * densityMultiplier : 0) + densityContributionBonus; const baseScore = fixedBaseScore !== undefined ? fixedBaseScore @@ -329,32 +337,32 @@ function computeScoreCore( 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", 0.8); + 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", 0.15); + 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", 2.0), + constant(constants, "MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER"), 1 + changesRequestedCount * reviewPenaltyRate, ); - const openPrCollateralPercent = constant(constants, "OPEN_PR_COLLATERAL_PERCENT", 0.2); + 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", 30), - constant(constants, "EXCESSIVE_PR_PENALTY_BASE_THRESHOLD", 2) + - Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE", 300)), + 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", 30), - constant(constants, "OPEN_ISSUE_SPAM_BASE_THRESHOLD", 2) + - Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT", 300)), + 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; // Upstream time-decay (#703): mirrors upstream's `scored.time_decay_multiplier` applied to a PR's score. @@ -911,8 +919,8 @@ function uniquePositiveInts(values: number[]): number[] { } function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constants: Record): number { - if (mode === "maintainer") return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER", 1.66); - if (mode === "standard") return constant(constants, "STANDARD_ISSUE_MULTIPLIER", 1.33); + if (mode === "maintainer") return constant(constants, "MAINTAINER_ISSUE_MULTIPLIER"); + if (mode === "standard") return constant(constants, "STANDARD_ISSUE_MULTIPLIER"); return 1; } @@ -977,7 +985,7 @@ function applyNonCodeLineCap(input: Pick, key: string, fallback: number): number { +// 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]; - return typeof value === "number" && Number.isFinite(value) ? value : fallback; + 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; } /** @@ -1007,10 +1023,10 @@ export function resolveTimeDecay( overrides?: RepoTimeDecayOverrides | null, ): { gracePeriodHours: number; sigmoidMidpointDays: number; sigmoidSteepness: number; minMultiplier: number } { return { - gracePeriodHours: pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS", 12)), - sigmoidMidpointDays: pickOverride(overrides?.sigmoidMidpointDays, constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT", 10)), - sigmoidSteepness: pickOverride(overrides?.sigmoidSteepness, constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR", 0.4)), - minMultiplier: pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER", 0.05)), + gracePeriodHours: 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: pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER")), }; } @@ -1035,9 +1051,9 @@ export function calculateTimeDecay(prAgeHours: number, constants: Record): number { - const scale = Math.max(constant(constants, "SRC_TOK_SATURATION_SCALE", 58), 1); + const scale = Math.max(constant(constants, "SRC_TOK_SATURATION_SCALE"), 1); return ( - constant(constants, "MERGED_PR_BASE_SCORE", 25) * (1 - Math.exp(-sourceTokenScore / scale)) + + constant(constants, "MERGED_PR_BASE_SCORE") * (1 - Math.exp(-sourceTokenScore / scale)) + saturationContributionBonus(totalTokenScore, constants) ); } @@ -1048,11 +1064,11 @@ function saturationContributionBonus(totalTokenScore: number, constants: Record< // 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; see model.ts #807). +// 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", 1500), 0, 1) * - constant(constants, "MAX_CONTRIBUTION_BONUS", 5) + clamp(totalTokenScore / constant(constants, "CONTRIBUTION_SCORE_FOR_FULL_BONUS"), 0, 1) * + constant(constants, "MAX_CONTRIBUTION_BONUS") ); } diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 0cfc09e2d7..d25ecc7bf1 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -1520,4 +1520,48 @@ NOVELTY_BONUS_SCALAR = 3 expect(buildScorePreview({ repo: repoDefault, snapshot, input }).scoreEstimate.timeDecayMultiplier).toBeLessThan(1); }); }); + + describe("single-source fallbacks (#812)", () => { + it("the density-era fallback constants are declared in DEFAULT_SCORING_CONSTANTS (no longer silent literals)", () => { + expect(DEFAULT_SCORING_CONSTANTS.MIN_TOKEN_SCORE_FOR_BASE_SCORE).toBe(5); + expect(DEFAULT_SCORING_CONSTANTS.MAX_CODE_DENSITY_MULTIPLIER).toBe(1.15); + expect(DEFAULT_SCORING_CONSTANTS.MERGED_PR_BASE_SCORE).toBe(25); + expect(DEFAULT_SCORING_CONSTANTS.SRC_TOK_SATURATION_SCALE).toBe(58); + }); + + it("a preview with an empty constants object resolves every fallback from DEFAULT_SCORING_CONSTANTS, matching an explicit-defaults preview", () => { + const input: ScorePreviewInput = { repoFullName: repo.fullName, sourceTokenScore: 60, totalTokenScore: 90, sourceLines: 50, openPrCount: 0, credibility: 1 }; + const emptyConstants = buildScorePreview({ + repo, + snapshot: { ...snapshot, activeModel: "pending_saturation_model" as const, constants: {} }, + input, + }); + const explicitDefaults = buildScorePreview({ + repo, + snapshot: { ...snapshot, activeModel: "pending_saturation_model" as const, constants: { ...DEFAULT_SCORING_CONSTANTS } }, + input, + }); + expect(emptyConstants.scoreEstimate).toEqual(explicitDefaults.scoreEstimate); + expect(emptyConstants.gates).toEqual(explicitDefaults.gates); + expect(emptyConstants.effectiveEstimatedScore).toBe(explicitDefaults.effectiveEstimatedScore); + expect(emptyConstants.effectiveEstimatedScore).toBeGreaterThan(0); + }); + + it("retains the density model branch as a supported activeModel (the #812 'if density is dead' condition is false)", () => { + const densityPreview = buildScorePreview({ + repo, + snapshot: { ...snapshot, activeModel: "current_density_model" as const, constants: { ...DEFAULT_SCORING_CONSTANTS } }, + input: { repoFullName: repo.fullName, sourceTokenScore: 60, totalTokenScore: 90, sourceLines: 50, openPrCount: 0, credibility: 1 }, + }); + expect(densityPreview.scoreEstimate.densityMultiplier).toBeGreaterThan(0); + expect(densityPreview.scoreEstimate.densityMultiplier).toBeLessThanOrEqual(DEFAULT_SCORING_CONSTANTS.MAX_CODE_DENSITY_MULTIPLIER!); + expect(densityPreview.effectiveEstimatedScore).toBeGreaterThan(0); + }); + + it("treats density-era constants as modeled (not unmodeled drift) once single-sourced in DEFAULT_SCORING_CONSTANTS", () => { + expect( + findUnmodeledUpstreamConstants("MIN_TOKEN_SCORE_FOR_BASE_SCORE = 5\nMAX_CODE_DENSITY_MULTIPLIER = 1.15\n"), + ).toEqual([]); + }); + }); });