@operato/twin-kernel 0.7.56 → 0.7.57

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.
package/README.md CHANGED
@@ -1,28 +1,50 @@
1
- # @operato-twin/kernel — 비즈니스레이어 디지털트윈 커널
1
+ # @operato/twin-kernel — 비즈니스레이어 디지털트윈 커널
2
2
 
3
- 물류창고·야드·스마트팩토리의 **비즈니스 실행**을 시뮬레이션·모니터링하는 **헤드리스·프레임워크 무관·zero-dep** TS 커널. UI/3D/DOM 없이 순수 Node 에서 도는 것이 목적(sim=능동 생산, live=수동 미러, 계약 동일).
3
+ 물류창고·야드·스마트팩토리·에너지의 **비즈니스 실행**을 시뮬레이션·모니터링하는 **헤드리스·프레임워크 무관·zero-dep** TS 커널. UI/3D/DOM 없이 순수 Node 에서 동작하는 것이 목적(sim=능동 생산, live=수동 미러, **계약 동일**).
4
4
 
5
- - 실행: `node --test test/*.test.ts` (Node 25 네이티브 TS, **무의존**)
6
- - 상태: **v0.2.3 · 261 tests green**, 3 버티컬(WMS/YMS/MES), ISA-95 4대 자원
5
+ - 실행: `node --test test/*.test.ts` — 네이티브 TS, **런타임 의존성 0개**
6
+ - 상태: **v0.7.56 · 1,085 tests green** · 시험 파일 116개 · 4 버티컬(WMS/YMS/MES/EMS) · ISA-95 4대 자원
7
7
 
8
8
  ## 계층 (아래로만 의존, 무방언)
9
9
 
10
10
  ```
11
- contract.ts 3채널 계약(State/Command/Scenario) + 운영 델타 + TwinKernel ← 도메인/표준 무관
12
- epcis.ts GS1 EPCIS 2.0 이벤트 machinery(타입·빌더·검증기·URI·CBV disp) ← 표준(도메인 무관)
13
- flow-engine.ts FlowEngine base — mechanics(RNG·clock·tick·자원배정·태스크진행·emit·snapshot)
14
- allocation-policy.ts AllocationPolicy 확장 시임(selectPlacement/selectStock)
15
- observed-reducer.ts 관측 이벤트 → State 접기(**두 구동이 쓰는 유일한 규칙**) ← 도메인 무관
16
- domain-definition.ts 공정 명세(ISA-95 OperationsSegment: 소요·변동·수율·자원 요구)
17
- iso-duration.ts xsd:duration 파서(달력 의존 단위는 거절)
18
- runtime.ts TwinRuntime — host-facing facade + 구독 프로토콜 ← 도메인/전송 무관
19
- face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증) ← ACL
20
-
21
- {wms,yms,mes}-profile.ts 도메인 어휘(bizStep/btt)만
22
- {wms,yms,mes}-kernel.ts 도메인 flow 동사(4 hook) — FlowEngine 확장
11
+ contract.ts 3채널 계약(State/Command/Scenario) + 운영 델타 + TwinKernel ← 도메인/표준 무관
12
+ epcis.ts GS1 EPCIS 2.0 이벤트 machinery(타입·빌더·검증기·URI·CBV) ← 표준(도메인 무관)
13
+ vocabulary.ts 은퇴 어휘 목록 + 가드 프라그마 — 개명을 기계가 지킨다
14
+ flow-engine.ts FlowEngine base — mechanics(RNG·clock·tick·자원배정·태스크진행·emit·snapshot)
15
+ observed-reducer.ts 관측 이벤트 → State 접기(**두 구동이 쓰는 유일한 규칙**) + 재개점 ← 도메인 무관
16
+ state-projector.ts 위 것의 옛 이름(소비처 호환 별칭)
17
+
18
+ allocation-policy.ts AllocationPolicy 확장 시임(selectPlacement/selectStock)
19
+ capability.ts 자원의 능력 선언 — concrete 결합 대신 능력으로 찾는다
20
+ capacity.ts 용량·점유 계산
21
+ operations-capability.ts 공정이 요구하는 능력 ↔ 자원이 가진 능력의 대조
22
+ domain-definition.ts 공정 명세(ISA-95 OperationsSegment: 소요·변동·수율·자원 요구)
23
+ domain-catalog.ts 도메인 어휘 카탈로그(선언 가능한 것의 목록)
24
+ duration-estimator.ts 실측 추정기 주입 시임
25
+ iso-duration.ts xsd:duration 파서(달력 의존 단위는 거절)
26
+ make-to-order.ts 주문 대응 생산
27
+ task-fold.ts 태스크 접기
28
+ job-response.ts ISA-95 작업 응답
29
+
30
+ event-journal.ts 이벤트 저널 + replay/replayFrom — 트윈의 기억
31
+ forecast.ts 몬테카를로 예측(분포로 답한다)
32
+ divergence.ts 예측 vs 관측 대조 → 드리프트 국소화
33
+ counterfactual.ts 과거 시점 분기 — "그때 X 했다면"
34
+ twin-observer.ts 자동 정합 루프
35
+
36
+ energy-ingest.ts 계측 유입(적산·구간)
37
+ energy-attribution.ts 에너지를 사용처로 귀속
38
+ operational-ingest.ts 운영 사실 유입
39
+ scenario-validate.ts 시나리오 선언 검증
40
+ face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증) ← ACL
41
+ runtime.ts TwinRuntime — host-facing facade + 구독 프로토콜 ← 도메인/전송 무관
42
+
43
+ {wms,yms,mes,ems}-profile.ts 도메인 어휘(bizStep/btt/표준 앵커)만
44
+ kernel.ts · {yms,mes,ems}-kernel.ts 도메인 flow 동사(4 hook) — FlowEngine 확장
23
45
  ```
24
46
 
25
- > **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/reducer/runtime)에 특정 도메인 용어를 넣지 않는다. EPCIS 는 표준이라 `epcis.ts`(≠wms). order.kind·task.resourceType 등은 도메인이 소유. 정책 인터페이스는 `selectPlacement`/`selectStock`(≠putaway/pallets).
47
+ > **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/reducer/runtime)에 특정 도메인 용어를 넣지 않는다. EPCIS 는 표준이라 `epcis.ts`(≠wms). `order.kind`·`task.resourceType` 등은 도메인이 소유. 정책 인터페이스는 `selectPlacement`/`selectStock`(≠putaway/pallets).
26
48
 
27
49
  ## FlowEngine — 단일 base, 도메인은 4 hook 만
28
50
 
@@ -35,17 +57,18 @@ face2-adapter.ts 레거시 레코드 → 정규 EPCIS(선언적 매핑 + 검증)
35
57
  | `allocate(order)` | created 오더 할당 → 재고 선택(정책) + 태스크. **시간창 게이트도 여기**(스케줄링) |
36
58
  | `onTaskComplete(task)` | 완료의 의미 — 이동(WMS/YMS) 또는 변환(MES). occupancy·EPCIS·오더 이행 전부 도메인 |
