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
20 changes: 19 additions & 1 deletion src/registry/normalize.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { JsonValue, RegistryRepoConfig, RegistrySnapshot } from "../types";
import type { JsonValue, RegistryRepoConfig, RegistrySnapshot, RepoTimeDecayOverrides } from "../types";

type RawRepoConfig = Record<string, JsonValue>;

Expand Down Expand Up @@ -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<string, JsonValue>).time_decay;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
const td = raw as Record<string, JsonValue>;
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;
}
Expand Down
46 changes: 33 additions & 13 deletions src/scoring/preview.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -902,21 +903,40 @@ function constant(constants: Record<string, number>, 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<string, number>,
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<string, number>): 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<string, number>, 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);
}

Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, JsonValue>;
};

Expand Down
29 changes: 29 additions & 0 deletions test/unit/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
37 changes: 36 additions & 1 deletion test/unit/scoring.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
});
});
4 changes: 4 additions & 0 deletions wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down