@things-factory/headless-twin 10.0.5 → 10.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +45 -53
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +16 -0
  12. package/dist-server/engine/kpi-query.js +129 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +181 -18
  39. package/dist-server/engine/twin-engine.js +545 -96
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +39 -0
  42. package/dist-server/engine/warm-start.js +37 -0
  43. package/dist-server/engine/warm-start.js.map +1 -0
  44. package/dist-server/index.js +8 -0
  45. package/dist-server/index.js.map +1 -1
  46. package/dist-server/service/index.d.ts +4 -2
  47. package/dist-server/service/index.js +21 -14
  48. package/dist-server/service/index.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +2 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +307 -1
  52. package/dist-server/service/reference/reference-master.js +96 -8
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +3 -3
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  59. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  60. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  61. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  62. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  63. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  64. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  65. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  66. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  67. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  68. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  69. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  70. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  71. package/dist-server/service/twin-event/twin-event.d.ts +16 -0
  72. package/dist-server/service/twin-event/twin-event.js +50 -0
  73. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  74. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  75. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  76. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  77. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  78. package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
  79. package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
  80. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  81. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  82. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  83. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  84. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  85. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  86. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  87. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  89. package/dist-server/service/twin-space/twin-space.js +5 -0
  90. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  91. package/dist-server/service/twin-structure/index.d.ts +2 -0
  92. package/dist-server/service/twin-structure/index.js +6 -0
  93. package/dist-server/service/twin-structure/index.js.map +1 -0
  94. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  95. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  96. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  97. package/dist-server/service/twin-target/index.d.ts +4 -0
  98. package/dist-server/service/twin-target/index.js +8 -0
  99. package/dist-server/service/twin-target/index.js.map +1 -0
  100. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  101. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  102. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  103. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  104. package/dist-server/service/twin-target/twin-target.js +112 -0
  105. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  106. package/dist-server/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +6 -6
  108. package/server/engine/entity-delta.ts +135 -15
  109. package/server/engine/index.ts +4 -0
  110. package/server/engine/kpi-fold.ts +62 -59
  111. package/server/engine/kpi-query.ts +140 -19
  112. package/server/engine/kpi-target.ts +226 -0
  113. package/server/engine/live-attentions.ts +12 -2
  114. package/server/engine/measured-estimator.ts +91 -0
  115. package/server/engine/model-basis.ts +94 -0
  116. package/server/engine/oee-accumulator.ts +5 -5
  117. package/server/engine/spec-coverage.ts +85 -0
  118. package/server/engine/structure-diff.ts +88 -0
  119. package/server/engine/travel-estimator.ts +133 -0
  120. package/server/engine/twin-engine.ts +587 -106
  121. package/server/engine/warm-start.ts +53 -0
  122. package/server/index.ts +9 -0
  123. package/server/service/index.ts +7 -0
  124. package/server/service/reference/reference-live.ts +2 -1
  125. package/server/service/reference/reference-master.ts +383 -10
  126. package/server/service/reference/reference-resolver.ts +3 -3
  127. package/server/service/reference/template-registry.ts +1 -1
  128. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  129. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  130. package/server/service/twin-event/backfill-keys.ts +72 -0
  131. package/server/service/twin-event/twin-event-keys.ts +102 -0
  132. package/server/service/twin-event/twin-event-type.ts +27 -0
  133. package/server/service/twin-event/twin-event.ts +62 -0
  134. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  135. package/server/service/twin-instance/twin-instance.ts +1 -1
  136. package/server/service/twin-journal/twin-journal-query.ts +129 -5
  137. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  138. package/server/service/twin-space/twin-space-area.ts +1 -1
  139. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  140. package/server/service/twin-space/twin-space.ts +14 -0
  141. package/server/service/twin-structure/index.ts +3 -0
  142. package/server/service/twin-structure/twin-structure.ts +93 -0
  143. package/server/service/twin-target/index.ts +5 -0
  144. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  145. package/server/service/twin-target/twin-target.ts +97 -0
  146. package/test/capability-mapping.test.ts +5 -5
  147. package/test/duration-estimators.test.ts +144 -0
  148. package/test/entity-delta.test.ts +150 -19
  149. package/test/ingest-bench.test.ts +9 -9
  150. package/test/kpi-fold.test.ts +254 -3
  151. package/test/live-mirror-parity.test.ts +53 -19
  152. package/test/master-to-twin.test.ts +87 -13
  153. package/test/model-basis.test.ts +86 -0
  154. package/test/oee-accumulator.test.ts +9 -9
  155. package/test/scale-twin-bench.test.ts +22 -22
  156. package/test/spec-coverage.test.ts +113 -0
  157. package/test/streamline-e2e.test.ts +10 -10
  158. package/test/structure-revision-db.test.ts +310 -0
  159. package/test/twin-event-keys.test.ts +108 -0
  160. package/test/vocabulary-guard.test.ts +43 -0
  161. package/test/warm-start.test.ts +78 -0
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * 결론(검증됨):
6
6
  * - 핵심 관측 상태(점유·용량·위치·아이템 = "사진에 찍힌 것")는 이벤트만으로 재구성 → sim↔live **완전 일치**.
7
- * - 파생 지표(mover OEE·node status = "계산해서 만든 통계")는 이벤트에 없음 → live 미러엔 **부재**.
7
+ * - 파생 지표(설비 OEE·location status = "계산해서 만든 통계")는 이벤트에 없음 → live 미러엔 **부재**.
8
8
  * 이는 라우팅 결함이 아니라(통로는 온전), live 에서 파생 지표를 **누가 생산하나**의 데이터원 결정 사항.
9
9
  * (실 텔레메트리 / projector 계산 / 별도 분석 층 — face2-inbound-live §6, reference-management P3.)
10
10
  */
@@ -25,11 +25,11 @@ const master = () => ({
25
25
  space: { width: 90, depth: 40, unit: 'm' },
26
26
  locations: [
27
27
  { id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' },
28
- { id: 'dock-recv', type: 'dock', role: 'node' as const, capacity: 100 },
29
- { id: 'rack-1', type: 'storage', role: 'node' as const, capacity: 60 },
30
- { id: 'rack-2', type: 'storage', role: 'node' as const, capacity: 60 },
31
- { id: 'staging', type: 'staging', role: 'node' as const, capacity: 80 },
32
- { id: 'dock-ship', type: 'dock-ship', role: 'node' as const, capacity: 100 }
28
+ { id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 },
29
+ { id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 60 },
30
+ { id: 'rack-2', type: 'storage', role: 'location' as const, capacity: 60 },
31
+ { id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 },
32
+ { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 }
33
33
  ],
34
34
  equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }]
35
35
  })
