@operato/twin-kernel 0.6.0 → 0.6.2

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.
@@ -874,6 +874,22 @@ export interface OrderState {
874
874
  requested?: number;
875
875
  fulfilled?: number;
876
876
  lines?: ObservedOrderLine[];
877
+ /**
878
+ * 이 오더가 **확보해 둔 물품들** — 진행 중인 할당.
879
+ *
880
+ * 이것이 상태에 없으면 **웜스타트가 잃는다.** 잃으면 되살아난 오더는 아무것도 안 잡은 것처럼
881
+ * 보이고, 그 오더에 딸린 진행 중 작업이 완료될 때 계보(`TransformationEvent`)가 **입력 없이**
882
+ * 나간다 — "무엇이 무엇으로 바뀌었나" 의 절반이 사라진다. 실제로 그렇게 되고 있었다.
883
+ *
884
+ * 씨앗이 잃은 것은 예측도 모른다 — 진행 중 자재를 안 잡은 것으로 놓고 미래를 굴리면 답이
885
+ * 낙관 쪽으로 치우친다(`hydrateObserved` 가 상태를 지키는 이유와 같다).
886
+ */
887
+ allocated?: string[];
888
+ /**
889
+ * 이 오더의 거래번호(작업지시·PO·SO) — 계보와 EPCIS 이벤트가 **무슨 거래로** 일어났는지 잇는 축.
890
+ * 이것도 상태에 없어서 되살아난 오더는 빈 문자열을 갖고, 이후 모든 이벤트의 거래번호가 빈다.
891
+ */
892
+ bizTransaction?: string;
877
893
  /** 우선순위 — 표준 `OperationsRequest.Priority`. 작은 값이 급하다. 오더 할당 순서를 정한다. */
878
894
  priority?: number;
879
895
  /** 예정 착수 — 표준 `OperationsRequest.StartTime`. */
@@ -1230,6 +1246,15 @@ export interface OrderStatusDelta {
1230
1246
  priority?: number;
1231
1247
  startTime?: ISOTime;
1232
1248
  endTime?: ISOTime;
1249
+ /**
1250
+ * 이 오더가 **확보해 둔 물품들**과 그 **거래번호** — 진행 중인 할당.
1251
+ *
1252
+ * 델타가 나르지 않으면 웜스타트가 잃는다. 잃으면 되살아난 오더는 아무것도 안 잡은 것처럼
1253
+ * 보이고, 그 오더의 진행 중 작업이 완료될 때 계보(`TransformationEvent`)가 **입력 없이** 나간다 —
1254
+ * "무엇이 무엇으로 바뀌었나" 의 절반이 사라진다. 실제로 그렇게 되고 있었다.
1255
+ */
1256
+ allocated?: string[];
1257
+ bizTransaction?: string;
1233
1258
  }
1234
1259
  export declare const CMD: {
1235
1260
  readonly orderHold: "order.hold";
@@ -1421,8 +1446,28 @@ export interface ProductionSpec {
1421
1446
  /** 쓸 레시피 키(미지정 시 첫 레시피) — 직렬 생산 경로(MES)만 쓴다. */
1422
1447
  recipeKey?: string;
1423
1448
  }
1449
+ /**
1450
+ * 공장을 갈아탄 결과 — **몇 개가 늘고 몇 개가 사라졌나.**
1451
+ *
1452
+ * 돌려주지 않으면 조용히 사라진다. 자리 하나가 없어진 것을 사용자가 화면의 수를 세어 눈치채기를
1453
+ * 기대할 수는 없다(특히 자리가 수천인 현장에서). 그래서 갈아타기는 **말없이 성공하지 않는다.**
1454
+ */
1455
+ export interface StructureShift {
1456
+ locationsAdded: number;
1457
+ locationsDropped: number;
1458
+ equipmentDropped: number;
1459
+ personsDropped: number;
1460
+ assetsDropped: number;
1461
+ }
1424
1462
  export interface TwinKernel {
1425
1463
  loadTwinModel(def: TwinModelDef): void;
1464
+ /**
1465
+ * 돌면서 공장을 갈아탄다 — 관측 구동(미러)에서만. 시뮬레이션은 거절한다(멈추고 다시 세운다).
1466
+ * 관측으로 알게 된 자리는 새 선언에 없어도 남고, 선언에서 사라진 자원은 버리되 수를 돌려준다.
1467
+ */
1468
+ adoptStructure(def: TwinModelDef): StructureShift;
1469
+ /** 이 커널이 관측으로 구동됨을 선언한다 — 첫 이벤트가 오기 전에도 그렇다. */
1470
+ observe(): void;
1426
1471
  getSnapshot(): StateSnapshot;
1427
1472
  onEvent(handler: EventHandler): Unsubscribe;
1428
1473
  dispatch(cmd: Command): CommandAck;
@@ -1,4 +1,4 @@
1
- import type { TwinModelDef, CanonicalEnvelope } from './contract.ts';
1
+ import type { TwinModelDef, CanonicalEnvelope, StructureShift } from './contract.ts';
2
2
  import { type ProjectedState } from './state-projector.ts';
3
3
  export declare class EventJournal {
4
4
  private events;
@@ -20,14 +20,14 @@ export interface StructureSegment {
20
20
  board: TwinModelDef;
21
21
  events: readonly CanonicalEnvelope[];
22
22
  }
23
- /** 구조가 바뀐 지점마다 무엇이 사라졌는지. 조용히 넘어가지 않는다. */
24
- export interface StructureShift {
23
+ /**
24
+ * 구조가 바뀐 지점마다 무엇이 사라졌는지. 조용히 넘어가지 않는다.
25
+ *
26
+ * 갈아타기의 결과 자체는 `StructureShift`(계약) 한 벌이고, 재생은 거기에 **몇 번째 마디였나**만
27
+ * 더한다 — 두 벌로 두면 한쪽에 필드가 늘어날 때 다른 쪽이 조용히 뒤처진다.
28
+ */
29
+ export interface SegmentShift extends StructureShift {
25
30
  index: number;
26
- locationsAdded: number;
27
- locationsDropped: number;
28
- equipmentDropped: number;
29
- personsDropped: number;
30
- assetsDropped: number;
31
31
  }
32
32
  /**
33
33
  * **구조가 바뀐 이력까지 이어서 재생한다.**
@@ -43,5 +43,5 @@ export interface StructureShift {
43
43
  */
44
44
  export declare function replaySegments(segments: readonly StructureSegment[]): {
45
45
  state: ProjectedState;
46
- shifts: StructureShift[];
46
+ shifts: SegmentShift[];
47
47
  };
@@ -1,4 +1,4 @@
1
- import type { ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState } from './contract.ts';
1
+ import type { ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, OrderState, PersonState, ScenarioControl, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift } 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';
@@ -311,6 +311,36 @@ export declare abstract class FlowEngine implements TwinKernel {
311
311
  */
312
312
  protected abstract onTaskComplete(task: FlowTask): void;
313
313
  loadTwinModel(def: TwinModelDef): void;
314
+ private buildLocation;
315
+ private buildPerson;
316
+ private buildAsset;
317
+ /**
318
+ * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
319
+ *
320
+ * 지금까지는 첫 `apply()` 가 이 사실을 뒤늦게 정했다. 그래서 "이 커널의 진실이 어디서 오나" 가
321
+ * **먼저 도착한 호출이 무엇이냐**에 달려 있었다 — 아직 아무 이벤트도 안 온 미러는 스스로를
322
+ * 시뮬레이션으로 여긴다. 세우는 쪽이 아는 사실이므로 세울 때 말하게 한다.
323
+ */
324
+ observe(): void;
325
+ /**
326
+ * 돌면서 공장을 갈아탄다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
327
+ *
328
+ * ── 왜 관측 구동에만 여는가 ────────────────────────────────────────────────
329
+ * 미러는 이미 있는 현실을 따라갈 뿐이라, 설비가 한 대 늘었다고 멈췄다 서는 것은 그 사이의
330
+ * 사실을 잃는 것이다(라이브에서 유실은 곧 거짓이다). 반면 **시뮬레이션은 멈춰도 된다** —
331
+ * 조건이 바뀐 실험은 다른 실험이고, 진행 중인 작업이 사라진 자리를 가리키게 두느니 새로
332
+ * 세우는 것이 옳다. 그래서 여기서 거절하는 것은 한계가 아니라 **규칙**이다.
333
+ *
334
+ * ── 무엇을 지키고 무엇을 버리는가 ──────────────────────────────────────────
335
+ * 판단은 관측층(`ObservedReducer.adoptStructure`)이 한다 — 관측은 지키고, 선언에서 사라진
336
+ * 자원은 버리되 **몇 개를 버렸는지 돌려준다.** 여기서는 그 판단을 커널 자신의 선언 표에도
337
+ * 그대로 옮긴다. 관측층만 갈면 사라진 설비가 커널 표에 남아 **화면이 없는 설비를 그린다.**
338
+ *
339
+ * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 심는다 — 다음 스냅샷에서 관측층이
340
+ * 되살리므로(`settleObserved` → `hydrateObserved`), 여기서 지켜야 할 것은 **선언분**뿐이다.
341
+ */
342
+ adoptStructure(def: TwinModelDef): StructureShift;
343
+ private buildEquipment;
314
344
  /**
315
345
  * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadTwinModel 설비 삽입과 동일 규약.
316
346
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
@@ -265,22 +265,115 @@ export class FlowEngine {
265
265
  if (def.productionSpec?.definition?.operations?.length)
266
266
  this.loadOperations(def.productionSpec.definition.operations);
267
267
  for (const n of readBoardLocations(def))
268
- this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId });
268
+ this.locations.set(n.id, this.buildLocation(n));
269
269
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
270
270
  this.materialDefs = new Map((def.materialDefinitions ?? []).filter(d => d?.id).map(d => [d.id, d]));
271
271
  for (const p of def.persons ?? [])
272
- this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.workCalendar ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOnly(p) });
272
+ this.persons.set(p.id, this.buildPerson(p));
273
273
  for (const a of readBoardAssets(def))
274
- this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOnly(a) });
275
- for (const m of readBoardEquipment(def)) {
276
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
277
- if (m.mtbfMs !== undefined) {
278
- eq.mtbfMs = m.mtbfMs;
279
- eq.mttrMs = m.mttrMs;
280
- eq.nextFailureMs = this.sampleExp(m.mtbfMs);
281
- }
282
- this.equipment.set(m.id, eq);
274
+ this.assets.set(a.id, this.buildAsset(a));
275
+ for (const m of readBoardEquipment(def))
276
+ this.equipment.set(m.id, this.buildEquipment(m));
277
+ }
278
+ /*
279
+ * ── 선언 하나를 상태 하나로 짓는 자리 ─────────────────────────────────────
280
+ * 최초 적재(`loadTwinModel`)와 가동 중 교체(`adoptStructure`)가 **같은 구성을 쓴다.** 두 곳에서
281
+ * 따로 지으면 곧 갈라지고, 갈라진 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
282
+ */
283
+ buildLocation(n) {
284
+ return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: 'idle', parentId: n.parentId };
285
+ }
286
+ buildPerson(p) {
287
+ return { id: p.id, personnelClassIds: p.personnelClassIds, status: 'idle', taskId: null, window: p.window, ...(p.workCalendar ? { workCalendar: p.workCalendar } : {}), ...(p.homeLocation ? { location: p.homeLocation } : {}), ...(p.properties ? { properties: p.properties } : {}), ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}), ...effectiveOnly(p) };
288
+ }
289
+ buildAsset(a) {
290
+ return { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: 'idle', taskId: null, ...(a.properties ? { properties: a.properties } : {}), ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}), ...effectiveOnly(a) };
291
+ }
292
+ /**
293
+ * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
294
+ *
295
+ * 지금까지는 첫 `apply()` 가 이 사실을 뒤늦게 정했다. 그래서 "이 커널의 진실이 어디서 오나" 가
296
+ * **먼저 도착한 호출이 무엇이냐**에 달려 있었다 — 아직 아무 이벤트도 안 온 미러는 스스로를
297
+ * 시뮬레이션으로 여긴다. 세우는 쪽이 아는 사실이므로 세울 때 말하게 한다.
298
+ */
299
+ observe() {
300
+ this.observeMode = true;
301
+ }
302
+ /**
303
+ * 돌면서 공장을 갈아탄다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
304
+ *
305
+ * ── 왜 관측 구동에만 여는가 ────────────────────────────────────────────────
306
+ * 미러는 이미 있는 현실을 따라갈 뿐이라, 설비가 한 대 늘었다고 멈췄다 서는 것은 그 사이의
307
+ * 사실을 잃는 것이다(라이브에서 유실은 곧 거짓이다). 반면 **시뮬레이션은 멈춰도 된다** —
308
+ * 조건이 바뀐 실험은 다른 실험이고, 진행 중인 작업이 사라진 자리를 가리키게 두느니 새로
309
+ * 세우는 것이 옳다. 그래서 여기서 거절하는 것은 한계가 아니라 **규칙**이다.
310
+ *
311
+ * ── 무엇을 지키고 무엇을 버리는가 ──────────────────────────────────────────
312
+ * 판단은 관측층(`ObservedReducer.adoptStructure`)이 한다 — 관측은 지키고, 선언에서 사라진
313
+ * 자원은 버리되 **몇 개를 버렸는지 돌려준다.** 여기서는 그 판단을 커널 자신의 선언 표에도
314
+ * 그대로 옮긴다. 관측층만 갈면 사라진 설비가 커널 표에 남아 **화면이 없는 설비를 그린다.**
315
+ *
316
+ * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 심는다 — 다음 스냅샷에서 관측층이
317
+ * 되살리므로(`settleObserved` → `hydrateObserved`), 여기서 지켜야 할 것은 **선언분**뿐이다.
318
+ */
319
+ adoptStructure(def) {
320
+ if (!this.observeMode)
321
+ throw new Error('adoptStructure is for observation-driven kernels (mirrors) — a simulation cannot swap its factory mid-run; stop and re-provision instead');
322
+ this.boardDef = def;
323
+ if (def.productionSpec?.definition?.operations?.length)
324
+ this.loadOperations(def.productionSpec.definition.operations);
325
+ this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
326
+ this.materialDefs = new Map((def.materialDefinitions ?? []).filter(d => d?.id).map(d => [d.id, d]));
327
+ /* **살아남은 것은 그대로 둔다** — 다시 지으면 가동시간·양품수가 0 으로 돌아간다(OEE 가 리셋된다).
328
+ 선언이 바뀐 트윈에서 성과 지표가 조용히 끊기는 것이 이 갈아타기의 가장 비싼 오류다. */
329
+ const sync = (map, declared, build) => {
330
+ const ids = new Set(declared.map(d => d.id));
331
+ let dropped = 0;
332
+ for (const id of [...map.keys()])
333
+ if (!ids.has(id)) {
334
+ map.delete(id);
335
+ dropped++;
336
+ }
337
+ for (const d of declared)
338
+ if (!map.has(d.id))
339
+ map.set(d.id, build(d));
340
+ return dropped;
341
+ };
342
+ const declaredLocations = readBoardLocations(def);
343
+ const locationsBefore = this.locations.size;
344
+ const locationsDropped = sync(this.locations, declaredLocations, n => this.buildLocation(n));
345
+ const equipmentDropped = sync(this.equipment, readBoardEquipment(def), m => this.buildEquipment(m));
346
+ const personsDropped = sync(this.persons, def.persons ?? [], p => this.buildPerson(p));
347
+ const assetsDropped = sync(this.assets, readBoardAssets(def), a => this.buildAsset(a));
348
+ const observed = this.observer?.adoptStructure(def);
349
+ if (this.observer)
350
+ this.observedDirty = true;
351
+ this.revision++;
352
+ /*
353
+ * **돌려주는 수는 실제로 일어난 일이어야 한다.**
354
+ *
355
+ * 자리는 관측층의 셈을 쓴다 — 관측으로 알게 된 자리는 선언에 없어도 남으므로, 커널 표만 보면
356
+ * "버렸다" 고 세었다가 다음 스냅샷에서 되살아난다(그 수를 그대로 보고하면 거짓말이 된다).
357
+ * 관측층이 아직 없으면(첫 이벤트 전) 커널 표의 셈이 곧 사실이다.
358
+ *
359
+ * 자원은 어느 층에서나 **선언이 전부**라 셈이 같고, 화면이 그리는 것은 커널 표이므로 이쪽을 쓴다.
360
+ */
361
+ return {
362
+ locationsAdded: observed?.locationsAdded ?? Math.max(0, this.locations.size - locationsBefore + locationsDropped),
363
+ locationsDropped: observed?.locationsDropped ?? locationsDropped,
364
+ equipmentDropped,
365
+ personsDropped,
366
+ assetsDropped
367
+ };
368
+ }
369
+ buildEquipment(m) {
370
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), status: 'idle', taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...(m.workCalendar ? { workCalendar: m.workCalendar } : {}), ...effectiveOnly(m) };
371
+ if (m.mtbfMs !== undefined) {
372
+ eq.mtbfMs = m.mtbfMs;
373
+ eq.mttrMs = m.mttrMs;
374
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
283
375
  }
