/* * 예측 정확도 진단(gap-analytics) — SPC 두 축(안정성·편향)·정확도·보정의 canonical 계산을 서버에서 단언. * 클라에서 이전한 순수 함수라 DB 무의존, 결정적. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { computeSpc, gapMetrics, learnedCalibration, aggregatePoints } from '../server/service/twin-forecast/gap-analytics.ts' const pt = (error: number, actual = 100, inBand = true) => ({ t: 0, p50: actual - error, min: 0, max: 0, actual, error, inBand, naiveAbsError: Math.abs(error) + 1, modelAbsError: Math.abs(error) }) test('computeSpc — 표본 부족(n<4)은 insufficient', () => { assert.equal(computeSpc([1, 2, 3]).insufficient, true) }) test('computeSpc — 안정적·무편향: 0 주변 교대 오차', () => { const spc = computeSpc([1, -1, 1, -1, 1, -1, 1, -1]) assert.equal(spc.stable, true, '이탈·추세 없음 → 안정') assert.equal(spc.biased, false, '평균 0 → 무편향') }) test('computeSpc — 안정적이지만 편향: 계속 +6 (안정성과 편향은 별개 축)', () => { const spc = computeSpc([6, 6, 6, 6, 6, 6]) assert.equal(spc.stable, true, '오차 폭 일정 → 안정') assert.equal(spc.biased, true, '평균이 0 에서 크게 벗어남 → 편향') assert.equal(spc.biasSign, 'under', 'CL>0 = 실제>예측 = 과소예측') }) test('computeSpc — 급변(3σ 이탈)은 shift', () => { const spc = computeSpc([0.1, -0.1, 0.1, -0.1, 0.1, -0.1, 10]) assert.equal(spc.instability, 'shift') assert.equal(spc.stable, false) }) test('gapMetrics — 정확도=1−WMAPE, 커버리지·FVA', () => { const m = gapMetrics([pt(1, 10, true), pt(-1, 10, false)]) assert.equal(m.accuracy, 90, '오차합2/실제합20=10% → 90%') assert.equal(m.inBandCount, 1) assert.equal(m.total, 2) assert.equal(m.fva, 2, 'Σ(naiveAbs−modelAbs) = (2−1)+(2−1)') assert.equal(m.fvaPct, 50, 'naive 오차 4 → 모델 2 → 50% 감소(예측 가치)') }) test('learnedCalibration — biasShift=평균오차, 편향 제거로 정확도 개선', () => { const pts = [pt(6, 100), pt(6, 100), pt(6, 100)] const learn = learnedCalibration(pts, gapMetrics(pts)) assert.equal(learn.biasShift, 6, '학습된 편향 = 평균 오차') assert.ok(learn.accAfter >= learn.accBefore, '편향 제거 → 정확도 유지·개선') assert.equal(learn.accAfter, 100, '일정 편향을 완전 제거 → 잔차 0 → 100%') }) /* * ★ Nothing to divide by is not 100% (2026-09-24). With every actual at 0 the card read * "100% accurate" beside "0 of 6 in band, mean error −20" — the installed build gave exactly that. */ test('gapMetrics — a window with no actual says the percentages are not measured, and keeps what is', () => { const idle = Array.from({ length: 6 }, () => ({ t: 0, p50: 20, min: 15, max: 25, actual: 0, error: -20, inBand: false, naiveAbsError: 0, modelAbsError: 20 })) const m = gapMetrics(idle) assert.equal(m.accuracy, null, 'WMAPE has no denominator — not 100%') assert.equal(m.fvaPct, null, 'the naive error is 0 — "adds nothing" would be invented') /* Measured in that window, so they stay. */ assert.equal(m.mpe, -20) assert.equal(m.inBandCount, 0) assert.equal(m.total, 6) assert.equal(m.fva, -120) }) test('learnedCalibration — the bias is still learned with no actual; only the percentages are not', () => { const idle = Array.from({ length: 4 }, () => pt(-20, 0, false)) const learn = learnedCalibration(idle, gapMetrics(idle)) assert.equal(learn.biasShift, -20, 'forecasting 20 over a standing line is a bias worth removing') assert.equal(learn.accBefore, null) assert.equal(learn.accAfter, null, 'no denominator was made up (it used to be `|| 1`)') }) /* 같은 격자 위의 점 — vantage 를 명시한다(예전 헬퍼는 t 를 늘 0 으로 두어 시간축을 전혀 시험하지 않았다). */ const at = (t: number, error: number, actual = 100) => ({ ...pt(error, actual), t }) test('aggregatePoints — 공동배치 합산(공장 총량)', () => { const a = [at(10, 2, 30), at(20, 4, 30)] const b = [at(10, 1, 20), at(20, 2, 20)] const { points, droppedVantages } = aggregatePoints([a, b]) assert.equal(points.length, 2) assert.equal(droppedVantages, 0) assert.equal(points[0].actual, 50, '실제 30+20') assert.equal(points[0].t, 10, '합산점의 시각은 그 vantage 다') assert.equal(points[0].error, points[0].actual - points[0].p50, 'error = 실제−예측(합산 후 재계산)') }) test('한 트윈이 못 잰 vantage 는 통째로 뺀다 — 빠진 트윈을 0 으로 세지 않는다', () => { /* * 예전에는 `max` 길이로 돌며 있는 것만 더했다. 그래서 이 경우 t=20 의 공장 총량이 20(b 뿐)으로 * 나왔다 — a 가 그 시각에 아무것도 안 했다는 뜻이 아니라, **못 쟀다**는 뜻인데도. * 공장 합계 곡선이 꼬리에서 아래로 꺾이고, 그 곡선으로 예측 정확도를 판정했다. */ const a = [at(10, 2, 30), null] const b = [at(10, 1, 20), at(20, 2, 20)] const { points, droppedVantages } = aggregatePoints([a as any, b]) assert.equal(points.length, 1, '모두가 값을 가진 vantage 만 남는다') assert.equal(points[0].t, 10) assert.equal(droppedVantages, 1, '뺀 개수를 돌려줘야 화면이 "2점 중 1점" 이라고 말할 수 있다') }) test('길이가 다르면 뒤쪽은 뺀다 — 남은 트윈만의 합을 공장 총량이라 부르지 않는다', () => { const a = [at(10, 2, 30)] const b = [at(10, 1, 20), at(20, 2, 20)] const { points, droppedVantages } = aggregatePoints([a, b]) assert.equal(points.length, 1) assert.equal(droppedVantages, 1) })