@operato/twin-kernel 0.2.0 → 0.2.1

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.
@@ -40,9 +40,11 @@ __export(index_exports, {
40
40
  MES_PRODUCT_GTINS: () => MES_PRODUCT_GTINS,
41
41
  MES_TYPES: () => MES_TYPES,
42
42
  MesKernel: () => MesKernel,
43
+ NODE_SATURATION_NEAR: () => NODE_SATURATION_NEAR,
43
44
  OP_EVENT: () => OP_EVENT,
44
45
  OP_PARAM: () => OP_PARAM,
45
- StateProjector: () => StateProjector,
46
+ ObservedReducer: () => ObservedReducer,
47
+ StateProjector: () => ObservedReducer,
46
48
  TwinHistory: () => TwinHistory,
47
49
  TwinObserver: () => TwinObserver,
48
50
  TwinRuntime: () => TwinRuntime,
@@ -70,6 +72,7 @@ __export(index_exports, {
70
72
  lgtinClass: () => lgtinClass,
71
73
  mapRecord: () => mapRecord,
72
74
  monteCarloForecast: () => monteCarloForecast,
75
+ nodeStatusOf: () => nodeStatusOf,
73
76
  objectEvent: () => objectEvent,
74
77
  parseEpc: () => parseEpc,
75
78
  parseIsoDuration: () => parseIsoDuration,
@@ -87,6 +90,13 @@ __export(index_exports, {
87
90
  module.exports = __toCommonJS(index_exports);
88
91
 
89
92
  // src/contract.ts
93
+ var NODE_SATURATION_NEAR = 0.9;
94
+ function nodeStatusOf(n) {
95
+ const cap = n.capacity;
96
+ if (!(typeof cap === "number" && cap > 0)) return void 0;
97
+ const r = (n.occupancy ?? 0) / cap;
98
+ return r >= 1 ? "full" : r >= NODE_SATURATION_NEAR ? "near-full" : "available";
99
+ }
90
100
  var OP_EVENT = {
91
101
  task: "task.status",
92
102
  equipment: "equipment.status",
@@ -517,14 +527,16 @@ function validateEpcisEvent(e) {
517
527
  return v;
518
528
  }
519
529
 
520
- // src/state-projector.ts
530
+ // src/observed-reducer.ts
521
531
  var UNKNOWN_TYPE = "unknown";
522
- var StateProjector = class {
532
+ var ObservedReducer = class {
523
533
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
524
534
  master = /* @__PURE__ */ new Map();
525
535
  items = /* @__PURE__ */ new Map();
526
536
  aggregation = /* @__PURE__ */ new Map();
527
- // parent SSCC child EPCs
537
+ /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
538
+ pendingParent = /* @__PURE__ */ new Map();
539
+ // 자식 EPC → 부모(물류단위)
528
540
  tasks = /* @__PURE__ */ new Map();
529
541
  movers = /* @__PURE__ */ new Map();
530
542
  persons = /* @__PURE__ */ new Map();
@@ -534,7 +546,7 @@ var StateProjector = class {
534
546
  /** 받은 정정 선언 — 상태에 반영하지 않되 **버리지도 않는다**(소비처가 볼 수 있게). */
535
547
  corrections = [];
536
548
  constructor(board) {
537
- for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parentId: n.parentId, origin: "master" });
549
+ for (const n of board.nodes) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
538
550
  for (const m of board.movers) this.movers.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeNode, origin: "master" });
539
551
  for (const p of board.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle" });
540
552
  for (const a of board.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle" });
@@ -547,10 +559,12 @@ var StateProjector = class {
547
559
  }
548
560
  const cur = this.master.get(u.node.id);
549
561
  const capacity = u.node.capacity ?? cur?.capacity;
562
+ const parallelism = u.node.parallelism ?? cur?.parallelism;
550
563
  this.master.set(u.node.id, {
551
564
  id: u.node.id,
552
565
  type: u.node.type ?? cur?.type ?? UNKNOWN_TYPE,
553
566
  ...capacity === void 0 ? {} : { capacity },
567
+ ...parallelism === void 0 ? {} : { parallelism },
554
568
  ...u.node.parentId ?? cur?.parentId ? { parentId: u.node.parentId ?? cur?.parentId } : {},
555
569
  /* 마스터가 말한 것은 마스터 출처다 — 관측으로 알게 된 것(origin='observed')을 덮어 승격한다. */
556
570
  origin: "master"
@@ -623,6 +637,7 @@ var StateProjector = class {
623
637
  progress: d.progress,
624
638
  remainingMs: d.remainingMs,
625
639
  durationMs: d.durationMs,
640
+ startedAtSimMs: d.startedAtSimMs,
626
641
  ...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
627
642
  ...d.assets?.length ? { assets: d.assets.slice() } : {}
628
643
  });
@@ -633,7 +648,7 @@ var StateProjector = class {
633
648
  if (this.stale(`mover:${d.moverId}`, e)) return;
634
649
  this.touchLocation(d.location);
635
650
  const known = this.movers.get(d.moverId);
636
- this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, motion: d.motion, origin: known?.origin ?? "observed" });
651
+ this.movers.set(d.moverId, { id: d.moverId, kind: d.kind, status: d.status, location: d.location, taskId: d.taskId, motion: d.motion, origin: known?.origin ?? "observed" });
637
652
  break;
638
653
  }
639
654
  case OP_EVENT.person: {
@@ -686,13 +701,17 @@ var StateProjector = class {
686
701
  this.aggregation.set(ev.parentID, [...ev.childEPCs]);
687
702
  for (const child of ev.childEPCs) {
688
703
  const cur = this.items.get(child);
689
- if (cur) this.items.set(child, { ...cur, parent: ev.parentID });
690
- else this.items.set(child, { epc: child, location: "", parent: ev.parentID });
704
+ if (cur) {
705
+ this.items.set(child, { ...cur, parent: ev.parentID });
706
+ continue;
707
+ }
708
+ this.pendingParent.set(child, ev.parentID);
691
709
  }
692
710
  } else if (ev.action === "DELETE") {
693
711
  for (const child of this.aggregation.get(ev.parentID) ?? []) {
694
712
  const cur = this.items.get(child);
695
713
  if (cur) this.items.set(child, { ...cur, parent: void 0 });
714
+ this.pendingParent.delete(child);
696
715
  }
697
716
  this.aggregation.delete(ev.parentID);
698
717
  }
@@ -755,7 +774,7 @@ var StateProjector = class {
755
774
  gtinKey: parsedClass?.gtinKey ?? parsedSelf.gtinKey ?? cur?.gtinKey,
756
775
  location: patch.location ?? cur?.location ?? "",
757
776
  disposition: patch.disposition ?? cur?.disposition,
758
- parent: cur?.parent,
777
+ parent: cur?.parent ?? this.pendingParent.get(epc),
759
778
  qty: q?.quantity ?? cur?.qty,
760
779
  uom: q?.uom ?? cur?.uom,
761
780
  /* 마스터데이터는 생겨날 때 한 번 정해진다 — 뒤 이벤트가 지우지 않게 기존 값을 남긴다. */
@@ -781,15 +800,21 @@ var StateProjector = class {
781
800
  return {
782
801
  revision: this.revision,
783
802
  ...this.corrections.length ? { corrections: this.corrections.map((c) => ({ ...c })) } : {},
784
- nodes: [...this.master.values()].map((n) => ({
785
- id: n.id,
786
- type: n.type,
787
- occupancy: occ.get(n.id) ?? 0,
788
- /* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
789
- ...n.capacity === void 0 ? {} : { capacity: n.capacity },
790
- ...n.parentId ? { parentId: n.parentId } : {},
791
- origin: n.origin
792
- })),
803
+ nodes: [...this.master.values()].map((n) => {
804
+ const occupancy = occ.get(n.id) ?? 0;
805
+ const status = nodeStatusOf({ occupancy, capacity: n.capacity });
806
+ return {
807
+ id: n.id,
808
+ type: n.type,
809
+ occupancy,
810
+ ...status ? { status } : {},
811
+ /* 용량 미상은 **키를 만들지 않는다** — 0 으로 실으면 "자리 없음" 이라는 없는 사실이 생긴다. */
812
+ ...n.capacity === void 0 ? {} : { capacity: n.capacity },
813
+ ...n.parallelism === void 0 ? {} : { parallelism: n.parallelism },
814
+ ...n.parentId ? { parentId: n.parentId } : {},
815
+ origin: n.origin
816
+ };
817
+ }),
793
818
  /* 들고 있는 것을 전부 내보낸다 — 축소하면 그 자리에서 정보가 사라진다. */
794
819
  items: [...this.items.values()].map((i) => ({ ...i })),
795
820
  persons: [...this.persons.values()].map((p) => ({ ...p })),
@@ -828,7 +853,7 @@ var EventJournal = class {
828
853
  }
829
854
  };
830
855
  function replay(board, events) {
831
- const proj = new StateProjector(board);
856
+ const proj = new ObservedReducer(board);
832
857
  for (const e of events) proj.apply(e);
833
858
  return proj.snapshot();
834
859
  }
@@ -1283,6 +1308,13 @@ var FlowEngine = class {
1283
1308
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
1284
1309
  */
1285
1310
  operationSpecs = /* @__PURE__ */ new Map();
1311
+ /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
1312
+ observer;
1313
+ /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
1314
+ observedDirty = false;
1315
+ observeMode = false;
1316
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
1317
+ boardDef;
1286
1318
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
1287
1319
  specUse = /* @__PURE__ */ new Map();
1288
1320
  epcSeq = 0;
@@ -1290,6 +1322,10 @@ var FlowEngine = class {
1290
1322
  orderSeq = 0;
1291
1323
  soSeq = 0;
1292
1324
  handlers = [];
1325
+ /** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
1326
+ handlersRef() {
1327
+ return this.handlers;
1328
+ }
1293
1329
  gens = [];
1294
1330
  generating = false;
1295
1331
  speed = 1;
@@ -1300,6 +1336,7 @@ var FlowEngine = class {
1300
1336
  }
1301
1337
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
1302
1338
  loadBoard(def) {
1339
+ this.boardDef = def;
1303
1340
  for (const n of def.nodes) this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId });
1304
1341
  for (const p of def.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: "idle", taskId: null, window: p.window });
1305
1342
  for (const a of def.assets ?? []) this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: "idle", taskId: null });
@@ -1341,15 +1378,15 @@ var FlowEngine = class {
1341
1378
  this.items.clear();
1342
1379
  for (const it of snap.items) {
1343
1380
  this.items.set(it.epc, {
1381
+ /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
1344
1382
  epc: it.epc,
1345
1383
  location: it.location,
1346
1384
  disposition: it.disposition ?? DISP.sellable,
1347
1385
  gtin: it.gtin,
1348
- gtinKey: it.gtinKey,
1349
- lot: it.lot,
1350
1386
  qty: it.qty ?? 1,
1351
1387
  uom: it.uom,
1352
1388
  parent: it.parent,
1389
+ carriedBy: it.carriedBy,
1353
1390
  expiry: it.expiry,
1354
1391
  ilmd: it.ilmd
1355
1392
  });
@@ -1387,6 +1424,7 @@ var FlowEngine = class {
1387
1424
  toNode: t.toNode ?? "",
1388
1425
  resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
1389
1426
  remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
1427
+ startedAtSimMs: t.startedAtSimMs,
1390
1428
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1391
1429
  orderId: t.orderId,
1392
1430
  intent: t.intent
@@ -1562,13 +1600,20 @@ var FlowEngine = class {
1562
1600
  this.processTasks(dt);
1563
1601
  }
1564
1602
  getSnapshot() {
1603
+ this.settleObserved();
1565
1604
  return {
1566
1605
  revision: this.revision,
1567
1606
  simClockMs: this.clockMs,
1568
- nodes: [...this.nodes.values()].map((n) => ({ ...n })),
1569
- items: [...this.items.values()].map((i) => ({ epc: i.epc, gtin: i.gtin, qty: i.qty, location: i.location, disposition: i.disposition, expiry: i.expiry })),
1607
+ /* 출처 표시 — 보드(마스터)에서 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1608
+ * 시뮬이 아무 표시도 하면 소비처가 스냅샷을 같은 규칙으로 읽지 못한다. */
1609
+ nodes: [...this.nodes.values()].map((n) => {
1610
+ const { status, ...rest } = n;
1611
+ const derived = nodeStatusOf(n);
1612
+ return { ...rest, ...derived ? { status: derived } : {}, origin: "master" };
1613
+ }),
1614
+ items: [...this.items.values()].map((i) => this.itemState(i)),
1570
1615
  movers: [...this.movers.values()].map((m) => {
1571
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...this.offShift(m) ? { offShift: true } : {} };
1616
+ const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, origin: "master", ...this.offShift(m) ? { offShift: true } : {} };
1572
1617
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
1573
1618
  if (t && t.status === "in-progress" && t.intent !== "process") s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
1574
1619
  return s;
@@ -1583,7 +1628,24 @@ var FlowEngine = class {
1583
1628
  if (this.personOffShift(p)) st.offShift = true;
1584
1629
  return st;
1585
1630
  }),
1586
- tasks: [...this.tasks.values()].map((t) => ({ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc], fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? void 0, orderId: t.orderId, progress: t.status === "in-progress" ? this.progressOf(t) : void 0, ...t.personnel?.length ? { personnel: t.personnel.slice() } : {} })),
1631
+ /* 스냅샷이 **델타보다 가난하면 된다** 예전에는 소요·남은 시간을 빼고 내보내서, 스냅샷으로
1632
+ * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
1633
+ * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
1634
+ tasks: [...this.tasks.values()].map((t) => ({
1635
+ id: t.id,
1636
+ kind: t.kind,
1637
+ status: t.status,
1638
+ itemRefs: [t.itemEpc],
1639
+ fromNode: t.fromNode,
1640
+ toNode: t.toNode,
1641
+ resourceRef: t.resource ?? void 0,
1642
+ orderId: t.orderId,
1643
+ ...t.intent ? { intent: t.intent } : {},
1644
+ ...t.durationMs ? { durationMs: t.durationMs } : {},
1645
+ ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
1646
+ ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
1647
+ ...t.assets?.length ? { assets: t.assets.slice() } : {}
1648
+ })),
1587
1649
  orders: [...this.orders.values()].map((o) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.requested ? o.fulfilled / o.requested : 0, held: o.held })),
1588
1650
  attentions: this.computeAttentions()
1589
1651
  };
@@ -1609,6 +1671,7 @@ var FlowEngine = class {
1609
1671
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
1610
1672
  */
1611
1673
  fork(tenantId = this.tenantId) {
1674
+ this.settleObserved();
1612
1675
  const Ctor = this.constructor;
1613
1676
  const clone = new Ctor(tenantId, this.policy);
1614
1677
  const skip = /* @__PURE__ */ new Set(["policy", "scenario", "tenantId", "handlers", "durationEstimator"]);
@@ -1631,6 +1694,46 @@ var FlowEngine = class {
1631
1694
  randInt(min, max) {
1632
1695
  return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1));
1633
1696
  }
1697
+ /**
1698
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
1699
+ *
1700
+ * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
1701
+ * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
1702
+ * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
1703
+ *
1704
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
1705
+ * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
1706
+ * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
1707
+ *
1708
+ * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
1709
+ * reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
1710
+ *
1711
+ * `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
1712
+ */
1713
+ apply(envelope) {
1714
+ if (!this.observer) {
1715
+ this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
1716
+ this.observeMode = true;
1717
+ }
1718
+ this.observer.apply(envelope);
1719
+ for (const h of this.observedHandlers()) h(envelope);
1720
+ this.observedDirty = true;
1721
+ this.revision++;
1722
+ }
1723
+ /** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
1724
+ settleObserved() {
1725
+ if (!this.observedDirty || !this.observer) return;
1726
+ this.observedDirty = false;
1727
+ this.hydrateObserved(this.observer.snapshot());
1728
+ }
1729
+ /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
1730
+ observedHandlers() {
1731
+ return this.handlersRef();
1732
+ }
1733
+ /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
1734
+ get observing() {
1735
+ return this.observeMode;
1736
+ }
1634
1737
  /**
1635
1738
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
1636
1739
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
@@ -1793,6 +1896,53 @@ var FlowEngine = class {
1793
1896
  bizTransactionList: opts.bizTransactionList
1794
1897
  }));
1795
1898
  }
1899
+ /**
1900
+ * 할당(예약) — **처분 변화를 이벤트로 낸다.**
1901
+ *
1902
+ * 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
1903
+ * 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
1904
+ * 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
1905
+ *
1906
+ * 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
1907
+ * 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
1908
+ *
1909
+ * `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
1910
+ * 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
1911
+ */
1912
+ reserve(epcs, bizStep) {
1913
+ this.observeDisposition(epcs, DISP.reserved, bizStep);
1914
+ }
1915
+ /**
1916
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
1917
+ *
1918
+ * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
1919
+ * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
1920
+ *
1921
+ * 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
1922
+ * 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
1923
+ */
1924
+ observeDisposition(epcs, disposition, bizStep, at) {
1925
+ const byLocation = /* @__PURE__ */ new Map();
1926
+ for (const epc of epcs) {
1927
+ const it = this.items.get(epc);
1928
+ if (!it || it.disposition === disposition) continue;
1929
+ it.disposition = disposition;
1930
+ const where = at ?? it.location ?? "";
1931
+ const bin = byLocation.get(where);
1932
+ if (bin) bin.push(epc);
1933
+ else byLocation.set(where, [epc]);
1934
+ }
1935
+ for (const [where, list] of byLocation) {
1936
+ this.emit(objectEvent({
1937
+ eventTime: this.now(),
1938
+ action: "OBSERVE",
1939
+ bizStep,
1940
+ disposition,
1941
+ epcList: list,
1942
+ ...where ? { readPoint: where, bizLocation: where } : {}
1943
+ }));
1944
+ }
1945
+ }
1796
1946
  /**
1797
1947
  * containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
1798
1948
  * consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
@@ -1810,6 +1960,11 @@ var FlowEngine = class {
1810
1960
  this.items.delete(c);
1811
1961
  }
1812
1962
  }
1963
+ return;
1964
+ }
1965
+ for (const c of children) {
1966
+ const it = this.items.get(c);
1967
+ if (it) it.parent = parent;
1813
1968
  }
1814
1969
  }
1815
1970
  /**
@@ -1819,6 +1974,10 @@ var FlowEngine = class {
1819
1974
  */
1820
1975
  disaggregate(parent, children, opts) {
1821
1976
  this.emit(aggregationEvent({ eventTime: this.now(), action: "DELETE", bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
1977
+ for (const c of children) {
1978
+ const it = this.items.get(c);
1979
+ if (it) it.parent = void 0;
1980
+ }
1822
1981
  if (opts.materialize) {
1823
1982
  const m = opts.materialize;
1824
1983
  for (const c of children) {
@@ -1882,7 +2041,7 @@ var FlowEngine = class {
1882
2041
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
1883
2042
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
1884
2043
  ...t.durationMs ? { durationMs: t.durationMs } : {},
1885
- ...inProgress ? { remainingMs: t.remainingMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
2044
+ ...inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : void 0 } : {}
1886
2045
  });
1887
2046
  }
1888
2047
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
@@ -1892,8 +2051,10 @@ var FlowEngine = class {
1892
2051
  emitPerson(p) {
1893
2052
  this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? void 0, ...this.personOffShift(p) ? { offShift: true } : {} });
1894
2053
  }
2054
+ /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
2055
+ * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
1895
2056
  emitMover(m, motion) {
1896
- this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion });
2057
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? void 0, motion });
1897
2058
  }
1898
2059
  emitOrder(o) {
1899
2060
  this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held });
@@ -1980,7 +2141,11 @@ var FlowEngine = class {
1980
2141
  if (!a) continue;
1981
2142
  a.status = "in-use";
1982
2143
  a.taskId = t.id;
1983
- if (t.itemEpc) a.carrying = t.itemEpc;
2144
+ if (t.itemEpc) {
2145
+ a.carrying = t.itemEpc;
2146
+ const it = this.items.get(t.itemEpc);
2147
+ if (it) it.carriedBy = a.id;
2148
+ }
1984
2149
  this.emitAsset(a);
1985
2150
  }
1986
2151
  }
@@ -1995,6 +2160,10 @@ var FlowEngine = class {
1995
2160
  a.status = "idle";
1996
2161
  a.taskId = null;
1997
2162
  a.location = t.toNode || a.location;
2163
+ if (a.carrying) {
2164
+ const it = this.items.get(a.carrying);
2165
+ if (it) it.carriedBy = void 0;
2166
+ }
1998
2167
  a.carrying = void 0;
1999
2168
  this.emitAsset(a);
2000
2169
  }
@@ -2116,6 +2285,33 @@ var FlowEngine = class {
2116
2285
  }
2117
2286
  }
2118
2287
  }
2288
+ /**
2289
+ * 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
2290
+ *
2291
+ * 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
2292
+ * 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
2293
+ * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
2294
+ */
2295
+ itemState(i) {
2296
+ const parsedClass = i.gtin ? parseEpc(i.gtin) : void 0;
2297
+ const parsedSelf = parseEpc(i.epc);
2298
+ const lot = parsedClass?.lot ?? parsedSelf.lot ?? (typeof i.ilmd?.[ILMD_ATTR.lot] === "string" ? i.ilmd[ILMD_ATTR.lot] : void 0);
2299
+ const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
2300
+ return {
2301
+ epc: i.epc,
2302
+ ...i.gtin ? { gtin: i.gtin } : {},
2303
+ ...gtinKey ? { gtinKey } : {},
2304
+ ...lot ? { lot } : {},
2305
+ location: i.location,
2306
+ ...i.disposition ? { disposition: i.disposition } : {},
2307
+ ...i.parent ? { parent: i.parent } : {},
2308
+ ...i.carriedBy ? { carriedBy: i.carriedBy } : {},
2309
+ ...i.qty !== void 0 ? { qty: i.qty } : {},
2310
+ ...i.uom ? { uom: i.uom } : {},
2311
+ ...i.expiry !== void 0 ? { expiry: i.expiry } : {},
2312
+ ...i.ilmd ? { ilmd: i.ilmd } : {}
2313
+ };
2314
+ }
2119
2315
  progressOf(t) {
2120
2316
  return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
2121
2317
  }
@@ -2141,7 +2337,18 @@ var FlowEngine = class {
2141
2337
  processOrders() {
2142
2338
  for (const o of this.orders.values()) if (o.status === "created" && !o.held) this.allocate(o);
2143
2339
  }
2340
+ /**
2341
+ * 작업 진행 — **진행을 먼저, 배정을 나중에.**
2342
+ *
2343
+ * 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
2344
+ * 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
2345
+ * tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
2346
+ */
2144
2347
  processTasks(dt) {
2348
+ this.advanceTasks(dt);
2349
+ this.assignTasks();
2350
+ }
2351
+ assignTasks() {
2145
2352
  for (const t of this.tasks.values()) {
2146
2353
  if (t.status !== "created") continue;
2147
2354
  const crew = this.claimPersonnel(t);
@@ -2151,6 +2358,7 @@ var FlowEngine = class {
2151
2358
  if (t.intent === "dwell") {
2152
2359
  t.status = "in-progress";
2153
2360
  t.remainingMs = t.durationMs;
2361
+ t.startedAtSimMs = this.clockMs;
2154
2362
  this.assignCrew(t, crew);
2155
2363
  this.assignAssets(t, gear);
2156
2364
  this.emitTask(t);
@@ -2171,12 +2379,16 @@ var FlowEngine = class {
2171
2379
  t.status = "in-progress";
2172
2380
  t.resource = mover.id;
2173
2381
  t.remainingMs = t.durationMs;
2382
+ t.startedAtSimMs = this.clockMs;
2174
2383
  this.assignCrew(t, crew);
2175
2384
  this.assignAssets(t, gear);
2176
2385
  this.emitTask(t);
2177
2386
  if (t.intent === "process") this.emitMover(mover);
2178
2387
  else this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
2179
2388
  }
2389
+ }
2390
+ /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
2391
+ advanceTasks(dt) {
2180
2392
  for (const t of this.tasks.values()) {
2181
2393
  if (t.status !== "in-progress") continue;
2182
2394
  if (t.resource && this.movers.get(t.resource)?.status === "down") continue;
@@ -2225,7 +2437,8 @@ var WmsKernel = class extends FlowEngine {
2225
2437
  const qtyList = [{ epcClass: gtin, quantity: qty }];
2226
2438
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
2227
2439
  const expiry = this.clockMs + SHELF_MS - this.epcSeq % 5 * SHELF_JITTER_MS;
2228
- this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry });
2440
+ const ilmd = { [ILMD_ATTR.expiry]: expiry };
2441
+ this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
2229
2442
  dock.occupancy++;
2230
2443
  this.emit(transactionEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
2231
2444
  this.emit(aggregationEvent({ eventTime, action: "ADD", bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
@@ -2239,7 +2452,7 @@ var WmsKernel = class extends FlowEngine {
2239
2452
  readPoint: dock.id,
2240
2453
  bizLocation: dock.id,
2241
2454
  bizTransactionList: poTxn,
2242
- ilmd: { [ILMD_ATTR.expiry]: expiry }
2455
+ ilmd
2243
2456
  }));
2244
2457
  const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews("storage") });
2245
2458
  if (!binId) return;
@@ -2314,12 +2527,12 @@ var WmsKernel = class extends FlowEngine {
2314
2527
  const available = [...this.items.values()].filter((i) => i.gtin === line.gtin && i.disposition === DISP.sellable && this.nodes.get(i.location)?.type === "storage").map((i) => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
2315
2528
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
2316
2529
  for (const epc of chosen) {
2317
- this.items.get(epc).disposition = DISP.reserved;
2318
2530
  o.allocated.push(epc);
2319
2531
  chosenAll.push(epc);
2320
2532
  }
2321
2533
  }
2322
2534
  if (chosenAll.length === 0) return;
2535
+ this.reserve(chosenAll, BIZSTEP.storing);
2323
2536
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
2324
2537
  for (const epc of chosenAll) {
2325
2538
  const it = this.items.get(epc);
@@ -2463,7 +2676,7 @@ var YmsKernel = class extends FlowEngine {
2463
2676
  if (!staging || avail.length < CARGO_PER_TRAILER) return;
2464
2677
  this.trailerCargo.set(trailer.epc, avail.slice(0, CARGO_PER_TRAILER).map((i) => i.epc));
2465
2678
  }
2466
- trailer.disposition = DISP.reserved;
2679
+ this.reserve([trailer.epc], bizStep);
2467
2680
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep, bizTransactionList: [{ type: BTT_DELIVERY, bizTransaction: o.bizTransaction }], epcList: [trailer.epc], readPoint: door.id }));
2468
2681
  const dockKind = mode === "drop" ? "pull" : "spot-live";
2469
2682
  const task = { id: `task-${++this.taskSeq}`, kind: dockKind, status: "created", itemEpc: trailer.epc, fromNode: trailer.location, toNode: door.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: dockKind, fromNode: trailer.location, toNode: door.id }, TRAVEL_MS2), orderId: o.id };
@@ -2497,7 +2710,7 @@ var YmsKernel = class extends FlowEngine {
2497
2710
  const outbound = order2?.kind === "appointment-out";
2498
2711
  const staging = this.nodeByType("staging");
2499
2712
  const cargo = this.trailerCargo.get(trailer.epc) ?? [];
2500
- this.emit(objectEvent({ eventTime: this.now(), action: "OBSERVE", bizStep: outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, disposition: DISP.in_progress, epcList: [trailer.epc], readPoint: to.id, bizLocation: to.id }));
2713
+ this.observeDisposition([trailer.epc], DISP.in_progress, outbound ? YARD_BIZSTEP.loading : YARD_BIZSTEP.unloading, to.id);
2501
2714
  if (outbound) {
2502
2715
  if (cargo.length && staging) {
2503
2716
  this.aggregate(trailer.epc, cargo, { bizStep: YARD_BIZSTEP.loading, readPoint: to.id, consume: { readPoint: staging.id, disposition: DISP.in_transit } });
@@ -2623,10 +2836,8 @@ var MesKernel = class extends FlowEngine {
2623
2836
  if (chosen.length < line.qty) return;
2624
2837
  picks.push(...chosen);
2625
2838
  }
2626
- for (const epc of picks) {
2627
- this.items.get(epc).disposition = DISP.reserved;
2628
- o.allocated.push(epc);
2629
- }
2839
+ for (const epc of picks) o.allocated.push(epc);
2840
+ this.reserve(picks, MES_BIZSTEP.producing);
2630
2841
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
2631
2842
  this.emitStation(o, s0, o.allocated[0], product.gtin);
2632
2843
  o.status = "op-" + s0.kind;
@@ -2723,10 +2934,8 @@ var MesKernel = class extends FlowEngine {
2723
2934
  if (chosen.length < line.qty) return;
2724
2935
  picks.push(...chosen);
2725
2936
  }
2726
- for (const epc of picks) {
2727
- this.items.get(epc).disposition = DISP.reserved;
2728
- o.allocated.push(epc);
2729
- }
2937
+ for (const epc of picks) o.allocated.push(epc);
2938
+ this.reserve(picks, MES_BIZSTEP.producing);
2730
2939
  this.emit(transactionEvent({ eventTime: this.now(), action: "ADD", bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
2731
2940
  this.emitStationDef(o, ops[0], o.allocated[0]);
2732
2941
  o.status = "op-" + ops[0].key;
@@ -2797,8 +3006,10 @@ var MesKernel = class extends FlowEngine {
2797
3006
  MES_PRODUCT_GTINS,
2798
3007
  MES_TYPES,
2799
3008
  MesKernel,
3009
+ NODE_SATURATION_NEAR,
2800
3010
  OP_EVENT,
2801
3011
  OP_PARAM,
3012
+ ObservedReducer,
2802
3013
  StateProjector,
2803
3014
  TwinHistory,
2804
3015
  TwinObserver,
@@ -2827,6 +3038,7 @@ var MesKernel = class extends FlowEngine {
2827
3038
  lgtinClass,
2828
3039
  mapRecord,
2829
3040
  monteCarloForecast,
3041
+ nodeStatusOf,
2830
3042
  objectEvent,
2831
3043
  parseEpc,
2832
3044
  parseIsoDuration,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Twin Domain Kernel — framework-agnostic, zero-dep (domain + sim + 3-channel contract). WMS/YMS/MES, EPCIS 2.0 · ISA-95.",
6
6
  "publishConfig": {