@things-factory/headless-twin 10.0.4 → 10.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist-server/engine/index.d.ts +2 -0
  2. package/dist-server/engine/index.js +2 -0
  3. package/dist-server/engine/index.js.map +1 -1
  4. package/dist-server/engine/kpi-fold.d.ts +179 -0
  5. package/dist-server/engine/kpi-fold.js +270 -0
  6. package/dist-server/engine/kpi-fold.js.map +1 -0
  7. package/dist-server/engine/kpi-query.d.ts +156 -0
  8. package/dist-server/engine/kpi-query.js +287 -0
  9. package/dist-server/engine/kpi-query.js.map +1 -0
  10. package/dist-server/engine/twin-engine.d.ts +29 -2
  11. package/dist-server/engine/twin-engine.js +78 -21
  12. package/dist-server/engine/twin-engine.js.map +1 -1
  13. package/dist-server/engine/warm-start.d.ts +39 -0
  14. package/dist-server/engine/warm-start.js +37 -0
  15. package/dist-server/engine/warm-start.js.map +1 -0
  16. package/dist-server/index.js +8 -0
  17. package/dist-server/index.js.map +1 -1
  18. package/dist-server/service/twin-event/backfill-keys.d.ts +11 -0
  19. package/dist-server/service/twin-event/backfill-keys.js +63 -0
  20. package/dist-server/service/twin-event/backfill-keys.js.map +1 -0
  21. package/dist-server/service/twin-event/twin-event-keys.d.ts +35 -0
  22. package/dist-server/service/twin-event/twin-event-keys.js +95 -0
  23. package/dist-server/service/twin-event/twin-event-keys.js.map +1 -0
  24. package/dist-server/service/twin-event/twin-event-type.d.ts +6 -0
  25. package/dist-server/service/twin-event/twin-event-type.js +32 -0
  26. package/dist-server/service/twin-event/twin-event-type.js.map +1 -0
  27. package/dist-server/service/twin-event/twin-event.d.ts +5 -0
  28. package/dist-server/service/twin-event/twin-event.js +45 -0
  29. package/dist-server/service/twin-event/twin-event.js.map +1 -1
  30. package/dist-server/service/twin-journal/twin-journal-query.d.ts +43 -0
  31. package/dist-server/service/twin-journal/twin-journal-query.js +127 -0
  32. package/dist-server/service/twin-journal/twin-journal-query.js.map +1 -1
  33. package/dist-server/tsconfig.tsbuildinfo +1 -1
  34. package/package.json +6 -6
  35. package/server/engine/index.ts +2 -0
  36. package/server/engine/kpi-fold.ts +417 -0
  37. package/server/engine/kpi-query.ts +435 -0
  38. package/server/engine/twin-engine.ts +95 -28
  39. package/server/engine/warm-start.ts +53 -0
  40. package/server/index.ts +9 -0
  41. package/server/service/twin-event/backfill-keys.ts +72 -0
  42. package/server/service/twin-event/twin-event-keys.ts +102 -0
  43. package/server/service/twin-event/twin-event-type.ts +27 -0
  44. package/server/service/twin-event/twin-event.ts +48 -0
  45. package/server/service/twin-journal/twin-journal-query.ts +136 -2
  46. package/test/kpi-fold.test.ts +469 -0
  47. package/test/twin-event-keys.test.ts +108 -0
  48. package/test/warm-start.test.ts +78 -0
