@things-factory/headless-twin 10.0.7 → 10.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-server/engine/attention-digest.d.ts +52 -0
- package/dist-server/engine/attention-digest.js +76 -0
- package/dist-server/engine/attention-digest.js.map +1 -0
- package/dist-server/engine/board-vocabulary.d.ts +17 -0
- package/dist-server/engine/board-vocabulary.js +63 -0
- package/dist-server/engine/board-vocabulary.js.map +1 -0
- package/dist-server/engine/command-routing.d.ts +33 -0
- package/dist-server/engine/command-routing.js +53 -0
- package/dist-server/engine/command-routing.js.map +1 -0
- package/dist-server/engine/entity-delta.d.ts +13 -6
- package/dist-server/engine/entity-delta.js +38 -12
- package/dist-server/engine/entity-delta.js.map +1 -1
- package/dist-server/engine/index.d.ts +6 -0
- package/dist-server/engine/index.js +10 -0
- package/dist-server/engine/index.js.map +1 -1
- package/dist-server/engine/kpi-baseline.d.ts +78 -0
- package/dist-server/engine/kpi-baseline.js +123 -0
- package/dist-server/engine/kpi-baseline.js.map +1 -0
- package/dist-server/engine/kpi-broadcast.d.ts +4 -0
- package/dist-server/engine/kpi-broadcast.js +16 -0
- package/dist-server/engine/kpi-broadcast.js.map +1 -0
- package/dist-server/engine/kpi-query.d.ts +31 -0
- package/dist-server/engine/kpi-query.js +50 -2
- package/dist-server/engine/kpi-query.js.map +1 -1
- package/dist-server/engine/live-attentions.d.ts +1 -0
- package/dist-server/engine/live-attentions.js +7 -1
- package/dist-server/engine/live-attentions.js.map +1 -1
- package/dist-server/engine/runtime-key.d.ts +15 -0
- package/dist-server/engine/runtime-key.js +64 -0
- package/dist-server/engine/runtime-key.js.map +1 -0
- package/dist-server/engine/state-axes.d.ts +16 -0
- package/dist-server/engine/state-axes.js +54 -0
- package/dist-server/engine/state-axes.js.map +1 -0
- package/dist-server/engine/twin-engine.d.ts +92 -16
- package/dist-server/engine/twin-engine.js +335 -77
- package/dist-server/engine/twin-engine.js.map +1 -1
- package/dist-server/engine/twin-level.d.ts +23 -0
- package/dist-server/engine/twin-level.js +52 -0
- package/dist-server/engine/twin-level.js.map +1 -0
- package/dist-server/engine/warm-start.d.ts +58 -12
- package/dist-server/engine/warm-start.js +80 -9
- package/dist-server/engine/warm-start.js.map +1 -1
- package/dist-server/service/reference/discovery-result.d.ts +34 -0
- package/dist-server/service/reference/discovery-result.js +84 -0
- package/dist-server/service/reference/discovery-result.js.map +1 -0
- package/dist-server/service/reference/ingest-space.d.ts +30 -0
- package/dist-server/service/reference/ingest-space.js +63 -0
- package/dist-server/service/reference/ingest-space.js.map +1 -0
- package/dist-server/service/reference/knob-defaults.d.ts +20 -0
- package/dist-server/service/reference/knob-defaults.js +59 -0
- package/dist-server/service/reference/knob-defaults.js.map +1 -0
- package/dist-server/service/reference/reference-live.js +2 -2
- package/dist-server/service/reference/reference-live.js.map +1 -1
- package/dist-server/service/reference/reference-master.d.ts +37 -2
- package/dist-server/service/reference/reference-master.js +55 -5
- package/dist-server/service/reference/reference-master.js.map +1 -1
- package/dist-server/service/reference/reference-resolver.d.ts +2 -2
- package/dist-server/service/reference/reference-resolver.js +71 -15
- package/dist-server/service/reference/reference-resolver.js.map +1 -1
- package/dist-server/service/twin-attention/twin-attention-query.d.ts +8 -1
- package/dist-server/service/twin-attention/twin-attention-query.js +39 -8
- package/dist-server/service/twin-attention/twin-attention-query.js.map +1 -1
- package/dist-server/service/twin-control/twin-control-mutation.d.ts +2 -0
- package/dist-server/service/twin-control/twin-control-mutation.js +29 -10
- package/dist-server/service/twin-control/twin-control-mutation.js.map +1 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js +2 -1
- package/dist-server/service/twin-forecast/twin-forecast-query.js.map +1 -1
- package/dist-server/service/twin-instance/twin-instance.js +4 -2
- package/dist-server/service/twin-instance/twin-instance.js.map +1 -1
- package/dist-server/service/twin-journal/twin-journal-query.d.ts +6 -2
- package/dist-server/service/twin-journal/twin-journal-query.js +25 -7
- package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.d.ts +8 -0
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js +23 -1
- package/dist-server/service/twin-lifecycle/twin-lifecycle-mutation.js.map +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js +1 -1
- package/dist-server/service/twin-metrics/twin-metrics-query.js.map +1 -1
- package/dist-server/service/twin-space/twin-space-resolver.js +9 -0
- package/dist-server/service/twin-space/twin-space-resolver.js.map +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js +1 -1
- package/dist-server/service/twin-state/twin-state-subscription.js.map +1 -1
- package/dist-server/service/twin-structure/twin-structure.js +2 -1
- package/dist-server/service/twin-structure/twin-structure.js.map +1 -1
- package/dist-server/service/twin-target/twin-target-resolver.js +21 -4
- package/dist-server/service/twin-target/twin-target-resolver.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/engine/attention-digest.ts +102 -0
- package/server/engine/board-vocabulary.ts +61 -0
- package/server/engine/command-routing.ts +67 -0
- package/server/engine/entity-delta.ts +33 -11
- package/server/engine/index.ts +10 -0
- package/server/engine/kpi-baseline.ts +202 -0
- package/server/engine/kpi-broadcast.ts +13 -0
- package/server/engine/kpi-query.ts +81 -3
- package/server/engine/live-attentions.ts +7 -2
- package/server/engine/runtime-key.ts +58 -0
- package/server/engine/state-axes.ts +55 -0
- package/server/engine/twin-engine.ts +350 -77
- package/server/engine/twin-level.ts +48 -0
- package/server/engine/warm-start.ts +130 -16
- package/server/service/reference/discovery-result.ts +95 -0
- package/server/service/reference/ingest-space.ts +70 -0
- package/server/service/reference/knob-defaults.ts +59 -0
- package/server/service/reference/reference-live.ts +2 -2
- package/server/service/reference/reference-master.ts +94 -8
- package/server/service/reference/reference-resolver.ts +80 -16
- package/server/service/twin-attention/twin-attention-query.ts +43 -6
- package/server/service/twin-control/twin-control-mutation.ts +31 -12
- package/server/service/twin-forecast/twin-forecast-query.ts +3 -2
- package/server/service/twin-instance/twin-instance.ts +6 -2
- package/server/service/twin-journal/twin-journal-query.ts +35 -5
- package/server/service/twin-lifecycle/twin-lifecycle-mutation.ts +18 -2
- package/server/service/twin-metrics/twin-metrics-query.ts +1 -1
- package/server/service/twin-space/twin-space-resolver.ts +10 -1
- package/server/service/twin-state/twin-state-subscription.ts +1 -1
- package/server/service/twin-structure/twin-structure.ts +4 -1
- package/server/service/twin-target/twin-target-resolver.ts +22 -4
- package/test/attention-digest.test.ts +135 -0
- package/test/board-vocabulary.test.ts +114 -0
- package/test/capability-mapping.test.ts +4 -4
- package/test/command-routing.test.ts +61 -0
- package/test/discovery-result.test.ts +75 -0
- package/test/entity-delta.test.ts +25 -25
- package/test/ingest-bench.test.ts +3 -3
- package/test/ingest-space.test.ts +50 -0
- package/test/knob-defaults.test.ts +72 -0
- package/test/kpi-baseline-db.test.ts +214 -0
- package/test/kpi-baseline.test.ts +196 -0
- package/test/kpi-query-bench.test.ts +128 -0
- package/test/live-mirror-parity.test.ts +35 -2
- package/test/master-to-twin.test.ts +7 -3
- package/test/mutation-gate.test.ts +108 -0
- package/test/oee-accumulator.test.ts +65 -1
- package/test/registry-key-guard.test.ts +80 -0
- package/test/runtime-key.test.ts +66 -0
- package/test/scale-twin-bench.test.ts +2 -2
- package/test/state-axes.test.ts +74 -0
- package/test/streamline-e2e.test.ts +2 -2
- package/test/structure-revision-db.test.ts +5 -4
- package/test/tenant-registry-db.test.ts +149 -0
- package/test/warm-start-seam.test.ts +140 -0
- package/test/warm-start.test.ts +138 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/headless-twin",
|
|
3
|
-
"version": "10.0.
|
|
3
|
+
"version": "10.0.9",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh <heartyoh@hatiolab.com>",
|
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@operato/twin-kernel": "^0.4.0",
|
|
29
|
-
"@things-factory/auth-base": "^10.0.
|
|
30
|
-
"@things-factory/cache-service": "^10.0.
|
|
31
|
-
"@things-factory/env": "^10.0.
|
|
32
|
-
"@things-factory/shell": "^10.0.
|
|
29
|
+
"@things-factory/auth-base": "^10.0.9",
|
|
30
|
+
"@things-factory/cache-service": "^10.0.9",
|
|
31
|
+
"@things-factory/env": "^10.0.8",
|
|
32
|
+
"@things-factory/shell": "^10.0.9"
|
|
33
33
|
},
|
|
34
|
-
"gitHead": "
|
|
34
|
+
"gitHead": "b7a6c48c213e7a504114397b752004a295c1df72"
|
|
35
35
|
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 주의 신호 요약 — **순수**. "화면이 실제로 쓰는 두 가지만 값싸게 만든다."
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 있나 (2026-08-05) ────────────────────────────────────────────────────
|
|
5
|
+
* 축 선택으로 리프레시를 80KB → 2KB 로 줄였는데, **신호가 많은 트윈은 거기서 막혔다**: `hatio-yard`
|
|
6
|
+
* 는 신호 397건이 108KB 라 5배 감소에 그쳤다. 그런데 화면이 그 397건으로 하는 일은 둘뿐이다.
|
|
7
|
+
*
|
|
8
|
+
* ① 레일에 **20건**만 보여주고 나머지는 "N건 더" 라고만 말한다.
|
|
9
|
+
* ② 자리(폴리곤)를 칠할 **자리별 최고 심각도**를 뽑는다 — 397건이 자리 7곳의 색 7개로 접힌다.
|
|
10
|
+
*
|
|
11
|
+
* 즉 전량을 내려보내 클라이언트가 접는 구조였다. 접는 것은 값싸므로 **서버가 접어서 보낸다.**
|
|
12
|
+
*
|
|
13
|
+
* ── 조용히 자르지 않는다 ────────────────────────────────────────────────────
|
|
14
|
+
* 잘랐다는 사실과 **전체 개수**를 함께 보낸다. 경고 목록에서 조용한 누락은 "괜찮은 줄 알았다" 로
|
|
15
|
+
* 이어진다 — 레일이 이미 "N건 더" 를 말하고 있었고, 그 N 은 전체 개수에서 나와야 맞다.
|
|
16
|
+
*
|
|
17
|
+
* ── 정렬을 한 곳에 둔다 ─────────────────────────────────────────────────────
|
|
18
|
+
* 상위 N 을 서버가 고르므로 **클라이언트와 같은 순서**여야 한다. 순서가 다르면 서버가 고른 20건이
|
|
19
|
+
* 화면이 고를 20건과 달라지고, 그 어긋남은 아무 데서도 오류로 드러나지 않는다.
|
|
20
|
+
* 순서 = 확인된 것은 아래로, 그다음 심각도 높은 것부터(클라이언트 정렬과 동일).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 알람 표준(ISA-18.2) 우선순위 — 큰 값이 급하다. */
|
|
24
|
+
const RANK: Record<string, number> = { low: 1, medium: 2, high: 3, critical: 4 }
|
|
25
|
+
|
|
26
|
+
interface AttentionLike {
|
|
27
|
+
id?: string
|
|
28
|
+
/** 어느 트윈(렌즈)에서 온 신호인가 — 공간에서 모을 때 붙는다. */
|
|
29
|
+
instanceId?: string
|
|
30
|
+
severity?: string
|
|
31
|
+
state?: string
|
|
32
|
+
anchor?: { locationId?: string }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AttentionDigestResult {
|
|
36
|
+
/** 상위 N — 레일이 그리는 것. */
|
|
37
|
+
attentions: AttentionLike[]
|
|
38
|
+
/** 전체 개수 — 레일의 "N건 더" 가 이 값에서 나온다(자른 사실을 밝힌다). */
|
|
39
|
+
attentionTotal: number
|
|
40
|
+
/**
|
|
41
|
+
* 자리별 최고 심각도 — **전량을 근거로** 접는다.
|
|
42
|
+
*
|
|
43
|
+
* 상위 N 만으로 접으면 20위 밖의 신호를 가진 자리가 색을 잃는다. 그것이 이 요약의 핵심 이유다:
|
|
44
|
+
* 레일은 잘라도 되지만 **지도 색은 잘라선 안 된다.**
|
|
45
|
+
*/
|
|
46
|
+
severityByLocation: Record<string, string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 화면 정렬과 같은 순서 — 확인된 것은 아래로, 그다음 심각도 내림차순. */
|
|
50
|
+
export const compareAttentions = (a: AttentionLike, b: AttentionLike): number =>
|
|
51
|
+
(a.state === 'acknowledged' ? 1 : 0) - (b.state === 'acknowledged' ? 1 : 0) ||
|
|
52
|
+
(RANK[b.severity ?? ''] ?? 0) - (RANK[a.severity ?? ''] ?? 0)
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 신호를 요약한다.
|
|
56
|
+
*
|
|
57
|
+
* `limit` 이 없거나 0 이하면 **자르지 않는다** — 자르는 쪽이 기본이면 이 값을 모르는 소비처가
|
|
58
|
+
* 조용히 일부만 받는다(요약을 도입하다 만드는 전형적인 사고다).
|
|
59
|
+
*/
|
|
60
|
+
export function digestAttentions(attentions: readonly AttentionLike[] | undefined | null, limit?: number): AttentionDigestResult {
|
|
61
|
+
const all = Array.isArray(attentions) ? attentions : []
|
|
62
|
+
|
|
63
|
+
/* 자리 색은 전량으로 접는다 — 잘린 뒤에 접으면 색을 잃는 자리가 생긴다. */
|
|
64
|
+
const severityByLocation: Record<string, string> = {}
|
|
65
|
+
for (const a of all) {
|
|
66
|
+
const nid = a?.anchor?.locationId
|
|
67
|
+
if (!nid) continue
|
|
68
|
+
const cur = severityByLocation[nid]
|
|
69
|
+
if (!cur || (RANK[a.severity ?? ''] ?? 0) > (RANK[cur] ?? 0)) severityByLocation[nid] = a.severity ?? ''
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sorted = [...all].sort(compareAttentions)
|
|
73
|
+
const capped = limit && limit > 0 ? sorted.slice(0, limit) : sorted
|
|
74
|
+
return { attentions: capped, attentionTotal: all.length, severityByLocation }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 렌즈 하나가 낸 신호 — 어느 트윈에서 왔는지와 함께. */
|
|
78
|
+
export interface LensAttentions {
|
|
79
|
+
instanceId: string
|
|
80
|
+
attentions: readonly AttentionLike[] | undefined | null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 여러 렌즈의 신호를 **한 현장의 목록으로** 모은다.
|
|
85
|
+
*
|
|
86
|
+
* 한 공장에 WMS·MES·YMS 가 함께 있으므로 "이 현장에서 봐야 할 것" 은 렌즈 하나로 답할 수 없다.
|
|
87
|
+
* 모으면서 지키는 것 셋:
|
|
88
|
+
*
|
|
89
|
+
* ① **어느 트윈에서 왔는지 붙인다**(`instanceId`) — 조치는 결국 그 트윈에 보내야 하고, 공간에서 보면
|
|
90
|
+
* 같은 자리 id 가 렌즈마다 다른 것을 가리킬 수 있다.
|
|
91
|
+
* ② **순서는 렌즈 순서가 아니라 급한 순서다** — 앞 렌즈의 낮은 신호가 뒤 렌즈의 심각한 신호를
|
|
92
|
+
* 밀어내면, 상위 N 만 보는 화면에서 정작 급한 것이 안 보인다.
|
|
93
|
+
* ③ **자리 색은 렌즈를 가로질러 가장 급한 것으로** 접는다 — 두 렌즈가 같은 자리를 가리키면 그 자리는
|
|
94
|
+
* 하나의 현실이므로 더 급한 쪽이 이긴다(둘 중 하나를 임의로 고르면 색이 렌즈 순서에 따라 달라진다).
|
|
95
|
+
*/
|
|
96
|
+
export function mergeLensAttentions(lenses: readonly LensAttentions[], limit?: number): AttentionDigestResult {
|
|
97
|
+
const all: AttentionLike[] = []
|
|
98
|
+
for (const lens of lenses) {
|
|
99
|
+
for (const a of lens.attentions ?? []) all.push({ ...a, instanceId: lens.instanceId } as AttentionLike)
|
|
100
|
+
}
|
|
101
|
+
return digestAttentions(all, limit)
|
|
102
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 저장된 보드의 **어휘 세대를 흡수한다** — 읽는 순간 한 어휘로. 순수.
|
|
3
|
+
*
|
|
4
|
+
* ── 계기 (2026-08-06, 사용자 신고) ───────────────────────────────────────────
|
|
5
|
+
* 트윈 모델러의 엔티티 목록이 **통째로 비어 있었다.** 오류도 없었다. 원인은 이 인스턴스의 저장된 보드가
|
|
6
|
+
* 옛 어휘였고 화면과 호스트는 새 어휘(`locations`/`equipment`)를 읽었기 때문이다. 그 옛 이름들은
|
|
7
|
+
* 아래 `RETIRED_KEYS` 에 있다 — 흡수할 세대의 이름을 적지 않고는 이 파일을 쓸 수 없다.
|
|
8
|
+
*
|
|
9
|
+
* 커널에는 이미 세대를 흡수하는 리더가 있다(`readBoardLocations`·`readBoardEquipment` — 세 세대
|
|
10
|
+
* `movers`→`equipmentList`→`equipment`, 실측 주석에 "저장된 보드 23개 중 13개가 `movers`"). 그런데 **그
|
|
11
|
+
* 리더를 거치는 곳이 목록뿐**이었다. 상세·구조 서명·주행 추정기·설비 동기는 컬렉션을 직접 읽어
|
|
12
|
+
* **오류 없이 빈 공장**으로 읽었다:
|
|
13
|
+
*
|
|
14
|
+
* · 모델러·팔레트·보드 자동생성 → 엔티티 0(사용자가 본 증상)
|
|
15
|
+
* · 구조 서명 → `N[]M[]` — **서로 다른 두 공장이 구조적으로 같아 보인다**(변경 감지가 죽는다)
|
|
16
|
+
* · 주행 추정기 → 설비 0이라 거리 기반 소요시간을 못 만든다
|
|
17
|
+
* · 설비 동기 → 아는 설비가 0이라 중복 append + 한 보드에 두 세대가 섞인다
|
|
18
|
+
*
|
|
19
|
+
* ── 왜 자리마다 고치지 않나 ─────────────────────────────────────────────────
|
|
20
|
+
* 저장된 보드를 읽는 자리가 **27곳**이다. 자리마다 리더를 쓰게 하면 다음에 추가되는 자리가 또 빠진다 —
|
|
21
|
+
* 그리고 빠진 것은 오류가 아니라 **빈 공장**이라 조용하다. 그래서 **DB에서 읽는 순간 한 번** 정규화한다
|
|
22
|
+
* (엔티티 컬럼 변환기). 그 뒤로는 코드가 세대를 알 필요가 없다.
|
|
23
|
+
*
|
|
24
|
+
* ── 쓰기는 건드리지 않는다 ──────────────────────────────────────────────────
|
|
25
|
+
* 저장은 주는 대로 한다(새 쓰기는 이미 새 어휘다). 읽기가 정규화되므로, 저장된 옛 보드는 **다음에
|
|
26
|
+
* 저장될 때 자연히 새 어휘로 옮겨간다** — 마이그레이션을 따로 돌리지 않는다.
|
|
27
|
+
*/
|
|
28
|
+
import { readBoardLocations, readBoardEquipment } from '@operato/twin-kernel'
|
|
29
|
+
|
|
30
|
+
/** 흡수하고 **내보내지 않는** 옛 키 — 남겨 두면 소비처가 두 어휘를 다시 보게 된다. */
|
|
31
|
+
const RETIRED_KEYS = ['nodes', 'movers', 'equipmentList'] as const // vocabulary-guard: allow — 옛 어휘를 흡수하는 것이 이 코드의 일이다(세대 이름을 적어야 흡수할 수 있다)
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 보드를 정본 어휘로 — `locations`·`equipment` 만 남는다. 새 어휘 보드에는 **아무 일도 하지 않는다**
|
|
35
|
+
* (같은 값을 다시 담을 뿐이라 멱등).
|
|
36
|
+
*
|
|
37
|
+
* 보드가 아닌 것(없음·배열·원시값)은 **그대로 통과**시킨다 — 모양을 짐작해 고치지 않는다.
|
|
38
|
+
*/
|
|
39
|
+
export function canonicalBoard<T = any>(board: T): T {
|
|
40
|
+
if (!board || typeof board !== 'object' || Array.isArray(board)) return board
|
|
41
|
+
|
|
42
|
+
const rest: Record<string, unknown> = {}
|
|
43
|
+
for (const [k, v] of Object.entries(board as Record<string, unknown>)) {
|
|
44
|
+
if ((RETIRED_KEYS as readonly string[]).includes(k)) continue
|
|
45
|
+
rest[k] = v
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* 리더가 세대를 고른다 — 여기서 `??` 사슬을 다시 적으면 세대 규칙이 두 곳이 된다. */
|
|
49
|
+
return { ...rest, locations: readBoardLocations(board as any), equipment: readBoardEquipment(board as any) } as T
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 엔티티 컬럼 변환기 — **읽을 때만** 정규화한다.
|
|
54
|
+
*
|
|
55
|
+
* `to`(쓰기)는 손대지 않는다: 저장은 호출부가 준 그대로가 진실이고, 읽기가 정규화되므로 다음 저장에서
|
|
56
|
+
* 자연히 새 어휘가 된다.
|
|
57
|
+
*/
|
|
58
|
+
export const boardColumnTransformer = {
|
|
59
|
+
from: (value: any) => canonicalBoard(value),
|
|
60
|
+
to: (value: any) => value
|
|
61
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 커맨드 라우팅 판정 — **순수**. "이 커맨드를 누구에게 보낼 것인가" 만 정한다.
|
|
3
|
+
* 실제 dispatch·저널 기록은 엔진이 한다(`design/plans/command-routing.md` §1·§4 P0).
|
|
4
|
+
*
|
|
5
|
+
* ── 왜 떼어냈나 ─────────────────────────────────────────────────────────────
|
|
6
|
+
* 예전에는 리졸버가 `inst.runtime.dispatch(...)` 를 곧바로 불렀다. `runtime` 은 **시뮬 경로에서만**
|
|
7
|
+
* 만들어지므로 실물을 비추는 트윈(mirror)에서는 그 한 줄이 `TypeError` 로 터졌다 — 조치 채널이
|
|
8
|
+
* 통째로 닿지 않았고, 사용자에게는 이유 있는 거절이 아니라 그냥 실패였다.
|
|
9
|
+
*
|
|
10
|
+
* 판정이 코드 한가운데 있으면 이런 부류를 테스트로 못 잡는다(엔진은 DB·TypeORM 을 물고 있어 단위
|
|
11
|
+
* 테스트가 불러올 수 없다). 그래서 판정만 순수하게 떼어 둔다 — `planWarmStart` 와 같은 이유다.
|
|
12
|
+
*
|
|
13
|
+
* ── 판정 규칙 = 권위 × 성격 ─────────────────────────────────────────────────
|
|
14
|
+
* | 성격 \ 모드 | 시뮬 | 미러(현실을 비춤) |
|
|
15
|
+
* | 액추에이션 | 커널 런타임 | **거절**(아웃바운드 어댑터 미결선) |
|
|
16
|
+
* | 주석(확인 ack) | 커널 런타임 | 관측 커널 |
|
|
17
|
+
*
|
|
18
|
+
* 미러에서 액추에이션을 그냥 허용하면 **새 거짓말이 생긴다**: 트윈은 "보류됨" 이라 말하고 실 시스템은
|
|
19
|
+
* 모른다. 조용히 커널만 바꾸지 않고, 이유를 밝혀 거절한다.
|
|
20
|
+
*
|
|
21
|
+
* 반면 확인(ack)은 액추에이션이 아니다 — 신호를 봤다는 트윈 쪽 주석이라 현실을 건드리지 않는다.
|
|
22
|
+
* 미러에서도 성립해야 하므로 관측 커널로 보낸다.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** 관측 쪽 주석 커맨드 — 현실을 바꾸지 않으므로 미러에서도 통과한다. */
|
|
26
|
+
const ANNOTATION_COMMANDS = new Set<string>(['attention.ack'])
|
|
27
|
+
|
|
28
|
+
/** 라우팅에 필요한 인스턴스 사실만 — 엔진 객체를 그대로 받지 않는다(순수하게 유지). */
|
|
29
|
+
export interface RoutableInstance {
|
|
30
|
+
/** 'live' 면 현실을 비추는 트윈(미러). 그 외는 시뮬. */
|
|
31
|
+
mode?: string
|
|
32
|
+
/** 커널 런타임(tick/dispatch)이 붙어 있나 — 시뮬 경로에서만 만들어진다. */
|
|
33
|
+
hasRuntime: boolean
|
|
34
|
+
/** 관측 커널이 dispatch 를 받을 수 있나. */
|
|
35
|
+
hasKernelDispatch: boolean
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CommandRoute =
|
|
39
|
+
/** 커널 런타임으로(시뮬 — 커널이 세계다). */
|
|
40
|
+
| { target: 'runtime' }
|
|
41
|
+
/** 관측 커널로(미러 — 주석만). 방출된 사실은 저널 큐에 실어야 한다. */
|
|
42
|
+
| { target: 'kernel' }
|
|
43
|
+
/** 보내지 않는다. 사유는 언어 중립 코드 — 화면이 `twin.cmderr.<code>` 로 사람 말을 만든다. */
|
|
44
|
+
| { target: 'reject'; errorCode: string; errorParams?: Record<string, string | number> }
|
|
45
|
+
|
|
46
|
+
/** 이 커맨드가 실 프로세스를 바꾸는 것인가(= 액추에이션). */
|
|
47
|
+
export const isActuation = (type: string): boolean => !ANNOTATION_COMMANDS.has(type)
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 어디로 보낼지 정한다.
|
|
51
|
+
*
|
|
52
|
+
* `inst` 가 없거나 호출자 도메인 소유가 아니면 **같은 사유로** 거절한다 — 존재 여부를 알려 주면
|
|
53
|
+
* 다른 테넌트의 인스턴스 id 를 추측할 수 있다(있음/없음이 구별되면 그것이 정보다).
|
|
54
|
+
*/
|
|
55
|
+
export function routeCommand(inst: RoutableInstance | undefined, ownedByCaller: boolean, type: string): CommandRoute {
|
|
56
|
+
if (!inst || !ownedByCaller) return { target: 'reject', errorCode: 'no-instance' }
|
|
57
|
+
if (!type) return { target: 'reject', errorCode: 'unknown-command' }
|
|
58
|
+
|
|
59
|
+
if (inst.mode !== 'live') {
|
|
60
|
+
/* 시뮬 — 커널이 세계다. 런타임이 없으면 터뜨리지 않고 이유를 말한다. */
|
|
61
|
+
return inst.hasRuntime ? { target: 'runtime' } : { target: 'reject', errorCode: 'no-runtime' }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* 미러 — 실 프로세스 변경은 아웃바운드 어댑터의 몫이고 아직 결선되지 않았다. */
|
|
65
|
+
if (isActuation(type)) return { target: 'reject', errorCode: 'actuation-not-wired', errorParams: { type } }
|
|
66
|
+
return inst.hasKernelDispatch ? { target: 'kernel' } : { target: 'reject', errorCode: 'no-runtime' }
|
|
67
|
+
}
|
|
@@ -31,11 +31,33 @@ export interface EntityDelta {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
/** 집약 고정 태그 — 클라 provider 와 일치해야 한다(런타임 생성 엔티티는 개별 배치가 불가하므로 집약 뷰). */
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
/*
|
|
35
|
+
* 집약 채널의 접두사 — **실제 태그는 트윈 식별자를 붙여 만든다.**
|
|
36
|
+
*
|
|
37
|
+
* 예전에는 이 상수가 곧 태그였다. 그런데 발행 키는 (도메인, 태그)이고, **한 공간에 트윈이 여럿**
|
|
38
|
+
* 있으면(실측: 3곳 × 3개) 셋이 같은 태그로 발행해 서로 덮었다. 보드의 주문 목록판이 세 공장의 주문
|
|
39
|
+
* 중 마지막에 도착한 것을 보여 준다 — 오류 없이.
|
|
40
|
+
*
|
|
41
|
+
* 구간 성과 태그가 이미 `__kpi__:<트윈>` 으로 트윈을 붙이고 있었다(`kpi-broadcast.ts`). 같은 규약으로 맞춘다.
|
|
42
|
+
*/
|
|
43
|
+
export const ORDERS_TAG_PREFIX = '__orders__'
|
|
44
|
+
export const TASKS_TAG_PREFIX = '__tasks__'
|
|
45
|
+
export const ITEMS_TAG_PREFIX = '__items__'
|
|
46
|
+
export const PERSONS_TAG_PREFIX = '__persons__'
|
|
47
|
+
export const ASSETS_TAG_PREFIX = '__assets__'
|
|
48
|
+
|
|
49
|
+
/** 이 트윈의 집약 채널 태그. 트윈 식별자가 없으면 만들지 않는다 — 덮어쓰기의 원인이었다. */
|
|
50
|
+
export function aggregateTag(prefix: string, instanceId: string): string {
|
|
51
|
+
if (!instanceId) throw new Error('aggregateTag: instanceId required — 트윈 구분이 없는 태그는 다른 트윈의 것을 덮는다')
|
|
52
|
+
|
|
53
|
+
return `${prefix}:${instanceId}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const ordersTag = (instanceId: string) => aggregateTag(ORDERS_TAG_PREFIX, instanceId)
|
|
57
|
+
export const tasksTag = (instanceId: string) => aggregateTag(TASKS_TAG_PREFIX, instanceId)
|
|
58
|
+
export const itemsTag = (instanceId: string) => aggregateTag(ITEMS_TAG_PREFIX, instanceId)
|
|
59
|
+
export const personsTag = (instanceId: string) => aggregateTag(PERSONS_TAG_PREFIX, instanceId)
|
|
60
|
+
export const assetsTag = (instanceId: string) => aggregateTag(ASSETS_TAG_PREFIX, instanceId)
|
|
39
61
|
|
|
40
62
|
/**
|
|
41
63
|
* 통과시키지 않는 필드 — **빼는 데는 이유가 필요하다.**
|
|
@@ -102,7 +124,7 @@ function aggregateItem(entity: any, idKey: 'id' | 'epc'): any {
|
|
|
102
124
|
*/
|
|
103
125
|
export const ITEMS_AGGREGATE_LIMIT = 500
|
|
104
126
|
|
|
105
|
-
export function buildEntityDeltas(state: any): EntityDelta[] {
|
|
127
|
+
export function buildEntityDeltas(state: any, instanceId: string): EntityDelta[] {
|
|
106
128
|
const out: EntityDelta[] = []
|
|
107
129
|
|
|
108
130
|
for (const n of state?.locations ?? []) {
|
|
@@ -139,23 +161,23 @@ export function buildEntityDeltas(state: any): EntityDelta[] {
|
|
|
139
161
|
* 런타임에 생기고 사라지는 엔티티(오더·작업·물품)는 보드에 개별 배치할 수 없다 → 한 컴포넌트가
|
|
140
162
|
* 전체를 추적하는 집약 태그를 항상 방출한다. */
|
|
141
163
|
const orders = state?.orders ?? []
|
|
142
|
-
out.push({ tag:
|
|
164
|
+
out.push({ tag: ordersTag(instanceId), data: { entity: 'orders', orders: orders.map((o: any) => aggregateItem(o, 'id')) } })
|
|
143
165
|
|
|
144
166
|
const tasks = state?.tasks ?? []
|
|
145
|
-
out.push({ tag:
|
|
167
|
+
out.push({ tag: tasksTag(instanceId), data: { entity: 'tasks', tasks: tasks.map((t: any) => aggregateItem(t, 'id')) } })
|
|
146
168
|
|
|
147
169
|
/* 사람 집약 — 인원은 보드에 개별 배치할 수도 있지만(설비처럼), 전체를 한눈에 보는 소비처도 있다.
|
|
148
170
|
* 인원을 선언하지 않은 트윈에서는 빈 배열이 나간다(없음과 모름을 구별). */
|
|
149
171
|
const persons = state?.persons ?? []
|
|
150
|
-
out.push({ tag:
|
|
172
|
+
out.push({ tag: personsTag(instanceId), data: { entity: 'persons', persons: persons.map((p: any) => aggregateItem(p, 'id')) } })
|
|
151
173
|
|
|
152
174
|
const assets = state?.assets ?? []
|
|
153
|
-
out.push({ tag:
|
|
175
|
+
out.push({ tag: assetsTag(instanceId), data: { entity: 'assets', assets: assets.map((a: any) => aggregateItem(a, 'id')) } })
|
|
154
176
|
|
|
155
177
|
const items = state?.items ?? []
|
|
156
178
|
const shown = items.slice(0, ITEMS_AGGREGATE_LIMIT)
|
|
157
179
|
out.push({
|
|
158
|
-
tag:
|
|
180
|
+
tag: itemsTag(instanceId),
|
|
159
181
|
data: {
|
|
160
182
|
entity: 'items',
|
|
161
183
|
items: shown.map((it: any) => aggregateItem(it, 'epc')),
|
package/server/engine/index.ts
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
export * from './twin-engine.js'
|
|
2
2
|
export * from './canonical-ingest.js'
|
|
3
3
|
export * from './kpi-fold.js'
|
|
4
|
+
export * from './kpi-baseline.js'
|
|
5
|
+
/* 지표 정본 표(TWIN_METRIC)·목표 판정 — 소비처(twin-ai 등)가 지표 이름을 다시 열거하지 않게. */
|
|
6
|
+
export * from './kpi-target.js'
|
|
7
|
+
export * from './kpi-broadcast.js'
|
|
8
|
+
/* 집약 채널 태그(접두사 + 트윈별 태그 생성) — 소비처가 문자열을 다시 적지 않게 내보낸다. */
|
|
9
|
+
export * from './entity-delta.js'
|
|
10
|
+
/* 공간 계층의 단계 — 씬 표현·성과 카드·AI 가 같은 목록을 쓴다. */
|
|
11
|
+
export * from './twin-level.js'
|
|
12
|
+
/* 런타임 레지스트리 키 규약 — 소비처(다른 키 캐시·테스트)가 `:` 를 다시 적지 않게 함께 내보낸다. */
|
|
13
|
+
export * from './runtime-key.js'
|
|
4
14
|
export * from './kpi-query.js'
|
|
5
15
|
export * from './spec-coverage.js'
|
|
6
16
|
export * from './travel-estimator.js'
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 여러 구간 동시 비교 — **"어제보다 나아졌다" 를 넘어선다.** 순수.
|
|
3
|
+
*
|
|
4
|
+
* ── 왜 필요한가 (성과 맵 ③) ─────────────────────────────────────────────────
|
|
5
|
+
* 지금 비교 축은 **한 구간**만 본다(직전 구간 또는 하루 전). 그래서 판단이 딱 한 문장에서 멈춘다:
|
|
6
|
+
* "어제보다 나아졌다." **어제도 나빴으면 그 말은 쓸모가 없다.** 목표(`kpi-target`)가 그 한계를 한쪽에서
|
|
7
|
+
* 풀지만, 목표를 아직 걸지 않은 현장은 여전히 자기 숫자가 평소와 같은지도 알 수 없다.
|
|
8
|
+
*
|
|
9
|
+
* 최근 며칠의 **같은 시간대**를 함께 접으면 목표 없이도 말할 수 있다 — "오늘 이 시간대는 지난 7일 중
|
|
10
|
+
* 가장 낮다." 그것이 이 파일이 만드는 것이다: 창 여러 개의 **분포**와 그 안에서 현재의 **자리**.
|
|
11
|
+
*
|
|
12
|
+
* ── 이 파일이 하지 않는 것 ──────────────────────────────────────────────────
|
|
13
|
+
* · **지표 어휘를 모른다.** 이름·방향·값 꺼내는 법은 `kpi-target.ts` 의 `TWIN_METRIC` 이 소유한다. 여기는
|
|
14
|
+
* **이미 꺼낸 값**만 받는다 — 어휘가 두 곳에 생기면 그 사이에 매핑 표가 자라고 그것이 곧 방언이다.
|
|
15
|
+
* (덤으로: 이 모듈은 런타임 import 가 없어 테스트가 소스를 그대로 든다.)
|
|
16
|
+
* · **DB·조회를 모른다.** 창 목록만 계산하고, 그 창을 읽는 것은 호출부의 일이다.
|
|
17
|
+
* · **좋고 나쁨의 임계를 발명하지 않는다.** "평소보다 나쁘다" 는 중앙값과의 비교로만 말한다 — 임의의
|
|
18
|
+
* 퍼센트 임계를 두면 그 숫자가 곧 숨은 목표가 된다.
|
|
19
|
+
*
|
|
20
|
+
* ── 정직성 규율 (이 도메인의 핵심) ──────────────────────────────────────────
|
|
21
|
+
* ① **결측 창을 조용히 빼지 않는다.** 요청한 창 전부를 `measured` 와 함께 돌려주고, 분포는 측정된
|
|
22
|
+
* 것만으로 만든다. 빼고 말하면 "지난 7일" 이 실제로는 3일이었다는 것을 아무도 모른다.
|
|
23
|
+
* ② **표본이 둘 미만이면 자리를 말하지 않는다.** 하나를 분포라 부를 수 없다.
|
|
24
|
+
* ③ **현재 값이 없으면 자리를 말하지 않는다.** 결측을 0 으로 읽으면 시간 지표는 "낮을수록 좋다" 라서
|
|
25
|
+
* **일이 없던 구간이 최고 성적**으로 보고된다(목표 판정에서 이미 겪은 결함이다).
|
|
26
|
+
* ④ 왜 말하지 않는지를 `reason` 으로 밝힌다 — 조용히 빈 값을 주면 화면이 "분포 없음" 을 "평범함" 으로
|
|
27
|
+
* 그린다.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** 창 하나 — 폴드의 창과 같은 모양(ms). */
|
|
31
|
+
export interface BaselineWindow {
|
|
32
|
+
fromMs: number
|
|
33
|
+
toMs: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 며칠 전 같은 시간대인가, 몇 주 전 같은 요일·시간대인가. */
|
|
37
|
+
export type BaselineStride = 'day' | 'week'
|
|
38
|
+
|
|
39
|
+
/** 한 번에 볼 수 있는 과거 구간 수 — 창마다 조회가 한 번 더 붙으므로 상한을 둔다. */
|
|
40
|
+
export const MAX_BASELINE_PERIODS = 14
|
|
41
|
+
|
|
42
|
+
const MS_DAY = 24 * 60 * 60_000
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 비교할 과거 창들 — 현재 창을 **같은 길이로** 하루(또는 한 주)씩 뒤로 옮긴다.
|
|
46
|
+
*
|
|
47
|
+
* 가까운 것부터 준다(어제 → 그제 → …). 화면이 시간 순서를 다시 정하지 않아도 되게.
|
|
48
|
+
*
|
|
49
|
+
* **한계를 밝힌다**: 옮기는 것은 ms 산술이다. 트윈 공간은 고정 오프셋(`utcOffsetMinutes`)을 선언하므로
|
|
50
|
+
* 현재 모델에서는 "같은 시간대" 가 정확하다. 일광절약시간을 쓰는 시간대를 나중에 지원하면 전환이 있는
|
|
51
|
+
* 주의 하루가 한 시간 밀린다 — 그때는 오프셋 기준 캘린더 산술로 바꿔야 한다(지금 지어내지 않는다).
|
|
52
|
+
*/
|
|
53
|
+
export function baselineWindows(
|
|
54
|
+
fromMs: number,
|
|
55
|
+
toMs: number,
|
|
56
|
+
periods: number,
|
|
57
|
+
stride: BaselineStride = 'day'
|
|
58
|
+
): BaselineWindow[] {
|
|
59
|
+
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return []
|
|
60
|
+
const count = Math.max(0, Math.min(MAX_BASELINE_PERIODS, Math.floor(periods)))
|
|
61
|
+
const step = stride === 'week' ? 7 * MS_DAY : MS_DAY
|
|
62
|
+
|
|
63
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
64
|
+
fromMs: fromMs - step * (i + 1),
|
|
65
|
+
toMs: toMs - step * (i + 1)
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 지표의 성질 — 이름과 값 꺼내기는 호출부(`TWIN_METRIC`)가 소유한다. */
|
|
70
|
+
export interface MetricSpecLike {
|
|
71
|
+
direction: 'higher' | 'lower'
|
|
72
|
+
unit: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 과거 창 하나에서 꺼낸 값들 — 그 창에 기록이 없으면 값이 `undefined` 다. */
|
|
76
|
+
export interface BaselineSampleInput<M extends string> {
|
|
77
|
+
fromMs: number
|
|
78
|
+
toMs: number
|
|
79
|
+
values: Partial<Record<M, number | undefined>>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 과거 창 하나의 결과 — **결측도 남긴다**(빼면 "지난 7일" 이 거짓이 된다). */
|
|
83
|
+
export interface BaselineSample {
|
|
84
|
+
fromMs: number
|
|
85
|
+
toMs: number
|
|
86
|
+
measured: boolean
|
|
87
|
+
value?: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 왜 자리를 말하지 않는지. */
|
|
91
|
+
export type BaselineNoPosition = 'no-current' | 'too-few-samples'
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 분포 안에서 현재의 자리 — **방향을 적용한 읽기.**
|
|
95
|
+
*
|
|
96
|
+
* 임의의 임계가 없다: 표본 전부보다 좋으면 `best`, 전부보다 나쁘면 `worst`, 그 사이는 중앙값을 기준으로
|
|
97
|
+
* 가른다. `typical` 은 중앙값과 **같을 때**만이다.
|
|
98
|
+
*/
|
|
99
|
+
export type BaselineStanding = 'best' | 'better-than-typical' | 'typical' | 'worse-than-typical' | 'worst'
|
|
100
|
+
|
|
101
|
+
export interface MetricBaseline<M extends string = string> {
|
|
102
|
+
metric: M
|
|
103
|
+
direction: 'higher' | 'lower'
|
|
104
|
+
unit: string
|
|
105
|
+
/** 지금 창의 값 — 없으면 `undefined`(0 이 아니다). */
|
|
106
|
+
current?: number
|
|
107
|
+
/** 요청한 과거 창 전부(결측 포함, 가까운 것부터). */
|
|
108
|
+
samples: BaselineSample[]
|
|
109
|
+
/** 요청한 창 수 / 그중 실제로 측정된 수 — 둘을 함께 봐야 "지난 7일" 을 믿을 수 있다. */
|
|
110
|
+
requested: number
|
|
111
|
+
measuredCount: number
|
|
112
|
+
/** 측정된 표본만의 요약. 표본이 없으면 전부 `undefined`. */
|
|
113
|
+
min?: number
|
|
114
|
+
median?: number
|
|
115
|
+
max?: number
|
|
116
|
+
/** 측정 표본을 오름차순으로 놓았을 때 현재 값이 몇 번째인가(1 = 가장 작음) — **방향 없는 사실**. */
|
|
117
|
+
rank?: number
|
|
118
|
+
/** 그 순위를 셀 때의 표본 수(+현재 1). 순위만 보면 표본이 둘이었는지 열이었는지 알 수 없다. */
|
|
119
|
+
rankOf?: number
|
|
120
|
+
/** 방향을 적용한 읽기. 말할 수 없으면 없다. */
|
|
121
|
+
standing?: BaselineStanding
|
|
122
|
+
/** 자리를 말하지 않은 이유. */
|
|
123
|
+
noPosition?: BaselineNoPosition
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 중앙값 — 짝수 개면 가운데 둘의 평균(폴드의 분위수 규약과 달리 여기 표본은 아주 작다). */
|
|
127
|
+
function medianOf(sorted: readonly number[]): number | undefined {
|
|
128
|
+
if (!sorted.length) return undefined
|
|
129
|
+
const mid = sorted.length >> 1
|
|
130
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 지표별로 분포와 자리를 만든다.
|
|
135
|
+
*
|
|
136
|
+
* @param specs 지표 성질(방향·단위). 열거된 지표만 낸다 — 모르는 이름을 짐작하지 않는다.
|
|
137
|
+
* @param current 지금 창에서 꺼낸 값들(결측은 `undefined`).
|
|
138
|
+
* @param inputs 과거 창들에서 꺼낸 값들(가까운 것부터).
|
|
139
|
+
*/
|
|
140
|
+
export function summarizeBaseline<M extends string>(
|
|
141
|
+
specs: Readonly<Record<M, MetricSpecLike>>,
|
|
142
|
+
current: Partial<Record<M, number | undefined>>,
|
|
143
|
+
inputs: readonly BaselineSampleInput<M>[]
|
|
144
|
+
): MetricBaseline<M>[] {
|
|
145
|
+
const out: MetricBaseline<M>[] = []
|
|
146
|
+
|
|
147
|
+
for (const metric of Object.keys(specs) as M[]) {
|
|
148
|
+
const spec = specs[metric]
|
|
149
|
+
const samples: BaselineSample[] = inputs.map(s => {
|
|
150
|
+
const v = s.values[metric]
|
|
151
|
+
const measured = typeof v === 'number' && Number.isFinite(v)
|
|
152
|
+
return measured ? { fromMs: s.fromMs, toMs: s.toMs, measured: true, value: v as number } : { fromMs: s.fromMs, toMs: s.toMs, measured: false }
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
const values = samples.filter(s => s.measured).map(s => s.value as number)
|
|
156
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
157
|
+
const cur = current[metric]
|
|
158
|
+
const hasCurrent = typeof cur === 'number' && Number.isFinite(cur)
|
|
159
|
+
|
|
160
|
+
const row: MetricBaseline<M> = {
|
|
161
|
+
metric,
|
|
162
|
+
direction: spec.direction,
|
|
163
|
+
unit: spec.unit,
|
|
164
|
+
...(hasCurrent ? { current: cur as number } : {}),
|
|
165
|
+
samples,
|
|
166
|
+
requested: inputs.length,
|
|
167
|
+
measuredCount: values.length,
|
|
168
|
+
...(sorted.length ? { min: sorted[0], median: medianOf(sorted), max: sorted[sorted.length - 1] } : {})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* 자리를 말할 수 없는 두 경우 — 이유를 밝힌다(조용한 빈 값이 "평범함" 으로 읽힌다). */
|
|
172
|
+
if (!hasCurrent) row.noPosition = 'no-current'
|
|
173
|
+
else if (sorted.length < 2) row.noPosition = 'too-few-samples'
|
|
174
|
+
else {
|
|
175
|
+
const value = cur as number
|
|
176
|
+
const median = medianOf(sorted) as number
|
|
177
|
+
/* 오름차순에서 몇 번째인가 — 같은 값은 뒤에 놓는다(동률이면 "더 큰 쪽" 으로 세지 않는다). */
|
|
178
|
+
row.rank = sorted.filter(v => v < value).length + 1
|
|
179
|
+
row.rankOf = sorted.length + 1
|
|
180
|
+
|
|
181
|
+
const higherIsBetter = spec.direction === 'higher'
|
|
182
|
+
const beatsAll = higherIsBetter ? value > sorted[sorted.length - 1] : value < sorted[0]
|
|
183
|
+
const losesToAll = higherIsBetter ? value < sorted[0] : value > sorted[sorted.length - 1]
|
|
184
|
+
/* 중앙값보다 큰 것이 "좋은" 쪽인가 — 방향을 여기 한 번만 적용한다. */
|
|
185
|
+
const aboveMedianIsBetter = higherIsBetter === value > median
|
|
186
|
+
|
|
187
|
+
row.standing = beatsAll
|
|
188
|
+
? 'best'
|
|
189
|
+
: losesToAll
|
|
190
|
+
? 'worst'
|
|
191
|
+
: value === median
|
|
192
|
+
? 'typical'
|
|
193
|
+
: aboveMedianIsBetter
|
|
194
|
+
? 'better-than-typical'
|
|
195
|
+
: 'worse-than-typical'
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
out.push(row)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return out
|
|
202
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 구간 성과 카드의 **대상 식별자**.
|
|
3
|
+
*
|
|
4
|
+
* 예전에는 이 파일이 방송 payload 도 만들었다(`shapeKpiBroadcast`). 2026-08-06 에 방송을 없애고 카드가
|
|
5
|
+
* `twinKpi` 를 직접 묻게 바꿔서, 남은 것은 팔레트가 대상을 가리킬 때 쓰는 식별자뿐이다.
|
|
6
|
+
* 왜 바꿨는지는 `twin-engine.ts` 의 주석과 `operato-twin/client/scene/twin-kpi-source.ts` 에 있다.
|
|
7
|
+
*/
|
|
8
|
+
export const KPI_TAG_PREFIX = '__kpi__'
|
|
9
|
+
|
|
10
|
+
/** 이 트윈의 구간 성과 태그. */
|
|
11
|
+
export const kpiTag = (instanceId: string) => `${KPI_TAG_PREFIX}:${instanceId}`
|
|
12
|
+
|
|
13
|
+
/** 표식은 `entity` 로 — `kind` 는 도메인의 낱말이라 표식이 훔치지 않는다(`entity-delta.ts` 와 같은 규약). */
|