/* * **한 바퀴** — 커널이 라인을 돌리고, 그 이벤트를 계산해, 배운 수율이 다시 커널로 들어간다. * * ── 왜 이 시험이 필요했나 (2026-08-19) ───────────────────────────────────── * 층마다 시험이 있었다(커널 7 · 호스트 9). 그런데 **사슬이 이어졌는지**는 층 시험으로 알 수 없다 — * 이벤트 모양이 한 칸만 어긋나도 각 층은 통과하면서 전체는 알리지 않고 상수로 돈다. * * 실 앱에서 보려고 데모 MES 트윈으로 한 바퀴를 돌리려 했는데 두 번 막혔다: ① 데모의 자극(시나리오)은 * **시드 때만 실려서** 트윈을 재기동하면 사라진다 ② 모델에서 읽은 값으로 자극을 재구성했지만 부품 도착이 * 레시피 입력과 맞물리지 않아 라인이 돌지 않았다. 그 두 개는 **데모 데이터의 문제**이고, 우리가 증명해야 * 하는 것(사슬)과는 다른 문제다. * * 그래서 여기서 **DB·서버 없이** 진짜 사슬을 돌린다: 커널(MES) → `task.status` 이벤트 → 호스트의 실제 * 폴드(`foldKpi`) → 실제 추정기(`buildYieldEstimator`) → 커널 시임. 가짜 이벤트를 손으로 만들지 않는다 — * 커널이 낸 것을 그대로 처리한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { foldKpi } from '../server/engine/kpi-fold.ts' import { buildYieldEstimator } from '../server/engine/measured-yield.ts' /* 커널은 한 벌로 묶인 산출물로 들어온다(서브패스는 exports 로 막혀 있다 — property-effects 시험과 같은 이유). */ const require = createRequire(import.meta.url) const { MesKernel } = require('@operato/twin-kernel') /* 이 시험이 쓰는 부품 품번을 여기서 선언합니다 — 커널이 생성한 값을 가져다 쓰지 않습니다(0.7.40 이 그 export 를 제거했습니다). 프리픽스는 GS1 이 예제로 지정한 952 대역입니다. */ const MES_PART_GTINS = { partA: 'urn:epc:idpat:sgtin:9521321.011111.*', partB: 'urn:epc:idpat:sgtin:9521321.022222.*' } /* 선언(SPEC.binding)과 같은 값이어야 자극이 그 자재로 들어옵니다. */ /** 보드·자극은 커널 시험(`mes.test.ts`)의 것과 같은 모양 — 여기서 공장을 지어내지 않는다. */ function board() { return { locations: [ { id: 'raw-store', type: 'raw-store', capacity: 500 }, { id: 'cut-station', type: 'cut-station', capacity: 20 }, { id: 'weld-station', type: 'weld-station', capacity: 20 }, { id: 'paint-booth', type: 'paint-booth', capacity: 20 }, { id: 'assembly-line', type: 'assembly-line', capacity: 20 }, { id: 'fg-store', type: 'fg-store', capacity: 200 } ], equipment: [ { id: 'cutter-1', kind: 'cutter', homeLocation: 'cut-station' }, { id: 'welder-1', kind: 'welder', homeLocation: 'weld-station' }, { id: 'painter-1', kind: 'painter', homeLocation: 'paint-booth' }, { id: 'assembler-1', kind: 'assembler', homeLocation: 'assembly-line' } ] } } const partsInbound = { seed: 3, generators: [ { kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 720 }, content: { skuMix: [{ gtin: MES_PART_GTINS.partA, weight: 1 }, { gtin: MES_PART_GTINS.partB, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } } ] } const workOrders = { seed: 7, generators: [ { kind: 'outbound-order', rate: { distribution: 'constant', meanPerHour: 360 }, content: { skuMix: [{ gtin: MES_PART_GTINS.partA, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } } ] } /** * 이 라인의 **생산 선언** — 커널이 더는 내장 데모 제품을 들지 않습니다(0.7.43). * * 무엇을 만드는가는 모델이 정합니다. 그래서 이 시험도 자기 자재·라우트·레시피를 선언하고, 자재는 * 자기 보관 자리를, 현장은 작업지시의 문서 타입을 선언합니다. 프리픽스는 GS1 이 예제로 지정한 952 * 대역입니다 — 실 배정 대역을 시험 데이터에 쓰지 않습니다. */ const PREFIX = '9521321' const SPEC = { companyPrefix: PREFIX, binding: { partA: '011111', partB: '022222', unit: '033333' }, identity: { documentTypes: { workorder: '403' } }, definition: { materials: [ { key: 'partA', label: '부품 A', locationType: 'raw-store' }, { key: 'partB', label: '부품 B', locationType: 'raw-store' }, { key: 'unit', label: '완성품', locationType: 'fg-store' } ], locationTypes: [ { key: 'raw-store', label: '자재 보관' }, { key: 'cut-station', label: '절단' }, { key: 'weld-station', label: '용접' }, { key: 'paint-booth', label: '도장' }, { key: 'assembly-line', label: '조립' }, { key: 'fg-store', label: '완제품 보관' } ], resourceTypes: [ { key: 'cutter', label: '절단기' }, { key: 'welder', label: '용접기' }, { key: 'painter', label: '도장기' }, { key: 'assembler', label: '조립기' } ], operations: [ { key: 'cut', label: '절단', intent: 'process', locationType: 'cut-station', resourceType: 'cutter', duration: 'PT1M' }, { key: 'weld', label: '용접', intent: 'process', locationType: 'weld-station', resourceType: 'welder', duration: 'PT1M' }, { key: 'paint', label: '도장', intent: 'process', locationType: 'paint-booth', resourceType: 'painter', duration: 'PT1M' }, { key: 'assembly', label: '조립', intent: 'process', locationType: 'assembly-line', resourceType: 'assembler', duration: 'PT1M' } ], routes: [{ key: 'line', label: '라인', steps: ['cut', 'weld', 'paint', 'assembly'] }], recipes: [ { key: 'unit', label: '완성품', route: 'line', inputs: [{ material: 'partA', qty: 1 }, { material: 'partB', qty: 1 }], outputs: [{ material: 'unit', qty: 1 }] } ] } } /** 라인을 한 바퀴 돌린다 — 부품을 채우고 작업지시를 흘리고 배수한다. */ function runLine(declaredYieldPct?: number): { events: any[]; kernel: any } { const k: any = new MesKernel('plantA', undefined, SPEC) const events: any[] = [] k.onEvent((e: any) => events.push(e)) k.loadTwinModel(board()) /* 선언값을 넣어 두면 「실측이 선언을 이긴다」를 같은 라인에서 확인할 수 있다. */ if (declaredYieldPct !== undefined) k.declareParameters({ assembly: { yield: String(declaredYieldPct / 100) } }) k.scenario.load(partsInbound) k.scenario.start() for (let i = 0; i < 400; i++) k.tick(1000) k.scenario.pause() for (let i = 0; i < 30; i++) k.tick(1000) k.scenario.load(workOrders) k.scenario.start() for (let i = 0; i < 300; i++) k.tick(1000) k.scenario.pause() for (let i = 0; i < 4000; i++) k.tick(1000) return { events, kernel: k } } /** 커널 이벤트를 저널 줄 모양으로 — 호스트가 DB 에서 읽는 것과 같은 모양(payload 안에 data). */ const asJournal = (events: any[]) => events.filter(e => e.eventType === 'task.status').map(e => ({ eventType: 'task.status', eventTime: e.eventTime, payload: { data: e.data } })) const window = (rows: { eventTime: string }[]) => { const times = rows.map(r => Date.parse(r.eventTime)).filter(n => Number.isFinite(n)) return { fromMs: Math.min(...times) - 1000, toMs: Math.max(...times) + 1000 } } test('한 바퀴: 커널이 판정을 내고 → 폴드가 종류별로 세고 → 추정기가 비율을 배운다', () => { const { events } = runLine() const rows = asJournal(events) assert.ok(rows.length > 50, `작업 전이가 충분히 나야 한다(관측 ${rows.length})`) /* 호스트의 **실제** 폴드로 처리한다 — 시험용 계산을 따로 만들지 않는다. */ const kpi = foldKpi(rows as any, window(rows as any), { groupBy: 'taskKind' }) const judged = (kpi.groups?.items ?? []).filter(g => g.quality) assert.ok(judged.length > 0, '품질이 세어진 종류가 있어야 한다') for (const g of judged) { assert.ok(g.quality!.good + g.quality!.scrap > 0) assert.ok(g.quality!.good + g.quality!.scrap <= g.tasks, '판정 수는 완료 수를 넘지 못한다') } /* 판정이 없는 종류도 있어야 한다 — 중간 스테이션은 품질을 가리지 않는다(분모에 섞이면 안 된다). */ assert.ok((kpi.groups?.items ?? []).some(g => !g.quality), '판정하지 않는 공정이 있다') const learned = buildYieldEstimator(kpi.groups?.items, { minSamples: 5 }) assert.ok(Object.keys(learned.learned).length > 0, '수율을 배워야 한다') for (const [kind, ratio] of Object.entries(learned.learned)) { assert.ok(ratio >= 0 && ratio <= 1, `${kind} 의 비율이 0..1 이어야 한다(관측 ${ratio})`) const s = learned.samples[kind] assert.equal(learned.learned[kind], s.good / (s.good + s.scrap), '비율은 판정 수에서 곧바로 나온다') } }) test('배운 값이 커널로 되돌아가 선언값을 이긴다 — 사슬의 마지막 칸', () => { /* 선언 60% 로 라인을 돌린다(그 현장의 이력이 그렇게 남는다). */ const { events } = runLine(60) const rows = asJournal(events) const kpi = foldKpi(rows as any, window(rows as any), { groupBy: 'taskKind' }) const learned = buildYieldEstimator(kpi.groups?.items, { minSamples: 5 }) const kinds = Object.keys(learned.learned) assert.ok(kinds.length > 0, '이력에서 배운 종류가 있어야 한다') /* 새 커널에 그 이력을 실으면 — 선언값(90%)이 아니라 이력값으로 답한다. */ const k2: any = new MesKernel('plantA') k2.declareParameters(Object.fromEntries(kinds.map(kind => [kind, { yield: '0.9' }]))) k2.yieldEstimator = learned.estimator for (const kind of kinds) { assert.equal(k2.yieldOf(kind), learned.learned[kind], `${kind}: 이력값이 이긴다`) assert.deepEqual(k2.specCoverage().operations.find((o: any) => o.kind === kind)?.parameterBasis, { yield: 'measured' }) } /* 이력이 없는 종류는 그대로 선언값이다 — 배운 것만 이긴다(전부 덮지 않는다). */ k2.declareParameters({ 'never-run': { yield: '0.5' } }) assert.equal(k2.yieldOf('never-run'), 0.5) })