@things-factory/headless-twin 10.0.6 → 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 (150) 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 +21 -4
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +9 -0
  12. package/dist-server/engine/kpi-query.js +118 -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 +153 -17
  39. package/dist-server/engine/twin-engine.js +473 -81
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +5 -5
  42. package/dist-server/engine/warm-start.js +4 -4
  43. package/dist-server/engine/warm-start.js.map +1 -1
  44. package/dist-server/service/index.d.ts +4 -2
  45. package/dist-server/service/index.js +21 -14
  46. package/dist-server/service/index.js.map +1 -1
  47. package/dist-server/service/reference/reference-live.js +2 -1
  48. package/dist-server/service/reference/reference-live.js.map +1 -1
  49. package/dist-server/service/reference/reference-master.d.ts +307 -1
  50. package/dist-server/service/reference/reference-master.js +96 -8
  51. package/dist-server/service/reference/reference-master.js.map +1 -1
  52. package/dist-server/service/reference/reference-resolver.js +3 -3
  53. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  54. package/dist-server/service/reference/template-registry.d.ts +1 -1
  55. package/dist-server/service/reference/template-registry.js.map +1 -1
  56. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  57. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  58. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  59. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  60. package/dist-server/service/twin-event/twin-event-keys.d.ts +1 -1
  61. package/dist-server/service/twin-event/twin-event-keys.js +3 -3
  62. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -1
  63. package/dist-server/service/twin-event/twin-event.d.ts +11 -0
  64. package/dist-server/service/twin-event/twin-event.js +6 -1
  65. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  66. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  67. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  68. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  69. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  70. package/dist-server/service/twin-journal/twin-journal-query.d.ts +9 -0
  71. package/dist-server/service/twin-journal/twin-journal-query.js +54 -3
  72. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  73. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  74. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  75. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  76. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  77. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  78. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  79. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  80. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  81. package/dist-server/service/twin-space/twin-space.js +5 -0
  82. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  83. package/dist-server/service/twin-structure/index.d.ts +2 -0
  84. package/dist-server/service/twin-structure/index.js +6 -0
  85. package/dist-server/service/twin-structure/index.js.map +1 -0
  86. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  87. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  88. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  89. package/dist-server/service/twin-target/index.d.ts +4 -0
  90. package/dist-server/service/twin-target/index.js +8 -0
  91. package/dist-server/service/twin-target/index.js.map +1 -0
  92. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  93. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  94. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  95. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  96. package/dist-server/service/twin-target/twin-target.js +112 -0
  97. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  98. package/dist-server/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +6 -6
  100. package/server/engine/entity-delta.ts +135 -15
  101. package/server/engine/index.ts +4 -0
  102. package/server/engine/kpi-fold.ts +40 -8
  103. package/server/engine/kpi-query.ts +129 -19
  104. package/server/engine/kpi-target.ts +226 -0
  105. package/server/engine/live-attentions.ts +12 -2
  106. package/server/engine/measured-estimator.ts +91 -0
  107. package/server/engine/model-basis.ts +94 -0
  108. package/server/engine/oee-accumulator.ts +5 -5
  109. package/server/engine/spec-coverage.ts +85 -0
  110. package/server/engine/structure-diff.ts +88 -0
  111. package/server/engine/travel-estimator.ts +133 -0
  112. package/server/engine/twin-engine.ts +498 -84
  113. package/server/engine/warm-start.ts +8 -8
  114. package/server/service/index.ts +7 -0
  115. package/server/service/reference/reference-live.ts +2 -1
  116. package/server/service/reference/reference-master.ts +383 -10
  117. package/server/service/reference/reference-resolver.ts +3 -3
  118. package/server/service/reference/template-registry.ts +1 -1
  119. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  120. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  121. package/server/service/twin-event/twin-event-keys.ts +2 -2
  122. package/server/service/twin-event/twin-event.ts +15 -1
  123. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  124. package/server/service/twin-instance/twin-instance.ts +1 -1
  125. package/server/service/twin-journal/twin-journal-query.ts +52 -4
  126. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  127. package/server/service/twin-space/twin-space-area.ts +1 -1
  128. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  129. package/server/service/twin-space/twin-space.ts +14 -0
  130. package/server/service/twin-structure/index.ts +3 -0
  131. package/server/service/twin-structure/twin-structure.ts +93 -0
  132. package/server/service/twin-target/index.ts +5 -0
  133. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  134. package/server/service/twin-target/twin-target.ts +97 -0
  135. package/test/capability-mapping.test.ts +5 -5
  136. package/test/duration-estimators.test.ts +144 -0
  137. package/test/entity-delta.test.ts +150 -19
  138. package/test/ingest-bench.test.ts +9 -9
  139. package/test/kpi-fold.test.ts +232 -3
  140. package/test/live-mirror-parity.test.ts +53 -19
  141. package/test/master-to-twin.test.ts +87 -13
  142. package/test/model-basis.test.ts +86 -0
  143. package/test/oee-accumulator.test.ts +9 -9
  144. package/test/scale-twin-bench.test.ts +22 -22
  145. package/test/spec-coverage.test.ts +113 -0
  146. package/test/streamline-e2e.test.ts +10 -10
  147. package/test/structure-revision-db.test.ts +310 -0
  148. package/test/twin-event-keys.test.ts +2 -2
  149. package/test/vocabulary-guard.test.ts +43 -0
  150. package/test/warm-start.test.ts +9 -9
@@ -3,7 +3,7 @@
3
3
  * TwinEngine — operato-twin 커널 인스턴스를 things-factory 위에서 호스팅.
4
4
  * integration-base 의 ScenarioEngine 패턴 미러: 메모리 인스턴스 맵 + 백그라운드 tick + pubsub 상태 발행.
5
5
  *
6
- * 커널(zero-dep, ESM+CJS 이중배포)은 CJS require 로 로드(Node 20 CommonJS 소비 — require(ESM) 회피).
6
+ * 커널(zero-dep, ESM+CJS 이중배포)은 CJS require 로 로드(Node 20 CommonJS 소비 — require(ESM) 회피). (vocabulary-guard: allow — Node 런타임 버전 표기)
7
7
  * 커널은 결정적 논리 연산 코어 — 이 host 가 전송(pubsub)·영속(TypeORM)·스케줄(tick)·복구(replay)만 얇게 래핑.
8
8
  * (설계 SoT: operato-twin/design/integration/things-factory-host.md)
9
9
  */
@@ -15,16 +15,22 @@ const twin_event_js_1 = require("../service/twin-event/twin-event.js");
15
15
  const twin_event_keys_js_1 = require("../service/twin-event/twin-event-keys.js");
16
16
  const warm_start_js_1 = require("./warm-start.js");
17
17
  const twin_instance_js_1 = require("../service/twin-instance/twin-instance.js");
18
+ const twin_structure_js_1 = require("../service/twin-structure/twin-structure.js");
18
19
  const twin_space_js_1 = require("../service/twin-space/twin-space.js");
20
+ const reference_master_js_1 = require("../service/reference/reference-master.js");
19
21
  const twin_space_representation_js_1 = require("../service/twin-space/twin-space-representation.js");
20
22
  const twin_space_area_js_1 = require("../service/twin-space/twin-space-area.js");
21
23
  const twin_area_js_1 = require("../service/twin-space/twin-area.js");
