@operato/twin-kernel 0.7.53 → 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.
@@ -204,17 +204,34 @@ levelOfType?: (type: string) => EquipmentLevel | undefined): Hierarchy;
204
204
  * 템플릿이다. 즉 「모른다」가 아니라 「원천 쪽」이다. 표식은 그 기본값과 다를 때 달린다.
205
205
  */
206
206
  export type PropertySource = 'reference' | 'local';
207
- export interface ResourceProperty {
208
- /** 속성 식별자 — 표준 `ID`. 어휘는 표준이 정하지 않으므로 우리가 한 곳에서 정한다 — 그 자리는 읽는 쪽에 있다
209
- * (headless-twin `EQUIPMENT_PROPERTY`·`PROPERTY_EFFECTS`). 커널은 속성을 **나르기만** 한다. */
210
- id: string;
211
- description?: string;
207
+ /**
208
+ * **ISA-95 `ValueType`** — 값·데이터형·단위 한 묶음. 1차 출처(B2MML v0700+): `ValueType`
209
+ * (`B2MML-Common.xsd`)의 `ValueString`·`DataType`·`UnitOfMeasure`.
210
+ *
211
+ * ── 왜 따로 뽑나 (2026-08-24) ────────────────────────────────────────────────
212
+ * 표준은 이 묶음을 **여러 곳에서 재사용한다** — 자원 속성(`<X>PropertyType.Value`) · 시험 측정값
213
+ * (`PropertyMeasurementType.Value`) · 공정 모수 실측(`OpSegmentDataType.Value`) · 자리 속성
214
+ * (`OperationalLocationPropertyType.Value`). 처음에는 자원 속성에만 있었고 세 필드가 그 안에 인라인
215
+ * 이었다. 두 번째 자리가 생기는 순간 **같은 매핑을 두 번 적게 되고**, 그때부터 한쪽만 고쳐지는 길이
216
+ * 열린다(단위를 한쪽에서만 필수로 만들거나, `dataType` 주석이 갈리는 식으로).
217
+ *
218
+ * 표준이 한 타입으로 둔 것을 우리도 한 타입으로 둔다. **평평하게 펴는 것**은 우리 선택이다 —
219
+ * 표준은 `Value` 를 자식 요소로 두지만, 소비처가 `p.value` 로 읽는 편이 `p.value.valueString` 보다
220
+ * 낫고 이 저장소가 이미 그렇게 하고 있었다. 그 선택을 여기 적어 둔다(되돌리려면 소비처 전부가 움직인다).
221
+ */
222
+ export interface StandardValue {
212
223
  /** 표준 `Value.ValueString`. 값의 표기는 문자열이고, 뜻은 `dataType`·`uom` 이 정한다. */
213
224
  value?: string;
214
225
  /** 표준 `Value.DataType` — 예: `xs:double`. 미지정이면 소비처가 형을 짐작하지 않는다. */
215
226
  dataType?: string;
216
- /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`). **단위 없는 물리량은 쓰지 않는다.** */
227
+ /** 표준 `Value.UnitOfMeasure` — UN/CEFACT 공통코드(예: `MTS`·`KMH`·`CEL`). **단위 없는 물리량은 쓰지 않는다.** */
217
228
  uom?: string;
229
+ }
230
+ export interface ResourceProperty extends StandardValue {
231
+ /** 속성 식별자 — 표준 `ID`. 어휘는 표준이 정하지 않으므로 우리가 한 곳에서 정한다 — 그 자리는 읽는 쪽에 있다
232
+ * (headless-twin `EQUIPMENT_PROPERTY`·`PROPERTY_EFFECTS`). 커널은 속성을 **나르기만** 한다. */
233
+ id: string;
234
+ description?: string;
218
235
  /** 하위 속성 — 표준 `<X>PropertyChild`(재귀). 구조화된 속성(예: 정격/실측 묶음)을 잃지 않기 위해. */
219
236
  children?: ResourceProperty[];
220
237
  /** 이 개체 속성이 구체화하는 **등급 속성** — 표준 `<X>ClassPropertyID`. */
@@ -278,14 +295,29 @@ export type TestSpecificationRefs = string[];
278
295
  * **결과가 선언돼 있으면 그때부터 제약이 된다**: 불합격이거나 유효기간이 지났으면 그 등급으로 자격이
279
296
  * 성립하지 않는다. 그것이 이 값을 싣는 이유다(싣지 않으면 아무 제약도 없다).
280
297
  *
281
- * 필드 이름은 최소로 둔다 — 표준 결과 타입(`B2MML-OperationsTest.xsd`)과 아직 대조하지 않았다.
298
+ * ── 1차 출처와 대조했다 (2026-08-24) ─────────────────────────────────────────
299
+ * 예전에는 「필드 이름을 아직 대조하지 않았다」고 유보해 두었다. `B2MML-OperationsTest.xsd` 의
300
+ * `TestResultType` 을 읽었고, 그 결과 **둘이 갈렸다.**
301
+ *
302
+ * 표준이 준 것 `EvaluationDate` · `Expiration` · `TestableObjectID`
303
+ * · `EvaluatedCriterionResult` · `PropertyMeasurement`(unbounded)
304
+ * 우리가 좁힌 것 `result: 'pass' | 'fail'` — **표준은 판정을 열거하지 않는다**(`TextType`)
305
+ *
306
+ * 좁힘을 적어 두는 이유: HACCP 같은 규제 판정에는 「적합·부적합」 밖의 값이 온다(재검·조건부). 그때
307
+ * 이 유니온을 넓혀야 하는데, **좁힘이라고 적혀 있지 않으면 다음 사람이 「표준이 둘만 정했다」고 읽는다.**
308
+ *
309
+ * `at`·`expiresAt` 은 표준 `EvaluationDate`·`Expiration` 이다 — 이름을 바꾸지 않는다(이미 나간 계약이고,
310
+ * 개명이 사는 값은 이 주석이 대신 낸다).
282
311
  */
283
312
  export interface TestResult {
284
313
  /** 어느 시험인가 — `TestSpecification.id` 를 가리킨다. */
285
314
  specId: string;
286
- /** 합격 여부. 표준의 판정 어휘를 대조하기 전이므로 둘만 둔다(모르는 값을 만들지 않는다). */
315
+ /**
316
+ * 합격 여부 — **우리가 좁힌 것이다.** 표준 `EvaluatedCriterionResult` 는 `TextType` 이고
317
+ * 판정 어휘를 열거하지 않는다(원문 대조 2026-08-24). 넓힐 때는 이 주석과 함께 움직인다.
318
+ */
287
319
  result: 'pass' | 'fail';
288
- /** 언제 통과·불합격했나(ISO). */
320
+ /** 언제 통과·불합격했나(ISO) — 표준 `EvaluationDate`. */
289
321
  at?: ISOTime;
290
322
  /**
291
323
  * 언제까지 유효한가(ISO) — 자격에는 대개 유효기간이 있다.
@@ -294,6 +326,18 @@ export interface TestResult {
294
326
  * (유효기간을 모르는 것과 지난 것은 다르다).
295
327
  */
296
328
  expiresAt?: ISOTime;
329
+ /**
330
+ * 이 판정의 **근거가 된 측정값들** — 표준 `TestResult.PropertyMeasurement`(`maxOccurs="unbounded"`).
331
+ *
332
+ * ── 왜 판정에 값을 붙이나 (당위) ──────────────────────────────────────────
333
+ * 「부적합」이라고만 적힌 기록과 「4.2°C 를 재어 한계를 넘었다」는 기록은 **다른 물건**이다. 앞의
334
+ * 것으로는 되짚을 수 없다 — 얼마나 벗어났나, 언제였나, 다시 재면 같은 답이 나오나에 답할 수 없다.
335
+ * 규제 기록은 성질상 **사고 뒤에** 읽히므로, 근거 없는 판정은 그때 아무 일도 하지 못한다.
336
+ *
337
+ * **없어도 판정은 성립한다** — 원본이 결론만 주는 현장이 있고, 그때 값을 지어내지 않는다. 빈 것은
338
+ * `testEvidenceGaps` 가 세어 낸다(조용히 넘기지 않는다).
339
+ */
340
+ propertyMeasurements?: PropertyMeasurement[];
297
341
  }
298
342
  /**
299
343
  * 이 시험 결과가 **이 시각에 유효한 합격인가.**
@@ -348,6 +392,76 @@ export interface Capability {
348
392
  available: boolean;
349
393
  reason: CapabilityReason;
350
394
  }
395
+ /**
396
+ * **판정 기준 하나** — 표준 `TestSpecificationCriteriaType`(`B2MML-OperationsTest.xsd`).
397
+ *
398
+ * ── 왜 이 축이 필요한가 (당위) ────────────────────────────────────────────────
399
+ * 측정값만 있으면 트윈은 「4.2」를 들고 있을 뿐이고 **그것이 괜찮은지 모른다.** 판정의 재료는
400
+ * 「값」과 「기준」 둘이다. 기준이 없으면 트윈은 조건을 알면서도 아무 말도 할 수 없다 — 계기판이 된다.
401
+ *
402
+ * ── 커널은 기준을 **정하지 않고, 재해석하지도 않는다** ──────────────────────
403
+ * 기준은 그 현장의 규정이다(식품안전·약전·사내 규격). 트윈이 정할 것이 아니고, **표현식을 커널이
404
+ * 평가하지도 않는다** — 표준의 `Expression` 이 `TextType` 이라 문법이 정의돼 있지 않다. 문법을 우리가
405
+ * 만들면 그 순간 방언이 되고, 현장의 규정과 우리 해석이 갈리는 날 어느 쪽이 옳은지 아무도 모른다.
406
+ *
407
+ * 그러면 이 값이 무슨 일을 하나: **판정에 근거가 있는지를 물을 수 있게 한다**(§`testEvidenceGaps`).
408
+ * 「부적합」이라고만 적힌 기록과 「4.2°C 를 재어 한계 4.0 을 넘었다」는 기록은 다른 물건이다.
409
+ */
410
+ export interface TestSpecificationCriterion {
411
+ /** 표준 `ID`. */
412
+ id: string;
413
+ /** 표준 `Description`. */
414
+ description?: string;
415
+ /** 표준 `Sequence` — 기준이 여럿일 때의 순서. */
416
+ sequence?: number;
417
+ /**
418
+ * 표준 `Expression`(`TextType`) — **그 현장의 표기 그대로** 나른다. 커널은 이것을 평가하지 않는다.
419
+ *
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
+ * 있다는 사실을 확인해 준 것이다.
445
+ */
446
+ limit?: {
447
+ /** 이 값 미만이면 벗어난다. 없으면 아래쪽 한계가 없다(모르는 것이 아니라 없는 것이다). */
448
+ minimum?: number;
449
+ /** 이 값 초과면 벗어난다. */
450
+ maximum?: number;
451
+ /**
452
+ * 한계의 단위 — 표준 `UnitOfMeasure` 와 같은 어휘(UN/CEFACT).
453
+ *
454
+ * **없으면 짐작하지 않는다.** 관측도 단위를 말하지 않으면 같은 척도로 보고 비교하고(같은 선언에서
455
+ * 온 값들이다), **둘이 서로 다른 단위를 말하면 판정하지 않는다** — 섭씨 한계에 화씨 관측을
456
+ * 견주면 조용히 틀린다.
457
+ */
458
+ uom?: string;
459
+ };
460
+ /** 표준 `Result` — 이 기준이 만족될 때 기대하는 값(현장 표기). */
461
+ result?: string;
462
+ /** 표준 `EvaluatedPropertyID` — 이 기준이 **무엇을 재어** 판정하나. 측정값과 짝을 맞추는 키다. */
463
+ evaluatedPropertyId?: string;
464
+ }
351
465
  export interface TestSpecification {
352
466
  /** 표준 `ID` — 자원의 `testSpecificationIds` 가 이 값을 가리킨다. */
353
467
  id: string;
@@ -355,7 +469,84 @@ export interface TestSpecification {
355
469
  description?: string;
356
470
  /** 표준 `Version` — 같은 시험의 판이 바뀌면 자격의 뜻도 바뀐다(어느 판으로 검증했나). */
357
471
  version?: string;
472
+ /**
473
+ * 판정 기준들 — 표준 `TestSpecificationCriteria`(`maxOccurs="unbounded"`).
474
+ *
475
+ * 선언하지 않아도 명세는 성립한다(「무엇으로 검증했다고 하는지」를 이름으로 답하는 것이 그 최소
476
+ * 역할이었다). 선언하면 **판정에 근거가 있는지**를 물을 수 있게 된다.
477
+ */
478
+ criteria?: TestSpecificationCriterion[];
358
479
  }
480
+ /**
481
+ * **측정값 하나** — 표준 `PropertyMeasurementType`(`B2MML-OperationsTest.xsd`).
482
+ *
483
+ * 표준에서 이것은 `TestResult` 의 자식이다: 판정 하나가 **자기 근거가 된 측정들을 든다.** 값의 모양은
484
+ * `ValueType` 이라 `StandardValue` 를 그대로 쓴다 — 단위 없는 물리량은 쓰지 않는다는 규율이 여기에도 산다.
485
+ */
486
+ export interface PropertyMeasurement extends StandardValue {
487
+ /** 표준 `ID`. */
488
+ id?: string;
489
+ /** 표준 `Description`. */
490
+ description?: string;
491
+ /** 표준 `TestableObjectPropertyID` — **무엇을 쟀나**(그 개체의 어느 속성인가). 기준과 짝을 맞추는 키다. */
492
+ testableObjectPropertyId?: string;
493
+ /** 표준 `MeasurementDate` — 언제 쟀나. */
494
+ measurementDate?: ISOTime;
495
+ /**
496
+ * 이 값이 **잰 것이 아니라 접은 것**임을 밝힌다 — §`DemandWindowState.derived` 와 같은 규율.
497
+ *
498
+ * 트윈은 원본이 주지 않는 값을 계산할 수 있다(그것이 트윈의 존재 이유다). 그런데 그 수를 실측과
499
+ * **같은 자리에 같은 모양으로** 두면 화면·보고서가 그것을 잰 값으로 읽는다. 규제 기록에서 그
500
+ * 구별이 사라지는 것은 결함이 아니라 사고다. 그래서 값 옆에 종류를 함께 싣는다.
501
+ */
502
+ derived?: boolean;
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;
532
+ /**
533
+ * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
534
+ *
535
+ * ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
536
+ * 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
537
+ * **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
538
+ * 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
539
+ *
540
+ * unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
541
+ * unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
542
+ *
543
+ * 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
544
+ * 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
545
+ */
546
+ export declare function testEvidenceGaps(spec: Pick<TestSpecification, 'criteria'> | undefined, result: Pick<TestResult, 'propertyMeasurements'> | undefined): {
547
+ unmeasured: string[];
548
+ unmatched: string[];
549
+ };
359
550
  /**
360
551
  * 유효 기간 — **ISA-95 `EffectiveStartDate` / `EffectiveEndDate`.**
361
552
  *
@@ -817,7 +1008,105 @@ export interface LocationState {
817
1008
  * 보드에 좌표가 없고 용량이 비어 있어 **계획에 참여하지 못한다**(그 사실을 감추지 않기 위한 표시).
818
1009
  */
819
1010
  origin?: 'master' | 'observed';
1011
+ /**
1012
+ * 이 자리에서 관측된 **물리량들의 마지막 값** — 속성마다 하나(§`LocationObservation`).
1013
+ *
1014
+ * 이력이 아니라 **지금 값**이다: 이력은 저널이 든다(그것이 「그때 그 방이 몇 도였나」에 답하는
1015
+ * 자리다). 여기 두는 이유는 판정이 지금 값을 보기 때문이고, 상태가 시간에 비례해 자라지 않게
1016
+ * **속성당 하나**만 든다.
1017
+ *
1018
+ * 없으면 **두지 않는다** — 빈 배열을 두면 「센서가 없는 자리」와 「아직 못 들은 자리」가 같아진다.
1019
+ */
1020
+ observations?: LocationObservation[];
820
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;
821
1110
  /**
822
1111
  * 물품 상태 — 표준이 담는 것을 담는다(EPCIS 2.0 / TDS).
823
1112
  *
@@ -1557,6 +1846,28 @@ export interface StateSnapshot {
1557
1846
  firstAtMs?: number;
1558
1847
  lastAtMs?: number;
1559
1848
  }[];
1849
+ /**
1850
+ * **개체를 잇지 못한 채 다음 공정으로 넘어간 횟수** — 계보에 구멍이 남았다.
1851
+ *
1852
+ * ── 왜 이 값이 필요했나 (2026-08-24 실측) ────────────────────────────────────
1853
+ * 미러의 오더는 **확보분을 가진 적이 없다.** 원본이 「이 오더에 어느 개체가 잡혀 있나」를 말하지
1854
+ * 않는 것이 정상이다(첫 실 연동: 오더 사실이 `status`·`requested`·`fulfilled` 만 든다). 그런데
1855
+ * 커널이 중간 공정을 넘을 때 **들고 갈 개체를 요구하고 던졌다** — 그래서 미러에서 예측이
1856
+ * 구조적으로 실패했다(`twinForecast` 가 오더 하나 때문에 전부 실패).
1857
+ *
1858
+ * 진행을 막은 것이 과했다. **단계를 넘는 것은 공정의 진행이고 개체를 잇는 것은 계보**다. 계보를
1859
+ * 못 이으면 계보를 비우는 것이 맞고, 그렇다고 진행까지 멈추면 있는 답(언제 끝나나·처리량)까지 잃는다.
1860
+ *
1861
+ * 그래서 넘기되 **세어 낸다.** 세지 않으면 계보에 구멍이 있는데 화면이 정상으로 보인다 —
1862
+ * `unhandled` 를 스냅샷이 떨어뜨려 정합성 검사가 영원히 조용했던 것과 같은 부류의 실수다.
1863
+ *
1864
+ * **목록이 아니라 수다**: 오더 수가 규모에 비례해 자라므로 목록은 잘라야 하고, 자르면 「조용히
1865
+ * 자르지 않는다」를 지킬 수 없다. 어느 오더인지는 그 오더의 계보가 빈 것으로 답한다.
1866
+ *
1867
+ * 자체 구동(시뮬)에서는 **이 값이 오르지 않는다** — 시뮬은 개체를 스스로 만들므로 확보분이 비는
1868
+ * 것이 곧 결함이고, 거기서는 여전히 던진다(진짜 결함을 조용하게 만들지 않는다).
1869
+ */
1870
+ stepsWithoutMaterial?: number;
1560
1871
  /**
1561
1872
  * 확인(ack)해 둔 주목 신호 id — **상태에서 파생되지 않는 유일한 축.**
1562
1873
  *
package/dist/contract.js CHANGED
@@ -245,6 +245,92 @@ 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
+ }
305
+ /**
306
+ * **이 판정에 근거가 있나** — 기준과 측정값의 짝을 맞춰 빈 곳을 돌려준다.
307
+ *
308
+ * ── 왜 커널이 이것을 답하나 ─────────────────────────────────────────────────
309
+ * 커널은 기준을 **평가하지 않는다**(`Expression` 은 문법이 정의되지 않은 자유 문자열). 그러나
310
+ * **짝이 맞는지는 문법을 몰라도 알 수 있다** — 그리고 그것이 이 축을 실은 이유다: 「부적합」이라고만
311
+ * 적힌 기록과 근거를 든 기록을 화면이 구별할 수 있어야 한다.
312
+ *
313
+ * unmeasured 기준은 있는데 그것을 잰 값이 없다 — **판정의 근거가 비었다**
314
+ * unmatched 측정값은 있는데 그것을 요구한 기준이 없다 — 어느 규정으로 잰 것인지 모른다
315
+ *
316
+ * 판정하지 않고 **세어서 낸다**: 이 저장소는 빈 것을 조용히 넘기지 않는다(§`ObservedReducer.unhandled`).
317
+ * 그리고 짝의 키는 표준이 준 것 그대로다 — `EvaluatedPropertyID` ↔ `TestableObjectPropertyID`.
318
+ */
319
+ export function testEvidenceGaps(spec, result) {
320
+ const criteria = spec?.criteria ?? [];
321
+ const measurements = result?.propertyMeasurements ?? [];
322
+ /* 기준이 재는 속성들 / 실제로 잰 속성들 — 속성 id 를 말하지 않은 것은 짝을 맞출 수 없으므로 뺀다. */
323
+ const wanted = new Set(criteria.map(c => c.evaluatedPropertyId).filter((k) => !!k));
324
+ const got = new Set(measurements.map(m => m.testableObjectPropertyId).filter((k) => !!k));
325
+ return {
326
+ unmeasured: criteria
327
+ .filter(c => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId))
328
+ .map(c => c.id),
329
+ unmatched: measurements
330
+ .map(m => m.testableObjectPropertyId)
331
+ .filter((k) => !!k && !wanted.has(k))
332
+ };
333
+ }
248
334
  /**
249
335
  * 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
250
336
  *
@@ -753,6 +839,40 @@ export function capabilityOf(r, ctx) {
753
839
  return { available: false, reason: 'working' };
754
840
  return { available: true, reason: 'available' };
755
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
+ }
756
876
  // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
757
877
  // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·equipment·orders 의 운영 상태는
758
878
  // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
@@ -560,6 +560,19 @@ export declare abstract class FlowEngine implements TwinKernel {
560
560
  protected localParams: Map<string, Map<string, string>>;
561
561
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
562
562
  private observer?;
563
+ /**
564
+ * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
565
+ *
566
+ * 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
567
+ * 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
568
+ * 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
569
+ *
570
+ * fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
571
+ * 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
572
+ */
573
+ protected get observationDriven(): boolean;
574
+ /** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
575
+ protected stepsWithoutMaterial: number;
563
576
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
564
577
  /**
565
578
  * **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
@@ -573,6 +573,21 @@ export class FlowEngine {
573
573
  localParams = new Map();
574
574
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
575
575
  observer;
576
+ /**
577
+ * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
578
+ *
579
+ * 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
580
+ * 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
581
+ * 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
582
+ *
583
+ * fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
584
+ * 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
585
+ */
586
+ get observationDriven() {
587
+ return !!this.observer;
588
+ }
589
+ /** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
590
+ stepsWithoutMaterial = 0;
576
591
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
577
592
  /**
578
593
  * **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
@@ -1528,6 +1543,8 @@ export class FlowEngine {
1528
1543
  const u = this.observer?.snapshot?.().unhandled;
1529
1544
  return u?.length ? { unhandled: u } : {};
1530
1545
  })(),
1546
+ /* 계보를 잇지 못한 채 넘어간 횟수 — 같은 규율(세기만 하고 내보내지 않으면 없는 것과 같다). */
1547
+ ...(this.stepsWithoutMaterial > 0 ? { stepsWithoutMaterial: this.stepsWithoutMaterial } : {}),
1531
1548
  tasks: [...this.tasks.values()].map(t => ({
1532
1549
  id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
1533
1550
  fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
@@ -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
  }
@@ -275,6 +275,13 @@ export declare class MesKernel extends FlowEngine {
275
275
  private onOrderDef;
276
276
  /** 정의 모드 할당 — 레시피 입력 BOM 전량 확보 후 첫 라우트 스텝 태스크. */
277
277
  private allocateDef;
278
+ /**
279
+ * 공정 하나의 작업을 낸다.
280
+ *
281
+ * `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
282
+ * 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
283
+ * `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
284
+ */
278
285
  private emitStationDef;
279
286
  /** 정의 모드 완료 — 라우트 인덱스: 중간=WIP 변환+다음 스텝, 마지막=완제품(수율). */
280
287
  private onTaskCompleteDef;
@@ -811,9 +811,16 @@ export class MesKernel extends FlowEngine {
811
811
  o.status = 'op-' + ops[0].key;
812
812
  this.emitOrder(o);
813
813
  }
814
+ /**
815
+ * 공정 하나의 작업을 낸다.
816
+ *
817
+ * `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
818
+ * 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
819
+ * `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
820
+ */
814
821
  emitStationDef(o, op, itemEpc) {
815
822
  const loc = this.locationByType(op.locationType);
816
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
823
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: 'created', itemEpc: itemEpc ?? '', fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
817
824
  this.tasks.set(task.id, task);
818
825
  this.emitTask(task);
819
826
  }
@@ -849,10 +856,34 @@ export class MesKernel extends FlowEngine {
849
856
  }
850
857
  /* 다음 자리에 무엇을 들고 가는가 — 만든 것이 있으면 그것, 없으면 들고 있던 것. */
851
858
  const carried = order.allocated[0];
852
- if (!carried) {
859
+ /*
860
+ * ── 개체가 없어도 **진행은 막지 않는다** (2026-08-24) ───────────────────────
861
+ * 예전에는 여기서 무조건 던졌다. 그래서 **미러에서 예측이 구조적으로 실패했다**: 미러의 오더는
862
+ * 확보분을 가진 적이 없고(원본이 「어느 개체가 잡혀 있나」를 말하지 않는 것이 정상이다 — 첫 실
863
+ * 연동의 오더 사실은 `status`·`requested`·`fulfilled` 뿐이다), 그 오더가 중간 공정을 넘는
864
+ * 순간마다 터졌다. 오더 하나 때문에 `twinForecast` 가 통째로 실패했다.
865
+ *
866
+ * 두 가지를 섞은 것이 잘못이었다.
867
+ *
868
+ * 단계를 넘는 것 공정의 **진행**이다 — 라우트·소요·용량이 답한다. 개체 번호가 필요 없다
869
+ * 개체를 잇는 것 **계보**다 — 개체가 없으면 이을 수 없다
870
+ *
871
+ * 계보를 못 이으면 **계보를 비우는 것**이 맞다(지어내지 않는다 — 예전에 `'WIP'` 를 지어내던
872
+ * 그 자리다). 그런데 진행까지 멈추면 있는 답(언제 끝나나·처리량)까지 잃는다. **없어도 되는
873
+ * 것을 요구해서 있는 답을 잃고 있었다.**
874
+ *
875
+ * ── 그러나 시뮬에서는 여전히 결함이다 ────────────────────────────────────
876
+ * 자체 구동은 개체를 스스로 만든다. 거기서 확보분이 비면 `allocateDef` 가 확보하지 못했거나
877
+ * 선언이 어긋난 것이고, 조용히 넘기면 **계보가 통째로 빈 시뮬**이 정상처럼 보인다. 그래서
878
+ * 관측 구동일 때만 넘긴다(§`observationDriven`).
879
+ */
880
+ if (!carried && !this.observationDriven) {
853
881
  throw new Error(`order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' — the order holds no allocated material ` +
854
882
  `and this step declares no produced material. Declare the step's input or its output in the model.`);
855
883
  }
