@things-factory/headless-twin 10.0.11 → 10.0.12

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 (125) hide show
  1. package/dist-server/engine/canonical-ingest.d.ts +25 -2
  2. package/dist-server/engine/canonical-ingest.js +48 -10
  3. package/dist-server/engine/canonical-ingest.js.map +1 -1
  4. package/dist-server/engine/declared-stimulus.d.ts +43 -0
  5. package/dist-server/engine/declared-stimulus.js +57 -0
  6. package/dist-server/engine/declared-stimulus.js.map +1 -0
  7. package/dist-server/engine/index.d.ts +2 -0
  8. package/dist-server/engine/index.js +2 -0
  9. package/dist-server/engine/index.js.map +1 -1
  10. package/dist-server/engine/kpi-fold.d.ts +12 -0
  11. package/dist-server/engine/kpi-fold.js +21 -1
  12. package/dist-server/engine/kpi-fold.js.map +1 -1
  13. package/dist-server/engine/kpi-query.d.ts +3 -3
  14. package/dist-server/engine/kpi-query.js +4 -4
  15. package/dist-server/engine/kpi-query.js.map +1 -1
  16. package/dist-server/engine/live-feed-registry.d.ts +1 -1
  17. package/dist-server/engine/live-feed-registry.js +1 -1
  18. package/dist-server/engine/live-feed-registry.js.map +1 -1
  19. package/dist-server/engine/local-declarations.d.ts +3 -6
  20. package/dist-server/engine/local-declarations.js +97 -16
  21. package/dist-server/engine/local-declarations.js.map +1 -1
  22. package/dist-server/engine/measured-yield.d.ts +42 -0
  23. package/dist-server/engine/measured-yield.js +75 -0
  24. package/dist-server/engine/measured-yield.js.map +1 -0
  25. package/dist-server/engine/operation-basis.d.ts +16 -0
  26. package/dist-server/engine/operation-basis.js +20 -2
  27. package/dist-server/engine/operation-basis.js.map +1 -1
  28. package/dist-server/engine/property-effects.js +17 -0
  29. package/dist-server/engine/property-effects.js.map +1 -1
  30. package/dist-server/engine/restart-policy.d.ts +13 -0
  31. package/dist-server/engine/restart-policy.js +52 -0
  32. package/dist-server/engine/restart-policy.js.map +1 -0
  33. package/dist-server/engine/spec-coverage.d.ts +8 -0
  34. package/dist-server/engine/spec-coverage.js +3 -1
  35. package/dist-server/engine/spec-coverage.js.map +1 -1
  36. package/dist-server/engine/twin-engine.d.ts +48 -15
  37. package/dist-server/engine/twin-engine.js +179 -39
  38. package/dist-server/engine/twin-engine.js.map +1 -1
  39. package/dist-server/index.js +1 -1
  40. package/dist-server/index.js.map +1 -1
  41. package/dist-server/service/index.d.ts +1 -1
  42. package/dist-server/service/reference/control-routing.d.ts +14 -0
  43. package/dist-server/service/reference/control-routing.js +64 -0
  44. package/dist-server/service/reference/control-routing.js.map +1 -0
  45. package/dist-server/service/reference/index.d.ts +1 -0
  46. package/dist-server/service/reference/index.js +2 -0
  47. package/dist-server/service/reference/index.js.map +1 -1
  48. package/dist-server/service/reference/reference-adapter.d.ts +67 -0
  49. package/dist-server/service/reference/reference-adapter.js +18 -0
  50. package/dist-server/service/reference/reference-adapter.js.map +1 -1
  51. package/dist-server/service/reference/reference-live.js +3 -3
  52. package/dist-server/service/reference/reference-live.js.map +1 -1
  53. package/dist-server/service/reference/reference-resolver.d.ts +3 -3
  54. package/dist-server/service/reference/reference-resolver.js +35 -18
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/twin-audit/command-audit.d.ts +34 -0
  57. package/dist-server/service/twin-audit/command-audit.js +15 -1
  58. package/dist-server/service/twin-audit/command-audit.js.map +1 -1
  59. package/dist-server/service/twin-audit/twin-audit-event.d.ts +3 -0
  60. package/dist-server/service/twin-audit/twin-audit-event.js +10 -0
  61. package/dist-server/service/twin-audit/twin-audit-event.js.map +1 -1
  62. package/dist-server/service/twin-control/twin-control-mutation.d.ts +15 -2
  63. package/dist-server/service/twin-control/twin-control-mutation.js +85 -34
  64. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  65. package/dist-server/service/twin-instance/twin-instance.d.ts +1 -1
  66. package/dist-server/service/twin-instance/twin-instance.js +2 -2
  67. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  68. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +2 -1
  69. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  70. package/dist-server/service/twin-model/twin-model-query.js +1 -1
  71. package/dist-server/service/twin-model/twin-model-query.js.map +1 -1
  72. package/package.json +3 -3
  73. package/server/engine/canonical-ingest.ts +78 -14
  74. package/server/engine/declared-stimulus.ts +66 -0
  75. package/server/engine/index.ts +2 -0
  76. package/server/engine/kpi-fold.ts +29 -1
  77. package/server/engine/kpi-query.ts +8 -8
  78. package/server/engine/live-feed-registry.ts +2 -2
  79. package/server/engine/local-declarations.ts +97 -19
  80. package/server/engine/measured-yield.ts +89 -0
  81. package/server/engine/operation-basis.ts +33 -2
  82. package/server/engine/property-effects.ts +17 -0
  83. package/server/engine/restart-policy.ts +55 -0
  84. package/server/engine/spec-coverage.ts +23 -3
  85. package/server/engine/twin-engine.ts +201 -42
  86. package/server/index.ts +1 -1
  87. package/server/service/reference/control-routing.ts +62 -0
  88. package/server/service/reference/index.ts +1 -0
  89. package/server/service/reference/reference-adapter.ts +78 -0
  90. package/server/service/reference/reference-live.ts +3 -3
  91. package/server/service/reference/reference-resolver.ts +46 -7
  92. package/server/service/twin-audit/command-audit.ts +34 -1
  93. package/server/service/twin-audit/twin-audit-event.ts +20 -0
  94. package/server/service/twin-control/twin-control-mutation.ts +75 -28
  95. package/server/service/twin-instance/twin-instance.ts +16 -10
  96. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +2 -1
  97. package/server/service/twin-model/twin-model-query.ts +1 -1
  98. package/test/adopt-structure-live.test.ts +7 -7
  99. package/test/boot-resume.test.ts +3 -3
  100. package/test/control-capability.test.ts +103 -0
  101. package/test/declared-stimulus.test.ts +88 -0
  102. package/test/ingest-running-guard.test.ts +5 -5
  103. package/test/instance-cache-lifecycle.test.ts +1 -1
  104. package/test/kpi-baseline-db.test.ts +1 -1
  105. package/test/kpi-query-bench.test.ts +1 -1
  106. package/test/lineage-survives-restart.test.ts +3 -3
  107. package/test/live-feed-registry.test.ts +6 -6
  108. package/test/local-declarations.test.ts +108 -1
  109. package/test/measured-yield.test.ts +90 -0
  110. package/test/operation-basis.test.ts +28 -1
  111. package/test/operational-vocabulary.test.ts +108 -0
  112. package/test/operations-capability-db.test.ts +4 -4
  113. package/test/project-structure-db.test.ts +1 -1
  114. package/test/projection-reaches-screen.test.ts +1 -1
  115. package/test/property-effects.test.ts +28 -0
  116. package/test/restart-policy.test.ts +111 -0
  117. package/test/resync-origin-site.test.ts +7 -1
  118. package/test/source-outcome-audit.test.ts +104 -0
  119. package/test/structure-revision-db.test.ts +27 -27
  120. package/test/tenant-registry-db.test.ts +2 -2
  121. package/test/twin-model-item-db.test.ts +3 -3
  122. package/test/twin-model-tree-db.test.ts +7 -7
  123. package/test/twin-origin-resync.test.ts +4 -4
  124. package/test/yield-loop.test.ts +144 -0
  125. package/tsconfig.tsbuildinfo +1 -1
