@operato/twin-kernel 0.7.10 → 0.7.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -336,14 +336,6 @@ export interface ResourceClassDef extends EffectivePeriod {
336
336
  /** 이 등급의 적격을 정하는 시험 명세들 — 표준 `TestSpecificationID`. */
337
337
  testSpecificationIds?: TestSpecificationRefs;
338
338
  }
339
- /**
340
- * 등급 소속을 **상속을 타고 닫는다** — "이 개체가 이 등급으로 통하는가".
341
- *
342
- * 순환은 방문 집합으로 끊는다(잘못된 마스터가 무한 루프를 만들지 않게). 등급 정의가 없으면 소속
343
- * 그대로만 본다 — 정의를 요구하지 않는다(정의를 싣지 않은 트윈이 그대로 동작해야 한다).
344
- *
345
- * `at` 를 주면 **유효 기간 밖의 등급은 제외**한다. 안 주면 기간을 보지 않는다(모르면 판단하지 않는다).
346
- */
347
339
  export declare function classClosure(directIds: readonly string[] | undefined, defs: readonly ResourceClassDef[] | undefined, at?: ISOTime): Set<string>;
348
340
  /**
349
341
  * 우선순위 — **ISA-95 `Priority`**(`JobOrderType`·`OperationsRequestType`, 타입은 `PriorityType` =
@@ -1133,6 +1125,20 @@ export interface OrderState {
1133
1125
  * 이것도 상태에 없어서 되살아난 오더는 빈 문자열을 갖고, 이후 모든 이벤트의 거래번호가 빈다.
1134
1126
  */
1135
1127
  bizTransaction?: string;
1128
+ /**
1129
+ * 도메인이 **약속해 둔 자리와 시각창** — 어디로/언제 들이기로 했는가.
1130
+ *
1131
+ * ── 왜 사실에 실어야 하나 (2026-08-17) ─────────────────────────────────────
1132
+ * 야드 트윈이 재기동 뒤 **첫 틱에서 죽었다.** 어포인트먼트를 받을 때 도크 도어를 정해 두는데
1133
+ * 그것이 어느 사실에도 실리지 않아, 웜스타트가 오더는 되살리고 배정은 잃었다. 그리고 다음 틱에
1134
+ * 「그 도어의 점유」를 읽다 예외가 났다(`Cannot read properties of undefined`). 안전망이 그 트윈만
1135
+ * 세워 다른 트윈은 살았지만, 야드 트윈 셋이 부팅마다 죽는 상태였다.
1136
+ *
1137
+ * `allocated` 가 두 자리에만 있어 계보를 잃었던 것과 **같은 부류**다: 상태가 필요로 하는 것을
1138
+ * 사실이 갖고 있지 않으면 재기동을 넘지 못한다.
1139
+ */
1140
+ dockDoor?: string;
1141
+ windowStartMs?: number;
1136
1142
  /** 우선순위 — 표준 `OperationsRequest.Priority`. 작은 값이 급하다. 오더 할당 순서를 정한다. */
1137
1143
  priority?: number;
1138
1144
  /** 예정 착수 — 표준 `OperationsRequest.StartTime`. */
@@ -1678,6 +1684,20 @@ export interface OrderStatusDelta {
1678
1684
  */
1679
1685
  allocated?: string[];
1680
1686
  bizTransaction?: string;
1687
+ /**
1688
+ * 도메인이 **약속해 둔 자리와 시각창** — 어디로/언제 들이기로 했는가.
1689
+ *
1690
+ * ── 왜 사실에 실어야 하나 (2026-08-17) ─────────────────────────────────────
1691
+ * 야드 트윈이 재기동 뒤 **첫 틱에서 죽었다.** 어포인트먼트를 받을 때 도크 도어를 정해 두는데
1692
+ * 그것이 어느 사실에도 실리지 않아, 웜스타트가 오더는 되살리고 배정은 잃었다. 그리고 다음 틱에
1693
+ * 「그 도어의 점유」를 읽다 예외가 났다(`Cannot read properties of undefined`). 안전망이 그 트윈만
1694
+ * 세워 다른 트윈은 살았지만, 야드 트윈 셋이 부팅마다 죽는 상태였다.
1695
+ *
1696
+ * `allocated` 가 두 자리에만 있어 계보를 잃었던 것과 **같은 부류**다: 상태가 필요로 하는 것을
1697
+ * 사실이 갖고 있지 않으면 재기동을 넘지 못한다.
1698
+ */
1699
+ dockDoor?: string;
1700
+ windowStartMs?: number;
1681
1701
  }
