Wiki:Packs/Probabilistic Staffing

From WFM Labs
Pack
ID CP-WFM-018
Name Probabilistic Staffing
Domain WFM
Blocks 1 instruction + 11 reference
Version 1.0
Source Contact Rate · Demand calculation · Workforce Cost Modeling · WFM Labs Risk Score™ · Probabilistic Forecasting · Bayesian Methods for Workforce Forecasting · Deterministic vs Probabilistic Models · The Flaw of Averages · Staffing to Percentile vs Mean Forecast · Erlang Sensitivity and the Staffing Cliff · The Occupancy Trap · Speed to proficiency curve · Workforce Digital Twins and Continuous Planning

A deployable Claude project setup for a probabilistic staffing model: a two-sided Monte Carlo that converts an uncertain transaction base into a required-productive-hours distribution, simulates the staffing pipeline that has to meet it, and reports a coverage probability rather than a headcount. It is re-run against daily actuals, and its parameters move by conjugate update rather than by argument.

Copy Block 1 into a project's custom instructions; save the remaining blocks as Markdown files and upload them as project knowledge. See Wiki:Packs for how packs work.

When to use it

Use this pack when demand is derived rather than observed — a portfolio of segments each converting transactions into contacts at its own contact rate, split across channels with different handling characteristics — and when the staffing question is being answered with a single number that nobody can attach a probability to.

It is most useful where the supply side is also uncertain: where requisitions may not fill, classes may start late, and graduates ramp. Workforce Cost Modeling prices those levers; this pack carries them as a distribution.

It is not an interval scheduling model. It answers "can we resource this", not "can we cover Tuesday at 14:00" — for that see Capacity Planning Methods and interval-level Erlang. It is also not a substitute for WFM Labs Risk Score™, which rates a plan that already exists; this builds the plan's distribution in the first place.

How to deploy

  1. Create a Claude project named for the portfolio, not for the method.
  2. Copy Block 1 into the project's custom instructions.
  3. Save the remaining blocks under the filenames in their headings and upload them as project knowledge.
  4. Build params.yaml from the template in Block 6, then generate MODEL-STATE.md at revision 0. Run the first forecast before any data exists — a model that waits for good data never gets calibrated, because nothing is scoring it.
  5. Each session: upload the current MODEL-STATE.md and the day's intake files. The session ends by emitting the next revision, which replaces the old one.

Block 1 — project instructions

# Project instructions — Probabilistic Staffing Model

## Context

This project runs a Monte Carlo staffing model for a **single contact portfolio assembled
from country segments**. Demand here is *derived*, not observed: each segment produces
transactions, a fraction of those become contacts at a segment-specific contact rate, and
those contacts split across voice, chat and email — each channel carrying its own handling
time, concurrency and occupancy assumption. Supply is equally uncertain: people must be
requisitioned, hired, classed, graduated, ramped and retained before they count as capacity.

The model's output is a **coverage probability and a shortfall distribution**, never a single
staffing number. It is re-run against yesterday's actuals, its parameters are updated by
evidence rather than by argument, and it reports what to measure next.

State lives in `MODEL-STATE.md`, which is uploaded to this project and replaced each cycle.

## Routing

| When the task is | Open |
|---|---|
| Understanding or explaining the model structure, units, or the demand→supply comparison | `01-method.md` |
| Choosing a distribution, eliciting a range, or reading a posterior | `02-parameters.md` |
| Running the simulation or producing charts | `03-simulator.md` (`mc_staffing.py`) |
| Updating a posterior, scoring a forecast, aging, effective sample size | `03b-updating.md` (`update.py`) |
| Loading data, checking a file, or answering "what do we need next?" | `04-intake-schema.md` |
| Running the daily cycle, scoring calibration, emitting a new state file | `05-daily-protocol.md` |
| Loading files, validating them, reconstructing a week's actual | `05b-intake.md` (`intake.py`) |
| The cycle code — scoring, posterior updates, the run loop | `05c-cycle.md` (`cycle.py`) |
| Reading results, writing a readout, or answering a challenge | `06-interpretation.md` |
| Arguing why the deterministic plan is wrong | `07-why-deterministic-fails.md` |
| Checking current parameter values and evidence counts | `MODEL-STATE.md` (format: `MODEL-STATE-template.md`) |

## Disciplines

- **Shrinkage is applied exactly once, on the supply side.** Demand output is *required
  productive hours*. Never gross demand up for shrink and then compare it to a headcount
  that already has shrink netted out.
- **Occupancy is not a constant.** It is a function of group size at a given service level.
  If volume varies across scenarios, occupancy must vary with it or be flagged as a fixed
  assumption that biases the tails.
- Never report a point estimate without its interval and the coverage probability behind it.
- Mark any parameter carrying no observations as `[prior only]` and state its effective n.
- A day with no data is recorded as **missing**, not skipped. Gaps are evidence.
- Never tune a parameter to make an answer come out. Posteriors move on data only.
- State the forgetting factor whenever a posterior is quoted.
- Where a required input does not exist, add it to the intake gap register with its variance
  contribution. Do not substitute a guess silently.

## Output

Lead with the decision, not the method. Every staffing recommendation carries three things:
the coverage probability at that headcount, the shortfall distribution if it misses, and the
single parameter contributing most to the remaining spread. Show the S-curve of coverage
probability against headcount — that is the chart the decision is actually made on.

Refuse to produce a bare staffing number for a commitment. If one is demanded, give the
headcount at a stated coverage probability and name the probability in the same sentence.

Source: Wiki:Packs/Probabilistic Staffing (CP-WFM-018) v1.0

Block 2 — 01-method.md

# 01 — Method

*Figures marked `[estimated]` are judgment, not measurement.*

## What the model is

A two-sided Monte Carlo. The **demand side** converts uncertain business activity into
required productive hours. The **supply side** converts an uncertain staffing pipeline into
available productive hours. The answer is the joint distribution of the difference.

Both sides are simulated over the same weekly grid across the planning horizon, draw by draw,
so that a draw with high volume is compared against the supply that same draw produced. Never
simulate the sides separately and compare summary statistics — that discards the correlation
and understates tail risk.

## Demand side

Two passes. The division by occupancy happens **after** segments are summed, not inside the
segment loop, because occupancy may be a function of load and the load on a channel is a
portfolio quantity. Dividing per segment applies an occupancy derived from one segment's
volume to the whole channel, which overstates the requirement by about 9% on the
demonstration configuration.

**Pass 1 — accumulate workload per channel, across all segments:**

```
transactions        T[s,w]  = PERT(min, mode, max) x weekly_index[w]
                              x fcst_err_systematic[s]     (drawn once per draw)
                              x fcst_err_weekly[s,w]       (drawn per week)
contact rate        CR[s]   ~ Beta
contacts            C[s,w]  = T[s,w] x CR[s]
channel split       p[s]    ~ Dirichlet over (voice, chat, email)
handling time       AHT[c]  ~ Lognormal      drawn ONCE for the portfolio
concurrency         m[c]    ~ Lognormal or fixed

interactive c:  Wl[c,w] += C[s,w] x p[s,c] x AHT[c] / 3600 / m[c]   (hours)
deferrable  c:  Wl[c,w] += C[s,w] x p[s,c]                          (items)
```

**Pass 2 — convert workload to productive hours, per channel:**

```
interactive c:  occ[c,w]  = Beta draw, or occupancy_curve at load Wl[c,w]
                D_int[w] += Wl[c,w] / occ[c,w]
deferrable  c:  tph[c]    ~ Lognormal
                D_def[w] += Wl[c,w] / tph[c]
```

The period requirement is `D[w] = D_int[w] + D_def[w]`, with the two lanes also reported
separately — see the deferrable-work note below.

