Data model
Aggregated Feature: filtered rollups over time windows.
Compute sum, min, max, count, and grouped rollups per entity, filtered by dimension, over sliding or tumbling windows — served directly instead of rebuilt by a stream job for every new feature.
What it is
The math over a window, computed inside the store.
An Aggregated Feature turns an entity's raw events into a number — or a small set of grouped numbers — over a time window, without you shipping the events anywhere. Ask for sum, min, max, count, or rate over the last 15 minutes or 7 days, optionally filtered by dimension and grouped by another.
Each entity keeps its own sparse aggregate state, so the model scales to millions of keys without a stream job per feature. Results are fresh at request time: they reflect the latest event, not the next batch cycle.
That freshness plus request-time filtering is the edge over a nightly pipeline or a pre-computed cube — new features are a read, not a new job.
Why TemporalStore
Why request-time rollups beat a pipeline, a cube, or hand-rolled buckets.
Every alternative to a request-time rollup trades away either freshness, coverage, or your team's time. A nightly pipeline gives you yesterday's number — useless for a fraud score or a budget cap that has to reflect the transaction happening right now, and it needs a new job every time a feature wants a different window, filter, or grouping. A pre-computed cube is fresh only for the dimensions you thought to materialize; the first analyst question that crosses an un-cubed axis falls back to a scan, and the cube's size explodes combinatorially as you add dimensions. Hand-rolled bucket code in the application — increment a Redis hash per window — works for one metric and then rots: every new window size, filter, or metric is another key schema, another TTL to tune, another race to get right.
TemporalStore computes the aggregate at the shard, at read time, over the raw
events. The math — count, sum, min,
max, rate — runs as a model-aware executor next to the data, so a
window read reflects the latest event with no batch cycle in between. There is no pre-materialized
cube to keep in sync and no separate feature to deploy: a new window, a new filter, or a new
grouping is a different argument on the same call, not a new pipeline.
Freshness is free because writes are append-structured. An event is a cheap ordered append to the WAL and the hot tier — there is no read-modify-write of a running total, so there is no write amplification and no lock contention on a hot counter. The rollup is reconstructed on read from the recent window, and a multi-layer cache keeps the hot band of events warm so repeated reads over the same window stay cheap.
Because each entity keeps its own sparse aggregate state, the model scales to
millions of sparse keys without paying for the ones that are idle — a
merchant with a million events and one with ten cost the same per read. Grouped rollups
(group_by=["channel"]) compute the whole small result set in one bounded read
instead of N round trips, and after a failover the same reads work unchanged because the
aggregate replays from the append log rather than a fragile external state store.
New features are a query argument, not a new job; sparse keys cost nothing when idle.
In practice
Read a window; filter and group in the same call.
f = ts.window(
table="checkout_events",
entity="user_42",
range="15m",
metrics=["count", "sum(amount_usd)"],
)
f = ts.window(
table="chargebacks",
entity="merchant_3",
range="7d",
group_by=["channel"],
metrics=["count", "sum(amount)"],
)
How to use it
From raw events to a fresh, grouped feature vector.
The loop is: append the raw events once, then read whatever windowed statistic a decision needs — a single metric, several at once, or a grouped breakdown — computed fresh at the shard on each call. No feature is ever pre-declared; the window, filter, metrics, and grouping are all read arguments.
Write raw events
One ordered append per event to the entity's stream — no running total to update.
Read a statistic
Ask for count/sum/rate over a sliding or tumbling window, fresh to the last event.
Slice the number
Apply a where predicate and a group_by to get a small grouped result in one read.
Feed the model
Assemble the metrics into a feature vector on the request path.
ts.append(
table="checkout_events",
entity="user_42",
ts_ms=event.ts_ms, # event time drives the window boundaries
attrs={"amount_usd": 129.00, "channel": "ios", "method": "card"},
)
You write events, not aggregates. Every metric, window, and grouping below is
derived from this one stream at read time, so adding a feature never means backfilling a new table
— it queries events that are already there. Attribute keys (channel,
method) become the dimensions you can later filter and group on.
f = ts.window(
table="checkout_events",
entity="user_42",
range="15m", # sliding window ending now
metrics=["count", "sum(amount_usd)"],
)
# -> {"count": 3, "sum(amount_usd)": 402.5}
A range is a sliding window ending at request time, so the number
always reflects the latest event. Request the metrics you need in one call; several metrics over the
same window share a single bounded read rather than one read each.
f = ts.window(
table="chargebacks",
entity="merchant_3",
range="7d",
where={"reason": "fraud"}, # predicate applied at the shard
group_by=["channel"], # one small result set, not N round trips
metrics=["count", "sum(amount)"],
)
# -> {"ios": {"count": 4, "sum(amount)": 512}, "web": {"count": 1, "sum(amount)": 88}}
Filtering and grouping happen in place, so a grouped read returns the whole
breakdown in one shot. Keep group_by to low-cardinality dimensions (channel, method,
country) — grouping on a high-cardinality field like a raw id defeats the point; use a
sequence when you need per-event rows instead of a rollup.
def risk_features(user_id):
fast = ts.window(table="checkout_events", entity=user_id,
range="15m", metrics=["count", "sum(amount_usd)"])
slow = ts.window(table="checkout_events", entity=user_id,
range="7d", metrics=["count", "max(amount_usd)"])
return {**fast, **slow} # both fresh to the latest event, no pipeline
Mixing a short and a long window gives a scorer both burst and baseline
signal, each computed fresh at read time. Gotchas: pick range to match the decision
(minutes for velocity, days for baselines), prefer a handful of metrics per call over many calls,
keep groupings low-cardinality, and lean on the multi-layer cache — repeated reads over the
same hot window stay cheap.
When to use it
When a decision needs a fresh number over recent events.
Reach for an Aggregated Feature when you would otherwise stand up a stream job or query a warehouse just to get one windowed statistic per entity at request time.
| Use case | Aggregate | Why it fits |
|---|---|---|
| Risk & fraud features | count / sum over minutes, filtered by dimension | Fresh signal at scoring time across millions of accounts. |
| Campaign spend windows | sum(amount) grouped by channel over a day | Enforces budgets and pacing without a spend pipeline. |
| Recommendation freshness | count / rate over the recent window | Reflects the latest interactions, not last night's batch. |
| Operational health | rate / max over a rolling window | Per-entity metrics served straight from the store. |
Related models