@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/headless-twin",
3
- "version": "10.0.5",
3
+ "version": "10.0.7",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh <heartyoh@hatiolab.com>",
@@ -25,11 +25,11 @@
25
25
  "test": "node --test test/*.test.ts"
26
26
  },
27
27
  "dependencies": {
28
- "@operato/twin-kernel": "^0.0.6",
29
- "@things-factory/auth-base": "^10.0.5",
30
- "@things-factory/cache-service": "^10.0.5",
28
+ "@operato/twin-kernel": "^0.4.0",
29
+ "@things-factory/auth-base": "^10.0.7",
30
+ "@things-factory/cache-service": "^10.0.7",
31
31
  "@things-factory/env": "^10.0.0",
32
- "@things-factory/shell": "^10.0.5"
32
+ "@things-factory/shell": "^10.0.7"
33
33
  },
34
- "gitHead": "42240a9b6d504181850ea30188f215434b2dd55f"
34
+ "gitHead": "fffd94e886b67ed9019993a572a632054e812fa3"
35
35
  }
@@ -1,9 +1,28 @@
1
1
  /*
2
2
  * 트윈 State 스냅샷 → 라이브 바인딩 delta 목록 (순수 매핑 — pubsub/도메인 무관 → 단위 테스트 가능).
3
3
  * publishEntityData 가 이 목록을 엔티티별 시그니처로 dedup 후 data(tag) 채널로 발행한다.
4
- * payload 계약은 씬 컴포넌트가 소비(twin-storage·mover·equipment·trend·order-tracker·resource-control):
5
- * node: occupancy/capacity/status/type · mover: location/motion/status/held/oee(정수 퍼센트) ·
6
- * order: status/progress/held · __orders__: 전체 오더 집약(Trackable 뷰).
4
+ *
5
+ * ── 규율: 레퍼런스가 아는 것은 **전부** 내보낸다 ────────────────────────────
6
+ * 트윈에서 사소한 속성은 없다. 소속 구역·소속 팔레트·수량·만료처럼 곁가지로 보이는 값이 실제로는
7
+ * 구역 롤업·적재 표현·로트 추적의 전제다. 그래서 여기서 **필드를 손으로 골라 담지 않는다.**
8
+ *
9
+ * 예전에는 엔티티마다 필요할 것 같은 필드만 열거했고, 그 결과 계약에 있는 것이 조용히 누락됐다
10
+ * (2026-08-01 감사: 노드 parentId 누락 · 작업 전량 누락 · 물품 전량 누락). 화이트리스트 방식은
11
+ * **계약이 자라도 화면이 따라오지 못한다** — 커널이 속성을 늘릴 때마다 이 파일을 고쳐야 하고, 고치는
12
+ * 것을 잊으면 아무 신호 없이 정보가 사라진다.
13
+ *
14
+ * 그래서 반대로 한다: **상태 객체가 가진 것을 그대로 통과**시키고, 통과시키지 **않을** 것만 이유와 함께
15
+ * 열거한다(EXCLUDED). 새 속성은 자동으로 화면에 도달하고, 빼려면 이유를 적어야 한다.
16
+ *
17
+ * ── 표식은 도메인 낱말을 훔치지 않는다 ─────────────────────────────────────
18
+ * 예전 payload 는 엔티티 종류 표식을 `kind` 로 실었다. 그런데 `kind` 는 **도메인의 낱말**이다 —
19
+ * 무버 kind=cutter, 작업 kind=putaway, 오더 kind=workorder. 표식이 그 이름을 차지하면 도메인 속성을
20
+ * 실을 자리가 없어진다(같은 병을 오늘 ox-timeline 의 `group` 에서도 고쳤다).
21
+ * 그래서 표식은 `entity`(우리 이름)로 옮기고 `kind` 는 도메인에 돌려준다.
22
+ *
23
+ * payload 계약(씬 컴포넌트가 소비): `entity`(표식) + 그 엔티티의 모든 속성(도메인 `kind` 포함).
24
+ * entity = location · equipment · person · asset · order · task · item,
25
+ * 그리고 집약 태그(__orders__ · __tasks__ · __items__ · __persons__ · __assets__).
7
26
  */
