Compare commits
3 Commits
p4/reginal
...
eeaf54c4ab
| Author | SHA1 | Date | |
|---|---|---|---|
| eeaf54c4ab | |||
| 153a3e85a1 | |||
|
|
ba3d9df88e |
@@ -25,6 +25,9 @@ test.describe('live backlog', () => {
|
|||||||
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
|
// rationale phrases are emitted only by the scheduler, never by the demo fixture.
|
||||||
await rail.getByRole('button', { name: 'Morning service' }).click()
|
await rail.getByRole('button', { name: 'Morning service' }).click()
|
||||||
await expect(win.getByText(/on the critical path|unblocks #|waits on #|· ready/).first()).toBeVisible()
|
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 win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-focus.png'), fullPage: true, animations: 'disabled' })
|
||||||
|
|
||||||
await app.close()
|
await app.close()
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
|
import type { BurnUpData } from '../../lib/dates.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. The
|
* Charts — geometry, not decoration. Ported from the handoff's Chart.js. When a
|
||||||
* fixed sample paths here stand in for scheduler/Monte Carlo output (P2); the
|
* `data` prop is supplied the cone is drawn from real Monte Carlo output
|
||||||
* shapes (cone from today, 80% band, actual polyline, today rule) are final.
|
* (#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 {
|
export interface BurnUpConeProps {
|
||||||
width?: number
|
width?: number
|
||||||
height?: 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 <BurnUpConeReal width={width} height={height} data={data} />
|
||||||
const pad = { l: 34, r: 96, t: 16, b: 26 }
|
const pad = { l: 34, r: 96, t: 16, b: 26 }
|
||||||
const W = width - pad.l - pad.r
|
const W = width - pad.l - pad.r
|
||||||
const H = height - pad.t - pad.b
|
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 (
|
||||||
|
<svg width="100%" viewBox={`0 0 ${width} ${height}`} style={{ display: 'block' }}>
|
||||||
|
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
|
||||||
|
<line key={f} x1={pad.l} x2={width - pad.r} y1={y(f)} y2={y(f)} stroke="var(--line-1)" strokeWidth="1" />
|
||||||
|
))}
|
||||||
|
{/* scope */}
|
||||||
|
<line x1={pad.l} x2={width - pad.r} y1={y(1)} y2={y(1)} stroke="var(--line-2)" strokeWidth="1.5" />
|
||||||
|
<text x={pad.l} y={y(1) - 6} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||||
|
scope · {data.scope} {data.scope === 1 ? 'issue' : 'issues'}
|
||||||
|
</text>
|
||||||
|
{/* cone */}
|
||||||
|
<polygon points={cone} fill="var(--cone-fill)" />
|
||||||
|
<polyline points={hi} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||||
|
<polyline points={lo} fill="none" stroke="var(--cone-line)" strokeWidth="1.2" strokeDasharray="3 3" />
|
||||||
|
<polyline points={mid} fill="none" stroke="var(--cone-line)" strokeWidth="1.4" />
|
||||||
|
{/* origin = today, 0% of remaining scope */}
|
||||||
|
<circle cx={x(0)} cy={y(0)} r="3.5" fill="var(--cone-actual)" />
|
||||||
|
{/* today rule */}
|
||||||
|
<line x1={x(0)} x2={x(0)} y1={pad.t} y2={height - pad.b} stroke="var(--jade)" strokeWidth="1" />
|
||||||
|
<text x={x(0) + 5} y={pad.t + 10} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--jade-7)' }}>
|
||||||
|
today
|
||||||
|
</text>
|
||||||
|
{/* 80% band label */}
|
||||||
|
<text x={width - pad.r + 10} y={y(0.95)} style={{ font: '500 11.5px var(--font-mono)', fill: 'var(--ink-1)' }}>
|
||||||
|
80%
|
||||||
|
</text>
|
||||||
|
<text x={width - pad.r + 10} y={y(0.95) + 14} style={{ font: '400 11px var(--font-mono)', fill: 'var(--ink-2)' }}>
|
||||||
|
{data.rangeLabel}
|
||||||
|
</text>
|
||||||
|
{/* x labels */}
|
||||||
|
<text x={pad.l} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||||
|
{data.startLabel}
|
||||||
|
</text>
|
||||||
|
<text x={width - pad.r - 34} y={height - 8} style={{ font: '400 10.5px var(--font-mono)', fill: 'var(--ink-3)' }}>
|
||||||
|
{data.endLabel}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export interface RunwayBarMilestone {
|
export interface RunwayBarMilestone {
|
||||||
tone: 'ok' | 'warn'
|
tone: 'ok' | 'warn'
|
||||||
pos: number
|
pos: number
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { FOCUS, type FocusIssue, type IssueRef, TODAY } from '../../data/fixtures.js'
|
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 { BurnUpCone } from '../charts/chart.js'
|
||||||
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
import { Badge, Button, Card, IconButton, Tag } from '../ui/index.js'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Morning service — the Now/Next/Later focus cards + the milestone burn-up cone.
|
* Morning service — the Now/Next/Later focus cards + the burn-up cone.
|
||||||
* `focus` (real scheduler output) overrides the demo fixture when gitea is
|
* `focus` (scheduler) and `forecast` (Monte Carlo) override the demo fixtures
|
||||||
* configured; the burn-up cone stays fixture until Monte Carlo (P2 next slice).
|
* when gitea is configured; both fall back to the handoff demo otherwise.
|
||||||
*/
|
*/
|
||||||
export function FocusScreen({
|
export function FocusScreen({
|
||||||
onOpenIssue,
|
onOpenIssue,
|
||||||
focus,
|
focus,
|
||||||
|
forecast,
|
||||||
}: {
|
}: {
|
||||||
onOpenIssue: (issue: IssueRef) => void
|
onOpenIssue: (issue: IssueRef) => void
|
||||||
focus?: FocusView
|
focus?: FocusView
|
||||||
|
forecast?: ForecastView
|
||||||
}) {
|
}) {
|
||||||
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
const view: FocusView = focus ?? { now: FOCUS.now, next: FOCUS.next, later: FOCUS.later }
|
||||||
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
const FocusRow = ({ slot, issue, jade }: { slot: string; issue: FocusIssue; jade?: boolean }) => (
|
||||||
@@ -94,13 +96,21 @@ export function FocusScreen({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card
|
<Card
|
||||||
overline="Milestone · Beta"
|
overline={forecast ? 'Backlog · open scope' : 'Milestone · Beta'}
|
||||||
title={<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>}
|
title={
|
||||||
|
forecast ? (
|
||||||
|
<>80% of the open backlog lands by <span style={{ whiteSpace: 'nowrap' }}>{forecast.rangeLabel}</span></>
|
||||||
|
) : (
|
||||||
|
<>80% this lands <span style={{ whiteSpace: 'nowrap' }}>Mar 3–12</span></>
|
||||||
|
)
|
||||||
|
}
|
||||||
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
|
actions={<IconButton icon="chart-line" label="Open runway" size="sm" />}
|
||||||
>
|
>
|
||||||
<BurnUpCone />
|
<BurnUpCone data={forecast?.cone} />
|
||||||
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
<p style={{ font: 'var(--text-agent)', color: 'var(--ink-2)', margin: '10px 0 0' }}>
|
||||||
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.'}
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'
|
|||||||
|
|
||||||
import logoIcon from '../../design/assets/logo-icon.png'
|
import logoIcon from '../../design/assets/logo-icon.png'
|
||||||
import type { IssueRef } from '../../data/fixtures.js'
|
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 { useBacklog } from '../../lib/use-backlog.js'
|
||||||
import { PrimitivesGallery } from '../gallery.js'
|
import { PrimitivesGallery } from '../gallery.js'
|
||||||
import { BoardScreen } from '../screens/board-screen.js'
|
import { BoardScreen } from '../screens/board-screen.js'
|
||||||
@@ -88,6 +88,8 @@ export function AppShell() {
|
|||||||
const backlog = useBacklog()
|
const backlog = useBacklog()
|
||||||
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined
|
||||||
const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : 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(() => {
|
useEffect(() => {
|
||||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light')
|
||||||
@@ -158,7 +160,7 @@ export function AppShell() {
|
|||||||
const renderScreen = () => {
|
const renderScreen = () => {
|
||||||
switch (view) {
|
switch (view) {
|
||||||
case 'focus':
|
case 'focus':
|
||||||
return <FocusScreen onOpenIssue={openIssue} focus={focus} />
|
return <FocusScreen onOpenIssue={openIssue} focus={focus} forecast={forecast} />
|
||||||
case 'standup':
|
case 'standup':
|
||||||
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
return <StandupScreen onBegin={() => setView('focus')} onOpenIssue={openIssue} />
|
||||||
case 'board':
|
case 'board':
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
type DependencyEdge,
|
type DependencyEdge,
|
||||||
|
forecast,
|
||||||
type GiteaIssue,
|
type GiteaIssue,
|
||||||
inferColumnV0,
|
inferColumnV0,
|
||||||
type LifecycleColumn,
|
type LifecycleColumn,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
} from '@commitea/core'
|
} from '@commitea/core'
|
||||||
|
|
||||||
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js'
|
||||||
|
import { type BurnUpData, buildBurnUpData } from './dates.js'
|
||||||
|
|
||||||
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
const COLUMN_LABELS: Record<LifecycleColumn, string> = {
|
||||||
diagnosis: 'Diagnosis',
|
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 }
|
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
|
* Run the deterministic scheduler over the open backlog and take the top three
|
||||||
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
* as Now/Next/Later. Estimates + priority come from label facts; dependency
|
||||||
* edges come from gitea's native issue dependencies.
|
* edges come from gitea's native issue dependencies.
|
||||||
*/
|
*/
|
||||||
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView {
|
export function scheduleFocus(issues: GiteaIssue[], deps: DependencyEdge[]): FocusView {
|
||||||
const open = issues.filter((i) => i.state === 'open')
|
const plan = schedule(toSchedulable(issues), deps)
|
||||||
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 f = selectFocus(plan)
|
const f = selectFocus(plan)
|
||||||
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) }
|
return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) }
|
||||||
}
|
}
|
||||||
|
|||||||
70
apps/desktop/src/renderer/src/lib/dates.ts
Normal file
70
apps/desktop/src/renderer/src/lib/dates.ts
Normal file
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
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,
|
ScheduledItem,
|
||||||
SchedulePlan,
|
SchedulePlan,
|
||||||
} from './scheduler/scheduler-v0.js'
|
} 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