22
- const reference_master_js_1 = require("../service/reference/reference-master.js");
24
+ const reference_master_js_2 = require("../service/reference/reference-master.js");
25
+ const travel_estimator_js_1 = require("./travel-estimator.js");
26
+ const measured_estimator_js_1 = require("./measured-estimator.js");
27
+ const kpi_query_js_1 = require("./kpi-query.js");
28
+ const node_crypto_1 = require("node:crypto");
23
29
  const entity_delta_js_1 = require("./entity-delta.js");
30
+ const structure_diff_js_1 = require("./structure-diff.js");
24
31
  const oee_accumulator_js_1 = require("./oee-accumulator.js");
25
- const live_attentions_js_1 = require("./live-attentions.js");
26
32
  /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
27
- const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel');
33
+ const { WmsKernel, YmsKernel, MesKernel, TwinRuntime, StateProjector, replay, replaySegments, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel');
28
34
  const KERNELS = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel };
29
35
  exports.DEFAULT_REALITY_MODE = 'sim-experiment';
30
36
  class TwinEngine {
@@ -157,17 +163,153 @@ class TwinEngine {
157
163
  return;
158
164
  }
159
165
  hydrate.call(kernel, plan.seed);
160
- console.log(`[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.moverCount} mover(s) restored. ` +
166
+ console.log(`[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.equipmentCount} equipment restored. ` +
161
167
  'Open orders are not restored (the snapshot carries no requested/fulfilled counts).');
162
168
  }
163
169
  /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
164
- static start(id, domainId, kind, board, realityMode, purpose) {
170
+ /**
171
+ * 공정 명세를 커널에 싣는다 — **시뮬레이션의 시간을 데이터가 말하게 하는 마지막 한 칸.**
172
+ *
173
+ * `board.operations`(마스터 인제스트가 통과시킨 ISA-95 OperationsSegment 명세)를 커널이 소비한다.
174
+ * 없으면 커널 기본 상수로 굴러가고, 커널 `specCoverage()` 가 무엇을 기본값으로 썼는지 보고한다.
175
+ *
176
+ * 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **조용히 넘어가지 않고 경고한다** — 명세를
177
+ * 선언했는데 반영되지 않는 상태를 모르고 지나가면, 예측이 상수로 돌아간 것을 아무도 알 수 없다.
178
+ */
179
+ static applyOperations(kernel, board, id) {
180
+ const ops = board?.operations;
181
+ if (!ops?.length)
182
+ return;
183
+ if (typeof kernel?.loadOperations !== 'function') {
184
+ console.warn(`[twin-engine] "${id}": board declares ${ops.length} operation spec(s) but the kernel cannot consume them (loadOperations missing — kernel needs publishing). Simulation will use built-in default durations.`);
185
+ return;
186
+ }
187
+ kernel.loadOperations(ops);
188
+ }
189
+ /**
190
+ * 소요시간 추정기 주입 — **상수를 데이터로 바꾸는 두 번째·세 번째 층.**
191
+ *
192
+ * 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 두 가지를 사슬로 넣는다:
193
+ * ① **실측**(저널의 작업 종류별 작업시간 p50) — 그 현장에서 실제로 얼마 걸렸나. 가장 강한 근거.
194
+ * ② **거리 × 속도**(board.layout + 설비 속도 속성) — 이동은 거리에 비례한다. 커널은 좌표를 모르므로
195
+ * 호스트가 계산해 넣는다.
196
+ * 둘 다 못 만들면 주입하지 않는다 — 커널이 명세·상수로 굴러가고 `specCoverage()` 가 그 사실을 남긴다.
197
+ *
198
+ * 실측은 DB 조회라 비동기다. 그래서 이 함수는 **await 하지 않는 쪽에서도 안전**하도록 실패를 삼키되,
199
+ * 무엇을 왜 못 넣었는지는 로그로 남긴다(조용한 무효화 금지).
200
+ */
201
+ static async installEstimators(kernel, domainId, instanceId, board) {
202
+ if (!kernel || typeof kernel !== 'object')
203
+ return;
204
+ const travel = (0, travel_estimator_js_1.buildTravelEstimator)({ layout: board?.layout, equipment: board?.equipment, unit: board?.unit });
205
+ const measured = await this.measuredEstimator(domainId, instanceId);
206
+ const chained = (0, travel_estimator_js_1.chainEstimators)([measured?.estimator, travel.estimator]);
207
+ if (!chained) {
208
+ /* 왜 못 넣었는지 한 번만 알린다 — 이동시간이 상수로 남은 이유를 모르고 지나가지 않게. */
209
+ if (travel.reasons.length)
210
+ console.warn(`[twin-engine] "${instanceId}": no duration estimator installed — ${travel.reasons.join('; ')}`);
211
+ return;
212
+ }
213
+ kernel.durationEstimator = chained;
214
+ const learned = Object.keys(measured?.learned ?? {});
215
+ const spread = Object.keys(measured?.spreads ?? {});
216
+ console.log(`[twin-engine] "${instanceId}": duration estimator installed — measured kinds: ${learned.length ? learned.join(',') : 'none'}` +
217
+ ` (with observed spread: ${spread.length ? spread.join(',') : 'none'})` +
218
+ `${travel.estimator ? `, travel from distance (speeds: ${Object.keys(travel.speedsByKind).join(',')})` : `, travel not derived (${travel.reasons.join('; ')})`}`);
219
+ }
220
+ /**
221
+ * 실측 추정기 — **예측 요청마다 저널을 다시 접지 않는다.**
222
+ *
223
+ * 예측 커널은 요청마다 새로 세워지고(미러 예측·백테스트), 화면은 시각을 긁으면 계속 재예측한다.
224
+ * 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로
225
+ * 짧은 TTL 로 재사용한다. 캐시는 인스턴스별이고, 만료 전에는 같은 값을 쓴다(예측 간 일관성도 얻는다).
226
+ */
227
+ static { this.measuredCache = new Map(); }
228
+ static { this.MEASURED_TTL_MS = 60_000; }
229
+ static async measuredEstimator(domainId, instanceId) {
230
+ const key = `${domainId}:${instanceId}`;
231
+ const hit = this.measuredCache.get(key);
232
+ if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS)
233
+ return hit.value;
234
+ let value;
235
+ try {
236
+ /* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
237
+ const kpi = await (0, kpi_query_js_1.computeTwinKpi)({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' });
238
+ value = (0, measured_estimator_js_1.buildMeasuredEstimator)(kpi?.groups?.items, {});
239
+ }
240
+ catch (err) {
241
+ console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, err?.message);
242
+ }
243
+ this.measuredCache.set(key, { at: Date.now(), value });
244
+ return value;
245
+ }
246
+ /**
247
+ * 지금 이 트윈이 어떤 모델로 굴러가는가 — 예측 출력 보정이 **자기가 배운 모델**에만 적용되도록
248
+ * 비교하는 지문. 재료는 호스트가 아는 것(실측·속도·선언 명세)이라 커널 발행 상태와 무관하다.
249
+ * 실패하면 undefined — 그때는 비교를 하지 않는다(모르는 것을 근거로 보정을 끊지 않는다).
250
+ */
251
+ static async modelBasis(domainId, instanceId) {
252
+ try {
253
+ const reg = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } });
254
+ const board = reg?.board;
255
+ const measured = await this.measuredEstimator(domainId, instanceId);
256
+ const travel = (0, travel_estimator_js_1.buildTravelEstimator)({ layout: board?.layout, equipment: board?.equipment, unit: board?.unit });
257
+ return { measured: measured?.learned, speeds: travel.speedsByKind, declared: board?.operations };
258
+ }
259
+ catch {
260
+ return undefined;
261
+ }
262
+ }
263
+ /**
264
+ * **이 트윈이 하루 몇 대를 낼 수 있는가** — 굴려 보지 않고 답한다.
265
+ *
266
+ * 계산은 커널이 자기 상태에서 한다(`kernel.capacity`). 여기서 하는 일은 **기준 주를 정해 주는
267
+ * 것**뿐이다: 공휴일이 없는 평상주여야 한다 — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시
268
+ * 천장을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 천장이 조용히 낮아진다.
269
+ *
270
+ * 트윈이 안 떠 있으면 `undefined` 다 — 0 이 아니다. 안 뜬 트윈의 천장을 0 이라고 답하면 화면은
271
+ * "이 공장은 아무것도 못 만든다" 고 말한다.
272
+ */
273
+ static async capacity(domainId, target, unitsPerDay, sampleWeekStartMs) {
274
+ /* 대상은 **등록부**가 정한다 — 떠 있는 런타임만 훑으면 "안 떠 있어서 안 보이는 것" 과 "없는 것" 이
275
+ 구별되지 않는다. 화면은 그 둘을 다르게 말해야 한다. */
276
+ const where = { domain: { id: domainId } };
277
+ if (target.instanceId)
278
+ where.instanceId = target.instanceId;
279
+ else
280
+ where.spaceId = target.spaceId;
281
+ const registered = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({ where });
282
+ return registered.map(reg => {
283
+ const rt = this.instances[reg.instanceId];
284
+ const kernel = rt?.domainId === domainId ? rt.kernel : undefined;
285
+ if (typeof kernel?.capacity !== 'function')
286
+ return { instanceId: reg.instanceId, kind: reg.kind, reason: 'not-running' };
287
+ const analysis = kernel.capacity({ unitsPerDay, sampleWeekStartMs });
288
+ /* 공정을 선언하지 않은 트윈(대개 YMS·WMS)은 **판정 대상이 아니다.** 0 으로 채워 넣으면
289
+ "야드가 병목" 이라는 없는 사실이 생긴다 — 잴 것이 없다고 말한다. */
290
+ if (!analysis?.operations?.length)
291
+ return { instanceId: reg.instanceId, kind: reg.kind, reason: 'no-operations' };
292
+ return { instanceId: reg.instanceId, kind: reg.kind, analysis };
293
+ });
294
+ }
295
+ static start(id, domainId, kind, board, realityMode, purpose, resumeFrom) {
165
296
  if (this.instances[id])
166
297
  return this.instances[id];
167
298
  const Kernel = KERNELS[kind] ?? WmsKernel;
168
- const kernel = new Kernel(domainId);
299
+ const kernel = new Kernel(domainId, undefined, this.mesSpecOf(board));
169
300
  kernel.loadBoard(board); // 구조만. 상태는 아래 웜스타트가 심는다.
301
+ this.applyOperations(kernel, board, id); // 시간·수율 명세(있으면) — 없으면 커널 기본값
302
+ /* 추정기는 DB 조회를 포함해 비동기 — 기동을 막지 않고 붙는다(붙기 전 작업은 명세·상수로 산출). */
303
+ this.installEstimators(kernel, domainId, id, board).catch(err => console.warn('[twin-engine] estimator install failed', err?.message));
170
304
  this.warmStart(id, kernel, purpose);
305
+ /*
306
+ * **번호를 이어 센다** — 저널에 이미 있는 번호와 겹치지 않게.
307
+ *
308
+ * 저널이 비어 있으면(0) 아무 일도 안 한다. 비어 있지 않은데 0부터 다시 세면 같은 트윈에 같은
309
+ * 리비전이 둘 생기고, 재생 순서가 뒤섞이며, 시간여행이 엉뚱한 시점을 답한다 — 오류는 안 난다.
310
+ */
311
+ if (resumeFrom && typeof kernel.resumeRevision === 'function')
312
+ kernel.resumeRevision(resumeFrom);
171
313
  const runtime = new TwinRuntime(kernel);
172
314
  /* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
173
315
  const inst = { id, domainId, runtime, kernel, realityMode: realityMode ?? exports.DEFAULT_REALITY_MODE, unsub: () => { } };
@@ -194,16 +336,67 @@ class TwinEngine {
194
336
  return inst;
195
337
  }
196
338
  /**
197
- * live-모드 기동(face2-inbound-live §1.1) 커널 tick 대신 StateProjector 로 실 이벤트를 미러.
198
- * 관측 상태(점유·위치·아이템)는 projector 가 재구성 → sim 과 동일 data(tag) payload → **보드 컴포넌트 동일 렌더**
199
- * (live-mirror-parity payload 검증됨). 계산 층(attentions·OEE)은 이벤트에 없어 부재 derive 층은 후속.
339
+ * live-모드 기동 — **관측 모드 커널**로 실 이벤트를 미러한다(통합 P2).
340
+ *
341
+ * 예전에는 `StateProjector` 세웠다. 그래서 라이브에는 **커널이 없었고**, 하나 때문에 우회가
342
+ * 줄줄이 생겼다: 예측하려면 임시 커널을 세워야 했고(`buildForecastKernel`), 주목 신호를 호스트가
343
+ * 덧붙여야 했고(`withLiveAttentions`), AI 예측 도구는 미러에서 "찾을 수 없다" 로 끝났다.
344
+ *
345
+ * 이제 라이브 인스턴스도 **커널이다.** 같은 규칙(`ObservedReducer`)으로 이벤트를 접고, 주목 신호를
346
+ * 스스로 내고, 그 자리에서 `fork` 해 예측한다. 구동만 다르다 — sim 은 `tick`, live 는 `apply`.
347
+ *
200
348
  * 실 이벤트원 = reference 어댑터 openLiveFeed → face2-adapter.ingest → CanonicalEnvelope → ingestLive().
201
349
  */
350
+ /**
351
+ * 보드가 실은 **생산 정의**(레시피·라우트·바인딩)를 꺼낸다 — 커널의 정의-구동 모드 입구.
352
+ *
353
+ * ── 없을 때 무엇이 일어났나 ──────────────────────────────────────────────
354
+ * 커널에는 정의-구동 MES 경로가 있는데 **호스트가 그것을 한 번도 넘기지 않았다.** 그래서 모든 MES
355
+ * 트윈이 **하드코딩된 레거시 흐름**으로 돌았다 — 현장 레시피를 아무리 정성껏 적어도 커널은 그것을
356
+ * 보지 못하고 토이 부품으로 토이 제품을 만들었다. 선언과 실행이 갈라져 있던 자리다.
357
+ *
358
+ * 보드에 없으면 `undefined` — 레거시 경로 그대로다(기존 트윈의 거동을 바꾸지 않는다).
359
+ */
360
+ static mesSpecOf(board) {
361
+ return board?.mesSpec;
362
+ }
363
+ /**
364
+ * 현장(공간)의 **시각 기준**을 보드에 얹는다 — 커널이 교대의 `HH:MM` 을 읽을 기준.
365
+ *
366
+ * **테넌트가 아니라 공간이 권위다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고,
367
+ * 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 그래서 인스턴스가 묶인 공간의
368
+ * `timezone` 을 읽는다. 공간이 말하지 않으면 **테넌트로 내려가지 않는다** — 잘못된 입자로 답하는 것이
369
+ * 모르는 것보다 나쁘다(그때는 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다).
370
+ *
371
+ * 커널은 zero-dep 이라 시간대 데이터베이스를 갖지 않으므로 **분 오프셋**으로 풀어 넘긴다. 그 값은
372
+ * 지금 계절의 것이다(일광절약시간) — 계절을 넘는 긴 예측은 한 시간 어긋난다(계약에 명시).
373
+ */
374
+ static async withSpaceTimeBase(board, domainId, spaceId) {
375
+ const sid = spaceId ?? board?.spaceId;
376
+ if (!sid)
377
+ return board;
378
+ const space = await (0, shell_1.getRepository)(twin_space_js_1.TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } }).catch(() => null);
379
+ const offset = (0, reference_master_js_1.utcOffsetOf)(space?.timezone);
380
+ if (offset === undefined) {
381
+ if (space?.timezone)
382
+ console.warn(`[twin-engine] space "${sid}" declares time zone "${space.timezone}" but it is not a known IANA zone — times will be read as UTC.`);
383
+ return board;
384
+ }
385
+ return { ...board, utcOffsetMinutes: offset };
386
+ }
202
387
  static startLive(id, domainId, kind, board) {
203
388
  if (this.instances[id])
204
389
  return this.instances[id];
205
- const projector = new StateProjector(board);
206
- const inst = { id, domainId, mode: 'live', realityMode: 'mirror', projector, oee: new oee_accumulator_js_1.OeeAccumulator(), unsub: () => { } };
390
+ const Kernel = KERNELS[kind] ?? WmsKernel;
391
+ const kernel = new Kernel(domainId, undefined, this.mesSpecOf(board));
392
+ kernel.loadBoard(board);
393
+ this.applyOperations(kernel, board, id); // 명세는 라이브에도 실린다(예측 자격이 sim 과 같아진다)
394
+ /* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
395
+ * (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
396
+ const projector = { apply: (e) => kernel.apply(e), snapshot: () => kernel.getSnapshot() };
397
+ const inst = { id, domainId, mode: 'live', realityMode: 'mirror', kernel, projector, oee: new oee_accumulator_js_1.OeeAccumulator(), unsub: () => { } };
398
+ /* 추정기(실측·거리)도 라이브에 붙인다 — 예측이 상수로 돌지 않게. 기동을 막지 않는다. */
399
+ this.installEstimators(kernel, domainId, id, board).catch(err => console.warn('[twin-engine] estimator install failed', err?.message));
207
400
  inst.metrics = { ingestedTotal: 0, broadcastTotal: 0, journaledTotal: 0, ingestRate: 0, broadcastRate: 0, journalRate: 0, backlog: 0, _accIngest: 0, _accBroadcast: 0, _accJournal: 0, _windowStartMs: Date.now() };
208
401
  this.instances[id] = inst;
209
402
  delete this.recovered[id];
@@ -315,22 +508,43 @@ class TwinEngine {
315
508
  * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
316
509
  * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
317
510
  */
318
- static journalRow(repo, domainId, instanceId, e, revision) {
511
+ static journalRow(repo, domainId, instanceId, e, revision, structureRev) {
319
512
  return repo.create({
320
513
  domain: { id: domainId },
321
514
  instanceId,
322
515
  tenantId: e?.tenantId,
323
516
  eventType: e?.eventType,
324
517
  revision,
518
+ /* **이 사실이 일어난 공장**을 함께 찍는다 — 이것이 없으면 나중에 구조가 바뀌었을 때 이 행을
519
+ 새 공장에 대고 접게 되고, 그때 없던 설비에서 일이 있었던 것처럼 보인다. */
520
+ ...(structureRev === undefined ? {} : { structureRev }),
325
521
  eventTime: e?.eventTime,
326
522
  ...(0, twin_event_keys_js_1.twinEventKeys)(e),
327
523
  payload: e
328
524
  });
329
525
  }
526
+ /**
527
+ * 이 트윈이 지금 어느 구조로 도는가 — 이벤트에 찍을 번호. 한 번 읽고 캐시한다(쓰기마다 조회 금지).
528
+ *
529
+ * 없으면 `undefined` 다 — 0 이 아니다. 구조 리비전이 생기기 전에 만들어진 트윈은 아직 리비전이
530
+ * 없고, 그 사실을 0 이라는 **유효해 보이는 번호**로 위장하면 안 된다.
531
+ */
532
+ static { this.structureRevCache = {}; }
533
+ static async structureRevOf(domainId, instanceId) {
534
+ const key = `${domainId}:${instanceId}`;
535
+ const cached = this.structureRevCache[key];
536
+ if (cached !== undefined)
537
+ return cached;
538
+ const latest = await (0, shell_1.getRepository)(twin_structure_js_1.TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } });
539
+ if (latest)
540
+ this.structureRevCache[key] = latest.rev;
541
+ return latest?.rev;
542
+ }
330
543
  /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
331
544
  static async persistBatch(domainId, instanceId, envelopes, startRevision) {
332
545
  const repo = (0, shell_1.getRepository)(twin_event_js_1.TwinEvent);
333
- const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1));
546
+ const structureRev = await this.structureRevOf(domainId, instanceId);
547
+ const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1, structureRev));
334
548
  await repo.save(rows, { chunk: 500 });
