Calibration → forecast flips off cold-start (#1); re-lands Monte Carlo (#10) + lifecycle (#5) #40

Merged
christian merged 3 commits from p2/calibration into main 2026-07-08 23:54:48 +00:00
Owner

⚠️ Also fixes a stacked-merge gap

#38 (Monte Carlo) and #39 (lifecycle) showed merged, but they merged into their stacked base branches, not main — only #37 (scheduler) actually reached main. This branch is cut from main and cherry-picks both back on, plus the new calibration work, so main becomes whole in one merge. Base is main (no stacking this time).


#1 — Calibration closes the D3 loop

The forecast now learns from the team's own estimate-vs-actual history (the working time #5 infers from git events) instead of guessing forever.

Core (calibration-v0)

  • fitCalibration(samples) — lognormal fit on log(actual/estimate): global + per-bucket (once a bucket clears the floor) + per-person bias. coldStart until n ≥ 20 closed-with-estimate issues.
  • calibrationSamples() — pull samples from the closed backlog via lifecycle inference.
  • forecast() gains options.model: past cold-start, the fitted params drive the sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart reflects the model.

App — AppShell fits calibration once from the reconciled backlog, feeds the model into the cone, and drives the Calibration screen + Runway header. Focus footer / Runway note / Calibration screen now show cold-start (N/20) vs calibrated (on N closed) from real data; scatter, bucket bias, and per-person all fitted, degrading honestly on a thin dataset.

Live result

The repo is honestly cold-start · 0/20: the dogfood issues were opened and closed the same day, so day-granular actuals are 0 and excluded. The flip-to-calibrated is proven by unit tests, not fabricated data.

Known refinements

  • Same-day closes → 0 working-day actuals (day-granular) are excluded; a fractional/hours-based actual would let fast repos accumulate history.
  • Per-person uses gitea login until a display-name map lands.

Re-landed in this PR

  • #10 Monte Carlo — real seeded burn-up cone (cold-start lognormal priors, deterministic-order sim).
  • #5 lifecycle inference — five-column board from the event stream + actualWorkingDays (which this PR now consumes).

Verified

74 core tests green (9 calibration + 2 forecast-switch new) · desktop typecheck clean · 14 fixture e2e green · live spec asserts the real cold-start calibration surface.

🤖 Generated with Claude Code

## ⚠️ Also fixes a stacked-merge gap #38 (Monte Carlo) and #39 (lifecycle) showed `merged`, but they merged into their **stacked base branches**, not `main` — only #37 (scheduler) actually reached `main`. This branch is cut from `main` and cherry-picks both back on, **plus** the new calibration work, so `main` becomes whole in one merge. Base is `main` (no stacking this time). --- ## #1 — Calibration closes the D3 loop The forecast now learns from the team's **own** estimate-vs-actual history (the working time #5 infers from git events) instead of guessing forever. **Core (`calibration-v0`)** - `fitCalibration(samples)` — lognormal fit on log(actual/estimate): global + per-bucket (once a bucket clears the floor) + per-person bias. `coldStart` until **n ≥ 20** closed-with-estimate issues. - `calibrationSamples()` — pull samples from the closed backlog via lifecycle inference. - `forecast()` gains `options.model`: past cold-start, the **fitted params drive the sim** (per bucket, global fallback); otherwise the code priors do. `Forecast.coldStart` reflects the model. **App** — AppShell fits calibration once from the reconciled backlog, feeds the model into the cone, and drives the Calibration screen + Runway header. Focus footer / Runway note / Calibration screen now show *cold-start (N/20)* vs *calibrated (on N closed)* from real data; scatter, bucket bias, and per-person all fitted, degrading honestly on a thin dataset. ## Live result The repo is honestly **cold-start · 0/20**: the dogfood issues were opened *and closed the same day*, so day-granular actuals are 0 and excluded. The flip-to-calibrated is proven by unit tests, not fabricated data. ## Known refinements - Same-day closes → 0 working-day actuals (day-granular) are excluded; a fractional/hours-based actual would let fast repos accumulate history. - Per-person uses gitea login until a display-name map lands. ## Re-landed in this PR - **#10 Monte Carlo** — real seeded burn-up cone (cold-start lognormal priors, deterministic-order sim). - **#5 lifecycle inference** — five-column board from the event stream + `actualWorkingDays` (which this PR now consumes). ## Verified 74 core tests green (9 calibration + 2 forecast-switch new) · desktop typecheck clean · 14 fixture e2e green · live spec asserts the real cold-start calibration surface. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
christian added 3 commits 2026-07-08 23:52:15 +00:00
Replace the demo cone on Morning service with a real, seeded Monte Carlo
forecast over the open backlog. The LLM never does this — it's plain,
reproducible code (evidence-based scheduling).

core (@commitea/core/forecast-v0):
- Code-resident lognormal cold-start priors per estimate bucket (D3):
  sampled actual = estimate * exp(N(mu, sigma)), mu > 0 (actuals run long),
  sigma shrinks as tickets grow. Replaced by the team's empirical fit at
  n >= 20 (#5 supplies the actuals).
- forecast(): seeded mulberry32 + Box-Muller over the scheduler's
  deterministic order (order is fixed from estimates/deps; only durations
  vary, so the cone stretches, never reorders). Returns p50/p80/p95 landing
  + a per-issue burn-up curve (p10/p50/p90). 12 unit tests; reproducible.

renderer:
- lib/dates.ts: working-day -> calendar mapper (skips weekends) + buildBurnUpData.
- BurnUpCone gains a data-driven twin; falls back byte-identical to the
  fixture cone when no forecast (demo mode unchanged).
- Focus card shows the real "80% of the open backlog lands by <range>",
  real scope count, and names the cold-start priors.

v0 scope (each a later slice): single serial worker (capacity is #8);
cold-start priors only (empirical fit is #5); no historical actual polyline
(needs lifecycle events, #5). Header chrome (reconcile time, ahead/behind
badge) stays fixture until milestone due dates land.

Verified: 51 core tests green, desktop typecheck clean, 14 fixture e2e green,
live spec asserts the real cone renders (25 open issues, "lands by Nov 11-27").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fill the board's Steeping / In-review columns (and the calibration actuals)
from real gitea timeline events, replacing the three-column-only v0.

core (@commitea/core):
- inferLifecycle(issue, events, asOf): five-column inference — closed → done;
  open PR ref → review; commit ref → steeping; any triage signal → triage;
  else diagnosis. Earliest event of each kind fixes the stage timestamp.
- Derives actualWorkingDays (work-start → close) — the estimate-vs-actual the
  calibration fit (D3) learns from — and steepingDays (first commit → now) for
  the board age badge.
- workingDaysBetween(): whole Mon–Fri days in [start, end), day-granular.
- normalizeTimeline() + client.getIssueTimeline(): map gitea's raw timeline
  (label/milestone → triage, commit_ref → commit, pull_ref → pull, close,
  reopen), drop the rest. Paginated.

app:
- reconcile now fetches every issue's timeline and returns it keyed by number;
  threaded through the bridge → useBacklog → board/focus.
- issuesToBoardColumns + scheduleFocus run inferLifecycle: real Steeping/In-review
  columns, steeping-age `days` badge, focus-card steeping badge.

Known refinement: gitea's pull_ref fires on any PR mention, so an issue merely
referenced in a PR body can read as In-review; distinguishing closing refs from
mentions needs the PR link's state (later). Re-opening multi-segment actuals
also deferred.

Verified: 63 core tests green (15 lifecycle, incl. workingDaysBetween + the five
transitions), desktop typecheck clean, 14 fixture e2e green, live spec asserts
the board's Done column is populated from real events.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Close the D3 loop. The forecast now learns from the team's own estimate-vs-actual
history (the working time #5 infers from git events) instead of guessing forever.

core (@commitea/core/calibration-v0):
- fitCalibration(samples): lognormal fit on log(actual/estimate) — global +
  per-bucket (once a bucket clears the floor) + per-person bias. coldStart until
  n >= 20 closed-with-estimate issues.
- calibrationSamples(): pull those samples from the closed backlog via lifecycle
  inference (estimate label vs inferred actualWorkingDays).
- toDurationModel(): project the fit to the params forecast consumes.
- forecast() gains options.model: when past cold-start, fitted params drive the
  sim (per bucket, global fallback); otherwise the code priors do. Forecast.coldStart
  now reflects the model. nearestBucket extracted + exported.

app:
- AppShell fits calibration once from the reconciled backlog, feeds the model into
  forecastBacklog (cone), and drives the Calibration screen + Runway header.
- Focus cone footer, Runway note, and Calibration screen now say cold-start (N/20)
  vs calibrated (on N closed) from real data; Calibration scatter / bucket bias /
  per-person all fitted, degrading honestly on a thin dataset.

Known refinement: same-day closes yield 0 working-day actuals (day-granular) and
are excluded, so a fast-moving repo can sit at n=0 — honest, but a fractional
(hours-based) actual would let those count. Per-person uses gitea login, not
display name, until the person map lands.

Note: also re-lands #10 (Monte Carlo) and #5 (lifecycle) which merged into their
stacked base branches but never propagated to main (stacked-merge trap); this
branch is cut from main and carries all three so main is whole again.

Verified: 74 core tests green (9 calibration + 2 forecast-switch added), desktop
typecheck clean, 14 fixture e2e green, live spec asserts the real cold-start
calibration surface (Runway note + screen badge fitted from actuals).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
christian merged commit a901b7f855 into main 2026-07-08 23:54:48 +00:00
Sign in to join this conversation.
No description provided.