/* * 실측 양품률 추정기 — **이력에서 배운 수율이 선언값·상수를 이긴다.** * * ── 왜 필요했나 (2026-08-19) ──────────────────────────────────────────────── * 소요시간에는 이력 층이 있었다(`measured-estimator`): 저널이 「그 현장에서 실제로 얼마 걸렸나」를 안다. * 수율에는 그 층이 없었다 — 양품률의 기본값은 커널 소스의 상수(0.8)였고, **그 상수가 불량 과다 주목 * 신호까지 만들었다.** 초기 라인이든 성숙한 라인이든 같은 수로 판정된다는 뜻이다. * * 그런데 이력을 만들 재료도 없었다: 양품/불량은 설비 누적 카운터(종류를 모른다)와 EPCIS disposition * (종류를 모른다)에만 남았다. 그래서 커널이 **판정을 일어난 자리(작업 완료)에 적게** 했고 * (`TaskStatusDelta.outcome`), 폴드가 축으로 들고(`TaskFacets.outcome`), KPI 가 종류별로 센다 * (`KpiGroup.quality`). 이 파일은 그 수를 비율로 바꿔 커널 시임에 넣는다. * * ── 정직 규율 (소요 추정기와 같은 것) ─────────────────────────────────────── * - **표본이 적으면 쓰지 않는다**(`minSamples`). 세 건 중 한 건 불량은 「불량률 33%」가 아니다. * - `'unknown'` 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼을 수 없다. * - **판정이 없는 작업은 분모에 넣지 않는다.** KPI 가 이미 그렇게 세지만(`quality` 는 판정된 것만), * 여기서도 `good + scrap` 만 쓴다 — `tasks` 를 분모로 쓰면 이동·체류가 섞여 양품률이 낮게 나온다. * - **0 도 사실이다.** 전부 불량이면 0 을 배운다(그 현장이 그랬다). 「값이 없다」와 구별해 다룬다 — * 커널이 `0` 을 받으면 전량 불량으로 굴러가고, 그것이 관측과 같은 결론이다. * - 무엇을 배웠고 무엇을 표본 부족으로 버렸는지 함께 낸다(알리지 않고 일부만 반영하지 않는다). */ /** 커널 `YieldEstimator` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */ export interface YieldEstimatorResult { estimator?: { estimate(ctx: { kind: string }): number | undefined } /** 작업 종류 → 실측 양품률(0..1). 추정기가 답하는 종류들. */ learned: Record /** 종류별 표본 — 배운 값의 근거 크기(화면·로그가 「몇 건에서 배웠나」를 말할 수 있게). */ samples: Record /** 표본이 모자라 쓰지 않은 종류와 판정 건수 — 배우지 못한 것을 밝힌다. */ skipped: { kind: string; judged: number }[] minSamples: number } /** * 기본 표본 하한 — 판정 20건 미만이면 그 종류는 배우지 않는다. * * 소요(5건)보다 높게 잡는 이유: 비율은 **드문 사건**을 재는 값이다. 불량률 5% 인 공정에서 20건을 봐도 * 불량은 한 건이고, 그 한 건이 있고 없음으로 비율이 0% 와 5% 사이를 오간다. 낮게 잡으면 추정기가 * 이력이라는 이름으로 잡음을 실어 보낸다. */ export const DEFAULT_YIELD_MIN_SAMPLES = 20 /** * 작업 종류별 KPI 그룹에서 양품률 추정기를 만든다(순수 — DB·커널 모름). * `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다. */ export function buildYieldEstimator( groups: { key: string; quality?: { good: number; scrap: number } }[] | undefined, opts: { minSamples?: number } = {} ): YieldEstimatorResult { const minSamples = opts.minSamples ?? DEFAULT_YIELD_MIN_SAMPLES const learned: Record = {} const samples: Record = {} const skipped: { kind: string; judged: number }[] = [] for (const g of groups ?? []) { if (!g?.key || g.key === 'unknown') continue const good = g.quality?.good ?? 0 const scrap = g.quality?.scrap ?? 0 const judged = good + scrap /* 판정이 아예 없는 종류는 **건너뛴 것이 아니다** — 품질을 가리지 않는 공정(이동·체류)이 정상이다. */ if (judged === 0) continue if (judged < minSamples) { skipped.push({ kind: g.key, judged }) continue } learned[g.key] = good / judged samples[g.key] = { good, scrap } } const kinds = Object.keys(learned) if (!kinds.length) return { learned, samples, skipped, minSamples } return { /* * 시임은 **무상태**다(커널이 fork 할 때 참조를 공유한다 — 소요 추정기와 같은 규약). * 배우지 않은 종류에는 `undefined` 를 답한다: 그러면 커널이 선언값 → 상수로 내려간다. */ estimator: { estimate: ({ kind }) => learned[kind] }, learned, samples, skipped, minSamples } }