Wiki:Packs/Migration Service-Level Simulation
| Pack | |
|---|---|
| ID | CP-WFM-019
|
| Name | Migration Service-Level Simulation |
| Domain | WFM |
| Blocks | 1 instruction + 6 reference |
| Version | 1.2 |
| Source | Service Level During Work Migration · Migration Archetypes · The Migration Health Pack · Erlang Sensitivity and the Staffing Cliff · Pooling Architecture in Service Workforces · Erlang-A · Employee Attrition and Turnover · Scenario Planning and Contingency Staffing |
A deployable Claude project setup for simulating service level through a work migration: a site exit, consolidation, outsourcing or transfer in which the source site keeps serving customers while its workforce shrinks. It is a weekly Monte Carlo that walks demand and supply through the transition together. Clients leave, transfer or re-platform on contract-driven dates. Staff attrition runs without backfill while a consultation period freezes notification and work moves. Training pulls transferees off the floor before each cutover. The result is a band of service level per pool and per week, on both sides of the move, rather than an end-state headcount.
The concepts are set out in Service Level During Work Migration. Copy Block 1 into a project's custom instructions and save the remaining blocks as files uploaded as project knowledge. See Wiki:Packs for how packs work. For a browser version that needs no Claude project or Python, see the Migration Scenario Modeler: a single blended team rather than per-client intake, with Erlang A or Erlang C service and a scenario export.
When to use it

Use this pack when a program holds an end-state headcount waterfall (start, minus attrition, transfers and releases) and is being asked what happens to service before that end state. It fits most closely the ownership-or-location change in Migration Archetypes, where demand is unchanged and the plan is a supply-side plan sitting downstream of a legal calendar it does not control. It can supply the requirement-against-supply artifact of The Migration Health Pack for a migration of that kind.
It is not a steady-state capacity model: for that see Wiki:Packs/Probabilistic Staffing. It is not a governance surface: the Migration Health Pack is. And it is not legal advice. Notice periods and consultation lengths are inputs, and they come from counsel.
How to deploy
- Create a Claude project named for the migration, not for the method.
- Copy Block 1 into the project's custom instructions.
- Save Blocks 2–7 under the filenames in their headings and upload them as project knowledge. Blocks 4 and 5 are on Wiki:Packs/Migration Service-Level Simulation/Modules.
- In each conversation, upload the program's client and staffing workbook. Resolve it with
--no-runfirst, close the material gaps, and only then run the base case and the levers.
Block 1 — project-instructions.md
# Project instructions — Migration Service-Level Simulation
## Context
This project simulates **what happens to service while work migrates between sites**: a site
exit, consolidation, outsourcing or transfer. The source keeps serving customers while its
workforce shrinks. A consultation freeze can stop notification, work moves and backfill for
months, and clients leave, transfer or re-platform on contract dates rather than plan dates.
Output is a **weekly service band per pool, source and destination**: the trough, the
probability of breaching a floor, the weeks below it, peak utilisation and, for interactive
pools, peak abandonment. It is never a single
number. The model reconciles to the program's end-state headcount waterfall, but it answers a
different question: what the weeks in between cost.
## Routing
| When the task is | Open |
|---|---|
| Why service breaks mid-migration; model structure | `02-method.md` |
| A parameter, a lever, a range, a scenario | `03-levers-and-parameters.md` |
| Reading the program's workbook, mapping, gap register, headcount | `04-intake-schema.md` |
| Running the simulation, scenarios, charts, Excel output | `05-simulator.md` |
| Reading results, a readout, a challenge | `06-interpretation.md` |
| The whole flow on synthetic data | `07-worked-example.md` |
## Sequence
1. **Read before modelling.** Describe each tab of an uploaded workbook in plain language and
reconcile its headcount totals. Flag duplicated roles, overhead counted as frontline, and
waterfall lines that are differences of other lines.
2. **Map, then resolve with `--no-run`.** Show the gap register, headcount reconciliation and
baseline utilisation.
3. **Close the gaps that matter.** Ask about every gap above about 10% of volume and every
baseline utilisation outside 0.50–0.95.
4. **Run the base case, the neutral case once as a check, then the levers.**
5. **Read out** as `06-interpretation.md` specifies and hand back the Excel workbook.
## Disciplines
- A default applied to a client is a gap-register row. Unowned parameters are `[estimated]`.
- Transactions are not contacts: ask for `contacts_per_unit` on every interactive pool.
- Only frontline roles carry capacity. Team leaders, support and management reconcile in the
waterfall.
- Ask for ranges: low and high first, then most likely.
- Never tune a parameter to produce an answer. Name the input that is wrong instead.
- Interactive pools default to **Erlang A** (callers abandon). Quote peak abandonment with
every trough; patience and redial rate are `[estimated]` until measured, so ask for them.
- `service.model: C` (nobody abandons) is a comparison only. Under it, report utilisation too.
- Save `config_resolved.yaml` with every result, and quote the draws and seed.
## Output
Lead with the decision. Every readout names the trough (with P10) and its abandonment, when
and for how long, the probability of breach, the lever that moves it most with its cost, and
the service model used. Show the
weekly fan chart per pool, with the freeze shaded. If a single number is demanded, give the
P50 trough with its breach probability in the same sentence.
Source: Wiki:Packs/Migration Service-Level Simulation (CP-WFM-019) v1.2
Block 2 — 02-method.md
# 02 — Method
*Figures marked `[estimated]` are judgment, not measurement.*
## The question
Work is leaving one site for another: a country exit, a consolidation, an outsourcing, a
transfer to a partner. Between the decision and the last wave, the source site has to keep
serving customers with a workforce that is shrinking, distracted and, for part of the time,
legally untouchable. **How bad does service get, for how long, and which levers move it?**
A headcount waterfall answers a different question: *where do we end up?* It is an end-state
reconciliation (start, minus attrition, minus transfers, minus releases, equals zero). It says
nothing about the weeks in between, and the weeks in between are where service breaks.
## Why service breaks in the middle
Four mechanisms, all present in almost every migration, and all invisible to an end-state model:
1. **Supply leaves before demand.** During a consultation freeze (works council, union,
regulator, or simply an unannounced decision) a program commonly defers notifying staff,
moving work and, in practice, backfilling, depending on the law, agreements and practice that
apply. Attrition keeps running and usually rises once the change is known. Demand does not fall until clients actually leave or transfer.
2. **Demand leaves in steps, and on contract dates, not plan dates.** Each client's work leaves
when its contract allows: at expiry, after a notice period, or at its transfer wave. The
work-leaving curve is a staircase with uncertain step dates.
3. **Transition work takes staff off the phones.** Training on the destination's systems and
processes, knowledge transfer and shadowing all come out of the same heads, in the weeks
just before each wave, when the source is already thinnest.
4. **Queues are non-linear.** Service level does not fall in proportion to staffing. A pool at
78% occupancy that loses 10% of its productive hours without losing any demand moves to
about 87%, and on a small pool that can take service from the mid-90s to below 50. The
same arithmetic makes small destination pools worse than their occupancy suggests. Where
callers can hang up, part of that loss shows as abandonment rather than as waiting.
## Model structure
A weekly Monte Carlo. Each draw samples the uncertain inputs once, then walks the same weekly
grid for demand and supply together, so a draw with a long freeze is compared against the
supply that same long freeze produced. The sides are never simulated separately and compared
as summary statistics, because that would discard the correlation and understate the tails.
### Phases
```
week 0 ─── freeze start ─────── freeze end = announcement ──── waves ──── steady state
no notice, no moves, split into transferees and work and staff move
no backfill releasees; dismissal notice in steps; releases
clock starts follow the work out
```
The freeze end is drawn per draw (PERT). Waves are defined as **offsets after the freeze end**,
so a longer freeze slips every wave with it. This is the defining constraint: nothing moves
until the consultation closes.
### Demand side (per client, per week)
```
fate[c] ~ Categorical(transfer, exit, replatform) from health priors or per-client override
exit week = contract expiry if fixed-term
= freeze_end + notice[c] if evergreen (notice ~ PERT, shorter with a
termination-for-convenience clause)
exit later than the client's wave → the work transfers at the wave instead
replatform = min(freeze_end + offset, wave) (moves to another internal platform; staff released;
never later than the wave, which would move it anyway)
transfer = freeze_end + wave offset (a fixed-term client that transfers keeps trading to the
wave even if its contract expires first: tacit renewal)
source workload[p,w] = Σ_c in pool p volume[c]/52 × season[w] × volume_error[p] while w < dep[c]
```
A fixed-term client whose contract expires *during* the freeze leaves during the freeze. That
is client-driven, so the freeze does not stop it, and it is the only demand relief the source
gets before the announcement.
### Supply side (per pool, per week)
```
before announcement one group; weekly attrition = base/52 × freeze multiplier; no backfill
at announcement split: transferees = heads × transfer share of workload × (1 − synergy)
releasees = the rest
after announcement transferees: attrition × post multiplier (transfer)
releasees: attrition × post multiplier (release) × (1 − retention effect)
shrinkage += post-announcement surge (absence rises after announcements)
at each wave transferees move in proportion to the transfer workload leaving
training: hours per transferee spread over the weeks before their wave, within
the shorter of the training window and the weeks between the
announcement and the wave (a close wave compresses the window;
a window is never shorter than the hours need at one productive
week per head per week; what still does not fit a week spills
into the next; hours that cannot fit before the wave at all are
reported as train_undelivered_hours, never silently dropped)
releases only after freeze end + dismissal notice; only down to what the
remaining work needs (+ buffer), held for a lag after the work leaves
contingency overflow FTE from another site, from a start week, at an AHT penalty
productive FTE = heads × (1 − shrinkage) − training FTE + overflow
```
### Destination side
Transferred work lands with its staff at each wave. By default the destination **hires to
plan**: it receives the planned headcount even if the source leaked some before the wave,
because the freeze binds the source, not the destination. Arriving work carries two
multipliers on handling time:
```
steady state = 1 − synergy × synergy realisation (the productivity the synergy assumes, as achieved)
ramp = 1 + (AHT uplift − 1) × max(0, 1 − weeks since landing / ramp weeks)
```
Set realisation to 1 and the synergy is fully real. Set it below 1 and the destination is
understaffed for its work permanently. This is the "synergy de-risk" question made explicit.
### Converting to service
**Interactive pools** (calls, chats): a queueing model per intraday bucket (peak, shoulder,
off-peak), volume-weighted to a weekly service level. Staff hours are allocated to buckets by a
schedule-fit blend of volume share and hour share, so the schedule never matches demand
perfectly. Fractional agents are interpolated between the floor and the ceiling.
The queueing model is set per pool by `service_model` (or globally in `service.model`):
- **Erlang A (default).** Waiting callers give up after an exponentially distributed patience
with mean `patience_seconds`. The model is the exact M/M/N+M queue: with h = AHT ÷ patience,
τ = threshold ÷ AHT and q₀ = 1, q_k = q_{k−1} × A ÷ (N + k·h) for k callers waiting,
Z = 1/B − 1 + Σ q_k (B = Erlang B),
```
P(wait) = Σ q_k ÷ Z
P(abandon) = Σ q_k × (k+1)h ÷ (N + (k+1)h) ÷ Z
service level = answered within the threshold ÷ ALL offered
= [1/B − 1 + Σ q_k × N ÷ (N + (k+1)h) × (1 − F_k)] ÷ Z
```
where F_k is the probability that a caller who finds k waiting has not reached an agent by the
threshold (a negative-binomial CDF). The simulator evaluates the sums in closed form through
the incomplete gamma function. Abandoned calls count as misses, so service level is never
flattered by abandonment. Very patient callers reproduce Erlang C; very impatient ones, Erlang B.
- **Redials.** Forecast volumes already contain today's redials, so only abandonment above the
pool's week-0 rate creates extra contacts. A share of them (`redial_rate`, default 0.4) arrives
the following week, scaled by the share of the pool's work still there, and is served (or
abandons) like any other contact:
```
redials[w+1] = redial_rate × max(0, abandon_rate[w] − abandon_rate[0]) × offered[w] × min(1, work[w+1] ÷ work[w])
```
The destination inherits the source's week-0 rate as its baseline.
- **Erlang C** (`service_model: C`). Nobody abandons. The same model as version 1.0 of this pack
exactly. Use it as a comparison, or when a plan being checked was built on Erlang C.
The service model changes only how staffing is *read*. Headcount, releases and the destination's
plan are driven by workload shares, not by an Erlang calculation, so they are identical under
both models.
**Deferrable pools** (email, offline transactions, back office): weekly capacity against
arrivals, with backlog carried forward. Service is a **timeliness index**:
`min(1, target_days / backlog_days)`. It is a first-in-first-out proxy, not a measured SLA.
**Utilisation** is reported alongside both: offered work ÷ productive capacity. Above 1.0 the
pool cannot clear its work at any service level.
## Outputs
| Output | Meaning |
|---|---|
| Weekly band | P10 / P50 / P90 service per pool per week, source and destination. Weeks in which a pool holds less than `scoring.min_share` of its baseline work (source) or of the work that will eventually land (destination) are not scored: a last or first client alone is a sliver, not a queue |
| Trough | Worst week's service in each draw, summarised as P50 and P10 |
| P(breach) | Share of draws whose trough falls below the pool's service floor |
| Weeks below floor | How long the damage lasts (P50 and P90) |
| Peak utilisation | How overloaded the worst week is, a direct measure of "how bad" |
| Peak abandonment | Worst week's abandon rate (Erlang A pools), P50 and P90 |
| P(abandonment over cap) | Share of draws in which abandonment passes `abandon_cap` (default 0.10) in at least one week |
| Client fates | Share of draws in which each client transfers, exits or re-platforms |
| Scenarios | The same table for each named set of lever overrides, on common random numbers |
## What the demonstration shows
On the synthetic configuration in `05-simulator.md` (three pools, 56 clients, freeze ending
in week 8–26 with mode 14, 2,000 draws, common random numbers). Figures are the shared pool's
source-side **P50 trough** and its worst week's abandonment, under the default Erlang A model
(patience 120 seconds, redial rate 0.4) and, for comparison, under Erlang C. Its week-0 service
level is 0.99 under Erlang A and 0.98 under Erlang C. The worked example in
`07-worked-example.md` runs the same synthetic operation through the intake layer, which carves
the largest dedicated client into its own pool; that changes the random streams, so its figures
differ from these in the second decimal place.
| Scenario | Erlang A trough | Erlang A P(breach) | Peak abandonment | Erlang C trough | Erlang C P(breach) | Reading |
|---|---|---|---|---|---|---|
| Base case | 0.86 | 0.05 | 10% | 0.29 | 0.87 | The valley: late in the freeze and through the first waves |
| Neutral (no training, surge, attrition, uplift) | 0.99 | 0.00 | 1% | 0.98 | 0.00 | The verification case: nothing leaks |
| Training 40 h over 4 weeks (base: 24 h over 6) | 0.61 | 0.80 | 25% | 0.00 | 1.00 | Cutover weeks overload the pool |
| Training spread over 10 weeks | 0.89 | 0.02 | 8% | 0.50 | 0.73 | Same hours, shallower valley |
| No pre-cutover training | 0.94 | 0.00 | 5% | 0.78 | 0.38 | Training timing is the largest single lever |
| Stay bonus cutting release-group attrition 40% | 0.86 | 0.04 | 10% | 0.32 | 0.85 | Barely moves it |
| Short freeze (6–12 weeks) | 0.87 | 0.02 | 9% | 0.36 | 0.83 | Trough arrives earlier, only slightly shallower |
| 10 overflow FTE at a 1.2× AHT penalty | 0.96 | 0.00 | 3% | 0.88 | 0.17 | Contingency capacity beats everything else tested |
On the destination side, the shared pool's P50 trough is 0.90 (7% peak abandonment) in the base
case. It falls to **0.84** with 11% abandonment when synergy realisation is doubted
(`[0.2, 0.5, 0.8]`), and to **0.81** with 13% when the destination inherits the source's leakage
instead of hiring to plan. Under Erlang C the same three cases read 0.62, 0.25 and 0.17.
What generalises:
- **The two models describe the same shortage differently.** Erlang C, in which nobody hangs up,
turns a shortfall into queues that never clear and service near zero. Erlang A turns it into
lost callers: a trough of 0.86 with one caller in ten abandoning is the same week as Erlang C's
0.29. Neither is "the" answer; how patient the callers really are decides which is closer, so
measure patience before quoting a trough, and always quote abandonment with it.
- **The valley is late.** Service holds through most of the freeze and breaks around the
announcement and first waves, when post-announcement attrition, the absence surge and
training stack on a workforce the freeze has already thinned.
- **Training timing matters more than attrition.** Moving training after cutover, or spreading
it, moves the trough more than a retention bonus does, under either model. Retention money is
better aimed at the transfer group and at the last wave.
- **Borrowed capacity is the strongest lever,** and it has to be arranged before the
announcement: it concerns capacity outside the affected site, so it can be arranged earlier
than levers that act on affected staff, subject to any information obligations that apply.
- **Synergy at constant occupancy still costs service.** In the neutral case the dedicated
pool's destination trough is 0.93 against 0.96 at source under Erlang A (0.83 against 0.91
under Erlang C): 25% fewer heads at 25% faster handling time gives the same occupancy but a
smaller queue, and smaller queues absorb variance worse. Proportional synergy maths overstates
the saving.
- **A shorter freeze moves the valley more than it fills it.** The post-announcement dynamics
are unchanged.
These are properties of the method on made-up data, not forecasts. Real inputs will move
every number.
## Limits
- **Patience is one average.** Erlang A assumes exponentially distributed patience with one mean
per pool. Real patience varies by customer, by queue announcements and by how long people have
already waited. Measure it from the pool's abandonment data; until then it is `[estimated]`.
- **Abandoners are lost apart from the redial share.** Callers who give up and do not redial are
assumed to go elsewhere (another channel, a complaint, or nowhere). The model does not route
them to other pools.
- **Under Erlang C** (`service_model: C`) nobody abandons, and under overload service level falls
towards zero. Read troughs below about 0.3 as "the pool is overloaded" and use peak
utilisation to say by how much.
- **No service-to-churn feedback.** In reality a client whose service collapses is more likely
to exit. The fate probabilities here are fixed per draw. Adding feedback would deepen the
valley's tail.
- **Pools are independent.** There is no cross-pool overflow unless it is entered explicitly
as overflow FTE.
- **Headcount is fungible within a pool.** The model does not know which named person serves
which client. A dedicated team with one client should be its own pool.
- **The deferrable index is a proxy.** For contractual turnaround SLAs, measure backlog age
directly.
## Verification
Run the neutral case: training hours 0, absence surge 0, base attrition 0, AHT uplift 1.0 and
synergy realisation 1.0. Source service must stay at its week-0 level throughout, apart from
volume and handling-time noise, and under Erlang A the source must generate no redials (the
destination may show a few: a landed team sized for the synergy runs at slightly higher
abandonment than the source did at week 0). Every scenario runs on the same seed and the
attrition draws are inverted from fixed uniform blocks, so a lever changes only what it acts on:
freeze-period leavers and pre-announcement headcount are identical draw for draw across the
scenario table. On the demonstration
configuration the shared pool's P50 trough equals its baseline (0.99 → 0.99 under Erlang A,
0.98 → 0.98 under Erlang C). If it does not, something in the supply mechanics is leaking.
The simulator's Erlang A was checked during development against an independent implementation
(the WFM Labs Migration Scenario Modeler) to 1e-9, against its own direct series, and against
Erlang C in the patient limit; `service_model: C` was checked against version 1.0's
outputs (identical where later fixes don't apply: see the change history). A quick self-check you can run: set `patience_seconds` very large (say
10,000,000) and the Erlang A results must match `service_model: C`.
Block 3 — 03-levers-and-parameters.md
# 03 — Levers and parameters
*Figures marked `[estimated]` are judgment, not measurement. Every default below is
`[estimated]` unless the row says otherwise. Replace them with local evidence before a result
leaves the room.*
Uncertain inputs take a **PERT triple `[low, most likely, high]`**. A plain number fixes the
value. Volume and handling-time error take a **90% interval `[low, high]`** as a multiplier.
## The levers: what management can actually move
These are the knobs a scenario turns. Everything else is the world.
| Lever | Key | Default | What it represents | Direction |
|---|---|---|---|---|
| Freeze length | `freeze.end_week` | `[8, 14, 26]` | Weeks until the consultation closes and the program can be announced | Shorter moves the valley earlier; it is not always shallower |
| Wave spacing | `waves.<name>` | `W1: 6, W2: 12, W3: 18` | Weeks after freeze end at which each wave cuts over | Earlier waves shorten exposure but compress training |
| Training load | `transfer.training_hours` | `24` | Off-floor hours per transferee before cutover | The largest single lever in testing |
| Training window | `transfer.training_weeks` | `6` | Weeks before the wave over which training is spread; a wave closer to the announcement than this compresses the window (hours are kept) | Longer window, shallower dip |
| Retention effect | `attrition.retention_effect` | `0.0` | Fractional cut to release-group attrition from a stay bonus | Helps late waves; weaker than expected overall |
| Release lag | `release.lag_weeks` | `2` | Weeks staff are held after their work has gone | Buffer against slipped waves; costs money |
| Release buffer | `release.buffer` | `0.05` | Headroom kept above the computed need | As above |
| Overflow capacity | `pools.<p>.overflow_fte` | `0` | FTE lent from another site | Strongest service lever in testing |
| Overflow start | `pools.<p>.overflow_start_week` | `0` | When the overflow switches on | Before the announcement matters most |
| Overflow AHT penalty | `pools.<p>.overflow_aht_mult` | `1.0` | Borrowed staff are slower on unfamiliar work | Typical `1.1–1.3` `[estimated]` |
| Synergy | `transfer.synergy` | `0.25` | Share of transfer headcount not carried to the destination | Planning assumption |
| Destination top-up | `destination.top_up_to_plan` | `true` | Destination hires to plan rather than inheriting leakage | `false` = worst case |
## The world: what the scenario has to live with
### Timing
| Parameter | Key | Default | Notes |
|---|---|---|---|
| Horizon | `horizon_weeks` | `52` | Run past the last wave plus the ramp |
| Freeze start | `freeze.start_week` | `0` | Week 0 is "today" |
| Dismissal notice | `release.notice_weeks` | `6` | Weeks after freeze end before anyone can be released. Jurisdiction-specific: take it from counsel, not from here |
| Release allowed | `release.allowed` | `true` | `false` models a no-redundancy scenario |
| Replatform offset | `replatform.offset_weeks` | `[4, 8, 16]` | Weeks after freeze end at which re-platformed work leaves |
| Exit notice, evergreen | `exit.notice_weeks.evergreen` | `[8, 13, 26]` | Client notice after the announcement |
| Exit notice, with termination-for-convenience | `exit.notice_weeks.evergreen_tfc` | `[4, 6, 13]` | Shorter: the clause exists to be used |
### Attrition and absence
| Parameter | Key | Default | Notes |
|---|---|---|---|
| Base annual attrition | `attrition.base_annual` | `0.15` | Use the site's trailing-12-month voluntary rate. This one is measurable |
| Freeze multiplier | `attrition.freeze_multiplier` | `[1.0, 1.4, 2.2]` | Rumour effect before any announcement |
| Post-announcement, release group | `attrition.post_multiplier_release` | `[1.5, 3.0, 5.0]` | People with a leaving date look for the next job now |
| Post-announcement, transfer group | `attrition.post_multiplier_transfer` | `[0.8, 1.2, 2.0]` | Some decline the move; some are reassured |
| Absence surge | `shrinkage.post_announcement_add` | `[0.0, 0.04, 0.10]` | Points of shrinkage added after the announcement. Sickness absence commonly rises after redundancy announcements |
| Destination backfill | `destination.backfill` | `true` | The destination replaces its own leavers |
### Service model (interactive pools)
Set once under `service:` for every interactive pool; any pool can override a key with the
pool-level name in brackets.
| Parameter | Key | Default | Notes |
|---|---|---|---|
| Queueing model | `service.model` (`service_model`) | `A` | `A`: Erlang A, callers abandon. `C`: Erlang C, nobody abandons; the same model as version 1.0 |
| Mean patience | `service.patience_seconds` (`patience_seconds`) | `120` | How long a waiting caller stays before hanging up, on average. Chat customers are usually more patient (`300` is a common starting point). The input that most changes an Erlang A result: measure it from the pool's abandonment data |
| Redial rate | `service.redial_rate` (`redial_rate`) | `0.4` | Share of the *extra* abandonment (above the week-0 rate) that calls back the following week |
| Abandonment cap | `service.abandon_cap` (`abandon_cap`) | `0.10` | The abandon rate that counts as a breach in the KPIs (`p_abandon_over_cap`, `weeks_over_cap_p50`) |
All four are `[estimated]` until measured. Patience can be estimated from a pool's own data:
with abandon rate *ab* and average time in queue of all callers *W* (including the time
abandoned callers waited, not just the answered callers' ASA), mean patience ≈ *W* ÷ *ab*. This
is the Erlang A identity abandonment rate = queue length ÷ patience; use a month of interval data,
not one week. Deferrable pools
ignore these settings.
### Transition at the destination
| Parameter | Key | Default | Notes |
|---|---|---|---|
| Synergy realisation | `transfer.synergy_realisation` | `[0.4, 0.8, 1.0]` | Share of the assumed productivity gain actually achieved. `1.0` = the synergy is real |
| AHT uplift at landing | `transfer.aht_uplift` | `[1.03, 1.10, 1.25]` | New systems, new processes, new colleagues. The "GDS impact" question in a travel operation |
| Ramp | `transfer.ramp_weeks` | `12` | Weeks for the uplift to decay linearly to steady state |
| Acceptance | `transfer.acceptance` | `1.0` | Share of transferees who actually move; used only when top-up is off |
### Clients
Each client carries its own row (see `04-intake-schema.md`). The fields that drive fate and timing:
| Field | Values | Role |
|---|---|---|
| `pool` | a pool name | Which queue serves it |
| `annual_volume` | contacts (interactive) or transactions (deferrable) | Workload |
| `health` | `green`, `amber`, `red` | Selects the fate prior |
| `contract` | `fixed`, `evergreen`, `evergreen_tfc` | Selects the exit timing rule |
| `expiry_week` | integer or blank | For `fixed`; the week the contract ends |
| `wave` | a wave name or blank | Overrides the pool's default wave |
| `p_transfer`, `p_exit`, `p_replatform` | probabilities | Override the prior when account teams have a view |
**Fate priors by relationship health** `[estimated]`:
| Health | Transfer | Exit | Replatform |
|---|---|---|---|
| Green | 0.85 | 0.10 | 0.05 |
| Amber | 0.65 | 0.25 | 0.10 |
| Red | 0.35 | 0.55 | 0.10 |
These are the single most contestable numbers in the model. Elicit them from account owners
client by client wherever the client is large enough to matter. A calibrated 90% interval
beats a confident point.
### Pools
| Field | Interactive | Deferrable | Notes |
|---|---|---|---|
| `mode` | `interactive` | `deferrable` | Split a mixed team into two pools |
| `headcount` | ✓ | ✓ | Heads today, not FTE budget |
| `shrinkage` | ✓ | ✓ | Base, before any surge |
| `wave` | ✓ | ✓ | Default wave for the pool's clients |
| `service_floor` | ✓ | ✓ | The line that counts as a breach |
| `aht_seconds` | ✓ | — | Handle time |
| `asa_seconds` | ✓ | — | The service-level threshold, e.g. 60 for 80/60 |
| `tx_per_fte_year` | — | ✓ | Throughput per head at base shrinkage |
| `service_model`, `patience_seconds`, `redial_rate`, `abandon_cap` | ✓ | — | Optional per-pool overrides of the `service` block |
| `channel` | ✓ | — | `voice` (default) or `chat`; the shape-card exporter carries chat handle time and patience separately, and the importer's `--apply-aht` writes handle times by channel |
| `target_days` | — | ✓ | Turnaround target |
| `volume_error`, `aht_error` | ✓ | ✓ | 90% multiplier intervals |
### Calendar
| Key | Default | Notes |
|---|---|---|
| `calendar.paid_hours_week` | `37.5` | Per head |
| `calendar.open_hours_week` | `55` | Hours the queue is open |
| `calendar.interval_seconds` | `1800` | Erlang interval |
| `calendar.buckets.volume_share` | `[0.45, 0.40, 0.15]` | Peak, shoulder, off-peak share of volume |
| `calendar.buckets.hour_share` | `[0.30, 0.45, 0.25]` | The same buckets' share of open hours |
| `calendar.schedule_fit` | `0.85` | 1 = staff perfectly follow volume; 0 = flat staffing |
| `seasonality` | none | Optional weekly index, length ≥ horizon |
| `scoring.min_share` | `0.10` | Weeks where a pool's work is below this share of its baseline (source) or of its eventual landed work (destination) are not scored; the destination is also not scored until its team reaches max(1, ceil(min_share × eventual heads)). Note the cliff: just above the floor a small landed team is scored at full weight, and a small queue serves worse than its occupancy suggests (see 06). The KPI table's `scorable_week_p50` says when scoring starts |
## Shape card
`export_shape_card.py` writes the scenario without its scale for the WFM Labs Migration Scenario
Modeler: timing, rates, multipliers, handle times, ranges with their status, week-0 occupancy and
the workload mix, built from an allow-list so no client id, pool or wave name, volume or headcount
can leave. Format 2 (pack 1.2, the default) also carries the book as shares (contract-type and
health mix, the fate priors, exit-notice and re-platform ranges, waves as shares of transferring
work) and the transfer/release split, so the modeler derives the departure curve itself and draws
real staircases in its Monte Carlo; format 1 (`--format 1`) carries only the expected staircase.
`import_shape_card.py` brings answers recorded online back as overrides (see the kit's `SETUP.md`).
| Version | Change |
|---|---|
| 1.1 | Format 1: expected departure staircase, allow-list guard, `status:` block, importer |
| 1.2 | Format 2: the book and the people split travel as shares; importer maps priors, notice ranges, transfer-group attrition and retention back |
| 1.3 | Text guard on every free-text leaf (title, owner, note, question text: figures and any client/pool/wave name token refused, joined or partial); the same guard on incoming cards; per-pool patience and service model reach the card; the CLI reports fidelity against the freeze-fixed curve; importer writes confirmed answers as fixed values, re-bases the freeze on the card's start whatever the key order, validates `service.model`, and adds no `status:` block when nothing was recorded |
## Eliciting the uncertain ones
For every PERT triple, ask for the **low and high first** ("what would surprise you on the
downside, and on the upside?"), then the most likely value. Anchoring on the middle first
narrows the range and understates the tail, and the tail is the whole point of this model.
Record who gave each number and when. A parameter with no owner is a guess nobody will defend.
## Scenario recipes
```python
scenarios(cfg, {
"freeze_best": {"freeze.end_week": [6, 8, 12]},
"freeze_worst": {"freeze.end_week": [20, 26, 40]},
"train_after": {"transfer.training_hours": 8}, # most training after cutover
"train_spread": {"transfer.training_weeks": 10},
"stay_bonus": {"attrition.retention_effect": 0.4},
"overflow": {"pools.SHR.overflow_fte": 10, "pools.SHR.overflow_aht_mult": 1.2},
"synergy_doubt": {"transfer.synergy_realisation": [0.2, 0.5, 0.8]},
"no_top_up": {"destination.top_up_to_plan": False},
"erlang_c": {"service.model": "C"}, # nobody abandons
"impatient": {"service.patience_seconds": 60},
"patient": {"service.patience_seconds": 240},
})
```
Every scenario runs on the same seed, and attrition is inverted from fixed uniform blocks per
week and pool, so a lever changes only the draws it acts on: freeze-period leavers and the
headcount before the announcement are identical across rows, and the differences between rows
are caused by the lever, not by sampling noise. (Departure timing draws come from one stream per
client, so adding or re-classifying a client leaves the others' weeks unchanged.)
The same table can be produced from the command line with a YAML file of override sets,
`{name: {dotted.key: value, ...}}`, e.g. the recipes above with the Python dict written as YAML:
python mig_sl_sim.py config_resolved.yaml --scenarios scenarios.yaml
The base row runs at the same draws as the KPI table, so the two agree.
Block 6 — 06-interpretation.md
# 06 — Reading and presenting the output
*Figures marked `[estimated]` are judgment, not measurement.*
## The four things in every readout
1. **The trough.** The worst week's service, P50 and P10, and for interactive pools the
worst week's abandonment. P50 is the typical bad week; P10 is the bad week the program
should plan to survive.
2. **When, and for how long.** The trough week and the weeks below the floor, P50 and P90.
A deep two-week dip and a shallow twelve-week sag need different responses.
3. **The probability of breach.** The share of futures in which the pool drops below its floor
at least once, and for interactive pools the share in which abandonment passes its cap
(`abandon_cap`, default 10%). These are the numbers that belong in the risk register.
4. **The lever that moves it most,** and what that lever costs.
Report the **source and destination side by side**. Programs routinely protect one at the
expense of the other: holding staff at the source to protect its tail starves the destination
ramp, and moving early to the destination strips the source.
## The chart the decision is made on
The weekly fan chart per pool: source and destination on one axis, P10–P90 bands, P50 lines,
the freeze shaded and the floor drawn. It shows the **migration valley**: service holding
through most of the freeze, falling around the announcement and first waves, and recovering
as the destination ramps.
The destination line starts where scoring starts: a landed team below max(1, ceil(`scoring.min_share`
× its eventual size)) is an Erlang small-pool artefact, not a queue the program will run, so those
weeks are blank. The KPI table's `scorable_week_p50` names the week. Just above that floor a small
landed team is scored at full weight and reads worse than its occupancy suggests; a first wave that
lands only a handful of agents should be read with that in mind (or given a destination baseline
through `dest_extra_fte`).
Read three things off it:
- **Where the valley starts.** If it starts inside the freeze, attrition without backfill is
the problem, and the fix is capacity from outside the affected population (overflow from
another site, overtime elsewhere, deferring non-urgent work).
- **How deep it is at the waves.** If the valley's floor sits on the wave weeks, transition
work (training, knowledge transfer) is the problem, and the fix is timing.
- **Whether the bands cross.** Where the destination's P10 sits above the source's P50, moving
the next wave earlier improves service overall. Where it sits below, it does not.
## Reading abandonment (Erlang A)
Interactive pools default to **Erlang A**: callers who wait longer than their patience hang up.
Service level is the share of *all* offered calls answered within the threshold, so abandoned
calls count as misses and the measure is never flattered. Two consequences for reading a run:
- **Troughs are shallower than under Erlang C, and the gap shows as abandonment.** An
overloaded queue sheds callers instead of growing without limit. A pool that reads 0.85 with
10% abandonment is losing one caller in ten, which is a service failure even though the
service level looks respectable. Always quote the two together.
- **Some abandoners come back.** A share of the abandonment above today's rate (`redial_rate`,
default 40%) redials the following week and adds to that week's load. The column
`redials_p50` in the Weekly sheet shows how much.
| Peak abandonment | Reading `[estimated]` |
|---|---|
| below 5% | Normal for most voice operations |
| 5–10% | Visible to customers and to account teams; complaints rise |
| above the cap (default 10%) | The week counts as a breach of the abandonment limit |
| above twice the cap | Customers are being lost at a rate the program should escalate |
Patience (`patience_seconds`, default 120) is the input that most changes the Erlang A
reading. Measure it from the pool's own abandonment curve before quoting results. Setting
`service.model: C` reproduces the classic Erlang C view, in which nobody hangs up; run it as a
comparison when a stakeholder's plan was built on Erlang C.
## Reading utilisation
Peak utilisation is the honest measure of "how bad" once service level is near zero.
| Peak utilisation | Reading |
|---|---|
| below 0.85 | Tight but workable. Service moves with small staffing changes |
| 0.85–0.95 | Service is fragile: one bad week of absence breaches |
| 0.95–1.00 | The pool clears its work only by making customers wait. Queues grow within the day |
| above 1.00 | The pool cannot clear its work. Backlog or abandonment grows every week it lasts |
For deferrable pools, a timeliness index of 1.0 with utilisation above 1.0 means the backlog
is growing but has not yet crossed the target. Check the week it does.
## Saying it in a sentence
The figures are the worked example's (`07-worked-example.md`), generated from the run.
> In the base case the shared pool's worst week is typically **0.85** service level
> with **10% of callers abandoning**, around **week 24**.
> Abandonment passes our limit in **52%** of futures, and service breaches the floor
> in **4%**. The biggest lever is **training timing**. Adding **10 overflow FTE** before the
> announcement, spreading training over ten weeks and a stay bonus together lift the trough to
> **0.97** with **2%** abandonment. The largest open uncertainties are
> **how long the consultation runs** and **how long our callers will wait**.
Lead with the decision. The method goes in the appendix or in answer to a question.
## Handling the standard challenges
**"The plan already shows how many people we keep. Why do we need this?"** The plan shows the
end state. Service is lost in the weeks between, when attrition runs ahead of the work leaving.
Put the waterfall reconciliation next to the plan: the end states agree, and the weekly path
is what the plan cannot see.
**"Service level can't really go to zero."** It doesn't, and under the default Erlang A model it
doesn't in the output either: callers abandon, and the trough shows as abandonment. If the run
uses Erlang C (`service.model: C`), which assumes nobody hangs up, a trough near zero means the
pool is overloaded, and peak utilisation says by how much. Quote it: "the worst week needs 118%
of the capacity we'll have".
**"Abandonment is just customers being impatient."** Abandonment is lost demand, and some of it
comes back the next week as redials on a pool that is already short. Under the default settings
the pool is judged against an abandonment limit as well as its service floor, for that reason.
**"These client probabilities are made up."** The priors are, and the gap register shows how
much volume they cover. That is the case for asking account owners for a view on the largest
clients. Run the scenario with their view against the prior to show how much the answer
depends on it.
**"We don't know how long the freeze will last."** Nobody does, which is why it is a range. The
freeze-length scenarios answer the useful question: what should be arranged *now* that pays
off whatever the length? Contingency capacity usually does. Retention money usually does not
until the announcement.
**"Just tell me how many people to keep."** Give the number with its probability: "Keeping N
more heads through the last wave takes the breach probability from X% to Y%." A retention
number without its probability is a promise the program cannot keep.
**"The synergy is in the business case."** Run the synergy-doubt scenario and show the
destination band. A synergy delivered at the same occupancy in a smaller team still costs
service, because smaller queues absorb variance worse. If the destination trough breaches,
the business case is assuming the synergy and the service level at the same time.
## What to arrange during a freeze
A consultation period constrains what can be decided and announced about affected staff. It
does not in itself prevent contingency planning.
The first three are in the order of leverage they showed in testing; the fourth is not
modelled:
1. **Contingency capacity** from another site, contracted but not yet activated.
2. **A training plan that puts most hours after cutover,** or spreads them.
3. **A retention offer** ready to issue at the announcement, aimed at the transfer group and at
staff whose work leaves in the last wave.
4. **Client conversations scheduled for the day after the announcement,** starting with the
clients whose contracts give them the shortest notice.
Local employment law and the consultation itself govern what may be done in a freeze. Check
each preparation with the people running the consultation before acting on it.
Block 7 — 07-worked-example.md
# 07 — Worked example
*Everything in this module is synthetic. The tables are generated by `worked_example.py` from a
2,000-draw run of the code in modules 04 and 05, so they match what the code produces.
Real inputs will move every number.*
## The situation
A service operation is leaving a site. 135 frontline agents in three teams serve 56 clients:
- **Dedicated:** four large clients, each with its own team.
- **Shared:** forty mid-size clients served from a common pool.
- **Back office:** twelve clients whose work is transactions with a two-day turnaround, not calls.
Most clients will transfer to a destination site in three waves. Some will leave at their
contract dates, and a few will move to another internal platform. A consultation freeze means
nothing can be announced, moved or backfilled until it closes. It is expected to close in
about 14 weeks, possibly as soon as 8 or as late as 26.
The program office has a workbook: a client tab, a staffing tab and an end-state waterfall.
The question from leadership is the one this pack exists for: **how bad does service get
before we're out, and what can we do about it?**
## Step 1 — Read the workbook as it arrives
The client tab, first six rows:
| Account | Team Type | Txn YTD | Adoption | Health | Contract | Expiry | FTE |
|---|---|---|---|---|---|---|---|
| DED-01 | Dedicated | 29292 | 0% | Amber | Contract with expiration date | 2028-05-01 | 10.7 |
| DED-02 | Dedicated | 32994 | 0% | Amber | Evergreen contract (inc termination for convenience clause) | | 12.1 |
| DED-03 | Dedicated | 52474 | 50% | Green | Contract with expiration date | 2026-10-05 | 9.6 |
| DED-04 | Dedicated | 25629 | 20% | Green | Contract with expiration date | 2028-06-19 | 7.5 |
| SHR-01 | Shared | 7408 | 35% | Amber | Contract with expiration date | 2027-07-12 | 1.6 |
| SHR-02 | Shared | 14219 | 35% | Red | Contract with expiration date | 2027-06-07 | 3.0 |
Nothing here is in model units yet. Volume is year-to-date transactions, adoption is a
percentage string, health and contract are free text, and FTE is an allocation, not a roster.
The staffing tab:
| Team | Team Type | Role | Location | Heads |
|---|---|---|---|---|
| T-DED | Dedicated | Agent | Site 1 | 40 |
| T-SHR | Shared | Agent | Site 1 | 45 |
| T-SHR | Shared | Agent | Site 2 | 25 |
| T-BO | Back office | Agent | Site 1 | 25 |
| T-SHR | Shared | Team leader | Site 1 | 7 |
| T-DED | Dedicated | Team leader | Site 1 | 4 |
| Support | | Support | Site 1 | 12 |
Two things to notice before mapping anything. **Team leaders and support staff** are on the
same tab as agents, so a naive sum would count 158 heads of capacity instead of 135. And the
shared team is **split across two sites**. That is fine as long as neither row is a copy of
the other.
## Step 2 — Map and resolve
The `params.yaml` in `04-intake-schema.md` maps these headers onto the canonical columns:
`Account → client_id`, `Team Type → team_type`, `Txn YTD → volume_ytd` (five months) and so
on. It also maps team types to pools and names `Agent` as the only frontline role. Resolving
with `--no-run` produces:
- annualised volume_ytd x 12/5
- split 1 dedicated client(s) out of DED into their own pools
**Headcount reconciliation.** The roster's frontline heads per pool agree with the client-tab
FTE to within rounding, so the two tabs describe the same workforce:
| pool | headcount_used | source | roster_frontline | client_fte_sum | roster_vs_clients |
|---|---|---|---|---|---|
| DED | 27.9 | roster | 27.9 | 27.8 | 0.0 |
| SHR | 70.0 | roster | 70.0 | 70.3 | -0.0 |
| BO | 25.0 | roster | 25.0 | 24.9 | 0.0 |
| DED-DED-02 | 12.1 | client FTE (carved) | | 12.1 | |
The overhead roles are reported, not simulated:
| role | heads |
|---|---|
| Support | 12 |
| Team leader | 11 |
**Baseline utilisation** sits inside 0.50–0.95 for every pool, so the units are plausible:
| pool | mode | headcount | clients | weekly_volume | utilisation |
|---|---|---|---|---|---|
| DED | interactive | 27.90 | 3 | 3509.18 | 0.74 |
| SHR | interactive | 70.00 | 40 | 9906.75 | 0.74 |
| BO | deferrable | 25.00 | 12 | 1861.46 | 0.88 |
| DED-DED-02 | interactive | 12.10 | 1 | 1522.80 | 0.74 |
The largest dedicated client (12.1 FTE) was carved into its own pool by `split_dedicated`.
**Its week-0 service level is about 0.84, and about 11% of its callers
already give up before they are answered, above the 10% abandonment cap.** In the combined
dedicated pool it was invisible: the pooled team looked healthy at the same 74% utilisation.
That finding is worth reporting on its own. The migration will be blamed for a risk that
exists today.
## Step 3 — The gap register on real-world mess
The demonstration files are clean. Real ones are not. With a few typical faults introduced
(two blank health ratings, a "Framework agreement" contract type the rules do not recognise,
a fixed-term contract that expired before week 0, two blank adoption rates), the gap register
reads:
| field | rule_applied | clients | volume_share | examples |
|---|---|---|---|---|
| adoption | blank -> 0 (all volume assisted) | 2 | 0.053 | DED-04, SHR-17 |
| health | blank or unrecognised -> amber | 2 | 0.061 | DED-01, SHR-04 |
| contract_type | blank or unrecognised -> evergreen | 3 | 0.075 | DED-02, SHR-09, SHR-27 |
| expiry_date | expired before start_date -> evergreen (rolling) | 1 | 0.049 | DED-03 |
No single gap touches more than about 8% of volume, so the run can proceed. The contract-type
gap is the one to close first: it decides when those clients can leave.
## Step 4 — Run the base case
The freeze ends at P10 week 11, P50 week 15, P90 week 20. Interactive pools use the default service model, **Erlang A**:
waiting callers give up after about 120 seconds on average `[estimated]`, service level is the
share of *all* calls answered within the threshold, and 40% of the extra abandonment redials the
following week `[estimated]`.
| pool | side | week0_p50 | trough_p50 | trough_p10 | trough_week_p50 | p_breach | weeks_below_p50 | peak_util_p50 | abandon_peak_p50 | p_abandon_over_cap |
|---|---|---|---|---|---|---|---|---|---|---|
| DED | source | 0.94 | 0.74 | 0.62 | 20.00 | 0.34 | 0.00 | 1.03 | 0.18 | 0.88 |
| DED | destination | | 0.80 | 0.72 | 22.00 | 0.06 | 0.00 | 0.91 | 0.14 | 0.87 |
| SHR | source | 0.99 | 0.85 | 0.75 | 24.00 | 0.04 | 0.00 | 0.98 | 0.10 | 0.52 |
| SHR | destination | | 0.90 | 0.83 | 28.00 | 0.00 | 0.00 | 0.89 | 0.07 | 0.20 |
| BO | source | 1.00 | 0.48 | 0.13 | 30.00 | 0.71 | 3.00 | 1.17 | | |
| BO | destination | | 1.00 | 0.31 | 35.00 | 0.28 | 0.00 | 1.05 | | |
| DED-DED-02 | source | 0.84 | 0.66 | 0.53 | 20.00 | 0.72 | 6.00 | 1.02 | 0.26 | 1.00 |
| DED-DED-02 | destination | | 0.72 | 0.66 | 22.00 | 0.35 | 0.00 | 0.90 | 0.21 | 1.00 |
Read it pool by pool:
- **Shared (SHR):** starts at 0.99, and its worst week is typically around week
24, inside the training window before its wave (which cuts over about twelve
weeks after the freeze ends). Service holds above the 0.70 floor in most futures because callers
who give up relieve the queue. The damage shows as **abandonment instead**: about
10% of callers hang up in the worst week, and abandonment passes the 10% cap in
52% of futures.
- **Dedicated (DED)** and **the carved client:** they break earlier, because their wave is
first. They lose roughly a fifth to a quarter of their callers in the worst week, and the carved client
is above the abandonment cap for most of the horizon because it started there.
- **Back office (BO):** a deferrable pool, so abandonment does not apply. The timeliness index
degrades more slowly, but its peak utilisation is above 1.0, so its backlog grows for several
weeks and has to be worked off.
- **Destination:** the dedicated pools stay above the abandonment cap for much of their time
there. They are small and inherit the synergy cut, and the ramp takes weeks. The shared
destination recovers within a few weeks.
**The same run under Erlang C**, which assumes nobody hangs up, puts the shared pool's trough at
**0.27** and breaches the floor in **88%** of futures (the second row of the
table in step 6). The overload is the same. Erlang C reports it as queues that never clear;
Erlang A reports it as customers lost. Which reading is closer depends on how patient these
callers really are, which is why patience is a parameter to measure, not a default to keep.
## Step 5 — Reconcile to the program's waterfall
The simulated mean headcount flows:
| pool | start | attrition_freeze | attrition_post | transferred | released | remaining | attrition_p10 | attrition_p90 |
|---|---|---|---|---|---|---|---|---|
| DED | 27.9 | 1.7 | 1.1 | 17.8 | 7.3 | 0.0 | 1.0 | 5.0 |
| SHR | 70.0 | 4.3 | 4.8 | 40.5 | 20.4 | 0.0 | 5.0 | 13.0 |
| BO | 25.0 | 1.5 | 2.3 | 13.5 | 7.8 | 0.0 | 1.0 | 6.0 |
| DED-DED-02 | 12.1 | 0.7 | 0.5 | 7.0 | 3.9 | 0.0 | 0.0 | 3.0 |
| TOTAL | 135.0 | 8.3 | 8.6 | 78.8 | 39.3 | 0.0 | | |
Against the program's end-state plan of 80 transferred, 40 released and 15 attrition:
| pool | flow | plan | simulated_mean | difference |
|---|---|---|---|---|
| TOTAL | transferred | 80 | 78.8 | -1.2 |
| TOTAL | released | 40 | 39.3 | -0.7 |
| TOTAL | attrition | 15 | 16.9 | 1.9 |
The end state agrees with the plan to within two heads. **The plan is not wrong about where
the program ends up. It says nothing about the weeks in between,** and the valley in step 4 is
invisible in it. The service model does not change the headcount flows: Erlang A and Erlang C
only read the same staffing differently. Attrition is the flow with the widest spread: the P10–P90 range for the shared
pool alone runs from 5 to 13 heads.
## Step 6 — Pull the levers
Every scenario runs on the same seed.
| Scenario | SHR trough P50 | SHR P(breach) | SHR peak abandon P50 | Carved client trough P50 | BO trough P50 | SHR destination trough P50 |
|---|---|---|---|---|---|---|
| Base case | 0.85 | 0.04 | 0.10 | 0.66 | 0.48 | 0.90 |
| Erlang C (nobody abandons) | 0.27 | 0.88 | 0.00 | 0.14 | 0.48 | 0.62 |
| Train after cutover (8 h before) | 0.92 | 0.00 | 0.06 | 0.72 | 1.00 | 0.90 |
| Spread training over 10 weeks | 0.89 | 0.01 | 0.08 | 0.66 | 0.55 | 0.90 |
| Stay bonus (−40% release attrition) | 0.86 | 0.03 | 0.10 | 0.66 | 0.53 | 0.90 |
| Overflow: 10 FTE to SHR, 4 to the carved pool | 0.96 | 0.00 | 0.03 | 0.85 | 0.48 | 0.90 |
| Freeze best case (6–12 weeks) | 0.87 | 0.02 | 0.09 | 0.67 | 0.58 | 0.90 |
| Freeze worst case (20–40 weeks) | 0.82 | 0.10 | 0.12 | 0.62 | 0.44 | 0.90 |
| Synergy doubted (realisation 0.2–0.8) | 0.85 | 0.04 | 0.10 | 0.66 | 0.48 | 0.84 |
| Package: spread training + overflow + stay bonus | 0.97 | 0.00 | 0.02 | 0.85 | 0.60 | 0.90 |
What the table says:
- **The service model is itself the largest assumption.** Switching to Erlang C turns a trough
of about 0.85 with 10% abandonment into one of 0.27.
Both describe the same shortage. Report the abandonment, and say which model the numbers use.
- **Training timing is the biggest single lever** on the source side. Moving most training
after cutover lifts the shared pool's worst week, cuts its peak abandonment and lifts back-office timeliness to near target.
- **Overflow capacity is the strongest lever overall,** and it is the only one that rescues the
carved client.
- **The stay bonus barely moves the source trough.** Release-group attrition matters less than
it feels like it should, because those staff are released when their work leaves anyway.
- **Freeze length moves the valley more than it fills it.** The worst-case freeze deepens the
trough, and the best case improves it only modestly.
- **Doubting the synergy does not touch the source,** but it lowers the shared destination's
trough and raises its abandonment. That is the business-case risk, and it sits on the other
side of the cutover.
- **The package** of spread training, overflow capacity and a stay bonus nearly removes the
valley on the source side and brings abandonment close to its week-0 level.
## Step 7 — The readout
> In the base case the shared pool's worst week is typically **0.85**
> service level with **10% of callers abandoning**, around **week
> 24**. Abandonment passes our 10% limit in **52%** of futures,
> and service breaches the floor in **4%**. The package of overflow capacity,
> spread training and a stay bonus lifts the trough to **0.97** with
> **2%** abandonment. Two things need decisions now, during the freeze:
> **contracting the overflow capacity** and **redesigning the training plan**. One risk
> predates the program: our largest dedicated client already loses more than one caller in ten
> today. These figures assume callers wait about two minutes before giving up; that assumption
> should be measured before the numbers are used.
Hand back `migration_sl_results.xlsx` and `intake_report.xlsx` with the readout. They are the
artifacts that travel.
## Reproducing this
```
python mig_intake.py --demo
```
This writes the synthetic workbook and roster, resolves them, runs the base case and saves
both Excel files. `worked_example.py` (below; keep it beside the other two files) adds the messy-input gap
register and the scenario table exactly as they appear above.
## `worked_example.py`
```python
"""Produce every table in 07-worked-example.md from a real run. Run by build-modules.py."""
from __future__ import annotations
import os
import tempfile
import numpy as np
import pandas as pd
import yaml
import mig_intake as mi
import mig_sl_sim as sim
DRAWS = 2000
def md(df, floatfmt=2):
df = df.copy()
for c in df.columns:
if pd.api.types.is_float_dtype(df[c]):
df[c] = df[c].map(lambda v: "" if pd.isna(v) else f"{v:.{floatfmt}f}")
else:
df[c] = df[c].map(lambda v: "" if (v is None or (isinstance(v, float) and np.isnan(v))) else v)
head = "| " + " | ".join(map(str, df.columns)) + " |"
rule = "|" + "|".join("---" for _ in df.columns) + "|"
body = ["| " + " | ".join(map(str, r)) + " |" for r in df.itertuples(index=False)]
return "\n".join([head, rule, *body])
def tables():
here = os.getcwd()
with tempfile.TemporaryDirectory() as tmp:
os.chdir(tmp)
try:
cpath, rpath, ppath = mi.write_demo_inputs(".")
clients = pd.read_excel(cpath)
roster = pd.read_excel(rpath)
with open(ppath) as f:
params = yaml.safe_load(f)
finally:
os.chdir(here)
t = {}
head = clients.head(6).copy()
head["Expiry"] = pd.to_datetime(head["Expiry"]).dt.strftime("%Y-%m-%d").fillna("")
t["CLIENTS_HEAD"] = md(head, 1)
t["ROSTER"] = md(roster.fillna(""))
# a realistically messy copy for the gap register
messy = clients.copy()
messy.loc[[0, 7], "Health"] = None
messy.loc[[1, 12, 30], "Contract"] = "Framework agreement"
messy.loc[2, "Expiry"] = "2025-06-30"
messy.loc[[3, 20], "Adoption"] = None
_, rep_messy = mi.build_config(messy, roster, params)
t["GAPS"] = md(rep_messy.gaps.drop(columns="severity"), 3)
cfg, rep = mi.build_config(clients, roster, params)
t["HEADCOUNT"] = md(rep.reconciliation.drop(columns=["flag"]).fillna(np.nan), 1)
t["OVERHEAD"] = md(rep.overhead, 0)
t["BASELINE"] = md(rep.baseline.drop(columns=["flag"]), 2)
t["NOTES"] = "\n".join(f"- {n}" for n in rep.notes)
res = sim.run(cfg, DRAWS)
k = sim.kpis(res)
t["KPIS"] = md(k[["pool", "side", "week0_p50", "trough_p50", "trough_p10", "trough_week_p50",
"p_breach", "weeks_below_p50", "peak_util_p50", "abandon_peak_p50", "p_abandon_over_cap"]], 2)
t["WATERFALL"] = md(sim.waterfall(res), 1)
t["PLAN"] = md(mi.compare_to_plan(res, params["intake"]["plan_waterfall"]), 1)
fe = np.percentile(res.freeze_end, [10, 50, 90])
t["FREEZE"] = f"P10 week {fe[0]:.0f}, P50 week {fe[1]:.0f}, P90 week {fe[2]:.0f}"
variants = {
"Erlang C (nobody abandons)": {"service.model": "C"},
"Train after cutover (8 h before)": {"transfer.training_hours": 8},
"Spread training over 10 weeks": {"transfer.training_weeks": 10},
"Stay bonus (−40% release attrition)": {"attrition.retention_effect": 0.4},
"Overflow: 10 FTE to SHR, 4 to the carved pool": {"pools.SHR.overflow_fte": 10, "pools.SHR.overflow_aht_mult": 1.2,
"pools.DED-DED-02.overflow_fte": 4, "pools.DED-DED-02.overflow_aht_mult": 1.2},
"Freeze best case (6–12 weeks)": {"freeze.end_week": [6, 8, 12]},
"Freeze worst case (20–40 weeks)": {"freeze.end_week": [20, 26, 40]},
"Synergy doubted (realisation 0.2–0.8)": {"transfer.synergy_realisation": [0.2, 0.5, 0.8]},
"Package: spread training + overflow + stay bonus": {
"transfer.training_weeks": 10, "attrition.retention_effect": 0.4,
"pools.SHR.overflow_fte": 10, "pools.SHR.overflow_aht_mult": 1.2,
"pools.DED-DED-02.overflow_fte": 4, "pools.DED-DED-02.overflow_aht_mult": 1.2},
}
sc = sim.scenarios(cfg, variants, draws=DRAWS)
def pick(pool, side, col):
return sc[(sc.pool == pool) & (sc.side == side)].set_index("scenario")[col]
order = ["base", *variants]
out = pd.DataFrame({
"Scenario": ["Base case", *variants],
"SHR trough P50": pick("SHR", "source", "trough_p50").reindex(order).values,
"SHR P(breach)": pick("SHR", "source", "p_breach").reindex(order).values,
"SHR peak abandon P50": pick("SHR", "source", "abandon_peak_p50").reindex(order).values,
"Carved client trough P50": pick("DED-DED-02", "source", "trough_p50").reindex(order).values,
"BO trough P50": pick("BO", "source", "trough_p50").reindex(order).values,
"SHR destination trough P50": pick("SHR", "destination", "trough_p50").reindex(order).values,
})
t["SCENARIOS"] = md(out, 2)
base = k.set_index(["pool", "side"])
t["SENTENCE_SHR_TROUGH"] = f"{base.at[('SHR', 'source'), 'trough_p50']:.2f}"
t["SENTENCE_SHR_BREACH"] = f"{base.at[('SHR', 'source'), 'p_breach']:.0%}"
t["SENTENCE_SHR_WEEKS"] = f"{base.at[('SHR', 'source'), 'weeks_below_p50']:.0f}"
t["SENTENCE_SHR_WEEK"] = f"{base.at[('SHR', 'source'), 'trough_week_p50']:.0f}"
t["SENTENCE_SHR_ABANDON"] = f"{base.at[('SHR', 'source'), 'abandon_peak_p50']:.0%}"
t["SENTENCE_SHR_CAP"] = f"{base.at[('SHR', 'source'), 'p_abandon_over_cap']:.0%}"
carved = ("DED-DED-02", "source")
t["CARVED_WEEK0"] = f"{base.at[carved, 'week0_p50']:.2f}"
w0 = sim.weekly(res)
t["CARVED_WEEK0_ABANDON"] = f"{w0[(w0.pool == 'DED-DED-02') & (w0.side == 'source') & (w0.week == 0)].abandon_p50.iloc[0]:.0%}"
t["SHR_WEEK0"] = f"{base.at[('SHR', 'source'), 'week0_p50']:.2f}"
pk = out.set_index("Scenario")
t["SENTENCE_PKG_TROUGH"] = f"{pk.at['Package: spread training + overflow + stay bonus', 'SHR trough P50']:.2f}"
t["SENTENCE_PKG_BREACH"] = f"{pk.at['Package: spread training + overflow + stay bonus', 'SHR P(breach)']:.0%}"
t["SENTENCE_PKG_ABANDON"] = f"{pk.at['Package: spread training + overflow + stay bonus', 'SHR peak abandon P50']:.0%}"
t["C_SHR_TROUGH"] = f"{pk.at['Erlang C (nobody abandons)', 'SHR trough P50']:.2f}"
t["C_SHR_BREACH"] = f"{pk.at['Erlang C (nobody abandons)', 'SHR P(breach)']:.0%}"
t["DRAWS"] = f"{DRAWS:,}"
return t
if __name__ == "__main__":
for k, v in tables().items():
print(f"== {k}\n{v}\n")
```
Blocks carrying the model code
- Block 4 —
04-intake-schema.md - Block 5 —
05-simulator.md - Block 8 —
export_shape_card.py(optional: shape cards for the online modeler) - Block 9 —
import_shape_card.py(optional: shape cards for the online modeler)
These blocks are the executable part of the pack and are carried on Wiki:Packs/Migration Service-Level Simulation/Modules, which keeps this page a readable length and each block inside the size at which the wiki's syntax highlighting is known to render. Save them under the filenames in their headings, in the same folder, and upload them with the blocks above.
Usage notes
Sizing. Blocks 4 and 5 carry the working Python (the intake layer and the simulator) and sit on the companion page Wiki:Packs/Migration Service-Level Simulation/Modules. Block 7 carries a short script that regenerates its own tables. Everything else is prose. The code needs numpy, pandas, scipy and openpyxl, all present in Claude's analysis environment. Every run writes an Excel workbook, which is the artifact colleagues without the project will open.
The model's own limits are stated in the blocks and should not be edited out. Interactive pools default to Erlang A, in which one average patience per pool decides how much of a shortage shows as abandonment rather than waiting; patience is an estimate until measured. Erlang C remains available for comparison; it assumes no abandonment, so its troughs under overload are overstated. Client fates do not respond to service. Pools are independent unless overflow is entered. The deferrable-work index is a proxy for turnaround, not a measured SLA.
Synthetic data. Every figure in Blocks 2, 6 and 7 comes from a synthetic configuration generated by the code itself. The figures are properties of the method, not benchmarks.
Drift. The 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-26 | Initial publication. |
| 1.1 | 2026-09-26 | Interactive pools default to Erlang A (exact M/M/N+M): caller patience, abandonment, redials of extra abandonment and an abandonment cap in the KPIs. Erlang C kept as service_model: C. Blocks 1, 2, 3, 5, 6 and 7 updated; worked example re-run.
|
| 1.2 | 2026-09-27 | Audit fixes: common random numbers hold inside the loop (levers no longer reshuffle attrition), destination scored only once it has a minimum team, training hours never dropped, re-platforming capped at the wave, input validation and intake gap rows. Shape-card export and import for the Migration Scenario Modeler (book of business and team split carried as shares). Worked example re-run; headline figures move by at most 0.02. |
}
See also
- Wiki:Packs — the pack index
- Migration Scenario Modeler — the interactive browser version
- Service Level During Work Migration — the concepts this pack implements
- Migration Archetypes — classifying the migration before modelling it
- The Migration Health Pack — the weekly governance surface a model like this reports through
- Migrating a Book of Business — the runbook for moving one book
- Wiki:Packs/Probabilistic Staffing — steady-state staffing as a distribution
- Erlang Sensitivity and the Staffing Cliff — why small staffing losses cost so much service
- Pooling Architecture in Service Workforces — why smaller pools serve worse at the same occupancy
- Scenario Planning and Contingency Staffing — the contingency capacity the scenarios test
