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.

Exponential severity decay with a half-life and a floor A severity score starts at its initial value and decays exponentially over time, halving each half-life period, and flattens as it approaches a lower floor below which it never falls. A recurrence event resets the score to its initial value, restarting the decay. Severity decay: halving each half-life, bounded by a floor score time → S₀ floor S₀⁄2

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 S0S_0, a decay constant λ\lambda, and elapsed time tt:

S(t)=max(Sfloor,S0eλt),λ=ln2t1/2S(t) = \max\left(S_{\text{floor}},\; S_0 \cdot e^{-\lambda t}\right), \qquad \lambda = \frac{\ln 2}{t_{1/2}}

where t1/2t_{1/2} is the half-life — the time for the score to fall by half. Recurrence resets S0S_0 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 initial and triggered_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.