**Forecast error carries two components.** A systematic one drawn once per simulation, because
bias persists, and a weekly one drawn per week. With only the systematic term every week moves
together — adjacent-week correlation 0.9999 before `forecast_error_weekly` existed,
0.952 after — which makes "the probability we cover every week"
arithmetically identical to "the probability we cover the worst week", and removes week-to-week
forecast error from the model entirely.

**Units discipline.** Workload hours are the hours of work that exist. Productive hours are
the hours people must be logged in and available to absorb that work, including the idle time
service level requires. The division by occupancy is what converts one into the other, and it
is the only place service level enters the demand side.

## Occupancy as a service-level proxy

Occupancy stands in for an Erlang calculation. This is legitimate and it is also the model's
weakest joint, for a specific reason: **the occupancy achievable at a given service level
rises with group size.** A 30-agent group and a 300-agent group hitting the same answer
threshold do not run at the same occupancy — the larger pools its idle time more efficiently.

So holding `occ` constant while volume swings across the simulated range removes a real
**stabilizing feedback**. In a low-volume draw the true achievable occupancy is lower, so the
requirement is higher than a fixed assumption says; in a high-volume draw it is higher, so the
requirement is lower. A fixed occupancy therefore **exaggerates both tails**.

This is worth stating carefully because the intuition runs the other way — an assumption that
ignores variation feels like it should narrow the answer. It does not. Measured on the
demonstration configuration at 60,000 draws across five seeds, peak-week spread
(P90/P10 − 1) is **56.9–57.6% under fixed occupancy and 48.5–49.2% under the curve**. The fixed assumption is the wider, not the tighter, of the two.

The consequence is that a fixed-occupancy model is not conservative. It overstates the extreme
requirement, which invites a reader who has lived through a peak to dismiss the tail as
alarmist — and to dismiss the rest of the model with it.

Two ways to handle it, in order of preference:

1. **Occupancy curve — implemented.** Set `occupancy: {kind: erlang_curve, target_sl,
   asa_seconds, operating_hours_per_week, spread}`. The model tabulates achievable occupancy
   against offered load at the target service level and interpolates per draw, so occupancy
   moves with volume. `spread` carries residual uncertainty about the curve itself, because no
   operation sits exactly on its theoretical curve. Better still, replace the theoretical
   curve with one fitted to the operation's own history — achieved occupancy against agents on
   the floor, within weeks that hit service level.
2. **Fixed occupancy, declared.** A Beta spec still works and is acceptable for a first cut.
   State in every readout that both tails are exaggerated.

Email is deferrable work. It is not an Erlang problem and should not carry a queueing-derived
occupancy — it is modeled as a throughput constraint, items per productive hour.

It is also kept in **its own lane**, and the two lanes are reported under a priority reading:

| Reported | Question it answers |
|---|---|
| `coverage(lane="interactive")` | Can queued work be covered when it has first call on capacity? |
| `coverage()` | Can everything be covered, deferrable work included? |

The gap between them is the deferrable work's exposure — it is what gets sacrificed first when
capacity is short, which is what actually happens on the floor. On the demonstration
configuration the two are **52% and 10%** across the whole horizon (56% and 12% in the peak
week): almost all of the apparent shortfall is email backlog rather than queue failure, and reporting only the pooled number would put a voice
staffing case in front of someone when the problem is an email backlog.

## Supply side

Cohort-based. Each draw walks the pipeline forward week by week:

```
requisitions opened      R[w]
fill probability         ~ Beta            -> offers accepted
time to fill             ~ Lognormal       -> start week
class fill rate          ~ Beta            -> seats actually filled
training duration        fixed by channel  -> graduation week
graduation rate          ~ Beta            -> graduates
ramp multiplier          ramp(k)           k = weeks since graduation
early attrition hazard   ~ Beta by tenure week
tenured attrition        ~ Beta
shrinkage                ~ Beta            planned + unplanned
scheduled hours per head fixed
```

Available productive hours for week *w*:

```
S[w] = sum over cohorts [ heads[cohort,w]
                          x ramp(k[cohort,w])
                          x scheduled_hours_per_head
                          x (1 - shrink[w]) ]
```

**The ramp multiplier is where most plans lie to themselves.** A graduate is not an FTE. A
common shape is 0.45 → 0.70 → 0.85 → 1.00 across the first four weeks on the floor
`[estimated]` — replace with the operation's own curve as soon as tenure-banded AHT and
occupancy data exist. Ramping agents also consume tenured capacity through support and
coaching; if that is material, carry it as a negative term rather than pretending it is free.

## The comparison

```
shortfall[w] = D[w] - S[w]                  (productive hours)
coverage      = P( S[w] >= D[w] )           per week and across the horizon
```

Report shortfall in productive hours and in FTE-equivalent, and translate the deficit to an
expected service outcome — a deficit of 4% of productive hours does not cost 4% of service
level, it costs considerably more. See `07-why-deterministic-fails.md`.

## Where shrinkage lives

Once, on the supply side. Demand produces required *productive* hours; supply converts
headcount into *productive* hours by netting shrinkage and ramp. Both sides then speak the
same unit and are directly comparable.

**This is a choice of convention, not the only correct one.** The standard annual capacity
formula puts shrinkage on the demand side — `Volume x AHT / 3600 / AnnualHours / Occupancy /
(1 - Shrinkage)` — and lands directly on required *paid headcount*. That is equally valid; it
simply answers a different question in a different unit. This model needs two distributions in
the same unit so they can be compared draw by draw, which is why shrinkage sits on the supply
side here.

The actual defect is applying it **twice, or not at all**: grossing demand up by
`1/(1-shrink)` and then comparing that to a roster which already has shrink netted out. Because
shrink is a sampled quantity, that error grows in exactly the draws that matter. State which
convention is in force in every readout, because a reader who has the annual formula in mind
will assume the other one.

## Limits

- Independence between segments is assumed unless a correlation matrix is supplied. Real
  portfolios correlate — a disruption event lifts several country segments at once. Where
  that is plausible, supply the correlation; the tails are wrong without it.
- **`erlang_curve` assumes a flat arrival profile.** Offered load is derived as weekly workload
  divided by `operating_hours_per_week`, so a week with the same total spread unevenly across
  the day gets the same occupancy. That is a genuine intraday assumption sitting inside the
  mechanism this block recommends, and it sits awkwardly beside the next bullet. It biases
  occupancy *upward* — a peaked profile pools worse than a flat one — and so biases the
  requirement downward. Fit the curve to the operation's own achieved occupancy to remove it.
- Intraday distribution is out of scope. This is a weekly capacity model, not a scheduling
  model. It answers "can we resource this", not "can we cover Tuesday 14:00".
- Erlang assumptions (Poisson arrivals, exponential handling, infinite patience) are inherited
  wherever the occupancy proxy is calibrated against Erlang.

Block 3 — 02-parameters.md

# 02 — Parameters, priors and updating

*Figures marked `[estimated]` are judgment, not measurement.*

## The register

