brackt/scripts/backfill-rounded-tie-splits.ts
Claude a143df51f6
All checks were successful
🚀 Deploy / 🧪 Test (pull_request) Successful in 3m4s
🚀 Deploy / ʦ🔍 Typecheck & Lint (pull_request) Successful in 1m22s
🚀 Deploy / 🐳 Build (pull_request) Has been skipped
🚀 Deploy / 🚀 Deploy (pull_request) Has been skipped
Fix review findings in the tie-split ledger and backfill
A review of the previous two commits found five defects in the new ledger
writer and backfill, plus one pre-existing scoring bug the refactor
exposed.

season_standings ties were never split. processSeasonStandings
deliberately writes the same finalPosition to every driver in a tied group
-- its comment says "the scoring system will handle averaging" -- but no
path ever did, so two drivers tied for 3rd each banked the full 50 instead
of the published 45. This predates the tie-split work; the original
cascade had only bracket and qualifying_points arms. Introduce
usesSharedPlacementSplit as the single definition of which patterns record
ties as a repeated placement, and route both calculatePickPoints and every
caller-side gate through it. The caller gates matter as much as the
helper: a gate left hardcoded to qualifying_points silently passes a tie
count of 1, which reads as "no tie" and makes the fix inert.

The ledger anchor picked the wrong event. Ordering on completedAt with no
isComplete filter ranked never-completed events first, because drizzle's
desc() emits a bare desc and Postgres orders DESC as NULLS FIRST.
Restrict to completed events and order explicitly with NULLS LAST plus a
stable tiebreak. The anchor is also no longer load-bearing for
idempotence: stale event-level rows for the sports season are cleared
before writing, so a re-run whose anchor moved replaces rather than
duplicates.

A ledger failure could abort finalization. The call sat unguarded after
the season was already marked completed, so a throw in any of its queries
would skip the standings recalculation and the Discord notification. Guard
both call sites the way the probability refresh directly below already is.

Rows could be mislabelled permanently. The backfill passed no eventName,
and the upsert never rewrote scoringEventName. Derive the label from the
scoring pattern inside the writer so omitting it is impossible, and
refresh it on conflict so existing rows can be repaired.

The backfill damaged unrelated leagues. recalculateStandings rewrites
previousRank, so sweeping every season wiped rank-movement arrows league
wide, including leagues holding no tie at all. Scope it to seasons
drafting from a sports season that actually contains a tied placement, and
correct the docblock that called it a pure recompute.

Also drops the inert Number.EPSILON guard from calculateAveragedPoints
(EPSILON is below the ULP for any value >= 2, and integer averages landing
on .5 are exactly representable) and extracts countSharedPlacements so the
ledger writer stops re-querying rows it already holds.

Every fix is covered by a test confirmed to fail when that fix alone is
reverted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LZLF1PAfeKdpYog3NKtyz
2026-08-08 07:32:14 +00:00

176 lines
6.3 KiB
TypeScript

