@operato/twin-kernel 0.7.54 → 0.7.56

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
+ * 기준은 아무 한계도 말하지 않으면서 「기준이 선언됐다」는 착각만 만든다.
422
425
  */
423
- expression: string;
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
+ * 이 모양으로 한계를 주는 실 원본이 있다는 것도 확인했다. **그것이 이 축을 정한 것은 아니다** —
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
+ };
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
  *
@@ -1908,6 +2070,18 @@ export declare const OP_EVENT: {
1908
2070
  * 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
1909
2071
  */
1910
2072
  readonly attentionAck: "attention.acked";
2073
+ /**
2074
+ * **자리에서 관측된 물리량** — 냉장실 온도·습도, 세척수 유량 같은 것(§`LocationObservation`).
2075
+ *
2076
+ * ── 왜 채널이 필요한가 ────────────────────────────────────────────────────
2077
+ * `LocationState.observations` 를 상태에 두었는데 그것을 낳는 사건이 없었다. 그러면 **상태 ⊆ 이벤트**
2078
+ * 가 깨진다: 채워도 재기동에서 사라지고, 폴드가 되살릴 수 없고, 미러가 이어받을 수도 없다. 상태에만
2079
+ * 있는 축은 조용히 사라지는 축이다.
2080
+ *
2081
+ * 이름을 `energy.measured` 와 같은 결로 둔다 — 그쪽이 에너지 계량의 도착이고 이쪽이 그 일반형이다.
2082
+ * 두 채널을 합치지 않는 이유는 에너지 쪽이 **구간에 누적되는 표본**이라 처리가 다르기 때문이다.
2083
+ */
2084
+ readonly observation: "location.measured";
1911
2085
  };
1912
2086
  /**
1913
2087
  * 에너지 트윈의 사건 — **물(物)의 계보가 아니라 스칼라의 시계열.**
@@ -2246,6 +2420,7 @@ export interface TwinModelDef {
2246
2420
  parallelism?: number;
2247
2421
  parentId?: string;
2248
2422
  properties?: ResourceProperty[];
2423
+ testSpecificationIds?: TestSpecificationRefs;
2249
2424
  }[];
2250
2425
  /**
2251
2426
  * 설비(설비). mtbfMs/mttrMs 지정 시 확률적 고장 모델 참여(OEE Availability 손실). 미지정=고장 없음.
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'.
@@ -807,7 +898,19 @@ export const OP_EVENT = {
807
898
  * "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
808
899
  * 구별되지 않는다.
809
900
  */
810
- attentionAck: 'attention.acked'
901
+ attentionAck: 'attention.acked',
902
+ /**
903
+ * **자리에서 관측된 물리량** — 냉장실 온도·습도, 세척수 유량 같은 것(§`LocationObservation`).
904
+ *
905
+ * ── 왜 채널이 필요한가 ────────────────────────────────────────────────────
906
+ * `LocationState.observations` 를 상태에 두었는데 그것을 낳는 사건이 없었다. 그러면 **상태 ⊆ 이벤트**
907
+ * 가 깨진다: 채워도 재기동에서 사라지고, 폴드가 되살릴 수 없고, 미러가 이어받을 수도 없다. 상태에만
908
+ * 있는 축은 조용히 사라지는 축이다.
909
+ *
910
+ * 이름을 `energy.measured` 와 같은 결로 둔다 — 그쪽이 에너지 계량의 도착이고 이쪽이 그 일반형이다.
911
+ * 두 채널을 합치지 않는 이유는 에너지 쪽이 **구간에 누적되는 표본**이라 처리가 다르기 때문이다.
912
+ */
913
+ observation: 'location.measured'
811
914
  };
812
915
  // ── 에너지(EMS) 사건 — 네 번째 종류의 어휘 ────────────────────────────────