| Parameter | Symbol | Family | Update | Notes |
|---|---|---|---|---|
| Transaction forecast | `T[s,w]` | PERT / supplied | **Not updated** — see below | Exogenous |
| Forecast error, systematic | `e[s]` | Lognormal | Normal–InvGamma on log | This is what we learn |
| Forecast error, weekly | `e[s,w]` | Lognormal | *no extractor yet* | Carries the week-to-week decorrelation; never learns |
| Contact rate | `CR[s]` | Beta | Beta–Binomial | contacts out of transactions |
| Channel split | `p[s]` | Dirichlet | Dirichlet–Multinomial | 3 categories |
| Handling time | `AHT[c]` | Lognormal | Normal–InvGamma on log | Per channel, per segment if it differs |
| Concurrency | `m[c]` | Lognormal or fixed | *no extractor yet* | Chat only; effective, not licensed |
| Deferrable throughput | `tph[c]` | Lognormal | *no extractor yet* | Also scores the deferrable lane |
| Occupancy | `occ[c]` | Beta **or** `erlang_curve` | Beta–Binomial on productive/staffed | Curve preferred; see block 01 |
| Shrinkage | `SH[w]` | Beta | Beta–Binomial with effective n | Split planned / unplanned |
| Requisition fill | `f` | Beta | Beta–Binomial | offers accepted / reqs opened |
| Time to fill | `ttf` | Lognormal | *no extractor yet* | Weeks |
| Class fill rate | `cf` | Beta | Beta–Binomial | seats filled / seats planned |
| Graduation rate | `g` | Beta | Beta–Binomial | graduates / starts |
| Ramp curve | `ramp(k)` | Beta per week (or fixed) | *no extractor yet* | Tenure-banded; draws forced non-decreasing |
| Early attrition | `h(k)` | Beta per tenure band | *no extractor yet* | Hazard, not cumulative |
| Tenured attrition | `a` | Beta | Beta–Binomial | Weekly rate |

**Several parameters carry no state node at all, and the count is computed rather than
asserted here.** `frozen_parameters(cfg, state)` derives it from `params.yaml` against
`bootstrap_state`, and `MODEL-STATE.md` prints the list every cycle. This prose stated the
number as four, then as five; both were wrong, and a figure that has been wrong twice should
not be written down a third time. `bootstrap_state` emits sixteen posteriors and every one of them has an extractor.
On the demonstration configuration the list is concurrency, time-to-fill, the ramp curve,
early attrition, `items_per_productive_hour` and the **weekly** forecast-error component —
so they neither learn nor age. They sit frozen in `params.yaml` at whatever was elicited, and
they will still be sitting there a year later while every other interval has moved.
`items_per_productive_hour` is the worst of the five: `bootstrap_state` skips deferrable
channels wholesale, it governs roughly a fifth of the requirement, and since the deferrable
lane is scored at *planned* throughput it is now the sole basis for that lane's actual — an
assumption scored against itself, which no evidence can correct.
`weeks_since_graduation` sits in the intake schema and is read by nothing.

Treat these as the pack's own intake gaps. Do not describe them as learned, and do not describe
them as aging priors either.

There is also **no cadence gate**: `apply_updates` updates everything present in the batch on
every run. The weekly-cadence guidance in `05` is a discipline for what you feed it, not
something the code enforces.

## Why the transaction forecast is not updated

Transaction volume is somebody else's forecast and somebody else's driver. Learning a
posterior for "transactions" would mean re-forecasting the business, which is out of scope
and would double-count whatever process already produces that number.

What **is** learned is the **forecast error ratio** — actual ÷ forecast, per segment. That
single move is worth more than most of the rest of the model: it converts a forecast you do
not control into a calibrated input, and it makes systematic optimism visible within weeks
rather than quarters. If the ratio's posterior mean sits meaningfully away from 1.0, say so
plainly and keep saying it.

## Eliciting a range when there is no data

Ask for the 90% interval directly, never the "expected" value. The question is: *what value
are you 95% sure it is above, and what value are you 95% sure it is below?* Widen whatever
comes back — unaided ranges are habitually too narrow, and the first weeks of data will tell
you by how much. If the elicited interval is repeatedly violated, the problem is the elicitor,
not the world.

For a bounded rate, convert the interval to a Beta by fixing the mean and solving for the
concentration `k` in `Beta(m·k, (1-m)·k)` so the interval width matches. For a positive
unbounded quantity like AHT, use a lognormal with:

```
mu    = ( ln(lo) + ln(hi) ) / 2
sigma = ( ln(hi) - ln(lo) ) / ( 2 x 1.645 )        for a 90% interval
```

## Conjugate updates

**Rates (contact rate, graduation, fill, attrition, shrink):**

```
Beta(a, b)  +  s successes in n trials  ->  Beta(a + s, b + n - s)
```

**The effective sample size is not optional, and it is not only about shrinkage.** Use
`effective_n(denominator, kind)` for every rate whose denominator is a correlated bulk count:

| Parameter | Denominator | `kind` | Divisor |
|---|---|---|---|
| Contact rate | transactions | `transactions` | 50 `[estimated]` |
| Channel split | contacts | `contacts` | 20 `[estimated]` |
| Occupancy, shrinkage | productive / scheduled hours | `hours` | 40 `[estimated]` |
| Graduation, class fill, requisition fill | starts, seats, requisitions | `events` | 1 |

Only the last row is genuinely independent trials. **Contact rate is the trap.** Its
denominator is transactions — of the order of a million a week — but those are not a million
independent trials of a stable propensity to make contact. They share a day, a disruption, a
campaign, an outage. Updated on the raw count, the concentration reaches the millions within
days and the 90% interval collapses to a few thousandths of a percentage point — on the
parameter carrying the largest single share of the model's output variance — **44% across both
segments** on the demonstration configuration, measured over exogenous inputs (the default
`attribution()` view; 31% if the endogenous occupancy term is left in). The model stops learning because
it has decided it already knows.

The divisors are crude, conservative and `[estimated]`. Replace them with a measured intra-day
correlation as soon as enough daily history exists to compute one; until then, note in any
readout that the intervals on these parameters rest on a judgment, not a measurement.

**Channel split:**

```
Dirichlet(a1,a2,a3)  +  counts (n1,n2,n3)  ->  Dirichlet(a1+n1, a2+n2, a3+n3)
```

The split freezes faster than anything else in the register, and for the same reason contact
rate does. Contacts are numerous, so even after the `contacts` divisor the concentration
reaches several thousand within three weeks and the split stops responding. That is wrong in
exactly the situation where channel mix matters most — a channel conversion, a new
self-service capability, a deflection program — because the split is then genuinely moving
and the posterior has already decided it is not. Lower the lambda for `split[*]` before any
deliberate mix change, or widen the divisor.

**Log-scale continuous (AHT, time to fill, forecast error):** Normal–Inverse-Gamma on
`ln(x)`, which updates both the location and the spread. Do not update the mean while holding
variance fixed at its prior — AHT variance moves, particularly through a channel shift, and a
frozen variance will make the model overconfident precisely when it should not be.

## Forgetting

Every posterior carries a forgetting factor `lambda`, applied before each update:

```
Beta:      a <- 1 + lambda(a - 1) ;  b <- 1 + lambda(b - 1)
Dirichlet: a_i <- 1 + lambda(a_i - 1)
Normal-IG: kappa <- lambda·kappa ;  alpha <- lambda·alpha ;  beta <- lambda·beta
```

**All three Normal-Inverse-Gamma parameters are discounted, `beta` included.** Discounting
`kappa` and `alpha` alone lets `alpha` settle at a steady state while `beta` accumulates
without bound, so the implied sigma inflates indefinitely — against a true 0.20 it drifts past
0.5 within a year at `lambda = 0.98`. The failure is hard to catch because it errs toward wide
intervals, which look like humility rather than like a defect.

`lambda = 0.98` per day gives an effective memory of roughly 50 days `[estimated]`.

**Lambda is quoted per day and must be raised to the elapsed gap**`lam_for(0.98, days)`.
Every update function discounts once per call, so a parameter updated weekly against a daily
lambda ages at 0.98 per week instead of 0.98⁷ = 0.868: roughly seven times too little
forgetting, silently. The same function handles a missing day, via `discount_only_beta` — the
posterior widens without being fed evidence, because time passed even though data did not.

Use a lower lambda where the operation is genuinely changing — a channel conversion, a new market,
a vendor transition — because stale observations are not merely less useful there, they are
actively wrong. Use a higher lambda for stable, slow-moving parameters like tenured attrition.

**Always state the lambda when quoting a posterior.** A tight interval produced by never
forgetting is not the same claim as a tight interval produced under discounting.

