@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
@@ -0,0 +1,141 @@
1
+ import type { KpiResult } from './kpi-fold.js';
2
+ /**
3
+ * 지표 어휘 — **이름을 한 곳에서 정한다.**
4
+ *
5
+ * 목표와 사실이 서로 다른 이름을 쓰면 그 사이에 매핑 표가 생기고, 그 표가 곧 방언이 된다. 그래서
6
+ * 목표가 가리킬 수 있는 지표는 여기 열거된 것뿐이고, 값을 꺼내는 방법도 여기 함께 있다.
7
+ *
8
+ * **`direction` 은 지표의 성질이지 사용자의 선택이 아니다.** 처리량은 높을수록 좋고 대기시간은 낮을수록
9
+ * 좋다 — 이것을 입력으로 받으면 반대로 적은 목표가 만들어지고, 그 뒤 판정이 전부 뒤집힌다.
10
+ */
11
+ export declare const TWIN_METRIC: {
12
+ readonly 'throughput.tasks': {
13
+ readonly direction: "higher";
14
+ readonly unit: "count";
15
+ readonly of: (k: KpiResult) => number;
16
+ };
17
+ readonly 'throughput.orders': {
18
+ readonly direction: "higher";
19
+ readonly unit: "count";
20
+ readonly of: (k: KpiResult) => number;
21
+ };
22
+ readonly 'leadTime.p50Ms': {
23
+ readonly direction: "lower";
24
+ readonly unit: "ms";
25
+ readonly of: (k: KpiResult) => number;
26
+ };
27
+ readonly 'leadTime.p90Ms': {
28
+ readonly direction: "lower";
29
+ readonly unit: "ms";
30
+ readonly of: (k: KpiResult) => number;
31
+ };
32
+ readonly 'workTime.p50Ms': {
33
+ readonly direction: "lower";
34
+ readonly unit: "ms";
35
+ readonly of: (k: KpiResult) => number;
36
+ };
37
+ readonly 'waitTime.p50Ms': {
38
+ readonly direction: "lower";
39
+ readonly unit: "ms";
40
+ readonly of: (k: KpiResult) => number;
41
+ };
42
+ /** 자원 점유 — 개별 자원이 아니라 **창 전체의 평균 비율**(자원별은 관점 축의 일이다). */
43
+ readonly 'utilization.ratio': {
44
+ readonly direction: "higher";
45
+ readonly unit: "ratio";
46
+ readonly of: (k: KpiResult) => number;
47
+ };
48
+ };
49
+ export type TwinMetricId = keyof typeof TWIN_METRIC;
50
+ export declare const isTwinMetric: (id: string) => id is TwinMetricId;
51
+ /**
52
+ * 저장된 목표 행에서 **이 범위에 걸린 목표**를 고른다 — 좁은 선언이 넓은 선언을 덮는다.
53
+ *
54
+ * **왜 순수 함수인가**: 이 규칙(공간 목표 위에 인스턴스 목표를 얹는다)은 지금까지 DB 접근 뒤에 숨어
55
+ * 있어서 **화면을 눌러 봐야만** 확인할 수 있었다. 그런데 이 프로젝트는 로그인 뒤 화면을 자동으로
56
+ * 검증할 방법이 없어, 실제로 여러 기능이 "실화면 미검증" 으로 쌓였다. 규칙을 밖으로 빼면 테스트가
57
+ * 값싸진다 — 조회는 행을 가져오기만 하고, **판단은 여기서** 한다.
58
+ */
59
+ /**
60
+ * 축 목표까지 담은 목록 — 전체 목표와 **축의 한 값**에 걸린 목표를 함께 든다.
61
+ *
62
+ * 축 목표는 전체 목표를 **덮지 않는다.** 다른 질문이기 때문이다: "현장 전체가 100건" 과
63
+ * "용접 구역이 30건" 은 동시에 참일 수 있다. 덮게 만들면 축 하나에 목표를 거는 순간 전체 판정이
64
+ * 사라져 사용자가 "목표를 지웠나?" 를 묻게 된다.
65
+ */
66
+ export interface ScopedTargets {
67
+ /** 창 전체에 걸린 목표. */
68
+ overall: TwinTargetSpec[];
69
+ /** 축 → (그 축의 값 → 목표들). 요청한 축만 채운다. */
70
+ byAxisKey: Record<string, TwinTargetSpec[]>;
71
+ }
72
+ /**
73
+ * 축의 각 값에 대해 목표 대비 판정 — **그룹 항목에 그대로 붙일 형태.**
74
+ *
75
+ * 그룹의 지표는 전체보다 좁다(주문 수·자원 점유는 축 단위로 뜻이 다르거나 없다). 없는 지표에 목표가
76
+ * 걸려 있으면 **판정하지 않는다** — 여기서 0 으로 읽으면 시간 지표가 "완벽 달성" 으로 뒤집힌다
77
+ * (전체 판정에서 이미 겪은 결함이다).
78
+ */
79
+ export declare function judgeGroupTargets(group: {
80
+ key: string;
81
+ tasks: number;
82
+ leadTime: {
83
+ count: number;
84
+ p50Ms: number;
85
+ };
86
+ workTime: {
87
+ count: number;
88
+ p50Ms: number;
89
+ };
90
+ waitTime: {
91
+ count: number;
92
+ p50Ms: number;
93
+ };
94
+ }, targets: readonly TwinTargetSpec[] | undefined): TargetVerdict[];
95
+ export declare function mergeTargets(rows: readonly TargetRow[], scope: {
96
+ spaceId?: string;
97
+ instanceIds: readonly string[];
98
+ axis?: string;
99
+ }): TwinTargetSpec[];
100
+ /** 저장된 목표 행 — 판단에 필요한 것만(엔티티 전체를 요구하지 않는다: 테스트가 값싸진다). */
101
+ export interface TargetRow {
102
+ metric: string;
103
+ target: number;
104
+ spaceId?: string | null;
105
+ instanceId?: string | null;
106
+ axis?: string | null;
107
+ axisKey?: string | null;
108
+ }
109
+ /** 전체 목표와 축 목표를 함께 고른다 — 좁은 선언(인스턴스)이 넓은 선언(공간)을 덮는 규칙은 양쪽 같다. */
110
+ export declare function scopedTargets(rows: readonly TargetRow[], scope: {
111
+ spaceId?: string;
112
+ instanceIds: readonly string[];
113
+ axis?: string;
114
+ }): ScopedTargets;
115
+ /** 목표 하나 — 어느 지표를, 얼마로. 방향은 지표가 안다(§TWIN_METRIC). */
116
+ export interface TwinTargetSpec {
117
+ metric: TwinMetricId;
118
+ target: number;
119
+ }
120
+ export interface TargetVerdict {
121
+ metric: TwinMetricId;
122
+ /** 이 구간의 실제 값. */
123
+ actual: number;
124
+ target: number;
125
+ direction: 'higher' | 'lower';
126
+ /** 목표를 만족했나. */
127
+ met: boolean;
128
+ /**
129
+ * 목표 대비 비율 — **항상 "1 이상이면 좋다"** 로 정규화한다(방향이 다른 지표를 한 화면에서 비교할 수
130
+ * 있게). 처리량은 `actual/target`, 시간은 `target/actual`.
131
+ */
132
+ ratio: number;
133
+ }
134
+ /**
135
+ * 목표 대비 판정 — **목표가 없는 지표는 판정하지 않는다.**
136
+ *
137
+ * 값이 없는 지표(그 구간에 기록이 없어 `undefined`)도 판정하지 않는다. 없는 것을 0 으로 놓으면
138
+ * "목표 크게 미달" 이라는 **없는 사실**이 만들어지고, 사용자는 멈춘 적 없는 라인을 고치러 간다.
139
+ * 목표를 0 으로 준 경우도 비율을 내지 않는다(0 으로 나누지 않는다) — 만족 여부만 답한다.
140
+ */
141
+ export declare function judgeTargets(kpi: KpiResult, targets: readonly TwinTargetSpec[] | undefined): TargetVerdict[];
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isTwinMetric = exports.TWIN_METRIC = void 0;
4
+ exports.judgeGroupTargets = judgeGroupTargets;
5
+ exports.mergeTargets = mergeTargets;
6
+ exports.scopedTargets = scopedTargets;
7
+ exports.judgeTargets = judgeTargets;
8
+ /** 측정된 기록이 있을 때만 값으로 인정한다 — 없으면 모름(0 이 아니다). */
9
+ const measured = (k, v) => (k.eventsInWindow > 0 ? v : undefined);
10
+ /** 분위수는 표본이 있을 때만 값이다 — `count === 0` 의 `p50Ms: 0` 은 "0 밀리초" 가 아니라 "없음" 이다. */
11
+ const statOf = (s, pick) => s.count > 0 ? pick(s) : undefined;
12
+ /**
13
+ * 지표 어휘 — **이름을 한 곳에서 정한다.**
14
+ *
15
+ * 목표와 사실이 서로 다른 이름을 쓰면 그 사이에 매핑 표가 생기고, 그 표가 곧 방언이 된다. 그래서
16
+ * 목표가 가리킬 수 있는 지표는 여기 열거된 것뿐이고, 값을 꺼내는 방법도 여기 함께 있다.
17
+ *
18
+ * **`direction` 은 지표의 성질이지 사용자의 선택이 아니다.** 처리량은 높을수록 좋고 대기시간은 낮을수록
19
+ * 좋다 — 이것을 입력으로 받으면 반대로 적은 목표가 만들어지고, 그 뒤 판정이 전부 뒤집힌다.
20
+ */
21
+ exports.TWIN_METRIC = {
22
+ /* 기록이 없는 구간은 처리량 0 이 아니라 **모름**이다 — `eventsInWindow` 가 그것을 가른다.
23
+ 0 으로 판정하면 "측정 못 한 구간" 이 "일을 안 한 구간" 으로 보고된다. */
24
+ 'throughput.tasks': { direction: 'higher', unit: 'count', of: (k) => measured(k, k.throughput.tasks) },
25
+ 'throughput.orders': { direction: 'higher', unit: 'count', of: (k) => measured(k, k.throughput.orders) },
26
+ /* 소요시간 분위수는 `count === 0` 이면 **0 이 아니라 없다.** 이것을 0 으로 읽으면 시간 지표는
27
+ "낮을수록 좋다" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 — 미달을 놓치는
28
+ 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). 테스트가 이것을 잡았다. */
29
+ 'leadTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k) => statOf(k.leadTime, d => d.p50Ms) },
30
+ 'leadTime.p90Ms': { direction: 'lower', unit: 'ms', of: (k) => statOf(k.leadTime, d => d.p90Ms) },
31
+ 'workTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k) => statOf(k.workTime, d => d.p50Ms) },
32
+ 'waitTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k) => statOf(k.waitTime, d => d.p50Ms) },
33
+ /** 자원 점유 — 개별 자원이 아니라 **창 전체의 평균 비율**(자원별은 관점 축의 일이다). */
34
+ 'utilization.ratio': {
35
+ direction: 'higher',
36
+ unit: 'ratio',
37
+ of: (k) => k.utilization.length ? k.utilization.reduce((s, u) => s + u.ratio, 0) / k.utilization.length : undefined
38
+ }
39
+ };
40
+ const isTwinMetric = (id) => id in exports.TWIN_METRIC;
41
+ exports.isTwinMetric = isTwinMetric;
42
+ /**
43
+ * 축의 각 값에 대해 목표 대비 판정 — **그룹 항목에 그대로 붙일 형태.**
44
+ *
45
+ * 그룹의 지표는 전체보다 좁다(주문 수·자원 점유는 축 단위로 뜻이 다르거나 없다). 없는 지표에 목표가
46
+ * 걸려 있으면 **판정하지 않는다** — 여기서 0 으로 읽으면 시간 지표가 "완벽 달성" 으로 뒤집힌다
47
+ * (전체 판정에서 이미 겪은 결함이다).
48
+ */
49
+ function judgeGroupTargets(group, targets) {
50
+ const value = (metric) => {
51
+ switch (metric) {
52
+ case 'throughput.tasks':
53
+ return group.tasks;
54
+ case 'leadTime.p50Ms':
55
+ return group.leadTime.count > 0 ? group.leadTime.p50Ms : undefined;
56
+ case 'workTime.p50Ms':
57
+ return group.workTime.count > 0 ? group.workTime.p50Ms : undefined;
58
+ case 'waitTime.p50Ms':
59
+ return group.waitTime.count > 0 ? group.waitTime.p50Ms : undefined;
60
+ default:
61
+ return undefined; // 축 단위로 뜻이 없는 지표(주문 수·자원 점유) — 지어내지 않는다
62
+ }
63
+ };
64
+ const out = [];
65
+ for (const t of targets ?? []) {
66
+ const spec = exports.TWIN_METRIC[t.metric];
67
+ const actual = value(t.metric);
68
+ if (!spec || typeof actual !== 'number' || !Number.isFinite(actual) || !Number.isFinite(t.target))
69
+ continue;
70
+ out.push(verdictOf(t.metric, actual, t.target, spec.direction));
71
+ }
72
+ return out;
73
+ }
74
+ function mergeTargets(rows, scope) {
75
+ return scopedTargets(rows, scope).overall;
76
+ }
77
+ /** 전체 목표와 축 목표를 함께 고른다 — 좁은 선언(인스턴스)이 넓은 선언(공간)을 덮는 규칙은 양쪽 같다. */
78
+ function scopedTargets(rows, scope) {
79
+ /* 같은 자리(지표 또는 축값+지표)에 공간·인스턴스 목표가 함께 있으면 인스턴스가 이긴다. */
80
+ const pick = new Map();
81
+ for (const r of rows) {
82
+ if (!(0, exports.isTwinMetric)(r.metric) || typeof r.target !== 'number' || !Number.isFinite(r.target))
83
+ continue;
84
+ const inSpace = !!scope.spaceId && r.spaceId === scope.spaceId;
85
+ const inInstance = !!r.instanceId && scope.instanceIds.includes(r.instanceId);
86
+ if (!inSpace && !inInstance)
87
+ continue;
88
+ if (r.axis) {
89
+ if (!scope.axis || r.axis !== scope.axis || !r.axisKey)
90
+ continue; // 지금 보는 축만
91
+ }
92
+ const slot = `${r.axis ?? ''}\u0000${r.axisKey ?? ''}\u0000${r.metric}`;
93
+ const prev = pick.get(slot);
94
+ if (prev?.fromInstance && !inInstance)
95
+ continue; // 인스턴스 선언을 공간 선언이 덮지 않는다
96
+ pick.set(slot, { target: r.target, fromInstance: inInstance, axisKey: r.axisKey ?? undefined, metric: r.metric });
97
+ }
98
+ const overall = [];
99
+ const byAxisKey = {};
100
+ for (const [slot, v] of pick) {
101
+ const spec = { metric: v.metric, target: v.target };
102
+ if (slot.startsWith('\u0000'))
103
+ overall.push(spec);
104
+ else
105
+ (byAxisKey[v.axisKey] ??= []).push(spec);
106
+ }
107
+ return { overall, byAxisKey };
108
+ }
109
+ /**
110
+ * 목표 대비 판정 — **목표가 없는 지표는 판정하지 않는다.**
111
+ *
112
+ * 값이 없는 지표(그 구간에 기록이 없어 `undefined`)도 판정하지 않는다. 없는 것을 0 으로 놓으면
113
+ * "목표 크게 미달" 이라는 **없는 사실**이 만들어지고, 사용자는 멈춘 적 없는 라인을 고치러 간다.
114
+ * 목표를 0 으로 준 경우도 비율을 내지 않는다(0 으로 나누지 않는다) — 만족 여부만 답한다.
115
+ */
116
+ function judgeTargets(kpi, targets) {
117
+ const out = [];
118
+ for (const t of targets ?? []) {
119
+ const spec = exports.TWIN_METRIC[t.metric];
120
+ if (!spec)
121
+ continue; // 모르는 지표 이름 — 짐작해 매핑하지 않는다
122
+ const actual = spec.of(kpi);
123
+ if (typeof actual !== 'number' || !Number.isFinite(actual))
124
+ continue; // 값이 없다 ≠ 값이 0
125
+ if (!Number.isFinite(t.target))
126
+ continue;
127
+ out.push(verdictOf(t.metric, actual, t.target, spec.direction));
128
+ }
129
+ return out;
130
+ }
131
+ /** 판정 산수 한 곳 — 전체 판정과 축 판정이 **같은 규칙**을 쓴다(두 벌이면 갈라진다). */
132
+ function verdictOf(metric, actual, target, direction) {
133
+ const met = direction === 'higher' ? actual >= target : actual <= target;
134
+ const ratio = direction === 'higher' ? (target > 0 ? actual / target : Number.NaN) : actual > 0 ? target / actual : Number.NaN;
135
+ return { metric, actual, target, direction, met, ratio };
136
+ }
137
+ //# sourceMappingURL=kpi-target.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"kpi-target.js","sourceRoot":"","sources":["../../server/engine/kpi-target.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 성과 목표 — **숫자를 판단으로 바꾸는 층.**\n *\n * ── 왜 필요한가 ─────────────────────────────────────────────────────────────\n * 화면과 AI 는 \"처리량 345건 · 작업시간 29초\" 를 정확히 말한다. 그런데 **그것이 좋은지 나쁜지는 말할 수\n * 없다.** 판단 근거가 앞 구간 비교뿐이라 \"어제보다 나아졌다\" 까지가 한계이고, 어제도 나빴으면 그 말은\n * 쓸모가 없다. 목표가 붙는 순간 같은 숫자가 **정보에서 판단으로** 바뀐다.\n *\n * ── 층을 섞지 않는다 ────────────────────────────────────────────────────────\n * 폴드(`kpi-fold`)는 **사실만** 낸다 — 목표를 모른다. 목표를 아는 순간 \"사실을 세는 규칙\" 과 \"잘했나\n * 판정하는 규칙\" 이 한 함수에 섞이고, 목표가 바뀔 때마다 사실 계산을 건드리게 된다. 그래서 판정은\n * 여기, **순수 함수**로 따로 둔다(폴드처럼 테스트가 값싸다).\n *\n * ── 목표의 집 (사용자 결정 2026-08-02) ──────────────────────────────────────\n * **트윈은 운영 목표를 갖고, `@things-factory/kpi` 는 경영 목표를 갖는다.** 경계:\n *\n * 트윈(TwinTarget) 이 구간·이 교대·이 공간의 **즉시 판단**. 현장 관리자가 트윈 화면에서 고친다.\n * 값은 저널 폴드에서 **그때그때 계산**된다(저장된 값을 읽지 않는다).\n * packages/kpi **기간 평가**(조직·분기·점수·가중치·알림). 값은 저장돼 있어야 한다.\n *\n * 이 경계를 지키는 규칙 하나: **트윈은 목표를 복제하지 않는다.** 경영 목표가 필요하면 트윈의 사실을\n * 내보내 그쪽이 소비하고, 그쪽 목표를 여기로 베껴 오지 않는다 — 베끼는 순간 두 개의 진실이 된다.\n */\n/*\n * ── 운영 임계(커널 attention)와의 경계 ─────────────────────────────────────\n *\n * **attention 은 개체 하나의 지금 상태**다 — 앵커(설비·자리·오더)와 할 수 있는 조치를 갖는다.\n * **target(여기)은 구간의 집계**다 — 앵커도 조치도 없고 추세를 판단한다.\n *\n * 그래서 창 단위 집계 판정을 attention 으로 넣지 않는다: \"가동률 저하\"(창 평균) attention 을 만들면\n * `utilization.ratio` 목표와 **같은 사실을 두 층이 다른 임계로 판정**하게 된다.\n * (design/map/performance.md §5.0-4 — 현재 다섯 attention 과 이 어휘는 겹치지 않는다.)\n */\nimport type { DurationStats, KpiResult } from './kpi-fold.js'\n\n/** 측정된 기록이 있을 때만 값으로 인정한다 — 없으면 모름(0 이 아니다). */\nconst measured = (k: KpiResult, v: number): number | undefined => (k.eventsInWindow > 0 ? v : undefined)\n\n/** 분위수는 표본이 있을 때만 값이다 — `count === 0` 의 `p50Ms: 0` 은 \"0 밀리초\" 가 아니라 \"없음\" 이다. */\nconst statOf = (s: DurationStats, pick: (d: DurationStats) => number): number | undefined =>\n s.count > 0 ? pick(s) : undefined\n\n/**\n * 지표 어휘 — **이름을 한 곳에서 정한다.**\n *\n * 목표와 사실이 서로 다른 이름을 쓰면 그 사이에 매핑 표가 생기고, 그 표가 곧 방언이 된다. 그래서\n * 목표가 가리킬 수 있는 지표는 여기 열거된 것뿐이고, 값을 꺼내는 방법도 여기 함께 있다.\n *\n * **`direction` 은 지표의 성질이지 사용자의 선택이 아니다.** 처리량은 높을수록 좋고 대기시간은 낮을수록\n * 좋다 — 이것을 입력으로 받으면 반대로 적은 목표가 만들어지고, 그 뒤 판정이 전부 뒤집힌다.\n */\nexport const TWIN_METRIC = {\n /* 기록이 없는 구간은 처리량 0 이 아니라 **모름**이다 — `eventsInWindow` 가 그것을 가른다.\n 0 으로 판정하면 \"측정 못 한 구간\" 이 \"일을 안 한 구간\" 으로 보고된다. */\n 'throughput.tasks': { direction: 'higher', unit: 'count', of: (k: KpiResult) => measured(k, k.throughput.tasks) },\n 'throughput.orders': { direction: 'higher', unit: 'count', of: (k: KpiResult) => measured(k, k.throughput.orders) },\n /* 소요시간 분위수는 `count === 0` 이면 **0 이 아니라 없다.** 이것을 0 으로 읽으면 시간 지표는\n \"낮을수록 좋다\" 라서 **일이 없던 구간이 목표를 완벽히 달성한 것으로** 보고된다 — 미달을 놓치는\n 것보다 나쁘다(고칠 곳이 있는데 잘하고 있다고 말한다). 테스트가 이것을 잡았다. */\n 'leadTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.leadTime, d => d.p50Ms) },\n 'leadTime.p90Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.leadTime, d => d.p90Ms) },\n 'workTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.workTime, d => d.p50Ms) },\n 'waitTime.p50Ms': { direction: 'lower', unit: 'ms', of: (k: KpiResult) => statOf(k.waitTime, d => d.p50Ms) },\n /** 자원 점유 — 개별 자원이 아니라 **창 전체의 평균 비율**(자원별은 관점 축의 일이다). */\n 'utilization.ratio': {\n direction: 'higher',\n unit: 'ratio',\n of: (k: KpiResult) =>\n k.utilization.length ? k.utilization.reduce((s, u) => s + u.ratio, 0) / k.utilization.length : undefined\n }\n} as const\n\nexport type TwinMetricId = keyof typeof TWIN_METRIC\nexport const isTwinMetric = (id: string): id is TwinMetricId => id in TWIN_METRIC\n\n/**\n * 저장된 목표 행에서 **이 범위에 걸린 목표**를 고른다 — 좁은 선언이 넓은 선언을 덮는다.\n *\n * **왜 순수 함수인가**: 이 규칙(공간 목표 위에 인스턴스 목표를 얹는다)은 지금까지 DB 접근 뒤에 숨어\n * 있어서 **화면을 눌러 봐야만** 확인할 수 있었다. 그런데 이 프로젝트는 로그인 뒤 화면을 자동으로\n * 검증할 방법이 없어, 실제로 여러 기능이 \"실화면 미검증\" 으로 쌓였다. 규칙을 밖으로 빼면 테스트가\n * 값싸진다 — 조회는 행을 가져오기만 하고, **판단은 여기서** 한다.\n */\n/**\n * 축 목표까지 담은 목록 — 전체 목표와 **축의 한 값**에 걸린 목표를 함께 든다.\n *\n * 축 목표는 전체 목표를 **덮지 않는다.** 다른 질문이기 때문이다: \"현장 전체가 100건\" 과\n * \"용접 구역이 30건\" 은 동시에 참일 수 있다. 덮게 만들면 축 하나에 목표를 거는 순간 전체 판정이\n * 사라져 사용자가 \"목표를 지웠나?\" 를 묻게 된다.\n */\nexport interface ScopedTargets {\n /** 창 전체에 걸린 목표. */\n overall: TwinTargetSpec[]\n /** 축 → (그 축의 값 → 목표들). 요청한 축만 채운다. */\n byAxisKey: Record<string, TwinTargetSpec[]>\n}\n\n/**\n * 축의 각 값에 대해 목표 대비 판정 — **그룹 항목에 그대로 붙일 형태.**\n *\n * 그룹의 지표는 전체보다 좁다(주문 수·자원 점유는 축 단위로 뜻이 다르거나 없다). 없는 지표에 목표가\n * 걸려 있으면 **판정하지 않는다** — 여기서 0 으로 읽으면 시간 지표가 \"완벽 달성\" 으로 뒤집힌다\n * (전체 판정에서 이미 겪은 결함이다).\n */\nexport function judgeGroupTargets(\n group: { key: string; tasks: number; leadTime: { count: number; p50Ms: number }; workTime: { count: number; p50Ms: number }; waitTime: { count: number; p50Ms: number } },\n targets: readonly TwinTargetSpec[] | undefined\n): TargetVerdict[] {\n const value = (metric: TwinMetricId): number | undefined => {\n switch (metric) {\n case 'throughput.tasks':\n return group.tasks\n case 'leadTime.p50Ms':\n return group.leadTime.count > 0 ? group.leadTime.p50Ms : undefined\n case 'workTime.p50Ms':\n return group.workTime.count > 0 ? group.workTime.p50Ms : undefined\n case 'waitTime.p50Ms':\n return group.waitTime.count > 0 ? group.waitTime.p50Ms : undefined\n default:\n return undefined // 축 단위로 뜻이 없는 지표(주문 수·자원 점유) — 지어내지 않는다\n }\n }\n const out: TargetVerdict[] = []\n for (const t of targets ?? []) {\n const spec = TWIN_METRIC[t.metric]\n const actual = value(t.metric)\n if (!spec || typeof actual !== 'number' || !Number.isFinite(actual) || !Number.isFinite(t.target)) continue\n out.push(verdictOf(t.metric, actual, t.target, spec.direction))\n }\n return out\n}\n\nexport function mergeTargets(\n rows: readonly TargetRow[],\n scope: { spaceId?: string; instanceIds: readonly string[]; axis?: string }\n): TwinTargetSpec[] {\n return scopedTargets(rows, scope).overall\n}\n\n/** 저장된 목표 행 — 판단에 필요한 것만(엔티티 전체를 요구하지 않는다: 테스트가 값싸진다). */\nexport interface TargetRow {\n metric: string\n target: number\n spaceId?: string | null\n instanceId?: string | null\n axis?: string | null\n axisKey?: string | null\n}\n\n/** 전체 목표와 축 목표를 함께 고른다 — 좁은 선언(인스턴스)이 넓은 선언(공간)을 덮는 규칙은 양쪽 같다. */\nexport function scopedTargets(\n rows: readonly TargetRow[],\n scope: { spaceId?: string; instanceIds: readonly string[]; axis?: string }\n): ScopedTargets {\n /* 같은 자리(지표 또는 축값+지표)에 공간·인스턴스 목표가 함께 있으면 인스턴스가 이긴다. */\n const pick = new Map<string, { target: number; fromInstance: boolean; axisKey?: string; metric: TwinMetricId }>()\n for (const r of rows) {\n if (!isTwinMetric(r.metric) || typeof r.target !== 'number' || !Number.isFinite(r.target)) continue\n const inSpace = !!scope.spaceId && r.spaceId === scope.spaceId\n const inInstance = !!r.instanceId && scope.instanceIds.includes(r.instanceId)\n if (!inSpace && !inInstance) continue\n if (r.axis) {\n if (!scope.axis || r.axis !== scope.axis || !r.axisKey) continue // 지금 보는 축만\n }\n const slot = `${r.axis ?? ''}\\u0000${r.axisKey ?? ''}\\u0000${r.metric}`\n const prev = pick.get(slot)\n if (prev?.fromInstance && !inInstance) continue // 인스턴스 선언을 공간 선언이 덮지 않는다\n pick.set(slot, { target: r.target, fromInstance: inInstance, axisKey: r.axisKey ?? undefined, metric: r.metric })\n }\n const overall: TwinTargetSpec[] = []\n const byAxisKey: Record<string, TwinTargetSpec[]> = {}\n for (const [slot, v] of pick) {\n const spec = { metric: v.metric, target: v.target }\n if (slot.startsWith('\\u0000')) overall.push(spec)\n else (byAxisKey[v.axisKey as string] ??= []).push(spec)\n }\n return { overall, byAxisKey }\n}\n\n/** 목표 하나 — 어느 지표를, 얼마로. 방향은 지표가 안다(§TWIN_METRIC). */\nexport interface TwinTargetSpec {\n metric: TwinMetricId\n target: number\n}\n\nexport interface TargetVerdict {\n metric: TwinMetricId\n /** 이 구간의 실제 값. */\n actual: number\n target: number\n direction: 'higher' | 'lower'\n /** 목표를 만족했나. */\n met: boolean\n /**\n * 목표 대비 비율 — **항상 \"1 이상이면 좋다\"** 로 정규화한다(방향이 다른 지표를 한 화면에서 비교할 수\n * 있게). 처리량은 `actual/target`, 시간은 `target/actual`.\n */\n ratio: number\n}\n\n/**\n * 목표 대비 판정 — **목표가 없는 지표는 판정하지 않는다.**\n *\n * 값이 없는 지표(그 구간에 기록이 없어 `undefined`)도 판정하지 않는다. 없는 것을 0 으로 놓으면\n * \"목표 크게 미달\" 이라는 **없는 사실**이 만들어지고, 사용자는 멈춘 적 없는 라인을 고치러 간다.\n * 목표를 0 으로 준 경우도 비율을 내지 않는다(0 으로 나누지 않는다) — 만족 여부만 답한다.\n */\nexport function judgeTargets(kpi: KpiResult, targets: readonly TwinTargetSpec[] | undefined): TargetVerdict[] {\n const out: TargetVerdict[] = []\n for (const t of targets ?? []) {\n const spec = TWIN_METRIC[t.metric]\n if (!spec) continue // 모르는 지표 이름 — 짐작해 매핑하지 않는다\n const actual = spec.of(kpi)\n if (typeof actual !== 'number' || !Number.isFinite(actual)) continue // 값이 없다 ≠ 값이 0\n if (!Number.isFinite(t.target)) continue\n out.push(verdictOf(t.metric, actual, t.target, spec.direction))\n }\n return out\n}\n\n/** 판정 산수 한 곳 — 전체 판정과 축 판정이 **같은 규칙**을 쓴다(두 벌이면 갈라진다). */\nfunction verdictOf(metric: TwinMetricId, actual: number, target: number, direction: 'higher' | 'lower'): TargetVerdict {\n const met = direction === 'higher' ? actual >= target : actual <= target\n const ratio = direction === 'higher' ? (target > 0 ? actual / target : Number.NaN) : actual > 0 ? target / actual : Number.NaN\n return { metric, actual, target, direction, met, ratio }\n}\n"]}
@@ -1,6 +1,8 @@
1
1
  export declare function withLiveAttentions<S extends {
2
- movers?: any[];
3
- nodes?: any[];
2
+ equipment?: any[];
3
+ locations?: any[];
4
4
  orders?: any[];
5
+ tasks?: any[];
5
6
  attentions?: any[];
7
+ nowTime?: string;
6
8
  }>(state: S): S;