813
916
  /**
package/dist/epcis.d.ts CHANGED
@@ -8,6 +8,58 @@ export declare const DISP: {
8
8
  readonly reserved: "urn:epcglobal:cbv:disp:reserved";
9
9
  readonly in_transit: "urn:epcglobal:cbv:disp:in_transit";
10
10
  readonly non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other";
11
+ /**
12
+ * **기한이 지났다** — CBV `expired`.
13
+ *
14
+ * ── 왜 `non_sellable` 로 접지 않나 (2026-08-24) ─────────────────────────────
15
+ * 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
16
+ * 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
17
+ * 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
18
+ *
19
+ * 그리고 표준에 **정확한 낱말이 있다** — 접는 것은 있는 낱말을 버리는 것이다.
20
+ *
21
+ * ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
22
+ * `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
23
+ * 「기한 지남」을 상태로 선언하는 시스템이 있다 — 그때 이 값은 관측이다.
24
+ *
25
+ * 둘이 어긋나면(날짜는 남았는데 상태가 지남, 또는 그 반대) **어느 쪽이 맞다고 정하지 않는다** —
26
+ * 아직 그 판정을 세울 근거가 없다. 어긋남의 구분을 없애지 않는 것이 지금의 규율이다.
27
+ *
28
+ * ── 원문으로 확인했다 (2026-08-24) ────────────────────────────────────────
29
+ * 1차 출처: **CBV Standard Release 2.0, Ratified Jun 2022** §7.2.3 처분 값 표(38개). 이 객체의
30
+ * 다른 값들(`in_progress`·`sellable_accessible`·`reserved`·`in_transit`·`non_sellable_other`)도
31
+ * 그 표에 있다.
32
+ *
33
+ * **`non_sellable_expired` 를 쓰지 않는 이유**: 그 값은 CBV 1.0 의 것이고 표준이 **폐기**했다 —
34
+ * 「deprecated in favour of new disposition values expired, damaged, disposed, … introduced in
35
+ * CBV 1.1」. 폐기된 값을 쓰면 새 소비처가 읽지 못한다.
36
+ *
37
+ * 참고: GS1 어휘 등록처(`ref.gs1.org/cbv/…`)로는 확인할 수 없었다 — **없는 값에도 같은 응답**을
38
+ * 준다(지어낸 값의 JSON-LD 가 실재 값과 바이트까지 같았다). 그 경로를 근거로 삼지 말 것.
39
+ */
40
+ readonly expired: "urn:epcglobal:cbv:disp:expired";
41
+ /**
42
+ * **검사에 합격했다 / 불합격했다** — CBV `conformant` / `non_conformant`.
43
+ *
44
+ * 1차 출처(CBV 2.0 §7.2.3) 정의 그대로다.
45
+ *
46
+ * conformant Outcome of a successful/passed inspection in an inspecting or repairing step
47
+ * non_conformant Outcome of an unsuccessful/failed inspection in an inspecting or repairing step
48
+ *
49
+ * ── 왜 시험 결과 축을 자원에 더하지 않고 이것을 쓰나 (2026-08-24) ────────────
50
+ * 로트의 검사 판정을 담을 자리를 찾다가 `ItemState.testResults` 를 더하려 했다. 그런데 표준은 그
51
+ * 사실을 **이미 처분으로 말한다**: `bizStep: inspecting` 사건에 이 처분이 붙는다.
52
+ *
53
+ * 처분을 쓰면 두 가지가 공짜로 성립한다.
54
+ * ① **상태 ⊆ 이벤트** — 처분은 이미 사건에서 온다. 상태에만 있는 축을 만들지 않는다
55
+ * ② **운영에 곧 닿는다** — 「이 자재를 쓸 수 있나」가 처분으로 답해진다(판정을 따로 읽지 않는다)
56
+ *
57
+ * 시험의 **자세한 내용**(어느 명세로, 무엇을 재어)은 다른 물음이고, 표준은 그것을 `TestResult` 로
58
+ * 두며 결과가 대상을 가리킨다(`TestableObjectID`) — 대상이 결과를 들지 않는다. 그 축이 필요해지면
59
+ * 그때 열되, **판정 자체는 여기서 끝난다.**
60
+ */
61
+ readonly conformant: "urn:epcglobal:cbv:disp:conformant";
62
+ readonly non_conformant: "urn:epcglobal:cbv:disp:non_conformant";
11
63
  };
12
64
  /**
13
65
  * **자재 소비·산출의 CBV 단계** — 도메인 무관하게 코어가 쓴다.
@@ -20,6 +72,15 @@ export declare const CBV_BIZSTEP: {
20
72
  readonly consuming: "urn:epcglobal:cbv:bizstep:consuming";
21
73
  /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
22
74
  readonly commissioning: "urn:epcglobal:cbv:bizstep:commissioning";
75
+ /**
76
+ * **검사** — CBV `inspecting`. 1차 출처(CBV 2.0) 정의: 「Process of reviewing objects to address
77
+ * potential physical or documentation defects」이고, 「표본과 달리 검사된 대상은 그대로 남는다」고
78
+ * 이어진다(즉 검사는 물건을 소비하지 않는다).
79
+ *
80
+ * 이 단계에 `DISP.conformant`/`DISP.non_conformant` 가 붙어 판정이 처분으로 남는다 — 입고검수·
81
+ * 공정 중 검사가 그 모양이다.
82
+ */
83
+ readonly inspecting: "urn:epcglobal:cbv:bizstep:inspecting";
23
84
  };
24
85
  export type EpcisEventType = 'ObjectEvent' | 'AggregationEvent' | 'TransactionEvent' | 'TransformationEvent';
25
86
  export type EpcisAction = 'ADD' | 'OBSERVE' | 'DELETE';
