-- 048 — EXPAND: event-aware retention proposition identity. -- -- WHY. The retention conflict identity is -- model_snapshots_cycle_prop_uniq (snapshot_id, player_key, stat, line, side) -- which carries no event component. An MLB doubleheader — the same hitter, the -- same stat, the same line, in two genuinely different games — is therefore ONE -- identity, and `ignoreDuplicates` discards the second row with no error while -- transport reports COMPLETE. Verified on production data: 2026-08-17 -- St. Louis @ Cincinnati holds 1,738 retained rows under a single game_id. -- -- WHAT. An additive unique index that adds the EVENT and nothing else. -- -- game_id AND canonical_event_id are both included, on purpose: -- * canonical_event_id splits a doubleheader (game_id is byte-identical for -- both halves), and MLB's admission gate makes it non-null for every row -- that can reach retention; -- * game_id is NOT NULL in the schema and is the only event label sports -- without a canonical resolver possess, so the weaker label still -- discriminates where the stronger one is absent. Nothing is fabricated. -- -- NULLS NOT DISTINCT is required, not stylistic. canonical_event_id is NULL for -- every non-MLB row, and under PostgreSQL's default NULL semantics two NULLs -- are DISTINCT — measured on a disposable PG17: the same NBA proposition -- inserted twice produced TWO rows, i.e. every retry would duplicate for ever. -- With NULLS NOT DISTINCT the same test yields one row. -- -- SAFE TO CREATE. Measured over all 434 real cohorts / 328,262 retained rows, -- the new identity produces exactly 328,262 identities — identical to the old -- count, 0 cohorts changed. Adding columns can only split, and legacy rows all -- carry canonical_event_id IS NULL with one game_id per proposition, so no -- existing row can violate it. -- -- THIS MIGRATION DOES NOT RETIRE THE OLD INDEX. Both coexist deliberately so a -- rolling deploy keeps working; the old index is dropped in 049 only after the -- fleet is proven converged onto a writer that no longer needs it. -- -- NO BACKFILL. No historical value is written or rewritten. Legacy rows remain -- LEGACY EVENT-AGNOSTIC RETENTION, and their NULL canonical_event_id continues -- to say exactly that. create unique index if not exists model_snapshots_cycle_event_prop_uniq on public.model_snapshots (snapshot_id, game_id, canonical_event_id, player_key, stat, line, side) nulls not distinct; comment on index public.model_snapshots_cycle_event_prop_uniq is 'Event-aware retention proposition identity. Supersedes model_snapshots_cycle_prop_uniq, which merged doubleheaders. NULLS NOT DISTINCT so a NULL canonical_event_id (every non-MLB row) still deduplicates on retry.';