Metadata-Version: 2.5
Name: trueppm-scheduler
Version: 0.4.0b6
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, 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:

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

## 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 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](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)
- 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](https://gitlab.com/trueppm/trueppm/-/issues/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](https://gitlab.com/trueppm/trueppm/-/issues/4079), [#4173](https://gitlab.com/trueppm/trueppm/-/issues/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](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. ([#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.0b6
```

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