376
+ return eq;
284
377
  }
285
378
  /**
286
379
  * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadTwinModel 설비 삽입과 동일 규약.
@@ -427,7 +520,11 @@ export class FlowEngine {
427
520
  없다.** 라이브 트윈이 납기 초과를 한 번도 보고하지 못한 원인 중 하나였다. */
428
521
  ...(o.endTime ? { endTime: o.endTime } : {}),
429
522
  ...(o.startTime ? { startTime: o.startTime } : {}),
430
- ...(o.priority !== undefined ? { priority: o.priority } : {})
523
+ ...(o.priority !== undefined ? { priority: o.priority } : {}),
524
+ /* 확보분과 거래번호도 함께 옮긴다 — 여기서 떨구면 위에서 실어 온 것이 마지막 한 걸음에서
525
+ 사라진다(같은 사실이 델타·스냅샷·이 변환 **세 길**을 지난다). */
526
+ ...(o.allocated?.length ? { allocated: o.allocated } : {}),
527
+ ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {})
431
528
  }));
432
529
  for (const o of observedOrders) {
433
530
  const lines = (o.lines ?? []).map(l => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter(l => l.requested > 0);
@@ -441,7 +538,10 @@ export class FlowEngine {
441
538
  continue; // 이미 이행 완료 → 예측 대상 아님
442
539
  this.orders.set(o.orderId, {
443
540
  id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0,
444
- bizTransaction: '', allocated: [], picked: [], shipmentEpc: null, lines,
541
+ /* **확보해 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
542
+ 보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
543
+ 않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
544
+ bizTransaction: o.bizTransaction ?? '', allocated: (o.allocated ?? []).slice(), picked: [], shipmentEpc: null, lines,
445
545
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
446
546
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
447
547
  ...(o.held ? { held: true } : {}),
@@ -767,6 +867,10 @@ export class FlowEngine {
767
867
  ...(o.priority !== undefined ? { priority: o.priority } : {}),
768
868
  ...(o.startTime ? { startTime: o.startTime } : {}),
769
869
  ...(o.endTime ? { endTime: o.endTime } : {}),
870
+ /* 진행 중 확보분과 거래번호 — **스냅샷에도** 실어야 한다. 델타에만 실으면 스냅샷으로
871
+ 재기동하는 경로(체크포인트)에서 그대로 잃는다(같은 사실을 두 길로 나르는 값이다). */
872
+ ...(o.allocated?.length ? { allocated: o.allocated.slice() } : {}),
873
+ ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}),
770
874
  held: o.held
771
875
  })),