1682
1702
  export declare const CMD: {
1683
1703
  readonly orderHold: "order.hold";
package/dist/contract.js CHANGED
@@ -184,17 +184,42 @@ export function meetsTests(required, results, at) {
184
184
  export function effectivityAt(p, at) {
185
185
  if (!p || !at)
186
186
  return undefined;
187
- const atMs = Date.parse(at);
187
+ const atMs = parsedMs(at);
188
188
  if (!Number.isFinite(atMs))
189
189
  return undefined;
190
- const from = p.effectiveStart ? Date.parse(p.effectiveStart) : NaN;
190
+ const from = p.effectiveStart ? parsedMs(p.effectiveStart) : NaN;
191
191
  if (Number.isFinite(from) && atMs < from)
192
192
  return 'not-yet';
193
- const to = p.effectiveEnd ? Date.parse(p.effectiveEnd) : NaN;
193
+ const to = p.effectiveEnd ? parsedMs(p.effectiveEnd) : NaN;
194
194
  if (Number.isFinite(to) && atMs > to)
195
195
  return 'expired';
196
196
  return undefined;
197
197
  }
198
+ /*
199
+ * ── 같은 시각 문자열을 몇천 번 다시 파싱하지 않는다 (2026-08-17 프로파일) ───
200
+ *
201
+ * 커널 틱의 비용을 재 보니 `Date.parse` 를 부르는 이 판정들이 **틱 시간의 5분의 1** 이었다. 한 틱
202
+ * 안에서 오가는 시각 문자열은 사실 몇 개뿐이다: 그 틱의 "지금" 하나와, 마스터에 적힌 유효기간들
203
+ * (부팅 뒤 바뀌지 않는다). 같은 문자열이 계속 다시 파싱되고 있었다.
204
+ *
205
+ * 값이 아니라 **문자열 자체**를 열쇠로 기억한다 — 같은 문자열은 언제 물어도 같은 밀리초다(ISO 시각은
206
+ * 절대 시각이다). 그래서 이 기억은 결과를 바꾸지 않는다: 캐시가 없을 때와 정확히 같은 수를 낸다.
207
+ *
208
+ * 크기를 묶는다. 시각 문자열은 트윈이 굴러가는 동안 계속 새로 생기므로(틱마다 새 "지금"), 묶지 않으면
209
+ * 이 표가 곧 누수다. 실제로 필요한 것은 최근 몇 개뿐이다.
210
+ */
211
+ const PARSED_MAX = 64;
212
+ const parsedCache = new Map();
213
+ function parsedMs(at) {
214
+ const hit = parsedCache.get(at);
215
+ if (hit !== undefined)
216
+ return hit;
217
+ const ms = Date.parse(at);
218
+ if (parsedCache.size >= PARSED_MAX)
219
+ parsedCache.clear();
220
+ parsedCache.set(at, ms);
221
+ return ms;
222
+ }
198
223
  /**
199
224
  * 등급 소속을 **상속을 타고 닫는다** — "이 개체가 이 등급으로 통하는가".
200
225
  *
@@ -203,8 +228,30 @@ export function effectivityAt(p, at) {
203
228
  *
204
229
  * `at` 를 주면 **유효 기간 밖의 등급은 제외**한다. 안 주면 기간을 보지 않는다(모르면 판단하지 않는다).
205
230
  */
231
+ /*
232
+ * ── 등급 정의 색인은 한 번만 만든다 (2026-08-17 프로파일) ───────────────────
233
+ *
234
+ * `classClosure` 는 부를 때마다 정의 목록으로 `Map` 을 새로 지었다. 그런데 등급 정의는 모델을 실을 때
235
+ * 정해지고 그 뒤 바뀌지 않는 목록이다 — 능력 판정마다 같은 표를 다시 짓느라 **틱 시간의 7분의 1** 을
236
+ * 썼다(프로파일 `classClosure` 14%).
237
+ *
238
+ * 배열 **자체**를 열쇠로 기억한다(`WeakMap`). 목록이 바뀌면 그것은 다른 배열이므로 새 표가 만들어지고,
239
+ * 목록이 사라지면 표도 함께 사라진다 — 무효화를 따로 관리할 것이 없다.
240
+ */
241
+ const classIndexCache = new WeakMap();
242
+ function classIndex(defs) {
243
+ if (!defs)
244
+ return EMPTY_CLASS_INDEX;
245
+ const hit = classIndexCache.get(defs);
246
+ if (hit)
247
+ return hit;
248
+ const built = new Map(defs.map(d => [d.id, d]));
249
+ classIndexCache.set(defs, built);
250
+ return built;
251
+ }
252
+ const EMPTY_CLASS_INDEX = new Map();
206
253
  export function classClosure(directIds, defs, at) {
207
- const byId = new Map((defs ?? []).map(d => [d.id, d]));
254
+ const byId = classIndex(defs);
208
255
  const inWindow = (d) => !d || effectivityAt(d, at) === undefined;
209
256
  const out = new Set();
210
257
  const stack = [...(directIds ?? [])];
@@ -499,8 +546,17 @@ export function activeShiftAt(entries, atMs, utcOffsetMinutes) {
499
546
  * 06시 교대가 **7시간 틀렸다.** 선언이 없으면 UTC 이고, 그 기본값을 숨기지 않고 밝힌다.
500
547
  */
501
548
  export function minuteOfDayAt(ms, utcOffsetMinutes) {
502
- const d = new Date(ms + (utcOffsetMinutes ?? 0) * 60_000);
503
- return d.getUTCHours() * 60 + d.getUTCMinutes();
549
+ /*
550
+ * `new Date(...)` 만들지 않는다 (2026-08-17 프로파일).
551
+ *
552
+ * 교대 판정이 이것을 자원마다 부르는데, 그때마다 Date 객체를 하나 만들고 버렸다 — 틱 시간의 12% 였다.
553
+ * UTC 기준 「하루 안의 분」은 나눗셈으로 나온다(getUTCHours 도 같은 계산을 한다). 음수 시각(1970 이전)
554
+ * 에서도 같은 답이 되도록 나머지를 한 번 더 올린다.
555
+ */
556
+ const shifted = ms + (utcOffsetMinutes ?? 0) * 60_000;
557
+ const DAY = 86_400_000;
558
+ const inDay = ((shifted % DAY) + DAY) % DAY;
559
+ return Math.floor(inDay / 60_000);
504
560
  }
505
561
  export function offCalendarReasonAt(r, ms, utcOffsetMinutes) {
506
562
  if (!offCalendarAt(r, ms, utcOffsetMinutes))
@@ -539,13 +595,41 @@ export function offCalendarAt(r, ms, utcOffsetMinutes) {
539
595
  export function requiredTestsFor(directIds, defs, at) {
540
596
  if (!defs?.length)
541
597
  return [];
598
+ /*
599
+ * ── 같은 답을 틱마다 다시 닫지 않는다 (2026-08-17 프로파일) ──────────────
600
+ *
601
+ * 이 함수의 답은 **셋에만** 달려 있다: 소속 등급, 등급 정의, 판정 시각. 자원의 상태(고장·교대·시험
602
+ * 결과)는 여기 들어오지 않는다 — 그것은 `capabilityOf` 가 따로 본다. 그런데 능력 판정이 자원마다·
603
+ * 작업마다 이것을 불러, 같은 셋으로 같은 답을 한 틱에 수백 번 다시 만들었다(닫기 + 정의 순회 =
604
+ * 프로파일 21%).
605
+ *
606
+ * 그래서 그 셋을 열쇠로 기억한다. 정의 목록은 배열 **자체**로 구분하므로(`classIndex` 와 같은 규율)
607
+ * 모델이 바뀌면 다른 열쇠가 된다. 시각이 흐르면 열쇠도 바뀌므로 유효기간 판정이 낡지 않는다.
608
+ *
609
+ * 돌려주는 배열은 **읽기 전용으로 다뤄야 한다** — 기억된 배열을 부르는 쪽이 고치면 다음 호출자가
610
+ * 고쳐진 것을 받는다. 지금 모든 소비처는 읽기만 한다(그래서 복사하지 않는다: 복사하면 이 기억의
611
+ * 값이 절반은 사라진다).
612
+ */
613
+ const cache = requiredTestsCache.get(defs) ?? new Map();
614
+ if (!requiredTestsCache.has(defs))
615
+ requiredTestsCache.set(defs, cache);
616
+ const key = `${at ?? ''}\u0000${(directIds ?? []).join('\u0001')}`;
617
+ const hit = cache.get(key);
618
+ if (hit)
619
+ return hit;
542
620
  const closure = classClosure(directIds, defs, at);
543
621
  const required = [];
544
622
  for (const d of defs)
545
623
  if (closure.has(d.id))
546
624
  required.push(...(d.testSpecificationIds ?? []));
625
+ /* 시각이 열쇠에 들어가므로 이 표는 트윈이 굴러가는 동안 계속 자란다 — 크기를 묶는다. */
626
+ if (cache.size >= REQUIRED_TESTS_MAX)
627
+ cache.clear();
628
+ cache.set(key, required);
547
629
  return required;
548
630
  }
631
+ const REQUIRED_TESTS_MAX = 512;
632
+ const requiredTestsCache = new WeakMap();
549
633
  /**
550
634
  * 자원의 **가용 능력을 판정한다** — 하나의 규칙, 하나의 자리.
551
635
  *
@@ -475,7 +475,21 @@ export declare abstract class FlowEngine implements TwinKernel {
475
475
  * 이미 시계가 흐른 뒤에 옮기면 그전에 낸 사실들과 시간축이 어긋나므로 **기동 직후에만** 부른다.
476
476
  */
477
477
  setClockOrigin(originMs: number): void;
478
+ /**
479
+ * 이 커널의 **지금**(ISO 문자열).
480
+ *
481
+ * ── 같은 시각을 몇천 번 다시 문자열로 만들지 않는다 (2026-08-17 프로파일) ──
482
+ * 능력 판정이 이 값을 자원마다·작업마다 부른다. 그래서 한 틱 안에서 이 함수가 수백 번 불리는데,
483
+ * 그때마다 `new Date(...).toISOString()` 이 새 문자열을 지었다 — 프로파일에서 **틱 시간의 40%** 가
484
+ * 여기였다.
485
+ *
486
+ * 밀리초가 같으면 문자열도 같다. 그래서 밀리초를 열쇠로 기억한다 — 근사가 아니라 **정확히 같은 값**을
487
+ * 내므로 판정은 하나도 달라지지 않는다. 덤으로 같은 문자열 인스턴스가 돌아오므로 그것을 다시 파싱하는
488
+ * 쪽(`effectivityAt`)의 캐시도 한 번에 맞는다.
489
+ */
478
490
  protected now(): string;
491
+ private nowIsoMs;
492
+ private nowIso;
479
493
  /**
480
494
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
481
495
  *
@@ -589,7 +589,9 @@ export class FlowEngine {
589
589
  사라진다(같은 사실이 델타·스냅샷·이 변환 **세 길**을 지난다). */
590
590
  ...(o.allocated?.length ? { allocated: o.allocated } : {}),
591
591
  ...(o.gtin ? { gtin: o.gtin } : {}),
592
- ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {})
592
+ ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
593
+ ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
594
+ ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
593
595
  }));
594
596
  for (const o of observedOrders) {
595
597
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
@@ -614,7 +616,11 @@ export class FlowEngine {
614
616
  ...(o.held ? { held: true } : {}),
615
617
  ...(o.endTime ? { endTime: o.endTime } : {}),
616
618
  ...(o.startTime ? { startTime: o.startTime } : {}),
617
- ...(o.priority !== undefined ? { priority: o.priority } : {})
619
+ ...(o.priority !== undefined ? { priority: o.priority } : {}),
620
+ /* 약속해 둔 자리·시각창을 이어받는다 — 없으면 도메인이 「정해 뒀다」고 믿는 것을 잃는다.
621
+ 사실이 말해 주지 않으면(옛 저널) 비는 것이 사실이다 — 지어내지 않는다. */
622
+ ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
623
+ ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
618
624
  });
619
625
  }
620
626
  /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
@@ -955,6 +961,9 @@ export class FlowEngine {
955
961
  /* 진행 중 확보분과 거래번호 — **스냅샷에도** 실어야 한다. 델타에만 실으면 스냅샷으로
956
962
  재기동하는 경로(체크포인트)에서 그대로 잃는다(같은 사실을 두 길로 나르는 값이다). */
957
963
  ...(o.allocated?.length ? { allocated: o.allocated.slice() } : {}),
964
+ /* 스냅샷도 함께 나른다 — 델타·스냅샷·복원 **세 길** 중 하나만 빠져도 재기동이 잃는다. */
965
+ ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
966
+ ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {}),
958
967
  ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
959
968
  held: o.held
960
969
  })),
