@operato/twin-kernel 0.7.61 → 0.7.62

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,210 +1,226 @@
1
- # @operato/twin-kernel — 비즈니스레이어 디지털트윈 커널
1
+ # @operato/twin-kernel — a business-layer digital twin kernel
2
2
 
3
- 물류창고·야드·스마트팩토리·에너지의 **비즈니스 실행**을 시뮬레이션·모니터링하는 **헤드리스·프레임워크 무관·zero-dep** TS 커널. UI/3D/DOM 없이 순수 Node 에서 동작하는 것이 목적(sim=능동 생산, live=수동 미러, **계약 동일**).
3
+ A **headless, framework-agnostic, zero-dependency** TypeScript kernel that simulates and mirrors the **business execution** of warehouses, yards, factories and energy systems. It runs in plain Node with no UI, no 3D and no DOM (`sim` = it generates events, `live` = it mirrors them — **same contract either way**).
4
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대 자원
5
+ - Run: `node --test test/*.test.ts` — native TS, **zero runtime dependencies**
6
+ - Status: **v0.7.61 · 1,131 tests green** · 122 test files · 4 verticals (WMS/YMS/MES/EMS) · ISA-95 four resources
7
7
 
8
- ## 계층 (아래로만 의존, 무방언)
8
+ ## Layers (dependencies point down only, no dialect)
9
9
 
10
10
  ```
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 확장
11
+ contract.ts 3-channel contract (State/Command/Scenario) + operational deltas + TwinKernel ← domain/standard agnostic
12
+ epcis.ts GS1 EPCIS 2.0 event machinery (types · builders · validator · URIs · CBV) ← standard, domain agnostic
13
+ vocabulary.ts retired-name list + guard pragma — renames are enforced by machine
14
+ flow-engine.ts FlowEngine base — mechanics (RNG · clock · tick · resource claim · task progress · emit · snapshot)
15
+ observed-reducer.ts events → State fold (**the single rule both drivers use**) + resume points ← domain agnostic
16
+ state-projector.ts the old name for the same thing (kept for consumers)
17
+
18
+ allocation-policy.ts AllocationPolicy seam (selectPlacement/selectStock)
19
+ capability.ts what a resource can do — find by capability, never by concrete type
20
+ capacity.ts capacity and occupancy
21
+ operations-capability.ts what an operation requires ↔ what a resource offers
22
+ domain-definition.ts operation specs (ISA-95 OperationsSegment: duration · variability · yield · resource needs)
23
+ domain-catalog.ts the catalogue of what can be declared, plus the declared relations between axes
24
+ duration-estimator.ts seam for measured estimators injected by the host
25
+ iso-duration.ts xsd:duration parser (calendar-dependent units are rejected)
26
+ make-to-order.ts make-to-order production
27
+ task-fold.ts task folding
28
+ job-response.ts ISA-95 job response
29
+
30
+ event-journal.ts the journal + replay/replayFrom — the twin's memory
31
+ forecast.ts Monte-Carlo forecast (answers with a distribution)
32
+ divergence.ts predicted vs observed → drift, localised
33
+ counterfactual.ts branch from a past instant — "what if we had done X"
34
+ twin-observer.ts the automatic reconciliation loop
35
+
36
+ energy-ingest.ts metering intake (totalisers, demand windows)
37
+ energy-attribution.ts attributing energy to consumers
38
+ operational-ingest.ts operational-fact intake, and the routing that decides which fact a record is
39
+ scenario-validate.ts validating a declared scenario
40
+ face2-adapter.ts legacy records → canonical EPCIS (declarative mapping + validation) ← ACL
41
+ runtime.ts TwinRuntime — host-facing facade + subscription protocol ← domain/transport agnostic
42
+
43
+ {wms,yms,mes,ems}-profile.ts domain vocabulary only (bizStep / btt / standard anchors)
44
+ kernel.ts · {yms,mes,ems}-kernel.ts domain flow verbs (4 hooks) — extend FlowEngine
45
45
  ```
46
46
 
47
- > **무방언 원칙**: 코어(contract/epcis/flow-engine/policy/reducer/runtime)에 특정 도메인 용어를 넣지 않는다. EPCIS 는 표준이라 `epcis.ts`(≠wms). `order.kind`·`task.resourceType` 등은 도메인이 소유. 정책 인터페이스는 `selectPlacement`/`selectStock`(≠putaway/pallets).
47
+ > **No dialect in the core.** `contract` / `epcis` / `flow-engine` / `policy` / `reducer` / `runtime` carry no domain words. EPCIS lives in `epcis.ts` because it is a standard, not because it is WMS. `order.kind` and `task.resourceType` belong to the domain. Policy interfaces are `selectPlacement` / `selectStock`, never `putaway` / `pallets`.
48
48
 
49
- ## FlowEngine — 단일 base, 도메인은 4 hook 만
49
+ ## FlowEngine — one base, four hooks per domain
50
50
 
51
- `abstract FlowEngine implements TwinKernel` 이 모든 mechanics 를 소유. 도메인 kernel 은 **flow 동사**만 구현:
51
+ `abstract FlowEngine implements TwinKernel` owns all the mechanics. A domain kernel implements only the **flow verbs**:
52
52
 
53
- | hook | 역할 |
53
+ | hook | what it means |
54
54
  |---|---|
55
- | `onArrival(spec)` | 입고/도착 자극 → 아이템 생성 + EPCIS + 반입 태스크 |
56
- | `onOrder(spec)` | 오더/작업지시 자극 → 오더 생성 |
57
- | `allocate(order)` | created 오더 할당 → 재고 선택(정책) + 태스크. **시간창 게이트도 여기**(스케줄링) |
58
- | `onTaskComplete(task)` | 완료의 의미 — 이동(WMS/YMS) 또는 변환(MES). occupancy·EPCIS·오더 이행 전부 도메인 |
55
+ | `onArrival(spec)` | inbound stimulus → create items + EPCIS + an inbound task |
56
+ | `onOrder(spec)` | order stimulus → create the order |
57
+ | `allocate(order)` | a `created` order → pick stock (policy) + create tasks. **Time-window gating lives here too** |
58
+ | `onTaskComplete(task)` | what completion *means* — a move (WMS/YMS) or a transformation (MES). Occupancy, EPCIS and order fulfilment are the domain's |
59
59
 
60
- base 는 태스크 **생명주기**(자원 배정·진행·완료·해제·델타)만 소유 → 이동-중립. 새 버티컬 = 프로파일(어휘) + kernel(4 hook).
60
+ The base owns only the task **lifecycle** (claim · progress · complete · release · delta), so it is move-neutral. A new vertical is a profile (vocabulary) plus a kernel (4 hooks).
61
61
 
62
- ## 4 버티컬 — base 가 여러 flow 성격을 담음을 실증
62
+ ## Four verticals — evidence that one base carries several flow shapes
63
63
 
64
- | 버티컬 | flow 성격 | base 대응 |
64
+ | vertical | flow shape | how the base copes |
65
65
  |---|---|---|
66
- | **WMS** | 이동 (putaway/pick/pack/ship) + 백오더 | 기본 |
67
- | **YMS** | 이동 (spot/pull) + **시간창 스케줄링**(어포인트먼트↔도크도어 예약) | 스케줄링=도메인 게이팅으로 **base 흡수** |
68
- | **MES** | **변환**(TransformationEvent) + 다단계 라우팅 + **이종 자원**(cutter/welder) | 자원 매칭=`FlowTask.resourceType` **base 확장** |
69
- | **EMS** | 계측의 흐름 — 적산·구간 마감·피크·귀속 | 유입·귀속 모듈로 확장(자리=**전기적 구간**) |
66
+ | **WMS** | movement (putaway/pick/pack/ship) + backorders | as-is |
67
+ | **YMS** | movement (spot/pull) + **time-window scheduling** (appointment ↔ dock door reservation) | scheduling **absorbed** as domain gating |
68
+ | **MES** | **transformation** (TransformationEvent) + multi-step routing + **heterogeneous resources** (cutter/welder) | resource matching = `FlowTask.resourceType`, a minimal base extension |
69
+ | **EMS** | the flow of measurement — totalising, window closing, peaks, attribution | intake + attribution modules (a "place" here is an **electrical segment**) |
70
70
 
71
- EMS 는 **표준 앵커가 다르다**: 물류·생산은 ISA-95/EPCIS 로 재고 에너지는 **IEC 61850**(설비 데이터 모델)·**ISO 50001**(에너지 경영)로 잰다. 대응할 표준 이름이 없으면 그 칸을 **비운다** — 억지로 가까운 이름을 적으면 적합성 표가 거짓을 말한다.
71
+ EMS **anchors to a different standard**: logistics and production are measured against ISA-95/EPCIS, energy against **IEC 61850** (equipment data model) and **ISO 50001** (energy management). Where the standard has no matching name, that cell is **left empty** — forcing a nearby name would make the conformance table lie.
72
72
 
73
- ## 4대 자원 (ISA-95)
73
+ ## Four resources (ISA-95)
74
74
 
