Skip to content
Closed
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
9 changes: 7 additions & 2 deletions packages/loopover-engine/src/local-scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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!;
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 {
Expand Down
3 changes: 2 additions & 1 deletion packages/loopover-engine/test/local-scorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
68 changes: 67 additions & 1 deletion test/unit/local-scorer.test.ts
Original file line number Diff line number Diff line change
@@ -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!;

describe("computeLocalScorerTokens (#782)", () => {
it("classifies source / test / non-code from metadata and sums additions + deletions", () => {
Expand All @@ -16,7 +21,7 @@ describe("computeLocalScorerTokens (#782)", () => {
sourceTokenScore: 12,
testTokenScore: 8,
nonCodeTokenScore: 6,
totalTokenScore: 26,
totalTokenScore: 12 + testWeight * 8 + 6,
sourceLines: 12,
});
expect(scorer.warnings).toBeUndefined();
Expand Down Expand Up @@ -71,4 +76,65 @@ 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,
},
});
expect(withExplicitTotal.scoreEstimate.contributionBonus).toBe(derivedTotal.scoreEstimate.contributionBonus);
expect(withExplicitTotal.scoreEstimate.estimatedMergedScore).toBe(derivedTotal.scoreEstimate.estimatedMergedScore);
});
});
2 changes: 1 addition & 1 deletion test/unit/mcp-cli-plan-scorer-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(14 + 0.05 * 8 + 3);
});

it("loopover_run_local_scorer surfaces a validation-failure warning without changing the scores", async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/mcp-run-local-scorer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading