@operato/twin-kernel 0.5.0 → 0.6.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.
@@ -362,7 +362,7 @@ export declare function conversionFactorOf(def: MaterialDefinition | undefined,
362
362
  * 되풀이를 간단히 적기 위한 선택이고, 그러면 **어느 기준의 06:00 인가**가 반드시 필요하다 —
363
363
  * Rosarito(UTC−7)의 06:00 을 UTC 로 읽으면 7시간이 틀린다.
364
364
  *
365
- * 그래서 `BoardDef.utcOffsetMinutes` 가 그 기준이다. **선언하지 않으면 UTC 로 읽고, 그 사실을 여기
365
+ * 그래서 `TwinModelDef.utcOffsetMinutes` 가 그 기준이다. **선언하지 않으면 UTC 로 읽고, 그 사실을 여기
366
366
  * 밝힌다**(조용히 가정하지 않기 위해). 프레임워크에는 이미 테넌트 시간대(`Domain.timezone`)와
367
367
  * 교대→절대구간 변환(`@things-factory/work-shift` `work-shift-range`, moment-timezone)이 있으므로,
368
368
  * **기준을 푸는 일은 호스트의 몫**이다(커널은 zero-dep — 시간대 데이터를 들 수 없다).
@@ -1267,14 +1267,14 @@ export type Unsubscribe = () => void;
1267
1267
  * 이 함수를 거치지 않고 `def.locations` 를 직접 읽는 코드를 두지 않는다 — 하나라도 남으면 그 경로에서만
1268
1268
  * 옛 보드의 자리가 조용히 사라진다(빈 배열 = 자리 없는 트윈 = 아무 일도 일어나지 않는다).
1269
1269
  */
1270
- export declare function readBoardLocations(def: BoardDef | (Record<string, unknown> & {
1270
+ export declare function readBoardLocations(def: TwinModelDef | (Record<string, unknown> & {
1271
1271
  locations?: unknown;
1272
1272
  nodes?: unknown;
1273
- })): BoardDef['locations'];
1274
- export declare function readBoardEquipment(def: BoardDef | Record<string, unknown>): BoardDef['equipment'];
1273
+ })): TwinModelDef['locations'];
1274
+ export declare function readBoardEquipment(def: TwinModelDef | Record<string, unknown>): TwinModelDef['equipment'];
1275
1275
  /** 저장된 보드의 반복사용 자산 — 설비와 같은 정규화를 거친다. */
1276
- export declare function readBoardAssets(def: BoardDef | Record<string, unknown>): NonNullable<BoardDef['assets']>;
1277
- export interface BoardDef {
1276
+ export declare function readBoardAssets(def: TwinModelDef | Record<string, unknown>): NonNullable<TwinModelDef['assets']>;
1277
+ export interface TwinModelDef {
1278
1278
  /** parallelism = 동시 처리 수(LocationState.parallelism 참조). capacity 는 저장 용량. */
1279
1279
  locations: {
1280
1280
  id: string;
@@ -1396,7 +1396,7 @@ export interface BoardDef {
1396
1396
  *
1397
1397
  * ── 왜 이제야 선언하나 (2026-08-08) ──────────────────────────────────────────
1398
1398
  * 이 자리는 **이미 쓰이고 있었다.** 마스터 인제스트가 `board.operations` 로 통과시키고 호스트가
1399
- * `applyOperations` 로 커널에 싣는다. 그런데 `BoardDef` 는 그것을 말한 적이 없다.
1399
+ * `applyOperations` 로 커널에 싣는다. 그런데 `TwinModelDef` 는 그것을 말한 적이 없다.
1400
1400
  *
1401
1401
  * 바로 위 `productionSpec` 이 겪은 것과 **같은 일**이다 — "계약에 없으니 아무도 이 자리를 발견할
1402
1402
  * 수 없었다. 타입이 말해 주지 않는 필드는 없는 필드다." 그때 배운 것을 여기 적용한다.
@@ -1421,8 +1421,28 @@ export interface ProductionSpec {
1421
1421
  /** 쓸 레시피 키(미지정 시 첫 레시피) — 직렬 생산 경로(MES)만 쓴다. */
1422
1422
  recipeKey?: string;
1423
1423
  }
1424
+ /**
1425
+ * 공장을 갈아탄 결과 — **몇 개가 늘고 몇 개가 사라졌나.**
1426
+ *
1427
+ * 돌려주지 않으면 조용히 사라진다. 자리 하나가 없어진 것을 사용자가 화면의 수를 세어 눈치채기를
1428
+ * 기대할 수는 없다(특히 자리가 수천인 현장에서). 그래서 갈아타기는 **말없이 성공하지 않는다.**
1429
+ */
1430
+ export interface StructureShift {
1431
+ locationsAdded: number;
1432
+ locationsDropped: number;
1433
+ equipmentDropped: number;
1434
+ personsDropped: number;
1435
+ assetsDropped: number;
1436
+ }
1424
1437
  export interface TwinKernel {
1425
- loadBoard(def: BoardDef): void;
1438
+ loadTwinModel(def: TwinModelDef): void;
1439
+ /**
1440
+ * 돌면서 공장을 갈아탄다 — 관측 구동(미러)에서만. 시뮬레이션은 거절한다(멈추고 다시 세운다).
1441
+ * 관측으로 알게 된 자리는 새 선언에 없어도 남고, 선언에서 사라진 자원은 버리되 수를 돌려준다.
1442
+ */
1443
+ adoptStructure(def: TwinModelDef): StructureShift;
1444
+ /** 이 커널이 관측으로 구동됨을 선언한다 — 첫 이벤트가 오기 전에도 그렇다. */
1445
+ observe(): void;
1426
1446
  getSnapshot(): StateSnapshot;
1427
1447
  onEvent(handler: EventHandler): Unsubscribe;
1428
1448
  dispatch(cmd: Command): CommandAck;
@@ -30,7 +30,7 @@ export interface DomainProfileInfo {
30
30
  export declare const DOMAIN_CATALOG: Record<DomainSystem, DomainProfileInfo>;
31
31
  export interface TwinAxisInfo {
32
32
  /**
33
- * 축의 이름 — 화면·관계 선언이 쓰는 키. 최상위 축은 `BoardDef` 의 키 **그대로**다
33
+ * 축의 이름 — 화면·관계 선언이 쓰는 키. 최상위 축은 `TwinModelDef` 의 키 **그대로**다
34
34
  * (저장·계약의 이름과 갈라지면 그 순간 방언이 생긴다).
35
35
  */
36
36
  axis: string;
@@ -1,5 +1,5 @@
1
1
  /*
2
- * 도메인 정의(DomainDefinition) — 커널의 선언적 도메인 계약. `BoardDef`(토폴로지 입력)의 확장 격.
2
+ * 도메인 정의(DomainDefinition) — 커널의 선언적 도메인 계약. `TwinModelDef`(토폴로지 입력)의 확장 격.
3
3
  * 커널이 "무엇이 있고 어떻게 흐르나"(타입 + 공정 route/BOM)를 **데이터로** 받는 형식. zero-dep(자기 타입).
4
4
  *
5
5
  * 특정 공정이 커널 코드에 하드코딩되던 것을 이 데이터 계약으로 대체한다(design/plans/domain-catalog-layering.md).
@@ -1,4 +1,4 @@
1
- import type { BoardDef, 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;
@@ -14,20 +14,20 @@ export declare class EventJournal {
14
14
  untilSimTime(iso: string): CanonicalEnvelope[];
15
15
  }
16
16
  /** 이벤트열 → 상태 재구성(시간여행). board = 마스터(토폴로지·설비). */
17
- export declare function replay(board: BoardDef, events: readonly CanonicalEnvelope[]): ProjectedState;
17
+ export declare function replay(board: TwinModelDef, events: readonly CanonicalEnvelope[]): ProjectedState;
18
18
  /** 한 구조 아래에서 일어난 이벤트들 — 재생의 한 마디. */
19
19
  export interface StructureSegment {
20
- board: BoardDef;
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, BoardDef, 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';
@@ -283,7 +283,7 @@ export declare abstract class FlowEngine implements TwinKernel {
283
283
  private observedDirty;
284
284
  private observeMode;
285
285
  /** 관측 구동이 투영기를 세울 때 필요한 원본 보드(구조는 이벤트가 아니라 마스터에서 온다). */
286
- protected boardDef?: BoardDef;
286
+ protected boardDef?: TwinModelDef;
287
287
  /** 명세 소비 기록 — 무엇을 선언값으로, 무엇을 기본값으로 계산했나(정직한 자기보고). */
288
288
  private specUse;
289
289
  protected epcSeq: number;
@@ -310,9 +310,39 @@ export declare abstract class FlowEngine implements TwinKernel {
310
310
  * 이동(WMS/YMS: from→to 이동) 이든 변환(MES: 소비→생산) 이든 여기서 결정 → base 가 이동-중립.
311
311
  */
312
312
  protected abstract onTaskComplete(task: FlowTask): void;
313
- loadBoard(def: BoardDef): void;
313
+ loadTwinModel(def: TwinModelDef): void;
314
+ private buildLocation;
315
+ private buildPerson;
316
+ private buildAsset;
314
317
  /**
315
- * what-if 구성 변주 fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
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;
344
+ /**
345
+ * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadTwinModel 설비 삽입과 동일 규약.
316
346
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
317
347
  */
318
348
  addEquipment(m: {
@@ -253,7 +253,7 @@ export class FlowEngine {
253
253
  this.policy = policy;
254
254
  }
255
255
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
256
- loadBoard(def) {
256
+ loadTwinModel(def) {
257
257
  this.boardDef = def;
258
258
  /*
259
259
  * **생산 선언은 어느 커널이든 싣는다.** 예전에는 MES 커널만 생성자에서 이것을 실었고(그 시절
@@ -265,25 +265,118 @@ 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
- * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
379
+ * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadTwinModel 설비 삽입과 동일 규약.
287
380
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
288
381
  */
289
382
  addEquipment(m) {
@@ -1,4 +1,4 @@
1
- import type { AssetState, BoardDef, 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
  *
@@ -72,7 +72,7 @@ export declare class ObservedReducer {
72
72
  private shifts;
73
73
  /** 시각 해석 기준(보드 선언) — 없으면 UTC. 캘린더의 `HH:MM` 이 어느 기준인지 정한다. */
74
74
  private utcOffsetMinutes?;
75
- constructor(board: BoardDef);
75
+ constructor(board: TwinModelDef);
76
76
  /**
77
77
  * **구조를 갈아탄다** — 관측된 사실은 지키고 토폴로지만 새 선언으로 바꾼다.
78
78
  *
@@ -91,13 +91,7 @@ export declare class ObservedReducer {
91
91
  * 관측으로 알게 된 자리(`origin: 'observed'`)는 **새 마스터에 없어도 남긴다** — 마스터가 모르는
92
92
  * 자리에서 물건이 실제로 보였다는 사실은 구조를 바꾼다고 사라지지 않는다.
93
93
  */
94
- adoptStructure(board: BoardDef): {
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
  /**
@@ -2280,26 +2280,104 @@ var FlowEngine = class {
2280
2280
  this.policy = policy;
2281
2281
  }
2282
2282
  // ── TwinKernel (mechanics, 도메인 무관) ───────────────────────────────────
2283
- loadBoard(def) {
2283
+ loadTwinModel(def) {
2284
2284
  this.boardDef = def;
2285
2285
  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 });
2286
+ for (const n of readBoardLocations(def)) this.locations.set(n.id, this.buildLocation(n));
2287
2287
  this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
2288
2288
  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);
2289
+ for (const p of def.persons ?? []) this.persons.set(p.id, this.buildPerson(p));
2290
+ for (const a of readBoardAssets(def)) this.assets.set(a.id, this.buildAsset(a));
2291
+ for (const m of readBoardEquipment(def)) this.equipment.set(m.id, this.buildEquipment(m));
2292
+ }
2293
+ /*
2294
+ * ── 선언 하나를 상태 하나로 짓는 자리 ─────────────────────────────────────
2295
+ * 최초 적재(`loadTwinModel`)와 가동 중 교체(`adoptStructure`)가 **같은 구성을 쓴다.** 두 곳에서
2296
+ * 따로 지으면 곧 갈라지고, 갈라진 쪽으로 들어온 자원만 필드 하나가 비는 식으로 조용히 다르다.
2297
+ */
2298
+ buildLocation(n) {
2299
+ return { id: n.id, type: n.type, capacity: n.capacity, parallelism: n.parallelism, occupancy: 0, status: "idle", parentId: n.parentId };
2300
+ }
2301
+ buildPerson(p) {
2302
+ 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) };
2303
+ }
2304
+ buildAsset(a) {
2305
+ 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) };
2306
+ }
2307
+ /**
2308
+ * 이 커널이 **관측으로 구동된다**고 선언한다 — 미러가 첫 이벤트를 받기 전에도 그렇다.
2309
+ *
2310
+ * 지금까지는 첫 `apply()` 가 이 사실을 뒤늦게 정했다. 그래서 "이 커널의 진실이 어디서 오나" 가
2311
+ * **먼저 도착한 호출이 무엇이냐**에 달려 있었다 — 아직 아무 이벤트도 안 온 미러는 스스로를
2312
+ * 시뮬레이션으로 여긴다. 세우는 쪽이 아는 사실이므로 세울 때 말하게 한다.
2313
+ */
2314
+ observe() {
2315
+ this.observeMode = true;
2316
+ }
2317
+ /**
2318
+ * 돌면서 공장을 갈아탄다 — **현실이 안 멈추므로 미러도 멈출 수 없다.**
2319
+ *
2320
+ * ── 왜 관측 구동에만 여는가 ────────────────────────────────────────────────
2321
+ * 미러는 이미 있는 현실을 따라갈 뿐이라, 설비가 한 대 늘었다고 멈췄다 서는 것은 그 사이의
2322
+ * 사실을 잃는 것이다(라이브에서 유실은 곧 거짓이다). 반면 **시뮬레이션은 멈춰도 된다** —
2323
+ * 조건이 바뀐 실험은 다른 실험이고, 진행 중인 작업이 사라진 자리를 가리키게 두느니 새로
2324
+ * 세우는 것이 옳다. 그래서 여기서 거절하는 것은 한계가 아니라 **규칙**이다.
2325
+ *
2326
+ * ── 무엇을 지키고 무엇을 버리는가 ──────────────────────────────────────────
2327
+ * 판단은 관측층(`ObservedReducer.adoptStructure`)이 한다 — 관측은 지키고, 선언에서 사라진
2328
+ * 자원은 버리되 **몇 개를 버렸는지 돌려준다.** 여기서는 그 판단을 커널 자신의 선언 표에도
2329
+ * 그대로 옮긴다. 관측층만 갈면 사라진 설비가 커널 표에 남아 **화면이 없는 설비를 그린다.**
2330
+ *
2331
+ * 관측으로 알게 된 자리는 선언에 없어도 지운 뒤 다시 심는다 — 다음 스냅샷에서 관측층이
2332
+ * 되살리므로(`settleObserved` → `hydrateObserved`), 여기서 지켜야 할 것은 **선언분**뿐이다.
2333
+ */
2334
+ adoptStructure(def) {
2335
+ if (!this.observeMode)
2336
+ throw new Error(
2337
+ "adoptStructure is for observation-driven kernels (mirrors) \u2014 a simulation cannot swap its factory mid-run; stop and re-provision instead"
2338
+ );
2339
+ this.boardDef = def;
2340
+ if (def.productionSpec?.definition?.operations?.length) this.loadOperations(def.productionSpec.definition.operations);
2341
+ this.classDefs = { personnel: def.personnelClasses, equipment: def.equipmentClasses, asset: def.assetClasses, material: def.materialClasses };
2342
+ this.materialDefs = new Map((def.materialDefinitions ?? []).filter((d) => d?.id).map((d) => [d.id, d]));
2343
+ const sync = (map, declared, build) => {
2344
+ const ids = new Set(declared.map((d) => d.id));
2345
+ let dropped = 0;
2346
+ for (const id of [...map.keys()]) if (!ids.has(id)) {
2347
+ map.delete(id);
2348
+ dropped++;
2297
2349
  }
2298
- this.equipment.set(m.id, eq);
2350
+ for (const d of declared) if (!map.has(d.id)) map.set(d.id, build(d));
2351
+ return dropped;
2352
+ };
2353
+ const declaredLocations = readBoardLocations(def);
2354
+ const locationsBefore = this.locations.size;
2355
+ const locationsDropped = sync(this.locations, declaredLocations, (n) => this.buildLocation(n));
2356
+ const equipmentDropped = sync(this.equipment, readBoardEquipment(def), (m) => this.buildEquipment(m));
2357
+ const personsDropped = sync(this.persons, def.persons ?? [], (p) => this.buildPerson(p));
2358
+ const assetsDropped = sync(this.assets, readBoardAssets(def), (a) => this.buildAsset(a));
2359
+ const observed = this.observer?.adoptStructure(def);
2360
+ if (this.observer) this.observedDirty = true;
2361
+ this.revision++;
2362
+ return {
2363
+ locationsAdded: observed?.locationsAdded ?? Math.max(0, this.locations.size - locationsBefore + locationsDropped),
2364
+ locationsDropped: observed?.locationsDropped ?? locationsDropped,
2365
+ equipmentDropped,
2366
+ personsDropped,
2367
+ assetsDropped
2368
+ };
2369
+ }
2370
+ buildEquipment(m) {
2371
+ 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) };
2372
+ if (m.mtbfMs !== void 0) {
2373
+ eq.mtbfMs = m.mtbfMs;
2374
+ eq.mttrMs = m.mttrMs;
2375
+ eq.nextFailureMs = this.sampleExp(m.mtbfMs);
2299
2376
  }
2377
+ return eq;
2300
2378
  }
2301
2379
  /**
2302
- * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadBoard 설비 삽입과 동일 규약.
2380
+ * what-if 구성 변주 — fork(또는 실행 중) 엔진에 설비 추가. loadTwinModel 설비 삽입과 동일 규약.
2303
2381
  * 기본은 mtbf 미지정(고장 없는 신뢰 자원) → sampleExp(rng) 무소비라 baseline fork 와 깨끗이 비교 가능.
2304
2382
  */
2305
2383
  addEquipment(m) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
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": {