@operato/twin-kernel 0.7.49 → 0.7.51

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/contract.js CHANGED
@@ -1,6 +1,36 @@
1
1
  /*
2
2
  * Face 1 — 3채널 계약 (walking skeleton 범위).
3
3
  * 설계 SoT: operato-twin/design/integration/face1-contract.md
4
+ *
5
+ * ── 이 계약을 넓히려는 사람이 먼저 읽을 것 (2026-08-23) ──────────────────────
6
+ *
7
+ * 사실은 두 종류이고, 종류가 그 자리를 정한다.
8
+ *
9
+ * **파생** — 시각만으로 넘어가는 판정. 근무 밖 · 교대 · 능력 · 자리 상태 · 납기 대비 · 진척.
10
+ * · **저장하지 않는다.** 저장하면 장부가 둘이 되고, 시각이 지나도 옛 값이 남는다.
11
+ * · **입력으로 받지 않는다.** 원본이 단정하면 답이 둘이 되고 커널이 어느 쪽을 믿을지 정해야 한다.
12
+ * · 계산할 수 없으면 **답하지 않는다.** 모르는 값을 숫자로 만들면 화면이 그것을 사실로 그린다
13
+ * (`dueStatusOf` 가 그 규율을 먼저 적었다: 납기가 없으면 「늦지 않았다」가 아니라 「판단할 수 없다」).
14
+ * · 파생이 **불가능할 때만** 원본의 단정을 쓴다. 그 순서가 선언돼 있어야 한다(`offShift`·`progress`).
15
+ *
16
+ * **누적** — 열린 구간에 쌓이는 값. 가동·준비·고장 시간 · 양품/불량 수 · 조건이 성립한 시각.
17
+ * · **다시 계산할 수 없다.** 원천은 「이번 창에서 지금까지 얼마」를 모른다.
18
+ * · 그래서 **재기동을 넘어 이어받는다.** 잃으면 오류 없이 값이 작아진다 — 가장 비싼 종류의 침묵이다.
19
+ *
20
+ * 그리고 **자리를 늘리는 조건은 하나다: 전이로 표현할 수 없는 사실.**
21
+ *
22
+ * 「자리가 없다」와 「담을 수 없다」는 다르다. 전자는 대개 이미 있는 축을 못 찾은 것이다 — 작업의
23
+ * 실제 착수·완료·대기는 **전이의 `eventTime`** 이 말하고(`task-fold`), 대기는 그것으로 계산된다
24
+ * (`kpi-fold`: `waitMs = startedMs − createdMs`). 원본이 전이를 놓쳐 보내면 **놓친 전이를 그 시각으로
25
+ * 함께 내는 것**이 답이고, 속성을 새로 여는 것이 아니다.
26
+ *
27
+ * 그러므로 원본을 붙이는 사람이 물어야 하는 것은 「이 컬럼을 실을 자리가 있나」가 아니라
28
+ * **「원본이 말하는 이 사실을 커널의 어휘로 어떻게 옮기나」**다. 컬럼 단위로 물으면 원본이 열이면
29
+ * 축도 열이 된다 — 2026-08-23 에 축 다섯을 열었다가 그 이유로 되돌렸다.
30
+ *
31
+ * 열지 않기로 한 것과 그 조건은 `design/04-decisions.md` ADR-0039 에 있다. 넓히기 전에 그 표를 본다.
32
+ * 지키는 하네스: `derived-not-input` · `actuals-come-from-transitions` · `accumulators-survive-restart` ·
33
+ * `purpose-conformance`.
4
34
  */
5
35
  /**
6
36
  * 자리의 상태 — **포화도에서 파생한다.** 저장하는 값이 아니다.
@@ -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';
@@ -78,6 +78,15 @@ export interface FlowPerson extends EffectivePeriod {
78
78
  };
79
79
  /** 근무 캘린더 — 표준 `WorkCalendarEntry`(복수). 있으면 `window` 보다 이것을 본다. */
80
80
  workCalendar?: WorkCalendarEntry[];
81
+ /**
82
+ * **원본이 말한 근무 밖 표시** — 캘린더가 없을 때만 쓰는 값이다.
83
+ *
84
+ * 캘린더가 있으면 시각으로 판정한다(`offCalendar`) — 저장값을 함께 보면 교대 안으로 돌아왔을 때 옛
85
+ * `true` 가 남는다. 그런데 근무 캘린더를 주지 않는 원본이 실 시스템에 흔하고, 그때 이것이 **유일한
86
+ * 답**이다. 리듀서가 이미 그 규율로 이 값을 지키고 있었는데(§`observed-reducer` offShift) 커널 상태에
87
+ * 자리가 없어 옮기는 자리에서 잃고 있었다.
88
+ */
89
+ offShift?: boolean;
81
90
  /** 지금 어디에 있나 — 표준 `Person.OperationalLocation`. 커널은 사람을 움직이지 않는다(마스터·관측이 말한다). */
82
91
  location?: string;
83
92
  /** 자원 속성 — 표준 `PersonProperty`. */
