/* * **브로드캐스팅 한 번의 비용** — 200ms 마다 무엇을 만드는지 값으로 남긴다 (2026-08-21). * * ── 왜 재는가 ─────────────────────────────────────────────────────────────── * `publishEntityData` 는 브로드캐스팅 주기마다 ① 상태를 통째로 투영하고(`getSnapshot`) ② 엔티티마다 payload 를 * 만들고(`buildEntityDeltas`) ③ 그 payload 를 문자열로 만들어 이전 것과 비교한다. ③ 의 비교가 없던 * 시절에는 바뀌지 않은 것까지 전송했고, 그 낭비는 2026-08-14 에 없앴다 — 그런데 없앤 것은 **전송**이고 * ①②③ 의 **계산은 그대로** 남았다. 물품이 수천 개인 트윈에서는 하나만 움직여도 수천 개를 만들어 * 문자열로 바꾼 뒤 「같다」를 확인하고 버린다. * * 고치기 전에 세 축의 몫을 값으로 남긴다. 짐작으로 고치면 무엇이 나아졌는지 말할 수 없다. * * ── 고친 뒤 (2026-08-21) ──────────────────────────────────────────────────── * 이제 브로드캐스팅은 **건드린 물품만** 만든다(`buildEntityDeltas` 의 `scope.items`). 그래서 이 시험은 두 * 창을 나란히 잰다: 범위를 모를 때(전부) 와 물품 하나를 건드렸을 때(좁힘). * * ── 게이트는 시간이 아니다 ────────────────────────────────────────────────── * 기계마다·스위트 병렬 실행마다 시간은 달라진다(같은 코드에서 3.2배와 14.8배가 나온 적이 있다). 그래서 * 단정은 **시간과 무관한 사실**로 한다: 좁힌 창이 만든 물품 payload 가 말한 것 하나뿐인가. 시간은 * 기준선으로 출력만 하고, 여러 번 재 그중 **가장 짧은 값**을 적는다(GC·JIT 잡음을 덜 타는 값). */ import { test } from 'node:test' import assert from 'node:assert/strict' import { MesKernel } from '@operato/twin-kernel' import { type CanonicalEnvelope, type ProductionSpec, type ScenarioDef, type TwinModelDef } from '@operato/ops-contract' import { buildEntityDeltas } from '../shared/entity-delta.ts' const COMMISSIONING = 'urn:epcglobal:cbv:bizstep:commissioning' /* GS1 이 예제로 지정한 `952` 대역 — 실 배정 대역을 픽스처에 두지 않는다(CBV 2.0). */ const PREFIX = '9521321' const RAW_GTIN = `urn:epc:idpat:sgtin:${PREFIX}.0001.*` const DEFINITION: any = { materials: [ { key: 'raw', label: '원자재', locationType: 'raw-store' }, { key: 'product', label: '완제품', locationType: 'fg-store' } ], locationTypes: [ { key: 'raw-store', label: '자재 보관' }, { key: 'cut-st', label: '절단' }, { key: 'fg-store', label: '완제품 보관' } ], resourceTypes: [{ key: 'cutter', label: '절단기' }], operations: [{ key: 'cut', label: '절단', intent: 'process', locationType: 'cut-st', resourceType: 'cutter', bizStep: COMMISSIONING }], routes: [{ key: 'line', label: '라인', steps: ['cut'] }], recipes: [{ key: 'prod', label: '완제품', inputs: [{ material: 'raw', qty: 1 }], outputs: [{ material: 'product', qty: 1 }], route: 'line' }] } const SPEC: ProductionSpec = { definition: DEFINITION, binding: { raw: '0001', product: '0002' }, identity: { documentTypes: { workorder: '403' }, namespaces: [`urn:epc:idpat:sgtin:${PREFIX}.`] }, companyPrefix: PREFIX } function board(): TwinModelDef { return { locations: [ { id: 'raw-store', type: 'raw-store', capacity: 1_000_000 }, { id: 'cut-st', type: 'cut-st', capacity: 1_000_000 }, { id: 'fg-store', type: 'fg-store', capacity: 1_000_000 } ], equipment: Array.from({ length: 8 }, (_, i) => ({ id: `cutter-${i}`, kind: 'cutter', homeLocation: 'cut-st' })) } as TwinModelDef } function scenario(ordersPerHour: number): ScenarioDef { return { seed: 9, generators: [ { kind: 'inbound-arrival', stimulus: 'arrival', rate: { distribution: 'constant', meanPerHour: ordersPerHour * 4 }, content: { skuMix: [{ gtin: RAW_GTIN, weight: 1 }], qtyPerLine: { min: 1, max: 1 } } }, { kind: 'workorder', stimulus: 'order', rate: { distribution: 'constant', meanPerHour: ordersPerHour }, content: { skuMix: [{ gtin: 'wo', weight: 1 }], qtyPerLine: { min: 1, max: 1 } } } ] } as ScenarioDef } interface Row { items: number entities: number payloads: number /** 물품 하나만 건드렸다고 말했을 때 만드는 payload 수 — 좁히기가 듣는지. */ scopedPayloads: number snapshotMs: number /** 매핑 + 시그니처(다섯 번 중 가장 짧은 값). */ buildMs: number broadcastMs: number /** 좁힌 창의 매핑 + 시그니처 시간 — 투영은 그대로 남는다(커널 몫). */ scopedMs: number /** 만든 payload 목록 — 단정이 「무엇을 만들었나」를 직접 본다(수만으로는 축을 구별하지 못한다). */ fullDeltas: any[] scopedDeltas: any[] } /** 한 규모에서 **브로드캐스팅 한 번**의 세 축을 잰다. 상태는 미리 쌓아 두고, 재는 것은 브로드캐스팅뿐이다. */ function measure(ticks: number, ordersPerHour: number): Row { const k: any = new MesKernel('bench', undefined, SPEC) k.onEvent((_e: CanonicalEnvelope) => {}) k.loadTwinModel(board()) k.scenario.load(scenario(ordersPerHour)) k.scenario.start() for (let i = 0; i < ticks; i++) k.tick(1000) /* ① 상태 투영 — 자리·물품·자원·작업·오더를 새 객체로 만든다. */ const t0 = performance.now() const st = k.getSnapshot() const t1 = performance.now() /* * ②③ payload 만들기 + 시그니처 — **다섯 번 재 가장 짧은 값을 쓴다.** 한 번만 재면 그 순간의 GC 가 * 값에 들어가, 좁힌 창이 전부 만든 창보다 오래 걸린 것처럼 보이는 일이 실제로 있었다. */ const pass = (scope?: { items: ReadonlySet }) => { let best = Infinity let built: any[] = [] let chars = 0 for (let i = 0; i < 5; i++) { const a = performance.now() built = buildEntityDeltas(st, 'bench', scope) chars = 0 for (const d of built) chars += JSON.stringify(d.data).length best = Math.min(best, performance.now() - a) } return { built, chars, ms: Math.round(best * 10) / 10 } } const fullPass = pass() const deltas = fullPass.built const chars = fullPass.chars const entities = st.locations.length + st.items.length + st.equipment.length + (st.assets?.length ?? 0) + (st.persons?.length ?? 0) + st.tasks.length + st.orders.length /* ④ 좁힌 창 — 물품 하나만 건드렸다고 말한 경우. 같은 상태로 재야 비교가 성립한다. */ const one = new Set([st.items[0]?.epc].filter(Boolean) as string[]) const scopedPass = pass({ items: one }) const scoped = scopedPass.built assert.ok(chars > 0 && scopedPass.chars > 0, '문자열을 만들지 않았다면 측정이 성립하지 않는다') const ms = (a: number, b: number) => Math.round((b - a) * 10) / 10 return { items: st.items.length, entities, payloads: deltas.length, scopedPayloads: scoped.length, snapshotMs: ms(t0, t1), buildMs: fullPass.ms, broadcastMs: Math.round((ms(t0, t1) + fullPass.ms) * 10) / 10, scopedMs: scopedPass.ms, fullDeltas: deltas, scopedDeltas: scoped } } test('브로드캐스팅 한 번의 비용 — 좁힌 창은 건드린 물품만 만든다', () => { /* 예열은 가장 큰 규모로 한 번 버린다 — 작은 규모부터 재면 뒤 측정이 앞의 JIT 이득을 본다. */ measure(600, 3600) const rows = [measure(600, 60), measure(600, 480), measure(600, 3600)] console.log('[브로드캐스팅 한 번의 비용 · MES 선언본]') for (const r of rows) { console.log( ` 물품 ${String(r.items).padStart(5)} · 엔티티 ${String(r.entities).padStart(5)} — payload ${String(r.payloads).padStart(5)}개 · ` + `투영 ${String(r.snapshotMs).padStart(6)}ms · 만들기 ${String(r.buildMs).padStart(6)}ms · 합 ${r.broadcastMs}ms` + ` ▸ 좁힌 창: payload ${String(r.scopedPayloads).padStart(4)}개 · ${r.scopedMs}ms` ) } /* 측정이 성립했는지 — 규모가 실제로 커져야 비교에 뜻이 있다. */ assert.ok(rows[0].items > 0, '물품이 쌓여야 한다') assert.ok(rows[rows.length - 1].items > rows[0].items * 3, '규모가 실제로 커져야 한다') /* * ── 고친 뒤의 단정 (2026-08-21) ────────────────────────────────────────── * 전부 만드는 창은 여전히 엔티티 수에 비례한다 — 그 창은 그물이고 주기마다 한 번뿐이다(§그물). * * **좁힌 창이 만드는 물품 payload 는 말한 것 하나뿐이다.** 이것이 시간과 무관한 단정이다. 기울기로 * 재지 않는 이유는 이 픽스처가 물품과 함께 오더·작업도 늘리기 때문이다 — 그 축은 좁히지 않으므로 * (자리 점유·집약과 같은 이유) 기울기에는 남의 몫이 섞인다. * * 실 현장의 비율은 물품 쪽이 훨씬 무겁다(hatio-us: 물품 2,805 · 오더 192 · 작업 221). 이 픽스처는 * 오더를 과하게 실은 쪽이므로, 여기서 보이는 절감은 **실 현장보다 작다.** */ const last = rows[rows.length - 1] const itemPayloads = (ds: any[]) => ds.filter(d => d?.data?.entity === 'item').length assert.equal(itemPayloads(last.fullDeltas), last.items, '전부 만드는 창은 물품 전부를 만든다') assert.equal(itemPayloads(last.scopedDeltas), 1, '좁힌 창은 건드린 물품 하나만 만들어야 한다') /* 집약은 좁히지 않는다 — 목록 전체가 한 payload 라서 한 물품이 바뀌면 통째로 새로 만든다. */ const aggregates = (ds: any[]) => ds.filter(d => String(d.tag).startsWith('__')).length assert.equal(aggregates(last.scopedDeltas), aggregates(last.fullDeltas), '집약 뷰는 좁힌 창에서도 그대로 나간다') })