37
59
 
38
- base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만 소유 → 이동-중립. 새 버티컬 = 프로파일(어휘) + kernel(4 hook), **~100줄**.
60
+ base 는 태스크 **생명주기**(자원 배정·진행·완료·해제·델타)만 소유 → 이동-중립. 새 버티컬 = 프로파일(어휘) + kernel(4 hook).
39
61
 
40
- ## 3 버티컬 — base 가 4 flow 성격을 담음을 실증
62
+ ## 4 버티컬 — base 가 여러 flow 성격을 담음을 실증
41
63
 
42
64
  | 버티컬 | flow 성격 | base 대응 |
43
65
  |---|---|---|
44
66
  | **WMS** | 이동 (putaway/pick/pack/ship) + 백오더 | 기본 |
45
67
  | **YMS** | 이동 (spot/pull) + **시간창 스케줄링**(어포인트먼트↔도크도어 예약) | 스케줄링=도메인 게이팅으로 **base 흡수** |
46
- | **MES** | **변환**(TransformationEvent) + 다단계 라우팅 + **이종 자원**(cutter/welder) | 자원 매칭=`FlowTask.resourceType` **base 확장**(load-bearing 검증) |
68
+ | **MES** | **변환**(TransformationEvent) + 다단계 라우팅 + **이종 자원**(cutter/welder) | 자원 매칭=`FlowTask.resourceType` **base 확장** |
69
+ | **EMS** | 계측의 흐름 — 적산·구간 마감·피크·귀속 | 유입·귀속 모듈로 확장(자리=**전기적 구간**) |
47
70
 
48
- 두 진화 축: 시간창 스케줄링=흡수, 자원-타입 매칭=최소 확장. 둘 다 backward-compatible(기존 도메인 무영향, 예제 바이트 동일로 검증).
71
+ EMS 는 **표준 앵커가 다르다**: 물류·생산은 ISA-95/EPCIS 로 재고 에너지는 **IEC 61850**(설비 데이터 모델)·**ISO 50001**(에너지 경영)로 잰다. 대응할 표준 이름이 없으면 그 칸을 **비운다** — 억지로 가까운 이름을 적으면 적합성 표가 거짓을 말한다.
49
72
 
50
73
  ## 4대 자원 (ISA-95)
51
74
 
@@ -54,7 +77,7 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
54
77
  | ISA-95 | 커널 | 요구 명세 |
55
78
  |---|---|---|
56
79
  | Personnel | `persons` · `PersonState` | `personnelSpecification` = 등급 + 인원 수 |
57
- | Equipment | `movers` · `MoverState` | `equipmentSpecification` = 등급 + 대수 |
80
+ | Equipment | `equipment` · `EquipmentState` | `equipmentSpecification` = 등급 + 대수 |
58
81
  | PhysicalAsset | `assets` · `AssetState` (GS1 **GRAI**) | `physicalAssetSpecification` |
59
82
  | Material | `items` · `ItemState` (EPCIS) | — |
60
83
 
@@ -62,6 +85,24 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
62
85
 
63
86
  > 팔레트를 물품으로 두지 않은 이유: GS1 에서 **SSCC**(물류단위)와 **GRAI**(돌아오는 팔레트 자체)는 다른 것이다. 같은 GRAI 가 오늘은 이 SSCC 를, 내일은 다른 SSCC 를 싣는다. 그래서 **신설하고 연결**한다(`AssetState.carrying` ↔ `ItemState.carriedBy`).
64
87
 
88
+ 자원에는 **자격**이 붙는다. 등급이 시험을 요구하고(`testSpecificationIds`) 개체가 결과를 든다(`testResults`) — 요구된 시험의 결과가 없거나 만료·불합격이면 자격이 성립하지 않는다(`meetsTests`). 결과가 **아예 없는 것**과 **불합격**을 같은 값으로 만들지 않는다.
89
+
90
+ ## 자리와 그 물리 조건
91
+
92
+ `locations` · `LocationState` — 자리는 ISA-95 설비 계층의 노드다(Enterprise→Site→Area→StorageZone→StorageUnit).
93
+
94
+ 자리는 **물리 조건**을 든다: `observations`(속성별 최신 관측 — 온도·습도 등)와 `testSpecificationIds`(그 자리에 걸린 기준). 조건 없이는 물건의 상태를 판정할 수 없고, **이 조인은 트윈만 할 수 있다** — 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은 조건 이력을 모른다.
95
+
96
+ | 축 | 표준 | 우리가 더한 것 |
97
+ |---|---|---|
98
+ | 관측 | `OperationsEvent` + `OperationsRecordTemplate`(`EffectiveTimestamp`·`EffectiveEndDate`·`HierarchyScope`) | 값을 `ValueType` 으로 **좁혔다**(단위 없는 물리량은 판정의 재료가 못 된다) |
99
+ | 기준 | `TestSpecificationCriteria`(`Expression` = 자유 문장) | **숫자 한계** `limit: {minimum, maximum, uom}` — 자유 문장으로는 판정할 수 없다 |
100
+ | 판정 | `TestResult.EvaluatedCriterionResult`(TextType) | `pass`/`fail` 로 좁힘 · CBV 처분 `conformant`/`non_conformant` |
101
+
102
+ 판정 함수 `outsideLimit()` 은 **세 갈래로 답한다** — 벗어남(`true`) · 안(`false`) · **판정 못 함(`undefined`)**. 숫자 한계가 없거나, 값이 수가 아니거나, 양쪽이 서로 다른 단위를 말하면 거절한다. 「모름」을 「적합」으로 만들지 않기 위한 것이고, 규제 기록에서 그 구별이 사라지면 결함이 아니라 사고다. 근거의 빈 곳은 `testEvidenceGaps()` 가 따로 센다.
103
+
104
+ 관측은 **사건으로 들어온다**(`OP_EVENT.observation` = `location.measured`) — 상태에만 있는 축은 조용히 사라지는 축이다. 구간 관측(`effectiveEndTime`)은 그 구간 안에서만 참이다: 원본이 「그날 아침」만 말하면 09:00 을 지어내지 않는다.
105
+
65
106
  ## 공정 명세와 소요시간 세 층
66
107
 
67
108
  `OperationDef` 가 ISA-95 `OperationsSegment` 를 담는다 — `duration`(xsd:duration) · `variability`(퍼짐) · `parameters`(수율·준비시간) · 자원 요구 셋. 소요시간은 **강한 근거부터** 찾는다:
@@ -74,48 +115,96 @@ base 는 task **생명주기**(자원 배정·진행·완료·해제·델타)만
74
115
 
75
116
  ## 3채널 계약 (Face 1) + 구독
76
117
 