@@ -130,7 +139,18 @@ export interface FlowEquipment extends EffectivePeriod {
130
139
  export interface FlowTask {
131
140
  id: string;
132
141
  kind: string;
133
- status: 'created' | 'in-progress' | 'completed';
142
+ /**
143
+ * **작업 상태 — 어휘는 하나다**(`TaskStatus`: created · assigned · in-progress · completed).
144
+ *
145
+ * 예전에는 여기만 셋이었다(`assigned` 없음). 시뮬레이션은 배정과 착수를 한 번에 하므로 그것으로
146
+ * 충분했지만, **미러는 원본이 말한 것을 그대로 들고 있어야 한다** — 그리고 주의 계산은 이미
147
+ * `assigned` 를 기다리고 있었다(§`collectAttentions`: `status === 'created' || status === 'assigned'`).
148
+ * 즉 그 자리는 영원히 오지 않는 값을 기다렸다.
149
+ *
150
+ * 시뮬 경로의 거동은 바뀌지 않는다: 시뮬은 `assigned` 를 만들지 않고, 배정 루프는 `created` 만
151
+ * 집는다(§`assign`). 넓힌 것은 **관측이 말한 사실을 담을 자리**다.
152
+ */
153
+ status: TaskStatus;
134
154
  /**
135
155
  * **실제로 들어가고 나온 자재** — ISA-95 `JobResponse.MaterialActual`.
136
156
  *
@@ -154,6 +174,15 @@ export interface FlowTask {
154
174
  /** 예정 창 — 표준 `JobOrder.StartTime`/`EndTime`. 실제 착수와 다른 축. */
155
175
  startTime?: string;
156
176
  endTime?: string;
177
+ /**
178
+ * **원본이 말한 진척** — 커널이 계산할 수 없을 때만 쓰는 값이다(§`progressOf`).
179
+ *
180
+ * 파생이 가능하면(소요를 알면) 파생이 이긴다. 이 자리는 파생을 대신하는 것이 아니라 **파생이
181
+ * 불가능할 때 침묵하지 않기 위한 것**이다 — 사람 교대(`offShift`)가 세워 둔 규율과 같다.
182
+ *
183
+ * 관측에서만 채운다. 시뮬은 자기 소요를 알므로 이 값을 쓸 일이 없다.
184
+ */
185
+ progress?: number;
157
186
  /** 이 작업에 투입된 사람들 — 설비와 별개 축(설비 1대 + 작업자 2명이 동시에 잡힌다). */
158
187
  personnel?: string[];
159
188
  /** 이 작업에 투입된 물리 자산(팔레트 등). */
@@ -700,7 +729,26 @@ export declare abstract class FlowEngine implements TwinKernel {
700
729
  id: string;
701
730
  since: string;
702
731
  }[];
703
- }, orders?: OrderStatusDelta[]): void;
732
+ }, orders?: OrderStatusDelta[],
733
+ /**
734
+ * **무엇을 하러 심나** — 기본은 씨앗이다(외부 호출자 전부가 그것이다).
735
+ *
736
+ * ── 왜 이 구별이 생겼나 (2026-08-22 실측) ─────────────────────────────────
737
+ * 이 함수는 원래 **스냅샷으로 커널을 세우는 문**이다(웜스타트·fork·예측). 그 목적에서는 딛고 설
738
+ * 물품·오더가 없는 작업을 빼는 것이 옳다 — 굴릴 수 없는 작업을 심으면 첫 틱에서 없는 자리를
739
+ * 가리킨다.
740
+ *
741
+ * 그런데 `settleObserved()` 가 **미러 자신의 관측을 옮길 때도** 같은 함수를 쓴다. 목적이 다르다:
742
+ * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
743
+ * 사실이 씨앗 규칙에 걸려 사라졌다.
744
+ *
745
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
746
+ * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
747
+ * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
748
+ *
749
+ * 그래서 규칙을 둘로 쪼개지 않고 **목적을 밝힌다.** 같은 함수, 같은 주입 경로, 다른 판정 하나다.
750
+ */
751
+ purpose?: 'seed' | 'observe'): void;
704
752
  /**
705
753
  * what-if 구성 변주 — **선언을 덮어쓴다**(fork 대상). 바꿨으면 true.
706
754
  *
@@ -1529,7 +1577,25 @@ export declare abstract class FlowEngine implements TwinKernel {
1529
1577
  * 로트는 LGTIN 이면 식별자 안에 있고, 직렬 개체는 마스터데이터(`ilmd`)에 실려 온다.
1530
1578
  */
1531
1579
  protected itemState(i: FlowItem): ItemState;
1532
- protected progressOf(t: FlowTask): number;
1580
+ /**
1581
+ * 진척 — **모르면 답하지 않는다.**
1582
+ *
1583
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
1584
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
1585
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
1586
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
1587
+ *
1588
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
1589
+ * 것이고, 화면은 그것을 사실로 그린다.
1590
+ *
1591
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
1592
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
1593
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
1594
+ *
1595
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
1596
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
1597
+ */
1598
+ protected progressOf(t: FlowTask): number | undefined;
1533
1599
  private generate;
1534
1600
  private processOrders;
1535
1601
  /**
@@ -832,7 +832,26 @@ export class FlowEngine {
832
832
  this.seedDanglingRefs += dropped;
833
833
  return { kept, dropped };
834
834
  }
835
- 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') {
836
855
  /* 확인 처리를 먼저 이어받는다 — 아래에서 상태를 주입하면 곧바로 주목 신호가 계산되므로, 늦게
837
856
  * 이어받으면 그 한 번은 확인 안 된 것으로 계산된다(화면이 잠깐 빨개진다). */
838
857
  for (const id of snap.acked ?? [])
