Skip to content
Merged
1 change: 1 addition & 0 deletions src/scenarios/scenario-summary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ const OPTION_NEXT_STEPS: Record<OpenPrStrategyOption, string> = {
const PUBLIC_BLOCKER_TEXT: Partial<Record<ScoreGateBlocker["code"], string>> = {
base_token_gate: "The change size may be too small to meet the contribution threshold.",
open_pr_threshold: "Too many concurrent open PRs exist; landing or closing some would help.",
open_issue_threshold: "Too many open issues exist; closing excess issues would help.",
credibility_floor: "Contributor credibility evidence is below the expected floor.",
review_penalty: "Review churn history may reduce the contribution quality signal.",
metadata_only: "Only metadata signals are available; detailed analysis requires full context.",
Expand Down
6 changes: 6 additions & 0 deletions src/scoring/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,24 @@ export const DEFAULT_SCORING_CONSTANTS: Record<string, number> = {
// 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,
Expand Down
52 changes: 48 additions & 4 deletions src/scoring/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type ScorePreviewInput = {
nonCodeTokenScore?: 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;
credibility?: number | undefined;
changesRequestedCount?: number | undefined;
fixedBaseScore?: number | undefined;
Expand Down Expand Up @@ -101,6 +103,7 @@ export type ScoreGateBlocker = {
| "inactive_allocation"
| "base_token_gate"
| "open_pr_threshold"
| "open_issue_threshold"
| "credibility_floor"
| "review_penalty"
| "metadata_only"
Expand All @@ -115,7 +118,7 @@ export type ScoreGateBlocker = {
};

export type ScoreGateDelta = {
gate: "open_pr_threshold" | "credibility_floor" | "linked_issue_multiplier";
gate: "open_pr_threshold" | "open_issue_threshold" | "credibility_floor" | "linked_issue_multiplier";
current: string;
projected: string;
explanation: string;
Expand Down Expand Up @@ -157,6 +160,7 @@ export type ScorePreviewResult = {
credibilityMultiplier: number;
reviewPenaltyMultiplier: number;
openPrMultiplier: number;
openIssueMultiplier: number;
/** Upstream sigmoid time-decay multiplier (#703). 1 = no decay (fresh PR, or feature off). */
timeDecayMultiplier: number;
estimatedMergedScore: number;
Expand All @@ -170,6 +174,8 @@ export type ScorePreviewResult = {
collateralFraction: number;
credibilityFloor: number;
credibilityObserved: number;
openIssueThreshold: number;
openIssueCount: number;
};
branchEligibility: BranchEligibilityResult;
effectiveEstimatedScore: number;
Expand Down Expand Up @@ -204,6 +210,7 @@ export function buildScorePreview(args: {
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.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
Expand Down Expand Up @@ -288,7 +295,10 @@ function computeScoreCore(
const directPrSlice = repoSlice * (1 - issueDiscoveryShare);
const issueDiscoverySlice = repoSlice * issueDiscoveryShare;
const sourceTokenScore = nonNegative(input.sourceTokenScore);
const totalTokenScore = nonNegative(input.totalTokenScore ?? sourceTokenScore + nonNegative(input.testTokenScore) + nonNegative(input.nonCodeTokenScore));
// 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", 0.05);
const totalTokenScore = nonNegative(input.totalTokenScore ?? sourceTokenScore + testFileWeight * nonNegative(input.testTokenScore) + nonNegative(input.nonCodeTokenScore));
const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore));
const fixedBaseScore = input.fixedBaseScore ?? config?.fixedBaseScore ?? undefined;
const rawDensity = sourceTokenScore / sourceLines;
Expand Down Expand Up @@ -325,13 +335,22 @@ function computeScoreCore(
Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE", 300)),
);
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)),
);
const openIssueMultiplier = openIssueCount <= openIssueThreshold ? 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 * timeDecayMultiplier,
baseScore * labelMultiplier * issueMultiplier * credibilityMultiplier * reviewPenaltyMultiplier * openPrMultiplier * openIssueMultiplier * timeDecayMultiplier,
);
const pendingSaturationScore = roundScore(saturationBaseScore);
return {
Expand All @@ -352,6 +371,7 @@ function computeScoreCore(
credibilityMultiplier: roundScore(credibilityMultiplier),
reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier),
openPrMultiplier,
openIssueMultiplier,
timeDecayMultiplier: roundScore(timeDecayMultiplier),
estimatedMergedScore,
pendingSaturationScore,
Expand All @@ -364,6 +384,8 @@ function computeScoreCore(
collateralFraction: constant(constants, "OPEN_PR_COLLATERAL_PERCENT", 0.2),
credibilityFloor,
credibilityObserved,
openIssueThreshold,
openIssueCount,
},
};
}
Expand Down Expand Up @@ -424,6 +446,9 @@ function buildScenarioPreviews(
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),
};
return [
Expand Down Expand Up @@ -488,7 +513,7 @@ function buildScenarioPreviews(
: "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, credibility at floor or above, and linked-issue context where applicable.",
"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, and linked-issue context where applicable.",
...(input.scenarioNotes ?? []),
...observedScenarioNotes(input),
], repo),
Expand Down Expand Up @@ -571,6 +596,15 @@ function blockedByFor(input: ScorePreviewInput, repo: RepositoryRecord | null, c
},
]
: []),
...(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.gates.credibilityObserved < core.gates.credibilityFloor
? [
{
Expand Down Expand Up @@ -643,6 +677,16 @@ function buildGateDeltas(current: ScoreCore, scenarios: ScoreScenarioPreview[]):
},
]
: []),
...(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.gates.credibilityObserved !== best.gates.credibilityObserved || current.scoreEstimate.credibilityMultiplier !== best.scoreEstimate.credibilityMultiplier
? [
{
Expand Down
Loading
Loading