@operato/twin-kernel 0.7.65 → 0.7.67

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.
@@ -1,5 +1,22 @@
1
1
  /** 로케이션(수동) 타입 키 — 배전 계통의 구간. */
2
2
  export const EMS_LOCATION_TYPES = ['incoming', 'feeder', 'submeter-zone'];
3
+ /**
4
+ * 계량 지점의 방향 — **무엇을 재는 계량기인가.**
5
+ *
6
+ * import 쓰는 쪽. 계통에서 받는다(대부분의 계량기).
7
+ * export 내는 쪽. 발전이 계통으로 나가는 것을 잰다.
8
+ * bidirectional 양쪽을 한 계량기로 잰다(상계 거래·축전지). 부호가 방향을 담는다.
9
+ *
10
+ * 선언하지 않으면 **모르는 것**이고, 모르는 것은 부하로 센다. 빼면 부하가 오류 없이 작아지고, 작아진
11
+ * 부하는 「계약 안쪽」이라는 더 위험한 거짓을 만든다(같은 이유로 정체 모를 계량기도 합에 넣는다).
12
+ *
13
+ * `bidirectional` 은 **그 계량기가 순 유입을 재는 것**이다. 발전을 부하에서 빼는 것이 아니다 — 그
14
+ * 계량기가 읽은 값이 그 접속점의 사실이고, 요금이 그 값에 매겨진다.
15
+ *
16
+ * 표준 근거: 계량기의 적산 레지스터가 유입·유출로 갈려 있고(IEC 62053 계열), ISO 50001 은 유입 경계를
17
+ * 따로 둔다(`EnergyInput`). 우리가 지은 낱말이 아니다.
18
+ */
19
+ export const METER_DIRECTION = ['import', 'export', 'bidirectional'];
3
20
  /** 설비(능동) 타입 키 — 계량 지점과 에너지 자원. */
4
21
  export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', 'utility', 'curtailable-load'];
5
22
  /**
@@ -11,6 +28,24 @@ export const EMS_EQUIPMENT_TYPES = ['meter', 'breaker', 'pv-array', 'battery', '
11
28
  export const EMS_PROPERTY = {
12
29
  /** 계약전력(kW) — 수전·분기 자리에 선언한다. 없으면 계약 대비 판정을 하지 않는다. */
13
30
  contractKW: 'contract.kW',
