@operato/twin-kernel 0.7.55 → 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.
@@ -439,9 +439,9 @@ export interface TestSpecificationCriterion {
439
439
  * 이 저장소의 규율이고(§`TestResult.result` 의 좁힘과 같은 자리), 그러면 다음 사람이 이것을
440
440
  * 「표준이 준 이름」으로 읽지 않는다.
441
441
  *
442
- * 실 원본이 이 모양으로 준다는 것이 확인됐다(첫 실 연동: `criticalLimits.{minimum,maximum}`).
443
- * **그것이 이 축을 정한 것은 아니다** — 판정이 필요하다는 당위가 정했고, 원본은 그것을 채울 수
444
- * 있다는 사실을 확인해 준 것이다.
442
+ * 이 모양으로 한계를 주는 실 원본이 있다는 것도 확인했다. **그것이 이 축을 정한 것은 아니다** —
443
+ * 판정이 필요하다는 당위가 정했고, 원본은 그것을 채울 수 있다는 사실을 확인해 준 것이다.
444
+ * 어느 배포가 무엇을 주는지는 **커널이 알 일이 아니므로 여기 적지 않는다**(연동 쪽 문서의 몫이다).
445
445
  */
446
446
  limit?: {
447
447
  /** 이 값 미만이면 벗어난다. 없으면 아래쪽 한계가 없다(모르는 것이 아니라 없는 것이다). */
@@ -2070,6 +2070,18 @@ export declare const OP_EVENT: {
2070
2070
  * 과거를 다시 계산해도 그때 무엇을 확인했는지 알 수 없다 — 저널이 현실을 불완전하게 담는 자리였다.
2071
2071
  */
2072
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";
2073
2085
  };
2074
2086
  /**
2075
2087
  * 에너지 트윈의 사건 — **물(物)의 계보가 아니라 스칼라의 시계열.**
@@ -2408,6 +2420,7 @@ export interface TwinModelDef {
2408
2420
  parallelism?: number;
2409
2421
  parentId?: string;
2410
2422
  properties?: ResourceProperty[];
2423
+ testSpecificationIds?: TestSpecificationRefs;
2411
2424
  }[];
2412
2425
  /**
2413
2426
  * 설비(설비). mtbfMs/mttrMs 지정 시 확률적 고장 모델 참여(OEE Availability 손실). 미지정=고장 없음.
package/dist/contract.js CHANGED
@@ -898,7 +898,19 @@ export const OP_EVENT = {
898
898
  * "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
899
899
  * 구별되지 않는다.
900
900
  */
901
- 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'
902
914
  };
903
915
  // ── 에너지(EMS) 사건 — 네 번째 종류의 어휘 ────────────────────────────────
904
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
  *
@@ -9,7 +9,7 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowLocation 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf } from "./contract.js";
12
+ import { OP_EVENT, CMD, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, classClosure, capabilityOf, requiredTestsFor, priorityRank, dueStatusOf, isOrderTerminal, effectivityAt, offCalendarAt, offCalendarReasonAt, minuteOfDayAt, activeShiftAt, subLotIdOf, itemKeyOf, identityGroundingOf, outsideLimit } from "./contract.js";
13
13
  import { ObservedReducer } from "./observed-reducer.js";
14
14
  import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR, CBV_BIZSTEP, objectUri, bizTransactionUri, gdtiUri } from "./epcis.js";
15
15
  import { OP_PARAM } from "./domain-definition.js";
@@ -108,6 +108,56 @@ thresholds) {
108
108
  }
109
109
  }
110
110
  for (const n of view.locations) {
111
+ /*
112
+ * **관측이 한계를 벗어났다** — 판정할 수 있을 때만 낸다.
113
+ *
114
+ * ── 왜 이 자리인가 ────────────────────────────────────────────────────────
115
+ * 기준과 측정값을 계약에 넣어 두고 **아무도 부르지 않았다**(`outsideLimit`). 부르지 않는 판정
116
+ * 함수는 시험만 통과하는 코드이고, 「선언만 있는 능력은 사용자에게 거짓말이 된다」와 같은 자리다.
117
+ *
118
+ * ── 커널이 하지 않는 것 ───────────────────────────────────────────────────
119
+ * 표현식을 읽지 않는다. 숫자 한계가 없으면 `outsideLimit` 이 `undefined` 를 주고, 그때는 **신호를
120
+ * 내지 않는다** — 「판정 못 했다」를 「벗어났다」로도 「괜찮다」로도 만들지 않는다.
121
+ *
122
+ * 그 「판정 못 했다」가 조용히 사라지는 것은 다른 축이 답한다(`criterionSaysNothing` ·
123
+ * `testEvidenceGaps`) — 그것은 설정의 흠이고 이것은 운영의 사실이라, 한 신호에 섞지 않는다.
124
+ *
125
+ * ── 심각도를 벗어난 폭으로 가르지 않는다 ──────────────────────────────────
126
+ * 에너지 초과는 5%·15% 로 갈랐다(계량 오차와 구별하기 어려우므로). 여기서는 그러지 않는다:
127
+ * 식품 안전 한계는 **넘으면 넘은 것**이고, 「조금 넘었다」를 낮게 부르면 그 판단을 커널이 대신
128
+ * 하는 것이 된다. 폭은 `params` 로 함께 내고 판단은 현장이 한다.
129
+ */
130
+ for (const c of n.criteria ?? []) {
131
+ const propertyId = c.evaluatedPropertyId;
132
+ if (!propertyId)
133
+ continue;
134
+ const observed = (n.observations ?? []).find(o => o.propertyId === propertyId);
135
+ if (!observed)
136
+ continue;
137
+ if (outsideLimit(c, observed) !== true)
138
+ continue;
139
+ out.push({
140
+ /* 자리·기준마다 하나 — 같은 방의 온도와 습도가 한 신호로 뭉치지 않는다. */
141
+ id: `observation-out-of-limit:${n.id}:${c.id}`,
142
+ kind: 'observation-out-of-limit',
143
+ severity: 'high',
144
+ anchor: { locationId: n.id },
145
+ /* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다. */
146
+ params: {
147
+ locationId: n.id,
148
+ criterionId: c.id,
149
+ propertyId,
150
+ /* 값이 없으면 판정이 서지 않으므로 여기 오지 않는다 — 그래도 타입을 좁혀 둔다. */
151
+ value: observed.value ?? '',
152
+ ...(observed.uom ? { uom: observed.uom } : {}),
153
+ ...(c.limit?.minimum !== undefined ? { minimum: c.limit.minimum } : {}),
154
+ ...(c.limit?.maximum !== undefined ? { maximum: c.limit.maximum } : {}),
155
+ /* **언제의 값인가** — 4개월 전 값으로 지금을 판정한 것인지 소비처가 알아야 한다. */
156
+ effectiveTime: observed.effectiveTime,
157
+ ...(observed.derived ? { derived: 'true' } : {})
158
+ }
159
+ });
160
+ }
111
161
  if ((n.capacity ?? 0) > 0) {
112
162
  const r = (n.occupancy ?? 0) / n.capacity;
113
163
  /* 기준은 현장이 정한다 — 없으면 기본값이고, 어느 쪽인지 함께 낸다(조용히 기본값을 진실로 두지 않는다). */
@@ -573,6 +623,47 @@ export class FlowEngine {
573
623
  localParams = new Map();
574
624
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
575
625
  observer;
626
+ /**
627
+ * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
628
+ *
629
+ * ── 무엇이 문제였나 (2026-08-24 실측) ──────────────────────────────────────
630
+ * 미러는 재기동마다 저널을 **0부터** 다시 집계했다. 실측으로 저널이 2,960만 줄이고, 그 때문에 기동
631
+ * 직후 몇 분간 상태가 비어 있었다. 계측처럼 경계 없이 자라는 흐름이 들어오면 그 몇 분이 몇십 분이
632
+ * 된다 — 불편이 아니라 벽이다.
633
+ *
634
+ * 재개점 자체는 오래전부터 있었다(`serialize`/`restore`). 조회 경로는 그것을 쓰는데
635
+ * (`replayFrom`) **라이브 경로에는 꺼낼 문이 없었다.** 그래서 호스트가 저장할 수 없었다.
636
+ *
637
+ * ── 상태 스냅샷으로는 대신할 수 없다 ──────────────────────────────────────
638
+ * 리듀서는 소비처가 보는 값 말고도 든다: 부모를 기다리는 담김·집계 중인 수량·담을 줄 몰라 세어 둔
639
+ * 사건. 상태만 되돌리고 이어 집계하면 **0부터 집계한 결과와 조용히 달라진다**(§`ReducerCheckpoint`).
640
+ *
641
+ * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
642
+ */
643
+ observedCheckpoint() {
644
+ return this.observer?.serialize();
645
+ }
646
+ /**
647
+ * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
648
+ *
649
+ * 리듀서가 아직 없으면 만든다: 미러는 첫 봉투가 올 때 리듀서를 만드는데(§`apply`), 되돌리기는 그보다
650
+ * 먼저 일어나야 한다(그러지 않으면 첫 봉투가 빈 리듀서를 만들고 되돌린 것을 덮는다).
651
+ *
652
+ * 되돌린 뒤 상태로 옮긴다 — 그러지 않으면 첫 스냅샷이 빈 상태를 보인다.
653
+ *
654
+ * **구조가 다르면 되돌리지 않는다**: 재개점은 그 모델 위에서 만들어진 것이고, 다른 공장의 재개점을
655
+ * 얹으면 없는 자리·설비가 생긴다. 판단은 부르는 쪽이 한다(`structureRev` 를 아는 것은 호스트다) —
656
+ * 여기서는 받은 것을 그대로 세운다.
657
+ */
658
+ restoreObserved(cp) {
659
+ if (!this.observer) {
660
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
661
+ this.observeMode = true;
662
+ }
663
+ this.observer.restore(cp);
664
+ this.observedDirty = true;
665
+ this.settleObserved();
666
+ }
576
667
  /**
577
668
  * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
578
669
  *
@@ -1663,9 +1754,31 @@ export class FlowEngine {
1663
1754
  collectAttentions() {
1664
1755
  // 계산 층은 순수 함수 deriveAttentions 로 위임 — sim(여기)과 live projector 미러가 공유(face2-inbound-live §1.1).
1665
1756
  const now = this.now();
1757
+ /*
1758
+ * **자리에 걸린 기준을 여기서 푼다** — 순수 함수는 모델을 읽지 않는다(그 규율은 `attentionThresholds`
1759
+ * 와 같다). 종류 선언(`LocationTypeDef.testSpecificationIds`) → 명세 → 기준으로 풀어 넘긴다.
1760
+ *
1761
+ * 선언이 없으면 빈 배열이고, 그러면 그 자리의 관측은 판정되지 않는다 — 관측은 남고 신호만 서지
1762
+ * 않는다(「선언한 것만 제약이 된다」).
1763
+ */
1764
+ const specById = new Map((this.boardDef?.testSpecifications ?? []).map(sp => [sp.id, sp]));
1765
+ const criteriaOf = new Map();
1766
+ if (specById.size) {
1767
+ for (const n of readBoardLocations(this.boardDef ?? {})) {
1768
+ const ids = n.testSpecificationIds;
1769
+ if (!ids?.length)
1770
+ continue;
1771
+ const cs = ids.flatMap(id => specById.get(id)?.criteria ?? []);
1772
+ if (cs.length)
1773
+ criteriaOf.set(n.id, cs);
1774
+ }
1775
+ }
1666
1776
  const out = deriveAttentions({
1667
1777
  equipment: [...this.equipment.values()],
1668
- locations: [...this.locations.values()],
1778
+ locations: [...this.locations.values()].map(n => {
1779
+ const criteria = criteriaOf.get(n.id);
1780
+ return criteria?.length ? { ...n, criteria } : n;
1781
+ }),
1669
1782
  orders: [...this.orders.values()],
1670
1783
  tasks: [...this.tasks.values()]
1671
1784
  }, this._acked, now, // 지연 판정의 "지금" — 관측 중이면 마지막으로 들은 시각이다(§nowMs)
@@ -1,4 +1,4 @@
1
- import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
1
+ import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
2
2
  interface ProjItem {
3
3
  epc: string;
4
4
  /** 로트의 부분(표준 MaterialSubLot.ID) — 비직렬 로트가 자리마다 갈릴 때만. */
@@ -141,10 +141,17 @@ export interface ReducerCheckpoint {
141
141
  firstAtMs?: number;
142
142
  lastAtMs?: number;
143
143
  }[];
144
+ /** 자리별 · 속성별 마지막 관측 — 이어 접기가 이 축을 0부터 다시 만들지 않게. */
145
+ observations?: {
146
+ id: string;
147
+ values: LocationObservation[];
148
+ }[];
144
149
  }
145
150
  export declare class ObservedReducer {
146
151
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
147
152
  private master;
153
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
154
+ private observations;
148
155
  private items;
149
156
  private aggregation;
150
157
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -41,6 +41,8 @@ function effectiveOf(r) {
41
41
  export class ObservedReducer {
42
42
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
43
43
  master = new Map();
44
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
45
+ observations = new Map();
44
46
  items = new Map();
45
47
  aggregation = new Map();
46
48
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -339,6 +341,27 @@ export class ObservedReducer {
339
341
  this.touchLocation(d.location);
340
342
  break;
341
343
  }
344
+ case OP_EVENT.observation: {
345
+ /*
346
+ * **자리의 물리 관측** — 속성마다 마지막 값 하나만 든다(§`LocationState.observations`).
347
+ *
348
+ * 이력을 들지 않는 이유: 상태가 **시간에 비례해 자라지 않게**. 「그때 몇 도였나」는 저널이
349
+ * 답하는 물음이고, 상태가 답하는 것은 「지금 몇 도냐」다.
350
+ *
351
+ * 늦게 온 옛 관측이 최신을 덮지 않게 `stale` 을 지난다 — 대상 키에 속성을 넣는다(온도가 늦게
352
+ * 와도 습도의 최신은 지켜져야 한다).
353
+ */
354
+ const d = e.data;
355
+ if (!d?.locationId || !d?.propertyId)
356
+ break;
357
+ if (this.stale(`observation:${d.locationId}:${d.propertyId}`, e))
358
+ return;
359
+ this.touchLocation(d.locationId);
360
+ const bin = this.observations.get(d.locationId) ?? new Map();
361
+ bin.set(d.propertyId, { ...d });
362
+ this.observations.set(d.locationId, bin);
363
+ break;
364
+ }
342
365
  case OP_EVENT.attentionAck: {
343
366
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
344
367
  const d = e.data;
@@ -747,6 +770,8 @@ export class ObservedReducer {
747
770
  */
748
771
  serialize() {
749
772
  return {
773
+ /* 관측도 재개점에 든다 — 없으면 이어 접기가 그 축을 0부터 다시 만든다. */
774
+ observations: [...this.observations.entries()].map(([id, bin]) => ({ id, values: [...bin.values()] })),
750
775
  revision: this.revision,
751
776
  master: [...this.master.values()].map(n => ({ ...n })),
752
777
  items: [...this.items.values()].map(i => ({ ...i })),
@@ -771,6 +796,7 @@ export class ObservedReducer {
771
796
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
772
797
  */
773
798
  restore(cp) {
799
+ this.observations = new Map((cp?.observations ?? []).map(o => [o.id, new Map((o.values ?? []).map(v => [v.propertyId, { ...v }]))]));
774
800
  this.revision = cp?.revision ?? 0;
775
801
  this.master = new Map((cp?.master ?? []).map(n => [n.id, { ...n }]));
776
802
  this.items = new Map((cp?.items ?? []).map(i => [i.epc, { ...i }]));
@@ -807,6 +833,11 @@ export class ObservedReducer {
807
833
  ...(n.capacity === undefined ? {} : { capacity: n.capacity }),
808
834
  ...(n.parallelism === undefined ? {} : { parallelism: n.parallelism }),
809
835
  ...(n.parentId ? { parentId: n.parentId } : {}),
836
+ /* 관측된 물리량 — 없으면 **키를 만들지 않는다**(빈 배열은 「센서가 없다」와 「못 들었다」를 같게 만든다). */
837
+ ...(() => {
838
+ const bin = this.observations.get(n.id);
839
+ return bin?.size ? { observations: [...bin.values()] } : {};
840
+ })(),
810
841
  origin: n.origin
811
842
  };
812
843
  }),
@@ -599,7 +599,19 @@ var OP_EVENT = {
599
599
  * "확인해라"(요청)이고 이벤트는 "확인했다"(사실)다. 같은 문자열을 쓰면 저널에서 요청과 사실이
600
600
  * 구별되지 않는다.
601
601
  */
602
- attentionAck: "attention.acked"
602
+ attentionAck: "attention.acked",
603
+ /**
604
+ * **자리에서 관측된 물리량** — 냉장실 온도·습도, 세척수 유량 같은 것(§`LocationObservation`).
605
+ *
606
+ * ── 왜 채널이 필요한가 ────────────────────────────────────────────────────
607
+ * `LocationState.observations` 를 상태에 두었는데 그것을 낳는 사건이 없었다. 그러면 **상태 ⊆ 이벤트**
608
+ * 가 깨진다: 채워도 재기동에서 사라지고, 폴드가 되살릴 수 없고, 미러가 이어받을 수도 없다. 상태에만
609
+ * 있는 축은 조용히 사라지는 축이다.
610
+ *
611
+ * 이름을 `energy.measured` 와 같은 결로 둔다 — 그쪽이 에너지 계량의 도착이고 이쪽이 그 일반형이다.
612
+ * 두 채널을 합치지 않는 이유는 에너지 쪽이 **구간에 누적되는 표본**이라 처리가 다르기 때문이다.
613
+ */
614
+ observation: "location.measured"
603
615
  };
604
616
  var ENERGY_EVENT = {
605
617
  /** 계량 도착 — 그 시점의 유효전력·누적량. 15분 수요 구간에 누적된다. */
@@ -971,14 +983,75 @@ var DISP = {
971
983
  sellable: "urn:epcglobal:cbv:disp:sellable_accessible",
972
984
  reserved: "urn:epcglobal:cbv:disp:reserved",
973
985
  in_transit: "urn:epcglobal:cbv:disp:in_transit",
974
- non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other"
986
+ non_sellable: "urn:epcglobal:cbv:disp:non_sellable_other",
975
987
  // 불량/scrap
988
+ /**
989
+ * **기한이 지났다** — CBV `expired`.
990
+ *
991
+ * ── 왜 `non_sellable` 로 접지 않나 (2026-08-24) ─────────────────────────────
992
+ * 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
993
+ * 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
994
+ * 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
995
+ *
996
+ * 그리고 표준에 **정확한 낱말이 있다** — 접는 것은 있는 낱말을 버리는 것이다.
997
+ *
998
+ * ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
999
+ * `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
1000
+ * 「기한 지남」을 상태로 선언하는 시스템이 있다 — 그때 이 값은 관측이다.
1001
+ *
1002
+ * 둘이 어긋나면(날짜는 남았는데 상태가 지남, 또는 그 반대) **어느 쪽이 맞다고 정하지 않는다** —
1003
+ * 아직 그 판정을 세울 근거가 없다. 어긋남의 구분을 없애지 않는 것이 지금의 규율이다.
1004
+ *
1005
+ * ── 원문으로 확인했다 (2026-08-24) ────────────────────────────────────────
1006
+ * 1차 출처: **CBV Standard Release 2.0, Ratified Jun 2022** §7.2.3 처분 값 표(38개). 이 객체의
1007
+ * 다른 값들(`in_progress`·`sellable_accessible`·`reserved`·`in_transit`·`non_sellable_other`)도
1008
+ * 그 표에 있다.
1009
+ *
1010
+ * **`non_sellable_expired` 를 쓰지 않는 이유**: 그 값은 CBV 1.0 의 것이고 표준이 **폐기**했다 —
1011
+ * 「deprecated in favour of new disposition values expired, damaged, disposed, … introduced in
1012
+ * CBV 1.1」. 폐기된 값을 쓰면 새 소비처가 읽지 못한다.
1013
+ *
1014
+ * 참고: GS1 어휘 등록처(`ref.gs1.org/cbv/…`)로는 확인할 수 없었다 — **없는 값에도 같은 응답**을
1015
+ * 준다(지어낸 값의 JSON-LD 가 실재 값과 바이트까지 같았다). 그 경로를 근거로 삼지 말 것.
1016
+ */
1017
+ expired: "urn:epcglobal:cbv:disp:expired",
1018
+ /**
1019
+ * **검사에 합격했다 / 불합격했다** — CBV `conformant` / `non_conformant`.
1020
+ *
1021
+ * 1차 출처(CBV 2.0 §7.2.3) 정의 그대로다.
1022
+ *
1023
+ * conformant Outcome of a successful/passed inspection in an inspecting or repairing step
1024
+ * non_conformant Outcome of an unsuccessful/failed inspection in an inspecting or repairing step
1025
+ *
1026
+ * ── 왜 시험 결과 축을 자원에 더하지 않고 이것을 쓰나 (2026-08-24) ────────────
1027
+ * 로트의 검사 판정을 담을 자리를 찾다가 `ItemState.testResults` 를 더하려 했다. 그런데 표준은 그
1028
+ * 사실을 **이미 처분으로 말한다**: `bizStep: inspecting` 사건에 이 처분이 붙는다.
1029
+ *
1030
+ * 처분을 쓰면 두 가지가 공짜로 성립한다.
1031
+ * ① **상태 ⊆ 이벤트** — 처분은 이미 사건에서 온다. 상태에만 있는 축을 만들지 않는다
1032
+ * ② **운영에 곧 닿는다** — 「이 자재를 쓸 수 있나」가 처분으로 답해진다(판정을 따로 읽지 않는다)
1033
+ *
1034
+ * 시험의 **자세한 내용**(어느 명세로, 무엇을 재어)은 다른 물음이고, 표준은 그것을 `TestResult` 로
1035
+ * 두며 결과가 대상을 가리킨다(`TestableObjectID`) — 대상이 결과를 들지 않는다. 그 축이 필요해지면
1036
+ * 그때 열되, **판정 자체는 여기서 끝난다.**
1037
+ */
1038
+ conformant: "urn:epcglobal:cbv:disp:conformant",
1039
+ non_conformant: "urn:epcglobal:cbv:disp:non_conformant"
976
1040
  };
977
1041
  var CBV_BIZSTEP = {
978
1042
  /** 공정에 자재가 들어갔다 — ISA-95 `MaterialUse: Consumed`. */
979
1043
  consuming: "urn:epcglobal:cbv:bizstep:consuming",
980
1044
  /** 새 물품이 생겨 계보가 시작된다 — ISA-95 `MaterialUse: Produced`. */
981
- commissioning: "urn:epcglobal:cbv:bizstep:commissioning"
1045
+ commissioning: "urn:epcglobal:cbv:bizstep:commissioning",
1046
+ /**
1047
+ * **검사** — CBV `inspecting`. 1차 출처(CBV 2.0) 정의: 「Process of reviewing objects to address
1048
+ * potential physical or documentation defects」이고, 「표본과 달리 검사된 대상은 그대로 남는다」고
1049
+ * 이어진다(즉 검사는 물건을 소비하지 않는다).
1050
+ *
1051
+ * 이 단계에 `DISP.conformant`/`DISP.non_conformant` 가 붙어 판정이 처분으로 남는다 — 입고검수·
1052
+ * 공정 중 검사가 그 모양이다.
1053
+ */
1054
+ inspecting: "urn:epcglobal:cbv:bizstep:inspecting"
982
1055
  };
983
1056
  function ssccUri(companyPrefix, serial) {
984
1057
  return `urn:epc:id:sscc:${companyPrefix}.${String(serial).padStart(10, "0")}`;
@@ -1256,6 +1329,8 @@ function effectiveOf(r) {
1256
1329
  var ObservedReducer = class {
1257
1330
  /** 로케이션 마스터 — 출처를 함께 들고 있다(마스터가 말한 자리 vs 관측으로 알게 된 자리). */
1258
1331
  master = /* @__PURE__ */ new Map();
1332
+ /** 자리별 · 속성별 **마지막 관측** — 이력이 아니다(§`OP_EVENT.observation`). */
1333
+ observations = /* @__PURE__ */ new Map();
1259
1334
  items = /* @__PURE__ */ new Map();
1260
1335
  aggregation = /* @__PURE__ */ new Map();
1261
1336
  /** 아직 관측되지 않은 자식의 담김 — 물품을 지어내지 않고 보류했다가 등장할 때 붙인다. */
@@ -1524,6 +1599,16 @@ var ObservedReducer = class {
1524
1599
  this.touchLocation(d.location);
1525
1600
  break;
1526
1601
  }
1602
+ case OP_EVENT.observation: {
1603
+ const d = e.data;
1604
+ if (!d?.locationId || !d?.propertyId) break;
1605
+ if (this.stale(`observation:${d.locationId}:${d.propertyId}`, e)) return;
1606
+ this.touchLocation(d.locationId);
1607
+ const bin = this.observations.get(d.locationId) ?? /* @__PURE__ */ new Map();
1608
+ bin.set(d.propertyId, { ...d });
1609
+ this.observations.set(d.locationId, bin);
1610
+ break;
1611
+ }
1527
1612
  case OP_EVENT.attentionAck: {
1528
1613
  const d = e.data;
1529
1614
  if (d?.id) this.acked.add(d.id);
@@ -1846,6 +1931,8 @@ var ObservedReducer = class {
1846
1931
  */
1847
1932
  serialize() {
1848
1933
  return {
1934
+ /* 관측도 재개점에 든다 — 없으면 이어 접기가 그 축을 0부터 다시 만든다. */
1935
+ observations: [...this.observations.entries()].map(([id, bin]) => ({ id, values: [...bin.values()] })),
1849
1936
  revision: this.revision,
1850
1937
  master: [...this.master.values()].map((n) => ({ ...n })),
1851
1938
  items: [...this.items.values()].map((i) => ({ ...i })),
@@ -1870,6 +1957,9 @@ var ObservedReducer = class {
1870
1957
  * 모델에서 오는 판정 입력(교대·등급·시간대)은 생성자가 이미 세웠으므로 건드리지 않는다.
1871
1958
  */
1872
1959
  restore(cp) {
1960
+ this.observations = new Map(
1961
+ (cp?.observations ?? []).map((o) => [o.id, new Map((o.values ?? []).map((v) => [v.propertyId, { ...v }]))])
1962
+ );
1873
1963
  this.revision = cp?.revision ?? 0;
1874
1964
  this.master = new Map((cp?.master ?? []).map((n) => [n.id, { ...n }]));
1875
1965
  this.items = new Map((cp?.items ?? []).map((i) => [i.epc, { ...i }]));
@@ -1905,6 +1995,11 @@ var ObservedReducer = class {
1905
1995
  ...n.capacity === void 0 ? {} : { capacity: n.capacity },
1906
1996
  ...n.parallelism === void 0 ? {} : { parallelism: n.parallelism },
1907
1997
  ...n.parentId ? { parentId: n.parentId } : {},
1998
+ /* 관측된 물리량 — 없으면 **키를 만들지 않는다**(빈 배열은 「센서가 없다」와 「못 들었다」를 같게 만든다). */
1999
+ ...(() => {
2000
+ const bin = this.observations.get(n.id);
2001
+ return bin?.size ? { observations: [...bin.values()] } : {};
2002
+ })(),
1908
2003
  origin: n.origin
1909
2004
  };
1910
2005
  }),
@@ -3421,6 +3516,34 @@ function deriveAttentions(view, acked, nowIso, thresholds) {
3421
3516
  }
3422
3517
  }
3423
3518
  for (const n of view.locations) {
3519
+ for (const c of n.criteria ?? []) {
3520
+ const propertyId = c.evaluatedPropertyId;
3521
+ if (!propertyId) continue;
3522
+ const observed = (n.observations ?? []).find((o) => o.propertyId === propertyId);
3523
+ if (!observed) continue;
3524
+ if (outsideLimit(c, observed) !== true) continue;
3525
+ out.push({
3526
+ /* 자리·기준마다 하나 — 같은 방의 온도와 습도가 한 신호로 뭉치지 않는다. */
3527
+ id: `observation-out-of-limit:${n.id}:${c.id}`,
3528
+ kind: "observation-out-of-limit",
3529
+ severity: "high",
3530
+ anchor: { locationId: n.id },
3531
+ /* 언어중립 원시값만 — 문장은 표현계층이 kind 로 골라 렌더한다. */
3532
+ params: {
3533
+ locationId: n.id,
3534
+ criterionId: c.id,
3535
+ propertyId,
3536
+ /* 값이 없으면 판정이 서지 않으므로 여기 오지 않는다 — 그래도 타입을 좁혀 둔다. */
3537
+ value: observed.value ?? "",
3538
+ ...observed.uom ? { uom: observed.uom } : {},
3539
+ ...c.limit?.minimum !== void 0 ? { minimum: c.limit.minimum } : {},
3540
+ ...c.limit?.maximum !== void 0 ? { maximum: c.limit.maximum } : {},
3541
+ /* **언제의 값인가** — 4개월 전 값으로 지금을 판정한 것인지 소비처가 알아야 한다. */
3542
+ effectiveTime: observed.effectiveTime,
3543
+ ...observed.derived ? { derived: "true" } : {}
3544
+ }
3545
+ });
3546
+ }
3424
3547
  if ((n.capacity ?? 0) > 0) {
3425
3548
  const r = (n.occupancy ?? 0) / n.capacity;
3426
3549
  const declaredPct = thresholds?.congestionPctOf?.(n.id);
@@ -3801,6 +3924,47 @@ var FlowEngine = class {
3801
3924
  localParams = /* @__PURE__ */ new Map();
3802
3925
  /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
3803
3926
  observer;
3927
+ /**
3928
+ * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
3929
+ *
3930
+ * ── 무엇이 문제였나 (2026-08-24 실측) ──────────────────────────────────────
3931
+ * 미러는 재기동마다 저널을 **0부터** 다시 집계했다. 실측으로 저널이 2,960만 줄이고, 그 때문에 기동
3932
+ * 직후 몇 분간 상태가 비어 있었다. 계측처럼 경계 없이 자라는 흐름이 들어오면 그 몇 분이 몇십 분이
3933
+ * 된다 — 불편이 아니라 벽이다.
3934
+ *
3935
+ * 재개점 자체는 오래전부터 있었다(`serialize`/`restore`). 조회 경로는 그것을 쓰는데
3936
+ * (`replayFrom`) **라이브 경로에는 꺼낼 문이 없었다.** 그래서 호스트가 저장할 수 없었다.
3937
+ *
3938
+ * ── 상태 스냅샷으로는 대신할 수 없다 ──────────────────────────────────────
3939
+ * 리듀서는 소비처가 보는 값 말고도 든다: 부모를 기다리는 담김·집계 중인 수량·담을 줄 몰라 세어 둔
3940
+ * 사건. 상태만 되돌리고 이어 집계하면 **0부터 집계한 결과와 조용히 달라진다**(§`ReducerCheckpoint`).
3941
+ *
3942
+ * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
3943
+ */
3944
+ observedCheckpoint() {
3945
+ return this.observer?.serialize();
3946
+ }
3947
+ /**
3948
+ * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
3949
+ *
3950
+ * 리듀서가 아직 없으면 만든다: 미러는 첫 봉투가 올 때 리듀서를 만드는데(§`apply`), 되돌리기는 그보다
3951
+ * 먼저 일어나야 한다(그러지 않으면 첫 봉투가 빈 리듀서를 만들고 되돌린 것을 덮는다).
3952
+ *
3953
+ * 되돌린 뒤 상태로 옮긴다 — 그러지 않으면 첫 스냅샷이 빈 상태를 보인다.
3954
+ *
3955
+ * **구조가 다르면 되돌리지 않는다**: 재개점은 그 모델 위에서 만들어진 것이고, 다른 공장의 재개점을
3956
+ * 얹으면 없는 자리·설비가 생긴다. 판단은 부르는 쪽이 한다(`structureRev` 를 아는 것은 호스트다) —
3957
+ * 여기서는 받은 것을 그대로 세운다.
3958
+ */
3959
+ restoreObserved(cp) {
3960
+ if (!this.observer) {
3961
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
3962
+ this.observeMode = true;
3963
+ }
3964
+ this.observer.restore(cp);
3965
+ this.observedDirty = true;
3966
+ this.settleObserved();
3967
+ }
3804
3968
  /**
3805
3969
  * 이 커널의 상태가 **관측에서 왔나** — 미러인가.
3806
3970
  *
@@ -4700,10 +4864,23 @@ var FlowEngine = class {
4700
4864
  */
4701
4865
  collectAttentions() {
4702
4866
  const now = this.now();
4867
+ const specById = new Map((this.boardDef?.testSpecifications ?? []).map((sp) => [sp.id, sp]));
4868
+ const criteriaOf = /* @__PURE__ */ new Map();
4869
+ if (specById.size) {
4870
+ for (const n of readBoardLocations(this.boardDef ?? {})) {
4871
+ const ids = n.testSpecificationIds;
4872
+ if (!ids?.length) continue;
4873
+ const cs = ids.flatMap((id) => specById.get(id)?.criteria ?? []);
4874
+ if (cs.length) criteriaOf.set(n.id, cs);
4875
+ }
4876
+ }
4703
4877
  const out = deriveAttentions(
4704
4878
  {
4705
4879
  equipment: [...this.equipment.values()],
4706
- locations: [...this.locations.values()],
4880
+ locations: [...this.locations.values()].map((n) => {
4881
+ const criteria = criteriaOf.get(n.id);
4882
+ return criteria?.length ? { ...n, criteria } : n;
4883
+ }),
4707
4884
  orders: [...this.orders.values()],
4708
4885
  tasks: [...this.tasks.values()]
4709
4886
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.55",
3
+ "version": "0.7.56",
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": {