77
- - **State**: `getSnapshot()`(스냅샷) + 델타 이벤트 스트림. 델타 = EPCIS(재고/위치/조립/변환) + 운영 델타(`task/equipment/order.status`, EPCIS 로 재구성 불가한 절반).
78
- - **Command** (행위/act): `dispatch(cmd)` 가 실제로 sim 을 변이 → State 델타 유발(폐루프). 코어 공통 `order.hold`/`resume`(할당 보류/재개) + 도메인 `order.release`(즉시 투입) 등은 `handleCommand` 시임으로 확장.
79
- - **Scenario**: `scenario.load/start/pause/setSpeed`(시뮬 자극).
118
+ - **State**: `getSnapshot()` + 델타 이벤트 스트림. 델타 = EPCIS(재고/위치/조립/변환) + 운영 델타(`task/equipment/order.status`, EPCIS 로 재구성 불가한 절반) + 관측(`location.measured`).
119
+ - **Command**(행위/act): `dispatch(cmd)` 가 실제로 상태를 변이 → State 델타 유발(폐루프). 공통 `order.hold`/`resume` + 도메인 확장은 `handleCommand` 시임.
120
+ - **Scenario**: `scenario.load/start/pause/setSpeed`(시뮬 자극). 선언은 `scenario-validate.ts` 가 검증한다.
80
121
  - **TwinRuntime**: `subscribe`(snapshot→delta, revision 연속) · `resync` · `tick`. sparse 스트리밍(전이·move-start 만, 매 tick 아님).
81
122
 
123
+ `StateSnapshot` 의 축: `locations` · `items` · `equipment` · `persons` · `assets` · `tasks` · `orders` · `attentions` · `energy` · `conformance` · `nowTime` · `identityGrounding` · `unhandled` · `stepsWithoutMaterial`.
124
+
125
+ **`nowTime` 은 트윈이 직접 말한다.** 소비처가 벽시계로 대신 재면 시뮬 트윈에서 엉뚱한 값이 나오고, 관측 모드에서는 「지금」이 마지막으로 들은 발생 시각이다. 없으면 소비처는 **재지 않는다**.
126
+
82
127
  ## State 이원 모델
128
+
83
129
  - **EPCIS 저널**(이산): 재고·위치·조립·변환. `epcis.ts` 로 정규 방출, `validateEpcisEvent` 검증.
84
- - **운영·키네마틱**(연속): 무버 motion(from/to/progress)·태스크·오더 진척. 운영 델타로 미러.
85
- - **`ObservedReducer`** 가 둘을 접어 재구성 → sim 이벤트든 live 이벤트든 같은 State(데이터원 스왑). `StateProjector` 는 같은 것의 옛 이름(별칭 유지).
130
+ - **운영·키네마틱**(연속): 설비 motion(from/to/progress)·태스크·오더 진척. 운영 델타로 미러.
131
+ - **`ObservedReducer`** 가 둘을 접어 재구성 → sim 이벤트든 live 이벤트든 같은 State(데이터원 스왑).
86
132
 
87
133
  ### 구동은 둘, 규칙은 하나 — 적합성 하네스
88
134
 
89
- 상태를 만드는 구동이 둘이다(시뮬 `tick()` / 관측 `apply()`). 각자 계약으로 옮기는 코드가 두 벌이면 한쪽만 고쳤을 때 **조용히 갈라진다** — 계약 필드가 거의 다 선택이라 "안 채우는 것이 합법"이고, 컴파일러가 잡아 주지 않는다.
135
+ 상태를 만드는 구동이 둘이다(시뮬 `tick()` / 관측 `apply()`). 각자 계약으로 옮기는 코드가 두 벌이면 한쪽만 고쳤을 때 **조용히 어긋난다** — 계약 필드가 거의 다 선택이라 "안 채우는 것이 합법"이고, 컴파일러가 잡아 주지 않는다.
136
+
137
+ > **불변식: 커널 상태의 모든 사실은 이벤트로 나가야 한다 (상태 ⊆ 이벤트).**
90
138
 
91
- > **불변식: 커널 상태의 모든 사실은 이벤트로 나가야 한다.**
139
+ 나가지 않는 사실은 미러가 모르고, 저널로 복원되지도 않고(시간여행), 미러에서 세운 예측 씨앗에도 실리지 않는다. `test/driver-conformance.test.ts` 가 시뮬을 굴려 **그 이벤트를 그대로 미러에 흘리고** 두 스냅샷을 대조한다. 정당한 예외는 주석이 아니라 **상수로** 들고 이유를 적는다(호스트가 적분하는 `oee`, 매 tick 가지 않는 보간값 — 대신 **앵커는 반드시 같다**).
92
140
 
93
- 나가지 않는 사실은 미러가 모르고, 저널로 복원되지도 않고(시간여행), 미러에서 세운 예측 씨앗에도 실리지 않는다. `test/driver-conformance.test.ts` 가 시뮬을 굴려 **그 이벤트를 그대로 미러에 흘리고** 두 스냅샷을 대조한다(WMS·YMS·MES). 정당한 예외는 주석이 아니라 **상수로** 들고 이유를 적는다(호스트가 적분하는 `oee`, 매 tick 가지 않는 보간값 — 대신 **앵커는 반드시 같다**).
141
+ ## 재기동 — 재개점에서 이어 접는다
142
+
143
+ 저널을 처음부터 다시 접지 않기 위한 축이다. 실 저널이 수천만 줄인 현장에서 이것은 성능이 아니라 **가능/불가능**의 문제다.
144
+
145
+ ```ts
146
+ observedCheckpoint(): ReducerCheckpoint | undefined // 재개점을 꺼낸다(관측 구동이 아니면 undefined)
147
+ restoreObserved(cp: ReducerCheckpoint): void // 재개점에서 되세운다
148
+ replayFrom(model, checkpoint, events) // 재개점 + 꼬리만 접는다
149
+ ```
94
150
 
95
- ## 트윈 본연 — 현재로부터 예측 + 정합 (mirror+sim 과 구별짓는 기능)
96
- 관측·예측·행위가 따로 있는 건 mirror+simulator. 트윈의 본질은 그 **커플링**:
97
- - **`kernel.fork()`** — 현재 상태(in-flight 포함)를 정확히 복제한 격리 엔진. 원본(live/sim)은 계속 가고, fork 는 **현재로부터 앞으로 굴려** forecast(완료 시각·처리량)·대안 what-if 탐색.
98
- - **`compareStates(predicted, actual)`** — fork 예측 vs 실제 관측을 같은 시점에 대조 → **드리프트(모델↔현실 이탈) 탐지·국소화**(item/node/order 필드별). 발산 = 이상/개입 신호.
99
- - 루프: 관측(현재) → fork 예측(미래) → 관측(실제) → 정합(발산) → 행위(개입).
100
- - **`EventJournal` + `replay(board, events)`** — 트윈의 **기억**: 이벤트열이 곧 상태(이벤트-소싱). `journal.until(revision)`/`untilSimTime(iso)` 을 projector 로 재생 → **임의 과거 시점 상태 재구성(시간여행)**. 감사·리플레이·발산 비교의 기반. things-factory 에선 append 를 DB 이벤트 테이블로 교체.
151
+ > **상태 스냅샷은 씨앗이 되지 못한다.** 리듀서는 소비처가 보는 값 말고도 든다 — 부모를 기다리는 담김·집계 중인 수량·담을 자리를 몰라 세어 둔 사건. 상태만 되돌리고 뒤를 이어 접으면 **0부터 접은 결과와 조용히 달라진다.**
101
152
 