335
549
  }
336
550
  /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */
@@ -352,29 +566,29 @@ class TwinEngine {
352
566
  }));
353
567
  }
354
568
  /**
355
- * 라이브 구조 변이(resource.add 등)를 저장된 board 에 반영 — 런타임 커널의 무버를 registry board.movers 에 동기.
569
+ * 라이브 구조 변이(resource.add 등)를 저장된 board 에 반영 — 런타임 커널의 무버를 registry board.equipment 에 동기.
356
570
  * 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 board 는 원본이라 프로비저닝 편집기·재기동(loadBoard)이
357
- * 추가분을 잃는다. 기존 board.movers 항목은 보존(homeNode 유지)하고 새 id 만 append(추가 시점 location=homeNode).
358
- * 좌표(layout)만 다루는 register 와 달리 movers 집합을 갱신하나, 재프로비전(purge)이 아니라 in-place 갱신이라
571
+ * 추가분을 잃는다. 기존 board.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation).
572
+ * 좌표(layout)만 다루는 register 와 달리 equipment 집합을 갱신하나, 재프로비전(purge)이 아니라 in-place 갱신이라
359
573
  * 저널은 보존된다(추가는 이미 equipment 델타로 저널됨 → replay 는 id-keyed upsert 라 이중계산 없음).
360
574
  */