772
876
  attentions: this.computeAttentions(),
@@ -1367,7 +1471,11 @@ export class FlowEngine {
1367
1471
  ...(o.priority !== undefined ? { priority: o.priority } : {}),
1368
1472
  ...(o.startTime ? { startTime: o.startTime } : {}),
1369
1473
  ...(o.endTime ? { endTime: o.endTime } : {}),
1370
- ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {})
1474
+ ...(o.lines?.length ? { lines: o.lines.map(l => ({ gtin: l.gtin, requested: l.requested })) } : {}),
1475
+ /* 진행 중 할당과 거래번호를 함께 싣는다 — 이것이 없으면 웜스타트가 잃고, 되살아난 오더의
1476
+ 계보가 입력 없이 나간다(무엇이 무엇으로 바뀌었나의 절반이 사라진다). */
1477
+ ...(o.allocated?.length ? { allocated: o.allocated.slice() } : {}),
1478
+ ...(o.bizTransaction ? { bizTransaction: o.bizTransaction } : {})
1371
1479
  });
1372
1480
  }
1373
1481
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
@@ -1,4 +1,4 @@
1
- import type { AssetState, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState } from './contract.ts';
1
+ import type { AssetState, TwinModelDef, CanonicalEnvelope, LocationState, ItemState, EquipmentState, PersonState, TaskState, OrderState, StructureShift } from './contract.ts';
2
2
  /**
3
3
  * 마스터 동기 — 선언적 로케이션 upsert/remove.
4
4
  *
@@ -91,13 +91,7 @@ export declare class ObservedReducer {
91
91
  * 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
92
92
  * 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
93
93
  */