@@ -13,7 +13,11 @@ function withLiveAttentions(state) {
13
13
  return state;
14
14
  return {
15
15
  ...state,
16
- attentions: (0, twin_kernel_1.deriveAttentions)({ movers: state.movers ?? [], nodes: state.nodes ?? [], orders: state.orders ?? [] })
16
+ /*
17
+ * 입력을 **시뮬 쪽과 똑같이** 넣는다. 한쪽만 빠지면 같은 공장이 한쪽에서만 막힌 것처럼 보이고,
18
+ * 그 어긋남은 규칙이 아니라 배선 때문이라 아무 데서도 안 잡힌다(`tasks` 를 빠뜨려 실제로 그럴 뻔했다).
19
+ */
20
+ attentions: (0, twin_kernel_1.deriveAttentions)({ equipment: state.equipment ?? [], locations: state.locations ?? [], orders: state.orders ?? [], tasks: state.tasks ?? [] }, undefined, state.nowTime)
17
21
  };
18
22
  }
19
23
  //# sourceMappingURL=live-attentions.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"live-attentions.js","sourceRoot":"","sources":["../../server/engine/live-attentions.ts"],"names":[],"mappings":";;AAQA,gDAMC;AAdD;;;;;GAKG;AACH,sDAAuD;AAEvD,SAAgB,kBAAkB,CAAkF,KAAQ;IAC1H,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAA;IACxB,OAAO;QACL,GAAG,KAAK;QACR,UAAU,EAAE,IAAA,8BAAgB,EAAC,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,EAAE,EAAE,CAAC;KACnH,CAAA;AACH,CAAC","sourcesContent":["/*\n * live State attention 보강 — projector 미러 State(관측)에 커널 deriveAttentions(계산)를 채운다.\n * live 스냅샷이 sim 동형(attentions 포함)이 되어 attention 질의/스트림이 live 에서도 신호를 준다.\n * 순수(입력 불변). face2-inbound-live §1.1 \"State-순수 파생\"(입력이 미러 State 에 있어 재적용 가능).\n * deriveAttentions 는 커널 공유 공식(sim/live 동일 임계값·규칙).\n */\nimport { deriveAttentions } from '@operato/twin-kernel'\n\nexport function withLiveAttentions<S extends { movers?: any[]; nodes?: any[]; orders?: any[]; attentions?: any[] }>(state: S): S {\n if (!state) return state\n return {\n ...state,\n attentions: deriveAttentions({ movers: state.movers ?? [], nodes: state.nodes ?? [], orders: state.orders ?? [] })\n }\n}\n"]}