75
- 작업이 요구하는 자원을 표준대로 넷으로 나눠 든다. **한 그릇에 담지 않는 이유**: 사람은 고장 나지 않고 교대로 살고, 설비는 고장 나며 OEE 로 평가받고, 팔레트는 돌아온다. 섞으면 한쪽 어휘가 다른 쪽에 붙어 둘 다 거짓이 된다.
75
+ A task's resource needs are held as the standard's four kinds. **Why not one bucket:** people do not break down and live by shifts, equipment does break down and is judged by OEE, pallets come back. Mix them and one vocabulary attaches to the wrong thing, making both false.
76
76
 
77
- | ISA-95 | 커널 | 요구 명세 |
77
+ | ISA-95 | kernel | requirement spec |
78
78
  |---|---|---|
79
- | Personnel | `persons` · `PersonState` | `personnelSpecification` = 등급 + 인원 수 |
80
- | Equipment | `equipment` · `EquipmentState` | `equipmentSpecification` = 등급 + 대수 |
79
+ | Personnel | `persons` · `PersonState` | `personnelSpecification` = class + headcount |
80
+ | Equipment | `equipment` · `EquipmentState` | `equipmentSpecification` = class + count |
81
81
  | PhysicalAsset | `assets` · `AssetState` (GS1 **GRAI**) | `physicalAssetSpecification` |
82
82
  | Material | `items` · `ItemState` (EPCIS) | — |
83
83
 
84
- 배정 규율: **부분 확보로 시작하지 않는다.** 한 종류라도 모자라면 기다린다(반쯤 잡고 실패하면 자원이 일 없이 묶인다). 확보(`claim*`)와 확정(`assign*`)이 나뉜 이유. 완료 시 함께 해제하되 **자산은 도착 자리에 남는다**(물건이므로 — 회수의 출발점).
84
+ Claim discipline: **never start with a partial claim.** If any kind is short, wait — half-claiming ties resources up with no work to show for it. That is why claiming (`claim*`) and committing (`assign*`) are separate. Everything is released on completion **except the asset, which stays where it arrived** (it is a physical thing — that is where recovery starts).
85
85
 
86
- > 팔레트를 물품으로 두지 않은 이유: GS1 에서 **SSCC**(물류단위)와 **GRAI**(돌아오는 팔레트 자체)는 다른 것이다. 같은 GRAI 가 오늘은 이 SSCC 를, 내일은 다른 SSCC 를 싣는다. 그래서 **신설하고 연결**한다(`AssetState.carrying` ↔ `ItemState.carriedBy`).
86
+ > Why a pallet is not an item: in GS1, an **SSCC** (a logistic unit) and a **GRAI** (the returnable pallet itself) are different things. The same GRAI carries one SSCC today and another tomorrow. So we model them separately and link them (`AssetState.carrying` ↔ `ItemState.carriedBy`).
87
87
 
88
- 자원에는 **자격**이 붙는다. 등급이 시험을 요구하고(`testSpecificationIds`) 개체가 결과를 든다(`testResults`) — 요구된 시험의 결과가 없거나 만료·불합격이면 자격이 성립하지 않는다(`meetsTests`). 결과가 **아예 없는 것**과 **불합격**을 같은 값으로 만들지 않는다.
88
+ Resources carry **qualifications**. A class requires tests (`testSpecificationIds`) and an instance holds results (`testResults`) — a required test that is missing, expired or failed means the qualification does not hold (`meetsTests`). **"No result" and "failed" are never the same value.**
89
89
 
90
- ## 자리와 그 물리 조건
90
+ ## Places and their physical conditions
91
91
 
92
- `locations` · `LocationState` — 자리는 ISA-95 설비 계층의 노드다(Enterprise→Site→Area→StorageZone→StorageUnit).
92
+ `locations` · `LocationState` — a place is a node in the ISA-95 equipment hierarchy (Enterprise→Site→Area→StorageZone→StorageUnit).
93
93
 
94
- 자리는 **물리 조건**을 든다: `observations`(속성별 최신 관측 — 온도·습도 등)와 `testSpecificationIds`(그 자리에 걸린 기준). 조건 없이는 물건의 상태를 판정할 수 없고, **이 조인은 트윈만 할 수 있다** — 계측 시스템은 물건의 자리 이력을 모르고, 물류 시스템은 조건 이력을 모른다.
94
+ A place holds **physical conditions**: `observations` (the latest reading per property — temperature, humidity …) and `testSpecificationIds` (the criteria that apply there). Without conditions you cannot judge the state of the goods, and **only the twin can make this join** — the metering system does not know where the goods were, and the logistics system does not know the conditions.
95
95
 
96
- | 축 | 표준 | 우리가 더한 것 |
96
+ | axis | standard | what we added |
97
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` |
98
+ | observation | `OperationsEvent` + `OperationsRecordTemplate` (`EffectiveTimestamp` · `EffectiveEndDate` · `HierarchyScope`) | we **narrowed** the value to `ValueType` — a physical quantity with no unit cannot be judged |
99
+ | criteria | `TestSpecificationCriteria` (`Expression`, free text) | **numeric limits** `limit: {minimum, maximum, uom}` — free text cannot be evaluated |
100
+ | verdict | `TestResult.EvaluatedCriterionResult` (TextType) | narrowed to `pass`/`fail` · CBV dispositions `conformant`/`non_conformant` |
101
101
 
102
- 판정 함수 `outsideLimit()` 은 **세 갈래로 답한다** — 벗어남(`true`) · 안(`false`) · **판정 못 함(`undefined`)**. 숫자 한계가 없거나, 값이 수가 아니거나, 양쪽이 서로 다른 단위를 말하면 거절한다. 「모름」을 「적합」으로 만들지 않기 위한 것이고, 규제 기록에서 그 구별이 사라지면 결함이 아니라 사고다. 근거의 빈 곳은 `testEvidenceGaps()` 가 따로 센다.
102
+ `outsideLimit()` answers **three ways** — outside (`true`), inside (`false`), **cannot judge (`undefined`)**. It refuses when there is no numeric limit, when the value is not a number, or when the two sides declare different units. This keeps "unknown" from collapsing into "conformant"; in a regulated record, losing that distinction is not a bug but an incident. Missing evidence is counted separately by `testEvidenceGaps()`.
103
103
 
104
- 관측은 **사건으로 들어온다**(`OP_EVENT.observation` = `location.measured`) — 상태에만 있는 축은 조용히 사라지는 축이다. 구간 관측(`effectiveEndTime`)은 그 구간 안에서만 참이다: 원본이 「그날 아침」만 말하면 09:00 을 지어내지 않는다.
104
+ Observations **arrive as events** (`OP_EVENT.observation` = `location.measured`) — an axis that lives only in state is an axis that quietly disappears. An interval observation (`effectiveEndTime`) is true only within its interval: if the source says "that morning", we do not invent 09:00.
105
105
 
106
- ## 공정 명세와 소요시간 세 층
106
+ ## Lots carry their test evidence
107
107
 
108
- `OperationDef` 가 ISA-95 `OperationsSegment` 를 담는다 — `duration`(xsd:duration) · `variability`(퍼짐) · `parameters`(수율·준비시간) · 자원 요구 셋. 소요시간은 **강한 근거부터** 찾는다:
108
+ `ItemState.testResults` — the latest result per specification, each carrying the measurements behind it.
109
109
 
110
- **실측 추정기(호스트 주입) > 선언된 명세 > 커널 상수**
110
+ A record that says only "non-conformant" and one that says "we measured 4.2 °C and it was over the limit" are **different objects**. Regulated records are read **after** an incident, so a verdict without evidence does nothing at the moment it is needed. And the twin is the only thing that can carry this forward: lots cross systems, and the system that judged does not know what happened next.
111
111
 
112
- `specCoverage()` 가 무엇을 썼는지 세어 예측의 **자격**을 낸다 — `relative`(전부 상수: 대안 비교만) · `partial` · `absolute-capable` · `calibrated`. 근거가 없다고 거절하지 않고, **무엇에 근거했는지 함께 말한다.**
112
+ In the standard the verdict is a separate record that **points at** its subject (`TestResult.TestableObjectID`), so events point and state folds it into the object — our narrowing, recorded in the contract. Only the latest result per specification is kept: history is a question for the journal, not for state.
113
113
 
114
- > 표준이 정하지 않은 것: ISA-95 는 자원 구조는 정하지만 파라미터 ID 어휘는 정하지 않는다. 그래서 `OP_PARAM`(yield·setupDuration) **한 곳**에서 정하고, 발명한 자리임을 숨기지 않는다.
114
+ When the source measures but does not judge, the kernel judges from the declared criteria and marks the verdict `derived`. A verdict from the field carries no mark — **absence means the field said it.** The kernel never overrides a verdict the source gave.
115
115
 
116
- ## 3채널 계약 (Face 1) + 구독
116
+ ## Operation specs and three layers of duration
117
117
 
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` 가 검증한다.
121
- - **TwinRuntime**: `subscribe`(snapshot→delta, revision 연속) · `resync` · `tick`. sparse 스트리밍(전이·move-start 만, 매 tick 아님).
118
+ `OperationDef` carries the ISA-95 `OperationsSegment` — `duration` (xsd:duration) · `variability` · `parameters` (yield, setup) · the three resource requirements. Duration is resolved **strongest evidence first**:
122
119
 
