@operato/twin-kernel 0.7.48 → 0.7.50

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.
@@ -70,8 +70,14 @@ export interface TwinAxisInfo {
70
70
  * 이 칸이 없던 동안 소비처는 `id`·`key`·`gtin` 을 **짐작**했고, 그래서 화면이 실재하는 항목을
71
71
  * 「식별자 없음 — 참조할 수 없는 항목」으로 보였다. 값은 있는데 가리킬 수 없다고 말한 것이다.
72
72
  * 짐작을 없애고 선언이 답한다 — 축이 사는 자리(`path`)를 선언하는 것과 같은 이유다.
73
+ *
74
+ * ── 여럿을 받는다 (2026-08-22) ────────────────────────────────────────────
75
+ * 정체성이 **한 칸으로 정해지지 않는 축**이 있다. 물품이 그렇다: 직렬 물품은 `epc` 가 유일하지만,
76
+ * 비직렬 로트가 자리마다 나뉘면 개체를 구별하는 것은 `subLotId` 다(같은 로트의 두 부분은 같은 `epc`
77
+ * 를 갖는다 — §`ItemState.subLotId`). 계약이 그 규칙을 말할 수 없으면 소비처가 다시 짐작한다.
78
+ * 그래서 **차례**를 받는다: 앞에서 값이 있는 첫 칸이 이긴다.
73
79
  */
74
- idField?: string;
80
+ idField?: string | string[];
75
81
  /**
76
82
  * 이 축을 **어디서 읽나.**
77
83
  *
@@ -97,6 +97,27 @@ export const TWIN_AXES = [
97
97
  standardClass: { isa95: 'OperationsRequest', epcis: 'TransactionEvent' }, systems: LOGISTICS },
98
98
  { axis: 'tasks', label: 'twin.axis.tasks', kind: 'instance', source: 'state', historical: true,
99
99
  standardClass: { isa95: 'SegmentResponse', epcis: 'TransformationEvent' }, systems: LOGISTICS },
100
+ /*
101
+ * ── 물품이 축이 아니었다 (2026-08-22) ──────────────────────────────────────
102
+ * 트윈에서 **수가 가장 많은 것**이 물품인데(실측: 엔티티 3,611 중 2,400 · hatio-us 는 2,805) 그것을
103
+ * 가리켜 걸어 들어갈 자리가 없었다. 지도와 집약 태그에는 이미 보이는데 「모델 살펴보기」에는 문이
104
+ * 없었다 — 사용자가 트윈에 가장 자주 묻는 것이 「내 물건이 어디 있나」이므로 그것은 접근 장벽이다.
105
+ *
106
+ * 성격은 `orders`·`tasks` 와 같다: 상태에 살고, 저널에 이력이 있고, 원본이 낸 것을 트윈이 관측한다.
107
+ * 그래서 같은 조합(`instance` · `state` · `historical`)이다.
108
+ *
109
+ * ── 표준 대응 ─────────────────────────────────────────────────────────────
110
+ * ISA-95 는 `MaterialLot` 이다 — 물품은 「무슨 품목인가」(정의)가 아니라 「그 품목의 이 덩어리」이고,
111
+ * 위치·수량·부분(`MaterialSubLot`)을 그 자리가 든다. EPCIS 는 `ObjectEvent` 다: 개체가 생기고
112
+ * 관측되고 사라지는 것을 그 사건이 말한다.
113
+ *
114
+ * ── 무엇이 이 항목을 가리키나 ─────────────────────────────────────────────
115
+ * `subLotId ?? epc` 다. 짐작에 맡기면 `gtin` 으로 떨어지고, 그러면 **같은 품목의 물품 전부가 한
116
+ * 식별자로 뭉친다** — 화면이 2,400개를 몇 개로 보인다.
117
+ */
118
+ { axis: 'items', label: 'twin.axis.items', kind: 'instance', source: 'state', historical: true,
119
+ idField: ['subLotId', 'epc'],
120
+ standardClass: { isa95: 'MaterialLot', epcis: 'ObjectEvent' }, systems: LOGISTICS },
100
121
  /*
101
122
  * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
102
123
  *
@@ -163,6 +184,24 @@ export const TWIN_RELATIONS = [
163
184
  { from: 'tasks', field: 'toNode', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at', optional: true },
164
185
  { from: 'tasks', field: 'resourceRef', target: { kind: 'axis', axis: 'equipment' }, via: 'twin.rel.by', optional: true },
165
186
  { from: 'tasks', field: 'personnel[]', target: { kind: 'axis', axis: 'persons' }, via: 'twin.rel.crew', optional: true },
187
+ /*
188
+ * 물품의 관계 (2026-08-22) — 없으면 축이 **걸어 들어갈 수 없는 목록**이 된다.
189
+ *
190
+ * `location` 은 필수다 — 물품은 언제나 어딘가에 있다(그것이 물품의 뜻이다). 나머지는 선택이다:
191
+ * 물류단위에 담기지 않은 물품, 자산에 실리지 않은 팔레트가 정상이다.
192
+ *
193
+ * `parent` 는 **물품 축을 자기 자신으로** 가리킨다(팔레트에 담긴 상자 — EPCIS `AggregationEvent`).
194
+ * `carriedBy` 는 다른 축이다 — 반복사용 자산(GRAI)이 물류단위를 실어 나른다(§`FlowItem.carriedBy`).
195
+ *
196
+ * 관계 이름은 **소문자 한 낱말**이다(`twin.rel.<name>`) — 기존 열여덟 개가 그 규율이고 시험이 지킨다.
197
+ *
198
+ * 품목(`gtin`)은 오더와 **같은 규율**이다: 자재 키가 아니라 GS1 품목 참조이므로 축을 직접 가리키지
199
+ * 않고 `external` 로 둔다. 축을 가리키게 적으면 없는 필드를 가리키는 선언이 된다.
200
+ */
201
+ { from: 'items', field: 'location', target: { kind: 'axis', axis: 'locations' }, via: 'twin.rel.at' },
202
+ { from: 'items', field: 'parent', target: { kind: 'axis', axis: 'items' }, via: 'twin.rel.parent', optional: true },
203
+ { from: 'items', field: 'carriedBy', target: { kind: 'axis', axis: 'assets' }, via: 'twin.rel.asset', optional: true },
204
+ { from: 'items', field: 'gtin', target: { kind: 'external', entity: 'gs1.itemRef' }, via: 'twin.rel.item', optional: true },
166
205
  /*
167
206
  * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
168
207
  * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
@@ -1,4 +1,4 @@
1
- import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, StructureShift, IdentityGroundingView, IdentityDeclaration } from './contract.ts';
1
+ import type { TestResult, ISOTime, MaterialQuantity, WorkCalendarEntry, EffectivePeriod, Effectivity, OffCalendarReason, ResourceProperty, ResourceClassDef, MaterialDefinition, Attention, TwinModelDef, CanonicalEnvelope, Command, CommandAck, EventHandler, EquipmentMotion, OeeMetrics, AssetState, GeneratorSpec, InterventionOutcome, OrderState, PersonState, ScenarioControl, ScenarioOverride, StateSnapshot, TwinKernel, Unsubscribe, LocationState, ItemState, EquipmentState, OrderStatusDelta, TaskState, TaskStatus, StructureShift, IdentityGroundingView, IdentityDeclaration } 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';
@@ -130,7 +130,18 @@ export interface FlowEquipment extends EffectivePeriod {
130
130
  export interface FlowTask {
131
131
  id: string;
132
132
  kind: string;
133
- status: 'created' | 'in-progress' | 'completed';
133
+ /**
134
+ * **작업 상태 — 어휘는 하나다**(`TaskStatus`: created · assigned · in-progress · completed).
135
+ *
136
+ * 예전에는 여기만 셋이었다(`assigned` 없음). 시뮬레이션은 배정과 착수를 한 번에 하므로 그것으로
137
+ * 충분했지만, **미러는 원본이 말한 것을 그대로 들고 있어야 한다** — 그리고 주의 계산은 이미
138
+ * `assigned` 를 기다리고 있었다(§`collectAttentions`: `status === 'created' || status === 'assigned'`).
139
+ * 즉 그 자리는 영원히 오지 않는 값을 기다렸다.
140
+ *
141
+ * 시뮬 경로의 거동은 바뀌지 않는다: 시뮬은 `assigned` 를 만들지 않고, 배정 루프는 `created` 만
142
+ * 집는다(§`assign`). 넓힌 것은 **관측이 말한 사실을 담을 자리**다.
143
+ */
144
+ status: TaskStatus;
134
145
  /**
135
146
  * **실제로 들어가고 나온 자재** — ISA-95 `JobResponse.MaterialActual`.
136
147
  *
@@ -700,7 +711,26 @@ export declare abstract class FlowEngine implements TwinKernel {
700
711
  id: string;
701
712
  since: string;
702
713
  }[];
703
- }, orders?: OrderStatusDelta[]): void;
714
+ }, orders?: OrderStatusDelta[],
715
+ /**
716
+ * **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
717
+ *
718
+ * ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
719
+ * 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
720
+ * 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
721
+ * 가리킨다.
722
+ *
723
+ * 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
724
+ * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
725
+ * 사실이 씨앗 규칙에 걸려 사라졌다.
726
+ *
727
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
728
+ * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
729
+ * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
730
+ *
731
+ * 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
732
+ */
733
+ purpose?: 'seed' | 'observe'): void;
704
734
  /**
705
735
  * what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
706
736
  *
@@ -388,7 +388,20 @@ export class ItemStore {
388
388
  clone() {
389
389
  const out = new ItemStore();
390
390
  for (const [k, it] of this.map)
391
- out.set(k, structuredClone(it));
391
+ out.map.set(k, structuredClone(it));
392
+ /*
393
+ * ── 색인은 **베껴 온다** (2026-08-22) ─────────────────────────────────────
394
+ * 예전에는 항목마다 `set()` 을 불러 색인을 다시 쌓았다. 색인이 하나일 때는 그 비용이 묻혔는데,
395
+ * 품목 색인이 생기면서 항목마다 Map 조회 둘 + Set 삽입 둘이 됐다 — 그리고 `fork()` 가 이 함수를
396
+ * 쓴다. 예측은 회차마다 사본을 뜨므로 그 비용이 곧바로 예측 비용이다.
397
+ *
398
+ * 사본은 **원본과 같은 배치**이므로 색인을 다시 계산할 이유가 없다. 그룹마다 Set 하나를 만들어
399
+ * 베낀다. 어긋날 위험은 `indexDrift()` 가 지킨다(시나리오를 돌린 뒤 그 값으로 확인한다).
400
+ */
401
+ for (const [loc, keys] of this.byLocation)
402
+ out.byLocation.set(loc, new Set(keys));
403
+ for (const [g, keys] of this.byGtin)
404
+ out.byGtin.set(g, new Set(keys));
392
405
  return out;
393
406
  }
394
407
  /*
@@ -819,7 +832,26 @@ export class FlowEngine {
819
832
  this.seedDanglingRefs += dropped;
820
833
  return { kept, dropped };
821
834
  }
822
- hydrateObserved(snap, orders = []) {
835
+ hydrateObserved(snap, orders = [],
836
+ /**
837
+ * **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
838
+ *
839
+ * ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
840
+ * 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
841
+ * 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
842
+ * 가리킨다.
843
+ *
844
+ * 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
845
+ * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
846
+ * 사실이 씨앗 규칙에 걸려 사라졌다.
847
+ *
848
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
849
+ * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
850
+ * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
851
+ *
852
+ * 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
853
+ */
854
+ purpose = 'seed') {
823
855
  /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
824
856
  * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
825
857
  for (const id of snap.acked ?? [])
@@ -869,7 +901,15 @@ export class FlowEngine {
869
901
  const prev = this.equipment.get(m.id);
870
902
  this.equipment.set(m.id, {
871
903
  ...(prev ?? {}),
872
- id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: null,
904
+ /*
905
+ * `taskId` — **관측이면 원본이 말한 값이다.**
906
+ *
907
+ * 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
908
+ * 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
909
+ * 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
910
+ * 오게 되고 한쪽만 도착한 순간 어긋난다.
911
+ */
912
+ id: m.id, kind: m.kind, location: m.location ?? '', ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status ?? 'idle', taskId: purpose === 'observe' ? (m.taskId ?? null) : null,
873
913
  runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
874
914
  goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
875
915
  /*
@@ -902,7 +942,10 @@ export class FlowEngine {
902
942
  const prev = this.persons.get(p.id);
903
943
  this.persons.set(p.id, {
904
944
  ...(prev ?? {}),
905
- id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds, status: 'idle', taskId: null,
945
+ /* 설비와 같은 규율 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
946
+ id: p.id, personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
947
+ status: purpose === 'observe' ? (p.status ?? 'idle') : 'idle',
948
+ taskId: purpose === 'observe' ? (p.taskId ?? null) : null,
906
949
  ...(p.location ? { location: p.location } : {}),
907
950
  ...(p.properties ? { properties: p.properties } : {}),
908
951
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
@@ -916,7 +959,10 @@ export class FlowEngine {
916
959
  const prev = this.assets.get(a.id);
917
960
  this.assets.set(a.id, {
918
961
  ...(prev ?? {}),
919
- id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying,
962
+ /* 설비·사람과 같은 규율 관측이면 원본이 말한 상태·묶임을 지킨다. */
963
+ id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location,
964
+ status: purpose === 'observe' ? (a.status ?? 'idle') : 'idle',
965
+ taskId: purpose === 'observe' ? (a.taskId ?? null) : null, carrying: a.carrying,
920
966
  ...(a.properties ? { properties: a.properties } : {}),
921
967
  ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
922
968
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
@@ -1009,38 +1055,83 @@ export class FlowEngine {
1009
1055
  }
1010
1056
  /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
1011
1057
  const seededOrderIds = new Set(this.orders.keys());
1012
- let orphaned = 0;
1058
+ /*
1059
+ * **떨어뜨린 이유를 따로 센다** — 예전에는 하나로 세고 「whose item is no longer in state」라고
1060
+ * 말했다. 그런데 오더에 걸린 경우도 같은 문장을 썼고, 그것을 본 사람이 물품 쪽을 반나절 뒤졌다.
1061
+ * 원인을 지어내는 문장은 없는 것보다 나쁘다.
1062
+ */
1063
+ let droppedNoItem = 0;
1064
+ let droppedNoOrder = 0;
1065
+ let droppedCompleted = 0;
1066
+ /*
1067
+ * **씨앗이냐 관측 정착이냐** — 아래 셋을 거를지 말지가 여기서 갈린다(§`purpose`).
1068
+ *
1069
+ * 씨앗(seed) 굴릴 수 없는 작업은 뺀다. 심으면 첫 틱에서 없는 자리를 가리킨다.
1070
+ * 관측(observe) **거르지 않는다.** 미러는 그 작업을 굴리지 않고, 들은 것을 말한다.
1071
+ */
1072
+ const filtering = purpose === 'seed';
1013
1073
  for (const t of snap.tasks ?? []) {
1014
- if (t.status === 'completed')
1074
+ if (filtering && t.status === 'completed') {
1075
+ droppedCompleted++;
1015
1076
  continue;
1077
+ }
1016
1078
  const ref = t.itemRefs?.[0];
1017
- if (ref && !this.itemByRef(ref)) {
1018
- orphaned++;
1079
+ if (filtering && ref && !this.itemByRef(ref)) {
1080
+ droppedNoItem++;
1019
1081
  continue;
1020
1082
  }
1021
1083
  /* 오더도 같다 — 이미 이행된 오더는 주입하지 않으므로(위 `remaining <= 0`), 그 오더에 딸린 작업만
1022
1084
  남으면 완료 시점에 없는 오더를 딛는다. 주입 단계에서 함께 뺀다. */
1023
- if (t.orderId && !seededOrderIds.has(t.orderId)) {
1024
- orphaned++;
1085
+ if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
1086
+ droppedNoOrder++;
1025
1087
  continue;
1026
1088
  }
1027
1089
  const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
1028
1090
  this.tasks.set(t.id, {
1029
1091
  id: t.id, kind: t.kind,
1030
- status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
1092
+ /*
1093
+ * **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
1094
+ * 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
1095
+ *
1096
+ * 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
1097
+ * `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
1098
+ * 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
1099
+ */
1100
+ status: purpose === 'observe' ? t.status : known && t.status === 'in-progress' ? 'in-progress' : 'created',
1031
1101
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
1032
1102
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
1033
1103
  itemEpc: t.itemRefs?.[0] ?? '',
1034
1104
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
1035
- resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
1105
+ /*
1106
+ * 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
1107
+ * (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
1108
+ * 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
1109
+ */
1110
+ resource: purpose === 'observe' ? (t.resourceRef ?? null) : known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
1036
1111
  remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
1037
1112
  startedAtSimMs: t.startedAtSimMs,
1038
1113
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1039
1114
  orderId: t.orderId,
1040
1115
  intent: t.intent
1041
1116
  });
1042
- /* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
1043
- if (known && t.status === 'in-progress' && t.resourceRef) {
1117
+ /*
1118
+ * 투입 인원은 **작업이 들고 있는 사실**이므로 관측에서도 그대로 옮긴다(위 `set` 에서 빠져 있어
1119
+ * 아래에서 채운다 — 씨앗 시절 진행 중인 작업에만 필요했던 자리다).
1120
+ */
1121
+ if (purpose === 'observe' && t.personnel?.length) {
1122
+ const restored = this.tasks.get(t.id);
1123
+ if (restored)
1124
+ restored.personnel = [...t.personnel];
1125
+ }
1126
+ /*
1127
+ * **자원의 상태는 여기서 정하지 않는다 — 관측일 때는.**
1128
+ *
1129
+ * 씨앗에서는 필요하다: 진행 중인 작업을 심었으면 그 설비가 다른 일을 또 받으면 안 되므로 점유를
1130
+ * 표시한다. 그런데 미러에서는 설비·사람의 상태가 **자기 관측 축**(`equipment.status`,
1131
+ * `person.status`)에서 온다. 작업 하나로 그것을 덮으면 원본이 「유휴」라고 말한 설비를 트윈이
1132
+ * 「바쁘다」고 말한다 — 축이 둘이 되고, 어느 쪽이 사실인지 알 수 없게 된다.
1133
+ */
1134
+ if (purpose === 'seed' && known && t.status === 'in-progress' && t.resourceRef) {
1044
1135
  const mv = this.equipment.get(t.resourceRef);
1045
1136
  if (mv) {
1046
1137
  mv.status = 'busy';
@@ -1048,7 +1139,7 @@ export class FlowEngine {
1048
1139
  }
1049
1140
  }
1050
1141
  /* 사람도 같다 — 진행 중이던 작업에 투입돼 있던 사람은 여전히 점유되어 있어야 한다. */
1051
- if (known && t.status === 'in-progress') {
1142
+ if (purpose === 'seed' && known && t.status === 'in-progress') {
1052
1143
  const restored = this.tasks.get(t.id);
1053
1144
  if (restored)
1054
1145
  restored.personnel = t.personnel ? [...t.personnel] : undefined;
@@ -1061,9 +1152,21 @@ export class FlowEngine {
1061
1152
  }
1062
1153
  }
1063
1154
  }
1064
- if (orphaned) {
1065
- console.warn(`[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state — ` +
1066
- 'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
1155
+ /*
1156
+ * **무엇을 뺐는지 그대로 말한다.** 씨앗에서만 나온다(관측은 빼지 않는다).
1157
+ *
1158
+ * 예전 문장은 셋을 하나로 뭉쳐 「whose item is no longer in state」라고 했다. 오더에 걸린 것을
1159
+ * 물품 탓으로 말했고, 그 문장을 읽은 사람이 물품 쪽을 뒤졌다. 원인을 지어내는 문장은 침묵보다 나쁘다.
1160
+ */
1161
+ const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
1162
+ if (droppedTotal) {
1163
+ const why = [
1164
+ droppedNoItem ? `${droppedNoItem} whose item is not in state` : '',
1165
+ droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : '',
1166
+ droppedCompleted ? `${droppedCompleted} already completed` : ''
1167
+ ].filter(Boolean).join(' · ');
1168
+ console.warn(`[twin-kernel] seed skipped ${droppedTotal} task(s) — ${why}. ` +
1169
+ 'They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(…, …, "observe").)');
1067
1170
  }
1068
1171
  }
1069
1172
  /**
@@ -1710,7 +1813,14 @@ export class FlowEngine {
1710
1813
  if (!this.observedDirty || !this.observer)
1711
1814
  return;
1712
1815
  this.observedDirty = false;
1713
- this.hydrateObserved(this.observer.snapshot());
1816
+ /*
1817
+ * **목적을 밝힌다 — 이것은 씨앗이 아니라 관측 정착이다**(§`hydrateObserved` 의 `purpose`).
1818
+ *
1819
+ * 이 한 낱말이 없던 동안 미러의 작업이 씨앗 규칙에 걸려 사라졌다: 포천 미러의 저널에
1820
+ * `task.status` 5,786건이 있는데 `getSnapshot().tasks` 는 0 이었고, 화면은 「아직 하나도
1821
+ * 없습니다」라고 말했다. 미러는 그 작업을 굴리지 않는다 — 들은 것을 말할 뿐이다.
1822
+ */
1823
+ this.hydrateObserved(this.observer.snapshot(), [], 'observe');
1714
1824
  }
1715
1825
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
1716
1826
  observedHandlers() {
@@ -2549,6 +2549,34 @@ var TWIN_AXES = [
2549
2549
  standardClass: { isa95: "SegmentResponse", epcis: "TransformationEvent" },
2550
2550
  systems: LOGISTICS
2551
2551
  },
2552
+ /*
2553
+ * ── 물품이 축이 아니었다 (2026-08-22) ──────────────────────────────────────
2554
+ * 트윈에서 **수가 가장 많은 것**이 물품인데(실측: 엔티티 3,611 중 2,400 · hatio-us 는 2,805) 그것을
2555
+ * 가리켜 걸어 들어갈 자리가 없었다. 지도와 집약 태그에는 이미 보이는데 「모델 살펴보기」에는 문이
2556
+ * 없었다 — 사용자가 트윈에 가장 자주 묻는 것이 「내 물건이 어디 있나」이므로 그것은 접근 장벽이다.
2557
+ *
2558
+ * 성격은 `orders`·`tasks` 와 같다: 상태에 살고, 저널에 이력이 있고, 원본이 낸 것을 트윈이 관측한다.
2559
+ * 그래서 같은 조합(`instance` · `state` · `historical`)이다.
2560
+ *
2561
+ * ── 표준 대응 ─────────────────────────────────────────────────────────────
2562
+ * ISA-95 는 `MaterialLot` 이다 — 물품은 「무슨 품목인가」(정의)가 아니라 「그 품목의 이 덩어리」이고,
2563
+ * 위치·수량·부분(`MaterialSubLot`)을 그 자리가 든다. EPCIS 는 `ObjectEvent` 다: 개체가 생기고
2564
+ * 관측되고 사라지는 것을 그 사건이 말한다.
2565
+ *
2566
+ * ── 무엇이 이 항목을 가리키나 ─────────────────────────────────────────────
2567
+ * `subLotId ?? epc` 다. 짐작에 맡기면 `gtin` 으로 떨어지고, 그러면 **같은 품목의 물품 전부가 한
2568
+ * 식별자로 뭉친다** — 화면이 2,400개를 몇 개로 보인다.
2569
+ */
2570
+ {
2571
+ axis: "items",
2572
+ label: "twin.axis.items",
2573
+ kind: "instance",
2574
+ source: "state",
2575
+ historical: true,
2576
+ idField: ["subLotId", "epc"],
2577
+ standardClass: { isa95: "MaterialLot", epcis: "ObjectEvent" },
2578
+ systems: LOGISTICS
2579
+ },
2552
2580
  /*
2553
2581
  * ── 에너지가 더하는 개념은 **하나**다 (2026-08-14, §10 6.5단계) ──────────────
2554
2582
  *
@@ -2623,6 +2651,24 @@ var TWIN_RELATIONS = [
2623
2651
  { from: "tasks", field: "toNode", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at", optional: true },
2624
2652
  { from: "tasks", field: "resourceRef", target: { kind: "axis", axis: "equipment" }, via: "twin.rel.by", optional: true },
2625
2653
  { from: "tasks", field: "personnel[]", target: { kind: "axis", axis: "persons" }, via: "twin.rel.crew", optional: true },
2654
+ /*
2655
+ * 물품의 관계 (2026-08-22) — 없으면 축이 **걸어 들어갈 수 없는 목록**이 된다.
2656
+ *
2657
+ * `location` 은 필수다 — 물품은 언제나 어딘가에 있다(그것이 물품의 뜻이다). 나머지는 선택이다:
2658
+ * 물류단위에 담기지 않은 물품, 자산에 실리지 않은 팔레트가 정상이다.
2659
+ *
2660
+ * `parent` 는 **물품 축을 자기 자신으로** 가리킨다(팔레트에 담긴 상자 — EPCIS `AggregationEvent`).
2661
+ * `carriedBy` 는 다른 축이다 — 반복사용 자산(GRAI)이 물류단위를 실어 나른다(§`FlowItem.carriedBy`).
2662
+ *
2663
+ * 관계 이름은 **소문자 한 낱말**이다(`twin.rel.<name>`) — 기존 열여덟 개가 그 규율이고 시험이 지킨다.
2664
+ *
2665
+ * 품목(`gtin`)은 오더와 **같은 규율**이다: 자재 키가 아니라 GS1 품목 참조이므로 축을 직접 가리키지
2666
+ * 않고 `external` 로 둔다. 축을 가리키게 적으면 없는 필드를 가리키는 선언이 된다.
2667
+ */
2668
+ { from: "items", field: "location", target: { kind: "axis", axis: "locations" }, via: "twin.rel.at" },
2669
+ { from: "items", field: "parent", target: { kind: "axis", axis: "items" }, via: "twin.rel.parent", optional: true },
2670
+ { from: "items", field: "carriedBy", target: { kind: "axis", axis: "assets" }, via: "twin.rel.asset", optional: true },
2671
+ { from: "items", field: "gtin", target: { kind: "external", entity: "gs1.itemRef" }, via: "twin.rel.item", optional: true },
2626
2672
  /*
2627
2673
  * 자격을 검증한 시험 — **여덟 갈래.** 자원(개체)과 등급 양쪽이 가리킨다: 표준이 그 둘 모두에 이
2628
2674
  * 참조를 두었기 때문이다(개체는 "이 사람이 통과했다", 등급은 "이 자격은 이 시험을 요구한다").
@@ -3515,7 +3561,9 @@ var ItemStore = class _ItemStore {
3515
3561
  */
3516
3562
  clone() {
3517
3563
  const out = new _ItemStore();
3518
- for (const [k, it] of this.map) out.set(k, structuredClone(it));
3564
+ for (const [k, it] of this.map) out.map.set(k, structuredClone(it));
3565
+ for (const [loc, keys] of this.byLocation) out.byLocation.set(loc, new Set(keys));
3566
+ for (const [g, keys] of this.byGtin) out.byGtin.set(g, new Set(keys));
3519
3567
  return out;
3520
3568
  }
3521
3569
  /*
@@ -3916,7 +3964,7 @@ var FlowEngine = class {
3916
3964
  this.seedDanglingRefs += dropped;
3917
3965
  return { kept, dropped };
3918
3966
  }
3919
- hydrateObserved(snap, orders = []) {
3967
+ hydrateObserved(snap, orders = [], purpose = "seed") {
3920
3968
  for (const id of snap.acked ?? []) this._acked.add(id);
3921
3969
  for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3922
3970
  for (const n of snap.locations) {
@@ -3946,12 +3994,20 @@ var FlowEngine = class {
3946
3994
  const prev = this.equipment.get(m.id);
3947
3995
  this.equipment.set(m.id, {
3948
3996
  ...prev ?? {},
3997
+ /*
3998
+ * `taskId` — **관측이면 원본이 말한 값이다.**
3999
+ *
4000
+ * 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
4001
+ * 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
4002
+ * 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
4003
+ * 오게 되고 한쪽만 도착한 순간 어긋난다.
4004
+ */
3949
4005
  id: m.id,
3950
4006
  kind: m.kind,
3951
4007
  location: m.location ?? "",
3952
4008
  ...m.homeLocation ? { homeLocation: m.homeLocation } : {},
3953
4009
  status: m.status ?? "idle",
3954
- taskId: null,
4010
+ taskId: purpose === "observe" ? m.taskId ?? null : null,
3955
4011
  runMs: oee?.runMs ?? 0,
3956
4012
  setupMs: oee?.setupMs ?? 0,
3957
4013
  downMs: oee?.downMs ?? 0,
@@ -3982,10 +4038,11 @@ var FlowEngine = class {
3982
4038
  const prev = this.persons.get(p.id);
3983
4039
  this.persons.set(p.id, {
3984
4040
  ...prev ?? {},
4041
+ /* 설비와 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
3985
4042
  id: p.id,
3986
4043
  personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
3987
- status: "idle",
3988
- taskId: null,
4044
+ status: purpose === "observe" ? p.status ?? "idle" : "idle",
4045
+ taskId: purpose === "observe" ? p.taskId ?? null : null,
3989
4046
  ...p.location ? { location: p.location } : {},
3990
4047
  ...p.properties ? { properties: p.properties } : {},
3991
4048
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
@@ -3998,11 +4055,12 @@ var FlowEngine = class {
3998
4055
  const prev = this.assets.get(a.id);
3999
4056
  this.assets.set(a.id, {
4000
4057
  ...prev ?? {},
4058
+ /* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
4001
4059
  id: a.id,
4002
4060
  assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
4003
4061
  location: a.location,
4004
- status: "idle",
4005
- taskId: null,
4062
+ status: purpose === "observe" ? a.status ?? "idle" : "idle",
4063
+ taskId: purpose === "observe" ? a.taskId ?? null : null,
4006
4064
  carrying: a.carrying,
4007
4065
  ...a.properties ? { properties: a.properties } : {},
4008
4066
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
@@ -4071,43 +4129,66 @@ var FlowEngine = class {
4071
4129
  });
4072
4130
  }
4073
4131
  const seededOrderIds = new Set(this.orders.keys());
4074
- let orphaned = 0;
4132
+ let droppedNoItem = 0;
4133
+ let droppedNoOrder = 0;
4134
+ let droppedCompleted = 0;
4135
+ const filtering = purpose === "seed";
4075
4136
  for (const t of snap.tasks ?? []) {
4076
- if (t.status === "completed") continue;
4137
+ if (filtering && t.status === "completed") {
4138
+ droppedCompleted++;
4139
+ continue;
4140
+ }
4077
4141
  const ref = t.itemRefs?.[0];
4078
- if (ref && !this.itemByRef(ref)) {
4079
- orphaned++;
4142
+ if (filtering && ref && !this.itemByRef(ref)) {
4143
+ droppedNoItem++;
4080
4144
  continue;
4081
4145
  }
4082
- if (t.orderId && !seededOrderIds.has(t.orderId)) {
4083
- orphaned++;
4146
+ if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
4147
+ droppedNoOrder++;
4084
4148
  continue;
4085
4149
  }
4086
4150
  const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
4087
4151
  this.tasks.set(t.id, {
4088
4152
  id: t.id,
4089
4153
  kind: t.kind,
4090
- status: known && t.status === "in-progress" ? "in-progress" : "created",
4154
+ /*
4155
+ * **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
4156
+ * 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
4157
+ *
4158
+ * 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
4159
+ * `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
4160
+ * 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
4161
+ */
4162
+ status: purpose === "observe" ? t.status : known && t.status === "in-progress" ? "in-progress" : "created",
4091
4163
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
4092
4164
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
4093
4165
  itemEpc: t.itemRefs?.[0] ?? "",
4094
4166
  fromNode: t.fromNode ?? "",
4095
4167
  toNode: t.toNode ?? "",
4096
- resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
4168
+ /*
4169
+ * 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
4170
+ * (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
4171
+ * 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
4172
+ */
4173
+ resource: purpose === "observe" ? t.resourceRef ?? null : known && t.status === "in-progress" ? t.resourceRef ?? null : null,
4097
4174
  remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
4098
4175
  startedAtSimMs: t.startedAtSimMs,
4099
4176
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
4100
4177
  orderId: t.orderId,
4101
4178
  intent: t.intent
4102
4179
  });
4103
- if (known && t.status === "in-progress" && t.resourceRef) {
4180
+ if (purpose === "observe" && t.personnel?.length) {
4181
+ const restored = this.tasks.get(t.id);
4182
+ if (restored) restored.personnel = [...t.personnel];
4183
+ }
4184
+ if (purpose === "seed" && known && t.status === "in-progress" && t.resourceRef) {
4104
4185
  const mv = this.equipment.get(t.resourceRef);
4105
4186
  if (mv) {
4106
4187
  mv.status = "busy";
4107
4188
  mv.taskId = t.id;
4108
4189
  }
4109
4190
  }
4110
- if (known && t.status === "in-progress") {
4191
+ if (purpose === "seed" && known && t.status === "in-progress") {
4111
4192
  const restored = this.tasks.get(t.id);
4112
4193
  if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
4113
4194
  for (const id of t.personnel ?? []) {
@@ -4119,9 +4200,15 @@ var FlowEngine = class {
4119
4200
  }
4120
4201
  }
4121
4202
  }
4122
- if (orphaned) {
4203
+ const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
4204
+ if (droppedTotal) {
4205
+ const why = [
4206
+ droppedNoItem ? `${droppedNoItem} whose item is not in state` : "",
4207
+ droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : "",
4208
+ droppedCompleted ? `${droppedCompleted} already completed` : ""
4209
+ ].filter(Boolean).join(" \xB7 ");
4123
4210
  console.warn(
4124
- `[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state \u2014 they cannot be continued (the item was consumed/shipped, or the observation did not carry it).`
4211
+ `[twin-kernel] seed skipped ${droppedTotal} task(s) \u2014 ${why}. They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(\u2026, \u2026, "observe").)`
4125
4212
  );
4126
4213
  }
4127
4214
  }
@@ -4704,7 +4791,7 @@ var FlowEngine = class {
4704
4791
  settleObserved() {
4705
4792
  if (!this.observedDirty || !this.observer) return;
4706
4793
  this.observedDirty = false;
4707
- this.hydrateObserved(this.observer.snapshot());
4794
+ this.hydrateObserved(this.observer.snapshot(), [], "observe");
4708
4795
  }
4709
4796
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
4710
4797
  observedHandlers() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.48",
3
+ "version": "0.7.50",
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": {