@@ -1062,7 +1071,28 @@ export class FlowEngine {
1062
1071
  throw new Error('clock origin must be a finite epoch millisecond value');
1063
1072
  this.originMs = originMs;
1064
1073
  }
1065
- now() { return new Date(this.nowMs()).toISOString(); }
1074
+ /**
1075
+ * 이 커널의 **지금**(ISO 문자열).
1076
+ *
1077
+ * ── 같은 시각을 몇천 번 다시 문자열로 만들지 않는다 (2026-08-17 프로파일) ──
1078
+ * 능력 판정이 이 값을 자원마다·작업마다 부른다. 그래서 한 틱 안에서 이 함수가 수백 번 불리는데,
1079
+ * 그때마다 `new Date(...).toISOString()` 이 새 문자열을 지었다 — 프로파일에서 **틱 시간의 40%** 가
1080
+ * 여기였다.
1081
+ *
1082
+ * 밀리초가 같으면 문자열도 같다. 그래서 밀리초를 열쇠로 기억한다 — 근사가 아니라 **정확히 같은 값**을
1083
+ * 내므로 판정은 하나도 달라지지 않는다. 덤으로 같은 문자열 인스턴스가 돌아오므로 그것을 다시 파싱하는
1084
+ * 쪽(`effectivityAt`)의 캐시도 한 번에 맞는다.
1085
+ */
1086
+ now() {
1087
+ const ms = this.nowMs();
1088
+ if (ms !== this.nowIsoMs) {
1089
+ this.nowIsoMs = ms;
1090
+ this.nowIso = new Date(ms).toISOString();
1091
+ }
1092
+ return this.nowIso;
1093
+ }
1094
+ nowIsoMs = Number.NaN;
1095
+ nowIso = '';
1066
1096
  /**
1067
1097
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
1068
1098
  *
@@ -1639,7 +1669,10 @@ export class FlowEngine {
1639
1669
  계보가 입력 없이 나간다(무엇이 무엇으로 바뀌었나의 절반이 사라진다). */