123
- `StateSnapshot` 의 축: `locations` · `items` · `equipment` · `persons` · `assets` · `tasks` · `orders` · `attentions` · `energy` · `conformance` · `nowTime` · `identityGrounding` · `unhandled` · `stepsWithoutMaterial`.
120
+ **measured estimator (host-injected) > declared spec > kernel constant**
124
121
 
125
- **`nowTime` 은 트윈이 직접 말한다.** 소비처가 벽시계로 대신 재면 시뮬 트윈에서 엉뚱한 값이 나오고, 관측 모드에서는 「지금」이 마지막으로 들은 발생 시각이다. 없으면 소비처는 **재지 않는다**.
122
+ `specCoverage()` counts which was used and reports the forecast's **standing** — `relative` (all constants: comparison only) · `partial` · `absolute-capable` · `calibrated`. It never refuses for lack of evidence; it **says what the answer rests on.**
126
123
 
127
- ## State 이원 모델
124
+ > What the standard does not fix: ISA-95 defines resource structure but not parameter-ID vocabulary. So `OP_PARAM` (yield, setupDuration) is defined in **one place**, and the fact that we invented it is not hidden.
128
125
 
129
- - **EPCIS 저널**(이산): 재고·위치·조립·변환. `epcis.ts` 로 정규 방출, `validateEpcisEvent` 검증.
130
- - **운영·키네마틱**(연속): 설비 motion(from/to/progress)·태스크·오더 진척. 운영 델타로 미러.
131
- - **`ObservedReducer`** 가 둘을 접어 재구성 → sim 이벤트든 live 이벤트든 같은 State(데이터원 스왑).
126
+ ## The three-channel contract (Face 1) and subscription
132
127
 
133
- ### 구동은 둘, 규칙은 하나 — 적합성 하네스
128
+ - **State**: `getSnapshot()` plus a delta stream. Deltas are EPCIS (stock/place/aggregation/transformation) + operational deltas (`task/equipment/order.status` — the half EPCIS cannot reconstruct) + observations (`location.measured`) + test results (`test.result`).
129
+ - **Command** (act): `dispatch(cmd)` actually mutates state and therefore produces State deltas (closing the loop). `order.hold`/`resume` are common; domains extend through the `handleCommand` seam.
130
+ - **Scenario**: `scenario.load/start/pause/setSpeed`. A declared scenario is validated by `scenario-validate.ts`.
131
+ - **TwinRuntime**: `subscribe` (snapshot→delta, continuous revision) · `resync` · `tick`. Sparse streaming — transitions and move-starts only, not every tick.
134
132
 
135
- 상태를 만드는 구동이 둘이다(시뮬 `tick()` / 관측 `apply()`). 각자 계약으로 옮기는 코드가 두 벌이면 한쪽만 고쳤을 때 **조용히 어긋난다** — 계약 필드가 거의 다 선택이라 "안 채우는 것이 합법"이고, 컴파일러가 잡아 주지 않는다.
133
+ `StateSnapshot` axes: `locations` · `items` · `equipment` · `persons` · `assets` · `tasks` · `orders` · `attentions` · `energy` · `conformance` · `nowTime` · `identityGrounding` · `unhandled` · `stepsWithoutMaterial`.
136
134
 
137
- > **불변식: 커널 상태의 모든 사실은 이벤트로 나가야 한다 (상태 ⊆ 이벤트).**
135
+ **`nowTime` is the twin speaking its own clock.** A consumer that substitutes wall-clock time gets nonsense in a simulated twin, and in observation mode "now" is the last event time we heard. If it is absent, consumers **do not measure**.
138
136
 
139
- 나가지 않는 사실은 미러가 모르고, 저널로 복원되지도 않고(시간여행), 미러에서 세운 예측 씨앗에도 실리지 않는다. `test/driver-conformance.test.ts` 가 시뮬을 굴려 **그 이벤트를 그대로 미러에 흘리고** 두 스냅샷을 대조한다. 정당한 예외는 주석이 아니라 **상수로** 들고 이유를 적는다(호스트가 적분하는 `oee`, 매 tick 가지 않는 보간값 — 대신 **앵커는 반드시 같다**).
137
+ ## The dual state model
140
138
 
141
- ## 재기동 — 재개점에서 이어 접는다
139
+ - **EPCIS journal** (discrete): stock, place, aggregation, transformation. Emitted canonically through `epcis.ts` and checked by `validateEpcisEvent`.
140
+ - **Operational and kinematic** (continuous): equipment motion (from/to/progress), task and order progress. Mirrored as operational deltas.
141
+ - **`ObservedReducer`** folds both into the same State, whether the events came from a simulation or from the field.
142
142
 
143
- 저널을 처음부터 다시 접지 않기 위한 축이다. 실 저널이 수천만 줄인 현장에서 이것은 성능이 아니라 **가능/불가능**의 문제다.
143
+ ### Two drivers, one rule — the conformance harness
144
+
145
+ Two things produce state (simulation `tick()` and observation `apply()`). If each has its own copy of the mapping to the contract, fixing one **silently diverges** from the other — nearly every contract field is optional, so "not filling it" is legal and the compiler says nothing.
146
+
147
+ > **Invariant: every fact in kernel state must go out as an event (state ⊆ events).**
148
+
149
+ A fact that does not go out is invisible to the mirror, cannot be restored from the journal (time travel), and is missing from the seed a forecast forks from. `test/driver-conformance.test.ts` runs a simulation, **feeds its own events into a mirror**, and compares the two snapshots. Legitimate exceptions are held as **constants with a stated reason** — never as prose — for example the host-integrated `oee` and interpolated values that do not travel every tick; **their anchors must still match.**
150
+
151
+ ## Restart — fold from a resume point, not from line one
152
+
153
+ This axis exists so a restart does not re-fold the journal from the beginning. On a real site whose journal runs to tens of millions of rows, that is not a performance question but a question of possible versus impossible.
144
154
 
145
155
  ```ts
146
- observedCheckpoint(): ReducerCheckpoint | undefined // 재개점을 꺼낸다(관측 구동이 아니면 undefined)
147
- restoreObserved(cp: ReducerCheckpoint): void // 재개점에서 되세운다
148
- replayFrom(model, checkpoint, events) // 재개점 + 꼬리만 접는다
156
+ observedCheckpoint(): ReducerCheckpoint | undefined // take the resume point (undefined unless observation-driven)
157
+ restoreObserved(cp: ReducerCheckpoint): void // stand the reducer back up from it
158
+ replayFrom(model, checkpoint, events) // fold the resume point plus the tail only
149
159
  ```
150
160
 
151
- > **상태 스냅샷은 씨앗이 되지 못한다.** 리듀서는 소비처가 보는 값 말고도 든다 — 부모를 기다리는 담김·집계 중인 수량·담을 자리를 몰라 세어 둔 사건. 상태만 되돌리고 뒤를 이어 접으면 **0부터 접은 결과와 조용히 달라진다.**
161
+ > **A state snapshot cannot serve as the seed.** The reducer holds more than what consumers see — aggregations awaiting their parent, quantities mid-tally, events counted because there was nowhere to put them. Restore only the state and fold the tail and you **quietly** get a different answer from folding from zero.
162
+
163
+ That equivalence (`resume point + tail == fold from zero`) is proved by `test/observed-checkpoint.test.ts`. The background, the wrong turns and the host wiring are written up in [`design/fold-and-resume.md`](../../design/fold-and-resume.md).
164
+
165
+ Axes the **source will not restate** are carried separately (`hydrateContinuity`) — the open demand window's accumulation, totaliser baselines, the peak since observation began, and when each attention started. Observation axes (stock, place, equipment) are deliberately **not** seeded that way.
166
+
167
+ ### What happened does not disappear; what is current does
152
168
 
153
- 그 동치(`재개점 + 꼬리 == 0부터 접기`)는 `test/observed-checkpoint.test.ts` 가 증명한다. 배경·시행착오·호스트 배선은 [`design/fold-and-resume.md`](../../design/fold-and-resume.md).
169
+ A later transition that does not mention a field does not erase it — **if that field records something that happened.** A thing that happened cannot become a thing that did not happen, so `materialActual` survives until the source restates it (an explicit empty array clears it).
154
170
 
155
- 원본이 **되풀어 주지 않는 축**은 따로 이어받는다(`hydrateContinuity`) — 열린 수요 구간의 누적·적산 기준점·관측 이후 최대·신호가 언제부터인지. 관측 축(재고·자리·설비)은 그 문으로 심지 않는다.
171
+ The opposite rule holds for current values. `remainingMs`, `progress` and the resource reference are emitted only while a task is running; keeping them would show a finished task still holding time and still gripping a resource it released. **Before widening this list, ask whether the field's absence could itself be a fact.** If it could, it does not belong here.
156
172
 
157
- ## 주의 신호 — 커널이 판정하고, 문장은 만들지 않는다
173
+ ## Attentions — the kernel judges, but writes no sentences
158
174
 
