@operato/twin-kernel 0.7.54 → 0.7.55

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.
@@ -415,12 +415,48 @@ export interface TestSpecificationCriterion {
415
415
  /** 표준 `Sequence` — 기준이 여럿일 때의 순서. */
416
416
  sequence?: number;
417
417
  /**
418
- * 표준 `Expression`(`TextType`) — **그 현장의 표기 그대로** 나른다. 커널은 평가하지 않는다.
418
+ * 표준 `Expression`(`TextType`) — **그 현장의 표기 그대로** 나른다. 커널은 이것을 평가하지 않는다.
419
419
  *
420
- * 표준에서는 선택이지만 **우리는 요구한다**(좁힘): 표현식이 없는 기준은 아무 한계도 말하지 않으므로
421
- * 「기준이 선언됐다」는 착각만 만든다. 선언만 있고 내용이 없는 것을 이 저장소는 거절한다.
420
+ * 문법이 정의돼 있지 않으므로(자유 문자열) 파싱하면 그 순간 방언이고, 현장 규정과 우리 해석이
421
+ * 갈리는 날 어느 쪽이 옳은지 아무도 모른다. 사람이 읽는 값이다.
422
+ *
423
+ * **`expression` 과 `limit` 중 적어도 하나는 있어야 한다**(§`criterionSaysNothing`) — 둘 다 없는
424
+ * 기준은 아무 한계도 말하지 않으면서 「기준이 선언됐다」는 착각만 만든다.
425
+ */
426
+ expression?: string;
427
+ /**
428
+ * **평가 가능한 한계** — 이것이 있으면 커널이 판정할 수 있다.
429
+ *
430
+ * ── 이 축은 **표준에 없다. 우리 것이다** (2026-08-24) ──────────────────────
431
+ * B2MML 일곱 파일(Common · OperationsTest · OperationsPerformance(+Types) · OperationalLocation ·
432
+ * OperationsEvent · OperationsSchedule · WorkAlert)을 원문으로 훑었고 `Minimum`·`Maximum`·
433
+ * `Tolerance`·`Range`·`LowerLimit`·`UpperLimit` 이 **하나도 없다.** ISA-95 의 기준은
434
+ * `Expression`(TextType)과 `Result`(TextType)뿐이고, `ValueType` 도 `ValueString`·`DataType`·
435
+ * `UnitOfMeasure`·`Key` 넷이다. 즉 표준은 한계를 **사람이 읽는 문장**으로만 담는다.
436
+ *
437
+ * 그런데 트윈은 **아무도 판정하지 않을 때 판정해야 한다** — 그것이 이 축의 근거다(당위). 자유 문장
438
+ * 으로는 그것을 할 수 없다. 그래서 숫자 한계를 우리가 더한다. **더한 것이라고 적어 두는 것**이
439
+ * 이 저장소의 규율이고(§`TestResult.result` 의 좁힘과 같은 자리), 그러면 다음 사람이 이것을
440
+ * 「표준이 준 이름」으로 읽지 않는다.
441
+ *
442
+ * 실 원본이 이 모양으로 준다는 것이 확인됐다(첫 실 연동: `criticalLimits.{minimum,maximum}`).
443
+ * **그것이 이 축을 정한 것은 아니다** — 판정이 필요하다는 당위가 정했고, 원본은 그것을 채울 수
444
+ * 있다는 사실을 확인해 준 것이다.
422
445
  */
423
- expression: string;
446
+ limit?: {
447
+ /** 이 값 미만이면 벗어난다. 없으면 아래쪽 한계가 없다(모르는 것이 아니라 없는 것이다). */
448
+ minimum?: number;
449
+ /** 이 값 초과면 벗어난다. */
450
+ maximum?: number;
451
+ /**
452
+ * 한계의 단위 — 표준 `UnitOfMeasure` 와 같은 어휘(UN/CEFACT).
453
+ *
454
+ * **없으면 짐작하지 않는다.** 관측도 단위를 말하지 않으면 같은 척도로 보고 비교하고(같은 선언에서
455
+ * 온 값들이다), **둘이 서로 다른 단위를 말하면 판정하지 않는다** — 섭씨 한계에 화씨 관측을
456
+ * 견주면 조용히 틀린다.
457
+ */
458
+ uom?: string;
459
+ };
424
460
  /** 표준 `Result` — 이 기준이 만족될 때 기대하는 값(현장 표기). */
425
461
  result?: string;
426
462
  /** 표준 `EvaluatedPropertyID` — 이 기준이 **무엇을 재어** 판정하나. 측정값과 짝을 맞추는 키다. */
@@ -465,6 +501,34 @@ export interface PropertyMeasurement extends StandardValue {
465
501
  */
466
502
  derived?: boolean;
467
503
  }
504
+ /**
505
+ * **이 기준이 아무 한계도 말하지 않나** — 선언만 있고 내용이 없는 것을 가려낸다.
506
+ *
507
+ * 표현식(사람이 읽는 것)도 없고 숫자 한계(커널이 판정하는 것)도 없으면, 그 기준은 「기준이 선언됐다」는
508
+ * 착각만 만든다. 화면이 「관리점 셋이 걸려 있다」고 말하는데 그중 하나가 아무것도 재지 않는 것은
509
+ * 이 저장소가 거절하는 모양이다.
510
+ */
511
+ export declare function criterionSaysNothing(c: Pick<TestSpecificationCriterion, 'expression' | 'limit'>): boolean;
512
+ /**
513
+ * **이 값이 한계를 벗어났나** — 판정할 수 있을 때만 답한다.
514
+ *
515
+ * ── 세 갈래를 가린다 ────────────────────────────────────────────────────────
516
+ * `true` 벗어났다
517
+ * `false` 한계 안이다
518
+ * `undefined` **판정할 수 없다** — 숫자 한계가 없거나, 값이 수가 아니거나, 단위가 어긋난다
519
+ *
520
+ * `undefined` 를 `false` 로 뭉개지 않는 것이 이 함수의 요점이다. 「한계 안이다」와 「판정 못 했다」를
521
+ * 같은 값으로 답하면 화면이 판정하지 못한 것을 **적합으로** 보여 준다 — 규제 기록에서 그것은 사고다.
522
+ *
523
+ * ── 표현식은 읽지 않는다 ────────────────────────────────────────────────────
524
+ * `expression` 이 있어도 파싱하지 않는다(§`TestSpecificationCriterion.expression`). 숫자 한계가
525
+ * 없으면 사람이 읽을 문장은 있어도 커널은 「모른다」고 답한다.
526
+ *
527
+ * ── 단위가 어긋나면 판정하지 않는다 ─────────────────────────────────────────
528
+ * 섭씨 한계에 화씨 관측을 견주면 **오류 없이 틀린다.** 둘 다 단위를 말하지 않으면 같은 척도로 본다 —
529
+ * 같은 선언에서 온 값들이고, 그때 판정을 거부하면 단위를 적지 않는 원본에서 이 축이 영원히 침묵한다.
530
+ */
531
+ export declare function outsideLimit(criterion: Pick<TestSpecificationCriterion, 'limit'> | undefined, observed: Pick<StandardValue, 'value' | 'uom'> | undefined): boolean | undefined;
468
532
  /**
469
533
  * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
470
534
  *
@@ -944,7 +1008,105 @@ export interface LocationState {
944
1008
  * 보드에 좌표가 없고 용량이 비어 있어 **계획에 참여하지 못한다**(그 사실을 감추지 않기 위한 표시).
945
1009
  */
946
1010
  origin?: 'master' | 'observed';
1011
+ /**
1012
+ * 이 자리에서 관측된 **물리량들의 마지막 값** — 속성마다 하나(§`LocationObservation`).
1013
+ *
1014
+ * 이력이 아니라 **지금 값**이다: 이력은 저널이 든다(그것이 「그때 그 방이 몇 도였나」에 답하는
1015
+ * 자리다). 여기 두는 이유는 판정이 지금 값을 보기 때문이고, 상태가 시간에 비례해 자라지 않게
1016
+ * **속성당 하나**만 든다.
1017
+ *
1018
+ * 없으면 **두지 않는다** — 빈 배열을 두면 「센서가 없는 자리」와 「아직 못 들은 자리」가 같아진다.
1019
+ */
1020
+ observations?: LocationObservation[];
947
1021
  }