102
- ### 본질 심화
103
- - **fork = 완전한 결정적 분기** — 상태 + 시나리오(gens) + **RNG state** 까지 복제 → 미래 생성까지 원본과 동일하게 이어가는 진짜 continuation(what-if 는 fork 후 scenario 교체).
104
- - **`TwinObserver`** — 자동 정합 루프: 주기적으로 fork-예측 후 실제가 그 시점 도달 시 대조 → **드리프트를 알림 이벤트로**. 트윈이 자기 모델↔현실 이탈을 능동 감지(방해 없으면 발산 0).
105
- - **`monteCarloForecast`** — 확률적 예측: seed 변주 N개 fork → 지표를 **분포**로(min/mean/p50/p90/max). "언제 끝나?"가 아니라 "P90 완료시각·소진 확률". 원본 무간섭.
106
- - **`TwinHistory` + `counterfactualAt`** — 반사실(기억+분기): 주기 checkpoint(전체 커널 fork 저장) → 과거 시점 T로 되돌아가(사이 시점은 결정적 재구동) **대안 결정을 fork** → 앞으로 굴려 baseline 과 대조 → **"그때 X 했다면?"의 효과**. 사후분석·의사결정 평가.
153
+ 그 동치(`재개점 + 꼬리 == 0부터 접기`)는 `test/observed-checkpoint.test.ts` 가 증명한다. 배경·시행착오·호스트 배선은 [`design/fold-and-resume.md`](../../design/fold-and-resume.md).
154
+
155
+ 원본이 **되풀어 주지 않는 축**은 따로 이어받는다(`hydrateContinuity`) — 열린 수요 구간의 누적·적산 기준점·관측 이후 최대·신호가 언제부터인지. 관측 축(재고·자리·설비)은 그 문으로 심지 않는다.
156
+
157
+ ## 주의 신호 — 커널이 판정하고, 문장은 만들지 않는다
158
+
159
+ `attentions` 는 커널이 스스로 내는 판정이다(`deriveAttentions`/`collectAttentions`). 각 신호는 `kind` · `severity` · `anchor`(어디의 일인가) · `params`(근거가 된 값)를 든다.
160
+
161
+ **문장을 만들지 않는다.** 커널은 키와 값만 내고 표현은 소비처가 한다 — 커널이 한국어 문장을 들면 그 트윈은 한 언어에 묶인다. 그리고 판정에는 **근거를 함께 싣는다**: 한계를 넘은 관측 신호는 값·단위·한계·**언제의 값인지**·잰 것인지 접은 것인지를 모두 낸다. 센서가 멈춘 현장에서 그 시각이 없으면 사람은 방금 벗어난 것으로 읽는다.
162
+
163
+ 판정할 수 없으면 **아무 말도 하지 않는다** — 없는 이탈을 만들지도, 확인되지 않은 합격을 만들지도 않는다.
164
+
165
+ ## 트윈 본연 — 현재로부터 예측 + 정합
166
+
167
+ 관측·예측·행위가 따로 있는 것은 mirror+simulator 다. 트윈의 본질은 그 **커플링**:
168
+
169
+ - **`fork()`** — 현재 상태(in-flight 포함)를 정확히 복제한 격리 엔진. 원본(live/sim)은 계속 진행하고, fork 는 **현재로부터 앞으로** 굴려 완료 시각·처리량을 예측하고 what-if 를 탐색한다. 상태 + 시나리오 + **RNG state** 까지 복제하는 진짜 continuation.
170
+ - **`compareStates(predicted, actual)`** — 같은 시점에 대조해 드리프트(모델↔현실 이탈)를 국소화한다(item/location/order 필드별). 발산 = 이상·개입 신호.
171
+ - **`monteCarloForecast`** — seed 변주 N개 fork → 지표를 **분포**로(min/mean/p50/p90/max). 「언제 끝나나」가 아니라 「P90 완료시각·소진 확률」.
172
+ - **`EventJournal` + `replay`/`replayFrom`** — 트윈의 기억. `until(revision)`/`untilSimTime(iso)` 로 **임의 과거 시점 재구성**(시간여행). 호스트에서는 append 를 DB 이벤트 표로 교체한다.
173
+ - **`TwinHistory` + `counterfactualAt`** — 과거 시점 T 로 돌아가 **대안 결정을 fork** → baseline 과 대조. 「그때 X 했다면」의 효과.
174
+ - **`TwinObserver`** — 주기적으로 fork-예측 후 실제가 그 시점에 도달하면 대조 → 드리프트를 알림 이벤트로.
175
+
176
+ 예측은 **자기 한계를 함께 말한다**: 계보를 잇지 못한 구간(`stepsWithoutMaterial`)과 원본이 개체를 말하지 않아 막힌 오더(`blocked-source-omits-material`)를 결과에 싣는다. 조용히 넘기면 계보가 빈 예측이 정상처럼 보인다.
107
177
 
108
178
  ## 확장 지점
179
+
109
180
  - **버티컬 추가**: `{x}-profile.ts`(어휘) + `{x}-kernel.ts`(4 hook).
181
+ - **능력(capability)으로 찾기**: concrete 타입에 강결합하지 않는다 — 자원이 능력을 선언하고 공정이 능력을 요구한다.
110
182
  - **할당 정책**: `AllocationPolicy` 교체(firstFit/partialFit 내장, FEFO/nearest/zone 추가 가능).
183
+ - **소요시간 추정기**: 실측 기반 추정기를 호스트가 주입.
111
184
  - **Face2 어댑터**: 실 시스템 페이로드 → 선언적 매핑 → 정규 EPCIS.
112
185
  - **host 결합**: `TwinRuntime` 를 GraphQL sub·서비스로 래핑(전송은 얇은 host 계층).
113
186
 
114
187
  ## 미구현(범위 밖)
115
- host 결합(things-factory 전송/퍼시스턴스/커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM).
116
188
 
117
- **모델의 남은 공백**(정직하게): 인원 자격·숙련도(등급만 있고 자격 매칭·숙련도별 소요 차이 없음) · 자산 회수 작업(빈 팔레트가 도착 자리에 남을 뿐) · `movers`/`nodes` 계층 겸직(이동설비↔워크센터, 위치↔워크센터) · next-event 도약(지금은 고정 간격 tick).
189
+ host 결합(전송·퍼시스턴스·커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM).
190
+
191
+ **모델의 남은 공백**(정직하게):
192
+
193
+ - 인원 **숙련도** — 등급과 자격 시험은 있으나 숙련도별 소요 차이는 없다
194
+ - 자산 **회수 작업** — 빈 팔레트가 도착 자리에 남을 뿐
195
+ - `equipment`/`locations` **계층 겸직** — 한 목록이 이동설비와 워크센터를, 다른 목록이 주소 가능한 자리와 작업 자리를 함께 든다(개명으로 이름은 정리됐고 계층은 그대로다)
196
+ - **next-event 도약** — 지금은 고정 간격 tick
197
+ - 라이브 **예측** — 미러 상태에서 fork 는 되지만 라이브 자체의 예측 경로는 통합 보류
198
+ - 구조가 **여러 번 갈린** 저널의 마디별 재개점 — 지금은 최신 하나
199
+
200
+ **1.0 게이트**: `equipment`/`locations` 계층을 정리하거나 가산적으로만 가능하도록 확정한 뒤. 계약 파괴가 예정된 채로 1.0 을 주면 다음 라운드가 곧 2.0 이 된다.
201
+
202
+ ## 설계 SoT
118
203
 