31
+ /*
32
+ * ── 계량 지점의 방향 (2026-08-25) ──────────────────────────────────────────
33
+ * **요금은 계통 접속점의 순 유입에 매겨진다.** 그러니 그 지점의 계량기가 무엇을 재는지 모르면
34
+ * 요금을 사실대로 말할 수 없다 — 내보낸 양을 쓴 양으로 세면 계약 대비 판단이 반대로 뒤집힌다.
35
+ *
36
+ * 이 축이 없는 동안 계량 지점의 kW 를 전부 부하로 셌다. 상계 거래를 하는 현장, 축전지를 붙인 현장,
37
+ * 열병합이 있는 현장 — 접속점 계량기가 양쪽을 재는 곳은 늘 있다.
38
+ *
39
+ * **발전기를 계량기로 만드는 것과 다른 일이다.** 발전은 설비의 사실이고(`energyGenerating`), 그것을
40
+ * 계량 지점으로 보내는 것은 여전히 하지 않는다. 이 축은 **접속점 계량기**가 무엇을 재는지 말하는
41
+ * 자리다.
42
+ *
43
+ * 방향은 **관측이 아니라 선언**이다. 표본마다 바뀌는 값이 아니고, 그 계량기가 무엇을 재도록
44
+ * 설치되었는지는 현장이 안다. 그래서 표본(`EnergyRecord`)에 넣지 않고 자원 속성으로 둔다.
45
+ *
46
+ * 값은 계량기 자신의 낱말을 쓴다(적산 레지스터가 그렇게 갈려 있다). §`METER_DIRECTION`.
47
+ */
48
+ meterDirection: 'meter.direction',
14
49
  /*
15
50
  * ── 부하 계수 — 시뮬레이션이 전기를 만들 수 있게 (2026-08-14) ───────────────
16
51
  * 「이 설비가 돌면 몇 kW 인가」는 **현장이 아는 값**이다. 그래서 타입 기본값을 두지 않는다:
@@ -96,6 +131,13 @@ export const EMS_PROPERTY = {
96
131
  };
97
132
  export const EMS_PROPERTY_SPEC = {
98
133
  [EMS_PROPERTY.contractKW]: { uom: 'kW', dataType: 'xs:double', note: 'contracted power at the metering point, in kW.' },
134
+ /* 값이 셋 중 하나여야 한다 — 그 밖의 낱말은 받지 않는다(뜻이 통할 것 같은 말도 받지 않는다). */
135
+ [EMS_PROPERTY.meterDirection]: {
136
+ dataType: 'xs:string',
137
+ note: 'what this metering point measures: "import" (drawn from the grid), "export" (generation sent out), ' +
138
+ 'or "bidirectional" (one meter for both, sign carries the direction). ' +
139
+ 'Left undeclared, the point counts as load — leaving it out never makes demand look smaller than it is.'
140
+ },
99
141
  [EMS_PROPERTY.ratedKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while running, in kW.' },
100
142
  [EMS_PROPERTY.standbyKW]: { uom: 'kW', dataType: 'xs:double', note: 'power drawn while idle, in kW.' },
101
143
  [EMS_PROPERTY.demandChargePerKW]: { dataType: 'xs:double', note: 'demand charge per kW of billing-period peak, in the declared currency.' },
@@ -53,10 +53,27 @@ export interface EnergyEquipmentRecord {
53
53
  /** 이 레코드가 설비 에너지 상태인가 — 라우팅 판정을 한 곳에 둔다. */
54
54
  export declare function isEnergyEquipmentRecord(record: unknown): boolean;
55
55
  /**
56
- * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 거부하고, 거부한 것은
57
- * 이유와 함께 돌려준다.
56
+ * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
57
+ * 이유와 함께 알린다.
58
58
  *
59
59
  * **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
60
60
  * 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
61
61
  */
62
62
  export declare function ingestEnergyEquipmentRecords(records: EnergyEquipmentRecord | EnergyEquipmentRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
63
+ /** 정규 발전 적산 레코드 — 커넥터가 이 모양으로 맞춰 준다(필드 이름이 계약이다). */
64
+ export interface EnergyGenerationRecord {
65
+ equipmentId: string;
66
+ /** 계기 적산값(kWh) — 연결된 시스템이 준 값 그대로. 차분은 소비처가 한다. */
67
+ kWh?: number;
68
+ at?: string;
69
+ }
70
+ /** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
71
+ export declare function isEnergyGenerationRecord(record: unknown): boolean;
72
+ /**
73
+ * 발전 적산 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
74
+ * 이유와 함께 알린다.
75
+ *
76
+ * **적산이 없는 레코드는 받지 않는다** — 이 문의 값은 그것 하나다. 순시 출력은 설비 상태 문이 받는다
77
+ * (`EnergyEquipmentRecord.generatedKW`).
78
+ */
79
+ export declare function ingestEnergyGenerationRecords(records: EnergyGenerationRecord | EnergyGenerationRecord[] | undefined | null, opts: EnergyIngestOptions): EnergyIngestResult;
@@ -99,8 +99,8 @@ export function isEnergyEquipmentRecord(record) {
99
99
  return typeof r.equipmentId === 'string' && r.equipmentId.trim().length > 0 && r.epc === undefined && r.meterId === undefined;
100
100
  }
101
101
  /**
102
- * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 거부하고, 거부한 것은
103
- * 이유와 함께 돌려준다.
102
+ * 설비 에너지 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
103
+ * 이유와 함께 알린다.
104
104
  *
105
105
  * **값이 하나도 없는 레코드는 거부한다** — 계량과 다른 점이다. 계량은 「응답했다」는 사실 자체가
106
106
  * 관측이지만(그래서 kW 없는 표본을 받는다), 여기서는 바꿀 상태가 없는 것을 사실로 적을 이유가 없다.
@@ -192,3 +192,59 @@ export function ingestEnergyEquipmentRecords(records, opts) {
192
192
  }
193
193
  return { accepted, rejected };
194
194
  }
195
+ /** 이 레코드가 발전 적산인가 — 어느 갈래로 보낼지를 한 곳에서 정한다. */
196
+ export function isEnergyGenerationRecord(record) {
197
+ if (!record || typeof record !== 'object')
198
+ return false;
199
+ const r = record;
200
+ /* 설비를 말하고 적산을 실었으면 발전이다. 계량 어휘가 함께 있으면 어느 쪽인지 알 수 없어 받지 않는다. */
201
+ return (typeof r.equipmentId === 'string' &&
202
+ r.equipmentId.trim().length > 0 &&
203
+ r.kWh !== undefined &&
204
+ r.meterId === undefined &&
205
+ r.epc === undefined);
206
+ }
207
+ /**
208
+ * 발전 적산 레코드 → 봉투. 계량과 같은 규율이다: 지어낼 수 없는 것이 빠지면 받지 않고, 받지 않은 것은
209
+ * 이유와 함께 알린다.
210
+ *
211
+ * **적산이 없는 레코드는 받지 않는다** — 이 문의 값은 그것 하나다. 순시 출력은 설비 상태 문이 받는다
212
+ * (`EnergyEquipmentRecord.generatedKW`).
213
+ */
214
+ export function ingestEnergyGenerationRecords(records, opts) {
215
+ const list = records === undefined || records === null ? [] : Array.isArray(records) ? records : [records];
216
+ const accepted = [];
217
+ const rejected = [];
218
+ let seq = 0;
219
+ for (const r of list) {
220
+ const errors = [];
221
+ const equipmentId = String(r?.equipmentId ?? '').trim();
222
+ if (!equipmentId)
223
+ errors.push('equipmentId 가 없다 — 어느 설비가 만든 것인지 지어낼 수 없다');
224
+ const raw = r?.kWh;
225
+ const kWh = Number(raw);
226
+ if (raw === undefined || raw === null || !Number.isFinite(kWh)) {
227
+ errors.push('kWh 가 없다 — 이 문이 받는 값은 적산 하나다(순시 출력은 설비 상태로 보낸다)');
228
+ }
229
+ else if (kWh < 0) {
230
+ /* 음수 적산은 계기가 낼 수 있는 값이 아니다. 받아 두면 그 뒤의 차분이 전부 거짓이 된다. */
231
+ errors.push(`kWh 가 음수다(${kWh}) — 적산은 뒤로 가더라도 음수가 되지 않는다`);
232
+ }
233
+ const eventTime = String(r?.at ?? '').trim() || opts.defaultEventTime || '';
234
+ if (!eventTime)
235
+ errors.push('at 이 없고 기본 시각도 주지 않았다 — 언제 잰 것인지 지어낼 수 없다');
236
+ if (errors.length) {
237
+ rejected.push({ record: r, errors });
238
+ continue;
239
+ }
240
+ const data = { equipmentId, kWh, at: eventTime };
241
+ accepted.push({
242
+ eventId: `${opts.tenantId}-generated-${++seq}`,
243
+ eventType: ENERGY_EVENT.generated,
244
+ eventTime,
245
+ tenantId: opts.tenantId,
246
+ data
247
+ });
248
+ }
249
+ return { accepted, rejected };
250
+ }
package/dist/epcis.d.ts CHANGED
@@ -11,12 +11,12 @@ export declare const DISP: {
11
11
  /**
12
12
  * **기한이 지났다** — CBV `expired`.
13
13
  *
14
- * ── 왜 `non_sellable` 로 접지 않나 (2026-08-24) ─────────────────────────────
14
+ * ── 왜 `non_sellable` 로 계산하지 않나 (2026-08-24) ─────────────────────────────
15
15
  * 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
16
16
  * 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
17
17
  * 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
18
18
  *
19
- * 그리고 표준에 **정확한 낱말이 있다** — 접는 것은 있는 낱말을 버리는 것이다.
19
+ * 그리고 표준에 **정확한 낱말이 있다** — 합치는 것은 있는 낱말을 버리는 것이다.
20
20
  *
21
21
  * ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
22
22
  * `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
package/dist/epcis.js CHANGED
@@ -20,12 +20,12 @@ export const DISP = {
20
20
  /**
21
21
  * **기한이 지났다** — CBV `expired`.
22
22
  *
23
- * ── 왜 `non_sellable` 로 접지 않나 (2026-08-24) ─────────────────────────────
23
+ * ── 왜 `non_sellable` 로 계산하지 않나 (2026-08-24) ─────────────────────────────
24
24
  * 커널의 `non_sellable` 은 CBV 의 `non_sellable_other`, 즉 **「그 밖의 이유」**다. 기한 지남을 거기
25
25
  * 넣으면 「기한이 지나 못 판다」와 「깨져서 못 판다」가 같은 값이 되고, 화면은 회수·폐기의 사유를
26
26
  * 구별할 수 없다. 식품에서 그 둘은 다른 조치다.
27
27
  *
28
- * 그리고 표준에 **정확한 낱말이 있다** — 접는 것은 있는 낱말을 버리는 것이다.
28
+ * 그리고 표준에 **정확한 낱말이 있다** — 합치는 것은 있는 낱말을 버리는 것이다.
29
29
  *
30
30
  * ── 기한 날짜와 다른 축이다 ────────────────────────────────────────────────
31
31
  * `ItemState.expiry` 는 **날짜**이고 이것은 **상태**다. 날짜가 있으면 「지났나」는 파생이지만, 원본이
@@ -1,5 +1,24 @@
1
1
  import type { TwinModelDef, CanonicalEnvelope, StructureShift } from './contract.ts';
2
2
  import { type ProjectedState, type ReducerCheckpoint } from './state-projector.ts';
3
+ /** 저장된 기록을 처리할 것 — 도메인 커널 또는 `StateProjector`. 계약은 넷뿐이다. */
4
+ export interface JournalProjector {
5
+ apply(e: CanonicalEnvelope): void;
6
+ snapshot(): ProjectedState;
7
+ serialize(): ReducerCheckpoint;
8
+ restore(cp: ReducerCheckpoint): void;
9
+ /** 공장이 바뀐 경계에서 구조를 갈아탄다 — 여러 마디를 이어 처리할 때만 쓴다(§`replaySegments`). */
10
+ adoptStructure(model: TwinModelDef): StructureShift;
11
+ }
12
+ /** 어떤 종류로 처리할지 부르는 쪽이 말한다 — 모델에는 종류가 없다(호스트가 든다). */
13
+ export interface ReplayOptions {
14
+ /** `wms` · `yms` · `mes` · `ems`. 없으면 도메인 규칙 없이 처리한다. */
15
+ kind?: string;
16
+ /** 생산 명세(`productionSpec`) — 도메인 커널이 받는다. */
17
+ productionSpec?: unknown;
18
+ /** 테넌트 — 커널 생성자가 받는다. 저장된 기록을 처리하는 데는 쓰이지 않지만 계약이 요구한다. */
19
+ tenantId?: string;
20
+ }
21
+ export declare function journalProjector(model: TwinModelDef, opts?: ReplayOptions): JournalProjector;
3
22
  export declare class EventJournal {
4
23
  private events;
5
24
  /** 이벤트 1건 기록(추가 전용). runtime/kernel 의 onEvent 에 연결. */
@@ -14,27 +33,27 @@ export declare class EventJournal {
14
33
  untilSimTime(iso: string): CanonicalEnvelope[];
15
34
  }
16
35
  /** 이벤트열 → 상태 재구성(시간여행). `model` = 그 시점의 트윈 모델(토폴로지·자원). */
17
- export declare function replay(model: TwinModelDef, events: readonly CanonicalEnvelope[]): ProjectedState;
36
+ export declare function replay(model: TwinModelDef, events: readonly CanonicalEnvelope[], opts?: ReplayOptions): ProjectedState;
18
37
  /**
19
- * **재개점에서 이어 접는다** — 0부터 다시 접지 않는다.
38
+ * **재개점에서 이어서 계산한다** — 0부터 다시 계산하지 않는다.
20
39
  *
21
40
  * ── 왜 (2026-08-18 실측) ────────────────────────────────────────────────────
22
- * 저널이 27만 건인 트윈에서 과거 상태를 물으면 전부 다시 접어야 했다. 그런데 우리는 이미 주기적으로
23
- * 재개점을 남기고 있다 — 그 지점부터 **뒤에 일어난 것만** 접으면 같은 답이 나온다.
41
+ * 저널이 27만 건인 트윈에서 과거 상태를 물으면 전부 다시 계산해야 했다. 그런데 우리는 이미 주기적으로
42
+ * 재개점을 남기고 있다 — 그 지점부터 **뒤에 일어난 것만** 계산하면 같은 답이 나온다.
24
43
  *
25
44
  * 「같은 답」은 말로 보장되지 않는다: 재개점이 리듀서의 **내부 상태 전부**여야 하고(보기가 아니라),
26
- * 그 사실은 시험이 증명한다(0부터 접기 == 재개점 + 꼬리). 그래서 이 함수는 새 재개점도 함께 낸다 —
45
+ * 그 사실은 시험이 증명한다(0부터 계산하기 == 재개점 + 꼬리). 그래서 이 함수는 새 재개점도 함께 낸다 —
27
46
  * 소비처가 그것을 저장해 다음 꼬리를 또 이어 붙일 수 있게.
28
47
  *
29
48
  * 구조가 바뀐 구간은 여기서 다루지 않는다(`replaySegments` 의 몫이다) — 재개점은 **한 구조 안에서**
30
49
  * 이어 붙이는 것이다. 구조가 바뀌었으면 부르는 쪽이 그 경계에서 갈라야 한다.
31
50
  */
32
- export declare function replayFrom(model: TwinModelDef, checkpoint: ReducerCheckpoint, events: readonly CanonicalEnvelope[]): {
51
+ export declare function replayFrom(model: TwinModelDef, checkpoint: ReducerCheckpoint, events: readonly CanonicalEnvelope[], opts?: ReplayOptions): {
33
52
  state: ProjectedState;
34
53
  checkpoint: ReducerCheckpoint;
35
54
  };
36
- /** 이벤트열을 접고 **재개점도 함께** 낸다 — 다음 번에 이어 붙일 수 있게. */
37
- export declare function replayWithCheckpoint(model: TwinModelDef, events: readonly CanonicalEnvelope[]): {
55
+ /** 이벤트열을 계산하고 **재개점도 함께** 낸다 — 다음 번에 이어 붙일 수 있게. */
56
+ export declare function replayWithCheckpoint(model: TwinModelDef, events: readonly CanonicalEnvelope[], opts?: ReplayOptions): {
38
57
  state: ProjectedState;
39
58
  checkpoint: ReducerCheckpoint;
40
59
  };
@@ -65,15 +84,15 @@ export interface SegmentShift extends StructureShift {
65
84
  * **구조가 바뀐 이력까지 이어서 재생한다.**
66
85
  *
67
86
  * 공장은 바뀐다. 지금까지는 구조가 바뀌면 저널을 지우는 것이 유일한 길이었다 — 안 지우면 옛 이벤트를
68
- * 새 공장에 대고 접게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
69
- * 공장에 접는다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
87
+ * 새 공장에 대고 계산하게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
88
+ * 공장에 계산한다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
70
89
  *
71
- * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 전환한다(관측된 사실은 이어
90
+ * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 계산하고, 경계에서 구조만 전환한다(관측된 사실은 이어
72
91
  * 간다). 그러면 이력이 "그때 그 공장의 사실" 로 계속 읽힌다.
73
92
  *
74
93
  * 경계에서 사라진 자원은 결과에 실어 보낸다 — 수가 줄어든 것을 사용자가 눈치채지 못하면 안 된다.
75
94
  */
76
- export declare function replaySegments(segments: readonly StructureSegment[]): {
95
+ export declare function replaySegments(segments: readonly StructureSegment[], opts?: ReplayOptions): {
77
96
  state: ProjectedState;
78
97
  shifts: SegmentShift[];
79
98
  };
@@ -7,6 +7,46 @@
7
7
  * (이벤트-소싱: 상태는 항상 이벤트열의 함수. state-projector 가 폴딩 함수.)
8
8
  */
9
9
  import { StateProjector } from "./state-projector.js";
10
+ import { WmsKernel } from "./kernel.js";
11
+ import { YmsKernel } from "./yms-kernel.js";
12
+ import { MesKernel } from "./mes-kernel.js";
13
+ import { EmsKernel } from "./ems-kernel.js";
14
+ /**
15
+ * 저장된 기록으로 상태를 만드는 것 — **도메인 커널이 있으면 그것을 쓴다** (2026-08-27).
16
+ *
17
+ * ── 무엇이 틀렸나 ───────────────────────────────────────────────────────────
18
+ * 이 파일의 함수들이 `StateProjector` 하나만 세웠다. 그런데 트윈이 도는 동안 사건을 처리하는 것은
19
+ * 도메인 커널이다(호스트가 `new EmsKernel(...)` 을 세워 `apply` 를 부른다). 두 코드가 아는 사건이
20
+ * 다르다 — `StateProjector` 는 `energy.*` 를 「담을 줄 모르는 사건」으로 세기만 한다.
21
+ *
22
+ * 그래서 같은 저장 기록에서 두 답이 나왔다. 과거 시점을 물으면(`recover`) 발전량과 계량이 늘 비어
23
+ * 있었다. 받아서 저장까지 해 둔 값인데 읽는 쪽이 건너뛴 것이다.
24
+ *
25
+ * 종류를 모르면 `StateProjector` 를 쓴다 — 부르는 쪽이 종류를 말해 주지 않는 경우가 있고, 그때
26
+ * 아무 커널이나 골라 세우면 없는 규칙이 상태에 섞인다.
27
+ */
28
+ const DOMAIN_KERNELS = { wms: WmsKernel, yms: YmsKernel, mes: MesKernel, ems: EmsKernel };
29
+ export function journalProjector(model, opts) {
30
+ const Kernel = opts?.kind ? DOMAIN_KERNELS[opts.kind] : undefined;
31
+ if (!Kernel)
32
+ return new StateProjector(model);
33
+ /*
34
+ * 라이브가 세우는 순서와 **같은 순서**로 세운다(호스트 `startLive`). 순서가 다르면 같은 코드라도
35
+ * 다른 답이 나온다 — 모델을 싣기 전에 사건이 들어오면 없는 자리에 담긴다.
36
+ */
37
+ const kernel = new Kernel(String(opts?.tenantId ?? 'replay'), undefined, opts?.productionSpec);
38
+ kernel.loadTwinModel(model);
39
+ /* 관측 구동임을 세울 때 말한다 — 말하지 않으면 커널이 시뮬레이션으로 여기고 원천의 틈에서 멈춘다. */
40
+ if (typeof kernel.observe === 'function')
41
+ kernel.observe();
42
+ return {
43
+ apply: (e) => kernel.apply(e),
44
+ snapshot: () => kernel.getSnapshot(),
45
+ serialize: () => kernel.observedCheckpoint(),
46
+ restore: (cp) => kernel.restoreObserved(cp),
47
+ adoptStructure: (m) => kernel.adoptStructure(m)
48
+ };
49
+ }
10
50
  export class EventJournal {
11
51
  events = [];
12
52
  /** 이벤트 1건 기록(추가 전용). runtime/kernel 의 onEvent 에 연결. */
@@ -33,36 +73,36 @@ export class EventJournal {
33
73
  }
34
74
  }
35
75
  /** 이벤트열 → 상태 재구성(시간여행). `model` = 그 시점의 트윈 모델(토폴로지·자원). */
36
- export function replay(model, events) {
37
- const proj = new StateProjector(model);
76
+ export function replay(model, events, opts) {
77
+ const proj = journalProjector(model, opts);
38
78
  for (const e of events)
39
79
  proj.apply(e);
40
80
  return proj.snapshot();
41
81
  }
42
82
  /**
43
- * **재개점에서 이어 접는다** — 0부터 다시 접지 않는다.
83
+ * **재개점에서 이어서 계산한다** — 0부터 다시 계산하지 않는다.
44
84
  *
45
85
  * ── 왜 (2026-08-18 실측) ────────────────────────────────────────────────────
46
- * 저널이 27만 건인 트윈에서 과거 상태를 물으면 전부 다시 접어야 했다. 그런데 우리는 이미 주기적으로
47
- * 재개점을 남기고 있다 — 그 지점부터 **뒤에 일어난 것만** 접으면 같은 답이 나온다.
86
+ * 저널이 27만 건인 트윈에서 과거 상태를 물으면 전부 다시 계산해야 했다. 그런데 우리는 이미 주기적으로
87
+ * 재개점을 남기고 있다 — 그 지점부터 **뒤에 일어난 것만** 계산하면 같은 답이 나온다.
48
88
  *
49
89
  * 「같은 답」은 말로 보장되지 않는다: 재개점이 리듀서의 **내부 상태 전부**여야 하고(보기가 아니라),
50
- * 그 사실은 시험이 증명한다(0부터 접기 == 재개점 + 꼬리). 그래서 이 함수는 새 재개점도 함께 낸다 —
90
+ * 그 사실은 시험이 증명한다(0부터 계산하기 == 재개점 + 꼬리). 그래서 이 함수는 새 재개점도 함께 낸다 —
51
91
  * 소비처가 그것을 저장해 다음 꼬리를 또 이어 붙일 수 있게.
52
92
  *
53
93
  * 구조가 바뀐 구간은 여기서 다루지 않는다(`replaySegments` 의 몫이다) — 재개점은 **한 구조 안에서**
54
94
  * 이어 붙이는 것이다. 구조가 바뀌었으면 부르는 쪽이 그 경계에서 갈라야 한다.
55
95
  */
56
- export function replayFrom(model, checkpoint, events) {
57
- const proj = new StateProjector(model);
96
+ export function replayFrom(model, checkpoint, events, opts) {
97
+ const proj = journalProjector(model, opts);
58
98
  proj.restore(checkpoint);
59
99
  for (const e of events)
60
100
  proj.apply(e);
61
101
  return { state: proj.snapshot(), checkpoint: proj.serialize() };
62
102
  }
63
- /** 이벤트열을 접고 **재개점도 함께** 낸다 — 다음 번에 이어 붙일 수 있게. */
64
- export function replayWithCheckpoint(model, events) {
65
- const proj = new StateProjector(model);
103
+ /** 이벤트열을 계산하고 **재개점도 함께** 낸다 — 다음 번에 이어 붙일 수 있게. */
104
+ export function replayWithCheckpoint(model, events, opts) {
105
+ const proj = journalProjector(model, opts);
66
106
  for (const e of events)
67
107
  proj.apply(e);
68
108
  return { state: proj.snapshot(), checkpoint: proj.serialize() };
@@ -71,18 +111,18 @@ export function replayWithCheckpoint(model, events) {
71
111
  * **구조가 바뀐 이력까지 이어서 재생한다.**
72
112
  *
73
113
  * 공장은 바뀐다. 지금까지는 구조가 바뀌면 저널을 지우는 것이 유일한 길이었다 — 안 지우면 옛 이벤트를
74
- * 새 공장에 대고 접게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
75
- * 공장에 접는다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
114
+ * 새 공장에 대고 계산하게 되어 이력이 거짓말을 하기 때문이다(도장 부스가 둘이던 시절의 사실을 여섯 개짜리
115
+ * 공장에 계산한다). 역사를 잃거나 거짓말을 하거나, 둘뿐이었다.
76
116
  *
77
- * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 접고, 경계에서 구조만 전환한다(관측된 사실은 이어
117
+ * 셋째 길이 이것이다: 마디마다 **그때의 구조**로 계산하고, 경계에서 구조만 전환한다(관측된 사실은 이어
78
118
  * 간다). 그러면 이력이 "그때 그 공장의 사실" 로 계속 읽힌다.
79
119
  *
80
120
  * 경계에서 사라진 자원은 결과에 실어 보낸다 — 수가 줄어든 것을 사용자가 눈치채지 못하면 안 된다.
81
121
  */
82
- export function replaySegments(segments) {
122
+ export function replaySegments(segments, opts) {
83
123
  if (!segments.length)
84
124
  throw new Error('재생할 마디가 없다 — 구조를 하나도 주지 않았다');
85
- const proj = new StateProjector(segments[0].model);
125
+ const proj = journalProjector(segments[0].model, opts);
86
126
  const shifts = [];
87
127
  for (let i = 0; i < segments.length; i++) {
88
128
  /* 첫 마디는 생성자가 이미 그 구조로 섰다 — 두 번 세우면 관측 전 상태를 다시 덮는다. */
@@ -550,7 +550,7 @@ export declare abstract class FlowEngine implements TwinKernel {
550
550
  * 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
551
551
  *
552
552
  * 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
553
- * 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
553
+ * 주입은 호스트가 한다(저널을 계산하는 쪽이 이력을 안다) — 커널은 시임만 든다.
554
554
  */
555
555
  yieldEstimator?: YieldEstimator;
556
556
  /**
@@ -572,7 +572,7 @@ export declare abstract class FlowEngine implements TwinKernel {
572
572
  * 읽는 쪽(`paramNumber`·`paramDuration`)이 명세 행보다 이것을 먼저 본다.
573
573
  */
574
574
  protected localParams: Map<string, Map<string, string>>;
575
- /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
575
+ /** 관측 구동(P0) — 이벤트를 계산하는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
576
576
  private observer?;
577
577
  /**
578
578
  * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
@@ -745,7 +745,7 @@ export declare abstract class FlowEngine implements TwinKernel {
745
745
  * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
746
746
  * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
747
747
  *
748
- * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
748
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 계산해 만든 것들이다:
749
749
  * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
750
750
  * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
751
751
  *
@@ -800,7 +800,7 @@ export declare abstract class FlowEngine implements TwinKernel {
800
800
  * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
801
801
  * 사실이 씨앗 규칙에 걸려 사라졌다.
802
802
  *
803
- * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
803
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 계산하면 작업 2,881건인데
804
804
  * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
805
805
  * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
806
806
  *
@@ -835,7 +835,7 @@ export declare abstract class FlowEngine implements TwinKernel {
835
835
  */
836
836
  private _acked;
837
837
  /**
838
- * 완료 시점에 **주체가 사라져 접은 작업 수** — 조용한 누락이 되지 않게 센다.
838
+ * 완료 시점에 **주체가 사라져 제외한 작업 수** — 조용한 누락이 되지 않게 센다.
839
839
  *
840
840
  * 물품이 포장·출하·소비로 없어지는 것은 정상이지만, 그 물품을 향한 작업이 남아 있었다는 것은
841
841
  * 상류에 어긋남이 있다는 신호다. 0 이 아니면 그 사실을 소비처가 볼 수 있어야 한다.
@@ -955,7 +955,7 @@ export declare abstract class FlowEngine implements TwinKernel {
955
955
  * 어긋난다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
956
956
  * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
957
957
  *
958
- * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
958
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 계산하고, 그 결과를 씨앗 경로
959
959
  * (`hydrateObserved`)로 커널 상태에 주입한다. 그래서 관측으로 실행한 커널을 그대로 `fork`·`tick` 할 수
960
960
  * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
961
961
  *
@@ -1434,7 +1434,7 @@ export declare abstract class FlowEngine implements TwinKernel {
1434
1434
  *
1435
1435
  * 그때 도메인 훅은 없는 것을 딛으려다 오류를 낸다(`order.gtin` · `item.location`). 그 예외 하나가
1436
1436
  * **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
1437
- * 여기서 묻고, 없으면 그 작업만 접는다.
1437
+ * 여기서 묻고, 없으면 그 작업만 제외한다.
1438
1438
  */
1439
1439
  protected missingContextOf(t: FlowTask): 'item' | 'order' | 'domain' | undefined;
1440
1440
  /**
@@ -16,6 +16,8 @@ import { OP_PARAM } from "./domain-definition.js";
16
16
  import { parseIsoDuration } from "./iso-duration.js";
17
17
  import { analyzeCapacity } from "./capacity.js";
18
18
  import { operationsCapabilityOf } from "./operations-capability.js";
19
+ /* 에너지 상태 필드는 **능력 선언에서 뽑는다** — 손으로 적으면 능력에 필드를 더한 날 스냅샷이 빠진다. */
20
+ import { stateFieldsOf } from "./capability.js";
19
21
  const BASE_EPOCH = Date.parse('2026-01-01T00:00:00Z');
20
22
  /**
21
23
  * 선언한 유효 기간만 뽑는다 — 마스터가 말하지 않으면 필드를 만들지 않는다.
@@ -358,14 +360,27 @@ export function computeOee(c, nowMs) {
358
360
  * 에너지 능력이 선언한 상태 필드를 스냅샷에 싣는다 — **온 것만.**
359
361
  *
360
362
  * 능력 계약이 이 필드들을 관측으로 선언해 두었는데 투영이 싣지 않으면, 값이 커널 안에서만 살고
361
- * 화면·성과·저널 어디에도 나타나지 않는다(실제로 그랬다: 값을 적었는데 스냅샷에 없었다).
363
+ * 화면·성과·지난 기록 어디에도 나타나지 않는다(실제로 그랬다: 값을 적었는데 스냅샷에 없었다).
362
364
  *
363
365
  * 값이 없으면 **키를 만들지 않는다** — 에너지에서 「모른다」와 「0 이다」는 요금이 갈리는 차이다.
366
+ *
367
+ * ── 목록을 손으로 적지 않는다 (2026-08-26) ─────────────────────────────────
368
+ * 여기가 필드 이름을 하드코딩하고 있었다. 그래서 능력에 필드를 하나 더하면 이 줄도 함께 고쳐야 했고,
369
+ * 잊으면 **바로 위 주석이 말하는 그 결함**이 다시 난다 — 실제로 그렇게 났다: 발전 적산
370
+ * (`generatedKWh`)을 능력에 선언하고 값을 적었는데 그때의 상태에 그 값이 없었다.
371
+ *
372
+ * 그래서 **선언에서 뽑는다.** 능력이 정본이고(`CAPABILITIES[*].stateFields`) 이 함수는 그것을 따른다.
373
+ * `measuredAt` 만 손으로 더한다 — 그것은 어느 능력의 값도 아니라 **언제 들었나**이고, 에너지 상태
374
+ * 필드 전체에 붙는 사실이다.
364
375
  */
376
+ const ENERGY_STATE_FIELDS = [
377
+ ...stateFieldsOf(['metered', 'curtailable', 'energyGenerating', 'energyStoring', 'switching']),
378
+ 'measuredAt'
379
+ ];
365
380
  function energyFieldsOf(m) {
366
381
  const out = {};
367
382
  const src = m;
368
- for (const k of ['generatedKW', 'exportKW', 'soc', 'chargeKW', 'dischargeKW', 'curtailable', 'minKW', 'position', 'measuredAt']) {
383
+ for (const k of ENERGY_STATE_FIELDS) {
369
384
  if (src[k] !== undefined)
370
385
  out[k] = src[k];
371
386
  }
@@ -636,7 +651,7 @@ export class FlowEngine {
636
651
  * 양품률 시임(선택) — **이력에서 배운 수율.** 소요시간과 같은 자리·같은 규율이다 (2026-08-19).
637
652
  *
638
653
  * 없으면 선언값(현장→원천), 그것도 없으면 도메인 상수. 무엇을 썼는지는 `specCoverage()` 가 밝힌다.
639
- * 주입은 호스트가 한다(저널을 접는 쪽이 이력을 안다) — 커널은 시임만 든다.
654
+ * 주입은 호스트가 한다(저널을 계산하는 쪽이 이력을 안다) — 커널은 시임만 든다.
640
655
  */
641
656
  yieldEstimator;
642
657
  /**
@@ -658,7 +673,7 @@ export class FlowEngine {
658
673
  * 읽는 쪽(`paramNumber`·`paramDuration`)이 명세 행보다 이것을 먼저 본다.
659
674
  */
660
675
  localParams = new Map();
661
- /** 관측 구동(P0) — 이벤트를 접는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
676
+ /** 관측 구동(P0) — 이벤트를 계산하는 투영기와 그 사실. tick 과 섞이지 않게 명시적으로 들고 있다. */
662
677
  observer;
663
678
  /**
664
679
  * **관측 리듀서의 재개점을 꺼낸다** — 호스트가 저장해 다음 기동에서 되돌릴 수 있게.
@@ -678,7 +693,23 @@ export class FlowEngine {
678
693
  * 관측 구동이 아니면 `undefined` — 시뮬은 리듀서를 갖지 않는다(저장할 것이 없다).
679
694
  */
680
695
  observedCheckpoint() {
681
- return this.observer?.serialize();
696
+ /*
697
+ * 관측 구동인데 리듀서가 아직 없으면 만든다 (2026-08-27).
698
+ *
699
+ * 리듀서는 첫 봉투가 이 클래스의 `apply` 에 닿을 때 만들어진다. 그런데 도메인 커널이 그 봉투를
700
+ * 가로채고 돌아가면(에너지 사건이 그렇다) 리듀서가 만들어지지 않는다. 그러면 이 함수가
701
+ * `undefined` 를 내고, 호스트는 재개점이 없다고 보아 다음 조회마다 저장 기록을 처음부터 다시
702
+ * 처리한다 — 에너지만 들어오는 트윈에서 실제로 그랬다.
703
+ */
704
+ if (!this.observer && this.observeMode) {
705
+ this.observer = new ObservedReducer(this.boardDef ?? { locations: [], equipment: [] });
706
+ }
707
+ const cp = this.observer?.serialize();
708
+ if (!cp)
709
+ return undefined;
710
+ /* 도메인 커널이 따로 드는 상태를 함께 담는다 — 없으면 그 칸을 만들지 않는다. */
711
+ const domain = typeof this.serializeDomain === 'function' ? this.serializeDomain() : undefined;
712
+ return domain === undefined ? cp : { ...cp, domain };
682
713
  }
683
714
  /**
684
715
  * **재개점에서 관측 리듀서를 되세운다** — 저널을 0부터 다시 집계하지 않게.
@@ -698,6 +729,11 @@ export class FlowEngine {
698
729
  this.observeMode = true;
699
730
  }
700
731
  this.observer.restore(cp);
732
+ /* 도메인 커널이 따로 드는 상태를 되돌린다 — 담기지 않았으면 되돌릴 것도 없다. */
733
+ if (cp?.domain !== undefined && typeof this.restoreDomain === 'function') {
734
+ ;
735
+ this.restoreDomain(cp.domain);
736
+ }
701
737
  this.observedDirty = true;
702
738
  this.settleObserved();
703
739
  }
@@ -942,7 +978,7 @@ export class FlowEngine {
942
978
  * 미러의 진실은 원천이다. 그래서 재고·위치·설비 상태는 스냅샷에서 심지 않는다 — 다음 계측·다음
943
979
  * 마스터 재읽기가 곧 정정하므로, 심으면 **떠난 물건이 되살아나는** 위험만 남는다.
944
980
  *
945
- * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 접어 만든 것들이다:
981
+ * 그런데 원천이 **애초에 다시 말해 주지 않는 축**이 있다. 우리가 계산해 만든 것들이다:
946
982
  * · 확인(ack)·조건이 처음 성립한 시각 — 사람이 한 일과 우리가 기억한 시각
947
983
  * · 열린 구간의 누적·적산 기준점·관측 이후 최대 — SCADA 는 「이번 15분에 지금까지 얼마」를 모른다
948
984
  *
@@ -990,7 +1026,7 @@ export class FlowEngine {
990
1026
  * 미러는 그 작업을 굴리지 않는다 — **들은 것을 말할 뿐이다.** 그런데 규칙이 하나였으므로 관측된
991
1027
  * 사실이 씨앗 규칙에 걸려 사라졌다.
992
1028
  *
993
- * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 접으면 작업 2,881건인데
1029
+ * 실측: 포천 미러의 저널에 `task.status` 5,786건이 있고 투영기로 계산하면 작업 2,881건인데
994
1030
  * `getSnapshot().tasks` 는 **0** 이었다. 화면은 「아직 하나도 없습니다」라고 말했고 사용자가
995
1031
  * 반나절을 찾았다. 재현하면 관측기 5건 → 커널 0건이고, 걸린 조건은 오더였다.
996
1032
  *
@@ -1014,7 +1050,7 @@ export class FlowEngine {
1014
1050
  }
1015
1051
  this.items.clear();
1016
1052
  for (const it of snap.items) {
1017
- /* **부분마다 한 줄로 주입한다** — `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 접혀
1053
+ /* **부분마다 한 줄로 주입한다** — `epc` 로 키를 잡으면 같은 로트의 두 부분이 하나로 합쳐져
1018
1054
  씨앗에서 재고가 줄어든다(§MaterialSubLot 에서 겪은 것과 같은 오류의 세 번째 자리). */
1019
1055
  this.items.set(itemKeyOf(it), {
1020
1056
  /* 품번 키·로트는 식별자에서 파생되므로 주입하지 않는다(스냅샷이 다시 낸다 — 두 벌을 두면 어긋난다). */
@@ -1077,6 +1113,20 @@ export class FlowEngine {
1077
1113
  ...(m.testSpecificationIds ? { testSpecificationIds: m.testSpecificationIds } : {}),
1078
1114
  /* 결과도 이어받는다 — 잃으면 예측이 **자격 만료를 모르는 현장**에서 출발한다(낙관 쪽으로 치우친다). */
1079
1115
  ...(m.testResults ? { testResults: m.testResults } : {}),
1116
+ /*
1117
+ * ── 에너지 값도 이어받는다 (2026-08-26) ────────────────────────────────
1118
+ *
1119
+ * 이 자리가 에너지 필드를 심지 않았다. 그래서 재기동하면 설비의 발전량·충전율·계량값이 빈 채로
1120
+ * 시작하고, 다음 표본이 올 때까지 화면이 비어 보였다. 태양광 발전소에서 관측했다 — 저장된
1121
+ * 상태에는 `generatedKW` 가 있는데 되살린 트윈에는 없었다.
1122
+ *
1123
+ * 어느 필드를 이어받는지는 **능력 선언에서 뽑는다**(`ENERGY_STATE_FIELDS`). 스냅샷을 만드는
1124
+ * 쪽(`energyFieldsOf`)과 같은 목록이다 — 두 곳에 손으로 적으면 한쪽이 빠진 날 값이 조용히
1125
+ * 사라진다. 실제로 오늘 그렇게 났다.
1126
+ *
1127
+ * 값이 없으면 키를 만들지 않는다 — 에너지에서 「모른다」와 「0 이다」는 요금이 갈리는 차이다.
1128
+ */
1129
+ ...energyFieldsOf(m),
1080
1130
  ...effectiveOnly(m)
1081
1131
  });
1082
1132
  }
@@ -1407,7 +1457,7 @@ export class FlowEngine {
1407
1457
  */
1408
1458
  _acked = new Set();
1409
1459
  /**
1410
- * 완료 시점에 **주체가 사라져 접은 작업 수** — 조용한 누락이 되지 않게 센다.
1460
+ * 완료 시점에 **주체가 사라져 제외한 작업 수** — 조용한 누락이 되지 않게 센다.
1411
1461
  *
1412
1462
  * 물품이 포장·출하·소비로 없어지는 것은 정상이지만, 그 물품을 향한 작업이 남아 있었다는 것은
1413
1463
  * 상류에 어긋남이 있다는 신호다. 0 이 아니면 그 사실을 소비처가 볼 수 있어야 한다.
@@ -2035,7 +2085,7 @@ export class FlowEngine {
2035
2085
  * 어긋난다(2026-08-01 하루에 아홉 곳). 근본 해법은 **한 상태 모델 두 구동**이고, 이것은 그 실현
2036
2086
  * 가능성을 재는 스파이크다(design/plans/kernel-unification-live-observe.md P0).
2037
2087
  *
2038
- * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 접고, 그 결과를 씨앗 경로
2088
+ * 여기서는 **이미 검증된 조각을 조립**한다: 투영기가 이벤트를 계산하고, 그 결과를 씨앗 경로
2039
2089
  * (`hydrateObserved`)로 커널 상태에 주입한다. 그래서 관측으로 실행한 커널을 그대로 `fork`·`tick` 할 수
2040
2090
  * 있다 — "미러에서 예측한다" 가 별도 배관 없이 성립하는지가 이 스파이크의 질문이다.
2041
2091
  *
@@ -3159,7 +3209,7 @@ export class FlowEngine {
3159
3209
  *
3160
3210
  * 그때 도메인 훅은 없는 것을 딛으려다 오류를 낸다(`order.gtin` · `item.location`). 그 예외 하나가
3161
3211
  * **예측 전체를 죽였다** — 사용자에게는 기능이 통째로 사라진 것으로 보였다. 그래서 완료 **전에**
3162
- * 여기서 묻고, 없으면 그 작업만 접는다.
3212
+ * 여기서 묻고, 없으면 그 작업만 제외한다.
3163
3213
  */
3164
3214
  missingContextOf(t) {
3165
3215
  if (t.itemEpc && !this.itemByRef(t.itemEpc))
@@ -3831,7 +3881,7 @@ export class FlowEngine {
3831
3881
  * 들어갈 때 없어지고 나올 때 생긴다.
3832
3882
  */
3833
3883
  /*
3834
- * **주체가 사라진 작업은 완료시키지 않고 접는다.**
3884
+ * **주체가 사라진 작업은 완료시키지 않고 계산한다.**
3835
3885
  *
3836
3886
  * 진행 중에 그 물품이 없어질 수 있다(포장으로 합쳐지고, 출하로 나가고, 다른 공정이 소비한다).
3837
3887
  * 그때 도메인 훅은 "없는 물품을 옮기려" 하다 오류를 냈고, **그 예외 하나가 예측 전체를 죽였다** —