94
- adoptStructure(board: TwinModelDef): {
95
- locationsAdded: number;
96
- locationsDropped: number;
97
- equipmentDropped: number;
98
- personsDropped: number;
99
- assetsDropped: number;
100
- };
94
+ adoptStructure(board: TwinModelDef): StructureShift;
101
95
  /** 마스터 동기 — 로케이션 추가/변경/제거. */
102
96
  applyMaster(u: MasterUpdate): void;
103
97
  /**
@@ -342,6 +342,10 @@ export class ObservedReducer {
342
342
  ...(d.priority !== undefined ? { priority: d.priority } : {}),
343
343
  ...(d.startTime ? { startTime: d.startTime } : {}),
344
344
  ...(d.endTime ? { endTime: d.endTime } : {}),
345
+ /* 진행 중 확보분과 거래번호 — **미러도 같은 필드를 채운다.** 시뮬만 아는 사실을 두면
346
+ 그 위에서 세운 예측이 두 구동에서 달라진다(패리티 검사가 지키는 것이 이것이다). */
347
+ ...(d.allocated?.length ? { allocated: d.allocated.slice() } : {}),
348
+ ...(d.bizTransaction ? { bizTransaction: d.bizTransaction } : {}),
345
349
  held: d.held
346
350
  });
347
351
  break;
