Skip to main content
Yanked

This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.4.0b7 instead.
Reason given by maintainers: release script issue; use beta 3

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, 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=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.

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

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

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.0b2
File Size Uploaded
trueppm_scheduler-0.4.0b2.tar.gz 316.0 kB Details

Built distribution (wheel)

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

Total release size: 426.0 kB

Release files / trueppm_scheduler-0.4.0b2.tar.gz

Download URL trueppm_scheduler-0.4.0b2.tar.gz
Size 316.0 kB
Tags Source
SHA-256 checksum
How to use checksums
cbc624027572106187e9d5c994c5b831957f0859266e5b26ef6a99e493fb91fb
BLAKE2b-256 checksum
How to use checksums
55aa8d208ae8b4c2e83ae0db06eaa4c6e4abbcc1af8c7a06c78a445a64eeac62
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

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

Download URL trueppm_scheduler-0.4.0b2-py3-none-any.whl
Size 109.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
eef277826fed24a2fc36b87341f8f8968c0748b177f13824793d70c51dee2926
BLAKE2b-256 checksum
How to use checksums
c8b71c5274391ce864e9f188f3beae8313b5404ebcee9c6a7d1cf9a734fbac52
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
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