# Canonical Tournament & Participant Layer **Date:** 2026-05-01 **Status:** Proposed ## Overview Qualifying-points sports (golf, tennis, CS2) use a four-event "season" that can roll across calendar boundaries (e.g., a June 2026 tennis window needs Wimbledon 2026, US Open 2026, Australian Open 2027, French Open 2027). Today the data model ties events and participants to a single `sportsSeasons` row, which means two overlapping windows must each have their own copy of Wimbledon 2026, their own Djokovic, their own surface Elo. Any change — results, corrections, Elo adjustments — must be applied to every copy. This design introduces a **canonical layer** above the existing per-window layer: - **Canonical** tables own real-world identity (tournaments, participants, final results, surface Elo). - **Per-window** tables keep their role: which tournaments a window uses, which participants its draft pool contains, and window-specific derived data (EV, QP totals, QP config). Results entered once on the canonical tournament fan out to every window that contains it. Surface Elo edited on a canonical participant propagates automatically. Creating a new rolling window is a point-and-click composition over the canonical data. Per-window data that is inherently window-specific (expected value under a particular 4-event set, QP running totals, QP point configuration) stays per-window — because the math depends on which 4 events you're summing over. ## Scope - **In scope:** All `scoringPattern='qualifying_points'` sports — golf, tennis, CS2. The model is sport-agnostic, but migration covers these three. - **Out of scope:** Team sports (NHL, MLB, NBA, WNBA, UCL, NCAAM/W), single-event sports (darts and snooker use only the World Championship — no rolling window problem). These keep their existing model. - **Out of scope:** Historical fully-completed windows. No league currently has a completed qualifying-points sportsSeason, so there's nothing to backfill for reporting. ## Architecture ### Canonical tables (new) ``` tournaments id uuid pk sport_id fk → sports name varchar -- "Wimbledon" year integer starts_at timestamp ends_at timestamp surface varchar nullable -- tennis only: hard | clay | grass location varchar nullable status enum -- scheduled | in_progress | completed external_key varchar nullable -- reserved for future ATP/PGA/HLTV IDs created_at, updated_at UNIQUE (sport_id, name, year) participants -- RENAMED; see migration section id uuid pk sport_id fk → sports name varchar external_key varchar nullable metadata jsonb created_at, updated_at UNIQUE (sport_id, name) tournament_results id uuid pk tournament_id fk → tournaments participant_id fk → participants placement integer raw_score decimal nullable created_at, updated_at UNIQUE (tournament_id, participant_id) participant_surface_elos -- tennis-specific; replaces the existing per-window table of the same name id uuid pk participant_id fk → participants (canonical) world_ranking integer nullable elo_hard integer nullable elo_clay integer nullable elo_grass integer nullable updated_at UNIQUE (participant_id) ``` **Shape note:** Kept wide format (`elo_hard`/`elo_clay`/`elo_grass`) to match the existing per-window `participant_surface_elos` table, minimizing simulator read-path churn. `world_ranking` moves here because it's a real-world attribute of the participant, not a per-window value. **Name collision handling:** The existing per-window `participant_surface_elos` table is renamed to `season_participant_surface_elos` in Phase 1 before the new canonical table of the same name is created. ### Per-window tables (renamed / extended) The existing `participants` table is renamed to `season_participants`. Related tables follow the same renaming pattern for clarity: | Old name | New name | |---|---| | `participants` | `season_participants` | | `participant_expected_values` | `season_participant_expected_values` | | `participant_qualifying_totals` | `season_participant_qualifying_totals` | | `participant_results` | `season_participant_results` | | `event_results.participant_id` | `event_results.season_participant_id` | | `participant_surface_elos` (existing per-window) | `season_participant_surface_elos` (renamed in Phase 1); removed entirely in Phase 4 after canonical values verified | FK additions (nullable at Phase 1, NOT NULL after Phase 2 backfill): - `scoring_events.tournament_id` → `tournaments.id` - `season_participants.participant_id` → `participants.id` Unchanged: `sports_seasons`, `season_sports`, `qualifying_point_config`, `draft_picks`, `teams`, `draft_slots`, `watchlist`, scoring calculation code in `app/models/scoring-calculator.ts`, simulators' Monte Carlo logic. ### Source of truth | Concept | Table | |---|---| | Tournament existence & final placements | `tournament_results` (canonical) | | Window → tournament mapping | `scoring_events` via `tournament_id` | | Canonical participant identity | `participants` | | Window's draftable roster | `season_participants` | | Per-window derived scoring (QP awarded, totals, EV) | `season_participant_*`, `event_results` | | Surface Elo & world ranking | `participant_surface_elos` (canonical) | ## Data Flow ### Creating a new window Admin flow has two new steps during `sports_seasons` creation for qualifying-points sports: 1. **Pick tournaments** — multi-select from `tournaments` filtered by `sport_id`. Inline create if missing. Writes `scoring_events` rows with `tournament_id` set. 2. **Pick roster** — multi-select from `participants` filtered by `sport_id`. Writes `season_participants` rows with `participant_id` set. Inline-create canonical participants if missing. Downstream (EV upload, QP config edit, draft) is unchanged. ### Entering results (enter-once flow) New admin route: `/admin/tournaments/:id`. 1. Admin enters placements for a canonical tournament. UI supports the same paste-and-parse flow as the existing `2026-04-12-batch-qualifying-results` design, but targets the canonical tournament. 2. Save writes `tournament_results` rows (upsert on `(tournament_id, participant_id)`). 3. `syncTournamentResults(tournamentId)` fans out: - For each `scoring_events` row with `tournament_id = tournamentId`: - For each participant in `tournament_results` whose canonical `participant_id` also appears in that window's `season_participants`: upsert a matching `event_results` row with `placement` and `raw_score`. - Apply existing "fill 0-QP for non-scoring season participants" behavior per the batch qualifying results design. - Run the existing `processQualifyingEvent` (idempotent — replaces QP, doesn't add) against that window's scoring event. - Update `season_participant_qualifying_totals` per the existing path. - Each window's fan-out is its own transaction. Failures are logged per-window with a retry action in the admin UI. 4. Tournament status auto-advances to `completed` on first result insert. ### Corrections Edit `tournament_results` → re-run `syncTournamentResults`. Idempotent updates to every affected window. No stale data unless sync fails (surfaced in UI). ### Surface Elo Read path: `app/models/surface-elo.ts`'s `getSurfaceEloMap(sportsSeasonId)` joins `season_participants → participants → participant_surface_elos` (canonical). Admin UI for surface Elo edits canonical `participant_surface_elos` directly, keyed on canonical participant. Edit affects all current and future windows containing that participant. ## Migration Plan Four phases. Each is reversible until the next begins. ### Phase 1 — Schema (data untouched) Two sequential Drizzle migrations, in order, to avoid name collisions between the old per-window tables and the new canonical tables: **Phase 1a — Renames of existing tables (no new tables yet):** - `participants` → `season_participants` - `participant_expected_values` → `season_participant_expected_values` - `participant_qualifying_totals` → `season_participant_qualifying_totals` - `participant_results` → `season_participant_results` - `participant_surface_elos` → `season_participant_surface_elos` - `event_results.participant_id` → `event_results.season_participant_id` Update every code reference, Drizzle relations, model file names, and type imports. Existing `app/models/participant.ts` is renamed to `app/models/season-participant.ts`. `npm run typecheck` and `npm run test:run` must pass before proceeding to Phase 1b. **Phase 1b — New canonical tables:** - Create `tournaments`, `participants` (canonical), `tournament_results`, `participant_surface_elos` (canonical). - Add nullable FKs: `scoring_events.tournament_id`, `season_participants.participant_id`. - A new `app/models/participant.ts` is created for the canonical table (the old model file was renamed in 1a, freeing the name). No logic paths read from canonical tables yet. Reversible: run migrations in reverse (drop canonical tables first, then reverse the renames). ### Phase 2 — Backfill One-off script `scripts/backfill-canonical-layer.ts` with `--dry-run` and `--sport=` flags. For each `sports_seasons` where `scoring_pattern='qualifying_points'`: 1. Upsert `tournaments` rows by `(sport_id, name, year)` from the window's `scoring_events`. Set `scoring_events.tournament_id`. 2. Upsert `participants` rows by `(sport_id, name)` from the window's `season_participants`. Set `season_participants.participant_id`. 3. For each `event_results` row with `qualifying_points_awarded IS NOT NULL` (i.e., already-scored events like Masters 2026): upsert a `tournament_results` row with `placement` and `raw_score`. **Do not copy `qualifying_points_awarded`** — QP stays on `event_results` because it's window-specific. 4. For each row in `season_participant_surface_elos` (the renamed per-window table): upsert into canonical `participant_surface_elos` keyed by canonical `participant_id`, copying `elo_hard`, `elo_clay`, `elo_grass`, `world_ranking`. Abort if two windows disagree on a canonical participant's values (not expected today — fail loud if encountered). Dry-run writes nothing; emits a CSV + summary to stdout. Go/no-go: dry-run reviewed by maintainer, real run matches dry-run counts exactly, spot-check Masters 2026 `tournament_results` row matches its `event_results` row. Reversible: truncate canonical tables, null out FK columns. Old code paths still functional. ### Phase 3 — Cutover - `ALTER` `scoring_events.tournament_id` and `season_participants.participant_id` to NOT NULL. - Implement `app/models/tournament.ts`, `app/models/participant.ts` (canonical), `app/models/tournament-result.ts`. - Implement `app/services/sync-tournament-results.ts`. - Add admin route `app/routes/admin.tournaments.$id.tsx` with paste-and-parse result entry. - Add admin "create window" flow additions for tournament + roster picking. - Switch surface Elo admin UI and `getSurfaceEloMap` to canonical read/write. - Switch tennis simulator's surface Elo source to canonical. - Existing per-window "Add Result" / batch entry routes become thin wrappers that write canonical + call sync. (They are removed in Phase 4.) Feature-flag the new admin entry route; cut over during a no-draft window. Go/no-go: new window created end-to-end in staging via canonical flow; all pre-Phase-1 baselines match post-Phase-3 values. ### Phase 4 — Cleanup - Remove per-window result entry wrappers. - Drop `season_participant_surface_elos` table via migration after verifying canonical `participant_surface_elos` is fully populated and all code paths read from canonical. - Remove any compatibility shims. ## Testing Matrix Required types: **U** unit, **M** model integration (real DB), **E** end-to-end (Cypress). ### Baselines to capture before Phase 1 1. Monte Carlo simulator output JSON for each in-flight qualifying-points window, committed as a test fixture. 2. Snapshot of `participant_qualifying_totals` (pre-rename; post-Phase-1a this is `season_participant_qualifying_totals`) per window. 3. Snapshot of each window's `event_results` for completed events (Masters 2026, any completed CS2 majors). These are used to verify no scoring drift across phases. ### Phase 1 — Schema | What | Where | Type | Scenarios | |---|---|---|---| | Drizzle migration applies cleanly | staging run + `npm run db:migrate` | — | Forward run; re-run `db:generate` and confirm no drift | | Renames propagate in code | `npm run typecheck`, `npm run test:run` | — | Pass | | Relations load | existing model tests (updated for renames) | M | All pass | ### Phase 2 — Backfill script | What | Where | Type | Scenarios | |---|---|---|---| | Backfill dry-run | `scripts/__tests__/backfill-canonical-layer.test.ts` (new) | M | (a) Empty DB → no-op. (b) One in-flight golf window with 4 events incl. Masters 2026 → 4 tournaments, N canonical participants, N `tournament_results` rows. (c) Two tennis windows with overlapping participants → one canonical participant per (sport, name). (d) Conflicting canonical rows pre-existing → script aborts with clear error. (e) Dry-run writes nothing; real run matches dry-run CSV exactly. | | Surface Elo backfill | same test file | M | All existing surface Elo rows translate to `participant_surface_elos (canonical)`; counts match; conflicts abort. | | Masters 2026 round-trip | same test file | M | Post-backfill: `tournament_results` for Masters 2026 matches `event_results` placement/raw_score; `qualifying_points_awarded` untouched on `event_results`; `season_participant_qualifying_totals` untouched. | | Baseline verification | `scripts/__tests__/verify-baselines.test.ts` (new) | M | `season_participant_qualifying_totals` post-backfill matches pre-Phase-1 baseline snapshot of `participant_qualifying_totals` (they should be identical — backfill doesn't touch QP). | ### Phase 3 — New code paths | What | Where | Type | Scenarios | |---|---|---|---| | `app/models/tournament.ts` | `app/models/__tests__/tournament.test.ts` | M | CRUD, unique `(sport_id, name, year)`, `findBySport`, status auto-advance. | | `app/models/participant.ts` (canonical) | `app/models/__tests__/participant.test.ts` | M | CRUD, unique `(sport_id, name)`, find-or-create. | | `app/models/tournament-result.ts` | `app/models/__tests__/tournament-result.test.ts` | M | Upsert semantics, placement updates, idempotency. | | `app/services/sync-tournament-results.ts` | `app/services/__tests__/sync-tournament-results.test.ts` | M | (a) Single window, roster matches all result participants → `event_results` created, QP processed. (b) Two windows share a tournament with overlapping but different rosters → only matching participants get `event_results` in each window; QP processed independently per window. (c) Participant in roster but not in `tournament_results` → 0-QP row (matches existing batch finalize). (d) Re-run with same data → idempotent, no duplicates. (e) Re-run after placement correction → all windows updated, QP re-processed. (f) One window's sync fails → others still succeed; failure logged and retryable. | | Admin `/admin/tournaments/:id` route | `app/routes/__tests__/admin.tournaments.$id.test.tsx` (new) | M | Loader returns tournament + results; action upserts + triggers sync; non-admin → 403. | | Admin create-window flow | `cypress/e2e/admin-create-qualifying-window.cy.ts` (new) | E | Admin picks 4 tournaments + roster → window created with FKs populated; draft room loads. | | Result entry fan-out | `cypress/e2e/enter-tournament-result.cy.ts` (new) | E | Two windows share Wimbledon 2026; admin enters Wimbledon results once; both windows' QP standings update. | | Surface Elo canonical | `app/models/__tests__/surface-elo.test.ts` (update) | M | Reads and writes go to canonical `participant_surface_elos`; tennis simulator receives correct values; world_ranking reads correctly. | | Tennis simulator surface Elo | `app/services/simulations/__tests__/tennis-simulator.test.ts` (update) | U | Fed canonical surface Elo; Monte Carlo output matches baseline fixture within tolerance. | | Back-compat wrappers for old per-window routes | existing route tests (update) | M | Writes via old routes still succeed and produce identical `event_results`. | | Masters 2026 regression | `scripts/__tests__/verify-baselines.test.ts` | M | Post-Phase-3 QP standings for existing golf window match pre-Phase-1 baseline exactly. | ### Phase 4 — Cleanup | What | Where | Type | Scenarios | |---|---|---|---| | Old result-entry routes removed | removed test files | — | Routes return 404. | | Drop `season_participant_surface_elos` | staging verification | — | Canonical `participant_surface_elos` fully populated; `season_participant_surface_elos` no longer read by any code path; drop migration applies cleanly. | | Full regression | `npm run test:all` | — | Green. | ### Cross-cutting smoke tests (after every phase) 1. Load an in-flight golf draft room → Masters 2026 results render, QP standings match baseline. 2. Run tennis simulator for an in-flight window → Monte Carlo output matches baseline fixture within tolerance. 3. Create a fresh draft for an existing window → draft picks save (no NULL FK errors). ## Risks & Mitigations **R1. Name collision for canonical participants.** `UNIQUE (sport_id, name)` is correct for today's roster sizes. `external_key` reserved for future disambiguation via ATP/PGA/HLTV IDs. **R2. CS2 team-name churn.** Rebranded team is a new canonical participant. Intentional — matches current fantasy semantics. **R3. Masters 2026 double-scoring.** `processQualifyingEvent` is already idempotent (replaces QP). Explicit test in Phase 2 + Phase 3 matrix. **R4. Sync fan-out partial failure.** Per-window transactions. Each window logs success/failure independently. Admin UI shows "synced to X of N windows" with per-window retry. **R5. Simulator output drift.** Baseline Monte Carlo output captured before Phase 1 and compared after each subsequent phase. **R6. Draft-in-progress during cutover.** Phase 3 deployed behind a feature flag, enabled during a no-draft window. **R7. Admin muscle memory.** Old routes stay as wrappers through Phase 3; removed in Phase 4. Release notes accompany Phase 3 cutover. ## Out of Scope / Flagged for Later - Backfill of fully-completed historical windows: no such windows exist today. - Automated import of tournament results from external feeds (ATP/PGA APIs). `external_key` column reserves space for this. - Cross-sport disambiguation for identical names (extremely rare; punted until real). - Merge tool for canonical participants if a duplicate is created accidentally (can be added later; not a v1 blocker given admin-only data entry).