@@ -8,7 +8,7 @@
8
8
  * (설계 SoT: operato-twin/design/integration/things-factory-host.md)
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.TwinEngine = exports.DEFAULT_REALITY_MODE = void 0;
11
+ exports.TwinEngine = void 0;
12
12
  const shell_1 = require("@things-factory/shell");
13
13
  /* 저널을 자를 조건은 SQL 이 안다 — 관용구만 쓴다(원시 SQL 은 5개 드라이버에서 갈라진다). */
14
14
  const typeorm_1 = require("typeorm");
@@ -20,6 +20,8 @@ const local_declarations_js_1 = require("./local-declarations.js");
20
20
  const runtime_key_js_1 = require("./runtime-key.js");
21
21
  const command_routing_js_1 = require("./command-routing.js");
22
22
  const twin_instance_js_1 = require("../service/twin-instance/twin-instance.js");
23
+ /* 자극의 집은 원본이다(ADR-0029) — 그 행을 읽고 쓴다. */
24
+ const twin_reference_js_1 = require("../service/reference/twin-reference.js");
23
25
  const twin_structure_js_1 = require("../service/twin-structure/twin-structure.js");
24
26
  const twin_space_js_1 = require("../service/twin-space/twin-space.js");
25
27
  const reference_master_js_1 = require("../service/reference/reference-master.js");
@@ -32,6 +34,9 @@ const reference_master_js_3 = require("../service/reference/reference-master.js"
32
34
  const project_structure_js_1 = require("../service/twin-model/project-structure.js");
33
35
  const travel_estimator_js_1 = require("./travel-estimator.js");
34
36
  const measured_estimator_js_1 = require("./measured-estimator.js");
37
+ const measured_yield_js_1 = require("./measured-yield.js");
38
+ const declared_stimulus_js_1 = require("./declared-stimulus.js");
39
+ const restart_policy_js_1 = require("./restart-policy.js");
35
40
  const kpi_query_js_1 = require("./kpi-query.js");
36
41
  const node_crypto_1 = require("node:crypto");
37
42
  const entity_delta_js_1 = require("@things-factory/headless-twin/dist-shared/entity-delta.js");
@@ -43,7 +48,7 @@ const attention_digest_js_1 = require("./attention-digest.js");
43
48
  const live_feed_registry_js_1 = require("./live-feed-registry.js");
44
49
  const twin_kernel_1 = require("@operato/twin-kernel");
45
50
  /* 커널 런타임 로드 — CJS 번들(dist-cjs). 타입은 위 import type 로. replay = 이벤트열→상태 재구성(복구·시간여행). */
46
- const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT } = require('@operato/twin-kernel');
51
+ const { WmsKernel, YmsKernel, MesKernel, EmsKernel, TwinRuntime, StateProjector, replay, replayFrom, replayWithCheckpoint, replaySegments, readBoardLocations, readBoardEquipment, DOMAIN_CATALOG, OP_EVENT, validateScenario } = require('@operato/twin-kernel');
47
52
  const KERNELS = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel };
48
53
  /**
49
54
  * 종류 문자열 → 커널. **모르는 값이면 던진다.**
@@ -61,7 +66,6 @@ function kernelFor(kind) {
61
66
  throw new Error(`unknown twin kind "${kind}" — expected one of ${Object.keys(KERNELS).join(' | ')}`);
62
67
  return K;
63
68
  }
64
- exports.DEFAULT_REALITY_MODE = 'sim-experiment';
65
69
  class TwinEngine {
66
70
  /*
67
71
  * 기동 중인 런타임 — **키는 `runtimeKey(domainId, instanceId)`** 다(`runtime-key.ts` 에 이유).
@@ -301,7 +305,7 @@ class TwinEngine {
301
305
  *
302
306
  * ── 모드를 지어내지 않는다 ──────────────────────────────────────────────────
303
307
  * 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다).
304
- * 그래서 선언된 `realityMode` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
308
+ * 그래서 선언된 `restartPolicy` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로.
305
309
  *
306
310
  * ── 되살릴 수 없으면 그렇게 적는다 ──────────────────────────────────────────
307
311
  * 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한
@@ -323,7 +327,7 @@ class TwinEngine {
323
327
  return;
324
328
  }
325
329
  try {
326
- if (row.realityMode === 'mirror') {
330
+ if (row.restartPolicy === 'resync') {
327
331
  if (!row.model)
328
332
  throw new Error('no model');
329
333
  /* 시각 기준은 **공간**이 갖는다 — 교대의 HH:MM 을 어느 기준으로 읽나(라이브 기동과 같은 규칙). */
@@ -334,7 +338,7 @@ class TwinEngine {
334
338
  }
335
339
  else {
336
340
  await this.startFromRegistry(domainId, instanceId);
337
- console.log(`[twin-engine] resumed ${row.realityMode} "${instanceId}".`);
341
+ console.log(`[twin-engine] resumed ${row.restartPolicy} "${instanceId}".`);
338
342
  }
339
343
  }
340
344
  catch (err) {
@@ -445,6 +449,22 @@ class TwinEngine {
445
449
  }
446
450
  }
447
451
  }
452
+ /* 공정 모수(수율·셋업)도 같은 규율로 싣는다 — 커널이 그 창구를 갖지 않으면 그 사실을 말한다. */
453
+ const params = model?.localParams;
454
+ if (params && Object.keys(params).length) {
455
+ if (typeof kernel?.declareParameters !== 'function') {
456
+ console.warn(`[twin-engine] "${id}": this facility declared operation parameters for ${Object.keys(params).length} operation(s) but the kernel cannot consume them ` +
457
+ '(declareParameters missing — kernel needs publishing). Yield and setup keep running on built-in constants.');
458
+ }
459
+ else {
460
+ try {
461
+ kernel.declareParameters(params);
462
+ }
463
+ catch (err) {
464
+ console.warn(`[twin-engine] "${id}": declared operation parameter rejected by the kernel — ${err?.message ?? err}`);
465
+ }
466
+ }
467
+ }
448
468
  const ops = model?.operations;
449
469
  if (!ops?.length)
450
470
  return;
@@ -479,6 +499,31 @@ class TwinEngine {
479
499
  return;
480
500
  }
481
501
  kernel.durationEstimator = chained;
502
+ /*
503
+ * **양품률도 이력에서 배운다** (2026-08-19) — 소요와 같은 자리에서 붙인다.
504
+ *
505
+ * 커널이 그 시임을 갖지 않은 버전이면(발행 이전) 조용히 넘어가지 않고 말한다: 수율이 상수로 남은
506
+ * 이유를 모르고 지나가면, 화면의 불량 판정이 그 현장의 사실이 아니라 우리 상수의 결과다.
507
+ */
508
+ const yields = await this.measuredYield(domainId, instanceId);
509
+ if (yields?.estimator) {
510
+ if (typeof kernel.yieldOf !== 'function') {
511
+ console.warn(`[twin-engine] "${instanceId}": learned yield for ${Object.keys(yields.learned).length} operation kind(s) but the kernel cannot consume it ` +
512
+ '(yieldOf missing — kernel needs publishing). Yield keeps running on the declared value or the built-in constant.');
513
+ }
514
+ else {
515
+ kernel.yieldEstimator = yields.estimator;
516
+ console.log(`[twin-engine] "${instanceId}": measured yield installed — ${Object.entries(yields.learned)
517
+ .map(([k, v]) => `${k}=${Math.round(v * 1000) / 10}%(${yields.samples[k].good + yields.samples[k].scrap}건)`)
518
+ .join(', ')}${yields.skipped.length ? ` · 표본 부족으로 뺀 종류: ${yields.skipped.map(s => `${s.kind}(${s.judged})`).join(', ')}` : ''}`);
519
+ }
520
+ }
521
+ else if (yields?.skipped.length) {
522
+ /* 배운 것이 없고 버린 것만 있으면 그 사실도 말한다 — 「이력이 없다」와 「표본이 모자라다」는 다르다. */
523
+ console.log(`[twin-engine] "${instanceId}": no measured yield yet — samples below ${yields.minSamples}: ${yields.skipped
524
+ .map(s => `${s.kind}(${s.judged})`)
525
+ .join(', ')}`);
526
+ }
482
527
  const learned = Object.keys(measured?.learned ?? {});