/**
* Backfill: recalculate standings after tie splits became whole points, and
* ledger the final placements that were never recorded.
*
* Two stored artifacts went stale when the tie-split math was unified:
*
* 1. team_standings.total_points / actual_points / projected_points hold
* pre-rounding values (a team carrying a golfer tied for 8th sits at
* 217.50 rather than 218). Standings are only rewritten when something
* re-triggers a recalculation, so leagues whose seasons already finished
* would keep the old figures indefinitely.
*
* 2. qualifying_points and season_standings sports were never written to
* team_score_events at all, so their results are missing from Recent
* Scores. recordFinalPlacementScoreEvents now writes them at
* finalization, but only for seasons finalized from here on.
*
* The ledger pass covers every already-finalized one-shot sports season and is
* safe to repeat: recordFinalPlacementScoreEvents clears that season's existing
* event-level rows before writing.
*
* The standings pass is deliberately narrow. recalculateStandings rewrites
* previousRank, so any season it touches loses its rank-movement arrows until the
* next scoring event — it is NOT a pure recompute. Only leagues drafting from a
* sports season that actually contains a tied placement are recalculated; every
* other league is left alone.
*
* Safe to re-run. Validate on a DB snapshot first. Reads DATABASE_URL.
*
* npx tsx scripts/backfill-rounded-tie-splits.ts # apply
* npx tsx scripts/backfill-rounded-tie-splits.ts --dry # report only
*/
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { inArray, eq } from "drizzle-orm";
import * as schema from "../database/schema.js";
import { DatabaseContext, database } from "../database/context.js";
import { recalculateStandings } from "../app/models/scoring-calculator.js";
import { recordFinalPlacementScoreEvents } from "../app/models/team-score-events.js";
import { countSharedPlacements } from "../app/models/participant-result.js";
const DRY = process.argv.includes("--dry");
const log = (...a: unknown[]) => console.log(...a);
/** Patterns that award all of their points at once, with no per-match deltas. */
type ScoringPattern = NonNullable<
(typeof schema.sportsSeasons.$inferSelect)["scoringPattern"]
>;
const ONE_SHOT_PATTERNS: ScoringPattern[] = ["qualifying_points", "season_standings"];
async function run() {
const db = database();
// Ledger pass: one-shot sports seasons that already have final placements.
const oneShotSeasons = await db.query.sportsSeasons.findMany({
where: inArray(schema.sportsSeasons.scoringPattern, ONE_SHOT_PATTERNS),
columns: { id: true, name: true, scoringPattern: true },
});
const finalized: typeof oneShotSeasons = [];
for (const ss of oneShotSeasons) {
const anyPlacement = await db.query.seasonParticipantResults.findFirst({
where: (r, { eq, and, gt }) =>
and(eq(r.sportsSeasonId, ss.id), gt(r.finalPosition, 0)),
columns: { id: true },
});
if (anyPlacement) finalized.push(ss);
}
log(
`One-shot sports seasons with final placements: ${finalized.length} of ${oneShotSeasons.length}`
);
let ledgered = 0;
let ledgerFailed = 0;
for (const ss of finalized) {
if (DRY) {
log(` (dry) ${ss.name} [${ss.scoringPattern}] — would write ledger rows`);
continue;
}
try {
await recordFinalPlacementScoreEvents({ sportsSeasonId: ss.id }, db);
ledgered += 1;
log(` ${ss.name} [${ss.scoringPattern}]: ledgered`);
} catch (e) {
ledgerFailed += 1;
log(` ! ${ss.name}: ${(e as Error).message}`);
}
}
// Standings pass, restricted to leagues that can actually change.
//
// recalculateStandings is NOT a pure recompute: it sets previousRank =
// currentRank, so every season it touches loses its rank-movement arrows until
// the next real scoring event. Sweeping all seasons would spend that cost on
// leagues holding no tied placement at all, so scope it to sports seasons that
// genuinely have a tie, then to the fantasy seasons drafting from them.
const allResults = await db.query.seasonParticipantResults.findMany({
columns: { sportsSeasonId: true, finalPosition: true },
});
const tiedSportsSeasonIds = new Set<string>();
for (const [sportsSeasonId, byPosition] of countSharedPlacements(allResults)) {
for (const count of byPosition.values()) {
if (count > 1) {
tiedSportsSeasonIds.add(sportsSeasonId);
break;
}
}
}
const affectedSeasonIds = new Set<string>();
if (tiedSportsSeasonIds.size > 0) {
const picks = await db
.select({
seasonId: schema.draftPicks.seasonId,
sportsSeasonId: schema.seasonParticipants.sportsSeasonId,
})
.from(schema.draftPicks)
.innerJoin(
schema.seasonParticipants,
eq(schema.draftPicks.participantId, schema.seasonParticipants.id)
)
.where(
inArray(schema.seasonParticipants.sportsSeasonId, [...tiedSportsSeasonIds])
);
for (const pick of picks) affectedSeasonIds.add(pick.seasonId);
}
log(
`\nSports seasons with a tied placement: ${tiedSportsSeasonIds.size}` +
`\nFantasy seasons to recalculate: ${affectedSeasonIds.size}`
);
let recalculated = 0;
let recalcFailed = 0;
for (const seasonId of affectedSeasonIds) {
if (DRY) {
log(` (dry) season ${seasonId} — would recalculate standings`);
continue;
}
try {
await recalculateStandings(seasonId, db);
recalculated += 1;
} catch (e) {
recalcFailed += 1;
log(` ! season ${seasonId}: ${(e as Error).message}`);
}
}
log(
`\nDone${DRY ? " (dry run — no writes)" : ""}. ` +
`ledgered=${ledgered} (failed ${ledgerFailed}), ` +
`standings recalculated=${recalculated} (failed ${recalcFailed}).`
);
}
async function main() {
const dbUrl = process.env.DATABASE_URL;
if (!dbUrl) {
console.error("ERROR: DATABASE_URL is required");
process.exit(1);
}
const client = postgres(dbUrl, { max: 1 });
const db = drizzle(client, { schema });
try {
await DatabaseContext.run(db, run);
} finally {
await client.end();
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});