Wiki:Packs/Demand Variance Decomposition
| Pack | |
|---|---|
| ID | CP-WFM-006
|
| Domain | WFM |
| Blocks | 1 instruction + 5 reference |
| Version | 1.1 |
| Source | Doubly Stochastic Arrivals and Demand Variance Decomposition · Supply Elasticity in Workforce Planning · Poisson Process in Contact Centers |
A pack is a deployable set for a Claude project. This one runs a single diagnostic: it splits the observed variability of contact demand into the part no forecast can ever predict, the part a forecast already explains, the part a better forecast could still explain, and the part that can only be met by supply response.
The four components call for different investments, and an operation that cannot tell them apart will reliably spend on the wrong one. The test needs only interval-level actuals and the forecast that was published against them.
When to use it
Use it when:
- Forecast accuracy has been improving and service outcomes have not moved with it
- A business case is being written for forecasting investment, automation, or flexible capacity, and the size of the addressable problem has not been established
- Demand is described as "volatile" without anyone having measured which kind of volatility
- A decision is pending on consolidating queues, and the effect on required buffer is contested
- Supply elasticity has been proposed as the binding constraint and needs evidence
Do not use it to produce a forecast, to grade forecaster performance, or to generate a single volatility index. It measures the shape of variability, not its level, and the whole value of the method is lost the moment the components are recombined into one number.
What it produces
| Output | What it decides |
|---|---|
| Three-way split — Poisson noise, explained rate variation, residual rate variation, as shares of total variance | Whether the problem is fragmentation, forecasting, or elasticity |
| Recoverable vs irreducible residual, from an out-of-sample model bake-off | How much of the remaining error a better forecast could actually reach |
| Regime test — residual CV against the buffer it forces | Whether the operation sits in the classical or the uncertainty regime, and whether scale still helps |
| Horizon resolution curve — MSE by forecast lead time, mapped to lever lead times | Which variance is knowable early enough for which lever to act on it |
| Workload extension — CV of interval mean handle time, and its correlation with volume | Whether workload volatility exceeds volume volatility, as it does in disruption-driven operations |
The two identities it rests on
Under a doubly stochastic arrival model, observed variance splits by the law of total variance, and forecast error carries the same irreducible floor:
Subtracting the floor from the forecast error is what separates residual rate uncertainty from the Poisson noise every forecast is scored against but none can remove. A forecast whose mean squared error equals the interval mean is already perfect — a fact that is invisible in conventional accuracy reporting, and that decides whether further accuracy investment can pay at all.
Full derivation and worked arithmetic are in Doubly Stochastic Arrivals and Demand Variance Decomposition.
How to deploy
- Create a project in Claude. Name it for the work, not the method
- Copy Block 1 into the project's custom instructions
- Save Blocks 2–6 under the filenames in their headings and upload as project knowledge
- Start a conversation
Block 1 — Project instructions
# Demand Variance Decomposition — Diagnostic Project
You run one diagnostic: splitting observed contact-arrival variability into the part no
forecast can predict, the part a forecast already explains, the part a better forecast
could still explain, and the part that can only be met by supply response.
The output decides where planning investment goes. An operation that spends on forecast
accuracy when its variance is irreducible gets nothing for the money; an operation that
buys flexible capacity when its forecast is simply under-built pays for the wrong fix.
The decomposition tells them apart, and it needs only interval actuals and the forecast
that was published against them.
Treat the user as a practitioner with data access, not a statistician. Ask for the data,
say plainly what each field unlocks, and proceed with whatever tier they can supply.
## Routing
| Task | File |
|---|---|
| Deciding what data to request, or checking whether a supplied extract is sufficient | `data-requirements.md` |
| Understanding why the method works, or explaining it to someone | `decomposition-method.md` |
| Actually producing the numbers — extract query, code, ordered procedure | `runbook.md` |
| Checking whether a result is trustworthy, or diagnosing an implausible number | `validity-and-traps.md` |
| Writing up the finding, or converting it into an investment recommendation | `results-report.md` |
Open `data-requirements.md` first in any session that starts without data already loaded.
When the user is an analyst who wants to run the test rather than understand it, go
straight to `runbook.md` and work its checklist in order.
## Disciplines
- State which tier of data is in hand, and what that tier cannot answer.
- Never report forecast error without subtracting the Poisson floor — raw error overstates
remaining opportunity, worst on low-volume queues.
- Detrend before computing dispersion. Growth measured as randomness is the standard error.
- Compute the split within interval-of-week cells, not pooled across intervals. Pooling
measures the daily profile and reports it as variability.
- Separate forecast bias from forecast variance. Bias is a process defect, not evidence
about volatility.
- A variance figure alone means nothing. Always run the regime test that compares it to
the staffing buffer it forces.
- Distinguish what the current forecast failed to explain from what is unexplainable. Only
an out-of-sample model bake-off separates them.
- Mark every illustrative figure `[estimated]`. Never mix assumed numbers with measured ones.
- If the data cannot support a step, say so and stop. Do not infer the missing component.
## Output
Lead with the three-way split as percentages of total variance, then the regime verdict,
then the recommended allocation of effort across demand and supply sides.
Always state: the cell definition used, the observation window, the forecast vintage
compared against, and the excluded intervals with the reason for exclusion.
Refuse to produce a single headline "volatility score". The whole value of the method is
that it separates components that a single number recombines.
Source: Wiki:Packs/Demand Variance Decomposition (CP-WFM-006) v1.0
Block 2 — data-requirements.md
# Data Requirements
Figures marked `[estimated]` are judgment, not measurement.
The decomposition runs on three numbers per cell: the mean of actuals, the variance of
actuals, and the mean squared forecast error. Everything below exists to produce those
three numbers honestly, and then to attribute them.
## Tier A — minimum viable
Answers: is the variability irreducible, explained, or residual? What regime is the
operation in? Where should investment go?
| Field | Format | Notes |
|---|---|---|
| `interval_start` | timestamp, local time | At the interval actually staffed to — 15 or 30 min. Not daily. |
| `queue_id` | string | One row per queue or skill per interval. Do not pre-aggregate across queues. |
| `offered_volume` | integer count | Contacts **offered**, not answered. Answered volume is capacity, not demand. |
| `forecast_volume` | numeric | The forecast **as published at the lock horizon**, not a later revision. |
| `forecast_vintage` | date | When that forecast was generated. Required even if only one vintage exists. |
Span: **52 weeks minimum.** Below ~30 observations per interval-of-week cell the dispersion
estimate is too noisy to act on; a full year also lets seasonality be removed rather than
absorbed into the residual.
Daylight-saving transitions must be handled explicitly — either local time with duplicated
and missing hours flagged, or UTC with a local-hour column.
## Tier B — workload
Answers: is workload more volatile than volume, and do volume and handle time move together?
| Field | Format | Notes |
|---|---|---|
| `handle_seconds_total` | numeric | **Total** handle seconds in the interval. Not average handle time — an average of averages carries its own bias and cannot be re-weighted. |
| `contacts_handled` | integer | The denominator that pairs with the above. |
| `forecast_aht` | numeric | Forecast handle time at the same lock horizon. |
Handle time variation matters through the interval **mean**, not through per-contact
scatter, which washes out at scale. The correlation between interval volume and interval
mean handle time is the field most often missing and most often decisive in
disruption-driven operations.
## Tier C — attribution
Answers: at what horizon does variance resolve, and does it sit in simple or complex work?
| Field | Why it matters |
|---|---|
| **Forecast vintages** — same interval forecast at several lead times (e.g. 13wk, 4wk, 1wk, 1day) | The single highest-value optional field. Produces the resolution curve, which maps variance onto lever lead times and is what makes the demand-versus-supply split actionable rather than descriptive. |
| `contact_type` / complexity flag | Splits the decomposition by work type. Determines whether automation absorbs the variance or inherits it. |
| Event markers — disruption, weather, outage, campaign, release | Converts residual variance into explained variance where the driver was knowable. Often the cheapest accuracy gain available. |
| `queue_open`, `capped`, `outage` flags | Marks intervals where observed arrivals are censored by capacity rather than generated by demand. |
| `abandoned`, `redial_flag` or contact-chain id | Lets endogenous re-contacts be removed. Redials inflate variance and couple it to the operation's own service level. |
| Actual staffing / scheduled hours per interval | Allows the response side to be tested, not just the demand side. |
## Requesting the extract
Ask for it in this form. Vague requests return daily aggregates and revised forecasts,
both of which destroy the analysis.
> Interval-level extract, 30-minute intervals, by queue, for the last 24 months.
> Columns: interval_start (local, with DST flag), queue_id, offered_volume,
> forecast_volume as locked at the published horizon, forecast_vintage date,
> handle_seconds_total, contacts_handled, forecast_aht, queue_open flag.
> Where multiple forecast vintages exist, one row per vintage.
> Offered volume, not answered. Unrevised forecast, not the current system value.
## Intake gates — check before analysing
1. **Is `offered_volume` really offered?** If it equals answered plus abandoned, good. If
it equals answered, the extract is capacity and the analysis cannot proceed.
2. **Is the forecast the locked one?** If forecast equals actual to within a few percent
everywhere, it has been overwritten with a hindsight value. This is common and fatal.
3. **Are there enough observations per cell?** Count rows per interval-of-week. Report the
minimum; flag any cell below 30.
4. **What proportion of intervals are closed, capped or in outage?** If above roughly 5%
`[estimated]`, censoring must be handled explicitly rather than by dropping rows, since
dropping the busiest intervals biases the variance downward.
5. **Does volume trend materially across the window?** If so, detrending is mandatory, not
optional.
## What each tier cannot answer
- **Tier A alone** cannot say whether the residual is recoverable by a better model — that
needs the bake-off in `decomposition-method.md`, which Tier A does support — and cannot
say at what horizon the variance resolves.
- **Tier A + B** still cannot map variance to levers. Without vintages, the horizon curve
is unavailable and the demand-versus-supply recommendation stays directional.
- **No tier** can attribute variance to a cause the data does not carry. Absent event
markers, event-driven variance appears as irreducible residual, and the irreducible
component is overstated.
State the tier in every output.
Block 3 — decomposition-method.md
# Decomposition Method
Figures marked `[estimated]` are judgment, not measurement.
## The model
Arrivals are doubly stochastic: the rate is itself random, and arrivals are Poisson given
the rate. Writing N for the realised count in an interval and Λ for the random rate:
```
Var(N) = E[Λ] + Var(Λ)
Poisson noise rate uncertainty
```
Overdispersion — variance above the mean — is the signature of rate uncertainty and has no
other source under this model.
## Step 0 — build cells and detrend
Group intervals into **interval-of-week cells**: Tuesday 10:00–10:30 across all Tuesdays.
Never pool across intervals with different underlying rates; that measures the daily
profile and reports it as randomness.
Remove trend and annual seasonality before computing dispersion. Growth over the window is
predictable rate movement and belongs in the explained component. Leaving it in is the most
common failure of this method.
Exclude censored intervals (closed, capped, outage) and record how many were excluded.
## Step 1 — dispersion
For each cell, with mean `μ` and sample variance `s²`:
```
D = s² / μ index of dispersion
Var(Λ) = (D − 1) · μ rate variance
CV(Λ) = sqrt( (D − 1) / μ ) rate coefficient of variation
```
Under pure Poisson, `D ≈ 1`. The statistic `(n−1)·D` is chi-squared with `n−1` degrees of
freedom, which gives a formal test rather than an eyeball judgment.
`D < 1` (underdispersion) means arrivals are more regular than random — scheduled work,
throttling, or a capacity cap. Investigate before proceeding; it usually indicates the
extract is censored.
## Step 2 — split the rate variance using the forecast
For a forecast `F` fixed at publication:
```
MSE = E[(N − F)²] = E[Λ] + E[(Λ − F)²]
```
The forecast error contains the Poisson floor. Subtract it:
```
residual rate variation = MSE − μ
```
The three components, from three numbers:
| Component | Formula |
|---|---|
| Poisson noise | `μ` |
| Explained rate variation | `Var(N) − MSE` |
| Residual rate variation | `MSE − μ` |
| **Total** | `Var(N)` |
Two derived measures:
```
forecast efficiency = MSE / μ floor is 1.0; 1.0 means the forecast is perfect
residual CV = sqrt(MSE − μ) / μ
```
**Remove bias first.** Decompose `MSE = bias² + error variance`. Bias is a correctable
process defect and must not be reported as volatility.
**Compute at two levels.** Across all intervals, the explained component includes the
intraday and day-of-week profile and flatters the forecast. Within interval-of-week cells,
it measures only what the forecast adds over a naive seasonal baseline. Report both; the
second is the honest basis for deciding on further forecast investment.
If `MSE < μ`, the forecast is at or below the Poisson floor. Do not report a negative
residual — it means either the forecast has been overwritten with hindsight, or the cell
has too few observations. Go to `validity-and-traps.md`.
## Step 3 — recoverable versus irreducible
Step 2 measures what the *current* process failed to explain, not what is unexplainable.
Separating them requires a model bake-off.
Fit a well-specified count model — negative binomial or Poisson GLM, or mixed-effects with
a random day effect — including day-of-week × time-of-day, seasonality, holidays, event
indicators, trend, and any exogenous regressors available. Evaluate **out of sample**, on a
held-out period, **at the same lock horizon** as the production forecast. A model scored
in-sample or with hindsight regressors will always win and the comparison is worthless.
```
recoverable = MSE_current − MSE_model forecasting execution gap
irreducible = MSE_model − μ genuine rate uncertainty
```
Under a negative binomial fit with `Var = μ + μ²/θ`, the residual rate CV is `1/sqrt(θ)`.
## Step 4 — the regime test
A variance figure is meaningless until compared to the buffer it forces. The two buffers
scale differently:
```
Poisson buffer ~ β · sqrt(λ) square-root in volume
Rate-uncertainty buffer ~ z · CV · λ LINEAR in volume
regime ratio = (z / β) · CV · sqrt(λ)
```
Use the **residual** CV, not total `CV(Λ)` — the explained part is already handled by the
forecast. Typical `β ≈ 1.5` `[estimated]`, `z ≈ 1.64` for a 95% one-sided target.
- ratio well below 1 → classical regime; Erlang-style thinking is sound
- ratio around 1 → parity; both matter
- ratio well above 1 → **uncertainty regime**; rate uncertainty dominates the staffing decision
Crossover sits near `CV ≈ β / (z·sqrt(λ))` — about **5%** at 400 contacts per interval.
The consequence worth stating explicitly: in the uncertainty regime the buffer grows in
proportion to volume rather than its square root, so **consolidating queues does not
rescue the operation** — it improves the Poisson term while the dominant term scales up.
## Step 5 — horizon resolution
Requires forecast vintages. Compute MSE for the same intervals as forecast at each lead
time and plot the decline.
| Lever | Actuation lag |
|---|---|
| Hiring | 3–6 months, gated by time to proficiency |
| Schedule publication | 2–4 weeks |
| Shift bid, overtime, VTO, contingent call-up | 1–7 days |
| Intraday reallocation, offline work movement | 15–60 minutes |
| Routing, overflow, automated capacity | Immediate |
Variance still unresolved at a lever's lead time cannot be converted by that lever, however
accurate the forecast becomes. A curve that stays flat until the final days means the
demand side has nothing left to give at planning horizons and the answer is elasticity.
## Step 6 — workload extension
Staffing responds to workload, not contacts. With `W` the interval workload:
```
CV(W)² = CV(T)²/E[N] + CV(N)² per-contact scatter divided by N — washes out at scale
CV(W)² ≈ CV(N)² + CV(M)² + 2ρ·CV(N)·CV(M) M = interval MEAN handle time, ρ = corr(N, M)
```
Per-contact handle-time variability is largely irrelevant for large queues. What matters is
variation in the interval mean, and the covariance term.
Worked: `CV(N)=14%`, `CV(M)=10%`, `ρ=0.5` `[estimated]` → `CV(W) = 20.9%`, against 17.2% at
`ρ=0`. In disruption-driven operations ρ is positive because volume and handle time rise
together, so workload volatility exceeds what either component suggests.
Use total handle seconds ÷ contacts handled for the interval mean. Never a reported AHT
average of averages.
## Worked example, end to end
Cell: mean 400 contacts per half-hour, observed variance 6,400, published forecast MSE
3,600. All figures `[estimated]`.
| Quantity | Calculation | Value |
|---|---|---|
| Index of dispersion | 6400 / 400 | 16.0 |
| Poisson noise | μ | 400 — 6.3% |
| Explained rate variation | 6400 − 3600 | 2,800 — 43.7% |
| Residual rate variation | 3600 − 400 | 3,200 — 50.0% |
| Residual CV | √3200 / 400 | 14.1% |
| Forecast efficiency | 3600 / 400 | 9.0 (floor 1.0) |
| Regime ratio | (1.64/1.5) × 0.141 × 20 | ≈ 3.1 — uncertainty regime |
Bake-off returns `MSE_model = 2,400`:
- recoverable = 3600 − 2400 = **1,200** (37.5% of residual) — forecasting execution gap
- irreducible = 2400 − 400 = **2,000** (62.5% of residual) — needs elasticity
Reading: both problems are present. About a third of the addressable variance belongs to
the demand side, two thirds to the supply side. Forecast accuracy reporting alone would
have shown only an RMSE of 60 and would have implied the whole of it was addressable.
Block 4 — runbook.md
# Runbook — Executing the Test
Figures marked `[estimated]` are judgment, not measurement.
This block is for the analyst who has to produce the numbers. Work through it in order.
Steps 1–4 and 6 need only summary statistics; step 5 is the only one requiring a model.
## Checklist
- [ ] Extract pulled at the staffing interval, **offered** volume, by queue, 52+ weeks
- [ ] Forecast joined at the **lock horizon vintage**, not the current system value
- [ ] Censored intervals flagged, counted, excluded — count recorded
- [ ] Cells built as interval-of-week; minimum observations per cell recorded
- [ ] Dispersion computed within 13-week blocks, then pooled — not across the full window
- [ ] Bias decomposed out of MSE and reported separately
- [ ] Split computed at both levels: across-all and within-cell
- [ ] Regime ratio computed from the **residual** CV
- [ ] Provenance block completed (eight items, `validity-and-traps.md`)
## Step 1 — the extract
The join is the part that goes wrong. Actuals are easy; attaching *the forecast as it stood
at the decision horizon* is what makes the analysis valid. Adapt dialect as needed.
```sql
-- Actuals at the staffing interval
WITH actuals AS (
SELECT
date_trunc('hour', c.contact_start)
+ floor(extract(minute FROM c.contact_start) / 30) * interval '30 minute'
AS interval_start,
c.queue_id,
count(*) AS offered,
sum(c.handle_seconds) AS handle_seconds_total,
count(*) FILTER (WHERE c.handled) AS contacts_handled
FROM contacts c
WHERE c.contact_start >= :window_start
AND c.contact_start < :window_end
AND c.contact_direction = 'inbound' -- exclude outbound and dialler traffic
AND c.is_redial IS NOT TRUE -- drop endogenous re-contacts if identifiable
GROUP BY 1, 2
)
SELECT
a.*,
f.forecast_volume,
f.forecast_aht,
f.vintage AS forecast_vintage
FROM actuals a
LEFT JOIN LATERAL (
-- the LAST forecast published AT OR BEFORE the lock horizon.
-- :lock_days is the decision lead time (e.g. 14 for schedule publication).
SELECT fc.forecast_volume, fc.forecast_aht, fc.vintage
FROM forecast fc
WHERE fc.queue_id = a.queue_id
AND fc.interval_start = a.interval_start
AND fc.vintage <= a.interval_start - (:lock_days * interval '1 day')
ORDER BY fc.vintage DESC
LIMIT 1
) f ON TRUE
ORDER BY a.queue_id, a.interval_start;
```
For the horizon curve (Tier C), run the same query once per `:lock_days` value — 91, 28, 7,
1 — and keep the results side by side.
**If the forecast table holds only the current value with no vintage column, stop.** The
analysis cannot be run on revised forecasts, and producing it anyway yields a
confidently wrong answer rather than a caveated one.
## Step 2 — prepare
```python
import numpy as np, pandas as pd
Z, BETA = 1.64, 1.5 # 95% one-sided; QED safety-staffing coefficient [estimated]
MIN_OBS = 30 # per cell, below which estimates are too noisy to act on
BLOCK_WKS = 13 # dispersion block length — short enough that trend is negligible
def prepare(df, open_col="queue_open"):
df = df.copy()
df["interval_start"] = pd.to_datetime(df["interval_start"])
if open_col in df.columns: # drop censored intervals
excluded = int((df[open_col] != 1).sum())
df = df[df[open_col] == 1]
print(f"excluded {excluded} censored intervals")
df = df.dropna(subset=["forecast_volume"])
df["dow"] = df["interval_start"].dt.dayofweek
df["tod"] = df["interval_start"].dt.strftime("%H:%M")
df["block"] = ((df["interval_start"] - df["interval_start"].min()).dt.days
// (BLOCK_WKS * 7))
return df
```
Censored intervals are dropped rather than modelled here. That biases variance **downward**,
because closures cluster in busy periods — state the direction of bias in the write-up.
## Step 3 — dispersion, without measuring trend as randomness
```python
def dispersion(g):
"""Index of dispersion within 13-week blocks, pooled by median.
Computing D across the full window measures growth as rate uncertainty."""
ds = []
for _, b in g.groupby("block"):
if len(b) < MIN_OBS:
continue
mu, var = b["offered"].mean(), b["offered"].var(ddof=1)
if mu > 0:
ds.append(var / mu)
return float(np.median(ds)) if ds else np.nan
```
## Step 4 — the decomposition
```python
def decompose_cell(g):
n = len(g)
mu = g["offered"].mean()
var = g["offered"].var(ddof=1)
err = g["offered"] - g["forecast_volume"] # actual minus forecast
mse = (err ** 2).mean()
bias = -err.mean() # forecast minus actual
mse_db = mse - bias ** 2 # MSE with bias removed
poisson = mu
residual = mse_db - mu
explained = var - mse_db
res_cv = np.sqrt(max(residual, 0.0)) / mu if mu > 0 else np.nan
regime = (Z / BETA) * res_cv * np.sqrt(mu)
return pd.Series({
"n": n, "mean": mu, "var": var, "D": dispersion(g),
"mse": mse, "bias": bias,
"poisson": poisson, "explained": explained, "residual": residual,
"poisson_pct": 100 * poisson / var if var > 0 else np.nan,
"explained_pct": 100 * explained / var if var > 0 else np.nan,
"residual_pct": 100 * residual / var if var > 0 else np.nan,
"forecast_efficiency": mse / mu if mu > 0 else np.nan,
"residual_cv": res_cv,
"regime_ratio": regime,
"flag": ("HINDSIGHT_OR_SMALL_N" if residual < 0
else "LOW_N" if n < MIN_OBS
else "CENSORED?" if (g["offered"].var(ddof=1) / mu) < 0.8
else ""),
})
def decompose(df, by=("queue_id", "dow", "tod")):
return (df.groupby(list(by), group_keys=True)
.apply(decompose_cell)
.reset_index())
```
The three components sum to `var` by construction. If they do not, the cell has been built
across differing rates — check the grouping.
Run it twice: grouped by `("queue_id","dow","tod")` for the within-cell view, and by
`("queue_id",)` for the across-all view. The first is the honest basis for further forecast
investment; the second includes the intraday profile and will flatter the forecast.
## Step 5 — the bake-off (the only modelling step)
```python
import statsmodels.api as sm
import statsmodels.formula.api as smf
def bakeoff(df, holdout_start):
train = df[df["interval_start"] < holdout_start]
test = df[df["interval_start"] >= holdout_start]
m = smf.glm("offered ~ C(dow) * C(tod) + C(month) + is_holiday + t",
data=train,
family=sm.families.NegativeBinomial()).fit()
pred = m.predict(test)
return {
"mse_model": float(((test["offered"] - pred) ** 2).mean()),
"mse_current": float(((test["offered"] - test["forecast_volume"]) ** 2).mean()),
"mu": float(test["offered"].mean()),
}
# recoverable = mse_current - mse_model -> forecasting execution gap
# irreducible = mse_model - mu -> genuine rate uncertainty
```
Three rules make this comparison fair, and breaking any one of them manufactures a
forecasting gap that cannot be realised:
1. **Out of sample.** Score on `holdout_start` onward, never on training data.
2. **Same lock horizon.** The challenger may use only information available when the
production forecast was locked. No same-day regressors, no realised event flags.
3. **Same exclusions.** Identical censoring treatment on both sides.
## Step 6 — workload extension (Tier B)
```python
def workload(g):
m = g["handle_seconds_total"] / g["contacts_handled"] # interval MEAN handle time
cvN = g["offered"].std(ddof=1) / g["offered"].mean()
cvM = m.std(ddof=1) / m.mean()
rho = float(np.corrcoef(g["offered"], m)[0, 1])
cvW = np.sqrt(cvN**2 + cvM**2 + 2*rho*cvN*cvM)
return pd.Series({"cv_volume": cvN, "cv_mean_aht": cvM,
"rho": rho, "cv_workload": cvW,
"cv_workload_if_independent": np.sqrt(cvN**2 + cvM**2)})
```
The gap between the last two columns is the cost of ignoring the correlation. In
disruption-driven operations it is positive and material.
## Handing the result over
Report per queue, never blended. Take the output straight into the structure in
`results-report.md`, and complete the eight-item provenance block before circulating —
a decomposition without provenance should be presented as directional, not decisional.
Block 5 — validity-and-traps.md
# Validity and Traps
Figures marked `[estimated]` are judgment, not measurement.
Most failures of this method produce a plausible number rather than an error. The checks
below are what stand between a decomposition and a confidently wrong investment case.
## Implausible results and what they mean
| Symptom | Almost always means |
|---|---|
| `MSE < μ` (negative residual) | The forecast has been overwritten with a hindsight or revised value, or the cell has too few observations. Not a perfect forecast. |
| `D < 0.8` (underdispersion) | Arrivals are censored — a capacity cap, IVR port limit, throttle, or queue closure — or the work is scheduled rather than customer-initiated. |
| `D` above ~50 with a modest mean | Trend or seasonality has not been removed, or the cell mixes intervals with genuinely different rates. |
| Explained component negative | `MSE > Var(N)` — the forecast is worse than the cell mean. Usually large uncorrected bias. Decompose MSE and re-run. |
| Residual CV varies wildly between adjacent intervals | Cell sample sizes are too small. Widen the cell or lengthen the window. |
| Every queue returns nearly the same figures | The extract was pre-aggregated and the queue column is cosmetic. |
## The seven traps
**1. Pooling across cells.** Computing dispersion across intervals with different rates
measures the intraday profile, not randomness. This is the error that makes arrivals look
non-Poisson when they are not — arrivals are Poisson *given the rate*, and the rate varies
across the day by design. Always work within interval-of-week cells.
**2. Trend read as randomness.** A window in which volume grew 18% will report that growth
as rate uncertainty and overstate the irreducible component. Detrend first, every time.
**3. Forecast error reported without the Poisson floor.** Raw error overstates the
remaining opportunity, and does so worst on low-volume queues where the floor is a large
share of total variance. On a cell with mean 40, the floor is 40; an MSE of 120 leaves only
80 of rate error, not 120. Always subtract.
**4. Redials counted as demand.** Offered contacts include customers calling back because
the operation was understaffed. This inflates measured variance and couples it to the
operation's own service level, creating a feedback loop in which poor performance
manufactures evidence of high volatility. De-duplicate to customer-initiated contacts where
contact-chain identifiers exist; where they do not, state the limitation and note that the
irreducible component is likely overstated.
**5. Censoring by dropping rows.** Closed, capped and outage intervals are not missing at
random — they cluster in the busiest periods. Dropping them biases the variance downward
and makes the operation look more predictable than it is. Flag and model them, or state the
exclusion and its expected direction of bias.
**6. Hindsight in the bake-off.** A challenger model scored in-sample, or using regressors
unavailable at the lock horizon, will always beat production and will manufacture a
"forecasting execution gap" that cannot be realised. Evaluate out of sample, at the
production lock horizon, on a held-out period.
**7. Vintage drift.** Comparing actuals against whichever forecast currently sits in the
system measures hindsight, not forecast performance. Use the forecast as locked at the
decision horizon, and state which horizon that was.
## Preconditions the method assumes
- Arrivals within an interval are Poisson **given** the rate. This holds for large-base,
customer-initiated inbound contact. It does not hold for scheduled callbacks, outbound
dialler traffic, appointment-driven work, or queues with announced wait times where
arrival becomes endogenous to system state.
- The rate is approximately constant within the interval. At 15–30 minutes this is
generally safe; at daily granularity it is not, and the decomposition should not be run
on daily data.
- The customer base is large relative to call frequency. For small populations — an
internal helpdesk with a few hundred potential callers `[estimated]` — a finite-population
model applies and dispersion below 1 is expected rather than anomalous.
- The forecast is a genuine ex-ante prediction, not a target, budget or plan number.
Where the "forecast" is actually a commitment, the decomposition measures the gap between
ambition and reality and says nothing about forecastability.
## Where the method genuinely breaks
- **Blended or converted streams.** Callback queues, deferred work and outbound blending
produce arrivals determined by agent availability rather than customer behaviour. The
Poisson floor is not the floor for these, and the decomposition does not apply.
- **Structural breaks.** A migration, a routing change, a merged queue or a channel shift
mid-window makes the cell non-stationary. Split the window at the break and analyse the
segments separately, or the break is measured as volatility.
- **Very low volume.** Below roughly 20 contacts per interval `[estimated]`, the Poisson
floor dominates and the rate components cannot be estimated with enough precision to act
on. Aggregate to a coarser interval and state that the result no longer speaks to the
staffing interval.
## What a defensible result carries
Every reported decomposition must state:
1. Cell definition and observation window
2. Tier of data used, and what that tier cannot answer
3. Forecast vintage and lock horizon compared against
4. Intervals excluded, count and reason
5. Whether detrending was applied and how
6. Bias removed or retained, with the figure
7. Whether the bake-off was out of sample at the production horizon
8. Minimum observations per cell
A result missing any of these should be presented as directional, not decisional.
Block 6 — results-report.md
# Results and Reporting
Figures marked `[estimated]` are judgment, not measurement.
## The report structure
Lead with the split. Everything else supports it.
```
1. Verdict One sentence: which regime, and where investment belongs
2. The split Three components as % of total variance, per queue
3. Recoverable vs How much of the residual a better model would reach
irreducible
4. Regime test Residual CV, regime ratio, and what it implies for scale
5. Horizon curve Where variance resolves, mapped to lever lead times
6. Workload CV(M) and ρ, if Tier B available
7. Provenance The eight items from validity-and-traps.md
8. What this does Explicit limits of the analysis
not establish
```
## The headline table
Report per queue, never blended. Blending across queues with different regimes produces an
average that describes none of them.
| Queue | Mean | Var | Poisson % | Explained % | Residual % | Residual CV | Regime ratio | Verdict |
|---|---|---|---|---|---|---|---|---|
| ... | | | | | | | | |
## Reading the pattern
| Pattern | Diagnosis | Where effort belongs |
|---|---|---|
| `D ≈ 1` | Rate is stable; variability is irreducible noise | Pooling and fragmentation. Forecast investment has no return here. |
| High `D`, large recoverable share | Forecasting execution gap | Method, regressors, event handling, bias correction |
| High `D`, large irreducible share, residual CV above threshold | Rate uncertainty dominates | Supply elasticity — reallocation, flexible staffing models, automated capacity |
| Resolution curve flat until inside lever lag | Variance is knowable too late to convert | Elasticity only. Further accuracy work cannot pay at planning horizons. |
| High ρ between volume and handle time | Workload volatility exceeds volume volatility | Plan on workload distributions; treat disruption as a joint event |
| Poisson share dominant on small queues | Pools are too small to average out | Consolidation or chaining, not forecasting or flexibility |
## Converting the finding into a recommendation
The split gives a defensible allocation of effort. Express it as proportions of the
*addressable* variance — total less the Poisson floor — since the floor is addressable by
neither side.
```
demand-side share = recoverable / (Var(N) − μ)
supply-side share = irreducible / (Var(N) − μ)
```
State both, and state the third thing the numbers imply: if the Poisson share is large, a
material part of the problem is neither, and consolidation is the cheapest lever available.
## Phrasing that survives challenge
- Say **"the current forecast leaves X of rate error, of which a well-specified model
reaches Y"** — not "the forecast is X% accurate".
- Say **"variance at this level forces a buffer that scales with volume"** — not "volatility
is high".
- Say **"this variance resolves at N days, inside the lead time of every lever except
intraday reallocation"** — not "the forecast is poor at short horizons".
- Attach the regime ratio to any claim about scale. The counter-intuitive result —
that consolidation raises absolute buffer requirements in the uncertainty regime — is the
one most likely to be challenged and the one best supported.
## What the analysis does not establish
Include this section every time. It is what makes the rest credible.
- It does not establish **cause**. It says how much variance is unexplained by the current
forecast, not why.
- It does not establish that the irreducible component is **permanently** irreducible. A
regressor not in the data — a disruption feed, a schedule-change signal, a partner event
calendar — can move variance from irreducible to explained. The result is conditional on
the information set available.
- It does not establish **where the variance sits by work type** unless a complexity flag
was supplied. Without it, no claim can be made about whether automation would absorb the
variance or inherit it.
- It does not measure the **response side**. A low residual does not mean the operation
copes; it means the operation could have known. Whether it acted is a separate test
requiring staffing actuals.
- Where redials could not be removed, the irreducible component is **likely overstated**,
because some of it is the operation's own understaffing reflected back.
## Common misreadings to pre-empt
**"The residual is unforecastable, so forecasting doesn't matter."** Wrong on two counts.
The explained component is usually the largest single block and it exists *because* of
forecasting — remove the forecast and it reverts to residual. And distributional output
matters more, not less, as the residual grows: a flexibility rule consumes a distribution,
so a large irreducible component raises the value of forecasting the *spread* even as it
lowers the value of forecasting the *point*.
**"High dispersion means our forecasters are underperforming."** Dispersion is a property
of demand. Forecast quality appears in the recoverable share, not in `D`.
**"We should consolidate queues to reduce volatility."** True for the Poisson component,
false for the dominant one in the uncertainty regime. Consolidation improves the term that
scales as √λ while the rate-uncertainty buffer scales as λ. Report both effects.
**"Let's set a volatility target."** The components move for different reasons and a
combined index conceals which one moved. Track the three separately.
Usage notes
Sizing. The instruction block is about 450 words and loads with every message inside the project. The five reference blocks total roughly 6,000 words and load only on retrieval.
Who each block is for. Blocks 2, 3 and 6 are written for whoever owns the question — they explain what to ask for, why the method works, and how to report it. Block 4 is written for the analyst who has to produce the numbers, and carries the extract query, the reference implementation and an ordered checklist. Block 5 serves both. A forecasting team can be handed Blocks 2 and 4 alone and run the test without reading the argument around it.
Data before deployment. Unlike most packs, this one is inert without data. Block 2 exists to be read before the extract is requested — a request phrased loosely returns daily aggregates and revised forecasts, either of which silently invalidates the analysis. The two fields most often lost are offered volume rather than answered, and the forecast as locked at the publication horizon rather than the current system value.
The bake-off is the expensive step. Every step except the bake-off runs on summary statistics and needs no modelling at all. Separating recoverable from irreducible residual is the one place a challenger model must be fitted and honestly evaluated out of sample. A session can deliver the split, the regime verdict and the workload extension without it, and should say which steps were and were not run.
Packs do not compose at runtime. Each pack is deployed as its own Claude project, so a session running this pack cannot reach another pack's reference blocks. The cross-references in Related packs are for a human choosing what to deploy next, not instructions the model can follow.
Illustrative figures. Every worked number and tuning constant in Blocks 3, 4 and 5 is marked `[estimated]`. They demonstrate arithmetic and are not benchmarks. There is no published distribution of dispersion indices for travel or corporate servicing that this pack could calibrate against, which is itself worth knowing.
Related packs
Deploy each in its own project when the work reaches it.
| Pack | Deploy it when the work reaches |
|---|---|
Planning Under Demand Volatility (CP-WFM-001) |
Acting on the finding — planning and capacity work once the variance shape is known |
Agent Capability Ontology (CP-WFM-002) |
Designing the supply-side response the residual component calls for |
Service Quality Comparability (CP-WFM-003) |
Comparing delivery across internal, captive and outsourced estates |
Change history
| Version | Date | Change |
|---|---|---|
| 1.0 | 2026-08-12 | Initial publication. One instruction block, four reference blocks, derived from the variance decomposition article. |
| 1.1 | 2026-08-12 | Added runbook.md as Block 4 — SQL extract with lock-horizon vintage join, Python reference implementation for the decomposition, bake-off and workload extension, and an execution checklist. Routing updated so an analyst who wants to run the test rather than understand it is sent straight there.
|
See also
- Doubly Stochastic Arrivals and Demand Variance Decomposition — the method, in full
- Supply Elasticity in Workforce Planning — why the split matters for investment allocation
- Poisson Process in Contact Centers — the baseline arrival model and its conditions
- Probabilistic Forecasting — the distributional output a large residual component argues for
- Wiki:Packs — the full pack inventory