1022
+ /**
1023
+ * **자리에서 관측된 물리량 하나** — 냉장실 온도·습도, 세척수 유량 같은 것.
1024
+ *
1025
+ * ── 왜 트윈이 이것을 들어야 하나 (당위) ─────────────────────────────────────
1026
+ * 트윈의 판정 대상은 「물건의 상태」이고, 물건의 상태는 **조건 없이 정해지지 않는다.** 「이 로트가
1027
+ * 냉장실에 있었다」까지만 아는 트윈은 그 로트가 괜찮았는지 말할 수 없다 — 기능 하나가 없는 것이
1028
+ * 아니라 **판정의 재료가 없는 것**이다.
1029
+ *
1030
+ * 그리고 **이 조인은 트윈만 할 수 있다.** 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은
1031
+ * 조건 이력을 모른다. 물건이 여러 시스템을 지나면 어느 원본도 전 경로를 모른다.
1032
+ *
1033
+ * ── 주인은 **자리**다. 물건이 아니다 ────────────────────────────────────────
1034
+ * 방 하나의 온도가 그 안의 물건 수백 개와 관계되고, **그 수백 개는 시간에 따라 바뀐다.** 값을 물건마다
1035
+ * 복사하면 한 사실의 사본이 수백 개 생기고 드나드는 물건마다 어긋난다. 그래서 관측은 **자리에 한 번**
1036
+ * 두고, 「그때 그 방에 있던 물건」은 시각∩자리로 파생한다(파생은 저장하지 않는다).
1037
+ *
1038
+ * 진행 중인 공정이 있어도 마찬가지다: 숙성창고에 로트가 여럿이면 숙성 작업도 여럿이고 구간이 각기
1039
+ * 다른데 **센서는 하나**다. 그 값은 어느 한 작업의 것이 아니다.
1040
+ *
1041
+ * ── 표준 근거 — 붙는 자리가 「자리」가 아니라 **설비 계층 노드**다 ────────────
1042
+ * 1차 출처: `OperationsEventType`(`B2MML-OperationsEvent.xsd`) + `OperationsRecordTemplateType`
1043
+ * (`B2MML-Common.xsd`). 붙는 자리는 `HierarchyScope.EquipmentID` 인데, ISA-95 에서는 **장소 계층이
1044
+ * 곧 설비 계층**이다(Enterprise → Site → Area → StorageZone → StorageUnit) — 냉장실은 `StorageZone`
1045
+ * 수준의 설비다. 커널의 자리가 그 노드이므로(§`LocationState.level` 이 `EquipmentLevel1Type` 근거)
1046
+ * `locationId` 가 그대로 대응된다.
1047
+ *
1048
+ * `OperationalLocationType` 에는 이 축이 **없다** — 그쪽은 나중에 들어온 공간(spatial) 개념이고
1049
+ * 마스터 속성만 든다(시계열이 아니다).
1050
+ *
1051
+ * ── 우리가 좁힌 것 ──────────────────────────────────────────────────────────
1052
+ * 표준의 기록 항목은 `InformationObject`(무엇이든)다. **그 임의성을 쓰지 않는다** — 값을 `ValueType`
1053
+ * 으로 좁힌다(§`StandardValue`). 단위 없는 물리량을 받으면 소비처가 3이 섭씨인지 화씨인지 알 수 없고,
1054
+ * 그 상태로는 어떤 판정도 못 한다.
1055
+ */
1056
+ export interface LocationObservation extends StandardValue {
1057
+ /** 표준 `HierarchyScope.EquipmentID` — 어느 자리인가. 커널의 자리 id 다. */
1058
+ locationId: string;
1059
+ /**
1060
+ * **무엇을 쟀나** — 속성 식별자. 기준과 짝을 맞추는 키다
1061
+ * (§`TestSpecificationCriterion.evaluatedPropertyId`).
1062
+ *
1063
+ * 어휘는 커널이 정하지 않는다 — 재는 것은 현장이 정한다(온도·습도·유량·중량). 커널은 나르고
1064
+ * 짝만 맞춘다. `OP_PARAM` 과 같은 규율이고, 표준도 파라미터 ID 어휘를 정하지 않는다.
1065
+ */
1066
+ propertyId: string;
1067
+ /**
1068
+ * **언제의 사실인가** — 표준 `EffectiveTimestamp`.
1069
+ *
1070
+ * 관측 시각이지 기록 시각이 아니다. 둘이 어긋나는 것이 실 연동의 정상이고, 그래서 아래를 따로 든다.
1071
+ */
1072
+ effectiveTime: ISOTime;
1073
+ /**
1074
+ * **언제까지의 사실인가** — 표준 `EffectiveEndDate`. 없으면 그 시점 하나의 값이다.
1075
+ *
1076
+ * ── 왜 구간이 필요한가 ────────────────────────────────────────────────────
1077
+ * ① **원본이 시각을 주지 않는 기록이 있다.** 수동 점검 장부는 「그날 아침」처럼 적고 시계를 적지
1078
+ * 않는다(표준도 주기 점검을 전제한다 — `RecurrenceTimeInterval`). 그때 09:00 을 찍으면 **자정을
1079
+ * 지어내는 것과 같은 발명**이다(§ADR-0039 「선언이 있으면 변환이지만 없으면 발명이다」).
1080
+ * 구간으로 적으면 참인 것만 말한다: 그날 아침의 어느 때.
1081
+ * ② **영향 로트를 찾는 데 구간이 필요하다.** 점 하나로는 며칠 머문 로트를 잡지 못한다 — 온도가 튄
1082
+ * 한 시간의 이벤트 목록에는 정작 그 방의 로트가 하나도 없을 수 있다.
1083
+ *
1084
+ * 구간을 **모르는 것**과 **점인 것**은 다르다: 점이면 이 값이 없고, 모르면 넓은 구간으로 적는다.
1085
+ */
1086
+ effectiveEndTime?: ISOTime;
1087
+ /** 언제 적혔나 — 표준 `RecordTimestamp`. 늦게 도착한 옛 관측이 최신을 덮지 않게 하는 재료다. */
1088
+ recordTime?: ISOTime;
1089
+ /** 누가 말했나 — 표준 `Source`. 계측기인지 사람이 적은 장부인지는 이 값이 답한다. */
1090
+ source?: string;
1091
+ /**
1092
+ * 이 값이 **잰 것이 아니라 접은 것**임을 밝힌다 — §`PropertyMeasurement.derived` 와 같은 규율.
1093
+ *
1094
+ * 트윈은 원본이 주지 않는 값을 계산할 수 있다(그것이 존재 이유다). 그러나 실측과 같은 자리에 같은
1095
+ * 모양으로 두면 보고서가 그것을 잰 값으로 읽는다. 규제 기록에서 그 구별이 사라지는 것은 결함이
1096
+ * 아니라 사고다.
1097
+ */
1098
+ derived?: boolean;
1099
+ }
1100
+ /**
1101
+ * **이 시각에 이 자리에서 참인 관측** — 구간을 든 관측을 시각으로 찾는다.
1102
+ *
1103
+ * 「그때 그 방이 몇 도였나」에 답하는 첫 칸이다. 점 관측은 그 시각에만, 구간 관측은 구간 안에서 참이다.
1104
+ * 여러 개가 겹치면 **가장 늦게 시작한 것**이 답이다(정정이 나중에 온다).
1105
+ *
1106
+ * 커널은 값이 옳은지 **판정하지 않는다** — 기준의 표현식은 문법이 정의되지 않은 자유 문자열이다
1107
+ * (§`TestSpecificationCriterion`). 여기서 하는 일은 짝을 찾아 주는 것까지다.
1108
+ */
1109
+ export declare function observationAt(observations: readonly LocationObservation[] | undefined, propertyId: string, at: ISOTime): LocationObservation | undefined;
948
1110
  /**
949
1111
  * 물품 상태 — 표준이 담는 것을 담는다(EPCIS 2.0 / TDS).
950
1112
  *
package/dist/contract.js CHANGED
@@ -245,6 +245,63 @@ export function meetsTests(required, results, at) {
245
245
  }
246
246
  return true;
247
247
  }
248
+ /**
249
+ * **이 기준이 아무 한계도 말하지 않나** — 선언만 있고 내용이 없는 것을 가려낸다.
250
+ *
251
+ * 표현식(사람이 읽는 것)도 없고 숫자 한계(커널이 판정하는 것)도 없으면, 그 기준은 「기준이 선언됐다」는
252
+ * 착각만 만든다. 화면이 「관리점 셋이 걸려 있다」고 말하는데 그중 하나가 아무것도 재지 않는 것은
253
+ * 이 저장소가 거절하는 모양이다.
254
+ */
255
+ export function criterionSaysNothing(c) {
256
+ if (c?.expression?.trim())
257
+ return false;
258
+ const l = c?.limit;
259
+ return !(typeof l?.minimum === 'number' || typeof l?.maximum === 'number');
260
+ }
261
+ /**
262
+ * **이 값이 한계를 벗어났나** — 판정할 수 있을 때만 답한다.
263
+ *
264
+ * ── 세 갈래를 가린다 ────────────────────────────────────────────────────────
265
+ * `true` 벗어났다
266
+ * `false` 한계 안이다
267
+ * `undefined` **판정할 수 없다** — 숫자 한계가 없거나, 값이 수가 아니거나, 단위가 어긋난다
268
+ *
269
+ * `undefined` 를 `false` 로 뭉개지 않는 것이 이 함수의 요점이다. 「한계 안이다」와 「판정 못 했다」를
270
+ * 같은 값으로 답하면 화면이 판정하지 못한 것을 **적합으로** 보여 준다 — 규제 기록에서 그것은 사고다.
271
+ *
272
+ * ── 표현식은 읽지 않는다 ────────────────────────────────────────────────────
273
+ * `expression` 이 있어도 파싱하지 않는다(§`TestSpecificationCriterion.expression`). 숫자 한계가
274
+ * 없으면 사람이 읽을 문장은 있어도 커널은 「모른다」고 답한다.
275
+ *
276
+ * ── 단위가 어긋나면 판정하지 않는다 ─────────────────────────────────────────
277
+ * 섭씨 한계에 화씨 관측을 견주면 **오류 없이 틀린다.** 둘 다 단위를 말하지 않으면 같은 척도로 본다 —
278
+ * 같은 선언에서 온 값들이고, 그때 판정을 거부하면 단위를 적지 않는 원본에서 이 축이 영원히 침묵한다.
279
+ */
280
+ export function outsideLimit(criterion, observed) {
281
+ const l = criterion?.limit;
282
+ if (!l)
283
+ return undefined;
284
+ const hasMin = typeof l.minimum === 'number';
285
+ const hasMax = typeof l.maximum === 'number';
286
+ if (!hasMin && !hasMax)
287
+ return undefined;
288
+ const raw = observed?.value;
289
+ if (raw === undefined || raw === null || String(raw).trim() === '')
290
+ return undefined;
291
+ const n = Number(raw);
292
+ if (!Number.isFinite(n))
293
+ return undefined;
294
+ /* 단위가 **둘 다 있고 다르면** 판정하지 않는다. 하나만 있거나 둘 다 없으면 같은 척도로 본다. */
295
+ const lu = l.uom?.trim();
296
+ const ou = observed?.uom?.trim();
297
+ if (lu && ou && lu !== ou)
298
+ return undefined;
299
+ if (hasMin && n < l.minimum)
300
+ return true;
301
+ if (hasMax && n > l.maximum)
302
+ return true;
303
+ return false;
304
+ }
248
305
  /**
249
306
  * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
250
307
  *
@@ -782,6 +839,40 @@ export function capabilityOf(r, ctx) {
782
839
  return { available: false, reason: 'working' };
783
840
  return { available: true, reason: 'available' };
784
841
  }
842
+ /**
843
+ * **이 시각에 이 자리에서 참인 관측** — 구간을 든 관측을 시각으로 찾는다.
844
+ *
845
+ * 「그때 그 방이 몇 도였나」에 답하는 첫 칸이다. 점 관측은 그 시각에만, 구간 관측은 구간 안에서 참이다.
846
+ * 여러 개가 겹치면 **가장 늦게 시작한 것**이 답이다(정정이 나중에 온다).
847
+ *
848
+ * 커널은 값이 옳은지 **판정하지 않는다** — 기준의 표현식은 문법이 정의되지 않은 자유 문자열이다
849
+ * (§`TestSpecificationCriterion`). 여기서 하는 일은 짝을 찾아 주는 것까지다.
850
+ */
851
+ export function observationAt(observations, propertyId, at) {
852
+ const t = parsedMs(at);
853
+ if (!(t >= 0))
854
+ return undefined;
855
+ let best;
856
+ let bestStart = -1;
857
+ for (const o of observations ?? []) {
858
+ if (o.propertyId !== propertyId)
859
+ continue;
860
+ const from = parsedMs(o.effectiveTime);
861
+ if (!(from >= 0) || from > t)
862
+ continue;
863
+ /* 구간이 있으면 그 안이어야 한다. 없으면 그 시점의 값이고, 그 뒤로도 정정될 때까지 유효하다. */
864
+ if (o.effectiveEndTime) {
865
+ const to = parsedMs(o.effectiveEndTime);
866
+ if (to >= 0 && to < t)
867
+ continue;
868
+ }
869
+ if (from > bestStart) {
870
+ best = o;
871
+ bestStart = from;
872
+ }
873
+ }
874
+ return best;
875
+ }
785
876
  // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
