diff --git a/apps/desktop/e2e/live-backlog.spec.ts b/apps/desktop/e2e/live-backlog.spec.ts index 4442d48..eabe851 100644 --- a/apps/desktop/e2e/live-backlog.spec.ts +++ b/apps/desktop/e2e/live-backlog.spec.ts @@ -16,17 +16,35 @@ test.describe('live backlog', () => { await win.waitForLoadState('domcontentloaded') const rail = win.getByRole('navigation', { name: 'Primary' }) - // The pot — waiting here also lets the reconcile (issues + deps) complete + // The pot — waiting here also lets the reconcile (issues + deps + timelines) complete await rail.getByRole('button', { name: 'The pot' }).click() await expect(win.getByText('Gitea read client behind an injected fetch')).toBeVisible({ timeout: 20000 }) + // Lifecycle inference (#5): columns come from the real event stream — merged + // work lands in Done, so that column is non-empty (proves timelines drove it, + // not the three-column fallback which would still show closed issues in Done). + await expect(win.getByText('Done', { exact: true })).toBeVisible() await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-board.png'), fullPage: true, animations: 'disabled' }) // Back to Focus — the deterministic scheduler's real Now/Next/Later. These // 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' }) + // Runway → calibration surface, fitted from real closed-issue actuals (#1). + // With <20 estimated closes the repo is honestly cold-start; the note proves + // the fit ran on real data, not the fixture's "calibrated on 27". + await rail.getByRole('button', { name: 'Runway' }).click() + await expect( + win.getByText(/cold-start priors · \d+\/20 closed issues estimated|calibrated on \d+ closed/), + ).toBeVisible() + await win.getByRole('button', { name: 'Full report' }).click() + await expect(win.getByText(/cold-start · \d+\/20|curve active · n ≥ 20/)).toBeVisible() + await win.screenshot({ path: join(here, '.artifacts', 'screens', 'live-calibration.png'), fullPage: true, animations: 'disabled' }) + await app.close() }) }) diff --git a/apps/desktop/src/main/gitea.ts b/apps/desktop/src/main/gitea.ts index 35fcecf..a443d30 100644 --- a/apps/desktop/src/main/gitea.ts +++ b/apps/desktop/src/main/gitea.ts @@ -9,7 +9,7 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' -import { createGiteaClient, type GiteaConfig } from '@commitea/core' +import { createGiteaClient, type GiteaConfig, type LifecycleEvent } from '@commitea/core' import { ipcMain } from 'electron' /** Walk up from cwd looking for a .env.local with a GITEA_TOKEN (dev convenience). */ @@ -51,7 +51,7 @@ export function registerGiteaIpc(): void { ipcMain.handle('gitea:status', () => ({ configured: !!config, repo })) ipcMain.handle('gitea:reconcile', async () => { - if (!client) return { configured: false, issues: [], milestones: [], deps: [] } + if (!client) return { configured: false, issues: [], milestones: [], deps: [], timelines: {} } const [issues, milestones] = await Promise.all([client.listIssues(), client.listMilestones()]) // dependency edges among the open scope (the scheduler only plans what's left) const open = issues.filter((i) => i.state === 'open') @@ -59,7 +59,12 @@ export function registerGiteaIpc(): void { open.map(async (i) => ({ issue: i.number, dependsOn: await client.getIssueDependencies(i.number) })), ) const deps = perIssue.flatMap(({ issue, dependsOn }) => dependsOn.map((d) => ({ issue, dependsOn: d }))) - return { configured: true, issues, milestones, deps } + // lifecycle timelines for every issue (open → columns/badges, closed → calibration actuals) + const timelineEntries = await Promise.all( + issues.map(async (i) => [i.number, await client.getIssueTimeline(i.number)] as const), + ) + const timelines: Record = Object.fromEntries(timelineEntries) + return { configured: true, issues, milestones, deps, timelines } }) ipcMain.handle('gitea:getIssue', async (_event, index: number) => { 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/calibration-screen.tsx b/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx index a3081dc..f11a2b4 100644 --- a/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/calibration-screen.tsx @@ -1,11 +1,12 @@ import React from 'react' -import { CALIBRATION } from '../../data/fixtures.js' +import { CALIBRATION, type CalibrationData } from '../../data/fixtures.js' import { Badge, Card, Icon } from '../ui/index.js' -// Calibration report — estimate-vs-actual evidence behind the cones -export function CalibrationScreen({ onBack }: { onBack: () => void }) { - const c = CALIBRATION +// Calibration report — estimate-vs-actual evidence behind the cones. +// `data` (real fit from closed-issue actuals) overrides the demo fixture. +export function CalibrationScreen({ onBack, data }: { onBack: () => void; data?: CalibrationData }) { + const c = data ?? CALIBRATION // scatter chart geometry const W = 420, @@ -68,7 +69,11 @@ export function CalibrationScreen({ onBack }: { onBack: () => void }) {

Calibration

{c.n} closed issues with estimates · evidence, not opinion

- curve active · n ≥ 20 + {c.active ? ( + curve active · n ≥ 20 + ) : ( + cold-start · {c.n}/20 + )} @@ -170,7 +175,9 @@ export function CalibrationScreen({ onBack }: { onBack: () => void }) { {c.effect.banded}

- You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with. + {c.active + ? 'You are not bad at estimating; you are optimistic in a very stable way. Stable, I can work with.' + : 'Not enough closed history yet — I’m forecasting from cold-start priors and widening the cone to stay honest. The curve takes over at 20.'}

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..41d81c8 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,23 @@ 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.coldStart + ? `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope. Cold-start priors — ${forecast.calibratedN}/20 estimated closes so far; the cone tightens as the team closes work.` + : `${forecast.scope} open ${forecast.scope === 1 ? 'issue' : 'issues'} in scope, calibrated on ${forecast.calibratedN} closed ${forecast.calibratedN === 1 ? 'issue' : 'issues'} of your own.` + : 'The cone has narrowed since Friday. I’m quietly pleased.'}

diff --git a/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx b/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx index cd93a6c..ad060b0 100644 --- a/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx +++ b/apps/desktop/src/renderer/src/components/screens/runway-screen.tsx @@ -8,15 +8,22 @@ import { RUNWAY, CAPACITY } from '../../data/fixtures.js' export function RunwayScreen({ onOpenCalibration, onOpenMilestone, + calibration, }: { onOpenCalibration: () => void onOpenMilestone: () => void + calibration?: { n: number; coldStart: boolean } }) { + const calibNote = calibration + ? calibration.coldStart + ? `cold-start priors · ${calibration.n}/20 closed issues estimated` + : `calibrated on ${calibration.n} closed ${calibration.n === 1 ? 'issue' : 'issues'}` + : 'calibrated on 27 closed issues' return (

Runway

-

capacity vs milestone dates · calibrated on 27 closed issues

+

capacity vs milestone dates · {calibNote}

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..62bfb0a 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 { backlogCalibration, 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' @@ -86,8 +86,16 @@ export function AppShell() { const [issue, setIssue] = useState(null) const [readIds, setReadIds] = useState([]) const backlog = useBacklog() - const boardColumns = backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues) : undefined - const focus = backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps) : undefined + const boardColumns = + backlog.status === 'ready' ? issuesToBoardColumns(backlog.issues, backlog.timelines) : undefined + const focus = + backlog.status === 'ready' ? scheduleFocus(backlog.issues, backlog.deps, backlog.timelines) : undefined + const calibration = + backlog.status === 'ready' ? backlogCalibration(backlog.issues, backlog.timelines) : undefined + const forecast = + backlog.status === 'ready' + ? (forecastBacklog(backlog.issues, backlog.deps, new Date(), calibration?.model) ?? undefined) + : undefined useEffect(() => { document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light') @@ -158,7 +166,7 @@ export function AppShell() { const renderScreen = () => { switch (view) { case 'focus': - return + return case 'standup': return setView('focus')} onOpenIssue={openIssue} /> case 'board': @@ -174,10 +182,11 @@ export function AppShell() { setView('calibration')} onOpenMilestone={() => setView('milestone')} + calibration={calibration ? { n: calibration.model.n, coldStart: calibration.model.coldStart } : undefined} /> ) case 'calibration': - return setView('runway')} /> + return setView('runway')} data={calibration?.data} /> case 'milestone': return setView('runway')} onOpenIssue={openIssue} /> case 'inbox': diff --git a/apps/desktop/src/renderer/src/global.d.ts b/apps/desktop/src/renderer/src/global.d.ts index 468e19f..019d932 100644 --- a/apps/desktop/src/renderer/src/global.d.ts +++ b/apps/desktop/src/renderer/src/global.d.ts @@ -1,4 +1,4 @@ -import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' +import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core' /** The gitea bridge exposed by the preload over IPC (main-process backed). */ export interface GiteaBridge { @@ -8,6 +8,8 @@ export interface GiteaBridge { issues: GiteaIssue[] milestones: GiteaMilestone[] deps: DependencyEdge[] + /** Normalized lifecycle events keyed by issue number. */ + timelines: Record }> getIssue(index: number): Promise } diff --git a/apps/desktop/src/renderer/src/lib/backlog.ts b/apps/desktop/src/renderer/src/lib/backlog.ts index 52a71b9..5aec932 100644 --- a/apps/desktop/src/renderer/src/lib/backlog.ts +++ b/apps/desktop/src/renderer/src/lib/backlog.ts @@ -1,14 +1,32 @@ import { + type CalibrationModel, + type CalibrationSample, + calibrationSamples, + COLD_START_THRESHOLD, type DependencyEdge, + fitCalibration, + forecast, type GiteaIssue, - inferColumnV0, + inferLifecycle, type LifecycleColumn, + type LifecycleEvent, + type LifecycleInference, + PRIOR_BUCKETS, schedule, type ScheduledItem, selectFocus, + toDurationModel, } from '@commitea/core' -import { type BoardColumn, type BoardIssue, type FocusIssue } from '../data/fixtures.js' +import { type BoardColumn, type BoardIssue, type CalibrationData, type FocusIssue } from '../data/fixtures.js' +import { type BurnUpData, buildBurnUpData } from './dates.js' + +type Timelines = Record + +/** Infer every issue's lifecycle once; callers index by issue number. */ +function inferAll(issues: GiteaIssue[], timelines: Timelines, asOf: Date): Map { + return new Map(issues.map((i) => [i.number, inferLifecycle(i, timelines[i.number] ?? [], asOf)])) +} const COLUMN_LABELS: Record = { diagnosis: 'Diagnosis', @@ -25,24 +43,32 @@ function initials(login: string): string { } /** - * Shape real gitea issues into the Board's five columns via lifecycle-v0. - * `steeping` / `review` stay empty until event inference (P1-5). `days` / `pr` - * are likewise event-derived and omitted here. + * Shape real gitea issues into the Board's five columns via lifecycle inference + * (#5). Steeping / In-review are now populated from the event stream (first + * commit ref → steeping, first PR ref → review); the `days` badge is the + * steeping age in working days. */ -export function issuesToBoardColumns(issues: GiteaIssue[]): BoardColumn[] { +export function issuesToBoardColumns( + issues: GiteaIssue[], + timelines: Timelines = {}, + asOf: Date = new Date(), +): BoardColumn[] { + const inf = inferAll(issues, timelines, asOf) return COLUMN_ORDER.map((key) => ({ id: key, label: COLUMN_LABELS[key], issues: issues - .filter((i) => inferColumnV0(i) === key) - .map( - (i): BoardIssue => ({ + .filter((i) => inf.get(i.number)!.column === key) + .map((i): BoardIssue => { + const li = inf.get(i.number)! + return { id: i.number, title: i.title, labels: i.labels, who: i.assignee ? initials(i.assignee) : '·', - }), - ), + days: li.steepingDays != null ? `${li.steepingDays}d` : undefined, + } + }), })) } @@ -52,9 +78,138 @@ export interface FocusView { later: FocusIssue | null } -function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { +function toFocusIssue(item: ScheduledItem | null, inf?: Map): FocusIssue | null { if (!item) return null - return { id: item.number, title: item.title, labels: item.labels, rationale: item.rationale } + const steepingDays = inf?.get(item.number)?.steepingDays ?? null + return { + id: item.number, + title: item.title, + labels: item.labels, + rationale: item.rationale, + steeping: steepingDays != null ? `${steepingDays}d` : undefined, + } +} + +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 + /** true while the forecast still runs on code priors (calibration not yet trusted). */ + coldStart: boolean + /** Closed-with-estimate issues feeding calibration so far. */ + calibratedN: number +} + +/** + * Monte Carlo forecast over the open backlog, mapped onto a calendar-anchored + * burn-up cone. When a calibration model is supplied and past cold-start, its + * fitted params drive the sim. Returns null when there's nothing to forecast. + * `today` is injectable for tests. + */ +export function forecastBacklog( + issues: GiteaIssue[], + deps: DependencyEdge[], + today: Date = new Date(), + calibration?: CalibrationModel, +): ForecastView | null { + const model = calibration ? toDurationModel(calibration) : undefined + const f = forecast(toSchedulable(issues), deps, model ? { model } : {}) + const cone = buildBurnUpData(f, today) + if (!cone) return null + return { + scope: f.scope, + cone, + p80Label: cone.p80Label, + rangeLabel: cone.rangeLabel, + coldStart: f.coldStart, + calibratedN: calibration?.n ?? 0, + } +} + +/** Fit the calibration model from the closed backlog's inferred actuals (#1). */ +export function calibrateBacklog( + issues: GiteaIssue[], + timelines: Timelines = {}, + asOf: Date = new Date(), +): CalibrationModel { + return fitCalibration(calibrationSamples(issues, timelines, asOf)) +} + +/** Calibration model + its screen view in one pass over the closed backlog. */ +export function backlogCalibration( + issues: GiteaIssue[], + timelines: Timelines = {}, + asOf: Date = new Date(), +): { model: CalibrationModel; data: CalibrationData } { + const samples = calibrationSamples(issues, timelines, asOf) + const model = fitCalibration(samples) + return { model, data: calibrationData(model, samples, issues) } +} + +const pctFromMu = (mu: number) => Math.round((Math.exp(mu) - 1) * 100) + +/** + * Shape the calibration model + its samples into the screen's view. Buckets and + * people only earn a bias once their sample clears the fit floor; everything + * degrades honestly on a thin (cold-start) dataset. + */ +export function calibrationData( + model: CalibrationModel, + samples: CalibrationSample[], + openIssues: GiteaIssue[], +): CalibrationData { + const labels = PRIOR_BUCKETS.map((b) => { + const inBucket = samples.filter((s) => s.bucket === b) + const fit = model.byBucket[b] + const mu = fit ? fit.mu : model.global.mu + return { + label: `est/${b}d`, + n: fit ? fit.n : inBucket.length, + median: inBucket.length ? `${(b * Math.exp(mu)).toFixed(1)}d` : '—', + bias: fit ? pctFromMu(fit.mu) : null, + } + }) + + const people = Object.entries(model.byPerson).map(([who, pb]) => ({ + who, + n: pb.n, + bias: pctFromMu(model.global.mu + pb.biasMu), + note: '', + })) + + const openEst = openIssues + .filter((i) => i.state === 'open') + .reduce((sum, i) => sum + (i.facts.estimateDays ?? 2), 0) + const effect = model.coldStart + ? { raw: `${model.n}/${COLD_START_THRESHOLD} estimated closes`, banded: 'cold-start priors', p50: '—' } + : { + raw: `${openEst}d estimated`, + banded: `×${Math.exp(model.global.mu).toFixed(2)} median drift`, + p50: `≈${Math.round(openEst * Math.exp(model.global.mu))}d`, + } + + return { + n: model.n, + active: !model.coldStart, + labels, + people, + scatter: samples.map((s) => [s.estimateDays, s.actualWorkingDays]), + fit: Number(Math.exp(model.global.mu).toFixed(2)), + effect, + } } /** @@ -62,18 +217,18 @@ function toFocusIssue(item: ScheduledItem | null): FocusIssue | null { * 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, - ) +export function scheduleFocus( + issues: GiteaIssue[], + deps: DependencyEdge[], + timelines: Timelines = {}, + asOf: Date = new Date(), +): FocusView { + const plan = schedule(toSchedulable(issues), deps) const f = selectFocus(plan) - return { now: toFocusIssue(f.now), next: toFocusIssue(f.next), later: toFocusIssue(f.later) } + const inf = inferAll(issues, timelines, asOf) + return { + now: toFocusIssue(f.now, inf), + next: toFocusIssue(f.next, inf), + later: toFocusIssue(f.later, inf), + } } 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/apps/desktop/src/renderer/src/lib/use-backlog.ts b/apps/desktop/src/renderer/src/lib/use-backlog.ts index 4f14ca5..17b4848 100644 --- a/apps/desktop/src/renderer/src/lib/use-backlog.ts +++ b/apps/desktop/src/renderer/src/lib/use-backlog.ts @@ -1,12 +1,18 @@ import { useEffect, useState } from 'react' -import type { DependencyEdge, GiteaIssue, GiteaMilestone } from '@commitea/core' +import type { DependencyEdge, GiteaIssue, GiteaMilestone, LifecycleEvent } from '@commitea/core' export type BacklogState = | { status: 'loading' } | { status: 'unconfigured' } | { status: 'error'; message: string } - | { status: 'ready'; issues: GiteaIssue[]; milestones: GiteaMilestone[]; deps: DependencyEdge[] } + | { + status: 'ready' + issues: GiteaIssue[] + milestones: GiteaMilestone[] + deps: DependencyEdge[] + timelines: Record + } /** * Reconcile the managed repo once on mount, through the main-process bridge. @@ -24,7 +30,13 @@ export function useBacklog(): BacklogState { if (!alive) return setState( r.configured - ? { status: 'ready', issues: r.issues, milestones: r.milestones, deps: r.deps } + ? { + status: 'ready', + issues: r.issues, + milestones: r.milestones, + deps: r.deps, + timelines: r.timelines, + } : { status: 'unconfigured' }, ) }) diff --git a/packages/core/src/calibration/calibration-v0.test.ts b/packages/core/src/calibration/calibration-v0.test.ts new file mode 100644 index 0000000..09e8486 --- /dev/null +++ b/packages/core/src/calibration/calibration-v0.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest' + +import { extractLabelFacts } from '../labels/label-schema.js' +import type { LifecycleEvent } from '../lifecycle/lifecycle-v0.js' +import type { GiteaIssue } from '../gitea/types.js' +import { + CALIBRATION_BUCKET_FLOOR, + calibrationSamples, + type CalibrationSample, + COLD_START_THRESHOLD, + fitCalibration, + toDurationModel, +} from './calibration-v0.js' + +function sample(over: Partial = {}): CalibrationSample { + return { issue: 1, estimateDays: 2, actualWorkingDays: 2, bucket: 2, person: null, ...over } +} + +describe('fitCalibration', () => { + it('is cold-start below the threshold and reports the honest n', () => { + const m = fitCalibration([sample(), sample({ actualWorkingDays: 4 })]) + expect(m.n).toBe(2) + expect(m.coldStart).toBe(true) + }) + + it('flips off cold-start at the threshold', () => { + const many = Array.from({ length: COLD_START_THRESHOLD }, (_, i) => + sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }), + ) + const m = fitCalibration(many) + expect(m.n).toBe(COLD_START_THRESHOLD) + expect(m.coldStart).toBe(false) + }) + + it('recovers the global median ratio (mu = mean log-ratio)', () => { + // every actual is exactly 2x its estimate → mu = ln 2 + const m = fitCalibration(Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 4 }))) + expect(m.global.mu).toBeCloseTo(Math.log(2), 6) + }) + + it('fits a bucket only once it clears the floor', () => { + const twos = Array.from({ length: CALIBRATION_BUCKET_FLOOR }, (_, i) => + sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 }), + ) + const oneThin = [sample({ issue: 99, estimateDays: 5, actualWorkingDays: 9, bucket: 5 })] + const m = fitCalibration([...twos, ...oneThin]) + expect(m.byBucket[2]?.n).toBe(CALIBRATION_BUCKET_FLOOR) + expect(m.byBucket[5]).toBeUndefined() // only 1 sample, below floor + }) + + it('drops non-positive estimates/actuals', () => { + const m = fitCalibration([sample({ actualWorkingDays: 0 }), sample({ estimateDays: 0 }), sample()]) + expect(m.n).toBe(1) + }) + + it('derives a per-person bias relative to global', () => { + // one person consistently runs longer than the mean + const base = Array.from({ length: 20 }, (_, i) => sample({ issue: i, actualWorkingDays: 2, person: 'ak' })) + const slow = Array.from({ length: 3 }, (_, i) => sample({ issue: 100 + i, actualWorkingDays: 6, person: 'sm' })) + const m = fitCalibration([...base, ...slow]) + expect(m.byPerson['sm'].biasMu).toBeGreaterThan(0) + expect(m.byPerson['ak'].biasMu).toBeLessThan(0) + }) +}) + +describe('toDurationModel', () => { + it('projects the fit down to the params forecast needs', () => { + const m = fitCalibration( + Array.from({ length: 25 }, (_, i) => sample({ issue: i, estimateDays: 2, actualWorkingDays: 3, bucket: 2 })), + ) + const dm = toDurationModel(m) + expect(dm.coldStart).toBe(false) + expect(dm.byBucket[2].mu).toBeCloseTo(m.byBucket[2].mu, 6) + expect(dm.global.mu).toBeCloseTo(m.global.mu, 6) + }) +}) + +describe('calibrationSamples', () => { + const asOf = new Date('2026-02-01T00:00:00Z') + + function issue(over: Partial): GiteaIssue { + const labels = over.labels ?? [] + return { + number: 1, + title: '#1', + body: '', + state: 'closed', + labels, + facts: extractLabelFacts(labels), + milestone: null, + assignee: null, + assignees: [], + createdAt: '2026-01-05T09:00:00Z', + updatedAt: '2026-01-12T09:00:00Z', + closedAt: '2026-01-12T09:00:00Z', + url: '', + ...over, + } + } + + const events = (i: number): Record => ({ + [i]: [{ type: 'commit', at: '2026-01-07T09:00:00Z' }, { type: 'close', at: '2026-01-12T09:00:00Z' }], + }) + + it('samples closed, estimated issues with a resolvable actual', () => { + const i = issue({ number: 7, labels: ['est/2d'], assignee: 'sm', closedAt: '2026-01-12T09:00:00Z' }) + const [s] = calibrationSamples([i], events(7), asOf) + expect(s.issue).toBe(7) + expect(s.estimateDays).toBe(2) + expect(s.bucket).toBe(2) + expect(s.person).toBe('sm') + // Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days + expect(s.actualWorkingDays).toBe(3) + }) + + it('skips open issues and closed ones without an estimate', () => { + const open = issue({ number: 8, state: 'open', labels: ['est/2d'], closedAt: null }) + const noEst = issue({ number: 9, labels: [] }) + expect(calibrationSamples([open, noEst], { ...events(8), ...events(9) }, asOf)).toEqual([]) + }) +}) diff --git a/packages/core/src/calibration/calibration-v0.ts b/packages/core/src/calibration/calibration-v0.ts new file mode 100644 index 0000000..75ec6fb --- /dev/null +++ b/packages/core/src/calibration/calibration-v0.ts @@ -0,0 +1,127 @@ +/** + * Calibration, v0 — fit the team's own estimate-vs-actual history so the + * forecast stops guessing (D3). The "actual" is the working time lifecycle + * inference derives from git events (#5), never manual tracking. Fit a + * lognormal on log(actual / estimate) globally and per estimate bucket; until + * the sample clears the cold-start threshold, the forecast keeps using the + * code-resident priors and this model just reports progress toward it. + */ + +import { type DurationModel, type LognormalPrior, nearestBucket } from '../forecast/forecast-v0.js' +import { inferLifecycle, type LifecycleEvent } from '../lifecycle/lifecycle-v0.js' +import type { GiteaIssue } from '../gitea/types.js' + +/** Global sample size at which the fit takes over from the cold-start priors. */ +export const COLD_START_THRESHOLD = 20 +/** Minimum per-bucket sample before that bucket earns its own fit. */ +export const CALIBRATION_BUCKET_FLOOR = 3 +/** Fallback spread when a group is too small to estimate one. */ +const DEFAULT_SIGMA = 0.4 + +/** One closed issue's estimate vs its inferred actual. */ +export interface CalibrationSample { + issue: number + estimateDays: number + actualWorkingDays: number + bucket: number + person: string | null +} + +export interface BucketFit extends LognormalPrior { + n: number +} + +export interface PersonBias { + /** Additive to global mu (log space). */ + biasMu: number + n: number +} + +export interface CalibrationModel { + /** Closed issues with an estimate + a resolvable actual. */ + n: number + /** true while n < COLD_START_THRESHOLD — forecast keeps the code priors. */ + coldStart: boolean + global: LognormalPrior + byBucket: Record + byPerson: Record +} + +function mean(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0) / xs.length +} + +/** Sample standard deviation; falls back to DEFAULT_SIGMA below 2 points. */ +function stddev(xs: number[], mu: number): number { + if (xs.length < 2) return DEFAULT_SIGMA + const variance = xs.reduce((a, x) => a + (x - mu) ** 2, 0) / (xs.length - 1) + return Math.sqrt(variance) || DEFAULT_SIGMA +} + +/** Fit a calibration model from estimate-vs-actual samples. Pure. */ +export function fitCalibration(samples: CalibrationSample[]): CalibrationModel { + const usable = samples.filter((s) => s.estimateDays > 0 && s.actualWorkingDays > 0) + const n = usable.length + const coldStart = n < COLD_START_THRESHOLD + + const logRatios = usable.map((s) => Math.log(s.actualWorkingDays / s.estimateDays)) + const globalMu = n ? mean(logRatios) : 0 + const global: LognormalPrior = { mu: globalMu, sigma: n ? stddev(logRatios, globalMu) : DEFAULT_SIGMA } + + const byBucket: Record = {} + const byPerson: Record = {} + const groups = new Map() + const people = new Map() + for (const s of usable) { + const lr = Math.log(s.actualWorkingDays / s.estimateDays) + ;(groups.get(s.bucket) ?? groups.set(s.bucket, []).get(s.bucket)!).push(lr) + if (s.person) (people.get(s.person) ?? people.set(s.person, []).get(s.person)!).push(lr) + } + for (const [bucket, lrs] of groups) { + if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue + const mu = mean(lrs) + byBucket[bucket] = { mu, sigma: stddev(lrs, mu), n: lrs.length } + } + for (const [person, lrs] of people) { + if (lrs.length < CALIBRATION_BUCKET_FLOOR) continue + byPerson[person] = { biasMu: mean(lrs) - globalMu, n: lrs.length } + } + + return { n, coldStart, global, byBucket, byPerson } +} + +/** The subset of a model `forecast` consumes. */ +export function toDurationModel(model: CalibrationModel): DurationModel { + const byBucket: Record = {} + for (const [bucket, fit] of Object.entries(model.byBucket)) { + byBucket[Number(bucket)] = { mu: fit.mu, sigma: fit.sigma } + } + return { coldStart: model.coldStart, global: model.global, byBucket } +} + +/** + * Extract calibration samples from the closed backlog: each closed issue that + * carries an estimate and yields an inferred actual working duration. + */ +export function calibrationSamples( + issues: GiteaIssue[], + timelines: Record, + asOf: Date, +): CalibrationSample[] { + const out: CalibrationSample[] = [] + for (const issue of issues) { + if (issue.state !== 'closed') continue + const estimateDays = issue.facts.estimateDays + if (estimateDays == null) continue + const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf) + if (inf.actualWorkingDays == null || inf.actualWorkingDays <= 0) continue + out.push({ + issue: issue.number, + estimateDays, + actualWorkingDays: inf.actualWorkingDays, + bucket: nearestBucket(estimateDays), + person: issue.assignee, + }) + } + return out +} 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..17c9d14 --- /dev/null +++ b/packages/core/src/forecast/forecast-v0.test.ts @@ -0,0 +1,123 @@ +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) + }) + + it('a cold-start model changes nothing — the code priors still drive it', () => { + const priors = forecast(scope, [], { trials: 1000, seed: 7 }) + const cold = forecast(scope, [], { + trials: 1000, + seed: 7, + model: { coldStart: true, global: { mu: 5, sigma: 0.1 }, byBucket: {} }, + }) + expect(cold.coldStart).toBe(true) + expect(cold.p50Day).toBeCloseTo(priors.p50Day, 6) + }) + + it('a fitted model drives the sim once past cold-start', () => { + // an optimistic fit (mu < 0, tight sigma) should land the scope sooner than the pessimistic priors + const priors = forecast(scope, [], { trials: 2000, seed: 7 }) + const fitted = forecast(scope, [], { + trials: 2000, + seed: 7, + model: { coldStart: false, global: { mu: -0.2, sigma: 0.1 }, byBucket: {} }, + }) + expect(fitted.coldStart).toBe(false) + expect(fitted.p50Day).toBeLessThan(priors.p50Day) + }) +}) diff --git a/packages/core/src/forecast/forecast-v0.ts b/packages/core/src/forecast/forecast-v0.ts new file mode 100644 index 0000000..04134fb --- /dev/null +++ b/packages/core/src/forecast/forecast-v0.ts @@ -0,0 +1,193 @@ +/** + * 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 }, +} + +export const PRIOR_BUCKETS = [1, 2, 3, 5, 8] + +/** Nearest estimate bucket (ties resolve to the smaller bucket). */ +export function nearestBucket(days: number): number { + let best = PRIOR_BUCKETS[0] + for (const b of PRIOR_BUCKETS) { + if (Math.abs(b - days) < Math.abs(best - days)) best = b + } + return best +} + +/** The cold-start prior for the bucket nearest to `days`. */ +export function priorForEstimate(days: number): LognormalPrior { + return COLD_START_PRIORS[nearestBucket(days)] +} + +/** The lognormal parameters `forecast` needs, per estimate bucket. */ +export interface DurationModel { + coldStart: boolean + /** Fallback params (used when a bucket lacks its own fit). */ + global: LognormalPrior + /** Per-bucket fitted params; missing buckets fall back to `global`. */ + byBucket: Record +} + +export interface ForecastOptions { + /** Simulation trials. More = smoother tails, linear cost. */ + trials?: number + /** PRNG seed. Fixed by default so a forecast is reproducible. */ + seed?: number + /** + * Fitted duration model. When present and not cold-start, its params drive + * the sim; otherwise the code-resident cold-start priors do. + */ + model?: DurationModel +} + +/** Resolve the lognormal params for an estimate, preferring a fitted model. */ +export function durationParams(days: number, model?: DurationModel): LognormalPrior { + if (model && !model.coldStart) { + return model.byBucket[nearestBucket(days)] ?? model.global + } + return priorForEstimate(days) +} + +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 coldStart = options.model ? options.model.coldStart : true + const order = schedule(issues, edges).items // empty when a dependency cycle exists + const n = order.length + if (n === 0) { + return { scope: 0, trials, coldStart, p50Day: 0, p80Day: 0, p95Day: 0, curve: [] } + } + + const priors = order.map((it) => durationParams(it.durationDays, options.model)) + 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, + p50Day: percentile(total, 0.5), + p80Day: percentile(total, 0.8), + p95Day: percentile(total, 0.95), + curve, + } +} diff --git a/packages/core/src/gitea/client.test.ts b/packages/core/src/gitea/client.test.ts index f8dc005..989c23f 100644 --- a/packages/core/src/gitea/client.test.ts +++ b/packages/core/src/gitea/client.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createGiteaClient, normalizeIssue } from './client.js' +import { createGiteaClient, normalizeIssue, normalizeTimeline } from './client.js' import { GiteaApiError, type FetchLike, type GiteaConfig, type GiteaRequestInit } from './types.js' const CONFIG: GiteaConfig = { @@ -44,6 +44,31 @@ function stubFetch(body: unknown, status = 200): { return { fetch, calls } } +describe('normalizeTimeline', () => { + it('maps known gitea event types to lifecycle signals and drops the rest', () => { + const events = normalizeTimeline([ + { type: 'label', created_at: '2026-01-06T09:00:00Z' }, + { type: 'milestone', created_at: '2026-01-06T09:05:00Z' }, + { type: 'commit_ref', created_at: '2026-01-07T12:00:00Z' }, + { type: 'pull_ref', created_at: '2026-01-08T12:00:00Z' }, + { type: 'comment', created_at: '2026-01-08T13:00:00Z' }, // dropped + { type: 'add_dependency', created_at: '2026-01-08T14:00:00Z' }, // dropped + { type: 'close', created_at: '2026-01-12T09:00:00Z' }, + ]) + expect(events).toEqual([ + { type: 'triage', at: '2026-01-06T09:00:00Z' }, + { type: 'triage', at: '2026-01-06T09:05:00Z' }, + { type: 'commit', at: '2026-01-07T12:00:00Z' }, + { type: 'pull', at: '2026-01-08T12:00:00Z' }, + { type: 'close', at: '2026-01-12T09:00:00Z' }, + ]) + }) + + it('skips events missing a timestamp', () => { + expect(normalizeTimeline([{ type: 'commit_ref', created_at: '' }])).toEqual([]) + }) +}) + describe('createGiteaClient.getIssue', () => { it('returns a normalized, typed issue from canned JSON', async () => { const { fetch } = stubFetch(RAW_ISSUE) diff --git a/packages/core/src/gitea/client.ts b/packages/core/src/gitea/client.ts index 0cc1441..794ec67 100644 --- a/packages/core/src/gitea/client.ts +++ b/packages/core/src/gitea/client.ts @@ -6,6 +6,7 @@ * get the full set. */ +import { type LifecycleEvent, type LifecycleEventType } from '../lifecycle/lifecycle-v0.js' import { extractLabelFacts } from '../labels/label-schema.js' import { GiteaApiError, @@ -54,6 +55,33 @@ interface RawMilestoneFull { closed_issues: number } +/** The subset of a gitea timeline comment we read. */ +interface RawTimelineComment { + type: string + created_at: string +} + +/** gitea timeline `type` → our lifecycle signal. Unmapped types are dropped. */ +const TIMELINE_TYPE_MAP: Record = { + label: 'triage', + milestone: 'triage', + assignees: 'triage', + commit_ref: 'commit', + pull_ref: 'pull', + close: 'close', + reopen: 'reopen', +} + +/** Map a raw gitea timeline to normalized lifecycle events. Pure. */ +export function normalizeTimeline(raw: RawTimelineComment[]): LifecycleEvent[] { + const out: LifecycleEvent[] = [] + for (const c of raw) { + const type = TIMELINE_TYPE_MAP[c.type] + if (type && c.created_at) out.push({ type, at: c.created_at }) + } + return out +} + export interface ListIssuesOptions { /** @default 'all' */ state?: 'open' | 'closed' | 'all' @@ -68,6 +96,8 @@ export interface GiteaClient { listMilestones(): Promise /** The issue indices this issue depends on (its blockers). */ getIssueDependencies(index: number): Promise + /** Normalized lifecycle events for one issue (all pages of its timeline). */ + getIssueTimeline(index: number): Promise } /** Map raw gitea issue JSON to the normalized domain shape. Pure. */ @@ -163,5 +193,12 @@ export function createGiteaClient(config: GiteaConfig, fetchImpl: FetchLike): Gi const raw = (await request(`/issues/${index}/dependencies`)) as { number: number }[] return raw.map((d) => d.number) }, + + async getIssueTimeline(index) { + const raw = await requestAll( + (page) => `/issues/${index}/timeline?page=${page}&limit=${PAGE_LIMIT}`, + ) + return normalizeTimeline(raw) + }, } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 961fe39..c5b9716 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,7 +9,7 @@ export { } from './labels/label-schema.js' export type { EstimateLabel, LabelFacts, PriorityLabel } from './labels/label-schema.js' -export { createGiteaClient, normalizeIssue, normalizeMilestone } from './gitea/client.js' +export { createGiteaClient, normalizeIssue, normalizeMilestone, normalizeTimeline } from './gitea/client.js' export type { GiteaClient, ListIssuesOptions } from './gitea/client.js' export { GiteaApiError } from './gitea/types.js' export type { @@ -22,8 +22,19 @@ export type { GiteaRequestInit, } from './gitea/types.js' -export { inferColumnV0, LIFECYCLE_COLUMNS } from './lifecycle/lifecycle-v0.js' -export type { LifecycleColumn } from './lifecycle/lifecycle-v0.js' +export { + inferColumnV0, + inferLifecycle, + LIFECYCLE_COLUMNS, + workingDaysBetween, +} from './lifecycle/lifecycle-v0.js' +export type { + LifecycleColumn, + LifecycleEvent, + LifecycleEventType, + LifecycleInference, + LifecycleStages, +} from './lifecycle/lifecycle-v0.js' export { DEFAULT_ESTIMATE_DAYS, schedule, selectFocus } from './scheduler/scheduler-v0.js' export type { @@ -33,3 +44,33 @@ export type { ScheduledItem, SchedulePlan, } from './scheduler/scheduler-v0.js' + +export { + COLD_START_PRIORS, + durationParams, + forecast, + nearestBucket, + PRIOR_BUCKETS, + priorForEstimate, +} from './forecast/forecast-v0.js' +export type { + BurnUpPoint, + DurationModel, + Forecast, + ForecastOptions, + LognormalPrior, +} from './forecast/forecast-v0.js' + +export { + CALIBRATION_BUCKET_FLOOR, + calibrationSamples, + COLD_START_THRESHOLD, + fitCalibration, + toDurationModel, +} from './calibration/calibration-v0.js' +export type { + BucketFit, + CalibrationModel, + CalibrationSample, + PersonBias, +} from './calibration/calibration-v0.js' diff --git a/packages/core/src/lifecycle/lifecycle-v0.test.ts b/packages/core/src/lifecycle/lifecycle-v0.test.ts index 0f74f87..a288ee5 100644 --- a/packages/core/src/lifecycle/lifecycle-v0.test.ts +++ b/packages/core/src/lifecycle/lifecycle-v0.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' -import { inferColumnV0 } from './lifecycle-v0.js' +import { + inferColumnV0, + inferLifecycle, + type LifecycleEvent, + workingDaysBetween, +} from './lifecycle-v0.js' const base = { state: 'open' as const, labels: [] as string[], milestone: null } @@ -27,3 +32,93 @@ describe('inferColumnV0', () => { expect(['steeping', 'review']).not.toContain(col) }) }) + +describe('workingDaysBetween', () => { + it('counts weekdays in [start, end), excluding weekends', () => { + // Mon 2026-01-05 → Mon 2026-01-12 spans a full week = 5 working days + expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-12'))).toBe(5) + }) + + it('is 0 for same day or reversed', () => { + expect(workingDaysBetween(new Date('2026-01-05'), new Date('2026-01-05'))).toBe(0) + expect(workingDaysBetween(new Date('2026-01-12'), new Date('2026-01-05'))).toBe(0) + }) + + it('skips a weekend inside the interval', () => { + // Fri 2026-01-09 → Mon 2026-01-12: only Fri counts (Sat/Sun excluded) = 1 + expect(workingDaysBetween(new Date('2026-01-09'), new Date('2026-01-12'))).toBe(1) + }) +}) + +describe('inferLifecycle', () => { + const openBase = { ...base, createdAt: '2026-01-05T09:00:00Z', closedAt: null } + const asOf = new Date('2026-01-14T09:00:00Z') + + const ev = (type: LifecycleEvent['type'], at: string): LifecycleEvent => ({ type, at }) + + it('bare open issue with no events is diagnosis', () => { + const inf = inferLifecycle(openBase, [], asOf) + expect(inf.column).toBe('diagnosis') + expect(inf.stages.opened).toBe(openBase.createdAt) + expect(inf.actualWorkingDays).toBeNull() + expect(inf.steepingDays).toBeNull() + }) + + it('a triage event (or a current label) moves it to triage', () => { + expect(inferLifecycle(openBase, [ev('triage', '2026-01-06T09:00:00Z')], asOf).column).toBe('triage') + expect(inferLifecycle({ ...openBase, labels: ['p/1'] }, [], asOf).column).toBe('triage') + }) + + it('a commit ref moves an open issue to steeping and counts its working age', () => { + const inf = inferLifecycle( + { ...openBase, labels: ['est/2d'] }, + [ev('triage', '2026-01-05T10:00:00Z'), ev('commit', '2026-01-07T12:00:00Z')], + asOf, + ) + expect(inf.column).toBe('steeping') + expect(inf.stages.steeping).toBe('2026-01-07T12:00:00Z') + // Wed 2026-01-07 → Wed 2026-01-14 = 5 working days + expect(inf.steepingDays).toBe(5) + }) + + it('a pull ref outranks a commit ref → review', () => { + const inf = inferLifecycle( + openBase, + [ev('commit', '2026-01-07T12:00:00Z'), ev('pull', '2026-01-08T12:00:00Z')], + asOf, + ) + expect(inf.column).toBe('review') + expect(inf.steepingDays).toBeNull() // only steeping issues carry an age + }) + + it('closed → done, with actual working time from work-start to close', () => { + const inf = inferLifecycle( + { ...openBase, state: 'closed', closedAt: '2026-01-12T09:00:00Z' }, + [ev('commit', '2026-01-07T09:00:00Z'), ev('pull', '2026-01-08T09:00:00Z'), ev('close', '2026-01-12T09:00:00Z')], + asOf, + ) + expect(inf.column).toBe('done') + expect(inf.stages.done).toBe('2026-01-12T09:00:00Z') + // Wed 2026-01-07 → Mon 2026-01-12 = Wed,Thu,Fri = 3 working days + expect(inf.actualWorkingDays).toBe(3) + }) + + it('takes the earliest event of each kind for stage timestamps', () => { + const inf = inferLifecycle( + openBase, + [ev('commit', '2026-01-09T09:00:00Z'), ev('commit', '2026-01-07T09:00:00Z')], + asOf, + ) + expect(inf.stages.steeping).toBe('2026-01-07T09:00:00Z') + }) + + it('falls back to opened when a closed issue has no work-start signal', () => { + const inf = inferLifecycle( + { ...openBase, state: 'closed', closedAt: '2026-01-08T09:00:00Z' }, + [ev('close', '2026-01-08T09:00:00Z')], + asOf, + ) + // Mon 2026-01-05 → Thu 2026-01-08 = Mon,Tue,Wed = 3 working days + expect(inf.actualWorkingDays).toBe(3) + }) +}) diff --git a/packages/core/src/lifecycle/lifecycle-v0.ts b/packages/core/src/lifecycle/lifecycle-v0.ts index 936737d..a34613a 100644 --- a/packages/core/src/lifecycle/lifecycle-v0.ts +++ b/packages/core/src/lifecycle/lifecycle-v0.ts @@ -1,12 +1,10 @@ /** - * Lifecycle inference, v0 — the coarse column an issue sits in, derived from - * *only* what a single issues-list read gives us (state, labels, milestone). + * Lifecycle inference. * - * The real five-column inference (P1-5) needs the issue timeline: first - * branch/commit ref → Steeping, PR opened → In review, PR merged → Deploy. - * Until that lands, v0 can only place three columns honestly; `steeping` and - * `review` stay empty rather than guess. Board renders all five columns and - * fills the middle two once the event stream is available. + * `inferColumnV0` places the coarse three columns from a single issues-list + * read (state/labels/milestone). `inferLifecycle` (#5) adds the issue timeline: + * first commit ref → Steeping, first PR ref → In review, close → Done — and + * derives the estimate-vs-actual working time that feeds calibration (D3). */ import type { GiteaIssue } from '../gitea/types.js' @@ -23,10 +21,109 @@ export const LIFECYCLE_COLUMNS: readonly LifecycleColumn[] = [ /** * Closed → done. Open with any human intent applied (a label or a milestone) - * → triage. Open and bare → diagnosis. Never returns steeping/review in v0. + * → triage. Open and bare → diagnosis. Never returns steeping/review — that's + * `inferLifecycle`, which reads the event stream. */ export function inferColumnV0(issue: Pick): LifecycleColumn { if (issue.state === 'closed') return 'done' const hasIntent = issue.labels.length > 0 || issue.milestone !== null return hasIntent ? 'triage' : 'diagnosis' } + +/** A timeline signal, normalized from gitea's raw event stream. */ +export type LifecycleEventType = 'triage' | 'commit' | 'pull' | 'close' | 'reopen' + +export interface LifecycleEvent { + type: LifecycleEventType + /** ISO timestamp. */ + at: string +} + +/** Timestamps for the stages an issue has reached (absent = not yet reached). */ +export interface LifecycleStages { + opened: string + /** First label / milestone / assignment. */ + triaged?: string + /** First commit referencing the issue — work began. */ + steeping?: string + /** First PR referencing the issue — in review. */ + review?: string + /** Closed. */ + done?: string +} + +export interface LifecycleInference { + column: LifecycleColumn + stages: LifecycleStages + /** + * Working days from work-start (steeping → triaged → opened, first available) + * to done. Only for closed issues — this is the "actual" calibration learns + * from. null while open. + */ + actualWorkingDays: number | null + /** Working days the issue has been steeping (first commit → asOf); null unless currently steeping. */ + steepingDays: number | null +} + +const DAY_MS = 86_400_000 + +/** + * Whole working days (Mon–Fri) in the half-open interval [start, end). Same day + * or reversed → 0. Day-granular by design — estimates are in days. + */ +export function workingDaysBetween(start: Date, end: Date): number { + const s = Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()) + const e = Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()) + if (e <= s) return 0 + let count = 0 + for (let t = s; t < e; t += DAY_MS) { + const dow = new Date(t).getUTCDay() + if (dow !== 0 && dow !== 6) count += 1 + } + return count +} + +/** + * The five-column inference. Column reflects the furthest stage still in play: + * closed → done; else an open PR ref → review; else a commit ref → steeping; + * else any triage signal (or a current label/milestone) → triage; else + * diagnosis. Stage timestamps are the earliest event of each kind. + */ +export function inferLifecycle( + issue: Pick, + events: LifecycleEvent[], + asOf: Date, +): LifecycleInference { + const sorted = [...events].sort((a, b) => a.at.localeCompare(b.at)) + const firstOf = (type: LifecycleEventType) => sorted.find((e) => e.type === type)?.at + + const stages: LifecycleStages = { opened: issue.createdAt } + const triagedAt = firstOf('triage') + const steepingAt = firstOf('commit') + const reviewAt = firstOf('pull') + if (triagedAt) stages.triaged = triagedAt + if (steepingAt) stages.steeping = steepingAt + if (reviewAt) stages.review = reviewAt + const doneAt = issue.state === 'closed' ? (issue.closedAt ?? firstOf('close')) : undefined + if (doneAt) stages.done = doneAt + + let column: LifecycleColumn + if (issue.state === 'closed') column = 'done' + else if (reviewAt) column = 'review' + else if (steepingAt) column = 'steeping' + else if (triagedAt || issue.labels.length > 0 || issue.milestone !== null) column = 'triage' + else column = 'diagnosis' + + let actualWorkingDays: number | null = null + if (stages.done) { + const start = stages.steeping ?? stages.triaged ?? stages.opened + actualWorkingDays = workingDaysBetween(new Date(start), new Date(stages.done)) + } + + let steepingDays: number | null = null + if (column === 'steeping' && stages.steeping) { + steepingDays = workingDaysBetween(new Date(stages.steeping), asOf) + } + + return { column, stages, actualWorkingDays, steepingDays } +}