From e83fb735b5cb75be8b86e46b4f1b2565f18bfcff Mon Sep 17 00:00:00 2001 From: philluiz2323 Date: Sun, 26 Jul 2026 07:48:31 -0700 Subject: [PATCH] fix(engine): weight local-scorer totalTokenScore with TEST_FILE_CONTRIBUTION_WEIGHT computeLocalScorerTokens summed raw test lines into totalTokenScore, so feeding that total back into buildScorePreview bypassed the 0.05x test-file discount preview.ts applies when deriving its own total. Compute the weighted total the same way as derivedTotalTokenScore so a test-heavy local preview matches the gate. Tests use `?? 0.05` when reading DEFAULT_SCORING_CONSTANTS so strict typecheck accepts the Record index (TS18048). Closes #8875 --- packages/loopover-engine/src/local-scorer.ts | 9 ++- .../loopover-engine/test/local-scorer.test.ts | 3 +- test/unit/local-scorer.test.ts | 70 ++++++++++++++++++- test/unit/mcp-run-local-scorer.test.ts | 2 +- 4 files changed, 79 insertions(+), 5 deletions(-) diff --git a/packages/loopover-engine/src/local-scorer.ts b/packages/loopover-engine/src/local-scorer.ts index 9c251ad509..af704863ae 100644 --- a/packages/loopover-engine/src/local-scorer.ts +++ b/packages/loopover-engine/src/local-scorer.ts @@ -10,6 +10,7 @@ // definitions. isCodeFile/isTestPath are the same portable classifiers local-branch.ts already delegates to. import { isCodeFile, isTestPath } from "./signals/test-evidence.js"; +import { DEFAULT_SCORING_CONSTANTS } from "./scoring/model.js"; export type LocalScorerChangedFile = { path: string; @@ -50,8 +51,12 @@ 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); + // Mirror preview.ts's derivedTotalTokenScore: test lines are discounted by TEST_FILE_CONTRIBUTION_WEIGHT + // so feeding this total back as `localScorer.totalTokenScore` does not bypass the gate's own weighting (#8875). + const testFileWeight = DEFAULT_SCORING_CONSTANTS.TEST_FILE_CONTRIBUTION_WEIGHT ?? 0.05; + const totalTokenScore = sourceTokenScore + testFileWeight * 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/test/local-scorer.test.ts b/packages/loopover-engine/test/local-scorer.test.ts index fa2038f47e..aee6a8cf58 100644 --- a/packages/loopover-engine/test/local-scorer.test.ts +++ b/packages/loopover-engine/test/local-scorer.test.ts @@ -20,7 +20,8 @@ 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); + // TEST_FILE_CONTRIBUTION_WEIGHT (0.05): 12 + 0.05*6 + 4 = 16.3 (#8875) + assert.equal(r.totalTokenScore, 12 + 0.05 * 6 + 4); assert.equal(r.warnings, undefined); }); diff --git a/test/unit/local-scorer.test.ts b/test/unit/local-scorer.test.ts index 2e80ffadef..cacf0278ef 100644 --- a/test/unit/local-scorer.test.ts +++ b/test/unit/local-scorer.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; import { computeLocalScorerTokens } from "../../src/signals/local-scorer"; +import { DEFAULT_SCORING_CONSTANTS } from "../../packages/loopover-engine/src/scoring/model"; +import { buildScorePreview } from "../../src/scoring/preview"; +import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; + +const testWeight = DEFAULT_SCORING_CONSTANTS.TEST_FILE_CONTRIBUTION_WEIGHT ?? 0.05; describe("computeLocalScorerTokens (#782)", () => { it("classifies source / test / non-code from metadata and sums additions + deletions", () => { @@ -16,7 +21,8 @@ describe("computeLocalScorerTokens (#782)", () => { sourceTokenScore: 12, testTokenScore: 8, nonCodeTokenScore: 6, - totalTokenScore: 26, + // Weighted total mirrors preview.ts (test lines at TEST_FILE_CONTRIBUTION_WEIGHT) (#8875) + totalTokenScore: 12 + testWeight * 8 + 6, sourceLines: 12, }); expect(scorer.warnings).toBeUndefined(); @@ -71,4 +77,66 @@ 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("applies TEST_FILE_CONTRIBUTION_WEIGHT so a test-heavy total agrees with preview.ts (#8875)", () => { + const scorer = computeLocalScorerTokens({ + changedFiles: [ + { path: "src/a.ts", additions: 20 }, + { path: "src/a.test.ts", additions: 200 }, + { path: "README.md", additions: 10 }, + ], + }); + const expectedWeighted = 20 + testWeight * 200 + 10; + expect(scorer.sourceTokenScore).toBe(20); + expect(scorer.testTokenScore).toBe(200); + expect(scorer.nonCodeTokenScore).toBe(10); + expect(scorer.totalTokenScore).toBe(expectedWeighted); + + const repo = { + fullName: "acme/widgets", + registryConfig: { emissionShare: 0.1, issueDiscoveryShare: 0, labelMultipliers: {}, defaultLabelMultiplier: 1 }, + } as RepositoryRecord; + const snapshot = { + id: "snap-1", + activeModel: "pending_saturation_model", + constants: { ...DEFAULT_SCORING_CONSTANTS }, + fetchedAt: new Date().toISOString(), + sourceKind: "fallback", + sourceUrl: "fixture://constants.py", + programmingLanguages: {}, + warnings: [], + payload: {}, + } as ScoringModelSnapshotRecord; + + const withExplicitTotal = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: "acme/widgets", + sourceTokenScore: scorer.sourceTokenScore, + testTokenScore: scorer.testTokenScore, + nonCodeTokenScore: scorer.nonCodeTokenScore, + totalTokenScore: scorer.totalTokenScore, + sourceLines: scorer.sourceLines, + openPrCount: 0, + credibility: 1, + }, + }); + const derivedTotal = buildScorePreview({ + repo, + snapshot, + input: { + repoFullName: "acme/widgets", + sourceTokenScore: scorer.sourceTokenScore, + testTokenScore: scorer.testTokenScore, + nonCodeTokenScore: scorer.nonCodeTokenScore, + sourceLines: scorer.sourceLines, + openPrCount: 0, + credibility: 1, + }, + }); + // Feeding the weighted local-scorer total must match preview's own derived total path. + expect(withExplicitTotal.scoreEstimate.contributionBonus).toBe(derivedTotal.scoreEstimate.contributionBonus); + expect(withExplicitTotal.scoreEstimate.estimatedMergedScore).toBe(derivedTotal.scoreEstimate.estimatedMergedScore); + }); }); diff --git a/test/unit/mcp-run-local-scorer.test.ts b/test/unit/mcp-run-local-scorer.test.ts index a7553be297..1153045341 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: 12 + 0.05 * 8 + 5 }); expect(data.usage).toMatch(/localScorer/); expect(JSON.stringify(data)).not.toMatch(/wallet|hotkey|reward|payout|trust score/i); });