361
- static async syncBoardMovers(domainId, instanceId) {
575
+ static async syncBoardEquipment(domainId, instanceId) {
362
576
  const inst = this.instances[instanceId];
363
577
  const snap = inst?.kernel?.getSnapshot?.();
364
- if (!snap?.movers)
578
+ if (!snap?.equipment)
365
579
  return;
366
580
  const repo = (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance);
367
581
  const reg = await repo.findOne({ where: { domain: { id: domainId }, instanceId } });
368
582
  if (!reg?.board)
369
583
  return;
370
584
  const board = reg.board;
371
- const known = new Set((board.movers ?? []).map(m => m.id));
372
- const added = snap.movers
585
+ const known = new Set((board.equipment ?? []).map(m => m.id));
586
+ const added = snap.equipment
373
587
  .filter((m) => !known.has(m.id))
374
- .map((m) => ({ id: m.id, kind: m.kind, homeNode: m.location }));
588
+ .map((m) => ({ id: m.id, kind: m.kind, homeLocation: m.location }));
375
589
  if (!added.length)
376
590
  return;
377
- board.movers = [...(board.movers ?? []), ...added];
591
+ board.equipment = [...(board.equipment ?? []), ...added];
378
592
  reg.board = board;
379
593
  await repo.save(reg);
380
594
  }
@@ -383,28 +597,122 @@ class TwinEngine {
383
597
  * 재프로비전 시 구조 시그니처가 바뀌면(노드·무버 집합/속성 변경) 기존 저널을 purge 한다(ADR-0015):
384
598
  * board 는 replay 의 마스터라 구조가 바뀌면 과거 이벤트의 전제가 깨진다. 좌표(layout)만 바뀌면 저널 보존.
385
599
  */
386
- static async provision(domainId, instanceId, kind, board) {
600
+ static async provision(domainId, instanceId, kind, board, comment) {
387
601
  if (this.instances[instanceId])
388
602
  throw new Error(`instance "${instanceId}" is running — stop before re-provisioning`);
389
603
  const repo = (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance);
390
604
  const existing = await repo.findOne({ where: { domain: { id: domainId }, instanceId } });
391
- if (existing?.board && this.structureSignature(existing.board) !== this.structureSignature(board)) {
392
- await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).delete({ domain: { id: domainId }, instanceId });
393
- delete this.recovered[instanceId];
394
- }
605
+ /*
606
+ * **구조가 바뀌어도 역사를 지우지 않는다.**
607
+ *
608
+ * 예전에는 서명이 다르면 그 트윈의 저널을 통째로 지웠다. 안 지우면 옛 이벤트를 새 공장에 대고
609
+ * 접게 되어 이력이 거짓말을 했기 때문이다 — 부스가 둘이던 시절의 사실을 여섯 개짜리 공장에
610
+ * 접으면 그때 없던 부스에서 일이 있었던 것처럼 보인다. 선택지가 **역사를 잃거나 거짓말을
611
+ * 하거나** 둘뿐이었고, 그래서 공장을 고칠 때마다 이력을 버려야 했다.
612
+ *
613
+ * 이제 셋째 길로 간다: 바뀐 구조를 **새 리비전**으로 남기고, 앞으로 쓰이는 이벤트가 그 번호를
614
+ * 달고 다닌다. 재생은 구조가 바뀌는 지점에서 갈아탄 뒤 이어 접는다(`replaySegments`).
615
+ */
616
+ await this.recordStructure(domainId, instanceId, board, comment);
395
617
  await this.register(domainId, instanceId, kind, board, existing?.status === 'running' ? 'stopped' : existing?.status ?? 'stopped');
396
618
  }
619
+ /**
620
+ * 이 구조를 리비전으로 남기고 그 번호를 돌려준다 — **바뀌었을 때만** 새 번호가 생긴다.
621
+ *
622
+ * 프로비저닝은 부팅마다 다시 도는데, 그때마다 리비전이 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다.
623
+ * 그래서 **구조 서명이 같으면 있던 리비전을 그대로 쓴다.**
624
+ */
625
+ static async recordStructure(domainId, instanceId, board, comment) {
626
+ const repo = (0, shell_1.getRepository)(twin_structure_js_1.TwinStructure);
627
+ const signature = this.structureFingerprint(board);
628
+ const latest = await repo.findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } });
629
+ /*
630
+ * **저장된 지문이 아니라 저장된 공장과 비교한다.**
631
+ *
632
+ * 지문 계산 방식을 한 번이라도 바꾸면(원문 → 해시로 바꿨다) 저장된 문자열은 전부 옛 방식의
633
+ * 것이라 무엇과도 안 맞는다. 그러면 **아무것도 안 바뀐 트윈들이 전부 "구조가 바뀌었다"** 로
634
+ * 기록된다 — 실제로 그렇게 됐고, 재기동 한 번에 리비전이 통째로 하나씩 늘었다.
635
+ *
636
+ * 그래서 안 맞을 때 한 번 더 묻는다: 저장된 **보드**로 지문을 다시 계산하면 같은가? 같다면
637
+ * 공장은 그대로이고 지문 표기만 낡은 것이니, 리비전을 만들지 않고 표기만 고친다.
638
+ */
639
+ if (latest && latest.signature !== signature && this.structureFingerprint(latest.board) === signature) {
640
+ latest.signature = signature;
641
+ await repo.save(latest);
642
+ }
643
+ if (latest?.signature === signature) {
644
+ this.structureRevCache[`${domainId}:${instanceId}`] = latest.rev;
645
+ return latest.rev;
646
+ }
647
+ const rev = (latest?.rev ?? 0) + 1;
648
+ await repo.save(repo.create({ domain: { id: domainId }, instanceId, rev, signature, board: board, ...(comment ? { comment } : {}) }));
649
+ this.structureRevCache[`${domainId}:${instanceId}`] = rev;
650
+ /* 구조가 바뀌면 재구성 캐시는 옛 공장의 것이다 — 버린다(지우는 건 캐시뿐, 사실은 남는다). */
651
+ delete this.recovered[instanceId];
652
+ if (latest)
653
+ console.info(`[twin-engine] "${instanceId}" structure changed → revision ${rev} (history kept; older events stay under revision ${latest.rev}).`);
654
+ return rev;
655
+ }
656
+ /**
657
+ * 이 트윈이 거쳐 온 구조들 — **무엇이 언제 달라졌나.**
658
+ *
659
+ * 리비전은 그 시절 공장을 통째로 담지만(재생에 필요하다), 사람이 보고 싶은 것은 통째가 아니라
660
+ * 차이다. 보드는 빼고 **차이 요약만** 내보낸다 — 62KB 짜리 보드를 화면에 실어 보낼 이유가 없다.
661
+ */
662
+ static async structureHistory(domainId, instanceId) {
663
+ const revs = await (0, shell_1.getRepository)(twin_structure_js_1.TwinStructure).find({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'ASC' } });
664
+ if (!revs.length)
665
+ return [];
666
+ /* 리비전마다 그 아래에서 몇 건이 일어났나 — 이력의 무게를 보여준다(짧게 스쳐간 구조인지). */
667
+ const counts = new Map();
668
+ for (const row of await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent)
669
+ .createQueryBuilder('e')
670
+ .select('e.structureRev', 'rev')
671
+ .addSelect('COUNT(1)', 'n')
672
+ .where('e.domain = :domainId', { domainId })
673
+ .andWhere('e.instanceId = :instanceId', { instanceId })
674
+ .groupBy('e.structureRev')
675
+ .getRawMany())
676
+ counts.set(row.rev === null || row.rev === undefined ? null : Number(row.rev), Number(row.n));
677
+ /* 번호 없는 옛 행은 **가장 오래된 리비전**의 몫이다 — 재생이 그렇게 접으므로 세는 것도 같아야 한다. */
678
+ const oldest = revs[0].rev;
679
+ const legacy = counts.get(null) ?? 0;
680
+ return revs.map((r, i) => ({
681
+ rev: r.rev,
682
+ createdAt: r.createdAt,
683
+ ...(r.comment ? { comment: r.comment } : {}),
684
+ events: (counts.get(r.rev) ?? 0) + (r.rev === oldest ? legacy : 0),
685
+ diff: (0, structure_diff_js_1.diffStructures)(i === 0 ? undefined : revs[i - 1].board, r.board)
686
+ }));
687
+ }
688
+ /** 지금 쓰이는 구조 리비전 — 이벤트에 찍을 번호. 아직 없으면 기록하며 만든다. */
689
+ static async currentStructureRev(domainId, instanceId, board) {
690
+ const latest = await (0, shell_1.getRepository)(twin_structure_js_1.TwinStructure).findOne({ where: { domain: { id: domainId }, instanceId }, order: { rev: 'DESC' } });
691
+ if (latest)
692
+ return latest.rev;
693
+ return board ? this.recordStructure(domainId, instanceId, board) : undefined;
694
+ }
397
695
  /** 구조 동일성 지문 — 좌표(layout) 등 뷰 관심사는 제외하고 커널이 보는 위상·용량·무버만. */