@@ -882,7 +901,15 @@ export class FlowEngine {
882
901
  const prev = this.equipment.get(m.id);
883
902
  this.equipment.set(m.id, {
884
903
  ...(prev ?? {}),
885
- 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,
886
913
  runMs: oee?.runMs ?? 0, setupMs: oee?.setupMs ?? 0, downMs: oee?.downMs ?? 0,
887
914
  goodCount: oee?.goodCount ?? 0, scrapCount: oee?.scrapCount ?? 0,
888
915
  /*
@@ -915,7 +942,19 @@ export class FlowEngine {
915
942
  const prev = this.persons.get(p.id);
916
943
  this.persons.set(p.id, {
917
944
  ...(prev ?? {}),
918
- 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,
949
+ /*
950
+ * 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
951
+ *
952
+ * 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
953
+ * `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
954
+ * 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
955
+ * 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
956
+ */
957
+ ...(purpose === 'observe' && p.offShift ? { offShift: true } : {}),
919
958
  ...(p.location ? { location: p.location } : {}),
920
959
  ...(p.properties ? { properties: p.properties } : {}),
921
960
  ...(p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {}),
@@ -929,7 +968,10 @@ export class FlowEngine {
929
968
  const prev = this.assets.get(a.id);
930
969
  this.assets.set(a.id, {
931
970
  ...(prev ?? {}),
932
- id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location, status: 'idle', taskId: null, carrying: a.carrying,
971
+ /* 설비·사람과 같은 규율 관측이면 원본이 말한 상태·묶임을 지킨다. */
972
+ id: a.id, assetClassIds: a.assetClassIds ?? prev?.assetClassIds, location: a.location,
973
+ status: purpose === 'observe' ? (a.status ?? 'idle') : 'idle',
974
+ taskId: purpose === 'observe' ? (a.taskId ?? null) : null, carrying: a.carrying,
933
975
  ...(a.properties ? { properties: a.properties } : {}),
934
976
  ...(a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {}),
935
977
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
@@ -993,11 +1035,35 @@ export class FlowEngine {
993
1035
  const remaining = lines.length
994
1036
  ? lines.reduce((s, l) => s + l.requested, 0)
995
1037
  : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
996
- if (remaining <= 0)
997
- continue; // 이미 이행 완료 예측 대상 아님
1038
+ /*
1039
+ * ── **씨앗은 남은 데맨드로, 미러는 들은 그대로** (2026-08-23) ──────────────
1040
+ *
1041
+ * 아래 세 값(`status`·`requested`·`fulfilled`)은 씨앗의 규약이다: 씨앗은 「무엇이 남았나」로
1042
+ * 미래를 굴리므로 요청 10·이행 4 를 **요청 6·이행 0·상태 created** 로 다시 표현한다. 그리고 이미
1043
+ * 이행된 오더는 예측 대상이 아니므로 건너뛴다.
1044
+ *
1045
+ * **미러에는 전부 틀렸다.** 미러는 굴리지 않고 들은 것을 말한다. 실측(포천 미러):
1046
+ *
1047
+ * 원본이 말한 것 picking · 요청 10 · 이행 4 · 진척 0.4
1048
+ * 커널이 낸 것 created · 요청 6 · 이행 0 · 진척 0 ← 40% 된 오더가 0% 로 보였다
1049
+ *
1050
+ * 그리고 이행 완료된 오더는 **상태에서 아예 사라졌다** — 원본이 그 오더가 있다고 말하는데
1051
+ * 트윈은 없다고 말한다. 오더의 상태 낱말은 계약이 도메인에 넘긴 것인데(`picking`·`shipped`…)
1052
+ * 씨앗 규약이 그것을 커널 낱말로 덮고 있었다.
1053
+ *
1054
+ * 이것은 작업에서 고친 것과 **같은 결함의 세 번째 사례**다(§`purpose`): 한 함수가 두 목적을 겸하고
1055
+ * 씨앗 규약이 관측에 새어 나온다.
1056
+ */
1057
+ const observing = purpose === 'observe';
1058
+ if (!observing && remaining <= 0)
1059
+ continue; // 씨앗: 이미 이행 완료 → 예측 대상 아님
998
1060
  const resolved = this.resolvedAllocated(o.allocated);
999
1061
  this.orders.set(o.orderId, {
1000
- id: o.orderId, kind: o.kind, status: 'created', requested: remaining, fulfilled: 0,
1062
+ id: o.orderId,
1063
+ kind: o.kind,
1064
+ status: observing ? (o.status ?? 'created') : 'created',
1065
+ requested: observing ? (o.requested ?? 0) : remaining,
1066
+ fulfilled: observing ? (o.fulfilled ?? 0) : 0,
1001
1067
  /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
1002
1068
  보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
1003
1069
  않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
@@ -1006,7 +1072,9 @@ export class FlowEngine {
1006
1072
  /* 레시피도 함께 — 품목만 이어받으면 되살아난 오더가 「무엇으로」를 모르고, 그 오더의
1007
1073
  남은 공정·소요가 다른 레시피 기준으로 계산된다(같은 품목에 대체 레시피가 있다). */
1008
1074
  ...(o.recipeKey ? { recipeKey: o.recipeKey } : {}),
1009
- bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null, lines,
1075
+ bizTransaction: o.bizTransaction ?? '', allocated: resolved.kept, picked: [], shipmentEpc: null,
1076
+ /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
1077
+ lines: observing ? (o.lines ?? []).map(l => ({ ...l })) : lines,
1010
1078
  ...(resolved.dropped ? { seedIncomplete: true } : {}),
1011
1079
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
1012
1080
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
@@ -1022,38 +1090,88 @@ export class FlowEngine {
1022
1090
  }
1023
1091
  /* 작업이 딛고 설 오더를 먼저 세운다 — 순서가 뒤바뀌면 아래 확인이 언제나 "없다" 로 답한다. */
1024
1092
  const seededOrderIds = new Set(this.orders.keys());
1025
- let orphaned = 0;
1093
+ /*
1094
+ * **떨어뜨린 이유를 따로 센다** — 예전에는 하나로 세고 「whose item is no longer in state」라고
1095
+ * 말했다. 그런데 오더에 걸린 경우도 같은 문장을 썼고, 그것을 본 사람이 물품 쪽을 반나절 뒤졌다.
1096
+ * 원인을 지어내는 문장은 없는 것보다 나쁘다.
1097
+ */
1098
+ let droppedNoItem = 0;
1099
+ let droppedNoOrder = 0;
1100
+ let droppedCompleted = 0;
1101
+ /*
1102
+ * **씨앗이냐 관측 정착이냐** — 아래 셋을 거를지 말지가 여기서 갈린다(§`purpose`).
1103
+ *
1104
+ * 씨앗(seed) 굴릴 수 없는 작업은 뺀다. 심으면 첫 틱에서 없는 자리를 가리킨다.
1105
+ * 관측(observe) **거르지 않는다.** 미러는 그 작업을 굴리지 않고, 들은 것을 말한다.
1106
+ */
1107
+ const filtering = purpose === 'seed';
1026
1108
  for (const t of snap.tasks ?? []) {
1027
- if (t.status === 'completed')
1109
+ if (filtering && t.status === 'completed') {
1110
+ droppedCompleted++;
1028
1111
  continue;
1112
+ }
1029
1113
  const ref = t.itemRefs?.[0];
1030
- if (ref && !this.itemByRef(ref)) {
1031
- orphaned++;
1114
+ if (filtering && ref && !this.itemByRef(ref)) {
1115
+ droppedNoItem++;
1032
1116
  continue;
1033
1117
  }
1034
1118
  /* 오더도 같다 — 이미 이행된 오더는 주입하지 않으므로(위 `remaining <= 0`), 그 오더에 딸린 작업만
1035
1119
  남으면 완료 시점에 없는 오더를 딛는다. 주입 단계에서 함께 뺀다. */
1036
- if (t.orderId && !seededOrderIds.has(t.orderId)) {
1037
- orphaned++;
1120
+ if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
1121
+ droppedNoOrder++;
1038
1122
  continue;
1039
1123
  }
1040
1124
  const known = typeof t.remainingMs === 'number' && Number.isFinite(t.remainingMs);
1041
1125
  this.tasks.set(t.id, {
1042
1126
  id: t.id, kind: t.kind,
1043
- status: known && t.status === 'in-progress' ? 'in-progress' : 'created',
1127
+ /*
1128
+ * **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
1129
+ * 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
1130
+ *
1131
+ * 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
1132
+ * `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
1133
+ * 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
1134
+ */
1135
+ status: purpose === 'observe' ? t.status : known && t.status === 'in-progress' ? 'in-progress' : 'created',
1044
1136
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
1045
1137
  ...(t.materialActual?.length ? { materialActual: t.materialActual.map(r => ({ ...r })) } : {}),
1046
1138
  itemEpc: t.itemRefs?.[0] ?? '',
1047
1139
  fromNode: t.fromNode ?? '', toNode: t.toNode ?? '',
1048
- resource: known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
1140
+ /*
1141
+ * 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
1142
+ * (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
1143
+ * 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
1144
+ */
1145
+ resource: purpose === 'observe' ? (t.resourceRef ?? null) : known && t.status === 'in-progress' ? (t.resourceRef ?? null) : null,
1049
1146
  remainingMs: known ? t.remainingMs : (t.durationMs ?? 0),
1050
1147
  startedAtSimMs: t.startedAtSimMs,
1051
1148
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
1052
1149
  orderId: t.orderId,
1053
- intent: t.intent
1150
+ intent: t.intent,
1151
+ /*
1152
+ * 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
1153
+ * 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
1154
+ */
1155
+ ...(purpose === 'observe' && typeof t.progress === 'number' ? { progress: t.progress } : {})
1054
1156
  });
1055
- /* 진행 중으로 살린 작업은 그 자원을 점유한 상태여야 한다(자원이 동시에 다른 일을 받지 않게). */
1056
- if (known && t.status === 'in-progress' && t.resourceRef) {
1157
+ /*
1158
+ * 투입 인원은 **작업이 들고 있는 사실**이므로 관측에서도 그대로 옮긴다(위 `set` 에서 빠져 있어
1159
+ * 아래에서 채운다 — 씨앗 시절 진행 중인 작업에만 필요했던 자리다).
1160
+ */
1161
+ if (purpose === 'observe' && t.personnel?.length) {
1162
+ const restored = this.tasks.get(t.id);
1163
+ if (restored)
1164
+ restored.personnel = [...t.personnel];
1165
+ }
1166
+ /*
1167
+ * **자원의 상태는 여기서 정하지 않는다 — 관측일 때는.**
1168
+ *
1169
+ * 씨앗에서는 필요하다: 진행 중인 작업을 심었으면 그 설비가 다른 일을 또 받으면 안 되므로 점유를
1170
+ * 표시한다. 그런데 미러에서는 설비·사람의 상태가 **자기 관측 축**(`equipment.status`,
1171
+ * `person.status`)에서 온다. 작업 하나로 그것을 덮으면 원본이 「유휴」라고 말한 설비를 트윈이
1172
+ * 「바쁘다」고 말한다 — 축이 둘이 되고, 어느 쪽이 사실인지 알 수 없게 된다.
1173
+ */
1174
+ if (purpose === 'seed' && known && t.status === 'in-progress' && t.resourceRef) {
1057
1175
  const mv = this.equipment.get(t.resourceRef);
1058
1176
  if (mv) {
1059
1177
  mv.status = 'busy';
@@ -1061,7 +1179,7 @@ export class FlowEngine {
1061
1179
  }
1062
1180
  }
1063
1181
  /* 사람도 같다 — 진행 중이던 작업에 투입돼 있던 사람은 여전히 점유되어 있어야 한다. */
1064
- if (known && t.status === 'in-progress') {
1182
+ if (purpose === 'seed' && known && t.status === 'in-progress') {
1065
1183
  const restored = this.tasks.get(t.id);
1066
1184
  if (restored)
1067
1185
  restored.personnel = t.personnel ? [...t.personnel] : undefined;
@@ -1074,9 +1192,21 @@ export class FlowEngine {
1074
1192
  }
1075
1193
  }
1076
1194
  }
1077
- if (orphaned) {
1078
- console.warn(`[twin-kernel] seed skipped ${orphaned} in-flight task(s) whose item is no longer in state — ` +
1079
- 'they cannot be continued (the item was consumed/shipped, or the observation did not carry it).');
1195
+ /*
1196
+ * **무엇을 뺐는지 그대로 말한다.** 씨앗에서만 나온다(관측은 빼지 않는다).
1197
+ *
1198
+ * 예전 문장은 셋을 하나로 뭉쳐 「whose item is no longer in state」라고 했다. 오더에 걸린 것을
1199
+ * 물품 탓으로 말했고, 그 문장을 읽은 사람이 물품 쪽을 뒤졌다. 원인을 지어내는 문장은 침묵보다 나쁘다.
1200
+ */
1201
+ const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
1202
+ if (droppedTotal) {
1203
+ const why = [
1204
+ droppedNoItem ? `${droppedNoItem} whose item is not in state` : '',
1205
+ droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : '',
1206
+ droppedCompleted ? `${droppedCompleted} already completed` : ''
1207
+ ].filter(Boolean).join(' · ');
1208
+ console.warn(`[twin-kernel] seed skipped ${droppedTotal} task(s) — ${why}. ` +
1209
+ 'They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(…, …, "observe").)');
1080
1210
  }
1081
1211
  }
1082
1212
  /**
@@ -1335,8 +1465,13 @@ export class FlowEngine {
1335
1465
  equipment: [...this.equipment.values()].map(m => {
1336
1466
  const s = { id: m.id, kind: m.kind, location: m.location, ...(m.homeLocation ? { homeLocation: m.homeLocation } : {}), status: m.status, taskId: m.taskId ?? undefined, oee: this.oeeOf(m), held: m.held, ...(m.properties ? { properties: m.properties } : {}), ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}), ...(m.testResults ? { testResults: m.testResults } : {}), capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: 'master', ...(this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}), ...this.effectivePart(m), ...(this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}), ...energyFieldsOf(m) };
1337
1467
  const t = m.taskId ? this.tasks.get(m.taskId) : undefined;
1338
- if (t && t.status === 'in-progress' && t.intent !== 'process')
1339
- 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 };
1468
+ /*
1469
+ * 이동 구간은 **소요를 때만** 낸다. 모르면 아래 산수가 전부 0 이나 음수가 되고, 화면은 「제자리에서
1470
+ * 다 끝난 이동」을 그린다 — 원본이 소요를 주지 않는 미러에서 실제로 그랬다(§`progressOf`).
1471
+ */
1472
+ const motionProgress = t && t.status === 'in-progress' && t.intent !== 'process' ? this.progressOf(t) : undefined;
1473
+ if (t && motionProgress !== undefined)
1474
+ s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
1340
1475
  return s;
1341
1476
  }),
1342
1477
  assets: [...this.assets.values()].map(a => {
@@ -1365,10 +1500,17 @@ export class FlowEngine {
1365
1500
  물을 수 없다(사실은 자기 집에서 나온다). 판정은 아래 `capability` 가 이미 답한다. */
1366
1501
  if (p.testResults)
1367
1502
  st.testResults = p.testResults;
1503
+ /*
1504
+ * 근무 밖 판정 — **선언이 있으면 선언이 이기고, 없을 때만 원본이 말한 값을 쓴다.**
1505
+ * 진척(`progressOf`)과 같은 순서다. 선언이 있는데 저장값을 함께 보면 교대 안으로 돌아왔을 때
1506
+ * 옛 `true` 가 남는다(리듀서가 그 이유를 적어 두었다).
1507
+ */
1368
1508
  if (this.personOffShift(p)) {
1369
1509
  st.offShift = true;
1370
1510
  Object.assign(st, this.offReason(p));
1371
1511
  }
1512
+ else if (p.offShift && !p.workCalendar?.length)
1513
+ st.offShift = true;
1372
1514
  const sh = this.shiftOf(p);
1373
1515
  if (sh)
1374
1516
  st.shift = sh;
@@ -1389,7 +1531,26 @@ export class FlowEngine {
1389
1531
  fromNode: t.fromNode, toNode: t.toNode, resourceRef: t.resource ?? undefined, orderId: t.orderId,
1390
1532
  ...(t.intent ? { intent: t.intent } : {}),
1391
1533
  ...(t.durationMs ? { durationMs: t.durationMs } : {}),
1392
- ...(t.status === 'in-progress' ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {}),
1534
+ /*
1535
+ * 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
1536
+ *
1537
+ * 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
1538
+ * 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
1539
+ *
1540
+ * 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
1541
+ * 것이 이 시스템에서 가장 비싼 침묵이다.
1542
+ */
1543
+ ...(t.status === 'in-progress'
1544
+ ? {
1545
+ remainingMs: t.remainingMs,
1546
+ startedAtSimMs: t.startedAtSimMs,
1547
+ ...(() => {
1548
+ const derived = this.progressOf(t);
1549
+ const p = derived ?? t.progress;
1550
+ return p === undefined ? {} : { progress: p };
1551
+ })()
1552
+ }
1553
+ : {}),
1393
1554
  ...(t.personnel?.length ? { personnel: t.personnel.slice() } : {}),
1394
1555
  ...(t.assets?.length ? { assets: t.assets.slice() } : {}),
1395
1556
  ...(t.resources?.length ? { resources: t.resources.slice() } : {}),
@@ -1723,7 +1884,14 @@ export class FlowEngine {
1723
1884
  if (!this.observedDirty || !this.observer)
1724
1885
  return;
1725
1886
  this.observedDirty = false;
1726
- this.hydrateObserved(this.observer.snapshot());
1887
+ /*
1888
+ * **목적을 밝힌다 — 이것은 씨앗이 아니라 관측 정착이다**(§`hydrateObserved` 의 `purpose`).
1889
+ *
1890
+ * 이 한 낱말이 없던 동안 미러의 작업이 씨앗 규칙에 걸려 사라졌다: 포천 미러의 저널에
1891
+ * `task.status` 5,786건이 있는데 `getSnapshot().tasks` 는 0 이었고, 화면은 「아직 하나도
1892
+ * 없습니다」라고 말했다. 미러는 그 작업을 굴리지 않는다 — 들은 것을 말할 뿐이다.
1893
+ */
1894
+ this.hydrateObserved(this.observer.snapshot(), [], 'observe');
1727
1895
  }
1728
1896
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
1729
1897
  observedHandlers() {
@@ -3321,7 +3489,29 @@ export class FlowEngine {
3321
3489
  ...(i.ilmd ? { ilmd: i.ilmd } : {})
3322
3490
  };
3323
3491
  }
3324
- progressOf(t) { return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs)); }
3492
+ /**
3493
+ * 진척 — **모르면 답하지 않는다.**
3494
+ *
3495
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
3496
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
3497
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
3498
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
3499
+ *
3500
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
3501
+ * 것이고, 화면은 그것을 사실로 그린다.
3502
+ *
3503
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
3504
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
3505
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
3506
+ *
3507
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
3508
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
3509
+ */
3510
+ progressOf(t) {
3511
+ if (!(t.durationMs > 0))
3512
+ return undefined;
3513
+ return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
3514
+ }
3325
3515
  generate() {
3326
3516
  for (const g of this.gens) {
3327
3517
  while (this.clockMs >= g.nextMs) {
@@ -3964,7 +3964,7 @@ var FlowEngine = class {
3964
3964
  this.seedDanglingRefs += dropped;
3965
3965
  return { kept, dropped };
3966
3966
  }
3967
- hydrateObserved(snap, orders = []) {
3967
+ hydrateObserved(snap, orders = [], purpose = "seed") {
3968
3968
  for (const id of snap.acked ?? []) this._acked.add(id);
3969
3969
  for (const e of snap.attentionSince ?? []) if (e?.id && e.since) this._attentionSince.set(e.id, e.since);
3970
3970
  for (const n of snap.locations) {
@@ -3994,12 +3994,20 @@ var FlowEngine = class {
3994
3994
  const prev = this.equipment.get(m.id);
3995
3995
  this.equipment.set(m.id, {
3996
3996
  ...prev ?? {},
3997
+ /*
3998
+ * `taskId` — **관측이면 원본이 말한 값이다.**
3999
+ *
4000
+ * 씨앗에서는 `null` 로 두고 작업 복원이 다시 세운다(작업이 함께 오지 않으면 그 자원이 영원히
4001
+ * 잡혀 있게 되므로). 그런데 관측에서는 `equipment.status` 가 이 값을 **직접 실어 온다** —
4002
+ * 상태(busy)는 그 축에서 받고 묶임은 작업에서 되세우면, 같은 사실의 두 조각이 서로 다른 축에서
4003
+ * 오게 되고 한쪽만 도착한 순간 어긋난다.
4004
+ */
3997
4005
  id: m.id,
3998
4006
  kind: m.kind,
3999
4007
  location: m.location ?? "",
4000
4008
  ...m.homeLocation ? { homeLocation: m.homeLocation } : {},
4001
4009
  status: m.status ?? "idle",
4002
- taskId: null,
4010
+ taskId: purpose === "observe" ? m.taskId ?? null : null,
4003
4011
  runMs: oee?.runMs ?? 0,
4004
4012
  setupMs: oee?.setupMs ?? 0,
4005
4013
  downMs: oee?.downMs ?? 0,
@@ -4030,10 +4038,20 @@ var FlowEngine = class {
4030
4038
  const prev = this.persons.get(p.id);
4031
4039
  this.persons.set(p.id, {
4032
4040
  ...prev ?? {},
4041
+ /* 설비와 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다(씨앗이면 작업 복원이 세운다). */
4033
4042
  id: p.id,
4034
4043
  personnelClassIds: p.personnelClassIds ?? prev?.personnelClassIds,
4035
- status: "idle",
4036
- taskId: null,
4044
+ status: purpose === "observe" ? p.status ?? "idle" : "idle",
4045
+ taskId: purpose === "observe" ? p.taskId ?? null : null,
4046
+ /*
4047
+ * 원본이 말한 근무 밖 표시 — **관측일 때만, 그리고 선언이 없을 때만 쓰인다.**
4048
+ *
4049
+ * 이 규율의 원본 자리는 리듀서다(§`offShift`: 「선언이 없을 때만 받는다 — 선언이 있으면
4050
+ * `snapshot()` 이 시각으로 판정하고, 저장값을 함께 두면 교대 안으로 돌아왔을 때 옛 `true` 가
4051
+ * 남는다」). 그런데 이 자리가 그 값을 **떨어뜨리고 있었다** — 리듀서가 지킨 것을 여기서 잃었다.
4052
+ * 근무 캘린더가 없는 원본(실 시스템에 흔하다)에서는 그것이 유일한 답이다.
4053
+ */
4054
+ ...purpose === "observe" && p.offShift ? { offShift: true } : {},
4037
4055
  ...p.location ? { location: p.location } : {},
4038
4056
  ...p.properties ? { properties: p.properties } : {},
4039
4057
  ...p.testSpecificationIds ? { testSpecificationIds: p.testSpecificationIds } : {},
@@ -4046,11 +4064,12 @@ var FlowEngine = class {
4046
4064
  const prev = this.assets.get(a.id);
4047
4065
  this.assets.set(a.id, {
4048
4066
  ...prev ?? {},
4067
+ /* 설비·사람과 같은 규율 — 관측이면 원본이 말한 상태·묶임을 지킨다. */
4049
4068
  id: a.id,
4050
4069
  assetClassIds: a.assetClassIds ?? prev?.assetClassIds,
4051
4070
  location: a.location,
4052
- status: "idle",
4053
- taskId: null,
4071
+ status: purpose === "observe" ? a.status ?? "idle" : "idle",
4072
+ taskId: purpose === "observe" ? a.taskId ?? null : null,
4054
4073
  carrying: a.carrying,
4055
4074
  ...a.properties ? { properties: a.properties } : {},
4056
4075
  ...a.testSpecificationIds ? { testSpecificationIds: a.testSpecificationIds } : {},
@@ -4084,14 +4103,15 @@ var FlowEngine = class {
4084
4103
  for (const o of observedOrders) {
4085
4104
  const lines = (o.lines ?? []).map((l) => ({ gtin: l.gtin, requested: l.requested - (l.fulfilled ?? 0) })).filter((l) => l.requested > 0);
4086
4105
  const remaining = lines.length ? lines.reduce((s, l) => s + l.requested, 0) : Math.max(0, (o.requested ?? 0) - (o.fulfilled ?? 0));
4087
- if (remaining <= 0) continue;
4106
+ const observing = purpose === "observe";
4107
+ if (!observing && remaining <= 0) continue;
4088
4108
  const resolved = this.resolvedAllocated(o.allocated);
4089
4109
  this.orders.set(o.orderId, {
4090
4110
  id: o.orderId,
4091
4111
  kind: o.kind,
4092
- status: "created",
4093
- requested: remaining,
4094
- fulfilled: 0,
4112
+ status: observing ? o.status ?? "created" : "created",
4113
+ requested: observing ? o.requested ?? 0 : remaining,
4114
+ fulfilled: observing ? o.fulfilled ?? 0 : 0,
4095
4115
  /* **확보해 둔 것과 거래번호를 이어받는다.** 비우면 되살아난 오더가 아무것도 안 잡은 것처럼
4096
4116
  보이고, 그 오더의 진행 중 작업이 완료될 때 계보가 입력 없이 나간다. 상태가 말해 주지
4097
4117
  않으면(옛 저널) 그때는 비는 것이 사실이다 — 없는 것을 지어내지 않는다. */
@@ -4104,7 +4124,8 @@ var FlowEngine = class {
4104
4124
  allocated: resolved.kept,
4105
4125
  picked: [],
4106
4126
  shipmentEpc: null,
4107
- lines,
4127
+ /* 라인도 같다 — 씨앗은 남은 양만 남기고, 미러는 원본이 말한 요청·이행을 그대로 든다. */
4128
+ lines: observing ? (o.lines ?? []).map((l) => ({ ...l })) : lines,
4108
4129
  ...resolved.dropped ? { seedIncomplete: true } : {},
4109
4130
  /* **보류를 이어받는다** — 잃으면 씨앗이 사람이 일부러 멈춘 오더를 다시 계획해 내보낸다.
4110
4131
  씨앗 왕복 대조가 이것을 잡았다(그 전에는 사람이 코드를 읽어야만 알 수 있었다). */
@@ -4119,43 +4140,71 @@ var FlowEngine = class {
4119
4140
  });
4120
4141
  }
4121
4142
  const seededOrderIds = new Set(this.orders.keys());
4122
- let orphaned = 0;
4143
+ let droppedNoItem = 0;
4144
+ let droppedNoOrder = 0;
4145
+ let droppedCompleted = 0;
4146
+ const filtering = purpose === "seed";
4123
4147
  for (const t of snap.tasks ?? []) {
4124
- if (t.status === "completed") continue;
4148
+ if (filtering && t.status === "completed") {
4149
+ droppedCompleted++;
4150
+ continue;
4151
+ }
4125
4152
  const ref = t.itemRefs?.[0];
4126
- if (ref && !this.itemByRef(ref)) {
4127
- orphaned++;
4153
+ if (filtering && ref && !this.itemByRef(ref)) {
4154
+ droppedNoItem++;
4128
4155
  continue;
4129
4156
  }
4130
- if (t.orderId && !seededOrderIds.has(t.orderId)) {
4131
- orphaned++;
4157
+ if (filtering && t.orderId && !seededOrderIds.has(t.orderId)) {
4158
+ droppedNoOrder++;
4132
4159
  continue;
4133
4160
  }
4134
4161
  const known = typeof t.remainingMs === "number" && Number.isFinite(t.remainingMs);
4135
4162
  this.tasks.set(t.id, {
4136
4163
  id: t.id,
4137
4164
  kind: t.kind,
4138
- status: known && t.status === "in-progress" ? "in-progress" : "created",
4165
+ /*
4166
+ * **관측이면 들은 상태를 그대로 둔다.** 씨앗이면 굴릴 수 있는 모양으로 맞춘다(남은 시간을
4167
+ * 모르는 작업을 `in-progress` 로 심으면 끝나지 않는다).
4168
+ *
4169
+ * 예전에는 관측 경로에서도 이 줄이 상태를 다시 썼다 — 원본이 `completed` 라고 말한 작업이
4170
+ * `created` 로, `assigned` 도 `created` 로 바뀌었다. 공정 타임라인·현장 성과·주의 목록이
4171
+ * 모두 이 축을 읽으므로, 여기서 뭉개면 그 셋이 함께 거짓이 된다.
4172
+ */
4173
+ status: purpose === "observe" ? t.status : known && t.status === "in-progress" ? "in-progress" : "created",
4139
4174
  /* 이미 일어난 자재 이동은 **씨앗에도 남는다** — 잃으면 실적이 재기동마다 지워진다. */
4140
4175
  ...t.materialActual?.length ? { materialActual: t.materialActual.map((r) => ({ ...r })) } : {},
4141
4176
  itemEpc: t.itemRefs?.[0] ?? "",
4142
4177
  fromNode: t.fromNode ?? "",
4143
4178
  toNode: t.toNode ?? "",
4144
- resource: known && t.status === "in-progress" ? t.resourceRef ?? null : null,
4179
+ /*
4180
+ * 자원 참조는 **작업 자신의 사실**이다. 씨앗에서는 굴릴 수 있는 작업만 자원을 잡게 하지만
4181
+ * (남은 시간을 모르면 배정 루프가 다시 잡아야 한다), 관측에서는 원본이 말한 참조를 지운 이유가
4182
+ * 없다 — 지우면 「어느 설비가 이 일을 하고 있나」가 화면에서 사라진다.
4183
+ */
4184
+ resource: purpose === "observe" ? t.resourceRef ?? null : known && t.status === "in-progress" ? t.resourceRef ?? null : null,
4145
4185
  remainingMs: known ? t.remainingMs : t.durationMs ?? 0,
4146
4186
  startedAtSimMs: t.startedAtSimMs,
4147
4187
  durationMs: t.durationMs ?? (known ? t.remainingMs : 0),
4148
4188
  orderId: t.orderId,
4149
- intent: t.intent
4189
+ intent: t.intent,
4190
+ /*
4191
+ * 원본이 말한 진척 — **관측일 때만, 그리고 커널이 계산할 수 없을 때 쓰인다**(§`progressOf`).
4192
+ * 씨앗 경로에서는 싣지 않는다: 씨앗은 굴러가면서 자기 소요로 진척을 만든다.
4193
+ */
4194
+ ...purpose === "observe" && typeof t.progress === "number" ? { progress: t.progress } : {}
4150
4195
  });
4151
- if (known && t.status === "in-progress" && t.resourceRef) {
4196
+ if (purpose === "observe" && t.personnel?.length) {
4197
+ const restored = this.tasks.get(t.id);
4198
+ if (restored) restored.personnel = [...t.personnel];
4199
+ }
4200
+ if (purpose === "seed" && known && t.status === "in-progress" && t.resourceRef) {
4152
4201
  const mv = this.equipment.get(t.resourceRef);
4153
4202
  if (mv) {
4154
4203
  mv.status = "busy";
4155
4204
  mv.taskId = t.id;
4156
4205
  }
4157
4206
  }
4158
- if (known && t.status === "in-progress") {
4207
+ if (purpose === "seed" && known && t.status === "in-progress") {
4159
4208
  const restored = this.tasks.get(t.id);
4160
4209
  if (restored) restored.personnel = t.personnel ? [...t.personnel] : void 0;
4161
4210
  for (const id of t.personnel ?? []) {
@@ -4167,9 +4216,15 @@ var FlowEngine = class {
4167
4216
  }
4168
4217
  }
4169
4218
  }
4170
- if (orphaned) {
4219
+ const droppedTotal = droppedNoItem + droppedNoOrder + droppedCompleted;
4220
+ if (droppedTotal) {
4221
+ const why = [
4222
+ droppedNoItem ? `${droppedNoItem} whose item is not in state` : "",
4223
+ droppedNoOrder ? `${droppedNoOrder} whose order is not in state` : "",
4224
+ droppedCompleted ? `${droppedCompleted} already completed` : ""
4225
+ ].filter(Boolean).join(" \xB7 ");
4171
4226
  console.warn(
4172
- `[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).`
4227
+ `[twin-kernel] seed skipped ${droppedTotal} task(s) \u2014 ${why}. They cannot be continued by a simulation. (A mirror keeps them: hydrateObserved(\u2026, \u2026, "observe").)`
4173
4228
  );
4174
4229
  }
4175
4230
  }
@@ -4397,7 +4452,8 @@ var FlowEngine = class {
4397
4452
  equipment: [...this.equipment.values()].map((m) => {
4398
4453
  const s = { id: m.id, kind: m.kind, location: m.location, ...m.homeLocation ? { homeLocation: m.homeLocation } : {}, status: m.status, taskId: m.taskId ?? void 0, oee: this.oeeOf(m), held: m.held, ...m.properties ? { properties: m.properties } : {}, ...m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}, ...m.testResults ? { testResults: m.testResults } : {}, capability: this.capabilityOfResource(m, m.kind ? [m.kind] : [], this.classDefs.equipment), origin: "master", ...this.offShift(m) ? { offShift: true, ...this.offReason(m) } : {}, ...this.effectivePart(m), ...this.shiftOf(m) ? { shift: this.shiftOf(m) } : {}, ...energyFieldsOf(m) };
4399
4454
  const t = m.taskId ? this.tasks.get(m.taskId) : void 0;
4400
- if (t && t.status === "in-progress" && t.intent !== "process") 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 };
4455
+ const motionProgress = t && t.status === "in-progress" && t.intent !== "process" ? this.progressOf(t) : void 0;
4456
+ if (t && motionProgress !== void 0) s.motion = { fromNode: t.fromNode, toNode: t.toNode, startedAtSimMs: this.clockMs - (t.durationMs - t.remainingMs), durationMs: t.durationMs, progress: motionProgress, elapsedMs: t.durationMs - t.remainingMs };
4401
4457
  return s;
4402
4458
  }),
4403
4459
  assets: [...this.assets.values()].map((a) => {
@@ -4419,7 +4475,7 @@ var FlowEngine = class {
4419
4475
  if (this.personOffShift(p)) {
4420
4476
  st.offShift = true;
4421
4477
  Object.assign(st, this.offReason(p));
4422
- }
4478
+ } else if (p.offShift && !p.workCalendar?.length) st.offShift = true;
4423
4479
  const sh = this.shiftOf(p);
4424
4480
  if (sh) st.shift = sh;
4425
4481
  Object.assign(st, this.effectivePart(p));
@@ -4445,7 +4501,24 @@ var FlowEngine = class {
4445
4501
  orderId: t.orderId,
4446
4502
  ...t.intent ? { intent: t.intent } : {},
4447
4503
  ...t.durationMs ? { durationMs: t.durationMs } : {},
4448
- ...t.status === "in-progress" ? { remainingMs: t.remainingMs, startedAtSimMs: t.startedAtSimMs, progress: this.progressOf(t) } : {},
4504
+ /*
4505
+ * 진척은 **커널이 계산한 값이 먼저, 못 하면 원본이 말한 값**이다(§`progressOf`).
4506
+ *
4507
+ * 이 순서는 사람 교대(`offShift`)가 세워 둔 규율과 같다: 파생이 가능하면 파생이 이기고, 파생이
4508
+ * 불가능할 때만 원본의 단정을 쓴다. 둘 다 없으면 **싣지 않는다** — 「모른다」를 숫자로 만들지 않는다.
4509
+ *
4510
+ * 예전에는 원본이 진척을 말해도 조용히 버렸다(문이 받아 놓고 파생이 덮었다). 넣었는데 사라지는
4511
+ * 것이 이 시스템에서 가장 비싼 침묵이다.
4512
+ */
4513
+ ...t.status === "in-progress" ? {
4514
+ remainingMs: t.remainingMs,
4515
+ startedAtSimMs: t.startedAtSimMs,
4516
+ ...(() => {
4517
+ const derived = this.progressOf(t);
4518
+ const p = derived ?? t.progress;
4519
+ return p === void 0 ? {} : { progress: p };
4520
+ })()
4521
+ } : {},
4449
4522
  ...t.personnel?.length ? { personnel: t.personnel.slice() } : {},
4450
4523
  ...t.assets?.length ? { assets: t.assets.slice() } : {},
4451
4524
  ...t.resources?.length ? { resources: t.resources.slice() } : {},
@@ -4752,7 +4825,7 @@ var FlowEngine = class {
4752
4825
  settleObserved() {
4753
4826
  if (!this.observedDirty || !this.observer) return;
4754
4827
  this.observedDirty = false;
4755
- this.hydrateObserved(this.observer.snapshot());
4828
+ this.hydrateObserved(this.observer.snapshot(), [], "observe");
4756
4829
  }
4757
4830
  /** 구독자 목록 — 관측 재방출용(private handlers 에 접근). */
4758
4831
  observedHandlers() {
@@ -6147,8 +6220,27 @@ var FlowEngine = class {
6147
6220
  ...i.ilmd ? { ilmd: i.ilmd } : {}
6148
6221
  };
6149
6222
  }
6223
+ /**
6224
+ * 진척 — **모르면 답하지 않는다.**
6225
+ *
6226
+ * ── 무엇이 틀려 있었나 (2026-08-23 실측) ───────────────────────────────────
6227
+ * 예전에는 `durationMs <= 0` 이면 `1` 을 돌려줬다. 시뮬에서는 그것이 맞다(소요 0 = 즉시 끝나는 일).
6228
+ * 그런데 **미러에서 `durationMs` 0 은 「즉시」가 아니라 「모른다」다** — 원본이 소요를 말해 주지
6229
+ * 않으면 씨앗 경로가 0 으로 메우고, 그러면 이 함수가 **진행 중인 작업을 100% 라고 말한다.**
6230
+ *
6231
+ * 실측: 소요를 주지 않는 원본의 진행 중 작업이 `progress: 1` 로 나왔다. 「모름」을 「완료」로 말하는
6232
+ * 것이고, 화면은 그것을 사실로 그린다.
6233
+ *
6234
+ * 시뮬에서는 이 반환이 바뀌지 않는다: 소요 0 인 작업은 배정된 tick 안에서 끝나므로 **진행 중으로
6235
+ * 관측되지 않는다**(`status = 'in-progress'; remainingMs = durationMs` → 다음 진행에서 즉시 완료).
6236
+ * 즉 `durationMs <= 0` 이면서 진행 중인 작업은 미러 경로에서만 나온다.
6237
+ *
6238
+ * 모를 때 원본이 진척을 말해 주었다면 그 값을 쓴다 — 그 규칙은 부르는 쪽에 있다(§`getSnapshot`).
6239
+ * 이 함수는 **커널이 계산할 수 있나**만 답한다.
6240
+ */
6150
6241
  progressOf(t) {
6151
- return t.durationMs <= 0 ? 1 : Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
6242
+ if (!(t.durationMs > 0)) return void 0;
6243
+ return Math.min(1, Math.max(0, (t.durationMs - t.remainingMs) / t.durationMs));
6152
6244
  }
6153
6245
  generate() {
6154
6246
  for (const g of this.gens) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/twin-kernel",
3
- "version": "0.7.49",
3
+ "version": "0.7.51",
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": {