Skip to content

adr: 0031 title: "Event + stat ripple engine (role-weighted, both teams, all event/stat types)" status: accepted implementation-status: implemented date: 2026-06-09 implemented-date: 2026-06-09 implemented-in: - ftl-backend feat/all-event-price-variation deciders: [@amalkrsihna] affects-specs: [pricing-ripple-engine, amm-pricing, event-ingestion] affects-code: - ftl-backend/internal/sportmonks/propagation_matrix.go - ftl-backend/internal/sportmonks/event_propagation.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/handler/admin.go - ftl-backend/cmd/api-server/main.go supersedes: null superseded-by: null


ADR-0031: Event + stat ripple engine

This is the source-of-truth decision for cross-team / cross-role price propagation as designed and built on 2026-06-09. It is independent of the earlier WC26-cycle proposal ADR-0028 (position-aware 8-knob propagation), which targeted a different branch family (integration/wc26-player-market-platform, a Postgres event_impact_config table, behaviour-preserving 1.5/2.0/3.0 seeds) and has not shipped to this integration line. This ADR ships on feat/pre-cooldown-rebuild with admin-overlay config (no new table) and the redesigned Option-C magnitudes. Where both eventually meet, this ADR is the live behaviour on this line.

Context

Two structural gaps made on-chart prices feel disconnected from the match:

  1. Most events moved only the actor. Only goal/penalty propagated across the match (the hardcoded goalTeamFallbackPct / goalConcedePct / goalConcedeGKPct constants in event_propagation.go). The other 18 discrete event types and every per-player stat delta moved at most the single actor — and when Sportmonks omitted the player_id, the bump was silently dropped entirely (ticker.go early-return), so e.g. a team-only yellow card produced a chart marker with no price move.
  2. 15 stat-delta types had no configured bump. detectStatDeltas pushed a chart marker for 22 stat types but EventBump() returned 0 for 15 of them (tackle, shot_on_target, key_pass, …) → marker with no price effect.

Separately, the stat-delta marker push wrote createdAt in seconds while discrete events used milliseconds; after the frontend chart standardised on Date(createdAt) (no ×1000), every stat marker landed at ~1970 and was dropped by the chart's visible-window filter.

Decision

Replace the goal-only propagation with a general, type- and role-aware ripple engine. Every event and every marker-eligible stat increment moves prices on EVERY player on BOTH teams, scaled by:

  1. event/stat type — a RippleMatrix row,
  2. relationship to the actor — teammate (same direction) vs opponent (opposite),
  3. role — GK / DEF / MID / FWD.

The opposing role most directly responsible for an event (the goalkeeper on a goal) takes the heaviest hit, locked at 0.75 of primary magnitude ("Option C"). Full matrices live in specs/pricing-ripple-engine.md.

Key pieces:

  • propagation_matrix.goRippleMatrix (8 factors), a unified defaultRipple table keyed by normalizeEventType covering all 20 events + the marker-eligible stat types, RippleFor(type), canonicalRole(position), orphanTeamFactor, and SetEventRipple hot-reload (mirrors SetEventBumps).
  • applyRipple (event_propagation.go) — fans a primary bump out to both teams' rosters by role via the existing bumpInstrumentEvent (durable event_score + transient micro_bump, clamped at PercentOfBaseCap). The actor instrument is skipped (already got the primary). A per-poll rosterCache collapses the active-squad PG lookup to one query per team per poll.
  • redistributePrimary — when the actor's player_id is unresolved, spread the primary across the acting team by orphanTeamFactor[role] so the event still moves prices.
  • WiringprocessEvents and detectStatDeltas both: apply the actor's primary (transient micro_bump; durable capture comes from the matchScore path, so no double-count) then applyRipple across both teams. processPlayerStats is intentionally NOT rippled (it would double-count the same stats and fan out ~22× per poll).
  • scoring.godefaultEventBumps extended with the 15 missing stat types.
  • detectStatDeltascreatedAt fixed to UnixMilli; MaxEventsPerFixture raised 100 → 500 so stat markers don't trim goals off the feed tail.
  • Adminscore_event_points.event_ripple_matrices overlay (validated in admin.go, applied in main.go), same omit-keeps-default semantics as event_bumps.

Alternatives considered

  • Postgres event_impact_config table (ADR-0028 shape). Rejected for this line: heavier (migrations, a new table, a config-version journal) than the admin-overlay JSON already used for event_bumps. The overlay reaches parity for the tuning surface operators need.
  • Ripple processPlayerStats (continuous matchScore delta). Rejected: double-counts the discrete stat deltas already rippled in detectStatDeltas and costs ~22× the Redis fan-out per poll. Discrete paths only.
  • Behaviour-preserving seeds (1.5/2.0/3.0). Rejected in favour of the redesigned Option-C magnitudes the product owner specified (goal +6%, opposing GK −4.5%).

Consequences

  • Positive: every event and marker-eligible stat now moves the whole match, role-differentiated; player-less events no longer vanish; stat markers render again (createdAt fix). All bumps remain clamped per player.
  • Cost: more Redis writes per poll during busy live windows (bounded by the roster cache + discrete-only rippling). Measured acceptable at the 50-fixture live ceiling.
  • Limitation: continuous low-signal stats (passes, touches, duels) move only the actor's own price via matchScore — they don't ripple and don't get markers. Re-evaluate post-launch.

Migration / rollout

No schema change. event_ripple_matrices is another optional block in the existing admin_settings.score_event_points JSONB. Ships with baked-in defaults; admins opt into overrides. Going-forward only — no backfill of past matches.