@things-factory/headless-twin 10.0.5 → 10.0.7

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 (161) hide show
  1. package/README.md +37 -24
  2. package/dist-server/engine/entity-delta.d.ts +13 -1
  3. package/dist-server/engine/entity-delta.js +130 -16
  4. package/dist-server/engine/entity-delta.js.map +1 -1
  5. package/dist-server/engine/index.d.ts +4 -0
  6. package/dist-server/engine/index.js +4 -0
  7. package/dist-server/engine/index.js.map +1 -1
  8. package/dist-server/engine/kpi-fold.d.ts +20 -3
  9. package/dist-server/engine/kpi-fold.js +45 -53
  10. package/dist-server/engine/kpi-fold.js.map +1 -1
  11. package/dist-server/engine/kpi-query.d.ts +16 -0
  12. package/dist-server/engine/kpi-query.js +129 -19
  13. package/dist-server/engine/kpi-query.js.map +1 -1
  14. package/dist-server/engine/kpi-target.d.ts +141 -0
  15. package/dist-server/engine/kpi-target.js +137 -0
  16. package/dist-server/engine/kpi-target.js.map +1 -0
  17. package/dist-server/engine/live-attentions.d.ts +4 -2
  18. package/dist-server/engine/live-attentions.js +5 -1
  19. package/dist-server/engine/live-attentions.js.map +1 -1
  20. package/dist-server/engine/measured-estimator.d.ts +50 -0
  21. package/dist-server/engine/measured-estimator.js +78 -0
  22. package/dist-server/engine/measured-estimator.js.map +1 -0
  23. package/dist-server/engine/model-basis.d.ts +23 -0
  24. package/dist-server/engine/model-basis.js +100 -0
  25. package/dist-server/engine/model-basis.js.map +1 -0
  26. package/dist-server/engine/oee-accumulator.d.ts +2 -2
  27. package/dist-server/engine/oee-accumulator.js +4 -4
  28. package/dist-server/engine/oee-accumulator.js.map +1 -1
  29. package/dist-server/engine/spec-coverage.d.ts +49 -0
  30. package/dist-server/engine/spec-coverage.js +60 -0
  31. package/dist-server/engine/spec-coverage.js.map +1 -0
  32. package/dist-server/engine/structure-diff.d.ts +25 -0
  33. package/dist-server/engine/structure-diff.js +63 -0
  34. package/dist-server/engine/structure-diff.js.map +1 -0
  35. package/dist-server/engine/travel-estimator.d.ts +57 -0
  36. package/dist-server/engine/travel-estimator.js +120 -0
  37. package/dist-server/engine/travel-estimator.js.map +1 -0
  38. package/dist-server/engine/twin-engine.d.ts +181 -18
  39. package/dist-server/engine/twin-engine.js +545 -96
  40. package/dist-server/engine/twin-engine.js.map +1 -1
  41. package/dist-server/engine/warm-start.d.ts +39 -0
  42. package/dist-server/engine/warm-start.js +37 -0
  43. package/dist-server/engine/warm-start.js.map +1 -0
  44. package/dist-server/index.js +8 -0
  45. package/dist-server/index.js.map +1 -1
  46. package/dist-server/service/index.d.ts +4 -2
  47. package/dist-server/service/index.js +21 -14
  48. package/dist-server/service/index.js.map +1 -1
  49. package/dist-server/service/reference/reference-live.js +2 -1
  50. package/dist-server/service/reference/reference-live.js.map +1 -1
  51. package/dist-server/service/reference/reference-master.d.ts +307 -1
  52. package/dist-server/service/reference/reference-master.js +96 -8
  53. package/dist-server/service/reference/reference-master.js.map +1 -1
  54. package/dist-server/service/reference/reference-resolver.js +3 -3
  55. package/dist-server/service/reference/reference-resolver.js.map +1 -1
  56. package/dist-server/service/reference/template-registry.d.ts +1 -1
  57. package/dist-server/service/reference/template-registry.js.map +1 -1
  58. package/dist-server/service/twin-attention/twin-attention-query.js +1 -1
  59. package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
  60. package/dist-server/service/twin-control/twin-control-mutation.js +1 -1
  61. package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
  62. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  63. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  64. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  65. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  66. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  67. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  68. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  69. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  70. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  71. package/dist-server/service/twin-event/twin-event.d.ts +16 -0
  72. package/dist-server/service/twin-event/twin-event.js +50 -0
  73. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  74. package/dist-server/service/twin-forecast/twin-forecast-query.js +50 -7
  75. package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
  76. package/dist-server/service/twin-instance/twin-instance.js +1 -1
  77. package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
  78. package/dist-server/service/twin-journal/twin-journal-query.d.ts +28 -0
  79. package/dist-server/service/twin-journal/twin-journal-query.js +127 -2
  80. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  81. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +1 -1
  82. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +4 -3
  83. package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
  84. package/dist-server/service/twin-space/twin-space-area.js +1 -1
  85. package/dist-server/service/twin-space/twin-space-area.js.map +1 -1
  86. package/dist-server/service/twin-space/twin-space-resolver.js +3 -3
  87. package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
  88. package/dist-server/service/twin-space/twin-space.d.ts +11 -0
  89. package/dist-server/service/twin-space/twin-space.js +5 -0
  90. package/dist-server/service/twin-space/twin-space.js.map +1 -1
  91. package/dist-server/service/twin-structure/index.d.ts +2 -0
  92. package/dist-server/service/twin-structure/index.js +6 -0
  93. package/dist-server/service/twin-structure/index.js.map +1 -0
  94. package/dist-server/service/twin-structure/twin-structure.d.ts +28 -0
  95. package/dist-server/service/twin-structure/twin-structure.js +97 -0
  96. package/dist-server/service/twin-structure/twin-structure.js.map +1 -0
  97. package/dist-server/service/twin-target/index.d.ts +4 -0
  98. package/dist-server/service/twin-target/index.js +8 -0
  99. package/dist-server/service/twin-target/index.js.map +1 -0
  100. package/dist-server/service/twin-target/twin-target-resolver.d.ts +7 -0
  101. package/dist-server/service/twin-target/twin-target-resolver.js +143 -0
  102. package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -0
  103. package/dist-server/service/twin-target/twin-target.d.ts +25 -0
  104. package/dist-server/service/twin-target/twin-target.js +112 -0
  105. package/dist-server/service/twin-target/twin-target.js.map +1 -0
  106. package/dist-server/tsconfig.tsbuildinfo +1 -1
  107. package/package.json +6 -6
  108. package/server/engine/entity-delta.ts +135 -15
  109. package/server/engine/index.ts +4 -0
  110. package/server/engine/kpi-fold.ts +62 -59
  111. package/server/engine/kpi-query.ts +140 -19
  112. package/server/engine/kpi-target.ts +226 -0
  113. package/server/engine/live-attentions.ts +12 -2
  114. package/server/engine/measured-estimator.ts +91 -0
  115. package/server/engine/model-basis.ts +94 -0
  116. package/server/engine/oee-accumulator.ts +5 -5
  117. package/server/engine/spec-coverage.ts +85 -0
  118. package/server/engine/structure-diff.ts +88 -0
  119. package/server/engine/travel-estimator.ts +133 -0
  120. package/server/engine/twin-engine.ts +587 -106
  121. package/server/engine/warm-start.ts +53 -0
  122. package/server/index.ts +9 -0
  123. package/server/service/index.ts +7 -0
  124. package/server/service/reference/reference-live.ts +2 -1
  125. package/server/service/reference/reference-master.ts +383 -10
  126. package/server/service/reference/reference-resolver.ts +3 -3
  127. package/server/service/reference/template-registry.ts +1 -1
  128. package/server/service/twin-attention/twin-attention-query.ts +1 -1
  129. package/server/service/twin-control/twin-control-mutation.ts +1 -1
  130. package/server/service/twin-event/backfill-keys.ts +72 -0
  131. package/server/service/twin-event/twin-event-keys.ts +102 -0
  132. package/server/service/twin-event/twin-event-type.ts +27 -0
  133. package/server/service/twin-event/twin-event.ts +62 -0
  134. package/server/service/twin-forecast/twin-forecast-query.ts +56 -8
  135. package/server/service/twin-instance/twin-instance.ts +1 -1
  136. package/server/service/twin-journal/twin-journal-query.ts +129 -5
  137. package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +4 -3
  138. package/server/service/twin-space/twin-space-area.ts +1 -1
  139. package/server/service/twin-space/twin-space-resolver.ts +3 -3
  140. package/server/service/twin-space/twin-space.ts +14 -0
  141. package/server/service/twin-structure/index.ts +3 -0
  142. package/server/service/twin-structure/twin-structure.ts +93 -0
  143. package/server/service/twin-target/index.ts +5 -0
  144. package/server/service/twin-target/twin-target-resolver.ts +124 -0
  145. package/server/service/twin-target/twin-target.ts +97 -0
  146. package/test/capability-mapping.test.ts +5 -5
  147. package/test/duration-estimators.test.ts +144 -0
  148. package/test/entity-delta.test.ts +150 -19
  149. package/test/ingest-bench.test.ts +9 -9
  150. package/test/kpi-fold.test.ts +254 -3
  151. package/test/live-mirror-parity.test.ts +53 -19
  152. package/test/master-to-twin.test.ts +87 -13
  153. package/test/model-basis.test.ts +86 -0
  154. package/test/oee-accumulator.test.ts +9 -9
  155. package/test/scale-twin-bench.test.ts +22 -22
  156. package/test/spec-coverage.test.ts +113 -0
  157. package/test/streamline-e2e.test.ts +10 -10
  158. package/test/structure-revision-db.test.ts +310 -0
  159. package/test/twin-event-keys.test.ts +108 -0
  160. package/test/vocabulary-guard.test.ts +43 -0
  161. package/test/warm-start.test.ts +78 -0