1640
1670
  ...(o.gtin ? { gtin: o.gtin } : {}),
1641
1671
  ...(o.allocated?.length ? { allocated: o.allocated.slice() } : {}),
1642
- ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {})
1672
+ ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
1673
+ /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
1674
+ ...(o.dockDoor ? { dockDoor: o.dockDoor } : {}),
1675
+ ...(o.windowStartMs !== undefined ? { windowStartMs: o.windowStartMs } : {})
1643
1676
  });
1644
1677
  }
1645
1678
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
@@ -357,6 +357,10 @@ export class ObservedReducer {
357
357
  ...(d.gtin ? { gtin: d.gtin } : {}),
358
358
  ...(d.allocated?.length ? { allocated: d.allocated.slice() } : {}),
359
359
  ...(d.bizTransaction ? { bizTransaction: d.bizTransaction } : {}),
360
+ /* 약속해 둔 자리·시각창도 채운다 — 시뮬만 알면 미러 위 예측이 「어디로 들일지」를 모른다.
361
+ (이 필드가 어느 길에서 빠지면 무슨 일이 나는지는 §dockDoor 에 적혀 있다.) */
362
+ ...(d.dockDoor ? { dockDoor: d.dockDoor } : {}),
363
+ ...(d.windowStartMs !== undefined ? { windowStartMs: d.windowStartMs } : {}),
360
364
  held: d.held
361
365
  });
