@operato/twin-kernel 0.7.9 → 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
  *
@@ -35,3 +35,22 @@ export interface MonteCarloOptions {
35
35
  * run 마다 fork(현재 보존) + scenario.load(seed+i)(미래 변주) + horizon 까지 구동. 원본 무간섭.
36
36
  */
37
37
  export declare function monteCarloForecast(twin: ForecastTwin, opts: MonteCarloOptions): MonteCarloResult;
38
+ /**
39
+ * 같은 예측을, **회차 사이에 자리를 내주면서** 계산한다.
40
+ *
41
+ * ── 왜 필요한가 (2026-08-17 실측) ───────────────────────────────────────────
42
+ * 이 예측은 호출한 쪽의 이벤트 루프에서 **동기로** 돈다. 실측으로 트윈 하나의 예측 한 번이
43
+ * 30회차 × 300틱 = 9,000틱, 한 틱 3.2ms → **28.5초**였다. 그동안 그 프로세스의 라이브 트윈도,
44
+ * HTTP 도, 구독도 전부 멈춘다 — 예측 한 번에 시스템 전체가 정지한다.
45
+ *
46
+ * 총 시간은 이 함수로 줄지 않는다(그건 틱 수를 줄이는 다른 일이다). 줄어드는 것은 **한 번에
47
+ * 붙잡는 시간**이다. 회차 하나가 끝날 때마다 자리를 내주면 그 사이에 틱과 요청이 지나간다.
48
+ *
49
+ * 양보 방법은 **호출자가 준다.** 커널은 실행 환경을 모른다(`setImmediate` 는 서버 런타임의 것이다).
50
+ * 주지 않으면 마이크로태스크로 떨어지는데, 그것은 I/O 에 자리를 내주지 못하므로 **얼어붙는 증상은
51
+ * 그대로**다 — 그래서 기본값에 기대지 말고 호출자가 명시하는 것이 맞다.
52
+ */
53
+ export declare function monteCarloForecastAsync(twin: ForecastTwin, opts: MonteCarloOptions & {
54
+ yieldFn?: () => Promise<void>;
55
+ yieldEveryTicks?: number;
56
+ }): Promise<MonteCarloResult>;
package/dist/forecast.js CHANGED
@@ -18,14 +18,9 @@ function clockOf(twin) {
18
18
  */
19
19
  export function monteCarloForecast(twin, opts) {
20
20
  const now = clockOf(twin);
21
- const step = opts.tickMs ?? 1000;
22
- const baseSeed = opts.scenario.seed ?? 1;
23
21
  const samples = [];
24
22
  for (let i = 0; i < opts.runs; i++) {
25
- const fc = twin.fork();
26
- fc.scenario.load({ ...opts.scenario, seed: baseSeed + i }); // 미래만 변주(현재 상태는 fork 로 보존)
27
- fc.scenario.start();
28
- const target = now + opts.horizonMs;
23
+ const { fc, step, target } = startRun(twin, opts, now, i);
29
24
  let guard = 0;
30
25
  while (clockOf(fc) < target && guard++ < 1_000_000)
31
26
  fc.tick(step);
@@ -33,6 +28,61 @@ export function monteCarloForecast(twin, opts) {
33
28
  }
34
29
  return summarize(opts.runs, samples);
35
30
  }
31
+ /**
32
+ * 한 회차의 **출발점** — fork(현재 보존) + 그 회차의 미래(seed 변주).
33
+ *
34
+ * 동기·비동기 두 진입점이 이것을 함께 쓴다. 구동 루프는 각자 쓰지만(하나는 중간에 await 한다),
35
+ * **표본이 무엇이 되는지를 정하는 규칙**(어디서 갈라져 어떤 seed 로 도는가)은 여기 한 곳이다.
36
+ * 그것이 갈라지면 같은 seed 가 다른 수를 내고, 그때는 어느 쪽이 옳은지 가릴 방법이 없다.
37
+ */
38
+ function startRun(twin, opts, nowMs, i) {
39
+ const fc = twin.fork();
40
+ fc.scenario.load({ ...opts.scenario, seed: (opts.scenario.seed ?? 1) + i }); // 미래만 변주(현재 상태는 fork 로 보존)
41
+ fc.scenario.start();
42
+ return { fc, step: opts.tickMs ?? 1000, target: nowMs + opts.horizonMs };
43
+ }
44
+ /**
45
+ * 같은 예측을, **회차 사이에 자리를 내주면서** 계산한다.
46
+ *
47
+ * ── 왜 필요한가 (2026-08-17 실측) ───────────────────────────────────────────
48
+ * 이 예측은 호출한 쪽의 이벤트 루프에서 **동기로** 돈다. 실측으로 트윈 하나의 예측 한 번이
49
+ * 30회차 × 300틱 = 9,000틱, 한 틱 3.2ms → **28.5초**였다. 그동안 그 프로세스의 라이브 트윈도,
50
+ * HTTP 도, 구독도 전부 멈춘다 — 예측 한 번에 시스템 전체가 정지한다.
51
+ *
52
+ * 총 시간은 이 함수로 줄지 않는다(그건 틱 수를 줄이는 다른 일이다). 줄어드는 것은 **한 번에
53
+ * 붙잡는 시간**이다. 회차 하나가 끝날 때마다 자리를 내주면 그 사이에 틱과 요청이 지나간다.
54
+ *
55
+ * 양보 방법은 **호출자가 준다.** 커널은 실행 환경을 모른다(`setImmediate` 는 서버 런타임의 것이다).
56
+ * 주지 않으면 마이크로태스크로 떨어지는데, 그것은 I/O 에 자리를 내주지 못하므로 **얼어붙는 증상은
57
+ * 그대로**다 — 그래서 기본값에 기대지 말고 호출자가 명시하는 것이 맞다.
58
+ */
59
+ export async function monteCarloForecastAsync(twin, opts) {
60
+ const now = clockOf(twin);
61
+ const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
62
+ /*
63
+ * **회차 사이만 내주면 부족하다.** 회차 하나가 300틱 × 3.2ms ≈ 1초라, 그동안 들어온 요청은 그만큼
64
+ * 기다린다(실측 응답 3초). 그래서 틱 몇 개마다도 끊는다 — 기본값은 라이브 틱 간격(1초)보다 훨씬
65
+ * 짧게 잡아, 트윈이 한 박자도 밀리지 않을 크기로 둔다.
66
+ */
67
+ const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
68
+ const samples = [];
69
+ for (let i = 0; i < opts.runs; i++) {
70
+ const { fc, step, target } = startRun(twin, opts, now, i);
71
+ let guard = 0;
72
+ let sinceYield = 0;
73
+ while (clockOf(fc) < target && guard++ < 1_000_000) {
74
+ fc.tick(step);
75
+ if (++sinceYield >= everyTicks) {
76
+ sinceYield = 0;
77
+ await yieldFn();
78
+ }
79
+ }
80
+ samples.push(opts.metric(fc.getSnapshot()));
81
+ if (i < opts.runs - 1)
82
+ await yieldFn();
83
+ }
84
+ return summarize(opts.runs, samples);
85
+ }
36
86
  function summarize(runs, samples) {
37
87
  const sorted = [...samples].sort((a, b) => a - b);
38
88
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
@@ -122,6 +122,7 @@ __export(index_exports, {
122
122
  meetsTests: () => meetsTests,
123
123
  minuteOfDayAt: () => minuteOfDayAt,
124
124
  monteCarloForecast: () => monteCarloForecast,
125
+ monteCarloForecastAsync: () => monteCarloForecastAsync,
125
126
  objectEvent: () => objectEvent,
126
127
  offCalendarAt: () => offCalendarAt,
127
128
  offCalendarReasonAt: () => offCalendarReasonAt,
@@ -260,16 +261,36 @@ function meetsTests(required, results, at) {
260
261
  }
261
262
  function effectivityAt(p, at) {
262
263
  if (!p || !at) return void 0;
263
- const atMs = Date.parse(at);
264
+ const atMs = parsedMs(at);
264
265
  if (!Number.isFinite(atMs)) return void 0;
265
- const from = p.effectiveStart ? Date.parse(p.effectiveStart) : NaN;
266
+ const from = p.effectiveStart ? parsedMs(p.effectiveStart) : NaN;
266
267
  if (Number.isFinite(from) && atMs < from) return "not-yet";
267
- const to = p.effectiveEnd ? Date.parse(p.effectiveEnd) : NaN;
268
+ const to = p.effectiveEnd ? parsedMs(p.effectiveEnd) : NaN;
268
269
  if (Number.isFinite(to) && atMs > to) return "expired";
269
270
  return void 0;
270
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();
271
292
  function classClosure(directIds, defs, at) {
272
- const byId = new Map((defs ?? []).map((d) => [d.id, d]));
293
+ const byId = classIndex(defs);
273
294
  const inWindow = (d) => !d || effectivityAt(d, at) === void 0;
274
295
  const out = /* @__PURE__ */ new Set();
275
296
  const stack = [...directIds ?? []];
@@ -408,8 +429,10 @@ function activeShiftAt(entries, atMs, utcOffsetMinutes) {
408
429
  return void 0;
409
430
  }
410
431
  function minuteOfDayAt(ms2, utcOffsetMinutes) {
411
- const d = new Date(ms2 + (utcOffsetMinutes ?? 0) * 6e4);
412
- 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);
413
436
  }
414
437
  function offCalendarReasonAt(r, ms2, utcOffsetMinutes) {
415
438
  if (!offCalendarAt(r, ms2, utcOffsetMinutes)) return void 0;
@@ -434,11 +457,20 @@ function offCalendarAt(r, ms2, utcOffsetMinutes) {
434
457
  }
435
458
  function requiredTestsFor(directIds, defs, at) {
436
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;
437
465
  const closure = classClosure(directIds, defs, at);
438
466
  const required = [];
439
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);
440
470
  return required;
441
471
  }
472
+ var REQUIRED_TESTS_MAX = 512;
473
+ var requiredTestsCache = /* @__PURE__ */ new WeakMap();
442
474
  function capabilityOf(r, ctx) {
443
475
  const at = ctx?.at;
444
476
  const eff = effectivityAt(r, at);
@@ -730,20 +762,42 @@ function clockOf2(twin) {
730
762
  }
731
763
  function monteCarloForecast(twin, opts) {
732
764
  const now = clockOf2(twin);
733
- const step = opts.tickMs ?? 1e3;
734
- const baseSeed = opts.scenario.seed ?? 1;
735
765
  const samples = [];
736
766
  for (let i = 0; i < opts.runs; i++) {
737
- const fc = twin.fork();
738
- fc.scenario.load({ ...opts.scenario, seed: baseSeed + i });
739
- fc.scenario.start();
740
- const target = now + opts.horizonMs;
767
+ const { fc, step, target } = startRun(twin, opts, now, i);
741
768
  let guard = 0;
742
769
  while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
743
770
  samples.push(opts.metric(fc.getSnapshot()));
744
771
  }
745
772
  return summarize(opts.runs, samples);
746
773
  }
774
+ function startRun(twin, opts, nowMs, i) {
775
+ const fc = twin.fork();
776
+ fc.scenario.load({ ...opts.scenario, seed: (opts.scenario.seed ?? 1) + i });
777
+ fc.scenario.start();
778
+ return { fc, step: opts.tickMs ?? 1e3, target: nowMs + opts.horizonMs };
779
+ }
780
+ async function monteCarloForecastAsync(twin, opts) {
781
+ const now = clockOf2(twin);
782
+ const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
783
+ const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
784
+ const samples = [];
785
+ for (let i = 0; i < opts.runs; i++) {
786
+ const { fc, step, target } = startRun(twin, opts, now, i);
787
+ let guard = 0;
788
+ let sinceYield = 0;
789
+ while (clockOf2(fc) < target && guard++ < 1e6) {
790
+ fc.tick(step);
791
+ if (++sinceYield >= everyTicks) {
792
+ sinceYield = 0;
793
+ await yieldFn();
794
+ }
795
+ }
796
+ samples.push(opts.metric(fc.getSnapshot()));
797
+ if (i < opts.runs - 1) await yieldFn();
798
+ }
799
+ return summarize(opts.runs, samples);
800
+ }
747
801
  function summarize(runs, samples) {
748
802
  const sorted = [...samples].sort((a, b) => a - b);
749
803
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
@@ -3598,9 +3652,28 @@ var FlowEngine = class {
3598
3652
  if (!Number.isFinite(originMs)) throw new Error("clock origin must be a finite epoch millisecond value");
3599
3653
  this.originMs = originMs;
3600
3654
  }
3655
+ /**
3656
+ * 이 커널의 **지금**(ISO 문자열).
3657
+ *
3658
+ * ── 같은 시각을 몇천 번 다시 문자열로 만들지 않는다 (2026-08-17 프로파일) ──
3659
+ * 능력 판정이 이 값을 자원마다·작업마다 부른다. 그래서 한 틱 안에서 이 함수가 수백 번 불리는데,
3660
+ * 그때마다 `new Date(...).toISOString()` 이 새 문자열을 지었다 — 프로파일에서 **틱 시간의 40%** 가
3661
+ * 여기였다.
3662
+ *
3663
+ * 밀리초가 같으면 문자열도 같다. 그래서 밀리초를 열쇠로 기억한다 — 근사가 아니라 **정확히 같은 값**을
3664
+ * 내므로 판정은 하나도 달라지지 않는다. 덤으로 같은 문자열 인스턴스가 돌아오므로 그것을 다시 파싱하는
3665
+ * 쪽(`effectivityAt`)의 캐시도 한 번에 맞는다.
3666
+ */
3601
3667
  now() {
3602
- 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;
3603
3674
  }
3675
+ nowIsoMs = Number.NaN;
3676
+ nowIso = "";
3604
3677
  /**
3605
3678
  * 자극이 선언한 **약속**을 오더 필드로 — 표준 `OperationsRequest.Priority`·`StartTime`·`EndTime`.
3606
3679
  *
@@ -6516,6 +6589,7 @@ function retiredVocabularyIn(line) {
6516
6589
  meetsTests,
6517
6590
  minuteOfDayAt,
6518
6591
  monteCarloForecast,
6592
+ monteCarloForecastAsync,
6519
6593
  objectEvent,
6520
6594
  offCalendarAt,
6521
6595
  offCalendarReasonAt,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.9",
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": {