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
28 changes: 28 additions & 0 deletions apps/gittensory-ui/public/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -4260,6 +4260,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -4271,6 +4274,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down Expand Up @@ -4508,6 +4512,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -4519,6 +4526,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down Expand Up @@ -4756,6 +4764,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -4767,6 +4778,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down Expand Up @@ -5004,6 +5016,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -5015,6 +5030,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down Expand Up @@ -5252,6 +5268,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -5263,6 +5282,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down Expand Up @@ -6071,6 +6091,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -6082,6 +6105,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down Expand Up @@ -6392,6 +6416,9 @@
},
"pendingSaturationScore": {
"type": "number"
},
"timeDecayMultiplier": {
"type": "number"
}
},
"required": [
Expand All @@ -6403,6 +6430,7 @@
"credibilityMultiplier",
"reviewPenaltyMultiplier",
"openPrMultiplier",
"timeDecayMultiplier",
"estimatedMergedScore",
"pendingSaturationScore"
]
Expand Down
9 changes: 6 additions & 3 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
});
Expand Down
2 changes: 2 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 6 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<string, unknown>,
data: makeScorePreviewRecord(scoreInput, snapshot, result) as unknown as Record<string, unknown>,
};
}

Expand Down
1 change: 1 addition & 0 deletions src/openapi/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
});
Expand Down
15 changes: 15 additions & 0 deletions src/scoring/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export const DEFAULT_SCORING_CONSTANTS: Record<string, number> = {
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 =
Expand Down Expand Up @@ -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<string, number>): ScoringModelSnapshotRecord["activeModel"] {
if (hasSaturationConstants(constants)) return "pending_saturation_model";
if (hasDensityConstants(constants)) {
Expand Down
35 changes: 34 additions & 1 deletion src/scoring/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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: {
Expand All @@ -337,6 +351,7 @@ function computeScoreCore(
credibilityMultiplier: roundScore(credibilityMultiplier),
reviewPenaltyMultiplier: roundScore(reviewPenaltyMultiplier),
openPrMultiplier,
timeDecayMultiplier: roundScore(timeDecayMultiplier),
estimatedMergedScore,
pendingSaturationScore,
},
Expand Down Expand Up @@ -887,6 +902,24 @@ function constant(constants: Record<string, number>, 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<string, number>): 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<string, number>): number {
const scale = Math.max(constant(constants, "SRC_TOK_SATURATION_SCALE", 58), 1);
return (
Expand Down
Loading
Loading