CPM Scheduler
This page is for Python developers who want to use TruePPM’s scheduling math in their own code — an end user never installs anything here; it’s what runs behind the scenes on the Schedule view and the Monte Carlo forecast. The scheduling engine ships independently as trueppm-scheduler on PyPI, with no dependency on the rest of TruePPM.
pip install trueppm-schedulerEvery modeling convention the engine commits to — lag units, the constraint set, the sampling distribution, float definitions, progress rules — is listed on one page, with where each differs from MS Project and Primavera P6: Scheduler Conventions.
Interactive notebooks
Section titled “Interactive notebooks”| Notebook | Contents |
|---|---|
01-cpm-quickstart.ipynb | Project definition, CPM run, per-task float table, custom calendar, SS dependency, cycle detection, JSON round-trip |
02-monte-carlo.ipynb | PERT three-point estimates, Monte Carlo run, P50/P80/P95 output, matplotlib histogram, scenario comparison |
03-calendar-aware.ipynb | Mon–Sat weeks, public holiday exceptions, multi-week shutdown blocks, calendar-aware lag, JSON round-trip |
04-incremental-scheduling.ipynb | Incremental CPM, equivalence verification, fallback behavior, local bench |
# Run locally (from repo root)pip install -e "packages/scheduler[dev]" matplotlibjupyter notebook packages/scheduler/notebooks/Critical Path Method
Section titled “Critical Path Method”schedule() performs a forward pass, backward pass, float calculation, and critical-path identification on a directed acyclic graph of tasks and dependencies.
Dependency types
Section titled “Dependency types”| Code | Name | Constraint |
|---|---|---|
FS | Finish-to-Start | Successor starts after predecessor finishes (+ lag) |
SS | Start-to-Start | Successor starts after predecessor starts (+ lag) |
FF | Finish-to-Finish | Successor finishes after predecessor finishes (+ lag) |
SF | Start-to-Finish | Successor finishes after predecessor starts (+ lag) |
Negative lag (lead) is supported on every type.
FF and SF constrain the successor’s finish, and a finish is the end of
its last working day. An SF link with zero lag therefore lets the successor
finish at the start of the predecessor’s first day, which puts the
successor’s last working day on the working day before the predecessor
starts. This is the MS Project and Primavera P6 reading, and it is the same for
a task predecessor and a milestone predecessor.
Lag is in calendar days, durations are in working days
Section titled “Lag is in calendar days, durations are in working days”The two inputs are counted in different units, which is the single most common source of surprise in the engine:
| Input | Unit |
|---|---|
Task.duration (and each PERT estimate) | Working days — weekends and Calendar.exceptions are skipped |
Dependency.lag | Calendar days — applied as elapsed time; only the resulting date is then snapped to the successor’s next working day |
A 5-working-day task starting Monday finishes Friday. But a 2-day FS lag after a Friday finish does not buy two working days of wait, because the weekend absorbs it — the successor still starts Monday:
FS lag after a Friday finish | Successor starts | Working days of delay |
|---|---|---|
0d | Mon | 1 |
1d | Mon | 1 |
2d | Mon | 1 |
3d | Tue | 2 |
4d | Wed | 3 |
To express a wait of n working days, either size the lag against the calendar the successor lands on, or model the wait as a zero-resource task — durations are working-day counted, so that path is calendar-aware end to end.
Negative lag snaps backward to the previous working day under the same rule.
Output fields
Section titled “Output fields”| Field | Type | Description |
|---|---|---|
early_start | date | Earliest date the task can start |
early_finish | date | Earliest date the task can finish |
late_start | date | Latest start without delaying the project |
late_finish | date | Latest finish without delaying the project |
total_float | timedelta | Working days of slack before the task delays the project end |
free_float | timedelta | Working days a task can slip without moving any live successor’s early dates (see note) |
is_critical | bool | True when total_float == timedelta(0) |
Which float answers which question
Section titled “Which float answers which question”The two are easy to conflate and they support different decisions, so it is worth naming them apart:
total_floatis how long the task can slip before the project finish moves. It is the number that defines the critical path:is_criticalis exactlytotal_float == 0.free_floatis how long it can slip before its own successors have to move. It is bounded by the nearest downstream early start, not by the project end.
free_float <= total_float always holds, with equality only when the task has no
live successor to bound it. The gap between them is where the practical answer
lives: a task carrying eight days of total float and two of free float has eight
days of room in the plan overall, and can only take two of them before the next
task is pushed. Spending the other six is a decision about somebody else’s start
date, not a free one — which is why a schedule read on total float alone tends to
under-count the disruption a slip causes.
Calendar arithmetic
Section titled “Calendar arithmetic”Working-day arithmetic skips weekends and any dates listed in Calendar.exceptions (DateRange entries). It governs task duration expansion and float counting. Dependency lag is applied in calendar days and then snapped to a working day — see Lag is in calendar days above.
Cycle detection
Section titled “Cycle detection”schedule() raises CyclicDependencyError if the graph contains a cycle. The .cycle attribute contains the list of task IDs forming the cycle.
from datetime import date, timedeltafrom trueppm_scheduler import ( Calendar, DateRange, Dependency, DependencyType, Project, Task, schedule, CyclicDependencyError,)
# Calendar: Mon–Fri, Good Friday excludedcal = Calendar( exceptions=[ DateRange(start=date(2026, 4, 3), end=date(2026, 4, 3)), ])
tasks = [ Task(id="design", name="Design", duration=timedelta(days=5)), Task(id="build", name="Build", duration=timedelta(days=10)), Task(id="test", name="Test", duration=timedelta(days=7)), Task(id="deploy", name="Deploy", duration=timedelta(days=2)),]
dependencies = [ Dependency(predecessor_id="design", successor_id="build"), Dependency(predecessor_id="design", successor_id="test"), Dependency(predecessor_id="build", successor_id="deploy"), Dependency(predecessor_id="test", successor_id="deploy"),]
project = Project( id="release-v1", name="Release v1.0", start_date=date(2026, 4, 1), tasks=tasks, dependencies=dependencies, calendar=cal,)
try: result = schedule(project)except CyclicDependencyError as e: print("Cycle:", e.cycle)
print(f"Finish: {result.project_finish}")print(f"Critical path: {' → '.join(result.critical_path)}")
for t in result.tasks: print(t.name, t.early_finish, "float:", t.total_float.days, "critical:", t.is_critical)Non-FS dependencies use the dep_type and optional lag arguments:
Dependency( predecessor_id="code", successor_id="test", dep_type=DependencyType.SS, lag=timedelta(days=2),)JSON round-trip
Section titled “JSON round-trip”json_str = project.to_json(indent=2)project_rt = Project.from_json(json_str)result_rt = schedule(project_rt)trueppm-scheduler schedule project.jsontrueppm-scheduler schedule project.json --jsonMonte Carlo Simulation
Section titled “Monte Carlo Simulation”monte_carlo() runs probabilistic simulation using PERT-Beta distributions: a Beta fitted by method of moments to the classic PERT mean (O + 4M + P) / 6 and standard deviation (P − O) / 6, not the λ=4 Beta-PERT that @RISK defaults to. Both share the mean; on a symmetric estimate this band is slightly narrower than @RISK’s default, so P80/P95 land a little earlier (#4133). Vectorized with numpy: 10,000 runs on a 200-task project takes about 60–100 ms on a current laptop CPU (measured on Apple Silicon, numpy 2.4) and longer on a shared CI runner or an older server.
Seeded runs are reproducible within one numpy and one trueppm-scheduler version. monte_carlo(project, seed=…) returns the same P50/P80/P95 every time for the same input on the same versions of both packages. numpy only guarantees a random stream within one release, so upgrading numpy — or the scheduler — can move a seeded percentile. Pin both alongside the seed if a baseline must stay bit-identical (#4099).
Three-point estimates
Section titled “Three-point estimates”Add optimistic_duration, most_likely_duration, and pessimistic_duration to any task you want sampled stochastically. Tasks without these fields use their deterministic duration on every run.
| Field | Meaning |
|---|---|
optimistic_duration | Best-case (timedelta) |
most_likely_duration | Expected case — should match duration (timedelta) |
pessimistic_duration | Worst-case (timedelta) |
All three must be set for a task to be sampled; a partial estimate is ignored. The three values must be internally ordered (optimistic <= most_likely <= pessimistic) or schedule() and monte_carlo() both raise InvalidScheduleInput.
Sampled durations are floored at Task.duration. duration is what the deterministic pass lays out, so a sample below it would make monte_carlo() forecast a finish schedule() has already ruled infeasible. Each sampled column is clamped up to duration before the network is solved — the PERT path and the velocity path alike. The practical consequence for a library consumer: setting a triple below duration does not pull the forecast in, it collapses that task to its deterministic duration. To model a task finishing faster than planned, lower duration. Risk above the plan passes through untouched.
Output
Section titled “Output”| Field | Description |
|---|---|
runs | Number of simulations executed |
p50 | Completion date in 50% of simulations |
p80 | Completion date in 80% of simulations (recommended stakeholder commitment date) |
p95 | Completion date in 95% of simulations (contractual deadline buffer) |
distribution | Full sorted list of completion dates (one per run) |
sensitivity | Duration-sensitivity tornado: [{task_id, index}], the tasks whose duration moves the finish most, sorted by index (absolute rank correlation, 0–1) descending |
Sensitivity (what’s holding the date)
Section titled “Sensitivity (what’s holding the date)”sensitivity ranks tasks by how strongly their sampled duration drives the project
finish: the absolute Spearman rank correlation between each task’s per-run duration
and the project completion date. This is the standard duration-sensitivity tornado —
it answers “which tasks do I protect to hold the date?” It accounts for network
position, so a high-variance task with lots of float ranks low (its slack absorbs the
variance) while a task on the binding path ranks high. Tasks whose duration cannot
vary the finish (deterministic durations, completed tasks, zero-duration milestones)
are omitted; a fully deterministic project returns an empty list. The list is capped
to the top entries.
from datetime import date, timedeltafrom trueppm_scheduler import Calendar, Dependency, Project, Task, monte_carlo, schedule
def days(n: int) -> timedelta: return timedelta(days=n)
tasks = [ Task( id="design", name="Design", duration=days(5), optimistic_duration=days(3), most_likely_duration=days(5), pessimistic_duration=days(10), ), Task( id="build", name="Build", duration=days(15), optimistic_duration=days(10), most_likely_duration=days(15), pessimistic_duration=days(25), ), # No PERT estimates — deterministic every run Task(id="deploy", name="Deploy", duration=days(2)),]
project = Project( id="release-mc", name="Release v1.0 (Monte Carlo)", start_date=date(2026, 4, 1), tasks=tasks, dependencies=[ Dependency(predecessor_id="design", successor_id="build"), Dependency(predecessor_id="build", successor_id="deploy"), ], calendar=Calendar(),)
# CPM deterministic baselinecpm = schedule(project)print(f"CPM finish (P50 proxy): {cpm.project_finish}")
# Monte Carlomc = monte_carlo(project, runs=10_000, seed=42)print(f"P50: {mc.p50}")print(f"P80: {mc.p80} ← recommended commitment date")print(f"P95: {mc.p95}")
slip = (mc.p80 - cpm.project_finish).daysprint(f"P80 vs CPM: +{slip} calendar days ({slip/7:.1f} weeks of schedule risk)"):::tip P80 is the commitment date
The CPM deterministic finish is typically close to P50 — meaning there is only a 50% chance the project finishes on the date shown in a traditional Gantt chart. Commit to the P80 date to reflect realistic schedule risk.
:::
# Summary outputtrueppm-scheduler monte-carlo project.json
# JSON output with full weekly distribution (for frontend histograms)trueppm-scheduler monte-carlo project.json --json --distributionErrors and input limits
Section titled “Errors and input limits”Every exception the engine raises subclasses ValueError, so a single
except ValueError covers them — but each is individually catchable.
| Exception | Raised when |
|---|---|
CyclicDependencyError | The dependency graph contains a cycle. .cycle holds the offending task IDs. |
SimulationCapExceeded | monte_carlo(runs=…) exceeds max_runs, or the project has more tasks than max_tasks. Both caps default to None (uncapped) — they are opt-in guards for an embedder exposing the engine to untrusted input. |
InvalidScheduleInput | The input is structurally valid but out of range (see below). |
UnknownTaskError | derive_value(project, task_id, …) is called with a task_id that names no task in the project. |
Because the engine walks the working calendar one day at a time, it validates
input up front rather than spinning on a degenerate project (a calendar with no
working day, or a century-long duration, would otherwise drive the day-by-day
walk to the date ceiling and raise an opaque OverflowError):
| Input | Limit |
|---|---|
Calendar.working_days | Must set at least one weekday bit (Mon–Sun). A calendar whose exceptions blanket the whole search window is also rejected. |
Task duration (and each PERT estimate) | 0 to MAX_DURATION_DAYS (36_525, ~100 years); negatives rejected. |
Dependency.lag | Within ±MAX_LAG_DAYS (36_525). |
| Dependency count | At most MAX_DEPENDENCIES (100_000) edges. |
| Cumulative project span | Sum of every task’s worst-case duration + every lag must stay under MAX_PROJECT_SPAN_DAYS (366_000, ~1000 years), regardless of task count. |
monte_carlo(runs=…) | Must be >= 1. |
Project.from_json() rejects the non-standard JSON literals NaN, Infinity,
and -Infinity.
from trueppm_scheduler import schedule, InvalidScheduleInput
try: result = schedule(project)except InvalidScheduleInput as e: print("Bad input:", e)Pre-flight helpers
Section titled “Pre-flight helpers”Two exported helpers let an embedder validate and normalize a graph at its own API
edge, without building a full Project or paying for a CPM pass.
find_cycle() — reject a bad edge on write
Section titled “find_cycle() — reject a bad edge on write”from trueppm_scheduler import find_cycle
# Raw (predecessor_id, successor_id) tuples — no model objects needed.edges = [("design", "build"), ("build", "test"), ("test", "design")]
check = find_cycle(edges)if check: # truthy when a cycle WAS found return 400, {"cycle": check.cycle} # check.cycle == ['design', 'build', 'test', 'design']CycleCheck.cycle is the cycle as an ordered list of task IDs with the first
repeated at the end, so a UI can render an unambiguous path; it is None when the
graph is acyclic. Because it takes raw tuples, this is the cheap check to run when
validating a single proposed dependency before persisting it — you do not have
to construct Task and Dependency objects just to answer “would this close a
loop?”.
Pass the optional children_map to also catch logical cycles that only exist
through summary tasks (a summary depending on one of its own descendants):
find_cycle(edges, children_map={"phase-1": ["design", "build"]})expand_summary_dependencies() — flatten summary-level links
Section titled “expand_summary_dependencies() — flatten summary-level links”A planner can draw a dependency on a summary task, but CPM operates on leaves. This fans those links out to the cross-product of the endpoints’ leaf descendants and drops the summaries from the task list:
from trueppm_scheduler import expand_summary_dependencies
expansion = expand_summary_dependencies( tasks, # including the summary tasks dependencies, # may reference summaries children_map={"phase-1": ["design", "build"], "phase-2": ["test", "ship"]},)
expansion.tasks # input tasks with summaries removed — leaves onlyexpansion.dependencies # leaf-level edges, self-links skipped and deduplicatedSummaryExpansion also unpacks as a pair for backward compatibility:
leaf_tasks, expanded_deps = expand_summary_dependencies(...).
Auto-scheduling in the API
Section titled “Auto-scheduling in the API”The recalculate_schedule Celery task fires automatically via transaction.on_commit() after every Task or Dependency write:
- Acquires a per-project Valkey lock (
SET NX) — prevents redundant concurrent recalculations - Fetches all live (non-deleted) tasks and dependencies for the project
- Calls
trueppm-scheduler’sschedule()function - Writes CPM output fields back to Task rows
- Broadcasts a
cpm_completeWebSocket event (plus per-tasktask_dates_updateddeltas) to all connected clients
If the lock is already held, the task re-queues itself with a 10-second countdown.