/* * 단계 판정 — **가벼운 성공을 최종 성공으로 잇는 길을, 사람의 체크가 아니라 증거로 센다.** * * ── 왜 이 모듈이 있나 ─────────────────────────────────────────────────────── * 트윈 도입은 「구조 → 라이브 → 예측 → 폐루프」로 간다. 그런데 이 진행을 **체크리스트**로 관리하면 * 사람이 체크하는 순간 참이 된다. 실제로 가장 자주 건너뛰어지는 자리가 구조 다음이다 — 구조가 서면 * 화면이 그럴듯해지므로 「실시간」이라고 말해 버리고, 되풀어 읽는 스냅샷이 그 말을 뒷받침하지 못한다. * * 그래서 단계를 **판정식**으로 둔다. 각 단계는 시스템이 이미 내놓는 사실로만 판정되고, 선언으로 * 위조할 수 없다. 특히: * * · **반출 관측**이 라이브의 판별식이다. 스냅샷을 되풀어 읽는 방식은 **원리적으로** 반출을 낼 수 없다 * (사라진 것은 그냥 없어질 뿐이라 `OBSERVE` 만 남는다). 그러므로 「반출을 한 번이라도 계산했는가」는 * 진짜 delta 의 증거이고, 폴링을 실시간이라 부르는 것을 막는다. * · **선언 적재량**이 예측의 판별식이다. 커널 기본값으로 돌아간 예측도 숫자를 내므로, 화면만 보면 * 구별되지 않는다. 그 숫자가 무엇 위에 섰는지는 `specCoverage` 만 안다. * * ── 이 모듈의 정수: 「모른다」를 「아니다」로도 「그렇다」로도 바꾸지 않는다 ────────────── * 계측되지 않은 신호는 `null` 로 온다. `null` 은 0 이 아니다. 계측되지 않았으면 그 단계는 **달성이 * 아니고**, 그 사실을 `unmeasured` 로 말한다 — 신호를 배선하지 않은 채 단계를 주장할 길을 남기지 * 않는다. (0 으로 대체하면 「반출 0건」과 「반출을 세지 않았다」가 같아진다.) * * 순수 함수다(프레임워크·DB 무의존) — 증거 수집은 호출자 몫이다. */ export type StageId = 'structure' | 'live' | 'forecast' | 'closed-loop' /** 판정에 쓰는 사실. `null` = **계측되지 않았다**(0 이 아니다). */ export interface StageEvidence { /* ── S1 구조 ── */ /** 이 트윈이 든 자리 수. */ locationCount: number /** 자리에 놓인 물품 수(비직렬 수량 물품 포함 — 식품·화학은 개수가 아니라 총량으로 온다). */ itemCount: number /** * 마스터를 옮기며 **못 옮긴 값의 수**. `null` = 세지 않았다. * * 0 이면 통과이고 n 이어도 통과다 — 이 판정이 요구하는 것은 「없다」가 아니라 **「말할 수 있다」**다. * 못 옮긴 값을 세지 않은 구조 인제스트는 도크를 랙으로 바꿔 놓고도 성공이라 답하면서 아무 일도 하지 않는다. */ masterWarnings: number | null /** * 이 축이 이 트윈에 **해당하나**. 레퍼런스 없이 템플릿으로 세운 트윈에는 옮겨 온 마스터가 없으므로 * 「못 옮긴 값」을 물을 대상이 아니다 — 그걸 미측정으로 처리하면 영원히 배선을 기다린다. */ masterWarningsApplicable?: boolean /* ── S2 라이브 ── */ /** 인제스트 누적 건수. */ liveIngestTotal: number /** **반출(remove)을 계산한 누적 건수.** `null` = 계측되지 않음. 0 = 세었으나 없었다(→ 스냅샷 의심). */ removalsObserved: number | null /* ── S3 예측 ── */ /** 이 트윈에서 예측이 한 번이라도 돌았나. */ forecastRan: boolean /** 선언 적재량 — `declared`/`total`. `null` = 알 수 없음. */ specCoverage: { declared: number; total: number } | null /* ── S4 폐루프 ── */ /** * 이 트윈이 **무엇인가** — 시뮬레이션은 원천에서 받는 것이 없다. * * 시뮬에 라이브를 요구하면 영원히 빨갛고, 그 빨강은 결함이 아니다. 도입 단계는 **연결된 트윈**의 * 축이므로 시뮬은 라이브 이후를 「해당 없음」으로 말한다(달성으로도, 미측정으로도 계산하지 않는다). */ subject?: 'live' | 'sim' /** 피드 성격 — `snapshot-poll` 이면 반출 0 은 「아직」이 아니라 **구조적으로 불가**다. */ feed?: 'push' | 'delta-poll' | 'snapshot-poll' | 'none' | 'unknown' /** 원본이 **선언∩구현**으로 액추에이션 능력을 갖나(`capabilitiesOf`). */ actuateCapability: boolean /** 내 명령의 효과가 인바운드로 **상관되어** 돌아온 누적 건수. `null` = 계측되지 않음. */ correlatedEffectObserved: number | null } export interface StageVerdict { id: StageId achieved: boolean /** 아직 아닌 이유 — 언어중립 코드(화면이 옮긴다). 달성이면 빈 배열. */ blockedBy: string[] /** 판정할 수 없었던 신호 — **배선되지 않은 계측**. 있으면 달성이 아니다. */ unmeasured: string[] } export interface StagePath { stages: StageVerdict[] /** 지금 서 있는 칸 — 연속으로 달성된 마지막 단계. 하나도 없으면 `null`. */ reached: StageId | null /** * 다음 한 걸음 — 사람이 오늘 할 수 있는 일 하나. 없으면(전부 달성) `null`. * 「무엇이 부족한지」의 목록이 아니라 **다음 하나**다. 목록은 사람을 멈추게 한다. */ next: { stage: StageId; blockedBy: string[]; unmeasured: string[] } | null } const ORDER: StageId[] = ['structure', 'live', 'forecast', 'closed-loop'] /** * 증거로 단계를 판정한다. * * **단계는 앞선 단계를 전제한다**(구조 없이 라이브를 달성했다고 말할 수 없다). 앞이 막히면 뒤는 * 자기 조건을 따지지 않고 `prerequisite` 으로 막는다 — 뒤 단계의 조건을 나열하면 사람이 순서를 * 건너뛴다. */ export function assessStagePath(e: StageEvidence): StagePath { const stages: StageVerdict[] = [] let prereqOk = true for (const id of ORDER) { const blockedBy: string[] = [] const unmeasured: string[] = [] if (!prereqOk) { blockedBy.push('prerequisite') } else if (id === 'structure') { if (!(e.locationCount > 0)) blockedBy.push('no-locations') if (!(e.itemCount > 0)) blockedBy.push('no-items') if (e.masterWarningsApplicable !== false && e.masterWarnings === null) unmeasured.push('master-warnings') } else if (id === 'live') { if (e.subject === 'sim') blockedBy.push('subject-is-simulation') else { if (!(e.liveIngestTotal > 0)) blockedBy.push('no-ingest') if (e.removalsObserved === null) unmeasured.push('removals') /* * 세었는데 없다. 이유가 둘이고 **처방이 정반대**다: 스냅샷을 되풀어 읽는 방식이면 더 기다려도 * 오지 않으므로 방식을 바꿔야 하고, 그 밖이면 아직 반출이 일어나지 않은 것일 수 있다. */ else if (e.removalsObserved === 0) blockedBy.push(e.feed === 'snapshot-poll' ? 'snapshot-only-feed' : 'no-removal-observed') } } else if (id === 'forecast') { if (!e.forecastRan) blockedBy.push('no-forecast-run') if (e.specCoverage === null) unmeasured.push('spec-coverage') /* 선언이 0 이면 예측은 커널 기본값 위에 섰다 — 숫자는 나오지만 이 현장의 숫자가 아니다. */ else if (!(e.specCoverage.declared > 0)) blockedBy.push('declarations-empty') } else { if (!e.actuateCapability) blockedBy.push('no-actuate-capability') if (e.correlatedEffectObserved === null) unmeasured.push('correlated-effect') /* 접수만으로는 폐루프가 아니다 — 효과가 관측으로 돌아와야 닫힌다. */ else if (!(e.correlatedEffectObserved > 0)) blockedBy.push('effect-not-observed') } const achieved = blockedBy.length === 0 && unmeasured.length === 0 stages.push({ id, achieved, blockedBy, unmeasured }) if (!achieved) prereqOk = false } const achievedIds = stages.filter(s => s.achieved).map(s => s.id) const firstUnmet = stages.find(s => !s.achieved) return { stages, reached: achievedIds.length ? achievedIds[achievedIds.length - 1] : null, next: firstUnmet ? { stage: firstUnmet.id, blockedBy: firstUnmet.blockedBy, unmeasured: firstUnmet.unmeasured } : null } } /** * 이 단계에 이르지 못했다면 **쓰지 않기로 한 말**. * * 근거 수준 규율(`connector-developer-guide` §8)을 판정과 같은 자리에 둔다. 문장이 문서에만 있으면 * 제안서를 쓰는 사람이 그 문서를 읽지 않는다. 값이 되면 화면이 그 자리에서 말할 수 있다. */ export const CLAIM_GATE: Record = { structure: 'claim.structure-synced', live: 'claim.realtime', forecast: 'claim.prediction', 'closed-loop': 'claim.automatic-control' } /** 아직 쓸 수 없는 주장들 — 도달하지 못한 단계의 게이트 전부. */ export function forbiddenClaims(path: StagePath): string[] { return path.stages.filter(s => !s.achieved).map(s => CLAIM_GATE[s.id]) }