884
+ /* 넘기되 **세어 낸다** — 세지 않으면 계보에 구멍이 있는데 화면이 정상으로 보인다. */
885
+ if (!carried)
886
+ this.stepsWithoutMaterial++;
856
887
  this.emitStationDef(order, next, carried);
857
888
  order.status = 'op-' + next.key;
858
889
  this.emitOrder(order);
@@ -898,6 +929,24 @@ export class MesKernel extends FlowEngine {
898
929
  this.emitOrder(order);
899
930
  return;
900
931
  }
932
+ /*
933
+ * ── 원본이 개체를 말하지 않는 현장 (2026-08-24) ────────────────────────────
934
+ * 위 `seedIncomplete` 는 **우리가 못 심은 것**이다(원본이 말한 물품이 스냅샷에 없었다). 이것은
935
+ * 다른 사실이다: **원본이 애초에 말하지 않는다.** 첫 실 연동의 오더 사실은 `status`·`requested`
936
+ * ·`fulfilled` 뿐이고, 「어느 개체가 잡혀 있나」를 주지 않는 것이 그 시스템의 정상이다.
937
+ *
938
+ * 두 사실을 한 값으로 뭉개지 않는다 — 앞의 것은 고칠 수 있는 결함이고 뒤의 것은 원본의 성질이다.
939
+ *
940
+ * **여기서는 진행도 넘기지 않는다.** 중간 단계는 넘긴다(§`onTaskCompleteDef` — 단계를 넘는 것은
941
+ * 공정의 진행이고 개체가 필요 없다). 그러나 마지막 단계는 **산출을 만든다**: 소비할 것 없이
942
+ * 제품을 내면 「일어나지 않은 생산」을 기록하는 것이고, 그것은 계보가 비는 것보다 나쁘다.
943
+ * 그래서 막혔다고 말하고 멈춘다 — 이유가 상태에 남으므로 화면이 원인을 지어내지 않는다.
944
+ */
945
+ if (this.observationDriven) {
946
+ order.status = 'blocked-source-omits-material';
947
+ this.emitOrder(order);
948
+ return;
949
+ }
901
950
  throw new Error(`order ${order.id} at final step '${t.kind}': nothing allocated to consume — the kernel does not make a ` +
902
951
  `product out of nothing. Declare the recipe inputs, or the step's produced material.`);
