/* * 여러 구간 동시 비교 — **"어제보다 나아졌다" 를 넘어선다.** 순수. * * ── 왜 필요한가 (성과 맵 ③) ───────────────────────────────────────────────── * 지금 비교 축은 **한 구간**만 본다(직전 구간 또는 하루 전). 그래서 판단이 딱 한 문장에서 멈춘다: * "어제보다 나아졌다." **어제도 나빴으면 그 말은 쓸모가 없다.** 목표(`kpi-target`)가 그 한계를 한쪽에서 * 풀지만, 목표를 아직 걸지 않은 현장은 여전히 자기 숫자가 평소와 같은지도 알 수 없다. * * 최근 며칠의 **같은 시간대**를 함께 계산하면 목표 없이도 말할 수 있다 — "오늘 이 시간대는 지난 7일 중 * 가장 낮다." 그것이 이 파일이 만드는 것이다: 창 여러 개의 **분포**와 그 안에서 현재의 **자리**. * * ── 이 파일이 하지 않는 것 ────────────────────────────────────────────────── * · **지표 어휘를 모른다.** 이름·방향·값 꺼내는 법은 `kpi-target.ts` 의 `TWIN_METRIC` 이 소유한다. 여기는 * **이미 꺼낸 값**만 받는다 — 어휘가 두 곳에 생기면 그 사이에 매핑 표가 자라고 그것이 곧 방언이다. * (덤으로: 이 모듈은 런타임 import 가 없어 테스트가 소스를 그대로 든다.) * · **DB·조회를 모른다.** 창 목록만 계산하고, 그 창을 읽는 것은 호출부의 일이다. * · **좋고 나쁨의 임계를 발명하지 않는다.** "평소보다 나쁘다" 는 중앙값과의 비교로만 말한다 — 임의의 * 퍼센트 임계를 두면 그 숫자가 곧 숨은 목표가 된다. * * ── 정직성 규율 (이 도메인의 핵심) ────────────────────────────────────────── * ① **결측 창을 알리지 않고 빼지 않는다.** 요청한 창 전부를 `measured` 와 함께 돌려주고, 분포는 측정된 * 것만으로 만든다. 빼고 말하면 "지난 7일" 이 실제로는 3일이었다는 것을 아무도 모른다. * ② **표본이 둘 미만이면 자리를 말하지 않는다.** 하나를 분포라 부를 수 없다. * ③ **현재 값이 없으면 자리를 말하지 않는다.** 결측을 0 으로 읽으면 시간 지표는 "낮을수록 좋다" 라서 * **일이 없던 구간이 최고 성적**으로 보고된다(목표 판정에서 이미 겪은 결함이다). * ④ 왜 말하지 않는지를 `reason` 으로 밝힌다 — 알리지 않고 빈 값을 주면 화면이 "분포 없음" 을 "평범함" 으로 * 그린다. */ /** 창 하나 — 폴드의 창과 같은 모양(ms). */ export interface BaselineWindow { fromMs: number toMs: number } /** 며칠 전 같은 시간대인가, 몇 주 전 같은 요일·시간대인가. */ export type BaselineStride = 'day' | 'week' /** 한 번에 볼 수 있는 과거 구간 수 — 창마다 조회가 한 번 더 붙으므로 상한을 둔다. */ export const MAX_BASELINE_PERIODS = 14 const MS_DAY = 24 * 60 * 60_000 /** * 비교할 과거 창들 — 현재 창을 **같은 길이로** 하루(또는 한 주)씩 뒤로 옮긴다. * * 가까운 것부터 준다(어제 → 그제 → …). 화면이 시간 순서를 다시 정하지 않아도 되게. * * **한계를 밝힌다**: 옮기는 것은 ms 산술이다. 트윈 공간은 고정 오프셋(`utcOffsetMinutes`)을 선언하므로 * 현재 모델에서는 "같은 시간대" 가 정확하다. 일광절약시간을 쓰는 시간대를 나중에 지원하면 전환이 있는 * 주의 하루가 한 시간 밀린다 — 그때는 오프셋 기준 캘린더 산술로 바꿔야 한다(지금 지어내지 않는다). */ export function baselineWindows( fromMs: number, toMs: number, periods: number, stride: BaselineStride = 'day' ): BaselineWindow[] { if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return [] const count = Math.max(0, Math.min(MAX_BASELINE_PERIODS, Math.floor(periods))) const step = stride === 'week' ? 7 * MS_DAY : MS_DAY return Array.from({ length: count }, (_, i) => ({ fromMs: fromMs - step * (i + 1), toMs: toMs - step * (i + 1) })) } /** 지표의 성질 — 이름과 값 꺼내기는 호출부(`TWIN_METRIC`)가 소유한다. */ export interface MetricSpecLike { direction: 'higher' | 'lower' unit: string } /** 과거 창 하나에서 꺼낸 값들 — 그 창에 기록이 없으면 값이 `undefined` 다. */ export interface BaselineSampleInput { fromMs: number toMs: number values: Partial> } /** 과거 창 하나의 결과 — **결측도 남긴다**(빼면 "지난 7일" 이 거짓이 된다). */ export interface BaselineSample { fromMs: number toMs: number measured: boolean value?: number } /** 왜 자리를 말하지 않는지. */ export type BaselineNoPosition = 'no-current' | 'too-few-samples' /** * 분포 안에서 현재의 자리 — **방향을 적용한 읽기.** * * 임의의 임계가 없다: 표본 전부보다 좋으면 `best`, 전부보다 나쁘면 `worst`, 그 사이는 중앙값을 기준으로 * 구분한다. `typical` 은 중앙값과 **같을 때**만이다. */ export type BaselineStanding = 'best' | 'better-than-typical' | 'typical' | 'worse-than-typical' | 'worst' export interface MetricBaseline { metric: M direction: 'higher' | 'lower' unit: string /** 지금 창의 값 — 없으면 `undefined`(0 이 아니다). */ current?: number /** 요청한 과거 창 전부(결측 포함, 가까운 것부터). */ samples: BaselineSample[] /** 요청한 창 수 / 그중 실제로 측정된 수 — 둘을 함께 봐야 "지난 7일" 을 믿을 수 있다. */ requested: number measuredCount: number /** 측정된 표본만의 요약. 표본이 없으면 전부 `undefined`. */ min?: number median?: number max?: number /** 측정 표본을 오름차순으로 놓았을 때 현재 값이 몇 번째인가(1 = 가장 작음) — **방향 없는 사실**. */ rank?: number /** 그 순위를 셀 때의 표본 수(+현재 1). 순위만 보면 표본이 둘이었는지 열이었는지 알 수 없다. */ rankOf?: number /** 방향을 적용한 읽기. 말할 수 없으면 없다. */ standing?: BaselineStanding /** 자리를 말하지 않은 이유. */ noPosition?: BaselineNoPosition } /** 중앙값 — 짝수 개면 가운데 둘의 평균(폴드의 분위수 규약과 달리 여기 표본은 아주 작다). */ function medianOf(sorted: readonly number[]): number | undefined { if (!sorted.length) return undefined const mid = sorted.length >> 1 return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 } /** * 지표별로 분포와 자리를 만든다. * * @param specs 지표 성질(방향·단위). 열거된 지표만 낸다 — 모르는 이름을 짐작하지 않는다. * @param current 지금 창에서 꺼낸 값들(결측은 `undefined`). * @param inputs 과거 창들에서 꺼낸 값들(가까운 것부터). */ export function summarizeBaseline( specs: Readonly>, current: Partial>, inputs: readonly BaselineSampleInput[] ): MetricBaseline[] { const out: MetricBaseline[] = [] for (const metric of Object.keys(specs) as M[]) { const spec = specs[metric] const samples: BaselineSample[] = inputs.map(s => { const v = s.values[metric] const measured = typeof v === 'number' && Number.isFinite(v) return measured ? { fromMs: s.fromMs, toMs: s.toMs, measured: true, value: v as number } : { fromMs: s.fromMs, toMs: s.toMs, measured: false } }) const values = samples.filter(s => s.measured).map(s => s.value as number) const sorted = [...values].sort((a, b) => a - b) const cur = current[metric] const hasCurrent = typeof cur === 'number' && Number.isFinite(cur) const row: MetricBaseline = { metric, direction: spec.direction, unit: spec.unit, ...(hasCurrent ? { current: cur as number } : {}), samples, requested: inputs.length, measuredCount: values.length, ...(sorted.length ? { min: sorted[0], median: medianOf(sorted), max: sorted[sorted.length - 1] } : {}) } /* 자리를 말할 수 없는 두 경우 — 이유를 밝힌다(들어온 것이 없는 빈 값이 "평범함" 으로 읽힌다). */ if (!hasCurrent) row.noPosition = 'no-current' else if (sorted.length < 2) row.noPosition = 'too-few-samples' else { const value = cur as number const median = medianOf(sorted) as number /* 오름차순에서 몇 번째인가 — 같은 값은 뒤에 놓는다(동률이면 "더 큰 쪽" 으로 세지 않는다). */ row.rank = sorted.filter(v => v < value).length + 1 row.rankOf = sorted.length + 1 const higherIsBetter = spec.direction === 'higher' const beatsAll = higherIsBetter ? value > sorted[sorted.length - 1] : value < sorted[0] const losesToAll = higherIsBetter ? value < sorted[0] : value > sorted[sorted.length - 1] /* 중앙값보다 큰 것이 "좋은" 쪽인가 — 방향을 여기 한 번만 적용한다. */ const aboveMedianIsBetter = higherIsBetter === value > median row.standing = beatsAll ? 'best' : losesToAll ? 'worst' : value === median ? 'typical' : aboveMedianIsBetter ? 'better-than-typical' : 'worse-than-typical' } out.push(row) } return out }