@@ -1181,6 +1181,10 @@ var ObservedReducer = class {
1181
1181
  ...d.priority !== void 0 ? { priority: d.priority } : {},
1182
1182
  ...d.startTime ? { startTime: d.startTime } : {},
1183
1183
  ...d.endTime ? { endTime: d.endTime } : {},
1184
+ /* 진행 중 확보분과 거래번호 — **미러도 같은 필드를 채운다.** 시뮬만 아는 사실을 두면
1185
+ 그 위에서 세운 예측이 두 구동에서 달라진다(패리티 검사가 지키는 것이 이것이다). */
1186
+ ...d.allocated?.length ? { allocated: d.allocated.slice() } : {},
1187
+ ...d.bizTransaction ? { bizTransaction: d.bizTransaction } : {},
1184
1188
  held: d.held
1185
1189
  });
1186
1190
  break;
@@ -2283,20 +2287,98 @@ var FlowEngine = class {
2283
2287
  loadTwinModel(def) {
2284
2288
  this.boardDef = def;
2285
2289
  if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
2286
- for (const n of readBoardLocations(def)) this.locations.set(n.id, { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId });
2290
+ for (const n of readBoardLocations(def)) this.locations.set(n.id, this.buildLocation(n));
2287
2291
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
2288
2292
  this.materialDefs = new Map((def.materialDefinitions ?? []).filter((d) => d?.id).map((d) => [d.id, d]));
2289
- for (const p of def.persons ?? []) this.persons.set(p.id, { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", taskId: null, window: p.window, ...p.workCalendar ? { workCalendar: p.workCalendar } : {}, ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOnly(p) });
2290
- for (const a of readBoardAssets(def)) this.assets.set(a.id, { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", taskId: null, ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOnly(a) });
2291
- for (const m of readBoardEquipment(def)) {
2292
- const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...m.workCalendar ? { workCalendar: m.workCalendar } : {}, ...effectiveOnly(m) };
2293
- if (m.mtbfMs !== void 0) {
2294
- eq.mtbfMs = m.mtbfMs;
2295
- eq.mttrMs = m.mttrMs;
2296
- eq.nextFailureMs = this.sampleExp(m.mtbfMs);
2293
+ for (const p of def.persons ?? []) this.persons.set(p.id, this.buildPerson(p));
2294
+ for (const a of readBoardAssets(def)) this.assets.set(a.id, this.buildAsset(a));
2295
+ for (const m of readBoardEquipment(def)) this.equipment.set(m.id, this.buildEquipment(m));
2296
+ }
2297
+ /*
2298
+ * ── 선언 하나를 상태 하나로 짓는 자리 ─────────────────────────────────────
2299
+ * 최초 적재(`loadTwinModel`)와 가동 중 교체(`adoptStructure`)가 **같은 구성을 쓴다.** 두 곳에서
2300
+ * 따로 지으면 곧 갈라지고, 갈라진 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
2301
+ */
2302
+ buildLocation(n) {
2303
+ return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId };
2304
+ }
2305
+ buildPerson(p) {
2306
+ return { id: p.id, personnelClassIds: p.personnelClassIds, status: "idle", taskId: null, window: p.window, ...p.workCalendar ? { workCalendar: p.workCalendar } : {}, ...p.homeLocation ? { location: p.homeLocation } : {}, ...p.properties ? { properties: p.properties } : {}, ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}, ...effectiveOnly(p) };
2307
+ }
2308
+ buildAsset(a) {
2309
+ return { id: a.id, assetClassIds: a.assetClassIds, location: a.homeLocation, status: "idle", taskId: null, ...a.properties ? { properties: a.properties } : {}, ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}, ...effectiveOnly(a) };
2310
+ }
2311
+ /**
2312
+ * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
2313
+ *
2314
+ * 지금까지는 첫 `apply()` 가 이 사실을 뒤늦게 정했다. 그래서 "이 커널의 진실이 어디서 오나" 가
2315
+ * **먼저 도착한 호출이 무엇이냐**에 달려 있었다 — 아직 아무 이벤트도 안 온 미러는 스스로를
2316
+ * 시뮬레이션으로 여긴다. 세우는 쪽이 아는 사실이므로 세울 때 말하게 한다.
2317
+ */
2318
+ observe() {
2319
+ this.observeMode = true;
2320
+ }
2321
+ /**
2322
+ * 돌면서 공장을 갈아탄다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
2323
+ *
2324
+ * ── 왜 관측 구동에만 여는가 ────────────────────────────────────────────────
2325
+ * 미러는 이미 있는 현실을 따라갈 뿐이라, 설비가 한 대 늘었다고 멈췄다 서는 것은 그 사이의
2326
+ * 사실을 잃는 것이다(라이브에서 유실은 곧 거짓이다). 반면 **시뮬레이션은 멈춰도 된다** —
2327
+ * 조건이 바뀐 실험은 다른 실험이고, 진행 중인 작업이 사라진 자리를 가리키게 두느니 새로
2328
+ * 세우는 것이 옳다. 그래서 여기서 거절하는 것은 한계가 아니라 **규칙**이다.
2329
+ *
2330
+ * ── 무엇을 지키고 무엇을 버리는가 ──────────────────────────────────────────
2331
+ * 판단은 관측층(`ObservedReducer.adoptStructure`)이 한다 — 관측은 지키고, 선언에서 사라진
2332
+ * 자원은 버리되 **몇 개를 버렸는지 돌려준다.** 여기서는 그 판단을 커널 자신의 선언 표에도
2333
+ * 그대로 옮긴다. 관측층만 갈면 사라진 설비가 커널 표에 남아 **화면이 없는 설비를 그린다.**
2334
+ *
2335
+ * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 심는다 — 다음 스냅샷에서 관측층이
2336
+ * 되살리므로(`settleObserved` → `hydrateObserved`), 여기서 지켜야 할 것은 **선언분**뿐이다.
2337
+ */
2338
+ adoptStructure(def) {
2339
+ if (!this.observeMode)
2340
+ throw new Error(
2341
+ "adoptStructure is for observation-driven kernels (mirrors) \u2014 a simulation cannot swap its factory mid-run; stop and re-provision instead"
2342
+ );
2343
+ this.boardDef = def;
2344
+ if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
2345
+ this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
2346
+ this.materialDefs = new Map((def.materialDefinitions ?? []).filter((d) => d?.id).map((d) => [d.id, d]));
2347
+ const sync = (map, declared, build) => {
2348
+ const ids = new Set(declared.map((d) => d.id));
2349
+ let dropped = 0;
2350
+ for (const id of [...map.keys()]) if (!ids.has(id)) {
2351
+ map.delete(id);
2352
+ dropped++;
2297
2353
  }
2298
- this.equipment.set(m.id, eq);
2354
+ for (const d of declared) if (!map.has(d.id)) map.set(d.id, build(d));
2355
+ return dropped;
2356
+ };
2357
+ const declaredLocations = readBoardLocations(def);
2358
+ const locationsBefore = this.locations.size;
2359
+ const locationsDropped = sync(this.locations, declaredLocations, (n) => this.buildLocation(n));
2360
+ const equipmentDropped = sync(this.equipment, readBoardEquipment(def), (m) => this.buildEquipment(m));
2361
+ const personsDropped = sync(this.persons, def.persons ?? [], (p) => this.buildPerson(p));
2362
+ const assetsDropped = sync(this.assets, readBoardAssets(def), (a) => this.buildAsset(a));
2363
+ const observed = this.observer?.adoptStructure(def);
2364
+ if (this.observer) this.observedDirty = true;
2365
+ this.revision++;
2366
+ return {
2367
+ locationsAdded: observed?.locationsAdded ?? Math.max(0, this.locations.size - locationsBefore + locationsDropped),
2368
+ locationsDropped: observed?.locationsDropped ?? locationsDropped,
2369
+ equipmentDropped,
2370
+ personsDropped,
2371
+ assetsDropped
2372
+ };
2373
+ }
2374
+ buildEquipment(m) {
2375
+ const eq = { id: m.id, kind: m.kind, location: m.homeLocation, homeLocation: m.homeLocation, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, status: "idle", taskId: null, runMs: 0, setupMs: 0, downMs: 0, goodCount: 0, scrapCount: 0, window: m.window, ...m.workCalendar ? { workCalendar: m.workCalendar } : {}, ...effectiveOnly(m) };
2376
+ if (m.mtbfMs !== void 0) {
2377
+ eq.mtbfMs = m.mtbfMs;
2378
+ eq.mttrMs = m.mttrMs;
2379
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
2299
2380
  }
2381
+ return eq;
2300
2382
  }
2301
2383
  /**
2302
2384
  * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadTwinModel 설비 삽입과 동일 규약.
@@ -2424,7 +2506,11 @@ var FlowEngine = class {
2424
2506
  없다.** 라이브 트윈이 납기 초과를 한 번도 보고하지 못한 원인 중 하나였다. */
2425
2507
  ...o.endTime ? { endTime: o.endTime } : {},
2426
2508
  ...o.startTime ? { startTime: o.startTime } : {},
2427
- ...o.priority !== void 0 ? { priority: o.priority } : {}
2509
+ ...o.priority !== void 0 ? { priority: o.priority } : {},
2510
+ /* 확보분과 거래번호도 함께 옮긴다 — 여기서 떨구면 위에서 실어 온 것이 마지막 한 걸음에서
2511
+ 사라진다(같은 사실이 델타·스냅샷·이 변환 **세 길**을 지난다). */
2512
+ ...o.allocated?.length ? { allocated: o.allocated } : {},
2513
+ ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}
2428
2514
  }));