786
877
  // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
787
878
  // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
@@ -22,6 +22,23 @@ export interface MonteCarloResult {
22
22
  mean: number;
23
23
  p50: number;
24
24
  p90: number;
25
+ /**
26
+ * **계보를 잇지 못한 채 넘어간 횟수** — 회차들 중 **최대값**(§`StateSnapshot.stepsWithoutMaterial`).
27
+ *
28
+ * ── 왜 예측 결과에 실어야 하나 (2026-08-24) ──────────────────────────────────
29
+ * 미러의 오더는 확보분을 갖지 않는다(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 그 시스템의
30
+ * 정상이다). 그래서 예측이 공정을 넘을 때 개체를 이을 수 없고, 커널은 **넘기되 세어 둔다.**
31
+ *
32
+ * 그런데 예측은 **회차마다 fork 를 만들고 버린다.** 그 카운터가 사본 안에만 있으면 아무에게도 닿지
33
+ * 않는다 — 이 저장소가 이미 그 실수를 겪었다(리듀서가 오래 세어 온 값을 스냅샷이 떨어뜨려, 그것을
34
+ * 읽도록 쓰인 검사가 영원히 조용했다). **세어 놓고 내보내지 않으면 안 센 것과 같다.**
35
+ *
36
+ * 왜 합이 아니라 최대인가: 회차는 **같은 미래의 표본들**이다. 서른 번 굴려 각 회차가 열 칸을
37
+ * 비웠으면 사실은 「열 칸」이고 300이 아니다. 합을 내면 회차 수를 늘리는 것만으로 숫자가 커진다.
38
+ *
39
+ * 0 이면 **싣지 않는다** — 0 을 정보처럼 보이게 하지 않는다.
40
+ */
41
+ stepsWithoutMaterial?: number;
25
42
  }
