The cold-start surface showed "N/20 closed issues estimated", implying you're
just (20−N) closes away. But calibrationSamples silently drops closed+estimated
issues that closed in 0 working days (same-day closes) — real closes that
structurally can't calibrate. On this repo that's 10 of 24 closes hidden: the
note read 14/20 as if 6 away, when a third of the history will never count.
- core: `calibrationCoverage(issues, timelines, asOf)` → { candidates, usable,
excludedSameDay }, counting the silently-excluded same-day closes. Pure, tested.
- surface it: CalibrationData gains `excludedSameDay`; backlogCalibration returns
the coverage; the Runway note and the Calibration screen now say "… · N same-day
closes can't calibrate" so the thin sample is explained, not just reported.
Verified on christian/commitea: closed=24, usable=14, excludedSameDay=10.
131 core green (incl. new coverage test); core + desktop typecheck; 14 fixture e2e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
165 lines
6.1 KiB
TypeScript
165 lines
6.1 KiB
TypeScript
/**
|
|
* 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<number, BucketFit>
|
|
byPerson: Record<string, PersonBias>
|
|
}
|
|
|
|
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<number, BucketFit> = {}
|
|
const byPerson: Record<string, PersonBias> = {}
|
|
const groups = new Map<number, number[]>()
|
|
const people = new Map<string, number[]>()
|
|
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<number, LognormalPrior> = {}
|
|
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<number, LifecycleEvent[]>,
|
|
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
|
|
}
|
|
|
|
/** How the closed+estimated backlog splits into usable samples vs. what can't calibrate. */
|
|
export interface CalibrationCoverage {
|
|
/** Closed issues carrying an estimate — the calibration candidates. */
|
|
candidates: number
|
|
/** Candidates that yielded a usable actual (> 0 working days) → become samples. */
|
|
usable: number
|
|
/**
|
|
* Candidates excluded because the issue closed with 0 working days (same-day
|
|
* close) or no resolvable actual — real closes that structurally can't
|
|
* calibrate. Counting them keeps `usable/threshold` honest: it's not "N more
|
|
* closes away" if some of your closes will never count.
|
|
*/
|
|
excludedSameDay: number
|
|
}
|
|
|
|
/**
|
|
* Coverage of the calibration candidates — how many closed+estimated issues are
|
|
* usable vs. silently unusable (same-day / 0-day closes). {@link calibrationSamples}
|
|
* drops the latter; this counts them so the UI can say *why* the sample is thin.
|
|
*/
|
|
export function calibrationCoverage(
|
|
issues: GiteaIssue[],
|
|
timelines: Record<number, LifecycleEvent[]>,
|
|
asOf: Date,
|
|
): CalibrationCoverage {
|
|
let candidates = 0
|
|
let usable = 0
|
|
for (const issue of issues) {
|
|
if (issue.state !== 'closed') continue
|
|
if (issue.facts.estimateDays == null) continue
|
|
candidates++
|
|
const inf = inferLifecycle(issue, timelines[issue.number] ?? [], asOf)
|
|
if (inf.actualWorkingDays != null && inf.actualWorkingDays > 0) usable++
|
|
}
|
|
return { candidates, usable, excludedSameDay: candidates - usable }
|
|
}
|