Operational KPI monitoring that tells you which slice broke the number, attributing every deviation across 23 dimension slices in 20 ms and clearing the mix shift decoy that a raw threshold alert gets wrong.
- "The number moved" is not an incident report. Most KPI monitoring stops at the top line. This decomposes each deviation additively across region, channel, product category, warehouse and customer segment, ranks slices by contribution and names the minimal explaining set. On the seeded EMEA carrier failure it returns one slice out of five carrying 95.8% of the movement, and top slice accuracy is 3 of 3 on the incidents that have a ground truth slice.
- Composition changes fire alerts that waste an analyst's morning. A promotion that shifts the customer mix drops top level revenue per order by 40.2% while every segment's own average order value is unchanged. The rate versus mix decomposition measures that as 99.2% mix and 1.5% rate, and the alert is not raised. Measured: 1 correct rejection, 0 decoy false positives.
- Alert thresholds are usually picked by feel and never checked. The threshold here is derived from a family wise error target, and the resulting false alarm rate is measured against seeded ground truth rather than asserted: 2 flagged days in 2,747 quiet KPI days, 0.073%, at precision 0.80 and recall 1.00.
An operations analytics team watches a handful of daily KPIs: orders, on time delivery, support contact rate, average order value. When one of them moves, the work is not noticing, it is explaining. The usual sequence is a dashboard alert, then forty minutes of pivoting a spreadsheet by region, then by channel, then by warehouse, until someone finds the slice that moved. On a team of four analysts each handling two or three of these a week, at a fully loaded cost around 60 dollars an hour, that is roughly 25 investigation hours a month, about 1,500 dollars, spent on work a computer can do exactly. Those figures are a representative scenario, not a measurement from a real customer. The larger cost is the alerts that turn out to be nothing: a marketing promotion changes the customer mix, average order value falls 40%, three people spend a morning on it, and nothing was ever broken.
This repository detects the movement and explains it in the same pass. Each KPI is decomposed into a trend (a trailing median with a drift correction), a weekly shape (centred medians of same weekday history) and a holiday effect (learned per holiday key as a multiplicative factor from previous occurrences of that key). Every component uses past data only, because the production job runs each morning and cannot see the future. Deviation is scored as a robust z against a MAD based scale. When a KPI deviates, the movement is decomposed algebraically across every dimension into a rate effect, a mix effect and an interaction, an identity that is exact rather than approximate, so the waterfall in the report sums to the number in the alert. The mix term is Simpson's paradox turned into a figure: when it carries the movement and the rate term does not, nothing is broken. The stack is Python (pandas, numpy, scipy), DuckDB as the local engine, sqlglot to render the aggregate SQL for Snowflake and Azure SQL as dialect targets, jinja2 for the report, and an Azure Data Factory pipeline definition with a local runner that executes the same activity graph.
The dataset is seeded, which is the point: five incidents are injected with known shape, slice and dates, so the detector can be scored instead of described. Against that ground truth, on 912 days across 1,313,280 fact rows and four monitored KPIs, the detector returns precision 0.800, recall 1.000, F1 0.889, median detection latency 0 days (mean 1.25, worst case 5 days on the gradual drift), and top slice attribution correct on 3 of the 3 incidents that have a ground truth slice. Outside every incident window it flagged 2 of 2,747 eligible KPI days, a 0.073% false alarm rate. The decoy was seen, attributed and correctly not raised. Full scan latency at 1.31M fact rows is 315 ms p50 and 417 ms p99 on a 2 vCPU Intel Xeon at 2.10 GHz with 7.8 GiB RAM. Raw scores in docs/evaluation.json, raw timings in benchmark/results/results.json.
flowchart TD
subgraph SRC["Source, simulated locally"]
SEED["kpi-radar seed<br/>2.5 years, 5 dimensions<br/>4 real incidents + 1 decoy"]
GT[("incident_ground_truth<br/>shape, slice, dates")]
end
subgraph WH["DuckDB warehouse, the only runtime engine"]
FACT[("fact_ops_daily<br/>1,313,280 rows")]
AGG[("agg_slice_daily<br/>20,976 rows<br/>one UNION ALL statement")]
BASE[("baseline_daily<br/>104,880 rows")]
KPID[("kpi_daily<br/>actual, expected, band, z")]
DET[("detected_incident<br/>attribution_slice")]
end
subgraph MODEL["Baseline and detection"]
DEC["decompose_frame<br/>trend + weekday + holiday<br/>past data only"]
SIG["rolling MAD sigma<br/>full window required"]
TIER["two tier rule<br/>robust z past 4.5 for one day, or<br/>robust z past 3.0 for two days"]
end
subgraph ATTR["Attribution"]
RECON["reconcile slice baselines<br/>onto the total baseline"]
SPLIT["rate / mix / interaction<br/>exact identity"]
CLASS{"mix at least 70% and<br/>rate at most 25%<br/>on any dimension?"}
end
SEED --> FACT
SEED --> GT
FACT --> AGG --> DEC --> BASE
DEC --> SIG --> TIER --> KPID
KPID --> RECON --> SPLIT --> CLASS
AGG --> RECON
CLASS -- yes --> SUPP["SUPPRESSED_MIX_SHIFT<br/>logged, not paged"]
CLASS -- no --> OPEN["OPEN incident<br/>ranked slices + narrative"]
SUPP --> DET
OPEN --> DET
DET --> RPT["HTML report + CLI explain"]
DET --> EVAL["evaluate vs ground truth<br/>precision, recall, latency"]
GT --> EVAL
F1["Failure boundary: empty landing table.<br/>CheckLandingData fails the run,<br/>nothing downstream executes"] -.-> FACT
F2["Failure boundary: insufficient history.<br/>168 day warmup, and a first ever holiday<br/>occurrence issues no verdict"] -.-> DEC
F3["Failure boundary: degenerate scale.<br/>A partial MAD window is not scored,<br/>sigma floored at 0.05% of expected"] -.-> SIG
F4["Failure boundary: composition artefact.<br/>Mix dominant movements are recorded,<br/>never paged"] -.-> CLASS
classDef boundary fill:#fff7ed,stroke:#c2410c,stroke-width:1px,color:#7c2d12;
class F1,F2,F3,F4 boundary;
| Technology | Role here | Why chosen for this problem |
|---|---|---|
| DuckDB 1.5 | The only runtime engine: fact table, precomputed aggregates, baselines and results | The aggregate layer is 20,976 rows and the fact is 1.3M. An embedded columnar engine rebuilds the whole aggregate in 381 ms with no server, so the demo runs from a clone with zero credentials, which is the constraint this repository is built under |
| pandas + numpy | Rolling medians, weekday grouping, the decomposition itself | The baseline is 115 daily series at depth 1 and 1,070 at depth 2. Vectorising the whole matrix at once with rolling(...).median() keeps a full rebuild at 2.3 s, where a per series Python loop would take minutes |
| scipy | The Gaussian tail used to derive the alert thresholds | The derivation needs norm.sf and nothing else. Deriving the threshold rather than picking it is the point of ADR 0001, so the dependency earns its place in one function |
| sqlglot | Renders the aggregate statement for Snowflake and Azure SQL | The aggregate SQL is the piece that would actually move to a cloud warehouse. Transpiling it and asserting the result reparses in the target dialect is an honest way to show portability without pretending to have a Snowflake account |
| jinja2 | The self contained HTML incident report | An incident report gets attached to a Jira ticket. A single file with inline SVG and no external assets survives that; anything needing a web server does not |
| matplotlib | The KPI series chart and the benchmark chart | The KPI chart overlays actual, baseline, band and two classes of flagged day across four panels. Static, scriptable and committed beats an interactive chart nobody can reproduce |
| rich | CLI tables for scan, explain and evaluate |
explain is the product surface. Aligned contribution columns with the minimal set highlighted are what make terminal output pasteable into a ticket |
| Azure Data Factory (definition) + local runner | Daily schedule, activity dependencies, retry policies | ADF is the orchestrator the team already runs. The pipeline JSON is the deployment artefact; the local runner parses that same file and executes a DuckDB implementation of each activity, so the graph in the repository is the graph that would ship |
| Python 3.11 + argparse | CLI entry point | Eight subcommands with no plugin system needed. A dependency free parser keeps the container to a single pip install |
Prerequisites:
- Python 3.11 or newer
- git
- No cloud account, no database server, no credentials of any kind
git clone https://github.com/Sandeep0430/ops-kpi-anomaly-radar.git
cd ops-kpi-anomaly-radar
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
# 1. Generate 2.5 years of operational data with five known incidents
kpi-radar seed
# 2. Decompose every slice series into trend, weekly shape and residual
kpi-radar baseline
# 3. Detect, attribute and classify
kpi-radar scan
# 4. Why did on time delivery break the day the EMEA carrier changed?
kpi-radar explain on_time_delivery_rate 2025-02-10
# 5. Why is the 40% drop in average order value NOT an incident?
kpi-radar explain revenue_per_order 2024-06-12
# 6. Score the detector against the seeded ground truth
kpi-radar evaluate
# 7. Render the self contained HTML report to reports/incident_report.html
kpi-radar reportEverything above is also make demo. Other entry points:
make test # pytest with coverage
make bench # the latency benchmark, rewrites benchmark/results/
make lint # ruff
kpi-radar run-dag # run the ADF activity graph locally
kpi-radar sql --dialect snowflake # the aggregate statement, Snowflake dialect
docker compose up --build # seed, run the DAG, then score, in containersThis distinction matters, so it is stated plainly rather than implied:
| Piece | Status |
|---|---|
orchestration/adf/pl_daily_kpi_radar.json |
A real ADF pipeline definition with seven activities, dependsOn conditions, retry policies and timeouts. It is a deployment artefact. It is not executed by this repository and no Azure resource is created or contacted. |
orchestration/adf/tr_daily_0530_utc.json |
A real ADF ScheduleTrigger definition, daily at 05:30 UTC. Also not executed here. |
kpi-radar run-dag |
Actually runs. It parses the same pipeline JSON, builds the activity graph from its dependsOn edges, topologically orders it and executes a local DuckDB implementation of each activity. |
| The activity to function mapping | Lives inside the pipeline JSON, in each activity's userProperties.localRunner. An activity with no mapping fails the local run rather than being silently skipped, which is asserted in tests/test_orchestration.py. |
NotifyOnRaisedIncidents |
A WebActivity in ADF. Locally it only logs. There is no outbound call and no webhook secret anywhere in this repository. Both the JSON and the test say so. |
| Snowflake and Azure SQL | Dialect targets only. kpi-radar sql --dialect snowflake renders the aggregate statement, and the tests assert it reparses as valid Snowflake and T-SQL. No connection is ever opened. |
The generated HTML report (jinja2, rendered headless with playwright chromium at 1440x900). The card shown is the decoy: revenue per order fell 40.2% at robust z -80.1, the waterfall bridges the 151.72 baseline to the 90.77 actual through the customer segment contributions, and the plain English explanation states the movement is 99.2% composition and 1.5% rate, so it is not raised. Every segment's own average order value is unchanged in the table underneath.
Each monitored KPI against its causal seasonal baseline and the z = 3 band. Ground truth windows are shaded: red for incidents that must alert, green for the decoy that must not. Red dots are days inside a raised incident, green squares are days the detector flagged and then explained away as a mix shift. The bottom panel is the decoy: the detector sees a 40% move and stays quiet.
Real terminal output: kpi-radar explain on the EMEA step change (verdict OPEN, EMEA carrying 95.8% of the move, 99.8% rate) and on the promotion decoy (verdict SUPPRESSED_MIX_SHIFT, 101.1% mix, -1.9% rate), followed by pytest --cov=src/kpi_radar: 128 passed, 99% total coverage. The raw text is committed at docs/terminal_capture.txt.
Method: benchmark/run_benchmark.py builds a warehouse at each scale, then times repeated operations against the warm DuckDB file in a single process with no concurrency. Each scan figure is 12 repeated full scans; each attribution figure is 60 single day attribution calls on distinct dates. Container: Intel Xeon at 2.10 GHz, 2 logical CPUs, 7.8 GiB RAM, Python 3.11.15, DuckDB 1.5.5, Linux.
Scan latency by dataset size (attribution depth 1, 23 slices):
| Scale | Fact rows | Aggregate rebuild | Baseline rebuild | Scan p50 | Scan p95 | Scan p99 |
|---|---|---|---|---|---|---|
| small | 59,076 | 56 ms | 1,479 ms | 133.1 ms | 153.3 ms | 158.6 ms |
| medium | 656,640 | 259 ms | 1,892 ms | 273.1 ms | 370.6 ms | 415.7 ms |
| large | 1,313,280 | 381 ms | 1,988 ms | 315.2 ms | 400.2 ms | 417.4 ms |
Attribution latency by slice count (fixed 1,313,280 row fact table):
| Configuration | Slices | Baseline rows | Baseline rebuild | Attribution p50 | Attribution p95 | Attribution p99 |
|---|---|---|---|---|---|---|
| depth 1, single dimensions | 23 | 104,880 | 2,323 ms | 19.96 ms | 38.05 ms | 64.18 ms |
| depth 2, every dimension pair | 214 | 975,840 | 19,306 ms | 33.25 ms | 53.32 ms | 63.59 ms |
Where it degrades, honestly: the per query costs scale gently, but the nightly baseline rebuild does not. Turning on attribution depth 2 multiplies the slice count by 9.3 and the rebuild by 8.3, from 2.3 s to 19.3 s, because each of the 1,070 series gets its own rolling median pass; a depth 3 configuration would multiply the slice count again into the thousands and push the rebuild past the point where it fits a morning batch window, which is why KPI_RADAR_ATTRIBUTION_DEPTH is validated to 1 or 2 rather than left open.
- ADR 0001: Explainable seasonal decomposition plus additive attribution, not an ML anomaly detector. Isolation Forest gives a score with no expected value to decompose, and Prophet gives a fitted value that would not reconcile with the attribution arithmetic; the requirement is that an analyst can defend every field of the alert to an executive.
- ADR 0002: A precomputed daily aggregate table, not on the fly computation over raw events. The boring choice: one
UNION ALLinto a long table, rebuilt in full each run, so attribution reads a few hundred rows instead of re-scanning 1.3M rows per series.
Not picked by feel. Two tiers, both derived from a false alarm budget across the KPI panel under Gaussian residuals:
- Persistence tier,
|z|at or above 3.0 on two consecutive days on the same side of the band. A single two sided test at 3.0 fires with probability 0.0027 per day. Across a panel of K = 4 KPIs that is one expected false alarm per1 / (4 * 0.0027) = 93days, roughly one a quarter, which is the budget an on call analyst will tolerate for a single day signal. Requiring the same sided excursion twice in a row squares the one sided rate, taking the panel expectation to about one false alarm per 190 years, at a cost of at most one day of latency on anything that is not a one day event. - Immediate tier,
|z|at or above 4.5 on a single day, two sided probability 6.8e-6, so one day events (an outage, a gateway failure) still fire with zero latency without opening the door to routine three sigma noise. - Materiality guard, the relative deviation must also be at least 0.5%, so a statistically clean but operationally irrelevant move never becomes a ticket.
Combined, the theoretical rate under Gaussian residuals is 1.04e-5 per KPI day. The measured rate on the seeded data is 0.073%, 2 flagged days out of 2,747 eligible quiet KPI days, about 70 times the Gaussian expectation. That gap is the honest finding: operational residuals have fatter tails than a normal distribution, so a threshold derived from Gaussian tail probability is optimistic by roughly two orders of magnitude, and the only way to know by how much is to measure it. Both numbers are printed by kpi-radar evaluate and stored in docs/evaluation.json.
Per incident results against the seeded ground truth:
| Incident | KPI | Shape | Alert expected | Latency | Top slice | Outcome |
|---|---|---|---|---|---|---|
| INC-01 | on_time_delivery_rate | step change confined to EMEA | yes | 0 days | correct | true positive |
| INC-02 | ticket_rate_per_100_orders | gradual drift in MOBILE | yes | 5 days | correct | true positive |
| INC-03 | ticket_rate_per_100_orders | single day spike, all slices | yes | 0 days | no ground truth slice | true positive |
| INC-04 | orders | weekend pattern break in RETAIL | yes | 0 days | correct | true positive |
| INC-05 | revenue_per_order | segment mix shift decoy | no | n/a | n/a | correct rejection |
The one false positive is a two day on time delivery excursion on 2024-10-20 at robust z -4.16 and a relative effect of -1.07%, recorded under unmatched_detections in docs/evaluation.json. It is real noise in the seeded data, not a bug, and it is counted against precision rather than explained away.
pytest --cov=src/kpi_radar --cov-report=term: 128 tests, 99% statement coverage. The suite covers the algebra (contributions sum to the deviation, the pure mix case shows zero rate effect), the estimator properties (the drift correction removes the lag on a ramp, weekday offsets recover a known pattern, holiday factors ignore priors inside the warmup), the failure paths (bad configuration, empty warehouse, malformed date, dependency cycle, unregistered activity), and dialect transpilation for Snowflake and T-SQL.
- Attribution depth 3 and above. Every dimension triple would take the slice count past 1,500 and the baseline rebuild past three minutes. Trigger to add it: an incident review where depth 2 leaves the top pair below 60% of the movement more than twice in a quarter.
- Sub daily granularity. The model is daily and the weekly seasonal term assumes it. Trigger: an SLA requiring detection inside the same day, which would mean an hourly profile per weekday and a much larger seasonal state.
- Alert routing, deduplication and on call escalation.
NotifyOnRaisedIncidentsis a WebActivity in the ADF definition and a log line locally. Trigger: the first time an incident is found in the report rather than in a notification. - Automatic remediation. The tool names the slice; a human opens the ticket. Trigger: none foreseen, an attribution engine should not be allowed to act.
- Streaming or event level ingestion. The fact is daily and precomputed, per ADR 0002. Trigger: the fact table growing to the point where a full aggregate rebuild misses the 05:30 window.
- A learned holiday model. Holiday effects come from a hardcoded calendar and at most two prior occurrences. Trigger: three or more years of history, at which point the per key factor is worth estimating with a proper interval.
- No credentials anywhere. DuckDB is a local file. Nothing here opens a network connection and there is no client library for any cloud service in the dependency list.
docker compose upneeds no secrets. - Configuration only through the environment. Every tunable is read in
src/kpi_radar/config.pyand validated at startup..env.exampledocuments each variable with a safe default;.envis gitignored. - What is never logged. The structured logger emits stage names, row counts, durations, KPI names, dimension names and slice labels. It never emits row level data and never emits the contents of an environment variable. The ADF
WebActivitysetssecureOutput: trueand authenticates with a managed identity, so there is no webhook secret to leak. - Least privilege in the deployment shape. The ADF Script activities call stored procedures rather than issuing DDL, so the service principal needs execute on those procedures and read on the fact, not ownership of the schema.
- Container hardening. The image runs as a non root user (uid 10001), writes only to a mounted
/datavolume, and has no shell entry point: the entry point is the CLI itself. - Reproducibility. The seeded dataset is generated from a fixed
KPI_RADAR_RANDOM_SEED, so a reviewer regenerating the data gets the same incidents and can re-derive every number in this README.
| Failure | Detection | Behaviour | Recovery |
|---|---|---|---|
| Landing data missing or empty for the run date | CheckLandingData validation activity counts fact_ops_daily rows |
The run fails before any downstream activity starts, so yesterday's results are never partially overwritten | Fix the upstream load and rerun the pipeline; the aggregate is rebuilt in full, so there is nothing to reconcile |
| Not enough history for a series (new KPI, new slice, restored warehouse) | expected is null and the day falls inside the 168 day warmup |
No verdict is issued for that day, and the day is excluded from the false positive denominator so the scorecard is not flattered | Wait for history, or lower KPI_RADAR_TREND_WINDOW_DAYS and KPI_RADAR_MAD_WINDOW_DAYS knowing the bands widen |
| A calendar event with no prior occurrence (a first Black Friday) | The holiday key has no usable prior in the history | The day is marked as having no comparable history: the band is widened and no verdict is issued, rather than guessing an effect | The next occurrence of that key learns from this one automatically |
| Degenerate scale estimate, MAD collapsing towards zero | Sigma is floored at KPI_RADAR_MIN_SIGMA_REL of the expected value, and a partial MAD window is not scored at all |
A quiet stretch cannot turn a trivial wobble into a large z score. This was a real bug, see the war story | Raise the floor if a KPI is genuinely near constant, or lengthen KPI_RADAR_MAD_WINDOW_DAYS |
| A movement is entirely a composition change (Simpson's paradox) | Mix share at or above 70% with rate share at or below 25% on any single dimension | The incident is recorded with status SUPPRESSED_MIX_SHIFT, the narrative names the dimension that explains it, and nobody is paged |
Tighten KPI_RADAR_MIX_SUPPRESSION_SHARE if a real regression is being masked; a suppressed incident is always stored, never dropped |
| Slice baselines not adding up to the total baseline | Each dimension's expectations are rescaled onto the total before decomposing | Contributions sum to the headline deviation exactly, so the waterfall and the alert cannot disagree. Also a real bug, fixed in 0ebd2e4 |
None needed; reconciliation runs on every attribution call |
| An ADF activity has no local implementation | resolve() looks up userProperties.localRunner in the pipeline JSON |
The local run fails loudly with the activity name instead of skipping it, so the local DAG cannot silently diverge from the deployed one | Register the function in orchestration._REGISTRY or remove the activity |
| A ratio KPI's denominator is zero for a slice on a given day | Checked before the weighted average decomposition | ValueError naming the KPI and the dimension, rather than a silent NaN propagating into the alert |
Investigate the slice; a day with no shipments at all is itself worth a look |
The first end to end scan raised 65 flagged KPI days on a dataset seeded with five incidents. Sorting the flags by absolute z put calendar events at the top, and the worst one was not merely wrong, it was impossible: on 25 December 2024, on time delivery rate had a baseline of 100.26 percent, so the actual value of 94.21 percent was flagged at robust z -5.9. A delivery rate cannot exceed 100 percent. The raw evidence is committed at docs/war_story_holiday_bug.txt.
The root cause was in how the holiday effect was learned. Each measure was decomposed independently, and the holiday adjustment was the median residual observed on previous occurrences of the same holiday key, applied as an additive offset. That is fine for a single count series. It is wrong for a ratio. Christmas removes roughly 70 percent of the day's volume, so both on_time_deliveries and shipments were shifted down by large absolute amounts, and those two amounts were estimated from different series with different noise. The ratio of two independently shifted numbers has no obligation to stay inside the unit interval, and on that day it did not. The deeper point is that holiday effects on operational volume are proportional, not absolute: Christmas is "a third of a normal day", not "minus 38,000 orders", and encoding a proportional effect as an additive one guarantees the error grows with the size of the effect.
The fix, commit a7fb223, learns the effect as a factor, actual / expected, and applies it multiplicatively. A numerator and a denominator that both carry the same factor leave the ratio's expectation exactly where it was. That change forced a second one: the factor cannot be learned without a baseline, and the baseline cannot be estimated cleanly across a ten day holiday cluster, so the decomposition became two passes. Pass one holds every calendar event day out of the rolling windows and measures what each holiday did. Pass two divides the holiday days by the factor learned for their key and re-estimates on the repaired series, so the windows are contiguous calendar days again. The first version of that idea kept the holidays excluded permanently, which was worse in a new way: with ten consecutive December holidays removed, the early January window reached three weeks further back and the drift extrapolation ran off the end of the peak trading season, producing a 50 percent positive residual on 3 January. Flagged days went from 65 to 20 with the multiplicative factor, then to 13 once the two pass structure removed the January artefact, and the rest came out with the threshold work.
A second, unrelated bug surfaced in the same session and is fixed in commit ab1eee8: the MAD scale estimator accepted a half full trailing window, and the first residuals available after the warmup were similar enough to each other that sigma collapsed towards its relative floor. Dividing by it turned a 2.1 percent wobble in daily orders into a robust z of -42.9, with the mirror image of +38.3 the following day. The fix is to require the full mad_window_days of residuals and extend the warmup to match, so no day is ever scored against a scale estimate built from a partial window.
- Ship a CUSUM companion for slow drift. The measured latency on the seeded drift is 5 days, but that only works because the ramp exceeds the detection floor of a trailing baseline, roughly
3 * sigma / lagper day. A cumulative sum on the residual would catch ramps below that floor. First metric to watch after deploying: the distribution of detection latency by incident shape, because a drift arriving 5 days late is a very different product from a step arriving the same day. - Learn the mix suppression thresholds from analyst feedback. 70% mix and 25% rate are defensible starting points, not measured optima. Once real incidents are confirmed or dismissed in an incident register, those two numbers become a small supervised problem with a precision recall curve behind them.
- Add a second grain to attribution: time of day, and the internal composition of a slice. Several real incidents are a slice behaving normally in aggregate while its own internal mix moves, which depth 2 catches only if the right pair of dimensions is materialised.
- Push the aggregate and the decomposition into the warehouse. The aggregate SQL already transpiles to Snowflake, and the decomposition is medians and differences that would run as window functions. That removes the Python hop entirely for teams whose data never lands locally.
- Track the false alarm rate as a first class production metric. The 0.073% measured here is against seeded ground truth. In production the equivalent is the share of raised incidents an analyst closes as "no action", and if that drifts above about 30% the thresholds need revisiting before people stop reading the alerts.



