A SUM that runs and returns a number is not the same as a SUM that means something. Additivity and grain are type-level properties of measures, and violating them produces numbers that are precise, plausible, and inflated by exactly the factor you won’t notice.
The second and third lies in the $7.2M answer were arithmetic errors that passed every syntax check. The agent summed three monthly snapshots of a stock measure (inflating by 3×), then aggregated across a join whose grain was finer than the measure (inflating by 3.4×). Together: roughly 10× the true value, from valid SQL.
This chapter treats both as instances of the same underlying problem: the aggregation is illegal given the measure’s type. The fix is a metric registry that encodes additivity and grain as properties of the measure rather than properties of the query, and a validator that rejects re-aggregations that violate them.
Every quantitative business measure falls into one of three classes:
| Class | Definition | Example | SUM across time? |
|---|---|---|---|
| Additive | Sums meaningfully across all dimensions | Revenue, cost, units sold | Yes |
| Semi-additive | Sums across some dimensions but not others | ARR, headcount, inventory on-hand, account balance | No — take period-end or average |
| Non-additive | Cannot be summed across any dimension; must be recomputed from base facts | Conversion rate, P95 latency, NPS score, average resolution time | No |
The rule: semi-additive measures are stocks, not flows. They represent a state at a point in time. ARR at $400K in May, June, and July is $400K of ARR, not $1.2M. An account at $400K ARR in each of three months has $400K at risk, not $1.2M — and the error is exactly 3×, which for a slightly smaller company still looks like a plausible ARR figure.
The thousand-fold inflation
Cube published a benchmark in 2026 (arXiv 2604.25149) comparing frontier models with and without a semantic layer. In their error analysis, models summed an on-hand quantity measure across every snapshot date in the table, producing values roughly a thousand times the correct magnitude. Not from a hallucinated table name or a broken join — from correctly summing a column that must not be summed.
-- Average of averages. Wrong unless every group is the same size.
SELECT AVG(daily_avg_resolution_hours) FROM daily_ticket_stats;
-- Sum of rates. Meaningless.
SELECT SUM(conversion_rate) FROM funnel_by_channel;
-- P95 of P95s. Not a percentile of anything.
SELECT AVG(p95_latency) FROM service_latency_hourly;
Each is syntactically perfect and semantically void. No linter catches them. Execution accuracy catches them only if someone happened to write a golden query for that exact question.
The fix is to encode the measure’s type in a registry that the query layer resolves through:
measures:
arr:
description: "Annual Recurring Revenue"
agg: sum
additivity: semi_additive
non_summable_dimensions: [month, date]
time_rule: last # take period-end snapshot, don't sum
grain: [account_id, month]
resolution_time_p95:
description: "95th percentile ticket resolution time"
agg: percentile(95)
additivity: non_additive
recompute_from: ticket_events # must go to base facts
grain: [ticket_id]
conversion_rate:
description: "Sessions that converted to purchase"
agg: ratio
numerator: conversions
denominator: sessions
additivity: non_additive
grain: [session_id]
# Compiles to SUM(conversions)/SUM(sessions) at target grain,
# NOT AVG(precomputed_rate)
When the query layer encounters a request to aggregate a measure, it consults the registry:
non_summable_dimensions?SUM(num)/SUM(den) or AVG(precomputed)?Violations are compile errors — the query is rejected before execution, not warned after.
Summarizability (Lenz & Shoshani, SSDBM 1997)
A measure \(m\) is summarizable with respect to an aggregation function \(f\) and a set of grouping dimensions \(D\) if and only if three conditions hold:
Type compatibility is precisely our additivity check: SUM is not type-compatible with a semi-additive measure over a time dimension. AVG is not type-compatible with a pre-aggregated average (requires returning to the micro-data and computing a weighted mean). Percentiles and medians are inherently non-summarizable and must always be recomputed from base facts.
The third lie: revenue was counted 3.4 times because a join introduced spurious multiplicity.
SELECT a.account_id, SUM(o.order_amount) AS revenue
FROM orders o
JOIN order_items i ON i.order_id = o.order_id
JOIN accounts a ON a.id = o.account_id
GROUP BY 1;
The order_items join was added to filter on product category, but order_amount lives at the order grain. Each order now appears once per line item. With an average of 3.4 items per order, revenue inflates 3.4×.
The nasty part: it inflates unevenly. Accounts that buy more SKUs per order look disproportionately large. The ranking changes, not just the magnitude. So the “top accounts” list is wrong in a way that survives a sanity check on the total, and wrong in a direction that correlates with product breadth — exactly the kind of correlation a human will find a story for.
Every table and every intermediate result has a grain — the set of columns that uniquely identifies a row:
grains:
orders: {order_id}
order_items: {order_id, line_item_id}
accounts: {account_id}
monthly_arr: {account_id, month}
When you join two tables, the grain of the result is the union of their keys (for an inner join on a shared key). If the result grain is finer than the measure’s declared grain, a fan-out has occurred:
join(orders, order_items) on order_id
→ result grain = {order_id, line_item_id}
→ order_amount.grain = {order_id}
→ FANOUT DETECTED: result grain ⊋ measure grain
Aggregating a fanout result set is an error unless you either:
Option 1 is preferred because the resulting query plan is readable and doesn’t depend on dialect quirks:
-- Correct: filter at item grain, then join pre-aggregated
WITH filtered_orders AS (
SELECT DISTINCT order_id
FROM order_items
WHERE category = 'Enterprise'
)
SELECT a.account_id, SUM(o.order_amount) AS revenue
FROM orders o
JOIN filtered_orders fo ON fo.order_id = o.order_id
JOIN accounts a ON a.id = o.account_id
GROUP BY 1;
Grain Theory (Karayannidis, arXiv 2601.00995)
Formalizes grain as a type and carries machine-checked proofs in Lean that aggregation over a fan-out join is unsound. The key result: a measure \(m\) with declared grain \(G_m\) can be safely aggregated over a result set with grain \(G_r\) if and only if \(G_r \subseteq G_m\). When \(G_r \supset G_m\), the multiplicative inflation equals \(|G_r| / |G_m|\) per group — the average fan-out.
from dataclasses import dataclass
@dataclass(frozen=True)
class Grain:
keys: frozenset[str]
def is_subset_of(self, other: "Grain") -> bool:
return self.keys <= other.keys
def join_with(self, other: "Grain", join_keys: set[str]) -> "Grain":
"""Grain of an inner join result."""
return Grain(self.keys | other.keys)
def has_fanout_for(self, measure_grain: "Grain") -> bool:
"""True if aggregating this measure over this result is unsafe."""
return not measure_grain.keys <= self.keys
# Unsafe when result grain is FINER than measure grain
@dataclass
class Measure:
name: str
grain: Grain
additivity: str # "additive", "semi_additive", "non_additive"
non_summable: frozenset[str] = frozenset()
time_rule: str = "sum" # "sum", "last", "average"
def validate_aggregation(measure: Measure, result_grain: Grain,
agg_function: str, group_dims: set[str]) -> str:
"""Returns None if valid, or an error message."""
# Check 1: fan-out
if not measure.grain.keys <= result_grain.keys:
# Result is coarser than measure — this is fine (rolling up)
pass
if result_grain.keys > measure.grain.keys:
return (f"Fan-out detected: result grain {result_grain.keys} is finer "
f"than measure grain {measure.grain.keys}. "
f"Pre-aggregate or deduplicate before aggregating.")
# Check 2: additivity
if measure.additivity == "non_additive" and agg_function in ("sum", "avg"):
return (f"Measure '{measure.name}' is non-additive. "
f"Cannot apply {agg_function}. Recompute from base facts.")
# Check 3: semi-additive over forbidden dimension
if measure.additivity == "semi_additive":
forbidden = measure.non_summable & group_dims
if forbidden and agg_function == "sum":
return (f"Measure '{measure.name}' is semi-additive. "
f"Cannot SUM across {forbidden}. "
f"Use time_rule='{measure.time_rule}' instead.")
return None # valid
A table daily_metrics has grain {account_id, date} and contains a column active_users which is a daily snapshot count (semi-additive: sums across accounts, not across dates). An agent generates: SELECT account_id, SUM(active_users) FROM daily_metrics WHERE date BETWEEN '2026-06-01' AND '2026-06-30' GROUP BY account_id. What does the validator say? What is the correct query?
The validator rejects: “Measure ‘active_users’ is semi-additive. Cannot SUM across {date}. Use time_rule=’last’ instead.” The correct query selects the last date in the range: SELECT account_id, active_users FROM daily_metrics WHERE date = '2026-06-30' (or uses a window function to pick the most recent non-null value per account).
You have tables orders(order_id, account_id, amount) and payments(payment_id, order_id, method, paid_amount). An agent wants total revenue by payment method. It writes: SELECT p.method, SUM(o.amount) FROM orders o JOIN payments p ON p.order_id = o.order_id GROUP BY 1. An order can have multiple partial payments. What goes wrong, and what does the grain checker report?
The join grain is {order_id, payment_id} (finer than the orders grain of {order_id}). The amount measure has grain {order_id}. Fan-out detected: each order appears once per payment, inflating SUM(amount) by the average number of payments per order. The correct approach: either sum paid_amount (which has the right grain) or pre-aggregate payments to the order level before joining.
Ratios deserve special treatment because the correct aggregation is never AVG(precomputed_rate) — it is always SUM(numerator) / SUM(denominator) at the target grain.
Consider conversion rate, defined as conversions / sessions. If Channel A has 10 conversions out of 100 sessions (10%), and Channel B has 5 out of 20 sessions (25%), the overall rate is 15/120 = 12.5%, not the average of 10% and 25% (17.5%). The average-of-averages is weighted equally by group, not by volume, and is always wrong unless all groups have identical denominators.
The registry encodes this:
conversion_rate:
agg: ratio
numerator: conversions
denominator: sessions
compile_to: "SUM(conversions) / NULLIF(SUM(sessions), 0)"
When the query planner encounters a request for “conversion rate by region,” it doesn’t look for a precomputed conversion_rate column and average it. It compiles to SUM(conversions) / SUM(sessions) grouped by region. The difference between these two queries is the difference between a meaningful metric and noise.
Semi-additive measures require time intelligence: the query layer must know how to handle the time dimension correctly without being told each time.
The registry’s time_rule field specifies the behavior:
| Rule | Behavior | Typical measures |
|---|---|---|
last | Take the last value in the period | ARR, headcount, account balance |
first | Take the first value in the period | Opening balance |
average | Average across the period | Average inventory, average DAU |
max | Peak value in the period | Peak concurrent users |
When an agent asks “What is our ARR this quarter?” and the query spans May–July, the compiled query automatically resolves to the July value (the last month in the range), not the sum. This happens at the IR level, before SQL generation, which means the model never needs to know the rule — it is enforced structurally.
SUM(arr) grouped by account_id → validSUM(arr) grouped by account_id, month → rejected (sum across time)AVG(conversion_rate) grouped by channel → rejected (average of ratios); should suggest the correct SUM/SUM formulationSUM(order_amount) after joining with order_items → rejected (fan-out)weekly_metrics table with grain {account_id, week}. It contains both additive measures (weekly_revenue) and semi-additive measures (week_end_arr). Design the rules for when the query planner can serve a request from this pre-aggregate vs. when it must fall through to base facts. Implement the grain-subsumption check: the pre-aggregate is usable only if its grain is equal to or coarser than what the query needs, AND the measure’s additivity permits the pre-aggregation that was already applied.net_revenue is defined as gross_revenue - refunds. Both components are additive. Is net_revenue itself additive? What about net_revenue_rate = net_revenue / orders? Design the rules for derived measures — measures computed from other measures — and how their additivity class is inferred from their components.