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:
100
packages/core/src/forecast/forecast-v0.test.ts
Normal file
100
packages/core/src/forecast/forecast-v0.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
165
packages/core/src/forecast/forecast-v0.ts
Normal file
165
packages/core/src/forecast/forecast-v0.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
||||
/** 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<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,
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user