Skip to content

CPM Scheduler

The scheduling engine lives in packages/scheduler and ships independently as trueppm-scheduler on PyPI. It has no Django dependency.

Terminal window
pip install trueppm-scheduler
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.

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)

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 (method-of-moments parameterization). Vectorized with numpy; 10,000 runs on a 200-task chain completes in under 5 seconds.

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