/* * 모델 근거 비교 — **배운 모델에만 보정을 적용하기 위한 관문.** * * 배경: 예측 출력 보정(P3.2)은 "그때 모델이 이만큼 치우쳐 있다" 를 배운다. 소요시간 추정기(실측·거리) * 와 공정 명세가 들어오면서 모델 자체가 바뀔 수 있게 됐고, 그러면 옛 편향을 그대로 밀어 주는 **이중 * 보정**이 생긴다. 근거가 달라졌으면 적용하지 않는다(그리고 그 사실을 응답에 싣는다). * * 비교를 **상대 허용오차**로 하는 이유: 양자화 지문은 버킷 경계에 걸린 값이 1초 드리프트로도 달라져 * "작은 드리프트로 학습을 무효화하지 않는다" 는 목적을 경계에서 못 지킨다. 비율은 어디서든 같다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { BASIS_TOLERANCE, describeModelBasis, modelBasisChanged, toleranceOf } from '../server/engine/model-basis.ts' test('같은 근거면 바뀌지 않았다 — 키 순서에 흔들리지 않는다', () => { assert.equal( modelBasisChanged({ measured: { pick: 22_000, putaway: 47_000 } }, { measured: { putaway: 47_000, pick: 22_000 } }), false ) }) test('작은 드리프트는 같은 모델 — 경계에서도 성립한다(버킷이 아니라 비율)', () => { /* 47.0 → 47.9 (약 1.9%): 5초 버킷이면 45↔50 으로 갈렸을 값이다. */ assert.equal(modelBasisChanged({ measured: { putaway: 47_000 } }, { measured: { putaway: 47_900 } }), false) /* 허용오차 경계 바로 아래·위 */ assert.equal(modelBasisChanged({ measured: { p: 100_000 } }, { measured: { p: 105_000 } }), false, '5%') assert.equal(modelBasisChanged({ measured: { p: 100_000 } }, { measured: { p: 130_000 } }), true, '30%') }) test('실측이 실제로 달라지면 다른 모델 — 30초 작업이 47초가 되면 옛 보정은 무효', () => { assert.equal(modelBasisChanged({ measured: { putaway: 30_000 } }, { measured: { putaway: 47_000 } }), true) }) test('새로 배운 종류·사라진 종류는 다른 모델 — 값이 없다가 생긴 것도 변화다', () => { assert.equal(modelBasisChanged({ measured: { pick: 20_000 } }, { measured: { pick: 20_000, putaway: 47_000 } }), true) assert.equal(modelBasisChanged({ measured: { pick: 20_000, putaway: 47_000 } }, { measured: { pick: 20_000 } }), true) }) test('속도가 바뀌면 다른 모델 — 이동시간 모델이 바뀐 것이다', () => { assert.equal(modelBasisChanged({ speeds: { forklift: 2 } }, { speeds: { forklift: 4 } }), true) assert.equal(modelBasisChanged({ speeds: { forklift: 2 } }, { speeds: { forklift: 2.05 } }), false) }) test('선언 명세가 바뀌면 다른 모델 — 표기 원문을 그대로 비교한다', () => { assert.equal( modelBasisChanged({ declared: [{ key: 'cut', duration: 'PT25M' }] }, { declared: [{ key: 'cut', duration: 'PT30M' }] }), true ) assert.equal( modelBasisChanged({ declared: [{ key: 'cut', duration: 'PT25M' }] }, { declared: [{ key: 'cut', duration: 'PT25M' }] }), false ) }) test('소요 없는 선언은 모델을 바꾸지 않는다', () => { assert.equal(modelBasisChanged({ declared: [{ key: 'cut' }] }, { declared: [] }), false) }) test('뜻 없는 값은 근거가 아니다 — 0·음수·NaN 은 무시한다', () => { assert.equal(modelBasisChanged({ measured: { pick: 0 } }, {}), false) assert.equal(modelBasisChanged({ measured: { pick: NaN } }, { speeds: { f: -1 } }), false) }) test('한쪽 근거를 모르면 판정하지 않는다 — 모르는 것을 근거로 학습을 끊지 않는다', () => { assert.equal(modelBasisChanged(undefined, { measured: { pick: 20_000 } }), false) assert.equal(modelBasisChanged({ measured: { pick: 20_000 } }, undefined), false) }) test('세 재료가 섞이지 않는다 — 같은 이름이 다른 재료에 있으면 다른 모델', () => { assert.equal(modelBasisChanged({ measured: { x: 10_000 } }, { speeds: { x: 10_000 } }), true) }) test('허용오차는 조절 가능하고 기본값이 명시돼 있다', () => { assert.equal(BASIS_TOLERANCE, 0.1) assert.equal(modelBasisChanged({ measured: { p: 100 } }, { measured: { p: 130 } }, 0.5), false, '느슨하게 주면 같은 모델') }) test('사람이 읽는 요약을 낸다 — 로그에서 왜 달라졌는지 눈으로 본다', () => { const d = describeModelBasis({ measured: { putaway: 47_000 }, speeds: { forklift: 2 }, declared: [{ key: 'cut', duration: 'PT25M' }] }) assert.match(d, /measured\(putaway=47s\)/) assert.match(d, /speed\(forklift=2m\/s\)/) assert.match(d, /declared\(cut=PT25M\)/) assert.equal(describeModelBasis({}), 'kernel defaults only') assert.equal(describeModelBasis(undefined), 'unknown') }) /* * ★ 허용오차는 **그 공정이 선언한 변동폭에서 나온다.** * * 단일 10% 잣대는 두 방향으로 틀렸다. 우리 트레일러 템플릿의 용접은 변동폭을 `PT55M~PT1H35M` * (선언 소요 `PT1H10M` 기준 −21%~+36%)로 스스로 선언한다 — 10% 관문은 그 공정의 **정상 변동조차** * "다른 모델" 로 판정해 보정을 끊는다. 반대로 1% 변동도 치명적인 공정에는 10% 가 너무 느슨하다. * * 도메인 설정을 새로 만들지 않는다: 얼마나 흔들리는 것이 정상인지는 **현장이 이미 선언했다.** */ const weld = (durationMs: number) => ({ measured: { weld: durationMs }, declared: [{ key: 'weld', duration: 'PT1H10M', variability: { distribution: 'triangular', min: 'PT55M', max: 'PT1H35M', mode: 'PT1H5M' } }] }) test('선언된 변동폭 안의 흔들림은 같은 모델이다 — 10% 잣대는 정상 변동을 끊었다', () => { const base = 70 * 60_000 // PT1H10M /* 반폭 = (95분 − 55분)/2 / 70분 ≈ 28.6%. 20% 흔들림은 그 안이다 — 예전 잣대(10%)로는 끊겼다. */ assert.equal(modelBasisChanged(weld(base), weld(base * 1.2)), false, '정상 변동으로 학습이 무효화되면 안 된다') /* 변동폭이 실제로 허용오차로 읽히는지 — 반폭 ≈ 28.6%. 이 값이 안 나오면 위 단언이 우연히 통과한다. */ const tol = toleranceOf(weld(base).declared).weld assert.ok(tol > 0.28 && tol < 0.29, `선언에서 읽은 허용오차가 ${tol} 다`) }) test('선언된 변동폭을 넘으면 다른 모델이다 — 넓혔다고 아무 변화나 삼키지 않는다', () => { const base = 70 * 60_000 assert.equal(modelBasisChanged(weld(base), weld(base * 1.6)), true, '60% 변화는 정상 변동이 아니다') }) test('정밀 공정은 더 좁게 판정한다 — 1% 변동이 치명적인 현장이 있다', () => { const precise = (ms: number) => ({ measured: { grind: ms }, declared: [{ key: 'grind', duration: 'PT10M', variability: { distribution: 'uniform', min: 'PT9M54S', max: 'PT10M6S' } }] }) const base = 10 * 60_000 /* 반폭 = 6초/10분 = 1%. 5% 흔들림은 이 공정에서 다른 모델이다 — 단일 10% 잣대는 삼켰다. */ assert.equal(modelBasisChanged(precise(base), precise(base * 1.05)), true, '느슨한 잣대가 정밀 공정의 변화를 삼켰다') assert.equal(modelBasisChanged(precise(base), precise(base * 1.005)), false, '선언한 변동폭 안은 같은 모델이다') }) test('선언이 침묵하면 기본값으로 떨어진다 — 없는 것을 지어내지 않는다', () => { const quiet = (ms: number) => ({ measured: { pack: ms }, declared: [{ key: 'pack', duration: 'PT5M' }] }) const base = 5 * 60_000 assert.equal(modelBasisChanged(quiet(base), quiet(base * 1.05)), false, '기본 10% 안') assert.equal(modelBasisChanged(quiet(base), quiet(base * 1.3)), true, '기본 10% 밖') }) test('변동폭이 바뀌면 다른 모델이다 — 판정의 잣대가 알리지 않고 바뀌지 않게', () => { const a = weld(70 * 60_000) const b = { measured: { weld: 70 * 60_000 }, declared: [{ key: 'weld', duration: 'PT1H10M', variability: { distribution: 'triangular', min: 'PT30M', max: 'PT3H', mode: 'PT1H5M' } }] } assert.equal(modelBasisChanged(a, b), true, '변동폭 선언이 바뀌었는데 같은 모델이라고 하면 잣대가 오류 없이 바뀐다') })