119
- **1.0 게이트**: `movers`/`nodes` 계층을 정리하거나 가산적으로만 가능하도록 확정한 뒤. 계약 파괴가 예정된 채로 1.0 을 주면 다음 라운드가 곧 2.0 이 된다.
204
+ `operato-twin/design/` —
120
205
 
121
- 설계 SoT: `operato-twin/design/plans/` — `simulation-spec.md`(명세·추정기·예측 자격) · `four-resources-and-conformance.md`(4대 자원·불변식) · `kernel-unification-live-observe.md`(구동 통합).
206
+ - [`fold-and-resume.md`](../../design/fold-and-resume.md) — 저널 접기·재개점(시행착오·원칙·현 구현)
207
+ - [`runtime-state-model.md`](../../design/runtime-state-model.md) — 라이브=권위 / 히스토리=파생, 시각축, 정체성
208
+ - [`04-decisions.md`](../../design/04-decisions.md) — ADR
209
+ - `plans/simulation-spec.md`(명세·추정기·예측 자격) · `plans/four-resources-and-conformance.md`(4대 자원·불변식) · `plans/kernel-unification-live-observe.md`(구동 통합)
210
+ - `profiles/ems.md` — 에너지 프로파일의 표준 앵커
@@ -315,8 +315,25 @@ export interface TestResult {
315
315
  /**
316
316
  * 합격 여부 — **우리가 좁힌 것이다.** 표준 `EvaluatedCriterionResult` 는 `TextType` 이고
317
317
  * 판정 어휘를 열거하지 않는다(원문 대조 2026-08-24). 넓힐 때는 이 주석과 함께 움직인다.
318
+ *
319
+ * ── 왜 선택인가 (2026-08-24) ──────────────────────────────────────────────
320
+ * **재기만 하고 판정하지 않는 원천이 정상이다.** 계측기는 값을 내고 판정은 규정이 한다. 그런데 이
321
+ * 필드가 필수이면 그런 원천의 사실을 **아예 담을 수 없다** — 판정을 지어내거나 측정값을 버리거나
322
+ * 둘 중 하나가 되고, 둘 다 이 저장소가 거절하는 것이다(결측을 값으로 바꾸지 않는다).
323
+ *
324
+ * 비어 있는 것은 **「모른다」이며 「합격」이 아니다.** `testPassedAt` 이 그것을 지킨다(거짓을 낸다).
325
+ * 기준이 선언돼 있으면 커널이 채울 수 있다(`judgeAgainstSpec`) — 그때는 `derived` 가 선다.
318
326
  */
319
- result: 'pass' | 'fail';
327
+ result?: 'pass' | 'fail';
328
+ /**
329
+ * 이 판정을 **우리가 냈나** — 원천이 판정하지 않아 선언된 기준으로 커널이 채운 것.
330
+ *
331
+ * 관측의 `derived` 와 같은 낱말·같은 뜻이다(낱말을 둘로 만들지 않는다). 규제 기록에서 「현장이
332
+ * 판정했다」와 「트윈이 계산했다」가 구별되지 않으면, 그 기록은 사고 뒤에 쓸 수 없다.
333
+ *
334
+ * 원천의 판정에는 표식이 없다 — **없음이 「원천이 말했다」다.**
335
+ */
336
+ derived?: boolean;
320
337
  /** 언제 통과·불합격했나(ISO) — 표준 `EvaluationDate`. */
321
338
  at?: ISOTime;
322
339
  /**
@@ -339,6 +356,16 @@ export interface TestResult {
339
356
  */
340
357
  propertyMeasurements?: PropertyMeasurement[];
341
358
  }
