From cf93b02b022ba3bc42c91b756b7bfff72f9a7c12 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Sun, 14 Jun 2026 10:30:37 -0700 Subject: [PATCH] feat(scoring): per-repo time-decay hyperparameters + go live (#703) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Time-decay now resolves each repo's curve from its registry config and goes live (SCORING_TIME_DECAY_ENABLED=true). The defaults come straight from upstream's constants.py (parsed into the live snapshot now that the TIME_DECAY_* constants are modeled); each repo's scoring.time_decay overrides overlay them per-field — mirroring upstream's resolve_time_decay. - registry/normalize.ts: parse the nested scoring.time_decay block per repo (grace_period_hours, sigmoid_midpoint_days, sigmoid_steepness, min_multiplier) into RegistryRepoConfig.timeDecay; absent/invalid fields stay null. - scoring/preview.ts: resolveTimeDecay overlays overrides on the snapshot defaults (per-field ?? default); calculateTimeDecay(prAgeHours, constants, overrides?) uses the resolved per-repo curve; computeScoreCore passes repo.registryConfig.timeDecay so each live repo's settings apply. - wrangler.jsonc: SCORING_TIME_DECAY_ENABLED=true — the go-live switch (deploys on merge). GITTENSOR_REGISTRY_URL is master_repositories.json, so these overrides are the SAME source upstream reads (e.g. JSONbored/gittensory already sets grace_period_hours: 24) — gittensory's projection matches upstream's scoring. A fresh PR is unaffected (decay 1.0); only aged-PR projections decay, so going live changes nothing until a caller supplies prAgeHours. Tests: scoring.time_decay parsing (partial/empty/absent), resolveTimeDecay overlay, calculateTimeDecay with overrides, and the preview applying each repo's resolved curve. 97% coverage held; OpenAPI clean. --- src/registry/normalize.ts | 20 ++++++++++++++++- src/scoring/preview.ts | 46 +++++++++++++++++++++++++++----------- src/types.ts | 14 ++++++++++++ test/unit/registry.test.ts | 29 ++++++++++++++++++++++++ test/unit/scoring.test.ts | 37 +++++++++++++++++++++++++++++- wrangler.jsonc | 4 ++++ 6 files changed, 135 insertions(+), 15 deletions(-) diff --git a/src/registry/normalize.ts b/src/registry/normalize.ts index 4876175449..9a10edd38b 100644 --- a/src/registry/normalize.ts +++ b/src/registry/normalize.ts @@ -1,4 +1,4 @@ -import type { JsonValue, RegistryRepoConfig, RegistrySnapshot } from "../types"; +import type { JsonValue, RegistryRepoConfig, RegistrySnapshot, RepoTimeDecayOverrides } from "../types"; type RawRepoConfig = Record; @@ -66,10 +66,28 @@ function normalizeRepo(repo: string, config: RawRepoConfig): RegistryRepoConfig defaultLabelMultiplier: numberValue(config.default_label_multiplier), fixedBaseScore: numberValue(config.fixed_base_score), eligibilityMode: stringValue(config.eligibility_mode), + timeDecay: parseTimeDecayOverrides(config.scoring), raw: config, }; } +// Per-repo time-decay overrides (#703), from the registry's nested `scoring.time_decay` (the same source +// upstream reads). Each key is optional; absent/non-numeric → null (resolveTimeDecay falls back to the +// global default). Returns null when there is no usable override, so a repo without one uses all defaults. +function parseTimeDecayOverrides(scoring: JsonValue | undefined): RepoTimeDecayOverrides | null { + if (!scoring || typeof scoring !== "object" || Array.isArray(scoring)) return null; + const raw = (scoring as Record).time_decay; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const td = raw as Record; + const overrides: RepoTimeDecayOverrides = { + gracePeriodHours: numberValue(td.grace_period_hours), + sigmoidMidpointDays: numberValue(td.sigmoid_midpoint_days), + sigmoidSteepness: numberValue(td.sigmoid_steepness), + minMultiplier: numberValue(td.min_multiplier), + }; + return Object.values(overrides).some((value) => value !== null) ? overrides : null; +} + function numberValue(value: JsonValue | undefined): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } diff --git a/src/scoring/preview.ts b/src/scoring/preview.ts index c8ab668548..697a55b2cf 100644 --- a/src/scoring/preview.ts +++ b/src/scoring/preview.ts @@ -1,4 +1,4 @@ -import type { ContributorEvidenceRecord, JsonValue, RepositoryRecord, ScoringModelSnapshotRecord, ScorePreviewRecord } from "../types"; +import type { ContributorEvidenceRecord, JsonValue, RepositoryRecord, RepoTimeDecayOverrides, ScoringModelSnapshotRecord, ScorePreviewRecord } from "../types"; import { nowIso } from "../utils/json"; export type ScorePreviewInput = { @@ -328,7 +328,8 @@ function computeScoreCore( // 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; + // Per-repo curve (#703): the repo's registry `scoring.time_decay` overrides overlay the snapshot defaults. + const timeDecayMultiplier = input.applyTimeDecay ? calculateTimeDecay(nonNegative(input.prAgeHours), constants, config?.timeDecay) : 1; const estimatedMergedScore = roundScore( baseScore * labelMultiplier * issueMultiplier * credibilityMultiplier * reviewPenaltyMultiplier * openPrMultiplier * timeDecayMultiplier, ); @@ -902,21 +903,40 @@ function constant(constants: Record, key: string, fallback: numb return typeof value === "number" && Number.isFinite(value) ? value : fallback; } +/** + * Resolve a repo's time-decay curve: each parameter is the repo's per-repo override (from the registry's + * `scoring.time_decay`) when present, else the global default constant from the live scoring snapshot. + * Mirrors upstream's `resolve_time_decay` (RepoTimeDecayConfig overlaid on the module constants). + */ +export function resolveTimeDecay( + constants: Record, + overrides?: RepoTimeDecayOverrides | null, +): { gracePeriodHours: number; sigmoidMidpointDays: number; sigmoidSteepness: number; minMultiplier: number } { + return { + gracePeriodHours: pickOverride(overrides?.gracePeriodHours, constant(constants, "TIME_DECAY_GRACE_PERIOD_HOURS", 12)), + sigmoidMidpointDays: pickOverride(overrides?.sigmoidMidpointDays, constant(constants, "TIME_DECAY_SIGMOID_MIDPOINT", 10)), + sigmoidSteepness: pickOverride(overrides?.sigmoidSteepness, constant(constants, "TIME_DECAY_SIGMOID_STEEPNESS_SCALAR", 0.4)), + minMultiplier: pickOverride(overrides?.minMultiplier, constant(constants, "TIME_DECAY_MIN_MULTIPLIER", 0.05)), + }; +} + +function pickOverride(value: number | null | undefined, fallback: number): number { + 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. + * `calculate_time_decay` (gittensor/validator/utils/datetime_utils.py): for the first grace-period hours the + * multiplier is exactly 1.0 (hard cutoff); after that it follows a logistic on days-since-merge centred at + * the sigmoid midpoint (50% at that point), floored at the minimum multiplier. The curve params are + * resolved PER-REPO (overrides ?? snapshot defaults), so each maintainer's registry hyperparameters apply. + * 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; +export function calculateTimeDecay(prAgeHours: number, constants: Record, overrides?: RepoTimeDecayOverrides | null): number { + const { gracePeriodHours, sigmoidMidpointDays, sigmoidSteepness, minMultiplier } = resolveTimeDecay(constants, overrides); + if (!Number.isFinite(prAgeHours) || prAgeHours < gracePeriodHours) 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))); + const sigmoid = 1 / (1 + Math.exp(sigmoidSteepness * (days - sigmoidMidpointDays))); return Math.max(sigmoid, minMultiplier); } diff --git a/src/types.ts b/src/types.ts index 0411303939..8088be27d5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -249,6 +249,18 @@ export type GitHubIssueCommentPayload = { updated_at?: string | null; }; +/** + * Per-repo time-decay overrides (#703), parsed from the registry's nested `scoring.time_decay`. Mirrors + * upstream's RepoTimeDecayConfig: every field optional; a missing/invalid field resolves to the global + * default constant (see resolveTimeDecay). The repo maintainer sets these in master_repositories.json. + */ +export type RepoTimeDecayOverrides = { + gracePeriodHours?: number | null | undefined; + sigmoidMidpointDays?: number | null | undefined; + sigmoidSteepness?: number | null | undefined; + minMultiplier?: number | null | undefined; +}; + export type RegistryRepoConfig = { repo: string; emissionShare: number; @@ -259,6 +271,8 @@ export type RegistryRepoConfig = { defaultLabelMultiplier?: number | null; fixedBaseScore?: number | null; eligibilityMode?: string | null; + /** Per-repo time-decay curve overrides (#703); null/absent = use the global defaults for every field. */ + timeDecay?: RepoTimeDecayOverrides | null; raw: Record; }; diff --git a/test/unit/registry.test.ts b/test/unit/registry.test.ts index 0bbf0edcf9..ae1b530fe5 100644 --- a/test/unit/registry.test.ts +++ b/test/unit/registry.test.ts @@ -32,6 +32,35 @@ describe("registry normalization", () => { labelMultipliers: { feature: 1.5 }, maintainerCut: 0.25, }); + // No scoring block → no per-repo time-decay overrides (uses global defaults downstream). + expect(snapshot.repositories[0]!.timeDecay ?? null).toBeNull(); + }); + + it("parses per-repo time-decay overrides from the registry scoring.time_decay block (#703)", () => { + const snapshot = normalizeRegistryPayload( + { + // JSONbored/gittensory's real master_repositories.json shape: a partial override (no steepness). + "JSONbored/gittensory": { + emission_share: 0.01, + scoring: { pr_lookback_days: 45, time_decay: { grace_period_hours: 24, sigmoid_midpoint_days: 10, min_multiplier: 0.05 } }, + }, + // A scoring block without time_decay → no overrides. + "other/repo": { emission_share: 0.02, scoring: { pr_lookback_days: 30 } }, + // A time_decay object with no usable numeric fields → still no overrides (every field null). + "empty/decay": { emission_share: 0.01, scoring: { time_decay: { note: "tbd" } } }, + }, + { kind: "raw-github", url: "https://example.test/master_repositories.json" }, + "2026-05-22T00:00:00.000Z", + ); + const byName = Object.fromEntries(snapshot.repositories.map((r) => [r.repo, r])); + expect(byName["JSONbored/gittensory"]!.timeDecay).toEqual({ + gracePeriodHours: 24, + sigmoidMidpointDays: 10, + sigmoidSteepness: null, // absent → falls back to the global default at resolve time + minMultiplier: 0.05, + }); + expect(byName["other/repo"]!.timeDecay ?? null).toBeNull(); + expect(byName["empty/decay"]!.timeDecay ?? null).toBeNull(); }); it("normalizes repository-list and array payload shapes defensively", () => { diff --git a/test/unit/scoring.test.ts b/test/unit/scoring.test.ts index 330117000f..89b4192bf6 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 { DEFAULT_SCORING_CONSTANTS, detectActiveModel, findUnmodeledUpstreamConstants, isTimeDecayEnabled, parsePythonNumberConstants, refreshScoringModelSnapshot } from "../../src/scoring/model"; -import { buildScorePreview, calculateTimeDecay, makeScorePreviewRecord } from "../../src/scoring/preview"; +import { buildScorePreview, calculateTimeDecay, makeScorePreviewRecord, resolveTimeDecay } from "../../src/scoring/preview"; import type { ScorePreviewInput } from "../../src/scoring/preview"; import type { RepositoryRecord, ScoringModelSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -793,5 +793,40 @@ MAX_CODE_DENSITY_MULTIPLIER = 1.15 expect(trajectory[2]!.after).toBeGreaterThan(trajectory[3]!.after); expect(trajectory[3]!.after).toBeLessThan(before); }); + + it("resolveTimeDecay overlays per-repo overrides on snapshot defaults, per-field (mirrors upstream)", () => { + const c = DEFAULT_SCORING_CONSTANTS; + // No overrides → all snapshot defaults. + expect(resolveTimeDecay(c, null)).toEqual({ gracePeriodHours: 12, sigmoidMidpointDays: 10, sigmoidSteepness: 0.4, minMultiplier: 0.05 }); + // Partial override (JSONbored/gittensory's real config: grace 24, midpoint 10, min 0.05, no steepness) + // → overridden fields apply, the absent steepness falls back to the default. + expect(resolveTimeDecay(c, { gracePeriodHours: 24, sigmoidMidpointDays: 10, minMultiplier: 0.05 })).toEqual({ + gracePeriodHours: 24, + sigmoidMidpointDays: 10, + sigmoidSteepness: 0.4, + minMultiplier: 0.05, + }); + // A non-finite/absent field falls back, not NaN. + expect(resolveTimeDecay(c, { sigmoidSteepness: Number.NaN }).sigmoidSteepness).toBe(0.4); + }); + + it("calculateTimeDecay honours a repo's per-repo curve (grace + midpoint overrides)", () => { + const c = DEFAULT_SCORING_CONSTANTS; + // Default 12h grace would decay at 18h; this repo's 24h grace keeps an 18h-old PR fresh. + expect(calculateTimeDecay(18, c)).toBeLessThan(1); + expect(calculateTimeDecay(18, c, { gracePeriodHours: 24 })).toBe(1); + // A shorter midpoint decays faster: 50% point moves from 10d to 5d (120h). + expect(calculateTimeDecay(120, c, { sigmoidMidpointDays: 5 })).toBeCloseTo(0.5, 5); + }); + + it("applies each live repo's resolved curve in the preview (per-repo, not global)", () => { + const input: ScorePreviewInput = { repoFullName: repo.fullName, sourceTokenScore: 58, totalTokenScore: 600, sourceLines: 60, openPrCount: 0, credibility: 1, applyTimeDecay: true, prAgeHours: 18 }; + // Repo with a 24h grace override (like JSONbored/gittensory) → an 18h-old PR is still fresh. + const repo24: RepositoryRecord = { ...repo, registryConfig: { ...repo.registryConfig!, timeDecay: { gracePeriodHours: 24 } } }; + expect(buildScorePreview({ repo: repo24, snapshot, input }).scoreEstimate.timeDecayMultiplier).toBe(1); + // Same PR on a repo using the default 12h grace → past grace, so it decays. + const repoDefault: RepositoryRecord = { ...repo, registryConfig: { ...repo.registryConfig!, timeDecay: null } }; + expect(buildScorePreview({ repo: repoDefault, snapshot, input }).scoreEstimate.timeDecayMultiplier).toBeLessThan(1); + }); }); }); diff --git a/wrangler.jsonc b/wrangler.jsonc index 9294257fd8..d234354c27 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -30,6 +30,10 @@ "GITTENSOR_UPSTREAM_REPO": "entrius/gittensor", "GITTENSOR_UPSTREAM_REF": "test", "GITTENSOR_REGISTRY_URL": "https://raw.githubusercontent.com/entrius/gittensor/test/gittensor/validator/weights/master_repositories.json", + // #703: apply upstream sigmoid time-decay in score previews, using each repo's resolved per-repo + // hyperparameters (registry scoring.time_decay overlaid on the upstream defaults). A fresh PR is + // unaffected (decay 1.0); only aged-PR projections decay. Owner-reviewed; this is the go-live switch. + "SCORING_TIME_DECAY_ENABLED": "true", "GITTENSORY_AUTO_FILE_DRIFT_ISSUES": "false", "GITTENSORY_DRIFT_ISSUE_REPO": "JSONbored/gittensory", "PUBLIC_API_ORIGIN": "https://gittensory-api.aethereal.dev",