> ## Documentation Index
> Fetch the complete documentation index at: https://docs.toorow.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Anomaly detection method

> Z-score over a rolling 30-day window, anti-double-count guard, context citations (AD-9).

## Method: Simple Z-Score

For each (project, connector, metric, date), we compute:

```text theme={null}
zscore = (observed_value - rolling_mean) / rolling_stddev
```

Where:

* `rolling_mean` and `rolling_stddev` are computed over a trailing 30-day window (configurable: `ANOMALY_BASELINE_WINDOW`, default 29 days preceding + current row)
* Source: `fact_daily_kpi` single-canonical-dimension totals (anti-double-count guard)

The exact SQL is in `dbt/models/marts/metric_baselines.sql` and `dbt/models/marts/anomalies_daily.sql`.

## Anti-Double-Count Guard

`fact_daily_kpi` contains parallel breakdown\_dimension series (e.g. `device_category` AND `country`) that EACH independently total the day. Summing across ALL breakdown rows would produce values 2-5x too large. `metric_baselines.sql` uses the `canonical_dim` pattern (same as `cross_source_conversions.sql`) to pick ONE breakdown\_dimension per (project, connector, metric) deterministically (`MIN(breakdown_dimension)`), then aggregates only over that dimension's rows. This is an architectural requirement (AD-13, review-1-6 lesson).

## Threshold

An anomaly is flagged when `|zscore| >= ANOMALY_Z_THRESHOLD` (default 3.0).

* `|z|` in `[3.0, 5.0)` → severity: `warning`
* `|z| >= 5.0` → severity: `error`

`ANOMALY_Z_THRESHOLD` is configurable via environment variable (see `.env.example`).

## Why Z-Score?

* **Transparent**: no model parameters to tune, no black box. The exact formula is visible in the dbt model SQL (AD-9 requirement).
* **Robust** for marketing KPIs at day grain.
* **Testable**: the seed generator (`generate_seed.py`) injects a 3x sessions spike at day 75 (AI-09), producing a z-score well above 3.0 for deterministic test validation.

## Limitations

* Requires at least 2 days of history (sample stddev is undefined with 1 row). Rows with `rolling_stddev IS NULL OR = 0` are excluded — no division by zero, no false alerts during the first day of data.
* Sensitive to sudden baseline shifts (e.g. new campaign launch). Use `context_events` to annotate known shifts.
* Does not detect gradual drift (use declared thresholds for that: Story 5.3).

## Context Event Citations (AD-9)

Anomalies **never assert a cause**. When context events exist for the anomaly date, they are listed as **candidate context** only. When absent, "missing context" is stated.

This is an AD-9 architectural requirement. Prohibited language in any generated message:

* "caused by", "due to", "because of", "as a result of"

A unit test (`test_causal_language_absent` in `server/tests/core/test_anomaly_alerts.py`) enforces this constraint.

## Pull ID Provenance for Anomaly Firings

Anomaly firings have `pull_ids = []` (empty array). Unlike business threshold firings which trace to specific raw data pull batches, anomalies are **derived signals** computed by the `anomalies_daily` dbt model from historical data. Provenance is the dbt model itself (documented method above), not a raw data event.

## Environment Variables

| Variable                  | Default | Description                                                |
| ------------------------- | ------- | ---------------------------------------------------------- |
| `ANOMALY_ALERTS_ENABLED`  | `false` | Master switch for anomaly evaluator                        |
| `ANOMALY_Z_THRESHOLD`     | `3.0`   | Z-score threshold for anomaly flagging                     |
| `ANOMALY_BASELINE_WINDOW` | `29`    | Trailing window in days (29 preceding + current = 30 days) |
| `ANOMALY_SEED_DAY`        | `75`    | Day index (0-based) for injected test anomaly in seed      |