362
366
  break;
@@ -8,6 +8,8 @@ export declare class YmsKernel extends FlowEngine {
8
8
  private cargoSeq;
9
9
  private trailerCargo;
10
10
  private apptMode;
11
+ /** 도어를 못 찾아 대기시킨 오더 — 같은 말을 매 틱 반복하지 않기 위한 표시(§allocate). */
12
+ private doorWarned;
11
13
  /** 야드 운영 정책(상하차 방식). 기본 drop(야드 버퍼). live 는 게이트/도크 직행. */
12
14
  mode: YardMode;
13
15
  constructor(tenantId: string, policy?: AllocationPolicy, mode?: YardMode);
@@ -39,6 +39,8 @@ export class YmsKernel extends FlowEngine {
39
39
  cargoSeq = 0;
40
40
  trailerCargo = new Map(); // 트레일러 epc → 화물 SSCC(하차 전 opaque / 상차 예약분)
41
41
  apptMode = new Map(); // orderId → 상하차 모드(생성 시 캡처)
42
+ /** 도어를 못 찾아 대기시킨 오더 — 같은 말을 매 틱 반복하지 않기 위한 표시(§allocate). */
43
+ doorWarned = new Set();
42
44
  /** 야드 운영 정책(상하차 방식). 기본 drop(야드 버퍼). live 는 게이트/도크 직행. */
43
45
  mode;
44
46
  constructor(tenantId, policy = firstFitPolicy, mode = 'drop') {
@@ -102,7 +104,25 @@ export class YmsKernel extends FlowEngine {
102
104
  return;
103
105
  if (this.clockMs < (o.windowStartMs ?? 0))
104
106
  return; // 창 도래 전
105
- const door = this.locations.get(o.dockDoor);
107
+ /*
108
+ * ── 배정된 도어가 없으면 **기다린다** (2026-08-17) ────────────────────────
109
+ *
110
+ * 예전에는 `this.locations.get(o.dockDoor!)!` 였다. 그래서 배정을 모르는 오더가 들어오면 바로
111
+ * 아래에서 `undefined.occupancy` 를 읽고 **틱 전체가 예외로 죽었다** — 야드 트윈 셋이 재기동마다
112
+ * 그렇게 멈췄다(안전망이 세운 것이지 스스로 멈춘 것이 아니다).
113
+ *
114
+ * 이제 그 오더만 대기로 남기고 **왜인지 한 번 말한다.** 매 틱 말하면 로그가 그것만 남으므로 한 번만
115
+ * 말하고, 조용히 넘기지도 않는다 — 조용하면 「왜 이 오더는 영원히 안 움직이나」를 아무도 답할 수 없다.
116
+ */
117
+ const door = o.dockDoor ? this.locations.get(o.dockDoor) : undefined;
118
+ if (!door) {
119
+ if (!this.doorWarned.has(o.id)) {
120
+ this.doorWarned.add(o.id);
121
+ console.warn(`[yms-kernel] order "${o.id}" waits: ${o.dockDoor ? `its dock door "${o.dockDoor}" is not in this model` : 'no dock door was assigned to it'}` +
122
+ ' — the appointment cannot be scheduled until the declaration says where.');
123
+ }
124
+ return;
125
+ }
106
126
  const inflight = [...this.tasks.values()].some(t => t.status !== 'completed' && t.toNode === door.id);
107
127
  if (door.occupancy > 0 || inflight)
108
128
  return; // 도어 사용중/예약됨 → 대기(직렬화)
@@ -261,16 +261,36 @@ function meetsTests(required, results, at) {
261
261
  }
262
262
  function effectivityAt(p, at) {
263
263
  if (!p || !at) return void 0;
264
- const atMs = Date.parse(at);
264
+ const atMs = parsedMs(at);
265
265
  if (!Number.isFinite(atMs)) return void 0;
266
- const from = p.effectiveStart ? Date.parse(p.effectiveStart) : NaN;
266
+ const from = p.effectiveStart ? parsedMs(p.effectiveStart) : NaN;
267
267
  if (Number.isFinite(from) && atMs < from) return "not-yet";
268
- const to = p.effectiveEnd ? Date.parse(p.effectiveEnd) : NaN;
268
+ const to = p.effectiveEnd ? parsedMs(p.effectiveEnd) : NaN;
269
269
  if (Number.isFinite(to) && atMs > to) return "expired";
270
270
  return void 0;
271
271
  }
272
+ var PARSED_MAX = 64;
273
+ var parsedCache = /* @__PURE__ */ new Map();
274
+ function parsedMs(at) {
275
+ const hit = parsedCache.get(at);
276
+ if (hit !== void 0) return hit;
277
+ const ms2 = Date.parse(at);
278
+ if (parsedCache.size >= PARSED_MAX) parsedCache.clear();
279
+ parsedCache.set(at, ms2);
280
+ return ms2;
281
+ }
282
+ var classIndexCache = /* @__PURE__ */ new WeakMap();
283
+ function classIndex(defs) {
284
+ if (!defs) return EMPTY_CLASS_INDEX;
285
+ const hit = classIndexCache.get(defs);
286
+ if (hit) return hit;
287
+ const built = new Map(defs.map((d) => [d.id, d]));
288
+ classIndexCache.set(defs, built);
289
+ return built;
290
+ }
291
+ var EMPTY_CLASS_INDEX = /* @__PURE__ */ new Map();
272
292
  function classClosure(directIds, defs, at) {
273
- const byId = new Map((defs ?? []).map((d) => [d.id, d]));
293
+ const byId = classIndex(defs);
274
294
  const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
275
295
  const out = /* @__PURE__ */ new Set();
276
296
  const stack = [...directIds ?? []];
@@ -409,8 +429,10 @@ function activeShiftAt(entries, atMs, utcOffsetMinutes) {
409
429
  return void 0;
410
430
  }
411
431
  function minuteOfDayAt(ms2, utcOffsetMinutes) {
412
- const d = new Date(ms2 + (utcOffsetMinutes ?? 0) * 6e4);
413
- return d.getUTCHours() * 60 + d.getUTCMinutes();
432
+ const shifted = ms2 + (utcOffsetMinutes ?? 0) * 6e4;
433
+ const DAY = 864e5;
434
+ const inDay = (shifted % DAY + DAY) % DAY;
435
+ return Math.floor(inDay / 6e4);
414
436
  }
415
437
  function offCalendarReasonAt(r, ms2, utcOffsetMinutes) {
416
438
  if (!offCalendarAt(r, ms2, utcOffsetMinutes)) return void 0;
@@ -435,11 +457,20 @@ function offCalendarAt(r, ms2, utcOffsetMinutes) {
435
457
  }
436
458
  function requiredTestsFor(directIds, defs, at) {
437
459
  if (!defs?.length) return [];
460
+ const cache = requiredTestsCache.get(defs) ?? /* @__PURE__ */ new Map();
461
+ if (!requiredTestsCache.has(defs)) requiredTestsCache.set(defs, cache);
462
+ const key = `${at ?? ""}\0${(directIds ?? []).join("")}`;
463
+ const hit = cache.get(key);
464
+ if (hit) return hit;
438
465
  const closure = classClosure(directIds, defs, at);
439
466
  const required = [];
440
467
  for (const d of defs) if (closure.has(d.id)) required.push(...d.testSpecificationIds ?? []);
468
+ if (cache.size >= REQUIRED_TESTS_MAX) cache.clear();
469
+ cache.set(key, required);
441
470
  return required;
442
471
  }
472
+ var REQUIRED_TESTS_MAX = 512;
473
+ var requiredTestsCache = /* @__PURE__ */ new WeakMap();
443
474
  function capabilityOf(r, ctx) {
444
475
  const at = ctx?.at;
445
476
  const eff = effectivityAt(r, at);
@@ -1323,6 +1354,10 @@ var ObservedReducer = class {
1323
1354
  ...d.gtin ? { gtin: d.gtin } : {},
1324
1355
  ...d.allocated?.length ? { allocated: d.allocated.slice() } : {},
1325
1356
  ...d.bizTransaction ? { bizTransaction: d.bizTransaction } : {},
1357
+ /* 약속해 둔 자리·시각창도 채운다 — 시뮬만 알면 미러 위 예측이 「어디로 들일지」를 모른다.
1358
+ (이 필드가 어느 길에서 빠지면 무슨 일이 나는지는 §dockDoor 에 적혀 있다.) */
1359
+ ...d.dockDoor ? { dockDoor: d.dockDoor } : {},
1360
+ ...d.windowStartMs !== void 0 ? { windowStartMs: d.windowStartMs } : {},
1326
1361
  held: d.held
1327
1362
  });
1328
1363
  break;
@@ -3188,7 +3223,9 @@ var FlowEngine = class {
3188
3223
  사라진다(같은 사실이 델타·스냅샷·이 변환 **세 길**을 지난다). */
3189
3224
  ...o.allocated?.length ? { allocated: o.allocated } : {},
3190
3225
  ...o.gtin ? { gtin: o.gtin } : {},
3191
- ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}
3226
+ ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
3227
+ ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
3228
+ ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
3192
3229
  }));
3193
3230
  for (const o of observedOrders) {
3194
3231
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
@@ -3215,7 +3252,11 @@ var FlowEngine = class {
3215
3252
  ...o.held ? { held: true } : {},
3216
3253
  ...o.endTime ? { endTime: o.endTime } : {},
3217
3254
  ...o.startTime ? { startTime: o.startTime } : {},
3218
- ...o.priority !== void 0 ? { priority: o.priority } : {}
3255
+ ...o.priority !== void 0 ? { priority: o.priority } : {},
3256
+ /* 약속해 둔 자리·시각창을 이어받는다 — 없으면 도메인이 「정해 뒀다」고 믿는 것을 잃는다.
3257
+ 사실이 말해 주지 않으면(옛 저널) 비는 것이 사실이다 — 지어내지 않는다. */
3258
+ ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
3259
+ ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
3219
3260
  });
3220
3261
  }
3221
3262
  const seededOrderIds = new Set(this.orders.keys());
@@ -3531,6 +3572,9 @@ var FlowEngine = class {
3531
3572
  /* 진행 중 확보분과 거래번호 — **스냅샷에도** 실어야 한다. 델타에만 실으면 스냅샷으로
3532
3573
  재기동하는 경로(체크포인트)에서 그대로 잃는다(같은 사실을 두 길로 나르는 값이다). */
3533
3574
  ...o.allocated?.length ? { allocated: o.allocated.slice() } : {},
3575
+ /* 스냅샷도 함께 나른다 — 델타·스냅샷·복원 **세 길** 중 하나만 빠져도 재기동이 잃는다. */
3576
+ ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
3577
+ ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {},
3534
3578
  ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
3535
3579
  held: o.held
3536
3580
  })),
