@operato/twin-kernel 0.2.0 → 0.2.2

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.
@@ -9,8 +9,9 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowNode 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD } from "./contract.js";
13
- import { transformationEvent, aggregationEvent, objectEvent, DISP } from "./epcis.js";
12
+ import { OP_EVENT, CMD, nodeStatusOf } from "./contract.js";
13
+ import { ObservedReducer } from "./observed-reducer.js";
14
+ import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR } from "./epcis.js";
14
15
  import { parseIsoDuration } from "./iso-duration.js";
15
16
  const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
16
17
  function mulberry32(seed) {
@@ -134,6 +135,13 @@ export class FlowEngine {
134
135
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
135
136
  */
136
137
  operationSpecs = new Map();
138
+ /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
139
+ observer;
140
+ /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
141
+ observedDirty = false;
142
+ observeMode = false;
143
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
144
+ boardDef;
137
145
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
138
146
  specUse = new Map();
139
147
  epcSeq = 0;
@@ -141,6 +149,10 @@ export class FlowEngine {
141
149
  orderSeq = 0;
142
150
  soSeq = 0;
143
151
  handlers = [];
152
+ /** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
153
+ handlersRef() {
154
+ return this.handlers;
155
+ }
144
156
  gens = [];
145
157
  generating = false;
146
158
  speed = 1;
@@ -151,6 +163,7 @@ export class FlowEngine {
151
163
  }
152
164
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
153
165
  loadBoard(def) {
166
+ this.boardDef = def;
154
167
  for (const n of def.nodes)
155
168
  this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
156
169
  for (const p of def.persons ?? [])
@@ -199,9 +212,10 @@ export class FlowEngine {
199
212
  this.items.clear();
200
213
  for (const it of snap.items) {
201
214
  this.items.set(it.epc, {
215
+ /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
202
216
  epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
203
- gtin: it.gtin, gtinKey: it.gtinKey, lot: it.lot, qty: it.qty ?? 1, uom: it.uom,
204
- parent: it.parent, expiry: it.expiry, ilmd: it.ilmd
217
+ gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom,
218
+ parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
205
219
  });
206
220
  }
207
221
  for (const m of snap.movers) {
@@ -247,6 +261,7 @@ export class FlowEngine {
247
261
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
248
262
  resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
249
263
  remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
264
+ startedAtSimMs: t.startedAtSimMs,
250
265
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
251
266
  orderId: t.orderId,
252
267
  intent: t.intent
@@ -273,7 +288,14 @@ export class FlowEngine {
273
288
  }
274
289
  }
275
290
  }
276
- for (const o of orders) {
291
+ /* 오더 원값은 이제 **스냅샷에 있다** — 따로 넘겨받은 것이 없으면 스냅샷에서 읽는다.
292
+ * (예전에는 상태가 progress 로 압축돼 호출부가 저널을 뒤져 원값을 넘겨야 했다.) */
293
+ const observedOrders = orders.length
294
+ ? orders
295
+ : (snap.orders ?? [])
296
+ .filter(o => o.requested !== undefined)
297
+ .map(o => ({ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled ?? 0, held: o.held, lines: o.lines }));
298
+ for (const o of observedOrders) {
277
299
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
278
300
  const remaining = lines.reduce((s, l) => s + l.requested, 0);
279
301
  if (remaining <= 0)
@@ -434,13 +456,21 @@ export class FlowEngine {
434
456
  this.processTasks(dt);
435
457
  }
436
458
  getSnapshot() {
459
+ this.settleObserved();
437
460
  return {
438
461
  revision: this.revision,
439
462
  simClockMs: this.clockMs,
440
- nodes: [...this.nodes.values()].map(n => ({ ...n })),
441
- items: [...this.items.values()].map(i => ({ epc: i.epc, gtin: i.gtin, qty: i.qty, location: i.location, disposition: i.disposition, expiry: i.expiry })),
463
+ /* 출처 표시 — 보드(마스터)에서 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
464
+ * 시뮬이 아무 표시도 하면 소비처가 스냅샷을 같은 규칙으로 읽지 못한다. */
465
+ nodes: [...this.nodes.values()].map(n => {
466
+ const { status, ...rest } = n;
467
+ /* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
468
+ const derived = nodeStatusOf(n);
469
+ return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
470
+ }),
471
+ items: [...this.items.values()].map(i => this.itemState(i)),
442
472
  movers: [...this.movers.values()].map(m => {
443
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(this.offShift(m) ? { offShift: true } : {}) };
473
+ const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
444
474
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
445
475
  if (t && t.status === 'in-progress' && t.intent !== 'process')
446
476
  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 };
@@ -458,8 +488,26 @@ export class FlowEngine {
458
488
  st.offShift = true;
459
489
  return st;
460
490
  }),
461
- 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 ?? undefined, orderId: t.orderId, progress: t.status === 'in-progress' ? this.progressOf(t) : undefined, ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}) })),
462
- 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 })),
491
+ /* 스냅샷이 **델타보다 가난하면 된다** 예전에는 소요·남은 시간을 빼고 내보내서, 스냅샷으로
492
+ * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 없었다(미러 스냅샷은
493
+ * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
494
+ tasks: [...this.tasks.values()].map(t => ({
495
+ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
496
+ fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
497
+ ...(t.intent ? { intent: t.intent } : {}),
498
+ ...(t.durationMs ? { durationMs: t.durationMs } : {}),
499
+ ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
500
+ ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
501
+ ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
502
+ ...(t.resources?.length ? { resources: t.resources.slice() } : {})
503
+ })),
504
+ orders: [...this.orders.values()].map(o => ({
505
+ id: o.id, kind: o.kind, status: o.status,
506
+ progress: o.requested ? o.fulfilled / o.requested : 0,
507
+ requested: o.requested, fulfilled: o.fulfilled,
508
+ ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
509
+ held: o.held
510
+ })),
463
511
  attentions: this.computeAttentions()
464
512
  };
465
513
  }
@@ -485,6 +533,7 @@ export class FlowEngine {
485
533
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
486
534
  */
487
535
  fork(tenantId = this.tenantId) {
536
+ this.settleObserved(); // 관측으로 굴러온 커널을 fork 하려면 먼저 상태로 옮겨야 한다
488
537
  const Ctor = this.constructor;
489
538
  const clone = new Ctor(tenantId, this.policy);
490
539
  // 상태·시나리오(gens/generating)·시퀀스 전부 복제 → 원본의 완전한 continuation.
@@ -510,6 +559,54 @@ export class FlowEngine {
510
559
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
511
560
  now() { return new Date(BASE_EPOCH + this.clockMs).toISOString(); }
512
561
  randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
562
+ /**
563
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
564
+ *
565
+ * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
566
+ * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
567
+ * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
568
+ *
569
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
570
+ * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
571
+ * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
572
+ *
573
+ * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
574
+ * reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
575
+ *
576
+ * `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
577
+ */
578
+ apply(envelope) {
579
+ if (!this.observer) {
580
+ this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
581
+ this.observeMode = true;
582
+ }
583
+ this.observer.apply(envelope);
584
+ /* **구독자에게 그대로 흘린다** — 호스트가 시뮬·관측 두 모드에서 같은 배선을 쓰게 하기 위해서다
585
+ * (`onEvent` 하나로 저널·방송이 붙는다). 관측 모드에서 이것은 **재방출**이지 새 사실이 아니다:
586
+ * 원천이 이미 그 이벤트를 갖고 있으므로, 호스트가 인입과 재방출을 **둘 다 저널에 적으면 중복**이
587
+ * 된다. 저널은 인입에서 한 번만 적는다. */
588
+ for (const h of this.observedHandlers())
589
+ h(envelope);
590
+ /* **이벤트마다 상태를 통째로 옮기지 않는다.** 옮기는 비용은 O(상태 크기)라, 이벤트 하나에 그것을
591
+ * 치르면 유입이 늘수록 감당이 안 된다. 필요해지는 순간(스냅샷·fork)에 **한 번만** 옮긴다. */
592
+ this.observedDirty = true;
593
+ this.revision++;
594
+ }
595
+ /** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
596
+ settleObserved() {
597
+ if (!this.observedDirty || !this.observer)
598
+ return;
599
+ this.observedDirty = false;
600
+ this.hydrateObserved(this.observer.snapshot());
601
+ }
602
+ /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
603
+ observedHandlers() {
604
+ return this.handlersRef();
605
+ }
606
+ /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
607
+ get observing() {
608
+ return this.observeMode;
609
+ }
513
610
  /**
514
611
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
515
612
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
@@ -694,6 +791,52 @@ export class FlowEngine {
694
791
  bizTransactionList: opts.bizTransactionList
695
792
  }));
696
793
  }
794
+ /**
795
+ * 할당(예약) — **처분 변화를 이벤트로 낸다.**
796
+ *
797
+ * 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
798
+ * 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
799
+ * 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
800
+ *
801
+ * 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
802
+ * 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
803
+ *
804
+ * `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
805
+ * 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
806
+ */
807
+ reserve(epcs, bizStep) {
808
+ this.observeDisposition(epcs, DISP.reserved, bizStep);
809
+ }
810
+ /**
811
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
812
+ *
813
+ * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
814
+ * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
815
+ *
816
+ * 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
817
+ * 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
818
+ */
819
+ observeDisposition(epcs, disposition, bizStep, at) {
820
+ const byLocation = new Map();
821
+ for (const epc of epcs) {
822
+ const it = this.items.get(epc);
823
+ if (!it || it.disposition === disposition)
824
+ continue;
825
+ it.disposition = disposition;
826
+ const where = at ?? it.location ?? '';
827
+ const bin = byLocation.get(where);
828
+ if (bin)
829
+ bin.push(epc);
830
+ else
831
+ byLocation.set(where, [epc]);
832
+ }
833
+ for (const [where, list] of byLocation) {
834
+ this.emit(objectEvent({
835
+ eventTime: this.now(), action: 'OBSERVE', bizStep, disposition,
836
+ epcList: list, ...(where ? { readPoint: where, bizLocation: where } : {})
837
+ }));
838
+ }
839
+ }
697
840
  /**
698
841
  * containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
699
842
  * consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
@@ -712,6 +855,14 @@ export class FlowEngine {
712
855
  this.items.delete(c);
713
856
  }
714
857
  }
858
+ return;
859
+ }
860
+ /* 흡수하지 않는 조립 — 자식은 독립 물품으로 남되 **소속을 상태에도 남긴다.**
861
+ * 예전에는 이벤트만 내고 상태를 안 바꿔서, 미러는 소속을 알고 시뮬은 몰랐다(같은 사실, 다른 답). */
862
+ for (const c of children) {
863
+ const it = this.items.get(c);
864
+ if (it)
865
+ it.parent = parent;
715
866
  }
716
867
  }
717
868
  /**
@@ -721,6 +872,12 @@ export class FlowEngine {
721
872
  */
722
873
  disaggregate(parent, children, opts) {
723
874
  this.emit(aggregationEvent({ eventTime: this.now(), action: 'DELETE', bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
875
+ /* 분해 — 소속이 끊어진다(자식이 독립으로 남든 새로 등장하든). */
876
+ for (const c of children) {
877
+ const it = this.items.get(c);
878
+ if (it)
879
+ it.parent = undefined;
880
+ }
724
881
  if (opts.materialize) {
725
882
  const m = opts.materialize;
726
883
  for (const c of children) {
@@ -789,8 +946,9 @@ export class FlowEngine {
789
946
  intent: t.intent,
790
947
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
791
948
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
949
+ ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
792
950
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
793
- ...(inProgress ? { remainingMs: t.remainingMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
951
+ ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
794
952
  });
795
953
  }
796
954
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
@@ -800,8 +958,18 @@ export class FlowEngine {
800
958
  emitPerson(p) {
801
959
  this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
802
960
  }
803
- emitMover(m, motion) { this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion }); }
804
- emitOrder(o) { this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held }); }
961
+ /** 설비 상태 전이 `taskId` 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
962
+ * 무슨 일을 하는 중인가" 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
963
+ emitMover(m, motion) {
964
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
965
+ }
966
+ /** 오더 델타 — **라인까지 싣는다.** 라인이 빠지면 미러가 남은 데맨드를 라인별로 재계획할 수 없다. */
967
+ emitOrder(o) {
968
+ this.emitOp(OP_EVENT.order, {
969
+ orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held,
970
+ ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
971
+ });
972
+ }
805
973
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
806
974
  /**
807
975
  * 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
@@ -892,8 +1060,14 @@ export class FlowEngine {
892
1060
  continue;
893
1061
  a.status = 'in-use';
894
1062
  a.taskId = t.id;
895
- if (t.itemEpc)
1063
+ if (t.itemEpc) {
896
1064
  a.carrying = t.itemEpc;
1065
+ /* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
1066
+ * 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
1067
+ const it = this.items.get(t.itemEpc);
1068
+ if (it)
1069
+ it.carriedBy = a.id;
1070
+ }
897
1071
  this.emitAsset(a);
898
1072
  }
899
1073
  }
@@ -909,6 +1083,11 @@ export class FlowEngine {
909
1083
  a.status = 'idle';
910
1084
  a.taskId = null;
911
1085
  a.location = t.toNode || a.location;
1086
+ if (a.carrying) {
1087
+ const it = this.items.get(a.carrying);
1088
+ if (it)
1089
+ it.carriedBy = undefined;
1090
+ }
912
1091
  a.carrying = undefined;
913
1092
  this.emitAsset(a);
914
1093
  }
@@ -939,6 +1118,31 @@ export class FlowEngine {
939
1118
  }
940
1119
  return picked;
941
1120
  }
1121
+ /**
1122
+ * 필요 설비를 고른다 — **인원·자산과 같은 규칙**(등급으로 요구, 부분 확보 없이 전량 아니면 대기).
1123
+ * 명세(`equipmentSpecification`)가 없으면 기존 거동: `resourceType` 한 대(없으면 아무 유휴 설비).
1124
+ * 여기서는 고르기만 한다 — 확정은 호출부가 다른 자원까지 확보한 뒤에 한다.
1125
+ */
1126
+ claimEquipment(t) {
1127
+ const free = (kind, picked = []) => [...this.movers.values()].filter(m => m.status === 'idle' && !m.held && !this.offShift(m) && !picked.includes(m.id) && (kind === undefined || m.kind === kind));
1128
+ const need = this.operationSpecs.get(t.kind)?.equipmentSpecification;
1129
+ if (!need?.length) {
1130
+ const one = free(t.resourceType)[0];
1131
+ return one ? [one.id] : null;
1132
+ }
1133
+ const picked = [];
1134
+ for (const req of need) {
1135
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
1136
+ if (!want)
1137
+ continue;
1138
+ const avail = free(req.equipmentClass, picked);
1139
+ if (avail.length < want)
1140
+ return null; // 한 등급이라도 모자라면 시작하지 않는다
1141
+ for (let i = 0; i < want; i++)
1142
+ picked.push(avail[i].id);
1143
+ }
1144
+ return picked.length ? picked : null;
1145
+ }
942
1146
  /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
943
1147
  assignCrew(t, crew) {
944
1148
  if (!crew.length)
@@ -1048,6 +1252,35 @@ export class FlowEngine {
1048
1252
  }
1049
1253
  }
1050
1254
  }
1255
+ /**
1256
+ * 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
1257
+ *
1258
+ * 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
1259
+ * 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
1260
+ * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
1261
+ */
1262
+ itemState(i) {
1263
+ const parsedClass = i.gtin ? parseEpc(i.gtin) : undefined;
1264
+ const parsedSelf = parseEpc(i.epc);
1265
+ const lot = parsedClass?.lot ??
1266
+ parsedSelf.lot ??
1267
+ (typeof i.ilmd?.[ILMD_ATTR.lot] === 'string' ? i.ilmd[ILMD_ATTR.lot] : undefined);
1268
+ const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
1269
+ return {
1270
+ epc: i.epc,
1271
+ ...(i.gtin ? { gtin: i.gtin } : {}),
1272
+ ...(gtinKey ? { gtinKey } : {}),
1273
+ ...(lot ? { lot } : {}),
1274
+ location: i.location,
1275
+ ...(i.disposition ? { disposition: i.disposition } : {}),
1276
+ ...(i.parent ? { parent: i.parent } : {}),
1277
+ ...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
1278
+ ...(i.qty !== undefined ? { qty: i.qty } : {}),
1279
+ ...(i.uom ? { uom: i.uom } : {}),
1280
+ ...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
1281
+ ...(i.ilmd ? { ilmd: i.ilmd } : {})
1282
+ };
1283
+ }
1051
1284
  progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
1052
1285
  generate() {
1053
1286
  for (const g of this.gens) {
@@ -1080,8 +1313,19 @@ export class FlowEngine {
1080
1313
  if (o.status === 'created' && !o.held)
1081
1314
  this.allocate(o);
1082
1315
  }
1316
+ /**
1317
+ * 작업 진행 — **진행을 먼저, 배정을 나중에.**
1318
+ *
1319
+ * 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
1320
+ * 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
1321
+ * tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
1322
+ */
1083
1323
  processTasks(dt) {
1084
- // 1) created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
1324
+ this.advanceTasks(dt);
1325
+ this.assignTasks();
1326
+ }
1327
+ assignTasks() {
1328
+ // created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
1085
1329
  for (const t of this.tasks.values()) {
1086
1330
  if (t.status !== 'created')
1087
1331
  continue;
@@ -1097,6 +1341,7 @@ export class FlowEngine {
1097
1341
  if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
1098
1342
  t.status = 'in-progress';
1099
1343
  t.remainingMs = t.durationMs;
1344
+ t.startedAtSimMs = this.clockMs;
1100
1345
  this.assignCrew(t, crew);
1101
1346
  this.assignAssets(t, gear);
1102
1347
  this.emitTask(t);
@@ -1106,10 +1351,12 @@ export class FlowEngine {
1106
1351
  * 이 제약이 없으면 대기가 생기지 않아 병목이 사라지고 예측이 낙관 쪽으로 치우친다. */
1107
1352
  if (this.stationFull(t.toNode))
1108
1353
  continue;
1109
- // resourceType 있으면 kind 무버만; 없으면 아무 유휴 무버. 교대 자원은 배정하지 않는다.
1110
- const mover = [...this.movers.values()].find(m => m.status === 'idle' && !m.held && !this.offShift(m) && (t.resourceType === undefined || m.kind === t.resourceType));
1111
- if (!mover)
1112
- continue; // 맞는 유휴 자원 없음 → 다음 task(break 아님: 다른 타입은 가용할 수 있음)
1354
+ /* 필요 설비 명세가 있으면 등급별 대수만큼(부분 확보 없이), 없으면 기존대로 대.
1355
+ * 확보 실패는 `null` 다음 작업으로 넘어간다(다른 타입은 가용할 있다). */
1356
+ const rigs = this.claimEquipment(t);
1357
+ if (!rigs)
1358
+ continue;
1359
+ const mover = this.movers.get(rigs[0]);
1113
1360
  // 체인지오버: task 의 changeoverKey 가 무버 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
1114
1361
  if (t.setupMs && t.changeoverKey !== undefined && mover.lastChangeoverKey !== undefined && mover.lastChangeoverKey !== t.changeoverKey) {
1115
1362
  t.appliedSetupMs = t.setupMs;
@@ -1117,11 +1364,17 @@ export class FlowEngine {
1117
1364
  }
1118
1365
  if (t.changeoverKey !== undefined)
1119
1366
  mover.lastChangeoverKey = t.changeoverKey;
1120
- mover.status = 'busy';
1121
- mover.taskId = t.id;
1367
+ for (const id of rigs) {
1368
+ const m = this.movers.get(id);
1369
+ m.status = 'busy';
1370
+ m.taskId = t.id;
1371
+ }
1122
1372
  t.status = 'in-progress';
1123
1373
  t.resource = mover.id;
1124
1374
  t.remainingMs = t.durationMs;
1375
+ t.startedAtSimMs = this.clockMs;
1376
+ if (rigs.length > 1)
1377
+ t.resources = rigs.slice(); // 대표만으로는 함께 잡힌 설비가 사라진다
1125
1378
  this.assignCrew(t, crew);
1126
1379
  this.assignAssets(t, gear);
1127
1380
  this.emitTask(t);
@@ -1131,7 +1384,9 @@ export class FlowEngine {
1131
1384
  else
1132
1385
  this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
1133
1386
  }
1134
- // 2) in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만.
1387
+ }
1388
+ /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
1389
+ advanceTasks(dt) {
1135
1390
  for (const t of this.tasks.values()) {
1136
1391
  if (t.status !== 'in-progress')
1137
1392
  continue;
@@ -1157,6 +1412,21 @@ export class FlowEngine {
1157
1412
  mover.taskId = null;
1158
1413
  if (t.intent !== 'process')
1159
1414
  mover.location = t.toNode; // 운반만 위치 이동; process 는 제자리
1415
+ /* 함께 잡힌 설비도 같은 규칙으로 놓아 준다 — 대표만 풀면 나머지가 영원히 묶인다. */
1416
+ for (const id of t.resources ?? []) {
1417
+ if (id === mover.id)
1418
+ continue;
1419
+ const m = this.movers.get(id);
1420
+ if (!m)
1421
+ continue;
1422
+ m.setupMs += setup;
1423
+ m.runMs += t.durationMs - setup;
1424
+ m.status = 'idle';
1425
+ m.taskId = null;
1426
+ if (t.intent !== 'process')
1427
+ m.location = t.toNode;
1428
+ this.emitMover(m);
1429
+ }
1160
1430
  this.emitTask(t);
1161
1431
  this.emitMover(mover);
1162
1432
  }
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './domain-catalog.ts';
12
12
  export * from './allocation-policy.ts';
13
13
  export * from './duration-estimator.ts';
14
14
  export * from './iso-duration.ts';
15
+ export * from './observed-reducer.ts';
15
16
  export * from './state-projector.ts';
16
17
  export * from './task-fold.ts';
17
18
  export * from './face2-adapter.ts';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./domain-catalog.js";
12
12
  export * from "./allocation-policy.js";
13
13
  export * from "./duration-estimator.js";
14
14
  export * from "./iso-duration.js";
15
+ export * from "./observed-reducer.js";
15
16
  export * from "./state-projector.js";
16
17
  export * from "./task-fold.js";
17
18
  export * from "./face2-adapter.js";
package/dist/kernel.js CHANGED
@@ -33,7 +33,10 @@ export class WmsKernel extends FlowEngine {
33
33
  const poTxn = [{ type: BTT.po, bizTransaction: po }];
34
34
  // 로트 만료(FEFO 용, 결정적 — rng 무소비로 byte-identical 유지). 편차로 도착순≠만료순 → FEFO 가 유의미.
35
35
  const expiry = this.clockMs + SHELF_MS - (this.epcSeq % 5) * SHELF_JITTER_MS;
36
- this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry });
36
+ /* 방출한 마스터데이터를 **상태에도 들고 있는다** 자기가 선언한 것을 자기가 모르면, 미러는 알고
37
+ * 시뮬은 모르는 비대칭이 생긴다(파리티 테스트가 잡은 바로 그 종류). */
38
+ const ilmd = { [ILMD_ATTR.expiry]: expiry };
39
+ this.items.set(epc, { epc, gtin, qty, location: dock.id, disposition: DISP.in_progress, expiry, ilmd });
37
40
  dock.occupancy++;
38
41
  this.emit(transactionEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, bizTransactionList: poTxn, epcList: [epc], quantityList: qtyList, readPoint: dock.id }));
39
42
  this.emit(aggregationEvent({ eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, parentID: epc, childQuantityList: qtyList, readPoint: dock.id }));
@@ -42,7 +45,7 @@ export class WmsKernel extends FlowEngine {
42
45
  this.emit(objectEvent({
43
46
  eventTime, action: 'ADD', bizStep: BIZSTEP.receiving, disposition: DISP.in_progress,
44
47
  epcList: [epc], quantityList: qtyList, readPoint: dock.id, bizLocation: dock.id, bizTransactionList: poTxn,
45
- ilmd: { [ILMD_ATTR.expiry]: expiry }
48
+ ilmd
46
49
  }));
47
50
  const binId = this.policy.selectPlacement({ item: { epc, gtin, qty }, slots: this.slotViews('storage') });
48
51
  if (!binId)
@@ -115,13 +118,15 @@ export class WmsKernel extends FlowEngine {
115
118
  .map(i => ({ epc: i.epc, location: i.location, qty: i.qty ?? 1, expiry: i.expiry }));
116
119
  const chosen = this.policy.selectStock({ gtin: line.gtin, qty: need, available });
117
120
  for (const epc of chosen) {
118
- this.items.get(epc).disposition = DISP.reserved;
119
121
  o.allocated.push(epc);
120
122
  chosenAll.push(epc);
121
123
  }
122
124
  }
123
125
  if (chosenAll.length === 0)
124
126
  return;
127
+ /* 할당은 아직 집은 것이 아니다 — 물건은 보관 자리에 있고 처분만 '예약' 으로 바뀐다.
128
+ * 그래서 단계는 storing 이다(피킹 관측은 실제로 집을 때 따로 난다). */
129
+ this.reserve(chosenAll, BIZSTEP.storing);
125
130
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: BIZSTEP.picking, bizTransactionList: [{ type: BTT.so, bizTransaction: o.bizTransaction }], epcList: chosenAll.slice() }));
126
131
  for (const epc of chosenAll) {
127
132
  const it = this.items.get(epc);
@@ -134,10 +134,9 @@ export class MesKernel extends FlowEngine {
134
134
  return; // 부품 부족 → 대기
135
135
  picks.push(...chosen);
136
136
  }
137
- for (const epc of picks) {
138
- this.items.get(epc).disposition = DISP.reserved;
137
+ for (const epc of picks)
139
138
  o.allocated.push(epc);
140
- }
139
+ this.reserve(picks, MES_BIZSTEP.producing);
141
140
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
142
141
  this.emitStation(o, s0, o.allocated[0], product.gtin);
143
142
  o.status = 'op-' + s0.kind;
@@ -243,10 +242,9 @@ export class MesKernel extends FlowEngine {
243
242
  return; // 자재 부족 → 대기
244
243
  picks.push(...chosen);
245
244
  }
246
- for (const epc of picks) {
247
- this.items.get(epc).disposition = DISP.reserved;
245
+ for (const epc of picks)
248
246
  o.allocated.push(epc);
249
- }
247
+ this.reserve(picks, MES_BIZSTEP.producing);
250
248
  this.emit(transactionEvent({ eventTime: this.now(), action: 'ADD', bizStep: MES_BIZSTEP.producing, bizTransactionList: [{ type: BTT_PRODORDER, bizTransaction: o.bizTransaction }], epcList: o.allocated.slice() }));
251
249
  this.emitStationDef(o, ops[0], o.allocated[0]);
252
250
  o.status = 'op-' + ops[0].key;