## Priors when starting cold

Start every parameter as `[prior only]` with an honest effective n — typically 5 to 20 —
derived from elicitation, not from a textbook default. Record where the prior came from in
`MODEL-STATE.md`. A prior with a named source can be defended; one that appeared from nowhere
cannot, and it will be the first thing attacked when the model disagrees with somebody.

Resist the temptation to give a prior a large effective n so the model looks confident on day
one. That confidence is fictional and it will suppress the evidence you are about to collect.

Block 6 — 04-intake-schema.md

# 04 — Intake schema and the gap register

*Figures marked `[estimated]` are judgment, not measurement.*

Three files. Two carry observations, one carries the model. Where a column cannot be sourced
yet, leave it empty — **do not fill it with an assumption**. An empty column is information;
a fabricated one is contamination.

**That applies to columns the model does not consume.** A column the reconstruction actually
reads cannot be blank, because there is nothing honest to do with a week that is missing half
its own inputs — those are refused at load, by name, rather than scored around. Which columns
those are depends on the config: `concurrency_effective` becomes required once any channel is
configured concurrent, and `items_resolved` once a deferrable channel has no planned
throughput. `required_demand_columns()` computes the list.

## `daily_demand.csv`

One row per date × segment × channel.

| Column | Type | Notes |
|---|---|---|
| `date` | ISO date | |
| `segment` | string | Must match a segment key in `params.yaml` |
| `channel` | voice / chat / email | |
| `transactions` | int | Segment-level; repeat across the channel rows, do not divide |
| `transactions_forecast` | int | What was forecast for this date. Feeds the error ratio |
| `contacts_offered` | int | Before abandonment |
| `contacts_handled` | int | |
| `aht_seconds` | float | Handled only. State the definition — see below |
| `concurrency_effective` | float | **Simultaneous sessions carried while handling**, not a utilization ratio. Measure as chat-session-hours ÷ agent-hours *spent handling chat* — the denominator excludes idle-but-available time. Dividing by logged-in hours instead gives concurrency × occupancy, which the model then divides by occupancy a second time: about +5% on the requirement, and below 1.0 at moderate occupancy, which is refused. Always ≥ 1 |
| `items_resolved` | int | Email only |
| `productive_hours` | float | Logged in and available, including idle |
| `occupancy_achieved` | float | Productive-working ÷ productive-available |
| `service_level` | float | So occupancy can be calibrated against the SL it bought |

## `daily_supply.csv`

One row per date × site (or per date × cohort where cohorts are tracked separately).

| Column | Type | Notes |
|---|---|---|
| `date` | ISO date | |
| `site` | string | |
| `cohort_id` | string | Blank for the tenured pool |
| `heads_on_roll` | int | |
| `heads_scheduled` | int | |
| `scheduled_hours` | float | |
| `productive_hours` | float | Must reconcile to the demand file's total |
| `shrink_planned_hours` | float | Leave, training, meetings, coaching |
| `shrink_unplanned_hours` | float | Absence, late, early leave |
| `weeks_since_graduation` | int | Drives the ramp curve |
| `separations` | int | |
| `separations_voluntary` | int | |

## `pipeline_events.csv`

One row per event. Sparse by nature.

| Column | Type | Notes |
|---|---|---|
| `date` | ISO date | |
| `event` | enum | `req_opened`, `offer_accepted`, `class_started`, `graduated`, `withdrew` |
| `cohort_id` | string | |
| `count` | int | |
| `planned_count` | int | The denominator, and it differs by event: `offer_accepted` → requisitions opened · `class_started` → seats planned · `graduated`**class starts**, never seats planned. The model composes class fill and graduation multiplicatively, so using seats planned here charges the same shortfall twice |
| `planned_date` | ISO date | For slip: planned versus actual |

## Validation before any run

1. Every `segment` and `channel` resolves to a config key.
2. `contacts_handled <= contacts_offered`.
3. `productive_hours` in the supply file reconciles to the demand file within 2%. It usually
   will not the first time, and the reconciliation is itself a finding.
4. `shrink_planned_hours + shrink_unplanned_hours <= scheduled_hours`.
5. Round-trip every rate in `params.yaml` through `check_beta` and confirm `ok` is true for
   all of them. A Beta can reproduce a requested mean while being entirely the wrong shape.
6. Dates are contiguous. A missing date is logged as missing, never interpolated.

## The definitions that decide the answer

Three definitional questions move the result more than most of the data:

- **Chat AHT.** Is it handle time per contact, or per concurrent session, or wall-clock from
  accept to close? These differ by a factor of two or more, and the difference lands directly
  in the requirement. Get it in writing and record it in `MODEL-STATE.md`.
- **Productive hours.** Does the source system mean scheduled, logged-in, or available? If the
  supply file and the demand file disagree on this, the model compares two different things
  and no amount of simulation will rescue it.
- **Occupancy.** Which states count as occupied. After-call work is the usual argument.

## The gap register

Every unsourced input carries a row in `INTAKE-GAPS.md`:

| Field | Meaning |
|---|---|
| Input | The parameter |
| Status | `absent` / `proxy` / `disputed definition` |
| Current treatment | Prior and its source |
| Variance share | From `attribution()`, refreshed each cycle |
| Confirmation | Whether the screening rank was confirmed by `pin_check` |
| Cost to obtain | Who, what system, roughly how long |
| Rank | Variance share weighted by feasibility |

**Rank by variance contribution, not by how badly it is missed.** The instinct is to chase
the input that feels most wrong. The right target is the one carrying the most output spread
per unit of effort to obtain — a badly-estimated parameter that barely moves the answer can
stay badly estimated. This is a value-of-information calculation, and it is the mechanism by
which the model tells you what to do next rather than only what it thinks.

Confirm the top two or three with `pin_check` before commissioning work against them. The
screening attribution assumes monotone, roughly additive effects and will mislead where
inputs interact — occupancy and volume do exactly that.

## `params.yaml` template

The model's configuration. Every rate is given as a mean plus a 90% interval; every positive
unbounded quantity as a 90% interval alone. Once a posterior exists, replace the elicited form
with the posterior form (`a`/`b` for Beta, `mu`/`sigma` for lognormal) — the simulator accepts
either and `MODEL-STATE.md` carries the current values.