483
528
  const spread = Object.keys(measured?.spreads ?? {});
484
529
  console.log(`[twin-engine] "${instanceId}": duration estimator installed — measured kinds: ${learned.length ? learned.join(',') : 'none'}` +
@@ -508,6 +553,11 @@ class TwinEngine {
508
553
  * 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 조용히 느려진다).
509
554
  */
510
555
  static { this.MEASURED_MAX = 5_000; }
556
+ /** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 접지 않는다). */
557
+ static async measuredYield(domainId, instanceId) {
558
+ await this.measuredEstimator(domainId, instanceId);
559
+ return this.measuredCache.get((0, runtime_key_js_1.runtimeKey)(domainId, instanceId))?.yields;
560
+ }
511
561
  static async measuredEstimator(domainId, instanceId) {
512
562
  /* 키는 `runtimeKey` 하나로 — 손으로 조립하면 지우는 쪽과 어긋나 못 지우는 항목이 생긴다. */
513
563
  const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
@@ -515,17 +565,20 @@ class TwinEngine {
515
565
  if (hit && Date.now() - hit.at < this.MEASURED_TTL_MS)
516
566
  return hit.value;
517
567
  let value;
568
+ let yields;
518
569
  try {
519
570
  /* 작업 종류별 실측 — 창은 넉넉히(하루) 두고 표본이 모자란 종류는 추정기가 스스로 뺀다. */
520
571
  const kpi = await (0, kpi_query_js_1.computeTwinKpi)({ domainId, instanceId, windowMinutes: 24 * 60, groupBy: 'taskKind' });
521
572
  value = (0, measured_estimator_js_1.buildMeasuredEstimator)(kpi?.groups?.items, {});
573
+ /* 같은 그룹에서 양품률도 배운다 — 한 번 접은 저널을 둘이 나눠 쓴다. */
574
+ yields = (0, measured_yield_js_1.buildYieldEstimator)(kpi?.groups?.items, {});
522
575
  }
523
576
  catch (err) {
524
577
  console.warn(`[twin-engine] "${instanceId}": measured duration lookup failed — falling back to declared/default durations.`, err?.message);
525
578
  }
526
579
  /* 다시 넣어 **최근 쓴 것**으로 만든다 — 삽입 순서가 곧 버릴 순서이므로 이 한 줄이 LRU 를 만든다. */
527
580
  this.measuredCache.delete(key);
528
- this.measuredCache.set(key, { at: Date.now(), value });
581
+ this.measuredCache.set(key, { at: Date.now(), value, yields });
529
582
  if (this.measuredCache.size > this.MEASURED_MAX) {
530
583
  const oldest = this.measuredCache.keys().next();
531
584
  if (!oldest.done)
@@ -533,6 +586,66 @@ class TwinEngine {
533
586
  }
534
587
  return value;
535
588
  }
589
+ /**
590
+ * **원본이 선언한 자극을 싣는다** — 재기동해도 살아 있게 (2026-08-19).
591
+ *
592
+ * ── 무엇이 났나 ────────────────────────────────────────────────────────────
593
+ * 데모의 시나리오는 시드 코드가 메모리에만 실었다. 그래서 트윈을 재기동하면 자극이 사라지고 구조만
594
+ * 서 있는 트윈이 남았다(작업 0·오더 0) — 「살아 있는 데모」가 **첫 재기동까지만** 살았다.
595
+ *
596
+ * 자극의 집은 **원본**이다(`TwinReference.connectionConfig.scenario`, ADR-0029 ·
597
+ * `plans/simulator-as-source.md` §4): 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터가 정하는 사실이고
598
+ * 트윈은 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 걷어내야 할 표면이 하나 늘어난다.
599
+ *
600
+ * 판정은 순수 함수가 한다(`planStimulus`) — 미러에 싣지 않고, 검사를 통과하지 못한 선언은 태우지 않고
601
+ * (그 이력이 있다: 잘못된 선언 하나가 다음 틱에서 서버를 내렸다), 못 실은 이유는 **말한다.**
602
+ */
603
+ static async installStimulus(domainId, instanceId, inst) {
604
+ let config;
605
+ try {
606
+ const ref = await (0, shell_1.getRepository)(twin_reference_js_1.TwinReference).findOne({ where: { domain: { id: domainId }, source: instanceId } });
607
+ config = ref?.connectionConfig;
608
+ }
609
+ catch (err) {
610
+ console.warn(`[twin-engine] "${instanceId}": could not read the declared stimulus — ${err?.message ?? err}`);
611
+ return;
612
+ }
613
+ const plan = (0, declared_stimulus_js_1.planStimulus)(config, { hasScenarioEngine: !!inst.runtime?.scenario, mode: inst.mode }, validateScenario);
614
+ if (plan.action === 'skip') {
615
+ /* 선언이 없는 것은 정상이므로 조용히 지난다. 나머지 셋은 **말한다** — 선언했는데 안 실린 상태다. */
616
+ if (plan.reason !== 'none') {
617
+ console.warn(`[twin-engine] "${instanceId}": a stimulus is declared on its source but was not loaded (${plan.reason}${plan.detail ? `: ${plan.detail}` : ''}).` +
618
+ (plan.reason === 'observed' ? ' This twin runs on observation — we do not manufacture arrivals for it.' : ''));
619
+ }
620
+ return;
621
+ }
622
+ try {
623
+ inst.runtime.scenario.load(plan.scenario);
624
+ inst.runtime.scenario.start();
625
+ const kinds = (plan.scenario.generators ?? []).map((g) => g?.kind).filter(Boolean);
626
+ console.log(`[twin-engine] "${instanceId}": stimulus from its source started — ${kinds.length ? kinds.join(', ') : 'no generators'}.`);
627
+ }
628
+ catch (err) {
629
+ console.warn(`[twin-engine] "${instanceId}": the declared stimulus was rejected at load — ${err?.message ?? err}`);
630
+ }
631
+ }
632
+ /**
633
+ * 자극을 **원본에 선언한다** — 프로비저닝·데모 시드가 부르는 문.
634
+ *
635
+ * 트윈이 아니라 원본에 적는 이유는 위와 같다(ADR-0029). 원본 행이 없으면 만들지 않는다 — 어떤 원본에서
636
+ * 온 트윈인지 모르는 채 자극을 지어 붙이면, 그 자극이 어디서 왔는지 아무도 되짚을 수 없다.
637
+ */
638
+ static async declareStimulus(domainId, instanceId, scenario) {
639
+ const repo = (0, shell_1.getRepository)(twin_reference_js_1.TwinReference);
640
+ const ref = await repo.findOne({ where: { domain: { id: domainId }, source: instanceId } });
641
+ if (!ref) {
642
+ console.warn(`[twin-engine] "${instanceId}": no source reference — a stimulus has nowhere to be declared.`);
643
+ return false;
644
+ }
645
+ ref.connectionConfig = (0, declared_stimulus_js_1.withStimulus)(ref.connectionConfig, scenario);
646
+ await repo.save(ref);
647
+ return true;
648
+ }
536
649
  /**
537
650
  * 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거.
538
651
  *
@@ -656,7 +769,7 @@ class TwinEngine {
656
769
  ].join(', ');
657
770
  console.log(`[twin-engine] mirror "${id}" carried over ${carried} — observation axes come from the source.`);
658
771
  }
659
- static start(id, domainId, kind, model, realityMode, purpose, resumeFrom) {
772
+ static start(id, domainId, kind, model, restartPolicy, purpose, resumeFrom) {
660
773
  const key = (0, runtime_key_js_1.runtimeKey)(domainId, id);
661
774
  if (this.instances[key])
662
775
  return this.instances[key];
@@ -695,11 +808,23 @@ class TwinEngine {
695
808
  * 실제로는 도는 커널이 옛 수를 그대로 쓰고 재기동 때 받는데, 그 사실이 답에서 사라진 것이다.
696
809
  * 규약에 기대는 대신 사실을 적는다: 「돌고 있나」를 묻는 쪽이 그 답을 받아야 한다.
697
810
  */
698
- const inst = { id, domainId, mode: 'sim', runtime, kernel, realityMode: realityMode ?? exports.DEFAULT_REALITY_MODE, spaceId: model?.spaceId, unsub: () => { } };
811
+ const inst = {
812
+ id,
813
+ domainId,
814
+ mode: 'sim',
815
+ runtime,
816
+ kernel,
817
+ /* 정책은 부르는 쪽이 선언한다 — 여기서 고르면 같은 트윈이 부르는 자리에 따라 다르게 재기동한다. */
818
+ restartPolicy: (0, restart_policy_js_1.readRestartPolicy)(restartPolicy, `start("${id}")`),
819
+ spaceId: model?.spaceId,
820
+ unsub: () => { }
821
+ };
699
822
  this.instances[key] = inst;
700
823
  /* 다시 세웠으므로 지난 정지 이유는 사실이 아니다 — 남겨 두면 도는 트윈이 「굶겨서 멈췄다」고 말한다. */
701
824
  this.stopNotes.delete(key);
702
825
  delete this.recovered[key]; // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
826
+ /* 원본이 선언한 자극 — 기동을 막지 않는다(추정기와 같은 규율). 못 실었으면 그 사실을 말한다. */
827
+ this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus install failed — ${err?.message ?? err}`));
703
828
  /* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
704
829
  (0, shell_1.getRepository)(shell_1.Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => { });
705
830
  /* State 채널: runtime.subscribe(snapshot→delta→clock) → pubsub 방송(구독 리졸버가 instanceId 필터). */
@@ -725,8 +850,8 @@ class TwinEngine {
725
850
  }
726
851
  });
727
852
  inst.unsub = () => sub.unsubscribe();
728
- /* 복구 앵커: 레지스트리에 model/kind/status/realityMode 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
729
- this.register(domainId, id, kind, model, 'running', inst.realityMode).catch(err => console.error('twin register fail', err));
853
+ /* 복구 앵커: 레지스트리에 model/kind/status/restartPolicy 영속(재부팅 시 이게 있어야 replay·선언 거동 가능). */
854
+ this.register(domainId, id, kind, model, 'running', inst.restartPolicy).catch(err => console.error('twin register fail', err));
730
855
  /* 워커 tick — 스켈레톤은 setInterval(메인 루프). 긴 시뮬 오프-루프(worker thread)는 스케일 하드닝(향후, §host 경계). */
731
856
  inst.timer = setInterval(() => this.tickGuarded(domainId, id, runtime), this.TICK_MS);
732
857
  return inst;
@@ -809,7 +934,7 @@ class TwinEngine {
809
934
  /* `projector` 필드는 옛 이름으로 남긴다 — 소비처가 `snapshot()` 을 부르므로 얇은 어댑터로 잇는다.
810
935
  * (P3 에서 소비처를 커널 어휘로 바꾸면 사라진다.) */
811
936
  const projector = { apply: (e) => kernel.apply(e), snapshot: () => kernel.getSnapshot() };
812
- const inst = { id, domainId, mode: 'live', realityMode: 'mirror', kernel, projector, oee: new oee_accumulator_js_1.OeeAccumulator(), spaceId: model?.spaceId, unsub: () => { } };
937
+ const inst = { id, domainId, mode: 'live', restartPolicy: 'resync', kernel, projector, oee: new oee_accumulator_js_1.OeeAccumulator(), spaceId: model?.spaceId, unsub: () => { } };
813
938
  /*
814
939
  * ── 커널이 **판정으로 낸 사실**도 저널에 남는다 (2026-08-14 실측으로 잡음) ────
815
940
  *
@@ -839,6 +964,8 @@ class TwinEngine {
839
964
  this.installEstimators(kernel, domainId, id, model).catch(err => console.warn('[twin-engine] estimator install failed', err?.message));
840
965
  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() };
841
966
  this.instances[key] = inst;
967
+ /* 미러에도 부른다 — 선언이 있으면 「미러에는 싣지 않는다」고 말해야 한다(조용한 무시 금지). */
968
+ this.installStimulus(domainId, id, inst).catch(err => console.warn(`[twin-engine] "${id}": stimulus check failed — ${err?.message ?? err}`));
842
969
  /*
843
970
  * **원천이 되풀어 주지 않는 것만 잇는다** (2026-08-18 실측으로 붙임).
844
971
  *
@@ -859,7 +986,7 @@ class TwinEngine {
859
986
  .findOne({ where: { domain: { id: domainId }, instanceId: id }, order: { revision: 'DESC' } })
860
987
  .then(top => (inst.revision = top?.revision ?? 0))
861
988
  .catch(() => (inst.revision = 0));
862
- this.register(domainId, id, kind, model, 'running', 'mirror').catch(err => console.error('twin register fail', err));
989
+ this.register(domainId, id, kind, model, 'running', 'resync').catch(err => console.error('twin register fail', err));
863
990
  return inst;
864
991
  }
865
992
  /**
@@ -1034,7 +1161,7 @@ class TwinEngine {
1034
1161
  await repo.save(rows, { chunk: 500 });
1035
1162
  }
1036
1163
  /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */
1037
- static async register(domainId, instanceId, kind, model, status = 'running', realityMode, origin,
1164
+ static async register(domainId, instanceId, kind, model, status = 'running', restartPolicy, origin,
1038
1165
  /*
1039
1166
  * 사람이 부르는 이름과 **누가 했나** — 업무키를 나눠 둔 대가로 이름을 함께 실어야 한다.
1040
1167
  * 사람 없는 경로(부팅 자동 프로비저닝·복구)는 `actor` 를 주지 않는다 — 아무 사용자를 적으면
@@ -1052,10 +1179,11 @@ class TwinEngine {
1052
1179
  // 인스턴스↔공간 1급 링크(space #3) — model.spaceId/areaId 를 컬럼으로 승격(dual-write). model JSON 도 유지(커널·복구용).
1053
1180
  spaceId: model?.spaceId ?? existing?.spaceId,
1054
1181
  areaId: model?.areaId ?? existing?.areaId,
1055
- /* 현실 출처 선언(§0 ①) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
1056
- 둘 다 없으면 여기서 **각인한다**. 컬럼을 비워 두면 읽는 자리마다 기본값을 고르게 되고,
1057
- 그러면 같은 트윈이 부르는 곳에 따라 다르게 재기동한다. 선언은 저장소에서 항상 명시적이다. */
1058
- realityMode: realityMode ?? existing?.realityMode ?? exports.DEFAULT_REALITY_MODE,
1182
+ /* 재기동 정책(ADR-0029 §2) — 명시값 우선, 없으면 기존값 보존(재프로비전이 선언을 지우지 않게).
1183
+ **둘 다 없으면 던진다**: 예전에는 여기서 기본값을 각인했는데, 그 기본값이 「저널 초기화」라서
1184
+ 선언을 빠뜨린 프로비저닝이 조용히 이력을 지우는 트윈을 만들었다. 선언은 부르는 쪽의 몫이다. */
1185
+ restartPolicy: restartPolicy ??
1186
+ (0, restart_policy_js_1.readRestartPolicy)(existing?.restartPolicy, `register("${instanceId}") did not declare a restart policy and the stored row has none`),
1059
1187
  /* 용도도 각인한다. 벤치로 뒤집는 것은 `setPurpose` 하나뿐이므로 기존값을 반드시 보존한다
1060
1188
  — 여기서 덮으면 재프로비전이 벤치 사본을 운영 트윈으로 되돌린다. */
1061
1189
  purpose: existing?.purpose ?? 'operational',
@@ -1186,7 +1314,15 @@ class TwinEngine {
1186
1314
  this.ensureBroadcastCoalescer();
1187
1315
  return { rev, ...shift };
1188
1316
  }
1189
- static async provision(domainId, instanceId, kind, model, comment, origin,
1317
+ static async provision(domainId, instanceId, kind, model,
1318
+ /**
1319
+ * **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다.
1320
+ *
1321
+ * 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서
1322
+ * 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 조용히 만들었다. 이제 만드는 쪽이
1323
+ * 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다.
1324
+ */
1325
+ restartPolicy, comment, origin,
1190
1326
  /** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */
1191
1327
  meta) {
1192
1328
  this.assertNotRunning(domainId, instanceId);
@@ -1207,7 +1343,7 @@ class TwinEngine {
1207
1343
  * 달고 다닌다. 재생은 구조가 바뀌는 지점에서 전환한 뒤 이어 접는다(`replaySegments`).
1208
1344
  */
1209
1345
  await this.recordStructure(domainId, instanceId, model, comment);
1210
- await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : existing?.status ?? 'stopped', undefined, origin, meta);
1346
+ await this.register(domainId, instanceId, kind, model, existing?.status === 'running' ? 'stopped' : existing?.status ?? 'stopped', restartPolicy, origin, meta);
1211
1347
  }
1212
1348
  /**
1213
1349
  * 이 구조를 리비전으로 남기고 그 번호를 돌려준다 — **바뀌었을 때만** 새 번호가 생긴다.
@@ -1308,7 +1444,7 @@ class TwinEngine {
1308
1444
  return `N[${locations}]M[${equipment}]`;
1309
1445
  }
1310
1446
  /** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */
1311
- static async startFromRegistry(domainId, instanceId, realityMode) {
1447
+ static async startFromRegistry(domainId, instanceId, restartPolicy) {
1312
1448
  const key = (0, runtime_key_js_1.runtimeKey)(domainId, instanceId);
1313
1449
  if (this.instances[key])
1314
1450
  return this.instances[key];
@@ -1336,7 +1472,7 @@ class TwinEngine {
1336
1472
  /*
1337
1473
  * 저널에 남아 있는 마지막 번호 — **모드와 무관하게** 이것을 이어 센다.
1338
1474
  *
1339
- * 저널을 초기화하고 기동하는 모드(sim-experiment)라면 이 값이 0이라 아무 영향이 없다. 규칙을
1475
+ * 저널을 초기화하고 기동하는 정책(`reset`)이라면 이 값이 0이라 아무 영향이 없다. 규칙을
1340
1476
  * 모드별로 구분하지 않는 이유: "저널이 비어 있지 않으면 그 뒤부터" 하나면 어느 모드에서도
1341
1477
  * 겹칠 수 없고, 모드가 늘어도 이 자리를 다시 손볼 일이 없다.
1342
1478
  */
@@ -1349,26 +1485,28 @@ class TwinEngine {
1349
1485
  return this.start(instanceId, domainId,
1350
1486
  /* 커널 종류·현실 선언은 레지스트리에 반드시 있다(둘 다 NOT NULL). 예전에는 `?? 'wms'` 로
1351
1487
  메웠는데, 그건 YMS/MES 트윈을 **조용히 WMS 로 부팅**시키는 길이었다 — 오류 없이 다른 공장이 뜬다. */
1352
- reg.kind, reg.model, realityMode ?? reg.realityMode, reg.purpose, Number(last?.max ?? 0) || 0);
1488
+ reg.kind, reg.model,
1489
+ /* 저장된 값은 **엄격히** 읽는다 — 모르는 값을 기본값으로 메우면 그 트윈이 조용히 다르게 재기동한다. */
1490
+ restartPolicy ?? (0, restart_policy_js_1.readRestartPolicy)(reg.restartPolicy, `twin "${instanceId}"`), reg.purpose, Number(last?.max ?? 0) || 0);
1353
1491
  }
1354
1492
  /**
1355
- * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 realityMode 에 따라 재기동 방식을 구분한다.
1493
+ * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 restartPolicy 에 따라 재기동 방식을 구분한다.
1356
1494
  * 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다.
1357
- * - 'mirror' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
1358
- * - 'sim-world' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
1495
+ * - 'resync' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음.
1496
+ * - 'resume' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감).
1359
1497
  * ⚠ GATE(§8 ②③): 커널 상태 직렬화/재개가 아직 없어 진짜 resume 불가 → 실 필요·실부하 시 구현.
1360
1498
  * 그때까지는 저널을 보존하되 revision 충돌을 피하려 fresh 로 기동하고, 선언과 구현의 간극을 크게 경고한다.
1361
- * - 'sim-experiment' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동).
1499
+ * - 'reset' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동).
1362
1500
  * 반환: 기동된 InstanceRuntime.
1363
1501
  */
1364
- static async bootDeclared(domainId, instanceId, mode = exports.DEFAULT_REALITY_MODE) {
1365
- if (mode === 'mirror') {
1502
+ static async bootDeclared(domainId, instanceId, mode) {
1503
+ if (mode === 'resync') {
1366
1504
  const reg = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } });
1367
1505
  if (!reg?.model)
1368
1506
  throw new Error(`instance "${instanceId}" not provisioned (no model)`);
1369
1507
  return this.startLive(instanceId, domainId, reg.kind, reg.model);
1370
1508
  }
1371
- if (mode === 'sim-world') {
1509
+ if (mode === 'resume') {
1372
1510
  /*
1373
1511
  * **이어지는 현실** — 저널을 지우지 않는다.
1374
1512
  *
@@ -1381,11 +1519,11 @@ class TwinEngine {
1381
1519
  * 같은 씨앗에서 다시 시작하므로 자극의 패턴이 재기동 지점에서 한 번 끊긴다. 쌓인 사실과
1382
1520
  * 상태는 이어지고, 앞으로 일어날 일의 무작위 순서만 새로 시작한다.
1383
1521
  */
1384
- return this.startFromRegistry(domainId, instanceId, 'sim-world');
1522
+ return this.startFromRegistry(domainId, instanceId, 'resume');
1385
1523
  }
1386
- // sim-experiment(기본): seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
1524
+ // `reset`: seed 재현 — 저널 초기화 후 revision 0 부터 재실행.
1387
1525
  await this.resetJournal(domainId, instanceId);
1388
- return this.startFromRegistry(domainId, instanceId, 'sim-experiment');
1526
+ return this.startFromRegistry(domainId, instanceId, 'reset');
1389
1527
  }
1390
1528
  /**
1391
1529
  * 저널 초기화 — 인스턴스 이벤트 저널 purge(레지스트리·상태는 유지).
@@ -1471,7 +1609,7 @@ class TwinEngine {
1471
1609
  spaceId: r.spaceId, // co-location: 같은 spaceId 인스턴스들이 한 현장 공유 — 현장뷰 집약 키(G7)
1472
1610
  /* 사람이 읽는 현장 이름 — 없으면 `undefined`(화면이 "이름 없음" 을 알아볼 수 있어야 한다). */
1473
1611
  spaceName: (r.spaceId ? spaceNameOf.get(r.spaceId) : undefined) || undefined,
1474
- realityMode: r.realityMode, // 현실 출처 선언(§0 ①) — mirror/sim-world/sim-experiment. 저장 시 각인되므로 비지 않는다.
1612
+ restartPolicy: r.restartPolicy, // 재기동 정책(ADR-0029 §2) — resync/resume/reset. 선언이므로 비어 있을 수 없다.
1475
1613
  purpose: r.purpose, // 운영 vs 벤치 사본(1급 구별 — 이름 접두사 아님). 저장 시 각인되므로 비지 않는다.
1476
1614
  copyOf: r.copyOf ?? undefined,
1477
1615
  running: !!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)],
@@ -1485,7 +1623,7 @@ class TwinEngine {
1485
1623
  /* 스스로 멈춘 이유 — 있으면 낸다. 재기동 뒤에는 없다(그때는 「모른다」가 사실이다). */
1486
1624
  stopNote: this.stopNotes.get((0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)) || undefined,
1487
1625
  liveFeed: (0, live_feed_registry_js_1.liveFeedStateOf)({
1488
- realityMode: r.realityMode,
1626
+ restartPolicy: r.restartPolicy,
1489
1627
  running: !!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)],
1490
1628
  instanceId: r.instanceId
1491
1629
  }),
@@ -1567,7 +1705,9 @@ class TwinEngine {
1567
1705
  * 기존 공간으로 들어갈 수 있다. 아래 병합은 원래 그 경우를 위해 있었는데(N:1) 만드는 흐름에서
1568
1706
  * 공간을 고를 방법이 없었다 — 마스터가 파생한 id 만 쓰였다. 판정은 `resolveIngestSpace`(순수).
1569
1707
  */
1570
- static async ingestMaster(domainId, master, into,
1708
+ static async ingestMaster(domainId, master,
1709
+ /** 만들어지는 트윈의 재기동 정책 — 원본이 정하는 것이 아니라 **만드는 쪽의 선언**이다(ADR-0029 §2). */
1710
+ restartPolicy, into,
1571
1711
  /** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */
1572
1712
  actor) {
1573
1713
  /*
@@ -1734,7 +1874,7 @@ class TwinEngine {
1734
1874
  }
1735
1875
  else {
1736
1876
  /* 사이트 이름이 곧 트윈의 이름이다 — 마스터가 이미 말했으므로 지어내지 않고 그대로 싣는다. */
1737
- await this.provision(domainId, master.source, master.system, model, undefined, master.origin, {
1877
+ await this.provision(domainId, master.source, master.system, model, restartPolicy, undefined, master.origin, {
1738
1878
  name: master.siteName,
1739
1879
  description: master.description,
1740
1880
  actor
@@ -1783,7 +1923,7 @@ class TwinEngine {
1783
1923
  kind: r.kind,
1784
1924
  status: r.status,
1785
1925
  running: !!this.instances[(0, runtime_key_js_1.runtimeKey)(domainId, r.instanceId)],
1786
- realityMode: r.realityMode, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
1926
+ restartPolicy: r.restartPolicy, // 현실 선언 — what-if 적용 가드용(mirror=예측 전용, 자극 주입 불가)
1787
1927
  model: r.model
1788
1928
  };
1789
1929
  }
@@ -2453,7 +2593,7 @@ class TwinEngine {
2453
2593
  mode: inst.mode ?? 'sim',
2454
2594
  domainId: inst.domainId,
2455
2595
  domainLabel: inst.domain?.subdomain,
2456
- realityMode: inst.realityMode,
2596
+ restartPolicy: inst.restartPolicy,
2457
2597
  tickMs: this.TICK_MS,
2458
2598
  running: !!inst.timer || inst.mode === 'live',
2459
2599
  ...((0, load_meter_js_1.loadSummary)(inst.load, this.TICK_MS) ?? { recent: null, total: null, forksCreated: 0, overBudget: 0, budgetMs: this.TICK_MS * 0.5, loadRatio: null })