Skip to content
Merged
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
10 changes: 8 additions & 2 deletions packages/loopover-engine/src/local-scorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
7 changes: 6 additions & 1 deletion packages/loopover-engine/src/scoring/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>` 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<string, number> = {
OSS_EMISSION_SHARE: 0.9,
// Upstream name is ISSUES_TREASURY_EMISSION_SHARE (plural). The prior singular spelling never matched
Expand All @@ -21,7 +26,7 @@ export const DEFAULT_SCORING_CONSTANTS: Record<string, number> = {
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.
Expand Down
2 changes: 1 addition & 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,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);
});

Expand Down
52 changes: 51 additions & 1 deletion test/unit/local-scorer.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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();
Expand Down Expand Up @@ -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);
});
});
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(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 () => {
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: 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);
});
Expand Down