@operato/twin-kernel 0.1.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.
@@ -9,8 +9,10 @@
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";
15
+ import { parseIsoDuration } from "./iso-duration.js";
14
16
  const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
15
17
  function mulberry32(seed) {
16
18
  let a = seed >>> 0;
@@ -116,19 +118,41 @@ export class FlowEngine {
116
118
  nodes = new Map();
117
119
  items = new Map();
118
120
  movers = new Map();
121
+ /** 사람 — 선언하지 않으면 빈 맵(인원 제약 없는 트윈, 기존 거동). */
122
+ persons = new Map();
123
+ /** 물리 자산 — 선언하지 않으면 빈 맵(자산 제약 없는 트윈, 기존 거동). */
124
+ assets = new Map();
119
125
  tasks = new Map();
120
126
  orders = new Map();
121
127
  revision = 0;
122
128
  clockMs = 0;
123
129
  rng = mulberry32(1);
124
130
  policy;
125
- /** duration 시임(선택) — 미주입 시 도메인 상수. 씬/보드 바인딩이 거리·속도 기반 estimator 주입. */
131
+ /** duration 시임(선택) — 미주입 시 명세, 명세도 없으면 도메인 상수. 이력 보정 추정기가 여기 들어온다. */
126
132
  durationEstimator;
133
+ /**
134
+ * 오퍼레이션 명세(선택) — 작업 종류(`FlowTask.kind` = `OperationDef.key`) → 소요·변동·모수.
135
+ * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
136
+ */
137
+ operationSpecs = new Map();
138
+ /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
139
+ observer;
140
+ /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
141
+ observedDirty = false;
142
+ observeMode = false;
143
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
144
+ boardDef;
145
+ /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
146
+ specUse = new Map();
127
147
  epcSeq = 0;
128
148
  taskSeq = 0;
129
149
  orderSeq = 0;
130
150
  soSeq = 0;
131
151
  handlers = [];
152
+ /** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
153
+ handlersRef() {
154
+ return this.handlers;
155
+ }
132
156
  gens = [];
133
157
  generating = false;
134
158
  speed = 1;
@@ -139,10 +163,15 @@ export class FlowEngine {
139
163
  }
140
164
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
141
165
  loadBoard(def) {
166
+ this.boardDef = def;
142
167
  for (const n of def.nodes)
143
- this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, occupancy: 0, status: 'idle', parentId: n.parentId });
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 });
169
+ for (const p of def.persons ?? [])
170
+ this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle', taskId: null, window: p.window });
171
+ for (const a of def.assets ?? [])
172
+ this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.homeNode, status: 'idle', taskId: null });
144
173
  for (const m of def.movers) {
145
- const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 };
174
+ const mover = { id: m.id, kind: m.kind, location: m.homeNode, status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window };
146
175
  if (m.mtbfMs !== undefined) {
147
176
  mover.mtbfMs = m.mtbfMs;
148
177
  mover.mttrMs = m.mttrMs;
@@ -174,13 +203,91 @@ export class FlowEngine {
174
203
  * 진행 중 개별 task 의 내부 상태는 관측만으론 복원 불가 → 재계획에 맡김(정직한 한계).
175
204
  */
176
205
  hydrateObserved(snap, orders = []) {
177
- for (const n of snap.nodes)
178
- this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, occupancy: n.occupancy ?? 0, status: 'idle', parentId: n.parentId });
206
+ /* **관측된 것을 버리지 않는다.** 예전에는 노드·무버 상태를 'idle' 로, OEE 누적을 0 으로 덮고
207
+ * 물품의 로트·단위·소속·마스터데이터를 떨어뜨렸다. 씨앗이 잃은 것은 **예측도 모른다**
208
+ * 고장 난 설비를 정상으로, 진행 중인 일을 없는 것으로 놓고 미래를 굴리면 답이 낙관 쪽으로 치우친다. */
209
+ for (const n of snap.nodes) {
210
+ this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity ?? 0, parallelism: n.parallelism, occupancy: n.occupancy ?? 0, status: n.status ?? 'idle', parentId: n.parentId });
211
+ }
179
212
  this.items.clear();