1
+ {"version":3,"file":"live-attentions.js","sourceRoot":"","sources":["../../server/engine/live-attentions.ts"],"names":[],"mappings":";;AAQA,gDAgBC;AAxBD;;;;;GAKG;AACH,sDAAuD;AAEvD,SAAgB,kBAAkB,CAChC,KAAQ;IAER,IAAI,CAAC,KAAK;QAAE,OAAO,KAAK,CAAA;IACxB,OAAO;QACL,GAAG,KAAK;QACR;;;WAGG;QACH,UAAU,EAAE,IAAA,8BAAgB,EAC1B,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,CAAC,SAAS,IAAI,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,KAAK,IAAI,EAAE,EAAE,EAC5H,SAAS,EACT,KAAK,CAAC,OAAO,CACd;KACF,CAAA;AACH,CAAC","sourcesContent":["/*\n * live State attention 보강 — projector 미러 State(관측)에 커널 deriveAttentions(계산)를 채운다.\n * live 스냅샷이 sim 동형(attentions 포함)이 되어 attention 질의/스트림이 live 에서도 신호를 준다.\n * 순수(입력 불변). face2-inbound-live §1.1 \"State-순수 파생\"(입력이 미러 State 에 있어 재적용 가능).\n * deriveAttentions 는 커널 공유 공식(sim/live 동일 임계값·규칙).\n */\nimport { deriveAttentions } from '@operato/twin-kernel'\n\nexport function withLiveAttentions<S extends { equipment?: any[]; locations?: any[]; orders?: any[]; tasks?: any[]; attentions?: any[]; nowTime?: string }>(\n state: S\n): S {\n if (!state) return state\n return {\n ...state,\n /*\n * 입력을 **시뮬 쪽과 똑같이** 넣는다. 한쪽만 빠지면 같은 공장이 한쪽에서만 막힌 것처럼 보이고,\n * 그 어긋남은 규칙이 아니라 배선 때문이라 아무 데서도 안 잡힌다(`tasks` 를 빠뜨려 실제로 그럴 뻔했다).\n */\n attentions: deriveAttentions(\n { equipment: state.equipment ?? [], locations: state.locations ?? [], orders: state.orders ?? [], tasks: state.tasks ?? [] },\n undefined,\n state.nowTime\n )\n }\n}\n"]}
@@ -0,0 +1,50 @@
1
+ /** 커널 `DurationEstimate` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */
2
+ export interface MeasuredEstimate {
3
+ meanMs: number;
4
+ spread?: {
5
+ distribution: 'triangular';
6
+ minMs: number;
7
+ maxMs: number;
8
+ modeMs: number;
9
+ };
10
+ }
11
+ export interface MeasuredEstimatorResult {
12
+ estimator?: {
13
+ estimate(ctx: {
14
+ kind: string;
15
+ fromNode: string;
16
+ toNode: string;
17
+ resourceKind?: string;
18
+ }): MeasuredEstimate | undefined;
19
+ };
20
+ /** 작업 종류 → 실측 소요(ms). 추정기가 답하는 종류들. */
21
+ learned: Record<string, number>;
22
+ /** 퍼짐까지 배운 종류 — 평균만 배운 것과 구별한다(변동 없는 소요는 대기를 과소평가한다). */
23
+ spreads: Record<string, {
24
+ minMs: number;
25
+ maxMs: number;
26
+ }>;
27
+ /** 표본이 모자라 쓰지 않은 종류와 그 건수 — 배우지 못한 것을 밝힌다. */
28
+ skipped: {
29
+ kind: string;
30
+ count: number;
31
+ }[];
32
+ minSamples: number;
33
+ }
34
+ /** 기본 표본 하한 — 이보다 적으면 그 종류는 배우지 않는다(작은 표본의 p50 을 현장 값으로 승격 금지). */
35
+ export declare const DEFAULT_MIN_SAMPLES = 5;
36
+ /**
37
+ * 작업 종류별 KPI 그룹에서 추정기를 만든다(순수 — DB·커널 모름).
38
+ * `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다.
39
+ */
40
+ export declare function buildMeasuredEstimator(groups: {
41
+ key: string;
42
+ workTime?: {
43
+ count: number;
44
+ p10Ms?: number;
45
+ p50Ms: number;
46
+ p90Ms?: number;
47
+ };
48
+ }[] | undefined, opts?: {
49
+ minSamples?: number;
50
+ }): MeasuredEstimatorResult;
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /*
3
+ * 실측 소요 추정기 — **이력에서 배운 값이 상수·선언값을 이긴다.**
4
+ *
5
+ * 배경(operato-twin design/plans/simulation-spec.md §5-4): 소요시간을 명세로 받을 수 있게 됐지만
6
+ * 명세는 선언이다 — 그 현장에서 실제로 얼마 걸리는지는 저널이 안다. `gap-analytics` 는 지표 수준의
7
+ * 편향만 배웠고(예측값 보정), **공정별 소요를 추정기로 되먹이는 경로는 없었다.**
8
+ *
9
+ * 여기서 하는 일: KPI 폴드가 작업 종류별로 낸 **작업시간(착수→완료) p50** 을 그 종류의 소요로 쓴다.
10
+ * 리드타임(생성→완료)이 아니라 작업시간을 쓰는 이유: 리드타임에는 **대기가 섞여 있고**, 대기는
11
+ * 시뮬레이션이 자원 경합으로 스스로 만들어야 하는 값이다. 대기를 소요에 넣으면 이중 계산이 된다.
12
+ *
13
+ * **평균만 배우면 대기와 병목을 과소평가한다.** 소요가 늘 정확히 같으면 줄이 생기지 않는다 — 현장의
14
+ * 줄은 흔들림에서 나온다. 그래서 p50 만이 아니라 **관측된 퍼짐(p10..p90)** 을 함께 넘기고, 표본은
15
+ * 커널이 자기 난수로 뽑는다(fork 결정성). 양쪽 꼬리를 다 관측에서 가져오는 이유: p90 만 있으면
16
+ * 아래쪽을 위쪽에서 거울처럼 베껴야 하는데 그건 발명이다.
17
+ *
18
+ * 정직 규율:
19
+ * - 표본이 적으면 쓰지 않는다(`minSamples`). 한두 건의 p50 은 그 현장의 값이 아니다.
20
+ * - 'unknown' 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼을 수 없다.
21
+ * - 퍼짐이 성립하지 않으면(p10 ≥ p90, 또는 0) **퍼짐 없이 평균만** 넘긴다. 없는 흔들림을 만들지 않는다.
22
+ * - 무엇을 배웠고 무엇을 표본 부족으로 버렸는지 함께 낸다(조용히 일부만 반영하지 않는다).
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.DEFAULT_MIN_SAMPLES = void 0;
26
+ exports.buildMeasuredEstimator = buildMeasuredEstimator;
27
+ /** 기본 표본 하한 — 이보다 적으면 그 종류는 배우지 않는다(작은 표본의 p50 을 현장 값으로 승격 금지). */
28
+ exports.DEFAULT_MIN_SAMPLES = 5;
29
+ /**
30
+ * 작업 종류별 KPI 그룹에서 추정기를 만든다(순수 — DB·커널 모름).
31
+ * `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다.
32
+ */
33
+ function buildMeasuredEstimator(groups, opts = {}) {
34
+ const minSamples = opts.minSamples ?? exports.DEFAULT_MIN_SAMPLES;
35
+ const learned = {};
36
+ const spreads = {};
37
+ const estimates = {};
38
+ const skipped = [];
39
+ for (const g of groups ?? []) {
40
+ if (!g?.key || g.key === 'unknown')
41
+ continue;
42
+ const w = g.workTime;
43
+ const count = w?.count ?? 0;
44
+ const p50 = w?.p50Ms ?? 0;
45
+ if (count < minSamples || !(p50 > 0)) {
46
+ skipped.push({ kind: g.key, count });
47
+ continue;
48
+ }
49
+ const mean = Math.round(p50);
50
+ learned[g.key] = mean;
51
+ /* 퍼짐은 **관측된 두 꼬리**로만 만든다. 한쪽이라도 없거나 구간이 성립하지 않으면 평균만 넘긴다. */
52
+ const lo = w?.p10Ms;
53
+ const hi = w?.p90Ms;
54
+ if (typeof lo === 'number' && typeof hi === 'number' && lo > 0 && hi > lo) {
55
+ const minMs = Math.round(lo);
56
+ const maxMs = Math.round(hi);
57
+ spreads[g.key] = { minMs, maxMs };
58
+ estimates[g.key] = { meanMs: mean, spread: { distribution: 'triangular', minMs, maxMs, modeMs: mean } };
59
+ }
60
+ else {
61
+ estimates[g.key] = { meanMs: mean };
62
+ }
63
+ }
64
+ if (!Object.keys(learned).length)
65
+ return { learned, spreads, skipped, minSamples };
66
+ return {
67
+ learned,
68
+ spreads,
69
+ skipped,
70
+ minSamples,
71
+ estimator: {
72
+ estimate(ctx) {
73
+ return estimates[ctx.kind];
74
+ }
75
+ }
76
+ };
77
+ }
78
+ //# sourceMappingURL=measured-estimator.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"measured-estimator.js","sourceRoot":"","sources":["../../server/engine/measured-estimator.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;;;AA4BH,wDAyCC;AAhDD,mEAAmE;AACtD,QAAA,mBAAmB,GAAG,CAAC,CAAA;AAEpC;;;GAGG;AACH,SAAgB,sBAAsB,CACpC,MAAkH,EAClH,OAAgC,EAAE;IAElC,MAAM,UAAU,GAAG,IAAI,CAAC,UAAU,IAAI,2BAAmB,CAAA;IACzD,MAAM,OAAO,GAA2B,EAAE,CAAA;IAC1C,MAAM,OAAO,GAAqD,EAAE,CAAA;IACpE,MAAM,SAAS,GAAqC,EAAE,CAAA;IACtD,MAAM,OAAO,GAAsC,EAAE,CAAA;IACrD,KAAK,MAAM,CAAC,IAAI,MAAM,IAAI,EAAE,EAAE,CAAC;QAC7B,IAAI,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC,GAAG,KAAK,SAAS;YAAE,SAAQ;QAC5C,MAAM,CAAC,GAAG,CAAC,CAAC,QAAQ,CAAA;QACpB,MAAM,KAAK,GAAG,CAAC,EAAE,KAAK,IAAI,CAAC,CAAA;QAC3B,MAAM,GAAG,GAAG,CAAC,EAAE,KAAK,IAAI,CAAC,CAAA;QACzB,IAAI,KAAK,GAAG,UAAU,IAAI,CAAC,CAAC,GAAG,GAAG,CAAC,CAAC,EAAE,CAAC;YAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC;YAAC,SAAQ;QAAC,CAAC;QACxF,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;QAC5B,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,IAAI,CAAA;QACrB,6DAA6D;QAC7D,MAAM,EAAE,GAAG,CAAC,EAAE,KAAK,CAAA;QACnB,MAAM,EAAE,GAAG,CAAC,EAAE,KAAK,CAAA;QACnB,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,GAAG,EAAE,EAAE,CAAC;YAC1E,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YAC5B,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,CAAA;YAC5B,OAAO,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,EAAE,CAAA;YACjC,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,YAAY,EAAE,YAAY,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE,CAAA;QACzG,CAAC;aAAM,CAAC;YACN,SAAS,CAAC,CAAC,CAAC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAA;QACrC,CAAC;IACH,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,MAAM;QAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,UAAU,EAAE,CAAA;IAClF,OAAO;QACL,OAAO;QACP,OAAO;QACP,OAAO;QACP,UAAU;QACV,SAAS,EAAE;YACT,QAAQ,CAAC,GAAG;gBACV,OAAO,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;YAC5B,CAAC;SACF;KACF,CAAA;AACH,CAAC","sourcesContent":["/*\n * 실측 소요 추정기 — **이력에서 배운 값이 상수·선언값을 이긴다.**\n *\n * 배경(operato-twin design/plans/simulation-spec.md §5-4): 소요시간을 명세로 받을 수 있게 됐지만\n * 명세는 선언이다 — 그 현장에서 실제로 얼마 걸리는지는 저널이 안다. `gap-analytics` 는 지표 수준의\n * 편향만 배웠고(예측값 보정), **공정별 소요를 추정기로 되먹이는 경로는 없었다.**\n *\n * 여기서 하는 일: KPI 폴드가 작업 종류별로 낸 **작업시간(착수→완료) p50** 을 그 종류의 소요로 쓴다.\n * 리드타임(생성→완료)이 아니라 작업시간을 쓰는 이유: 리드타임에는 **대기가 섞여 있고**, 대기는\n * 시뮬레이션이 자원 경합으로 스스로 만들어야 하는 값이다. 대기를 소요에 넣으면 이중 계산이 된다.\n *\n * **평균만 배우면 대기와 병목을 과소평가한다.** 소요가 늘 정확히 같으면 줄이 생기지 않는다 — 현장의\n * 줄은 흔들림에서 나온다. 그래서 p50 만이 아니라 **관측된 퍼짐(p10..p90)** 을 함께 넘기고, 표본은\n * 커널이 자기 난수로 뽑는다(fork 결정성). 양쪽 꼬리를 다 관측에서 가져오는 이유: p90 만 있으면\n * 아래쪽을 위쪽에서 거울처럼 베껴야 하는데 그건 발명이다.\n *\n * 정직 규율:\n * - 표본이 적으면 쓰지 않는다(`minSamples`). 한두 건의 p50 은 그 현장의 값이 아니다.\n * - 'unknown' 축은 쓰지 않는다 — 종류를 모르는 기록을 특정 종류의 근거로 삼을 수 없다.\n * - 퍼짐이 성립하지 않으면(p10 ≥ p90, 또는 0) **퍼짐 없이 평균만** 넘긴다. 없는 흔들림을 만들지 않는다.\n * - 무엇을 배웠고 무엇을 표본 부족으로 버렸는지 함께 낸다(조용히 일부만 반영하지 않는다).\n */\n\n/** 커널 `DurationEstimate` 와 같은 모양(커널 타입에 의존하지 않기 위해 구조로 맞춘다). */\nexport interface MeasuredEstimate {\n meanMs: number\n spread?: { distribution: 'triangular'; minMs: number; maxMs: number; modeMs: number }\n}\n\nexport interface MeasuredEstimatorResult {\n estimator?: {\n estimate(ctx: { kind: string; fromNode: string; toNode: string; resourceKind?: string }): MeasuredEstimate | undefined\n }\n /** 작업 종류 → 실측 소요(ms). 추정기가 답하는 종류들. */\n learned: Record<string, number>\n /** 퍼짐까지 배운 종류 — 평균만 배운 것과 구별한다(변동 없는 소요는 대기를 과소평가한다). */\n spreads: Record<string, { minMs: number; maxMs: number }>\n /** 표본이 모자라 쓰지 않은 종류와 그 건수 — 배우지 못한 것을 밝힌다. */\n skipped: { kind: string; count: number }[]\n minSamples: number\n}\n\n/** 기본 표본 하한 — 이보다 적으면 그 종류는 배우지 않는다(작은 표본의 p50 을 현장 값으로 승격 금지). */\nexport const DEFAULT_MIN_SAMPLES = 5\n\n/**\n * 작업 종류별 KPI 그룹에서 추정기를 만든다(순수 — DB·커널 모름).\n * `groups` 는 `computeTwinKpi({ groupBy: 'taskKind' })` 의 `groups.items` 를 그대로 넘긴다.\n */\nexport function buildMeasuredEstimator(\n groups: { key: string; workTime?: { count: number; p10Ms?: number; p50Ms: number; p90Ms?: number } }[] | undefined,\n opts: { minSamples?: number } = {}\n): MeasuredEstimatorResult {\n const minSamples = opts.minSamples ?? DEFAULT_MIN_SAMPLES\n const learned: Record<string, number> = {}\n const spreads: Record<string, { minMs: number; maxMs: number }> = {}\n const estimates: Record<string, MeasuredEstimate> = {}\n const skipped: { kind: string; count: number }[] = []\n for (const g of groups ?? []) {\n if (!g?.key || g.key === 'unknown') continue\n const w = g.workTime\n const count = w?.count ?? 0\n const p50 = w?.p50Ms ?? 0\n if (count < minSamples || !(p50 > 0)) { skipped.push({ kind: g.key, count }); continue }\n const mean = Math.round(p50)\n learned[g.key] = mean\n /* 퍼짐은 **관측된 두 꼬리**로만 만든다. 한쪽이라도 없거나 구간이 성립하지 않으면 평균만 넘긴다. */\n const lo = w?.p10Ms\n const hi = w?.p90Ms\n if (typeof lo === 'number' && typeof hi === 'number' && lo > 0 && hi > lo) {\n const minMs = Math.round(lo)\n const maxMs = Math.round(hi)\n spreads[g.key] = { minMs, maxMs }\n estimates[g.key] = { meanMs: mean, spread: { distribution: 'triangular', minMs, maxMs, modeMs: mean } }\n } else {\n estimates[g.key] = { meanMs: mean }\n }\n }\n if (!Object.keys(learned).length) return { learned, spreads, skipped, minSamples }\n return {\n learned,\n spreads,\n skipped,\n minSamples,\n estimator: {\n estimate(ctx) {\n return estimates[ctx.kind]\n }\n }\n }\n}\n"]}
@@ -0,0 +1,23 @@
1
+ /** 같은 모델로 볼 상대 허용오차 — 이 비율 안의 변화는 드리프트, 넘으면 다른 모델. */
2
+ export declare const BASIS_TOLERANCE = 0.1;
3
+ export interface ModelBasis {
4
+ /** 작업 종류 → 실측 소요(ms). `buildMeasuredEstimator().learned`. */
5
+ measured?: Record<string, number>;
6
+ /** 자원 종류 → 속도(m/s). `buildTravelEstimator().speedsByKind`. */
7
+ speeds?: Record<string, number>;
8
+ /** 선언된 공정 소요 — key + ISO 8601 원문(문자열은 그대로 비교한다: 선언이 바뀌면 다른 모델). */
9
+ declared?: {
10
+ key: string;
11
+ duration?: string;
12
+ }[];
13
+ }
14
+ /**
15
+ * 모델이 달라졌는가 — 보정을 적용하기 전의 관문.
16
+ * 한쪽 근거를 모르면 **판정하지 않는다**(false) — 모르는 것을 근거로 학습을 끊지 않는다.
17
+ */
18
+ export declare function modelBasisChanged(before?: ModelBasis, after?: ModelBasis, tol?: number): boolean;
19
+ /**
20
+ * 사람이 읽는 근거 요약 — **로그·진단용**(비교의 근거가 아니다).
21
+ * 지문으로 같음을 판정하지 않는 이유는 위 주석 참조. 두 근거가 왜 달라졌는지 눈으로 보려면 이것을 찍는다.
22
+ */
23
+ export declare function describeModelBasis(basis?: ModelBasis): string;
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ /*
3
+ * 모델 근거 — **배운 모델에만 보정을 적용하기 위한 비교.**
4
+ *
5
+ * 예측 출력 보정(P3.2 `twinLearnCalibration`)은 "우리 모델이 늘 이만큼 적게 본다" 를 배워 결과를
6
+ * 밀어 준다. 그 학습은 **그때의 모델**을 전제로 한다. 그런데 소요시간 추정기(실측·거리계산)와 공정
7
+ * 명세가 들어오면서 모델 자체가 바뀔 수 있게 됐다 — 모델이 이미 그 편향을 잃었는데 보정이 또 밀어
8
+ * 주면 **이중 보정**이 되어 정확도가 나빠진다. 학습 시점의 accAfter 도 더는 유효하지 않다.
9
+ *
10
+ * 그래서 보정 기록에 **그때 모델의 근거**를 함께 남기고, 적용 전에 지금 근거와 비교한다. 달라졌으면
11
+ * 적용하지 않고 **그 사실을 응답에 싣는다**(조용히 사라지면 사용자는 학습이 왜 안 듣는지 모른다).
12
+ *
13
+ * 비교는 **상대 허용오차**로 한다(양자화 지문이 아니라). 실측 p50 은 새 기록이 쌓일 때마다 1~2초씩
14
+ * 흔들리는데, 값을 버킷으로 뭉개 비교하면 버킷 경계에 걸린 값이 1초 드리프트로도 달라져 버린다 —
15
+ * "작은 드리프트로 학습을 무효화하지 않는다" 는 목적을 경계에서 못 지킨다. 비율로 비교하면 어디서든
16
+ * 같은 규칙이 성립한다: 10% 이내는 같은 모델, 30초→47초(57%)는 다른 모델.
17
+ *
18
+ * 재료는 호스트가 아는 것들이다(커널 API 에 의존하지 않아 발행 상태와 무관하게 동작한다):
19
+ * ① 실측으로 배운 작업 종류별 소요 ② 자원 종류별 속도 ③ 선언된 공정 소요.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.BASIS_TOLERANCE = void 0;
23
+ exports.modelBasisChanged = modelBasisChanged;
24
+ exports.describeModelBasis = describeModelBasis;
25
+ /** 같은 모델로 볼 상대 허용오차 — 이 비율 안의 변화는 드리프트, 넘으면 다른 모델. */
26
+ exports.BASIS_TOLERANCE = 0.1;
27
+ /** 뜻 있는 수만 남긴다 — 0·음수·NaN 은 모델이 아니다. */
28
+ function clean(map) {
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(map ?? {}))
31
+ if (Number.isFinite(v) && v > 0)
32
+ out[k] = v;
33
+ return out;
34
+ }
35
+ function declaredMap(list) {
36
+ const out = {};
37
+ for (const o of list ?? [])
38
+ if (o?.key && o.duration)
39
+ out[o.key] = o.duration;
40
+ return out;
41
+ }
42
+ /** 두 수치 지도가 실질적으로 다른가 — 키 집합이 다르거나, 값이 허용오차를 넘어 다르면 다르다. */
43
+ function numericChanged(a, b, tol) {
44
+ const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
45
+ for (const k of keys) {
46
+ const x = a[k];
47
+ const y = b[k];
48
+ if (x === undefined || y === undefined)
49
+ return true; // 새로 배운 종류·사라진 종류 = 다른 모델
50
+ const base = Math.max(Math.abs(x), Math.abs(y));
51
+ if (base > 0 && Math.abs(x - y) / base > tol)
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ /**
57
+ * 모델이 달라졌는가 — 보정을 적용하기 전의 관문.
58
+ * 한쪽 근거를 모르면 **판정하지 않는다**(false) — 모르는 것을 근거로 학습을 끊지 않는다.
59
+ */
60
+ function modelBasisChanged(before, after, tol = exports.BASIS_TOLERANCE) {
61
+ if (!before || !after)
62
+ return false;
63
+ if (numericChanged(clean(before.measured), clean(after.measured), tol))
64
+ return true;
65
+ if (numericChanged(clean(before.speeds), clean(after.speeds), tol))
66
+ return true;
67
+ const da = declaredMap(before.declared);
68
+ const db = declaredMap(after.declared);
69
+ const keys = new Set([...Object.keys(da), ...Object.keys(db)]);
70
+ for (const k of keys)
71
+ if (da[k] !== db[k])
72
+ return true;
73
+ return false;
74
+ }
75
+ /**
76
+ * 사람이 읽는 근거 요약 — **로그·진단용**(비교의 근거가 아니다).
77
+ * 지문으로 같음을 판정하지 않는 이유는 위 주석 참조. 두 근거가 왜 달라졌는지 눈으로 보려면 이것을 찍는다.
78
+ */
79
+ function describeModelBasis(basis) {
80
+ if (!basis)
81
+ return 'unknown';
82
+ const seg = [];
83
+ const m = Object.entries(clean(basis.measured))
84
+ .map(([k, v]) => `${k}=${Math.round(v / 1000)}s`)
85
+ .sort();
86
+ if (m.length)
87
+ seg.push(`measured(${m.join(',')})`);
88
+ const s = Object.entries(clean(basis.speeds))
89
+ .map(([k, v]) => `${k}=${Math.round(v * 10) / 10}m/s`)
90
+ .sort();
91
+ if (s.length)
92
+ seg.push(`speed(${s.join(',')})`);
93
+ const d = Object.entries(declaredMap(basis.declared))
94
+ .map(([k, v]) => `${k}=${v}`)
95
+ .sort();
96
+ if (d.length)
97
+ seg.push(`declared(${d.join(',')})`);
98
+ return seg.length ? seg.join(' ') : 'kernel defaults only';
99
+ }
100
+ //# sourceMappingURL=model-basis.js.map