Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/scoring/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export const DEFAULT_SCORING_CONSTANTS: Record<string, number> = {
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,
Expand Down Expand Up @@ -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
Expand Down
74 changes: 45 additions & 29 deletions src/scoring/preview.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -295,28 +296,35 @@ 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 =
input.totalTokenScore === undefined ? nonNegative(derivedTotalTokenScore) : applyNonCodeCapToTotal(input.totalTokenScore, input, cappedNonCodeTokenScore);
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
Expand All @@ -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.
Expand Down Expand Up @@ -911,8 +919,8 @@ function uniquePositiveInts(values: number[]): number[] {
}

function selectIssueMultiplier(mode: "none" | "standard" | "maintainer", constants: Record<string, number>): 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;
}

Expand Down Expand Up @@ -977,7 +985,7 @@ function applyNonCodeLineCap(input: Pick<ScorePreviewInput, "nonCodeTokenScore"
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", 300);
const maxLines = constant(constants, "MAX_LINES_SCORED_FOR_NON_CODE_EXT");
return lines <= maxLines ? score : score * (maxLines / lines);
}

Expand All @@ -992,9 +1000,17 @@ function applyNonCodeCapToTotal(
return Math.max(0, total - (nonCodeTokenScore - cappedNonCodeTokenScore));
}

function constant(constants: Record<string, number>, 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<string, number>, 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;
}

/**
Expand All @@ -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")),
};
}

Expand All @@ -1035,9 +1051,9 @@ export function calculateTimeDecay(prAgeHours: number, constants: Record<string,
}

function saturationScore(sourceTokenScore: number, totalTokenScore: number, constants: Record<string, number>): 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)
);
}
Expand All @@ -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<string, number>): 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")
);
}

Expand Down
44 changes: 44 additions & 0 deletions test/unit/scoring.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
});
});
});
Loading