Learn
Analysis
Some numbers always wobble, so a change is only news once it is bigger than the noise and you know where it happened.
How the engine finds what changed and who moved it, with SQL facts, noise bands, and contribution and price-volume-mix splits.
Stage 3 of 10. MEASURE, DETECT and DECOMPOSE turn the metrics into facts, find the changes worth your time, and split each change into who caused it. No language model runs here either.
The problem it solves
Section titled “The problem it solves”Six months of revenue is easy to look at. Six months of five metrics by six customers, two channels and five products is not: some numbers always wobble, a seasonal peak is not news, and “revenue fell” is useless until you know where it fell. A model asked to eyeball the table will find patterns in noise and do the subtraction in its head.
So detection is a fixed library of tests, each one SQL plus a small function, and every number it produces is a fact with an ID.
What happens on the example
Section titled “What happens on the example”MEASURE · DETECT · DECOMPOSE · the coffee-roaster run
In
MEASURE · DETECT · DECOMPOSE
- 1 · CompareThe latest month against the one before, as SQL facts and a function fact for the change.
- 2 · Test against noiseIs the move bigger than the series usually wobbles? A robust z-score and a noise band answer it.
- 3 · Split the changeWho moved? Contribution by channel, and price × volume × mix by product.
From facts, insights and decompositions of the run.
Compare. March revenue was $44,100N12, April’s $36,200N11. Both are SQL facts: each chip opens its query, parameters and cited rows in the fact ledger at the end of the page. The change, −$7,900N29, and the percentage, −17.9%N30, are function facts: delta and pct_change applied to the two SQL facts, so each one points at its inputs.
Is it bigger than the noise? Every series wobbles. The engine measures how much revenue usually moves and asks whether April is outside that. Two tests agree here:
- a robust z-score of −24.55N18, against a threshold of ±3.5: far outside the usual wobble; Robust because it measures distance from the median in units of the median absolute deviation, so one outlier can’t stretch the scale. See the glossary.
- a noise band of ±741.3, against a level shift of −$6,400N28 after March (template
trend_break.v1). The band is not a fact but a parameter of the test, stored in the insight’s details: 2 × 1.4826 × the median absolute deviation of the series’ other month-to-month changes, so the change being tested can’t widen its own band (noiseBandinbaselines.ts).
March’s own rise from February was small enough to stay inside the band, so it is not reported at all. On this run 20 candidate insights came out of 8 templates (new_lost.v1, anomaly.v1, trend_break.v1, period_change.v1, ratio_shift.v1, trend.v1, concentration.v1, mix_shift.v1); ranking keeps the ones that matter.
Who moved it: contribution. Split by channel, the change adds up exactly:
Wholesale is more than 100% of the drop (108%N147) because retail grew a little ($600N145). A share above 100% is not an error: it means the other members moved the other way.
Price, volume or mix? Split by product with price-volume-mix: fewer units sold (volume) −$1,911.07N173, a shift toward cheaper products (mix) −$7,170.84N174, prices themselves $1,181.91N175. The three add up to −$7,900N176. Most of the fall is mix: sales moved away from the 1 kg bags, the products wholesale customers buy.
The same kind of split by customer, drawn as a waterfall:Drawn from the build’s own run of the example, the same run as every chip on this page.
Contribution by channel · Revenue fell after March, driven by wholesale
- Revenue 2026-03$44,100 N12
- wholesale−$8,500 N144108% of the change N147
- retail$600 N145−7.6% of the change N148
- Revenue 2026-04$36,200 N11
Both totals are drawn from a broken axis (≈), so the changes show at their own scale. Each change and its share are contribution.v1 facts recomputed by the verifier. The members drawn are all of them: their changes are the decomposition's total, −$7,900 N146.
The ideas behind it
Section titled “The ideas behind it”Facts, not answers
Section titled “Facts, not answers”Each number is stored with its computation: the SQL and parameters, or the function and the IDs of its inputs. That is what lets the verifier recompute it later, and you open it from its chip.
Robust statistics
Section titled “Robust statistics”An ordinary z-score uses the mean and standard deviation, which one outlier can drag around. A robust z-score uses the median and the median absolute deviation (MAD) instead, so a single strange month cannot hide another. A value beyond about 3.5 robust z is a common rule of thumb for “unusual”.
Exact decompositions
Section titled “Exact decompositions”Contribution, price-volume-mix and rate-versus-mix are identities: the parts always add up to the whole, and a test checks it on random data. There is also a Simpson’s-paradox guard: when a rate moves one way in every group but the other way in total, the engine reports the mix shift instead of a misleading headline.
Seasonality, on real data
Section titled “Seasonality, on real data”A December peak in retail is not news. The engine compares a month with the same month in earlier years when it has them, and doesn’t report a move that happens every year. The coffee example has only six months, so this is shown on the public datasets of the real-data evals (evals/results/2026-10-23-baseline-real.json):
- Hotel booking demand datasets: The summer peak: August is the top month of room revenue in 2015, 2016 and 2017 (G2). 2015 peak 2015-08; 2016 peak 2016-08; 2017 peak 2017-08; 3/3 values equal SQL (pass)
- Electronic card transactions, September 2026: The December peak: December is the top month of 2023, 2024 and 2025 (G1). 2023 peak 2023-12; 2024 peak 2024-12; 2025 peak 2025-12; 3/3 values equal SQL (pass)
- Electronic card transactions, September 2026: The regular January fall is not reported as news (G4: January is below December in 24 of 24 years). none (pass)
- Online Retail II: The Nov–Dec peak: November is the top month of 2010 and 2011 (G2). 2010 peak 2010-11; 2011 peak 2011-11; 3/3 values equal SQL (pass)
- Online Retail II: No finding about December 2011, a partial month (G3: the data ends 2011-12-09). no finding at 2011-12 (pass)
Try it
Section titled “Try it”The first tutorial’s job prints these findings; aa explain opens any fact:
AA_DEMO_MODEL=scripted bun run aa analyze examples/coffee-roaster --yes --embedder hash --today 2026-07-01 --currency USDbun run aa explain <job-id> N30See Analyse your first file for the recorded output.
In the code
Section titled “In the code”packages/analysis/src/templates.ts: the insight templates.packages/analysis/src/stats.tsandbaselines.ts: robust statistics and the noise band.packages/analysis/src/seasonal.ts: the seasonal guard.packages/analysis/src/decompose.ts: contribution, PVM, rate and mix.packages/analysis/src/rank.ts: which findings make the report.- The spec:
docs/specs/analysis-library.md.
Go deeper: Detecting changes and Finding drivers.
Further reading
Section titled “Further reading”- NIST/SEMATECH e-Handbook of Statistical Methods, Detection of outliers (the modified z-score and its 3.5 cut-off).
- Hyndman and Athanasopoulos, Forecasting: Principles and Practice, the chapters on time-series decomposition and seasonality.
- Stanford Encyclopedia of Philosophy, Simpson’s Paradox.
Facts on this page
Section titled “Facts on this page”| Fact | Value | What it is | How it was computed | Verified |
|---|---|---|---|---|
| N11 | $36,200 | revenue_2026-04 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($1))) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL)
SELECT period, value FROM m1 ORDER BY period parameters: | recomputed |
| N12 | $44,100 | revenue_2026-03 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($1))) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL)
SELECT period, value FROM m1 ORDER BY period parameters: | recomputed |
| N14 | $41,200 | revenue_2026-01 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($1))) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL)
SELECT period, value FROM m1 ORDER BY period parameters: | not in the report |
| N15 | $42,900 | revenue_2026-02 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($1))) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL)
SELECT period, value FROM m1 ORDER BY period parameters: | not in the report |
| N16 | $35,800 | revenue_2026-05 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($1))) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL)
SELECT period, value FROM m1 ORDER BY period parameters: | not in the report |
| N17 | $37,000 | revenue_2026-06 | SQL, run in DuckDBWITH m1 AS (SELECT strftime("date", '%Y-%m') AS period, sum("amount") AS value FROM "t_t1" WHERE NOT _is_total AND "date" IS NOT NULL AND (("status" IS NULL) OR ("status" NOT IN ($1))) AND strftime("date", '%Y-%m') BETWEEN $2 AND $3 GROUP BY ALL)
SELECT period, value FROM m1 ORDER BY period parameters: | recomputed |
| N18 | −24.55 | I3 anomaly.v1 revenue 2026-04:robust_z | robust_z N14 N15 N12 N11 N16 N17 | not in the report |
| N28 | −$6,400 | I5 trend_break.v1 revenue 2026-04:shift | delta N27N26 | not in the report |
| N29 | −$7,900 | I5 trend_break.v1 revenue 2026-04:change | delta N11 N12 | recomputed |
| N30 | −17.9% | I5 trend_break.v1 revenue 2026-04:change_pct | pct_change N11 N12 | recomputed |
| N144 | −$8,500 | contribution.v1 revenue 2026-04 by channel:delta:wholesale | delta N113N112 | recomputed |
| N145 | $600 | contribution.v1 revenue 2026-04 by channel:delta:retail | delta N111N110 | recomputed |
| N146 | −$7,900 | contribution.v1 revenue 2026-04 by channel:delta:total | sum N145 N144 | recomputed |
| N147 | 108% | contribution.v1 revenue 2026-04 by channel:share:wholesale | contribution.v1 N144 N146 | recomputed |
| N148 | −7.6% | contribution.v1 revenue 2026-04 by channel:share:retail | contribution.v1 N145 N146 | recomputed |
| N173 | −$1,911.07 | pvm.v1 revenue 2026-04 by product:pvm:volume | pvm.v1 N115N163N116N164N117N165N118N166N119N167N120N168N121N169N122N170N123N171N124N172 | recomputed |
| N174 | −$7,170.84 | pvm.v1 revenue 2026-04 by product:pvm:mix | pvm.v1 N115N163N116N164N117N165N118N166N119N167N120N168N121N169N122N170N123N171N124N172 | recomputed |
| N175 | $1,181.91 | pvm.v1 revenue 2026-04 by product:pvm:price | pvm.v1 N115N163N116N164N117N165N118N166N119N167N120N168N121N169N122N170N123N171N124N172 | recomputed |
| N176 | −$7,900 | pvm.v1 revenue 2026-04 by product:pvm:total | sum N173 N174 N175 | not in the report |