159
- `attentions` 는 커널이 스스로 내는 판정이다(`deriveAttentions`/`collectAttentions`). 각 신호는 `kind` · `severity` · `anchor`(어디의 일인가) · `params`(근거가 된 값)를 든다.
175
+ `attentions` are judgements the kernel makes for itself (`deriveAttentions` / `collectAttentions`). Each carries `kind` · `severity` · `anchor` (whose problem it is) · `params` (the values behind it).
160
176
 
161
- **문장을 만들지 않는다.** 커널은 키와 값만 내고 표현은 소비처가 한다 — 커널이 한국어 문장을 들면 그 트윈은 한 언어에 묶인다. 그리고 판정에는 **근거를 함께 싣는다**: 한계를 넘은 관측 신호는 값·단위·한계·**언제의 값인지**·잰 것인지 접은 것인지를 모두 낸다. 센서가 멈춘 현장에서 그 시각이 없으면 사람은 방금 벗어난 것으로 읽는다.
177
+ **It writes no sentences.** The kernel emits keys and values; wording belongs to the consumer — a kernel that holds Korean sentences is a twin bound to one language. And a judgement always carries its evidence: an out-of-limit observation reports the value, the unit, the limit, **when it was measured**, and whether it was measured or derived. On a site whose sensor has stopped, a signal without that timestamp reads as though it just happened.
162
178
 
163
- 판정할 수 없으면 **아무 말도 하지 않는다** — 없는 이탈을 만들지도, 확인되지 않은 합격을 만들지도 않는다.
179
+ When it cannot judge, it **says nothing** — it neither invents a violation nor manufactures an unverified pass.
164
180
 
165
- ## 트윈 본연 — 현재로부터 예측 + 정합
181
+ ## What makes it a twin — forecasting from now, and reconciliation
166
182
 
167
- 관측·예측·행위가 따로 있는 것은 mirror+simulator 다. 트윈의 본질은 그 **커플링**:
183
+ Observation, prediction and action existing separately is a mirror plus a simulator. A twin is their **coupling**:
168
184
 
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-예측 후 실제가 그 시점에 도달하면 대조 → 드리프트를 알림 이벤트로.
185
+ - **`fork()`** — an isolated engine cloned from the current state, in-flight work included. The original (live or sim) keeps going while the fork rolls **forward from now** to predict completion times and throughput and to explore what-ifs. State, scenario and **RNG state** are all cloned, so it is a true continuation.
186
+ - **`compareStates(predicted, actual)`** — compares at the same instant and localises drift between model and reality (per item / location / order field). Divergence is the signal to look, or to intervene.
187
+ - **`monteCarloForecast`** — N seed-varied forks give the metric as a **distribution** (min/mean/p50/p90/max). Not "when will it finish" but "P90 completion time, probability of running out".
188
+ - **`EventJournal` + `replay`/`replayFrom`** — the twin's memory. `until(revision)` / `untilSimTime(iso)` reconstruct **any past instant** (time travel). In the host, append is swapped for a database event table.
189
+ - **`TwinHistory` + `counterfactualAt`** — return to instant T, **fork an alternative decision**, and compare against the baseline. "What if we had done X."
190
+ - **`TwinObserver`** — forks a prediction periodically and compares once reality reaches that instant, raising drift as an event.
175
191
 
176
- 예측은 **자기 한계를 함께 말한다**: 계보를 잇지 못한 구간(`stepsWithoutMaterial`)과 원본이 개체를 말하지 않아 막힌 오더(`blocked-source-omits-material`)를 결과에 싣는다. 조용히 넘기면 계보가 빈 예측이 정상처럼 보인다.
192
+ A forecast also **states its own limits**: steps taken without being able to link an item (`stepsWithoutMaterial`) and orders blocked because the source never named one (`blocked-source-omits-material`) are reported in the result. Passing over them silently would make a forecast with no lineage look normal.
177
193
 
178
- ## 확장 지점
194
+ ## Extension points
179
195
 
180
- - **버티컬 추가**: `{x}-profile.ts`(어휘) + `{x}-kernel.ts`(4 hook).
181
- - **능력(capability)으로 찾기**: concrete 타입에 강결합하지 않는다 — 자원이 능력을 선언하고 공정이 능력을 요구한다.
182
- - **할당 정책**: `AllocationPolicy` 교체(firstFit/partialFit 내장, FEFO/nearest/zone 추가 가능).
183
- - **소요시간 추정기**: 실측 기반 추정기를 호스트가 주입.
184
- - **Face2 어댑터**: 실 시스템 페이로드 → 선언적 매핑 → 정규 EPCIS.
185
- - **host 결합**: `TwinRuntime` 를 GraphQL sub·서비스로 래핑(전송은 얇은 host 계층).
196
+ - **A new vertical**: `{x}-profile.ts` (vocabulary) + `{x}-kernel.ts` (4 hooks).
197
+ - **Find by capability**: never bind to a concrete type — resources declare capabilities and operations require them.
198
+ - **Allocation policy**: swap `AllocationPolicy` (firstFit/partialFit ship with it; FEFO/nearest/zone can be added).
199
+ - **Duration estimators**: the host injects estimators built from measurements.
200
+ - **Face2 adapter**: a real system's payload → declarative mapping → canonical EPCIS.
201
+ - **Host binding**: wrap `TwinRuntime` in a GraphQL subscription or service (transport stays a thin host layer).
186
202
 
187
- ## 미구현(범위 밖)
203
+ ## Not implemented (out of scope)
188
204
 
189
- host 결합(전송·퍼시스턴스·커넥터) · 보드 바인딩(컴포넌트↔SGLN) · 도메인 깊이(WMS 멀티라인, YMS 상하차 AggregationEvent, MES BOM).
205
+ Host binding (transport, persistence, connectors) · board binding (component ↔ SGLN) · domain depth (WMS multi-line, YMS load/unload AggregationEvent, MES BOM).
190
206
 
191
- **모델의 남은 공백**(정직하게):
207
+ **Gaps in the model, stated plainly:**
192
208
 
193
- - 인원 **숙련도** — 등급과 자격 시험은 있으나 숙련도별 소요 차이는 없다
194
- - 자산 **회수 작업** — 빈 팔레트가 도착 자리에 남을 뿐
195
- - `equipment`/`locations` **계층 겸직** — 한 목록이 이동설비와 워크센터를, 다른 목록이 주소 가능한 자리와 작업 자리를 함께 든다(개명으로 이름은 정리됐고 계층은 그대로다)
196
- - **next-event 도약** — 지금은 고정 간격 tick
197
- - 라이브 **예측** — 미러 상태에서 fork 는 되지만 라이브 자체의 예측 경로는 통합 보류
198
- - 구조가 **여러 번 갈린** 저널의 마디별 재개점 — 지금은 최신 하나
209
+ - Personnel **skill level** — classes and qualification tests exist, but skill does not change duration
210
+ - Asset **recovery work** — an empty pallet simply stays where it arrived
211
+ - `equipment` / `locations` **level conflation** — one list holds both mobile equipment and work centres, the other both addressable places and work positions (the rename cleaned up the names, not the levels)
212
+ - **Next-event jumping** — ticks are still fixed-interval
213
+ - **Live forecasting** — a mirror's state can be forked, but the live path's own forecast is deliberately not unified yet
214
+ - Per-segment resume points for a journal whose **structure changed several times** — only the latest is kept
199
215
 
200
- **1.0 게이트**: `equipment`/`locations` 계층을 정리하거나 가산적으로만 가능하도록 확정한 뒤. 계약 파괴가 예정된 채로 1.0 을 주면 다음 라운드가 곧 2.0 이 된다.
216
+ **1.0 gate**: after the `equipment` / `locations` levels are either resolved or fixed as additive-only. Shipping 1.0 with a contract break already scheduled makes the next round a 2.0.
201
217
 
202
- ## 설계 SoT
218
+ ## Design sources of truth
203
219
 
204
220
  `operato-twin/design/` —
