Exponential half-life decay for counters and scores, in dependency-free PHP.
Evidence gathered a year ago should not weigh as much as evidence gathered yesterday. This library computes that discount: after one half-life a value is worth half of what it was, after two it is worth a quarter, and so on.
composer require cleatsquad/php-state-decayRequires PHP 8.2 or later. No runtime dependencies.
use CleatSquad\StateDecay\DecayConfig;
use CleatSquad\StateDecay\HalfLifeDecay;
$decay = new HalfLifeDecay(new DecayConfig(halfLifeDays: 30));
$decay->applyToValue(100.0, 30 * 86400); // 50.0
$decay->applyToValue(100.0, 60 * 86400); // 25.0$counters = ['successes' => 40, 'failures' => 10];
$decay->applyToCounters($counters, 30 * 86400);
// ['successes' => 20, 'failures' => 5]Counters are rounded, not floored. A pass that runs every hour would otherwise floor barely-aged evidence down to nothing, one unit at a time.
$decay->factor(0); // 1.0
$decay->factor(30 * 86400); // 0.5
$decay->factor(-3600); // 1.0 — clock skew never resurrects stateDays are the usual unit, but anything shorter is expressible:
DecayConfig::fromSeconds(1800); // 30 minutes
DecayConfig::fromDays(0.25); // 6 hours
DecayConfig::never(); // decay disabled; factor() stays 1.0Elapsed time is the caller's business. Nothing here reads a clock. You pass the number of seconds that went by, which keeps the library free of any time dependency and makes every test deterministic. If you use a PSR-20 clock, take the difference yourself and hand it over.
Time never runs backwards. A negative or zero elapsed time yields a factor
of 1.0. Clock skew between machines cannot make aged state young again.
Counters cannot be owed. A negative count is treated as 0 rather than
propagated.
Invalid configuration fails loudly. A negative, NAN or INF half-life
throws InvalidArgumentException instead of silently poisoning every later
computation.
Good fits: recency ranking, reputation and trust scores, abuse and rate heuristics, health scores, recommendation weights — anywhere accumulated evidence should fade rather than be dropped by a cutoff.
Poor fits: you need a hard expiry (use a TTL), or a fixed-size sliding window (count within the window instead).
composer install
composer test # PHPUnit
composer analyse # PHPStan, max levelMIT. See LICENSE.