2026-06-26 05:16:54 +00:00
import { and , eq , inArray , isNotNull , sql } from "drizzle-orm" ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
import { database } from "~/database/context" ;
import * as schema from "~/database/schema" ;
import {
getManifestSimulatorProfile ,
simulatorInputLabel ,
type SimulatorInputKey ,
type SimulatorManifestProfile ,
} from "~/services/simulations/manifest" ;
import {
2026-06-26 05:16:54 +00:00
getSimulatorInputPolicy ,
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
ratingRequirementLabel ,
resolveRatings ,
resolveSourceElos ,
sourceEloRequirementLabel ,
} from "~/services/simulations/input-policy" ;
import { SIMULATOR_TYPES , type SimulatorType } from "~/services/simulations/registry" ;
2026-08-17 22:50:35 +00:00
import { countSeasonRaces } from "~/models/season-races" ;
/** Simulator types driven by a race calendar plus championship standings. */
const RACE_CALENDAR_SIMULATORS : SimulatorType [ ] = [ "f1_standings" , "indycar_standings" ] ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
export interface SimulatorProfile extends SimulatorManifestProfile {
isActive : boolean ;
}
export interface SportsSeasonSimulatorConfig {
sportsSeasonId : string ;
simulatorType : SimulatorType ;
config : Record < string , unknown > ;
profile : SimulatorProfile ;
}
export interface ParticipantSimulatorInput {
participantId : string ;
sportsSeasonId : string ;
sourceOdds : number | null ;
sourceElo : number | null ;
worldRanking : number | null ;
rating : number | null ;
projectedWins : number | null ;
projectedTablePoints : number | null ;
seed : number | null ;
region : string | null ;
metadata : Record < string , unknown > | null ;
}
export interface UpsertParticipantSimulatorInput {
participantId : string ;
sportsSeasonId : string ;
sourceOdds? : number | null ;
sourceElo? : number | null ;
worldRanking? : number | null ;
rating? : number | null ;
projectedWins? : number | null ;
projectedTablePoints? : number | null ;
seed? : number | null ;
region? : string | null ;
metadata? : Record < string , unknown > | null ;
}
export interface SimulatorReadiness {
status : "ready" | "not_ready" ;
canRun : boolean ;
participantCount : number ;
participantInputCount : number ;
derivedInputCount : number ;
fallbackInputCount : number ;
missingInputs : string [ ] ;
warnings : string [ ] ;
}
export interface SportsSeasonSimulatorSummary {
sportsSeasonId : string ;
seasonName : string ;
year : number ;
seasonStatus : string ;
simulationStatus : string ;
fantasySeasonId : string | null ;
fantasySeasonName : string | null ;
leagueName : string | null ;
sportName : string ;
sportSlug : string ;
simulatorType : SimulatorType ;
simulatorName : string ;
participantCount : number ;
participantInputCount : number ;
lastSimulatedDate : string | null ;
readiness : SimulatorReadiness ;
2026-06-26 05:16:54 +00:00
/** Whether this simulator exposes a Futures Odds setup section. */
supportsFuturesOdds : boolean ;
/** Number of participants that currently have stored futures odds. */
oddsParticipantCount : number ;
/** Weight (0– 1) the season's policy gives futures odds when blending into Elo. */
oddsWeight : number ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
}
function parseDecimal ( value : string | null | undefined ) : number | null {
if ( value === null || value === undefined ) return null ;
const parsed = Number ( value ) ;
return Number . isFinite ( parsed ) ? parsed : null ;
}
function isSimulatorType ( value : string | null | undefined ) : value is SimulatorType {
return typeof value === "string" && ( SIMULATOR_TYPES as readonly string [ ] ) . includes ( value ) ;
}
function mergeProfile (
simulatorType : SimulatorType ,
dbProfile? : typeof schema . simulatorProfiles . $inferSelect | null
) : SimulatorProfile {
const manifest = getManifestSimulatorProfile ( simulatorType ) ;
if ( ! dbProfile ) return { . . . manifest , isActive : true } ;
return {
. . . manifest ,
displayName : dbProfile.displayName ,
description : dbProfile.description ? ? manifest . description ,
defaultConfig : {
. . . manifest . defaultConfig ,
. . . dbProfile . defaultConfig ,
} ,
isActive : dbProfile.isActive ,
} ;
}
function hasInputValue ( input : ParticipantSimulatorInput , key : SimulatorInputKey ) : boolean {
const value = input [ key ] ;
if ( value === null || value === undefined ) return false ;
if ( typeof value === "string" ) return value . trim ( ) . length > 0 ;
if ( typeof value === "object" ) return Object . keys ( value ) . length > 0 ;
return true ;
}
function isFallbackMethod ( method : string ) : boolean {
2026-05-12 22:26:25 -07:00
return method === "fallbackElo" || method === "fallbackRating" || method === "averageKnown" || method === "worstKnownMinus" ;
}
2026-06-26 05:16:54 +00:00
const GENERATED_RATING_METHODS = [ "sourceOdds" , "blend" , "fallbackRating" , "averageKnown" , "worstKnownMinus" ] as const ;
const GENERATED_SOURCE_ELO_METHODS = [ "projectedWins" , "projectedTablePoints" , "sourceOdds" , "blend" , "fallbackElo" , "averageKnown" , "worstKnownMinus" ] as const ;
2026-05-12 22:26:25 -07:00
function isGeneratedRatingMethod ( method : unknown ) : boolean {
return typeof method === "string" && GENERATED_RATING_METHODS . includes ( method as ( typeof GENERATED_RATING_METHODS ) [ number ] ) ;
}
2026-05-13 15:02:23 -07:00
function isGeneratedSourceEloMethod ( method : unknown ) : boolean {
return typeof method === "string" && GENERATED_SOURCE_ELO_METHODS . includes ( method as ( typeof GENERATED_SOURCE_ELO_METHODS ) [ number ] ) ;
}
2026-05-12 22:26:25 -07:00
function isGeneratedRatingInput ( input : typeof schema . seasonParticipantSimulatorInputs . $inferSelect | undefined ) : boolean {
return isGeneratedRatingMethod ( input ? . metadata ? . ratingMethod ) ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
}
2026-05-13 15:02:23 -07:00
function isGeneratedSourceEloInput ( input : typeof schema . seasonParticipantSimulatorInputs . $inferSelect | undefined ) : boolean {
return isGeneratedSourceEloMethod ( input ? . metadata ? . sourceEloMethod ) ;
}
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
function inputRequirementLabel (
key : SimulatorInputKey ,
profile : SimulatorManifestProfile
) : string {
if ( key === "sourceElo" && profile . derivableInputs ? . sourceElo ? . length ) {
return sourceEloRequirementLabel ( profile ) ;
}
if ( key === "rating" && profile . derivableInputs ? . rating ? . length ) {
return ratingRequirementLabel ( profile ) ;
}
return simulatorInputLabel ( key ) ;
}
export async function getSimulatorProfile ( simulatorType : SimulatorType ) : Promise < SimulatorProfile > {
const db = database ( ) ;
const dbProfile = await db . query . simulatorProfiles . findFirst ( {
where : eq ( schema . simulatorProfiles . simulatorType , simulatorType ) ,
} ) ;
return mergeProfile ( simulatorType , dbProfile ) ;
}
export async function upsertSimulatorProfile (
profile : Pick < SimulatorProfile , "simulatorType" | "displayName" | "description" | "defaultConfig" > & {
inputSchema? : Record < string , unknown > ;
isActive? : boolean ;
}
) : Promise < void > {
const db = database ( ) ;
const now = new Date ( ) ;
await db
. insert ( schema . simulatorProfiles )
. values ( {
simulatorType : profile.simulatorType ,
displayName : profile.displayName ,
description : profile.description ,
defaultConfig : profile.defaultConfig ,
inputSchema : profile.inputSchema ? ? { } ,
isActive : profile.isActive ? ? true ,
createdAt : now ,
updatedAt : now ,
} )
. onConflictDoUpdate ( {
target : schema.simulatorProfiles.simulatorType ,
set : {
displayName : sql ` excluded.display_name ` ,
description : sql ` excluded.description ` ,
defaultConfig : sql ` excluded.default_config ` ,
inputSchema : sql ` excluded.input_schema ` ,
isActive : sql ` excluded.is_active ` ,
updatedAt : now ,
} ,
} ) ;
}
export async function getSportsSeasonSimulatorConfig (
sportsSeasonId : string
) : Promise < SportsSeasonSimulatorConfig | null > {
const db = database ( ) ;
const season = await db . query . sportsSeasons . findFirst ( {
where : eq ( schema . sportsSeasons . id , sportsSeasonId ) ,
with : {
sport : true ,
simulatorConfig : true ,
} ,
} ) ;
const simulatorType = season ? . simulatorConfig ? . simulatorType ? ? season ? . sport ? . simulatorType ;
if ( ! season || ! isSimulatorType ( simulatorType ) ) return null ;
const profile = await getSimulatorProfile ( simulatorType ) ;
const config = {
. . . profile . defaultConfig ,
. . . season . simulatorConfig ? . config ,
} ;
return {
sportsSeasonId ,
simulatorType ,
config ,
profile ,
} ;
}
export async function upsertSportsSeasonSimulatorConfig ( input : {
sportsSeasonId : string ;
simulatorType : SimulatorType ;
config? : Record < string , unknown > ;
} ) : Promise < void > {
const db = database ( ) ;
const now = new Date ( ) ;
await db
. insert ( schema . sportsSeasonSimulatorConfigs )
. values ( {
sportsSeasonId : input.sportsSeasonId ,
simulatorType : input.simulatorType ,
config : input.config ? ? { } ,
createdAt : now ,
updatedAt : now ,
} )
. onConflictDoUpdate ( {
target : schema.sportsSeasonSimulatorConfigs.sportsSeasonId ,
set : {
simulatorType : sql ` excluded.simulator_type ` ,
config : sql ` excluded.config ` ,
updatedAt : now ,
} ,
} ) ;
}
export async function getParticipantSimulatorInputs (
sportsSeasonId : string
) : Promise < ParticipantSimulatorInput [ ] > {
const db = database ( ) ;
const [ seasonParticipants , simulatorInputs , legacyEvs ] = await Promise . all ( [
db . query . seasonParticipants . findMany ( {
where : eq ( schema . seasonParticipants . sportsSeasonId , sportsSeasonId ) ,
columns : { id : true } ,
} ) ,
db . query . seasonParticipantSimulatorInputs . findMany ( {
where : eq ( schema . seasonParticipantSimulatorInputs . sportsSeasonId , sportsSeasonId ) ,
} ) ,
db . query . seasonParticipantExpectedValues . findMany ( {
where : eq ( schema . seasonParticipantExpectedValues . sportsSeasonId , sportsSeasonId ) ,
columns : {
participantId : true ,
sourceOdds : true ,
sourceElo : true ,
worldRanking : true ,
} ,
} ) ,
] ) ;
const simulatorByParticipant = new Map ( simulatorInputs . map ( ( row ) = > [ row . participantId , row ] ) ) ;
const legacyByParticipant = new Map ( legacyEvs . map ( ( row ) = > [ row . participantId , row ] ) ) ;
return seasonParticipants . map ( ( participant ) = > {
const input = simulatorByParticipant . get ( participant . id ) ;
const legacy = legacyByParticipant . get ( participant . id ) ;
return {
participantId : participant.id ,
sportsSeasonId ,
sourceOdds : input?.sourceOdds ? ? legacy ? . sourceOdds ? ? null ,
2026-05-13 15:02:23 -07:00
sourceElo : input
? isGeneratedSourceEloInput ( input ) ? null : input . sourceElo
: legacy ? . sourceElo ? ? null ,
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
worldRanking : input?.worldRanking ? ? legacy ? . worldRanking ? ? null ,
2026-05-12 22:26:25 -07:00
rating : isGeneratedRatingInput ( input ) ? null : parseDecimal ( input ? . rating ) ,
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
projectedWins : parseDecimal ( input ? . projectedWins ) ,
projectedTablePoints : parseDecimal ( input ? . projectedTablePoints ) ,
seed : input?.seed ? ? null ,
region : input?.region ? ? null ,
metadata : input?.metadata ? ? null ,
} ;
} ) ;
}
export async function batchUpsertParticipantSimulatorInputs (
inputs : UpsertParticipantSimulatorInput [ ]
) : Promise < void > {
if ( inputs . length === 0 ) return ;
const db = database ( ) ;
const now = new Date ( ) ;
await db . transaction ( async ( tx ) = > {
await tx
. insert ( schema . seasonParticipantSimulatorInputs )
. values (
inputs . map ( ( input ) = > ( {
participantId : input.participantId ,
sportsSeasonId : input.sportsSeasonId ,
sourceOdds : input.sourceOdds ? ? null ,
sourceElo : input.sourceElo ? ? null ,
worldRanking : input.worldRanking ? ? null ,
rating : input.rating?.toString ( ) ? ? null ,
projectedWins : input.projectedWins?.toString ( ) ? ? null ,
projectedTablePoints : input.projectedTablePoints?.toString ( ) ? ? null ,
seed : input.seed ? ? null ,
region : input.region ? ? null ,
metadata : input.metadata ? ? null ,
createdAt : now ,
updatedAt : now ,
} ) )
)
. onConflictDoUpdate ( {
target : [
schema . seasonParticipantSimulatorInputs . participantId ,
schema . seasonParticipantSimulatorInputs . sportsSeasonId ,
] ,
2026-06-30 17:05:52 +00:00
// Non-destructive: only overwrite a column when the incoming value is
// non-null. This lets a partial import (e.g. odds-only) update just the
// columns it provides without clobbering previously stored inputs like a
// manually entered Elo or rating. Mirrors the COALESCE bridge used for the
// legacy EV table below.
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
set : {
2026-06-30 17:05:52 +00:00
sourceOdds : sql ` COALESCE(excluded.source_odds, ${ schema . seasonParticipantSimulatorInputs . sourceOdds } ) ` ,
sourceElo : sql ` COALESCE(excluded.source_elo, ${ schema . seasonParticipantSimulatorInputs . sourceElo } ) ` ,
worldRanking : sql ` COALESCE(excluded.world_ranking, ${ schema . seasonParticipantSimulatorInputs . worldRanking } ) ` ,
rating : sql ` COALESCE(excluded.rating, ${ schema . seasonParticipantSimulatorInputs . rating } ) ` ,
projectedWins : sql ` COALESCE(excluded.projected_wins, ${ schema . seasonParticipantSimulatorInputs . projectedWins } ) ` ,
projectedTablePoints : sql ` COALESCE(excluded.projected_table_points, ${ schema . seasonParticipantSimulatorInputs . projectedTablePoints } ) ` ,
seed : sql ` COALESCE(excluded.seed, ${ schema . seasonParticipantSimulatorInputs . seed } ) ` ,
region : sql ` COALESCE(excluded.region, ${ schema . seasonParticipantSimulatorInputs . region } ) ` ,
// Metadata carries the method flags (sourceEloMethod/ratingMethod) that
// tell readers whether the stored Elo/rating is generated vs. a trusted
Fix review findings on the MLB projected-wins change
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
// direct value. Two rules apply, and both always apply — they are not
// alternatives:
//
// 1. Drop the method flag for any column receiving a fresh direct
// value, otherwise a stale "generated" flag would cause that
// newly-entered Elo/rating to be filtered out as derived (see
// getParticipantSimulatorInputs).
// 2. Merge any metadata the caller supplied over the result
// (prepareSimulatorInputsForRun and the projection importers set the
// correct flags) — a merge rather than a replace so a caller that
// only needs to stamp one method flag does not wipe unrelated keys.
//
// Ordering matters: strip first, then merge, so a caller stamping one flag
// still gets the other column's stale flag cleared. Running these as
// exclusive CASE branches instead would mean a bulk row carrying both a
// direct `rating` and a `projectedWins` (which stamps sourceEloMethod)
// silently kept a stale ratingMethod, hiding the rating it just set.
metadata : sql ` (
COALESCE ( $ { schema . seasonParticipantSimulatorInputs . metadata } , '{}' : : jsonb )
2026-06-30 17:05:52 +00:00
- ( CASE WHEN excluded . source_elo IS NOT NULL THEN 'sourceEloMethod' ELSE '' END )
- ( CASE WHEN excluded . rating IS NOT NULL THEN 'ratingMethod' ELSE '' END )
Fix review findings on the MLB projected-wins change
The previous commit did not build. `simulatorInputLabel` was called from the
rendered component for the Base Elo Source select, which defeated the
treeshaking that keeps the simulator manifest — and through it the registry,
every simulator, and ~/database/context — out of the browser. Vite fails the
client build outright with "Server-only module referenced by client". The
label is now resolved in the loader alongside inputColumns, which is the
pattern the loader comment already documents. Typecheck, lint and the unit
suite all passed on the broken commit; none of them run a production build.
A stale projection now yields to Elo instead of clamping to an extreme.
seedingWinRateFor clamped the rest-of-season target into [0.01, 0.99], so a
96-40 team projected for 95 was simulated to go 0-26 and a 40-70 team
projected for 95 was simulated to win out. A target outside (0, 1) is proof
the projection has gone stale, not a reason to bet everything on it, so it
falls back to the Elo rate — the same escape hatch nll-simulator uses. The
blend weight is also clamped inside the helper now, so a stray config value
cannot turn it into an extrapolation.
Seeding only applies a projection that actually produced the resolved Elo,
gated on metadata.sourceEloMethod via projectionForSeeding. Previously the raw
projection drove seeding regardless of the input policy: with the default
Elo-first ordering a projection was ignored as the Elo source yet still
dictated the standings, and with futures odds blended in at oddsWeight,
seeding and playoff matchups ran on two different strength scales.
The simulator page's preview resolved its Elo and rating maps only for
required inputs, but renders those columns solely from the maps, so stored
values displayed as "—" for profiles that treat the input as optional
(playoff_bracket, ncaam_bracket, golf_qualifying_points). Both maps now key
off the same required-plus-optional set the columns do.
The metadata upsert's CASE branches were mutually exclusive, so supplying any
metadata skipped the stale-flag clearing: a bulk row carrying both a direct
rating and a projection kept a stale ratingMethod and hid the rating it had
just set. Stripping now always runs, with the caller's metadata merged over
the result.
Not changed: projectedWinsWeight still defaults to 1 with no decay toward Elo.
That is the final-win-total semantics chosen for this work; the stale-target
fallback removes its pathological case.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CQSEmmojmqmGdJttgzqCWK
2026-08-29 06:18:41 +00:00
) || COALESCE ( excluded . metadata , '{}' : : jsonb ) ` ,
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
updatedAt : sql ` excluded.updated_at ` ,
} ,
} ) ;
// Compatibility bridge: keep legacy simulator metadata columns populated until
// every simulator has been migrated to season_participant_simulator_inputs.
await tx
. insert ( schema . seasonParticipantExpectedValues )
. values (
inputs . map ( ( input ) = > ( {
participantId : input.participantId ,
sportsSeasonId : input.sportsSeasonId ,
probFirst : "0" ,
probSecond : "0" ,
probThird : "0" ,
probFourth : "0" ,
probFifth : "0" ,
probSixth : "0" ,
probSeventh : "0" ,
probEighth : "0" ,
expectedValue : "0" ,
source : input.sourceOdds !== null && input . sourceOdds !== undefined ? "futures_odds" as const : "elo_simulation" as const ,
sourceOdds : input.sourceOdds ? ? null ,
sourceElo : input.sourceElo ? ? null ,
worldRanking : input.worldRanking ? ? null ,
calculatedAt : now ,
updatedAt : now ,
} ) )
)
. onConflictDoUpdate ( {
target : [
schema . seasonParticipantExpectedValues . participantId ,
schema . seasonParticipantExpectedValues . sportsSeasonId ,
] ,
set : {
sourceOdds : sql ` COALESCE(excluded.source_odds, ${ schema . seasonParticipantExpectedValues . sourceOdds } ) ` ,
sourceElo : sql ` COALESCE(excluded.source_elo, ${ schema . seasonParticipantExpectedValues . sourceElo } ) ` ,
worldRanking : sql ` COALESCE(excluded.world_ranking, ${ schema . seasonParticipantExpectedValues . worldRanking } ) ` ,
updatedAt : now ,
} ,
} ) ;
} ) ;
}
export async function validateSimulatorReadiness (
sportsSeasonId : string
) : Promise < SimulatorReadiness > {
const config = await getSportsSeasonSimulatorConfig ( sportsSeasonId ) ;
const inputs = await getParticipantSimulatorInputs ( sportsSeasonId ) ;
const missingInputs : string [ ] = [ ] ;
const warnings : string [ ] = [ ] ;
let derivedInputCount = 0 ;
let fallbackInputCount = 0 ;
if ( ! config ) {
return {
status : "not_ready" ,
canRun : false ,
participantCount : inputs.length ,
participantInputCount : 0 ,
derivedInputCount ,
fallbackInputCount ,
missingInputs : [ "simulator type" ] ,
warnings ,
} ;
}
const participantCount = inputs . length ;
if ( participantCount === 0 ) {
missingInputs . push ( "participants" ) ;
}
const requiredInputs = config . profile . requiredInputs ;
const resolvedSourceElos = requiredInputs . includes ( "sourceElo" )
? resolveSourceElos ( inputs , config . profile , config . config )
: new Map ( ) ;
const resolvedRatings = requiredInputs . includes ( "rating" )
? resolveRatings ( inputs , config . profile , config . config )
: new Map ( ) ;
derivedInputCount =
[ . . . resolvedSourceElos . values ( ) ] . filter ( ( input ) = > input . method !== "direct" ) . length +
[ . . . resolvedRatings . values ( ) ] . filter ( ( input ) = > input . method !== "direct" ) . length ;
2026-05-12 22:26:25 -07:00
fallbackInputCount =
[ . . . resolvedSourceElos . values ( ) ] . filter ( ( input ) = > isFallbackMethod ( input . method ) ) . length +
[ . . . resolvedRatings . values ( ) ] . filter ( ( input ) = > isFallbackMethod ( input . method ) ) . length ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
function hasRequiredInput ( input : ParticipantSimulatorInput , key : SimulatorInputKey ) : boolean {
if ( hasInputValue ( input , key ) ) return true ;
if ( key === "sourceElo" ) return resolvedSourceElos . has ( input . participantId ) ;
if ( key === "rating" ) return resolvedRatings . has ( input . participantId ) ;
return false ;
}
const participantInputCount = requiredInputs . length === 0
? participantCount
: inputs . filter ( ( input ) = > requiredInputs . every ( ( key ) = > hasRequiredInput ( input , key ) ) ) . length ;
if ( requiredInputs . length > 0 && participantInputCount < participantCount ) {
for ( const key of requiredInputs ) {
const missingCount = inputs . filter ( ( input ) = > ! hasRequiredInput ( input , key ) ) . length ;
if ( missingCount > 0 ) {
missingInputs . push ( ` ${ inputRequirementLabel ( key , config . profile ) } for ${ missingCount } participant(s) ` ) ;
}
}
}
if ( derivedInputCount > 0 ) {
warnings . push ( ` Derived simulator inputs will be used for ${ derivedInputCount } participant(s). ` ) ;
}
if ( fallbackInputCount > 0 ) {
2026-05-12 22:26:25 -07:00
warnings . push ( ` Configured fallback simulator inputs will be used for ${ fallbackInputCount } participant(s). ` ) ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
}
if ( config . simulatorType === "cs2_major_qualifying_points" ) {
const missingRankCount = inputs . filter ( ( input ) = > ! hasInputValue ( input , "worldRanking" ) ) . length ;
if ( missingRankCount > 0 ) {
warnings . push (
` CS2 will use rank 9999 for ${ missingRankCount } participant(s) without world ranking; this is acceptable for deep-tail teams but less precise. `
) ;
}
}
2026-08-17 22:50:35 +00:00
if ( RACE_CALENDAR_SIMULATORS . includes ( config . simulatorType ) ) {
// Without a calendar the simulator cannot tell how many races are left, so
// it falls back to futures odds and ignores the championship standings
// entirely. A warning, not a blocker — a season drafted before the schedule
// is published still needs to run.
const races = await countSeasonRaces ( sportsSeasonId ) ;
if ( races . total === 0 ) {
warnings . push (
"No race calendar found for this season. Add the schedule on the events page — until then the simulation uses futures odds only and ignores championship standings."
) ;
}
}
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
if ( config . profile . setupSections . includes ( "regularStandings" ) ) {
warnings . push ( "Regular-season standings may be needed for in-season accuracy." ) ;
}
if ( config . profile . setupSections . includes ( "bracket" ) ) {
warnings . push ( "Bracket-aware simulators use completed bracket results when available." ) ;
}
const status = missingInputs . length === 0 ? "ready" : "not_ready" ;
return {
status ,
canRun : status === "ready" ,
participantCount ,
participantInputCount ,
derivedInputCount ,
fallbackInputCount ,
missingInputs ,
warnings ,
} ;
}
export async function prepareSimulatorInputsForRun ( sportsSeasonId : string ) : Promise < void > {
const config = await getSportsSeasonSimulatorConfig ( sportsSeasonId ) ;
if ( ! config ) return ;
const inputs = await getParticipantSimulatorInputs ( sportsSeasonId ) ;
const resolvedSourceElos = config . profile . requiredInputs . includes ( "sourceElo" )
? resolveSourceElos ( inputs , config . profile , config . config )
: new Map ( ) ;
const resolvedRatings = config . profile . requiredInputs . includes ( "rating" )
? resolveRatings ( inputs , config . profile , config . config )
: new Map ( ) ;
const rowsToUpsert : UpsertParticipantSimulatorInput [ ] = [ ] ;
for ( const input of inputs ) {
const resolvedElo = resolvedSourceElos . get ( input . participantId ) ;
const resolvedRating = resolvedRatings . get ( input . participantId ) ;
if ( ! resolvedElo && ! resolvedRating ) continue ;
rowsToUpsert . push ( {
participantId : input.participantId ,
sportsSeasonId ,
sourceOdds : input.sourceOdds ,
sourceElo : resolvedElo?.sourceElo ? ? input . sourceElo ,
worldRanking : input.worldRanking ,
rating : resolvedRating?.rating ? ? input . rating ,
projectedWins : input.projectedWins ,
projectedTablePoints : input.projectedTablePoints ,
seed : input.seed ,
region : input.region ,
metadata : {
. . . input . metadata ,
. . . ( resolvedElo ? { sourceEloMethod : resolvedElo.method } : { } ) ,
. . . ( resolvedRating ? { ratingMethod : resolvedRating.method } : { } ) ,
} ,
} ) ;
}
await batchUpsertParticipantSimulatorInputs ( rowsToUpsert ) ;
}
export async function listSportsSeasonSimulatorSummaries ( ) : Promise < SportsSeasonSimulatorSummary [ ] > {
const db = database ( ) ;
const seasons = await db . query . sportsSeasons . findMany ( {
with : {
sport : true ,
fantasySeason : { with : { league : true } } ,
participants : { columns : { id : true } } ,
simulatorConfig : true ,
} ,
} ) ;
const simulatorSeasons = seasons . filter ( ( season ) = > {
const simulatorType = season . simulatorConfig ? . simulatorType ? ? season . sport . simulatorType ;
if ( ! isSimulatorType ( simulatorType ) ) return false ;
// Brackt's public sports season is a draftable template. The runnable
// simulations are the per-league private Brackt seasons.
if ( season . sport . slug === "brackt" ) return season . fantasySeasonId !== null ;
return season . fantasySeasonId === null ;
} ) ;
const seasonIds = simulatorSeasons . map ( ( season ) = > season . id ) ;
const snapshotRows = seasonIds . length > 0
? await db
. select ( {
sportsSeasonId : schema.participantEvSnapshots.sportsSeasonId ,
lastSimulatedDate : sql < string > ` max( ${ schema . participantEvSnapshots . snapshotDate } ) ` ,
} )
. from ( schema . participantEvSnapshots )
. where ( inArray ( schema . participantEvSnapshots . sportsSeasonId , seasonIds ) )
. groupBy ( schema . participantEvSnapshots . sportsSeasonId )
: [ ] ;
const lastSnapshotBySeason = new Map ( snapshotRows . map ( ( row ) = > [ row . sportsSeasonId , row . lastSimulatedDate ] ) ) ;
2026-06-26 05:16:54 +00:00
const oddsCountRows = seasonIds . length > 0
? await db
. select ( {
sportsSeasonId : schema.seasonParticipantSimulatorInputs.sportsSeasonId ,
oddsCount : sql < number > ` count(*)::int ` ,
} )
. from ( schema . seasonParticipantSimulatorInputs )
. where (
and (
inArray ( schema . seasonParticipantSimulatorInputs . sportsSeasonId , seasonIds ) ,
isNotNull ( schema . seasonParticipantSimulatorInputs . sourceOdds )
)
)
. groupBy ( schema . seasonParticipantSimulatorInputs . sportsSeasonId )
: [ ] ;
const oddsCountBySeason = new Map ( oddsCountRows . map ( ( row ) = > [ row . sportsSeasonId , Number ( row . oddsCount ) ] ) ) ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
// Each season calls getSimulatorProfile + validateSimulatorReadiness (~4 queries each).
// Acceptable for an admin-only page; revisit if season counts grow past ~50.
const summaries = await Promise . all (
simulatorSeasons . map ( async ( season ) = > {
const simulatorType = ( season . simulatorConfig ? . simulatorType ? ? season . sport . simulatorType ) as SimulatorType ;
const profile = await getSimulatorProfile ( simulatorType ) ;
const readiness = await validateSimulatorReadiness ( season . id ) ;
2026-06-26 07:26:55 +00:00
const mergedConfig = { . . . profile . defaultConfig , . . . season . simulatorConfig ? . config } ;
2026-06-26 05:16:54 +00:00
const policy = getSimulatorInputPolicy ( mergedConfig ) ;
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
return {
sportsSeasonId : season.id ,
seasonName : season.name ,
year : season.year ,
seasonStatus : season.status ,
simulationStatus : season.simulationStatus ,
fantasySeasonId : season.fantasySeasonId ,
fantasySeasonName : season.fantasySeason ? ` ${ season . fantasySeason . year } Season ` : null ,
leagueName : season.fantasySeason?.league?.name ? ? null ,
sportName : season.sport.name ,
sportSlug : season.sport.slug ,
simulatorType ,
simulatorName : profile.displayName ,
participantCount : season.participants.length ,
participantInputCount : readiness.participantInputCount ,
lastSimulatedDate : lastSnapshotBySeason.get ( season . id ) ? ? null ,
readiness ,
2026-06-26 05:16:54 +00:00
supportsFuturesOdds : profile.setupSections.includes ( "futuresOdds" ) ,
oddsParticipantCount : oddsCountBySeason.get ( season . id ) ? ? 0 ,
oddsWeight : policy.oddsWeight ,
Formalize simulator system with manifest, input-policy, runner, and admin UI (#409)
Introduces three new schema tables (simulator_profiles,
sports_season_simulator_configs, season_participant_simulator_inputs),
a central model layer (app/models/simulator.ts), and a single runner
entry point so every simulator run follows the same prepare → simulate
→ persist → snapshot → recalculate flow.
Key additions:
- manifest.ts: per-simulator display names, default configs, required/
optional inputs, derivable-input declarations, and setup sections
- input-policy.ts: resolves sourceElo from projectedWins,
projectedTablePoints, or sourceOdds; resolves ratings from sourceOdds;
supports block / fallbackElo / averageKnown / worstKnownMinus strategies
- runner.ts: single entry point for admin simulation runs; materialises
derived inputs, normalises result columns, zeroes omitted participants,
snapshots EVs, and recalculates linked fantasy standings
- /admin/simulators: inventory page with per-season readiness and bulk run
- /admin/sports-seasons/:id/simulator: per-season setup page with readiness
summary, input-policy editor, raw JSON config override, and CSV bulk input
- NCAAM/NCAAW simulators now read ratings from season_participant_simulator_inputs,
falling back to the hardcoded name-keyed maps while DB data is being populated
- Clone flow copies simulator config by default; volatile inputs (odds, Elo)
only copied when explicitly requested
Code-review fixes included in this commit:
- source field in compatibility bridge checked with !== null instead of !== undefined
- sourceEloRequirementLabel no longer appends "configured fallback" when the
participant is already excluded from all resolved sources
- Duplicate inline label maps in input-policy.ts replaced with simulatorInputLabel
- save-config preserves existing inputPolicy when the submitted JSON omits it
- Input table truncation label added (Showing 20 of N)
- CSV description notes values must not contain commas
- N+1 comment added to listSportsSeasonSimulatorSummaries
- assertRegistrySchemaDriftFree called in manifest tests
- Runner test suite added covering happy path, already-running guard,
readiness failure, empty results, and error recovery with status reset
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 21:09:53 -07:00
} ;
} )
) ;
return summaries . toSorted ( ( a , b ) = >
a . sportName . localeCompare ( b . sportName ) ||
( a . leagueName ? ? "" ) . localeCompare ( b . leagueName ? ? "" ) ||
b . year - a . year ||
a . seasonName . localeCompare ( b . seasonName )
) ;
}
export async function assertRegistrySchemaDriftFree ( ) : Promise < void > {
const schemaValues = schema . simulatorTypeEnum . enumValues ;
const missingFromSchema = SIMULATOR_TYPES . filter ( ( type ) = > ! schemaValues . includes ( type ) ) ;
const missingFromRegistry = schemaValues . filter ( ( type ) = > ! ( SIMULATOR_TYPES as readonly string [ ] ) . includes ( type ) ) ;
if ( missingFromSchema . length > 0 || missingFromRegistry . length > 0 ) {
throw new Error (
` Simulator registry/schema drift detected. ` +
` Missing from schema: ${ missingFromSchema . join ( ", " ) || "none" } . ` +
` Missing from registry: ${ missingFromRegistry . join ( ", " ) || "none" } . `
) ;
}
}