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
MicroBumpDecayas 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_scorechannelprocessPlayerStatsalready 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.