/* * 실측 소요 추정기 — **이력에서 배운 값이 상수·선언값을 이긴다.** * * 배경(operato-twin design/plans/simulation-spec.md §5-4): 소요시간을 명세로 받을 수 있게 됐지만 * 명세는 선언이다 — 그 현장에서 실제로 얼마 걸리는지는 저널이 안다. `gap-analytics` 는 지표 수준의 * 편향만 배웠고(예측값 보정), **공정별 소요를 추정기로 되먹이는 경로는 없었다.** * * 여기서 하는 일: KPI 폴드가 작업 종류별로 낸 **작업시간(착수→완료) p50** 을 그 종류의 소요로 쓴다. * 리드타임(생성→완료)이 아니라 작업시간을 쓰는 이유: 리드타임에는 **대기가 섞여 있고**, 대기는 * 시뮬레이션이 자원 경합으로 스스로 만들어야 하는 값이다. 대기를 소요에 넣으면 이중 계산이 된다. * * **평균만 배우면 대기와 병목을 과소평가한다.** 소요가 늘 정확히 같으면 줄이 생기지 않는다 — 현장의 * 줄은 흔들림에서 나온다. 그래서 p50 만이 아니라 **관측된 퍼짐(p10..p90)** 을 함께 넘기고, 표본은 * 커널이 자기 난수로 뽑는다(fork 결정성). 양쪽 꼬리를 다 관측에서 가져오는 이유: p90 만 있으면 * 아래쪽을 위쪽에서 거울처럼 베껴야 하는데 그건 발명이다. * * 정직 규율: * - 표본이 적으면 쓰지 않는다(`minSamples`). 한두 건의 p50 은 그 현장의 값이 아니다. * - 'unknown' 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼을 수 없다. * - 퍼짐이 성립하지 않으면(p10 ≥ p90, 또는 0) **퍼짐 없이 평균만** 넘긴다. 없는 흔들림을 만들지 않는다. * - 무엇을 배웠고 무엇을 표본 부족으로 버렸는지 함께 낸다(알리지 않고 일부만 반영하지 않는다). */ /** 커널 `DurationEstimate` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */ export interface MeasuredEstimate { meanMs: number spread?: { distribution: 'triangular'; minMs: number; maxMs: number; modeMs: number } } export interface MeasuredEstimatorResult { estimator?: { estimate(ctx: { kind: string; fromNode: string; toNode: string; resourceKind?: string }): MeasuredEstimate | undefined } /** 작업 종류 → 실측 소요(ms). 추정기가 답하는 종류들. */ learned: Record /** 퍼짐까지 배운 종류 — 평균만 배운 것과 구별한다(변동 없는 소요는 대기를 과소평가한다). */ spreads: Record /** 표본이 모자라 쓰지 않은 종류와 그 건수 — 배우지 못한 것을 밝힌다. */ skipped: { kind: string; count: number }[] minSamples: number } /** 기본 표본 하한 — 이보다 적으면 그 종류는 배우지 않는다(작은 표본의 p50 을 현장 값으로 승격 금지). */ export const DEFAULT_MIN_SAMPLES = 5 /** * 작업 종류별 KPI 그룹에서 추정기를 만든다(순수 — DB·커널 모름). * `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다. */ export function buildMeasuredEstimator( groups: { key: string; workTime?: { count: number; p10Ms?: number; p50Ms: number; p90Ms?: number } }[] | undefined, opts: { minSamples?: number } = {} ): MeasuredEstimatorResult { const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES const learned: Record = {} const spreads: Record = {} const estimates: Record = {} const skipped: { kind: string; count: number }[] = [] for (const g of groups ?? []) { if (!g?.key || g.key === 'unknown') continue const w = g.workTime const count = w?.count ?? 0 const p50 = w?.p50Ms ?? 0 if (count < minSamples || !(p50 > 0)) { skipped.push({ kind: g.key, count }); continue } const mean = Math.round(p50) learned[g.key] = mean /* 퍼짐은 **관측된 두 꼬리**로만 만든다. 한쪽이라도 없거나 구간이 성립하지 않으면 평균만 넘긴다. */ const lo = w?.p10Ms const hi = w?.p90Ms if (typeof lo === 'number' && typeof hi === 'number' && lo > 0 && hi > lo) { const minMs = Math.round(lo) const maxMs = Math.round(hi) spreads[g.key] = { minMs, maxMs } estimates[g.key] = { meanMs: mean, spread: { distribution: 'triangular', minMs, maxMs, modeMs: mean } } } else { estimates[g.key] = { meanMs: mean } } } if (!Object.keys(learned).length) return { learned, spreads, skipped, minSamples } return { learned, spreads, skipped, minSamples, estimator: { estimate(ctx) { return estimates[ctx.kind] } } } }