feat: Monte Carlo forecast → real burn-up cone (#10)

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>
This commit is contained in:
Croissant Le Doux
2026-07-08 19:10:34 -04:00
parent 68a93de098
commit ba3d9df88e
9 changed files with 467 additions and 25 deletions

View File

@@ -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> = {}): 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)
})
})

View File

@@ -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<number, LognormalPrior> = {
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
}
}
/** BoxMuller 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<number>(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,
}
}

View File

@@ -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'