This release is a pre-release and may not be stable for production use.
trueppm-scheduler
Project-schedule math as a library — critical path and delivery-risk forecasting, without a 500 MB desktop app or a SaaS subscription.
Answer the two questions every plan has to answer:
- "What's the earliest this can finish, and which tasks can't slip?" — a full forward/backward critical-path pass computes early/late dates, total and free float, and flags the tasks on the critical path.
- "How confident are we in that date?" — Monte Carlo simulation turns three-point estimates into a P50/P80/P95 forecast, so you can commit to a date you'll actually hit instead of the best-case one.
It's pure Python with just networkx and numpy underneath — no Django, no web server, no GUI. Drop it into a backend, a data pipeline, a Jupyter notebook, or a CLI and get the same engine that powers the TruePPM platform.
Why reach for this
- Real scheduling semantics, not a toy. All four dependency types (finish-to-start, start-to-start, finish-to-finish, start-to-finish) with lead/lag on every link — most lightweight schedulers only do finish-to-start, which cannot express an overlap or a wait without faking it with a dummy task.
- Working-time aware. A built-in working-day calendar skips weekends and honors holiday exceptions, so durations resolve to real delivery dates.
- Risk forecasting built in. PERT-Beta Monte Carlo, numpy-vectorized at ~10k runs/sec — the difference between "due March 3" and "70% likely by March 3, 95% by March 14."
- Fails loud on bad input. Cycle detection that names the offending task IDs, plus up-front validation of durations, lag, and project span — no silent wrong answers, no spinning on a degenerate graph.
- Embeds anywhere. Two dependencies, no framework. Serialize a plan to JSON, schedule it, and read back structured results.
Features
- Forward/backward CPM pass with all four dependency types (FS, SS, FF, SF), total/free float, and critical-path flagging
- Calendar-aware working-day arithmetic (weekend skip + holiday exceptions), with optional per-task calendars for mixed-team schedules
- Monte Carlo schedule-risk simulation via PERT-Beta distributions (numpy-vectorized, ~10k runs/sec) → P50/P80/P95 completion dates
- Hybrid agile/waterfall forecasting — mark a task
delivery_mode=SCRUMwith astory_pointsestimate and Monte Carlo samples its duration from the team's own velocity history instead of a per-task PERT guess, so a project can mix sprint-delivered and traditionally-estimated work in one simulation - Explainable results —
derive_value()answers "why is this date what it is?" for any early/late/float value, naming the exact predecessor, dependency type, and lag that won, plus every constraint it beat - JSON round-tripping for plans (
Project.from_json()/Project.to_json()) - CLI:
trueppm-scheduler schedule/trueppm-scheduler monte-carlo
Install
pip install trueppm-scheduler
Requires Python 3.11+.
Quick start
from datetime import date, timedelta
from trueppm_scheduler import schedule, Calendar, Project, Task, Dependency, DependencyType
calendar = Calendar() # Mon–Fri, no holidays (whole-day scheduling)
task_a = Task(id="t-1", name="Design", duration=timedelta(days=5))
task_b = Task(id="t-2", name="Build", duration=timedelta(days=10))
dep = Dependency(predecessor_id="t-1", successor_id="t-2", dep_type=DependencyType.FS)
project = Project(
id="p-1",
name="My Project",
start_date=date(2026, 1, 5),
tasks=[task_a, task_b],
dependencies=[dep],
calendar=calendar,
)
result = schedule(project)
build = next(t for t in result.tasks if t.id == "t-2")
print(build.early_finish) # 2026-01-23 (15 working days from 2026-01-05, across two weekends)
Scheduling granularity. The engine schedules in whole working-day units.
Calendar.hours_per_dayandCalendar.timezoneround-trip through serialization for API parity but are not consumed by the CPM or Monte Carlo passes — they do not change any computed date. Sub-day scheduling is a future change.
Duration and lag are counted in different units
This trips people up, so it is worth stating plainly:
| Input | Unit |
|---|---|
Task.duration (and every PERT estimate) |
Working days — weekends and calendar exceptions are skipped |
Dependency.lag |
Calendar days — the offset is applied as elapsed time, and only the resulting date is then snapped forward to the next working day |
So a 5-working-day task starting Monday finishes Friday, not the following Tuesday. But a 2-day FS lag after a Friday finish does not buy two working days of wait — the weekend absorbs it:
# Predecessor finishes Friday 2026-01-09, Mon–Fri calendar, FS link.
#
# lag=0d → successor starts Mon 2026-01-12 (1 working day later)
# lag=1d → successor starts Mon 2026-01-12 (1 working day later)
# lag=2d → successor starts Mon 2026-01-12 (1 working day later)
# lag=3d → successor starts Tue 2026-01-13 (2 working days later)
# lag=4d → successor starts Wed 2026-01-14 (3 working days later)
If you need a wait of n working days, size the lag against the calendar the successor will actually land on — or model the wait as a zero-resource task, which is duration-counted and therefore calendar-aware end to end.
Negative lag (lead) is supported and follows the same calendar-day rule, snapping backward to the previous working day.
Per-task calendars
By default every task is scheduled on the single Project.calendar. A task can
instead opt into its own working week — useful when one schedule spans teams or
projects that keep different calendars:
seven_day = Calendar(working_days=0b111_1111) # every day is a working day
support = Task(id="t-3", name="Hotfix", duration=timedelta(days=3), calendar_id="ops")
project = Project(
id="p-1",
name="My Project",
start_date=date(2026, 1, 5),
tasks=[task_a, support],
calendar=Calendar(), # pass-level default (Mon–Fri)
calendars={"ops": seven_day}, # registry tasks opt into by id
)
Conventions:
- Duration arithmetic uses the task's own calendar (
calendar_id→ entry inProject.calendars). Acalendar_idofNone, or one with no matching entry, falls back to the pass-levelProject.calendar— never an error. - Lag on a dependency edge is applied as calendar days (above) and then snapped on the successor's calendar: the constraint lands where the wait is actually consumed.
- It is fully backward compatible — a project with no
calendarsregistry schedules byte-for-byte as before. - Both passes honor them: the CPM
schedule()pass (early/late dates, float, criticality) andmonte_carlo(). A fully deterministic mixed-calendar project simulates to precisely its CPM finish date, so the two agree by construction.
See the full documentation for CPM output fields, Monte Carlo usage, and CLI reference.
Explaining a result
The CPM pass picks the max (forward) or min (backward) of several candidate
constraints for each date, then moves on — it doesn't remember which one won.
derive_value() replays that decision for a single task and value, and names
the constraint that actually set it:
from trueppm_scheduler import derive_value, Quantity
d = derive_value(project, task_id="t-2", quantity=Quantity.EARLY_START)
print(d.value, d.binding.kind, d.binding.source_task_name, d.binding.dep_type)
# 2026-01-12 predecessor_fs Design FS — Build starts when Design finishes on an FS link
for c in d.contributions:
print(c.kind, c.source_task_name, c.is_binding) # every constraint considered, winner flagged
This is what lets a UI (or an AI agent) answer "why is this task starting on the 17th?" with the actual dependency instead of a guess — the value returned is computed the same way the engine computed it, not re-derived heuristically.
Mixing agile and waterfall in one schedule
Real programs are rarely pure CPM or pure Scrum. A task can opt into sprint-based uncertainty instead of a three-point estimate:
from trueppm_scheduler import DeliveryMode, Task
sprint_work = Task(
id="t-4",
name="Checkout redesign",
duration=timedelta(days=10), # fallback if delivery_mode is later cleared
delivery_mode=DeliveryMode.SCRUM,
story_points=21,
)
project = Project(
...,
tasks=[task_a, task_b, sprint_work],
velocity_samples=[18, 22, 15, 24, 19], # the team's last five sprints
)
monte_carlo() then samples sprint_work's duration from how many sprints the
team's own velocity history says 21 points takes — not from a PERT guess nobody
on the team would stand behind — while every other task in the same run still
uses its three-point estimate. DeliveryMode.WATERFALL (the default) is
unaffected; mixing modes is opt-in per task.
Interpreting the output
The two entry points answer different questions, and their outputs are not the
same kind of thing. Reading a schedule() date as if it were a commitment is
the single most common way to misuse this library.
schedule() returns one date, and it is the optimistic one. The CPM finish
is the earliest feasible completion — the date you get if every task takes
exactly the duration you estimated, no task slips, and no risk fires. It is a
point on a distribution, not the distribution. Nothing about the forward pass
makes that point likely; it is simply the arithmetic consequence of the numbers
you fed it.
monte_carlo() returns the distribution that date sits in. Sampling each
task's duration and re-running the network thousands of times produces the
range of finishes the plan can actually deliver:
| Percentile | Reading | Use it for |
|---|---|---|
p50 |
Half the simulated runs finished on or before this date. In practice it lands close to the CPM finish. | A midpoint, never a commitment |
p80 |
4 in 5 runs finished by this date. | The commitment date — internal and stakeholder plans |
p95 |
19 in 20 runs finished by this date. | Contractual deadlines, launch dates, regulatory submissions |
So a CPM finish of 2026-03-03 and a P80 of 2026-03-14 do not disagree. They
say: the plan can finish on March 3, and it will finish by March 14 four times
out of five. Committing to March 3 is committing to a coin flip.
Two consequences worth internalizing:
- A task with no three-point estimate contributes no uncertainty. It uses
its fixed
durationon every run, so a project where nothing is estimated simulates to precisely the CPM finish —p50 == p80 == p95. That is a correct result, not a broken one: you asked what varies, and the answer was nothing. Add optimistic/most-likely/pessimistic values to the tasks that drive the date (MonteCarloResult.sensitivityranks them) to get a real band. - Widening the band is not pessimism. A pessimistic estimate set to
most_likely × 1.2produces a distribution too narrow to be useful — PERT derives σ as(P − O) / 6, so a P barely above M encodes near-certainty. The P value should describe a realistic bad day.
The same framing, with the underlying math, is in the Monte Carlo documentation.
Scope. Only this package's Python engine runs Monte Carlo. The companion Rust/WASM engine (
trueppm-wasm-scheduler, used for browser-side and offline recompute) implements the deterministic CPM pass only — there is no probabilistic path there to keep in conformance.
Errors and input limits
Every exception the engine documents raising subclasses ValueError (via the
common SchedulerError base), so one except ValueError catches them all — but
each is individually catchable:
| Exception | Raised when |
|---|---|
CyclicDependencyError |
The dependency graph contains a cycle. .cycle lists the task IDs forming it. |
SimulationCapExceeded |
monte_carlo(runs=…) exceeds max_runs, or the project has more tasks than max_tasks. |
InvalidScheduleInput |
The input is structurally valid but out of range, or the wrong type (see below). |
UnknownTaskError |
derive_value(project, task_id, …) is called with a task_id that names no task in the project. |
What this guarantee actually covers. Project.from_dict() / Project.from_json()
— the untrusted-input path — type-check and coerce every field, so a malformed
document of any shape raises InvalidScheduleInput and never a bare
AttributeError/TypeError. Constructing Project/Task/Dependency directly in
Python (the direct-object API, as in every example above) is held to the same
contract for every field _validate_project() type-guards: Project.calendar,
.tasks, .dependencies, .calendars, .status_date, .velocity_samples;
Task.duration and the three PERT fields (optimistic_duration,
most_likely_duration, pessimistic_duration); and Dependency.predecessor_id,
.successor_id, .dep_type, .lag. A wrong-typed value on any of those (None,
a string, a bare int where a list/dict is expected, an unhashable value, …) raises
InvalidScheduleInput, not a bare Python exception.
One direct-object field is not yet covered: Task.id. Setting it to None or
a list surfaces a bare TypeError from the underlying graph library rather than
InvalidScheduleInput — tracked separately as
#2463. Every other field on
the three input dataclasses is covered; tests/test_exception_contract.py pins
this per-field guarantee as a permanent regression test, generated from a single-
field-mutation sweep over every init field × 12 adversarial values × both
schedule()/monte_carlo() entry points, so a future field addition that reopens
a gap fails a test rather than becoming a silent leak.
The engine walks the working calendar one day at a time, so it also validates input up front rather than spinning on a degenerate project:
- Calendar —
working_daysmust set at least one weekday bit (Mon–Sun); a calendar whoseexceptionsblanket the entire search window is rejected too. - Duration — each task duration must be between
0andMAX_DURATION_DAYS(36_525, ~100 years). Negative durations are rejected. - Lag — each dependency lag must be within
±MAX_LAG_DAYS(36_525). - Project span — the cumulative span (every task's worst-case duration plus
the magnitude of every lag) must stay under
MAX_PROJECT_SPAN_DAYS(366_000, ~1000 years), regardless of task count. - Monte Carlo —
runsmust be>= 1.
Project.from_json() also 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) # "Task 't-1' duration exceeds the maximum of 36525 days (got …)."
API stability and versioning
The public API is the __all__ surface of the top-level trueppm_scheduler
package — the names you can import directly from trueppm_scheduler
(schedule, monte_carlo, Project, Task, Dependency, DependencyType,
Calendar, ScheduleResult, MonteCarloResult, the exception types, etc.).
Everything else — the trueppm_scheduler.engine internals, any underscore-
prefixed helper, and module layout — is unstable and may move or change
without notice.
This package is Development Status :: 4 - Beta as of 0.4.0b1: the public
API may still change before 1.0. Pin an exact version rather than a range:
trueppm-scheduler==0.4.0b3
Beta releases are pre-releases — pip install trueppm-scheduler skips them
unless you pass --pre. Breaking changes are recorded in
CHANGELOG.md,
which also ships inside the wheel.
Reproducibility (seeded runs)
Monte Carlo simulation is reproducible for a fixed seed: the same seed
always yields the same P50/P80/P95 forecast for the same input. This is a
supported, tested property you can rely on for reproducible reports and
regression baselines — not an implementation detail.
This is a statement about repeatability of the sampling, not about the shape
of the answer — a seeded Monte Carlo run still returns a probability
distribution. Do not confuse it with the deterministic single-date output of
schedule(), which is a different thing entirely.
Security
Found a vulnerability in the scheduling engine? Please report it privately —
do not open a public issue. Email security@trueppm.com or open a
confidential GitLab issue. Full policy, response SLAs, and safe-harbor terms are
in SECURITY.md
at the monorepo root.
License
Apache 2.0
Metadata
Release files for trueppm-scheduler 0.4.0b3
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| trueppm_scheduler-0.4.0b3.tar.gz | 316.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| trueppm_scheduler-0.4.0b3-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 426.0 kB
Release files / trueppm_scheduler-0.4.0b3.tar.gz
| Download URL | trueppm_scheduler-0.4.0b3.tar.gz |
|---|---|
| Size | 316.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
e55c7dbe5932798a652127a8e5cf3420d5c01dabb78e0f487086cae826dbdc9c
|
|
BLAKE2b-256 checksum How to use checksums |
f3df1e17fcd1a1a0261090fc56f5c4d3b011453ad82b4e13f1a2d47374d3fc8e
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitLab CI/CD, verified by PyPI on Sep 19, 2026.
Transparency logRelease files / trueppm_scheduler-0.4.0b3-py3-none-any.whl
| Download URL | trueppm_scheduler-0.4.0b3-py3-none-any.whl |
|---|---|
| Size | 109.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
fbb34ee7fd994419d5e8b393d0da5ca4f04cf37515cfe7eb7ab95f94488cd8d7
|
|
BLAKE2b-256 checksum How to use checksums |
8a34385e906c272439dff7c1ca1f1b22fc18bbef6326c597a93ce8b1c0ce22a6
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.11.15
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitLab CI/CD, verified by PyPI on Sep 19, 2026.
Transparency log