2429
2515
  for (const o of observedOrders) {
2430
2516
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
@@ -2436,8 +2522,11 @@ var FlowEngine = class {
2436
2522
  status: "created",
2437
2523
  requested: remaining,
2438
2524
  fulfilled: 0,
2439
- bizTransaction: "",
2440
- allocated: [],
2525
+ /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
2526
+ 보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
2527
+ 않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
2528
+ bizTransaction: o.bizTransaction ?? "",
2529
+ allocated: (o.allocated ?? []).slice(),
2441
2530
  picked: [],
2442
2531
  shipmentEpc: null,
2443
2532
  lines,
@@ -2745,6 +2834,10 @@ var FlowEngine = class {
2745
2834
  ...o.priority !== void 0 ? { priority: o.priority } : {},
2746
2835
  ...o.startTime ? { startTime: o.startTime } : {},
2747
2836
  ...o.endTime ? { endTime: o.endTime } : {},
2837
+ /* 진행 중 확보분과 거래번호 — **스냅샷에도** 실어야 한다. 델타에만 실으면 스냅샷으로
2838
+ 재기동하는 경로(체크포인트)에서 그대로 잃는다(같은 사실을 두 길로 나르는 값이다). */
2839
+ ...o.allocated?.length ? { allocated: o.allocated.slice() } : {},
2840
+ ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {},
2748
2841
  held: o.held
2749
2842
  })),
2750
2843
  attentions: this.computeAttentions(),
@@ -3289,7 +3382,11 @@ var FlowEngine = class {
3289
3382
  ...o.priority !== void 0 ? { priority: o.priority } : {},
3290
3383
  ...o.startTime ? { startTime: o.startTime } : {},
3291
3384
  ...o.endTime ? { endTime: o.endTime } : {},
3292
- ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {}
3385
+ ...o.lines?.length ? { lines: o.lines.map((l) => ({ gtin: l.gtin, requested: l.requested })) } : {},
3386
+ /* 진행 중 할당과 거래번호를 함께 싣는다 — 이것이 없으면 웜스타트가 잃고, 되살아난 오더의
3387
+ 계보가 입력 없이 나간다(무엇이 무엇으로 바뀌었나의 절반이 사라진다). */
3388
+ ...o.allocated?.length ? { allocated: o.allocated.slice() } : {},
3389
+ ...o.bizTransaction ? { bizTransaction: o.bizTransaction } : {}
3293
3390
  });
3294
3391
  }
3295
3392
  // ── 내부 mechanics ─────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
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": {