@operato/twin-kernel 0.2.0 → 0.2.1

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.
@@ -8,6 +8,20 @@ export interface CanonicalEnvelope<T = unknown> {
8
8
  data: T;
9
9
  }
10
10
  export type TaskStatus = 'created' | 'assigned' | 'in-progress' | 'completed';
11
+ /**
12
+ * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
13
+ *
14
+ * 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 노드 상태 채널이
15
+ * 없어 비어 있었다. 화면은 그 값을 그대로 보여 주고 있었다 — **정보처럼 보이는데 정보가 아니었다.**
16
+ *
17
+ * 문턱은 병목 주목(`deriveAttentions`)이 쓰는 것과 **같다**: 90% 이상이면 임박, 100% 이상이면 포화.
18
+ * 규칙이 둘이면 화면과 주목이 다른 말을 한다. 용량을 모르면 상태도 모른다(undefined — 꾸미지 않는다).
19
+ */
20
+ export declare const NODE_SATURATION_NEAR = 0.9;
21
+ export declare function nodeStatusOf(n: {
22
+ occupancy?: number;
23
+ capacity?: number;
24
+ }): 'available' | 'near-full' | 'full' | undefined;
11
25
  export interface NodeState {
12
26
  id: string;
13
27
  type: string;
@@ -29,6 +43,7 @@ export interface NodeState {
29
43
  */
30
44
  parallelism?: number;
31
45
  occupancy: number;
46
+ /** 포화도 파생 상태 — `nodeStatusOf` 가 낸다(두 구동이 같은 함수를 쓴다). 용량 미상이면 없다. */
32
47
  status?: string;
33
48
  parentId?: string;
34
49
  /**
@@ -182,9 +197,18 @@ export interface TaskState {
182
197
  resourceRef?: string;
183
198
  orderId?: string;
184
199
  progress?: number;
185
- /** 남은 시간·총 소요(ms) — 진행 중인 작업을 이어서 굴리는 데 필요(씨앗의 충실도). */
200
+ /**
201
+ * 남은 시간·총 소요(ms) — 진행 중인 작업을 이어서 굴리는 데 필요(씨앗의 충실도).
202
+ * `remainingMs` 는 **마지막 전이 시점의 값**이다. 델타는 매 tick 오지 않으므로(설계) 미러가 든 값은
203
+ * 그때의 것이고, 지금 값은 `startedAtSimMs` 로 보간한다 — 모션(MoverMotion)과 같은 규율.
204
+ */
186
205
  remainingMs?: number;
187
206
  durationMs?: number;
207
+ /**
208
+ * 착수 시각(절대 sim-clock) — **보간 앵커.** 이것이 없으면 미러는 "그때 얼마 남았었나" 만 알고
209
+ * "지금 얼마 남았나" 를 못 낸다. progress = (now − startedAtSimMs) / durationMs.
210
+ */
211
+ startedAtSimMs?: number;
188
212
  /** 작업 의도 — 무자원이 설계인지(체류) 기록 누락인지 구별하는 근거. */
189
213
  intent?: 'transport' | 'process' | 'dwell';
190
214
  /**
@@ -380,6 +404,8 @@ export interface QualityDelta {
380
404
  }
381
405
  export interface TaskStatusDelta {
382
406
  taskId: string;
407
+ /** 착수 시각(절대 sim-clock) — 보간 앵커. TaskState 와 같은 뜻. */
408
+ startedAtSimMs?: number;
383
409
  /** 투입된 사람들 — 미러가 인원 배정을 그대로 비추려면 델타에 실려야 한다. */
384
410
  personnel?: string[];
385
411
  /** 투입된 물리 자산(팔레트 등). */
@@ -414,6 +440,8 @@ export interface EquipmentStatusDelta {
414
440
  kind: string;
415
441
  status: string;
416
442
  location?: string;
443
+ /** 지금 붙어 있는 작업 — 사람·자산 델타와 같은 자리. 없으면 미러가 작업↔자원 연결을 모른다. */
444
+ taskId?: string;
417
445
  motion?: MoverMotion;
418
446
  }
419
447
  /** 관측된 오더 라인(SKU 데맨드) — 실 시스템 오더는 품목 라인을 가짐. 이행 예측(남은 데맨드 재계획)에 필요. */
package/dist/contract.js CHANGED
@@ -2,6 +2,23 @@
2
2
  * Face 1 — 3채널 계약 (walking skeleton 범위).
3
3
  * 설계 SoT: operato-twin/design/integration/face1-contract.md
4
4
  */
5
+ /**
6
+ * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
7
+ *
8
+ * 예전에는 시뮬이 `'idle'` 로 두고 한 번도 바꾸지 않았고(변경 지점 0), 미러에는 노드 상태 채널이
9
+ * 없어 비어 있었다. 화면은 그 값을 그대로 보여 주고 있었다 — **정보처럼 보이는데 정보가 아니었다.**
10
+ *
11
+ * 문턱은 병목 주목(`deriveAttentions`)이 쓰는 것과 **같다**: 90% 이상이면 임박, 100% 이상이면 포화.
12
+ * 규칙이 둘이면 화면과 주목이 다른 말을 한다. 용량을 모르면 상태도 모른다(undefined — 꾸미지 않는다).
13
+ */
14
+ export const NODE_SATURATION_NEAR = 0.9;
15
+ export function nodeStatusOf(n) {
16
+ const cap = n.capacity;
17
+ if (!(typeof cap === 'number' && cap > 0))
18
+ return undefined;
19
+ const r = (n.occupancy ?? 0) / cap;
20
+ return r >= 1 ? 'full' : r >= NODE_SATURATION_NEAR ? 'near-full' : 'available';
21
+ }
5
22
  // ── 운영 델타(비-EPCIS) — State 채널의 나머지 절반 ──────────────────────────
6
23
  // EPCIS 이벤트는 재고/위치만 재구성 가능. tasks·movers(equipment)·orders 의 운영 상태는
7
24
  // 이 델타로 미러한다. envelope.eventType = 'task.status' | 'equipment.status' | 'order.status'.
@@ -1,4 +1,4 @@
1
- import type { Attention, BoardDef, Command, CommandAck, EventHandler, MoverMotion, OeeMetrics, AssetState, GeneratorSpec, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, NodeState, ItemState, MoverState, OrderStatusDelta, TaskState } from './contract.ts';
1
+ import type { Attention, BoardDef, CanonicalEnvelope, Command, CommandAck, EventHandler, MoverMotion, OeeMetrics, AssetState, GeneratorSpec, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, NodeState, ItemState, MoverState, OrderStatusDelta, TaskState } from './contract.ts';
2
2
  import type { EpcisEvent, BizTransactionElement } from './epcis.ts';
3
3
  import type { AllocationPolicy, SlotView } from './allocation-policy.ts';
4
4
  import type { DurationEstimator, DurationContext } from './duration-estimator.ts';
@@ -12,13 +12,29 @@ export interface FlowNode {
12
12
  status: string;
13
13
  parentId?: string;
14
14
  }
15
+ /**
16
+ * 시뮬이 들고 있는 물품 — **계약(ItemState)을 축소하지 않는다.**
17
+ *
18
+ * 미러(투영기)는 로트·단위·소속·마스터데이터까지 들고 있는데 시뮬은 여섯 필드뿐이었다. 그래서 같은
19
+ * 사실을 두 구동이 다르게 말했다(파리티 테스트가 잡았다). 여기서 갖는 것은 **보유해야 하는 상태**다 —
20
+ * 품번 키·로트처럼 식별자에서 순수하게 나오는 값은 저장하지 않고 스냅샷에서 파생한다(둘을 다 저장하면
21
+ * 어긋날 수 있다).
22
+ */
15
23
  export interface FlowItem {
16
24
  epc: string;
17
25
  location: string;
18
26
  disposition: string;
19
27
  gtin?: string;
20
28
  qty?: number;
29
+ /** 수량 단위(UN/CEFACT). 개수면 없다 — 없는 것을 'EA' 로 꾸미지 않는다. */
30
+ uom?: string;
31
+ /** 소속 물류단위(팔레트 SSCC 등) — 조립으로 맺어진다. 3D 적재 표현의 재료. */
32
+ parent?: string;
33
+ /** 이 물류단위를 싣고 있는 반복사용 자산(GRAI) — `parent` 와 다른 축. */
34
+ carriedBy?: string;
21
35
  expiry?: number;
36
+ /** 개체·로트 마스터데이터 원문 — 생겨날 때 정해지고 뒤 이벤트가 지우지 않는다. */
37
+ ilmd?: Record<string, unknown>;
22
38
  }
23
39
  /** 물리 자산 — 반복사용(팔레트·랙·용기). 설비도 물품도 아니다(GRAI vs SSCC 구분은 계약 주석 참조). */
24
40
  export interface FlowAsset {
@@ -73,6 +89,8 @@ export interface FlowTask {
73
89
  fromNode: string;
74
90
  toNode: string;
75
91
  resource: string | null;
92
+ /** 착수 시각(절대 sim-clock) — 보간 앵커(모션과 같은 규율). */
93
+ startedAtSimMs?: number;
76
94
  /** 이 작업에 투입된 사람들 — 설비와 별개 축(설비 1대 + 작업자 2명이 동시에 잡힌다). */
77
95
  personnel?: string[];
78
96
  /** 이 작업에 투입된 물리 자산(팔레트 등). */
@@ -178,6 +196,13 @@ export declare abstract class FlowEngine implements TwinKernel {
178
196
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
179
197
  */
180
198
  protected operationSpecs: Map<string, OperationDef>;
199
+ /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
200
+ private observer?;
201
+ /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
202
+ private observedDirty;
203
+ private observeMode;
204
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
205
+ protected boardDef?: BoardDef;
181
206
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
182
207
  private specUse;
183
208
  protected epcSeq: number;
@@ -185,6 +210,8 @@ export declare abstract class FlowEngine implements TwinKernel {
185
210
  protected orderSeq: number;
186
211
  protected soSeq: number;
187
212
  private handlers;
213
+ /** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
214
+ private handlersRef;
188
215
  private gens;
189
216
  private generating;
190
217
  private speed;
@@ -260,6 +287,29 @@ export declare abstract class FlowEngine implements TwinKernel {
260
287
  fork(tenantId?: string): this;
261
288
  protected now(): string;
262
289
  protected randInt(min: number, max: number): number;
290
+ /**
291
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
292
+ *
293
+ * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
294
+ * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
295
+ * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
296
+ *
297
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
298
+ * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
299
+ * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
300
+ *
301
+ * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
302
+ * reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
303
+ *
304
+ * `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
305
+ */
306
+ apply(envelope: CanonicalEnvelope): void;
307
+ /** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
308
+ private settleObserved;
309
+ /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
310
+ private observedHandlers;
311
+ /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
312
+ get observing(): boolean;
263
313
  /**
264
314
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
265
315
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
@@ -335,6 +385,30 @@ export declare abstract class FlowEngine implements TwinKernel {
335
385
  bizLocation?: string;
336
386
  bizTransactionList?: BizTransactionElement[];
337
387
  }): void;
388
+ /**
389
+ * 할당(예약) — **처분 변화를 이벤트로 낸다.**
390
+ *
391
+ * 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
392
+ * 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
393
+ * 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
394
+ *
395
+ * 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
396
+ * 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
397
+ *
398
+ * `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
399
+ * 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
400
+ */
401
+ protected reserve(epcs: string[], bizStep: string): void;
402
+ /**
403
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
404
+ *
405
+ * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
406
+ * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
407
+ *
408
+ * 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
409
+ * 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
410
+ */
411
+ protected observeDisposition(epcs: string[], disposition: string, bizStep: string, at?: string): void;
338
412
  /**
339
413
  * containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
340
414
  * consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
@@ -382,6 +456,8 @@ export declare abstract class FlowEngine implements TwinKernel {
382
456
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
383
457
  protected emitAsset(a: FlowAsset): void;
384
458
  protected emitPerson(p: FlowPerson): void;
459
+ /** 설비 상태 전이 — `taskId` 를 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
460
+ * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
385
461
  protected emitMover(m: FlowMover, motion?: MoverMotion): void;
386
462
  protected emitOrder(o: FlowOrder): void;
387
463
  /**
@@ -460,9 +536,27 @@ export declare abstract class FlowEngine implements TwinKernel {
460
536
  * down 중 무버는 배정 불가 + 진행중 task 동결(processTasks 가 skip) → OEE Availability 손실.
461
537
  */
462
538
  private processFailures;
539
+ /**
540
+ * 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
541
+ *
542
+ * 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
543
+ * 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
544
+ * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
545
+ */
546
+ protected itemState(i: FlowItem): ItemState;
463
547
  protected progressOf(t: FlowTask): number;
464
548
  private generate;
465
549
  private processOrders;
550
+ /**
551
+ * 작업 진행 — **진행을 먼저, 배정을 나중에.**
552
+ *
553
+ * 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
554
+ * 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
555
+ * tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
556
+ */
466
557
  private processTasks;
558
+ private assignTasks;
559
+ /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
560
+ private advanceTasks;
467
561
  }
468
562
  export {};
@@ -9,8 +9,9 @@
9
9
  * 통합 타입은 도메인 필드를 옵셔널로 넓혀(FlowItem.gtin?, FlowOrder.shipmentEpc? 등) 두 도메인을 담는다.
10
10
  * (roadmap Phase5 발견 → 추출. [[project_flow_single_base_vision]] FlowNode 단일 base 방향과 정합.)
11
11
  */
12
- import { OP_EVENT, CMD } from "./contract.js";
13
- import { transformationEvent, aggregationEvent, objectEvent, DISP } from "./epcis.js";
12
+ import { OP_EVENT, CMD, nodeStatusOf } from "./contract.js";
13
+ import { ObservedReducer } from "./observed-reducer.js";
14
+ import { transformationEvent, aggregationEvent, objectEvent, parseEpc, DISP, ILMD_ATTR } from "./epcis.js";
14
15
  import { parseIsoDuration } from "./iso-duration.js";
15
16
  const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
16
17
  function mulberry32(seed) {
@@ -134,6 +135,13 @@ export class FlowEngine {
134
135
  * 도메인 정의에서 실어 온다(`loadOperations`). 없으면 커널 기본값을 쓰고 그 사실을 `specCoverage()` 가 밝힌다.
135
136
  */
136
137
  operationSpecs = new Map();
138
+ /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
139
+ observer;
140
+ /** 관측분이 아직 커널 상태로 옮겨지지 않았다 — 스냅샷·fork 직전에 한 번만 옮긴다. */
141
+ observedDirty = false;
142
+ observeMode = false;
143
+ /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
144
+ boardDef;
137
145
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
138
146
  specUse = new Map();
139
147
  epcSeq = 0;
@@ -141,6 +149,10 @@ export class FlowEngine {
141
149
  orderSeq = 0;
142
150
  soSeq = 0;
143
151
  handlers = [];
152
+ /** 구독자 접근(관측 재방출) — emit 과 같은 목록을 쓴다(두 경로가 갈리지 않게). */
153
+ handlersRef() {
154
+ return this.handlers;
155
+ }
144
156
  gens = [];
145
157
  generating = false;
146
158
  speed = 1;
@@ -151,6 +163,7 @@ export class FlowEngine {
151
163
  }
152
164
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
153
165
  loadBoard(def) {
166
+ this.boardDef = def;
154
167
  for (const n of def.nodes)
155
168
  this.nodes.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
156
169
  for (const p of def.persons ?? [])
@@ -199,9 +212,10 @@ export class FlowEngine {
199
212
  this.items.clear();
200
213
  for (const it of snap.items) {
201
214
  this.items.set(it.epc, {
215
+ /* 품번 키·로트는 식별자에서 파생되므로 심지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
202
216
  epc: it.epc, location: it.location, disposition: it.disposition ?? DISP.sellable,
203
- gtin: it.gtin, gtinKey: it.gtinKey, lot: it.lot, qty: it.qty ?? 1, uom: it.uom,
204
- parent: it.parent, expiry: it.expiry, ilmd: it.ilmd
217
+ gtin: it.gtin, qty: it.qty ?? 1, uom: it.uom,
218
+ parent: it.parent, carriedBy: it.carriedBy, expiry: it.expiry, ilmd: it.ilmd
205
219
  });
206
220
  }
207
221
  for (const m of snap.movers) {
@@ -247,6 +261,7 @@ export class FlowEngine {
247
261
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
248
262
  resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
249
263
  remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
264
+ startedAtSimMs: t.startedAtSimMs,
250
265
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
251
266
  orderId: t.orderId,
252
267
  intent: t.intent
@@ -434,13 +449,21 @@ export class FlowEngine {
434
449
  this.processTasks(dt);
435
450
  }
436
451
  getSnapshot() {
452
+ this.settleObserved();
437
453
  return {
438
454
  revision: this.revision,
439
455
  simClockMs: this.clockMs,
440
- nodes: [...this.nodes.values()].map(n => ({ ...n })),
441
- items: [...this.items.values()].map(i => ({ epc: i.epc, gtin: i.gtin, qty: i.qty, location: i.location, disposition: i.disposition, expiry: i.expiry })),
456
+ /* 출처 표시 — 보드(마스터)에서 자리다. 미러는 관측으로 알게 된 자리를 'observed' 로 구별하는데,
457
+ * 시뮬이 아무 표시도 하면 소비처가 스냅샷을 같은 규칙으로 읽지 못한다. */
458
+ nodes: [...this.nodes.values()].map(n => {
459
+ const { status, ...rest } = n;
460
+ /* 상태는 저장값이 아니라 포화도 파생 — 미러와 **같은 함수**를 쓴다(규칙이 둘이면 갈라진다). */
461
+ const derived = nodeStatusOf(n);
462
+ return { ...rest, ...(derived ? { status: derived } : {}), origin: 'master' };
463
+ }),
464
+ items: [...this.items.values()].map(i => this.itemState(i)),
442
465
  movers: [...this.movers.values()].map(m => {
443
- const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(this.offShift(m) ? { offShift: true } : {}) };
466
+ const s = { id: m.id, kind: m.kind, location: m.location, status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, origin: 'master', ...(this.offShift(m) ? { offShift: true } : {}) };
444
467
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
445
468
  if (t && t.status === 'in-progress' && t.intent !== 'process')
446
469
  s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: this.progressOf(t), elapsedMs: t.durationMs - t.remainingMs };
@@ -458,7 +481,18 @@ export class FlowEngine {
458
481
  st.offShift = true;
459
482
  return st;
460
483
  }),
461
- tasks: [...this.tasks.values()].map(t => ({ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc], fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId, progress: t.status === 'in-progress' ? this.progressOf(t) : undefined, ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}) })),
484
+ /* 스냅샷이 **델타보다 가난하면 된다** 예전에는 소요·남은 시간을 빼고 내보내서, 스냅샷으로
485
+ * 다른 커널을 심으면(hydrateObserved) 진행 중이던 작업을 이어 굴릴 수 없었다(미러 스냅샷은
486
+ * 델타에서 왔으므로 갖고 있었다 — 같은 계약을 두 구동이 다르게 채우던 자리). */
487
+ tasks: [...this.tasks.values()].map(t => ({
488
+ id: t.id, kind: t.kind, status: t.status, itemRefs: [t.itemEpc],
489
+ fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
490
+ ...(t.intent ? { intent: t.intent } : {}),
491
+ ...(t.durationMs ? { durationMs: t.durationMs } : {}),
492
+ ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
493
+ ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
494
+ ...(t.assets?.length ? { assets: t.assets.slice() } : {})
495
+ })),
462
496
  orders: [...this.orders.values()].map(o => ({ id: o.id, kind: o.kind, status: o.status, progress: o.requested ? o.fulfilled / o.requested : 0, held: o.held })),
463
497
  attentions: this.computeAttentions()
464
498
  };
@@ -485,6 +519,7 @@ export class FlowEngine {
485
519
  * rng 는 fork 의 시나리오 load 시 재시드(드레인 예측은 생성 없어 rng 무관·결정적).
486
520
  */
487
521
  fork(tenantId = this.tenantId) {
522
+ this.settleObserved(); // 관측으로 굴러온 커널을 fork 하려면 먼저 상태로 옮겨야 한다
488
523
  const Ctor = this.constructor;
489
524
  const clone = new Ctor(tenantId, this.policy);
490
525
  // 상태·시나리오(gens/generating)·시퀀스 전부 복제 → 원본의 완전한 continuation.
@@ -510,6 +545,54 @@ export class FlowEngine {
510
545
  // ── 보호 헬퍼 (도메인 hook 에서 사용) ──────────────────────────────────────
511
546
  now() { return new Date(BASE_EPOCH + this.clockMs).toISOString(); }
512
547
  randInt(min, max) { return max <= min ? min : min + Math.floor(this.rng() * (max - min + 1)); }
548
+ /**
549
+ * 관측 구동(P0 스파이크) — **이벤트로 커널을 굴린다.**
550
+ *
551
+ * 상태를 만드는 구동이 둘인데(시뮬 `tick` / 미러 `apply`) 지금은 **모델도 둘**이라 한쪽만 고치면
552
+ * 갈라진다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
553
+ * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
554
+ *
555
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
556
+ * (`hydrateObserved`)로 커널 상태에 심는다. 그래서 관측으로 굴린 커널을 그대로 `fork`·`tick` 할 수
557
+ * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
558
+ *
559
+ * **비용은 정직하게**: 이벤트마다 전체를 다시 심으므로 O(상태 크기)다. P1 에서 반영 로직을 순수
560
+ * reduce 모듈로 추출해 투영기와 공유하면 사라진다. 지금은 계약이 성립하는지만 본다.
561
+ *
562
+ * `tick` 과 섞어 쓰지 않는다 — 섞으면 무엇이 진실인지 알 수 없다(관측이 시뮬을 덮어쓴다).
563
+ */
564
+ apply(envelope) {
565
+ if (!this.observer) {
566
+ this.observer = new ObservedReducer(this.boardDef ?? { nodes: [], movers: [] });
567
+ this.observeMode = true;
568
+ }
569
+ this.observer.apply(envelope);
570
+ /* **구독자에게 그대로 흘린다** — 호스트가 시뮬·관측 두 모드에서 같은 배선을 쓰게 하기 위해서다
571
+ * (`onEvent` 하나로 저널·방송이 붙는다). 관측 모드에서 이것은 **재방출**이지 새 사실이 아니다:
572
+ * 원천이 이미 그 이벤트를 갖고 있으므로, 호스트가 인입과 재방출을 **둘 다 저널에 적으면 중복**이
573
+ * 된다. 저널은 인입에서 한 번만 적는다. */
574
+ for (const h of this.observedHandlers())
575
+ h(envelope);
576
+ /* **이벤트마다 상태를 통째로 옮기지 않는다.** 옮기는 비용은 O(상태 크기)라, 이벤트 하나에 그것을
577
+ * 치르면 유입이 늘수록 감당이 안 된다. 필요해지는 순간(스냅샷·fork)에 **한 번만** 옮긴다. */
578
+ this.observedDirty = true;
579
+ this.revision++;
580
+ }
581
+ /** 관측분을 커널 상태로 옮긴다 — 필요할 때 한 번만(같은 규칙, 같은 씨앗 경로). */
582
+ settleObserved() {
583
+ if (!this.observedDirty || !this.observer)
584
+ return;
585
+ this.observedDirty = false;
586
+ this.hydrateObserved(this.observer.snapshot());
587
+ }
588
+ /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
589
+ observedHandlers() {
590
+ return this.handlersRef();
591
+ }
592
+ /** 관측 구동으로 굴러가는 중인가 — 소비처가 "이 커널의 진실이 어디서 오나" 를 물을 수 있게. */
593
+ get observing() {
594
+ return this.observeMode;
595
+ }
513
596
  /**
514
597
  * 오퍼레이션 명세를 싣는다 — 도메인 정의(ISA-95 OperationsSegment)의 시뮬 명세를 커널이 소비하는 입구.
515
598
  * 같은 key 를 다시 실으면 덮어쓴다(정의가 권위).
@@ -694,6 +777,52 @@ export class FlowEngine {
694
777
  bizTransactionList: opts.bizTransactionList
695
778
  }));
696
779
  }
780
+ /**
781
+ * 할당(예약) — **처분 변화를 이벤트로 낸다.**
782
+ *
783
+ * 예전에는 네 곳(WMS·YMS·MES 두 경로)이 각자 `disposition = reserved` 로 상태만 바꾸고 거래
784
+ * 이벤트(TransactionEvent)만 냈다. 거래 이벤트는 **처분을 싣지 않으므로** 미러는 그 물건이 잡혔다는
785
+ * 사실을 영영 알 수 없었다(적합성 하네스가 잡았다). 저널로 복원해도, 예측 씨앗에도 안 실린다.
786
+ *
787
+ * 관측 이벤트로 낸다 — 표준이 처분 변화를 표현하는 자리다(ObjectEvent OBSERVE + disposition).
788
+ * 물건이 여러 자리에 흩어져 있으면 **자리별로 나눠** 낸다(한 이벤트에 한 readPoint 가 맞다).
789
+ *
790
+ * `bizStep` 은 **호출부가 정한다.** 할당 자체를 가리키는 CBV 단계(reserving)를 1차 출처로 확인하지
791
+ * 못했으므로 어휘를 발명하지 않고, 그 할당이 속한 업무 단계를 그대로 쓴다.
792
+ */
793
+ reserve(epcs, bizStep) {
794
+ this.observeDisposition(epcs, DISP.reserved, bizStep);
795
+ }
796
+ /**
797
+ * 처분 변화 관측 — **상태와 이벤트를 한 번에.** 둘을 따로 쓰면 반드시 갈라진다.
798
+ *
799
+ * 실제로 양쪽으로 갈라져 있었다: 할당은 상태만 바꾸고 이벤트를 안 냈고(미러가 모름), 야드 도크
800
+ * 도착은 이벤트만 내고 상태를 안 바꿨다(이벤트와 상태가 다른 말). 적합성 하네스가 둘 다 잡았다.
801
+ *
802
+ * 물건이 여러 자리에 있으면 자리별로 나눠 낸다(한 이벤트에 한 readPoint 가 맞다).
803
+ * 이미 그 처분이면 아무 일도 하지 않는다(같은 사실을 두 번 말하지 않는다).
804
+ */
805
+ observeDisposition(epcs, disposition, bizStep, at) {
806
+ const byLocation = new Map();
807
+ for (const epc of epcs) {
808
+ const it = this.items.get(epc);
809
+ if (!it || it.disposition === disposition)
810
+ continue;
811
+ it.disposition = disposition;
812
+ const where = at ?? it.location ?? '';
813
+ const bin = byLocation.get(where);
814
+ if (bin)
815
+ bin.push(epc);
816
+ else
817
+ byLocation.set(where, [epc]);
818
+ }
819
+ for (const [where, list] of byLocation) {
820
+ this.emit(objectEvent({
821
+ eventTime: this.now(), action: 'OBSERVE', bizStep, disposition,
822
+ epcList: list, ...(where ? { readPoint: where, bizLocation: where } : {})
823
+ }));
824
+ }
825
+ }
697
826
  /**
698
827
  * containment 조립(EPCIS AggregationEvent ADD) — 자식들을 부모(용기)로 집約.
699
828
  * consume 지정 시 자식이 컨테이너로 흡수되며 독립 아이템에서 이탈(dematerialize: ObjectEvent DELETE + 제거).
@@ -712,6 +841,14 @@ export class FlowEngine {
712
841
  this.items.delete(c);
713
842
  }
714
843
  }
844
+ return;
845
+ }
846
+ /* 흡수하지 않는 조립 — 자식은 독립 물품으로 남되 **소속을 상태에도 남긴다.**
847
+ * 예전에는 이벤트만 내고 상태를 안 바꿔서, 미러는 소속을 알고 시뮬은 몰랐다(같은 사실, 다른 답). */
848
+ for (const c of children) {
849
+ const it = this.items.get(c);
850
+ if (it)
851
+ it.parent = parent;
715
852
  }
716
853
  }
717
854
  /**
@@ -721,6 +858,12 @@ export class FlowEngine {
721
858
  */
722
859
  disaggregate(parent, children, opts) {
723
860
  this.emit(aggregationEvent({ eventTime: this.now(), action: 'DELETE', bizStep: opts.bizStep, parentID: parent, childEPCs: children.slice(), readPoint: opts.readPoint }));
861
+ /* 분해 — 소속이 끊어진다(자식이 독립으로 남든 새로 등장하든). */
862
+ for (const c of children) {
863
+ const it = this.items.get(c);
864
+ if (it)
865
+ it.parent = undefined;
866
+ }
724
867
  if (opts.materialize) {
725
868
  const m = opts.materialize;
726
869
  for (const c of children) {
@@ -790,7 +933,7 @@ export class FlowEngine {
790
933
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
791
934
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
792
935
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
793
- ...(inProgress ? { remainingMs: t.remainingMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
936
+ ...(inProgress ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: t.durationMs ? done / t.durationMs : undefined } : {})
794
937
  });
795
938
  }
796
939
  /** 사람 상태 전이 — 설비와 별개 채널(어휘가 다르다: 고장이 아니라 교대·투입). */
@@ -800,7 +943,11 @@ export class FlowEngine {
800
943
  emitPerson(p) {
801
944
  this.emitOp(OP_EVENT.person, { personId: p.id, personnelClass: p.personnelClass, status: p.status, taskId: p.taskId ?? undefined, ...(this.personOffShift(p) ? { offShift: true } : {}) });
802
945
  }
803
- emitMover(m, motion) { this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, motion }); }
946
+ /** 설비 상태 전이 `taskId` 함께 싣는다(사람·자산 델타와 같은 규칙). 없으면 미러가 "이 설비가
947
+ * 무슨 일을 하는 중인가" 를 알 수 없어 작업↔자원 연결이 한쪽에서만 성립한다. */
948
+ emitMover(m, motion) {
949
+ this.emitOp(OP_EVENT.equipment, { moverId: m.id, kind: m.kind, status: m.status, location: m.location, taskId: m.taskId ?? undefined, motion });
950
+ }
804
951
  emitOrder(o) { this.emitOp(OP_EVENT.order, { orderId: o.id, kind: o.kind, status: o.status, requested: o.requested, fulfilled: o.fulfilled, held: o.held }); }
805
952
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
806
953
  /**
@@ -892,8 +1039,14 @@ export class FlowEngine {
892
1039
  continue;
893
1040
  a.status = 'in-use';
894
1041
  a.taskId = t.id;
895
- if (t.itemEpc)
1042
+ if (t.itemEpc) {
896
1043
  a.carrying = t.itemEpc;
1044
+ /* 반대 방향도 맺는다 — 계약이 두 축을 다 정의했으므로 한쪽만 채우면 소비처가 물품에서
1045
+ * 자산을 못 찾는다(자산 목록을 뒤져야 한다). */
1046
+ const it = this.items.get(t.itemEpc);
1047
+ if (it)
1048
+ it.carriedBy = a.id;
1049
+ }
897
1050
  this.emitAsset(a);
898
1051
  }
899
1052
  }
@@ -909,6 +1062,11 @@ export class FlowEngine {
909
1062
  a.status = 'idle';
910
1063
  a.taskId = null;
911
1064
  a.location = t.toNode || a.location;
1065
+ if (a.carrying) {
1066
+ const it = this.items.get(a.carrying);
1067
+ if (it)
1068
+ it.carriedBy = undefined;
1069
+ }
912
1070
  a.carrying = undefined;
913
1071
  this.emitAsset(a);
914
1072
  }
@@ -1048,6 +1206,35 @@ export class FlowEngine {
1048
1206
  }
1049
1207
  }
1050
1208
  }
1209
+ /**
1210
+ * 물품 상태 산출 — **보유값 + 식별자에서 나오는 파생값.**
1211
+ *
1212
+ * 품번 키(`gtinKey`)·로트(`lot`)는 식별자의 순수 함수라 저장하지 않고 여기서 낸다. 투영기와 **같은
1213
+ * 규칙**(`parseEpc`)을 쓴다 — 두 구동이 같은 식별자를 다르게 뜯으면 같은 사실이 다르게 보인다.
1214
+ * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
1215
+ */
1216
+ itemState(i) {
1217
+ const parsedClass = i.gtin ? parseEpc(i.gtin) : undefined;
1218
+ const parsedSelf = parseEpc(i.epc);
1219
+ const lot = parsedClass?.lot ??
1220
+ parsedSelf.lot ??
1221
+ (typeof i.ilmd?.[ILMD_ATTR.lot] === 'string' ? i.ilmd[ILMD_ATTR.lot] : undefined);
1222
+ const gtinKey = parsedClass?.gtinKey ?? parsedSelf.gtinKey;
1223
+ return {
1224
+ epc: i.epc,
1225
+ ...(i.gtin ? { gtin: i.gtin } : {}),
1226
+ ...(gtinKey ? { gtinKey } : {}),
1227
+ ...(lot ? { lot } : {}),
1228
+ location: i.location,
1229
+ ...(i.disposition ? { disposition: i.disposition } : {}),
1230
+ ...(i.parent ? { parent: i.parent } : {}),
1231
+ ...(i.carriedBy ? { carriedBy: i.carriedBy } : {}),
1232
+ ...(i.qty !== undefined ? { qty: i.qty } : {}),
1233
+ ...(i.uom ? { uom: i.uom } : {}),
1234
+ ...(i.expiry !== undefined ? { expiry: i.expiry } : {}),
1235
+ ...(i.ilmd ? { ilmd: i.ilmd } : {})
1236
+ };
1237
+ }
1051
1238
  progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
1052
1239
  generate() {
1053
1240
  for (const g of this.gens) {
@@ -1080,8 +1267,19 @@ export class FlowEngine {
1080
1267
  if (o.status === 'created' && !o.held)
1081
1268
  this.allocate(o);
1082
1269
  }
1270
+ /**
1271
+ * 작업 진행 — **진행을 먼저, 배정을 나중에.**
1272
+ *
1273
+ * 예전에는 배정을 먼저 하고 같은 tick 에서 곧바로 dt 만큼 깎았다. 그래서 이제 막 시작한 작업이
1274
+ * 시작하자마자 한 스텝 진행된 것으로 계산됐고, **방출한 모션 앵커(startedAtSimMs)와 스냅샷이 한
1275
+ * tick 어긋났다**(적합성 하네스가 잡았다). 스텝이 커질수록 오차도 커진다.
1276
+ */
1083
1277
  processTasks(dt) {
1084
- // 1) created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
1278
+ this.advanceTasks(dt);
1279
+ this.assignTasks();
1280
+ }
1281
+ assignTasks() {
1282
+ // created → in-progress. dwell(무자원)은 즉시 진행, transport/process 는 가용 자원 배정.
1085
1283
  for (const t of this.tasks.values()) {
1086
1284
  if (t.status !== 'created')
1087
1285
  continue;
@@ -1097,6 +1295,7 @@ export class FlowEngine {
1097
1295
  if (t.intent === 'dwell') { // 무설비 체류 — 설비 배정 없이 진행(인원 요구가 있으면 위에서 확보됨)
1098
1296
  t.status = 'in-progress';
1099
1297
  t.remainingMs = t.durationMs;
1298
+ t.startedAtSimMs = this.clockMs;
1100
1299
  this.assignCrew(t, crew);
1101
1300
  this.assignAssets(t, gear);
1102
1301
  this.emitTask(t);
@@ -1122,6 +1321,7 @@ export class FlowEngine {
1122
1321
  t.status = 'in-progress';
1123
1322
  t.resource = mover.id;
1124
1323
  t.remainingMs = t.durationMs;
1324
+ t.startedAtSimMs = this.clockMs;
1125
1325
  this.assignCrew(t, crew);
1126
1326
  this.assignAssets(t, gear);
1127
1327
  this.emitTask(t);
@@ -1131,7 +1331,9 @@ export class FlowEngine {
1131
1331
  else
1132
1332
  this.emitMover(mover, { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs, durationMs: t.durationMs, progress: 0, elapsedMs: 0 });
1133
1333
  }
1134
- // 2) in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만.
1334
+ }
1335
+ /** in-progress 진행/완료 — 상태 효과는 전부 도메인(onTaskComplete). base 는 자원 해제·델타만. */
1336
+ advanceTasks(dt) {
1135
1337
  for (const t of this.tasks.values()) {
1136
1338
  if (t.status !== 'in-progress')
1137
1339
  continue;
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export * from './domain-catalog.ts';
12
12
  export * from './allocation-policy.ts';
13
13
  export * from './duration-estimator.ts';
14
14
  export * from './iso-duration.ts';
15
+ export * from './observed-reducer.ts';
15
16
  export * from './state-projector.ts';
16
17
  export * from './task-fold.ts';
17
18
  export * from './face2-adapter.ts';
package/dist/index.js CHANGED
@@ -12,6 +12,7 @@ export * from "./domain-catalog.js";
12
12
  export * from "./allocation-policy.js";
13
13
  export * from "./duration-estimator.js";
14
14
  export * from "./iso-duration.js";
15
+ export * from "./observed-reducer.js";
15
16
  export * from "./state-projector.js";
16
17
  export * from "./task-fold.js";
17
18
  export * from "./face2-adapter.js";