@@ -0,0 +1,156 @@
1
+ import { type CrossedGroup, type KpiGroupBy, type KpiResult } from './kpi-fold.js';
2
+ export interface TwinKpiInput {
3
+ domainId: string;
4
+ /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */
5
+ instanceId?: string;
6
+ /**
7
+ * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 접는다**.
8
+ *
9
+ * 트윈별로 접어 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다).
10
+ * 그래서 이벤트를 모아 **한 번** 접는다. 사용자는 "부산 창고" 를 보고, 그 안에 트윈이 몇 개든
11
+ * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율).
12
+ */
13
+ spaceId?: string;
14
+ /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */
15
+ toTime?: string;
16
+ /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */
17
+ fromTime?: string;
18
+ /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */
19
+ windowMinutes?: number;
20
+ /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */
21
+ lookbackMinutes?: number;
22
+ /**
23
+ * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음).
24
+ * 조회는 한 번이고 접기만 N 번 반복한다 — 폴드가 순수하므로 값싸다.
25
+ */
26
+ buckets?: number;
27
+ /**
28
+ * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별.
29
+ *
30
+ * 고정된 한 렌즈만 낼 수 있으면 "구역별로는 어떤가" 라는 질문에 화면도 AI 도 답할 수 없다.
31
+ * 축은 조회 인자 하나이고, 접기는 순수 함수라 축을 하나 더 보는 값이 거의 없다.
32
+ */
33
+ groupBy?: KpiGroupBy;
34
+ /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */
35
+ groupLimit?: number;
36
+ /**
37
+ * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday).
38
+ *
39
+ * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다("29초" 가 개선인지 악화인지). 창만 옮겨 한 번 더
40
+ * 접으면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** —
41
+ * 없는 것을 0% 로 그리면 "변화 없음" 이라는 거짓이 된다.
42
+ */
43
+ compareTo?: 'previous' | 'yesterday';
44
+ }
45
+ export interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {
46
+ /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */
47
+ window: {
48
+ fromTime: string;
49
+ toTime: string;
50
+ minutes: number;
51
+ /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */
52
+ basis: 'given' | 'latest-event';
53
+ };
54
+ /**
55
+ * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다).
56
+ *
57
+ * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 조용히 잘라내면 숫자가 조용히
58
+ * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다.
59
+ */
60
+ eventsCapped?: boolean;
61
+ /**
62
+ * 이 구간에 **측정할 기록이 있었는가**. false 는 "처리량 0" 이 아니라 **"기록이 없다"** 는 뜻이다 —
63
+ * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다.
64
+ */
65
+ measured: boolean;
66
+ note?: string;
67
+ /** 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */
68
+ /**
69
+ * 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.
70
+ *
71
+ * `realityModes` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·
72
+ * sim-world(가상 세계) · sim-experiment(실험 사본). 같은 "345건" 이 현실의 실적일 수도, 시뮬레이션의
73
+ * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.
74
+ */
75
+ scope: {
76
+ spaceId?: string;
77
+ instanceIds: string[];
78
+ realityModes: string[];
79
+ };
80
+ /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */
81
+ buckets?: {
82
+ fromTime: string;
83
+ toTime: string;
84
+ tasks: number;
85
+ orders: number;
86
+ workP50Ms: number;
87
+ }[];
88
+ /**
89
+ * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.
90
+ *
91
+ * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다).
92
+ * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는
93
+ * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다.
94
+ */
95
+ comparison?: {
96
+ basis: 'previous' | 'yesterday';
97
+ window: {
98
+ fromTime: string;
99
+ toTime: string;
100
+ };
101
+ measured: boolean;
102
+ throughput?: {
103
+ tasks: number;
104
+ orders: number;
105
+ };
106
+ workTime?: KpiResult['workTime'];
107
+ leadTime?: KpiResult['leadTime'];
108
+ waitTime?: KpiResult['waitTime'];
109
+ delta?: {
110
+ tasks: number;
111
+ orders: number;
112
+ workP50Ms?: number;
113
+ leadP50Ms?: number;
114
+ waitP50Ms?: number;
115
+ };
116
+ };
117
+ /**
118
+ * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이
119
+ * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.
120
+ */
121
+ groups?: {
122
+ by: KpiGroupBy;
123
+ items: (KpiGroupOut & {
124
+ label?: string;
125
+ })[];
126
+ /**
127
+ * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·놀고 있는 자원이
128
+ * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다.
129
+ */
130
+ disappeared?: {
131
+ key: string;
132
+ label?: string;
133
+ tasks: number;
134
+ }[];
135
+ /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */
136
+ truncated?: number;
137
+ truncatedTasks?: number;
138
+ note?: string;
139
+ };
140
+ }
141
+ /** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */
142
+ type KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] & Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>;
143
+ /**
144
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
145
+ *
146
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
147
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
148
+ */
149
+ export declare function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]>;
150
+ /**
151
+ * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
152
+ *
153
+ * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).
154
+ */
155
+ export declare function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput>;
156
+ export {};
@@ -0,0 +1,287 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveTwinTargets = resolveTwinTargets;
4
+ exports.computeTwinKpi = computeTwinKpi;
5
+ /*
6
+ * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.
7
+ *
8
+ * 왜 한 곳인가: 처음에 GraphQL 질의와 AI 도구가 각각 같은 조회를 적었다. 그러면 창 규칙이 두 곳에서
9
+ * 갈라지고(한쪽만 고치면 화면과 AI 가 다른 숫자를 말한다) 조회 상한·테넌트 조건도 복제된다.
10
+ * 계산은 `kpi-fold`(순수)가 소유하고, 이 파일은 **무엇을 읽을지**만 정한다.
11
+ *
12
+ * ── 창의 기준 시각은 "트윈의 시계" 다 ──────────────────────────────────────
13
+ * 시뮬 트윈은 자기 클록으로 산다(예: 저널의 eventTime 이 2026-01-01T00:25Z 인데 실제 지금은 7월).
14
+ * 그런 트윈에 "최근 1시간" 을 **실제 시각**으로 물으면 그 구간에는 아무 기록이 없어 항상
15
+ * "측정된 기록 없음" 이 된다 — 폴드는 정상인데 답이 쓸모없다.
16
+ *
17
+ * 그래서 호출부가 끝 시각을 주지 않으면 **그 트윈의 마지막 기록 시각**을 끝으로 잡는다. 커널의 시각
18
+ * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).
19
+ * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.
20
+ */
21
+ const typeorm_1 = require("typeorm");
22
+ const shell_1 = require("@things-factory/shell");
23
+ const twin_event_js_1 = require("../service/twin-event/twin-event.js");
24
+ const twin_instance_js_1 = require("../service/twin-instance/twin-instance.js");
25
+ const twin_area_js_1 = require("../service/twin-space/twin-area.js");
26
+ const twin_space_js_1 = require("../service/twin-space/twin-space.js");
27
+ const twin_engine_js_1 = require("./twin-engine.js");
28
+ const kpi_fold_js_1 = require("./kpi-fold.js");
29
+ /** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */
30
+ const BUSINESS_EVENTS = ['task.status', 'order.status'];
31
+ /** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */
32
+ const MAX_EVENTS = 20000;
33
+ /**
34
+ * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가
35
+ * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.
36
+ *
37
+ * 구역 이름은 `TwinArea` 에 따로 있다 — id 만 보여주면 사용자가 무엇인지 모른다.
38
+ */
39
+ async function nodeAreaMap(domainId, instanceIds, spaceId) {
40
+ const nodeArea = {};
41
+ if (instanceIds.length) {
42
+ const rows = await (0, shell_1.getRepository)(twin_instance_js_1.TwinInstance).find({
43
+ where: { domain: { id: domainId }, instanceId: (0, typeorm_1.In)(instanceIds) }
44
+ });
45
+ for (const row of rows) {
46
+ for (const n of (row.board?.nodes ?? [])) {
47
+ if (n?.id && n?.parentId)
48
+ nodeArea[String(n.id)] = String(n.parentId);
49
+ }
50
+ }
51
+ }
52
+ const labels = {};
53
+ const sid = spaceId;
54
+ if (sid) {
55
+ const space = await (0, shell_1.getRepository)(twin_space_js_1.TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } });
56
+ if (space) {
57
+ const areas = await (0, shell_1.getRepository)(twin_area_js_1.TwinArea).find({
58
+ where: { domain: { id: domainId }, space: { id: space.id } }
59
+ });
60
+ for (const a of areas)
61
+ if (a.areaId && a.name)
62
+ labels[a.areaId] = a.name;
63
+ }
64
+ }
65
+ return { nodeArea, labels };
66
+ }
67
+ /** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */
68
+ async function latestEventTime(domainId, instanceIds) {
69
+ if (instanceIds.length === 0)
70
+ return null;
71
+ const row = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).findOne({
72
+ where: { domain: { id: domainId }, instanceId: (0, typeorm_1.In)(instanceIds), eventType: (0, typeorm_1.In)(BUSINESS_EVENTS) },
73
+ order: { revision: 'DESC' }
74
+ });
75
+ return row?.eventTime ?? null;
76
+ }
77
+ /**
78
+ * 창 구간의 업무 이벤트 — 창보다 `lookbackMs` 만큼 앞까지 읽는다(완료된 작업의 시작이 창 밖일 수 있다).
79
+ *
80
+ * eventTime 은 ISO 문자열 컬럼이라 사전식 비교가 성립한다(같은 파일군의 시각 커서와 동일 전제).
81
+ * 비교 구간도 같은 규칙으로 읽어야 하므로 함수로 둔다 — 두 곳에 적으면 규칙이 갈라진다.
82
+ */
83
+ async function fetchEvents(domainId, instanceIds, fromMs, toMs, lookbackMs) {
84
+ if (instanceIds.length === 0)
85
+ return { events: [], capped: false };
86
+ const rows = await (0, shell_1.getRepository)(twin_event_js_1.TwinEvent).find({
87
+ where: {
88
+ domain: { id: domainId },
89
+ instanceId: (0, typeorm_1.In)(instanceIds),
90
+ eventType: (0, typeorm_1.In)(BUSINESS_EVENTS),
91
+ eventTime: (0, typeorm_1.Between)(new Date(fromMs - lookbackMs).toISOString(), new Date(toMs).toISOString())
92
+ },
93
+ order: { revision: 'ASC' },
94
+ take: MAX_EVENTS
95
+ });
96
+ /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히
97
+ * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */
98
+ return {
99
+ events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime, payload: r.payload })),
100
+ capped: rows.length >= MAX_EVENTS
101
+ };
102
+ }
103
+ /**
104
+ * 집계 대상 트윈 목록.
105
+ *
106
+ * 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.
107
+ * 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.
108
+ */
109
+ async function resolveTargets(input) {
110
+ if (!input.instanceId && !input.spaceId)
111
+ throw new Error('either instanceId or spaceId is required');
112
+ const list = await twin_engine_js_1.TwinEngine.list(input.domainId);
113
+ const rows = input.instanceId
114
+ ? list.filter((x) => x?.instanceId === input.instanceId)
115
+ : list.filter((x) => x?.running && x.spaceId === input.spaceId && x.purpose !== 'bench' && x.instanceId);
116
+ /* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */
117
+ const instanceIds = rows.length ? rows.map((x) => x.instanceId) : input.instanceId ? [input.instanceId] : [];
118
+ const realityModes = [...new Set(rows.map((x) => String(x.realityMode ?? 'unknown')))];
119
+ return { instanceIds, realityModes };
120
+ }
121
+ /**
122
+ * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**
123
+ *
124
+ * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을
125
+ * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.
126
+ */
127
+ async function resolveTwinTargets(domainId, instanceId, spaceId) {
128
+ const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId });
129
+ return instanceIds;
130
+ }
131
+ /**
132
+ * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.
133
+ *
134
+ * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).
135
+ */
136
+ async function computeTwinKpi(input) {
137
+ const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60));
138
+ const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000;
139
+ const { instanceIds, realityModes } = await resolveTargets(input);
140
+ const scope = { spaceId: input.spaceId, instanceIds, realityModes };
141
+ /* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */
142
+ let basis = 'given';
143
+ let toMs = input.toTime ? Date.parse(input.toTime) : NaN;
144
+ if (!Number.isFinite(toMs)) {
145
+ const latest = await latestEventTime(input.domainId, instanceIds);
146
+ if (!latest) {
147
+ const now = Date.now();
148
+ return {
149
+ window: {
150
+ fromTime: new Date(now - minutes * 60_000).toISOString(),
151
+ toTime: new Date(now).toISOString(),
152
+ minutes,
153
+ basis: 'latest-event'
154
+ },
155
+ measured: false,
156
+ scope,
157
+ note: instanceIds.length === 0
158
+ ? 'no running twin in this space — nothing to measure.'
159
+ : 'no operational events in the journal yet — there is nothing to measure (this is NOT "zero throughput").'
160
+ };
161
+ }
162
+ toMs = Date.parse(latest);
163
+ basis = 'latest-event';
164
+ }
165
+ const fromMs = input.fromTime ? Date.parse(input.fromTime) : toMs - minutes * 60_000;
166
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) {
167
+ throw new Error('invalid window: fromTime must be a valid time before toTime');
168
+ }
169
+ const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs);
170
+ /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */
171
+ const areaMap = input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} };
172
+ const kpi = (0, kpi_fold_js_1.foldKpi)(events, { fromMs, toMs }, {
173
+ groupBy: input.groupBy,
174
+ nodeArea: areaMap.nodeArea,
175
+ groupLimit: input.groupLimit
176
+ });
177
+ const window = {
178
+ fromTime: new Date(fromMs).toISOString(),
179
+ toTime: new Date(toMs).toISOString(),
180
+ minutes: Math.round((toMs - fromMs) / 60_000),
181
+ basis
182
+ };
183
+ if (kpi.eventsInWindow === 0) {
184
+ return {
185
+ window,
186
+ measured: false,
187
+ scope,
188
+ note: 'no operational events in this window — nothing to measure (this is NOT "zero throughput").'
189
+ };
190
+ }
191
+ /* 구간별 값 — 쪼개기 규칙은 순수 모듈이 소유한다(테스트 가능). 여기서는 시각을 ISO 로 옮기기만. */
192
+ const rawBuckets = (0, kpi_fold_js_1.foldKpiBuckets)(events, { fromMs, toMs }, Number(input.buckets) || 0);
193
+ const buckets = rawBuckets.length
194
+ ? rawBuckets.map(b => ({
195
+ fromTime: new Date(b.fromMs).toISOString(),
196
+ toTime: new Date(b.toMs).toISOString(),
197
+ tasks: b.tasks,
198
+ orders: b.orders,
199
+ workP50Ms: b.workP50Ms
200
+ }))
201
+ : undefined;
202
+ /* 비교 구간 — **같은 길이·같은 규칙**으로 한 번 더 접는다. 기록이 없으면 delta 를 만들지 않는다
203
+ * (없는 것을 0 으로 그리면 "변화 없음" 이라는 거짓이 된다). */
204
+ let comparison;
205
+ /* 관점 × 비교 교차 — 축별 delta 와 **사라진 축**(전에 있었고 지금 없는 것). */
206
+ let crossed;
207
+ /* 비교 구간 조회도 상한에 걸릴 수 있다 — 그 경우 delta 가 실제보다 작아 보인다. */
208
+ let prevCappedFlag = false;
209
+ if (input.compareTo === 'previous' || input.compareTo === 'yesterday') {
210
+ const w = (0, kpi_fold_js_1.shiftWindow)(fromMs, toMs, input.compareTo);
211
+ const { events: prevEvents, capped: prevCapped } = await fetchEvents(input.domainId, instanceIds, w.fromMs, w.toMs, lookbackMs);
212
+ /* 축이 요청됐으면 비교 구간도 **같은 축**으로 접는다 — 교차("구역별로 어제 대비")를 내기 위해.
213
+ * 상한은 축이 서로 어긋나지 않게 넉넉히 둔다(현재 축이 상한 안에 있는데 비교 축에서 밀려나면
214
+ * 있는데 없다고 말하게 된다). */
215
+ const prev = (0, kpi_fold_js_1.foldKpi)(prevEvents, w, {
216
+ groupBy: input.groupBy,
217
+ nodeArea: areaMap.nodeArea,
218
+ groupLimit: input.groupBy ? 100 : undefined
219
+ });
220
+ prevCappedFlag = prevCapped;
221
+ if (input.groupBy && kpi.groups && prev.groups && prev.eventsInWindow > 0) {
222
+ crossed = (0, kpi_fold_js_1.crossGroups)(kpi.groups.items, prev.groups.items);
223
+ }
224
+ const base = {
225
+ basis: input.compareTo,
226
+ window: { fromTime: new Date(w.fromMs).toISOString(), toTime: new Date(w.toMs).toISOString() }
227
+ };
228
+ comparison =
229
+ prev.eventsInWindow === 0
230
+ ? { ...base, measured: false }
231
+ : {
232
+ ...base,
233
+ measured: true,
234
+ throughput: prev.throughput,
235
+ workTime: prev.workTime,
236
+ leadTime: prev.leadTime,
237
+ waitTime: prev.waitTime,
238
+ delta: {
239
+ tasks: kpi.throughput.tasks - prev.throughput.tasks,
240
+ orders: kpi.throughput.orders - prev.throughput.orders,
241
+ workP50Ms: (0, kpi_fold_js_1.deltaP50)(kpi.workTime, prev.workTime),
242
+ leadP50Ms: (0, kpi_fold_js_1.deltaP50)(kpi.leadTime, prev.leadTime),
243
+ waitP50Ms: (0, kpi_fold_js_1.deltaP50)(kpi.waitTime, prev.waitTime)
244
+ }
245
+ };
246
+ }
247
+ /* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로
248
+ * 비었을 때의 이유다 — "구역별 0건" 과 "노드에 구역이 안 붙어 있다" 는 완전히 다른 상황이다. */
249
+ const groups = kpi.groups
250
+ ? {
251
+ by: kpi.groups.by,
252
+ items: (crossed?.items ?? kpi.groups.items).map(g => ({
253
+ ...g,
254
+ ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})
255
+ })),
256
+ ...(crossed?.disappeared.length
257
+ ? {
258
+ disappeared: crossed.disappeared.map(d => ({
259
+ ...d,
260
+ ...(areaMap.labels[d.key] ? { label: areaMap.labels[d.key] } : {})
261
+ }))
262
+ }
263
+ : {}),
264
+ ...(kpi.groups.truncated
265
+ ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }
266
+ : {}),
267
+ ...(input.groupBy === 'area' &&
268
+ Object.keys(areaMap.nodeArea).length === 0 &&
269
+ kpi.groups.items.some(g => g.key === 'unknown')
270
+ ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT "no work in areas".' }
271
+ : {})
272
+ }
273
+ : undefined;
274
+ /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */
275
+ const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi;
276
+ return {
277
+ window,
278
+ measured: true,
279
+ scope,
280
+ ...(capped || prevCappedFlag ? { eventsCapped: true } : {}),
281
+ ...rest,
282
+ ...(buckets ? { buckets } : {}),
283
+ ...(groups ? { groups } : {}),
284
+ ...(comparison ? { comparison } : {})
285
+ };
286
+ }
287
+ //# sourceMappingURL=kpi-query.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kpi-query.js","sourceRoot":"","sources":["../../server/engine/kpi-query.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 업무 KPI 조회 — **창 해소 + 저널 조회 + 폴드**를 한 곳에.\n *\n * 왜 한 곳인가: 처음에 GraphQL 질의와 AI 도구가 각각 같은 조회를 적었다. 그러면 창 규칙이 두 곳에서\n * 갈라지고(한쪽만 고치면 화면과 AI 가 다른 숫자를 말한다) 조회 상한·테넌트 조건도 복제된다.\n * 계산은 `kpi-fold`(순수)가 소유하고, 이 파일은 **무엇을 읽을지**만 정한다.\n *\n * ── 창의 기준 시각은 \"트윈의 시계\" 다 ──────────────────────────────────────\n * 시뮬 트윈은 자기 클록으로 산다(예: 저널의 eventTime 이 2026-01-01T00:25Z 인데 실제 지금은 7월).\n * 그런 트윈에 \"최근 1시간\" 을 **실제 시각**으로 물으면 그 구간에는 아무 기록이 없어 항상\n * \"측정된 기록 없음\" 이 된다 — 폴드는 정상인데 답이 쓸모없다.\n *\n * 그래서 호출부가 끝 시각을 주지 않으면 **그 트윈의 마지막 기록 시각**을 끝으로 잡는다. 커널의 시각\n * 상수를 앱에서 재현하지 않고(무방언), 라이브 트윈에서도 자연스럽다(마지막 기록 ≈ 지금).\n * 어떤 기준을 썼는지는 결과의 `window.basis` 로 밝힌다 — 숨기면 사용자가 숫자를 오해한다.\n */\nimport { Between, In } from 'typeorm'\n\nimport { getRepository } from '@things-factory/shell'\n\nimport { TwinEvent } from '../service/twin-event/twin-event.js'\nimport { TwinInstance } from '../service/twin-instance/twin-instance.js'\nimport { TwinArea } from '../service/twin-space/twin-area.js'\nimport { TwinSpace } from '../service/twin-space/twin-space.js'\nimport { TwinEngine } from './twin-engine.js'\nimport {\n crossGroups,\n deltaP50,\n foldKpi,\n foldKpiBuckets,\n shiftWindow,\n type CrossedGroup,\n type KpiEvent,\n type KpiGroupBy,\n type KpiResult\n} from './kpi-fold.js'\n\n/** 업무 전이만 읽는다 — 설비 상태(equipment.status)는 OEE 누적기의 몫이고 여기선 쓰지 않는다. */\nconst BUSINESS_EVENTS = ['task.status', 'order.status']\n\n/** 한 번에 접을 이벤트 상한 — 큰 구간이 서버를 붙잡지 않게. */\nconst MAX_EVENTS = 20000\n\nexport interface TwinKpiInput {\n domainId: string\n /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */\n instanceId?: string\n /**\n * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 접는다**.\n *\n * 트윈별로 접어 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다).\n * 그래서 이벤트를 모아 **한 번** 접는다. 사용자는 \"부산 창고\" 를 보고, 그 안에 트윈이 몇 개든\n * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율).\n */\n spaceId?: string\n /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */\n toTime?: string\n /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */\n fromTime?: string\n /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */\n windowMinutes?: number\n /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */\n lookbackMinutes?: number\n /**\n * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음).\n * 조회는 한 번이고 접기만 N 번 반복한다 — 폴드가 순수하므로 값싸다.\n */\n buckets?: number\n /**\n * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별.\n *\n * 고정된 한 렌즈만 낼 수 있으면 \"구역별로는 어떤가\" 라는 질문에 화면도 AI 도 답할 수 없다.\n * 축은 조회 인자 하나이고, 접기는 순수 함수라 축을 하나 더 보는 값이 거의 없다.\n */\n groupBy?: KpiGroupBy\n /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */\n groupLimit?: number\n /**\n * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday).\n *\n * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다(\"29초\" 가 개선인지 악화인지). 창만 옮겨 한 번 더\n * 접으면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** —\n * 없는 것을 0% 로 그리면 \"변화 없음\" 이라는 거짓이 된다.\n */\n compareTo?: 'previous' | 'yesterday'\n}\n\nexport interface TwinKpiOutput extends Omit<Partial<KpiResult>, 'window'> {\n /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */\n window: {\n fromTime: string\n toTime: string\n minutes: number\n /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */\n basis: 'given' | 'latest-event'\n }\n /**\n * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다).\n *\n * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 조용히 잘라내면 숫자가 조용히\n * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다.\n */\n eventsCapped?: boolean\n /**\n * 이 구간에 **측정할 기록이 있었는가**. false 는 \"처리량 0\" 이 아니라 **\"기록이 없다\"** 는 뜻이다 —\n * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다.\n */\n measured: boolean\n note?: string\n /** 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */\n /**\n * 무엇을 집계했나 — 공간이면 합쳐 접은 트윈 목록.\n *\n * `realityModes` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) ·\n * sim-world(가상 세계) · sim-experiment(실험 사본). 같은 \"345건\" 이 현실의 실적일 수도, 시뮬레이션의\n * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다.\n */\n scope: { spaceId?: string; instanceIds: string[]; realityModes: string[] }\n /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */\n buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number }[]\n /**\n * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 접은 앞 구간과의 차이.\n *\n * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다).\n * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는\n * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다.\n */\n comparison?: {\n basis: 'previous' | 'yesterday'\n window: { fromTime: string; toTime: string }\n measured: boolean\n throughput?: { tasks: number; orders: number }\n workTime?: KpiResult['workTime']\n leadTime?: KpiResult['leadTime']\n waitTime?: KpiResult['waitTime']\n delta?: { tasks: number; orders: number; workP50Ms?: number; leadP50Ms?: number; waitP50Ms?: number }\n }\n /**\n * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이\n * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다.\n */\n groups?: {\n by: KpiGroupBy\n items: (KpiGroupOut & { label?: string })[]\n /**\n * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·놀고 있는 자원이\n * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다.\n */\n disappeared?: { key: string; label?: string; tasks: number }[]\n /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */\n truncated?: number\n truncatedTasks?: number\n note?: string\n }\n}\n\n/** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */\ntype KpiGroupOut = NonNullable<KpiResult['groups']>['items'][number] &\n Partial<Pick<CrossedGroup, 'prevTasks' | 'deltaTasks' | 'deltaWorkP50Ms' | 'isNew'>>\n\n/**\n * 노드 → 구역 지도. **저널에는 구역이 없다**(노드까지만 있다) — 보드 정의의 `nodes[].parentId` 가\n * 마스터 계층에서 온 소속 구역이다. 기동하지 않은 트윈도 보드는 남아 있어 이 경로가 가장 튼튼하다.\n *\n * 구역 이름은 `TwinArea` 에 따로 있다 — id 만 보여주면 사용자가 무엇인지 모른다.\n */\nasync function nodeAreaMap(\n domainId: string,\n instanceIds: string[],\n spaceId?: string\n): Promise<{ nodeArea: Record<string, string>; labels: Record<string, string> }> {\n const nodeArea: Record<string, string> = {}\n if (instanceIds.length) {\n const rows = await getRepository(TwinInstance).find({\n where: { domain: { id: domainId }, instanceId: In(instanceIds) }\n })\n for (const row of rows) {\n for (const n of ((row.board as any)?.nodes ?? []) as any[]) {\n if (n?.id && n?.parentId) nodeArea[String(n.id)] = String(n.parentId)\n }\n }\n }\n\n const labels: Record<string, string> = {}\n const sid = spaceId\n if (sid) {\n const space = await getRepository(TwinSpace).findOne({ where: { domain: { id: domainId }, spaceId: sid } })\n if (space) {\n const areas = await getRepository(TwinArea).find({\n where: { domain: { id: domainId } as any, space: { id: space.id } as any }\n })\n for (const a of areas) if (a.areaId && a.name) labels[a.areaId] = a.name\n }\n }\n return { nodeArea, labels }\n}\n\n/** 이 대상들의 마지막 업무 기록 시각(ISO). 기록이 없으면 null. */\nasync function latestEventTime(domainId: string, instanceIds: string[]): Promise<string | null> {\n if (instanceIds.length === 0) return null\n const row = await getRepository(TwinEvent).findOne({\n where: { domain: { id: domainId }, instanceId: In(instanceIds), eventType: In(BUSINESS_EVENTS) },\n order: { revision: 'DESC' }\n })\n return row?.eventTime ?? null\n}\n\n/**\n * 창 구간의 업무 이벤트 — 창보다 `lookbackMs` 만큼 앞까지 읽는다(완료된 작업의 시작이 창 밖일 수 있다).\n *\n * eventTime 은 ISO 문자열 컬럼이라 사전식 비교가 성립한다(같은 파일군의 시각 커서와 동일 전제).\n * 비교 구간도 같은 규칙으로 읽어야 하므로 함수로 둔다 — 두 곳에 적으면 규칙이 갈라진다.\n */\nasync function fetchEvents(domainId: string, instanceIds: string[], fromMs: number, toMs: number, lookbackMs: number) {\n if (instanceIds.length === 0) return { events: [] as KpiEvent[], capped: false }\n const rows = await getRepository(TwinEvent).find({\n where: {\n domain: { id: domainId },\n instanceId: In(instanceIds),\n eventType: In(BUSINESS_EVENTS),\n eventTime: Between(new Date(fromMs - lookbackMs).toISOString(), new Date(toMs).toISOString())\n },\n order: { revision: 'ASC' },\n take: MAX_EVENTS\n })\n /* 상한에 정확히 닿으면 **더 있었을 수 있다** — 그 사실을 돌려준다. 조용히 잘라내면 숫자가 조용히\n * 작아지고, 사용자는 그것을 성과 하락으로 읽는다(가장 나쁜 종류의 거짓이다). */\n return {\n events: rows.map(r => ({ eventType: r.eventType, eventTime: r.eventTime, payload: r.payload })),\n capped: rows.length >= MAX_EVENTS\n }\n}\n\n/**\n * 집계 대상 트윈 목록.\n *\n * 공간이 주어지면 **가동 중이고 운영 목적인** 것만 모은다 — 벤치 사본은 사용자가 보는 현장이 아니다.\n * 트윈이 지정되면 그것 하나. 둘 다 없으면 무엇을 재야 할지 알 수 없으므로 명시 실패한다.\n */\nasync function resolveTargets(input: TwinKpiInput): Promise<{ instanceIds: string[]; realityModes: string[] }> {\n if (!input.instanceId && !input.spaceId) throw new Error('either instanceId or spaceId is required')\n const list = await TwinEngine.list(input.domainId)\n const rows = input.instanceId\n ? list.filter((x: any) => x?.instanceId === input.instanceId)\n : list.filter((x: any) => x?.running && x.spaceId === input.spaceId && x.purpose !== 'bench' && x.instanceId)\n /* 목록에서 못 찾아도(정지·비등록) 지정된 트윈은 그대로 조회한다 — 저널은 남아 있을 수 있다. */\n const instanceIds = rows.length ? rows.map((x: any) => x.instanceId as string) : input.instanceId ? [input.instanceId] : []\n const realityModes = [...new Set(rows.map((x: any) => String(x.realityMode ?? 'unknown')))]\n return { instanceIds, realityModes }\n}\n\n/**\n * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.**\n *\n * 화면에서 \"이 공간\" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을\n * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다.\n */\nexport async function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise<string[]> {\n const { instanceIds } = await resolveTargets({ domainId, instanceId, spaceId } as TwinKpiInput)\n return instanceIds\n}\n\n/**\n * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드.\n *\n * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임).\n */\nexport async function computeTwinKpi(input: TwinKpiInput): Promise<TwinKpiOutput> {\n const minutes = Math.max(1, Math.min(24 * 60, Number(input.windowMinutes) || 60))\n const lookbackMs = Math.max(0, Math.min(24 * 60, input.lookbackMinutes ?? 60)) * 60_000\n\n const { instanceIds, realityModes } = await resolveTargets(input)\n const scope = { spaceId: input.spaceId, instanceIds, realityModes }\n\n /* 끝 시각 — 지정이 없으면 대상들의 마지막 기록. 그것도 없으면 측정할 것이 없다. */\n let basis: 'given' | 'latest-event' = 'given'\n let toMs = input.toTime ? Date.parse(input.toTime) : NaN\n if (!Number.isFinite(toMs)) {\n const latest = await latestEventTime(input.domainId, instanceIds)\n if (!latest) {\n const now = Date.now()\n return {\n window: {\n fromTime: new Date(now - minutes * 60_000).toISOString(),\n toTime: new Date(now).toISOString(),\n minutes,\n basis: 'latest-event'\n },\n measured: false,\n scope,\n note:\n instanceIds.length === 0\n ? 'no running twin in this space — nothing to measure.'\n : 'no operational events in the journal yet — there is nothing to measure (this is NOT \"zero throughput\").'\n }\n }\n toMs = Date.parse(latest)\n basis = 'latest-event'\n }\n\n const fromMs = input.fromTime ? Date.parse(input.fromTime) : toMs - minutes * 60_000\n if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) {\n throw new Error('invalid window: fromTime must be a valid time before toTime')\n }\n\n const { events, capped } = await fetchEvents(input.domainId, instanceIds, fromMs, toMs, lookbackMs)\n\n /* 구역 축만 이벤트 밖의 지식을 필요로 한다 — 다른 축은 payload 에서 바로 나온다. */\n const areaMap =\n input.groupBy === 'area' ? await nodeAreaMap(input.domainId, instanceIds, input.spaceId) : { nodeArea: {}, labels: {} }\n const kpi = foldKpi(events, { fromMs, toMs }, {\n groupBy: input.groupBy,\n nodeArea: areaMap.nodeArea,\n groupLimit: input.groupLimit\n })\n const window = {\n fromTime: new Date(fromMs).toISOString(),\n toTime: new Date(toMs).toISOString(),\n minutes: Math.round((toMs - fromMs) / 60_000),\n basis\n }\n if (kpi.eventsInWindow === 0) {\n return {\n window,\n measured: false,\n scope,\n note: 'no operational events in this window — nothing to measure (this is NOT \"zero throughput\").'\n }\n }\n\n /* 구간별 값 — 쪼개기 규칙은 순수 모듈이 소유한다(테스트 가능). 여기서는 시각을 ISO 로 옮기기만. */\n const rawBuckets = foldKpiBuckets(events, { fromMs, toMs }, Number(input.buckets) || 0)\n const buckets = rawBuckets.length\n ? rawBuckets.map(b => ({\n fromTime: new Date(b.fromMs).toISOString(),\n toTime: new Date(b.toMs).toISOString(),\n tasks: b.tasks,\n orders: b.orders,\n workP50Ms: b.workP50Ms\n }))\n : undefined\n\n /* 비교 구간 — **같은 길이·같은 규칙**으로 한 번 더 접는다. 기록이 없으면 delta 를 만들지 않는다\n * (없는 것을 0 으로 그리면 \"변화 없음\" 이라는 거짓이 된다). */\n let comparison: TwinKpiOutput['comparison']\n /* 관점 × 비교 교차 — 축별 delta 와 **사라진 축**(전에 있었고 지금 없는 것). */\n let crossed: ReturnType<typeof crossGroups> | undefined\n /* 비교 구간 조회도 상한에 걸릴 수 있다 — 그 경우 delta 가 실제보다 작아 보인다. */\n let prevCappedFlag = false\n if (input.compareTo === 'previous' || input.compareTo === 'yesterday') {\n const w = shiftWindow(fromMs, toMs, input.compareTo)\n const { events: prevEvents, capped: prevCapped } = await fetchEvents(\n input.domainId,\n instanceIds,\n w.fromMs,\n w.toMs,\n lookbackMs\n )\n /* 축이 요청됐으면 비교 구간도 **같은 축**으로 접는다 — 교차(\"구역별로 어제 대비\")를 내기 위해.\n * 상한은 축이 서로 어긋나지 않게 넉넉히 둔다(현재 축이 상한 안에 있는데 비교 축에서 밀려나면\n * 있는데 없다고 말하게 된다). */\n const prev = foldKpi(prevEvents, w, {\n groupBy: input.groupBy,\n nodeArea: areaMap.nodeArea,\n groupLimit: input.groupBy ? 100 : undefined\n })\n prevCappedFlag = prevCapped\n if (input.groupBy && kpi.groups && prev.groups && prev.eventsInWindow > 0) {\n crossed = crossGroups(kpi.groups.items, prev.groups.items)\n }\n const base = {\n basis: input.compareTo,\n window: { fromTime: new Date(w.fromMs).toISOString(), toTime: new Date(w.toMs).toISOString() }\n }\n comparison =\n prev.eventsInWindow === 0\n ? { ...base, measured: false }\n : {\n ...base,\n measured: true,\n throughput: prev.throughput,\n workTime: prev.workTime,\n leadTime: prev.leadTime,\n waitTime: prev.waitTime,\n delta: {\n tasks: kpi.throughput.tasks - prev.throughput.tasks,\n orders: kpi.throughput.orders - prev.throughput.orders,\n workP50Ms: deltaP50(kpi.workTime, prev.workTime),\n leadP50Ms: deltaP50(kpi.leadTime, prev.leadTime),\n waitP50Ms: deltaP50(kpi.waitTime, prev.waitTime)\n }\n }\n }\n\n /* 관점 값은 시각 변환이 없어 그대로 통과한다. 붙이는 것은 ① 사람이 읽는 이름과 ② 축이 통째로\n * 비었을 때의 이유다 — \"구역별 0건\" 과 \"노드에 구역이 안 붙어 있다\" 는 완전히 다른 상황이다. */\n const groups = kpi.groups\n ? {\n by: kpi.groups.by,\n items: (crossed?.items ?? kpi.groups.items).map(g => ({\n ...g,\n ...(areaMap.labels[g.key] ? { label: areaMap.labels[g.key] } : {})\n })),\n ...(crossed?.disappeared.length\n ? {\n disappeared: crossed.disappeared.map(d => ({\n ...d,\n ...(areaMap.labels[d.key] ? { label: areaMap.labels[d.key] } : {})\n }))\n }\n : {}),\n ...(kpi.groups.truncated\n ? { truncated: kpi.groups.truncated, truncatedTasks: kpi.groups.truncatedTasks ?? 0 }\n : {}),\n ...(input.groupBy === 'area' &&\n Object.keys(areaMap.nodeArea).length === 0 &&\n kpi.groups.items.some(g => g.key === 'unknown')\n ? { note: 'nodes carry no area (parentId) in this twin — cannot break down by area. this is NOT \"no work in areas\".' }\n : {})\n }\n : undefined\n\n /* 폴드가 돌려준 raw 창(ms)은 버린다 — 위의 ISO 창이 사람이 읽을 정본이다. */\n const { window: _rawWindow, groups: _rawGroups, ...rest } = kpi\n return {\n window,\n measured: true,\n scope,\n ...(capped || prevCappedFlag ? { eventsCapped: true } : {}),\n ...rest,\n ...(buckets ? { buckets } : {}),\n ...(groups ? { groups } : {}),\n ...(comparison ? { comparison } : {})\n }\n}\n"]}
@@ -63,8 +63,29 @@ export declare class TwinEngine {
63
63
  * 라이브 tick 재개(sim 결정적 re-run / live 인제스트 지속)는 후속. 여기선 상태 복원 + 노출.
64
64
  */
65
65
  static bootstrap(): Promise<void>;
66
- /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
67
- static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode): InstanceRuntime;
66
+ /**
67
+ * 웜스타트 기동하는 커널에 **직전 관측 상태**를 심는다.
68
+ *
69
+ * ── 왜 필요한가 ─────────────────────────────────────────────────────────────
70
+ * `loadBoard` 는 **구조만** 싣는다(노드·무버). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한
71
+ * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고
72
+ * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인).
73
+ * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데,
74
+ * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다.
75
+ *
76
+ * ── 정직한 한계 ─────────────────────────────────────────────────────────────
77
+ * · **오더는 복원하지 않는다.** `hydrateObserved` 의 오더 인자는 requested/fulfilled/lines 를 요구하는데
78
+ * 스냅샷의 `OrderState` 에는 `progress` 밖에 없다. progress 에서 역산하면 없는 숫자를 지어내는 것이라
79
+ * 넘기지 않는다 — 재고·노드·무버만 복원되고 진행 중 오더는 비어서 시작한다.
80
+ * · 진행 중 개별 task 의 내부 상태도 관측만으로는 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김).
81
+ * · 근본 해법(상태 영속 계약·revision 이어붙임)은 별도 과제.
82
+ *
83
+ * ── 벤치는 시드하지 않는다 ──────────────────────────────────────────────────
84
+ * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 심으면 측정이 오염된다.
85
+ */
86
+ private static warmStart;
87
+ /** 트윈 인스턴스 시작 — 커널 생성 + 보드 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */
88
+ static start(id: string, domainId: string, kind: string, board: BoardDef, realityMode?: RealityMode, purpose?: string): InstanceRuntime;
68
89
  /**
69
90
  * live-모드 기동(face2-inbound-live §1.1) — 커널 tick 대신 StateProjector 로 실 이벤트를 미러.
70
91
  * 관측 상태(점유·위치·아이템)는 projector 가 재구성 → sim 과 동일 data(tag) payload → **보드 컴포넌트 동일 렌더**
@@ -85,6 +106,12 @@ export declare class TwinEngine {
85
106
  private static ensureBroadcastCoalescer;
86
107
  /** dirty live 인스턴스 방송 flush(주기 tick 또는 명시 호출). 테스트/즉시 방송용으로 public. */
87
108
  static flushLiveBroadcasts(): void;
109
+ /**
110
+ * 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.**
111
+ * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데
112
+ * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다.
113
+ */
114
+ private static journalRow;
88
115
  /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */
89
116
  static persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise<void>;
90
117
  /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */