82 days, 37% likely: what a Monte Carlo simulation saw that the critical path didn't
Ledger's critical path said go-live in 82 working days. Twenty thousand simulated projects said that date had a 37% chance. The model, the 50 lines of Python that reproduce it exactly, and how the team turned a probability into a commitment.
On 18 June 2027, before baselining the schedule, Priya Raman did something most project managers never do. She took the plan everyone had agreed (17 activities, a critical path of 82 working days, go-live on Monday 4 October) and ran it 20,000 times.
The answer was uncomfortable. The plan's own date came true in only 37% of the runs. The median outcome was a day later. One run in five finished after 8 October. And the activity that looked safest on the network diagram turned out to be the second-biggest threat to the date.
This article walks through that analysis from the book's Ledger case, including the code. Every number below comes out of a script you can run on a laptop in under a second. That's deliberate: in week 7 every Gantt Guild student writes this simulation, checks it, and defends what it means.
Start with honest estimates
A single-number estimate hides the most important thing about it: how wrong it could be. Three-point estimating asks the activity owner for three numbers instead: O, the best realistic case; M, the most likely; and P, the worst realistic case (not the apocalypse). The PERT formula weights the most likely value: E = (O + 4M + P) ÷ 6, with a standard deviation of roughly σ = (P − O) ÷ 6.
| ID | Activity | O | M | P | PERT | σ | Float |
|---|---|---|---|---|---|---|---|
| A | Kickoff and mobilization | 2 | 2 | 2 | 2.0 | 0.00 | 0 |
| B | Solution design | 15 | 19 | 29 | 20.0 | 2.33 | 0 |
| C | Integration design | 11 | 14 | 23 | 15.0 | 2.00 | 5 |
| D | Policy-to-rules mapping (working group) | 28 | 33 | 50 | 35.0 | 3.67 | 5 |
| E | Configuration sprints 1–2 | 16 | 20 | 24 | 20.0 | 1.33 | 0 |
| F | Configuration sprints 3–4 | 12 | 14 | 22 | 15.0 | 1.67 | 0 |
| G | Workday and NetSuite integrations | 22 | 29 | 42 | 30.0 | 3.33 | 10 |
| H | Okta SSO/SCIM | 10 | 13 | 26 | 14.7 | 2.67 | 25 |
| I | Card feeds (22 programs) | 25 | 32 | 58 | 35.2 | 5.50 | 5 |
| L | System integration test (SIT) | 8 | 9 | 16 | 10.0 | 1.33 | 0 |
| N | User acceptance test (UAT) | 8 | 10 | 12 | 10.0 | 0.67 | 0 |
| P–Q | Cutover rehearsal, go/no-go, cutover | 4 | 5 | 6 | 5.0 | 0.33 | 0 |
Six of the seventeen activities shown. Critical path A–B–E–F–L–N–P–Q = 82 working days from 9 June. Path σ ≈ 3.5 days.
Read the table for risk, not just time. Card feeds (I) have by far the widest range, because they depend on 22 external card providers, each with its own file format and its own idea of urgency. Fixed-duration items like the kickoff have no variance at all: they're scheduled events.
The textbook move is to add up the critical path's variances. With a mean of 82 days and σ ≈ 3.5, a normal approximation says there's about a 50% chance of finishing within 82 days, 84% within 85.5 and 95% within 88. The book is blunt about why that's not good enough: it only looks at the critical path, so near-critical paths with wide ranges are ignored; and real activities aren't independent. A slow vendor slows several things at once.
Twenty thousand projects
Monte Carlo simulation handles what the formula can't. It runs the whole network thousands of times. In each run, every activity's duration is drawn at random from its own distribution, every merge point waits for its latest input, and the finish date is recorded. The result isn't a date. It's a distribution of dates.
import numpy as np
# Ledger's 17 activities: (optimistic, most likely, pessimistic) working days, and predecessors
ACTIVITIES = {
"A": ((2, 2, 2), []), # Kickoff and mobilization
"B": ((15, 19, 29), ["A"]), # Solution design
"C": ((11, 14, 23), ["A"]), # Integration design
"D": ((28, 33, 50), ["A"]), # Policy-to-rules mapping
"E": ((16, 20, 24), ["B"]), # Configuration sprints 1-2
"F": ((12, 14, 22), ["E", "D"]), # Configuration sprints 3-4
"G": ((22, 29, 42), ["C"]), # Workday and NetSuite integrations
"H": ((10, 13, 26), ["C"]), # Okta SSO/SCIM
"I": ((25, 32, 58), ["C"]), # Card feeds (22 programs)
"J": ((10, 14, 24), ["B"]), # Data migration trial load
"K": ((11, 15, 19), ["B"]), # Test case preparation
"L": ((8, 9, 16), ["F", "G", "H", "I", "K"]), # System integration test (SIT)
"M": ((5, 7, 15), ["J"]), # Migration rehearsal
"N": ((8, 10, 12), ["L", "M"]), # User acceptance test
"O": ((11, 15, 19), ["F"]), # Training and enablement
"P": ((2, 3, 4), ["N", "O"]), # Cutover rehearsal, go/no-go
"Q": ((2, 2, 2), ["P"]), # Cutover and go-live
}
RUNS = 20_000
rng = np.random.default_rng(7)
def beta_pert(o, m, p):
"""RUNS samples from a beta-PERT distribution."""
if p == o:
return np.full(RUNS, float(o))
a = 1 + 4 * (m - o) / (p - o)
b = 1 + 4 * (p - m) / (p - o)
return o + rng.beta(a, b, RUNS) * (p - o)
finish = {}
for act, (estimate, preds) in ACTIVITIES.items(): # listed in dependency order
start = np.max([finish[p] for p in preds], axis=0) if preds else 0
finish[act] = start + beta_pert(*estimate)
go_live = finish["Q"]
print(f"P(<= 82 days) {np.mean(go_live <= 82):.0%}")
for q in (10, 50, 80, 90, 95):
print(f"P{q:<3} {np.percentile(go_live, q):.1f} days")
print(f"P(<= 95 days) {np.mean(go_live <= 95):.1%}")
# Criticality: which input finished last, and so set SIT's start?
inputs = ACTIVITIES["L"][1]
driver = np.argmax([finish[p] for p in inputs], axis=0)
for i, p in enumerate(inputs):
print(f"SIT start set by {p}: {np.mean(driver == i):.0%}")P(<= 82 days) 37%
P10 78.6 days
P50 83.2 days
P80 86.6 days
P90 88.5 days
P95 90.2 days
P(<= 95 days) 99.5%
SIT start set by F: 76%
SIT start set by G: 2%
SIT start set by H: 0%
SIT start set by I: 22%
SIT start set by K: 0%Those are the book's published numbers, reproduced exactly: same network, same estimates, same distribution. Translated into calendar dates, here's what Priya put in front of the sponsor:
| Measure | Working days from 9 Jun | Calendar date |
|---|---|---|
| Deterministic critical path | 82 | Mon 4 Oct |
| P10 | 78.6 | 28 Sep |
| P50 (median) | 83.2 | 5 Oct |
| P80 | 86.6 | 8 Oct |
| P90 | 88.5 | 12 Oct |
| P95 | 90.2 | 13 Oct |
| Probability of meeting 82 days | 37% | |
| Probability of meeting the latest acceptable date (95 days) | 99.5% | 22 Oct |
Interpretation: 4 October is a target, not a commitment. Commit externally to “4 October, with a fallback to 18 October”. Watch card feeds and the policy working group as closely as the critical path.
Why the plan was optimistic by construction
Every activity in the plan used its PERT mean, the statistically expected value. So why did the project finish late in 63% of the runs?
The answer is merge bias. SIT can't start until configuration, three integrations, the card feeds and the test cases are all done. Its start is set by whichever finishes last. If any one of five paths runs long, SIT starts late; for SIT to start on time, all five have to behave at once. Averages don't survive a max(). Real schedules are full of merge points, which is why projects that hit every activity estimate “on average” still finish late.
The path nobody was watching
The last lines of the output are the most useful. In each run, the script records which input finished last at the SIT merge point. Configuration (F) set SIT's start in 76% of runs, as you'd expect from the critical path. But card feeds (I) set it in 22% of runs, despite five days of float. A separate check found policy mapping (D) driving configuration sprints 3–4 in 16% of runs.
On the network diagram, card feeds looked comfortable. In the simulation, they were the second most likely reason for a late go-live. That changed what Priya watched every week: not just the critical path, but the card-feed providers and the policy working group.
Deciding under uncertainty: the SCIM failure
In August, uncertainty stopped being theoretical. On 11 August, the expense vendor's Okta connector failed its user-provisioning tests (the assumption the team had logged during vendor selection). Three options were on the table, and the team used a decision tree to compare them.
- Fixed by 31 Aug 60%$0
- Not fixed 40%
- Manual provisioning runs clean 80%$8k
- Access errors to clean up 20%$28k
- Works 90%$12k
- Needs rework 10%$22k
EMV(A) = 0.4 × (0.8 × $8k + 0.2 × $28k) = $4.8k. EMV(B) = 0.9 × $12k + 0.1 × $22k = $13.0k.
Option A, wait for the vendor's fix while fast-tracking SIT with manual provisioning as the fallback, had the lowest expected cost. But the numbers in a decision tree are estimates, and the steering committee knew it. Helena Brandt, the Corporate Controller, asked the right question: what probability of the vendor fixing on time would make option B better?
The answer took a minute. Option A's expected cost is 12 × (1 − p) thousand dollars. That only exceeds B's $13k if p is below zero, which is impossible. Option A dominated for any plausible probability, so the decision was robust and the conversation moved on. That's the real value of a decision tree: not the EMV, but making the assumptions explicit enough to test.
Sizing the reserve
Expected monetary value also sized the money. Ledger's contingency reserve was essentially the sum of the threat EMVs in its register, plus an allowance for estimating uncertainty. By the August steering committee the arithmetic looked like this:
The August risk report also re-ran the Monte Carlo with actual progress and revised estimates for SIT and card feeds: P50 go-live 6 October, P80 11 October, and a 97% chance of making the 22 October backstop. The 4 October target was still achievable, but only with the SIT fast-track in place. That's what a living quantitative analysis looks like: not a slide from planning, but a forecast refreshed as reality arrives.
Agile teams do the same thing with throughput
The same idea powers probabilistic forecasting for agile teams. Instead of activity estimates, the simulation samples the team's own historical weekly throughput, thousands of times, to answer “when will this scope be done?” as a range. In the book, this is literally the product the Beacon team is building, and they used it on themselves.
On 9 August, after a security fix and a production incident had eaten their capacity, the forecast panel on GitLab's own 20.4 milestone showed the remaining GA scope as 85% likely by 4 October: more than two weeks past the merge cut-off for the 16 September release. That forecast, more than any argument, triggered a scope cut. After the cut, the panel showed 85% by 8 September, inside the cut-off. The product manager posted a screenshot in the planning issue: “customer zero, doing its job.”
What to take from it
- Single-point plans are optimistic by construction. Merge bias guarantees it.
- Ask for ranges, and write down why each range is wide.
- Simulate the network, not just the critical path. Criticality tells you which near-critical path to watch.
- Commit to a date with a probability and a fallback. Keep the target inside the team.
- Re-run the model as actuals arrive. A forecast from June is history by August.
How students practice this
In weeks 5 to 7, students estimate Ledger's activities with three-point ranges, compute the critical path in code, then write this simulation with Claude Code or Codex and verify it line by line: the distributions, the merge points, the random seed. They build the SCIM decision tree, size the reserve from their own risk register, and present a committed date with its probability to a skeptical sponsor.
Adapted from Projects, For Real, chapters 18, 19, 24 and 36. The projects, people, vendors and numbers are the book's fictional case studies, set against GitLab's publicly documented practices. Gantt Guild is not affiliated with GitLab Inc.