26
43
  export interface MonteCarloOptions {
27
44
  runs: number;
package/dist/forecast.js CHANGED
@@ -19,14 +19,17 @@ function clockOf(twin) {
19
19
  export function monteCarloForecast(twin, opts) {
20
20
  const now = clockOf(twin);
21
21
  const samples = [];
22
+ let gaps = 0;
22
23
  for (let i = 0; i < opts.runs; i++) {
23
24
  const { fc, step, target } = startRun(twin, opts, now, i);
24
25
  let guard = 0;
25
26
  while (clockOf(fc) < target && guard++ < 1_000_000)
26
27
  fc.tick(step);
27
- samples.push(opts.metric(fc.getSnapshot()));
28
+ const snap = fc.getSnapshot();
29
+ gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
30
+ samples.push(opts.metric(snap));
28
31
  }
29
- return summarize(opts.runs, samples);
32
+ return summarize(opts.runs, samples, gaps);
30
33
  }
31
34
  /**
32
35
  * 한 회차의 **출발점** — fork(현재 보존) + 그 회차의 미래(seed 변주).
@@ -66,6 +69,7 @@ export async function monteCarloForecastAsync(twin, opts) {
66
69
  */
67
70
  const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
68
71
  const samples = [];
72
+ let gaps = 0;
69
73
  for (let i = 0; i < opts.runs; i++) {
70
74
  const { fc, step, target } = startRun(twin, opts, now, i);
71
75
  let guard = 0;
@@ -77,15 +81,27 @@ export async function monteCarloForecastAsync(twin, opts) {
77
81
  await yieldFn();
78
82
  }
79
83
  }
80
- samples.push(opts.metric(fc.getSnapshot()));
84
+ const snap = fc.getSnapshot();
85
+ gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
86
+ samples.push(opts.metric(snap));
81
87
  if (i < opts.runs - 1)
82
88
  await yieldFn();
83
89
  }
84
- return summarize(opts.runs, samples);
90
+ return summarize(opts.runs, samples, gaps);
85
91
  }