903
952
  }
@@ -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,
@@ -168,6 +171,7 @@ __export(index_exports, {
168
171
  ssccUri: () => ssccUri,
169
172
  stateFieldsOf: () => stateFieldsOf,
170
173
  subLotIdOf: () => subLotIdOf,
174
+ testEvidenceGaps: () => testEvidenceGaps,
171
175
  testPassedAt: () => testPassedAt,
172
176
  transactionEvent: () => transactionEvent,
173
177
  transformationEvent: () => transformationEvent,
@@ -289,6 +293,38 @@ function meetsTests(required, results, at) {
289
293
  }
290
294
  return true;
291
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
+ }
318
+ function testEvidenceGaps(spec, result) {
319
+ const criteria = spec?.criteria ?? [];
320
+ const measurements = result?.propertyMeasurements ?? [];
321
+ const wanted = new Set(criteria.map((c) => c.evaluatedPropertyId).filter((k) => !!k));
322
+ const got = new Set(measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k));
323
+ return {
324
+ unmeasured: criteria.filter((c) => c.evaluatedPropertyId && !got.has(c.evaluatedPropertyId)).map((c) => c.id),
325
+ unmatched: measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k && !wanted.has(k))
326
+ };
327
+ }
292
328
  function effectivityAt(p, at) {
293
329
  if (!p || !at) return void 0;
294
330
  const atMs = parsedMs(at);
@@ -521,6 +557,26 @@ function capabilityOf(r, ctx) {
521
557
  if (r.status && r.status !== "idle" && r.status !== "available") return { available: false, reason: "working" };
522
558
  return { available: true, reason: "available" };
523
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
+ }
524
580
  var OP_EVENT = {
525
581
  task: "task.status",
526
582
  equipment: "equipment.status",
@@ -814,13 +870,16 @@ function clockOf2(twin) {
814
870
  function monteCarloForecast(twin, opts) {
815
871
  const now = clockOf2(twin);
816
872
  const samples = [];
873
+ let gaps = 0;
817
874
  for (let i = 0; i < opts.runs; i++) {
818
875
  const { fc, step, target } = startRun(twin, opts, now, i);
819
876
  let guard = 0;
820
877
  while (clockOf2(fc) < target && guard++ < 1e6) fc.tick(step);
821
- 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));
822
881
  }
823
- return summarize(opts.runs, samples);
882
+ return summarize(opts.runs, samples, gaps);
824
883
  }
825
884
  function startRun(twin, opts, nowMs, i) {
826
885
  const fc = twin.fork();
@@ -833,6 +892,7 @@ async function monteCarloForecastAsync(twin, opts) {
833
892
  const yieldFn = opts.yieldFn ?? (() => Promise.resolve());
834
893
  const everyTicks = Math.max(1, opts.yieldEveryTicks ?? 50);
835
894
  const samples = [];
895
+ let gaps = 0;
836
896
  for (let i = 0; i < opts.runs; i++) {
837
897
  const { fc, step, target } = startRun(twin, opts, now, i);
838
898
  let guard = 0;
@@ -844,16 +904,28 @@ async function monteCarloForecastAsync(twin, opts) {
844
904
  await yieldFn();
845
905
  }
846
906
  }
847
- 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));
848
910
  if (i < opts.runs - 1) await yieldFn();
849
911
  }
