Modeling metrics, time, and state
How to place a computation — feature, metric, skill, or glossary term — and how to model balances, time windows, cohorts, and ratio KPIs correctly.
Where each computation lives, and how time-varying state gets a grain it can be aggregated on correctly.
When you need this
You're defining "total MRR" and the source table holds historical and cancelled rows.
Someone asks "MRR in March" or "NDR for the Q1 cohort" and you're unsure what to define versus what to query.
A KPI divides one entity's aggregate by another's — ARPDAU, CAC — and has no obvious home.
You're about to add
revenue_7dnext to an existingrevenue_30d.The same rate comes out different depending on who computes it.
The principle
Model state at the grain where it is true, and put each computation in the narrowest primitive that owns it. A balance like MRR is true per subscription per month — that grain must exist as physical rows before any metric can aggregate it, because Lynk cannot create a grain that doesn't exist. And definitions carry only what is always true: a window or segment that is a parameter of the question stays out of the schema and goes into query-time WHERE.
Patterns
Place the computation by its shape
Run this decision tree before writing anything:
A row-grain value of one entity → a feature. Grove:
subscription.mrr.An aggregate over an entity's own rows → a metric on that entity:
subscription.total_mrr.An aggregate consumed across an entity boundary → a feature wrapping the metric. Grove's
customer.total_mrrissql: metric(subscription.total_mrr)withjoin_name: customer_to_subscription— never a metric on the consuming entity, because metrics are entity-local.A way of computing — multi-step, opinionated — → a skill.
A word the team uses → a glossary entry.
Deviate only at a domain boundary, where the move is an import, not a new definition.
Model semi-additive state on a snapshot entity
A balance — MRR, headcount, inventory — is semi-additive: it sums within one point in time, never across time. If maindb.public.subscriptions holds historical and cancelled rows, SUM(subscription.mrr) adds March's balance to February's; nothing errors, the number is just several times too large. The snapshot grain must already exist upstream as a table or view — an entity's identity is never an inline query. With subscription_months (one row per subscription per active month) in the warehouse, model it:
"MRR in March" is now query-time: WHERE month = '2026-03-01'. The same grain answers end-of-period and average-of-period balances, with no new definitions.
Compute cohorts on the snapshot; canonicalize in a skill
Aggregate inside each CTE, before any join — the shape that is correct by construction (CTEs and subqueries). NDR compares the same customers' MRR across two windows: compute each window in its own CTE at the snapshot grain, then join aggregate to aggregate.
The window edges and who counts as "starting" are opinions, so this query lives in a Grove skill (say ndr-analysis) — the reproducible home of the computation. The glossary entry ndr stays one sentence of vocabulary and points at the skill.
Give cross-entity ratio KPIs a skill, not a home they don't have
Arcadia's ARPDAU divides metric(purchase.sum_net_revenue_usd) by metric(player.count_dau). Two entities' aggregates: not a metric (entity-local), not a feature (no row owns it). Don't invent a KPI entity — the schema has no primitive for this, by design. The pattern is a skill carrying the canonical Lynk SQL — one CTE per aggregate, joined on the shared key or date — with the glossary term defining the word. CAC-style ratios follow the same shape.
Bake a time boundary only when it is a business definition
Arcadia bakes duration_seconds > 5 into player_to_meaningful_session because the boundary is the definition of a meaningful session. "Last 30 days" is a parameter — it belongs in query-time WHERE, not in a definition. Bake when removing the filter changes what the word means; defer when it only changes which question was asked.
Choose the filter mechanism by scope
filter: on a feature
one feature's source rows
the narrowing is part of this value's meaning — player.ios_spend_usd
filter: on a metric
one aggregate's rows
a differently-scoped aggregate is its own definition — order.completed_revenue
filtered relationship
every definition and query using the join
the narrowed set is itself a concept — player_to_meaningful_session
query-time WHERE
one query
the boundary is a parameter — dates, segments, "in March"
Anti-patterns
Summing a balance across periods
With twelve months of history the result is roughly 12× actual MRR — every month's balance added to every other's. It compiles and returns a number; the number is wrong. The fix is the snapshot pattern above: name the metric for what it computes, say in its description that time must be constrained, and pin the period at query time.
Averaging per-row ratios across an entity boundary
The single-entity rule — a rate is a ratio of sums — is owned by Metric. The cross-entity variant sneaks past it: aggregate a per-customer refund_rate feature on Bly's customer:
The number moves when the customer mix moves, not when refunds do. The company-wide rate is order.refund_rate — the metric on the entity whose rows carry both the numerator and the denominator.
Measure explosion
Every window multiplies the metric list, and the descriptions are indistinguishable — the agent's choice between them is a coin flip. The window is a parameter: keep the one canonical sum_net_revenue and put the window in query-time WHERE.
Glossary-as-computation
Prose is not executable. Each time the question comes up, the agent re-derives the CTEs slightly differently, and two askers get two numbers. The glossary defines words (GLOSSARY.yml); the canonical query lives in a skill, and the glossary entry points at it.
The bar
Every metric aggregates only the entity it is defined on; every cross-boundary aggregate is a feature wrapping
metric()with ajoin_name.Every balance-like value is modeled on a snapshot grain that physically exists, and its metric's description says how to constrain time.
No definition encodes a parameter the question should supply; every baked filter is a business definition you can name.
Every rate is a ratio of sums, computed on the entity that owns the rows.
Any two metrics the agent must choose between are distinguishable from their names and descriptions alone, and every
sqlcomputes exactly what its description says.Every KPI built from two entities' aggregates has a skill holding its canonical Lynk SQL; the glossary term points at it.
Query-time cross-grain math aggregates inside CTEs before joining.
Related
Lynk SQL — query-time
WHERE, CTEs,metric()· SQL expressions — the authoring grammarSibling: Choosing and shaping entities
Last updated