/** * 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 } /** 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, 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 } }