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

19 KiB

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_idtournaments.id
  • season_participants.participant_idparticipants.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):

  • participantsseason_participants
  • participant_expected_valuesseason_participant_expected_values
  • participant_qualifying_totalsseason_participant_qualifying_totals
  • participant_resultsseason_participant_results
  • participant_surface_elosseason_participant_surface_elos
  • event_results.participant_idevent_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).