/* * ★ 라이브 미러 시임 — projector→payload vs 커널→payload (sim↔live). * P0(live-모드 런타임, face2-inbound-live) 디리스크. 같은 이벤트를 두 드라이버에 넣고 data(tag) payload 를 비교. * * 결론(검증됨): * - 핵심 관측 상태(점유·용량·위치·아이템 = "사진에 찍힌 것")는 이벤트만으로 재구성 → sim↔live **완전 일치**. * - 파생 지표(설비 OEE·location status = "계산해서 만든 통계")는 이벤트에 없음 → live 미러엔 **부재**. * 이는 라우팅 결함이 아니라(통로는 온전), live 에서 파생 지표를 **누가 생산하나**의 데이터원 결정 사항. * (실 텔레메트리 / projector 계산 / 별도 분석 층 — face2-inbound-live §6, reference-management P3.) */ import { test } from 'node:test' import assert from 'node:assert/strict' import { WmsKernel, StateProjector, deriveAttentions } from '@operato/twin-kernel' /* OEE 공식은 계약에 있다 — 시뮬·미러·MES 셋이 같은 것을 부른다. */ import { computeOee } from '@operato/ops-contract' import { masterToTwin } from '../server/service/reference/reference-master.ts' import { buildEntityDeltas } from '../shared/entity-delta.ts' import { withLiveAttentions } from '../server/engine/live-attentions.ts' const GTIN = 'urn:epc:idpat:sgtin:0614141.099999.*' const master = () => ({ source: 'mirror', system: 'wms' as const, siteName: 'M', /* 이 픽스처가 식별자를 만드는 이름공간 — **픽스처마다 다르게** 둔다(같은 접두 아래에서 서로 다른 시험의 번호가 부딪치면 표준의 유일성 요구를 깬다, CBV 2.0 §8.2.4). 커널은 선언 없이 팔레트·문서를 만들지 않는다(0.7.45 에서 날조 프리픽스를 지웠다). */ identity: { namespaces: ['https://twin.test/live-mirror-parity'] }, space: { width: 90, depth: 40, unit: 'm' }, locations: [ { id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' }, { id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 }, { id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 60 }, { id: 'rack-2', type: 'storage', role: 'location' as const, capacity: 60 }, { id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 }, { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 } ], equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }] }) /** 커널 sim 구동 + 방출 이벤트 수집(커널 face2 테스트 패턴). */ function runSim() { const model = masterToTwin(master()).model const kernel = new WmsKernel('t') const events: any[] = [] kernel.onEvent((e: any) => events.push(e)) kernel.loadTwinModel(model) kernel.scenario.load({ seed: 42, generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 120 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }] }) kernel.scenario.start() for (let i = 0; i < 300; i++) kernel.tick(1000) kernel.scenario.pause() for (let i = 0; i < 60; i++) kernel.tick(1000) return { model, kernel, events } } const sortByTag = (arr: any[]) => [...arr].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0)) /** 파생/sim-계산 필드 제거 → "핵심 관측 상태"만 남긴다. * location.status = 커널 기본값(파생), equipment.oee = 커널 계산 지표(파생). 이벤트에 없어 live 미러 부재. */ /** * 설계상 두 구동이 다른 자리만 걷어낸다 — **여기 없는 차이는 결함이다.** * * 엔티티 마커가 `kind` → `entity` 로 바뀌었을 때(도메인 kind 를 마커가 덮어쓰던 문제) 이 필터가 * 따라가지 못해 **아무것도 걸러내지 못하고 있었다.** 그래서 실패가 "정합성 결함" 처럼 보였다. * * 지금 남는 차이는 하나뿐이다: * · `oee` — 시뮬은 자기가 계측하고, 라이브는 **호스트**가 이벤트를 적분해 채운다(withLiveOee). * 커널 층에서만 비대칭이고 호스트가 닫는다(커널 적합성 하네스도 같은 예외를 선언한다). * · `__orders__.finished` — 「최근 T 완료」 창의 오른쪽 끝은 그 구동의 「지금」이다(ADR-0092). 시뮬은 시계, * 호스트의 미러는 관측 모드 커널이 마지막으로 들은 시각을 싣는다. 이 시험의 `StateProjector` 는 저널 재생용이라 * 「지금」이 없고, 그래서 창을 싣지 않는다 — 0 으로 메우지 않는 것이 설계다. 창 수의 정합은 order-fold-host 시험이 본다. * (`location.status` 는 예외였다가 해소됐다 — 저장값이 아니라 포화도 파생이라 두 구동이 같은 값을 낸다.) */ const coreOnly = (d: any) => { const c = { ...d } if (c.entity === 'equipment') delete c.oee if (c.entity === 'orders') delete c.finished return c } /** sim(커널) · live(projector 재투영) payload 를 태그로 정렬해 나란히. */ function bothPayloads() { const { model, kernel, events } = runSim() assert.ok(events.length > 0, 'sim 이벤트 생성됨') const proj = new StateProjector(model) for (const env of events) proj.apply(env) return { sim: sortByTag(buildEntityDeltas(kernel.getSnapshot(), 't1')), live: sortByTag(buildEntityDeltas(proj.snapshot(), 't1')) } } test('라이브 미러: 엔티티 태그 집합 sim↔live 동일(모든 엔티티 미러)', () => { const { sim, live } = bothPayloads() assert.deepEqual(live.map(d => d.tag), sim.map(d => d.tag), '태그 집합 동일') }) test('★ 라이브 미러: 핵심 관측 상태(점유·용량·위치·아이템) sim↔live 완전 일치', () => { const { sim, live } = bothPayloads() const simCore = sim.map(d => ({ tag: d.tag, data: coreOnly(d.data) })) const liveCore = live.map(d => ({ tag: d.tag, data: coreOnly(d.data) })) // 통로(라우팅)는 온전 — 이벤트로 재구성되는 관측 상태는 두 드라이버가 동일 → 모델이 동일 렌더. assert.deepEqual(liveCore, simCore, 'occupancy/capacity/location/item 등 핵심 관측 상태 일치') }) /* 고장 주입(mtbf) + 소용량 랙(병목) + 고부하 — 파생 층(attentions·oee·motion·clock)을 최대 노출. 드레인 안 함. */ function runSimRich() { const m = { source: 'rich', system: 'wms' as const, siteName: 'R', /* 같은 파일이라도 픽스처가 다르면 이름공간도 다르다 — 두 픽스처의 번호가 한 공간에서 겹치지 않게. */ identity: { namespaces: ['https://twin.test/live-mirror-parity/rich'] }, space: { width: 90, depth: 40, unit: 'm' }, locations: [ { id: 'a', type: 'warehouse', role: 'area' as const, name: '창고' }, { id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 }, { id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 6 }, // 소용량 → 병목 { id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 }, { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 } ], equipment: [ { id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv', mtbfMs: 5000, mttrMs: 8000 }, // 고장 { id: 'fk-2', kind: 'forklift', homeLocationId: 'dock-recv' } ] } const model = masterToTwin(m).model const kernel = new WmsKernel('t') const events: any[] = [] kernel.onEvent((e: any) => events.push(e)) kernel.loadTwinModel(model) kernel.scenario.load({ seed: 7, generators: [{ kind: 'inbound-arrival', rate: { distribution: 'constant', meanPerHour: 600 }, content: { skuMix: [{ gtin: GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }] }) kernel.scenario.start() for (let i = 0; i < 120; i++) kernel.tick(1000) // 이동중·고장·병목이 살아있는 시점 const proj = new StateProjector(model) for (const env of events) proj.apply(env) return { sim: kernel.getSnapshot() as any, live: proj.snapshot() as any } } /* * ★ 명시(완전 목록): sim 이 "계산"하는 모든 파생 층은 live 미러에 부재/부분 — 라우팅 결함 아님(관측은 미러됨). * live 모드는 projector(관측) + 커널 derive 함수(계산 층) 로 이 층을 복원해야 한다(P0). face2-inbound-live §6. */ test('명시: 파생 층(attentions·OEE·motion·clock)은 live 미러 부재/부분 — live derive 층 필요', () => { const { sim, live } = runSimRich() // 1) attentions(주목/알람) — 모니터링 핵심. projector 미계산 → live 부재. (현재 상태 순수함수라 재계산 가능.) assert.ok((sim.attentions?.length ?? 0) > 0, 'sim: attentions 계산됨(예: breakdown)') assert.equal(sim.attentions.length > 0 && (live.attentions?.length ?? 0), 0, 'live: attentions 부재 — computeAttentions 를 미러 State 에 재적용해야 복원') // 2) OEE(계산 지표) — 시간 누적. live 부재. const simFk = sim.equipment.find((x: any) => x.id === 'fk-1') const liveFk = live.equipment.find((x: any) => x.id === 'fk-1') assert.ok(simFk?.oee, 'sim: 설비 OEE 계산됨') assert.equal(liveFk?.oee, undefined, 'live: OEE 부재(시간 누적 지표)') // 3) simClockMs(sim 클럭) — live 는 실시간이 클럭원. assert.equal(typeof sim.simClockMs, 'number', 'sim: simClockMs 있음') assert.equal(live.simClockMs, undefined, 'live: sim 클럭 없음(실시간이 클럭원)') // 4) 관측 status 는 일치(이벤트 기반) — 라우팅/통로가 온전함의 재확인. assert.equal(simFk?.status, liveFk?.status, 'operable status 는 sim↔live 일치(관측)') // (motion 은 부분 재구성 — move 이벤트로 일부만. 완전 보간은 live derive 층 몫.) }) /* * ★ derive 층 복원: sim/live 가 계산 층(deriveAttentions)을 공유 — 커널 함수를 projector 미러 State 에 재적용해 * live 에 없던 관측기반 attentions(breakdown·bottleneck·hold)를 복원. (OEE 등 metric 층은 별도.) * → face2-inbound-live §1.1 "live = projector(관측) + derive 층(계산)" 을 attentions 로 실증. */ test('★ derive 층: computeAttentions 를 projector 미러에 재적용 → 관측기반 attentions 복원', () => { const { sim, live } = runSimRich() // (a) 순수 함수가 커널 자체 attentions 를 재현 — 추출 정확성(sim state 로 호출 == 커널 계산 관측기반). const simDerivedKinds = new Set(deriveAttentions({ equipment: sim.equipment, locations: sim.locations, orders: sim.orders }).map(a => a.kind)) for (const a of sim.attentions ?? []) { if (['breakdown', 'bottleneck', 'hold'].includes(a.kind)) { assert.ok(simDerivedKinds.has(a.kind), `순수 함수가 sim 관측기반 attention '${a.kind}' 재현`) } } // (b) ★ projector 미러 State 에 재적용 → live 원본엔 없던 attentions 복원. assert.equal(live.attentions?.length ?? 0, 0, 'live projector 원본엔 attentions 없음(계산 안 함)') const liveKinds = new Set(deriveAttentions({ equipment: live.equipment, locations: live.locations, orders: live.orders }).map(a => a.kind)) assert.ok(liveKinds.has('breakdown') || liveKinds.has('bottleneck'), 'derive 재적용 → live 에 관측기반 attention(breakdown/bottleneck) 복원') }) test('★ withLiveAttentions: projector 미러 스냅샷에 attentions 보강 (attention 질의 live 결선)', () => { const { live } = runSimRich() // 원 갭: projector 미러 스냅샷엔 attentions 없음. assert.equal(live.attentions?.length ?? 0, 0, 'projector 미러엔 attentions 부재') // 보강 → 스냅샷에 attentions(관측기반) 포함 → TwinEngine.snapshot(live) 이 이걸 질의에 노출. const enriched = withLiveAttentions(live) const kinds = new Set((enriched.attentions ?? []).map((a: any) => a.kind)) assert.ok(kinds.has('breakdown') || kinds.has('bottleneck'), 'withLiveAttentions → 스냅샷 attentions 보강(sim 동형)') }) /* * ★ OEE derive: attentions 와 달리 OEE 입력(누적 카운터)은 관측 State·이벤트에 없다 → 미러만으론 재계산 불가. * 그러나 **공식 computeOee 는 sim/live 공유** — live 가 카운터(실 텔레메트리 또는 이벤트 누적기)를 공급하면 재생산. * 이 테스트는 공식이 공급된 카운터로 sim OEE 를 재생산함을 증명(= 입력원만 결정하면 live OEE 성립). */ test('★ OEE derive: 공유 공식 computeOee 가 공급된 카운터로 OEE 재생산 (live=텔레메트리/누적기 카운터 + 공식)', () => { const { sim } = runSimRich() const m = sim.equipment.find((x: any) => x.oee && (x.oee.runMs > 0 || x.oee.downMs > 0)) ?? sim.equipment.find((x: any) => x.oee) assert.ok(m?.oee, 'sim 무버 oee 존재') const o = m.oee /* * live 텔레메트리/누적기가 공급할 카운터 + **계측 창** 으로 공식 재적용. * * 창을 명시한다(`metricsSinceMs: 0` 부터 `planned` 까지). 예전 이 검사는 창을 넘기지 않고 공식이 * 그것을 epoch(0)로 메우는 것에 **기대고** 있었다 — 그 메움이 화면에 56,000년 유휴를 세운 결함이라 * 없앴다. 이제 창을 모르면 잰 시간이 없으므로, 재생산에는 창이 함께 와야 한다. 라이브 공급자가 * 실제로 하는 일이 그것이다(누적기는 첫 이벤트 시각을 창으로 낸다). */ const planned = o.runMs + o.setupMs + o.downMs + o.delayMs const derived = computeOee( { runMs: o.runMs, setupMs: o.setupMs, downMs: o.downMs, goodCount: o.goodCount, nonconformingCount: o.nonconformingCount, metricsSinceMs: 0 }, planned ) /* 세 비율은 근거가 없으면 없다 — 양쪽 다 없는 것도 재생산이다(계약 0.5.0). */ const near = (a?: number, b?: number) => (a == null && b == null) || (a != null && b != null && Math.abs(a - b) < 1e-6) assert.ok(near(derived.availability, o.availability), `availability 재생산 (${derived.availability} vs ${o.availability})`) assert.ok(near(derived.performance, o.performance), 'performance 재생산') assert.ok(near(derived.quality, o.quality), 'quality 재생산') assert.ok(near(derived.overall, o.overall), 'overall 재생산') }) /* * ── 배선 감시: 라이브가 커널에 **모든 채널을 넘기는가** ────────────────────── * * 규칙(deriveAttentions)은 커널 테스트가 지킨다. 지켜지지 않던 것은 **배선**이다 — * `withLiveAttentions` 가 채널 하나를 안 넘기면 같은 공장이 시뮬에서는 막혔다고 하고 라이브에서는 * 멀쩡하다고 답한다. 규칙은 멀쩡하니 규칙 테스트는 통과하고, 어긋남은 아무 데서도 안 잡힌다. * (`tasks` 를 새로 넣을 때 실제로 그럴 뻔했다.) * * 그래서 채널마다 신호가 하나씩 나오는 상태를 만들고, **넷이 다 나오는지** 본다. 앞으로 채널이 * 하나 더 늘고 라이브 배선만 빠지면 여기서 깨진다. */ test('★ withLiveAttentions 는 채널을 하나도 떨어뜨리지 않는다(설비·자리·오더·작업)', () => { const state = { nowTime: '2026-03-02T12:00:00.000Z', equipment: [{ id: 'rbt-1', status: 'down', location: 'wc-1' }], locations: [{ id: 'wc-1', capacity: 2, occupancy: 2 }], orders: [{ id: 'ord-1', held: true, status: 'released' }], tasks: Array.from({ length: 8 }, (_, i) => ({ id: `t${i}`, kind: 'paint', status: 'created' })) } const kinds = new Set((withLiveAttentions(state as any).attentions ?? []).map((a: any) => a.kind)) for (const expected of ['breakdown', 'bottleneck', 'hold', 'work-backlog']) assert.ok(kinds.has(expected), `라이브 배선이 '${expected}' 채널을 떨어뜨렸다 — 넘긴 것: ${[...kinds].join(', ') || '없음'}`) }) /* * ── 되짚은 상태에도 신호가 있어야 한다 (2026-08-05) ──────────────────────── * 라이브·시뮬은 커널이 신호를 스스로 내지만, 저널을 계산하는 경로는 프로젝터 상태만 낸다 — 신호 키가 * 아예 없었다. 지도는 `snap.attentions` 를 읽으므로 **과거를 되짚으면 주의 레일이 텅 비었고**, * 기동돼 있지 않은 트윈을 보는 화면도 같았다. 실 저널로 재 보니 `rosarito-wms` 는 246건이 비어 있었다. * * 신호는 상태에서 계산되는 것이므로 같은 공식으로 채우면 된다(두 벌을 두지 않는다). 다만 "늦었나" * 판정이 `nowTime` 을 보므로 그 값이 먼저다 — 되짚은 시점의 정직한 "지금" 은 물어본 시각이고, * 벽시계를 끼워 넣으면 과거 화면이 "지금 기준으로 늦었다" 고 말한다. */ test('★ 프로젝터 폴드 상태에는 신호 키가 없다 — 보강이 필요한 이유', () => { const { model, events } = runSim() const proj = new StateProjector(model) for (const env of events) proj.apply(env) const raw: any = proj.snapshot() assert.equal(raw.attentions, undefined, '계산한 상태는 신호를 스스로 내지 않는다(커널이 아니다)') }) test('★ 보강하면 다시 계산한 상태에도 신호가 있다 — 지도의 주의 레일이 비지 않게', () => { /* 용량 1 자리를 채워 병목이 스스로 성립하게 만든다(신호를 손으로 넣지 않는다). */ const st: any = { revision: 3, nowTime: '2026-08-05T00:00:00.000Z', locations: [{ id: 'bin-tight', type: 'storage', capacity: 1, occupancy: 1 }], equipment: [], orders: [], tasks: [] } const out = withLiveAttentions(st) assert.ok((out.attentions ?? []).length > 0, '계산한 상태에서도 규칙이 신호를 낸다') assert.equal(out.attentions![0].kind, 'bottleneck') })