scheduler: pre-1.0 public-surface decisions — phantom fields, docstring drift, percentile convention, exception wrapping

Severity: 🟡 cluster — pre-1.0 stable-surface decisions to pin before downstream consumers materialize.

Found by: pre-release scheduler-engine audit (2026-05-28). Each sub-item below is a decision (often "remove or document") not a fix.

Sub-items

A. Phantom fields on Task: `planned_finish`, `percent_complete`

  • Location: `packages/scheduler/src/trueppm_scheduler/models.py:55,69`
  • Both are public dataclass fields serialized in `to_dict`/`from_dict` but never read by `schedule()` or `monte_carlo()`. Only `planned_start` is consumed.
  • Decision: (a) honour them (planned_finish = SNLT finish constraint; percent_complete = remaining-duration driver) or (b) remove. Either is breaking at 1.0 — pick pre-1.0.

B. Unused Calendar fields: `hours_per_day`, `timezone`

  • Location: `models.py:146-147`
  • Engine works entirely in whole-day units; both fields are no-ops.
  • Decision: (a) consume them or (b) remove. `hours_per_day` is a particularly common knob users will assume affects calculation.

C. `monte_carlo()` default cap vs docstring claim

  • Location: `engine.py:810` (`runs=1_000, max_runs=1_000`) vs docstring/README "10k runs/sec" example.
  • PyPI users following the example will trip `SimulationCapExceeded`.
  • Fix: raise the default or correct the prose.

D. Percentile indexing convention undocumented

  • Location: `engine.py:974-976`
  • Uses lower-median nearest-rank with `-1` offset (so `runs=10 → P50 = all_dates[4]`). No convention documented (nearest-rank vs linear vs C=1 vs C=0).
  • Fix: switch to `numpy.percentile(distribution, [50, 80, 95])` (de-facto Python standard) and document.

E. `DateRange.from_dict` / `Project.from_json` leak `KeyError`/`ValueError`

  • Location: `models.py:38-42`
  • README documents `InvalidScheduleInput`/`CyclicDependencyError`/`SimulationCapExceeded` only.
  • Fix: wrap deserialisation errors in `InvalidScheduleInput` (or a sibling exception) so the public exception surface is complete.

F. `ScheduleResult.tasks` — mutable result, no copy on construction

  • Location: `engine.py:103, 725`
  • A consumer mutating result tasks mutates the internal state. Sharing a `ScheduleResult` across threads is unsafe.
  • Decision: document immutability or use `@dataclass(frozen=True)` + tuple.

Acceptance per sub-item

  • A — decision recorded; fields either removed or honoured + docstring updated
  • B — same as A
  • C — default and docstring agree
  • D — percentile convention chosen, documented, tests pin it
  • E — deserialisation errors wrapped; README exception list complete
  • F — immutability stance documented in code + README