@operato/twin-kernel 0.7.9 → 0.7.10

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.
@@ -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,
@@ -730,20 +731,42 @@ function clockOf2(twin) {
730
731
  }
731
732
  function monteCarloForecast(twin, opts) {
732
733
  const now = clockOf2(twin);
733
- const step = opts.tickMs ?? 1e3;
734
- const baseSeed = opts.scenario.seed ?? 1;
735
734
  const samples = [];
736
735
  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;
736
+ const { fc, step, target } = startRun(twin, opts, now, i);
741
737
  let guard = 0;
742
738
  while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
743
739
  samples.push(opts.metric(fc.getSnapshot()));
744
740
  }
745
741
  return summarize(opts.runs, samples);
746
742
  }
743
+ function startRun(twin, opts, nowMs, i) {
744
+ const fc = twin.fork();
745
+ fc.scenario.load({ ...opts.scenario, seed: (opts.scenario.seed ?? 1) + i });
746
+ fc.scenario.start();
747
+ return { fc, step: opts.tickMs ?? 1e3, target: nowMs + opts.horizonMs };
748
+ }
749
+ async function monteCarloForecastAsync(twin, opts) {
750
+ const now = clockOf2(twin);
751
+ const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
752
+ const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
753
+ const samples = [];
754
+ for (let i = 0; i < opts.runs; i++) {
755
+ const { fc, step, target } = startRun(twin, opts, now, i);
756
+ let guard = 0;
757
+ let sinceYield = 0;
758
+ while (clockOf2(fc) < target && guard++ < 1e6) {
759
+ fc.tick(step);
760
+ if (++sinceYield >= everyTicks) {
761
+ sinceYield = 0;
762
+ await yieldFn();
763
+ }
764
+ }
765
+ samples.push(opts.metric(fc.getSnapshot()));
766
+ if (i < opts.runs - 1) await yieldFn();
767
+ }
768
+ return summarize(opts.runs, samples);
769
+ }
747
770
  function summarize(runs, samples) {
748
771
  const sorted = [...samples].sort((a, b) => a - b);
749
772
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
@@ -6516,6 +6539,7 @@ function retiredVocabularyIn(line) {
6516
6539
  meetsTests,
6517
6540
  minuteOfDayAt,
6518
6541
  monteCarloForecast,
6542
+ monteCarloForecastAsync,
6519
6543
  objectEvent,
6520
6544
  offCalendarAt,
6521
6545
  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.10",
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": {