205
221
 
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` — 에너지 프로파일의 표준 앵커
222
+ - [`fold-and-resume.md`](../../design/fold-and-resume.md) — folding and resume points (the wrong turns, the principles, what is implemented)
223
+ - [`runtime-state-model.md`](../../design/runtime-state-model.md) — live = authority / history = derived, the time axis, identity
224
+ - [`04-decisions.md`](../../design/04-decisions.md) — ADRs
225
+ - `plans/simulation-spec.md` (specs, estimators, forecast standing) · `plans/four-resources-and-conformance.md` (four resources, invariants) · `plans/kernel-unification-live-observe.md` (driver unification)
226
+ - `profiles/ems.md` — the energy profile's standard anchors
@@ -1058,7 +1058,7 @@ export interface LocationState {
1058
1058
  /**
1059
1059
  * 이 자리를 **어떻게 알게 됐는가** — `master`(원 시스템 마스터/저작이 말해 준 자리) ·
1060
1060
  * `observed`(이벤트에 등장해서 알게 된 자리). 소비처가 둘을 구별해야 한다: 관측으로 알게 된 자리는
1061
- * 보드에 좌표가 없고 용량이 비어 있어 **계획에 참여하지 못한다**(그 사실을 감추지 않기 위한 표시).
1061
+ * 트윈 모델에 좌표가 없고 용량이 비어 있어 **계획에 참여하지 못한다**(그 사실을 감추지 않기 위한 표시).
1062
1062
  */
1063
1063
  origin?: 'master' | 'observed';
1064
1064
  /**
@@ -2102,7 +2102,7 @@ export interface ScenarioDef {
2102
2102
  * ── 왜 시나리오에 있나 (2026-08-17) ───────────────────────────────────────
2103
2103
  * 시나리오는 이미 「우리가 가정한 미래」다. 「30분 뒤 이 설비를 세운다」는 정확히 그 미래의 일부이므로
2104
2104
  * 여기 있어야 한다. 호스트가 fork 를 굴리며 밖에서 커맨드를 쏘는 방법도 되지만, 그러면 가정이 fork
2105
- * 밖에 남아 **다른 소비처가 같은 미래를 재생할 수 없다**(예측·백테스트·AI·보드가 각자 타이밍을 다시
2105
+ * 밖에 남아 **다른 소비처가 같은 미래를 재생할 수 없다**(예측·백테스트·AI·트윈 모델이 각자 타이밍을 다시
2106
2106
  * 짜야 한다). fork 가 자기완결이면 같은 선언 하나로 어디서든 같은 미래가 나온다.
2107
2107
  *
2108
2108
  * `atMs` 는 **시나리오를 시작한 시점부터의 경과**다(절대 시각이 아니다) — fork 는 언제 갈라져도
@@ -2491,32 +2491,32 @@ export type OperationalDelta = TaskStatusDelta | EquipmentStatusDelta | PersonSt
2491
2491
  export type EventHandler = (e: CanonicalEnvelope) => void;
2492
2492
  export type Unsubscribe = () => void;
2493
2493
  /**
2494
- * **저장된 보드를 읽는 단 하나의 입구.**
2494
+ * **저장된 트윈 모델을 읽는 단 하나의 입구.**
2495
2495
  *
2496
- * `equipment` 로 개명하기 전에 저장된 보드는 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
2496
+ * `equipment` 로 개명하기 전에 저장된 트윈 모델은 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
2497
2497
  * 저장물을 다시 쓰지 않고 **읽을 때 흡수**한다 — 마이그레이션은 되돌리기 어렵고, 읽기 호환은 값싸다.
2498
2498
  *
2499
2499
  * 규율 둘:
2500
2500
  * - 이 함수를 **거치지 않고** `def.equipment` 를 직접 읽는 코드를 두지 않는다. 하나라도 남으면
2501
- * 그 경로에서만 옛 보드의 설비가 조용히 사라진다(빈 배열).
2501
+ * 그 경로에서만 옛 트윈 모델의 설비가 조용히 사라진다(빈 배열).
2502
2502
  * - **쓸 때는 새 이름만** 쓴다. 두 이름으로 쓰기 시작하면 저장물에 두 벌이 영구히 섞인다.
2503
2503
  *
2504
- * 제거 시점: 저장된 보드가 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
2504
+ * 제거 시점: 저장된 트윈 모델이 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
2505
2505
  * 사라진다. 그때까지 남겨 두는 이유를 여기 적어 두는 것이 주석의 일이다.
2506
2506
  */
2507
2507
  /**
2508
- * **저장된 보드의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
2508
+ * **저장된 트윈 모델의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
2509
2509
  *
2510
- * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 보드는 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
2510
+ * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 트윈 모델은 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
2511
2511
  * 이 함수를 거치지 않고 `def.locations` 를 직접 읽는 코드를 두지 않는다 — 하나라도 남으면 그 경로에서만
2512
- * 옛 보드의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
2512
+ * 옛 트윈 모델의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
2513
2513
  */
2514
2514
  export declare function readBoardLocations(def: TwinModelDef | (Record<string, unknown> & {
2515
2515
  locations?: unknown;
2516
2516
  nodes?: unknown;
2517
2517
  })): TwinModelDef['locations'];
2518
2518
  export declare function readBoardEquipment(def: TwinModelDef | Record<string, unknown>): TwinModelDef['equipment'];
2519
- /** 저장된 보드의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
2519
+ /** 저장된 트윈 모델의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
2520
2520
  export declare function readBoardAssets(def: TwinModelDef | Record<string, unknown>): NonNullable<TwinModelDef['assets']>;
2521
2521
  export interface TwinModelDef {
2522
2522
  /**
@@ -2632,7 +2632,7 @@ export interface TwinModelDef {
2632
2632
  *
2633
2633
  * 그 이름의 유래는 자재가 아니라 **소비자**다: `MesKernel` 의 생성자 인자 이름이
2634
2634
  * `mesSpec: MesDefinitionSpec` 이고(정의-구동 모드를 MES 에만 도입한 커밋 `ce4fbeb`), 호스트가
2635
- * 보드에 얹을 때 그 인자 이름을 그대로 가져왔다. 소비자가 하나일 때는 어색하지 않았다.
2635
+ * 트윈 모델에 얹을 때 그 인자 이름을 그대로 가져왔다. 소비자가 하나일 때는 어색하지 않았다.
2636
2636
  *
2637
2637
  * `materialSpec` 도 답이 아니다. ① 담긴 것이 자재가 아니다 — 타입·오퍼레이션(소요·변동·모수·
2638
2638
  * 인원/설비/자산/자재 명세)·라우트·레시피이고 자재는 그중 한 항목의 한 필드다. ② 그 이름은 이미
package/dist/contract.js CHANGED
@@ -1040,25 +1040,25 @@ export const CMD = {
1040
1040
  };
1041
1041
  // ── 보드 청사진 바인딩 (최소) ──────────────────────────────────────────────
1042
1042
  /**
1043
- * **저장된 보드를 읽는 단 하나의 입구.**
1043
+ * **저장된 트윈 모델을 읽는 단 하나의 입구.**
1044
1044
  *
1045
- * `equipment` 로 개명하기 전에 저장된 보드는 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
1045
+ * `equipment` 로 개명하기 전에 저장된 트윈 모델은 `movers` 키를 갖고 있다(개명 시점 23개 인스턴스). (vocabulary-guard: allow — 읽기 호환 설명)
1046
1046
  * 저장물을 다시 쓰지 않고 **읽을 때 흡수**한다 — 마이그레이션은 되돌리기 어렵고, 읽기 호환은 값싸다.
1047
1047
  *
1048
1048
  * 규율 둘:
1049
1049
  * - 이 함수를 **거치지 않고** `def.equipment` 를 직접 읽는 코드를 두지 않는다. 하나라도 남으면
1050
- * 그 경로에서만 옛 보드의 설비가 조용히 사라진다(빈 배열).
1050
+ * 그 경로에서만 옛 트윈 모델의 설비가 조용히 사라진다(빈 배열).
1051
1051
  * - **쓸 때는 새 이름만** 쓴다. 두 이름으로 쓰기 시작하면 저장물에 두 벌이 영구히 섞인다.
1052
1052
  *
1053
- * 제거 시점: 저장된 보드가 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
1053
+ * 제거 시점: 저장된 트윈 모델이 모두 `equipment` 키로 바뀐 것이 확인되면(운영 데이터 점검 후) 이 함수는
1054
1054
  * 사라진다. 그때까지 남겨 두는 이유를 여기 적어 두는 것이 주석의 일이다.
1055
1055
  */
1056
1056
  /**
1057
- * **저장된 보드의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
1057
+ * **저장된 트윈 모델의 자리를 읽는 단 하나의 입구.** `readBoardEquipment` 와 같은 규율.
1058
1058
  *
1059
- * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 보드는 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
1059
+ * `nodes` → `locations` 개명(2026-08-01) 전에 저장된 트윈 모델은 `nodes` 키를 갖고 있다(개명 시점 23개). (vocabulary-guard: allow — 읽기 호환 설명)
1060
1060
  * 이 함수를 거치지 않고 `def.locations` 를 직접 읽는 코드를 두지 않는다 — 하나라도 남으면 그 경로에서만
1061
- * 옛 보드의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
1061
+ * 옛 트윈 모델의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
1062
1062
  */
1063
1063
  export function readBoardLocations(def) {
1064
1064
  const d = def; // vocabulary-guard: allow — 옛 키 캐스트
@@ -1066,12 +1066,12 @@ export function readBoardLocations(def) {
1066
1066
  }
1067
1067
  export function readBoardEquipment(def) {
1068
1068
  /* `movers` 는 개명 **이전의 이름**이다(movers → equipmentList → equipment). 저장된 보드에는 세 세대가
1069
- 섞여 있어(실측: 23개 중 13개가 `movers`) 하나라도 빠뜨리면 그 보드는 **설비가 0인 공장**으로 읽힌다 —
1069
+ 섞여 있어(실측: 23개 중 13개가 `movers`) 하나라도 빠뜨리면 그 트윈 모델은 **설비가 0인 공장**으로 읽힌다 —
1070
1070
  오류 없이. 실제로 그랬다: 화면의 설비 수가 0이고, 용량 판정에 자원이 없고, 카탈로그 통합 테스트가
1071
1071
  "완제품 0" 으로 떨어졌다. 셋 다 원인이 이 한 줄이었다. */
1072
1072
  const d = def; // vocabulary-guard: allow — 옛 키를 읽어야 하는 자리
1073
1073
  const list = d.equipment ?? d.equipmentList ?? d.movers ?? []; // vocabulary-guard: allow — 옛 키 흡수
1074
- /* 소속 자리 키도 함께 정규화한다 — `homeNode → homeLocation` 개명 전 보드가 23개 있다. (vocabulary-guard: allow — 옛 키 정규화 설명)
1074
+ /* 소속 자리 키도 함께 정규화한다 — `homeNode → homeLocation` 개명 전 트윈 모델이 23개 있다. (vocabulary-guard: allow — 옛 키 정규화 설명)
1075
1075
  배열만 흡수하고 안쪽 키를 놓치면 설비는 나타나지만 **소속이 전부 비어** 롤업이 통째로 사라진다. */
1076
1076
  return list.map(e => normalizeHomeLocation(e));
1077
1077
  }
@@ -1083,7 +1083,7 @@ function normalizeHomeLocation(entry) {
1083
1083
  const { homeNode: _drop, ...rest } = entry; // vocabulary-guard: allow — 옛 키 제거
1084
1084
  return { ...rest, homeLocation: legacy };
1085
1085
  }
1086
- /** 저장된 보드의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
1086
+ /** 저장된 트윈 모델의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
1087
1087
  export function readBoardAssets(def) {
1088
1088
  const list = (def.assets ?? []);
1089
1089
  return list.map(a => normalizeHomeLocation(a));
@@ -86,7 +86,7 @@ export interface TwinAxisInfo {
86
86
  * 현장이 낳는 사실이고, 그 집은 커널 상태와 저널이다(ISA-95 Part 4).
87
87
  *
88
88
  * 이 칸이 없으면 축만 늘려도 소비처가 board 에서 찾다가 **언제나 0 을 답한다** — 오류 없이,
89
- * 그냥 빈 공장처럼. 이 프로젝트가 이미 그 모양으로 한 번 무너졌다(옛 어휘 보드가 자리 0·설비 0).
89
+ * 그냥 빈 공장처럼. 이 프로젝트가 이미 그 모양으로 한 번 무너졌다(옛 어휘 트윈 모델이 자리 0·설비 0).
90
90
  *
91
91
  * · `document` — 저장된 트윈 모델 안. `path` 가 그 자리를 말한다
92
92
  * · `state` — 커널 상태. **지금의 사실**이고 커널이 돌 때만 있다(멈추면 `null`, 0 이 아니다)
@@ -41,7 +41,7 @@ export declare function replayWithCheckpoint(model: TwinModelDef, events: readon
41
41
  /**
42
42
  * 한 구조 아래에서 일어난 이벤트들 — 재생의 한 마디.
43
43
  *
44
- * 필드 이름이 `model` 인 이유: 이 값의 정체는 **트윈 모델**이다(`TwinModelDef`). 보드는 그것을 그리는
44
+ * 필드 이름이 `model` 인 이유: 이 값의 정체는 **트윈 모델**이다(`TwinModelDef`). 트윈 모델은 그것을 그리는
45
45
  * 표현 중 하나일 뿐이고, 소비처는 이미 전부 `model` 로 옮겼다. 그동안 호스트가 넘길 때마다 `model` 을
46
46
  * `board` 키로 되돌려 담는 **어휘 번역기**가 경계에 끼어 있었다 — 조용히 `model` 로 넘기면 커널이
47
47
  * `undefined` 를 읽고 재생이 통째로 죽던 자리다. 이름을 맞추면 그 브릿지가 사라진다.
@@ -635,7 +635,7 @@ export declare abstract class FlowEngine implements TwinKernel {
635
635
  private materialSpecCrossKeyOverlaps;
636
636
  private observedDirty;
637
637
  private observeMode;
638
- /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
638
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 트윈 모델(구조는 이벤트가 아니라 마스터에서 온다). */
639
639
  protected boardDef?: TwinModelDef;
640
640
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
641
641
  private specUse;
@@ -1598,7 +1598,7 @@ export declare abstract class FlowEngine implements TwinKernel {
1598
1598
  /**
1599
1599
  * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
1600
1600
  *
1601
- * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
1601
+ * **트윈 모델이 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
1602
1602
  * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
1603
1603
  */
1604
1604
  protected minuteOfDay(): number;
@@ -705,7 +705,7 @@ export class FlowEngine {
705
705
  materialSpecCrossKeyOverlaps = 0;
706
706
  observedDirty = false;
707
707
  observeMode = false;
708
- /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
708
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 트윈 모델(구조는 이벤트가 아니라 마스터에서 온다). */
709
709
  boardDef;
710
710
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
711
711
  specUse = new Map();
@@ -1035,7 +1035,7 @@ export class FlowEngine {
1035
1035
  /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
1036
1036
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
1037
1037
  ...(m.held ? { held: true } : {}),
1038
- /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
1038
+ /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 트윈 모델에서 읽어 실어 온다). */
1039
1039
  ...(m.properties ? { properties: m.properties } : {}),
1040
1040
  ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}),
1041
1041
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
@@ -1561,7 +1561,7 @@ export class FlowEngine {
1561
1561
  ...(this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {})
1562
1562
  } }
1563
1563
  : {}),
1564
- /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1564
+ /* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
1565
1565
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
1566
1566
  locations: [...this.locations.values()].map(n => {
1567
1567
  const { status, ...rest } = n;
@@ -3532,7 +3532,7 @@ export class FlowEngine {
3532
3532
  /**
3533
3533
  * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
3534
3534
  *
3535
- * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
3535
+ * **트윈 모델이 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
3536
3536
  * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
3537
3537
  */
3538
3538
  minuteOfDay() {
@@ -179,7 +179,7 @@ export declare class ObservedReducer {
179
179
  private corrections;
180
180
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
181
181
  private unhandled;
182
- /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
182
+ /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
183
183
  private testSpecs;
184
184
  /**
185
185
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
@@ -195,7 +195,7 @@ export declare class ObservedReducer {
195
195
  * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
196
196
  */
197
197
  private classDefs;
198
- /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
198
+ /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
199
199
  private utcOffsetMinutes?;
200
200
  constructor(model: TwinModelDef);
201
201
  /**
@@ -228,7 +228,7 @@ export declare class ObservedReducer {
228
228
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
229
229
  *
230
230
  * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
231
- * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
231
+ * (트윈 모델에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
232
232
  */
233
233
  private touchLocation;
234
234
  /**
@@ -68,7 +68,7 @@ export class ObservedReducer {
68
68
  corrections = [];
69
69
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
70
70
  unhandled = new Map();
71
- /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
71
+ /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
72
72
  testSpecs = new Map();
73
73
  /**
74
74
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
@@ -84,7 +84,7 @@ export class ObservedReducer {
84
84
  * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
85
85
  */
86
86
  classDefs = {};
87
- /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
87
+ /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
88
88
  utcOffsetMinutes;
89
89
  constructor(model) {
90
90
  this.utcOffsetMinutes = model.utcOffsetMinutes;
@@ -209,7 +209,7 @@ export class ObservedReducer {
209
209
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
210
210
  *
211
211
  * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
212
- * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
212
+ * (트윈 모델에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
213
213
  */
214
214
  touchLocation(id) {
215
215
  if (!id || this.master.has(id))
@@ -271,6 +271,32 @@ export class ObservedReducer {
271
271
  * 예측을 이어 굴릴 수 있고, 무자원이 설계인지(체류) 구별할 수 있다. */
272
272
  this.touchLocation(d.fromNode);
273
273
  this.touchLocation(d.toNode);
274
+ /*
275
+ * ── ★ **일어난 일은 다시 말하지 않아도 지우지 않는다** (2026-08-24) ────────
276
+ *
277
+ * 이 자리는 매번 **새 객체를 짓는다.** 그래서 뒤에 온 전이가 앞의 필드를 조용히 지웠다 — 물품은
278
+ * 오래전부터 병합하는데(`mergeItem`: 「아는 것을 잃지 않는다」) **작업만 그 규율 밖에 있었다.**
279
+ *
280
+ * 실측(포천): 작업 하나가 전이 여러 건으로 온다((로트,공정) 76짝 중 52짝이 2건 이상). 완료
281
+ * 전이에 투입 실적을 실으면 **그 뒤 어떤 전이도 그것을 지웠다.**
282
+ *
283
+ * ── 그런데 **통째 병합은 반대 방향으로 틀린다** ────────────────────────────
284
+ * 어떤 필드는 **일부러 사라진다**: `remainingMs`·`progress`·`startedAtSimMs` 는 진행 중일 때만
285
+ * 실리고(§`emitTask`), 자원 참조는 놓으면 빠진다. 그것을 지키면 끝난 작업이 남은 시간을 들고
286
+ * 놓은 자원을 계속 쥔 것으로 보인다.
287
+ *
288
+ * ── 그래서 선을 이렇게 긋는다 ──────────────────────────────────────────
289
+ * **과거의 사실**은 다시 말하지 않아도 지우지 않는다 — 일어난 일은 안 일어난 일이 될 수 없다
290
+ * **지금의 값**은 말하지 않으면 없는 것이다 — 그것이 「지금」의 뜻이다
291
+ *
292
+ * 지금 이 규율이 걸리는 것은 `materialActual`(무엇을 얼마나 썼나) 하나다 — 상태가 드는 것 중
293
+ * 「일어난 일」이 그것뿐이다(`outcome` 은 상태 계약에 아직 없다). 명시로 지우려면 원천이
294
+ * **빈 배열**을 보낸다(`materialActual: []`) — 그때는 말한 것이다.
295
+ *
296
+ * 늘릴 때는 「이 필드가 사라지는 것이 사실일 수 있나」를 먼저 물어야 한다. 그렇다면 넣지 않는다.
297
+ */
298
+ const prevTask = this.tasks.get(d.taskId);
299
+ const keptActual = d.materialActual === undefined ? prevTask?.materialActual : undefined;
274
300
  this.tasks.set(d.taskId, {
275
301
  id: d.taskId, kind: d.kind, status: d.status, fromNode: d.fromNode, toNode: d.toNode,
276
302
  itemRefs: d.itemRefs, resourceRef: d.resourceRef, orderId: d.orderId,
@@ -279,8 +305,13 @@ export class ObservedReducer {
279
305
  ...(d.personnel?.length ? { personnel: d.personnel.slice() } : {}),
280
306
  ...(d.assets?.length ? { assets: d.assets.slice() } : {}),
281
307
  ...(d.resources?.length ? { resources: d.resources.slice() } : {}),
282
- /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다). */
283
- ...(d.materialActual?.length ? { materialActual: d.materialActual.map(r => ({ ...r })) } : {}),
308
+ /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다).
309
+ 말하지 않았으면 **앞의 것을 지킨다**(위 주석) — 빈 배열은 「지웠다」로 말한 것이다. */
310
+ ...(d.materialActual?.length
311
+ ? { materialActual: d.materialActual.map(r => ({ ...r })) }
312
+ : keptActual?.length
313
+ ? { materialActual: keptActual.map(r => ({ ...r })) }
314
+ : {}),
284
315
  ...(d.priority !== undefined ? { priority: d.priority } : {}),
285
316
  ...(d.startTime ? { startTime: d.startTime } : {}),
286
317
  ...(d.endTime ? { endTime: d.endTime } : {})
@@ -1,6 +1,6 @@
1
1
  import type { IngestResult } from './face2-adapter.ts';
2
2
  /** 이 문이 받는 여섯 가지 — 리듀서가 접는 것과 같은 목록(주목 확인은 우리 안의 행위라 제외). */
3
- export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test';
3
+ export type OperationalKind = 'task' | 'equipment' | 'person' | 'asset' | 'order' | 'quality' | 'test' | 'observation';
4
4
  /**
5
5
  * 정규 운영 레코드 — **델타의 필드 이름 + 시각(`at`)**.
6
6
  *
@@ -140,6 +140,28 @@ const SPECS = {
140
140
  derived: 'boolean', propertyMeasurements: 'object[]', recordTime: 'string'
141
141
  },
142
142
  enums: { result: ['pass', 'fail'] }
143
+ },
144
+ /*
145
+ * **자리의 물리 관측** — 어느 자리의 어느 속성을 언제 얼마로 쟀나.
146
+ *
147
+ * 값에 **단위를 함께** 받는다(`ValueType`). 단위 없는 물리량은 판정의 재료가 못 된다 — 3 이 섭씨인지
148
+ * 화씨인지 모르면 어떤 기준으로도 판정할 수 없다. 다만 **요구하지는 않는다**: 단위를 비우는 실 원본이
149
+ * 흔하고, 요구하면 그 원본의 관측을 아예 담지 못한다(그때 판정은 커널이 거부한다 — `outsideLimit`).
150
+ *
151
+ * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
152
+ * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
153
+ */
154
+ observation: {
155
+ eventType: OP_EVENT.observation,
156
+ identity: 'locationId',
157
+ /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
158
+ required: ['locationId', 'propertyId'],
159
+ fields: {
160
+ locationId: 'string', propertyId: 'string',
161
+ value: 'string', dataType: 'string', uom: 'string',
162
+ effectiveTime: 'string', effectiveEndTime: 'string', recordTime: 'string',
163
+ source: 'string', derived: 'boolean'
164
+ }
143
165
  }
144
166
  };
145
167
  /**
@@ -187,6 +209,20 @@ export function operationalKindOf(record) {
187
209
  */
188
210
  if (has('testableObjectId'))
189
211
  return 'test';
212
+ /*
213
+ * ── ★ **채널을 열고 또 길을 내지 않았다** (2026-08-24) ──────────────────────
214
+ * `OP_EVENT.observation`(`location.measured`)을 내고 상태(`LocationState.observations`)와 조회
215
+ * (`observationAt`)까지 붙였는데 **이 라우팅이 `locationId` 를 보지 않았다.** 커넥터가 방의 온습도를
216
+ * 실어 보내면 「어느 운영 사실인지 모른다」로 거부됐다.
217
+ *
218
+ * 같은 부류를 하루에 일곱 번 만났다. 다만 이번엔 **거부되고 이유가 남았다** — 시험 결과 때는 어느
219
+ * 통도 아니어서 조용히 사라졌다. 그 차이가 이것을 5분 만에 찾게 했다(§`isOperationalRecord`).
220
+ *
221
+ * **둘을 함께 요구한다.** `locationId` 만으로는 자리를 말하는 다른 사실과 섞인다. 관측은 「어느
222
+ * 자리의 **무엇**을 쟀나」이므로 속성 없이는 담을 곳이 없다 — 그때는 받지 않는 것이 옳다.
223
+ */
224
+ if (has('locationId') && has('propertyId'))
225
+ return 'observation';
190
226
  return undefined;
191
227
  }
192
228
  /** 이 레코드가 운영 사실인가 — 호스트의 라우팅이 묻는 자리. */
@@ -34,13 +34,13 @@ export const VOCABULARY_EXCEPTIONS = [
34
34
  { token: 'moverId', why: 'journal wire field — renaming would mix two keys for one fact across history' },
35
35
  { token: 'fromNode', why: 'journal wire field (task.status payload)' },
36
36
  { token: 'toNode', why: 'journal wire field (task.status payload)' },
37
- /* ── 보드의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
37
+ /* ── 트윈 모델의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
38
38
  * 설비 배열의 이름은 세 세대를 거쳤다(movers → equipmentList → equipment). 저장된 보드에는
39
- * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 보드는 **설비가 0인 공장**
39
+ * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 트윈 모델은 **설비가 0인 공장**
40
40
  * 으로 조용히 읽힌다 — 화면의 설비 수가 0이 되고 용량 판정에서 자원이 사라진다. 쓰는 곳은
41
41
  * `readBoardEquipment` 한 곳뿐이고, 거기서 새 이름으로 정규화해 내보낸다. */
42
42
  { token: 'movers', why: 'legacy board key (movers → equipmentList → equipment); read-only normalization in readBoardEquipment, 13 stored boards still use it' },
43
- /* ── 씬 컴포넌트 타입 — 보드에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
43
+ /* ── 씬 컴포넌트 타입 — 트윈 모델에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
44
44
  * 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
45
45
  * 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
46
46
  { token: 'twin-mover', why: 'scene component type persisted in boards; names a motion representation, not a resource' },
@@ -1394,7 +1394,7 @@ var ObservedReducer = class {
1394
1394
  corrections = [];
1395
1395
  /** 반영하지 못한 사건의 종류별 집계 — 원문은 쌓지 않는다(저널에 이미 있다). */
1396
1396
  unhandled = /* @__PURE__ */ new Map();
1397
- /** 선언된 판정 기준(보드에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
1397
+ /** 선언된 판정 기준(트윈 모델에서 한 번 읽는다) — 판정은 선언한 것에만 걸린다. */
1398
1398
  testSpecs = /* @__PURE__ */ new Map();
1399
1399
  /**
1400
1400
  * 자원별 **교대 선언** — 미러가 "지금 근무 중인가" 를 스스로 판정하기 위한 재료.
@@ -1410,7 +1410,7 @@ var ObservedReducer = class {
1410
1410
  * 통과시키는 어긋남이 생기고, 그 어긋남은 조용하다.
1411
1411
  */
1412
1412
  classDefs = {};
1413
- /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
1413
+ /** 시각 해석 기준(트윈 모델 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
1414
1414
  utcOffsetMinutes;
1415
1415
  constructor(model) {
1416
1416
  this.utcOffsetMinutes = model.utcOffsetMinutes;
@@ -1514,7 +1514,7 @@ var ObservedReducer = class {
1514
1514
  * 종류는 모르므로 `unknown`, **용량은 비워 둔다**(발명하지 않는다), 출처는 `observed`.
1515
1515
  *
1516
1516
  * 출처를 표시하는 이유: 소비처가 "마스터가 말한 자리" 와 "관측으로 알게 된 자리" 를 구별해야 한다
1517
- * (보드에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
1517
+ * (트윈 모델에 좌표가 없고, 용량을 채워야 계획에 참여한다). 마스터 동기가 오면 `master` 로 승격된다.
1518
1518
  */
1519
1519
  touchLocation(id) {
1520
1520
  if (!id || this.master.has(id)) return;
@@ -1568,6 +1568,8 @@ var ObservedReducer = class {
1568
1568
  if (this.stale(`task:${d.taskId}`, e)) return;
1569
1569
  this.touchLocation(d.fromNode);
1570
1570
  this.touchLocation(d.toNode);
1571
+ const prevTask = this.tasks.get(d.taskId);
1572
+ const keptActual = d.materialActual === void 0 ? prevTask?.materialActual : void 0;
1571
1573
  this.tasks.set(d.taskId, {
1572
1574
  id: d.taskId,
1573
1575
  kind: d.kind,
@@ -1585,8 +1587,9 @@ var ObservedReducer = class {
1585
1587
  ...d.personnel?.length ? { personnel: d.personnel.slice() } : {},
1586
1588
  ...d.assets?.length ? { assets: d.assets.slice() } : {},
1587
1589
  ...d.resources?.length ? { resources: d.resources.slice() } : {},
1588
- /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다). */
1589
- ...d.materialActual?.length ? { materialActual: d.materialActual.map((r) => ({ ...r })) } : {},
1590
+ /* 실제 자재 이동 — 인원·설비와 같은 채널로 온다(실적을 한 곳에서 읽는다).
1591
+ 말하지 않았으면 **앞의 것을 지킨다**(위 주석) — 빈 배열은 「지웠다」로 말한 것이다. */
1592
+ ...d.materialActual?.length ? { materialActual: d.materialActual.map((r) => ({ ...r })) } : keptActual?.length ? { materialActual: keptActual.map((r) => ({ ...r })) } : {},
1590
1593
  ...d.priority !== void 0 ? { priority: d.priority } : {},
1591
1594
  ...d.startTime ? { startTime: d.startTime } : {},
1592
1595
  ...d.endTime ? { endTime: d.endTime } : {}
@@ -4078,7 +4081,7 @@ var FlowEngine = class {
4078
4081
  materialSpecCrossKeyOverlaps = 0;
4079
4082
  observedDirty = false;
4080
4083
  observeMode = false;
4081
- /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
4084
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 트윈 모델(구조는 이벤트가 아니라 마스터에서 온다). */
4082
4085
  boardDef;
4083
4086
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
4084
4087
  specUse = /* @__PURE__ */ new Map();
@@ -4360,7 +4363,7 @@ var FlowEngine = class {
4360
4363
  /* 계획 정지를 이어받는다 — 잃으면 씨앗이 **정비 중인 설비를 가용으로 놓고** 미래를 시뮬레이션한다
4361
4364
  (예측이 낙관 쪽으로 치우친다). 씨앗 왕복 대조가 이것을 잡았다. */
4362
4365
  ...m.held ? { held: true } : {},
4363
- /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 보드에서 읽어 실어 온다). */
4366
+ /* 관측 스냅샷이 들고 있는 것은 관측을 따른다(미러가 트윈 모델에서 읽어 실어 온다). */
4364
4367
  ...m.properties ? { properties: m.properties } : {},
4365
4368
  ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {},
4366
4369
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
@@ -4775,7 +4778,7 @@ var FlowEngine = class {
4775
4778
  ...this.seedDanglingRefs ? { seedDanglingRefs: this.seedDanglingRefs } : {},
4776
4779
  ...this.materialSpecCrossKeyOverlaps ? { materialSpecCrossKeyOverlaps: this.materialSpecCrossKeyOverlaps } : {}
4777
4780
  } } : {},
4778
- /* 출처 표시 — 보드(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
4781
+ /* 출처 표시 — 트윈 모델(마스터)에서 온 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
4779
4782
  * 시뮬이 아무 표시도 안 하면 소비처가 두 스냅샷을 같은 규칙으로 읽지 못한다. */
4780
4783
  locations: [...this.locations.values()].map((n) => {
4781
4784
  const { status, ...rest } = n;
@@ -6485,7 +6488,7 @@ var FlowEngine = class {
6485
6488
  /**
6486
6489
  * 시뮬 시각의 분(0..1439) — 캘린더 판정의 기준.
6487
6490
  *
6488
- * **보드가 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
6491
+ * **트윈 모델이 선언한 기준으로 읽는다**(`utcOffsetMinutes`). 예전에는 UTC 로 읽어서 Rosarito(UTC−7)의
6489
6492
  * 06시 교대가 7시간 틀렸다 — 시각대만 적고 기준을 안 적으면 반드시 이렇게 된다.
6490
6493
  */
6491
6494
  minuteOfDay() {
@@ -9030,6 +9033,34 @@ var SPECS = {
9030
9033
  recordTime: "string"
9031
9034
  },
9032
9035
  enums: { result: ["pass", "fail"] }
9036
+ },
9037
+ /*
9038
+ * **자리의 물리 관측** — 어느 자리의 어느 속성을 언제 얼마로 쟀나.
9039
+ *
9040
+ * 값에 **단위를 함께** 받는다(`ValueType`). 단위 없는 물리량은 판정의 재료가 못 된다 — 3 이 섭씨인지
9041
+ * 화씨인지 모르면 어떤 기준으로도 판정할 수 없다. 다만 **요구하지는 않는다**: 단위를 비우는 실 원본이
9042
+ * 흔하고, 요구하면 그 원본의 관측을 아예 담지 못한다(그때 판정은 커널이 거부한다 — `outsideLimit`).
9043
+ *
9044
+ * `effectiveTime` 도 요구하지 않는다. 없으면 봉투의 시각이 그 자리를 대신한다(§`resolve`) — 원본이
9045
+ * 시각을 말하지 않는 수기 점검이 실재하고, 그때 지어낸 시각보다 폴링 시각이 정직하다.
9046
+ */
9047
+ observation: {
9048
+ eventType: OP_EVENT.observation,
9049
+ identity: "locationId",
9050
+ /* 자리와 속성 — 둘 중 하나가 없으면 그 관측은 아무 데도 붙지 못한다. */
9051
+ required: ["locationId", "propertyId"],
9052
+ fields: {
9053
+ locationId: "string",
9054
+ propertyId: "string",
9055
+ value: "string",
9056
+ dataType: "string",
9057
+ uom: "string",
9058
+ effectiveTime: "string",
9059
+ effectiveEndTime: "string",
9060
+ recordTime: "string",
9061
+ source: "string",
9062
+ derived: "boolean"
9063
+ }
9033
9064
  }
9034
9065
  };
9035
9066
  function operationalKindOf(record) {
@@ -9043,6 +9074,7 @@ function operationalKindOf(record) {
9043
9074
  if (has("taskId")) return "task";
9044
9075
  if (has("orderId")) return "order";
9045
9076
  if (has("testableObjectId")) return "test";
9077
+ if (has("locationId") && has("propertyId")) return "observation";
9046
9078
  return void 0;
9047
9079
  }
9048
9080
  function isOperationalRecord(record) {
@@ -9353,13 +9385,13 @@ var VOCABULARY_EXCEPTIONS = [
9353
9385
  { token: "moverId", why: "journal wire field \u2014 renaming would mix two keys for one fact across history" },
9354
9386
  { token: "fromNode", why: "journal wire field (task.status payload)" },
9355
9387
  { token: "toNode", why: "journal wire field (task.status payload)" },
9356
- /* ── 보드의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
9388
+ /* ── 트윈 모델의 옛 세대 키 — 저장된 데이터라 읽어는 줘야 한다 ────────────────
9357
9389
  * 설비 배열의 이름은 세 세대를 거쳤다(movers → equipmentList → equipment). 저장된 보드에는
9358
- * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 보드는 **설비가 0인 공장**
9390
+ * 셋이 섞여 있고(실측 23개 중 13개가 `movers`), 하나라도 안 읽으면 그 트윈 모델은 **설비가 0인 공장**
9359
9391
  * 으로 조용히 읽힌다 — 화면의 설비 수가 0이 되고 용량 판정에서 자원이 사라진다. 쓰는 곳은
9360
9392
  * `readBoardEquipment` 한 곳뿐이고, 거기서 새 이름으로 정규화해 내보낸다. */
9361
9393
  { token: "movers", why: "legacy board key (movers \u2192 equipmentList \u2192 equipment); read-only normalization in readBoardEquipment, 13 stored boards still use it" },
9362
- /* ── 씬 컴포넌트 타입 — 보드에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
9394
+ /* ── 씬 컴포넌트 타입 — 트윈 모델에 저장된 값이고, 뜻이 어긋나지도 않는다 ──────
9363
9395
  * 보드 7개가 이 타입으로 컴포넌트를 담고 있어 개명하면 그 컴포넌트가 조용히 안 그려진다.
9364
9396
  * 그리고 씬에서 이 이름은 자원이 아니라 **움직임 표현**을 가리킨다(표준과 충돌 아님). */
9365
9397
  { token: "twin-mover", why: "scene component type persisted in boards; names a motion representation, not a resource" },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.61",
3
+ "version": "0.7.62",
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": {