696
+ /**
697
+ * 지문을 **64자로 줄인다** — 컬럼이 varchar(64) 다.
698
+ *
699
+ * 원문 지문은 자리·설비를 전부 이어 붙인 문자열이라 큰 공장에서는 수만 자가 된다. sqlite 는
700
+ * 길이를 강제하지 않아 그냥 들어가지만 **Postgres 는 거기서 터진다** — 개발에서는 멀쩡하고
701
+ * 운영에서만 죽는 종류의 실패다. 비교에만 쓰는 값이므로 해시로 충분하다.
702
+ */
703
+ static structureFingerprint(board) {
704
+ return (0, node_crypto_1.createHash)('sha256').update(this.structureSignature(board)).digest('hex');
705
+ }
398
706
  static structureSignature(board) {
399
- const nodes = [...(board.nodes ?? [])]
707
+ const locations = [...(board.locations ?? [])]
400
708
  .map(n => `${n.id}:${n.type}:${n.capacity}`)
401
709
  .sort()
402
710
  .join('|');
403
- const movers = [...(board.movers ?? [])]
404
- .map(m => `${m.id}:${m.kind}:${m.homeNode}`)
711
+ const equipment = [...(board.equipment ?? [])]
712
+ .map(m => `${m.id}:${m.kind}:${m.homeLocation}`)
405
713
  .sort()
406
714
  .join('|');
407
- return `N[${nodes}]M[${movers}]`;
715
+ return `N[${locations}]M[${equipment}]`;
408
716
  }
