diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index e54b0c8e7b..45a810efec 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -4299,6 +4299,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -4306,6 +4309,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] @@ -4551,6 +4555,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -4558,6 +4565,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] @@ -4803,6 +4811,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -4810,6 +4821,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] @@ -5055,6 +5067,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -5062,6 +5077,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] @@ -5307,6 +5323,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -5314,6 +5333,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] @@ -6207,6 +6227,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -6214,6 +6237,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] @@ -6455,6 +6479,9 @@ }, "credibilityObserved": { "type": "number" + }, + "reviewCollateralMultiplier": { + "type": "number" } }, "required": [ @@ -6462,6 +6489,7 @@ "openPrThreshold", "openPrCount", "collateralFraction", + "reviewCollateralMultiplier", "credibilityFloor", "credibilityObserved" ] diff --git a/src/api/routes.ts b/src/api/routes.ts index 9c6e6ad66e..7077b280fb 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -467,6 +467,7 @@ const localBranchScorerSchema = z sourceLines: z.number().min(0).optional(), testTokenScore: z.number().min(0).optional(), nonCodeTokenScore: z.number().min(0).optional(), + nonCodeLines: z.number().min(0).optional(), warnings: z.array(z.string().max(MAX_LOCAL_SCORER_WARNING_CHARS)).max(MAX_LOCAL_SCORER_WARNING_COUNT).optional(), }) .strict(); @@ -544,6 +545,7 @@ const scorePreviewSchema = z.object({ sourceLines: z.number().min(0).optional(), testTokenScore: z.number().min(0).optional(), nonCodeTokenScore: z.number().min(0).optional(), + nonCodeLines: z.number().min(0).optional(), existingContributorTokenScore: z.number().min(0).optional(), prAgeHours: z.number().min(0).optional(), openPrCount: z.number().int().min(0).optional(), diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 49e78b43de..66c09f8a20 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1262,6 +1262,7 @@ const ScoreGatesSchema = z.object({ openPrThreshold: z.number(), openPrCount: z.number(), collateralFraction: z.number(), + reviewCollateralMultiplier: z.number(), credibilityFloor: z.number(), credibilityObserved: z.number(), }); diff --git a/src/registry/normalize.ts b/src/registry/normalize.ts index 9a10edd38b..fc30656332 100644 --- a/src/registry/normalize.ts +++ b/src/registry/normalize.ts @@ -1,9 +1,8 @@ +import { DEFAULT_ISSUE_DISCOVERY_SHARE } from "../scoring/model"; import type { JsonValue, RegistryRepoConfig, RegistrySnapshot, RepoTimeDecayOverrides } from "../types"; type RawRepoConfig = Record; -const DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5; - export function normalizeRegistryPayload(payload: unknown, source: RegistrySnapshot["source"], fetchedAt: string): RegistrySnapshot { const repos = extractRepoEntries(payload).map(([repo, config]) => normalizeRepo(repo, config)); const totalEmissionShare = repos.reduce((sum, repo) => sum + repo.emissionShare, 0); diff --git a/src/scoring/model.ts b/src/scoring/model.ts index 5314dad384..dc5bbd8d7d 100644 --- a/src/scoring/model.ts +++ b/src/scoring/model.ts @@ -7,6 +7,8 @@ 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 @@ -34,6 +36,9 @@ export const DEFAULT_SCORING_CONSTANTS: Record = { 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, diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index e1630fc77e..19e0d418fd 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -14,6 +14,8 @@ export type ScorePreviewInput = { 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). */ @@ -171,7 +173,10 @@ export type ScorePreviewResult = { 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; @@ -298,7 +303,10 @@ function computeScoreCore( // 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 cappedNonCodeTokenScore = applyNonCodeLineCap(input, constants); + const totalTokenScore = nonNegative( + input.totalTokenScore ?? sourceTokenScore + testFileWeight * nonNegative(input.testTokenScore) + cappedNonCodeTokenScore, + ); const sourceLines = Math.max(1, nonNegative(input.sourceLines ?? sourceTokenScore)); const fixedBaseScore = input.fixedBaseScore ?? config?.fixedBaseScore ?? undefined; const rawDensity = sourceTokenScore / sourceLines; @@ -325,7 +333,13 @@ function computeScoreCore( const credibilityFloor = constant(constants, "MIN_CREDIBILITY", 0.8); const credibilityMultiplier = credibilityObserved >= credibilityFloor ? 1 : credibilityObserved / credibilityFloor; const changesRequestedCount = nonNegative(input.changesRequestedCount); - const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * constant(constants, "REVIEW_PENALTY_RATE", 0.15), 0, 1); + const reviewPenaltyRate = constant(constants, "REVIEW_PENALTY_RATE", 0.15); + const reviewPenaltyMultiplier = clamp(1 - changesRequestedCount * reviewPenaltyRate, 0, 1); + const reviewCollateralMultiplier = Math.min( + constant(constants, "MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER", 2.0), + 1 + changesRequestedCount * reviewPenaltyRate, + ); + const openPrCollateralPercent = constant(constants, "OPEN_PR_COLLATERAL_PERCENT", 0.2); 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. @@ -381,7 +395,8 @@ function computeScoreCore( baseTokenGatePassed, openPrThreshold, openPrCount, - collateralFraction: constant(constants, "OPEN_PR_COLLATERAL_PERCENT", 0.2), + reviewCollateralMultiplier: roundScore(reviewCollateralMultiplier), + collateralFraction: roundScore(openPrCollateralPercent * reviewCollateralMultiplier), credibilityFloor, credibilityObserved, openIssueThreshold, @@ -955,6 +970,14 @@ function inferCredibility(evidence?: ContributorEvidenceRecord | null): number { 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", 300); + return lines <= maxLines ? score : score * (maxLines / lines); +} + function constant(constants: Record, key: string, fallback: number): number { const value = constants[key]; return typeof value === "number" && Number.isFinite(value) ? value : fallback; diff --git a/src/signals/local-branch.ts b/src/signals/local-branch.ts index e4ee531f4b..98c5cde15a 100644 --- a/src/signals/local-branch.ts +++ b/src/signals/local-branch.ts @@ -471,6 +471,7 @@ function buildLocalScoreInput(args: { sourceLines: scorer?.sourceLines ?? Math.max(1, sourceLineCount || args.changedLineCount || 1), testTokenScore: scorer?.testTokenScore ?? testLineCount, nonCodeTokenScore: scorer?.nonCodeTokenScore ?? nonCodeLineCount, + nonCodeLines: nonCodeLineCount, openPrCount: args.outcomeHistory.totals.openPullRequests, openIssueCount: args.repoOutcome?.openIssues ?? args.outcomeHistory.totals.openIssues, credibility: args.repoOutcome?.credibility ?? args.outcomeHistory.totals.credibility, diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index ae1b530fe5..5ea072f2b1 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getRepository, upsertRepositoryFromGitHub } from "../../src/db/repositories"; import { normalizeRegistryPayload } from "../../src/registry/normalize"; +import { DEFAULT_ISSUE_DISCOVERY_SHARE } from "../../src/scoring/model"; import { getLatestRegistrySnapshot, persistRegistrySnapshot, refreshRegistry } from "../../src/registry/sync"; import { createTestEnv } from "../helpers/d1"; @@ -122,8 +123,15 @@ describe("registry normalization", () => { trustedLabelPipeline: true, }); expect(fromObjectMap.repositories.map((repo) => repo.repo)).toEqual(["JSONbored/gittensory"]); + expect(fromObjectMap.repositories[0]).toMatchObject({ + repo: "JSONbored/gittensory", + issueDiscoveryShare: DEFAULT_ISSUE_DISCOVERY_SHARE, + }); expect(fromArray.repositories.map((repo) => repo.repo)).toEqual(["JSONbored/gittensory", "bad/numbers"]); - expect(fromArray.repositories.find((repo) => repo.repo === "bad/numbers")).toMatchObject({ emissionShare: 0, issueDiscoveryShare: 0.5 }); + expect(fromArray.repositories.find((repo) => repo.repo === "bad/numbers")).toMatchObject({ + emissionShare: 0, + issueDiscoveryShare: DEFAULT_ISSUE_DISCOVERY_SHARE, + }); expect(empty.repoCount).toBe(0); }); diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index ebc8df6078..139972c2e8 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getLatestScoringModelSnapshot, listUpstreamDriftReports, persistScoringModelSnapshot } from "../../src/db/repositories"; -import { DEFAULT_SCORING_CONSTANTS, detectActiveModel, findUnmodeledUpstreamConstants, getOrCreateScoringModelSnapshot, isTimeDecayEnabled, parsePythonNumberConstants, refreshScoringModelSnapshot, SCORING_SNAPSHOT_STALE_MS, scoringSnapshotStalenessWarning } from "../../src/scoring/model"; +import { DEFAULT_ISSUE_DISCOVERY_SHARE, DEFAULT_SCORING_CONSTANTS, detectActiveModel, findUnmodeledUpstreamConstants, getOrCreateScoringModelSnapshot, isTimeDecayEnabled, parsePythonNumberConstants, refreshScoringModelSnapshot, SCORING_SNAPSHOT_STALE_MS, scoringSnapshotStalenessWarning } from "../../src/scoring/model"; import { buildScorePreview, calculateTimeDecay, makeScorePreviewRecord, resolveTimeDecay } from "../../src/scoring/preview"; import { unmodeledScoringConstantsFingerprint } from "../../src/upstream/unmodeled-scoring-drift"; import type { ScorePreviewInput } from "../../src/scoring/preview"; @@ -168,6 +168,126 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 ).not.toContain("ISSUES_TREASURY_EMISSION_SHARE"); }); + it("models upstream review-collateral, non-code line cap, and issue-discovery defaults (#809)", () => { + expect(DEFAULT_SCORING_CONSTANTS).toMatchObject({ + MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER: 2.0, + MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300, + DEFAULT_ISSUE_DISCOVERY_SHARE, + }); + expect(DEFAULT_ISSUE_DISCOVERY_SHARE).toBe(0.5); + expect( + findUnmodeledUpstreamConstants(` +MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER = 2.0 +MAX_LINES_SCORED_FOR_NON_CODE_EXT = 300 +DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5 +NOVELTY_BONUS_SCALAR = 3 +`), + ).toEqual(["NOVELTY_BONUS_SCALAR"]); + + const collateralPreview = buildScorePreview({ + repo, + snapshot: { + ...snapshot, + constants: { + ...snapshot.constants, + MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER: 2.0, + OPEN_PR_COLLATERAL_PERCENT: 0.2, + REVIEW_PENALTY_RATE: 0.15, + }, + }, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 60, + sourceLines: 50, + changesRequestedCount: 4, + openPrCount: 1, + credibility: 1, + }, + }); + expect(collateralPreview.gates.reviewCollateralMultiplier).toBe(1.6); + expect(collateralPreview.gates.collateralFraction).toBeCloseTo(0.32, 5); + expect(collateralPreview.scoreEstimate.reviewPenaltyMultiplier).toBe(0.4); + + const cappedCollateral = buildScorePreview({ + repo, + snapshot: { + ...snapshot, + constants: { + ...snapshot.constants, + MAX_OPEN_PR_REVIEW_COLLATERAL_MULTIPLIER: 2.0, + REVIEW_PENALTY_RATE: 0.15, + }, + }, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 60, + totalTokenScore: 60, + sourceLines: 50, + changesRequestedCount: 10, + openPrCount: 1, + credibility: 1, + }, + }); + expect(cappedCollateral.gates.reviewCollateralMultiplier).toBe(2); + expect(cappedCollateral.gates.collateralFraction).toBeCloseTo(0.4, 5); + + const uncappedNonCode = buildScorePreview({ + repo, + snapshot: { + ...snapshot, + constants: { ...snapshot.constants, MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300, CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, MAX_CONTRIBUTION_BONUS: 25 }, + }, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 100, + testTokenScore: 0, + nonCodeTokenScore: 600, + sourceLines: 100, + openPrCount: 0, + credibility: 1, + }, + }); + const cappedNonCode = buildScorePreview({ + repo, + snapshot: { + ...snapshot, + constants: { ...snapshot.constants, MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300, CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, MAX_CONTRIBUTION_BONUS: 25 }, + }, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 100, + testTokenScore: 0, + nonCodeTokenScore: 600, + nonCodeLines: 600, + sourceLines: 100, + openPrCount: 0, + credibility: 1, + }, + }); + expect(uncappedNonCode.scoreEstimate.contributionBonus).toBeGreaterThan(cappedNonCode.scoreEstimate.contributionBonus); + expect(cappedNonCode.scoreEstimate.contributionBonus).toBeCloseTo( + buildScorePreview({ + repo, + snapshot: { + ...snapshot, + constants: { ...snapshot.constants, MAX_LINES_SCORED_FOR_NON_CODE_EXT: 300, CONTRIBUTION_SCORE_FOR_FULL_BONUS: 1500, MAX_CONTRIBUTION_BONUS: 25 }, + }, + input: { + repoFullName: repo.fullName, + sourceTokenScore: 100, + testTokenScore: 0, + nonCodeTokenScore: 300, + nonCodeLines: 300, + sourceLines: 100, + openPrCount: 0, + credibility: 1, + }, + }).scoreEstimate.contributionBonus, + 5, + ); + }); + it("detects the active model from fetched constants before default fallback constants", async () => { const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "token" }); const fetchedUrls: string[] = [];