From ba3d9df88eeeeaf0d276657b43de72f6c8ae9717 Mon Sep 17 00:00:00 2001 From: Croissant Le Doux Date: Wed, 8 Jul 2026 19:10:34 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20Monte=20Carlo=20forecast=20=E2=86=92=20?= =?UTF-8?q?real=20burn-up=20cone=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ", 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) --- apps/desktop/e2e/live-backlog.spec.ts | 3 + .../renderer/src/components/charts/chart.tsx | 70 +++++++- .../src/components/screens/focus-screen.tsx | 26 ++- .../src/components/shell/app-shell.tsx | 6 +- apps/desktop/src/renderer/src/lib/backlog.ts | 49 ++++-- apps/desktop/src/renderer/src/lib/dates.ts | 70 ++++++++ .../core/src/forecast/forecast-v0.test.ts | 100 +++++++++++ packages/core/src/forecast/forecast-v0.ts | 165 ++++++++++++++++++ packages/core/src/index.ts | 3 + 9 files changed, 467 insertions(+), 25 deletions(-) create mode 100644 apps/desktop/src/renderer/src/lib/dates.ts create mode 100644 packages/core/src/forecast/forecast-v0.test.ts create mode 100644 packages/core/src/forecast/forecast-v0.ts diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index 4442d48..be9d7f8 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -25,6 +25,9 @@ test.describe('live backlog', () => { // rationale phrases are emitted only by the scheduler, never by the demo fixture. await rail.getByRole('button', { name: 'Morning service' }).click() await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible() + // Real Monte Carlo cone — this headline is emitted only for real forecasts. + await expect(win.getByText(/80% of the open backlog lands by/)).toBeVisible() + await expect(win.getByText(/Cold-start priors/)).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' }) await app.close() diff --git a/apps/desktop/src/renderer/src/components/charts/chart.tsx b/apps/desktop/src/renderer/src/components/charts/chart.tsx index a9c16fb..3ffff42 100644 --- a/apps/desktop/src/renderer/src/components/charts/chart.tsx +++ b/apps/desktop/src/renderer/src/components/charts/chart.tsx @@ -1,15 +1,21 @@ +import type { BurnUpData } from '../../lib/dates.js' + /** - * Charts — geometry, not decoration. Ported from the handoff's Chart.js. The - * fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the - * shapes (cone from today, 80% band, actual polyline, today rule) are final. + * Charts — geometry, not decoration. Ported from the handoff's Chart.js. When a + * `data` prop is supplied the cone is drawn from real Monte Carlo output + * (#10); without it, the fixed sample paths below stand in (demo mode). The + * shapes (cone from today, 80% band, today rule) are final. */ export interface BurnUpConeProps { width?: number height?: number + /** Real forecast geometry. When present it replaces the demo sample paths. */ + data?: BurnUpData } -export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) { +export function BurnUpCone({ width = 640, height = 220, data }: BurnUpConeProps) { + if (data) return const pad = { l: 34, r: 96, t: 16, b: 26 } const W = width - pad.l - pad.r const H = height - pad.t - pad.b @@ -89,6 +95,62 @@ export function BurnUpCone({ width = 640, height = 220 }: BurnUpConeProps) { ) } +/** Data-driven twin of BurnUpCone — same visual grammar, real forecast geometry. */ +function BurnUpConeReal({ width, height, data }: { width: number; height: number; data: BurnUpData }) { + const pad = { l: 34, r: 96, t: 16, b: 26 } + const W = width - pad.l - pad.r + const H = height - pad.t - pad.b + const x = (day: number) => pad.l + (day / data.horizonDays) * W + const y = (frac: number) => pad.t + (1 - frac) * H + + const hi = data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`).join(' ') + const lo = data.band.map((p) => `${x(p.loDay)},${y(p.fraction)}`).join(' ') + const mid = data.band.map((p) => `${x(p.midDay)},${y(p.fraction)}`).join(' ') + const cone = [ + ...data.band.map((p) => `${x(p.hiDay)},${y(p.fraction)}`), + ...[...data.band].reverse().map((p) => `${x(p.loDay)},${y(p.fraction)}`), + ].join(' ') + + return ( + + {[0, 0.25, 0.5, 0.75, 1].map((f) => ( + + ))} + {/* scope */} + + + scope · {data.scope} {data.scope === 1 ? 'issue' : 'issues'} + + {/* cone */} + + + + + {/* origin = today, 0% of remaining scope */} + + {/* today rule */} + + + today + + {/* 80% band label */} + + 80% + + + {data.rangeLabel} + + {/* x labels */} + + {data.startLabel} + + + {data.endLabel} + + + ) +} + export interface RunwayBarMilestone { tone: 'ok' | 'warn' pos: number diff --git a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx index d71a848..299731b 100644 --- a/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/focus-screen.tsx @@ -1,21 +1,23 @@ import React from 'react' import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js' -import { type FocusView } from '../../lib/backlog.js' +import { type ForecastView, type FocusView } from '../../lib/backlog.js' import { BurnUpCone } from '../charts/chart.js' import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js' /** - * Morning service — the Now/Next/Later focus cards + the milestone burn-up cone. - * `focus` (real scheduler output) overrides the demo fixture when gitea is - * configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice). + * Morning service — the Now/Next/Later focus cards + the burn-up cone. + * `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures + * when gitea is configured; both fall back to the handoff demo otherwise. */ export function FocusScreen({ onOpenIssue, focus, + forecast, }: { onOpenIssue: (issue: IssueRef) => void focus?: FocusView + forecast?: ForecastView }) { const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later } const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => ( @@ -94,13 +96,21 @@ export function FocusScreen({ 80% this lands Mar 3–12} + overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'} + title={ + forecast ? ( + <>80% of the open backlog lands by {forecast.rangeLabel} + ) : ( + <>80% this lands Mar 3–12 + ) + } actions={} > - +

