diff --git a/apps/gittensory-ui/public/openapi.json b/apps/gittensory-ui/public/openapi.json index 8e830d0326..cb8a9b2333 100644 --- a/apps/gittensory-ui/public/openapi.json +++ b/apps/gittensory-ui/public/openapi.json @@ -4260,6 +4260,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -4271,6 +4274,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] @@ -4508,6 +4512,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -4519,6 +4526,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] @@ -4756,6 +4764,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -4767,6 +4778,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] @@ -5004,6 +5016,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -5015,6 +5030,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] @@ -5252,6 +5268,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -5263,6 +5282,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] @@ -6071,6 +6091,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -6082,6 +6105,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] @@ -6392,6 +6416,9 @@ }, "pendingSaturationScore": { "type": "number" + }, + "timeDecayMultiplier": { + "type": "number" } }, "required": [ @@ -6403,6 +6430,7 @@ "credibilityMultiplier", "reviewPenaltyMultiplier", "openPrMultiplier", + "timeDecayMultiplier", "estimatedMergedScore", "pendingSaturationScore" ] diff --git a/src/api/routes.ts b/src/api/routes.ts index 957abbd47f..5c157ce4a7 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -121,7 +121,7 @@ import { handleMcpRequest } from "../mcp/server"; import { buildOpenApiSpec } from "../openapi/spec"; import { generateSignalSnapshots } from "../queue/processors"; import { getLatestRegistrySnapshot, listLatestRegistrySnapshots, refreshRegistry } from "../registry/sync"; -import { getOrCreateScoringModelSnapshot, refreshScoringModelSnapshot } from "../scoring/model"; +import { getOrCreateScoringModelSnapshot, isTimeDecayEnabled, refreshScoringModelSnapshot } from "../scoring/model"; import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; import { explainBlockersWithAgent, @@ -462,6 +462,7 @@ const scorePreviewSchema = z.object({ testTokenScore: z.number().min(0).optional(), nonCodeTokenScore: z.number().min(0).optional(), existingContributorTokenScore: z.number().min(0).optional(), + prAgeHours: z.number().min(0).optional(), openPrCount: z.number().int().min(0).optional(), credibility: z.number().min(0).max(1).optional(), changesRequestedCount: z.number().int().min(0).optional(), @@ -1402,8 +1403,10 @@ export function createApp() { getOrCreateScoringModelSnapshot(c.env), parsed.data.contributorLogin ? getContributorEvidence(c.env, parsed.data.contributorLogin) : Promise.resolve(null), ]); - const result = buildScorePreview({ input: parsed.data, repo, snapshot, contributorEvidence: evidence }); - const record = makeScorePreviewRecord(parsed.data, snapshot, result); + // Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable). + const input = { ...parsed.data, applyTimeDecay: isTimeDecayEnabled(c.env) }; + const result = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); + const record = makeScorePreviewRecord(input, snapshot, result); await persistScorePreview(c.env, record); return c.json(record); }); diff --git a/src/env.d.ts b/src/env.d.ts index d307c097a6..6c1ebfe65e 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -28,6 +28,8 @@ declare global { GITTENSOR_UPSTREAM_REF?: string; GITTENSOR_REGISTRY_URL: string; GITHUB_PUBLIC_TOKEN?: string; + /** #703: owner-gated global to apply upstream sigmoid time-decay in score previews. Default off. */ + SCORING_TIME_DECAY_ENABLED?: string; GITTENSORY_AUTO_FILE_DRIFT_ISSUES?: string; GITTENSORY_DRIFT_ISSUE_REPO?: string; GITTENSORY_DRIFT_ISSUE_TOKEN?: string; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 3949b745c2..88b3db0dc2 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -38,7 +38,7 @@ import { buildNotificationFeed } from "../notifications/service"; import { contributorRepoStatsFromGittensor, fetchGittensorContributorSnapshot } from "../gittensor/api"; import { fetchPublicContributorProfile } from "../github/public"; import { listLatestRegistrySnapshots } from "../registry/sync"; -import { getOrCreateScoringModelSnapshot } from "../scoring/model"; +import { getOrCreateScoringModelSnapshot, isTimeDecayEnabled } from "../scoring/model"; import { buildScorePreview, makeScorePreviewRecord } from "../scoring/preview"; import { explainBlockersWithAgent, @@ -281,6 +281,7 @@ const scorePreviewShape = { testTokenScore: z.number().min(0).optional(), nonCodeTokenScore: z.number().min(0).optional(), existingContributorTokenScore: z.number().min(0).optional(), + prAgeHours: z.number().min(0).optional(), openPrCount: z.number().int().min(0).optional(), credibility: z.number().min(0).max(1).optional(), changesRequestedCount: z.number().int().min(0).optional(), @@ -1457,10 +1458,12 @@ export class GittensoryMcp { getOrCreateScoringModelSnapshot(this.env), input.contributorLogin ? getContributorEvidence(this.env, input.contributorLogin) : Promise.resolve(null), ]); - const result = buildScorePreview({ input, repo, snapshot, contributorEvidence: evidence }); + // Time-decay (#703) is an owner-gated global, injected server-side (not caller-controllable). + const scoreInput = { ...input, applyTimeDecay: isTimeDecayEnabled(this.env) }; + const result = buildScorePreview({ input: scoreInput, repo, snapshot, contributorEvidence: evidence }); return { summary: `Private Gittensory scoring preview for ${input.repoFullName}.`, - data: makeScorePreviewRecord(input, snapshot, result) as unknown as Record, + data: makeScorePreviewRecord(scoreInput, snapshot, result) as unknown as Record, }; } diff --git a/src/openapi/schemas.ts b/src/openapi/schemas.ts index 0dc758b147..35af0d8b95 100644 --- a/src/openapi/schemas.ts +++ b/src/openapi/schemas.ts @@ -1208,6 +1208,7 @@ const ScoreEstimateSchema = z.object({ credibilityMultiplier: z.number(), reviewPenaltyMultiplier: z.number(), openPrMultiplier: z.number(), + timeDecayMultiplier: z.number(), estimatedMergedScore: z.number(), pendingSaturationScore: z.number(), }); diff --git a/src/scoring/model.ts b/src/scoring/model.ts index 5db3c7b238..bb49a6e50f 100644 --- a/src/scoring/model.ts +++ b/src/scoring/model.ts @@ -30,6 +30,12 @@ export const DEFAULT_SCORING_CONSTANTS: Record = { OPEN_PR_THRESHOLD_TOKEN_SCORE: 300, MAX_OPEN_PR_THRESHOLD: 30, SRC_TOK_SATURATION_SCALE: 58, + // Upstream time-decay (#703): a merged PR's score decays on a sigmoid after a grace period. Modeled here + // so they no longer surface as unmodeled drift (#690); APPLICATION is opt-in + default-off (see preview). + TIME_DECAY_GRACE_PERIOD_HOURS: 12, + TIME_DECAY_SIGMOID_MIDPOINT: 10, + TIME_DECAY_SIGMOID_STEEPNESS_SCALAR: 0.4, + TIME_DECAY_MIN_MULTIPLIER: 0.05, }; export const SCORING_CONSTANTS_URL = @@ -125,6 +131,15 @@ export function findUnmodeledUpstreamConstants(source: string): string[] { .sort(); } +/** + * Owner-controlled global gate for applying upstream time-decay to score previews (#703). Default OFF: the + * roadmap deferral requires the owner to review a before/after ranking diff before enabling. Even when on, + * a fresh PR is unaffected (decay 1.0), so it only changes aged-PR projections. + */ +export function isTimeDecayEnabled(env: Env): boolean { + return /^(1|true|yes|on)$/i.test(env.SCORING_TIME_DECAY_ENABLED ?? ""); +} + export function detectActiveModel(constants: Record): ScoringModelSnapshotRecord["activeModel"] { if (hasSaturationConstants(constants)) return "pending_saturation_model"; if (hasDensityConstants(constants)) { diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index ff031437e2..c8ab668548 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -36,6 +36,12 @@ export type ScorePreviewInput = { pendingScenarioObserved?: boolean | undefined; observedScenarioNotes?: string[] | undefined; branchEligibility?: BranchEligibilityInput | undefined; + /** Hours since the PR merged, for upstream time-decay (#703). Absent / below the grace period = a fresh + * PR (multiplier 1.0). Only consulted when `applyTimeDecay` is on. */ + prAgeHours?: number | undefined; + /** Opt-in upstream time-decay (#703), default OFF and env-gated (SCORING_TIME_DECAY_ENABLED) at the call + * site. Even when on, a fresh PR is unaffected, so it never changes a normal new-PR preview. */ + applyTimeDecay?: boolean | undefined; }; export type BranchEligibilityInput = { @@ -151,6 +157,8 @@ export type ScorePreviewResult = { credibilityMultiplier: number; reviewPenaltyMultiplier: number; openPrMultiplier: number; + /** Upstream sigmoid time-decay multiplier (#703). 1 = no decay (fresh PR, or feature off). */ + timeDecayMultiplier: number; estimatedMergedScore: number; pendingSaturationScore: number; }; @@ -317,7 +325,13 @@ function computeScoreCore( Math.floor(nonNegative(input.existingContributorTokenScore) / constant(constants, "OPEN_PR_THRESHOLD_TOKEN_SCORE", 300)), ); const openPrMultiplier = openPrCount <= openPrThreshold ? 1 : 0; - const estimatedMergedScore = roundScore(baseScore * labelMultiplier * issueMultiplier * credibilityMultiplier * reviewPenaltyMultiplier * openPrMultiplier); + // Upstream time-decay (#703): mirrors upstream's `scored.time_decay_multiplier` applied to a PR's score. + // Opt-in + env-gated (default off). A fresh PR (prAgeHours below the grace period) yields 1.0, so a normal + // new-PR preview is unchanged even when enabled — only an aged-PR projection decays. + const timeDecayMultiplier = input.applyTimeDecay ? calculateTimeDecay(nonNegative(input.prAgeHours), constants) : 1; + const estimatedMergedScore = roundScore( + baseScore * labelMultiplier * issueMultiplier * credibilityMultiplier * reviewPenaltyMultiplier * openPrMultiplier * timeDecayMultiplier, + ); const pendingSaturationScore = roundScore(saturationBaseScore); return { laneMath: { @@ -337,6 +351,7 @@ function computeScoreCore( credibilityMultiplier: roundScore(credibilityMultiplier), reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier), openPrMultiplier, + timeDecayMultiplier: roundScore(timeDecayMultiplier), estimatedMergedScore, pendingSaturationScore, }, @@ -887,6 +902,24 @@ function constant(constants: Record, key: string, fallback: numb return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +/** + * Upstream gittensor's sigmoid time-decay multiplier (#703), ported verbatim from the validator's + * `calculate_time_decay` (gittensor/validator/utils/datetime_utils.py): for the first + * TIME_DECAY_GRACE_PERIOD_HOURS the multiplier is exactly 1.0 (hard grace cutoff); after that it follows a + * logistic on days-since-merge centred at TIME_DECAY_SIGMOID_MIDPOINT (50% at that point) with + * TIME_DECAY_SIGMOID_STEEPNESS_SCALAR, floored at TIME_DECAY_MIN_MULTIPLIER. Pure + deterministic. + */ +export function calculateTimeDecay(prAgeHours: number, constants: Record): number { + const grace = constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS", 12); + if (!Number.isFinite(prAgeHours) || prAgeHours < grace) return 1; + const days = prAgeHours / 24; + const midpoint = constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT", 10); + const steepness = constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR", 0.4); + const minMultiplier = constant(constants, "TIME_DECAY_MIN_MULTIPLIER", 0.05); + const sigmoid = 1 / (1 + Math.exp(steepness * (days - midpoint))); + return Math.max(sigmoid, minMultiplier); +} + function saturationScore(sourceTokenScore: number, totalTokenScore: number, constants: Record): number { const scale = Math.max(constant(constants, "SRC_TOK_SATURATION_SCALE", 58), 1); return ( diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 914450165e..330117000f 100644 --- a/test/unit/scoring.test.ts +++ b/test/unit/scoring.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { getLatestScoringModelSnapshot } from "../../src/db/repositories"; -import { detectActiveModel, findUnmodeledUpstreamConstants, parsePythonNumberConstants, refreshScoringModelSnapshot } from "../../src/scoring/model"; -import { buildScorePreview, makeScorePreviewRecord } from "../../src/scoring/preview"; +import { DEFAULT_SCORING_CONSTANTS, detectActiveModel, findUnmodeledUpstreamConstants, isTimeDecayEnabled, parsePythonNumberConstants, refreshScoringModelSnapshot } from "../../src/scoring/model"; +import { buildScorePreview, calculateTimeDecay, makeScorePreviewRecord } from "../../src/scoring/preview"; import type { ScorePreviewInput } from "../../src/scoring/preview"; import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -119,27 +119,29 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 }); it("flags upstream scoring constants gittensory does not model (staleness visibility)", () => { - // SRC_TOK_SATURATION_SCALE is modeled; the TIME_DECAY_* constants are NOT — so they surface as unmodeled. + // SRC_TOK_SATURATION_SCALE and the TIME_DECAY_* constants are now modeled (#703); a hypothetical new + // upstream dimension is NOT — so only that surfaces as unmodeled drift. const unmodeled = findUnmodeledUpstreamConstants( - "SRC_TOK_SATURATION_SCALE = 58.0\nTIME_DECAY_GRACE_PERIOD_HOURS = 12\nTIME_DECAY_SIGMOID_MIDPOINT = 10\n", + "SRC_TOK_SATURATION_SCALE = 58.0\nTIME_DECAY_GRACE_PERIOD_HOURS = 12\nNOVELTY_BONUS_SCALAR = 3\n", ); - expect(unmodeled).toEqual(["TIME_DECAY_GRACE_PERIOD_HOURS", "TIME_DECAY_SIGMOID_MIDPOINT"]); + expect(unmodeled).toEqual(["NOVELTY_BONUS_SCALAR"]); expect(unmodeled).not.toContain("SRC_TOK_SATURATION_SCALE"); + expect(unmodeled).not.toContain("TIME_DECAY_GRACE_PERIOD_HOURS"); // modeled as of #703 }); it("warns on the snapshot when upstream defines an unmodeled scoring dimension", async () => { const env = createTestEnv(); vi.stubGlobal("fetch", async (input: RequestInfo | URL) => { const url = input.toString(); - if (url.includes("constants.py")) return new Response("SRC_TOK_SATURATION_SCALE = 58.0\nTIME_DECAY_GRACE_PERIOD_HOURS = 12\n"); + if (url.includes("constants.py")) return new Response("SRC_TOK_SATURATION_SCALE = 58.0\nNOVELTY_BONUS_SCALAR = 3\n"); if (url.includes("programming_languages.json")) return Response.json({ TypeScript: 1 }); return new Response("not found", { status: 404 }); }); const refreshed = await refreshScoringModelSnapshot(env); - expect(refreshed.warnings.join(" ")).toMatch(/does not yet model.*TIME_DECAY_GRACE_PERIOD_HOURS/); - expect(refreshed.payload.constants).toMatchObject({ unmodeledUpstreamConstants: ["TIME_DECAY_GRACE_PERIOD_HOURS"] }); + expect(refreshed.warnings.join(" ")).toMatch(/does not yet model.*NOVELTY_BONUS_SCALAR/); + expect(refreshed.payload.constants).toMatchObject({ unmodeledUpstreamConstants: ["NOVELTY_BONUS_SCALAR"] }); }); it("uses saturation math as the active private preview model", () => { @@ -721,4 +723,75 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(thrownFallback.sourceKind).toBe("fallback"); expect(thrownFallback.activeModel).toBe("unknown"); }); + + describe("upstream time-decay (#703)", () => { + it("calculateTimeDecay matches the upstream sigmoid (grace, 50%-at-midpoint, floor, monotonic)", () => { + const c = DEFAULT_SCORING_CONSTANTS; + // Within the 12h grace period → no decay. + expect(calculateTimeDecay(0, c)).toBe(1); + expect(calculateTimeDecay(11.9, c)).toBe(1); + // Non-finite age is treated as fresh (defensive). + expect(calculateTimeDecay(Number.NaN, c)).toBe(1); + // Decay begins right after the grace boundary. + expect(calculateTimeDecay(12, c)).toBeLessThan(1); + // 50% at the 10-day midpoint (240h). + expect(calculateTimeDecay(240, c)).toBeCloseTo(0.5, 5); + // Floored at the 5% minimum for very old PRs (100 days). + expect(calculateTimeDecay(2400, c)).toBeCloseTo(0.05, 5); + // Strictly monotonic decreasing past the grace period. + expect(calculateTimeDecay(120, c)).toBeGreaterThan(calculateTimeDecay(240, c)); + expect(calculateTimeDecay(240, c)).toBeGreaterThan(calculateTimeDecay(480, c)); + }); + + it("the constants are modeled (no longer flagged as upstream drift)", () => { + expect(DEFAULT_SCORING_CONSTANTS.TIME_DECAY_SIGMOID_MIDPOINT).toBe(10); + expect(findUnmodeledUpstreamConstants("TIME_DECAY_GRACE_PERIOD_HOURS = 12\nTIME_DECAY_MIN_MULTIPLIER = 0.05\n")).toEqual([]); + }); + + it("isTimeDecayEnabled is OFF by default and only on for an explicit truthy flag", () => { + expect(isTimeDecayEnabled({} as Env)).toBe(false); + expect(isTimeDecayEnabled({ SCORING_TIME_DECAY_ENABLED: "false" } as unknown as Env)).toBe(false); + expect(isTimeDecayEnabled({ SCORING_TIME_DECAY_ENABLED: "true" } as unknown as Env)).toBe(true); + expect(isTimeDecayEnabled({ SCORING_TIME_DECAY_ENABLED: "1" } as unknown as Env)).toBe(true); + }); + + it("does not change the preview unless applied AND the PR is past the grace period", () => { + const input: ScorePreviewInput = { repoFullName: repo.fullName, sourceTokenScore: 58, totalTokenScore: 600, sourceLines: 60, openPrCount: 0, credibility: 1 }; + const base = buildScorePreview({ repo, snapshot, input }).scoreEstimate; + expect(base.timeDecayMultiplier).toBe(1); + + // Flag on but a fresh PR (no/zero age) → still 1.0, score unchanged. + const fresh = buildScorePreview({ repo, snapshot, input: { ...input, applyTimeDecay: true, prAgeHours: 0 } }).scoreEstimate; + expect(fresh.timeDecayMultiplier).toBe(1); + expect(fresh.estimatedMergedScore).toBe(base.estimatedMergedScore); + + // Age present but flag OFF → no decay applied. + const agedOff = buildScorePreview({ repo, snapshot, input: { ...input, prAgeHours: 240 } }).scoreEstimate; + expect(agedOff.timeDecayMultiplier).toBe(1); + expect(agedOff.estimatedMergedScore).toBe(base.estimatedMergedScore); + }); + + it("applies the decay multiplier to the estimate when on for an aged PR", () => { + const input: ScorePreviewInput = { repoFullName: repo.fullName, sourceTokenScore: 58, totalTokenScore: 600, sourceLines: 60, openPrCount: 0, credibility: 1 }; + const base = buildScorePreview({ repo, snapshot, input }).scoreEstimate; + const aged = buildScorePreview({ repo, snapshot, input: { ...input, applyTimeDecay: true, prAgeHours: 240 } }).scoreEstimate; + expect(aged.timeDecayMultiplier).toBeCloseTo(0.5, 2); + // 10-day-old PR scores ~half a fresh one (the before/after the owner reviews before enabling). + expect(aged.estimatedMergedScore).toBeCloseTo(base.estimatedMergedScore * 0.5, 1); + }); + + it("before/after: the decay trajectory for owner review (default-off; this is what enabling would do)", () => { + const input: ScorePreviewInput = { repoFullName: repo.fullName, sourceTokenScore: 58, totalTokenScore: 600, sourceLines: 60, openPrCount: 0, credibility: 1 }; + const before = buildScorePreview({ repo, snapshot, input }).scoreEstimate.estimatedMergedScore; + const trajectory = [0, 120, 240, 720].map((hours) => ({ + ageDays: hours / 24, + after: buildScorePreview({ repo, snapshot, input: { ...input, applyTimeDecay: true, prAgeHours: hours } }).scoreEstimate.estimatedMergedScore, + })); + // Fresh = unchanged; 5d > 10d > 30d; 30d floored well below fresh. Monotonic non-increasing. + expect(trajectory[0]!.after).toBe(before); + expect(trajectory[1]!.after).toBeGreaterThan(trajectory[2]!.after); + expect(trajectory[2]!.after).toBeGreaterThan(trajectory[3]!.after); + expect(trajectory[3]!.after).toBeLessThan(before); + }); + }); });