@@ -52,11 +52,21 @@ function runSim() {
52
52
  const sortByTag = (arr: any[]) => [...arr].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
53
53
 
54
54
  /** 파생/sim-계산 필드 제거 → "핵심 관측 상태"만 남긴다.
55
- * node.status = 커널 기본값(파생), mover.oee = 커널 계산 지표(파생). 이벤트에 없어 live 미러 부재. */
55
+ * location.status = 커널 기본값(파생), equipment.oee = 커널 계산 지표(파생). 이벤트에 없어 live 미러 부재. */
56
+ /**
57
+ * 설계상 두 구동이 다른 자리만 걷어낸다 — **여기 없는 차이는 결함이다.**
58
+ *
59
+ * 엔티티 마커가 `kind` → `entity` 로 바뀌었을 때(도메인 kind 를 마커가 덮어쓰던 문제) 이 필터가
60
+ * 따라가지 못해 **아무것도 걸러내지 못하고 있었다.** 그래서 실패가 "정합성 결함" 처럼 보였다.
61
+ *
62
+ * 지금 남는 차이는 하나뿐이다:
63
+ * · `oee` — 시뮬은 자기가 계측하고, 라이브는 **호스트**가 이벤트를 적분해 채운다(withLiveOee).
64
+ * 커널 층에서만 비대칭이고 호스트가 닫는다(커널 적합성 하네스도 같은 예외를 선언한다).
65
+ * (`location.status` 는 예외였다가 해소됐다 — 저장값이 아니라 포화도 파생이라 두 구동이 같은 값을 낸다.)
66
+ */
56
67
  const coreOnly = (d: any) => {
57
68
  const c = { ...d }
58
- if (c.kind === 'node') delete c.status
59
- if (c.kind === 'mover') delete c.oee
69
+ if (c.entity === 'equipment') delete c.oee
60
70
  return c
61
71
  }
62
72
 
@@ -92,10 +102,10 @@ function runSimRich() {
92
102
  space: { width: 90, depth: 40, unit: 'm' },
93
103
  locations: [
94
104
  { id: 'a', type: 'warehouse', role: 'area' as const, name: '창고' },
95
- { id: 'dock-recv', type: 'dock', role: 'node' as const, capacity: 100 },
96
- { id: 'rack-1', type: 'storage', role: 'node' as const, capacity: 6 }, // 소용량 → 병목
97
- { id: 'staging', type: 'staging', role: 'node' as const, capacity: 80 },
98
- { id: 'dock-ship', type: 'dock-ship', role: 'node' as const, capacity: 100 }
105
+ { id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 },
106
+ { id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 6 }, // 소용량 → 병목
107
+ { id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 },
108
+ { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 }
99
109
  ],
100
110
  equipment: [
101
111
  { id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv', mtbfMs: 5000, mttrMs: 8000 }, // 고장
@@ -127,9 +137,9 @@ test('명시: 파생 층(attentions·OEE·motion·clock)은 live 미러 부재/
127
137
  assert.equal(sim.attentions.length > 0 && (live.attentions?.length ?? 0), 0, 'live: attentions 부재 — computeAttentions 를 미러 State 에 재적용해야 복원')
128
138
 
129
139
  // 2) OEE(계산 지표) — 시간 누적. live 부재.
130
- const simFk = sim.movers.find((x: any) => x.id === 'fk-1')
131
- const liveFk = live.movers.find((x: any) => x.id === 'fk-1')
132
- assert.ok(simFk?.oee, 'sim: mover OEE 계산됨')
140
+ const simFk = sim.equipment.find((x: any) => x.id === 'fk-1')
141
+ const liveFk = live.equipment.find((x: any) => x.id === 'fk-1')
142
+ assert.ok(simFk?.oee, 'sim: 설비 OEE 계산됨')
133
143
  assert.equal(liveFk?.oee, undefined, 'live: OEE 부재(시간 누적 지표)')
134
144
 
135
145
  // 3) simClockMs(sim 클럭) — live 는 실시간이 클럭원.
@@ -150,7 +160,7 @@ test('★ derive 층: computeAttentions 를 projector 미러에 재적용 →
150
160
  const { sim, live } = runSimRich()
151
161
 
152
162
  // (a) 순수 함수가 커널 자체 attentions 를 재현 — 추출 정확성(sim state 로 호출 == 커널 계산 관측기반).
153
- const simDerivedKinds = new Set(deriveAttentions({ movers: sim.movers, nodes: sim.nodes, orders: sim.orders }).map(a => a.kind))
163
+ const simDerivedKinds = new Set(deriveAttentions({ equipment: sim.equipment, locations: sim.locations, orders: sim.orders }).map(a => a.kind))
154
164
  for (const a of sim.attentions ?? []) {
155
165
  if (['breakdown', 'bottleneck', 'hold'].includes(a.kind)) {
156
166
  assert.ok(simDerivedKinds.has(a.kind), `순수 함수가 sim 관측기반 attention '${a.kind}' 재현`)
@@ -159,7 +169,7 @@ test('★ derive 층: computeAttentions 를 projector 미러에 재적용 →
159
169
 
160
170
  // (b) ★ projector 미러 State 에 재적용 → live 원본엔 없던 attentions 복원.
161
171
  assert.equal(live.attentions?.length ?? 0, 0, 'live projector 원본엔 attentions 없음(계산 안 함)')
162
- const liveKinds = new Set(deriveAttentions({ movers: live.movers, nodes: live.nodes, orders: live.orders }).map(a => a.kind))
172
+ const liveKinds = new Set(deriveAttentions({ equipment: live.equipment, locations: live.locations, orders: live.orders }).map(a => a.kind))
163
173
  assert.ok(liveKinds.has('breakdown') || liveKinds.has('bottleneck'),
164
174
  'derive 재적용 → live 에 관측기반 attention(breakdown/bottleneck) 복원')
165
175
  })
@@ -181,7 +191,7 @@ test('★ withLiveAttentions: projector 미러 스냅샷에 attentions 보강 (a
181
191
  */
182
192
  test('★ OEE derive: 공유 공식 computeOee 가 공급된 카운터로 OEE 재생산 (live=텔레메트리/누적기 카운터 + 공식)', () => {
183
193
  const { sim } = runSimRich()
184
- const m = sim.movers.find((x: any) => x.oee && (x.oee.runMs > 0 || x.oee.downMs > 0)) ?? sim.movers.find((x: any) => x.oee)
194
+ const m = sim.equipment.find((x: any) => x.oee && (x.oee.runMs > 0 || x.oee.downMs > 0)) ?? sim.equipment.find((x: any) => x.oee)
185
195
  assert.ok(m?.oee, 'sim 무버 oee 존재')
186
196
  const o = m.oee
187
197
 
@@ -197,3 +207,27 @@ test('★ OEE derive: 공유 공식 computeOee 가 공급된 카운터로 OEE
197
207
  assert.ok(near(derived.quality, o.quality), 'quality 재생산')
198
208
  assert.ok(near(derived.overall, o.overall), 'overall 재생산')
199
209
  })
210
+
211
+ /*
212
+ * ── 배선 감시: 라이브가 커널에 **모든 채널을 넘기는가** ──────────────────────
213
+ *
214
+ * 규칙(deriveAttentions)은 커널 테스트가 지킨다. 지켜지지 않던 것은 **배선**이다 —
215
+ * `withLiveAttentions` 가 채널 하나를 안 넘기면 같은 공장이 시뮬에서는 막혔다고 하고 라이브에서는
216
+ * 멀쩡하다고 답한다. 규칙은 멀쩡하니 규칙 테스트는 통과하고, 어긋남은 아무 데서도 안 잡힌다.
217
+ * (`tasks` 를 새로 넣을 때 실제로 그럴 뻔했다.)
218
+ *
219
+ * 그래서 채널마다 신호가 하나씩 나오는 상태를 만들고, **넷이 다 나오는지** 본다. 앞으로 채널이
220
+ * 하나 더 늘고 라이브 배선만 빠지면 여기서 깨진다.
221
+ */
222
+ test('★ withLiveAttentions 는 채널을 하나도 떨어뜨리지 않는다(설비·자리·오더·작업)', () => {
223
+ const state = {
224
+ nowTime: '2026-03-02T12:00:00.000Z',
225
+ equipment: [{ id: 'rbt-1', status: 'down', location: 'wc-1' }],
226
+ locations: [{ id: 'wc-1', capacity: 2, occupancy: 2 }],
227
+ orders: [{ id: 'ord-1', held: true, status: 'released' }],
228
+ tasks: Array.from({ length: 8 }, (_, i) => ({ id: `t${i}`, kind: 'paint', status: 'created' }))
229
+ }
230
+ const kinds = new Set((withLiveAttentions(state as any).attentions ?? []).map((a: any) => a.kind))
231
+ for (const expected of ['breakdown', 'bottleneck', 'hold', 'work-backlog'])
232
+ assert.ok(kinds.has(expected), `라이브 배선이 '${expected}' 채널을 떨어뜨렸다 — 넘긴 것: ${[...kinds].join(', ') || '없음'}`)
233
+ })
@@ -1,7 +1,7 @@
1
1
  /*
2
- * masterToTwin — 모델→board 토폴로지 변환 시임. 템플릿/레퍼런스 master → board{nodes,movers,layout,spaceId}+space.
2
+ * masterToTwin — 모델→board 토폴로지 변환 시임. 템플릿/레퍼런스 master → board{locations,equipment,layout,spaceId}+space.
3
3
  * 스트림라인의 "모델링→board" 연결부: 템플릿(build)이 낸 master 를 커널이 소비할 BoardDef 로 바꾼다.
4
- * role(node/area/both) 분기·footprint→layout·spaceId 파생·미지 타입 경고를 고정.
4
+ * role(location/area/both) 분기·footprint→layout·spaceId 파생·미지 타입 경고를 고정.
5
5
  */
6
6
  import { test } from 'node:test'
7
7
  import assert from 'node:assert/strict'
@@ -17,22 +17,22 @@ const wmsMaster = () => ({
17
17
  space: { width: 90, depth: 40, unit: 'm', geo: { lat: 37.5, lon: 127.0, rotation: 0 } },
18
18
  locations: [
19
19
  { id: 'a-dc', type: 'warehouse', role: 'area' as const, name: '창고' },
20
- { id: 'dock-recv', type: 'dock', role: 'node' as const, name: '입고 도크', capacity: 100 },
21
- { id: 'rack-1', type: 'storage', role: 'node' as const, name: '랙1', parentId: 'a-dc', capacity: 60 },
22
- { id: 'rack-2', type: 'storage', role: 'node' as const, name: '랙2', parentId: 'a-dc', capacity: 60 },
23
- { id: 'dock-ship', type: 'dock-ship', role: 'node' as const, name: '출고 도크', capacity: 100 }
20
+ { id: 'dock-recv', type: 'dock', role: 'location' as const, name: '입고 도크', capacity: 100 },
21
+ { id: 'rack-1', type: 'storage', role: 'location' as const, name: '랙1', parentId: 'a-dc', capacity: 60 },
22
+ { id: 'rack-2', type: 'storage', role: 'location' as const, name: '랙2', parentId: 'a-dc', capacity: 60 },
23
+ { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, name: '출고 도크', capacity: 100 }
24
24
  ],
25
25
  equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv' }]
26
26
  })
27
27
 
28
28
  test('locations role → 노드/영역 분기, equipment → 무버', () => {
29
29
  const { board, spaceContent } = masterToTwin(wmsMaster())
30
- assert.deepEqual(board.nodes.map((n: any) => n.id).sort(), ['dock-recv', 'dock-ship', 'rack-1', 'rack-2'], '노드=role node')
30
+ assert.deepEqual(board.locations.map((n: any) => n.id).sort(), ['dock-recv', 'dock-ship', 'rack-1', 'rack-2'], '노드=role location')
31
31
  assert.equal(spaceContent.areas.length, 1, '영역=role area')
32
32
  assert.equal(spaceContent.areas[0].id, 'a-dc')
33
- assert.deepEqual(board.movers, [{ id: 'fk-1', kind: 'forklift', homeNode: 'dock-recv', mtbfMs: undefined, mttrMs: undefined, gs1Id: undefined }], 'equipment→mover(homeLocationId→homeNode, gs1Id 통과)')
34
- assert.equal(board.nodes.find((n: any) => n.id === 'rack-1').capacity, 60, 'capacity 전달')
35
- assert.equal(board.nodes.find((n: any) => n.id === 'dock-recv').parentId, undefined, 'parentId 없으면 undefined')
33
+ assert.deepEqual(board.equipment, [{ id: 'fk-1', kind: 'forklift', homeLocation: 'dock-recv', mtbfMs: undefined, mttrMs: undefined, gs1Id: undefined }], '마스터 equipment→보드 equipment(homeLocationId→homeLocation, gs1Id 통과)')
34
+ assert.equal(board.locations.find((n: any) => n.id === 'rack-1').capacity, 60, 'capacity 전달')
35
+ assert.equal(board.locations.find((n: any) => n.id === 'dock-recv').parentId, undefined, 'parentId 없으면 undefined')
36
36
  })
37
37
 
38
38
  test('coordinate-free: footprint 없으면 layout 비어있음(미배치 → A2)', () => {
@@ -57,10 +57,10 @@ test('spaceId: 명시 시 참조, 없으면 source 파생 (공동배치 시임)'
57
57
 
58
58
  test('미지 노드 타입 경고 — 카탈로그 있으면 검증(버리진 않음)', () => {
59
59
  const m = wmsMaster()
60
- ;(m.locations as any).push({ id: 'weird', type: '__unknown__', role: 'node', capacity: 1 })
60
+ ;(m.locations as any).push({ id: 'weird', type: '__unknown__', role: 'location', capacity: 1 })
61
61
  const withCat = masterToTwin(m, DOMAIN_CATALOG)
62
62
  assert.ok(withCat.warnings.some(w => w.includes('__unknown__')), '미지 타입 경고')
63
- assert.ok(withCat.board.nodes.some((n: any) => n.id === 'weird'), '경고해도 노드는 유지(반영 우선)')
63
+ assert.ok(withCat.board.locations.some((n: any) => n.id === 'weird'), '경고해도 노드는 유지(반영 우선)')
64
64
  // 정상 타입만이면 경고 0
65
65
  assert.equal(masterToTwin(wmsMaster(), DOMAIN_CATALOG).warnings.length, 0, '알려진 타입만 → 경고 0')
66
66
  // 카탈로그 없으면 검증 생략
@@ -71,7 +71,7 @@ test("role 'both' 는 노드·영역 양쪽에 등장(도크 도어 등)", () =>
71
71
  const m = wmsMaster()
72
72
  ;(m.locations as any).push({ id: 'dock-door-1', type: 'dock-door', role: 'both', name: '도어1', capacity: 1 })
73
73
  const { board, spaceContent } = masterToTwin(m)
74
- assert.ok(board.nodes.some((n: any) => n.id === 'dock-door-1'), 'both → 노드')
74
+ assert.ok(board.locations.some((n: any) => n.id === 'dock-door-1'), 'both → 노드')
75
75
  assert.ok(spaceContent.areas.some((a: any) => a.id === 'dock-door-1'), 'both → 영역')
76
76
  })
77
77
 
@@ -111,3 +111,77 @@ test('현장 물질화(#4): 표현도 geo 도 없으면 빈 표현 + 경고(정
111
111
  '뷰 없는 공간 경고 표면화'
112
112
  )
113
113
  })
114
+
115
+ /* ── 공정 명세 통과 (ISA-95 OperationsSegment) ──────────────────────────────
116
+ *
117
+ * 시뮬레이션의 소요·수율이 커널 상수였던 것을 데이터로 옮기는 경로의 마지막 한 칸: 마스터가 명세를
118
+ * 말하면 board 를 통해 커널까지 **손대지 않고** 간다. 기간은 표준 표기(ISO 8601)로 그대로 흐른다 —
119
+ * 여기서 밀리초로 바꾸면 표준과 어긋나고, 커널이 다시 해석할 기회를 잃는다.
120
+ */
121
+ const mesMaster = () => ({
122
+ source: 'plant-1',
123
+ system: 'mes' as const,
124
+ siteName: '공장',
125
+ space: { width: 100, depth: 50, geo: { lat: 32.3, lon: -117.0 } },
126
+ locations: [
127
+ { id: 'a-prod', type: 'factory', role: 'area' as const, name: '생산' },
128
+ { id: 'cut-1', type: 'cut-station', role: 'location' as const, name: '절단', capacity: 4 }
129
+ ],
130
+ equipment: [{ id: 'mch-1', kind: 'cutter', homeLocationId: 'cut-1' }],
131
+ operations: [
132
+ {
133
+ key: 'cut', label: 'cutting', intent: 'process' as const, locationType: 'cut-station', resourceType: 'cutter',
134
+ duration: 'PT25M',
135
+ variability: { distribution: 'triangular' as const, min: 'PT20M', max: 'PT35M', mode: 'PT24M' },
136
+ parameters: [{ id: 'yield', value: '0.995' }]
137
+ }
138
+ ]
139
+ })
140
+
141
+ test('공정 명세가 board 로 그대로 통과한다 — 표준 표기 유지, 값 변형 없음', () => {
142
+ const { board, warnings } = masterToTwin(mesMaster(), DOMAIN_CATALOG)
143
+ assert.equal((board as any).operations.length, 1)
144
+ const op = (board as any).operations[0]
145
+ assert.equal(op.duration, 'PT25M', 'ISO 8601 기간을 밀리초로 바꾸지 않는다')
146
+ assert.deepEqual(op.variability, { distribution: 'triangular', min: 'PT20M', max: 'PT35M', mode: 'PT24M' })
147
+ assert.deepEqual(op.parameters, [{ id: 'yield', value: '0.995' }])
148
+ assert.equal(warnings.length, 0)
149
+ })
150
+
151
+ test('명세를 주지 않으면 키를 만들지 않는다 — 빈 배열로 "선언했다" 고 말하지 않는다', () => {
152
+ const { board } = masterToTwin(wmsMaster(), DOMAIN_CATALOG)
153
+ assert.equal('operations' in (board as any), false)
154
+ })
155
+
156
+ test('내용 없는 명세는 경고한다 — 선언했는데 아무 말도 하지 않는 명세는 침묵하지 않는다', () => {
157
+ const m: any = mesMaster()
158
+ m.operations = [{ key: 'cut', label: 'cutting', intent: 'process' }]
159
+ const { warnings } = masterToTwin(m, DOMAIN_CATALOG)
160
+ assert.ok(
161
+ warnings.some(w => w.includes('cut') && w.includes('kernel defaults')),
162
+ `기본값으로 굴러갈 것을 알린다: ${JSON.stringify(warnings)}`
163
+ )
164
+ })
165
+
166
+ /* ── 처리 능력·교대 통과 ─────────────────────────────────────────────────
167
+ *
168
+ * `capacity` 한 필드가 저장 용량과 처리 능력을 겸하던 것을 갈랐다. 인제스트도 두 축을 따로 실어야
169
+ * 커널이 "20개를 쌓아 두지만 한 번에 한 대만 깎는다" 를 표현할 수 있다. 교대도 같다.
170
+ */
171
+ test('처리 능력과 교대가 board 로 통과한다 — 저장 용량과 섞이지 않는다', () => {
172
+ const m: any = mesMaster()
173
+ m.locations[1].parallelism = 1 // cut-1: 저장 4, 동시 처리 1
174
+ m.equipment[0].window = { startHour: 8, endHour: 18 }
175
+ const { board } = masterToTwin(m, DOMAIN_CATALOG)
176
+ const location = (board as any).locations.find((n: any) => n.id === 'cut-1')
177
+ assert.equal(location.capacity, 4, '저장 용량')
178
+ assert.equal(location.parallelism, 1, '처리 능력 — 다른 축')
179
+ assert.deepEqual((board as any).equipment[0].window, { startHour: 8, endHour: 18 })
180
+ })
181
+
182
+ test('선언하지 않으면 키를 만들지 않는다 — 미지정과 0 을 뭉개지 않는다', () => {
183
+ const { board } = masterToTwin(mesMaster(), DOMAIN_CATALOG)
184
+ const location = (board as any).locations.find((n: any) => n.id === 'cut-1')
185
+ assert.equal('parallelism' in location, false)
186
+ assert.equal('window' in (board as any).equipment[0], false)
187
+ })
@@ -0,0 +1,86 @@
1
+ /*
2
+ * 모델 근거 비교 — **배운 모델에만 보정을 적용하기 위한 관문.**
3
+ *
4
+ * 배경: 예측 출력 보정(P3.2)은 "그때 모델이 이만큼 치우쳐 있다" 를 배운다. 소요시간 추정기(실측·거리)
5
+ * 와 공정 명세가 들어오면서 모델 자체가 바뀔 수 있게 됐고, 그러면 옛 편향을 그대로 밀어 주는 **이중
6
+ * 보정**이 생긴다. 근거가 달라졌으면 적용하지 않는다(그리고 그 사실을 응답에 싣는다).
7
+ *
8
+ * 비교를 **상대 허용오차**로 하는 이유: 양자화 지문은 버킷 경계에 걸린 값이 1초 드리프트로도 달라져
9
+ * "작은 드리프트로 학습을 무효화하지 않는다" 는 목적을 경계에서 못 지킨다. 비율은 어디서든 같다.
10
+ */
11
+ import { test } from 'node:test'
12
+ import assert from 'node:assert/strict'
13
+
14
+ import { BASIS_TOLERANCE, describeModelBasis, modelBasisChanged } from '../server/engine/model-basis.ts'
15
+
16
+ test('같은 근거면 바뀌지 않았다 — 키 순서에 흔들리지 않는다', () => {
17
+ assert.equal(
18
+ modelBasisChanged({ measured: { pick: 22_000, putaway: 47_000 } }, { measured: { putaway: 47_000, pick: 22_000 } }),
19
+ false
20
+ )
21
+ })
22
+
23
+ test('작은 드리프트는 같은 모델 — 경계에서도 성립한다(버킷이 아니라 비율)', () => {
24
+ /* 47.0 → 47.9 (약 1.9%): 5초 버킷이면 45↔50 으로 갈렸을 값이다. */
25
+ assert.equal(modelBasisChanged({ measured: { putaway: 47_000 } }, { measured: { putaway: 47_900 } }), false)
26
+ /* 허용오차 경계 바로 아래·위 */
27
+ assert.equal(modelBasisChanged({ measured: { p: 100_000 } }, { measured: { p: 105_000 } }), false, '5%')
28
+ assert.equal(modelBasisChanged({ measured: { p: 100_000 } }, { measured: { p: 130_000 } }), true, '30%')
29
+ })
30
+
31
+ test('실측이 실제로 달라지면 다른 모델 — 30초 작업이 47초가 되면 옛 보정은 무효', () => {
32
+ assert.equal(modelBasisChanged({ measured: { putaway: 30_000 } }, { measured: { putaway: 47_000 } }), true)
33
+ })
34
+
35
+ test('새로 배운 종류·사라진 종류는 다른 모델 — 값이 없다가 생긴 것도 변화다', () => {
36
+ assert.equal(modelBasisChanged({ measured: { pick: 20_000 } }, { measured: { pick: 20_000, putaway: 47_000 } }), true)
37
+ assert.equal(modelBasisChanged({ measured: { pick: 20_000, putaway: 47_000 } }, { measured: { pick: 20_000 } }), true)
38
+ })
39
+
40
+ test('속도가 바뀌면 다른 모델 — 이동시간 모델이 바뀐 것이다', () => {
41
+ assert.equal(modelBasisChanged({ speeds: { forklift: 2 } }, { speeds: { forklift: 4 } }), true)
42
+ assert.equal(modelBasisChanged({ speeds: { forklift: 2 } }, { speeds: { forklift: 2.05 } }), false)
43
+ })
44
+
45
+ test('선언 명세가 바뀌면 다른 모델 — 표기 원문을 그대로 비교한다', () => {
46
+ assert.equal(
47
+ modelBasisChanged({ declared: [{ key: 'cut', duration: 'PT25M' }] }, { declared: [{ key: 'cut', duration: 'PT30M' }] }),
48
+ true
49
+ )
50
+ assert.equal(
51
+ modelBasisChanged({ declared: [{ key: 'cut', duration: 'PT25M' }] }, { declared: [{ key: 'cut', duration: 'PT25M' }] }),
52
+ false
53
+ )
54
+ })
55
+
56
+ test('소요 없는 선언은 모델을 바꾸지 않는다', () => {
57
+ assert.equal(modelBasisChanged({ declared: [{ key: 'cut' }] }, { declared: [] }), false)
58
+ })
59
+
60
+ test('뜻 없는 값은 근거가 아니다 — 0·음수·NaN 은 무시한다', () => {
61
+ assert.equal(modelBasisChanged({ measured: { pick: 0 } }, {}), false)
62
+ assert.equal(modelBasisChanged({ measured: { pick: NaN } }, { speeds: { f: -1 } }), false)
63
+ })
64
+
65
+ test('한쪽 근거를 모르면 판정하지 않는다 — 모르는 것을 근거로 학습을 끊지 않는다', () => {
66
+ assert.equal(modelBasisChanged(undefined, { measured: { pick: 20_000 } }), false)
67
+ assert.equal(modelBasisChanged({ measured: { pick: 20_000 } }, undefined), false)
68
+ })
69
+
70
+ test('세 재료가 섞이지 않는다 — 같은 이름이 다른 재료에 있으면 다른 모델', () => {
71
+ assert.equal(modelBasisChanged({ measured: { x: 10_000 } }, { speeds: { x: 10_000 } }), true)
72
+ })
73
+
74
+ test('허용오차는 조절 가능하고 기본값이 명시돼 있다', () => {
75
+ assert.equal(BASIS_TOLERANCE, 0.1)
76
+ assert.equal(modelBasisChanged({ measured: { p: 100 } }, { measured: { p: 130 } }, 0.5), false, '느슨하게 주면 같은 모델')
77
+ })
78
+
79
+ test('사람이 읽는 요약을 낸다 — 로그에서 왜 달라졌는지 눈으로 본다', () => {
80
+ const d = describeModelBasis({ measured: { putaway: 47_000 }, speeds: { forklift: 2 }, declared: [{ key: 'cut', duration: 'PT25M' }] })
81
+ assert.match(d, /measured\(putaway=47s\)/)
82
+ assert.match(d, /speed\(forklift=2m\/s\)/)
83
+ assert.match(d, /declared\(cut=PT25M\)/)
84
+ assert.equal(describeModelBasis({}), 'kernel defaults only')
85
+ assert.equal(describeModelBasis(undefined), 'unknown')
86
+ })
@@ -21,10 +21,10 @@ function runSimCollect() {
21
21
  space: { width: 90, depth: 40, unit: 'm' },
22
22
  locations: [
23
23
  { id: 'a', type: 'warehouse', role: 'area' as const },
24
- { id: 'dock-recv', type: 'dock', role: 'node' as const, capacity: 100 },
25
- { id: 'rack-1', type: 'storage', role: 'node' as const, capacity: 60 },
26
- { id: 'staging', type: 'staging', role: 'node' as const, capacity: 80 },
27
- { id: 'dock-ship', type: 'dock-ship', role: 'node' as const, capacity: 100 }
24
+ { id: 'dock-recv', type: 'dock', role: 'location' as const, capacity: 100 },
25
+ { id: 'rack-1', type: 'storage', role: 'location' as const, capacity: 60 },
26
+ { id: 'staging', type: 'staging', role: 'location' as const, capacity: 80 },
27
+ { id: 'dock-ship', type: 'dock-ship', role: 'location' as const, capacity: 100 }
28
28
  ],
29
29
  equipment: [{ id: 'fk-1', kind: 'forklift', homeLocationId: 'dock-recv', mtbfMs: 5000, mttrMs: 8000 }]
30
30
  }
@@ -41,7 +41,7 @@ function runSimCollect() {
41
41
 
42
42
  test('★ OEE 누적기: downMs 정확 · busyMs=run+setup · 근사 OEE 유효', () => {
43
43
  const { events, snap } = runSimCollect()
44
- const simOee = snap.movers.find((x: any) => x.id === 'fk-1')?.oee
44
+ const simOee = snap.equipment.find((x: any) => x.id === 'fk-1')?.oee
45
45
  assert.ok(simOee, 'sim fk-1 oee 존재')
46
46
  assert.ok(simOee.downMs > 0, '시나리오가 고장(down) 유발 — 검증 의미 있음')
47
47
 
@@ -82,20 +82,20 @@ test('★ OEE 누적기: quality.output 델타 소비 → quality 정확 (good/s
82
82
  assert.ok(Math.abs(oee.quality - 0.8) < 1e-9, 'quality = good/(good+scrap) = 0.8 (텔레메트리 없이 이벤트로 도출)')
83
83
  })
84
84
 
85
- test('★ withLiveOee: projector 미러(oee 없음) + 누적기 → live mover payload 에 oee 포함 (원 갭 종료)', () => {
85
+ test('★ withLiveOee: projector 미러(oee 없음) + 누적기 → live 설비 payload 에 oee 포함 (원 갭 종료)', () => {
86
86
  const { events, board } = runSimCollect()
87
87
  const proj = new StateProjector(board)
88
88
  const acc = new OeeAccumulator()
89
89
  for (const e of events) { proj.apply(e); acc.apply(e) }
90
90
 
91
91
  const rawLive = proj.snapshot() as any
92
- // 원 갭: projector 미러 mover oee 없음.
93
- assert.equal(rawLive.movers.find((m: any) => m.id === 'fk-1')?.oee, undefined, 'projector 미러엔 oee 부재(원 갭)')
92
+ // 원 갭: projector 미러 설비엔 oee 없음.
93
+ assert.equal(rawLive.equipment.find((m: any) => m.id === 'fk-1')?.oee, undefined, 'projector 미러엔 oee 부재(원 갭)')
94
94
 
95
95
  // withLiveOee 로 보강 → buildEntityDeltas payload 에 oee 포함(sim 과 동일 형상 = 보드 OEE 컴포넌트 live 렌더).
96
96
  const enriched = withLiveOee(rawLive, acc)
97
97
  const fk = buildEntityDeltas(enriched).find(d => d.tag === 'fk-1')!.data as any
98
- assert.ok(fk.oee, 'withLiveOee 후 mover data(tag) payload 에 oee 포함 — live OEE 갭 종료')
98
+ assert.ok(fk.oee, 'withLiveOee 후 설비 data(tag) payload 에 oee 포함 — live OEE 갭 종료')
99
99
  // buildEntityDeltas shapeOee = 정수 퍼센트(0-100), raw ms 제외.
100
100
  assert.ok(fk.oee.overall >= 0 && fk.oee.overall <= 100, 'oee.overall ∈ [0,100] 정수 퍼센트')
101
101
  assert.ok(fk.oee.availability < 100, 'availability < 100 (고장 시간 반영 — 누적기 downMs)')
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  * 스케일 테스트 트윈 벤치 — 미국 공장(WMS+MES+YMS) 형태를 대규모로 스케일업, 5종 동기 엔티티 전부 부하.
3
- * 동기 엔티티 = nodes·items(재고)·movers·tasks·orders (노드/무버뿐이 아님). area 는 계층(건물>층>존).
3
+ * 동기 엔티티 = locations·items(재고)·equipment·tasks·orders (노드/무버뿐이 아님). area 는 계층(건물>층>존).
4
4
  * 측정(가정 아닌 실측): masterToTwin(구조 생성)·apply(혼합 이벤트 처리량)·snapshot(전체 투영)·buildEntityDeltas(방송)·heap.
5
5
  * 순수 커널+호스트 매핑(shell·DB 무관) → node:test. area 는 공간층(projector State 밖)이라 처리량엔 무영향 — 구조 생성 비용에만.
6
6
  */
@@ -15,7 +15,7 @@ const T0 = '2026-07-20T00:00:00.000Z'
15
15
  const MB = (b: number) => Math.round(b / 1048576)
16
16
 
17
17
  /** 미국 공장형 대규모 master 생성: 건물>층>존 area 계층 + 노드(storage/dock/station) + 무버(forklift/cutter/welder/hostler). */
18
- function bigMaster(tier: { buildings: number; floorsPer: number; zonesPer: number; nodesPerZone: number; movers: number }) {
18
+ function bigMaster(tier: { buildings: number; floorsPer: number; zonesPer: number; nodesPerZone: number; equipment: number }) {
19
19
  const locations: any[] = []
20
20
  let nodeN = 0
21
21
  for (let b = 0; b < tier.buildings; b++) {
@@ -27,57 +27,57 @@ function bigMaster(tier: { buildings: number; floorsPer: number; zonesPer: numbe
27
27
  locations.push({ id: zid, type: 'zone', role: 'area', name: `존 ${b}-${f}-${z}`, parentId: `bld-${b}-flr-${f}` })
28
28
  for (let n = 0; n < tier.nodesPerZone; n++) {
29
29
  const t = n % 4 === 0 ? 'dock' : n % 4 === 1 ? 'staging' : 'storage'
30
- locations.push({ id: `nd-${nodeN}`, type: t, role: 'node', name: `노드 ${nodeN}`, parentId: zid, capacity: 500, footprint: { x: nodeN % 100, y: (nodeN / 100) | 0, w: 1, h: 1 } })
30
+ locations.push({ id: `nd-${nodeN}`, type: t, role: 'location', name: `노드 ${nodeN}`, parentId: zid, capacity: 500, footprint: { x: nodeN % 100, y: (nodeN / 100) | 0, w: 1, h: 1 } })
31
31
  nodeN++
32
32
  }
33
33
  }
34
34
  }
35
35
  }
36
36
  const KINDS = ['forklift', 'cutter', 'welder', 'hostler']
37
- const equipment = Array.from({ length: tier.movers }, (_, i) => ({ id: `mv-${i}`, kind: KINDS[i % 4], homeLocationId: `nd-${i % nodeN}` }))
37
+ const equipment = Array.from({ length: tier.equipment }, (_, i) => ({ id: `mv-${i}`, kind: KINDS[i % 4], homeLocationId: `nd-${i % nodeN}` }))
38
38
  return {
39
39
  master: { source: 'scale-us', system: 'wms' as const, siteName: 'US 공장(스케일)', space: { width: 500, depth: 300, unit: 'm', geo: { lat: 32.7, lon: -117.1 } }, locations, equipment },
40
- nodeCount: nodeN,
40
+ locationCount: nodeN,
41
41
  areaCount: locations.filter(l => l.role === 'area').length
42
42
  }
43
43
  }
44
44
 
45
- function itemEnv(i: number, node: string): any {
46
- const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [`urn:epc:id:sscc:0614141.${i}`], readPoint: node })
45
+ function itemEnv(i: number, location: string): any {
46
+ const ev = objectEvent({ eventTime: T0, action: 'OBSERVE' as any, bizStep: BIZSTEP.storing, disposition: DISP.sellable, epcList: [`urn:epc:id:sscc:0614141.${i}`], readPoint: location })
47
47
  return { eventId: `i${i}`, eventType: `epcis.${ev.type}`, eventTime: T0, tenantId: 't', data: ev }
48
48
  }
49
- const moverEnv = (id: string, kind: string, loc: string): any => ({ eventId: id, eventType: OP_EVENT.equipment, eventTime: T0, tenantId: 't', data: { moverId: id, kind, status: 'running', location: loc } })
49
+ const equipmentEnv = (id: string, kind: string, loc: string): any => ({ eventId: id, eventType: OP_EVENT.equipment, eventTime: T0, tenantId: 't', data: { moverId: id, kind, status: 'running', location: loc } })
50
50
  const taskEnv = (id: string, to: string): any => ({ eventId: id, eventType: OP_EVENT.task, eventTime: T0, tenantId: 't', data: { taskId: id, kind: 'move', status: 'in-progress', toNode: to } })
51
51
  const orderEnv = (id: string): any => ({ eventId: id, eventType: OP_EVENT.order, eventTime: T0, tenantId: 't', data: { orderId: id, kind: 'inbound', status: 'in-progress', requested: 100, fulfilled: 50 } })
52
52
 
53
53
  const TIERS = {
54
- medium: { buildings: 3, floorsPer: 3, zonesPer: 4, nodesPerZone: 8, movers: 80, items: 30000, tasks: 2000, orders: 500 },
55
- large: { buildings: 6, floorsPer: 4, zonesPer: 6, nodesPerZone: 12, movers: 300, items: 120000, tasks: 10000, orders: 2000 }
54
+ medium: { buildings: 3, floorsPer: 3, zonesPer: 4, nodesPerZone: 8, equipment: 80, items: 30000, tasks: 2000, orders: 500 },
55
+ large: { buildings: 6, floorsPer: 4, zonesPer: 6, nodesPerZone: 12, equipment: 300, items: 120000, tasks: 10000, orders: 2000 }
56
56
  }
57
57
 
58
58
  for (const [name, tier] of Object.entries(TIERS)) {
59
59
  test(`스케일 트윈 [${name}] — 구조 생성 + 5종 엔티티 부하 실측`, () => {
60
60
  // 1) 구조 생성(masterToTwin) — 노드·무버·area 계층
61
- const { master, nodeCount, areaCount } = bigMaster(tier)
61
+ const { master, locationCount, areaCount } = bigMaster(tier)
62
62
  let t = performance.now()
63
63
  const { board, spaceContent } = masterToTwin(master)
64
64
  const tMaster = performance.now() - t
65
- console.log(`\n [${name}] 구조: 노드 ${nodeCount.toLocaleString()} · 무버 ${tier.movers} · area ${areaCount}(계층) · masterToTwin ${tMaster.toFixed(0)}ms`)
66
- assert.equal(board.nodes.length, nodeCount)
65
+ console.log(`\n [${name}] 구조: 노드 ${locationCount.toLocaleString()} · 무버 ${tier.equipment} · area ${areaCount}(계층) · masterToTwin ${tMaster.toFixed(0)}ms`)
66
+ assert.equal(board.locations.length, locationCount)
67
67
  assert.equal((spaceContent.areas ?? []).length, areaCount, 'area 계층 생성')
68
68
 
69
69
  const proj = new StateProjector(board)
70
- const movers = board.movers as any[]
70
+ const equipment = board.equipment as any[]
71
71
 
72
- // 2) apply 처리량 — 5종 혼합(items·movers·tasks·orders)
72
+ // 2) apply 처리량 — 5종 혼합(items·equipment·tasks·orders)
73
73
  t = performance.now()
74
- for (let i = 0; i < tier.items; i++) proj.apply(itemEnv(i, `nd-${i % nodeCount}`))
75
- for (let i = 0; i < tier.movers; i++) proj.apply(moverEnv(movers[i].id, movers[i].kind, `nd-${i % nodeCount}`))
76
- for (let i = 0; i < tier.tasks; i++) proj.apply(taskEnv(`tk-${i}`, `nd-${i % nodeCount}`))
74
+ for (let i = 0; i < tier.items; i++) proj.apply(itemEnv(i, `nd-${i % locationCount}`))
75
+ for (let i = 0; i < tier.equipment; i++) proj.apply(equipmentEnv(equipment[i].id, equipment[i].kind, `nd-${i % locationCount}`))
76
+ for (let i = 0; i < tier.tasks; i++) proj.apply(taskEnv(`tk-${i}`, `nd-${i % locationCount}`))
77
77
  for (let i = 0; i < tier.orders; i++) proj.apply(orderEnv(`or-${i}`))
78
- const total = tier.items + tier.movers + tier.tasks + tier.orders
78
+ const total = tier.items + tier.equipment + tier.tasks + tier.orders
79
79
  const tApply = performance.now() - t
80
- console.log(` [${name}] apply: ${total.toLocaleString()} 이벤트(items+movers+tasks+orders) ${tApply.toFixed(0)}ms = ${Math.round((total / tApply) * 1000).toLocaleString()} ev/s`)
80
+ console.log(` [${name}] apply: ${total.toLocaleString()} 이벤트(items+equipment+tasks+orders) ${tApply.toFixed(0)}ms = ${Math.round((total / tApply) * 1000).toLocaleString()} ev/s`)
81
81
 
82
82
  // 3) snapshot(전체 투영) — 5종 전부
83
83
  let s: any
@@ -85,7 +85,7 @@ for (const [name, tier] of Object.entries(TIERS)) {
85
85
  t = performance.now()
86
86
  for (let k = 0; k < iters; k++) s = proj.snapshot()
87
87
  const tSnap = (performance.now() - t) / iters
88
- console.log(` [${name}] snapshot: ${tSnap.toFixed(2)}ms/call (items ${s.items.length.toLocaleString()}·nodes ${s.nodes.length.toLocaleString()}·movers ${s.movers.length}·tasks ${s.tasks.length}·orders ${s.orders.length}) → 최대 ${Math.round(1000 / tSnap).toLocaleString()} snap/s`)
88
+ console.log(` [${name}] snapshot: ${tSnap.toFixed(2)}ms/call (items ${s.items.length.toLocaleString()}·locations ${s.locations.length.toLocaleString()}·equipment ${s.equipment.length}·tasks ${s.tasks.length}·orders ${s.orders.length}) → 최대 ${Math.round(1000 / tSnap).toLocaleString()} snap/s`)
89
89
 
90
90
  // 4) 방송 payload(buildEntityDeltas)
91
91
  t = performance.now()
@@ -95,7 +95,7 @@ for (const [name, tier] of Object.entries(TIERS)) {
95
95
  console.log(` [${name}] heap: ${MB(process.memoryUsage().heapUsed)}MB`)
96
96
 
97
97
  // 정합 sanity
98
- assert.equal(s.movers.length, tier.movers)
98
+ assert.equal(s.equipment.length, tier.equipment)
99
99
  assert.equal(s.orders.length, tier.orders)
100
100
  assert.ok(s.items.length > 0)
101
101
  })
@@ -0,0 +1,113 @@
1
+ /*
2
+ * 시뮬 명세 자격 판정 — **"이 예측으로 무엇을 말할 수 있나" 를 숫자로 가른다.**
3
+ *
4
+ * 배경: 공정 소요가 커널 상수였을 때와 현장 명세로 굴렸을 때가 응답에서 똑같이 "p50 168" 로 보였다.
5
+ * 그 둘을 구별하지 못하면 상대 비교용 숫자가 절대 예측처럼 읽힌다.
6
+ */
7
+ import { test } from 'node:test'
8
+ import assert from 'node:assert/strict'
9
+
10
+ import { classifySpecCoverage, readSpecCoverage } from '../server/engine/spec-coverage.ts'
11
+
12
+ test('전부 기본값이면 상대 비교만 — 절대 시각을 주장할 근거가 없다', () => {
13
+ const r = classifySpecCoverage({
14
+ operations: [
15
+ { kind: 'putaway', duration: 'default' },
16
+ { kind: 'pick', duration: 'default' }
17
+ ]
18
+ })
19
+ assert.equal(r!.qualification, 'relative')
20
+ assert.equal(r!.declaredDurations, 0)
21
+ assert.equal(r!.defaultDurations, 2)
22
+ })
23
+
24
+ test('전부 선언되면 절대 예측 시도 가능 — 단 정확도는 별개(이름이 capable 이다)', () => {
25
+ const r = classifySpecCoverage({
26
+ operations: [
27
+ { kind: 'cut', duration: 'declared', variability: 'triangular', parameters: ['yield'] },
28
+ { kind: 'weld', duration: 'declared', variability: 'uniform' }
29
+ ]
30
+ })
31
+ assert.equal(r!.qualification, 'absolute-capable')
32
+ assert.equal(r!.varyingDurations, 2)
33
+ })
34
+
35
+ test('섞이면 partial — 한쪽만 접지된 사실을 감추지 않는다', () => {
36
+ const r = classifySpecCoverage({
37
+ operations: [
38
+ { kind: 'cut', duration: 'declared' },
39
+ { kind: 'paint', duration: 'default' }
40
+ ]
41
+ })
42
+ assert.equal(r!.qualification, 'partial')
43
+ assert.equal(r!.declaredDurations, 1)
44
+ assert.equal(r!.defaultDurations, 1)
45
+ })
46
+
47
+ test('변동 없는 선언은 varying 0 — 고정값이면 대기·병목이 과소평가된다는 신호', () => {
48
+ const r = classifySpecCoverage({ operations: [{ kind: 'cut', duration: 'declared' }] })
49
+ assert.equal(r!.varyingDurations, 0)
50
+ const c = classifySpecCoverage({ operations: [{ kind: 'cut', duration: 'declared', variability: 'constant' }] })
51
+ assert.equal(c!.varyingDurations, 0, 'constant 는 변동이 아니다')
52
+ })
53
+
54
+ test('관측된 작업이 없으면 판정하지 않는다 — 모름을 relative 로 단정하지 않는다', () => {
55
+ assert.equal(classifySpecCoverage({ operations: [] }), undefined)
56
+ assert.equal(classifySpecCoverage({} as any), undefined)
57
+ })
58
+
59
+ test('커널이 보고 API 를 갖지 않으면 undefined — "전부 기본값" 이라 단정하지 않는다', () => {
60
+ assert.equal(readSpecCoverage({}), undefined)
61
+ assert.equal(readSpecCoverage(undefined), undefined)
62
+ })
63
+
64
+ test('커널 보고가 던져도 예측을 깨지 않는다', () => {
65
+ assert.equal(readSpecCoverage({ specCoverage: () => { throw new Error('boom') } }), undefined)
66
+ })
67
+
68
+ test('커널 보고를 그대로 분류한다 — 카운트를 다시 세지 않고 준 값을 존중', () => {
69
+ const r = readSpecCoverage({
70
+ specCoverage: () => ({
71
+ operations: [{ kind: 'cut', duration: 'declared', parameters: ['yield'] }],
72
+ declaredDurations: 1,
73
+ defaultDurations: 0
74
+ })
75
+ })
76
+ assert.equal(r!.qualification, 'absolute-capable')
77
+ assert.deepEqual(r!.operations[0].parameters, ['yield'])
78
+ })
79
+
80
+ /* ── 실측이 들어오면 자격이 한 칸 올라간다 ─────────────────────────────── */
81
+
82
+ test('전부 이력에서 온 소요면 calibrated — 선언은 사람이 적은 값, 이것은 일어난 값', () => {
83
+ const r = classifySpecCoverage({
84
+ operations: [
85
+ { kind: 'putaway', duration: 'measured' },
86
+ { kind: 'pick', duration: 'measured' }
87
+ ]
88
+ })
89
+ assert.equal(r!.qualification, 'calibrated')
90
+ assert.equal(r!.measuredDurations, 2)
91
+ })
92
+
93
+ test('실측과 선언이 섞이면 absolute-capable — 상수가 없으면 시각을 말할 수 있다', () => {
94
+ const r = classifySpecCoverage({
95
+ operations: [
96
+ { kind: 'putaway', duration: 'measured' },
97
+ { kind: 'cut', duration: 'declared' }
98
+ ]
99
+ })
100
+ assert.equal(r!.qualification, 'absolute-capable')
101
+ })
102
+
103
+ test('상수가 하나라도 섞이면 partial — 실측이 있어도 감추지 않는다', () => {
104
+ const r = classifySpecCoverage({
105
+ operations: [
106
+ { kind: 'putaway', duration: 'measured' },
107
+ { kind: 'pick', duration: 'default' }
108
+ ]
109
+ })
110
+ assert.equal(r!.qualification, 'partial')
111
+ assert.equal(r!.measuredDurations, 1)
112
+ assert.equal(r!.defaultDurations, 1)
113
+ })