Skip to content

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.

Terminal window
pip install trueppm-scheduler

Every 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.

NotebookContents
01-cpm-quickstart.ipynbProject definition, CPM run, per-task float table, custom calendar, SS dependency, cycle detection, JSON round-trip
02-monte-carlo.ipynbPERT three-point estimates, Monte Carlo run, P50/P80/P95 output, matplotlib histogram, scenario comparison
03-calendar-aware.ipynbMon–Sat weeks, public holiday exceptions, multi-week shutdown blocks, calendar-aware lag, JSON round-trip
04-incremental-scheduling.ipynbIncremental CPM, equivalence verification, fallback behavior, local bench
Terminal window
# Run locally (from repo root)
pip install -e "packages/scheduler[dev]" matplotlib
jupyter notebook packages/scheduler/notebooks/

schedule() performs a forward pass, backward pass, float calculation, and critical-path identification on a directed acyclic graph of tasks and dependencies.

CodeNameConstraint
FSFinish-to-StartSuccessor starts after predecessor finishes (+ lag)
SSStart-to-StartSuccessor starts after predecessor starts (+ lag)
FFFinish-to-FinishSuccessor finishes after predecessor finishes (+ lag)
SFStart-to-FinishSuccessor 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:

InputUnit
Task.duration (and each PERT estimate)Working days — weekends and Calendar.exceptions are skipped
Dependency.lagCalendar 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 finishSuccessor startsWorking days of delay
0dMon1
1dMon1
2dMon1
3dTue2
4dWed3

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.

FieldTypeDescription
early_startdateEarliest date the task can start
early_finishdateEarliest date the task can finish
late_startdateLatest start without delaying the project
late_finishdateLatest finish without delaying the project
total_floattimedeltaWorking days of slack before the task delays the project end
free_floattimedeltaWorking days a task can slip without moving any live successor’s early dates (see note)
is_criticalboolTrue when total_float == timedelta(0)

The two are easy to conflate and they support different decisions, so it is worth naming them apart:

  • total_float is how long the task can slip before the project finish moves. It is the number that defines the critical path: is_critical is exactly total_float == 0.
  • free_float is 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.

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.

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, timedelta
from trueppm_scheduler import (
Calendar, DateRange, Dependency, DependencyType,
Project, Task, schedule, CyclicDependencyError,
)
# Calendar: Mon–Fri, Good Friday excluded
cal = 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_str = project.to_json(indent=2)
project_rt = Project.from_json(json_str)
result_rt = schedule(project_rt)
Terminal window
trueppm-scheduler schedule project.json
trueppm-scheduler schedule project.json --json

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).

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.

FieldMeaning
optimistic_durationBest-case (timedelta)
most_likely_durationExpected case — should match duration (timedelta)
pessimistic_durationWorst-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.

FieldDescription
runsNumber of simulations executed
p50Completion date in 50% of simulations
p80Completion date in 80% of simulations (recommended stakeholder commitment date)
p95Completion date in 95% of simulations (contractual deadline buffer)
distributionFull sorted list of completion dates (one per run)
sensitivityDuration-sensitivity tornado: [{task_id, index}], the tasks whose duration moves the finish most, sorted by index (absolute rank correlation, 0–1) descending

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, timedelta
from 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 baseline
cpm = schedule(project)
print(f"CPM finish (P50 proxy): {cpm.project_finish}")
# Monte Carlo
mc = 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).days
print(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.

:::

Terminal window
# Summary output
trueppm-scheduler monte-carlo project.json
# JSON output with full weekly distribution (for frontend histograms)
trueppm-scheduler monte-carlo project.json --json --distribution

Every exception the engine raises subclasses ValueError, so a single except ValueError covers them — but each is individually catchable.

ExceptionRaised when
CyclicDependencyErrorThe dependency graph contains a cycle. .cycle holds the offending task IDs.
SimulationCapExceededmonte_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.
InvalidScheduleInputThe input is structurally valid but out of range (see below).
UnknownTaskErrorderive_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):

InputLimit
Calendar.working_daysMust 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.lagWithin ±MAX_LAG_DAYS (36_525).
Dependency countAt most MAX_DEPENDENCIES (100_000) edges.
Cumulative project spanSum 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)

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"]})
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 only
expansion.dependencies # leaf-level edges, self-links skipped and deduplicated

SummaryExpansion also unpacks as a pair for backward compatibility: leaf_tasks, expanded_deps = expand_summary_dependencies(...).

The recalculate_schedule Celery task fires automatically via transaction.on_commit() after every Task or Dependency write:

  1. Acquires a per-project Valkey lock (SET NX) — prevents redundant concurrent recalculations
  2. Fetches all live (non-deleted) tasks and dependencies for the project
  3. Calls trueppm-scheduler’s schedule() function
  4. Writes CPM output fields back to Task rows
  5. Broadcasts a cpm_complete WebSocket event (plus per-task task_dates_updated deltas) to all connected clients

If the lock is already held, the task re-queues itself with a 10-second countdown.