/* * **원본이 말한 적 없는 것** — 미해결 참조를 세는 한 자리. 순수(import 없음). * * ── 무엇을 답하나 ─────────────────────────────────────────────────────────── * 원천이 우리 모델에 없는 id 를 보내면 어떻게 되나. 유입 문(`ingestCanonicalRecords`)은 **거부하지 * 않는다** — 실측으로 확인했다(2026-08-20): * * 모르는 로케이션 「오타-99」 → 통과 * 모르는 bizStep 「PUTAWAY_X17」 → 통과 * 모르는 계량기 「MMXU-오타-99」 → 통과 * * 거부하지 않는 것이 옳다. 원천이 모델보다 먼저 새 로케이션을 말하는 것은 정상이고(구조 갱신은 늦게 온다), * 거부하면 그 선행 계측을 잃는다. 그래서 커널은 **흡수하고 표시한다** — 마스터에 없던 로케이션은 * `origin: 'observed'` 로 승격되어 스냅샷에 실린다(`ObservedReducer.touchLocation`). * * ── 왜 이 파일이 있나 ─────────────────────────────────────────────────────── * 그 표시를 읽는 곳이 **모델 인스펙션 화면 하나**였다. 그런데 이 사실을 필요로 하는 사람은 「이 트윈이 * 현장과 맞춰지고 있나」를 보는 사람이다 — 원천이 우리가 모르는 로케이션만 보내고 있으면 트윈은 도는데 * 스키매틱은 비어 있고, 그것이 우리가 「빈 공장」이라 불러 온 상태다. * * 도출을 두 곳에 적으면 두 화면이 다른 수를 말하게 된다. 그래서 여기 한 벌만 둔다. * * ── 판정을 바꾸지 않는다 ──────────────────────────────────────────────────── * 동기화 판정(`syncVerdictOf`)은 **피드**를 말한다: 오고 있나, 통과하나. 이 갭은 **모델**을 말한다: * 원천이 아는 것을 우리가 선언했나. 둘을 한 판정에 섞으면 「유입은 정상인데 모델이 뒤처졌다」와 * 「유입이 막혔다」가 같은 색으로 보이고, 처방이 완전히 다르다. */ /** 커널 스냅샷에서 우리가 읽는 것만. 나머지 필드는 알 필요가 없다. */ export interface ModelGapSource { locations?: { id?: string; origin?: string }[] equipment?: { id?: string; origin?: string }[] persons?: { id?: string; origin?: string }[] /** 커널이 담을 줄 몰라 세어 둔 사건 — 종류별 개수와 처음·마지막 시각. */ unhandled?: { eventType: string; count: number; firstAtMs?: number; lastAtMs?: number }[] /** * EMS 상태 — **`energy` 안에 있다.** * * `unknownEquipment` 를 루트에서 읽고 있었다(2026-08-20에 잡음). 커널은 그것을 `EnergyState` 안에 * 담으므로, 루트에서 읽으면 **영원히 `null`** 이다 — 오류도 없이 「에너지 트윈이 아니다」로 읽힌다. * 나만 읽는 필드를 잘못된 경로에서 읽으면 이렇게 오류 없이 사라진다. * * 계량 지점의 `origin` 은 **없을 수 있다**(옛 스냅샷에서 이어받은 지점). 그때는 「선언되었다」가 * 아니라 **모른다**다. */ energy?: { points?: { id?: string; origin?: string }[] /** 모델이 모르는 설비가 에너지 상태를 보내 온 횟수. 0 이면 커널이 이 칸을 만들지 않는다. */ unknownEquipment?: number } } /** * 화면에 내는 id 수 상한. * * 오타 하나를 찾는 데 스무 개면 충분하고, 원천이 수천 개를 보내는 경우(모델을 아예 안 맞춘 경우)에 * 목록으로 화면을 채울 이유가 없다. **넘친 수는 함께 낸다** — 알리지 않고 자르지 않는다. */ export const GAP_ID_LIMIT = 20 export interface ModelGapAxis { /** 관측으로만 알게 된 id 수. */ count: number /** 그중 화면에 싣는 것(최대 `GAP_ID_LIMIT`). */ ids: string[] /** 상한에서 밀려난 수. 0 이면 전부 실었다. */ more: number /** * 출처를 **모르는** 수 — 표시가 없는 항목. * * 「선언되었다」로 세지 않는다. 계량기는 이 표시가 늦게 생겼으므로 옛 스냅샷에서 이어받은 지점에는 * 칸이 없다(`undefined`). 그것을 마스터로 세면 오타를 놓치고, 관측으로 세면 정상을 문제로 만든다. * 둘 다 틀리므로 **모른다고 센다.** */ unknown: number } export interface ModelGapView { locations: ModelGapAxis equipment: ModelGapAxis persons: ModelGapAxis /** * EMS 계량 지점. * * ── 이 축이 가장 무겁다 ──────────────────────────────────────────────────── * 모르는 `meterId` 는 알리지 않고 새 계량기가 되고 그 kW 가 그 현장의 **수요 구간·피크·요금 판정**에 * 더해진다. 오타 하나면 진짜 계량기는 멈추고 유령이 누적된다 — 숫자는 그럴듯하고 틀린다. 자리 오타는 * 스키매틱이 비는 것으로 드러나지만, 이건 그럴듯한 숫자로 나온다. * * 커널이 `origin` 을 **표본마다 다시 맞춘다** — 선언의 정본은 모델이고 모델은 채택으로 늘어나므로, * 한 번 찍고 두면 나중에 선언된 계량기가 영원히 `observed` 로 남는다. 그래서 이 값은 「지금 모델에 * 있는가」의 답이고, 「한 번 오타였다」의 답이 아니다. */ meters: ModelGapAxis /** 세 축의 합 — 화면이 「있나 없나」를 한 번에 묻기 위한 값. */ total: number /** 커널이 담을 줄 모른 사건들(종류별). 참조 문제와 다른 사실이므로 따로 낸다. */ unhandled: { eventType: string; count: number; lastAt: string | null }[] /** EMS: 모델이 모르는 설비의 에너지 보고 횟수. 없으면 `null`(0 과 구별한다). */ unknownEquipmentReports: number | null /** * 아직 덮지 못하는 축 — **모르는 것을 모른다고 말한다.** * * 계량기는 커널 0.7.39 부터 덮인다(그 전에는 `origin` 표시가 없어 오타를 찾아 줄 수 없었다). * 비어 있는 것이 정상이고, 새 축이 표시 없이 들어오면 여기에 이름을 넣는다 — 화면이 「다 찾아 * 준다」고 말하지 않게. */ notCovered: string[] } /** 계량기 축이 아직 덮이지 않는 이유 — 화면이 그대로 말할 수 있게 이름을 여기 둔다. */ export const NOT_COVERED_METERS = 'meters' function axisOf(rows: { id?: string; origin?: string }[] | undefined): ModelGapAxis { const ids: string[] = [] let count = 0 let unknown = 0 for (const r of rows ?? []) { if (r?.origin === 'observed') { count++ if (ids.length < GAP_ID_LIMIT) ids.push(String(r.id ?? '')) } else if (r?.origin !== 'master') { /* 표시가 없다 — 마스터로도 관측으로도 세지 않는다. 모르는 것은 모른다고 센다. */ unknown++ } } return { count, ids, more: Math.max(0, count - ids.length), unknown } } /** * 스냅샷 → 모델 갭. * * 스냅샷이 없으면 `null` 이다 — **0 이 아니다.** 트윈이 돌지 않으면 비교할 상대가 없고, 「없다」와 * 「세어 보지 못했다」는 다른 사실이다(모델 인스펙션이 같은 규율을 쓴다). */ export function modelGapOf(snapshot: ModelGapSource | null | undefined): ModelGapView | null { if (!snapshot) return null const locations = axisOf(snapshot.locations) const equipment = axisOf(snapshot.equipment) const persons = axisOf(snapshot.persons) const meters = axisOf(snapshot.energy?.points) return { locations, equipment, persons, meters, total: locations.count + equipment.count + persons.count + meters.count, unhandled: (snapshot.unhandled ?? []).map(u => ({ eventType: u.eventType, count: u.count, /* 마지막 시각이 멈춘 종류는 이미 고쳐진 것이다. 없으면 지어내지 않는다. */ lastAt: Number.isFinite(u.lastAtMs) ? new Date(u.lastAtMs as number).toISOString() : null })), /* * 커널은 0 이면 이 칸을 만들지 않는다. 그래서 「없음」은 두 뜻이다 — 에너지 트윈이 아니거나, 세었고 * 0 이었다. 둘을 가르려면 `energy` 자체가 있는지를 본다: 있으면 세었고 0 이다. */ unknownEquipmentReports: typeof snapshot.energy?.unknownEquipment === 'number' ? snapshot.energy.unknownEquipment : snapshot.energy ? 0 : null, /* * 이제 계량기도 덮는다(커널 0.7.39 가 `MeterPointState.origin` 을 낸다). 덮지 못하는 축이 생기면 * 다시 여기에 이름을 넣는다 — 화면이 「다 찾아 준다」고 말하지 않게. */ notCovered: [] } }