180
- for (const it of snap.items)
181
- this.items.set(it.epc, { epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable, gtin: it.gtin, qty: it.qty ?? 1 });
182
- for (const m of snap.movers)
183
- this.movers.set(m.id, { id: m.id, kind: m.kind, location: m.location ?? '', status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0 });
213
+ for (const it of snap.items) {
214
+ this.items.set(it.epc, {
215
+ /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
216
+ epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
217
+ gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom,
218
+ parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
219
+ });
220
+ }
221
+ for (const m of snap.movers) {
222
+ /*
223
+ * 관측된 계측을 이어받는다 — **없어서 0 이었던 게 아니라 잘못 읽어서 0 이었다.**
224
+ * `OeeMetrics` 의 이름은 `runMs`·`setupMs`·`downMs`·`goodCount`·`scrapCount` 인데 예전 코드가
225
+ * `oee.good`·`oee.scrap`(존재하지 않는 키)을 읽어 항상 0 으로 떨어졌고, 시간 누적은 아예 0 으로
226
+ * 시작했다. 그래서 씨앗이 "가동 이력이 전혀 없는 설비" 로 출발해 예측의 OEE 가 리셋됐다.
227
+ *
228
+ * 라이브(미러)에서 이 값의 출처: 원 시스템이 시간 누적을 보내 주는 것이 아니라, 호스트가
229
+ * `equipment.status` 전이 구간을 적분하고 `quality.output` 카운터를 모아 파생한다. 그 파생에는
230
+ * 한계가 있다 — busy 를 가동으로 근사하고 **셋업을 분리하지 못해 0** 으로 둔다. 여기서는 받은
231
+ * 값을 그대로 이어받고, 없으면 0 에서 시작한다(꾸미지 않는다).
232
+ */
233
+ const oee = m.oee;
234
+ this.movers.set(m.id, {
235
+ id: m.id, kind: m.kind, location: m.location ?? '', status: m.status ?? 'idle', taskId: null,
236
+ runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
237
+ goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0
238
+ });
239
+ }
240
+ /* 사람을 이어 붙인다 — **없으면 인원 요구가 영원히 채워지지 않는다.** 관측 상태에 사람이 있는데
241
+ * 씨앗이 버리면, 인원을 요구하는 공정의 작업이 하나도 시작되지 못하고 예측이 멈춘다(0 명 < 2 명).
242
+ * 배정 상태(busy/taskId)는 아래 작업 복원이 다시 세우므로 여기서는 등급·교대만 살린다. */
243
+ for (const p of snap.persons ?? []) {
244
+ this.persons.set(p.id, { id: p.id, personnelClass: p.personnelClass, status: 'idle', taskId: null });
245
+ }
246
+ /* 자산도 같다 — 잃으면 자산을 요구하는 작업이 영원히 못 나간다(빈 팔레트 0개 < 1개). */
247
+ for (const a of snap.assets ?? []) {
248
+ this.assets.set(a.id, { id: a.id, assetClass: a.assetClass, location: a.location, status: 'idle', taskId: null, carrying: a.carrying });
249
+ }
250
+ /* 진행 중이던 작업을 이어 붙인다 — 없으면 예측이 "일이 하나도 없는 현장" 에서 출발한다.
251
+ * 남은 시간을 모르면 **진척을 꾸미지 않고** 미착수(created)로 되돌린다: 그 일이 남아 있다는 사실은
252
+ * 지키면서, 얼마나 진행됐는지는 모른다고 말하는 쪽이 정직하다(커널이 다시 배정해 굴린다). */
253
+ for (const t of snap.tasks ?? []) {
254
+ if (t.status === 'completed')
255
+ continue;
256
+ const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
257
+ this.tasks.set(t.id, {
258
+ id: t.id, kind: t.kind,
259
+ status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
260
+ itemEpc: t.itemRefs?.[0] ?? '',
261
+ fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
262
+ resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
263
+ remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
264
+ startedAtSimMs: t.startedAtSimMs,
265
+ durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
266
+ orderId: t.orderId,
267
+ intent: t.intent
268
+ });
269
+ /* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
270
+ if (known && t.status === 'in-progress' && t.resourceRef) {
271
+ const mv = this.movers.get(t.resourceRef);
272
+ if (mv) {
273
+ mv.status = 'busy';
274
+ mv.taskId = t.id;
275
+ }
276
+ }
277
+ /* 사람도 같다 — 진행 중이던 작업에 투입돼 있던 사람은 여전히 묶여 있어야 한다. */
278
+ if (known && t.status === 'in-progress') {
279
+ const restored = this.tasks.get(t.id);
280
+ if (restored)
281
+ restored.personnel = t.personnel ? [...t.personnel] : undefined;
282
+ for (const id of t.personnel ?? []) {
283
+ const pp = this.persons.get(id);
284
+ if (pp) {
285
+ pp.status = 'busy';
286
+ pp.taskId = t.id;
287
+ }
288
+ }
289
+ }
290
+ }
184
291
  for (const o of orders) {
185
292
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
186
293
  const remaining = lines.reduce((s, l) => s + l.requested, 0);
@@ -326,7 +433,7 @@ export class FlowEngine {
326
433
  return;
327
434
  this.generating = true;
328
435
  for (const g of this.gens)
329
- g.nextMs = this.clockMs + this.intervalMs(g.spec);
436
+ g.nextMs = this.nextFireMs(g.spec, this.clockMs);
330
437
  },
331
438
  pause: () => { this.generating = false; },
332
439
  reset: () => { this.generating = false; this.gens = []; },
@@ -342,19 +449,50 @@ export class FlowEngine {
342
449
  this.processTasks(dt);
343
450
  }
344
451
  getSnapshot() {
452
+ this.settleObserved();
345
453
  return {
346
454
  revision: this.revision,
347
455
  simClockMs: this.clockMs,
348
- nodes: [...this.nodes.values()].map(n => ({ ...n })),
349
- items: [...this.items.values()].map(i => ({ epc: i.epc, gtin: i.gtin, qty: i.qty, location: i.location, disposition: i.disposition, expiry: i.expiry })),
456
+ /* 출처 표시 — 보드(마스터)에서 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
457
+ * 시뮬이 아무 표시도 하면 소비처가 스냅샷을 같은 규칙으로 읽지 못한다. */
458
+ nodes: [...this.nodes.values()].map(n => {
459
+ const { status, ...rest } = n;
460
+ /* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
461
+ const derived = nodeStatusOf(n);
462
+ return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
463
+ }),
464
+ items: [...this.items.values()].map(i => this.itemState(i)),
350
465
  movers: [...this.movers.values()].map(m => {
351
- 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 };
466
+ 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 } : {}) };
352
467
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
353
468
  if (t && t.status === 'in-progress' && t.intent !== 'process')
354
469
  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 };
355
470
  return s;
356
471
  }),
357
- 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 })),
472
+ assets: [...this.assets.values()].map(a => {
473
+ const st = { id: a.id, assetClass: a.assetClass, location: a.location, status: a.status, taskId: a.taskId ?? undefined };
474
+ if (a.carrying)
475
+ st.carrying = a.carrying;
476
+ return st;
477
+ }),
478
+ persons: [...this.persons.values()].map(p => {
479
+ const st = { id: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined };
480
+ if (this.personOffShift(p))
481
+ st.offShift = true;
482
+ return st;
483
+ }),
484
+ /* 스냅샷이 **델타보다 가난하면 안 된다** — 예전에는 소요·남은 시간을 빼고 내보내서, 이 스냅샷으로
485
+ * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
486
+ * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
487
+ tasks: [...this.tasks.values()].map(t => ({
488
+ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
489
+ fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
490
+ ...(t.intent ? { intent: t.intent } : {}),
491
+ ...(t.durationMs ? { durationMs: t.durationMs } : {}),
492
+ ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
493
+ ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
494
+ ...(t.assets?.length ? { assets: t.assets.slice() } : {})
495
+ })),
358
496
  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 })),
359
497
  attentions: this.computeAttentions()
360
498
  };
@@ -381,6 +519,7 @@ export class FlowEngine {
381
519
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
382
520
  */
383
521
  fork(tenantId = this.tenantId) {
522
+ this.settleObserved(); // 관측으로 굴러온 커널을 fork 하려면 먼저 상태로 옮겨야 한다
384
523
  const Ctor = this.constructor;
385
524
  const clone = new Ctor(tenantId, this.policy);
386
525
  // 상태·시나리오(gens/generating)·시퀀스 전부 복제 → 원본의 완전한 continuation.
@@ -398,14 +537,197 @@ export class FlowEngine {
398
537
  }
399
538
  clone.rng.state = this.rng.state; // RNG 연속성 → 생성 포함 결정적 분기
400
539
  clone.durationEstimator = this.durationEstimator; // 무상태 시임 — 참조 공유(policy 와 동형)
540
+ /* 명세는 불변(정의) → 참조 공유. 사용 기록은 fork 자기 것(무엇을 기본값으로 굴렸는지 fork 별로 다르다). */
541
+ clone.operationSpecs = this.operationSpecs;
542
+ clone.specUse = new Map([...this.specUse.entries()].map(([k, u]) => [k, { duration: u.duration, variability: u.variability, params: new Set(u.params) }]));
401
543
  return clone;
402
544
  }
403
545
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
404
546
  now() { return new Date(BASE_EPOCH + this.clockMs).toISOString(); }
405
547
  randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
406
- /** task 소요 산출 — estimator 주입 시 그 값, 미주입/undefined 시 도메인 상수(fallback). "얼마"만 소비, "경로"는 씬. */
548
+ /**
549
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
550
+ *
551
+ * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
552
+ * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
553
+ * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
554
+ *
555
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
556
+ * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
557
+ * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
558
+ *
559
+ * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
560
+ * reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
561
+ *
562
+ * `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
563
+ */
564
+ apply(envelope) {
565
+ if (!this.observer) {
566
+ this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
567
+ this.observeMode = true;
568
+ }
569
+ this.observer.apply(envelope);
570
+ /* **구독자에게 그대로 흘린다** — 호스트가 시뮬·관측 두 모드에서 같은 배선을 쓰게 하기 위해서다
571
+ * (`onEvent` 하나로 저널·방송이 붙는다). 관측 모드에서 이것은 **재방출**이지 새 사실이 아니다:
572
+ * 원천이 이미 그 이벤트를 갖고 있으므로, 호스트가 인입과 재방출을 **둘 다 저널에 적으면 중복**이
573
+ * 된다. 저널은 인입에서 한 번만 적는다. */
574
+ for (const h of this.observedHandlers())
575
+ h(envelope);
576
+ /* **이벤트마다 상태를 통째로 옮기지 않는다.** 옮기는 비용은 O(상태 크기)라, 이벤트 하나에 그것을
577
+ * 치르면 유입이 늘수록 감당이 안 된다. 필요해지는 순간(스냅샷·fork)에 **한 번만** 옮긴다. */
578
+ this.observedDirty = true;
579
+ this.revision++;
580
+ }
581
+ /** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
582
+ settleObserved() {
583
+ if (!this.observedDirty || !this.observer)
584
+ return;
585
+ this.observedDirty = false;
586
+ this.hydrateObserved(this.observer.snapshot());
587
+ }
588
+ /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
589
+ observedHandlers() {
590
+ return this.handlersRef();
591
+ }
592
+ /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
593
+ get observing() {
594
+ return this.observeMode;
595
+ }
596
+ /**
597
+ * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
598
+ * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
599
+ */
600
+ loadOperations(ops = []) {
601
+ for (const o of ops)
602
+ if (o?.key)
603
+ this.operationSpecs.set(o.key, o);
604
+ }
605
+ /**
606
+ * task 소요 산출 — 우선순위: **① 추정기(이력 보정) → ② 명세(ISA-95 Duration + 변동) → ③ 도메인 상수.**
607
+ *
608
+ * 이 순서인 이유: 실측에서 배운 값이 선언값을 이기고, 선언값이 우리가 코드에 박아 둔 상수를 이긴다.
609
+ * 셋 중 무엇을 썼는지는 `specCoverage()` 로 드러낸다 — 상수를 쓴 것이 조용히 넘어가지 않게.
610
+ * "얼마"만 소비하고 "경로"는 씬이 소유한다(좌표-free 유지).
611
+ */
407
612
  durationOf(ctx, fallbackMs) {
408
- return this.durationEstimator?.estimate(ctx) ?? fallbackMs;
613
+ const estimated = this.durationEstimator?.estimate(ctx);
614
+ /* 추정기가 답한 것은 **실측·계산에서 온 값**이므로 선언값과 구별해 기록한다 — "현장이 선언했다" 와
615
+ * "이력에서 배웠다" 는 예측의 자격이 다르다(후자가 더 강하다). 뭉개면 그 차이가 사라진다. */
616
+ if (typeof estimated === 'number') {
617
+ this.noteSpecUse(ctx.kind, 'measured');
618
+ return estimated;
619
+ }
620
+ if (estimated) {
621
+ /* 분포까지 아는 추정치 — **관측된 퍼짐**으로 흔든다. 평균만 쓰면 변동이 0 이라 줄이 생기지 않는다. */
622
+ this.noteSpecUse(ctx.kind, 'measured', estimated.spread?.distribution);
623
+ return this.sampleSpread(estimated.meanMs, estimated.spread);
624
+ }
625
+ const spec = this.operationSpecs.get(ctx.kind);
626
+ const declared = parseIsoDuration(spec?.duration);
627
+ if (declared === undefined) {
628
+ this.noteSpecUse(ctx.kind, 'default');
629
+ return fallbackMs;
630
+ }
631
+ this.noteSpecUse(ctx.kind, 'declared');
632
+ return this.applyVariability(declared, spec?.variability);
633
+ }
634
+ /**
635
+ * 소요시간 변동 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 표준 밖 확장이므로 미지정이면 상수.
636
+ * 모수가 모자라면(uniform 에 min/max 없음 등) 변동을 발명하지 않고 평균을 그대로 쓴다.
637
+ */
638
+ applyVariability(meanMs, v) {
639
+ if (!v || v.distribution === 'constant')
640
+ return meanMs;
641
+ if (v.distribution === 'exponential')
642
+ return -Math.log(1 - this.rng()) * meanMs;
643
+ const minMs = parseIsoDuration(v.min);
644
+ const maxMs = parseIsoDuration(v.max);
645
+ if (minMs === undefined || maxMs === undefined)
646
+ return meanMs;
647
+ return this.sampleSpread(meanMs, { distribution: v.distribution, minMs, maxMs, modeMs: parseIsoDuration(v.mode) });
648
+ }
649
+ /**
650
+ * 퍼짐 표본 — **엔진 난수원으로만** 뽑는다(fork 결정성). 선언 명세(ISO 표기)와 실측 분포(ms)가
651
+ * 같은 수식을 쓴다: 한쪽만 고치면 두 경로가 다른 답을 낸다.
652
+ * 모수가 모자라거나 뒤집혀 있으면 **퍼짐을 발명하지 않고** 평균을 그대로 쓴다.
653
+ */
654
+ sampleSpread(meanMs, spread) {
655
+ if (!spread)
656
+ return meanMs;
657
+ const { minMs, maxMs } = spread;
658
+ if (!Number.isFinite(minMs) || !Number.isFinite(maxMs) || maxMs < minMs)
659
+ return meanMs;
660
+ if (spread.distribution === 'uniform')
661
+ return minMs + this.rng() * (maxMs - minMs);
662
+ // triangular — 최빈값 미지정이면 평균을 최빈값으로 본다(구간 밖이면 안으로 클램프).
663
+ const mode = Math.min(maxMs, Math.max(minMs, spread.modeMs ?? meanMs));
664
+ const u = this.rng();
665
+ const span = maxMs - minMs;
666
+ if (span <= 0)
667
+ return minMs;
668
+ const c = (mode - minMs) / span;
669
+ return u < c ? minMs + Math.sqrt(u * span * (mode - minMs)) : maxMs - Math.sqrt((1 - u) * span * (maxMs - mode));
670
+ }
671
+ /** 명세 모수(숫자) — 선언 없으면 undefined(0 으로 꾸미지 않는다). 소비처가 기본값을 정한다. */
672
+ paramNumber(opKey, id) {
673
+ const p = this.operationSpecs.get(opKey)?.parameters?.find(x => x.id === id);
674
+ if (!p)
675
+ return undefined;
676
+ const n = Number(p.value);
677
+ if (!Number.isFinite(n))
678
+ return undefined;
679
+ this.noteParamUse(opKey, id);
680
+ return n;
681
+ }
682
+ /** 명세 모수(기간) — ISO 8601 문자열을 밀리초로. 선언 없으면 undefined. */
683
+ paramDuration(opKey, id) {
684
+ const p = this.operationSpecs.get(opKey)?.parameters?.find(x => x.id === id);
685
+ const ms = parseIsoDuration(p?.value);
686
+ if (ms !== undefined)
687
+ this.noteParamUse(opKey, id);
688
+ return ms;
689
+ }
690
+ noteSpecUse(kind, duration, variability) {
691
+ const cur = this.specUse.get(kind);
692
+ if (cur) {
693
+ cur.duration = duration;
694
+ if (variability)
695
+ cur.variability = variability;
696
+ return;
697
+ }
698
+ this.specUse.set(kind, { duration, ...(variability ? { variability } : {}), params: new Set() });
699
+ }
700
+ noteParamUse(kind, id) {
701
+ const cur = this.specUse.get(kind);
702
+ if (cur)
703
+ cur.params.add(id);
704
+ else
705
+ this.specUse.set(kind, { duration: 'default', params: new Set([id]) });
706
+ }
707
+ /**
708
+ * 시뮬 명세 자기보고 — **어디까지 데이터로 말했고 어디부터 우리가 박아 둔 상수인가.**
709
+ *
710
+ * 시뮬레이션 결과를 받는 쪽이 이걸 봐야 한다: 소요시간이 전부 기본값이면 그 예측으로 말할 수 있는 것은
711
+ * "같은 조건에서의 상대 비교" 뿐이고 "몇 시에 끝난다" 는 근거가 없다. 그 구분을 숫자로 드러낸다.
712
+ */
713
+ specCoverage() {
714
+ const operations = [...this.specUse.entries()].map(([kind, u]) => {
715
+ const spec = this.operationSpecs.get(kind);
716
+ /* 실측 분포를 쓴 경우가 우선 — 선언 명세의 변동보다 실제로 굴린 것이 사실이다. */
717
+ const variability = u.variability ?? spec?.variability?.distribution;
718
+ return {
719
+ kind,
720
+ duration: u.duration,
721
+ ...(variability ? { variability } : {}),
722
+ parameters: [...u.params].sort()
723
+ };
724
+ });
725
+ return {
726
+ operations,
727
+ measuredDurations: operations.filter(o => o.duration === 'measured').length,
728
+ declaredDurations: operations.filter(o => o.duration === 'declared').length,
729
+ defaultDurations: operations.filter(o => o.duration === 'default').length
730
+ };
409
731
  }
410
732
  /** skuMix 에서 weight 로 gtin 선택(rng) — 도착/오더 자극의 품목 결정. */
411
733
  pickGtin(mix) {
@@ -455,6 +777,52 @@ export class FlowEngine {
455
777
  bizTransactionList: opts.bizTransactionList
456
778
  }));
457
779
  }
780
+ /**
781
+ * 할당(예약) — **처분 변화를 이벤트로 낸다.**
782
+ *
783
+ * 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
784
+ * 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
785
+ * 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
786
+ *
787
+ * 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
788
+ * 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
789
+ *
790
+ * `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
791
+ * 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
792
+ */
793
+ reserve(epcs, bizStep) {
794
+ this.observeDisposition(epcs, DISP.reserved, bizStep);
795
+ }
796
+ /**
797
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
798
+ *
799
+ * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
800
+ * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
801
+ *
802
+ * 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
803
+ * 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
804
+ */
805
+ observeDisposition(epcs, disposition, bizStep, at) {
806
+ const byLocation = new Map();
807
+ for (const epc of epcs) {
808
+ const it = this.items.get(epc);
809
+ if (!it || it.disposition === disposition)
810
+ continue;
811
+ it.disposition = disposition;
812
+ const where = at ?? it.location ?? '';
813
+ const bin = byLocation.get(where);
814
+ if (bin)
815
+ bin.push(epc);
816
+ else
817
+ byLocation.set(where, [epc]);
818
+ }
819
+ for (const [where, list] of byLocation) {
820
+ this.emit(objectEvent({
821
+ eventTime: this.now(), action: 'OBSERVE', bizStep, disposition,
822
+ epcList: list, ...(where ? { readPoint: where, bizLocation: where } : {})
823
+ }));
824
+ }
825
+ }
458
826
  /**
459
827
  * containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
460
828
  * consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
@@ -473,6 +841,14 @@ export class FlowEngine {
473
841
  this.items.delete(c);
474
842
  }
475
843
  }
844
+ return;
845
+ }
846
+ /* 흡수하지 않는 조립 — 자식은 독립 물품으로 남되 **소속을 상태에도 남긴다.**
847
+ * 예전에는 이벤트만 내고 상태를 안 바꿔서, 미러는 소속을 알고 시뮬은 몰랐다(같은 사실, 다른 답). */
848
+ for (const c of children) {
849
+ const it = this.items.get(c);
850
+ if (it)
851
+ it.parent = parent;
476
852
  }
477
853
  }
478
854
  /**
@@ -482,6 +858,12 @@ export class FlowEngine {
482
858
  */
483
859
  disaggregate(parent, children, opts) {
484
860
  this.emit(aggregationEvent({ eventTime: this.now(), action: 'DELETE', bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
861
+ /* 분해 — 소속이 끊어진다(자식이 독립으로 남든 새로 등장하든). */
862
+ for (const c of children) {
863
+ const it = this.items.get(c);
864
+ if (it)
865
+ it.parent = undefined;
866
+ }
485
867
  if (opts.materialize) {
486
868
  const m = opts.materialize;
487
869
  for (const c of children) {
@@ -534,13 +916,259 @@ export class FlowEngine {
534
916
  for (const h of this.handlers)
535
917
  h(e);
536
918
  }
537
- emitTask(t) { this.emitOp(OP_EVENT.task, { taskId: t.id, orderId: t.orderId, kind: t.kind, status: t.status, fromNode: t.fromNode, toNode: t.toNode, itemRefs: [t.itemEpc], resourceRef: t.resource ?? undefined }); }
538
- emitMover(m, motion) { this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion }); }
919
+ /**
920
+ * 작업 전이 방출 **커널이 아는 것을 미러도 알게** 한다.
921
+ *
922
+ * 예전에는 진척·남은 시간·의도를 싣지 않아, 미러 상태를 씨앗으로 한 예측이 "진행 중인 일이 없는
923
+ * 현장" 에서 출발했고, 소비처는 무자원 체류를 기록 누락으로 오해할 수밖에 없었다.
924
+ * 진척은 진행 중일 때만 뜻이 있으므로 그때만 싣는다(생성·완료 시점의 0/1 은 노이즈).
925
+ */
926
+ emitTask(t) {
927
+ const inProgress = t.status === 'in-progress';
928
+ const done = Math.max(0, (t.durationMs ?? 0) - (t.remainingMs ?? 0));
929
+ this.emitOp(OP_EVENT.task, {
930
+ taskId: t.id, orderId: t.orderId, kind: t.kind, status: t.status,
931
+ fromNode: t.fromNode, toNode: t.toNode, itemRefs: [t.itemEpc], resourceRef: t.resource ?? undefined,
932
+ intent: t.intent,
933
+ ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
934
+ ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
935
+ ...(t.durationMs ? { durationMs: t.durationMs } : {}),
936
+ ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
937
+ });
938
+ }
939
+ /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
940
+ emitAsset(a) {
941
+ this.emitOp(OP_EVENT.asset, { assetId: a.id, assetClass: a.assetClass, status: a.status, location: a.location, taskId: a.taskId ?? undefined, carrying: a.carrying });
942
+ }
943
+ emitPerson(p) {
944
+ this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
945
+ }
946
+ /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
947
+ * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
948
+ emitMover(m, motion) {
949
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
950
+ }
539
951
  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 }); }
540
952
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
953
+ /**
954
+ * 자극 간격 — **계약이 선언한 네 분포를 실제로 판정한다.**
955
+ *
956
+ * 예전에는 `poisson` 만 구현하고 나머지는 전부 상수로 떨어졌다. 계약이 `uniform`·`profile` 을
957
+ * 선언하고 있었으므로, 그것을 지정한 사람은 자기가 요청한 분포로 도는 줄 알았다 — **조용한 거짓**이다.
958
+ *
959
+ * constant 간격이 일정(평균 그대로)
960
+ * poisson 무기억 도착(지수 간격) — 평균 유지
961
+ * uniform 0..2×평균 균등 — 평균을 유지하면서 흔들린다(교과서적 U(0,2μ))
962
+ * profile 시간대별 배율(`profile[시]`)로 도착률을 조절 — 하루 안의 수요 곡선
963
+ */
541
964
  intervalMs(spec) {
542
- const base = 3_600_000 / spec.rate.meanPerHour;
543
- return spec.rate.distribution === 'poisson' ? -Math.log(1 - this.rng()) * base : base;
965
+ const perHour = spec.rate.meanPerHour * this.profileFactor(spec.rate);
966
+ if (!(perHour > 0))
967
+ return Number.POSITIVE_INFINITY; // 그 시간대엔 도착이 없다(0 을 1/0=∞ 로 정직하게)
968
+ const base = 3_600_000 / perHour;
969
+ switch (spec.rate.distribution) {
970
+ case 'poisson':
971
+ return -Math.log(1 - this.rng()) * base;
972
+ case 'uniform':
973
+ return this.rng() * 2 * base;
974
+ case 'profile':
975
+ case 'constant':
976
+ default:
977
+ return base;
978
+ }
979
+ }
980
+ /**
981
+ * 시간대 배율 — `profile[시]`. `profile` 분포일 때만 적용하며, 배열이 짧으면 **순환**한다
982
+ * (24개면 하루, 8개면 8시간 주기). 미지정·다른 분포면 1(무영향).
983
+ * 시(hour)는 **시뮬 시각 자신의 프레임**(BASE_EPOCH 기준 UTC)이다 — 계약에 표준시가 없으므로
984
+ * 현지 시간대 해석은 아직 하지 않는다(꾸미지 않는다).
985
+ */
986
+ /**
987
+ * 다음 발화 시각 — **발화가 없는 시간대를 영원한 침묵으로 만들지 않는다.**
988
+ *
989
+ * 배율 0(그 시간대 도착 없음)이면 간격이 무한이 된다. 그것을 그대로 예약하면 이후 어떤 시간대가
990
+ * 와도 깨어나지 않는다 — 그래서 **다음 정시로 미뤄 다시 판정**한다(시간대가 바뀌면 배율도 바뀐다).
991
+ * 시나리오 시작과 구동 루프가 같은 규칙을 쓰도록 한 곳에 둔다(예전에는 시작 경로만 따로였다).
992
+ */
993
+ nextFireMs(spec, from) {
994
+ const interval = this.intervalMs(spec);
995
+ if (Number.isFinite(interval))
996
+ return from + interval;
997
+ const hourMs = 3_600_000;
998
+ return Math.floor(from / hourMs) * hourMs + hourMs; // 다음 정시에 재판정
999
+ }
1000
+ profileFactor(rate) {
1001
+ if (rate.distribution !== 'profile')
1002
+ return 1;
1003
+ const p = rate.profile;
1004
+ if (!p?.length)
1005
+ return 1;
1006
+ const f = p[this.hourOfDay() % p.length];
1007
+ return Number.isFinite(f) && f >= 0 ? f : 1;
1008
+ }
1009
+ /**
1010
+ * 필요 물리 자산을 확보한다 — 인원과 **같은 규칙**(등급으로 요구, 부분 투입 없음, 확정은 나중).
1011
+ * 자산은 사람과 달리 교대가 없고 **자리**가 있다(빈 팔레트가 어디 있는지가 다음 문제이지만,
1012
+ * 지금은 자리를 따지지 않는다 — 따지려면 자산 이송 작업이 먼저 있어야 한다).
1013
+ */
1014
+ claimAssets(t) {
1015
+ const need = this.operationSpecs.get(t.kind)?.physicalAssetSpecification;
1016
+ if (!need?.length)
1017
+ return [];
1018
+ const picked = [];
1019
+ for (const req of need) {
1020
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
1021
+ if (!want)
1022
+ continue;
1023
+ const avail = [...this.assets.values()].filter(a => a.status === 'idle' && !picked.includes(a.id) && (req.assetClass === undefined || a.assetClass === req.assetClass));
1024
+ if (avail.length < want)
1025
+ return null;
1026
+ for (let i = 0; i < want; i++)
1027
+ picked.push(avail[i].id);
1028
+ }
1029
+ return picked;
1030
+ }
1031
+ /** 확보한 자산을 작업에 묶는다 — 싣는 물류단위(SSCC)가 있으면 연결한다(GRAI ↔ SSCC). */
1032
+ assignAssets(t, gear) {
1033
+ if (!gear.length)
1034
+ return;
1035
+ t.assets = gear;
1036
+ for (const id of gear) {
1037
+ const a = this.assets.get(id);
1038
+ if (!a)
1039
+ continue;
1040
+ a.status = 'in-use';
1041
+ a.taskId = t.id;
1042
+ if (t.itemEpc) {
1043
+ a.carrying = t.itemEpc;
1044
+ /* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
1045
+ * 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
1046
+ const it = this.items.get(t.itemEpc);
1047
+ if (it)
1048
+ it.carriedBy = a.id;
1049
+ }
1050
+ this.emitAsset(a);
1051
+ }
1052
+ }
1053
+ /**
1054
+ * 작업이 끝나면 자산을 놓아 준다 — **사람과 다른 점: 자산은 도착 자리에 남는다**(물건이므로).
1055
+ * 싣고 있던 것은 놓는다(빈 팔레트로 돌아간다 — 회수·재사용의 출발점).
1056
+ */
1057
+ releaseAssets(t) {
1058
+ for (const id of t.assets ?? []) {
1059
+ const a = this.assets.get(id);
1060
+ if (!a)
1061
+ continue;
1062
+ a.status = 'idle';
1063
+ a.taskId = null;
1064
+ a.location = t.toNode || a.location;
1065
+ if (a.carrying) {
1066
+ const it = this.items.get(a.carrying);
1067
+ if (it)
1068
+ it.carriedBy = undefined;
1069
+ }
1070
+ a.carrying = undefined;
1071
+ this.emitAsset(a);
1072
+ }
1073
+ }
1074
+ /**
1075
+ * 필요 인원을 확보한다 — **등급으로 요구하고 등급으로 고른다**(특정인 지목이 아니다).
1076
+ * 요구가 없으면 빈 배열, 모자라면 `null`(작업은 기다린다 — **부분 투입으로 시작하지 않는다**).
1077
+ * 여기서는 고르기만 하고 잡지 않는다: 설비까지 확보된 뒤 `assignCrew` 가 확정한다
1078
+ * (반쯤 잡고 실패하면 사람이 아무 일도 못 하면서 묶인다).
1079
+ */
1080
+ claimPersonnel(t) {
1081
+ const need = this.operationSpecs.get(t.kind)?.personnelSpecification;
1082
+ if (!need?.length)
1083
+ return [];
1084
+ const picked = [];
1085
+ for (const req of need) {
1086
+ const want = Math.max(0, Math.floor(req.quantity ?? 0));
1087
+ if (!want)
1088
+ continue;
1089
+ const avail = [...this.persons.values()].filter(p => p.status === 'idle' &&
1090
+ !picked.includes(p.id) &&
1091
+ !this.personOffShift(p) &&
1092
+ (req.personnelClass === undefined || p.personnelClass === req.personnelClass));
1093
+ if (avail.length < want)
1094
+ return null; // 한 등급이라도 모자라면 시작하지 않는다
1095
+ for (let i = 0; i < want; i++)
1096
+ picked.push(avail[i].id);
1097
+ }
1098
+ return picked;
1099
+ }
1100
+ /** 확보한 사람을 작업에 묶는다(설비까지 확정된 뒤). */
1101
+ assignCrew(t, crew) {
1102
+ if (!crew.length)
1103
+ return;
1104
+ t.personnel = crew;
1105
+ for (const id of crew) {
1106
+ const p = this.persons.get(id);
1107
+ if (!p)
1108
+ continue;
1109
+ p.status = 'busy';
1110
+ p.taskId = t.id;
1111
+ this.emitPerson(p);
1112
+ }
1113
+ }
1114
+ /** 작업이 끝나면 사람을 놓아 준다 — 설비 해제와 별개 경로. */
1115
+ releaseCrew(t) {
1116
+ for (const id of t.personnel ?? []) {
1117
+ const p = this.persons.get(id);
1118
+ if (!p)
1119
+ continue;
1120
+ p.status = 'idle';
1121
+ p.taskId = null;
1122
+ this.emitPerson(p);
1123
+ }
1124
+ }
1125
+ /** 사람의 교대 판정 — 자원(offShift)과 같은 규칙. */
1126
+ personOffShift(p) {
1127
+ const w = p.window;
1128
+ if (!w)
1129
+ return false;
1130
+ const h = this.hourOfDay();
1131
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
1132
+ }
1133
+ /**
1134
+ * 이 자리가 동시 처리 한도에 찼는가 — `parallelism` 을 선언한 자리만 판정한다(미선언=제약 없음).
1135
+ * 세는 대상은 **그 자리에서 진행 중인 작업**(`toNode` 기준, in-progress). 대기 중인 작업은 세지 않는다.
1136
+ */
1137
+ stationFull(nodeId) {
1138
+ if (!nodeId)
1139
+ return false;
1140
+ const limit = this.nodes.get(nodeId)?.parallelism;
1141
+ if (!(typeof limit === 'number' && limit > 0))
1142
+ return false;
1143
+ let running = 0;
1144
+ for (const t of this.tasks.values())
1145
+ if (t.status === 'in-progress' && t.toNode === nodeId)
1146
+ running++;
1147
+ return running >= limit;
1148
+ }
1149
+ /** 교대 밖인가 — 자원이 지금 일하지 않는 이유 중 고장·계획정지와 구별되는 세 번째. */
1150
+ offShift(m) {
1151
+ const w = m.window;
1152
+ if (!w)
1153
+ return false;
1154
+ const h = this.hourOfDay();
1155
+ return !(w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour);
1156
+ }
1157
+ /** 시뮬 시각의 시(0..23) — 운영시간·시간대 배율의 기준. */
1158
+ hourOfDay() {
1159
+ return new Date(BASE_EPOCH + this.clockMs).getUTCHours();
1160
+ }
1161
+ /**
1162
+ * 운영시간 안인가 — `window {startHour, endHour}`. **선언만 되고 소비처가 없던 필드**를 판정한다.
1163
+ * `startHour <= endHour` 면 같은 날 구간, 넘어가면 자정을 가로지르는 구간(야간 교대: 22→6).
1164
+ * 미지정이면 언제나 참(24시간 가동).
1165
+ */
1166
+ inWindow(spec) {
1167
+ const w = spec.window;
1168
+ if (!w)
1169
+ return true;
1170
+ const h = this.hourOfDay();
1171
+ return w.startHour <= w.endHour ? h >= w.startHour && h < w.endHour : h >= w.startHour || h < w.endHour;
544
1172
  }
545
1173
  /** 지수분포 표본(고장/수리 간격) — mean 을 평균으로 하는 무기억 프로세스. */
546
1174
  sampleExp(meanMs) { return -Math.log(1 - this.rng()) * meanMs; }
@@ -578,10 +1206,51 @@ export class FlowEngine {
578
1206
  }
579
1207
  }
580
1208
  }
1209
+ /**
1210
+ * 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
1211
+ *
1212
+ * 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
1213
+ * 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
1214
+ * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
1215
+ */
1216
+ itemState(i) {
1217
+ const parsedClass = i.gtin ? parseEpc(i.gtin) : undefined;
1218
+ const parsedSelf = parseEpc(i.epc);
1219
+ const lot = parsedClass?.lot ??
1220
+ parsedSelf.lot ??
1221
+ (typeof i.ilmd?.[ILMD_ATTR.lot] === 'string' ? i.ilmd[ILMD_ATTR.lot] : undefined);
1222
+ const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
1223
+ return {
1224
+ epc: i.epc,
1225
+ ...(i.gtin ? { gtin: i.gtin } : {}),
1226
+ ...(gtinKey ? { gtinKey } : {}),
1227
+ ...(lot ? { lot } : {}),
1228
+ location: i.location,
1229
+ ...(i.disposition ? { disposition: i.disposition } : {}),
1230
+ ...(i.parent ? { parent: i.parent } : {}),
1231
+ ...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
1232
+ ...(i.qty !== undefined ? { qty: i.qty } : {}),
1233
+ ...(i.uom ? { uom: i.uom } : {}),
1234
+ ...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
1235
+ ...(i.ilmd ? { ilmd: i.ilmd } : {})
1236
+ };
1237
+ }
581
1238
  progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
582
1239
  generate() {
583
1240
  for (const g of this.gens) {
584
1241
  while (this.clockMs >= g.nextMs) {
1242
+ const next = this.nextFireMs(g.spec, g.nextMs);
1243
+ /* 배율 0(도착 없는 시간대) — 발화하지 않고 다음 정시에 다시 판정한다. */
1244
+ if (this.intervalMs(g.spec) === Number.POSITIVE_INFINITY) {
1245
+ g.nextMs = next;
1246
+ continue;
1247
+ }
1248
+ /* 운영시간 밖이면 **자극을 만들지 않고 다음 슬롯으로 넘긴다** — 문을 닫은 시간에 트럭이 오지
1249
+ * 않는다. 예전에는 window 를 아무도 보지 않아 24시간 가동으로만 굴렀다. */
1250
+ if (!this.inWindow(g.spec)) {
1251
+ g.nextMs = next;
1252
+ continue;
1253
+ }
585
1254
  // 자극 클래스(공급/수요)로 hook 분기 — 도메인 kind 라벨이 아니라 stimulus 로 라우팅(무방언).
586
1255
  // stimulus 미지정 시 레거시 kind 로 추론(하위호환). 도메인은 kind 를 자유 명명하고 stimulus 로 분류.
587
1256
  const stimulus = g.spec.stimulus ?? (g.spec.kind === 'outbound-order' ? 'order' : 'arrival');
@@ -589,7 +1258,7 @@ export class FlowEngine {
589
1258
  this.onOrder(g.spec);
590
1259
  else
591
1260
  this.onArrival(g.spec);
592
- g.nextMs += this.intervalMs(g.spec);
1261
+ g.nextMs = next;
593
1262
  }
594
1263
  }
595
1264
  }
@@ -598,19 +1267,46 @@ export class FlowEngine {
598
1267
  if (o.status === 'created' && !o.held)
599
1268
  this.allocate(o);
600
1269
  }
1270
+ /**
1271
+ * 작업 진행 — **진행을 먼저, 배정을 나중에.**
1272
+ *
1273
+ * 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
1274
+ * 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
1275
+ * tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
1276
+ */
601
1277
  processTasks(dt) {
602
- // 1) created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
1278
+ this.advanceTasks(dt);
1279
+ this.assignTasks();
1280
+ }
1281
+ assignTasks() {
1282
+ // created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
603
1283
  for (const t of this.tasks.values()) {
604
1284
  if (t.status !== 'created')
605
1285
  continue;
606
- if (t.intent === 'dwell') { // 무자원 체류 자원 배정 없이 즉시 진행
1286
+ /* 필요 인원 오퍼레이션 명세가 요구하면 등급별로 사람을 잡는다. 모자라면 **기다린다**
1287
+ * (설비가 놀아도 사람이 없으면 일이 안 된다 — 그 줄이 현장의 실제 병목인 경우가 많다). */
1288
+ const crew = this.claimPersonnel(t);
1289
+ if (crew === null)
1290
+ continue; // 인원 부족 → 다음 작업(다른 등급은 가능할 수 있다)
1291
+ /* 필요 물리 자산 — 빈 팔레트가 없으면 출고가 못 나간다. 인원과 같은 규칙(부분 투입 없음). */
1292
+ const gear = this.claimAssets(t);
1293
+ if (gear === null)
1294
+ continue;
1295
+ if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
607
1296
  t.status = 'in-progress';
608
1297
  t.remainingMs = t.durationMs;
1298
+ t.startedAtSimMs = this.clockMs;
1299
+ this.assignCrew(t, crew);
1300
+ this.assignAssets(t, gear);
609
1301
  this.emitTask(t);
610
1302
  continue;
611
1303
  }
612
- // resourceType 있으면 kind 무버만; 없으면 아무 유휴 무버.
613
- const mover = [...this.movers.values()].find(m => m.status === 'idle' && !m.held && (t.resourceType === undefined || m.kind === t.resourceType));
1304
+ /* 자리의 동시 처리 수를 넘지 않는다 자원이 남아도 스테이션이 한 대씩만 깎으면 줄을 선다.
1305
+ * 제약이 없으면 대기가 생기지 않아 병목이 사라지고 예측이 낙관 쪽으로 치우친다. */
1306
+ if (this.stationFull(t.toNode))
1307
+ continue;
1308
+ // resourceType 있으면 그 kind 무버만; 없으면 아무 유휴 무버. 교대 밖 자원은 배정하지 않는다.
1309
+ const mover = [...this.movers.values()].find(m => m.status === 'idle' && !m.held && !this.offShift(m) && (t.resourceType === undefined || m.kind === t.resourceType));
614
1310
  if (!mover)
615
1311
  continue; // 맞는 유휴 자원 없음 → 다음 task(break 아님: 다른 타입은 가용할 수 있음)
616
1312
  // 체인지오버: task 의 changeoverKey 가 무버 직전 키와 다르면 셋업 부착(첫 작업은 셋업 없음).
@@ -625,6 +1321,9 @@ export class FlowEngine {
625
1321
  t.status = 'in-progress';
626
1322
  t.resource = mover.id;
627
1323
  t.remainingMs = t.durationMs;
1324
+ t.startedAtSimMs = this.clockMs;
1325
+ this.assignCrew(t, crew);
1326
+ this.assignAssets(t, gear);
628
1327
  this.emitTask(t);
629
1328
  // process: 제자리 변환(모션 없음). transport: 이동 모션 방출.
630
1329
  if (t.intent === 'process')
@@ -632,7 +1331,9 @@ export class FlowEngine {
632
1331
  else
633
1332
  this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
634
1333
  }
635
- // 2) in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만.
1334
+ }
1335
+ /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
1336
+ advanceTasks(dt) {
636
1337
  for (const t of this.tasks.values()) {
637
1338
  if (t.status !== 'in-progress')
638
1339
  continue;
@@ -643,6 +1344,8 @@ export class FlowEngine {
643
1344
  continue;
644
1345
  this.onTaskComplete(t);
645
1346
  t.status = 'completed';
1347
+ this.releaseCrew(t); // 사람은 설비와 별개로 해제한다(dwell 도 사람은 잡고 있었을 수 있다)
1348
+ this.releaseAssets(t); // 자산도 같다 — 자산은 도착 자리에 남는다(사람과 달리 물건이므로)
646
1349
  if (!t.resource) {
647
1350
  this.emitTask(t);
648
1351
  continue;