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
15 changes: 14 additions & 1 deletion src/scoring/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,21 @@ export async function refreshScoringModelSnapshot(env: Env): Promise<ScoringMode
return snapshot;
}

// Mirror Pipeline B's UPSTREAM_STALE_MS (upstream/ruleset.ts): a served scoring snapshot older than this
// window means the last upstream refresh failed or has not run, so previews are quietly using last-good (or
// DEFAULT) constants with no other staleness signal on the scoring side (#810).
export const SCORING_SNAPSHOT_STALE_MS = 2 * 60 * 60 * 1000;

export function scoringSnapshotStalenessWarning(snapshot: Pick<ScoringModelSnapshotRecord, "fetchedAt">, now: number = Date.now()): string | null {
if (Date.parse(snapshot.fetchedAt) + SCORING_SNAPSHOT_STALE_MS >= now) return null;
return "Scoring constants snapshot is stale: the last upstream refresh is older than the freshness window, so scoring may be using last-good or default constants and be behind upstream.";
}

export async function getOrCreateScoringModelSnapshot(env: Env): Promise<ScoringModelSnapshotRecord> {
return (await getLatestScoringModelSnapshot(env)) ?? refreshScoringModelSnapshot(env);
const snapshot = (await getLatestScoringModelSnapshot(env)) ?? (await refreshScoringModelSnapshot(env));
// Surface staleness so previews do not silently use last-good/DEFAULT constants after a failed/old refresh (#810).
const stalenessWarning = scoringSnapshotStalenessWarning(snapshot);
return stalenessWarning ? { ...snapshot, warnings: [...snapshot.warnings, stalenessWarning] } : snapshot;
}

export function parsePythonNumberConstants(source: string, options: { knownOnly?: boolean } = { knownOnly: true }): Record<string, number> {
Expand Down
32 changes: 30 additions & 2 deletions test/unit/scoring.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { getLatestScoringModelSnapshot, listUpstreamDriftReports } from "../../src/db/repositories";
import { DEFAULT_SCORING_CONSTANTS, detectActiveModel, findUnmodeledUpstreamConstants, isTimeDecayEnabled, parsePythonNumberConstants, refreshScoringModelSnapshot } from "../../src/scoring/model";
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 { buildScorePreview, calculateTimeDecay, makeScorePreviewRecord, resolveTimeDecay } from "../../src/scoring/preview";
import { unmodeledScoringConstantsFingerprint } from "../../src/upstream/unmodeled-scoring-drift";
import type { ScorePreviewInput } from "../../src/scoring/preview";
Expand Down Expand Up @@ -86,6 +86,34 @@ OSS_EMISSION_SHARE = 0.90
expect(parsed.OSS_EMISSION_SHARE).toBe(0.9);
});

it("flags only scoring snapshots older than the freshness window as stale (#810)", () => {
const now = Date.parse("2026-06-21T12:00:00.000Z");
const justFresh = new Date(now - SCORING_SNAPSHOT_STALE_MS + 60_000).toISOString();
const clearlyStale = new Date(now - SCORING_SNAPSHOT_STALE_MS - 60_000).toISOString();
expect(scoringSnapshotStalenessWarning({ fetchedAt: justFresh }, now)).toBeNull();
expect(scoringSnapshotStalenessWarning({ fetchedAt: clearlyStale }, now)).toMatch(/stale/i);
});

it("appends a staleness warning when getOrCreateScoringModelSnapshot serves an old snapshot (#810)", async () => {
const env = createTestEnv();
await persistScoringModelSnapshot(env, snapshot);
const served = await getOrCreateScoringModelSnapshot(env);
expect(served.id).toBe(snapshot.id);
expect(served.warnings.some((warning) => /stale/i.test(warning))).toBe(true);
});

it("does not add a staleness warning when getOrCreate refreshes a fresh snapshot (#810)", async () => {
const env = createTestEnv({ GITHUB_PUBLIC_TOKEN: "token" });
vi.stubGlobal("fetch", async (input: RequestInfo | URL) => {
const url = input.toString();
if (url.includes("constants.py")) return new Response("MERGED_PR_BASE_SCORE = 25\n");
if (url.includes("programming_languages.json")) return Response.json({});
return new Response("not found", { status: 404 });
});
const served = await getOrCreateScoringModelSnapshot(env);
expect(served.warnings.some((warning) => /stale/i.test(warning))).toBe(false);
});

it("prefers exponential saturation when mixed upstream constants are present", () => {
const parsed = parsePythonNumberConstants(`
MERGED_PR_BASE_SCORE = 25
Expand Down
Loading