package/dist/epcis.js CHANGED
@@ -16,7 +16,59 @@ export const DISP = {
16
16
  sellable: 'urn:epcglobal:cbv:disp:sellable_accessible',
17
17
  reserved: 'urn:epcglobal:cbv:disp:reserved',
18
18
  in_transit: 'urn:epcglobal:cbv:disp:in_transit',
19
- non_sellable: 'urn:epcglobal:cbv:disp:non_sellable_other' // 불량/scrap
19
+ non_sellable: 'urn:epcglobal:cbv:disp:non_sellable_other', // 불량/scrap
20
+ /**
21
+ * **기한이 지났다** — CBV `expired`.
22
+ *
23
+ * ── 왜 `non_sellable` 로 접지 않나 (2026-08-24) ─────────────────────────────
24
+ * 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
25
+ * 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
26
+ * 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
27
+ *
28
+ * 그리고 표준에 **정확한 낱말이 있다** — 접는 것은 있는 낱말을 버리는 것이다.
29
+ *
30
+ * ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
31
+ * `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
32
+ * 「기한 지남」을 상태로 선언하는 시스템이 있다 — 그때 이 값은 관측이다.
33
+ *
34
+ * 둘이 어긋나면(날짜는 남았는데 상태가 지남, 또는 그 반대) **어느 쪽이 맞다고 정하지 않는다** —
35
+ * 아직 그 판정을 세울 근거가 없다. 어긋남의 구분을 없애지 않는 것이 지금의 규율이다.
36
+ *
37
+ * ── 원문으로 확인했다 (2026-08-24) ────────────────────────────────────────
38
+ * 1차 출처: **CBV Standard Release 2.0, Ratified Jun 2022** §7.2.3 처분 값 표(38개). 이 객체의
39
+ * 다른 값들(`in_progress`·`sellable_accessible`·`reserved`·`in_transit`·`non_sellable_other`)도
40
+ * 그 표에 있다.
41
+ *
42
+ * **`non_sellable_expired` 를 쓰지 않는 이유**: 그 값은 CBV 1.0 의 것이고 표준이 **폐기**했다 —
43
+ * 「deprecated in favour of new disposition values expired, damaged, disposed, … introduced in
44
+ * CBV 1.1」. 폐기된 값을 쓰면 새 소비처가 읽지 못한다.
45
+ *
46
+ * 참고: GS1 어휘 등록처(`ref.gs1.org/cbv/…`)로는 확인할 수 없었다 — **없는 값에도 같은 응답**을
47
+ * 준다(지어낸 값의 JSON-LD 가 실재 값과 바이트까지 같았다). 그 경로를 근거로 삼지 말 것.
48
+ */
49
+ expired: 'urn:epcglobal:cbv:disp:expired',
50
+ /**
51
+ * **검사에 합격했다 / 불합격했다** — CBV `conformant` / `non_conformant`.
52
+ *
53
+ * 1차 출처(CBV 2.0 §7.2.3) 정의 그대로다.
54
+ *
55
+ * conformant Outcome of a successful/passed inspection in an inspecting or repairing step
56
+ * non_conformant Outcome of an unsuccessful/failed inspection in an inspecting or repairing step
57
+ *
58
+ * ── 왜 시험 결과 축을 자원에 더하지 않고 이것을 쓰나 (2026-08-24) ────────────
59
+ * 로트의 검사 판정을 담을 자리를 찾다가 `ItemState.testResults` 를 더하려 했다. 그런데 표준은 그
60
+ * 사실을 **이미 처분으로 말한다**: `bizStep: inspecting` 사건에 이 처분이 붙는다.
61
+ *
62
+ * 처분을 쓰면 두 가지가 공짜로 성립한다.
63
+ * ① **상태 ⊆ 이벤트** — 처분은 이미 사건에서 온다. 상태에만 있는 축을 만들지 않는다
64
+ * ② **운영에 곧 닿는다** — 「이 자재를 쓸 수 있나」가 처분으로 답해진다(판정을 따로 읽지 않는다)
65
+ *
66
+ * 시험의 **자세한 내용**(어느 명세로, 무엇을 재어)은 다른 물음이고, 표준은 그것을 `TestResult` 로
67
+ * 두며 결과가 대상을 가리킨다(`TestableObjectID`) — 대상이 결과를 들지 않는다. 그 축이 필요해지면
68
+ * 그때 열되, **판정 자체는 여기서 끝난다.**
69
+ */
70
+ conformant: 'urn:epcglobal:cbv:disp:conformant',
71
+ non_conformant: 'urn:epcglobal:cbv:disp:non_conformant'
20
72
  };
