/* * **원천이 축마다 언제·얼마나 자주 내놓나** — 유입 건강이 이 선언으로 견준다. * * ── 왜 필요한가 (2026-09-04 실측) ────────────────────────────────────────── * 유입 건강이 **한 자로 전부를 쟀다.** 10분 안에 아무것도 오지 않으면 「유입 없음」이다. 그런데 * 원천마다 시간의 결이 다르다. * * 태양광 발전 일몰에 멈춘다 — 두 사이트가 같은 분 19:25 에 함께 * 부하 계측 밤에도 온다 · 15분마다 * 인증서 가격 화·목만 거래된다 · 그 날 하루 한 번 * MES · 주방 현장 근무시간 밖에는 사실이 안 난다 · 훅이라 「보내면 온다」 * * 그래서 **정상인데 매일 밤 절반이 빨갛게 났다.** 거짓 빨강이 쌓이면 진짜 빨강도 같이 안 읽힌다. * * ── 두 축이고 서로 다르다 ─────────────────────────────────────────────────── * window 지금이 그 축의 활동 시각인가 → 아니면 조용한 것이 정상 * expectedSilenceMs 활동 시각 안에서 이만큼 조용해도 정상 → 넘기면 유입 없음 * * 주기를 넘겼어도 활동 시각 밖이면 정상이다. 한 값으로 접을 수 없다 — 인증서 가격이 그 증거다. * 「매일 와야 한다」로 재면 주 5일 중 3일이 빨갛고, 「주 2회」로 재면 이어 쓴 날을 못 본다. * * ── 커널이 일출·일몰을 계산하지 않는다 ───────────────────────────────────── * 굴절·고위도·박명 정의가 다 선택이고, 우리가 정하면 그 순간 방언이다. 그리고 계약이 같은 문제에 * 이미 답해 뒀다(§`TwinModelDef.utcOffsetMinutes`): 「긴 지평선의 정확한 답은 호스트가 표준대로 * **절대 구간**을 계산해 넣는 것이다」. * * 그래서 원천은 `windowKind: 'daylight'` 로 **종류만** 선언하고, 좌표를 아는 호스트가 날마다 * 구체적인 구간을 채운다. **고정 시각을 선언에 박지 않는다** — 일몰 19:25 는 오늘 잰 값이고 두 달 * 뒤에는 틀리다. 그것을 박으면 겨울마다 거짓 빨강이 다시 난다. * * ── 「주기 없음」과 「모른다」는 다르다 ────────────────────────────────────── * MES 는 훅으로 밀어 준다 — 몇 분마다가 아니라 「보내면 온다」다. 보낼 것이 없으면 안 보내는 것이 * 정상이고, 그것을 침묵으로 판정하면 안 된다. * * 선언하지 않은 축은 **지금까지처럼** 창 하나로 견준다(옛 거동). 「모른다」를 「안 와도 된다」로 * 접지 않는다 — 그러면 정말 끊긴 원천이 통째로 조용해진다. */ import type { WorkCalendarEntry } from '@operato/ops-contract' /** * 창의 종류 — **호스트가 채워야 하는 것**만 이름이 있다. * * 지금은 하나다. 늘리기 전에 「호스트가 그것을 계산할 근거를 갖고 있나」를 먼저 물어야 한다 — * 좌표 없이 `daylight` 를 선언하면 호스트가 채울 것이 없고, 그러면 그 축은 창이 없는 것과 같다. */ export type LiveWindowKind = 'daylight' /** 한 축의 시간성 — 어느 사실이 언제·얼마나 자주 오나. */ export interface LiveCadence { /** * 어느 축인가 — **`OP_EVENT` 의 값 그대로**(`equipment.status` · `quality.output` …). * * 새 낱말을 만들지 않는다. 만들면 커넥터가 부르는 이름과 장부가 세는 이름이 갈리고, 그때 * 선언이 조용히 아무 축에도 안 붙는다. */ axis: string /** * 활동 시각 안에서 **이만큼 조용해도 정상**(ms). 없으면 창 하나로 견준다. * * 15분마다 긷는 원천은 이 값을 선언해야 한다 — 안 하면 긷는 사이마다 「유입 없음」으로 보인다. */ expectedSilenceMs?: number /** * 이 축이 **활동하는 창** — 근무 달력 어휘 그대로(요일·시간대·휴일). * * 계약이 이미 그 셋을 표현한다. 새 모양을 만들지 않는 이유는 판정하는 함수가 이미 하나 * 있기 때문이다(`inWorkCalendar`) — 두 벌이면 화면과 커널이 다른 답을 낸다. */ window?: readonly WorkCalendarEntry[] /** * 창을 **호스트가 채우는 종류** — 좌표·표준으로 계산해야 하는 것. * * `window` 와 함께 선언하면 호스트가 채운 것이 이긴다(계산이 선언보다 그 날에 맞다). */ windowKind?: LiveWindowKind /** * **보내면 온다** — 이 축은 침묵으로 판정하지 않는다(훅·구독). * * 「주기가 없다」와 「모른다」를 가르는 값이다. 훅은 보낼 것이 없으면 안 보내고, 그것이 정상이다. */ pushed?: boolean } /** 그 축의 선언을 찾는다 — 없으면 `undefined`(옛 거동으로 견준다). */ export function cadenceOf(cadences: readonly LiveCadence[] | undefined, axis: string): LiveCadence | undefined { return cadences?.find(c => c.axis === axis) } /** * 유입 건강이 이 축을 어떻게 견줄 것인가 — **판정에 넘길 두 값으로 옮긴다.** * * `syncVerdictOf` 가 받는 모양(§`ingest-health.ts`)에 맞춘다. 그 함수는 순수하고 달력을 보지 * 않으므로, 창 판정은 여기서 끝내고 참·거짓만 넘긴다 — `capabilityOf` 가 `requiredTests` 를 받는 * 것과 같은 규율이다. * * ── 「보내면 온다」는 침묵으로 판정하지 않는다 ────────────────────────────── * `pushed` 면 `quietNow` 를 늘 참으로 낸다. 그러면 침묵이 「유입 없음」이 되지 않는다. **다른 * 갈래는 그대로 산다** — 버려지는 중·결선 없음은 몇 시든 문제이고 그것들이 이 값보다 앞선다. * * ── 모르면 아무것도 답하지 않는다 ────────────────────────────────────────── * 선언이 없으면 둘 다 비운다. 그때 판정은 지금까지처럼 창 하나로 견준다 — 「모른다」를 「안 와도 * 된다」로 접으면 정말 끊긴 원천이 통째로 조용해진다. */ export function ingestExpectation( cadence: LiveCadence | undefined, ctx: { nowMs: number; utcOffsetMinutes?: number; inWindow?: (window: readonly WorkCalendarEntry[]) => boolean } ): { expectedSilenceMs?: number; quietNow?: boolean } { if (!cadence) return {} const out: { expectedSilenceMs?: number; quietNow?: boolean } = {} if (typeof cadence.expectedSilenceMs === 'number' && cadence.expectedSilenceMs > 0) { out.expectedSilenceMs = cadence.expectedSilenceMs } if (cadence.pushed) { /* 보낼 것이 없으면 안 보내는 것이 정상이다 — 침묵을 근거로 삼지 않는다. */ out.quietNow = true return out } /* * 창이 있으면 그것으로 판정한다. **판정할 수단을 안 받았으면 답하지 않는다** — 부르는 쪽이 * 달력 판정 함수를 주지 않았는데 우리가 「활동 시각이다」로 단언하면 그것이 지어낸 값이다. */ if (cadence.window?.length && ctx.inWindow) out.quietNow = !ctx.inWindow(cadence.window) return out }