86
- function summarize(runs, samples) {
92
+ function summarize(runs, samples, stepsWithoutMaterial = 0) {
87
93
  const sorted = [...samples].sort((a, b) => a - b);
88
94
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
89
95
  const mean = samples.reduce((a, b) => a + b, 0) / (samples.length || 1);
90
- return { runs, samples, min: sorted[0], max: sorted[sorted.length - 1], mean, p50: pct(0.5), p90: pct(0.9) };
96
+ return {
97
+ runs,
98
+ samples,
99
+ min: sorted[0],
100
+ max: sorted[sorted.length - 1],
101
+ mean,
102
+ p50: pct(0.5),
103
+ p90: pct(0.9),
104
+ /* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
105
+ ...(stepsWithoutMaterial > 0 ? { stepsWithoutMaterial } : {})
106
+ };
91
107
  }
@@ -96,6 +96,7 @@ __export(index_exports, {
96
96
  constantDuration: () => constantDuration,
97
97
  conversionFactorOf: () => conversionFactorOf,
98
98
  counterfactualAt: () => counterfactualAt,
99
+ criterionSaysNothing: () => criterionSaysNothing,
99
100
  demandWindowStart: () => demandWindowStart,
100
101
  deriveAttentions: () => deriveAttentions,
101
102
  documentPath: () => documentPath,
@@ -142,10 +143,12 @@ __export(index_exports, {
142
143
  monteCarloForecastAsync: () => monteCarloForecastAsync,
143
144
  objectEvent: () => objectEvent,
144
145
  objectUri: () => objectUri,
146
+ observationAt: () => observationAt,
145
147
  offCalendarAt: () => offCalendarAt,
146
148
  offCalendarReasonAt: () => offCalendarReasonAt,
147
149
  operationalKindOf: () => operationalKindOf,
148
150
  operationsCapabilityOf: () => operationsCapabilityOf,
151
+ outsideLimit: () => outsideLimit,
149
152
  parseEpc: () => parseEpc,
150
153
  parseIsoDuration: () => parseIsoDuration,
151
154
  partialFitPolicy: () => partialFitPolicy,
@@ -290,6 +293,28 @@ function meetsTests(required, results, at) {
290
293
  }
291
294
  return true;
292
295
  }
296
+ function criterionSaysNothing(c) {
297
+ if (c?.expression?.trim()) return false;
298
+ const l = c?.limit;
299
+ return !(typeof l?.minimum === "number" || typeof l?.maximum === "number");
300
+ }
301
+ function outsideLimit(criterion, observed) {
302
+ const l = criterion?.limit;
303
+ if (!l) return void 0;
304
+ const hasMin = typeof l.minimum === "number";
305
+ const hasMax = typeof l.maximum === "number";
306
+ if (!hasMin && !hasMax) return void 0;
307
+ const raw = observed?.value;
308
+ if (raw === void 0 || raw === null || String(raw).trim() === "") return void 0;
309
+ const n = Number(raw);
310
+ if (!Number.isFinite(n)) return void 0;
311
+ const lu = l.uom?.trim();
312
+ const ou = observed?.uom?.trim();
313
+ if (lu && ou && lu !== ou) return void 0;
314
+ if (hasMin && n < l.minimum) return true;
315
+ if (hasMax && n > l.maximum) return true;
316
+ return false;
317
+ }
293
318
  function testEvidenceGaps(spec, result) {
294
319
  const criteria = spec?.criteria ?? [];
295
320
  const measurements = result?.propertyMeasurements ?? [];
@@ -532,6 +557,26 @@ function capabilityOf(r, ctx) {
532
557
  if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
533
558
  return { available: true, reason: "available" };
534
559
  }
560
+ function observationAt(observations, propertyId, at) {
561
+ const t = parsedMs(at);
562
+ if (!(t >= 0)) return void 0;
563
+ let best;
564
+ let bestStart = -1;
565
+ for (const o of observations ?? []) {
566
+ if (o.propertyId !== propertyId) continue;
567
+ const from = parsedMs(o.effectiveTime);
568
+ if (!(from >= 0) || from > t) continue;
569
+ if (o.effectiveEndTime) {
570
+ const to = parsedMs(o.effectiveEndTime);
571
+ if (to >= 0 && to < t) continue;
572
+ }
573
+ if (from > bestStart) {
574
+ best = o;
575
+ bestStart = from;
576
+ }
577
+ }
578
+ return best;
579
+ }
535
580
  var OP_EVENT = {
536
581
  task: "task.status",
537
582
  equipment: "equipment.status",
@@ -825,13 +870,16 @@ function clockOf2(twin) {
825
870
  function monteCarloForecast(twin, opts) {
826
871
  const now = clockOf2(twin);
827
872
  const samples = [];
873
+ let gaps = 0;
828
874
  for (let i = 0; i < opts.runs; i++) {
829
875
  const { fc, step, target } = startRun(twin, opts, now, i);
830
876
  let guard = 0;
831
877
  while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
832
- samples.push(opts.metric(fc.getSnapshot()));
878
+ const snap = fc.getSnapshot();
879
+ gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
880
+ samples.push(opts.metric(snap));
833
881
  }
834
- return summarize(opts.runs, samples);
882
+ return summarize(opts.runs, samples, gaps);
835
883
  }
836
884
  function startRun(twin, opts, nowMs, i) {
837
885
  const fc = twin.fork();
@@ -844,6 +892,7 @@ async function monteCarloForecastAsync(twin, opts) {
844
892
  const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
845
893
  const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
846
894
  const samples = [];
895
+ let gaps = 0;
847
896
  for (let i = 0; i < opts.runs; i++) {
848
897
  const { fc, step, target } = startRun(twin, opts, now, i);
849
898
  let guard = 0;
@@ -855,16 +904,28 @@ async function monteCarloForecastAsync(twin, opts) {
855
904
  await yieldFn();
856
905
  }
857
906
  }
858
- samples.push(opts.metric(fc.getSnapshot()));
907
+ const snap = fc.getSnapshot();
908
+ gaps = Math.max(gaps, snap.stepsWithoutMaterial ?? 0);
909
+ samples.push(opts.metric(snap));
859
910
  if (i < opts.runs - 1) await yieldFn();
860
911
  }
861
- return summarize(opts.runs, samples);
912
+ return summarize(opts.runs, samples, gaps);
862
913
  }
863
- function summarize(runs, samples) {
914
+ function summarize(runs, samples, stepsWithoutMaterial = 0) {
864
915
  const sorted = [...samples].sort((a, b) => a - b);
865
916
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
866
917
  const mean = samples.reduce((a, b) => a + b, 0) / (samples.length || 1);
867
- return { runs, samples, min: sorted[0], max: sorted[sorted.length - 1], mean, p50: pct(0.5), p90: pct(0.9) };
918
+ return {
919
+ runs,
920
+ samples,
921
+ min: sorted[0],
922
+ max: sorted[sorted.length - 1],
923
+ mean,
924
+ p50: pct(0.5),
925
+ p90: pct(0.9),
926
+ /* 0 은 싣지 않는다 — 「구멍이 없다」와 「이 축을 모른다」를 화면이 구별할 수 있게. */
927
+ ...stepsWithoutMaterial > 0 ? { stepsWithoutMaterial } : {}
928
+ };
868
929
  }
869
930
 
870
931
  // src/twin-observer.ts
@@ -9126,6 +9187,7 @@ function retiredVocabularyIn(line) {
9126
9187
  constantDuration,
9127
9188
  conversionFactorOf,
9128
9189
  counterfactualAt,
9190
+ criterionSaysNothing,
9129
9191
  demandWindowStart,
9130
9192
  deriveAttentions,
9131
9193
  documentPath,
@@ -9172,10 +9234,12 @@ function retiredVocabularyIn(line) {
9172
9234
  monteCarloForecastAsync,
9173
9235
  objectEvent,
9174
9236
  objectUri,
9237
+ observationAt,
9175
9238
  offCalendarAt,
9176
9239
  offCalendarReasonAt,
9177
9240
  operationalKindOf,
9178
9241
  operationsCapabilityOf,
9242
+ outsideLimit,
9179
9243
  parseEpc,
9180
9244
  parseIsoDuration,
9181
9245
  partialFitPolicy,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.54",
3
+ "version": "0.7.55",
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": {