409
717
  /** 레지스트리 board 로 기동(프로비전된 인스턴스 start). board 인자 없이 저장된 구조로 재기동. */
410
718
  static async startFromRegistry(domainId, instanceId, realityMode) {
@@ -431,7 +739,20 @@ class TwinEngine {
431
739
  this.recovered[instanceId] = { revision: state.revision, state };
432
740
  }
433
741
  }
434
- return this.start(instanceId, domainId, reg.kind ?? 'wms', reg.board, realityMode ?? reg.realityMode ?? undefined, reg.purpose ?? undefined);
742
+ /*
743
+ * 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
744
+ *
745
+ * 저널을 초기화하고 기동하는 모드(sim-experiment)라면 이 값이 0이라 아무 영향이 없다. 규칙을
746
+ * 모드별로 가르지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
747
+ * 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
748
+ */
749
+ const last = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent)
750
+ .createQueryBuilder('e')
751
+ .select('MAX(e.revision)', 'max')
752
+ .where('e.domain = :domainId', { domainId })
753
+ .andWhere('e.instanceId = :instanceId', { instanceId })
754
+ .getRawOne();
755
+ return this.start(instanceId, domainId, reg.kind ?? 'wms', reg.board, realityMode ?? reg.realityMode ?? undefined, reg.purpose ?? undefined, Number(last?.max ?? 0) || 0);
435
756
  }
436
757
  /**
437
758
  * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 realityMode 에 따라 재기동 방식을 가른다.
@@ -451,9 +772,18 @@ class TwinEngine {
451
772
  return this.startLive(instanceId, domainId, reg.kind ?? 'wms', reg.board);
452
773
  }
453
774
  if (mode === 'sim-world') {
454
- // 선언은 world(지속)지만 resume 기계장치(커널 상태 복원)가 아직 없다 → 정직하게 큰 경고 후 fresh 기동.
455
- console.warn(`[twin-engine] "${instanceId}" declared sim-world but resume is not yet implemented (runtime-state-model §8 gate ②③) booting FRESH (journal reset). Persisted continuity will attach here once kernel-state resume lands.`);
456
- await this.resetJournal(domainId, instanceId);
775
+ /*
776
+ * **이어지는 현실**저널을 지우지 않는다.
777
+ *
778
+ * 예전에는 여기서 저널을 지우고 fresh 로 띄웠다. resume 기계장치가 없다고 봤기 때문인데,
779
+ * 실제로 없던 것은 **번호 이어 세기 하나**였다: 상태 복원은 이미 `startFromRegistry` 가
780
+ * 저널 재생(또는 체크포인트)으로 하고 있었고, 막힌 것은 커널이 리비전을 0부터 다시 세어
781
+ * 기존 행과 겹치는 문제였다. 그 하나를 `resumeRevision` 으로 풀었으므로 이제 이어 간다.
782
+ *
783
+ * 아직 이어지지 않는 것(정직하게): 시나리오 생성기의 난수 흐름은 재기동에 이어지지 않는다 —
784
+ * 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과
785
+ * 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다.
786
+ */
457
787
  return this.startFromRegistry(domainId, instanceId, 'sim-world');
458
788
  }
459
789
  // sim-experiment(기본): seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
@@ -481,7 +811,7 @@ class TwinEngine {
481
811
  const rows = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({ where: { domain: { id: domainId } } });
482
812
  const out = [];
483
813
  for (const r of rows) {
484
- const board = r.board ?? { nodes: [], movers: [] };
814
+ const board = r.board ?? { locations: [], equipment: [] };
485
815
  const last = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).findOne({
486
816
  where: { domain: { id: domainId }, instanceId: r.instanceId },
487
817
  order: { revision: 'DESC' }
@@ -496,8 +826,8 @@ class TwinEngine {
496
826
  copyOf: r.copyOf ?? undefined,
497
827
  running: !!this.instances[r.instanceId],
498
828
  revision: last?.revision ?? 0,
499
- nodeCount: board.nodes?.length ?? 0,
500
- moverCount: board.movers?.length ?? 0
829
+ locationCount: board.locations?.length ?? 0,
830
+ equipmentCount: board.equipment?.length ?? 0
501
831
  });
502
832
  }
503
833
  return out;
@@ -515,10 +845,10 @@ class TwinEngine {
515
845
  if (!r.spaceId || r.purpose === 'bench' || this.isBenchSpace(r.spaceId))
516
846
  continue; // 벤치·잔재 제외
517
847
  const board = r.board ?? {};
518
- const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, nodes: 0, movers: 0 };
848
+ const a = agg.get(r.spaceId) ?? { spaceId: r.spaceId, name: nameOf.get(r.spaceId) ?? r.spaceId, instances: 0, locations: 0, equipment: 0 };
519
849
  a.instances++;
520
- a.nodes += board.nodes?.length ?? 0;
521
- a.movers += board.movers?.length ?? 0;
850
+ a.locations += board.locations?.length ?? 0;
851
+ a.equipment += board.equipment?.length ?? 0;
522
852
  agg.set(r.spaceId, a);
523
853
  }
524
854
  return [...agg.values()];