21
73
  /**
22
74
  * **자재 소비·산출의 CBV 단계** — 도메인 무관하게 코어가 쓴다.
@@ -28,7 +80,16 @@ export const CBV_BIZSTEP = {
28
80
  /** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
29
81
  consuming: 'urn:epcglobal:cbv:bizstep:consuming',
30
82
  /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
31
- commissioning: 'urn:epcglobal:cbv:bizstep:commissioning'
83
+ commissioning: 'urn:epcglobal:cbv:bizstep:commissioning',
84
+ /**
85
+ * **검사** — CBV `inspecting`. 1차 출처(CBV 2.0) 정의: 「Process of reviewing objects to address
86
+ * potential physical or documentation defects」이고, 「표본과 달리 검사된 대상은 그대로 남는다」고
87
+ * 이어진다(즉 검사는 물건을 소비하지 않는다).
88
+ *
89
+ * 이 단계에 `DISP.conformant`/`DISP.non_conformant` 가 붙어 판정이 처분으로 남는다 — 입고검수·
90
+ * 공정 중 검사가 그 모양이다.
91
+ */
92
+ inspecting: 'urn:epcglobal:cbv:bizstep:inspecting'
32
93
  };
33
94
  // ── GS1 EPC URI 헬퍼 (표준) ────────────────────────────────────────────────
34
95
  /** SSCC (물류단위: 팔레트/화물/트레일러) — 결정적 카운터 기반. */
@@ -1,4 +1,5 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration, TestSpecificationCriterion, LocationObservation } from './contract.ts';
2
+ import type { ReducerCheckpoint } from './observed-reducer.ts';
2
3
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
4
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
5
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -369,6 +370,10 @@ export declare function deriveAttentions(view: {
369
370
  id: string;
370
371
  capacity?: number;
371
372
  occupancy?: number;
373
+ /** 이 자리에 걸린 판정 기준들 — 없으면 관측을 판정하지 않는다. */
374
+ criteria?: TestSpecificationCriterion[];
375
+ /** 이 자리에서 관측된 물리량들(속성당 최신). */
376
+ observations?: LocationObservation[];
372
377
  }[];
373
378
  orders: {
374
379
  id: string;
@@ -560,6 +565,37 @@ export declare abstract class FlowEngine implements TwinKernel {
560
565
  protected localParams: Map<string, Map<string, string>>;
561
566
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
562
567
  private observer?;
568
+ /**
569
+ * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
570
+ *
571
+ * ── 무엇이 문제였나 (2026-08-24 실측) ──────────────────────────────────────
572
+ * 미러는 재기동마다 저널을 **0부터** 다시 집계했다. 실측으로 저널이 2,960만 줄이고, 그 때문에 기동
573
+ * 직후 몇 분간 상태가 비어 있었다. 계측처럼 경계 없이 자라는 흐름이 들어오면 그 몇 분이 몇십 분이
574
+ * 된다 — 불편이 아니라 벽이다.
575
+ *
576
+ * 재개점 자체는 오래전부터 있었다(`serialize`/`restore`). 조회 경로는 그것을 쓰는데
577
+ * (`replayFrom`) **라이브 경로에는 꺼낼 문이 없었다.** 그래서 호스트가 저장할 수 없었다.
578
+ *
579
+ * ── 상태 스냅샷으로는 대신할 수 없다 ──────────────────────────────────────
580
+ * 리듀서는 소비처가 보는 값 말고도 든다: 부모를 기다리는 담김·집계 중인 수량·담을 줄 몰라 세어 둔
581
+ * 사건. 상태만 되돌리고 이어 집계하면 **0부터 집계한 결과와 조용히 달라진다**(§`ReducerCheckpoint`).
582
+ *
583
+ * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
584
+ */
585
+ observedCheckpoint(): ReducerCheckpoint | undefined;
586
+ /**
587
+ * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
588
+ *
589
+ * 리듀서가 아직 없으면 만든다: 미러는 첫 봉투가 올 때 리듀서를 만드는데(§`apply`), 되돌리기는 그보다
590
+ * 먼저 일어나야 한다(그러지 않으면 첫 봉투가 빈 리듀서를 만들고 되돌린 것을 덮는다).
591
+ *
592
+ * 되돌린 뒤 상태로 옮긴다 — 그러지 않으면 첫 스냅샷이 빈 상태를 보인다.
593
+ *
594
+ * **구조가 다르면 되돌리지 않는다**: 재개점은 그 모델 위에서 만들어진 것이고, 다른 공장의 재개점을
595
+ * 얹으면 없는 자리·설비가 생긴다. 판단은 부르는 쪽이 한다(`structureRev` 를 아는 것은 호스트다) —
596
+ * 여기서는 받은 것을 그대로 세운다.
597
+ */
598
+ restoreObserved(cp: ReducerCheckpoint): void;
563
599
  /**
564
600
  * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
565
601
  *