Skip to content

adr: 0025 title: "Per-event decay via persistence split (durable vs transient bump)" status: superseded superseded-by: [0026, 0027] implementation-status: in-progress date: 2026-06-07 implemented-date: null implemented-in: - ftl-backend feat/event-config-decay - ftl-frontend feat/event-config-decay deciders: [@amalkrsihna] affects-specs: [event-ingestion] affects-code: - ftl-backend/internal/sportmonks/scoring.go - ftl-backend/internal/sportmonks/ticker.go - ftl-backend/internal/handler/admin.go - ftl-frontend/src/pages/Admin.tsx supersedes: null superseded-by: null


ADR-0025: Per-event decay via persistence split

Context

ADR-0024 added per-event admin config (enabled / maxMovePct / category / cooldown) and reserved per-event decay for a follow-up. Wiring it exposed a modelling constraint: price decay is applied as a single global per-poll factor (MicroBumpDecay) to one scalar accumulator, micro_bump. That accumulator merges every event's contribution into one number, so a literal per-event decay rate ("fade this event at X, that one at Y") is not expressible without event-sourcing the price (tracking each event's contribution separately and decaying them independently) — a large, high-risk change to the live pricing hot path.

The engine already assembles price from two event channels, though:

live_base_price = base + matchScore·sensitivity
                + micro_bump   (transient — decays each poll at MicroBumpDecay)
                + event_score  (durable   — held for the match, reset at FT)

Decision

Express per-event decay as a persistence split over the existing two channels, via EventConfig.persistencePct ∈ [0,100]:

  • (100 − persistencePct)% of the event's bump → micro_bump (fades fast)
  • persistencePct%event_score (persists for the match)

SplitEventBump(bump, persistencePct) performs the split; processEvents applies each part (clamped to the per-event/global cap) under the existing exactly-once event_bump_log gate. persistencePct = 0 (default) keeps the historical all-transient behavior, so nothing changes until an admin opts in. Higher values make an event's price effect fade more slowly — the per-event decay control, expressed in the units the engine already uses.

Alternatives considered

  • Literal per-event decay rate on micro_bump. Rejected: the scalar accumulator can't attribute decay per event without event-sourcing.
  • Per-(instrument,event) decaying contributions in Redis, summed into the price each poll. Rejected for now: real per-event decay, but a substantial redesign of the price-assembly hot path for marginal benefit over the split.
  • Expose the global MicroBumpDecay as an admin setting. Useful but not per-event; can still be added later, orthogonal to this.

Consequences

  • Positive: admins get a meaningful per-event "how long does this linger" control with zero new price-model machinery and a safe default; reuses the durable event_score channel processPlayerStats already folds in each poll.
  • Limitation: it's a persistence split, not an arbitrary decay curve — an event is either fast-fading, durable-for-the-match, or a blend. Finer control would need the event-sourced redesign above (not currently justified).
  • Pricing-path change, but gated to no-op at the default.

Migration / rollout

No schema change — persistencePct is another field in the existing admin_settings.event_config JSONB (seeded {} by mig 000044). Ships no-op-by-default; admins opt in per event.