359
+ /**
360
+ * `OP_EVENT.test` 의 페이로드 — **결과가 대상을 가리킨다.**
361
+ *
362
+ * 표준(`TestResultType`)의 방향 그대로다. 상태에서는 개체 안에 접어 두지만(소비처가 대상별로 묻는다)
363
+ * 사건에서는 가리킨다 — 그래야 대상이 무엇이든(로트·설비·사람·자리) 한 채널로 들어온다.
364
+ */
365
+ export interface TestResultFact extends TestResult {
366
+ /** 무엇을 시험했나 — 표준 `TestResult.TestableObjectID`. 물품이면 EPC. */
367
+ testableObjectId: string;
368
+ }
342
369
  /**
343
370
  * 이 시험 결과가 **이 시각에 유효한 합격인가.**
344
371
  *
@@ -547,6 +574,32 @@ export declare function testEvidenceGaps(spec: Pick<TestSpecification, 'criteria
547
574
  unmeasured: string[];
548
575
  unmatched: string[];
549
576
  };
577
+ /**
578
+ * **선언된 기준으로 판정한다** — 원천이 판정하지 않았을 때 커널이 답을 낼 수 있는가.
579
+ *
580
+ * ── 왜 이 함수가 있나 (당위) ────────────────────────────────────────────────
581
+ * 트윈은 **아무도 판정하지 않을 때 판정해야 한다.** 값이 있고 기준이 있는데 판정이 없으면, 화면은
582
+ * 아무 말도 하지 않고 그것은 「전부 이상 없음」과 **화면상 똑같이 보인다.** 그 침묵이 이 함수가
583
+ * 없앤 것이다.
584
+ *
585
+ * ── 세 갈래로 답한다 (이 함수의 전부) ───────────────────────────────────────
586
+ *
587
+ * 'fail' 기준 하나라도 **분명히** 벗어났다
588
+ * 'pass' **말하는 기준 전부**를 판정했고 전부 안에 있다
589
+ * undefined 판정할 수 없다 — 하나라도 판정 못 한 기준이 있거나, 판정할 기준이 아예 없다
590
+ *
591
+ * `'pass'` 가 「말하는 기준 **전부**」를 요구하는 것이 이 함수의 핵심이다. 하나라도 판정하지 못했는데
592
+ * 합격이라 하면 **확인되지 않은 합격**이 되고, 규제 기록에서 그것은 결함이 아니라 사고다. 그래서
593
+ * 모르는 것이 하나라도 있으면 아무 말도 하지 않는다.
594
+ *
595
+ * 아무 한계도 말하지 않는 기준(`criterionSaysNothing`)은 **셈에서 뺀다** — 자유 문장만 적힌 기준
596
+ * 때문에 판정 가능한 것까지 침묵하면, 이 축이 그 현장에서 영원히 죽는다(고치려던 것의 반대 방향).
597
+ * 그 기준이 비어 있다는 사실은 `testEvidenceGaps` 와 `criterionSaysNothing` 이 따로 낸다 —
598
+ * **설정의 흠과 운영의 사실을 한 답에 섞지 않는다.**
599
+ *
600
+ * 커널은 `Expression` 을 **읽지 않는다**(문법이 정의되지 않은 자유 문자열). 판정은 숫자 한계로만 한다.
601
+ */
602
+ export declare function judgeAgainstSpec(spec: Pick<TestSpecification, 'criteria'> | undefined, result: Pick<TestResult, 'propertyMeasurements'> | undefined): 'pass' | 'fail' | undefined;
550
603
  /**
551
604
  * 유효 기간 — **ISA-95 `EffectiveStartDate` / `EffectiveEndDate`.**
552
605
  *
@@ -1151,6 +1204,39 @@ export interface ItemState {
1151
1204
  lot?: string;
1152
1205
  location: string;
1153
1206
  disposition?: string;
1207
+ /**
1208
+ * 이 로트의 **시험 결과** — 명세당 최신 하나.
1209
+ *
1210
+ * ── 로트에 「어느 기준이 걸리나」는 두지 않았다 ────────────────────────────
1211
+ * 표준 `MaterialLotType` 에는 `TestSpecificationID` 가 있다. 우리는 그것을 **로트에 두지 않는다** —
1212
+ * 이미 `TwinModelDef.materialDefinitions[].testSpecificationIds` 가 「이 품목에 무엇이 걸리나」를
1213
+ * 말하고, 물품은 `gtinKey` 로 그 선언에 닿는다. 로트마다 또 적으면 두 벌이 되고, 두 벌은 언젠가
1214
+ * 어긋난다.
1215
+ *
1216
+ * 그리고 **채우는 곳이 없는 칸은 만들지 않는다.** 로트별로 다른 기준을 말하는 원천을 만나면 그때
1217
+ * 열고, 그때는 채우는 코드와 함께 온다 — 선언만 있는 능력은 소비처에게 거짓말이 된다.
1218
+ *
1219
+ * ── 당위 (2026-08-24) ────────────────────────────────────────────────────
1220
+ * 트윈은 「이 자재를 쓸 수 있나」에 답해야 한다. 그 답의 **근거가 측정값**이다. 판정만 들고 값을
1221
+ * 버리면 「합격이라고 적혀 있다」만 남고 **왜 합격인지 되짚을 수 없다** — 규제 기록은 성질상 사고
1222
+ * 뒤에 읽히므로 그때 아무 일도 하지 못한다. 그리고 이 기록을 이어 갈 수 있는 것은 트윈뿐이다:
1223
+ * 로트는 시스템을 넘어 다니고, 판정한 시스템은 그 뒤를 모른다.
1224
+ *
1225
+ * ── 표준에서 왜 여기 있나 (원문 대조) ─────────────────────────────────────
1226
+ * `MaterialLotType`(B2MML-Material.xsd)이 드는 것은 `TestSpecificationID`(어느 기준)와
1227
+ * `Disposition`(판정)이고, **시험 결과는 로트 안에 없다.** 결과는 `TestResultType` 이라는 별개
1228
+ * 기록이고 `TestableObjectID` 로 **대상을 가리킨다**(B2MML-OperationsTest.xsd).
1229
+ *
1230
+ * **그것이 우리가 좁힌 자리다**: 우리 상태는 대상별 투영이라(소비처가 「이 물품은?」을 묻는다) 가리키는
1231
+ * 기록을 개체 안에 접어 둔다. 같은 사실이고 방향만 다르다 — 사건에서는 표준 그대로 대상을 가리킨다
1232
+ * (§`OP_EVENT.test`). 자원에서 이미 같은 좁힘을 했다(`EquipmentState.testResults`).
1233
+ *
1234
+ * ── 명세당 하나인 이유 ────────────────────────────────────────────────────
1235
+ * 이력을 들면 상태가 **계측 주기로 자란다**(품목 100만 기준에서는 그것이 곧 벽이다). 「그때 무엇을
1236
+ * 쟀나」는 저널이 답하는 물음이고, 상태가 답하는 것은 「지금 이 로트의 자격이 무엇이냐」다.
1237
+ * 자리의 관측을 속성당 하나만 든 것과 같은 규율이다(§`LocationState.observations`).
1238
+ */
1239
+ testResults?: TestResult[];
1154
1240
  /** 소속 물류단위(팔레트 SSCC 등) — AggregationEvent 로 맺어진다. 3D 적재 표현의 재료. */
1155
1241
  parent?: string;
