A severity score set at detection time and never revisited is wrong within the hour: a critical alert nobody acted on is not as urgent at hour six as it was at minute one, yet a static score keeps it pinned at the top of the queue forever. This guide implements the decay math that ages scores gracefully, as one precise technique within dynamic severity scoring and the broader alert correlation rule engines pipeline.
Root-Cause Context
Severity is treated as a constant when it is really a function of time. The problem surfaces in two directions. Without decay, a queue accumulates stale high-severity alerts that were never triaged, and their permanence pushes genuinely fresh signals down — the analyst sorts by severity and sees a wall of aging criticals. With crude decay (a nightly batch that drops everything a level), a real incident that is actively recurring gets aged out just as it heats up.
The correct model treats a score as a decaying quantity that resets on genuine recurrence. A brute-force burst that stops was probably blocked or abandoned, so its urgency should decay; a brute-force burst that keeps recurring should have its clock reset on each new hit, so it stays hot. The math that expresses this cleanly is exponential decay with a configurable half-life, bounded below by a floor so a decayed alert never disappears entirely — it simply falls in priority until something refreshes it.
Prerequisites
The implementation targets Python 3.11+ and uses only the standard library. It consumes scores produced by the parent dynamic severity scoring engine and assumes event timestamps are normalized upstream.
python3 --version # 3.11+; no third-party dependencies required
The decayed score is computed on read, not written on a schedule — a critical design choice covered in the operational notes.
Production-Ready Implementation
The decay function follows the standard exponential form. Given an initial score , a decay constant , and elapsed time :
where is the half-life — the time for the score to fall by half. Recurrence resets and the clock.
from __future__ import annotations
import math
from dataclasses import dataclass
@dataclass
class DecayingScore:
"""A severity score that ages exponentially and refreshes on recurrence."""
initial: float # S0 at (re)trigger
triggered_at: float # epoch seconds of last (re)trigger
half_life_seconds: float = 3600.0
floor: float = 10.0 # never decays below this
@property
def _lambda(self) -> float:
return math.log(2) / self.half_life_seconds
def value_at(self, now: float) -> float:
"""Current decayed score, computed on read."""
if now < self.triggered_at:
raise ValueError("ERR_SCORE_041: now precedes trigger time")
elapsed = now - self.triggered_at
decayed = self.initial * math.exp(-self._lambda * elapsed)
return max(self.floor, decayed)
def refresh(self, new_initial: float, now: float) -> None:
"""Recurrence: reset to the higher of current-decayed and new score."""
current = self.value_at(now)
self.initial = max(current, new_initial) # never drop on recurrence
self.triggered_at = now
def half_life_for_target(target_fraction: float, seconds: float) -> float:
"""Solve the half-life that leaves target_fraction of the score after `seconds`."""
if not 0 < target_fraction < 1:
raise ValueError("ERR_SCORE_042: target_fraction must be in (0, 1)")
# target = 0.5 ** (seconds / t_half) -> t_half = seconds * ln2 / -ln(target)
return seconds * math.log(2) / (-math.log(target_fraction))
if __name__ == "__main__":
s = DecayingScore(initial=90.0, triggered_at=1_000_000.0, half_life_seconds=3600)
assert abs(s.value_at(1_003_600.0) - 45.0) < 1e-6 # one half-life -> half
s.refresh(new_initial=70.0, now=1_003_600.0) # recurrence
assert s.value_at(1_003_600.0) == 70.0 # reset, not below current
Computing the score on read rather than writing decayed values on a schedule is the key move: it means no background job, no write amplification, and no drift between “the stored score” and “the real score.” The refresh method guarantees a recurrence never lowers urgency — it takes the max of the current decayed value and the new trigger.
Error-Code Reference
| Code | Meaning | Action |
|---|---|---|
ERR_SCORE_041 |
now precedes the trigger time (clock skew) |
Clamp elapsed to zero; fix source NTP; log the skew |
ERR_SCORE_042 |
Invalid half-life or target fraction parameter | Reject config; fall back to the default half-life |
ERR_SCORE_043 |
Half-life set below the ingestion latency | Warn — scores decay before alerts are triaged; raise the half-life |
ERR_SCORE_044 |
Floor set above initial score | Reject config; the floor must be below any initial score |
Operational Notes
- Compute on read, never on a timer. Store
initialandtriggered_at; derive the current score when the queue is sorted. This eliminates a whole class of batch-job drift and scales for free. - Pick half-life per detection class. Fast, self-limiting noise (port scans) can use a short half-life; slow-burn threats (data staging) need a long one so they do not age out mid-attack. Tune these through threshold tuning strategies.
- Refresh, don’t re-add. On recurrence, reset the clock and take the max score; adding decayed scores together inflates them unboundedly.
- The floor keeps history visible. A decayed alert should sink in priority but never vanish, so an analyst reviewing the day still sees it.
Verification Checklist
FAQ
Should I write decayed scores on a schedule or compute them on read?
Compute on read. Storing only the initial score and its trigger time, then deriving the current value when you sort the queue, avoids a background job entirely and guarantees the displayed score is always exact. Scheduled decay writes cause drift between the stored and true score, add write amplification, and introduce a batch-lag failure mode — all for no benefit, since the decay function is cheap to evaluate.
How do I choose a half-life?
Match it to how quickly the threat class self-limits. Noisy, self-resolving detections like port scans can decay fast (minutes), so they clear the queue once they stop. Slow-burn threats like data staging or beaconing need a long half-life (many hours) so an active-but-quiet incident does not age out before an analyst reaches it. Validate by replaying incidents and confirming real cases stay actionable long enough.
Why take the max on recurrence instead of adding?
Because adding decayed scores has no natural ceiling and lets a chatty-but-benign source climb indefinitely, while taking the max of the current decayed value and the new trigger keeps the score bounded and meaningful: a recurring threat stays hot without inflating past its true severity. Convergence across sources is handled separately by the scoring engine’s multiplier, not by summing decays.