bv.ewma
Exponentially-weighted moving average over arrival-time, with
half_life-controlled decay.
Signature
bv.ewma(
field: str,
*,
half_life: str,
where: bv.Col | None = None,
) -> AggDescriptor
Description
bv.ewma is an exponentially-weighted moving average — a single running
estimate of "what is this entity's typical value of field lately?",
with the influence of older events decaying exponentially with arrival
age. Each new observation updates the estimate as
value_t = α * x_t + (1 - α) * value_{t-1}, where the decay coefficient
α = 1 - 0.5^(Δt / half_life) (equivalently 1 - exp(-Δt * ln(2) / half_life)).
Δt is the server processing-time gap (now_ms() at this event minus
now_ms() at the previous event) per
project_redis_shaped_no_event_time_ever —
beava intentionally has no event-time concept, so older events here means
"older by arrival order, weighted by elapsed wall-time between arrivals".
half_life is the time after which an observation's influence has decayed
to ½. So bv.ewma("amount", half_life="1h") means "an event 1h old
contributes half as much as a brand-new event; an event 2h old contributes
¼; an event 3h old, ⅛". Use bv.ewma when you want a smoothed running
estimate of a numeric field that adapts to drift faster than a long
fixed-window mean would (e.g. a 5-minute mean drops history sharply at
5m, while EWMA decays smoothly), or when the right window length is
genuinely uncertain — pick a half-life equal to the timescale of the
behaviour you care about and let history fade naturally.
bv.ewma belongs to the decay family. Per-event update is one
exp() call plus three scalar multiplies; cost is Tier 1 (~15 ns
algorithm floor / ~35 ns measured) and memory is O(1) per entity
(three slots: value, last_now_ms, initialized). Lifetime mode is
the only mode — half_life sets the decay rate, no window= kwarg
exists.
Parameters
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
field |
str |
Yes | — | Numeric field (i64 or f64) to track. |
half_life |
str |
Yes | — | Duration string matching \d+(ms|s|m|h|d). Must be positive; "forever" is rejected (decay with infinite half-life is just a running last-value). |
where |
bv.Col |
No | None |
Boolean expression on event fields; only matching events update the EWMA. |
Returns
A single f64 — the current EWMA estimate. Cold-start (no matching
events seen) returns null (Python None).
Complexity
| Resource | Bound |
|---|---|
| CPU per event | Tier 1 (~15 ns floor / ~35 ns measured) — see cost-class.md |
| Memory per entity | O(1) — (value: f64, last_now_ms: i64, initialized: bool) ≈ 24 B |
| Lifetime mode | Required — no window= kwarg; half_life controls decay rate |
Examples
Example 1: EWMA of transaction amount per user, 1h half-life
import beava as bv
@bv.event
class Txn:
user_id: str
amount: float
@bv.table(key="user_id")
def UserAmtEwma(txns) -> bv.Table:
return (
txns.group_by("user_id")
.agg(amt_ewma_1h=bv.ewma("amount", half_life="1h"))
)
# Push events
app.push("Txn", {"user_id": "alice", "amount": 100.0}) # value = 100
app.push("Txn", {"user_id": "alice", "amount": 200.0}) # value blends toward 200
# Query
result = app.get("UserAmtEwma", "alice")
# result == {"amt_ewma_1h": <float between 100 and 200, biased by elapsed Δt>}
Example 2: Filtered EWMA of approved-payment amounts, 30m half-life
@bv.table(key="user_id")
def UserOkAmtEwma(txns) -> bv.Table:
return (
txns.group_by("user_id")
.agg(ok_amt_ewma=bv.ewma("amount",
half_life="30m",
where=bv.col("status") == "ok"))
)
Wire
JSON wire form in a register payload:
{
"kind": "derivation",
"name": "UserAmtEwma",
"output_kind": "table",
"key": ["user_id"],
"agg": {
"amt_ewma_1h": {
"op": "ewma",
"params": {
"field": "amount",
"half_life": "1h"
}
}
}
}
See examples/wire/register-fraud-team.request.json for a full payload example.
Edge cases
- Empty stream / cold-start: result is
null. The first matching event seedsvalue = xand flips theinitializedflag. - Late or duplicate event (Δt ≤ 0): the helper applies an unweighted blend (
value = 0.5 * x + 0.5 * value) and does not advancelast_now_ms. Time never moves backward; this preserves replay determinism. - Missing or non-numeric
field: the event is silently skipped (no update); the EWMA value is unchanged. This matchesbv.mean/bv.sumbehaviour. where=filter excludes the event: no update; non-matching events do not advancelast_now_mseither.- Missing
half_life=: raisesValueErrorat SDK-helper-call time. half_life="forever": rejected by_validate_half_lifewithValueError— decay with infinite half-life would freeze on the first observation; usebv.firstfor that semantic.half_life="0…": rejected at SDK call time (regex requires[1-9]\d*prefix). Server-side,register_validate.rsreturns structured erroraggregation_invalid_half_lifeif a malformedhalf_lifesomehow reaches it.- Cold-entity eviction (
@bv.event(cold_after=...)): drops the underlying state; the next event after eviction seeds a new EWMA fromx.
Aliases
bv.ema— same op; alias preserved as a convention shortcut.emaandewmamap to the sameAggKind::Ewmavariant incrates/beava-core/src/agg_op.rsand the sameO(1)lifetime-bound classification (crates/beava-core/src/register_validate.rsline ~436 lists"ewma" | "ema"together). The Python helperbv.ema(...)is a thin pass-through tobv.ewma(...)(python/beava/_agg.pyline 345-347). Choose whichever name reads better in your code; the wireopvalue is"ewma"for both.
See also
- Decay family index — overview of all 6 decay-family ops
- cost-class.md — performance tier (Tier 1)
- bv.ewvar — companion exponentially-weighted variance (state-shares the same
last_now_msconvention) - bv.ew_zscore — current event z-score against EWMA / EWVar baseline
- bv.mean — fixed-window arithmetic mean (no decay; pick this when window is fixed)
- bv.twa — time-weighted average for irregularly-sampled gauge fields
- pipeline-dsl/compilation-rules.md — chain compilation rules