1156
1242
  /**
@@ -2062,6 +2148,19 @@ export declare const OP_EVENT: {
2062
2148
  readonly asset: "asset.status";
2063
2149
  readonly order: "order.status";
2064
2150
  readonly quality: "quality.output";
2151
+ /**
2152
+ * **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
2153
+ *
2154
+ * `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
2155
+ * 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
2156
+ *
2157
+ * **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
2158
+ * 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
2159
+ *
2160
+ * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
2161
+ * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
2162
+ */
2163
+ readonly test: "test.result";
2065
2164
  /**
2066
2165
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
2067
2166
  *
package/dist/contract.js CHANGED
@@ -331,6 +331,51 @@ export function testEvidenceGaps(spec, result) {
331
331
  .filter((k) => !!k && !wanted.has(k))
332
332
  };
333
333
  }
334
+ /**
335
+ * **선언된 기준으로 판정한다** — 원천이 판정하지 않았을 때 커널이 답을 낼 수 있는가.
336
+ *
337
+ * ── 왜 이 함수가 있나 (당위) ────────────────────────────────────────────────
338
+ * 트윈은 **아무도 판정하지 않을 때 판정해야 한다.** 값이 있고 기준이 있는데 판정이 없으면, 화면은
339
+ * 아무 말도 하지 않고 그것은 「전부 이상 없음」과 **화면상 똑같이 보인다.** 그 침묵이 이 함수가
340
+ * 없앤 것이다.
341
+ *
342
+ * ── 세 갈래로 답한다 (이 함수의 전부) ───────────────────────────────────────
343
+ *
344
+ * 'fail' 기준 하나라도 **분명히** 벗어났다
345
+ * 'pass' **말하는 기준 전부**를 판정했고 전부 안에 있다
346
+ * undefined 판정할 수 없다 — 하나라도 판정 못 한 기준이 있거나, 판정할 기준이 아예 없다
347
+ *
348
+ * `'pass'` 가 「말하는 기준 **전부**」를 요구하는 것이 이 함수의 핵심이다. 하나라도 판정하지 못했는데
349
+ * 합격이라 하면 **확인되지 않은 합격**이 되고, 규제 기록에서 그것은 결함이 아니라 사고다. 그래서
350
+ * 모르는 것이 하나라도 있으면 아무 말도 하지 않는다.
351
+ *
352
+ * 아무 한계도 말하지 않는 기준(`criterionSaysNothing`)은 **셈에서 뺀다** — 자유 문장만 적힌 기준
353
+ * 때문에 판정 가능한 것까지 침묵하면, 이 축이 그 현장에서 영원히 죽는다(고치려던 것의 반대 방향).
354
+ * 그 기준이 비어 있다는 사실은 `testEvidenceGaps` 와 `criterionSaysNothing` 이 따로 낸다 —
355
+ * **설정의 흠과 운영의 사실을 한 답에 섞지 않는다.**
356
+ *
357
+ * 커널은 `Expression` 을 **읽지 않는다**(문법이 정의되지 않은 자유 문자열). 판정은 숫자 한계로만 한다.
358
+ */
359
+ export function judgeAgainstSpec(spec, result) {
360
+ /* 판정할 기준만 남긴다 — 말하지 않는 기준은 이 답에 관여하지 않는다. */
361
+ const criteria = (spec?.criteria ?? []).filter(c => !criterionSaysNothing(c));
362
+ if (!criteria.length)
363
+ return undefined;
364
+ const measurements = result?.propertyMeasurements ?? [];
365
+ let allJudged = true;
366
+ for (const c of criteria) {
367
+ /* 그 기준이 재는 속성의 측정값 — 속성을 말하지 않은 기준은 짝을 맞출 수 없다. */
368
+ const m = c.evaluatedPropertyId
369
+ ? measurements.find(x => x.testableObjectPropertyId === c.evaluatedPropertyId)
370
+ : undefined;
371
+ const out = outsideLimit(c, m);
372
+ if (out === true)
373
+ return 'fail';
374
+ if (out === undefined)
375
+ allJudged = false;
376
+ }
377
+ return allJudged ? 'pass' : undefined;
378
+ }
334
379
  /**
335
380
  * 이 시각에 유효 기간 밖인가 — **한 규칙**으로 개체·등급·설비↔자산 매핑을 모두 판정한다.
336
381
  *
@@ -886,6 +931,19 @@ export const OP_EVENT = {
886
931
  asset: 'asset.status',
887
932
  order: 'order.status',
888
933
  quality: 'quality.output', // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
934
+ /**
935
+ * **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
936
+ *
937
+ * `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
938
+ * 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
939
+ *
940
+ * **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
941
+ * 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
942
+ *
943
+ * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
944
+ * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
945
+ */
946
+ test: 'test.result',
889
947
  /**
890
948
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
891
949
  *
@@ -1,4 +1,4 @@
1
- import type { AssetState, MaterialQuantity, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, LocationObservation, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
1
+ import type { AssetState, TestResult, 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) — 비직렬 로트가 자리마다 갈릴 때만. */
@@ -16,6 +16,8 @@ interface ProjItem {
16
16
  /** 받은 개체·로트 마스터데이터 원문 — 이름을 모르는 속성도 잃지 않는다. */
17
17
  ilmd?: Record<string, unknown>;
18
18
  expiry?: number;
19
+ /** 이 로트의 시험 결과 — **명세당 최신 하나**(상태가 계측 주기로 자라지 않게). */
20
+ testResults?: TestResult[];
19
21
  }
20
22
  /**
21
23
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
@@ -177,6 +179,8 @@ export declare class ObservedReducer {
177
179
  private corrections;
178
180
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
179
181
  private unhandled;
182
+ /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
183
+ private testSpecs;
180
184
  /**
181
185
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
182
186
  *
@@ -19,7 +19,7 @@
19
19
  * - 운영 델타(task/equipment/order.status) → tasks·equipment·orders (EPCIS 로 재구성 불가한 절반)
20
20
  * 마스터(로케이션)는 board 초기화 + applyMaster 로 갱신(마스터 동기).
21
21
  */
22
- import { OP_EVENT, capabilityOf, itemKeyOf, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
22
+ import { OP_EVENT, capabilityOf, itemKeyOf, judgeAgainstSpec, requiredTestsFor, locationStatusOf, readBoardEquipment, readBoardLocations, readBoardAssets, effectivityAt, offCalendarAt, offCalendarReasonAt, activeShiftAt } from "./contract.js";
23
23
  import { ILMD_ATTR, parseEpc } from "./epcis.js";
24
24
  /**
25
25
  * 투영이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
@@ -68,6 +68,8 @@ export class ObservedReducer {
68
68
  corrections = [];
69
69
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
70
70
  unhandled = new Map();
71
+ /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
72
+ testSpecs = new Map();
71
73
  /**
72
74
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
73
75
  *
@@ -87,6 +89,9 @@ export class ObservedReducer {
87
89
  constructor(model) {
88
90
  this.utcOffsetMinutes = model.utcOffsetMinutes;
89
91
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
92
+ /* 선언된 판정 기준 — 원천이 판정하지 않은 결과를 커널이 판정할 때 쓴다(§`judgeAgainstSpec`). */
93
+ for (const sp of model.testSpecifications ?? [])
94
+ this.testSpecs.set(sp.id, sp);
90
95
  for (const n of readBoardLocations(model))
91
96
  this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: 'master' });
92
97
  // 설비 기준선(마스터) — equipment.status 델타로 갱신됨.
@@ -362,6 +367,43 @@ export class ObservedReducer {
362
367
  this.observations.set(d.locationId, bin);
363
368
  break;
364
369
  }
