From c3c0817c3be90916ae0c94cb7f3dbc0d901ffe3d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 20:38:25 -0300 Subject: [PATCH] feat(quota): add burnRate EMA estimator and time-to-exhaustion (B/F6) --- src/lib/quota/burnRate.ts | 74 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 src/lib/quota/burnRate.ts diff --git a/src/lib/quota/burnRate.ts b/src/lib/quota/burnRate.ts new file mode 100644 index 0000000000..3e6cfd9626 --- /dev/null +++ b/src/lib/quota/burnRate.ts @@ -0,0 +1,74 @@ +/** + * burnRate.ts — Burn-rate EMA estimator for quota consumption. + * + * Computes an exponential moving average (alpha=0.3) over a series of + * (timestamp, consumed) samples and projects time to exhaustion. + * + * Part of: Group B — Quota Sharing Engine (plan 22, frente F6). + */ + +const EMA_ALPHA = 0.3; + +export interface BurnRateSample { + ts: number; // epoch ms + consumed: number; // cumulative consumed value at this ts +} + +export interface BurnRateResult { + /** Estimated tokens (or units) consumed per second. */ + tokensPerSecond: number; + /** + * Estimated milliseconds until the remaining quota is exhausted. + * null if rate is 0 or the caller did not provide a remaining value. + */ + timeToExhaustionMs: number | null; +} + +/** + * Compute the current burn rate from a series of samples. + * + * @param history Array of { ts, consumed } ordered oldest → newest. + * Needs at least 2 entries; fewer returns zeros. + * @param remaining Optional remaining quota (same unit as consumed). + * When provided, `timeToExhaustionMs` is calculated. + */ +export function computeBurnRate( + history: BurnRateSample[], + remaining?: number +): BurnRateResult { + if (history.length < 2) { + return { tokensPerSecond: 0, timeToExhaustionMs: null }; + } + + // Build EMA over consecutive deltas. + let emaRate = 0; + let initialized = false; + + for (let i = 1; i < history.length; i++) { + const deltaConsumed = history[i].consumed - history[i - 1].consumed; + const deltaTs = history[i].ts - history[i - 1].ts; // ms + + if (deltaTs <= 0) continue; // skip duplicate or out-of-order timestamps + + const instantRate = deltaConsumed / (deltaTs / 1000); // per second + + if (!initialized) { + emaRate = instantRate; + initialized = true; + } else { + emaRate = EMA_ALPHA * instantRate + (1 - EMA_ALPHA) * emaRate; + } + } + + if (!initialized) { + return { tokensPerSecond: 0, timeToExhaustionMs: null }; + } + + const safeRate = Math.max(0, emaRate); + const timeToExhaustionMs = + safeRate > 0 && remaining !== undefined && remaining >= 0 + ? (remaining / safeRate) * 1000 + : null; + + return { tokensPerSecond: safeRate, timeToExhaustionMs }; +}