850
- return summarize(opts.runs, samples);
912
+ return summarize(opts.runs, samples, gaps);
851
913
  }
852
- function summarize(runs, samples) {
914
+ function summarize(runs, samples, stepsWithoutMaterial = 0) {
853
915
  const sorted = [...samples].sort((a, b) => a - b);
854
916
  const pct = (p) => sorted[Math.min(sorted.length - 1, Math.floor(p * sorted.length))];
855
917
  const mean = samples.reduce((a, b) => a + b, 0) / (samples.length || 1);
856
- 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
+ };
857
929
  }
858
930
 
859
931
  // src/twin-observer.ts
@@ -3729,6 +3801,21 @@ var FlowEngine = class {
3729
3801
  localParams = /* @__PURE__ */ new Map();
3730
3802
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
3731
3803
  observer;
3804
+ /**
3805
+ * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
3806
+ *
3807
+ * 자체 구동(시뮬)과 가르는 축이다. 두 구동은 **같은 빈 값에 다른 뜻**을 갖는다: 시뮬에서 확보분이
3808
+ * 비는 것은 결함이고, 미러에서는 원본이 말하지 않는 정상이다. 그 구분 없이 한쪽 규칙을 쓰면
3809
+ * 시뮬의 진짜 결함이 조용해지거나 미러가 정상 상태에서 죽는다.
3810
+ *
3811
+ * fork 도 이 표식을 물려받는다(§`fork` — skip 목록에 없다). 예측은 미러에서 갈라져 나오므로
3812
+ * 그것이 맞다: 예측이 든 상태는 여전히 **관측에서 온 상태**다.
3813
+ */
3814
+ get observationDriven() {
3815
+ return !!this.observer;
3816
+ }
3817
+ /** 계보를 잇지 못한 채 넘어간 횟수 — §`StateSnapshot.stepsWithoutMaterial`. */
3818
+ stepsWithoutMaterial = 0;
3732
3819
  /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
3733
3820
  /**
3734
3821
  * **관측 모드에서 원본과 어긋난 횟수** — 받아들였지만 사실이 맞지 않았다.
@@ -4501,6 +4588,8 @@ var FlowEngine = class {
4501
4588
  const u = this.observer?.snapshot?.().unhandled;
4502
4589
  return u?.length ? { unhandled: u } : {};
4503
4590
  })(),
4591
+ /* 계보를 잇지 못한 채 넘어간 횟수 — 같은 규율(세기만 하고 내보내지 않으면 없는 것과 같다). */
4592
+ ...this.stepsWithoutMaterial > 0 ? { stepsWithoutMaterial: this.stepsWithoutMaterial } : {},
4504
4593
  tasks: [...this.tasks.values()].map((t) => ({
4505
4594
  id: t.id,
4506
4595
  kind: t.kind,
@@ -7598,9 +7687,16 @@ var MesKernel = class extends FlowEngine {
7598
7687
  o.status = "op-" + ops[0].key;
7599
7688
  this.emitOrder(o);
7600
7689
  }
7690
+ /**
7691
+ * 공정 하나의 작업을 낸다.
7692
+ *
7693
+ * `itemEpc` 가 **비어 있을 수 있다**(빈 문자열) — 원본이 개체를 말하지 않는 미러에서 그렇다. 그때
7694
+ * 이 작업은 「무엇을」 없이 「어디서 무엇을 한다」만 안다. 빈 값으로 두는 것이 규약이다(§1453 의
7695
+ * `t.itemRefs?.[0] ?? ''` 와 같은 자리) — 없는 개체를 지어내지 않는다.
7696
+ */
7601
7697
  emitStationDef(o, op, itemEpc) {
7602
7698
  const loc = this.locationByType(op.locationType);
7603
- const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc, fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
7699
+ const task = { id: `task-${++this.taskSeq}`, kind: op.key, status: "created", itemEpc: itemEpc ?? "", fromNode: loc.id, toNode: loc.id, resource: null, remainingMs: 0, durationMs: this.durationOf({ kind: op.key, fromNode: loc.id, toNode: loc.id, resourceKind: op.resourceType }, DEFAULT_CYCLE_MS), orderId: o.id, resourceType: op.resourceType, changeoverKey: o.gtin, setupMs: this.paramDuration(op.key, OP_PARAM.setupDuration) ?? DEFAULT_SETUP_MS, intent: op.intent };
7604
7700
  this.tasks.set(task.id, task);
7605
7701
  this.emitTask(task);
7606
7702
  }
@@ -7624,11 +7720,12 @@ var MesKernel = class extends FlowEngine {
7624
7720
  order.allocated = [made];
7625
7721
  }
7626
7722
  const carried = order.allocated[0];
7627
- if (!carried) {
7723
+ if (!carried && !this.observationDriven) {
7628
7724
  throw new Error(
7629
7725
  `order ${order.id} at step '${t.kind}': nothing to carry to '${next.key}' \u2014 the order holds no allocated material and this step declares no produced material. Declare the step's input or its output in the model.`
7630
7726
  );
7631
7727
  }
7728
+ if (!carried) this.stepsWithoutMaterial++;
7632
7729
  this.emitStationDef(order, next, carried);
7633
7730
  order.status = "op-" + next.key;
7634
7731
  this.emitOrder(order);
@@ -7643,6 +7740,11 @@ var MesKernel = class extends FlowEngine {
7643
7740
  this.emitOrder(order);
7644
7741
  return;
7645
7742
  }
7743
+ if (this.observationDriven) {
7744
+ order.status = "blocked-source-omits-material";
7745
+ this.emitOrder(order);
7746
+ return;
7747
+ }
7646
7748
  throw new Error(
7647
7749
  `order ${order.id} at final step '${t.kind}': nothing allocated to consume \u2014 the kernel does not make a product out of nothing. Declare the recipe inputs, or the step's produced material.`
7648
7750
  );
@@ -9085,6 +9187,7 @@ function retiredVocabularyIn(line) {
9085
9187
  constantDuration,
9086
9188
  conversionFactorOf,
9087
9189
  counterfactualAt,
9190
+ criterionSaysNothing,
9088
9191
  demandWindowStart,
9089
9192
  deriveAttentions,
9090
9193
  documentPath,
@@ -9131,10 +9234,12 @@ function retiredVocabularyIn(line) {
9131
9234
  monteCarloForecastAsync,
9132
9235
  objectEvent,
9133
9236
  objectUri,
9237
+ observationAt,
9134
9238
  offCalendarAt,
9135
9239
  offCalendarReasonAt,
9136
9240
  operationalKindOf,
9137
9241
  operationsCapabilityOf,
9242
+ outsideLimit,
9138
9243
  parseEpc,
9139
9244
  parseIsoDuration,
9140
9245
  partialFitPolicy,
@@ -9157,6 +9262,7 @@ function retiredVocabularyIn(line) {
9157
9262
  ssccUri,
9158
9263
  stateFieldsOf,
9159
9264
  subLotIdOf,
9265
+ testEvidenceGaps,
9160
9266
  testPassedAt,
9161
9267
  transactionEvent,
9162
9268
  transformationEvent,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.53",
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": {