370
+ case OP_EVENT.test: {
371
+ /*
372
+ * **시험 결과** — 대상을 가리켜 들어오고(표준 방향), 상태에서는 그 개체 안에 접힌다.
373
+ *
374
+ * 명세당 마지막 하나만 든다: 이력을 들면 상태가 계측 주기로 자란다(품목 100만 기준에서 벽이다).
375
+ * 늦게 온 옛 결과가 최신을 덮지 않게 `stale` 을 지나며, **대상 키에 명세를 넣는다** — 온도
376
+ * 기준의 결과가 늦게 와도 중량 기준의 최신은 지켜져야 한다.
377
+ *
378
+ * ── 물품을 지어내지 않는다 ────────────────────────────────────────────
379
+ * 자리의 관측은 모르는 자리를 세운다(`origin: 'observed'` — 자리는 있는 것이다). **물품은
380
+ * 세우지 않는다**: 없는 재고를 만드는 것이고, 그것은 사실을 잃는 것보다 나쁘다. 그래서 모르는
381
+ * 대상의 결과는 `unhandled` 로 **세어서** 낸다 — 조용히 버리지 않는다.
382
+ *
383
+ * ── 원천이 판정하지 않으면 커널이 판정한다 ──────────────────────────
384
+ * 값이 있고 기준이 선언돼 있는데 판정이 없으면 화면은 침묵하고, 그 침묵은 「이상 없음」과
385
+ * 구별되지 않는다. 그래서 `judgeAgainstSpec` 으로 채우고 **`derived` 를 세운다** — 「현장이
386
+ * 판정했다」와 「트윈이 계산했다」가 섞이면 그 기록은 사고 뒤에 쓸 수 없다.
387
+ * 판정할 수 없으면 비워 둔다(모름을 합격으로 만들지 않는다).
388
+ */
389
+ const d = e.data;
390
+ if (!d?.testableObjectId || !d?.specId)
391
+ break;
392
+ if (this.stale(`test:${d.testableObjectId}:${d.specId}`, e))
393
+ return;
394
+ const item = this.items.get(d.testableObjectId);
395
+ if (!item)
396
+ break;
397
+ const { testableObjectId: _target, ...rest } = d;
398
+ const judged = rest.result === undefined ? judgeAgainstSpec(this.testSpecs.get(d.specId), rest) : undefined;
399
+ const next = {
400
+ ...rest,
401
+ ...(judged ? { result: judged, derived: true } : {})
402
+ };
403
+ const kept = (item.testResults ?? []).filter(r => r.specId !== d.specId);
404
+ item.testResults = [...kept, next];
405
+ break;
406
+ }
365
407
  case OP_EVENT.attentionAck: {
366
408
  /* 확인한 사실만 담는다 — 그 신호가 지금도 성립하는지는 상태가 답한다(여기서 판단하지 않는다). */
367
409
  const d = e.data;
@@ -132,6 +132,7 @@ __export(index_exports, {
132
132
  isTransformationRecord: () => isTransformationRecord,
133
133
  isoDurationHours: () => isoDurationHours,
134
134
  itemKeyOf: () => itemKeyOf,
135
+ judgeAgainstSpec: () => judgeAgainstSpec,
135
136
  levelOfLocationType: () => levelOfLocationType,
136
137
  lgtinClass: () => lgtinClass,
137
138
  locationStatusOf: () => locationStatusOf,
@@ -325,6 +326,19 @@ function testEvidenceGaps(spec, result) {
325
326
  unmatched: measurements.map((m) => m.testableObjectPropertyId).filter((k) => !!k && !wanted.has(k))
326
327
  };
327
328
  }
329
+ function judgeAgainstSpec(spec, result) {
330
+ const criteria = (spec?.criteria ?? []).filter((c) => !criterionSaysNothing(c));
331
+ if (!criteria.length) return void 0;
332
+ const measurements = result?.propertyMeasurements ?? [];
333
+ let allJudged = true;
334
+ for (const c of criteria) {
335
+ const m = c.evaluatedPropertyId ? measurements.find((x) => x.testableObjectPropertyId === c.evaluatedPropertyId) : void 0;
336
+ const out = outsideLimit(c, m);
337
+ if (out === true) return "fail";
338
+ if (out === void 0) allJudged = false;
339
+ }
340
+ return allJudged ? "pass" : void 0;
341
+ }
328
342
  function effectivityAt(p, at) {
329
343
  if (!p || !at) return void 0;
330
344
  const atMs = parsedMs(at);
@@ -587,6 +601,19 @@ var OP_EVENT = {
587
601
  order: "order.status",
588
602
  quality: "quality.output",
589
603
  // 품질 산출(양품/불량) — OEE quality 입력. live 누적기가 이걸로 good/scrap 정확 추적.
604
+ /**
605
+ * **시험 결과** — 어느 대상을 어느 기준으로 재고 판정했나.
606
+ *
607
+ * `quality.output` 과 **다른 사실이다.** 그것은 생산의 양품·불량 수(가동률 입력)이고, 이것은 선언된
608
+ * 기준에 대한 검사 판정이다. 한 낱말이 두 일을 하면 어느 쪽 어휘도 옳지 않게 된다.
609
+ *
610
+ * **대상을 가리킨다**(`testableObjectId`) — 표준 `TestResult.TestableObjectID` 그대로. 로트·설비·
611
+ * 사람·자리에 두루 쓰이므로 주체를 이름에 넣지 않았다.
612
+ *
613
+ * 이 채널이 없으면 시험 결과는 **상태에만 있는 축**이 된다 — 재기동에서 사라지고, 폴드가 되살릴 수
614
+ * 없고, 미러가 이어받지 못한다(§상태 ⊆ 이벤트).
615
+ */
616
+ test: "test.result",
590
617
  /**
591
618
  * 주목 신호 확인(ack) — **사람이 한 행위**라 파생될 수 없다.
592
619
  *
@@ -1357,6 +1384,8 @@ var ObservedReducer = class {
1357
1384
  corrections = [];
1358
1385
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
1359
1386
  unhandled = /* @__PURE__ */ new Map();
1387
+ /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
1388
+ testSpecs = /* @__PURE__ */ new Map();
1360
1389
  /**
1361
1390
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
1362
1391
  *
@@ -1376,6 +1405,7 @@ var ObservedReducer = class {
1376
1405
  constructor(model) {
1377
1406
  this.utcOffsetMinutes = model.utcOffsetMinutes;
1378
1407
  this.classDefs = { personnel: model.personnelClasses, equipment: model.equipmentClasses, asset: model.assetClasses };
1408
+ for (const sp of model.testSpecifications ?? []) this.testSpecs.set(sp.id, sp);
1379
1409
  for (const n of readBoardLocations(model)) this.master.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, parentId: n.parentId, origin: "master" });
1380
1410
  for (const m of readBoardEquipment(model)) this.equipment.set(m.id, { id: m.id, kind: m.kind, status: "idle", location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, ...effectiveOf(m), origin: "master" });
1381
1411
  for (const p of model.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...p.testResults ? { testResults: p.testResults } : {}, ...effectiveOf(p) });
@@ -1609,6 +1639,22 @@ var ObservedReducer = class {
1609
1639
  this.observations.set(d.locationId, bin);
1610
1640
  break;
1611
1641
  }
1642
+ case OP_EVENT.test: {
1643
+ const d = e.data;
1644
+ if (!d?.testableObjectId || !d?.specId) break;
1645
+ if (this.stale(`test:${d.testableObjectId}:${d.specId}`, e)) return;
1646
+ const item = this.items.get(d.testableObjectId);
1647
+ if (!item) break;
1648
+ const { testableObjectId: _target, ...rest } = d;
1649
+ const judged = rest.result === void 0 ? judgeAgainstSpec(this.testSpecs.get(d.specId), rest) : void 0;
1650
+ const next = {
1651
+ ...rest,
1652
+ ...judged ? { result: judged, derived: true } : {}
1653
+ };
1654
+ const kept = (item.testResults ?? []).filter((r) => r.specId !== d.specId);
1655
+ item.testResults = [...kept, next];
1656
+ break;
1657
+ }
1612
1658
  case OP_EVENT.attentionAck: {
1613
1659
  const d = e.data;
1614
1660
  if (d?.id) this.acked.add(d.id);
@@ -9400,6 +9446,7 @@ function retiredVocabularyIn(line) {
9400
9446
  isTransformationRecord,
9401
9447
  isoDurationHours,
9402
9448
  itemKeyOf,
9449
+ judgeAgainstSpec,
9403
9450
  levelOfLocationType,
9404
9451
  lgtinClass,
9405
9452
  locationStatusOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.56",
3
+ "version": "0.7.57",
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": {