import { type TargetVerdict } from './kpi-target.js'; import { type CrossedGroup, type KpiGroupBy, type KpiResult } from './kpi-fold.js'; import { type BaselineStride, type MetricBaseline } from './kpi-baseline.js'; export interface TwinKpiInput { domainId: string; /** 한 트윈. `spaceId` 와 둘 중 하나는 있어야 한다. */ instanceId?: string; /** * 한 **공간** — 그 공간에서 가동 중인 트윈 전부를 **한 번에 계산한다**. * * 트윈별로 계산해 평균을 다시 평균하면 분위수가 거짓이 된다(p90 의 평균은 p90 이 아니다). * 그래서 이벤트를 모아 **한 번** 계산한다. 사용자는 "부산 창고" 를 보고, 그 안에 트윈이 몇 개든 * 합쳐서 본다(헤더 트윈 셀렉터를 없앤 결정과 같은 규율). */ spaceId?: string; /** 창의 끝(ISO). 미지정이면 **그 트윈의 마지막 기록 시각**. */ toTime?: string; /** 창의 시작(ISO). 미지정이면 끝에서 windowMinutes 만큼 뒤로. */ fromTime?: string; /** 창 길이(분). fromTime 이 있으면 무시. 기본 60, 1~1440. */ windowMinutes?: number; /** 시작 이벤트를 찾기 위해 창보다 앞으로 더 읽는 시간(분). 기본 60, 0~1440. */ lookbackMinutes?: number; /** * 창을 N 등분해 **구간별 값도** 함께 낸다(추세용). 1~48, 기본 0(쪼개지 않음). * 조회는 한 번이고 계산만 N 번 반복한다 — 폴드가 순수하므로 값싸다. */ buckets?: number; /** * 같은 사실을 **다른 관점으로** 쪼갠다: 자원별·작업종류별·도착지점별·오더별·구역별. * * 고정된 한 렌즈만 낼 수 있으면 "구역별로는 어떤가" 라는 질문에 화면도 AI 도 답할 수 없다. * 축은 조회 인자 하나이고, 계산는 순수 함수라 축을 하나 더 보는 값이 거의 없다. */ groupBy?: KpiGroupBy; /** 축의 상한(기본 20, 최대 100). 넘치면 잘라내고 잘린 수를 함께 알린다. */ groupLimit?: number; /** * **무엇과 비교할 것인가** — 같은 길이의 앞 구간(previous)이나 하루 전 같은 시간(yesterday). * * 숫자 하나로는 좋아졌는지 나빠졌는지 알 수 없다("29초" 가 개선인지 악화인지). 창만 옮겨 한 번 더 * 계산하면 되므로(폴드는 순수) 값싸다. 비교 구간에 기록이 없으면 **delta 를 만들지 않는다** — * 없는 것을 0% 로 그리면 "변화 없음" 이라는 거짓이 된다. */ compareTo?: 'previous' | 'yesterday'; /** * **여러 구간 동시 비교** — 최근 N 구간의 같은 시간대를 함께 계산해 **분포**와 그 안의 자리를 낸다. * * `compareTo` 는 한 구간만 봐서 판단이 "어제보다 나아졌다" 에서 멈춘다 — 어제도 나빴으면 쓸모가 없다. * 최근 며칠의 같은 시간대를 보면 목표 없이도 "지난 7일 중 가장 낮다" 를 말할 수 있다. * * `stride: 'week'` 는 같은 요일을 본다(주말·평일이 섞이지 않게). `periods` 는 1~14 * (창마다 조회가 한 번 더 붙는다 — 값을 공짜로 말하지 않는다). * * `compareTo` 와 함께 쓸 수 있다: 다른 질문이기 때문이다(직전 한 구간 vs 최근 분포). */ baseline?: { periods: number; stride?: BaselineStride; }; } export interface TwinKpiOutput extends Omit, 'window'> { /** 사람이 읽는 창(ISO) + 기준. 폴드의 raw 창(ms)은 여기에 흡수한다. */ window: { fromTime: string; toTime: string; minutes: number; /** 끝 시각을 어디서 얻었나 — 'given'(호출부 지정) | 'latest-event'(트윈의 마지막 기록). */ basis: 'given' | 'latest-event'; }; /** * 조회가 **상한에 걸렸는가**(구간이 너무 커서 일부만 읽었을 수 있다). * * true 면 이 구간의 숫자는 **하한**이다 — 실제로는 더 있었을 수 있다. 알리지 않고 잘라내면 숫자가 알리지 않고 * 작아지고 사용자는 그것을 성과 하락으로 읽는다. 구간을 좁혀 다시 물어야 한다. */ eventsCapped?: boolean; /** * 이 구간에 **측정할 기록이 있었는가**. false 는 "처리량 0" 이 아니라 **"기록이 없다"** 는 뜻이다 — * 두 상황은 완전히 다르고, 섞으면 사용자가 잘못된 결론을 낸다. */ measured: boolean; note?: string; /** 무엇을 집계했나 — 공간이면 합쳐 계산한 트윈 목록을 밝힌다(숫자의 출처를 숨기지 않는다). */ /** * 무엇을 집계했나 — 공간이면 합쳐 계산한 트윈 목록. * * `restartPolicies` 는 **이 숫자가 무엇의 성과인지**를 말한다: mirror(실제 시스템을 반영) · * `resume`(이어지는 가상 세계) · `reset`(재현 실험 사본). 같은 "345건" 이 현실의 실적일 수도, 시뮬레이션의 * 산출일 수도 있다 — 그 구별 없이 숫자만 보여주면 사용자가 시뮬 결과를 실적으로 착각한다. */ scope: { spaceId?: string; instanceIds: string[]; restartPolicies: string[]; }; /** 구간별 값(요청했을 때만) — 추세를 그리기 위한 최소 정보. */ buckets?: { fromTime: string; toTime: string; tasks: number; orders: number; workP50Ms: number; }[]; /** * **목표 대비 판정** — 숫자를 판단으로 바꾸는 값(§kpi-target). * * 목표가 걸린 지표만 들어온다. 목표가 없거나 그 구간에 값이 없는 지표는 **여기 나타나지 않는다** — * 전부 "목표 없음" 으로 채우면 화면이 회색 노이즈가 되고, 없는 값을 0 으로 놓으면 "목표 크게 미달" * 이라는 없는 사실이 만들어진다. */ targets?: TargetVerdict[]; /** * 비교 구간(요청했을 때만) — 같은 규칙·같은 길이로 계산한 앞 구간과의 차이. * * `measured:false` 면 그 구간에 기록이 없다는 뜻이고 `delta` 는 없다(0 이 아니다). * delta 는 **현재 − 비교구간** 이다. 좋고 나쁨은 판단하지 않는다 — 처리량은 클수록 좋고 소요는 * 작을수록 좋다는 해석은 소비처(화면·AI)의 몫이고, 계산 층이 판단을 섞으면 목표 없이 판정하게 된다. */ comparison?: { basis: 'previous' | 'yesterday'; window: { fromTime: string; toTime: string; }; measured: boolean; throughput?: { tasks: number; orders: number; }; workTime?: KpiResult['workTime']; leadTime?: KpiResult['leadTime']; waitTime?: KpiResult['waitTime']; delta?: { tasks: number; orders: number; workP50Ms?: number; leadP50Ms?: number; waitP50Ms?: number; }; }; /** * **여러 구간 분포**(요청했을 때만) — 지표별로 최근 N 구간의 분포와 지금의 자리. * * `samples` 는 **요청한 창 전부**다(기록 없는 창도 `measured:false` 로 남는다) — 빼고 말하면 * "지난 7일" 이 실제로는 3일이었다는 것을 아무도 모른다. 자리를 말할 수 없으면 `noPosition` 이 * 이유를 밝힌다(표본 부족·현재 값 없음). 방향은 지표의 성질이라 `standing` 이 그것을 적용한 읽기다. */ baseline?: { stride: BaselineStride; /** 요청한 과거 구간 수(상한 적용 후). */ periods: number; metrics: MetricBaseline[]; /** 과거 창 조회 중 하나라도 상한에 걸렸나 — 그 창의 값은 하한이라 분포가 낮게 보일 수 있다. */ capped?: boolean; }; /** * 관점별 값(요청했을 때만). `key` 는 식별자, `label` 은 사람이 읽는 이름(있을 때만 — 구역은 이름이 * 따로 저장되어 있다). `truncated` 는 상한 때문에 빠진 축의 수다. */ groups?: { by: KpiGroupBy; items: (KpiGroupOut & { label?: string; })[]; /** * 비교 구간에는 일이 있었는데 **지금은 없는** 축. 표에서 그냥 빠지면 멈춘 구역·대기 중인 자원이 * 눈에 보이지 않는다 — 운영에서는 이것이 가장 중요한 신호일 수 있다. */ disappeared?: { key: string; label?: string; tasks: number; }[]; /** 상한 때문에 빠진 축의 수와, 그 축들이 안고 있던 건수(둘 다 알려야 꼬리를 놓치지 않는다). */ truncated?: number; truncatedTasks?: number; note?: string; }; } /** 축 하나의 값 — 폴드의 KpiGroup 과 같은 모양(시각 변환이 없어 그대로 통과한다). */ type KpiGroupOut = NonNullable['items'][number] & Partial>; /** * 대상 해소 공개 창구 — **저널 목록 질의도 KPI 와 같은 규칙을 써야 한다.** * * 화면에서 "이 공간" 은 하나의 뜻이어야 한다. 성과 화면과 원장 화면이 각자 co-located 트윈을 * 추리면 같은 공간을 보면서 다른 대상 집합을 세게 되고, 두 숫자가 어긋나는 이유를 아무도 설명하지 못한다. */ export declare function resolveTwinTargets(domainId: string, instanceId?: string, spaceId?: string): Promise; /** * 시간창 업무 KPI. 창 해소 → 저널 조회 → 순수 폴드. * * 테넌트 조건은 호출부가 준 `domainId` 로만 좁힌다(클라이언트 값을 그대로 쓰지 않는 것은 호출부 책임). */ export declare function computeTwinKpi(input: TwinKpiInput): Promise; export {};