8
27
 
9
28
  export interface EntityDelta {
@@ -11,11 +30,26 @@ export interface EntityDelta {
11
30
  data: any
12
31
  }
13
32
 
14
- /** 오더 집약 고정 태그 — 클라 provider(ORDERS_TAG)와 일치. */
33
+ /** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */
15
34
  export const ORDERS_TAG = '__orders__'
35
+ export const TASKS_TAG = '__tasks__'
36
+ export const ITEMS_TAG = '__items__'
37
+ export const PERSONS_TAG = '__persons__'
38
+ export const ASSETS_TAG = '__assets__'
16
39
 
17
- /** OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트. 원시 누적 ms(idleMs 등)는 매 delta 증가해
18
- * 무변화-생략을 무력화하므로 제외(idle 자원의 매초 재방송 억제). oee 미측정이면 undefined. */
40
+ /**
41
+ * 통과시키지 않는 필드 — **빼는 데는 이유가 필요하다.**
42
+ *
43
+ * · `id`/`epc` — 태그로 이미 나가므로 개별 payload 에 중복해 싣지 않는다(집약 뷰에는 넣는다).
44
+ * · `oee` — 원시 누적(idleMs 등)이 매 delta 증가해 "무변화 생략" 을 무력화한다. shapeOee 로 정형화해 얹는다.
45
+ */
46
+ const EXCLUDED = new Set(['id', 'epc', 'oee'])
47
+
48
+ /**
49
+ * OEE 원시 비율/카운트 → 표시용 정수 퍼센트 + 카운트.
50
+ * 원시 누적 ms(idleMs 등)는 매 delta 증가해 무변화-생략을 무력화하므로 제외(유휴 자원의 매초 재방송 억제).
51
+ * oee 미측정이면 undefined.
52
+ */
19
53
  function shapeOee(o: any): any {
20
54
  if (!o) return undefined
21
55
  return {
@@ -28,22 +62,108 @@ function shapeOee(o: any): any {
28
62
  }
29
63
  }
30
64
 
65
+ /**
66
+ * 엔티티 하나의 payload — `entity` 표식 + 나머지 전부(도메인 `kind` 포함).
67
+ *
68
+ * `undefined` 값은 싣지 않는다(키의 유무가 시그니처를 흔들지 않게). 값이 없는 것과 키가 없는 것은
69
+ * 소비처에서 같으므로 안전하고, 무변화 생략이 잘 듣는다.
70
+ */
71
+ function payloadOf(entityKind: string, entity: any): any {
72
+ /* 표식은 `entity` — 도메인의 `kind` 를 덮지 않는다. */
73
+ const data: any = { entity: entityKind }
74
+ for (const [k, v] of Object.entries(entity ?? {})) {
75
+ if (EXCLUDED.has(k) || v === undefined) continue
76
+ data[k] = v
77
+ }
78
+ return data
79
+ }
80
+
81
+ /**
82
+ * 집약 뷰의 항목 — 개별 payload 와 같은 내용 + 식별자.
83
+ * 집약은 "전체를 한 컴포넌트가 추적" 하는 용도라 식별자가 있어야 한다.
84
+ */
85
+ function aggregateItem(entity: any, idKey: 'id' | 'epc'): any {
86
+ const out: any = { [idKey]: entity?.[idKey] }
87
+ for (const [k, v] of Object.entries(entity ?? {})) {
88
+ if (k === idKey || k === 'oee' || v === undefined) continue
89
+ out[k] = v
90
+ }
91
+ const shaped = shapeOee(entity?.oee)
92
+ if (shaped) out.oee = shaped
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * 물품 집약의 상한 — 큰 현장은 물품이 수만 개라, 전량을 매 방송에 실으면 전달이 현장을 느리게 한다.
98
+ *
99
+ * **조용히 자르지 않는다**: 넘치면 `total`(전체 수)과 `truncated`(잘린 수)를 함께 실어, 소비처가
100
+ * "이게 전부" 라고 오해하지 않게 한다. 개별 물품은 태그별 delta 로 따로 나가고 그쪽은 시그니처 dedup
101
+ * 이 걸려 **변한 것만** 재방송된다.
102
+ */
103
+ export const ITEMS_AGGREGATE_LIMIT = 500
104
+
31
105
  export function buildEntityDeltas(state: any): EntityDelta[] {
32
106
  const out: EntityDelta[] = []
33
- for (const n of state?.nodes ?? []) {
34
- out.push({ tag: n.id, data: { kind: 'node', occupancy: n.occupancy, capacity: n.capacity, status: n.status, type: n.type } })
107
+
108
+ for (const n of state?.locations ?? []) {
109
+ out.push({ tag: n.id, data: payloadOf('location', n) })
110
+ }
111
+ for (const m of state?.equipment ?? []) {
112
+ /* Mobile(location/motion) + Operable(status/held) + Processable(oee) — 한 payload 로.
113
+ * oee 만 정형화가 필요해 따로 얹는다(나머지는 통과). */
114
+ const data = payloadOf('equipment', m)
115
+ const oee = shapeOee(m?.oee)
116
+ if (oee) data.oee = oee
117
+ out.push({ tag: m.id, data })
35
118
  }
36
- for (const m of state?.movers ?? []) {
37
- // Mobile(location/motion) + Operable(status/held) + Processable(oee) 를 한 payload 로.
38
- out.push({ tag: m.id, data: { kind: 'mover', location: m.location, motion: m.motion, status: m.status, held: m.held, oee: shapeOee(m.oee) } })
119
+ /* 사람 — 설비와 별개 엔티티다. 같은 통과 규칙(가진 것을 전부 내보낸다). */
120
+ for (const p of state?.persons ?? []) {
121
+ out.push({ tag: p.id, data: payloadOf('person', p) })
122
+ }
123
+ /* 물리 자산(반복사용) — 물품도 설비도 아닌 네 번째 자원. */
124
+ for (const a of state?.assets ?? []) {
125
+ out.push({ tag: a.id, data: payloadOf('asset', a) })
39
126
  }
40
127
  for (const o of state?.orders ?? []) {
41
- out.push({ tag: o.id, data: { kind: 'order', status: o.status, progress: o.progress, held: o.held } })
128
+ out.push({ tag: o.id, data: payloadOf('order', o) })
129
+ }
130
+ for (const t of state?.tasks ?? []) {
131
+ out.push({ tag: t.id, data: payloadOf('task', t) })
132
+ }
133
+ for (const it of state?.items ?? []) {
134
+ /* 물품의 태그는 epc(개체 식별자) — 노드·무버와 달리 id 가 아니다. */
135
+ if (it?.epc) out.push({ tag: it.epc, data: payloadOf('item', it) })
42
136
  }
43
- // 집약(Trackable) — 오더는 런타임 생성이라 개별 배치 불가 → 한 컴포넌트가 전체 추적(집약 N:M). 항상 방출.
137
+
138
+ /* ── 집약 뷰 ──────────────────────────────────────────────────────────────
139
+ * 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가
140
+ * 전체를 추적하는 집약 태그를 항상 방출한다. */
141
+ const orders = state?.orders ?? []
142
+ out.push({ tag: ORDERS_TAG, data: { entity: 'orders', orders: orders.map((o: any) => aggregateItem(o, 'id')) } })
143
+
144
+ const tasks = state?.tasks ?? []
145
+ out.push({ tag: TASKS_TAG, data: { entity: 'tasks', tasks: tasks.map((t: any) => aggregateItem(t, 'id')) } })
146
+
147
+ /* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다.
148
+ * 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */
149
+ const persons = state?.persons ?? []
150
+ out.push({ tag: PERSONS_TAG, data: { entity: 'persons', persons: persons.map((p: any) => aggregateItem(p, 'id')) } })
151
+
152
+ const assets = state?.assets ?? []
153
+ out.push({ tag: ASSETS_TAG, data: { entity: 'assets', assets: assets.map((a: any) => aggregateItem(a, 'id')) } })
154
+
155
+ const items = state?.items ?? []
156
+ const shown = items.slice(0, ITEMS_AGGREGATE_LIMIT)
44
157
  out.push({
45
- tag: ORDERS_TAG,
46
- data: { kind: 'orders', orders: (state?.orders ?? []).map((o: any) => ({ id: o.id, kind: o.kind, status: o.status, progress: o.progress, held: o.held })) }
158
+ tag: ITEMS_TAG,
159
+ data: {
160
+ entity: 'items',
161
+ items: shown.map((it: any) => aggregateItem(it, 'epc')),
162
+ total: items.length,
163
+ /* 잘렸으면 그 사실을 말한다 — "이게 전부" 로 읽히면 재고를 과소 판단한다. */
164
+ ...(items.length > shown.length ? { truncated: items.length - shown.length } : {})
165
+ }
47
166
  })
167
+
48
168
  return out
49
169
  }
@@ -2,3 +2,7 @@ export * from './twin-engine.js'
2
2
  export * from './canonical-ingest.js'
3
3
  export * from './kpi-fold.js'
4
4
  export * from './kpi-query.js'
5
+ export * from './spec-coverage.js'
6
+ export * from './travel-estimator.js'
7
+ export * from './measured-estimator.js'
8
+ export * from './model-basis.js'
@@ -19,6 +19,14 @@
19
19
  * · DB·커널을 모른다. 입력은 평범한 배열이고 출력은 평범한 객체다(테스트가 값싸다).
20
20
  */
21
21
 
22
+ /*
23
+ * 짝맞춤은 **커널이 소유한다**(`foldTaskRecords`). 완료는 작업당 하나·나중 것이 사실, 착수는 처음 것,
24
+ * 종류·오더는 어느 전이에서든 — 그 규칙은 커널 상태 기계에서 나오는 지식이다. 여기서 다시 적었더니
25
+ * 공정 타임라인과 **다른 답**을 냈다(재전송된 완료를 두 건으로 세어 처리량·점유가 부풀려졌다).
26
+ * 이 파일은 이제 "접힌 기록 → 창 지표" 만 한다.
27
+ */
28
+ import { foldTaskRecords, activeShiftAt, type TaskFacets, type WorkCalendarEntry } from '@operato/twin-kernel'
29
+
22
30
  /** 저널 한 줄 — 필요한 것만(엔티티·typeorm 비의존). */
23
31
  export interface KpiEvent {
24
32
  eventType?: string
@@ -36,6 +44,11 @@ export interface KpiWindow {
36
44
  export interface DurationStats {
37
45
  /** 짝이 맞아 계산된 건수. */
38
46
  count: number
47
+ /**
48
+ * 하위 꼬리 — **분포를 양쪽에서 접지하기 위해** 낸다.
49
+ * p50·p90 만 있으면 아래쪽 퍼짐을 위쪽에서 거울처럼 베껴 쓸 수밖에 없다(발명). 관측이 말하게 한다.
50
+ */
51
+ p10Ms: number
39
52
  p50Ms: number
40
53
  p90Ms: number
41
54
  avgMs: number
@@ -48,10 +61,11 @@ export interface DurationStats {
48
61
  * 축을 못 내면 AI 도 못 낸다 — 그래서 축은 계산 층의 능력이어야 한다.
49
62
  *
50
63
  * resource 자원별(누가 했나) taskKind 작업 종류별(무엇을 했나)
51
- * node 도착 지점별(어디서) order 오더별(무엇을 위해)
64
+ * location 도착 지점별(어디서) order 오더별(무엇을 위해)
52
65
  * area 구역별 — 노드→구역 지도가 필요하다(이벤트에 없다. 호출부가 준다)
66
+ * shift 교대별(언제) — 완료 시각 + 현장의 시각 기준에서 **계산한다**(이벤트에 없다)
53
67
  */
54
- export type KpiGroupBy = 'resource' | 'taskKind' | 'node' | 'order' | 'area'
68
+ export type KpiGroupBy = 'resource' | 'taskKind' | 'location' | 'order' | 'area' | 'shift'
55
69
 
56
70
  export interface KpiGroup {
57
71
  /** 축의 값(자원 id·작업 종류·노드 id·오더 id·구역 id). 값이 없던 기록은 'unknown'. */
@@ -107,7 +121,17 @@ export interface KpiFoldOptions {
107
121
  * 노드 → 구역 지도. **이벤트에는 구역이 없다**(노드까지만 있다) — 스냅샷·마스터가 아는 값이라
108
122
  * 호출부가 넘긴다. 없으면 구역 축은 'unknown' 으로 모인다(조용히 다른 축으로 바꾸지 않는다).
109
123
  */
110
- nodeArea?: Record<string, string>
124
+ locationArea?: Record<string, string>
125
+ /**
126
+ * 교대 선언 — **이벤트에는 교대가 없다**(시각만 있다). 현장 마스터가 아는 값이라 호출부가 넘긴다.
127
+ * 없으면 교대 축은 'unknown' 으로 모인다(다른 축으로 조용히 바꾸지 않는다).
128
+ */
129
+ shifts?: WorkCalendarEntry[]
130
+ /**
131
+ * 현장의 시각 기준(분) — 교대 축이 `HH:MM` 을 어느 기준으로 읽을지. **공간이 갖는 값**이다
132
+ * (테넌트가 아니다: 한 테넌트가 다른 시간대의 공장을 함께 가질 수 있다). 없으면 UTC.
133
+ */
134
+ utcOffsetMinutes?: number
111
135
  /**
112
136
  * 축의 상한(기본 20). 넘치면 잘라내고 **잘린 축의 수와 그 안의 건수를 함께 알린다**.
113
137
  *
@@ -121,14 +145,6 @@ export interface KpiFoldOptions {
121
145
  * 축의 값은 이벤트 payload 에 이미 들어 있다(`TaskStatusDelta`: kind·orderId·fromNode·toNode·
122
146
  * resourceRef). 새 계측을 심을 필요가 없다 — **이미 쌓인 저널을 다르게 묶기만** 한다.
123
147
  */
124
- interface TaskFacets {
125
- resource?: string
126
- taskKind?: string
127
- /** 작업이 **도착한** 지점. 없으면 출발 지점(둘 다 없으면 축은 'unknown'). */
128
- node?: string
129
- order?: string
130
- }
131
-
132
148
  /** 창 안에 완료된 한 건의 기록 — 통계의 원료이자 관점 축의 원료. */
133
149
  interface CompletionRecord {
134
150
  taskId: string
@@ -139,23 +155,6 @@ interface CompletionRecord {
139
155
  waitMs?: number
140
156
  }
141
157
 
142
- /**
143
- * 이 작업에 대해 본 축의 값을 기억한다 — **처음 본 값을 남긴다**.
144
- *
145
- * 왜 처음 것인가: 작업의 종류·소속 오더는 생성 시점에 정해지고 이후 전이는 그것을 되풀이할 뿐이다.
146
- * 반면 노드는 진행에 따라 바뀌므로 **마지막 것**(가장 최근 도착지)이 사실이다.
147
- */
148
- function rememberFacets(store: Map<string, TaskFacets>, id: string, d: any): void {
149
- const prev = store.get(id) ?? {}
150
- const node = d.toNode ?? d.fromNode
151
- store.set(id, {
152
- resource: prev.resource ?? (d.resourceRef || undefined),
153
- taskKind: prev.taskKind ?? (d.kind || undefined),
154
- order: prev.order ?? (d.orderId || undefined),
155
- node: node || prev.node
156
- })
157
- }
158
-
159
158
  /** 이 기록이 요청한 축에서 어느 값에 속하는가. 값이 없으면 'unknown'(조용히 버리지 않는다). */
160
159
  function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
161
160
  const f = rec.facets
@@ -163,15 +162,30 @@ function facetKey(rec: CompletionRecord, options: KpiFoldOptions): string {
163
162
  case 'resource':
164
163
  return f.resource ?? 'unknown'
165
164
  case 'taskKind':
166
- return f.taskKind ?? 'unknown'
167
- case 'node':
168
- return f.node ?? 'unknown'
165
+ return f.kind ?? 'unknown'
166
+ case 'location':
167
+ return f.location ?? 'unknown'
169
168
  case 'order':
170
169
  return f.order ?? 'unknown'
171
170
  case 'area':
172
171
  /* 구역은 이벤트 밖의 지식이다 — 지도가 없으면 없다고 말한다(노드 id 로 대체하면 사용자가
173
172
  * 그것을 구역으로 오해한다). */
174
- return (f.node && options.nodeArea?.[f.node]) ?? 'unknown'
173
+ return (f.location && options.locationArea?.[f.location]) ?? 'unknown'
174
+ case 'shift':
175
+ /*
176
+ * 교대는 **저널에 찍혀 있지 않다 — 계산한다.**
177
+ *
178
+ * 이벤트에 교대 이름을 스탬프하면 두 가지가 무너진다: ① 같은 사실이 두 곳에 생기고,
179
+ * ② 나중에 교대 시간을 고치면 **과거가 옛 구분으로 굳어** 다시 셀 수 없다. 교대는
180
+ * `완료 시각 + 현장의 시각 기준 + 교대 선언`에서 나오는 순수 파생이므로 여기서 낸다 —
181
+ * 라이브·히스토리·예측이 **커널과 같은 함수**(`activeShiftOf`)를 쓴다.
182
+ *
183
+ * 선언이 없으면 'unknown' 이다. 교대를 나눠 놓지 않은 현장에 이름을 지어내지 않는다.
184
+ */
185
+ if (!options.shifts?.length) return 'unknown'
186
+ /* 시각까지 넘긴다 — **휴일에 일어난 일은 어느 교대에도 속하지 않는다.** 되풀이만 보면 그날 서지도
187
+ 않은 교대에 집계돼, 교대별 성과가 조용히 거짓이 된다. */
188
+ return activeShiftAt(options.shifts, rec.at, options.utcOffsetMinutes) ?? 'unknown'
175
189
  default:
176
190
  return 'unknown'
177
191
  }
@@ -235,11 +249,12 @@ function quantile(sorted: number[], q: number): number {
235
249
  }
236
250
 
237
251
  function stats(values: number[]): DurationStats {
238
- if (values.length === 0) return { count: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
252
+ if (values.length === 0) return { count: 0, p10Ms: 0, p50Ms: 0, p90Ms: 0, avgMs: 0 }
239
253
  const sorted = [...values].sort((a, b) => a - b)
240
254
  const sum = sorted.reduce((a, b) => a + b, 0)
241
255
  return {
242
256
  count: sorted.length,
257
+ p10Ms: quantile(sorted, 0.1),
243
258
  p50Ms: quantile(sorted, 0.5),
244
259
  p90Ms: quantile(sorted, 0.9),
245
260
  avgMs: Math.round(sum / sorted.length)
@@ -257,13 +272,8 @@ function stats(values: number[]): DurationStats {
257
272
  * (고정 오프셋은 상쇄된다). 파싱 불가한 줄은 버린다.
258
273
  */
259
274
  export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldOptions = {}): KpiResult {
260
- /* 작업별 이정표 시각 — 마지막 값을 남긴다(재시도로 같은 전이가 두 번 오면 나중 것이 사실). */
261
- const created = new Map<string, number>()
262
- const started = new Map<string, number>()
263
- /* 축의 값은 **어느 전이에서든** 올 수 있다(완료 이벤트에 kind 가 빠져 있고 생성에만 있는 구현이 있다).
264
- * 그래서 작업별로 한 번 본 값을 기억한다 — 축이 이벤트 모양에 따라 통째로 비는 것을 막는다. */
265
- const facets = new Map<string, TaskFacets>()
266
- const completedTasks: { id: string; at: number; resourceId?: string }[] = []
275
+ /* 작업 짝맞춤은 **커널 규칙**으로(중복 구현 금지 — 두 곳에 적으면 같은 저널로 다른 답이 나온다). */
276
+ const { records: tasks } = foldTaskRecords(events.filter(e => e.eventType === 'task.status'))
267
277
  let completedOrders = 0
268
278
  let seen = 0
269
279
  let inWindow = 0
@@ -275,15 +285,6 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
275
285
  if (at >= window.fromMs && at <= window.toMs) inWindow++
276
286
  const d = e.payload?.data ?? e.payload ?? {}
277
287
 
278
- if (e.eventType === 'task.status') {
279
- const id = d.taskId
280
- if (!id) continue
281
- rememberFacets(facets, id, d)
282
- if (d.status === 'created') created.set(id, at)
283
- else if (d.status === 'in-progress' && !started.has(id)) started.set(id, at)
284
- else if (d.status === 'completed') completedTasks.push({ id, at, resourceId: d.resourceRef })
285
- continue
286
- }
287
288
  if (e.eventType === 'order.status') {
288
289
  /* 오더 완료 어휘는 도메인 소유다 — 코어가 강제하지 않는다. 그래서 이름을 하나로 못 박지 않고
289
290
  * 완료로 읽히는 표현을 넓게 받는다(그 밖은 세지 않는다). */
@@ -305,19 +306,21 @@ export function foldKpi(events: KpiEvent[], window: KpiWindow, options: KpiFoldO
305
306
  let unpairedLead = 0
306
307
  let unpairedWork = 0
307
308
 
308
- for (const t of completedTasks) {
309
- if (t.at < window.fromMs || t.at > window.toMs) continue // 성과는 완료 시점으로 센다
309
+ for (const t of tasks) {
310
+ const completed = t.completedMs
311
+ if (completed === undefined) continue // 아직 완료되지 않은 작업 — 성과로 세지 않는다
312
+ if (completed < window.fromMs || completed > window.toMs) continue // 성과는 완료 시점으로 센다
310
313
  tasksInWindow++
311
- const c = created.get(t.id)
312
- const s = started.get(t.id)
313
- const rec: CompletionRecord = { taskId: t.id, at: t.at, facets: facets.get(t.id) ?? {} }
314
- if (t.resourceId) rec.facets = { ...rec.facets, resource: t.resourceId }
315
- if (c !== undefined && t.at >= c) leads.push((rec.leadMs = t.at - c))
314
+ const c = t.createdMs
315
+ const s = t.startedMs
316
+ const rec: CompletionRecord = { taskId: t.taskId, at: completed, facets: t.facets }
317
+ const resource = rec.facets.resource
318
+ if (c !== undefined && completed >= c) leads.push((rec.leadMs = completed - c))
316
319
  else unpairedLead++
317
- if (s !== undefined && t.at >= s) {
318
- const busy = t.at - s
320
+ if (s !== undefined && completed >= s) {
321
+ const busy = completed - s
319
322
  works.push((rec.workMs = busy))
320
- if (t.resourceId) busyByResource.set(t.resourceId, (busyByResource.get(t.resourceId) ?? 0) + busy)
323
+ if (resource) busyByResource.set(resource, (busyByResource.get(resource) ?? 0) + busy)
321
324
  } else {
322
325
  unpairedWork++
323
326
  }