- The cone has narrowed since Friday. I’m quietly pleased. + {forecast + ? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — the cone tightens as the team closes work.` + : 'The cone has narrowed since Friday. I’m quietly pleased.'}

diff --git a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx index 48c9790..7ab83ed 100644 --- a/apps/desktop/src/renderer/src/components/shell/app-shell.tsx +++ b/apps/desktop/src/renderer/src/components/shell/app-shell.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react' import logoIcon from '../../design/assets/logo-icon.png' import type { IssueRef } from '../../data/fixtures.js' -import { issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' +import { forecastBacklog, issuesToBoardColumns, scheduleFocus } from '../../lib/backlog.js' import { useBacklog } from '../../lib/use-backlog.js' import { PrimitivesGallery } from '../gallery.js' import { BoardScreen } from '../screens/board-screen.js' @@ -88,6 +88,8 @@ export function AppShell() { const backlog = useBacklog() const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined + const forecast = + backlog.status === 'ready' ? (forecastBacklog(backlog.issues, backlog.deps) ?? undefined) : undefined useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') @@ -158,7 +160,7 @@ export function AppShell() { const renderScreen = () => { switch (view) { case 'focus': - return + return case 'standup': return setView('focus')} onOpenIssue={openIssue} /> case 'board': diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts index 52a71b9..0a2dd90 100644 --- a/apps/desktop/src/renderer/src/lib/backlog.ts +++ b/apps/desktop/src/renderer/src/lib/backlog.ts @@ -1,5 +1,6 @@ import { type DependencyEdge, + forecast, type GiteaIssue, inferColumnV0, type LifecycleColumn, @@ -9,6 +10,7 @@ import { } from '@commitea/core' import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js' +import { type BurnUpData, buildBurnUpData } from './dates.js' const COLUMN_LABELS: Record = { diagnosis: 'Diagnosis', @@ -57,23 +59,48 @@ function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale } } +function toSchedulable(issues: GiteaIssue[]) { + return issues + .filter((i) => i.state === 'open') + .map((i) => ({ + number: i.number, + title: i.title, + labels: i.labels, + estimateDays: i.facts.estimateDays, + priority: i.facts.priority, + })) +} + +export interface ForecastView { + scope: number + cone: BurnUpData + p80Label: string + rangeLabel: string +} + +/** + * Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored + * burn-up cone. Returns null when there's nothing to forecast (no open scope) — + * the UI then falls back to the demo cone. `today` is injectable for tests. + */ +export function forecastBacklog( + issues: GiteaIssue[], + deps: DependencyEdge[], + today: Date = new Date(), +): ForecastView | null { + const f = forecast(toSchedulable(issues), deps) + const cone = buildBurnUpData(f, today) + if (!cone) return null + return { scope: f.scope, cone, p80Label: cone.p80Label, rangeLabel: cone.rangeLabel } +} + /** * Run the deterministic scheduler over the open backlog and take the top three * as Now/Next/Later. Estimates + priority come from label facts; dependency * edges come from gitea's native issue dependencies. */ export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView { - const open = issues.filter((i) => i.state === 'open') - const plan = schedule( - open.map((i) => ({ - number: i.number, - title: i.title, - labels: i.labels, - estimateDays: i.facts.estimateDays, - priority: i.facts.priority, - })), - deps, - ) + const plan = schedule(toSchedulable(issues), deps) const f = selectFocus(plan) return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) } } diff --git a/apps/desktop/src/renderer/src/lib/dates.ts b/apps/desktop/src/renderer/src/lib/dates.ts new file mode 100644 index 0000000..7175edc --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/dates.ts @@ -0,0 +1,70 @@ +import type { Forecast } from '@commitea/core' + +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] + +/** Advance `base` by whole working days (skipping Sat/Sun). Fractions round to nearest day. */ +export function addWorkingDays(base: Date, workingDays: number): Date { + const d = new Date(base.getFullYear(), base.getMonth(), base.getDate()) + let remaining = Math.max(0, Math.round(workingDays)) + while (remaining > 0) { + d.setDate(d.getDate() + 1) + const day = d.getDay() + if (day !== 0 && day !== 6) remaining -= 1 + } + return d +} + +/** "Mar 6" */ +export function formatShort(d: Date): string { + return `${MONTHS[d.getMonth()]} ${d.getDate()}` +} + +/** "Feb 24 – Mar 6" (collapses the month when both ends share it → "Mar 3–12"). */ +export function formatRange(lo: Date, hi: Date): string { + if (lo.getMonth() === hi.getMonth()) return `${MONTHS[lo.getMonth()]} ${lo.getDate()}–${hi.getDate()}` + return `${formatShort(lo)} – ${formatShort(hi)}` +} + +export interface BurnUpBandPoint { + fraction: number + loDay: number + midDay: number + hiDay: number +} + +/** Chart-ready geometry + calendar labels derived from a Monte Carlo forecast. */ +export interface BurnUpData { + scope: number + horizonDays: number + band: BurnUpBandPoint[] + p80Day: number + p80Label: string + rangeLabel: string + startLabel: string + endLabel: string +} + +/** + * Map a working-day forecast onto a calendar-anchored burn-up cone. `today` + * anchors day 0; the cone emanates from (today, 0) and widens to the right. + * The historical "actual" polyline behind today awaits lifecycle events (#5). + */ +export function buildBurnUpData(f: Forecast, today: Date): BurnUpData | null { + if (f.scope === 0 || f.curve.length === 0) return null + const last = f.curve[f.curve.length - 1] + const horizonDays = Math.max(1, Math.ceil(last.p90Day * 1.05)) + const band: BurnUpBandPoint[] = [ + { fraction: 0, loDay: 0, midDay: 0, hiDay: 0 }, + ...f.curve.map((p) => ({ fraction: p.fraction, loDay: p.p10Day, midDay: p.p50Day, hiDay: p.p90Day })), + ] + return { + scope: f.scope, + horizonDays, + band, + p80Day: f.p80Day, + p80Label: formatShort(addWorkingDays(today, f.p80Day)), + rangeLabel: formatRange(addWorkingDays(today, f.p50Day), addWorkingDays(today, last.p90Day)), + startLabel: formatShort(today), + endLabel: formatShort(addWorkingDays(today, horizonDays)), + } +} diff --git a/packages/core/src/forecast/forecast-v0.test.ts b/packages/core/src/forecast/forecast-v0.test.ts new file mode 100644 index 0000000..7919e68 --- /dev/null +++ b/packages/core/src/forecast/forecast-v0.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' + +import { type DependencyEdge, type SchedulableIssue } from '../scheduler/scheduler-v0.js' +import { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast-v0.js' + +function issue(number: number, over: Partial = {}): SchedulableIssue { + return { number, title: `#${number}`, labels: [], estimateDays: 2, priority: 2, ...over } +} + +describe('priorForEstimate', () => { + it('returns the exact prior for a bucket day count', () => { + expect(priorForEstimate(3)).toBe(COLD_START_PRIORS[3]) + expect(priorForEstimate(8)).toBe(COLD_START_PRIORS[8]) + }) + + it('snaps a non-bucket estimate to the nearest bucket', () => { + expect(priorForEstimate(4)).toBe(COLD_START_PRIORS[3]) // tie → smaller bucket + expect(priorForEstimate(6)).toBe(COLD_START_PRIORS[5]) + expect(priorForEstimate(100)).toBe(COLD_START_PRIORS[8]) + }) + + it('every prior is pessimistic (median actual runs longer than the estimate)', () => { + for (const p of Object.values(COLD_START_PRIORS)) expect(p.mu).toBeGreaterThan(0) + }) +}) + +describe('forecast', () => { + const scope = [ + issue(1, { estimateDays: 2 }), + issue(2, { estimateDays: 3 }), + issue(3, { estimateDays: 1 }), + ] + + it('is reproducible: same seed → identical result', () => { + const a = forecast(scope, [], { trials: 500, seed: 42 }) + const b = forecast(scope, [], { trials: 500, seed: 42 }) + expect(a).toEqual(b) + }) + + it('percentiles are ordered p50 <= p80 <= p95', () => { + const f = forecast(scope, [], { trials: 3000 }) + expect(f.p50Day).toBeLessThanOrEqual(f.p80Day) + expect(f.p80Day).toBeLessThanOrEqual(f.p95Day) + }) + + it('the burn-up curve is monotonic in both fraction and day', () => { + const f = forecast(scope, [], { trials: 3000 }) + expect(f.curve).toHaveLength(3) + for (let i = 1; i < f.curve.length; i++) { + expect(f.curve[i].fraction).toBeGreaterThan(f.curve[i - 1].fraction) + expect(f.curve[i].p50Day).toBeGreaterThan(f.curve[i - 1].p50Day) + } + expect(f.curve.at(-1)!.fraction).toBeCloseTo(1) + }) + + it('lo/mid/hi are ordered within each cone point', () => { + const f = forecast(scope, [], { trials: 3000 }) + for (const pt of f.curve) { + expect(pt.p10Day).toBeLessThanOrEqual(pt.p50Day) + expect(pt.p50Day).toBeLessThanOrEqual(pt.p90Day) + } + }) + + it('the median landing runs longer than the raw estimate sum (pessimism)', () => { + const rawSum = 2 + 3 + 1 + const f = forecast(scope, [], { trials: 4000 }) + expect(f.p50Day).toBeGreaterThan(rawSum) + }) + + it('reports scope and cold-start honestly', () => { + const f = forecast(scope, [], { trials: 100 }) + expect(f.scope).toBe(3) + expect(f.coldStart).toBe(true) + }) + + it('a dependency cycle yields an empty, zeroed forecast', () => { + const edges: DependencyEdge[] = [ + { issue: 1, dependsOn: 2 }, + { issue: 2, dependsOn: 1 }, + ] + const f = forecast([issue(1), issue(2)], edges, { trials: 100 }) + expect(f.scope).toBe(0) + expect(f.curve).toEqual([]) + expect(f.p80Day).toBe(0) + }) + + it('an empty scope forecasts nothing', () => { + const f = forecast([], [], { trials: 100 }) + expect(f.scope).toBe(0) + expect(f.curve).toEqual([]) + }) + + it('unestimated issues fall back to the default-estimate prior', () => { + const f = forecast([issue(1, { estimateDays: null }), issue(2, { estimateDays: null })], [], { + trials: 500, + }) + expect(f.scope).toBe(2) + expect(f.p50Day).toBeGreaterThan(0) + }) +}) diff --git a/packages/core/src/forecast/forecast-v0.ts b/packages/core/src/forecast/forecast-v0.ts new file mode 100644 index 0000000..82308c0 --- /dev/null +++ b/packages/core/src/forecast/forecast-v0.ts @@ -0,0 +1,165 @@ +/** + * Monte Carlo forecast, v0. The LLM never does this — it's plain, seeded, + * reproducible code (evidence-based scheduling). It samples an actual duration + * per open issue from a lognormal prior, walks the scheduler's deterministic + * order on a single serial worker, and reads percentiles off the resulting + * completion-day distribution. + * + * v0 simplifications (each a later slice, not a hack): + * - single serial worker; per-person capacity + parallelism is #8. + * - cold-start priors only; the team's empirical calibration fit lands at + * n >= 20 closed-with-estimate issues (#5 supplies the actuals). + * - forecast covers remaining (open) scope from today forward; the historical + * burn-up "actual" polyline needs lifecycle event dates (#5). + */ + +import { + type DependencyEdge, + schedule, + type SchedulableIssue, +} from '../scheduler/scheduler-v0.js' + +export interface LognormalPrior { + /** Median log-ratio: sampled median duration = estimate * e^mu. */ + mu: number + /** Spread of log(actual / estimate). */ + sigma: number +} + +/** + * Code-resident cold-start priors (D3). Lognormal on log(actual / estimate): + * sampled actual = estimateDays * exp(N(mu, sigma)). mu > 0 encodes the honest + * fact that actuals run long; sigma shrinks as tickets grow (a snag doubles a + * 1-day task but barely dents an 8-day one). The team's fitted model replaces + * these at n >= 20 (see calibration model in pm-state.md). + */ +export const COLD_START_PRIORS: Record = { + 1: { mu: 0.25, sigma: 0.55 }, + 2: { mu: 0.22, sigma: 0.48 }, + 3: { mu: 0.2, sigma: 0.44 }, + 5: { mu: 0.18, sigma: 0.4 }, + 8: { mu: 0.16, sigma: 0.36 }, +} + +const PRIOR_BUCKETS = [1, 2, 3, 5, 8] + +/** Nearest estimate bucket (ties resolve to the smaller bucket). */ +export function priorForEstimate(days: number): LognormalPrior { + let best = PRIOR_BUCKETS[0] + for (const b of PRIOR_BUCKETS) { + if (Math.abs(b - days) < Math.abs(best - days)) best = b + } + return COLD_START_PRIORS[best] +} + +export interface ForecastOptions { + /** Simulation trials. More = smoother tails, linear cost. */ + trials?: number + /** PRNG seed. Fixed by default so a forecast is reproducible. */ + seed?: number +} + +export interface BurnUpPoint { + /** Cumulative fraction of remaining scope complete (0 < f <= 1]. */ + fraction: number + /** Working-day offset from today at the p10 / p50 / p90 of reaching it. */ + p10Day: number + p50Day: number + p90Day: number +} + +export interface Forecast { + /** Open issues in scope (== scheduled count). */ + scope: number + trials: number + /** true while code priors drive the sim; false once the empirical fit is in (v1). */ + coldStart: boolean + /** Working-day offsets from today for the whole scope landing. */ + p50Day: number + p80Day: number + p95Day: number + /** Burn-up cone, one point per scheduled issue, fraction ascending. */ + curve: BurnUpPoint[] +} + +const DEFAULT_TRIALS = 2000 +const DEFAULT_SEED = 0x9e3779b9 + +/** mulberry32 — small, fast, seedable PRNG (no reliance on Math.random). */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** Box–Muller standard normal from a uniform PRNG. */ +function standardNormal(rng: () => number): number { + let u1 = rng() + const u2 = rng() + if (u1 < 1e-12) u1 = 1e-12 + return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) +} + +function percentile(sortedAsc: number[], q: number): number { + const idx = Math.min(sortedAsc.length - 1, Math.max(0, Math.round(q * (sortedAsc.length - 1)))) + return sortedAsc[idx] +} + +/** + * Monte Carlo over the deterministic schedule order. Order is fixed (it's + * derived from estimate labels + dependencies, not sampled), so only durations + * vary across trials — the cone stretches, it never reorders. + */ +export function forecast( + issues: SchedulableIssue[], + edges: DependencyEdge[], + options: ForecastOptions = {}, +): Forecast { + const trials = options.trials ?? DEFAULT_TRIALS + const seed = options.seed ?? DEFAULT_SEED + const order = schedule(issues, edges).items // empty when a dependency cycle exists + const n = order.length + if (n === 0) { + return { scope: 0, trials, coldStart: true, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] } + } + + const priors = order.map((it) => priorForEstimate(it.durationDays)) + const rng = mulberry32(seed) + + // endByRank[k][t] = working day the (k+1)-th scheduled issue completes on trial t. + const endByRank: number[][] = Array.from({ length: n }, () => new Array(trials)) + for (let t = 0; t < trials; t++) { + let cursor = 0 + for (let k = 0; k < n; k++) { + const p = priors[k] + const sampled = order[k].durationDays * Math.exp(p.mu + p.sigma * standardNormal(rng)) + cursor += sampled + endByRank[k][t] = cursor + } + } + + const curve: BurnUpPoint[] = endByRank.map((row, k) => { + const sorted = [...row].sort((a, b) => a - b) + return { + fraction: (k + 1) / n, + p10Day: percentile(sorted, 0.1), + p50Day: percentile(sorted, 0.5), + p90Day: percentile(sorted, 0.9), + } + }) + + const total = [...endByRank[n - 1]].sort((a, b) => a - b) + return { + scope: n, + trials, + coldStart: true, + p50Day: percentile(total, 0.5), + p80Day: percentile(total, 0.8), + p95Day: percentile(total, 0.95), + curve, + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 961fe39..e0d047f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,3 +33,6 @@ export type { ScheduledItem, SchedulePlan, } from './scheduler/scheduler-v0.js' + +export { COLD_START_PRIORS, forecast, priorForEstimate } from './forecast/forecast-v0.js' +export type { BurnUpPoint, Forecast, ForecastOptions, LognormalPrior } from './forecast/forecast-v0.js' -- 2.49.1