@@ -16,12 +16,16 @@
16
16
  */
17
17
  import { Between, In } from 'typeorm'
18
18
 
19
+ import { hierarchyOf } from '@operato/twin-kernel'
20
+
19
21
  import { getRepository } from '@things-factory/shell'
20
22
 
21
23
  import { TwinEvent } from '../service/twin-event/twin-event.js'
22
24
  import { TwinInstance } from '../service/twin-instance/twin-instance.js'
23
25
  import { TwinArea } from '../service/twin-space/twin-area.js'
24
26
  import { TwinSpace } from '../service/twin-space/twin-space.js'
27
+ import { TwinTarget } from '../service/twin-target/twin-target.js'
28
+ import { judgeTargets, judgeGroupTargets, scopedTargets, type ScopedTargets, type TargetVerdict } from './kpi-target.js'
25
29
  import { TwinEngine } from './twin-engine.js'
26
30
  import {
27
31
  crossGroups,
@@ -118,6 +122,14 @@ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
118
122
  scope: { spaceId?: string; instanceIds: string[]; realityModes: string[] }
119
123
  /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */
120
124
  buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]
125
+ /**
126
+ * **목표 대비 판정** — 숫자를 판단으로 바꾸는 값(§kpi-target).
127
+ *
128
+ * 목표가 걸린 지표만 들어온다. 목표가 없거나 그 구간에 값이 없는 지표는 **여기 나타나지 않는다** —
129
+ * 전부 "목표 없음" 으로 채우면 화면이 회색 노이즈가 되고, 없는 값을 0 으로 놓으면 "목표 크게 미달"
130
+ * 이라는 없는 사실이 만들어진다.
131
+ */
132
+ targets?: TargetVerdict[]
121
133
  /**
122
134
  * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.
123
135
  *
@@ -159,24 +171,94 @@ type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] &
159
171
  Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>
160
172
 
161
173
  /**
162
- * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가
163
- * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
174
+ * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `locations[].parentId` 가
175
+ * 마스터 계층에서 온 소속이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
176
+ *
177
+ * **한 홉만 보면 안 된다.** `parentId` 는 구역일 수도 있고 중간 단(라인·셀)일 수도 있다. 라인이 끼면
178
+ * 한 홉짜리 코드는 "구역이 LINE-1" 이라고 답하고, 그 id 는 구역 목록에 없으므로 그 아래 스테이션이
179
+ * **집계에서 조용히 빠진다**(에러 없이 틀린 숫자). 그래서 커널 `hierarchyOf` 로 사슬을 끝까지 걷는다 —
180
+ * 걷는 규칙은 커널에 한 벌뿐이고 소비처는 그것만 부른다.
181
+ */
182
+ /**
183
+ * 교대 축의 재료 — **교대 선언과 현장의 시각 기준.**
184
+ *
185
+ * 저널에는 시각만 있고 교대가 없다(일부러 그렇게 뒀다 — 교대는 파생이라 찍어 두면 나중에 교대 시간을
186
+ * 고쳤을 때 과거가 옛 구분으로 굳는다). 그래서 폴드가 계산할 재료를 여기서 모은다.
187
+ *
188
+ * 선언의 출처는 **보드의 설비**다. 여러 설비가 서로 다른 교대를 가질 수 있지만, 축은 하나여야 하므로
189
+ * **가장 많이 쓰이는 선언 한 벌**을 쓴다 — 3교대 공장은 전 설비가 같은 교대이므로 이것이 곧 정답이고,
190
+ * 자원마다 교대가 다른 현장에서는 이 축이 거칠다는 뜻이다(그 사실을 여기 적어 둔다).
164
191
  *
165
- * 구역 이름은 `TwinArea` 따로 있다 id 보여주면 사용자가 무엇인지 모른다.
192
+ * 시각 기준은 보드에 실려 있다(`utcOffsetMinutes` 공간이 갖는 값을 기동 호스트가 풀어 넣는다).
193
+ * 없으면 UTC 로 읽고, 그 기본값은 커널 계약에 밝혀져 있다.
166
194
  */
167
- async function nodeAreaMap(
195
+ /**
196
+ * 이 범위에 걸린 운영 목표 — **좁은 선언이 넓은 선언을 덮는다.**
197
+ *
198
+ * 공간 전체 목표 위에 인스턴스 목표를 얹을 수 있어야 예외를 표현할 수 있다("이 라인만 다르다").
199
+ * 같은 지표에 둘 다 있으면 인스턴스가 이긴다.
200
+ */
201
+ async function loadTargets(
202
+ domainId: string,
203
+ spaceId: string | undefined,
204
+ instanceIds: string[],
205
+ axis?: string
206
+ ): Promise<ScopedTargets> {
207
+ /* 조회는 **가져오기만** 한다 — 어느 목표가 이기는지는 순수 함수(`scopedTargets`)가 정한다.
208
+ 그 규칙이 DB 접근 뒤에 있으면 화면을 눌러 봐야만 확인된다(그리고 실제로 확인되지 않았다). */
209
+ const rows = await getRepository(TwinTarget).find({ where: { domain: { id: domainId } } }).catch(() => [])
210
+ return scopedTargets(rows, { spaceId, instanceIds, axis })
211
+ }
212
+
213
+ async function shiftContext(
214
+ domainId: string,
215
+ instanceIds: string[]
216
+ ): Promise<{ shifts?: any[]; utcOffsetMinutes?: number }> {
217
+ if (!instanceIds.length) return {}
218
+ const rows = await getRepository(TwinInstance).find({ where: { domain: { id: domainId }, instanceId: In(instanceIds) } })
219
+ const tally = new Map<string, { decl: any[]; n: number }>()
220
+ let utcOffsetMinutes: number | undefined
221
+ for (const row of rows) {
222
+ const board = row.board as any
223
+ if (utcOffsetMinutes === undefined && typeof board?.utcOffsetMinutes === 'number') utcOffsetMinutes = board.utcOffsetMinutes
224
+ for (const e of (board?.equipment ?? []) as any[]) {
225
+ const decl = e?.workCalendar
226
+ if (!Array.isArray(decl) || !decl.length) continue
227
+ const key = JSON.stringify(decl)
228
+ const hit = tally.get(key)
229
+ if (hit) hit.n++
230
+ else tally.set(key, { decl, n: 1 })
231
+ }
232
+ }
233
+ let best: { decl: any[]; n: number } | undefined
234
+ for (const v of tally.values()) if (!best || v.n > best.n) best = v
235
+ return { ...(best ? { shifts: best.decl } : {}), ...(utcOffsetMinutes !== undefined ? { utcOffsetMinutes } : {}) }
236
+ }
237
+
238
+ async function locationAreaMap(
168
239
  domainId: string,
169
240
  instanceIds: string[],
170
241
  spaceId?: string
171
- ): Promise<{ nodeArea: Record<string, string>; labels: Record<string, string> }> {
172
- const nodeArea: Record<string, string> = {}
242
+ ): Promise<{ locationArea: Record<string, string>; labels: Record<string, string> }> {
243
+ const locationArea: Record<string, string> = {}
173
244
  if (instanceIds.length) {
174
245
  const rows = await getRepository(TwinInstance).find({
175
246
  where: { domain: { id: domainId }, instanceId: In(instanceIds) }
176
247
  })
177
248
  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)
249
+ const locations = (((row.board as any)?.locations ?? []) as any[]).filter(n => n?.id)
250
+ if (!locations.length) continue
251
+ /* 순환은 hierarchyOf 가 던진다 — 한 트윈의 잘못된 계층이 다른 트윈의 집계까지 죽이지 않게 여기서 가둔다. */
252
+ let h
253
+ try {
254
+ h = hierarchyOf({ locations })
255
+ } catch (e: any) {
256
+ console.warn(`[twin-kpi] "${row.instanceId}": location hierarchy is broken — area rollup skipped for this twin: ${e?.message}`)
257
+ continue
258
+ }
259
+ for (const n of locations) {
260
+ const area = h.rollupOf(String(n.id))
261
+ if (area) locationArea[String(n.id)] = area // 구역 미상은 넣지 않는다 — 없는 구역을 만들지 않는다
180
262
  }
181
263
  }
182
264
  }
@@ -192,7 +274,7 @@ async function nodeAreaMap(
192
274
  for (const a of areas) if (a.areaId && a.name) labels[a.areaId] = a.name
193
275
  }
194
276
  }
195
- return { nodeArea, labels }
277
+ return { locationArea, labels }
196
278
  }
197
279
 
198
280
  /** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */
@@ -249,6 +331,17 @@ async function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: strin
249
331
  return { instanceIds, realityModes }
250
332
  }
251
333
 
334
+ /**
335
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
336
+ *
337
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
338
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
339
+ */
340
+ export async function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]> {
341
+ const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId } as TwinKpiInput)
342
+ return instanceIds
343
+ }
344
+
252
345
  /**
253
346
  * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
254
347
  *
@@ -294,14 +387,24 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
294
387
 
295
388
  const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs)
296
389
 
297
- /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
390
+ /* 구역·교대 축은 **이벤트 밖의 지식**을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
298
391
  const areaMap =
299
- input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} }
392
+ input.groupBy === 'area' ? await locationAreaMap(input.domainId, instanceIds, input.spaceId) : { locationArea: {}, labels: {} }
393
+ const shiftCtx = input.groupBy === 'shift' ? await shiftContext(input.domainId, instanceIds) : {}
300
394
  const kpi = foldKpi(events, { fromMs, toMs }, {
301
395
  groupBy: input.groupBy,
302
- nodeArea: areaMap.nodeArea,
396
+ locationArea: areaMap.locationArea,
397
+ ...shiftCtx,
303
398
  groupLimit: input.groupLimit
304
399
  })
400
+ /*
401
+ * 목표를 **폴드 뒤, 그룹 조립 앞**에 읽는다 — 전체 판정과 축별 판정이 같은 목록에서 나와야
402
+ * 두 곳의 임계가 갈라지지 않는다. 폴드 자체는 목표를 모른다(사실 층과 판정 층의 분리).
403
+ */
404
+ const scoped = await loadTargets(input.domainId, input.spaceId, instanceIds, input.groupBy)
405
+ const verdicts = judgeTargets(kpi, scoped.overall)
406
+ const groupTargets = scoped.byAxisKey
407
+
305
408
  const window = {
306
409
  fromTime: new Date(fromMs).toISOString(),
307
410
  toTime: new Date(toMs).toISOString(),
@@ -350,7 +453,8 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
350
453
  * 있는데 없다고 말하게 된다). */
351
454
  const prev = foldKpi(prevEvents, w, {
352
455
  groupBy: input.groupBy,
353
- nodeArea: areaMap.nodeArea,
456
+ locationArea: areaMap.locationArea,
457
+ ...shiftCtx, // 비교 구간도 **같은 교대 정의**로 접는다(정의가 다르면 축이 어긋난다)
354
458
  groupLimit: input.groupBy ? 100 : undefined
355
459
  })
356
460
  prevCappedFlag = prevCapped
@@ -386,10 +490,16 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
386
490
  const groups = kpi.groups
387
491
  ? {
388
492
  by: kpi.groups.by,
389
- items: (crossed?.items ?? kpi.groups.items).map(g => ({
390
- ...g,
391
- ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})
392
- })),
493
+ items: (crossed?.items ?? kpi.groups.items).map(g => {
494
+ /* 축의 이 값에 목표가 걸려 있으면 판정을 함께 낸다 — "이 구역만 목표가 다르다" 를
495
+ 표에서 바로 있게. 목표가 없는 축값에는 필드를 만들지 않는다. */
496
+ const gt = judgeGroupTargets(g as any, groupTargets[g.key])
497
+ return {
498
+ ...g,
499
+ ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {}),
500
+ ...(gt.length ? { targets: gt } : {})
501
+ }
502
+ }),
393
503
  ...(crossed?.disappeared.length
394
504
  ? {
395
505
  disappeared: crossed.disappeared.map(d => ({
@@ -402,13 +512,23 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
402
512
  ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }
403
513
  : {}),
404
514
  ...(input.groupBy === 'area' &&
405
- Object.keys(areaMap.nodeArea).length === 0 &&
515
+ Object.keys(areaMap.locationArea).length === 0 &&
406
516
  kpi.groups.items.some(g => g.key === 'unknown')
407
- ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
517
+ ? { note: 'locations carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
408
518
  : {})
409
519
  }
410
520
  : undefined
411
521
 
522
+ /*
523
+ * 목표 대비 판정 — **사실 계산 뒤에 얹는다.**
524
+ *
525
+ * 폴드는 목표를 모른다(사실만 낸다). 목표를 아는 순간 "세는 규칙" 과 "잘했나 판정하는 규칙" 이
526
+ * 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 조회 층에서 목표를
527
+ * 실어 오고, 판정은 순수 함수(`judgeTargets`)가 한다.
528
+ *
529
+ * **목표가 없으면 판정하지 않는다** — 필드 자체를 만들지 않는다. 전부 "목표 없음" 으로 채우면
530
+ * 화면이 회색 노이즈가 되고, 목표가 있는 지표가 묻힌다.
531
+ */
412
532
  /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
413
533
  const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi
414
534
  return {
@@ -419,6 +539,7 @@ export async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput
419
539
  ...rest,
420
540
  ...(buckets ? { buckets } : {}),
421
541
  ...(groups ? { groups } : {}),
542
+ ...(verdicts.length ? { targets: verdicts } : {}),
422
543
  ...(comparison ? { comparison } : {})
423
544
  }
424
545
  }
@@ -0,0 +1,226 @@
1
+ /*
2
+ * 성과 목표 — **숫자를 판단으로 바꾸는 층.**
3
+ *
4
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
5
+ * 화면과 AI 는 "처리량 345건 · 작업시간 29초" 를 정확히 말한다. 그런데 **그것이 좋은지 나쁜지는 말할 수
6
+ * 없다.** 판단 근거가 앞 구간 비교뿐이라 "어제보다 나아졌다" 까지가 한계이고, 어제도 나빴으면 그 말은
7
+ * 쓸모가 없다. 목표가 붙는 순간 같은 숫자가 **정보에서 판단으로** 바뀐다.
8
+ *
9
+ * ── 층을 섞지 않는다 ────────────────────────────────────────────────────────
10
+ * 폴드(`kpi-fold`)는 **사실만** 낸다 — 목표를 모른다. 목표를 아는 순간 "사실을 세는 규칙" 과 "잘했나
11
+ * 판정하는 규칙" 이 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 판정은
12
+ * 여기, **순수 함수**로 따로 둔다(폴드처럼 테스트가 값싸다).
13
+ *
14
+ * ── 목표의 집 (사용자 결정 2026-08-02) ──────────────────────────────────────
15
+ * **트윈은 운영 목표를 갖고, `@things-factory/kpi` 는 경영 목표를 갖는다.** 경계:
16
+ *
17
+ * 트윈(TwinTarget) 이 구간·이 교대·이 공간의 **즉시 판단**. 현장 관리자가 트윈 화면에서 고친다.
18
+ * 값은 저널 폴드에서 **그때그때 계산**된다(저장된 값을 읽지 않는다).
19
+ * packages/kpi **기간 평가**(조직·분기·점수·가중치·알림). 값은 저장돼 있어야 한다.
20
+ *
21
+ * 이 경계를 지키는 규칙 하나: **트윈은 목표를 복제하지 않는다.** 경영 목표가 필요하면 트윈의 사실을
22
+ * 내보내 그쪽이 소비하고, 그쪽 목표를 여기로 베껴 오지 않는다 — 베끼는 순간 두 개의 진실이 된다.
23
+ */
24
+ /*
25
+ * ── 운영 임계(커널 attention)와의 경계 ─────────────────────────────────────
26
+ *
27
+ * **attention 은 개체 하나의 지금 상태**다 — 앵커(설비·자리·오더)와 할 수 있는 조치를 갖는다.
28
+ * **target(여기)은 구간의 집계**다 — 앵커도 조치도 없고 추세를 판단한다.
29
+ *
30
+ * 그래서 창 단위 집계 판정을 attention 으로 넣지 않는다: "가동률 저하"(창 평균) attention 을 만들면
31
+ * `utilization.ratio` 목표와 **같은 사실을 두 층이 다른 임계로 판정**하게 된다.
32
+ * (design/map/performance.md §5.0-4 — 현재 다섯 attention 과 이 어휘는 겹치지 않는다.)
33
+ */
34
+ import type { DurationStats, KpiResult } from './kpi-fold.js'
35
+
36
+ /** 측정된 기록이 있을 때만 값으로 인정한다 — 없으면 모름(0 이 아니다). */
37
+ const measured = (k: KpiResult, v: number): number | undefined => (k.eventsInWindow > 0 ? v : undefined)
38
+
39
+ /** 분위수는 표본이 있을 때만 값이다 — `count === 0` 의 `p50Ms: 0` 은 "0 밀리초" 가 아니라 "없음" 이다. */
40
+ const statOf = (s: DurationStats, pick: (d: DurationStats) => number): number | undefined =>
41
+ s.count > 0 ? pick(s) : undefined
42
+
43
+ /**
44
+ * 지표 어휘 — **이름을 한 곳에서 정한다.**
45
+ *
46
+ * 목표와 사실이 서로 다른 이름을 쓰면 그 사이에 매핑 표가 생기고, 그 표가 곧 방언이 된다. 그래서
47
+ * 목표가 가리킬 수 있는 지표는 여기 열거된 것뿐이고, 값을 꺼내는 방법도 여기 함께 있다.
48
+ *
49
+ * **`direction` 은 지표의 성질이지 사용자의 선택이 아니다.** 처리량은 높을수록 좋고 대기시간은 낮을수록
50
+ * 좋다 — 이것을 입력으로 받으면 반대로 적은 목표가 만들어지고, 그 뒤 판정이 전부 뒤집힌다.
51
+ */
52
+ export const TWIN_METRIC = {
53
+ /* 기록이 없는 구간은 처리량 0 이 아니라 **모름**이다 — `eventsInWindow` 가 그것을 가른다.
54
+ 0 으로 판정하면 "측정 못 한 구간" 이 "일을 안 한 구간" 으로 보고된다. */
55
+ 'throughput.tasks': { direction: 'higher', unit: 'count', of: (k: KpiResult) => measured(k, k.throughput.tasks) },
56
+ 'throughput.orders': { direction: 'higher', unit: 'count', of: (k: KpiResult) => measured(k, k.throughput.orders) },
57
+ /* 소요시간 분위수는 `count === 0` 이면 **0 이 아니라 없다.** 이것을 0 으로 읽으면 시간 지표는
58
+ "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 — 미달을 놓치는
59
+ 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). 테스트가 이것을 잡았다. */
60
+ 'leadTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.leadTime, d => d.p50Ms) },
61
+ 'leadTime.p90Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.leadTime, d => d.p90Ms) },
62
+ 'workTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.workTime, d => d.p50Ms) },
63
+ 'waitTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.waitTime, d => d.p50Ms) },
64
+ /** 자원 점유 — 개별 자원이 아니라 **창 전체의 평균 비율**(자원별은 관점 축의 일이다). */
65
+ 'utilization.ratio': {
66
+ direction: 'higher',
67
+ unit: 'ratio',
68
+ of: (k: KpiResult) =>
69
+ k.utilization.length ? k.utilization.reduce((s, u) => s + u.ratio, 0) / k.utilization.length : undefined
70
+ }
71
+ } as const
72
+
73
+ export type TwinMetricId = keyof typeof TWIN_METRIC
74
+ export const isTwinMetric = (id: string): id is TwinMetricId => id in TWIN_METRIC
75
+
76
+ /**
77
+ * 저장된 목표 행에서 **이 범위에 걸린 목표**를 고른다 — 좁은 선언이 넓은 선언을 덮는다.
78
+ *
79
+ * **왜 순수 함수인가**: 이 규칙(공간 목표 위에 인스턴스 목표를 얹는다)은 지금까지 DB 접근 뒤에 숨어
80
+ * 있어서 **화면을 눌러 봐야만** 확인할 수 있었다. 그런데 이 프로젝트는 로그인 뒤 화면을 자동으로
81
+ * 검증할 방법이 없어, 실제로 여러 기능이 "실화면 미검증" 으로 쌓였다. 규칙을 밖으로 빼면 테스트가
82
+ * 값싸진다 — 조회는 행을 가져오기만 하고, **판단은 여기서** 한다.
83
+ */
84
+ /**
85
+ * 축 목표까지 담은 목록 — 전체 목표와 **축의 한 값**에 걸린 목표를 함께 든다.
86
+ *
87
+ * 축 목표는 전체 목표를 **덮지 않는다.** 다른 질문이기 때문이다: "현장 전체가 100건" 과
88
+ * "용접 구역이 30건" 은 동시에 참일 수 있다. 덮게 만들면 축 하나에 목표를 거는 순간 전체 판정이
89
+ * 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다.
90
+ */
91
+ export interface ScopedTargets {
92
+ /** 창 전체에 걸린 목표. */
93
+ overall: TwinTargetSpec[]
94
+ /** 축 → (그 축의 값 → 목표들). 요청한 축만 채운다. */
95
+ byAxisKey: Record<string, TwinTargetSpec[]>
96
+ }
97
+
98
+ /**
99
+ * 축의 각 값에 대해 목표 대비 판정 — **그룹 항목에 그대로 붙일 형태.**
100
+ *
101
+ * 그룹의 지표는 전체보다 좁다(주문 수·자원 점유는 축 단위로 뜻이 다르거나 없다). 없는 지표에 목표가
102
+ * 걸려 있으면 **판정하지 않는다** — 여기서 0 으로 읽으면 시간 지표가 "완벽 달성" 으로 뒤집힌다
103
+ * (전체 판정에서 이미 겪은 결함이다).
104
+ */
105
+ export function judgeGroupTargets(
106
+ group: { key: string; tasks: number; leadTime: { count: number; p50Ms: number }; workTime: { count: number; p50Ms: number }; waitTime: { count: number; p50Ms: number } },
107
+ targets: readonly TwinTargetSpec[] | undefined
108
+ ): TargetVerdict[] {
109
+ const value = (metric: TwinMetricId): number | undefined => {
110
+ switch (metric) {
111
+ case 'throughput.tasks':
112
+ return group.tasks
113
+ case 'leadTime.p50Ms':
114
+ return group.leadTime.count > 0 ? group.leadTime.p50Ms : undefined
115
+ case 'workTime.p50Ms':
116
+ return group.workTime.count > 0 ? group.workTime.p50Ms : undefined
117
+ case 'waitTime.p50Ms':
118
+ return group.waitTime.count > 0 ? group.waitTime.p50Ms : undefined
119
+ default:
120
+ return undefined // 축 단위로 뜻이 없는 지표(주문 수·자원 점유) — 지어내지 않는다
121
+ }
122
+ }
123
+ const out: TargetVerdict[] = []
124
+ for (const t of targets ?? []) {
125
+ const spec = TWIN_METRIC[t.metric]
126
+ const actual = value(t.metric)
127
+ if (!spec || typeof actual !== 'number' || !Number.isFinite(actual) || !Number.isFinite(t.target)) continue
128
+ out.push(verdictOf(t.metric, actual, t.target, spec.direction))
129
+ }
130
+ return out
131
+ }
132
+
133
+ export function mergeTargets(
134
+ rows: readonly TargetRow[],
135
+ scope: { spaceId?: string; instanceIds: readonly string[]; axis?: string }
136
+ ): TwinTargetSpec[] {
137
+ return scopedTargets(rows, scope).overall
138
+ }
139
+
140
+ /** 저장된 목표 행 — 판단에 필요한 것만(엔티티 전체를 요구하지 않는다: 테스트가 값싸진다). */
141
+ export interface TargetRow {
142
+ metric: string
143
+ target: number
144
+ spaceId?: string | null
145
+ instanceId?: string | null
146
+ axis?: string | null
147
+ axisKey?: string | null
148
+ }
149
+
150
+ /** 전체 목표와 축 목표를 함께 고른다 — 좁은 선언(인스턴스)이 넓은 선언(공간)을 덮는 규칙은 양쪽 같다. */
151
+ export function scopedTargets(
152
+ rows: readonly TargetRow[],
153
+ scope: { spaceId?: string; instanceIds: readonly string[]; axis?: string }
154
+ ): ScopedTargets {
155
+ /* 같은 자리(지표 또는 축값+지표)에 공간·인스턴스 목표가 함께 있으면 인스턴스가 이긴다. */
156
+ const pick = new Map<string, { target: number; fromInstance: boolean; axisKey?: string; metric: TwinMetricId }>()
157
+ for (const r of rows) {
158
+ if (!isTwinMetric(r.metric) || typeof r.target !== 'number' || !Number.isFinite(r.target)) continue
159
+ const inSpace = !!scope.spaceId && r.spaceId === scope.spaceId
160
+ const inInstance = !!r.instanceId && scope.instanceIds.includes(r.instanceId)
161
+ if (!inSpace && !inInstance) continue
162
+ if (r.axis) {
163
+ if (!scope.axis || r.axis !== scope.axis || !r.axisKey) continue // 지금 보는 축만
164
+ }
165
+ const slot = `${r.axis ?? ''}\u0000${r.axisKey ?? ''}\u0000${r.metric}`
166
+ const prev = pick.get(slot)
167
+ if (prev?.fromInstance && !inInstance) continue // 인스턴스 선언을 공간 선언이 덮지 않는다
168
+ pick.set(slot, { target: r.target, fromInstance: inInstance, axisKey: r.axisKey ?? undefined, metric: r.metric })
169
+ }
170
+ const overall: TwinTargetSpec[] = []
171
+ const byAxisKey: Record<string, TwinTargetSpec[]> = {}
172
+ for (const [slot, v] of pick) {
173
+ const spec = { metric: v.metric, target: v.target }
174
+ if (slot.startsWith('\u0000')) overall.push(spec)
175
+ else (byAxisKey[v.axisKey as string] ??= []).push(spec)
176
+ }
177
+ return { overall, byAxisKey }
178
+ }
179
+
180
+ /** 목표 하나 — 어느 지표를, 얼마로. 방향은 지표가 안다(§TWIN_METRIC). */
181
+ export interface TwinTargetSpec {
182
+ metric: TwinMetricId
183
+ target: number
184
+ }
185
+
186
+ export interface TargetVerdict {
187
+ metric: TwinMetricId
188
+ /** 이 구간의 실제 값. */
189
+ actual: number
190
+ target: number
191
+ direction: 'higher' | 'lower'
192
+ /** 목표를 만족했나. */
193
+ met: boolean
194
+ /**
195
+ * 목표 대비 비율 — **항상 "1 이상이면 좋다"** 로 정규화한다(방향이 다른 지표를 한 화면에서 비교할 수
196
+ * 있게). 처리량은 `actual/target`, 시간은 `target/actual`.
197
+ */
198
+ ratio: number
199
+ }
200
+
201
+ /**
202
+ * 목표 대비 판정 — **목표가 없는 지표는 판정하지 않는다.**
203
+ *
204
+ * 값이 없는 지표(그 구간에 기록이 없어 `undefined`)도 판정하지 않는다. 없는 것을 0 으로 놓으면
205
+ * "목표 크게 미달" 이라는 **없는 사실**이 만들어지고, 사용자는 멈춘 적 없는 라인을 고치러 간다.
206
+ * 목표를 0 으로 준 경우도 비율을 내지 않는다(0 으로 나누지 않는다) — 만족 여부만 답한다.
207
+ */
208
+ export function judgeTargets(kpi: KpiResult, targets: readonly TwinTargetSpec[] | undefined): TargetVerdict[] {
209
+ const out: TargetVerdict[] = []
210
+ for (const t of targets ?? []) {
211
+ const spec = TWIN_METRIC[t.metric]
212
+ if (!spec) continue // 모르는 지표 이름 — 짐작해 매핑하지 않는다
213
+ const actual = spec.of(kpi)
214
+ if (typeof actual !== 'number' || !Number.isFinite(actual)) continue // 값이 없다 ≠ 값이 0
215
+ if (!Number.isFinite(t.target)) continue
216
+ out.push(verdictOf(t.metric, actual, t.target, spec.direction))
217
+ }
218
+ return out
219
+ }
220
+
221
+ /** 판정 산수 한 곳 — 전체 판정과 축 판정이 **같은 규칙**을 쓴다(두 벌이면 갈라진다). */
222
+ function verdictOf(metric: TwinMetricId, actual: number, target: number, direction: 'higher' | 'lower'): TargetVerdict {
223
+ const met = direction === 'higher' ? actual >= target : actual <= target
224
+ const ratio = direction === 'higher' ? (target > 0 ? actual / target : Number.NaN) : actual > 0 ? target / actual : Number.NaN
225
+ return { metric, actual, target, direction, met, ratio }
226
+ }
@@ -6,10 +6,20 @@
6
6
  */
7
7
  import { deriveAttentions } from '@operato/twin-kernel'
8
8
 
9
- export function withLiveAttentions<S extends { movers?: any[]; nodes?: any[]; orders?: any[]; attentions?: any[] }>(state: S): S {
9
+ export function withLiveAttentions<S extends { equipment?: any[]; locations?: any[]; orders?: any[]; tasks?: any[]; attentions?: any[]; nowTime?: string }>(
10
+ state: S
11
+ ): S {
10
12
  if (!state) return state
11
13
  return {
12
14
  ...state,
13
- attentions: deriveAttentions({ movers: state.movers ?? [], nodes: state.nodes ?? [], orders: state.orders ?? [] })
15
+ /*
16
+ * 입력을 **시뮬 쪽과 똑같이** 넣는다. 한쪽만 빠지면 같은 공장이 한쪽에서만 막힌 것처럼 보이고,
17
+ * 그 어긋남은 규칙이 아니라 배선 때문이라 아무 데서도 안 잡힌다(`tasks` 를 빠뜨려 실제로 그럴 뻔했다).
18
+ */
19
+ attentions: deriveAttentions(
20
+ { equipment: state.equipment ?? [], locations: state.locations ?? [], orders: state.orders ?? [], tasks: state.tasks ?? [] },
21
+ undefined,
22
+ state.nowTime
23
+ )
14
24
  }
15
25
  }
@@ -0,0 +1,91 @@
1
+ /*
2
+ * 실측 소요 추정기 — **이력에서 배운 값이 상수·선언값을 이긴다.**
3
+ *
4
+ * 배경(operato-twin design/plans/simulation-spec.md §5-4): 소요시간을 명세로 받을 수 있게 됐지만
5
+ * 명세는 선언이다 — 그 현장에서 실제로 얼마 걸리는지는 저널이 안다. `gap-analytics` 는 지표 수준의
6
+ * 편향만 배웠고(예측값 보정), **공정별 소요를 추정기로 되먹이는 경로는 없었다.**
7
+ *
8
+ * 여기서 하는 일: KPI 폴드가 작업 종류별로 낸 **작업시간(착수→완료) p50** 을 그 종류의 소요로 쓴다.
9
+ * 리드타임(생성→완료)이 아니라 작업시간을 쓰는 이유: 리드타임에는 **대기가 섞여 있고**, 대기는
10
+ * 시뮬레이션이 자원 경합으로 스스로 만들어야 하는 값이다. 대기를 소요에 넣으면 이중 계산이 된다.
11
+ *
12
+ * **평균만 배우면 대기와 병목을 과소평가한다.** 소요가 늘 정확히 같으면 줄이 생기지 않는다 — 현장의
13
+ * 줄은 흔들림에서 나온다. 그래서 p50 만이 아니라 **관측된 퍼짐(p10..p90)** 을 함께 넘기고, 표본은
14
+ * 커널이 자기 난수로 뽑는다(fork 결정성). 양쪽 꼬리를 다 관측에서 가져오는 이유: p90 만 있으면
15
+ * 아래쪽을 위쪽에서 거울처럼 베껴야 하는데 그건 발명이다.
16
+ *
17
+ * 정직 규율:
18
+ * - 표본이 적으면 쓰지 않는다(`minSamples`). 한두 건의 p50 은 그 현장의 값이 아니다.
19
+ * - 'unknown' 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼을 수 없다.
20
+ * - 퍼짐이 성립하지 않으면(p10 ≥ p90, 또는 0) **퍼짐 없이 평균만** 넘긴다. 없는 흔들림을 만들지 않는다.
21
+ * - 무엇을 배웠고 무엇을 표본 부족으로 버렸는지 함께 낸다(조용히 일부만 반영하지 않는다).
22
+ */
23
+
24
+ /** 커널 `DurationEstimate` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */
25
+ export interface MeasuredEstimate {
26
+ meanMs: number
27
+ spread?: { distribution: 'triangular'; minMs: number; maxMs: number; modeMs: number }
28
+ }
29
+
30
+ export interface MeasuredEstimatorResult {
31
+ estimator?: {
32
+ estimate(ctx: { kind: string; fromNode: string; toNode: string; resourceKind?: string }): MeasuredEstimate | undefined
33
+ }
34
+ /** 작업 종류 → 실측 소요(ms). 추정기가 답하는 종류들. */
35
+ learned: Record<string, number>
36
+ /** 퍼짐까지 배운 종류 — 평균만 배운 것과 구별한다(변동 없는 소요는 대기를 과소평가한다). */
37
+ spreads: Record<string, { minMs: number; maxMs: number }>
38
+ /** 표본이 모자라 쓰지 않은 종류와 그 건수 — 배우지 못한 것을 밝힌다. */
39
+ skipped: { kind: string; count: number }[]
40
+ minSamples: number
41
+ }
42
+
43
+ /** 기본 표본 하한 — 이보다 적으면 그 종류는 배우지 않는다(작은 표본의 p50 을 현장 값으로 승격 금지). */
44
+ export const DEFAULT_MIN_SAMPLES = 5
45
+
46
+ /**
47
+ * 작업 종류별 KPI 그룹에서 추정기를 만든다(순수 — DB·커널 모름).
48
+ * `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다.
49
+ */
50
+ export function buildMeasuredEstimator(
51
+ groups: { key: string; workTime?: { count: number; p10Ms?: number; p50Ms: number; p90Ms?: number } }[] | undefined,
52
+ opts: { minSamples?: number } = {}
53
+ ): MeasuredEstimatorResult {
54
+ const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES
55
+ const learned: Record<string, number> = {}
56
+ const spreads: Record<string, { minMs: number; maxMs: number }> = {}
57
+ const estimates: Record<string, MeasuredEstimate> = {}
58
+ const skipped: { kind: string; count: number }[] = []
59
+ for (const g of groups ?? []) {
60
+ if (!g?.key || g.key === 'unknown') continue
61
+ const w = g.workTime
62
+ const count = w?.count ?? 0
63
+ const p50 = w?.p50Ms ?? 0
64
+ if (count < minSamples || !(p50 > 0)) { skipped.push({ kind: g.key, count }); continue }
65
+ const mean = Math.round(p50)
66
+ learned[g.key] = mean
67
+ /* 퍼짐은 **관측된 두 꼬리**로만 만든다. 한쪽이라도 없거나 구간이 성립하지 않으면 평균만 넘긴다. */
68
+ const lo = w?.p10Ms
69
+ const hi = w?.p90Ms
70
+ if (typeof lo === 'number' && typeof hi === 'number' && lo > 0 && hi > lo) {
71
+ const minMs = Math.round(lo)
72
+ const maxMs = Math.round(hi)
73
+ spreads[g.key] = { minMs, maxMs }
74
+ estimates[g.key] = { meanMs: mean, spread: { distribution: 'triangular', minMs, maxMs, modeMs: mean } }
75
+ } else {
76
+ estimates[g.key] = { meanMs: mean }
77
+ }
78
+ }
79
+ if (!Object.keys(learned).length) return { learned, spreads, skipped, minSamples }
80
+ return {
81
+ learned,
82
+ spreads,
83
+ skipped,
84
+ minSamples,
85
+ estimator: {
86
+ estimate(ctx) {
87
+ return estimates[ctx.kind]
88
+ }
89
+ }
90
+ }
91
+ }