@@ -3621,9 +3665,28 @@ var FlowEngine = class {
3621
3665
  if (!Number.isFinite(originMs)) throw new Error("clock origin must be a finite epoch millisecond value");
3622
3666
  this.originMs = originMs;
3623
3667
  }
3668
+ /**
3669
+ * 이 커널의 **지금**(ISO 문자열).
3670
+ *
3671
+ * ── 같은 시각을 몇천 번 다시 문자열로 만들지 않는다 (2026-08-17 프로파일) ──
3672
+ * 능력 판정이 이 값을 자원마다·작업마다 부른다. 그래서 한 틱 안에서 이 함수가 수백 번 불리는데,
3673
+ * 그때마다 `new Date(...).toISOString()` 이 새 문자열을 지었다 — 프로파일에서 **틱 시간의 40%** 가
3674
+ * 여기였다.
3675
+ *
3676
+ * 밀리초가 같으면 문자열도 같다. 그래서 밀리초를 열쇠로 기억한다 — 근사가 아니라 **정확히 같은 값**을
3677
+ * 내므로 판정은 하나도 달라지지 않는다. 덤으로 같은 문자열 인스턴스가 돌아오므로 그것을 다시 파싱하는
3678
+ * 쪽(`effectivityAt`)의 캐시도 한 번에 맞는다.
3679
+ */
3624
3680
  now() {
3625
- return new Date(this.nowMs()).toISOString();
3681
+ const ms2 = this.nowMs();
3682
+ if (ms2 !== this.nowIsoMs) {
3683
+ this.nowIsoMs = ms2;
3684
+ this.nowIso = new Date(ms2).toISOString();
3685
+ }
3686
+ return this.nowIso;
3626
3687
  }
