Metadata-Version: 2.5
Name: trueppm-scheduler
Version: 0.4.0b7
Summary: Critical-path method (CPM) and Monte Carlo schedule-risk engine for project management
Project-URL: Homepage, https://trueppm.com
Project-URL: Documentation, https://docs.trueppm.com/features/scheduler
Project-URL: Repository, https://gitlab.com/trueppm/trueppm
Project-URL: Issues, https://gitlab.com/trueppm/trueppm/-/issues
Project-URL: Changelog, https://gitlab.com/trueppm/trueppm/-/blob/main/packages/scheduler/CHANGELOG.md
Author: Kelly Hair
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: cpm,critical-path,gantt,monte-carlo,networkx,pert,project-management,project-scheduling,risk-analysis,schedule-risk,scheduling
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Information Technology
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Office/Business :: Scheduling
Classifier: Topic :: Scientific/Engineering :: Information Analysis
Classifier: Topic :: Scientific/Engineering :: Mathematics
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: networkx<4,>=3.0
Requires-Dist: numpy<3,>=1.26
Provides-Extra: dev
Requires-Dist: diff-cover<10.0,>=9.0; extra == 'dev'
Requires-Dist: hypothesis<7.0,>=6.0; extra == 'dev'
Requires-Dist: mutmut==3.8.0; extra == 'dev'
Requires-Dist: mypy<2.0,>=1.0; extra == 'dev'
Requires-Dist: pytest-cov<8.0,>=4.0; extra == 'dev'
Requires-Dist: pytest<10.0,>=7.0; extra == 'dev'
Requires-Dist: ruff<1.0,>=0.8; extra == 'dev'
Requires-Dist: types-networkx<4,>=3.0; extra == 'dev'
Description-Content-Type: text/markdown

# trueppm-scheduler