@@ -559,7 +889,7 @@ class TwinEngine {
559
889
  * 노드타입은 커널 카탈로그로 검증(warning). start 는 호출측(bootstrap/mutation)이 결정.
560
890
  */
561
891
  static async ingestMaster(domainId, master) {
562
- const { board, spaceContent, warnings } = (0, reference_master_js_1.masterToTwin)(master, DOMAIN_CATALOG);
892
+ const { board, spaceContent, warnings } = (0, reference_master_js_2.masterToTwin)(master, DOMAIN_CATALOG);
563
893
  const spaceId = board.spaceId;
564
894
  const repo = (0, shell_1.getRepository)(twin_space_js_1.TwinSpace);
565
895
  const existing = await repo.findOne({ where: { domain: { id: domainId }, spaceId } });
@@ -586,7 +916,17 @@ class TwinEngine {
586
916
  // areas 는 content 에 더 이상 쓰지 않는다(area 단일화 P3) — 권위=TwinArea. 아래 TwinArea 로만 기록.
587
917
  landmarks: unionById(prev.landmarks, cur.landmarks)
588
918
  };
589
- const savedSpace = await repo.save(repo.create({ ...(existing ?? {}), domain: { id: domainId }, spaceId, name: existing?.name || master.siteName, content: mergedContent }));
919
+ /*
920
+ * **현장의 시각 기준을 공간에 남긴다** — 그러지 않으면 공간이 권위인데 그 값을 영원히 모른다.
921
+ *
922
+ * 마스터가 보드에 오프셋을 실어 주므로 갓 프로비저닝한 트윈은 맞게 돌지만, `withSpaceTimeBase` 가
923
+ * 읽는 곳은 **공간**이다. 여기서 옮기지 않으면 공간의 시간대가 계속 비어 있고, 사용자가 화면에서
924
+ * 그것을 보거나 고칠 수도 없다(만들고 잇지 않으면 없는 것이다).
925
+ *
926
+ * **사용자가 고친 값을 마스터가 덮지 않는다** — 이미 있으면 그대로 둔다(현장이 정본을 이긴다).
927
+ */
928
+ const timezone = existing?.timezone || master.space.timezone;
929
+ const savedSpace = await repo.save(repo.create({ ...(existing ?? {}), domain: { id: domainId }, spaceId, name: existing?.name || master.siteName, ...(timezone ? { timezone } : {}), content: mergedContent }));
590
930
  /*
591
931
  * 표현을 twin_space_representations 테이블에 저장한다 — 뷰(twin-map-page 등)는 content 가 아니라 테이블 표현을 소싱하므로,
592
932
  * 저장하지 않으면 인제스트 사이트에 "대표 표현" 이 없어 지도로 시작하지 못한다. rep.areas 가 있으면 TwinSpaceArea 도 함께 저장.
@@ -660,7 +1000,7 @@ class TwinEngine {
660
1000
  /* 상태 출처 스왑 — sim: 커널 runtime, live: projector 미러(+OEE 계산 층 보강). 계약·payload 동일, 드라이버만 다름. */
661
1001
  const st = inst.mode === 'live'
662
1002
  ? inst.oee
663
- ? (0, oee_accumulator_js_1.withLiveOee)(inst.projector?.snapshot?.(), inst.oee) // 관측 미러 + 계산(OEE) → mover payload 에 oee 포함(sim 동형)
1003
+ ? (0, oee_accumulator_js_1.withLiveOee)(inst.projector?.snapshot?.(), inst.oee) // 관측 커널 + 계산(OEE) → equipment payload 에 oee 포함(sim 동형)
664
1004
  : inst.projector?.snapshot?.()
665
1005
  : inst.runtime?.resync?.()?.state;
666
1006
  if (!st)
@@ -690,7 +1030,7 @@ class TwinEngine {
690
1030
  }
691
1031
  static async persist(domainId, instanceId, msg) {
692
1032
  const repo = (0, shell_1.getRepository)(twin_event_js_1.TwinEvent);
693
- await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision));
1033
+ await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision, await this.structureRevOf(domainId, instanceId)));
694
1034
  }
695
1035
  /**
696
1036
  * 재부팅 복구 / 시간여행 — DB 저널을 replay 해 상태 재구성.
@@ -720,17 +1060,67 @@ class TwinEngine {
720
1060
  order: { revision: 'ASC' }
721
1061
  });
722
1062
  const cutoffMs = untilTime != null ? Date.parse(untilTime) : NaN;
723
- const events = rows
724
- .filter(r => {
1063
+ const wanted = rows.filter(r => {
725
1064
  if (untilTime != null && !Number.isNaN(cutoffMs)) {
726
1065
  const t = r.eventTime != null ? Date.parse(String(r.eventTime)) : NaN;
727
1066
  return Number.isNaN(t) ? true : t <= cutoffMs; // eventTime 없는 레거시 행은 포함(보수적)
728
1067
  }
729
1068
  return untilRevision == null || (r.revision ?? 0) <= untilRevision;
730
- })
731
- .map(r => r.payload)
732
- .filter(Boolean);
733
- return replay(reg.board, events);
1069
+ });
1070
+ /*
1071
+ * **그때의 공장으로 접는다.**
1072
+ *
1073
+ * 예전에는 모든 이벤트를 지금 등록된 보드 하나로 접었다. 구조가 바뀐 적 없으면 맞지만, 바뀐
1074
+ * 뒤에는 옛 사실을 새 공장에 대고 접게 되어 — 그때 없던 설비에서 일이 있었던 것처럼 보인다.
1075
+ * (그래서 예전 프로비저닝은 구조가 바뀌면 저널을 아예 지웠다. 역사를 잃거나 거짓말을 하거나.)
1076
+ *
1077
+ * 이제 이벤트가 자기 구조를 달고 오므로, 리비전이 바뀌는 지점에서 구조를 갈아타며 이어 접는다.
1078
+ * 리비전이 하나뿐이거나(대다수) 아예 없으면(구조 이력 이전) 예전과 똑같이 한 번에 접는다.
1079
+ */
1080
+ const structures = await (0, shell_1.getRepository)(twin_structure_js_1.TwinStructure).find({
1081
+ where: { domain: { id: domainId }, instanceId },
1082
+ order: { rev: 'ASC' }
1083
+ });
1084
+ const boardOf = new Map(structures.map(x => [x.rev, x.board]));
1085
+ /* 컬럼이 생기기 전 행은 리비전이 비어 있다 — **가장 오래된 구조**에 속한다(0 으로 채우지 않는다). */
1086
+ const oldest = structures[0]?.rev;
1087
+ const revOf = (r) => r.structureRev ?? oldest;
1088
+ const segments = [];
1089
+ for (const r of wanted) {
1090
+ if (!r.payload)
1091
+ continue;
1092
+ const rev = revOf(r);
1093
+ const board = (rev !== undefined && boardOf.get(rev)) || reg.board;
1094
+ const last = segments[segments.length - 1];
1095
+ if (last && last.board === board)
1096
+ last.events.push(r.payload);
1097
+ else
1098
+ segments.push({ board, events: [r.payload] });
1099
+ }
1100
+ /*
1101
+ * **가장 새 구조가 지금의 공장이다** — 그 아래에서 아직 아무 일도 없었더라도.
1102
+ *
1103
+ * 재프로비저닝 직후가 정확히 그 상태다: 부스를 넷 늘렸는데 새 이벤트는 아직 하나도 없다. 이때
1104
+ * 마지막 이벤트의 구조로만 접으면 화면은 **옛 공장**을 보여준다 — 방금 늘린 것이 안 보인다.
1105
+ * 그래서 마디의 끝에 지금 구조를 한 번 더 얹는다(이벤트 없는 마디).
1106
+ *
1107
+ * **다만 "지금" 을 물었을 때만이다.** 과거 시점을 물었는데 최신 구조를 얹으면, 그 시점에 없던
1108
+ * 설비가 화면에 서고 — 이 작업이 막으려던 바로 그 거짓말이 된다.
1109
+ */
1110
+ const asOfNow = untilRevision == null && untilTime == null;
1111
+ const newest = structures[structures.length - 1]?.board;
1112
+ if (asOfNow && newest && segments[segments.length - 1]?.board !== newest)
1113
+ segments.push({ board: newest, events: [] });
1114
+ if (!segments.length)
1115
+ return replay(((asOfNow && newest) || reg.board), []);
1116
+ if (segments.length === 1)
1117
+ return replay(segments[0].board, segments[0].events);
1118
+ const { state, shifts } = replaySegments(segments);
1119
+ /* 경계에서 사라진 것을 조용히 넘기지 않는다 — 수가 줄어든 이유를 어딘가에는 남겨야 한다. */
1120
+ for (const sh of shifts)
1121
+ if (sh.equipmentDropped || sh.locationsDropped || sh.personsDropped || sh.assetsDropped)
1122
+ console.info(`[twin-engine] "${instanceId}" replay crossed a structure change — dropped ${sh.equipmentDropped} equipment, ${sh.locationsDropped} locations, ${sh.personsDropped} persons, ${sh.assetsDropped} assets that no longer exist.`);
1123
+ return state;
734
1124
  }