3688
+ nowIsoMs = Number.NaN;
3689
+ nowIso = "";
3627
3690
  /**
3628
3691
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
3629
3692
  *
@@ -4158,7 +4221,10 @@ var FlowEngine = class {
4158
4221
  계보가 입력 없이 나간다(무엇이 무엇으로 바뀌었나의 절반이 사라진다). */
4159
4222
  ...o.gtin ? { gtin: o.gtin } : {},
4160
4223
  ...o.allocated?.length ? { allocated: o.allocated.slice() } : {},
4161
- ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}
4224
+ ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
4225
+ /* 약속해 둔 자리·시각창 — 이것이 빠져서 야드 트윈이 재기동마다 첫 틱에 죽었다(§dockDoor). */
4226
+ ...o.dockDoor ? { dockDoor: o.dockDoor } : {},
4227
+ ...o.windowStartMs !== void 0 ? { windowStartMs: o.windowStartMs } : {}
4162
4228
  });
4163
4229
  }
4164
4230
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
@@ -5250,6 +5316,8 @@ var YmsKernel = class extends FlowEngine {
5250
5316
  // 트레일러 epc → 화물 SSCC(하차 전 opaque / 상차 예약분)
5251
5317
  apptMode = /* @__PURE__ */ new Map();
5252
5318
  // orderId → 상하차 모드(생성 시 캡처)
5319
+ /** 도어를 못 찾아 대기시킨 오더 — 같은 말을 매 틱 반복하지 않기 위한 표시(§allocate). */
5320
+ doorWarned = /* @__PURE__ */ new Set();
5253
5321
  /** 야드 운영 정책(상하차 방식). 기본 drop(야드 버퍼). live 는 게이트/도크 직행. */
5254
5322
  mode;
5255
5323
  constructor(tenantId, policy = firstFitPolicy, mode = "drop") {
@@ -5321,7 +5389,16 @@ var YmsKernel = class extends FlowEngine {
5321
5389
  const trailer = this.items.get(o.allocated[0]);
5322
5390
  if (!trailer) return;
5323
5391
  if (this.clockMs < (o.windowStartMs ?? 0)) return;
5324
- const door = this.locations.get(o.dockDoor);
5392
+ const door = o.dockDoor ? this.locations.get(o.dockDoor) : void 0;
5393
+ if (!door) {
5394
+ if (!this.doorWarned.has(o.id)) {
5395
+ this.doorWarned.add(o.id);
5396
+ console.warn(
5397
+ `[yms-kernel] order "${o.id}" waits: ${o.dockDoor ? `its dock door "${o.dockDoor}" is not in this model` : "no dock door was assigned to it"} \u2014 the appointment cannot be scheduled until the declaration says where.`
5398
+ );
5399
+ }
5400
+ return;
5401
+ }
5325
5402
  const inflight = [...this.tasks.values()].some((t) => t.status !== "completed" && t.toNode === door.id);
5326
5403
  if (door.occupancy > 0 || inflight) return;
5327
5404
  const mode = this.apptMode.get(o.id) ?? "drop";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.10",
3
+ "version": "0.7.12",
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": {