brackt/docs/superpowers/specs/2026-05-01-canonical-tournament-layer-design.md
Chris Parsons 81af8907cc
Add design + 5 phase plans: canonical tournament & participant layer
Spec and phased implementation plans for eliminating data duplication
across overlapping qualifying-points windows (golf, tennis, CS2) by
introducing canonical tournaments, participants, tournament_results,
and surface-Elo tables above the existing per-window layer.

- Phase 1a: rename existing per-window tables to season_* prefix
- Phase 1b: create canonical tables + nullable FKs
- Phase 2:  backfill canonical from existing in-flight windows
- Phase 3:  cutover - sync service, admin UI, simulator read switch
- Phase 4:  cleanup - drop deprecated tables and routes

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 05:20:34 +00:00

287 lines
19 KiB
Markdown

# 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=<id>` 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).