735
1125
  /**
736
1126
  * 공간(공동배치) 시각 범위 — 스크러버 앵커(runtime-state-model §4·§6). 그 공간 전 인스턴스 저널의 min/max eventTime.
@@ -793,6 +1183,21 @@ class TwinEngine {
793
1183
  static owns(domainId, id) {
794
1184
  return this.instances[id]?.domainId === domainId;
795
1185
  }
1186
+ /**
1187
+ * 이 트윈이 이 테넌트 것인가 — **떠 있든 아니든.**
1188
+ *
1189
+ * `owns()` 는 **떠 있는** 런타임만 안다. 그것을 이력 조회의 관문으로 쓰면, 꺼진 트윈의 저널을
1190
+ * 읽으려 할 때 "이 테넌트에 없다" 는 답이 돌아온다 — 두 가지가 틀렸다. 첫째, 저널은 트윈이 꺼져
1191
+ * 있을 때 **가장 필요한 것**이다(그게 이력의 존재 이유다). 둘째, 그 문장은 남의 것이라는 뜻이라
1192
+ * 사용자가 권한 문제로 오해한다. 실제로는 그냥 안 돌고 있을 뿐이다.
1193
+ *
1194
+ * 소유는 **등록부**가 안다. 이력·집계처럼 런타임과 무관한 질문은 이쪽에 묻는다.
1195
+ */
1196
+ static async ownsRegistered(domainId, instanceId) {
1197
+ if (this.owns(domainId, instanceId))
1198
+ return true;
1199
+ return !!(await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } }));
1200
+ }
796
1201
  /** 라이브 커널(ForecastTwin) — forecast/divergence 예측 연산용. */
797
1202
  static kernel(id) {
798
1203
  return this.instances[id]?.kernel;
@@ -828,32 +1233,15 @@ class TwinEngine {
828
1233
  }
829
1234
  return rows;
830
1235
  }
831
- /**
832
- * 라이브 예측용 임시 커널(kernel-unification P1) — 라이브는 projector 미러라 커널이 없어 예측을 못 한다.
833
- * 관측 스냅샷(재고·무버·노드) + 저널의 오더 원값·라인을 hydrate 해 예측 가능한 임시 커널을 만든다.
834
- * 라이브 런타임은 무간섭(이 커널은 fork 대상 임시본, instances 넣지 않음). sim 인스턴스면 null(그쪽은 kernel() 사용).
1236
+ /*
1237
+ * `buildForecastKernel` **사라졌다**(통합 P3, 2026-08-01).
1238
+ *
1239
+ * 라이브가 `StateProjector` 였을 때는 커널이 없어서, 예측하려면 관측 스냅샷 + 저널의 오더 원값으로
1240
+ * **일회용 커널을 세워야** 했다. 이제 라이브 인스턴스가 곧 커널이므로(`startLive`) 그 자리에서
1241
+ * `fork` 하면 된다 — `TwinEngine.kernel(id)` 가 sim·live 둘 다 돌려준다.
1242
+ *
1243
+ * (오더 원값을 저널에서 되찾던 이유도 사라졌다 — 상태가 requested·fulfilled·lines 를 지킨다.)
835
1244
  */
836
- static async buildForecastKernel(domainId, instanceId) {
837
- const inst = this.instances[instanceId];
838
- if (inst?.mode !== 'live' || !inst.projector)
839
- return null;
840
- const reg = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } });
841
- if (!reg?.board)
842
- return null;
843
- const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel;
844
- const k = new Kernel(domainId);
845
- k.loadBoard(reg.board);
846
- // 저널에서 오더별 최신 관측(원값+라인) 수집 — projector 스냅샷은 오더에 손실적(progress 만)이라 저널을 쓴다.
847
- const rows = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).find({ where: { domain: { id: domainId }, instanceId, eventType: OP_EVENT.order }, order: { revision: 'ASC' } });
848
- const latest = new Map();
849
- for (const r of rows) {
850
- const d = r.payload?.data;
851
- if (d?.orderId)
852
- latest.set(d.orderId, d);
853
- }
854
- k.hydrateObserved(this.snapshot(instanceId), [...latest.values()]);
855
- return k;
856
- }
857
1245
  /**
858
1246
  * 예측용 커널을 **임의 시각 T 기준**으로 재구성 — 과거-vantage 예측(백테스트)·"그때 서서 본 미래".
859
1247
  * recover(untilTime)로 T 시점 상태를, T 이하 오더 관측을 모아 hydrate → monteCarloForecast 가 T 에서 앞으로 굴린다.
@@ -864,8 +1252,10 @@ class TwinEngine {
864
1252
  if (!reg?.board)
865
1253
  return null;
866
1254
  const Kernel = KERNELS[reg.kind ?? 'wms'] ?? WmsKernel;
867
- const k = new Kernel(domainId);
1255
+ const k = new Kernel(domainId, undefined, this.mesSpecOf(reg.board));
868
1256
  k.loadBoard(reg.board);
1257
+ this.applyOperations(k, reg.board, instanceId); // 예측도 같은 명세로 굴러야 한다(화면과 다른 숫자 금지)
1258
+ await this.installEstimators(k, domainId, instanceId, reg.board); // 예측은 기다린다 — 실측을 놓치면 예측이 상수로 돈다
869
1259
  const state = await this.recover(domainId, instanceId, undefined, untilTime).catch(() => null);
870
1260
  if (!state)
871
1261
  return null;
@@ -886,9 +1276,11 @@ class TwinEngine {
886
1276
  static snapshot(id) {
887
1277
  const inst = this.instances[id];
888
1278
  if (inst?.mode === 'live' && inst.projector) {
889
- // live: projector 미러 + 계산 (OEE·attentions) 보강 질의(attention 등)가 sim 동형 스냅샷을 봄.
890
- const st = inst.oee ? (0, oee_accumulator_js_1.withLiveOee)(inst.projector.snapshot(), inst.oee) : inst.projector.snapshot();
891
- return (0, live_attentions_js_1.withLiveAttentions)(st);
1279
+ /* live: **커널이 주목 신호를 스스로 낸다**(관측 모드) 호스트가 덧붙이던 withLiveAttentions
1280
+ * 필요 없다. OEE 호스트가 채운다: 시스템이 시간 누적을 보내 주지 않아 상태 전이를 적분해
1281
+ * 만드는 값이라 커널이 대신할 수 없다(적합성 하네스의 유일한 예외). */
1282
+ const st = inst.projector.snapshot();
1283
+ return inst.oee ? (0, oee_accumulator_js_1.withLiveOee)(st, inst.oee) : st;
892
1284
  }
893
1285
  return inst?.runtime?.resync() ?? this.recovered[id];
894
1286
  }