[![PyPI version](https://img.shields.io/pypi/v/trueppm-scheduler.svg)](https://pypi.org/project/trueppm-scheduler/)
[![PyPI downloads](https://img.shields.io/pypi/dm/trueppm-scheduler.svg)](https://pypi.org/project/trueppm-scheduler/)
[![CI](https://gitlab.com/trueppm/trueppm/badges/main/pipeline.svg)](https://gitlab.com/trueppm/trueppm/-/pipelines)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://www.apache.org/licenses/LICENSE-2.0)

**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](https://trueppm.com) 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](#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

```bash
pip install trueppm-scheduler
```

Requires Python 3.11+.

## Quick start

```python
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:

```python
# 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: the
candidate date snaps **forward** to the next working day too, never backward.
A lead that pulls the candidate into a weekend does not borrow the Friday
before it — it lands on the Monday after:

```python
# Predecessor A is 6 working days starting Mon 2026-01-05, so it finishes
# Mon 2026-01-12. FS link, lag=-3 (a 3-day lead).
#
#   candidate = A.finish + 1 day - 3 days = Sat 2026-01-10
#   Sat is non-working, so it snaps forward -> successor starts Mon 2026-01-12
#
# Not Fri 2026-01-09 — the engine never snaps a lead or lag backward.
```

### 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:

```python
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](https://docs.trueppm.com/features/scheduler) 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:

```python
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:

```python
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](https://docs.trueppm.com/features/monte-carlo/#interpreting-results).

> **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.
>
> Both engines are tested against each other and, separately, against an
> independent reference CPM written from the conventions below. The fields that
> comparison covers exactly, and the ones it does not, are listed in
> [CONTRIBUTING.md](https://gitlab.com/trueppm/trueppm/-/blob/main/packages/scheduler/CONTRIBUTING.md#the-independent-cpm-oracle).

## 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](https://docs.trueppm.com/features/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](https://gitlab.com/trueppm/trueppm/-/issues/826))
- `Calendar.hours_per_day` and `Calendar.timezone` are **inert** — they round-trip and change no date. **Differs.** ([#4131](https://gitlab.com/trueppm/trueppm/-/issues/4131))
- Lag counts **calendar days**; the resulting date always snaps **forward** to the successor's next working day — a lead (negative lag) follows the same rule and never snaps backward. **Differs** — MS Project and P6 count lag in working time by default. ([#2534](https://gitlab.com/trueppm/trueppm/-/issues/2534); open question [#2535](https://gitlab.com/trueppm/trueppm/-/issues/2535))
- Per-task calendars: duration expands on the task's own calendar, lag is consumed on the successor's. ([ADR-0120](https://gitlab.com/trueppm/trueppm/-/blob/main/docs/adr/0120-cross-project-dependencies-within-program.md))

**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](https://gitlab.com/trueppm/trueppm/-/issues/3806), decided: keep)
- A zero-lag **FF** and a zero-lag **SS** on a whole-day Standard calendar reproduce the Start/Finish MS Project itself computed and saved for the same tasks — confirmed against a real MS Project export (`LinkTypes.mpp`), not just the documented rule. ([#4323](https://gitlab.com/trueppm/trueppm/-/issues/4323))
- 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. A positive lag counts from the predecessor's start itself, so a weekend just before it cannot absorb the lag (a Monday start with `SF+2d` finishes the successor Tuesday); a lead counts back from the zero-lag finish. **Verified** for the zero-lag, ordinary-work case against a real MS-Project-saved schedule (`tests/fixtures/msproject/link_types_2013.py`, whose SF predecessor starts on a Tuesday). The positive-lag rule matches what #4333 reports from MPXJ's `task-links-project2016` files for a 2-day lag from a Monday start (pinned by `tests/test_sf_from_work.py`; the files are not committed here). A zero-lag anchor that crosses a weekend, and an SF link out of a milestone, remain unverified ([#4327](https://gitlab.com/trueppm/trueppm/-/issues/4327)). ([#4145](https://gitlab.com/trueppm/trueppm/-/issues/4145), [#4322](https://gitlab.com/trueppm/trueppm/-/issues/4322), [#4333](https://gitlab.com/trueppm/trueppm/-/issues/4333))
- A task with an **SF link** may be placed before the project start: its project-start floor is the earlier of the project start and its SF placement, which its SF links already enforce, so the project start never holds it back, and a binding FS, SS or FF lead can also place it there. A task with no SF link keeps the floor, so its leads stay floored. The data date, `planned_start` and a recorded `actual_start` hold every task. A shorter lag never finishes work later; on a network of tasks with work, an FS, SS or FF link that does not bind moves nothing. MS Project's behavior for mixed links and for leads is **unverified** ([#4319](https://gitlab.com/trueppm/trueppm/-/issues/4319)). ([#4218](https://gitlab.com/trueppm/trueppm/-/issues/4218), [#4220](https://gitlab.com/trueppm/trueppm/-/issues/4220))
- 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 — confirmed for the work-driven case against a real MS Project export with all 10 zero-duration milestones in a saved `.mpp` chain. The project start only bounds the day a milestone is shown on: a milestone that a lead or an SF-placed predecessor puts before it is shown on the project's first working day (unless it has an SF link of its own), but its successors' lags count from the earlier point — **unverified** against MS Project or P6 ([#4225](https://gitlab.com/trueppm/trueppm/-/issues/4225)). Its start and finish are one instant, so an FF link into a milestone places it exactly where FS does, a lagged SF link places it on its anchor plus the lag (a positive lag counts from the predecessor's start itself), and an FF link out of it measures a positive lag from that instant; with no lag or a lead it counts from the end of the working day before the milestone, so a longer lead never finishes the successor later. ([#4079](https://gitlab.com/trueppm/trueppm/-/issues/4079), [#4173](https://gitlab.com/trueppm/trueppm/-/issues/4173), [#4225](https://gitlab.com/trueppm/trueppm/-/issues/4225), [#4272](https://gitlab.com/trueppm/trueppm/-/issues/4272), [#4273](https://gitlab.com/trueppm/trueppm/-/issues/4273), [#4323](https://gitlab.com/trueppm/trueppm/-/issues/4323))
- 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](https://gitlab.com/trueppm/trueppm/-/issues/3345), [#804](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/blob/main/docs/adr/0370-reject-ss-sf-from-summary-tasks.md))

**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](https://gitlab.com/trueppm/trueppm/-/blob/main/docs/adr/0136-completed-task-full-duration-span.md))
- 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](https://gitlab.com/trueppm/trueppm/-/blob/main/docs/adr/0132-data-date-aware-progress-forecasting.md))

**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](https://gitlab.com/trueppm/trueppm/-/issues/1828))
- The order of `ScheduleResult.tasks` is **unspecified** — look tasks up by `id`. ([#1862](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/issues/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. ([ADR-1245](https://gitlab.com/trueppm/trueppm/-/blob/main/docs/adr/1245-pert-sampler-moment-fit-convention.md), [#4133](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/issues/3806))
- A fixed `seed` reproduces P50/P80/P95 on the **same numpy and `trueppm-scheduler` versions** — see [Reproducibility](#reproducibility-seeded-runs). ([#4099](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/issues/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`.

```python
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.0b7
```

Beta releases are pre-releases: `pip` installs a pre-release only when you pass
`--pre`, **or when no final release satisfies the requirement yet**. PyPI
currently holds only pre-releases of `trueppm-scheduler` (no version has
reached final), so today a bare `pip install trueppm-scheduler` falls into the
second case and installs the latest beta. Once `0.4.0` final ships, that
changes: a bare install will resolve to `0.4.0` and skip any later `0.5.0`
betas unless you pass `--pre` — which is why pinning an exact version (above)
is the reliable approach in either state. Breaking changes are recorded in
[`CHANGELOG.md`](https://gitlab.com/trueppm/trueppm/-/blob/main/packages/scheduler/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](https://numpy.org/neps/nep-0019-rng-policy.html)),
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](#interpreting-the-output).

## 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`](https://gitlab.com/trueppm/trueppm/-/blob/main/SECURITY.md)
at the monorepo root.

## License

Apache 2.0
