/* * 실측 양품률 — **이력에서 배운 수율이 선언값·상수를 이긴다.** * * ── 왜 (2026-08-19) ───────────────────────────────────────────────────────── * 소요시간에는 이력 층이 있었는데 수율에는 없었다. 양품률 기본값은 커널 소스의 상수(0.8)였고 **그 상수가 * 불량 과다 주목 신호까지 만들었다.** 이제 커널이 판정을 작업 완료에 적고(`outcome`), 폴드가 축으로 들고, * KPI 가 종류별로 세고(`quality`), 이 모듈이 비율로 바꿔 커널 시임에 넣는다. * * 이 시험이 지키는 것: * ① 분모는 **판정된 것만** — 이동·체류를 섞으면 양품률이 낮게 나온다. * ② 표본이 모자라면 **배우지 않는다** — 비율은 드문 사건을 재는 값이라 작은 표본이 잡음을 만든다. * ③ 0 도 사실이다 — 전량 불량이면 0 을 배운다(「값이 없다」와 다르다). * ④ 모르는 종류에는 `undefined` — 그러면 커널이 선언값·상수로 내려간다. * ⑤ 배우지 못한 것을 **밝힌다** — 알리지 않고 일부만 반영하지 않는다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { buildYieldEstimator, DEFAULT_YIELD_MIN_SAMPLES } from '../server/engine/measured-yield.ts' const group = (key: string, good: number, scrap: number) => ({ key, quality: { good, scrap } }) test('판정된 것만 분모다 — 이동·체류가 섞이지 않는다', () => { /* tasks 는 1000 건이어도 판정은 40 건뿐인 종류가 정상이다(중간 스테이션은 품질을 가리지 않는다). */ const r = buildYieldEstimator([{ key: 'assembly', tasks: 1000, quality: { good: 36, scrap: 4 } } as any]) assert.equal(r.learned.assembly, 0.9, '36/40 = 0.9') assert.deepEqual(r.samples.assembly, { good: 36, scrap: 4 }) }) test('표본이 모자라면 배우지 않고 그 사실을 낸다 — 세 건 중 한 건 불량은 33% 가 아니다', () => { const r = buildYieldEstimator([group('weld', 2, 1)]) assert.deepEqual(r.learned, {}) assert.deepEqual(r.skipped, [{ kind: 'weld', judged: 3 }]) assert.equal(r.estimator, undefined, '배운 것이 없으면 시임을 만들지 않는다') assert.equal(r.minSamples, DEFAULT_YIELD_MIN_SAMPLES) }) test('하한은 소요보다 높다 — 비율은 드문 사건을 재는 값이다', () => { /* 불량률 5% 인 공정은 20건에서 불량이 한 건이다 — 그 아래로 내려가면 이력이 잡음이 된다. */ assert.ok(DEFAULT_YIELD_MIN_SAMPLES >= 20, `하한이 ${DEFAULT_YIELD_MIN_SAMPLES} 이면 비율에 너무 낮다`) }) test('전량 불량은 0 을 배운다 — 「값이 없다」와 구별한다', () => { const r = buildYieldEstimator([group('paint', 0, 25)]) assert.equal(r.learned.paint, 0) assert.equal(r.estimator!.estimate({ kind: 'paint' }), 0, '0 을 답한다(undefined 로 뭉개지 않는다)') }) test('판정이 아예 없는 종류는 건너뛴 것이 아니다 — 품질을 가리지 않는 공정이 정상이다', () => { const r = buildYieldEstimator([{ key: 'putaway', tasks: 500 } as any]) assert.deepEqual(r.learned, {}) assert.deepEqual(r.skipped, [], '표본 부족으로 세지 않는다') }) test('모르는 종류에는 undefined — 커널이 선언값·상수로 내려갈 수 있게', () => { const r = buildYieldEstimator([group('weld', 30, 2)]) assert.equal(r.estimator!.estimate({ kind: 'weld' }), 30 / 32) assert.equal(r.estimator!.estimate({ kind: 'paint' }), undefined) }) test("'unknown' 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼지 않는다", () => { const r = buildYieldEstimator([group('unknown', 100, 5)]) assert.deepEqual(r.learned, {}) }) test('빈 입력에도 답을 만든다 — 부르는 쪽이 조건문을 두 벌 갖지 않게', () => { for (const input of [undefined, []]) { const r = buildYieldEstimator(input as any) assert.deepEqual(r.learned, {}) assert.equal(r.estimator, undefined) } }) /* * ── 사슬이 이어져 있나 (소스로 지킨다) ────────────────────────────────────── * 이 층은 넷이 이어져야 뜻이 있다: 커널이 판정을 적고 → 폴드가 축으로 들고 → KPI 가 세고 → 여기서 비율. * 한 칸만 빠져도 화면은 「이력에서 배운 값」이라고 말하면서 상수로 돈다. 실 앱에서 관측하려면 라인이 * 한 바퀴 돌아야 하므로(판정은 완료에서 일어난다), 경로는 여기서 지킨다. */ test('사슬 넷이 이어져 있다 — 판정→축→집계→비율', () => { const hostSrc = (f: string) => readFileSync(fileURLToPath(new URL(`../server/${f}`, import.meta.url)), 'utf-8') assert.match(hostSrc('engine/kpi-fold.ts'), /facets\.outcome/, 'KPI 가 판정 축을 읽는다') assert.match(hostSrc('engine/kpi-fold.ts'), /quality\?: \{ good: number; scrap: number \}/, '종류별로 센다') const engine = hostSrc('engine/twin-engine.ts') assert.match(engine, /buildYieldEstimator\(kpi\?\.groups\?\.items/, '같은 폴드에서 배운다(두 번 계산하지 않는다)') assert.match(engine, /kernel\.yieldEstimator = yields\.estimator/, '커널 시임에 넣는다') assert.match(engine, /yieldOf missing/, '옛 커널이면 그 사실을 말한다') })