diff --git a/packages/loopover-engine/src/local-scorer.ts b/packages/loopover-engine/src/local-scorer.ts index 9c251ad509..d1f6d227d4 100644 --- a/packages/loopover-engine/src/local-scorer.ts +++ b/packages/loopover-engine/src/local-scorer.ts @@ -9,6 +9,7 @@ // moving the large, Node-coupled local-branch.ts; they are structurally identical to local-branch.ts's // definitions. isCodeFile/isTestPath are the same portable classifiers local-branch.ts already delegates to. +import { TEST_FILE_CONTRIBUTION_WEIGHT } from "./scoring/model.js"; import { isCodeFile, isTestPath } from "./signals/test-evidence.js"; export type LocalScorerChangedFile = { @@ -50,8 +51,13 @@ export function computeLocalScorerTokens(input: { changedFiles: LocalScorerChang const files = input.changedFiles.filter((file) => !file.binary); const testTokenScore = files.filter((file) => isTestPath(file.path)).reduce((sum, file) => sum + fileLines(file), 0); const sourceTokenScore = files.filter((file) => isCodeFile(file.path)).reduce((sum, file) => sum + fileLines(file), 0); - const totalTokenScore = files.reduce((sum, file) => sum + fileLines(file), 0); - const nonCodeTokenScore = Math.max(0, totalTokenScore - sourceTokenScore - testTokenScore); + const rawLineTotal = files.reduce((sum, file) => sum + fileLines(file), 0); + const nonCodeTokenScore = Math.max(0, rawLineTotal - sourceTokenScore - testTokenScore); + // #8875: weight test-file tokens with TEST_FILE_CONTRIBUTION_WEIGHT (0.05×) so this total mirrors + // buildScorePreview's `derivedTotalTokenScore`. Emitting a raw unweighted line sum here let a caller feed it + // back into the preview as an explicit `totalTokenScore`, which bypasses the test-file discount preview applies + // when it derives the total itself — over-counting a test-heavy diff's test lines in the contribution-bonus ramp. + const totalTokenScore = sourceTokenScore + TEST_FILE_CONTRIBUTION_WEIGHT * testTokenScore + nonCodeTokenScore; const failed = (input.validation ?? []).some((entry) => entry.status === "failed"); const warnings = failed ? ["Local validation reported failures — token scores describe the diff, not a passing build."] : []; return { diff --git a/packages/loopover-engine/src/scoring/model.ts b/packages/loopover-engine/src/scoring/model.ts index 407ca0aae0..f9bd45e6a7 100644 --- a/packages/loopover-engine/src/scoring/model.ts +++ b/packages/loopover-engine/src/scoring/model.ts @@ -8,6 +8,11 @@ import type { ScoringModelSnapshotRecord } from "./types.js"; export const DEFAULT_ISSUE_DISCOVERY_SHARE = 0.5; +// Upstream weights test-file tokens at 0.05× relative to source tokens (#808). Exported as a strongly-typed +// named constant so non-snapshot callers (local-scorer.ts) can weight their own totals without indexing the +// `Record` map — keeping the value single-sourced with DEFAULT_SCORING_CONSTANTS below. +export const TEST_FILE_CONTRIBUTION_WEIGHT = 0.05; + 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 @@ -21,7 +26,7 @@ export const DEFAULT_SCORING_CONSTANTS: Record = { 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, + TEST_FILE_CONTRIBUTION_WEIGHT, // 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. diff --git a/packages/loopover-engine/test/local-scorer.test.ts b/packages/loopover-engine/test/local-scorer.test.ts index fa2038f47e..9e41eba33c 100644 --- a/packages/loopover-engine/test/local-scorer.test.ts +++ b/packages/loopover-engine/test/local-scorer.test.ts @@ -20,7 +20,7 @@ test("classifies source / test / non-code disjointly from changed-file metadata" assert.equal(r.sourceTokenScore, 12); assert.equal(r.testTokenScore, 6); assert.equal(r.nonCodeTokenScore, 4); - assert.equal(r.totalTokenScore, 22); + assert.equal(r.totalTokenScore, 16.3); // #8875: 12 source + 0.05 × 6 test + 4 non-code assert.equal(r.warnings, undefined); }); diff --git a/test/unit/local-scorer.test.ts b/test/unit/local-scorer.test.ts index 2e80ffadef..296ec3b1f0 100644 --- a/test/unit/local-scorer.test.ts +++ b/test/unit/local-scorer.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vitest"; import { computeLocalScorerTokens } from "../../src/signals/local-scorer"; +import { buildScorePreview } from "../../src/scoring/preview"; +import type { ScoringModelSnapshotRecord } from "../../src/types"; + +// Minimal snapshot: empty `constants` means every constant (incl. TEST_FILE_CONTRIBUTION_WEIGHT) falls back to +// DEFAULT_SCORING_CONSTANTS, so preview's derived total uses the same 0.05× weight the local scorer applies. +const snapshot: ScoringModelSnapshotRecord = { + id: "local-scorer-fixture", + sourceKind: "test", + sourceUrl: "fixture://constants.py", + fetchedAt: "2026-05-23T00:00:00.000Z", + activeModel: "current_density_model", + constants: {}, + programmingLanguages: {}, + registrySnapshotId: "registry-fixture", + warnings: [], + payload: {}, +}; describe("computeLocalScorerTokens (#782)", () => { it("classifies source / test / non-code from metadata and sums additions + deletions", () => { @@ -16,7 +33,7 @@ describe("computeLocalScorerTokens (#782)", () => { sourceTokenScore: 12, testTokenScore: 8, nonCodeTokenScore: 6, - totalTokenScore: 26, + totalTokenScore: 18.4, // #8875: 12 source + 0.05 × 8 test + 6 non-code (test lines weighted, not raw-summed) sourceLines: 12, }); expect(scorer.warnings).toBeUndefined(); @@ -71,4 +88,37 @@ describe("computeLocalScorerTokens (#782)", () => { expect(computeLocalScorerTokens({ changedFiles: [{ path: "src/a.ts", additions: 1 }], validation: [{ command: "t", status: "passed" }] }).warnings).toBeUndefined(); expect(computeLocalScorerTokens({ changedFiles: [{ path: "src/a.ts", additions: 1 }] }).warnings).toBeUndefined(); }); + + it("test-heavy diff: totalTokenScore weights test lines and agrees with buildScorePreview's derived total (#8875)", () => { + const scorer = computeLocalScorerTokens({ + changedFiles: [ + { path: "src/widget.ts", additions: 20, deletions: 5 }, // source: 25 + { path: "test/widget.test.ts", additions: 180, deletions: 20 }, // test: 200 (test-heavy) + { path: "docs/widget.md", additions: 10, deletions: 0 }, // non-code: 10 + ], + }); + expect(scorer).toMatchObject({ sourceTokenScore: 25, testTokenScore: 200, nonCodeTokenScore: 10 }); + // 25 source + 0.05 × 200 test + 10 non-code = 45 — NOT the raw unweighted 235. + expect(scorer.totalTokenScore).toBe(45); + + const baseInput = { + repoFullName: "octo/demo", + sourceTokenScore: scorer.sourceTokenScore, + testTokenScore: scorer.testTokenScore, + nonCodeTokenScore: scorer.nonCodeTokenScore, + sourceLines: scorer.sourceLines, + openPrCount: 0, + credibility: 1, + }; + // Preview deriving its own total from components, vs. being handed the local scorer's explicit total. + const derived = buildScorePreview({ repo: null, snapshot, input: baseInput }); + const explicit = buildScorePreview({ repo: null, snapshot, input: { ...baseInput, totalTokenScore: scorer.totalTokenScore } }); + // The two now agree — the explicit total no longer bypasses the test-file discount. + expect(explicit.scoreEstimate.contributionBonus).toBe(derived.scoreEstimate.contributionBonus); + expect(explicit.scoreEstimate.estimatedMergedScore).toBe(derived.scoreEstimate.estimatedMergedScore); + + // Regression guard: the pre-fix raw unweighted total (25 + 200 + 10 = 235) over-counts test lines in the ramp. + const rawUnweighted = buildScorePreview({ repo: null, snapshot, input: { ...baseInput, totalTokenScore: 235 } }); + expect(rawUnweighted.scoreEstimate.contributionBonus).toBeGreaterThan(explicit.scoreEstimate.contributionBonus); + }); }); diff --git a/test/unit/mcp-cli-plan-scorer-tools.test.ts b/test/unit/mcp-cli-plan-scorer-tools.test.ts index 50836bb6ee..27ce1e5db4 100644 --- a/test/unit/mcp-cli-plan-scorer-tools.test.ts +++ b/test/unit/mcp-cli-plan-scorer-tools.test.ts @@ -64,7 +64,7 @@ describe("loopover-mcp plan-DAG + local-scorer + predict-gate tools (#6150) — expect(data.sourceTokenScore).toBe(14); expect(data.testTokenScore).toBe(8); expect(data.nonCodeTokenScore).toBe(3); - expect(data.totalTokenScore).toBe(25); + expect(data.totalTokenScore).toBe(17.4); // #8875: 14 source + 0.05 × 8 test + 3 non-code (test lines weighted) }); it("loopover_run_local_scorer surfaces a validation-failure warning without changing the scores", async () => { diff --git a/test/unit/mcp-run-local-scorer.test.ts b/test/unit/mcp-run-local-scorer.test.ts index a7553be297..880d203078 100644 --- a/test/unit/mcp-run-local-scorer.test.ts +++ b/test/unit/mcp-run-local-scorer.test.ts @@ -28,7 +28,7 @@ describe("MCP loopover_run_local_scorer (#782)", () => { }); expect(result.isError).toBeFalsy(); const data = result.structuredContent as { tokenScores: { mode: string; sourceTokenScore: number; testTokenScore: number; nonCodeTokenScore: number; totalTokenScore: number }; usage: string }; - expect(data.tokenScores).toMatchObject({ mode: "external_command", sourceTokenScore: 12, testTokenScore: 8, nonCodeTokenScore: 5, totalTokenScore: 25 }); + expect(data.tokenScores).toMatchObject({ mode: "external_command", sourceTokenScore: 12, testTokenScore: 8, nonCodeTokenScore: 5, totalTokenScore: 17.4 }); // #8875: 12 + 0.05 × 8 + 5 expect(data.usage).toMatch(/localScorer/); expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); });