Skip to content

adr: 0022 title: "Comprehensive event-bump coverage; key normalisation in EventBump" status: accepted implementation-status: implemented date: 2026-06-04 implemented-date: 2026-06-04 implemented-in: - ftl-backend feat/event-bump-coverage deciders: [@vaishnav-s-01] affects-specs: [amm-pricing] affects-code: - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/sportmonks/events_feed.go - ftl-backend/migrations/000040_normalize_and_extend_event_bumps.up.sql supersedes: null superseded-by: null


ADR-0022: Comprehensive event-bump coverage; key normalisation in EventBump

Context

The match chart's marker overlay (PR ftl-frontend#132, PriceChart.tsx) draws an icon for every Sportmonks event the ticker writes to match_events:<fixture> — goals, cards, subs, but also corner, foul, foul_drawn, shot_blocked, dispossessed, long_ball, error_leading_to_goal, save, and so on. The icons are drawn off a single REST poll (GET /api/matches/:id/events) that returns the entire feed list without filtering.

The price path, however, only nudged a subset of those types. defaultEventBumps in ftl-backend/internal/sportmonks/scoring.go (line 570) shipped 18 entries — goals, penalties, assists, cards, subs, corners, free kicks, throw-ins, fouls, offsides, VAR — and missed every stat-delta type (foul_drawn, save, dispossessed, long_ball, error_leading_to_goal, shot_blocked). When EventBump(typeName) returned 0 for those, ticker.go:374 did a continue that skipped the entire price-update block. The user-visible result was the chart showing event icons land while the price line stayed flat — a contradiction of the project requirement that "if any event occurs, +ve → up, -ve → down".

A second issue surfaced while triaging the first: the existing keys were a mix of dashes (own-goal, penalty-missed, throw-in) and spaces (yellow card, free kick), but the actual lookup at ticker.go:373 called EventBump with the raw Sportmonks Type.Name ("Yellow Card", "Own Goal", "Penalty Missed"). EventBump did a single strings.ToLower and looked up the result. So eventBumps["own goal"] (input lowercased) missed the "own-goal" key, and the same applied to several other types — they were nominally "in the table" but never actually fired.

The noise generator already enforces the requirement on the cosmetic side (ADR-0021); the durable channel — event_scorelive_base_price — was the path that needed both expansion and a key-normalisation fix.

Decision

We will expand defaultEventBumps to cover every event type the chart can render, and route every lookup through normalizeEventType, so a raw Sportmonks string always resolves to the canonical underscored key.

Concrete changes:

  1. Underscored canonical keys. defaultEventBumps is rewritten using keys that match normalizeEventType output (lowercase, dashes and spaces collapsed to underscores): goal, penalty, own_goal, assist, red_card, yellow_card, penalty_missed, substitution, corner, free_kick, throw_in, foul, offside, var. The duplicate dash-and-space variants are dropped — one canonical key per type.

  2. Stat-delta entries added. Six new keys cover the events that currently produce icons but no price move:

Event Bump Rationale
foul_drawn +0.15 % Minor positive: defensive value created.
shot_blocked +0.10 % Minor positive: defensive action.
dispossessed -0.10 % Minor negative: possession lost.
long_ball +0.05 % Minimal positive: chance-creation attempt.
error_leading_to_goal -3.0 % High negative: direct concession responsibility.
save +1.5 % Keeper-specific positive (stats path).

Magnitudes are deliberately conservative for the stat-delta types so they don't drown out goal/card signals; the headline events keep their original sizes.

  1. EventBump normalises before lookup. The function now passes the input through normalizeEventType (the same canonical-form helper already used at the events-feed write path in events_feed.go:43-62) before the map read. This makes the events-path and the stats-path call sites symmetric — both can pass any spelling variant and hit the same row.

  2. SetEventBumps and cloneBumps normalise stored keys too. An admin who types "Yellow Card" in the admin UI now stores it under yellow_card, matching the runtime lookup. Previously the override would land under "yellow card" (lowercased only) and silently miss.

  3. Migration 40 runs jsonb_set on the event_bumps half of admin_settings.score_event_points so existing deployments converge to the same keyspace. position_multipliers is untouched. Additive only — no DROP / type changes — and idempotent.

Consequences

  • Positive — parity with the chart overlay. Every icon the chart can draw now corresponds to a real, signed price nudge. The "marker but no move" class of bug is closed at the source.
  • Positive — bug fix is bundled. The latent key-mismatch (own-goal etc.) is resolved as part of the same change rather than shipped separately. Tests pin the new behaviour (TestEventBumpNormalization, TestSetEventBumps_NormalisesOverrideKeys).
  • Positive — admin UX is forgiving. Admins can save keys in whatever spelling Sportmonks happens to expose; the runtime folds the form before storing.
  • Cost — slightly more pub/sub volume during chatty matches. A live fixture with frequent foul_drawn / dispossessed deltas now publishes micro-bumps for the holders of those instruments. Bounded by the existing activity gate (ADR-0018 B1): only live-match players + recently traded players publish; everyone else stays quiet. At full tournament scale this falls inside the same envelope the noise loop already spends.
  • Neutral — no schema change. Migration 40 only updates one JSONB row. Rollback is via the .down.sql (not run in CI per migration-safety §3) or via the admin UI.

Alternatives considered

Alternative A: Filter the marker overlay instead of expanding the bumps

match/events.go:GetEvents could continue past any row whose EventBump(dto.Type) == 0, so the chart only ever draws icons that have a corresponding price move. Cheapest possible diff — one if branch.

Rejected. It hides information from the player. Knowing that a foul_drawn or shot_blocked happened is useful match context even if the price impact is small. The product intent is "more signal, not less", so the right answer is to give those events a small signed bump rather than suppress them.

Alternative B: Add bumps as admin-only overrides (don't change defaults)

Leave defaultEventBumps alone and write the new types into admin_settings.score_event_points only. A fresh dev install would still have the old keys; staging / prod would carry the expanded set.

Rejected. Defaults are the in-binary safety net for when the DB row is missing or partially populated. Letting a fresh install silently produce flat charts for stat-delta events recreates the bug on every new environment. The defaults and the seed migration should agree.

Alternative C: Defer the key-normalisation fix to a separate PR

Ship the expansion now; fix own-goal / penalty-missed / red_card keying later.

Rejected. The two fixes share the same test surface and the same ADR rationale; splitting them doubles the review burden without reducing risk. The user explicitly chose to bundle.

Verification

  • Unit tests. go test ./internal/sportmonks/... passes including the new TestEventBumpNormalization (raw Sportmonks strings in 20+ variants must resolve to the right signed value) and TestSetEventBumps_NormalisesOverrideKeys (admin overrides with non-canonical keys take effect).
  • Migration applies cleanly. migrate up from version 39 → 40 against local Postgres; SELECT version, dirty FROM schema_migrations shows 40 | f; jsonb_pretty(value->'event_bumps') shows the 20-key payload with all underscored keys.
  • Live behaviour. With the api-server rebooted to pick up the new defaults, watch a live-match player's chart. A foul_drawn, dispossessed, or save event from Sportmonks should now produce a small visible step on the price line in addition to its existing icon. Verify micro_bump and event_score change in HMGET instrument:<uuid> ... for that player.
  • Admin UI parity. GET /api/admin/settings returns the row with all 20 keys; the Admin → Scoring form renders an input per key with no frontend change needed (the form is data-driven — Admin.tsx:adminListSettings).