Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
/**
|
|
|
|
|
* Model for Participant Golf Skills
|
|
|
|
|
*
|
|
|
|
|
* Manages golf-specific skill data for qualifying-points golf seasons.
|
|
|
|
|
* Primary metric: SG: Total (strokes gained per round vs. field average).
|
|
|
|
|
* Optional per-major American odds allow major-specific probability blending.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { database } from "~/database/context";
|
2026-05-01 06:37:53 +00:00
|
|
|
import { participantGolfSkills, seasonParticipants } from "~/database/schema";
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
import { eq, sql } from "drizzle-orm";
|
|
|
|
|
|
|
|
|
|
export interface GolfSkillsRecord {
|
|
|
|
|
id: string;
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
sgTotal: number | null;
|
|
|
|
|
datagolfRank: number | null;
|
|
|
|
|
mastersOdds: number | null;
|
|
|
|
|
usOpenOdds: number | null;
|
|
|
|
|
openChampionshipOdds: number | null;
|
|
|
|
|
pgaChampionshipOdds: number | null;
|
|
|
|
|
updatedAt: Date;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GolfSkillsWithName extends GolfSkillsRecord {
|
|
|
|
|
participantName: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface GolfSkillsInput {
|
|
|
|
|
participantId: string;
|
|
|
|
|
sportsSeasonId: string;
|
|
|
|
|
sgTotal?: number | null;
|
|
|
|
|
datagolfRank?: number | null;
|
|
|
|
|
mastersOdds?: number | null;
|
|
|
|
|
usOpenOdds?: number | null;
|
|
|
|
|
openChampionshipOdds?: number | null;
|
|
|
|
|
pgaChampionshipOdds?: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get all golf skill records for a sports season, joined with participant names.
|
2026-05-01 06:37:53 +00:00
|
|
|
* Returns one record per participant (seasonParticipants with no record are excluded).
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
*/
|
|
|
|
|
export async function getGolfSkillsForSeason(
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<GolfSkillsWithName[]> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select({
|
|
|
|
|
id: participantGolfSkills.id,
|
|
|
|
|
participantId: participantGolfSkills.participantId,
|
|
|
|
|
sportsSeasonId: participantGolfSkills.sportsSeasonId,
|
|
|
|
|
sgTotal: participantGolfSkills.sgTotal,
|
|
|
|
|
datagolfRank: participantGolfSkills.datagolfRank,
|
|
|
|
|
mastersOdds: participantGolfSkills.mastersOdds,
|
|
|
|
|
usOpenOdds: participantGolfSkills.usOpenOdds,
|
|
|
|
|
openChampionshipOdds: participantGolfSkills.openChampionshipOdds,
|
|
|
|
|
pgaChampionshipOdds: participantGolfSkills.pgaChampionshipOdds,
|
|
|
|
|
updatedAt: participantGolfSkills.updatedAt,
|
2026-05-01 06:37:53 +00:00
|
|
|
participantName: seasonParticipants.name,
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
})
|
|
|
|
|
.from(participantGolfSkills)
|
2026-05-01 06:37:53 +00:00
|
|
|
.innerJoin(seasonParticipants, eq(participantGolfSkills.participantId, seasonParticipants.id))
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId))
|
2026-05-01 06:37:53 +00:00
|
|
|
.orderBy(seasonParticipants.name);
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
|
|
|
|
|
return rows.map((r) => ({
|
|
|
|
|
...r,
|
|
|
|
|
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Returns a Map from participantId → GolfSkillsRecord for use in the simulator.
|
|
|
|
|
* Participants with no record are absent from the map (simulator falls back to SG = 0).
|
|
|
|
|
*/
|
|
|
|
|
export async function getGolfSkillsMap(
|
|
|
|
|
sportsSeasonId: string
|
|
|
|
|
): Promise<Map<string, GolfSkillsRecord>> {
|
|
|
|
|
const db = database();
|
|
|
|
|
const rows = await db
|
|
|
|
|
.select()
|
|
|
|
|
.from(participantGolfSkills)
|
|
|
|
|
.where(eq(participantGolfSkills.sportsSeasonId, sportsSeasonId));
|
|
|
|
|
|
|
|
|
|
return new Map(rows.map((r) => [
|
|
|
|
|
r.participantId,
|
|
|
|
|
{
|
|
|
|
|
...r,
|
|
|
|
|
sgTotal: r.sgTotal !== null ? Number(r.sgTotal) : null,
|
|
|
|
|
},
|
|
|
|
|
]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-05-01 06:37:53 +00:00
|
|
|
* Upsert golf skill ratings for a batch of seasonParticipants.
|
Add golf qualifying points simulator (Plackett-Luce Monte Carlo) (#223)
* Add golf QP simulator with Plackett-Luce model, fixes #120
- New `participant_golf_skills` table (migration 0061) for SG: Total and
per-major American odds per player/season
- New `app/models/golf-skills.ts` with getGolfSkillsMap, getGolfSkillsForSeason,
batchUpsertGolfSkills
- Full `GolfSimulator` implementation replacing the TODO stub: Plackett-Luce
ranking model (PL_BETA=1.5, FIELD_SIZE=156), 10k Monte Carlo iterations,
awards QP by finishing position, ranks by total QP across all 4 majors
- New admin route `sports-seasons/:id/golf-skills` with bulk CSV import,
fuzzy name matching, per-player SG + per-major odds inputs; saves skills
and auto-runs simulation on submit
- Simulator dropdown on sport admin sorted alphabetically; renamed to
"Golf Qualifying Points Monte Carlo"
- Golf Skills button shown on sports season admin when simulator type is
golf_qualifying_points
- Extract normalizeName/diceCoefficient to shared `app/lib/fuzzy-match.ts`,
removing duplication from surface-elo and golf-skills routes
- Parallelize 4 DB queries in GolfSimulator.simulate() with Promise.all
- O(1) field array removal via swap-to-end + pop (was O(N) splice)
- Fix source tag: performance_model (not elo_simulation) for SG-based model
- 23 unit tests covering americanToImplied, getMajorOddsKey, resolveSkill,
simulateMajor, and Monte Carlo calibration properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix oxlint errors: no-non-null-assertion and eqeqeq
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:46:02 -07:00
|
|
|
* Uses INSERT … ON CONFLICT DO UPDATE so all columns are overwritten atomically.
|
|
|
|
|
*/
|
|
|
|
|
export async function batchUpsertGolfSkills(
|
|
|
|
|
inputs: GolfSkillsInput[]
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (inputs.length === 0) return;
|
|
|
|
|
const db = database();
|
|
|
|
|
const now = new Date();
|
|
|
|
|
|
|
|
|
|
await db
|
|
|
|
|
.insert(participantGolfSkills)
|
|
|
|
|
.values(
|
|
|
|
|
inputs.map(({
|
|
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
sgTotal,
|
|
|
|
|
datagolfRank,
|
|
|
|
|
mastersOdds,
|
|
|
|
|
usOpenOdds,
|
|
|
|
|
openChampionshipOdds,
|
|
|
|
|
pgaChampionshipOdds,
|
|
|
|
|
}) => ({
|
|
|
|
|
participantId,
|
|
|
|
|
sportsSeasonId,
|
|
|
|
|
// decimal columns are stored/passed as strings in Drizzle
|
|
|
|
|
sgTotal: sgTotal !== null && sgTotal !== undefined ? String(sgTotal) : null,
|
|
|
|
|
datagolfRank: datagolfRank ?? null,
|
|
|
|
|
mastersOdds: mastersOdds ?? null,
|
|
|
|
|
usOpenOdds: usOpenOdds ?? null,
|
|
|
|
|
openChampionshipOdds: openChampionshipOdds ?? null,
|
|
|
|
|
pgaChampionshipOdds: pgaChampionshipOdds ?? null,
|
|
|
|
|
updatedAt: now,
|
|
|
|
|
}))
|
|
|
|
|
)
|
|
|
|
|
.onConflictDoUpdate({
|
|
|
|
|
target: [participantGolfSkills.participantId, participantGolfSkills.sportsSeasonId],
|
|
|
|
|
set: {
|
|
|
|
|
sgTotal: sql`excluded.sg_total`,
|
|
|
|
|
datagolfRank: sql`excluded.datagolf_rank`,
|
|
|
|
|
mastersOdds: sql`excluded.masters_odds`,
|
|
|
|
|
usOpenOdds: sql`excluded.us_open_odds`,
|
|
|
|
|
openChampionshipOdds: sql`excluded.open_championship_odds`,
|
|
|
|
|
pgaChampionshipOdds: sql`excluded.pga_championship_odds`,
|
|
|
|
|
updatedAt: sql`excluded.updated_at`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|