/* * 모델 근거 — **배운 모델에만 보정을 적용하기 위한 비교.** * * 예측 출력 보정(P3.2 `twinLearnCalibration`)은 "우리 모델이 늘 이만큼 적게 본다" 를 배워 결과를 * 밀어 준다. 그 학습은 **그때의 모델**을 전제로 한다. 그런데 소요시간 추정기(실측·거리계산)와 공정 * 명세가 들어오면서 모델 자체가 바뀔 수 있게 됐다 — 모델이 이미 그 편향을 잃었는데 보정이 또 밀어 * 주면 **이중 보정**이 되어 정확도가 나빠진다. 학습 시점의 accAfter 도 더는 유효하지 않다. * * 그래서 보정 기록에 **그때 모델의 근거**를 함께 남기고, 적용 전에 지금 근거와 비교한다. 달라졌으면 * 적용하지 않고 **그 사실을 응답에 싣는다**(오류 없이 사라지면 사용자는 학습이 왜 안 듣는지 모른다). * * 비교는 **상대 허용오차**로 한다(양자화 지문이 아니라). 실측 p50 은 새 기록이 쌓일 때마다 1~2초씩 * 흔들리는데, 값을 버킷으로 합쳐 비교하면 버킷 경계에 걸린 값이 1초 드리프트로도 달라져 버린다 — * "작은 드리프트로 학습을 무효화하지 않는다" 는 목적을 경계에서 못 지킨다. 비율로 비교하면 어디서든 * 같은 규칙이 성립한다: 10% 이내는 같은 모델, 30초→47초(57%)는 다른 모델. * * 재료는 호스트가 아는 것들이다(커널 API 에 의존하지 않아 발행 상태와 무관하게 동작한다): * ① 실측으로 배운 작업 종류별 소요 ② 자원 종류별 속도 ③ 선언된 공정 소요. */ /** * 허용오차의 **기본값** — 선언이 아무 말도 없을 때만 쓴다. * * 10% 는 근거에서 나온 수가 아니라 **판단값**이다(그 사실을 적어 두는 것이 이 주석의 목적이다). 그리고 * 단일 잣대로 쓰면 틀린다 — 우리 트레일러 템플릿이 그것을 보여 준다: 용접은 변동폭을 `PT55M~PT1H35M` * (선언 소요 `PT1H10M` 기준 −21%~+36%)로 **스스로 선언한다.** 10% 관문은 그 공정의 **정상 변동조차** * "다른 모델" 로 판정해 보정을 끊는다. 반대로 1% 변동도 치명적인 공정에는 10% 가 너무 느슨하다. * * 그래서 허용오차는 **그 공정이 선언한 변동폭에서 나온다**(§toleranceOf). 선언이 있으면 선언이 이기고, * 없을 때만 이 값으로 떨어진다 — 도메인 설정을 새로 만들지 않는다(현장이 이미 말한 것을 읽는다). */ export const BASIS_TOLERANCE = 0.1 export interface ModelBasis { /** 작업 종류 → 실측 소요(ms). `buildMeasuredEstimator().learned`. */ measured?: Record /** 자원 종류 → 속도(m/s). `buildTravelEstimator().speedsByKind`. */ speeds?: Record /** * 선언된 공정 — key + ISO 8601 원문(문자열은 그대로 비교한다: 선언이 바뀌면 다른 모델). * * **변동폭도 지문에 든다.** 변동폭이 허용오차를 정하므로(§toleranceOf), 그것이 바뀌었는데 "같은 모델" * 이라고 하면 판정의 잣대가 알리지 않고 바뀐 채로 학습이 이어진다. */ declared?: DeclaredOperation[] } /** 선언된 공정 하나 — 소요와 변동폭(모델의 `operations` 원형에서 필요한 만큼만). */ export interface DeclaredOperation { key: string duration?: string variability?: { distribution?: string; min?: string; max?: string; mode?: string } } /** ISO 8601 기간 → 밀리초. 읽을 수 없으면 `undefined`(짐작해 고치지 않는다). */ function isoMs(iso?: string): number | undefined { if (!iso) return undefined const m = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:([\d.]+)S)?)?$/.exec(iso) if (!m || !m.slice(1).some(Boolean)) return undefined return ((Number(m[1] ?? 0) * 24 + Number(m[2] ?? 0)) * 60 + Number(m[3] ?? 0)) * 60_000 + Number(m[4] ?? 0) * 1000 } /** * 작업 종류별 **허용오차** — 그 공정이 선언한 변동폭에서 나온다. * * 실측 p50 은 정상 변동 안에서 흔들린다. 그 흔들림을 "다른 모델" 로 읽으면 보정이 자꾸 끊기고, 사용자는 * 학습이 왜 안 듣는지 모른다. 얼마나 흔들리는 것이 정상인지는 **현장이 이미 선언했다**(`variability`). * * 반폭을 쓴다(`(max − min) / 2 / 소요`) — 중앙에서 어느 쪽으로든 그만큼은 정상이라는 뜻이다. * 선언이 없거나 읽을 수 없으면 기본값으로 떨어진다(짐작해 만들지 않는다). */ export function toleranceOf(declared?: DeclaredOperation[]): Record { const out: Record = {} for (const o of declared ?? []) { if (!o?.key) continue const base = isoMs(o.duration) const min = isoMs(o.variability?.min) const max = isoMs(o.variability?.max) if (!base || min === undefined || max === undefined || !(max > min)) continue const half = (max - min) / 2 / base if (Number.isFinite(half) && half > 0) out[o.key] = half } return out } /** 뜻 있는 수만 남긴다 — 0·음수·NaN 은 모델이 아니다. */ function clean(map?: Record): Record { const out: Record = {} for (const [k, v] of Object.entries(map ?? {})) if (Number.isFinite(v) && v > 0) out[k] = v return out } function declaredMap(list?: DeclaredOperation[]): Record { const out: Record = {} for (const o of list ?? []) { if (!o?.key) continue const v = o.variability /* 변동폭까지 한 문장으로 — 순서를 고정해 같은 선언이 같은 문자열이 되게 한다. */ const spread = v ? `~${v.distribution ?? '?'}:${v.min ?? ''}..${v.max ?? ''}@${v.mode ?? ''}` : '' if (o.duration || spread) out[o.key] = `${o.duration ?? ''}${spread}` } return out } /** 두 수치 지도가 실질적으로 다른가 — 키 집합이 다르거나, 값이 허용오차를 넘어 다르면 다르다. */ function numericChanged(a: Record, b: Record, tolOf: (key: string) => number): boolean { const keys = new Set([...Object.keys(a), ...Object.keys(b)]) for (const k of keys) { const x = a[k] const y = b[k] if (x === undefined || y === undefined) return true // 새로 배운 종류·사라진 종류 = 다른 모델 const base = Math.max(Math.abs(x), Math.abs(y)) if (base > 0 && Math.abs(x - y) / base > tolOf(k)) return true } return false } /** * 모델이 달라졌는가 — 보정을 적용하기 전의 관문. * 한쪽 근거를 모르면 **판정하지 않는다**(false) — 모르는 것을 근거로 학습을 끊지 않는다. */ export function modelBasisChanged(before?: ModelBasis, after?: ModelBasis, tol: number = BASIS_TOLERANCE): boolean { if (!before || !after) return false /* * 허용오차는 **선언이 정한다** — 배운 시점과 지금의 선언이 다를 수 있으므로 둘 중 **넓은 쪽**을 쓴다. * 좁은 쪽을 쓰면 선언을 넓힌 직후에 정상 변동이 "다른 모델" 로 잡힌다(그 변경 자체는 아래 선언 비교가 * 이미 잡으므로, 여기서 또 잡을 필요가 없다). */ const tolBefore = toleranceOf(before.declared) const tolAfter = toleranceOf(after.declared) const tolOf = (key: string): number => Math.max(tolBefore[key] ?? tol, tolAfter[key] ?? tol) if (numericChanged(clean(before.measured), clean(after.measured), tolOf)) return true /* 속도는 자원 종류별이고 선언된 변동폭이 없다 — 기본값이 그대로 쓰인다. */ if (numericChanged(clean(before.speeds), clean(after.speeds), () => tol)) return true const da = declaredMap(before.declared) const db = declaredMap(after.declared) const keys = new Set([...Object.keys(da), ...Object.keys(db)]) for (const k of keys) if (da[k] !== db[k]) return true return false } /** * 사람이 읽는 근거 요약 — **로그·진단용**(비교의 근거가 아니다). * 지문으로 같음을 판정하지 않는 이유는 위 주석 참조. 두 근거가 왜 달라졌는지 눈으로 보려면 이것을 찍는다. */ export function describeModelBasis(basis?: ModelBasis): string { if (!basis) return 'unknown' const seg: string[] = [] const m = Object.entries(clean(basis.measured)) .map(([k, v]) => `${k}=${Math.round(v / 1000)}s`) .sort() if (m.length) seg.push(`measured(${m.join(',')})`) const s = Object.entries(clean(basis.speeds)) .map(([k, v]) => `${k}=${Math.round(v * 10) / 10}m/s`) .sort() if (s.length) seg.push(`speed(${s.join(',')})`) const d = Object.entries(declaredMap(basis.declared)) .map(([k, v]) => `${k}=${v}`) .sort() if (d.length) seg.push(`declared(${d.join(',')})`) return seg.length ? seg.join(' ') : 'kernel defaults only' }