@operato/twin-kernel 0.7.10 → 0.7.11

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` =
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
  *
@@ -1062,7 +1062,28 @@ export class FlowEngine {
1062
1062
  throw new Error('clock origin must be a finite epoch millisecond value');
1063
1063
  this.originMs = originMs;
1064
1064
  }
1065
- now() { return new Date(this.nowMs()).toISOString(); }
1065
+ /**
1066
+ * 이 커널의 **지금**(ISO 문자열).
1067
+ *
1068
+ * ── 같은 시각을 몇천 번 다시 문자열로 만들지 않는다 (2026-08-17 프로파일) ──
1069
+ * 능력 판정이 이 값을 자원마다·작업마다 부른다. 그래서 한 틱 안에서 이 함수가 수백 번 불리는데,
1070
+ * 그때마다 `new Date(...).toISOString()` 이 새 문자열을 지었다 — 프로파일에서 **틱 시간의 40%** 가
1071
+ * 여기였다.
1072
+ *
1073
+ * 밀리초가 같으면 문자열도 같다. 그래서 밀리초를 열쇠로 기억한다 — 근사가 아니라 **정확히 같은 값**을
1074
+ * 내므로 판정은 하나도 달라지지 않는다. 덤으로 같은 문자열 인스턴스가 돌아오므로 그것을 다시 파싱하는
1075
+ * 쪽(`effectivityAt`)의 캐시도 한 번에 맞는다.
1076
+ */
1077
+ now() {
1078
+ const ms = this.nowMs();
1079
+ if (ms !== this.nowIsoMs) {
1080
+ this.nowIsoMs = ms;
1081
+ this.nowIso = new Date(ms).toISOString();
1082
+ }
1083
+ return this.nowIso;
1084
+ }
1085
+ nowIsoMs = Number.NaN;
1086
+ nowIso = '';
1066
1087
  /**
1067
1088
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
1068
1089
  *
@@ -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);
@@ -3621,9 +3652,28 @@ var FlowEngine = class {
3621
3652
  if (!Number.isFinite(originMs)) throw new Error("clock origin must be a finite epoch millisecond value");
3622
3653
  this.originMs = originMs;
3623
3654
  }
3655
+ /**
3656
+ * 이 커널의 **지금**(ISO 문자열).
3657
+ *
3658
+ * ── 같은 시각을 몇천 번 다시 문자열로 만들지 않는다 (2026-08-17 프로파일) ──
3659
+ * 능력 판정이 이 값을 자원마다·작업마다 부른다. 그래서 한 틱 안에서 이 함수가 수백 번 불리는데,
3660
+ * 그때마다 `new Date(...).toISOString()` 이 새 문자열을 지었다 — 프로파일에서 **틱 시간의 40%** 가
3661
+ * 여기였다.
3662
+ *
3663
+ * 밀리초가 같으면 문자열도 같다. 그래서 밀리초를 열쇠로 기억한다 — 근사가 아니라 **정확히 같은 값**을
3664
+ * 내므로 판정은 하나도 달라지지 않는다. 덤으로 같은 문자열 인스턴스가 돌아오므로 그것을 다시 파싱하는
3665
+ * 쪽(`effectivityAt`)의 캐시도 한 번에 맞는다.
3666
+ */
3624
3667
  now() {
3625
- return new Date(this.nowMs()).toISOString();
3668
+ const ms2 = this.nowMs();
3669
+ if (ms2 !== this.nowIsoMs) {
3670
+ this.nowIsoMs = ms2;
3671
+ this.nowIso = new Date(ms2).toISOString();
3672
+ }
3673
+ return this.nowIso;
3626
3674
  }
3675
+ nowIsoMs = Number.NaN;
3676
+ nowIso = "";
3627
3677
  /**
3628
3678
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
3629
3679
  *
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.11",
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": {