@things-factory/headless-twin 10.0.4 → 10.0.6

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.
Files changed (48) hide show
  1. package/dist-server/engine/index.d.ts +2 -0
  2. package/dist-server/engine/index.js +2 -0
  3. package/dist-server/engine/index.js.map +1 -1
  4. package/dist-server/engine/kpi-fold.d.ts +179 -0
  5. package/dist-server/engine/kpi-fold.js +270 -0
  6. package/dist-server/engine/kpi-fold.js.map +1 -0
  7. package/dist-server/engine/kpi-query.d.ts +156 -0
  8. package/dist-server/engine/kpi-query.js +287 -0
  9. package/dist-server/engine/kpi-query.js.map +1 -0
  10. package/dist-server/engine/twin-engine.d.ts +29 -2
  11. package/dist-server/engine/twin-engine.js +78 -21
  12. package/dist-server/engine/twin-engine.js.map +1 -1
  13. package/dist-server/engine/warm-start.d.ts +39 -0
  14. package/dist-server/engine/warm-start.js +37 -0
  15. package/dist-server/engine/warm-start.js.map +1 -0
  16. package/dist-server/index.js +8 -0
  17. package/dist-server/index.js.map +1 -1
  18. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  19. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  20. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  21. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  22. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  23. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  24. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  25. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  26. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  27. package/dist-server/service/twin-event/twin-event.d.ts +5 -0
  28. package/dist-server/service/twin-event/twin-event.js +45 -0
  29. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  30. package/dist-server/service/twin-journal/twin-journal-query.d.ts +43 -0
  31. package/dist-server/service/twin-journal/twin-journal-query.js +127 -0
  32. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  33. package/dist-server/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +6 -6
  35. package/server/engine/index.ts +2 -0
  36. package/server/engine/kpi-fold.ts +417 -0
  37. package/server/engine/kpi-query.ts +435 -0
  38. package/server/engine/twin-engine.ts +95 -28
  39. package/server/engine/warm-start.ts +53 -0
  40. package/server/index.ts +9 -0
  41. package/server/service/twin-event/backfill-keys.ts +72 -0
  42. package/server/service/twin-event/twin-event-keys.ts +102 -0
  43. package/server/service/twin-event/twin-event-type.ts +27 -0
  44. package/server/service/twin-event/twin-event.ts +48 -0
  45. package/server/service/twin-journal/twin-journal-query.ts +136 -2
  46. package/test/kpi-fold.test.ts +469 -0
  47. package/test/twin-event-keys.test.ts +108 -0
  48. package/test/warm-start.test.ts +78 -0
@@ -0,0 +1,435 @@
1
+ /*
2
+ * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.
3
+ *
4
+ * 왜 한 곳인가: 처음에 GraphQL 질의와 AI 도구가 각각 같은 조회를 적었다. 그러면 창 규칙이 두 곳에서
5
+ * 갈라지고(한쪽만 고치면 화면과 AI 가 다른 숫자를 말한다) 조회 상한·테넌트 조건도 복제된다.
6
+ * 계산은 `kpi-fold`(순수)가 소유하고, 이 파일은 **무엇을 읽을지**만 정한다.
7
+ *
8
+ * ── 창의 기준 시각은 "트윈의 시계" 다 ──────────────────────────────────────
9
+ * 시뮬 트윈은 자기 클록으로 산다(예: 저널의 eventTime 이 2026-01-01T00:25Z 인데 실제 지금은 7월).
10
+ * 그런 트윈에 "최근 1시간" 을 **실제 시각**으로 물으면 그 구간에는 아무 기록이 없어 항상
11
+ * "측정된 기록 없음" 이 된다 — 폴드는 정상인데 답이 쓸모없다.
12
+ *
13
+ * 그래서 호출부가 끝 시각을 주지 않으면 **그 트윈의 마지막 기록 시각**을 끝으로 잡는다. 커널의 시각
14
+ * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).
15
+ * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.
16
+ */
17
+ import { Between, In } from 'typeorm'
18
+
19
+ import { getRepository } from '@things-factory/shell'
20
+
21
+ import { TwinEvent } from '../service/twin-event/twin-event.js'
22
+ import { TwinInstance } from '../service/twin-instance/twin-instance.js'
23
+ import { TwinArea } from '../service/twin-space/twin-area.js'
24
+ import { TwinSpace } from '../service/twin-space/twin-space.js'
25
+ import { TwinEngine } from './twin-engine.js'
26
+ import {
27
+ crossGroups,
28
+ deltaP50,
29
+ foldKpi,
30
+ foldKpiBuckets,
31
+ shiftWindow,
32
+ type CrossedGroup,
33
+ type KpiEvent,
34
+ type KpiGroupBy,
35
+ type KpiResult
36
+ } from './kpi-fold.js'
37
+
38
+ /** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */
39
+ const BUSINESS_EVENTS = ['task.status', 'order.status']
40
+
41
+ /** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */
42
+ const MAX_EVENTS = 20000
43
+
44
+ export interface TwinKpiInput {
45
+ domainId: string
46
+ /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */
47
+ instanceId?: string
48
+ /**
49
+ * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 접는다**.
50
+ *
51
+ * 트윈별로 접어 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다).
52
+ * 그래서 이벤트를 모아 **한 번** 접는다. 사용자는 "부산 창고" 를 보고, 그 안에 트윈이 몇 개든
53
+ * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율).
54
+ */
55
+ spaceId?: string
56
+ /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */
57
+ toTime?: string
58
+ /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */
59
+ fromTime?: string
60
+ /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */
61
+ windowMinutes?: number
62
+ /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */
63
+ lookbackMinutes?: number
64
+ /**
65
+ * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음).
66
+ * 조회는 한 번이고 접기만 N 번 반복한다 — 폴드가 순수하므로 값싸다.
67
+ */
68
+ buckets?: number
69
+ /**
70
+ * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별.
71
+ *
72
+ * 고정된 한 렌즈만 낼 수 있으면 "구역별로는 어떤가" 라는 질문에 화면도 AI 도 답할 수 없다.
73
+ * 축은 조회 인자 하나이고, 접기는 순수 함수라 축을 하나 더 보는 값이 거의 없다.
74
+ */
75
+ groupBy?: KpiGroupBy
76
+ /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */
77
+ groupLimit?: number
78
+ /**
79
+ * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday).
80
+ *
81
+ * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다("29초" 가 개선인지 악화인지). 창만 옮겨 한 번 더
82
+ * 접으면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** —
83
+ * 없는 것을 0% 로 그리면 "변화 없음" 이라는 거짓이 된다.
84
+ */
85
+ compareTo?: 'previous' | 'yesterday'
86
+ }
87
+
88
+ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
89
+ /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */
90
+ window: {
91
+ fromTime: string
92
+ toTime: string
93
+ minutes: number
94
+ /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */
95
+ basis: 'given' | 'latest-event'
96
+ }
97
+ /**
98
+ * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다).
99
+ *
100
+ * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 조용히 잘라내면 숫자가 조용히
101
+ * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다.
102
+ */
103
+ eventsCapped?: boolean
104
+ /**
105
+ * 이 구간에 **측정할 기록이 있었는가**. false 는 "처리량 0" 이 아니라 **"기록이 없다"** 는 뜻이다 —
106
+ * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다.
107
+ */
108
+ measured: boolean
109
+ note?: string
110
+ /** 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */
111
+ /**
112
+ * 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.
113
+ *
114
+ * `realityModes` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·
115
+ * sim-world(가상 세계) · sim-experiment(실험 사본). 같은 "345건" 이 현실의 실적일 수도, 시뮬레이션의
116
+ * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.
117
+ */
118
+ scope: { spaceId?: string; instanceIds: string[]; realityModes: string[] }
119
+ /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */
120
+ buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]
121
+ /**
122
+ * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.
123
+ *
124
+ * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다).
125
+ * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는
126
+ * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다.
127
+ */
128
+ comparison?: {
129
+ basis: 'previous' | 'yesterday'
130
+ window: { fromTime: string; toTime: string }
131
+ measured: boolean
132
+ throughput?: { tasks: number; orders: number }
133
+ workTime?: KpiResult['workTime']
134
+ leadTime?: KpiResult['leadTime']
135
+ waitTime?: KpiResult['waitTime']
136
+ delta?: { tasks: number; orders: number; workP50Ms?: number; leadP50Ms?: number; waitP50Ms?: number }
137
+ }
138
+ /**
139
+ * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이
140
+ * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.
141
+ */
142
+ groups?: {
143
+ by: KpiGroupBy
144
+ items: (KpiGroupOut & { label?: string })[]
145
+ /**
146
+ * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·놀고 있는 자원이
147
+ * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다.
148
+ */
149
+ disappeared?: { key: string; label?: string; tasks: number }[]
150
+ /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */
151
+ truncated?: number
152
+ truncatedTasks?: number
153
+ note?: string
154
+ }
155
+ }
156
+
157
+ /** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */
158
+ type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] &
159
+ Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>
160
+
161
+ /**
162
+ * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가
163
+ * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
164
+ *
165
+ * 구역 이름은 `TwinArea` 에 따로 있다 — id 만 보여주면 사용자가 무엇인지 모른다.
166
+ */
167
+ async function nodeAreaMap(
168
+ domainId: string,
169
+ instanceIds: string[],
170
+ spaceId?: string
171
+ ): Promise<{ nodeArea: Record<string, string>; labels: Record<string, string> }> {
172
+ const nodeArea: Record<string, string> = {}
173
+ if (instanceIds.length) {
174
+ const rows = await getRepository(TwinInstance).find({
175
+ where: { domain: { id: domainId }, instanceId: In(instanceIds) }
176
+ })
177
+ for (const row of rows) {
178
+ for (const n of ((row.board as any)?.nodes ?? []) as any[]) {
179
+ if (n?.id && n?.parentId) nodeArea[String(n.id)] = String(n.parentId)
180
+ }
181
+ }
182
+ }
183
+
184
+ const labels: Record<string, string> = {}
185
+ const sid = spaceId
186
+ if (sid) {
187
+ const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } })
188
+ if (space) {
189
+ const areas = await getRepository(TwinArea).find({
190
+ where: { domain: { id: domainId } as any, space: { id: space.id } as any }
191
+ })
192
+ for (const a of areas) if (a.areaId && a.name) labels[a.areaId] = a.name
193
+ }
194
+ }
195
+ return { nodeArea, labels }
196
+ }
197
+
198
+ /** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */
199
+ async function latestEventTime(domainId: string, instanceIds: string[]): Promise<string | null> {
200
+ if (instanceIds.length === 0) return null
201
+ const row = await getRepository(TwinEvent).findOne({
202
+ where: { domain: { id: domainId }, instanceId: In(instanceIds), eventType: In(BUSINESS_EVENTS) },
203
+ order: { revision: 'DESC' }
204
+ })
205
+ return row?.eventTime ?? null
206
+ }
207
+
208
+ /**
209
+ * 창 구간의 업무 이벤트 — 창보다 `lookbackMs` 만큼 앞까지 읽는다(완료된 작업의 시작이 창 밖일 수 있다).
210
+ *
211
+ * eventTime 은 ISO 문자열 컬럼이라 사전식 비교가 성립한다(같은 파일군의 시각 커서와 동일 전제).
212
+ * 비교 구간도 같은 규칙으로 읽어야 하므로 함수로 둔다 — 두 곳에 적으면 규칙이 갈라진다.
213
+ */
214
+ async function fetchEvents(domainId: string, instanceIds: string[], fromMs: number, toMs: number, lookbackMs: number) {
215
+ if (instanceIds.length === 0) return { events: [] as KpiEvent[], capped: false }
216
+ const rows = await getRepository(TwinEvent).find({
217
+ where: {
218
+ domain: { id: domainId },
219
+ instanceId: In(instanceIds),
220
+ eventType: In(BUSINESS_EVENTS),
221
+ eventTime: Between(new Date(fromMs - lookbackMs).toISOString(), new Date(toMs).toISOString())
222
+ },
223
+ order: { revision: 'ASC' },
224
+ take: MAX_EVENTS
225
+ })
226
+ /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히
227
+ * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */
228
+ return {
229
+ events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime, payload: r.payload })),
230
+ capped: rows.length >= MAX_EVENTS
231
+ }
232
+ }
233
+
234
+ /**
235
+ * 집계 대상 트윈 목록.
236
+ *
237
+ * 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.
238
+ * 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.
239
+ */
240
+ async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: string[]; realityModes: string[] }> {
241
+ if (!input.instanceId && !input.spaceId) throw new Error('either instanceId or spaceId is required')
242
+ const list = await TwinEngine.list(input.domainId)
243
+ const rows = input.instanceId
244
+ ? list.filter((x: any) => x?.instanceId === input.instanceId)
245
+ : list.filter((x: any) => x?.running && x.spaceId === input.spaceId && x.purpose !== 'bench' && x.instanceId)
246
+ /* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */
247
+ const instanceIds = rows.length ? rows.map((x: any) => x.instanceId as string) : input.instanceId ? [input.instanceId] : []
248
+ const realityModes = [...new Set(rows.map((x: any) => String(x.realityMode ?? 'unknown')))]
249
+ return { instanceIds, realityModes }
250
+ }
251
+
252
+ /**
253
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
254
+ *
255
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
256
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
257
+ */
258
+ export async function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]> {
259
+ const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId } as TwinKpiInput)
260
+ return instanceIds
261
+ }
262
+
263
+ /**
264
+ * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
265
+ *
266
+ * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).
267
+ */
268
+ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput> {
269
+ const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60))
270
+ const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000
271
+
272
+ const { instanceIds, realityModes } = await resolveTargets(input)
273
+ const scope = { spaceId: input.spaceId, instanceIds, realityModes }
274
+
275
+ /* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */
276
+ let basis: 'given' | 'latest-event' = 'given'
277
+ let toMs = input.toTime ? Date.parse(input.toTime) : NaN
278
+ if (!Number.isFinite(toMs)) {
279
+ const latest = await latestEventTime(input.domainId, instanceIds)
280
+ if (!latest) {
281
+ const now = Date.now()
282
+ return {
283
+ window: {
284
+ fromTime: new Date(now - minutes * 60_000).toISOString(),
285
+ toTime: new Date(now).toISOString(),
286
+ minutes,
287
+ basis: 'latest-event'
288
+ },
289
+ measured: false,
290
+ scope,
291
+ note:
292
+ instanceIds.length === 0
293
+ ? 'no running twin in this space — nothing to measure.'
294
+ : 'no operational events in the journal yet — there is nothing to measure (this is NOT "zero throughput").'
295
+ }
296
+ }
297
+ toMs = Date.parse(latest)
298
+ basis = 'latest-event'
299
+ }
300
+
301
+ const fromMs = input.fromTime ? Date.parse(input.fromTime) : toMs - minutes * 60_000
302
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) {
303
+ throw new Error('invalid window: fromTime must be a valid time before toTime')
304
+ }
305
+
306
+ const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs)
307
+
308
+ /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
309
+ const areaMap =
310
+ input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} }
311
+ const kpi = foldKpi(events, { fromMs, toMs }, {
312
+ groupBy: input.groupBy,
313
+ nodeArea: areaMap.nodeArea,
314
+ groupLimit: input.groupLimit
315
+ })
316
+ const window = {
317
+ fromTime: new Date(fromMs).toISOString(),
318
+ toTime: new Date(toMs).toISOString(),
319
+ minutes: Math.round((toMs - fromMs) / 60_000),
320
+ basis
321
+ }
322
+ if (kpi.eventsInWindow === 0) {
323
+ return {
324
+ window,
325
+ measured: false,
326
+ scope,
327
+ note: 'no operational events in this window — nothing to measure (this is NOT "zero throughput").'
328
+ }
329
+ }
330
+
331
+ /* 구간별 값 — 쪼개기 규칙은 순수 모듈이 소유한다(테스트 가능). 여기서는 시각을 ISO 로 옮기기만. */
332
+ const rawBuckets = foldKpiBuckets(events, { fromMs, toMs }, Number(input.buckets) || 0)
333
+ const buckets = rawBuckets.length
334
+ ? rawBuckets.map(b => ({
335
+ fromTime: new Date(b.fromMs).toISOString(),
336
+ toTime: new Date(b.toMs).toISOString(),
337
+ tasks: b.tasks,
338
+ orders: b.orders,
339
+ workP50Ms: b.workP50Ms
340
+ }))
341
+ : undefined
342
+
343
+ /* 비교 구간 — **같은 길이·같은 규칙**으로 한 번 더 접는다. 기록이 없으면 delta 를 만들지 않는다
344
+ * (없는 것을 0 으로 그리면 "변화 없음" 이라는 거짓이 된다). */
345
+ let comparison: TwinKpiOutput['comparison']
346
+ /* 관점 × 비교 교차 — 축별 delta 와 **사라진 축**(전에 있었고 지금 없는 것). */
347
+ let crossed: ReturnType<typeof crossGroups> | undefined
348
+ /* 비교 구간 조회도 상한에 걸릴 수 있다 — 그 경우 delta 가 실제보다 작아 보인다. */
349
+ let prevCappedFlag = false
350
+ if (input.compareTo === 'previous' || input.compareTo === 'yesterday') {
351
+ const w = shiftWindow(fromMs, toMs, input.compareTo)
352
+ const { events: prevEvents, capped: prevCapped } = await fetchEvents(
353
+ input.domainId,
354
+ instanceIds,
355
+ w.fromMs,
356
+ w.toMs,
357
+ lookbackMs
358
+ )
359
+ /* 축이 요청됐으면 비교 구간도 **같은 축**으로 접는다 — 교차("구역별로 어제 대비")를 내기 위해.
360
+ * 상한은 축이 서로 어긋나지 않게 넉넉히 둔다(현재 축이 상한 안에 있는데 비교 축에서 밀려나면
361
+ * 있는데 없다고 말하게 된다). */
362
+ const prev = foldKpi(prevEvents, w, {
363
+ groupBy: input.groupBy,
364
+ nodeArea: areaMap.nodeArea,
365
+ groupLimit: input.groupBy ? 100 : undefined
366
+ })
367
+ prevCappedFlag = prevCapped
368
+ if (input.groupBy && kpi.groups && prev.groups && prev.eventsInWindow > 0) {
369
+ crossed = crossGroups(kpi.groups.items, prev.groups.items)
370
+ }
371
+ const base = {
372
+ basis: input.compareTo,
373
+ window: { fromTime: new Date(w.fromMs).toISOString(), toTime: new Date(w.toMs).toISOString() }
374
+ }
375
+ comparison =
376
+ prev.eventsInWindow === 0
377
+ ? { ...base, measured: false }
378
+ : {
379
+ ...base,
380
+ measured: true,
381
+ throughput: prev.throughput,
382
+ workTime: prev.workTime,
383
+ leadTime: prev.leadTime,
384
+ waitTime: prev.waitTime,
385
+ delta: {
386
+ tasks: kpi.throughput.tasks - prev.throughput.tasks,
387
+ orders: kpi.throughput.orders - prev.throughput.orders,
388
+ workP50Ms: deltaP50(kpi.workTime, prev.workTime),
389
+ leadP50Ms: deltaP50(kpi.leadTime, prev.leadTime),
390
+ waitP50Ms: deltaP50(kpi.waitTime, prev.waitTime)
391
+ }
392
+ }
393
+ }
394
+
395
+ /* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로
396
+ * 비었을 때의 이유다 — "구역별 0건" 과 "노드에 구역이 안 붙어 있다" 는 완전히 다른 상황이다. */
397
+ const groups = kpi.groups
398
+ ? {
399
+ by: kpi.groups.by,
400
+ items: (crossed?.items ?? kpi.groups.items).map(g => ({
401
+ ...g,
402
+ ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})
403
+ })),
404
+ ...(crossed?.disappeared.length
405
+ ? {
406
+ disappeared: crossed.disappeared.map(d => ({
407
+ ...d,
408
+ ...(areaMap.labels[d.key] ? { label: areaMap.labels[d.key] } : {})
409
+ }))
410
+ }
411
+ : {}),
412
+ ...(kpi.groups.truncated
413
+ ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }
414
+ : {}),
415
+ ...(input.groupBy === 'area' &&
416
+ Object.keys(areaMap.nodeArea).length === 0 &&
417
+ kpi.groups.items.some(g => g.key === 'unknown')
418
+ ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
419
+ : {})
420
+ }
421
+ : undefined
422
+
423
+ /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
424
+ const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi
425
+ return {
426
+ window,
427
+ measured: true,
428
+ scope,
429
+ ...(capped || prevCappedFlag ? { eventsCapped: true } : {}),
430
+ ...rest,
431
+ ...(buckets ? { buckets } : {}),
432
+ ...(groups ? { groups } : {}),
433
+ ...(comparison ? { comparison } : {})
434
+ }
435
+ }
@@ -11,6 +11,8 @@ import { pubsub, getRepository, Domain } from '@things-factory/shell'
11
11
  import { cacheService } from '@things-factory/cache-service'
12
12
 
13
13
  import { TwinEvent } from '../service/twin-event/twin-event.js'
14
+ import { twinEventKeys } from '../service/twin-event/twin-event-keys.js'
15
+ import { planWarmStart } from './warm-start.js'
14
16
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
15
17
  import { TwinSpace } from '../service/twin-space/twin-space.js'
16
18
  import { TwinSpaceRepresentation } from '../service/twin-space/twin-space-representation.js'
@@ -177,19 +179,62 @@ export class TwinEngine {
177
179
  }
178
180
  }
179
181
 
180
- /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
181
- static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode): InstanceRuntime {
182
+ /**
183
+ * 웜스타트 기동하는 커널에 **직전 관측 상태**를 심는다.
184
+ *
185
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
186
+ * `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
187
+ * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
188
+ * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
189
+ * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
190
+ * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
191
+ *
192
+ * ── 정직한 한계 ─────────────────────────────────────────────────────────────
193
+ * · **오더는 복원하지 않는다.** `hydrateObserved` 의 오더 인자는 requested/fulfilled/lines 를 요구하는데
194
+ * 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
195
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작한다.
196
+ * · 진행 중 개별 task 의 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
197
+ * · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
198
+ *
199
+ * ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
200
+ * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
201
+ */
202
+ private static warmStart(id: string, kernel: TwinKernel, purpose?: string): void {
203
+ const hydrate = (kernel as any).hydrateObserved
204
+ const plan = planWarmStart(this.recovered[id]?.state, purpose, typeof hydrate === 'function')
205
+
206
+ if (plan.action === 'skip') {
207
+ if (plan.reason === 'bench') {
208
+ console.log(`[twin-engine] "${id}" is a bench twin — starting empty on purpose (seeding would skew the measurement).`)
209
+ } else if (plan.reason === 'unsupported') {
210
+ console.warn(
211
+ `[twin-engine] kernel for "${id}" cannot be warm-started (no hydrateObserved) — it starts with structure only, so held stock will read as zero.`
212
+ )
213
+ }
214
+ return
215
+ }
216
+
217
+ hydrate.call(kernel, plan.seed)
218
+ console.log(
219
+ `[twin-engine] warm-started "${id}" — ${plan.itemCount} item(s), ${plan.moverCount} mover(s) restored. ` +
220
+ 'Open orders are not restored (the snapshot carries no requested/fulfilled counts).'
221
+ )
222
+ }
223
+
224
+ /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
225
+ static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string): InstanceRuntime {
182
226
  if (this.instances[id]) return this.instances[id]
183
227
 
184
228
  const Kernel = KERNELS[kind] ?? WmsKernel
185
229
  const kernel: TwinKernel = new Kernel(domainId)
186
- kernel.loadBoard(board)
230
+ kernel.loadBoard(board) // 구조만. 상태는 아래 웜스타트가 심는다.
231
+ this.warmStart(id, kernel, purpose)
187
232
  const runtime: TwinRuntimeType = new TwinRuntime(kernel)
188
233
 
189
234
  /* subscribe 는 RuntimeSubscription({ unsubscribe() }) 반환 → () => void 로 감쌈. */
190
235
  const inst: InstanceRuntime = { id, domainId, runtime, kernel, realityMode: realityMode ?? DEFAULT_REALITY_MODE, unsub: () => {} }
191
236
  this.instances[id] = inst
192
- delete this.recovered[id] // 라이브가 우선
237
+ delete this.recovered[id] // 웜스타트로 커널에 옮겨 심었다 — 이제 라이브가 진실이다.
193
238
 
194
239
  /* 라이브 바인딩(P3): data 채널 필터가 subdomain 을 보므로 Domain 객체를 1회 해석해 둔다. */
195
240
  getRepository(Domain).findOne({ where: { id: domainId } }).then(d => (inst.domain = d)).catch(() => {})
@@ -312,20 +357,28 @@ export class TwinEngine {
312
357
  if (!anyLive && this.broadcastTimer) { clearInterval(this.broadcastTimer); this.broadcastTimer = undefined } // live 없으면 tick 정지
313
358
  }
314
359
 
360
+ /**
361
+ * 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
362
+ * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
363
+ * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
364
+ */
365
+ private static journalRow(repo: any, domainId: string, instanceId: string, e: any, revision: number) {
366
+ return repo.create({
367
+ domain: { id: domainId } as any,
368
+ instanceId,
369
+ tenantId: e?.tenantId,
370
+ eventType: e?.eventType,
371
+ revision,
372
+ eventTime: e?.eventTime,
373
+ ...twinEventKeys(e),
374
+ payload: e
375
+ })
376
+ }
377
+
315
378
  /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
316
379
  static async persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void> {
317
380
  const repo = getRepository(TwinEvent)
318
- const rows = envelopes.map((e, i) =>
319
- repo.create({
320
- domain: { id: domainId } as any,
321
- instanceId,
322
- tenantId: e?.tenantId,
323
- eventType: e?.eventType,
324
- revision: startRevision + i + 1,
325
- eventTime: e?.eventTime,
326
- payload: e
327
- })
328
- )
381
+ const rows = envelopes.map((e, i) => this.journalRow(repo, domainId, instanceId, e, startRevision + i + 1))
329
382
  await repo.save(rows, { chunk: 500 })
330
383
  }
331
384
 
@@ -417,7 +470,32 @@ export class TwinEngine {
417
470
  if (this.instances[instanceId]) return this.instances[instanceId]
418
471
  const reg = await getRepository(TwinInstance).findOne({ where: { domain: { id: domainId }, instanceId } })
419
472
  if (!reg?.board) throw new Error(`instance "${instanceId}" not provisioned (no board)`)
420
- return this.start(instanceId, domainId, reg.kind ?? 'wms', reg.board as BoardDef, realityMode ?? (reg.realityMode as RealityMode) ?? undefined)
473
+
474
+ /*
475
+ * 웜스타트 재료를 **여기서 확실히 확보한다.**
476
+ * `start()` 는 동기라 스스로 캐시를 읽을 수 없어서 `recovered` 에 미리 담겨 있기를 기대하는데,
477
+ * 그건 `bootstrap()` 이 먼저 돌았을 때만 참이다. 부팅 순서에 기대면 어떤 날은 재고가 살아나고
478
+ * 어떤 날은 조용히 빈 채로 뜬다 — 재현되지 않는 결함이 가장 나쁘다.
479
+ * 체크포인트 캐시 우선(O(1) + 라이브 파생상태 보존), 없으면 저널 replay 폴백(부팅과 같은 순서).
480
+ */
481
+ if (!this.recovered[instanceId] && reg.purpose !== 'bench') {
482
+ const cached = await this.loadSnapshot(domainId, instanceId).catch(() => null)
483
+ if (cached?.state) {
484
+ this.recovered[instanceId] = { revision: cached.revision, state: cached.state }
485
+ } else {
486
+ const state = await this.recover(domainId, instanceId).catch(() => null)
487
+ if (state) this.recovered[instanceId] = { revision: state.revision, state }
488
+ }
489
+ }
490
+
491
+ return this.start(
492
+ instanceId,
493
+ domainId,
494
+ reg.kind ?? 'wms',
495
+ reg.board as BoardDef,
496
+ realityMode ?? (reg.realityMode as RealityMode) ?? undefined,
497
+ reg.purpose ?? undefined
498
+ )
421
499
  }
422
500
 
423
501
  /**
@@ -681,18 +759,7 @@ export class TwinEngine {
681
759
 
682
760
  static async persist(domainId: string, instanceId: string, msg: any): Promise<void> {
683
761
  const repo = getRepository(TwinEvent)
684
- const e = msg.event
685
- await repo.save(
686
- repo.create({
687
- domain: { id: domainId } as any,
688
- instanceId,
689
- tenantId: e?.tenantId,
690
- eventType: e?.eventType,
691
- revision: msg.revision,
692
- eventTime: e?.eventTime,
693
- payload: e
694
- })
695
- )
762
+ await repo.save(this.journalRow(repo, domainId, instanceId, msg.event, msg.revision))
696
763
  }
697
764
 
698
765
  /**
@@ -0,0 +1,53 @@
1
+ /*
2
+ * 웜스타트 판정 — **순수**. "기동하는 커널에 직전 상태를 심을 것인가, 심는다면 무엇을" 만 정한다.
3
+ * 실제 주입(hydrateObserved 호출)과 로그는 엔진이 한다.
4
+ *
5
+ * ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────
6
+ * 이 판정이 틀리면 증상이 정반대 두 방향으로 나온다: 심어야 할 때 안 심으면 **있는 재고가 0 으로**
7
+ * 보이고(2026-07-31 hatiolab-wms: 저널에 입고 540·출고 94 인데 재고 화면이 비어 있었다), 심지
8
+ * 말아야 할 벤치에 심으면 **용량 측정이 오염된다.** 둘 다 조용히 틀리는 종류라 규칙을 고정한다.
9
+ */
10
+
11
+ /** 커널에 심을 관측 상태 — 구조가 아니라 "무엇이 어디에 얼마나". */
12
+ export interface ObservedSeed {
13
+ nodes: unknown[]
14
+ items: unknown[]
15
+ movers: unknown[]
16
+ }
17
+
18
+ export type WarmStartPlan =
19
+ | { action: 'hydrate'; seed: ObservedSeed; itemCount: number; moverCount: number }
20
+ /** 벤치 트윈 — 새 시작에서 용량을 재는 게 목적이라 현재 상태를 심으면 측정이 오염된다. */
21
+ | { action: 'skip'; reason: 'bench' }
22
+ /** 심을 상태가 없다 — 처음 만든 트윈이거나 저널·체크포인트가 비었다. 정상이다. */
23
+ | { action: 'skip'; reason: 'no-state' }
24
+ /** 커널이 관측 주입을 지원하지 않는다 — 구조만으로 시작하므로 보유량은 0 으로 읽힌다(알려야 한다). */
25
+ | { action: 'skip'; reason: 'unsupported' }
26
+
27
+ /**
28
+ * 무엇을 할지 정한다.
29
+ *
30
+ * **오더는 의도적으로 심지 않는다.** 커널의 관측 주입은 오더에 requested/fulfilled/lines 를 요구하는데
31
+ * 스냅샷의 오더에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이므로
32
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작하는 편이 정직하다.
33
+ */
34
+ export function planWarmStart(
35
+ state: { nodes?: unknown[]; items?: unknown[]; movers?: unknown[] } | null | undefined,
36
+ purpose: string | undefined,
37
+ canHydrate: boolean
38
+ ): WarmStartPlan {
39
+ /* 벤치 판정이 먼저다 — 상태가 있든 없든 벤치에는 심지 않는다는 사실이 바뀌지 않는다. */
40
+ if (purpose === 'bench') return { action: 'skip', reason: 'bench' }
41
+ if (!state) return { action: 'skip', reason: 'no-state' }
42
+
43
+ const nodes = state.nodes ?? []
44
+ const items = state.items ?? []
45
+ const movers = state.movers ?? []
46
+ /* 셋 다 비었으면 심을 것이 없다 — 빈 주입으로 로그만 남기지 않는다. */
47
+ if (nodes.length === 0 && items.length === 0 && movers.length === 0) return { action: 'skip', reason: 'no-state' }
48
+
49
+ /* 지원 여부는 마지막에 본다 — 심을 게 있는데 못 심는 상황이라야 경고할 값어치가 있다. */
50
+ if (!canHydrate) return { action: 'skip', reason: 'unsupported' }
51
+
52
+ return { action: 'hydrate', seed: { nodes, items, movers }, itemCount: items.length, moverCount: movers.length }
53
+ }