Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

trueppm-scheduler

PyPI version PyPI downloads CI License

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 (a Beta moment-fitted to the classic PERT mean and σ = (P − O)/6 — see Conventions), 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 — method-of-moments fit to the classic PERT mean and σ = (P − O)/6, not the λ=4 Beta-PERT (numpy-vectorized, ~10k runs/sec) → P50/P80/P95 completion dates
  • Hybrid agile/waterfall forecasting — mark a task delivery_mode=SCRUM with a story_points estimate 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_day and Calendar.timezone round-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 in Project.calendars). A calendar_id of None, or one with no matching entry, falls back to the pass-level Project.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 calendars registry schedules byte-for-byte as before.
  • Both passes honor them: the CPM schedule() pass (early/late dates, float, criticality) and monte_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 duration on 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.sensitivity ranks them) to get a real band.
  • Widening the band is not pessimism. A pessimistic estimate set to most_likely × 1.2 produces 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.

Conventions

Every modeling rule the engine commits to, one line each, with the issue or ADR that decided it. Differs marks the rules where a schedule built in MS Project or Primavera P6 can come out differently here; everything else follows their convention. The same list, with the comparison spelled out, is on Scheduler Conventions.

Units and calendars

  • Durations and three-point estimates count working days, in whole days only; a sub-day duration or lag raises InvalidScheduleInput. Differs — both tools schedule in hours. (#826)
  • Calendar.hours_per_day and Calendar.timezone are inert — they round-trip and change no date. Differs. (#4131)
  • Lag counts calendar days; the resulting date snaps to the successor's next working day (previous, for a lead). Differs — MS Project and P6 count lag in working time by default. (#2534; open question #2535)
  • Per-task calendars: duration expands on the task's own calendar, lag is consumed on the successor's. (ADR-0120)

Links and constraints

  • An FF/SF-driven task stays contiguous and right-aligned on its pinned finish, so its start moves back — the MS Project convention. Consequence: CPM is non-monotone in duration on an FF/SF network (a longer task can start earlier). (#3806, decided: keep)
  • An SF link finishes the successor at the start of the predecessor's start day — with zero lag, its last working day is the day before. (#4145)
  • A zero-duration milestone is an instant: at the end of its driver's finish day, or the start of the day a floor holds it to. A lag, or a predecessor's recorded finish on a non-working day, that places it after a weekend or holiday shows it at the start of the next working day. (#4079, #4173)
  • The only date constraint is start-no-earlier-than, via planned_start; planned_finish is reserved and inert — no deadline, finish, must-start-on or ALAP constraint. Differs. (#3345, #804)
  • An SS or SF link from a summary task is rejected (FS/FF from a summary expand to its leaves). Differs — MS Project accepts it. (ADR-0370)

Progress and actuals

  • A completed task (actual_finish set, or 100%) is pinned to its recorded dates verbatim — even on a non-working day — and is never re-sampled. (ADR-0136)
  • An in-progress task schedules its remaining duration − floor(duration × pct / 100) working days forward from the data date (status_date), floored at its unsnapped actual_start. (ADR-0132)

Float and output

  • is_critical is exactly total_float == 0; total float is the working days from early to late start.
  • free_float inverts the forward constraint across all four link types, capped at total float; no live successor → total float. (#1828)
  • The order of ScheduleResult.tasks is unspecified — look tasks up by id. (#1862)
  • project_finish and a milestone's early_finish are the day the finish is shown on, and milestone_at_day_end says which edge of it. The end of a Friday and the start of the next Monday are the same point in working time, so the shown day can hop a weekend or holiday with no working-time move. Compute slip in working time, reading a start-of-day milestone finish as the end of the working day before it — never as a calendar-day difference of two finishes. (#4178)
  • The same holds for Monte Carlo: MonteCarloResult.p50/p80/p95 are shown days, and p50_at_day_start/p80_at_day_start/p95_at_day_start say which edge of each. Compare a percentile with the CPM finish, or with another run's percentile, in working time. (#4204)

Monte Carlo

  • Three-point estimates sample a Beta fitted by method of moments to the classic PERT mean (O + 4M + P) / 6 and σ = (P − O) / 6 — not the λ=4 Beta-PERT. Same mean; on a symmetric estimate the band is ~12% narrower (P80/P95 slightly earlier), on one whose mode sits at an end it is wider. Differs from @RISK's default. (#4133)
  • Every sampled duration is floored at Task.duration — the optimistic tail below the plan is not expressed. Differs — risk tools sample the whole estimate. (#3765)
  • "Never before the CPM finish" holds only on FS/SS-only networks; on an FF/SF network a percentile can land earlier, because CPM itself is non-monotone there. (#3806)
  • A fixed seed reproduces P50/P80/P95 on the same numpy and trueppm-scheduler versions — see Reproducibility. (#4099)

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_days must set at least one weekday bit (Mon–Sun); a calendar whose exceptions blanket the entire search window is rejected too.
  • Duration — each task duration must be between 0 and MAX_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 — runs must 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.0b5

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, on the same numpy version and the same trueppm-scheduler version. Within that envelope this is a supported, tested property you can rely on for reproducible reports and regression baselines — not an implementation detail.

Outside it, it is not promised. The dependency range is numpy>=1.26,<3, and numpy guarantees a Generator's random stream (Generator.beta included) only within one release (NEP 19), so upgrading numpy can move seeded percentiles. A trueppm-scheduler release can move them too — a sampling fix or a change to the order tasks are sampled in is recorded in the changelog. If a seeded baseline must stay bit-identical, pin both packages alongside the seed.

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.0b5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for trueppm-scheduler 0.4.0b5
File Size Uploaded
trueppm_scheduler-0.4.0b5.tar.gz 426.7 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for trueppm-scheduler 0.4.0b5
File Interpreter ABI Platform
trueppm_scheduler-0.4.0b5-py3-none-any.whl Python 3 none any Details

Total release size: 579.8 kB

Release files / trueppm_scheduler-0.4.0b5.tar.gz

Download URL trueppm_scheduler-0.4.0b5.tar.gz
Size 426.7 kB
Tags Source
SHA-256 checksum
How to use checksums
201356dcabac963f52a7ac5aec3454ecd5ffba1b2771df6420ffa6686008d6ce
BLAKE2b-256 checksum
How to use checksums
c605a338e227d1245832533fcaa61ad4ee0c5d144cfd63b1196328ac496cb4be
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 29, 2026.

Transparency log

Release files / trueppm_scheduler-0.4.0b5-py3-none-any.whl

Download URL trueppm_scheduler-0.4.0b5-py3-none-any.whl
Size 153.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
aff13d9458b0213998f68fade8033a0876b9b6428fef0a10cfabfdf4ad40b8c2
BLAKE2b-256 checksum
How to use checksums
22d897f7c5b111447f178c9f67a764dee49adb2ef8271e8d05363cf2d9a537cb
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 29, 2026.

Transparency log
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page