```yaml
horizon_weeks: 13

# Operating shape. Omit entirely for a 24/7 operation; the default is all seven
# days. Declaring this is what lets a five-day operation and a holiday week
# complete normally instead of failing validation every week.
calendar:
  operating_days: [mon, tue, wed, thu, fri, sat, sun]
  closed_dates: []          # holidays and shutdowns, as YYYY-MM-DD

channels:
  voice:
    kind: interactive
    aht_seconds:  {ci: [380, 520]}
    concurrency:  1.0
    # PREFERRED: occupancy tracks load instead of being assumed.
    occupancy:
      kind: erlang_curve
      target_sl: 0.80
      asa_seconds: 20
      operating_hours_per_week: 168      # the window the load is spread over
      spread: 0.03                       # residual uncertainty about the curve
  chat:
    kind: interactive
    aht_seconds:  {ci: [600, 950]}       # STATE THE DEFINITION in MODEL-STATE.md
    concurrency:  {ci: [1.4, 2.2]}       # effective, not licensed
    occupancy:
      kind: erlang_curve
      target_sl: 0.80
      asa_seconds: 30
      operating_hours_per_week: 168
      spread: 0.03
    # FALLBACK, still supported — a fixed occupancy, which exaggerates both tails:
    # occupancy: {mean: 0.78, ci: [0.70, 0.85]}
  email:
    kind: deferrable
    items_per_productive_hour: {ci: [4.0, 7.0]}

segments:
  SEG-A:
    transactions_per_week: [950000, 1100000, 1320000]   # min, most likely, max
    weekly_index: [1.00, 1.02, 1.05, 1.09, 1.12, 1.10, 1.06,
                   1.03, 1.00, 0.97, 0.95, 0.94, 0.96]   # length == horizon_weeks
    forecast_error:        {ci: [0.90, 1.12]}            # systematic bias, drawn once
    forecast_error_weekly: {ci: [0.93, 1.08]}            # week-to-week error, drawn per week
    contact_rate:   {mean: 0.031, ci: [0.024, 0.040]}
    channel_split:  {voice: 55, chat: 30, email: 15}     # Dirichlet concentration
  SEG-B:
    transactions_per_week: [300000, 375000, 525000]
    weekly_index: [1.00, 1.01, 1.03, 1.04, 1.06, 1.05, 1.03,
                   1.01, 1.00, 0.99, 0.98, 0.98, 0.99]
    forecast_error:        {ci: [0.85, 1.20]}
    forecast_error_weekly: {ci: [0.90, 1.11]}
    contact_rate:   {mean: 0.052, ci: [0.038, 0.070]}
    channel_split:  {voice: 40, chat: 42, email: 18}

supply:
  starting_productive_heads: 300
  scheduled_hours_per_head: 37.5
  # Tenure curves: each week is a fixed float OR a Beta spec. Prefer the Beta
  # form — a ramp curve carrying no uncertainty cannot express the failure mode
  # where it reaches 1.0 sooner than the tenure data supports. Ramp draws are
  # forced non-decreasing across tenure.
  ramp_curve:
    - {mean: 0.45, ci: [0.30, 0.60]}     # weeks since graduation
    - {mean: 0.70, ci: [0.55, 0.82]}
    - {mean: 0.85, ci: [0.73, 0.93]}
    - 1.00
  early_attrition_weekly:
    - {mean: 0.030, ci: [0.015, 0.050]}
    - {mean: 0.025, ci: [0.012, 0.043]}
    - {mean: 0.018, ci: [0.008, 0.033]}
    - {mean: 0.012, ci: [0.005, 0.024]}
  tenured_attrition_weekly: {mean: 0.006, ci: [0.003, 0.010]}
  shrinkage_planned:     {mean: 0.18, ci: [0.15, 0.21]}
  shrinkage_unplanned:   {mean: 0.09, ci: [0.05, 0.15]}
  requisition_fill_prob: {mean: 0.70, ci: [0.50, 0.86]}
  time_to_fill_weeks:    {ci: [3.0, 9.0]}
  class_fill_rate:       {mean: 0.88, ci: [0.72, 0.97]}
  graduation_rate:       {mean: 0.84, ci: [0.70, 0.93]}
  training_weeks: 5
  class_plan:
    - {planned_start_week: 0, seats_planned: 24, requisitions_opened: 40, lead_weeks: 6}
    - {planned_start_week: 4, seats_planned: 24, requisitions_opened: 40, lead_weeks: 6}
```

**The Dirichlet concentration is not a percentage.** `{voice: 55, chat: 30, email: 15}` gives
the right mean split, but the *magnitude* sets how much the split varies between draws. Those
values imply roughly 100 observations' worth of confidence. If the split is genuinely volatile
— during a channel conversion, for instance — scale all three down together (`{11, 6, 3}`)
to widen it without moving the mean.

Block 7 — 05-daily-protocol.md

# 05 — The daily cycle

*Figures marked `[estimated]` are judgment, not measurement.*

Each session is one cycle. It reads `MODEL-STATE.md`, ingests new days, scores any week that
closed, updates posteriors, re-runs, and emits the next revision.

**Scoring happens before updating.** Otherwise the model is graded on data it has already
absorbed and the calibration record means nothing.

## The grain problem, and how it is resolved

The simulation is **weekly**. The intake files are **daily**. These do not score against each
other directly, and pretending otherwise is the easiest way to produce a calibration log that
looks fine and measures nothing.

The resolution:

- **Posteriors update daily.** Contact rate, AHT, occupancy and shrinkage are parameters, and
  a day is a perfectly good observation of a parameter.
- **Forecasts are scored weekly, when a week closes.** A week is complete when all seven dates
  are present. Partial weeks are carried, never scored.

Two quantities are scored, both weekly. They are **not independent** — both are driven by the
same week — so read their PIT histories together rather than as separate evidence:

| Quantity | Actual is |
|---|---|
| `required_productive_hours` | A counterfactual reconstruction: the week's **offered** contacts at the week's realized AHT and concurrency, divided by the **target** occupancy (or the curve's occupancy at the realized load). What the week *would* have required to serve everything offered at target service. Deliberately not achieved occupancy — see `realized_required_hours`. |
| `available_productive_hours` | Summed directly from `daily_supply.csv`, and only for a **complete** week of supply data. |
| *deferrable lane* | Offered items divided by the **planned** throughput from `params.yaml`, not the throughput the week achieved — a week that cleared a backlog by working faster would otherwise score as having required less. |

## State, and why it is a JSON block

`MODEL-STATE.md` carries a fenced `json` block that is the machine-readable truth, plus
rendered tables above it for humans. The code reads and rewrites the block; the tables are
regenerated from it. Never hand-edit either — a parameter moves through `cycle.py` or not at
all.

The block includes a **forecast archive**: a 40-point quantile grid per issued forecast. This
is what makes step 3 possible. Without it the previous cycle's predictive distribution is gone
by the time the actual arrives, and a pack built on falsifiability would ship no way to
falsify anything.

The code that performs these steps is split in two: `intake.py` (`05b-intake.md`) loads,
validates and reconstructs; `cycle.py` (`05c-cycle.md`) scores, updates and runs the loop.

---

## The steps

**1. Load state.** `read_state()`. Confirm the revision and `covers_through`. `apply_updates`
ages posteriors by the real elapsed gap, so a skipped day widens intervals rather than
vanishing — but say out loud when the gap is more than a day.

**2. Ingest and validate.** `load_intake()` then `validate()`, both in `05b-intake.md`. **A non-empty failure list halts
the cycle.** `run_cycle` returns without touching a posterior. Report all failures; do not work
around them.

**3. Score — before updating.** `score()` walks the forecast archive, finds every target week
that has closed, and computes PIT, CRPS and the 80% interval hit from the stored grid. Over a
fair run the 80% interval should contain the actual about 80% of the time. Materially above
means the intervals are too wide and are buying apparent honesty with uselessness; materially
below means overconfidence.

**4. Check for regime change.** `calibration_summary()` returns `regime`. If it is `LOW` or
`HIGH`, **stop and surface it.** Seven of the last ten actuals in one tail is a structural
change the model does not represent. Absorbing that into a posterior hides it. Name the likely
cause — a channel shift, a new market, a vendor transition, a changed definition — and
consider lowering the forgetting factor rather than updating harder.

**5. Update posteriors.** `apply_updates()` handles aging, effective sample sizes and the
discount-only path for parameters with no data. Read its notes: `no prior in state, skipped`
means a parameter is silently not learning.

**6. Re-run — on the updated config, not the original.**

```python
cfg2 = apply_posteriors(cfg, state)      # skips role: diagnostic nodes
res = run(cfg2)
```

`apply_posteriors` (in `05c-cycle.md`) is what makes "parameters move on evidence" true rather than aspirational;
without it the posteriors accumulate in the state file and never reach the simulation. Check
its `_posteriors_skipped` list — anything unexpectedly in it is a parameter that is not
actually feeding the model. Then produce the summary, coverage curve and charts.

**7. Refresh attribution and the gap register.** `attribution()`, then confirm the top two or
three with `pin_check`. Update `INTAKE-GAPS.md` and re-rank.

**8. Archive and emit.** `archive_forecast()` stores the new quantile grids — **this is what
makes the next cycle scoreable**, and skipping it breaks the chain silently. Then write
`MODEL-STATE.md` at `revision + 1` with a short readout leading on what changed and what it
means for the decision.

## What must never happen in a cycle

- Adjusting a parameter because the output looked wrong. Posteriors move on data. If the
  output is implausible, the finding is that the model and the operation disagree — worth more
  than a quiet correction.
- Dropping an outlier without recording it. An outlier is the highest-information observation
  in the set. Name it, say why, keep it in the log.
- Reporting a narrower interval than the previous cycle without pointing at the evidence that
  narrowed it.
- Letting a `[prior only]` parameter lose its marking through age. Age is not evidence.
- Skipping step 8 because nothing changed. An unarchived forecast can never be scored.

## Cadence

Ingest and score daily. Fast parameters — contact rate, shrinkage, AHT — update daily. Slow
ones — tenured attrition, graduation rate, ramp — update weekly, because a single day carries
almost no information about them. `lam_for()` handles the arithmetic; do not compensate by
hand.

Re-elicit priors only when `regime_flag` fires or the operation changes by design. Routine
disagreement with the model is not grounds for re-elicitation; it is the model working.

## First cycle, before any data

The first cycle produces a fully `[prior only]` model and should be labeled loudly as one. Its
job is not to be right — it is to publish a falsifiable forecast so the second cycle has
something to score. **Run step 8 even on cycle one.** A model that waits for good data before
predicting never gets calibrated, because nothing is ever scoring it.

Block 10 — 06-interpretation.md

# 06 — Reading and presenting the output

*Figures marked `[estimated]` are judgment, not measurement.*

## The three numbers that go in every readout

1. **Coverage probability** at the current plan — the chance every week is covered.
2. **Shortfall if it misses** — P50 and P90 of the deficit, in productive hours and FTE. A
   90% coverage with a catastrophic 10% is a worse position than 80% coverage with a
   manageable 20%, and only the shortfall distribution shows the difference.
3. **The dominant uncertainty** — the single input carrying the most remaining spread, and
   whether it is knowable.

## The chart the decision is made on

The coverage curve — probability of covering every week against additional heads. It converts
"how many people do we need" into "how much certainty are we buying, and what does the next
increment cost". Executives who reject a distribution will accept this chart, because it is a
purchasing decision rather than a statistics lesson.

Read the **shape**, not a point on it. A steep section means each head buys a lot of
certainty. A flat section means more hiring is not the constraint, and something structural —
handling time, channel mix, concurrency, the pipeline itself — is. Recommending more heads on
a flat section is the most expensive mistake this model exists to prevent.

Note the curve computed by `coverage_curve()` assumes additional heads are **fully ramped from
week zero**. That is an upper bound, not a plan. The achievable version needs those heads run
through the pipeline in the config, and it will always be worse.

## Saying it in a sentence

> At today's plan we cover every week with probability **X%**. If we are short, the shortfall
> is typically **N FTE** and at the 90th percentile **M FTE**. Getting to 80% coverage costs
> **H more heads**, and the largest remaining uncertainty is **contact rate in SEG-A**, which
> we could narrow with three weeks of clean transaction-to-contact matching.

Lead with the decision. The method goes in the appendix or in answer to a question.

## Handling the four standard challenges

**"Just give me the number."** Give it, with the probability in the same sentence: "412, which
covers us about half the time. 470 covers us four times in five." Refusing to give a number
loses the room. Giving one without its probability loses the argument later.

**"The range is too wide to be useful."** The range is the honest width of current knowledge.
Narrowing it requires evidence, and the attribution table names exactly which evidence and in
what order. A narrower interval produced without new evidence is not a better model, it is a
less honest one.

**"Last year we managed with a single number."** Ask what the achieved service level was
against target and whether anyone could say afterwards why. The deterministic plan's defining
property is that its failures are unattributable — see `07-why-deterministic-fails.md`.

**"This is just a more complicated forecast."** No: a forecast asserts, this one is scored.
Show the calibration log. If it has not been running long enough to show one, say that
plainly — the honest answer for the first weeks is that the model is not yet calibrated.

## Failure modes to check before presenting

- **Coverage near 0% or 100% everywhere.** Almost always a configuration error rather than a
  finding — usually a units mistake, a starting headcount that does not correspond to the
  demand scope, or shrink applied twice. Reconcile productive hours on both sides first.
- **Occupancy appearing near the top of the attribution table.** Under `erlang_curve` it is a
  function of load, so it inherits volume's correlation while actually reducing the
  requirement. `attribution()` excludes it by default; if you passed
  `include_endogenous=True`, do not rank the gap register on it.
- **A single input above ~70% of variance.** Either it is genuinely the whole story, or its
  elicited range is far too wide relative to the others. Check the elicitation before
  commissioning work against it.
- **Intervals narrowing quickly in early cycles.** Suspect an effective sample size that is
  too large — hours being treated as independent trials.
- **A ramp curve reaching 1.0 sooner than tenure-banded data supports.** This inflates supply
  in exactly the weeks a new class was hired to cover.
- **Fixed occupancy across a wide volume range.** Exaggerates both tails — it removes the
  stabilizing feedback whereby occupancy rises with load. Use `erlang_curve`, or declare the
  fixed assumption every time.
- **Pooled coverage much worse than interactive coverage.** The shortfall is deferrable
  backlog, not queue failure. Say which one it is; they have different remedies and only one
  of them is a hiring case.

## What not to claim

- Do not present the model as a prediction of what will happen. It is a statement of what is
  consistent with current knowledge.
- Do not attribute a specific past miss to a specific parameter using this model. Attribution
  here is variance decomposition over simulated futures — an association, not a cause. A
  claim about why a particular week failed is a causal claim and needs causal work.
- Do not quote a percentile beyond P95 unless the draw count supports it and the tail is
  driven by modeled structure rather than by the shape of an elicited prior.
- Do not describe the model as validated until the calibration log has enough observations to
  say so. Thirty to forty scored periods `[estimated]` before the hit rate means much.

Block 11 — 07-why-deterministic-fails.md

# 07 — Why the deterministic plan fails

*Figures marked `[estimated]` are judgment, not measurement. The worked numbers below come
from the demonstration configuration in `03-simulator.md` **at 60,000 draws** — the module
default is 20,000, at which the percentiles differ materially — and reproduce from the pack's
own functions. They illustrate the shape of the effect, not any particular operation.*

## First, the argument that does not work

It is commonly said that deterministic staffing models systematically understate the
requirement, because averaging the inputs and then computing loses the effect of the tail.

**Do not lead with this.** It is true here, and it is worth about one percent.

### Why it is nearly absent

Required productive hours are

```
transactions x contact rate x channel share x AHT / concurrency / occupancy
```

The first four terms are a **product**. For independent factors the expectation of a product
is the product of the expectations, so multiplying means through the chain introduces no bias
at all. Note the condition: *independent*. `01-method.md`'s Limits block concedes that real
segments correlate, and where they do this term stops being exactly zero — which is an
argument for supplying a correlation matrix, not for the folklore.

### Where the bias actually comes from

The model does not only multiply. It **divides** — by concurrency, by occupancy, and for
deferrable work by throughput. And `1/x` is convex, so for each division
`E[W/x] = E[W]·E[1/x] > E[W]/E[x]`. That is a genuine Jensen effect, and it is the real source
of the gap. Measured on the demonstration configuration as `E[1/x]·E[x] − 1`:

| Divisor | Spec it comes from | Jensen gap |
|---|---|---|
| Chat concurrency | `{ci: [1.4, 2.2]}`, demo | +1.90% |
| Email throughput | `{ci: [4.0, 7.0]}`, demo | +2.94% |
| Occupancy | `{mean: 0.78, ci: [0.70, 0.85]}`, the `04` fallback | +0.35% |

The demo itself uses `erlang_curve` for both interactive channels, so it carries no fixed
occupancy; the occupancy row is quoted against the documented fallback spec in `04` and is
labeled as such.

Weighted by each channel's share of demand these come to roughly a percent, which is what the
whole-model measurement shows: the deterministic plan sits **1.1% to 1.3% below the simulated
mean**, depending on configuration. The divisions with the widest relative spread — throughput
and concurrency — dominate; occupancy contributes least, and under `erlang_curve` less still,
because occupancy then rises with load (correlation of +0.64 for voice and +0.80 for chat
against that channel's own workload) and the correlation offsets part of the convexity.

### So make the concession and move on

The bias is real. It points in the direction the folklore claims. It is about one percent.

Arguing it invites the obvious reply — *"so the correction is one percent?"* — and having
conceded that, the rest of the case is harder to make. It is also the easiest claim in this
document to check, so overstating it is the fastest way to lose a numerate reader.

The case against determinism does not rest on a one percent bias in the mean. It rests on the
fact that a point estimate answers the wrong question entirely. It has four better arguments —
and one that is better still.

## 1. A point estimate is a coin flip sold as a plan

At the peak week of the demonstration run, the deterministic answer was 9,643 productive
hours. The probability that the actual requirement exceeds it: **50%**.

Two things about that 50%, because a numerate reader will ask. It is graded against *this
model's* distribution, not against the world — the deterministic plan and the simulation share
their inputs, so this says the plug-in answer lands at the model's median, not that reality
obliges. And it is a property of the plug-in convention: feeding **means** through the two-pass
calculation lands at P50.4 here, while feeding medians lands around P47. Neither is a law. What
survives both caveats is the shape of the thing — a single number with no probability attached
is a bet whose odds nobody stated.

That is what a single-point plan means. Not "our best estimate" — a **50% chance of being
short**, every period, by construction. Stated plainly, no one would commit to it. It survives
because the number arrives without a probability attached and is therefore never seen as the
bet it is.

What the distribution adds is the price of not being a coin flip:

| Service posture | Required productive hours | vs deterministic |
|---|---|---|
| Deterministic point | 9,643 | — |
| P80 — short one week in five | 10,976 | **+14%** |
| P95 — short one week in twenty | 12,458 | **+29%** |

The deterministic model does not merely omit these numbers. It omits the *question*, and so
the organization never chooses its risk posture — it inherits P50 by accident.

## 2. Service does not degrade gracefully — it sheds demand

The deficit-to-service relationship is violently non-linear, and it gets worse as an operation
gets more efficient. Erlang C at 450s AHT, 80% in 20s `[illustrative]`:

| Offered load | Agents at 80/20 | Occupancy | SL at 5% short | at 10% short |
|---|---|---|---|---|
| 60 erlangs | 68 | 88.2% | 66.4% | 18.5% |
| 150 erlangs | 161 | 93.2% | 36.0% | 0% |
| 300 erlangs | 313 | 95.8% | **0%** | 0% |

At 300 erlangs the entire buffer between staffing and offered load is 13 agents — 4.3%. A 5%
shortfall takes staffing *below the offered load*.

### The caveat that has to be stated

**Erlang C assumes infinite patience.** Every caller waits forever, so below offered load the
queue grows without bound and the model reports zero service. Real callers hang up, and that
abandonment is precisely what stabilizes the system. The zeroes in the table above are an
artifact of the assumption, not a prediction.

`01-method.md` already concedes this assumption. Asserting collapse while relying on it is the
easiest sentence in this document to knock down, so here is the same case under Erlang A, with
a mean patience of 120 seconds `[illustrative]`:

| Offered load | Deficit | Erlang C says | Abandonment | Callers delayed |
|---|---|---|---|---|
| 60 erlangs | 5% | SL 66.4% | 3.2% | 19.9% |
| | 15% | SL 0% | 8.7% | 43.8% |
| 300 erlangs | 0% | SL 80.4% | 1.2% | 16.7% |
| | 5% | SL 0% | **3.6%** | 39.9% |
| | 10% | SL 0% | 7.1% | 63.5% |
| | 15% | SL 0% | **11.7%** | 82.5% |

The queue does not run away. **What changes is the character of the failure, not its
existence.** The cost moves off the wait-time metric and onto lost contacts: at a 15% deficit
roughly one caller in nine abandons and four in five wait. Abandoned contacts largely return,
so tomorrow's volume rises — the shortfall partly recycles itself into more demand, which is
worse than a bad service level and does not show up in a service-level report at all.

So the honest form of the argument is not "service collapses." It is: **the response to a
shortfall is steeply non-linear and it is measured on the wrong instrument.** Because it is
convex in the deficit, average service under a plan built at average demand is worse than that
plan appears to deliver — and part of the damage never reaches the service-level number.

### On scale

A tempting flourish — *the bigger and better-utilized the operation, the more brutal the
asymmetry* — is only true at **equal percentage shortfall**, which is not a fair comparison.
Where demand variation is Poisson arrival noise, relative variability falls as scale rises, so
a large operation is proportionally *less* likely to be 5% short. That is the ordinary
intuition and it is correct on its own terms.

What breaks it here is that this model's spread is not arrival noise. It is **parameter
uncertainty** — not knowing the contact rate, the handling time, the concurrency — and
parameter uncertainty does not shrink with scale. Contact rate alone carries around 44% of
shortfall variance in the demonstration run, measured over exogenous inputs. A portfolio ten times larger has the same
percentage uncertainty about its contact rate, and therefore roughly the same probability of
a 5% shortfall, while suffering far more when one occurs.

That is the defensible version: scale protects against arrival noise and offers no protection
at all against not knowing your parameters. It is also a better argument, because it points
somewhere useful — at measurement.

## 3. Uncertainty compounds through the chain, and precision cannot reduce it

Demand here is a product of four or five uncertain quantities. From the demonstration run,
P10–P90 spread:

Spread here is **P90 / P10 − 1**, stated explicitly so the figures reproduce:

| Quantity | P90/P10 − 1 |
|---|---|
| Transactions, SEG-A | 27% |
| Transactions, SEG-B | 48% |
| Contact rate, SEG-A | 50% |
| Contact rate, SEG-B | 62% |
| Voice AHT | 28% |
| Chat AHT | 43% |
| **Required hours** | **49%** |

Two effects run against each other. Within a segment the uncertainties **multiply**, so
log-variances add and the chain is wider than any link in it. Across segments and channels the
requirement is a **sum**, and summing imperfectly-correlated components pulls relative spread
back down, and occupancy adds a third effect in the same direction — it rises with load, so a
high-volume draw is partly self-correcting. Here the three leave the output at 49%: wider than
most inputs and narrower than the widest.

Note what that does *not* say. The output is not bounded below by the widest input — contact
rate in SEG-B is wider than the total. The claim that survives is narrower and still
sufficient: **the spread is irreducible by precision.** No amount of decimal places in the
point estimate shrinks it; they only conceal it. A model that
reports 9,538 rather than 9,500 is not more accurate, it is more confident, and those are
different things.

## 4. Supply is not deterministic either, and this is the half usually missing

A plan saying "we need 412" quietly assumes 412 people will be trained, present and productive
when required. Each link in that chain is a random variable: requisitions fill or do not, time
to fill varies by weeks, classes start late, graduates are a fraction of starts, new agents
carry roughly half an FTE of capacity in their first weeks, and early attrition takes some of
them before they ever reach full productivity.

This is not a rhetorical point; it is simulated. On the demonstration configuration the supply
side alone spans **P10 7,766 to P90 9,031 productive hours at week 12** — a **16% spread (P90/P10 − 1) with
the demand side held still**, produced entirely by requisition fill, time-to-fill slip, class
fill, graduation, ramp and early attrition compounding. That is the same order as the spread on
transactions, and it is the half of the plan that usually carries no uncertainty at all.

A deterministic plan can be right about demand and still fail on that. Worse, when it does
fail, there is no way to tell which side failed — and so the same argument gets had every
quarter without resolution.

## 5. A deterministic model cannot learn, and cannot be wrong

This is the one that matters most over time.

A point forecast that misses tells you nothing. Was the model wrong, or was the month unusual?
There is no way to answer, so the model gets adjusted by whoever argues most persuasively.
Nothing accumulates. The same disagreements recur indefinitely because no evidence can settle
them.

A probabilistic forecast is **falsifiable**. The actual either fell inside the 80% interval or
it did not, and over 40 observations the hit rate either is or is not near 80%. That single
property changes the character of the work:

- Parameters move on evidence, not on seniority.
- Persistent bias becomes visible within weeks — seven of ten actuals in one tail is a
  structural change, not a run of bad luck.
- The model can say **what to measure next**, by ranking inputs on their contribution to the
  remaining spread. A deterministic model has no spread to decompose and so cannot generate
  its own research agenda.

A deterministic model asks to be believed. A probabilistic one asks to be scored. Only the
second one improves.

## What to concede

Do not oversell this. A Monte Carlo built on invented ranges is not better than a point
estimate built on experience — it is the same guess wearing a lab coat, and the interval it
produces is fiction with decimal places. The method earns its keep only once it is being
scored against actuals and the ranges are moving as a result.

Say so when presenting it. The first weeks of output are `[prior only]` and should be labeled
that way. The claim is not "this model is right" — it is "this model can be shown to be
wrong, and the one it replaces cannot."

Block 12 — MODEL-STATE-template.md

# MODEL-STATE.md — format and template

> This file is **generated**, not written. `cycle.py`'s `write_state()` (`05c-cycle.md`) emits it and
> `read_state()` reads it back. Replace the copy in project knowledge at the end of every
> cycle. Never hand-edit a parameter — it moves through `cycle.py` or not at all.

## What it carries

| Section | Purpose |
|---|---|
| Header | Revision, the date the data covers, which parameters are still `[prior only]` |
| Calibration | Scored periods, 80% hit rate, mean CRPS, PIT mean, `regime_flag` |
| Open flags | Regime warnings, reconciliation breaks, unconfirmed definitions |
| This cycle | The readout: what moved and what it means for the decision |
| Machine state | The `json` block — the actual truth; everything above is rendered from it |

## The json block

| Key | Meaning |
|---|---|
| `revision` | Increments once per cycle |
| `covers_through` | Last date of data reflected |
| `ingested_through` | Last date absorbed into a posterior. Absent at revision 0 — `bootstrap_state` does not set it. Guards against applying the same evidence twice when the intake files grow |
| `posteriors` | One node per parameter. `beta` carries `a`/`b`; `nig` carries `mu`/`kappa`/`alpha`/`beta`; `dirichlet` carries `alpha` plus its channel order. `prior_only` is removed by the first real observation and by nothing else |
| `lambda_daily` | Per-parameter daily forgetting factor. Slow parameters sit at 0.99–0.995 |
| `forecast_archive` | 40-point quantile grids for the near horizon. **This is what makes scoring possible** — without it the previous cycle's distribution is gone when the actual arrives |
| `calibration` | One row per scored week and quantity: actual, PIT, CRPS, 80% hit |
| `definitions` | Chat AHT, productive hours, occupancy. `UNCONFIRMED` until someone confirms them in writing |
| `flags` | Open warnings, carried forward |

## Sizing

A live file runs about 10 kB with four weeks of archive and a six-month calibration log.
`prune_archive()` keeps it there — it drops scored forecasts beyond the calibration window.
If it grows well past that, the archive is not being pruned and the cycle is skipping step 8.

## Building revision 0

```python
from mc_staffing import load_config, run
import cycle as C

cfg = load_config("params.yaml")
state = C.bootstrap_state(cfg)              # priors from params.yaml, all [prior only]
res = run(cfg)
C.archive_forecast(state, res, issued="<today>", first_week_start="<next Monday>")
C.write_state(state, "MODEL-STATE.md")
```

Run `archive_forecast` on cycle one even though there is nothing to score yet. A model that
waits for good data before publishing a forecast never gets calibrated, because nothing is
ever scoring it.

---

# Generated example — revision 0, all parameters `[prior only]`

    # MODEL-STATE.md
    
    **Revision:** 0 · **Covers data through:** — · **Emitted:** 2026-09-19
    
    **16 of 16 parameters are `[prior only]`:** AHT[chat], AHT[voice], CR[SEG-A], CR[SEG-B], attrition_tenured, class_fill_rate, fcst_err[SEG-A], fcst_err[SEG-B], graduation_rate, occ[chat], occ[voice], req_fill_prob, shrink_planned, shrink_unplanned, split[SEG-A], split[SEG-B]
    
    **Tracked but not model inputs:** occ[chat], occ[voice] — compare against what the occupancy curve predicts at the observed load; a persistent gap means the curve needs refitting.
    
    ## Calibration
    
    | Metric | Value |
    |---|---|
    | Scored periods | 0 |
    | 80% interval hit rate | — |
    | Mean CRPS | — |
    | PIT mean (0.5 = unbiased) | — |
    | regime_flag | none |
    
    ## This cycle
    
    _(what moved this cycle, and the evidence that moved it)_
    
    ## Machine state
    
    Do not hand-edit. Parameters move through `cycle.py` or not at all.
    
    ```json
    {
     "calibration": [],
     "covers_through": null,
     "definitions": {
      "chat_aht": "UNCONFIRMED",
      "occupancy": "UNCONFIRMED",
      "productive_hours": "UNCONFIRMED"
     },
     "flags": [],
     "forecast_archive": [
      {
       "issued": "2026-09-07",
       "q": [
        6296.49,
        6767.3,
        7045.31,
        7234.53,
        7384.55,
        7513.35,
        7642.32,
        7753.27,
        7862.49,
        7965.21,
        8068.58,
        8162.72,
        8245.29,
        8334.66,
        8423.47,
        8507.58,
        8593.06,
        8674.51,
    ... (remaining posteriors, archive and calibration omitted) ...

Blocks carrying the model code

  • Block 403-simulator.md
  • Block 503b-updating.md
  • Block 805b-intake.md
  • Block 905c-cycle.md

These blocks are the executable part of the pack and are carried on Wiki:Packs/Probabilistic Staffing/Modules. They are separated for a mechanical reason: a single syntax-highlighted block past roughly a thousand lines silently renders as unhighlighted plain text, losing the border that tells a reader where the block starts and stops. Splitting them keeps every block inside that limit and keeps this page a readable length.

Save them under the filenames in their headings and upload them as project knowledge alongside the blocks above.


Usage notes

Sizing. Blocks 4, 5, 8 and 9 carry the working Python and sit on the companion page Wiki:Packs/Probabilistic Staffing/Modules — Block 4 the simulator, Block 5 the updating and scoring helpers, Blocks 8 and 9 the intake layer and the cycle. Everything on this page is prose.

The model's own limits are stated in the blocks and should not be edited out. Supply is not split by skill, so the two lane figures assume one pool. The occupancy curve is theoretical rather than fitted and assumes a flat arrival profile. Five parameters are sampled but carry no state node, so they neither learn nor age.

Shrinkage convention. This pack applies shrinkage once, on the supply side, so that demand and supply are compared in the same unit. Demand calculation applies it on the demand side and lands on paid headcount. Both are valid; state which is in force.

Drift. The reference blocks are derived from the pages in the Source field. A material change to any of them is a trigger to regenerate the blocks and increment the version.

Change history

Version Date Change
1.0 2026-09-19 Initial publication.

See also