import type { ReducerCheckpoint } from '@operato/twin-kernel'; import type { VocabularyElement } from '@operato/ops-contract'; import type { OeeCheckpoint } from './oee-accumulator.js'; import { type ReferenceMaster } from '../service/reference/reference-master.js'; import { type IngestWarning } from '../service/reference/reference-master.js'; import { type RestartPolicy } from './restart-policy.js'; import { type ModelBasis } from './model-basis.js'; import { type LoadMeter, type LoadPhase } from './load-meter.js'; import { type StructureDiff } from './structure-diff.js'; import { OeeAccumulator } from './oee-accumulator.js'; import { type IngestHealthView, type IngestWindow } from './ingest-health.js'; import { FactDeduper } from './ingest-dedupe.js'; import type { TwinRuntime as TwinRuntimeType } from '@operato/twin-kernel'; import type { TwinModelDef, StructureShift, CanonicalEnvelope } from '@operato/ops-contract'; interface TwinMetrics { ingestedTotal: number; broadcastTotal: number; journaledTotal: number; ingestRate: number; broadcastRate: number; journalRate: number; /** * **지금 대기 중인** 저널 건수 — 아직 DB 로 나가지 않은 것. * * ── 무엇이 틀렸었나 (2026-08-21) ────────────────────────────────────────── * 예전에는 「마지막 flush 에서 쓴 건수」를 여기 넣었다. 그런데 200ms 마다 모아 한 문으로 넣는 것은 * **정상 동작**이다(그렇게 만든 것이 어제 고친 성능 작업이다). 그래서 정상적으로 10건을 쓴 직후 * 화면에 「밀림 10」 경고가 떴고, 다음 flush 까지 그 값이 남았다 — 큐는 이미 0 인데도. * * 운영자에게 그것은 「저장이 10건 밀려 있다」로 읽힌다. 거짓 경보는 참 경보를 죽인다. * * 이제 이 값은 **지금 남아 있는 것**이다: 다 나갔으면 0 이고, 정말로 쌓이는 중이면 그 수가 자란다. */ backlog: number; _accIngest: number; _accBroadcast: number; _accJournal: number; _windowStartMs: number; } export type { RestartPolicy } from './restart-policy.js'; interface InstanceRuntime { /** 부하 계기판 — 작업별 소요. 시뮬·라이브 모두 붙는다(누가 루프를 점유하는지 보려면 둘 다 필요하다). */ load?: LoadMeter; /** 현실 출처 선언(§0 프레임 ①). 부팅 거동(reset/resume/resync)의 근거. */ restartPolicy?: RestartPolicy; id: string; domainId: string; domain?: any; runtime?: TwinRuntimeType; kernel?: any; mode?: 'sim' | 'live'; /** 이 트윈이 선 현장 — 같은 현장의 트윈끼리 서로의 설비 상태를 읽을 수 있게 하는 열쇠. */ spaceId?: string; /** * 이 트윈의 모델 — **대상별 사건 목록에 품목 줄을 적을 때** 쓴다 (2026-08-27). * * 개체 식별자에서 선언된 품목을 찾는 판정이 그 트윈의 선언을 본다(§`item-resolver`). 사건마다 * 등록부를 읽으면 쓰기마다 DB 조회가 하나 늘어나므로, 세울 때 받은 모델을 들고 있는다. */ model?: TwinModelDef; /** * live 의 상태 접근 어댑터 — 이제 **관측 모드 커널**을 가리킨다(`kernel` 과 같은 것). * 이름은 소비처 호환으로 남았고, P3 에서 커널 어휘로 정리하면 사라진다. */ projector?: any; oee?: OeeAccumulator; /** * 같은 사실이 두 번 오는 것을 거르는 자리(§`FactDeduper`). * * 트윈마다 하나다 — 정체는 트윈 안에서만 뜻이 있고, 트윈 하나가 되풀이 받는 것을 거르는 것이 목적이다. * 프로세스가 새로 뜨면 비어 있다(그 사실은 `FactDeduper` 주석에 적어 두었다). */ deduper?: FactDeduper; unsub: () => void; /** * 방금 인입한 봉투들 — 커널이 그것을 재방출할 때 **저널에 두 번 적지 않기** 위한 표시. * 라이브만 쓴다(sim 은 커널이 스스로 낸 것만 흘린다). 약한 참조라 따로 비울 것이 없다. */ applying?: WeakSet; timer?: any; /** 엔티티별 마지막 발행 시그니처 — 값이 바뀐 엔티티만 재발행(무변화 반복 push 방지). */ entitySigs?: Map; /** live 브로드캐스팅 병합(ingest-scale §4.4) — ingest 마다 브로드캐스팅하지 않고 dirty 표시 → coalescer tick 이 주기 브로드캐스팅. */ dirty?: boolean; /** * 이 창에 **건드린 물품 태그** — 브로드캐스팅이 그것만 만든다. * * `undefined` 는 「말할 수 없다」다(낯선 사건이 하나라도 왔다) → 전부 만든다. 빈 집합은 「물품은 * 하나도 건드리지 않았다」이고, 그 둘은 다른 사실이다. */ dirtyItems?: Set; /** 기동 이후 이 트윈이 발행한 건수 — 무엇이 대기열을 채우는지 값으로 말한다. */ publishedTotal?: number; /** 다음 브로드캐스팅에서 전부 만들어야 하나 — 주기마다 한 번은 그물로 전부 만든다. */ fullBroadcastDue?: boolean; /** 전부 만들기까지 남은 창 수. */ fullBroadcastCountdown?: number; /** live 저널 결선 — ingest 이벤트를 모았다가 coalescer tick 에서 배치 기록(이벤트마다 DB write 금지). */ pendingJournal?: any[]; /** live 저널 revision 카운터(sim 의 delta revision 대응 — 시간여행·히스토리 노브 앵커). */ revision?: number; /** live 처리량 계측(모니터, ④-1). */ metrics?: TwinMetrics; } /** * 트윈이 **스스로 멈춘 이유** — 문장이 아니라 코드다. * * 서버가 만든 영어 문장을 그대로 실으면 한국어·일본어 화면에 그 영어가 뜬다. 이 시스템은 거절 메시지에서 * 같은 결론에 이르렀다(코드+파라미터를 내고 화면이 옮긴다). `reason` 만은 원문 그대로다 — 커널·런타임이 * 낸 오류 문구는 옮길 수 있는 어휘가 아니고, 옮기려 들면 원인을 잃는다. */ export interface StopNote { code: 'starved' | 'tick-failed'; params: Record; } export declare class TwinEngine { static instances: Record; /** 라이브 런타임이 없을 때(복구 후 미기동) 저널에서 재구성한 상태 캐시. */ /** 웜스타트 씨앗 — `instances` 와 **같은 키**(겹치면 남의 스냅샷으로 재고가 섞인다). */ static recovered: Record; static TICK_MS: number; /** 부팅 때 트윈 하나를 되살린 뒤 루프를 비워 주는 시간(ms) — 그 사이 도착한 요청이 처리된다. */ static BOOT_YIELD_MS: number; private static tickPhase; /** * 흩어진 첫 발화 뒤 주기 틱. **핸들은 항상 진짜 타이머**다 — 정지하는 쪽이 `clearInterval(inst.timer)` * 하나로 끝내야 하므로, 첫 발화 전에는 그 `setTimeout` 을, 이후에는 `setInterval` 을 같은 자리에 둔다 * (감싼 객체를 주면 `clearInterval` 이 아무 일도 하지 않고 트윈이 멈추지 않는다 — 들어온 것이 없는 결함이 된다). */ private static startTickTimer; /** 굶김 문턱 — 틱 간격의 배수(1초 간격이면 5초). 이 시간만큼 호스트가 멈춘다. */ static STARVE_FACTOR: number; /** 연속 몇 번이면 멈추나 — 3번이면 15초를 굶긴 셈이고, 그건 우연이 아니다. */ static STARVE_STREAK: number; private static starveStreak; /** * 왜 멈췄나 — **화면이 그대로 말할 수 있게.** 스스로 멈춘 트윈이 이유 없이 「정지」로만 보이면 * 사람은 자기가 멈춘 줄 안다. 메모리에만 둔다(재기동하면 사라진다 — 그때는 「모른다」가 사실이다). */ private static stopNotes; static SNAPSHOT_CACHE_ID: string; static CHAIN_CACHE_ID: string; static CHAIN_INDEX_CACHE_ID: string; static CHAIN_STRIDE: number; static CHAIN_KEEP: number; static SNAPSHOT_TTL_S: number; /** * 멈춘 트윈의 마지막 상태를 두는 기간 — 사실상 만료 없음 (2026-08-27). * * 캐시 계층에 「만료 없음」을 뜻하는 값이 없어서 큰 수를 쓴다(`ICacheService` 는 초 단위 TTL 하나만 * 받는다). 100년이면 이 시스템의 수명보다 길다. */ static SNAPSHOT_KEEP_S: number; static CHECKPOINT_MS: number; /** * **저널 보존 — 지우는 것은 선언이 있을 때만 한다.** * * ── 왜 필요한가 (2026-08-22 실측) ───────────────────────────────────────── * 개발 저널이 시간당 **581,794행 · 0.85GB** 로 자랐다(하루 20GB). 데모 MES 트윈 셋이 전체의 **97%** * 를 만들고 지우는 것이 없었다. 오늘 고친 것들은 「그 크기에서도 질의가 빠르게」이고, **크기 자체를 * 줄이는 것은 아무것도 없었다.** 그러면 며칠마다 같은 자리로 돌아온다. * * ── 그런데 저널을 지우는 것은 사실을 잃는 일이다 ────────────────────────── * 그래서 세 규율을 지킨다. * * ① **선언이 없으면 아무것도 지우지 않는다.** 기본값은 없음이다 — 알리지 않고 지우는 편이 알리지 않고 쌓는 * 것보다 나쁘다. 지우는 것은 사람이 정한다. * ② **체크포인트가 대신할 수 있는 만큼만.** 스냅샷이 없거나 그 리비전을 넘는 자리는 건드리지 않는다. * 주석이 「만료 시 저널 replay 폴백」이라고 적어 둔 그대로 — 스냅샷이 사라지면 저널이 **유일한** * 복구 수단이므로, 둘을 함께 잃으면 그 트윈의 상태는 되돌릴 수 없다. * ③ **지운 것을 말한다.** 몇 건을 어느 시각까지 지웠는지 로그에 남긴다. 알리지 않고 줄어든 저널은 * 「없었던 일」과 구별되지 않는다. * * 그리고 보존 기간은 **스냅샷 TTL(7일)보다 짧을 수 없다.** 더 짧으면 스냅샷이 살아 있는데 그것이 * 가리키는 앞쪽 저널이 없는 구간이 생기고, 시간여행·계보 추적이 그 구간에서 알리지 않고 빈다. */ static JOURNAL_RETENTION_DAYS?: number; /** * **도메인마다 다른 보존 기간을 앱이 답한다** — 엔진은 그 값이 어디서 오는지 모른다. * * 보존 기간은 테넌트의 정책이다: 한 고객은 30일이 필요하고 다른 고객은 7일이면 된다. 그런데 엔진이 * `Setting` 을 읽게 만들면 **엔진이 화면 관심사를 알게 된다** — 방향이 거꾸로다(엔진은 커널의 호스트이고 * 그 위에 어떤 화면이 있는지 몰라야 한다). * * 그래서 시임을 둔다. 앱이 이 함수를 심고, 그 안에서 `Setting` 이든 다른 무엇이든 읽는다. 심지 않으면 * `JOURNAL_RETENTION_DAYS`(설정 파일에서 온 프로세스 기본값)가 답한다. * * `undefined` 를 돌려주면 **그 도메인은 지우지 않는다** — 「모른다」를 「지워도 된다」로 읽지 않는다. */ static retentionDaysOf?: (domainId: string) => Promise; static RETENTION_SWEEP_MS: number; private static retentionTimer?; private static checkpointTimer?; /** 최신 스냅샷을 cache-service 에 체크포인트(도메인+instanceId 키). display-only·비차단·오류흡수. */ static persistSnapshot(domainId: string, instanceId: string, /** * 만료를 두지 않는다 — **트윈을 멈출 때만** 참이다 (2026-08-27). * * 도는 트윈은 주기적으로 다시 쓰므로 7일 만료가 걸리지 않는다. 멈춘 트윈은 아무도 쓰지 않아서 * 7일 뒤 만료되고, 그러면 그 트윈의 마지막 상태를 아는 것이 아무것도 없다. 멈추는 순간이 그 * 상태가 메모리에 있는 마지막 순간이므로 그때 만료 없이 적는다. * * 만료된 뒤에도 그 트윈은 보관된 구간에서 다시 세워진다(보관 기간만큼이므로 유한하다). 다만 그 * 구간 앞의 상태는 알 수 없다. 멈추는 순간에 적어 두면 그 손실이 없다. */ keepForever?: boolean): Promise; /** * 계산의 **재개점** — 리듀서 내부 상태 전부 + 가동 누적기. * * 스냅샷(`state`)은 소비처가 보는 값이라 이어 계산의 씨앗이 되지 못한다(보류된 담김·집합·반영 못 한 * 사건 집계가 없다 — 그 상태로 뒤를 계산하면 0부터 계산한 결과와 오류 없이 달라진다). 그래서 씨앗은 따로 든다. */ private static readonly FOLD_NOTE; /** * 재기동에 쓸 **웜스타트 씨앗**을 만든다 — 상태 + 이어 계산할 재개점. * * ── 왜 이 자리가 생겼나 (2026-08-24) ────────────────────────────────────── * 두 호출부가 같은 일을 조금씩 다르게 하고 있었고(겹포장을 한쪽만 벗겼다), 둘 다 **재개점을 버리고** * 상태만 들고 갔다. 그래서 저장된 재개점을 읽는 쪽이 다 있는데도 재기동은 매번 저널을 처음부터 * 계산했다(실측: 저널 2,960만 줄). * * 여기서 하는 일 셋: * ① 겹포장을 벗긴다 — 옛 형식으로 저장된 값이 한 번은 반드시 나온다 * ② **그 공장이 아직 그 공장인지** 심판한다 — 아니면 씨앗을 버린다(아래) * ③ 씨앗이 저널 끝보다 앞서 있으면 **그 꼬리만 계산해** 끝까지 밀어 둔다 * * ②가 필요한 이유: 재개점은 그때의 보드 위에서 만들어진 것이다. 그 뒤 구조가 바뀌었다면(자리가 * 빠졌다·설비가 옮겨졌다) 되세운 리듀서는 **지금 없는 자리와 설비를 든다** — 없는 냉장실이 화면에 * 나오고 그 자리의 판정이 계속 돌아간다. 오류 없이 틀리므로 눈에 띄지 않는다. * * ③이 필요한 이유: 스냅샷은 체크포인트 주기로 쓰이므로 마지막 주기 이후의 사실은 저널에만 있다. * 그것을 빼고 되세우면 그만큼이 오류 없이 사라진다 — 「모름」을 「없음」으로 적는 것과 같은 부류다. * 계산하는 구간은 **그 틈뿐**이고(저널 전체가 아니다), `recover` 가 그 자리에서 새 재개점을 남겨 준다. */ private static warmSeedFor; /** 체크포인트된 최신 스냅샷 로드(없으면 null). getFromCache 는 CacheStore 엔티티를 반환 → 페이로드는 .value. */ static loadSnapshot(domainId: string, instanceId: string): Promise<{ revision: number; state: any; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint; }; } | null>; /** * 계산한 상태를 스냅샷으로 남긴다 — **라이브가 아니어도.** * * `persistSnapshot` 은 기동 중인 인스턴스에서만 뜬다(메모리가 진실이므로 옳다). 그런데 **멈춘** * 트윈을 조회할 때마다 저널을 전량 다시 계산하고 있었다(23,731건짜리 트윈에서 4.6초). 그 폴드의 결과를 * 남겨 두면 **처음 한 번만 느리다.** * * `structureRev` 를 함께 적는다: 이벤트가 하나도 안 늘어도 구조를 갈아치우면(재프로비저닝) 그 * 상태는 낡은 것이다. 리비전만 보면 새 설비가 없는 옛 상태를 "지금" 으로 내게 된다. */ static saveFoldedSnapshot(domainId: string, instanceId: string, value: { revision: number; state: any; structureRev?: number | null; fold?: { reducer: ReducerCheckpoint; oee: OeeCheckpoint; }; }): Promise; /** 사슬 색인 — 어떤 리비전 지점을 들고 있나(최신순 아님, 오름차순). */ private static chainIndex; /** * 목표 이전의 **가장 가까운 지점**을 고른다 — 없으면 `null`(0부터 계산한다). * * 시각으로 물었으면 그 지점의 마지막 사실 시각이 목표 이내여야 한다(리비전만 보면 목표보다 뒤의 * 사실이 씨앗에 섞인다). 구조가 바뀐 트윈에서는 쓰지 않는다 — 마디를 건너뛴 씨앗은 그 경계의 * 판정을 잃는다(그 경우는 0부터 계산하는 것이 옳다). */ private static chainSeedFor; /** 눈금을 넘었으면 한 지점을 남긴다 — 오래된 것은 버린다(색인도 함께 줄인다). */ private static keepChainPoint; /** 이 트윈 저널의 끝 리비전 · 최신 구조 리비전 — 스냅샷이 지금의 사실인지 가리는 두 값. */ private static tipOf; /** * **저널을 보존 기간까지만 둔다.** * * 지우는 조건은 하나다 — `createdAt` 이 보존 기간보다 오래됐다(**행이 쓰인 실제 시각**이 기준이다). * * **도는 트윈**에서만 조건이 하나 더 붙는다: `revision` 이 체크포인트 리비전 이하여야 한다. 도는 * 트윈은 메모리에서 이어 집계하므로, 체크포인트가 아직 반영하지 못한 사건을 지우면 그 구간이 * 어디에도 없어진다. 멈춘 트윈에는 이어 집계할 메모리가 없어서 이 조건이 없다. * * ── 왜 `eventTime` 이 아니라 `createdAt` 인가 (2026-08-22 실측으로 고침) ──── * 처음에 `eventTime` 으로 적었다. 그것은 **트윈의 시계**다 — 시뮬레이션은 자기 시계로 사건을 찍고, * 그 시계는 실제 시각과 무관하게 앞서거나 뒤선다. 실측: * * order-check 가장 늦은 eventTime = 2027-06-08 ← 실제 시각보다 10개월 앞 * hatio-mx2 2026-01-01 ~ 지금 ← 7개월치를 한 번에 지우게 된다 * * 즉 「7일」이 트윈마다 다른 뜻이 된다: 시계가 앞선 트윈은 영원히 지워지지 않고, 과거부터 찍은 트윈은 * 거의 전부가 한 번에 지워진다. **보존은 저장 나이의 문제**이고 저장 나이는 실제 시계다. * * `createdAt` 은 그 행이 DB 에 쓰인 시각이고 1,599만 행 전부 채워져 있다(확인함). 도메인 시각을 * 정책에 쓰지 않는다 — 그 둘을 섞으면 시뮬과 미러에서 같은 설정이 다르게 동작한다. * * ── 왜 체크포인트를 전제 조건으로 두지 않나 (2026-08-27) ─────────────────── * 예전에는 체크포인트가 없으면 그 트윈을 건드리지 않았다. 「저널이 유일한 복구 수단」이라는 전제 * 때문이었고, 그 전제가 틀렸다. 트윈이 자기 저널과 어떤 관계인지는 `restartPolicy` 가 선언한다 — * `resync` 는 연결된 시스템에서 다시 읽고, `reset` 은 씨앗부터 돌리고, `resume` 은 보관된 구간에서 * 세운다. 셋 다 보관 기간 밖의 사건을 요구하지 않는다. * * 보존 기간을 7일로 선언한 것이 곧 「7일 전 상태로는 세우지 않는다」는 뜻이다. 그 전제를 지키느라 * 지우지 않으면, 멈춘 트윈의 저널이 영구히 남는다 — 실측으로 세 트윈에 2,900만 행이 그렇게 남았다. * * 「지울 수 없었다」는 사실은 그대로 센다(도는데 체크포인트가 아직 없는 경우) — 알리지 않고 넘기면 * 「보존이 도는데 왜 안 줄어드나」가 된다. * * `createdAt` 이 빈 옛 행은 **지우지 않는다**(시각을 모르는 것을 「오래됐다」로 읽지 않는다). * * 드라이버 다섯을 다 지나야 하므로 raw SQL 을 쓰지 않는다 — 조건 삭제는 쿼리빌더가 이식한다. */ static pruneJournal(domainId: string): Promise<{ deleted: number; instances: number; }>; /** * 한 번의 지우기가 다루는 행 수 — 쓰기 잠금을 이 만큼만 잡는다. * * ── 20,000 이 왜 컸나 (2026-08-28 실측) ──────────────────────────────────── * 앞 버전은 20,000 이었고 근거는 「한 문장으로 수백만 행을 지우면 74초였다」였다. 그것은 **더 나쁜 * 쪽과 견준 수**이고, 20,000 자체를 잰 것이 아니었다. 실제로 재 보니 이렇다(서버 기록): * * hatio-mx1 20,123줄 5.6초 * hatio-mx2 20,130줄 5.2초 * hatio-us 20,139줄 4.2초 * 한 창의 합계 약 15초 * * sqlite 는 연결이 하나다. 그래서 그 4~5초 동안 **화면의 모든 질의가 줄을 선다** — 실측으로 첫 * 화면 조회가 12.2초였다(정상 상태에서는 40밀리초다). * * 그리고 예산(`JOURNAL_PRUNE_BUDGET_MS` = 2초)이 **문장이 끝난 뒤에** 검사되므로, 한 문장이 예산을 * 2~3배 넘긴다. 배치를 나눈 뜻이 그만큼 없어진다. * * ── 2,000 의 근거 ────────────────────────────────────────────────────────── * 위 실측이 줄당 약 0.26밀리초다(5.2초 / 20,130줄). 2,000줄이면 **한 문장이 약 0.5초**다. 화면이 * 한 번 멈추는 길이가 5초에서 0.5초로 줄고, 예산 검사도 뜻을 갖는다(한 창에 문장 넷). * * 총량은 같고 나눠서 치른다 — 창마다 지우는 수가 줄지만 지우는 일은 급하지 않다(창이 10분마다 온다). * * 값을 바꾸려면 다시 재고 이 문장을 고칠 것. */ static JOURNAL_PRUNE_BATCH: number; /** * 한 창에서 지우는 데 쓰는 시간 상한(ms) — 남은 것은 다음 창이 이어서 지운다. * * 밀린 몫이 며칠치면 한 창에서 다 지울 수 없다. 다 지우려 들면 그동안 호스트가 멈추고, 그것이 바로 * 고치려는 증상이다. 총량은 같고 나눠서 치른다. * * ── 2초에서 5초로 올린 이유 (2026-08-28) ─────────────────────────────────── * 배치를 20,000 → 2,000 으로 줄였다(§`JOURNAL_PRUNE_BATCH`). 예산을 그대로 두면 **창마다 지우는 * 양이 2.5배 줄어** 밀린 몫이 빠지지 않는다. * * 예산을 5초로 두면 한 창에 문장 열 개(각 0.5초)가 돌아 **지우는 양은 전과 같고**, 잠금은 열 번 * 나뉘어 그 사이에 화면 질의가 처리된다. 고치려던 것은 총 시간이 아니라 **한 번에 멈추는 길이**다. * * 전 한 문장 5.2초 · 그동안 모든 조회가 줄을 선다 * 후 문장 열 개 × 0.5초 · 사이마다 루프를 비워 준다 */ static JOURNAL_PRUNE_BUDGET_MS: number; /** 보존 정리 주기 기동(1회) — 선언이 없으면 아무것도 하지 않는다. */ static startRetentionLoop(domainId: string): void; /** 체크포인트 루프 기동(1회) — 라이브 인스턴스들의 최신 스냅샷을 주기 영속. */ static startCheckpointLoop(): void; /** * 부팅 훅 — DB 에 status='running' 으로 남은 인스턴스를 저널에서 상태 재구성(DB→replay). * 라이브 tick 재개(sim 결정적 re-run / live 인제스트 지속)는 후속. 여기선 상태 복원 + 노출. */ static bootstrap(): Promise; /** * 부팅 때 **도는 트윈을 실제로 되살린다** — 「도는 중」이 사실이 되게. * * ── 무엇이 거짓말이었나 (2026-08-14) ──────────────────────────────────────── * `bootstrap()` 은 상태만 되찾아 `recovered` 에 담았고, 커널을 세우는 것은 **명시 mutation 뿐**이었다 * (이 파일 위쪽 주석이 「향후」라고 적어 둔 그 자리다). 그래서 서버를 한 번 재기동하면 등록부는 * `running` 이라 말하는데 **아무 커널도 돌지 않았다** — 화면은 도는 트윈을, 실제로는 멈춘 트윈을. * 미러 트윈에서는 더 나쁘다: 계측이 알리지 않고 끊기고, 사람은 「값이 안 변하네」로 알게 된다. * * ── 모드를 지어내지 않는다 ────────────────────────────────────────────────── * 미러였던 트윈을 시뮬로 되살리면 **없던 움직임을 만들어 낸다**(관측 트윈이 스스로 물건을 옮긴다). * 그래서 선언된 `restartPolicy` 그대로 되살린다 — 미러는 관측 구동으로, 시뮬은 시뮬로. * * ── 되살릴 수 없으면 그렇게 적는다 ────────────────────────────────────────── * 실패를 삼키면 등록부가 계속 `running` 이라 말한다 — 우리가 고치려던 그 거짓말이다. 그래서 실패한 * 행은 `stopped` 로 적고 이유를 남긴다. 「멈췄다」는 사실이고, 「도는 중」은 사실이 아니었다. */ private static resumeRow; /** 되살리지 못한 행을 정직하게 적는다 — 「도는 중」이라 말하는 채로 두지 않는다. */ private static markStopped; /** * 웜스타트 — 기동하는 커널에 **직전 관측 상태**를 주입한다. * * ── 왜 필요한가 ───────────────────────────────────────────────────────────── * `loadTwinModel` 는 **구조만** 싣는다(자리·설비). 상태(무엇이 어디에 얼마나)는 없다. 그래서 재기동한 * 트윈은 저널에 입고 540건이 남아 있어도 재고가 0 이었고, 화면은 "보유 중인 것이 없습니다" 라고 * 말했다 — 있는 재고를 없다고 하는 셈이다(2026-07-31 hatiolab-wms 실측으로 확인). * `bootstrap()` 이 이미 체크포인트 캐시(없으면 저널 replay)로 상태를 복구해 `recovered` 에 담아 두는데, * 기동 순간 그걸 **버리고** 있었다. 반만 연결돼 있던 장치를 잇는다. * * ── 반쪽이던 복구를 마무리한다 (2026-08-05) ───────────────────────────────── * 재고는 살아났는데 **진행 중 주문이 통째로 사라진** 화면이 남아 있었다. 씨앗이 자리·물품·설비 * 셋만 넘겼기 때문이다. 그 근거는 "스냅샷 오더에 progress 밖에 없다" 였고, 그때는 맞았다 — * 그러나 커널 `OrderState` 가 원값(`requested`·`fulfilled`·`lines`)을 되찾은 뒤에도 이 자리만 * 그대로 남았다. 오류를 내지 않는 종류라 오래 버텼다. * * 지금은 **관측 스냅샷 전체**를 넘긴다(오더·작업·사람·자산 포함). 시뮬은 커널 `snapshot()`, * 라이브는 `StateProjector` 가 그 일곱 축을 모두 담는다. 무엇을 심을 수 있는지 판정하는 규칙 * (이행 완료 오더 제외·고아 작업 제외)은 커널에 있고, 호스트가 미리 골라내지 않는다. * * ── 남은 한계는 숨기지 않고 센다 ──────────────────────────────────────────── * · 원값이 없는 오더(progress 만 있는 것)는 남은 수량을 알 수 없어 주입하지 않는다. **지어내지 않는 * 대신 몇 건인지 말한다** — 세지 않으면 "주문이 없다" 와 "주문을 못 심었다" 가 화면에서 같아진다. * · 진행 중 개별 작업의 내부 상태는 관측만으로 완전히 복원되지 않는다(커널이 명시한 한계, 재계획에 맡김). * * ── 벤치는 시드하지 않는다 ────────────────────────────────────────────────── * 부하 벤치는 **새 시작에서 용량을 재는 것**이 목적이라 현재 상태를 주입하면 측정이 오염된다. */ private static warmStart; /** 트윈 인스턴스 시작 — 커널 생성 + 모델 로드 + 직전 상태 웜스타트 + State 스트림 브리지 + 워커 tick + 레지스트리 영속. */ /** * 공정 명세를 커널에 싣는다 — **시뮬레이션의 시간을 데이터가 말하게 하는 마지막 한 칸.** * * `model.operations`(마스터 인제스트가 통과시킨 ISA-95 OperationsSegment 명세)를 커널이 소비한다. * 없으면 커널 기본 상수로 굴러가고, 커널 `specCoverage()` 가 무엇을 기본값으로 썼는지 보고한다. * * 커널이 아직 이 API 를 갖지 않은 버전이면(발행 이전) **알리지 않고 넘어가지 않고 경고한다** — 명세를 * 선언했는데 반영되지 않는 상태를 모르고 지나가면, 예측이 상수로 돌아간 것을 아무도 알 수 없다. */ private static applyOperations; /** * 소요시간 추정기 주입 — **상수를 데이터로 바꾸는 두 번째·세 번째 층.** * * 커널 `durationOf` 의 우선순위는 추정기 > 명세 > 상수다. 그 추정기 자리에 두 가지를 사슬로 넣는다: * ① **실측**(저널의 작업 종류별 작업시간 p50) — 그 현장에서 실제로 얼마 걸렸나. 가장 강한 근거. * ② **거리 × 속도**(model.layout + 설비 속도 속성) — 이동은 거리에 비례한다. 커널은 좌표를 모르므로 * 호스트가 계산해 넣는다. * 둘 다 못 만들면 주입하지 않는다 — 커널이 명세·상수로 굴러가고 `specCoverage()` 가 그 사실을 남긴다. * * 실측은 DB 조회라 비동기다. 그래서 이 함수는 **await 하지 않는 쪽에서도 안전**하도록 실패를 삼키되, * 무엇을 왜 못 넣었는지는 로그로 남긴다(들어온 것이 없는 무효화 금지). */ static installEstimators(kernel: any, domainId: string, instanceId: string, model: any): Promise; /** * 실측 추정기 — **예측 요청마다 저널을 다시 계산하지 않는다.** * * 예측 커널은 요청마다 새로 세워지고(미러 예측·백테스트), 화면은 시각을 긁으면 계속 재예측한다. * 거기에 KPI 조회를 그대로 달면 요청당 저널 스캔이 하나씩 붙는다 — 실측은 분 단위로 바뀌지 않으므로 * 짧은 TTL 로 재사용한다. 캐시는 인스턴스별이고, 만료 전에는 같은 값을 쓴다(예측 간 일관성도 얻는다). */ private static measuredCache; private static readonly MEASURED_TTL_MS; /** * 캐시 항목 상한 — **라이프사이클이 놓친 것까지 막는 두 번째 방어.** * * 지움은 `forgetInstance` 가 한다(그것이 첫 번째 방어이고 정확한 쪽이다). 그런데 트윈이 우리 API 를 * 지나지 않고 사라지는 길이 있다: 도메인(테넌트)째로 지워지거나 DB 를 직접 손대는 경우다. 그때 * 남은 항목을 지울 주인이 없으므로 상한이 필요하다. * * 넘치면 **가장 오래 손대지 않은 것**부터 버린다(Map 의 삽입 순서가 곧 그 순서다 — 값을 쓸 때마다 * 다시 넣으므로). 버리는 것이 손해가 아닌 이유: 이 값은 캐시이고, 없으면 다시 계산한다. * * 수를 크게 잡는다 — 트윈 규모는 늘 크고, 항목 하나는 작업 종류별 소요 몇 줄이다. 상한이 작으면 * 정상 규모에서 서로 밀어내며 캐시가 무의미해진다(그게 더 나쁘다: 알리지 않고 느려진다). */ private static readonly MEASURED_MAX; /** 이 트윈이 이력에서 배운 양품률 — 소요와 **같은 폴드·같은 캐시**에서 온다(저널을 두 번 계산하지 않는다). */ private static measuredYield; private static measuredEstimator; /** * **원본이 선언한 자극을 싣는다** — 재기동해도 살아 있게 (2026-08-19). * * ── 무엇이 났나 ──────────────────────────────────────────────────────────── * 데모의 시나리오는 시드 코드가 메모리에만 실었다. 그래서 트윈을 재기동하면 자극이 사라지고 구조만 * 서 있는 트윈이 남았다(작업 0·오더 0) — 「살아 있는 데모」가 **첫 재기동까지만** 살았다. * * 자극의 집은 **원본**이다(`TwinReference.connectionConfig.scenario`, ADR-0029 · * `plans/simulator-as-source.md` §4): 무엇이 들어오고 무슨 주문이 나는지는 시뮬레이터가 정하는 사실이고 * 트윈은 반영한다. 트윈에 새 축을 만들면 ADR-0029 가 걷어내야 할 표면이 하나 늘어난다. * * 판정은 순수 함수가 한다(`planStimulus`) — 미러에 싣지 않고, 검사를 통과하지 못한 선언은 태우지 않고 * (그 이력이 있다: 잘못된 선언 하나가 다음 틱에서 서버를 내렸다), 못 실은 이유는 **말한다.** */ private static installStimulus; /** * 자극을 **원본에 선언한다** — 프로비저닝·데모 시드가 부르는 문. * * 트윈이 아니라 원본에 적는 이유는 위와 같다(ADR-0029). 원본 행이 없으면 만들지 않는다 — 어떤 원본에서 * 온 트윈인지 모르는 채 자극을 지어 붙이면, 그 자극이 어디서 왔는지 아무도 되짚을 수 없다. */ static declareStimulus(domainId: string, instanceId: string, scenario: any): Promise; /** * 이 트윈이 **이력에서 시간을 배운 작업 종류들** — 재기동에도 남는 근거. * * ── 왜 필요한가 (2026-08-18 실측) ──────────────────────────────────────── * 커널의 자기보고(`specCoverage()`)는 **작업이 새로 생길 때** 채워진다. 그래서 재기동 직후에는 이력이 * 풍부한 트윈에서도 목록이 비고, 「이 트윈이 무슨 공정을 돌리나」 화면이 빈칸이 된다 — 값을 채울 곳을 * 보여야 하는 화면이 정작 그때 아무 말도 못 한다. * * 그 빈칸을 이력로 메운다: 저널에서 배운 종류는 **추정기가 이미 답할 수 있는 종류**이므로, 지어내는 * 것이 아니라 있는 사실을 꺼내는 것이다. 같은 캐시(60초)를 쓰므로 조회마다 저널을 다시 계산하지 않는다. */ static measuredOperationKinds(domainId: string, instanceId: string): Promise; /** * 지금 이 트윈이 어떤 모델로 굴러가는가 — 예측 출력 보정이 **자기가 배운 모델**에만 적용되도록 * 비교하는 지문. 재료는 호스트가 아는 것(실측·속도·선언 명세)이라 커널 발행 상태와 무관하다. * 실패하면 undefined — 그때는 비교를 하지 않는다(모르는 것을 근거로 보정을 끊지 않는다). */ static modelBasis(domainId: string, instanceId: string): Promise; /** * **이 트윈이 하루 몇 대를 낼 수 있는가** — 실행해 보지 않고 답한다. * * 계산은 커널이 자기 상태에서 한다(`kernel.capacity`). 여기서 하는 일은 **기준 주를 정해 주는 * 것**뿐이다: 공휴일이 없는 평상주여야 한다 — 공휴일은 연간 가용량을 따로 깎지, 이 공장의 평상시 * 상한을 정하지 않는다. 커널이 임의로 고르게 두면 그 주에 공휴일이 끼었을 때 상한이 알리지 않고 낮아진다. * * 트윈이 기동 중이 아니면 `undefined` 다 — 0 이 아니다. 기동하지 않은 트윈의 상한을 0 이라고 답하면 화면은 * "이 공장은 아무것도 못 만든다" 고 말한다. */ static capacity(domainId: string, target: { instanceId?: string; spaceId?: string; }, unitsPerDay: number, sampleWeekStartMs: number): Promise<{ instanceId: string; kind?: string; analysis?: any; reason?: 'not-running' | 'no-operations'; }[]>; /** * **생산 능력 보고서** — ISA-95 Part 4 `OperationsCapability`. 구간의 양을 종류로 구분한다 * (약정·가용·달성불가). 용량(`capacity`)은 비율을 내고 이것은 **구간의 양**을 낸다. * * 대상 선정·거절 사유는 `capacity` 와 **같은 규율**이다: 등록부가 대상을 정하고(안 기동 중인 것과 * 없는 것을 구별한다), 공정을 선언하지 않은 트윈은 판정 대상이 아니라고 말한다(0 으로 채우면 * "야드가 병목" 이라는 없는 사실이 생긴다). */ static operationsCapability(domainId: string, target: { instanceId?: string; spaceId?: string; }, unitsPerDay: number, sampleWeekStartMs: number, window: { fromTime: string; toTime: string; }): Promise<{ instanceId: string; kind?: string; report?: any; reason?: 'not-running' | 'no-operations'; }[]>; /** * 미러 기동의 연속성 씨앗 — 이어받은 것은 **말한다**(알리지 않고 잇지 않는다). * * 커널이 그 문을 열어 두지 않았으면 그 사실도 말한다: 그 트윈은 재기동마다 열린 구간을 잃는다. */ private static seedLiveContinuity; static start(id: string, domainId: string, kind: string, model: TwinModelDef, restartPolicy?: RestartPolicy, purpose?: string, resumeFrom?: number): InstanceRuntime; /** * live-모드 기동 — **관측 모드 커널**로 실 이벤트를 미러한다(통합 P2). * * 예전에는 `StateProjector` 를 세웠다. 그래서 라이브에는 **커널이 없었고**, 그 하나 때문에 우회가 * 줄줄이 생겼다: 예측하려면 임시 커널을 세워야 했고(`buildForecastKernel`), 주목 신호를 호스트가 * 덧붙여야 했고(`withLiveAttentions`), AI 예측 도구는 미러에서 "찾을 수 없다" 로 끝났다. * * 이제 라이브 인스턴스도 **커널이다.** 같은 규칙(`ObservedReducer`)으로 이벤트를 계산하고, 주목 신호를 * 스스로 내고, 그 자리에서 `fork` 해 예측한다. 구동만 다르다 — sim 은 `tick`, live 는 `apply`. * * 실 이벤트원 = reference 어댑터 openLiveFeed → face2-adapter.ingest → CanonicalEnvelope → ingestLive(). */ /** * 모델이 실은 **생산 정의**(레시피·라우트·바인딩)를 꺼낸다 — 커널의 정의-구동 모드 입구. * * ── 없을 때 무엇이 일어났나 ────────────────────────────────────────────── * 커널에는 정의-구동 MES 경로가 있는데 **호스트가 그것을 한 번도 넘기지 않았다.** 그래서 모든 MES * 트윈이 **하드코딩된 레거시 흐름**으로 돌았다 — 현장 레시피를 아무리 정성껏 적어도 커널은 그것을 * 보지 못하고 토이 부품으로 토이 제품을 만들었다. 선언과 실행이 갈라져 있던 자리다. * * 모델에 없으면 `undefined` — 레거시 경로 그대로다(기존 트윈의 거동을 바꾸지 않는다). */ /** * 생산 선언을 꺼낸다 — 커널 생성자에 넘긴다. * * **옛 이름(`mesSpec`)은 읽지 않는다.** 그것은 계약에 선언조차 없던 필드였고(MES 커널 생성자 인자 * 이름이 그대로 굳은 것), 일반 기제에 한 시스템 이름이 붙어 있었기 때문에 창고 트윈이 이 자리를 * 쓰지 못했다. 별명으로 남겨 두면 그 혼동이 계속되므로 하나로 통일했다. * * 옛 이름만 가진 모델이 있으면 **알리지 않고 생산 선언을 잃는 대신 분명히 멈춘다** — 그 트윈은 공정이 * 없는 채로 돌게 되고(라인이 서 있는 창고), 원인을 찾기 어렵다. */ private static productionSpecOf; /** * 현장(공간)의 **시각 기준**을 모델에 얹는다 — 커널이 교대의 `HH:MM` 을 읽을 기준. * * **테넌트가 아니라 공간이 권위다.** 한 테넌트가 Rosarito(태평양)와 한국 공장을 함께 가질 수 있고, * 테넌트 단위(`Domain.timezone`)로 두면 둘 중 하나는 반드시 틀린다. 그래서 인스턴스가 연결된 공간의 * `timezone` 을 읽는다. 공간이 말하지 않으면 **테넌트로 내려가지 않는다** — 잘못된 입자로 답하는 것이 * 모르는 것보다 나쁘다(그때는 커널이 UTC 로 읽고, 그 기본값은 계약에 밝혀져 있다). * * 커널은 zero-dep 이라 시간대 데이터베이스를 갖지 않으므로 **분 오프셋**으로 풀어 넘긴다. * * ── 이름도 함께 얹는다 (2026-09-07) ──────────────────────────────────────── * 오프셋 하나만 얹으면 **그 값이 이 순간의 것으로 모델에 굳는다.** 트윈은 몇 주씩 돌고, 자료에 * 이미 `America/Tijuana`(PST −480 / PDT −420)가 있다 — 여름에 뜬 트윈이 11월까지 돌면 그 뒤 모든 * 시각이 한 시간 틀린다. * * 지난 구간을 접을 때가 더 나쁘다. 1년치를 교대별로 접으면 그 1년을 한 오프셋으로 읽는데 그 안에 * 전환이 두 번 있다. 어긋난 시각이 오류를 내지 않고 **다른 교대로 세어진다.** * * 그래서 이름(`timezone`)을 함께 얹어, 순간마다 다시 풀 수 있게 한다. 커널은 이 값을 쓰지 않는다 — * 여전히 오프셋을 호출마다 받는다. 푸는 것은 호스트의 몫이고 그것이 계약이 적어 둔 분담이다. * * `utcOffsetMinutes` 도 계속 얹는다. 아직 이름을 안 보는 소비처가 그 값으로 돌고, 이름이 없는 공간에 * 대해서는 그것이 유일한 답이다. * * ⚠ 이것으로도 완전하지 않다. 정확한 답은 호스트가 표준대로 **절대 구간**을 계산해 넣는 것이다. */ static withSpaceTimeBase(model: TwinModelDef, domainId: string, spaceId?: string): Promise; static startLive(id: string, domainId: string, kind: string, model: TwinModelDef): InstanceRuntime; /** * live 이벤트 인제스트 — projector 구동 + data(tag) 브로드캐스팅(폐루프의 인바운드 도착 지점, command-routing §8.2). * reference 어댑터가 낸 records → 커널 face2-adapter.ingest → CanonicalEnvelope 를 여기로 밀어넣는다. * (State 채널 델타/저널 결선은 후속 — 스켈레톤은 data(tag) 미러 중심.) * * ── 넣은 수를 **답한다** (2026-08-20) ──────────────────────────────────────── * 트윈이 라이브로 돌지 않으면 여기서 봉투를 버린다. 그것 자체는 맞다(넣을 커널이 없다). 문제는 * **알리지 않고** 버린 것이었다: 트윈이 멈춘 뒤에도 피드는 남아 레코드를 나르고, 유입 장부는 그것을 * 「통과」로 셌다. 화면은 멈춘 트윈 옆에 「150 통과 · 100%」라고 적었다 — 사실이 사라지는 동안 * 화면이 안심시킨 것이다. * * 그래서 **넣은 수를 돌려준다.** 부르는 쪽이 제시 수와 견주어 버려진 수를 장부에 적는다. 반환을 * 무시하는 호출부는 그대로 동작한다(전과 같다). */ /** * 유입 결과를 수로 낸다 — 받은 수 · 중복으로 버린 수 · 보낸 수. * * `ingestLive` 는 받은 수만 돌려주는데, 웹훅으로 받을 때는 **몇 건이 중복이었는지도 답해야** 한다. * 밀어 주는 쪽이 그 수를 보고 자기가 되풀이 보내고 있다는 것을 알 수 있어야 하고, 그것을 모르면 * 우리 응답이 「전부 새 것이었다」로 읽힌다. */ static ingestLiveResult(domainId: string, id: string, envelopes: CanonicalEnvelope[], source?: string): { offered: number; applied: number; duplicates: number; }; /** * **마스터데이터를 받는다** — 변하지 않는 속성을 상태에 세운다. 저널에 적지 않는다. * * ── 왜 저널에 적지 않나 ──────────────────────────────────────────────────── * 유통기한·로트번호 같은 값은 그 물건의 생애 동안 같다. 그래서 「그때는 얼마였나」라는 물음이 * 성립하지 않는다 — 시각축이 없는 값이므로 사건이 아니다. 되세울 근거는 중간 저장본이 든다 * (리듀서가 그 값을 저장본에 담는다). * * ── 이 문이 없어서 무엇이 났나 (2026-08-28 실측) ─────────────────────────── * 표준은 그 값을 `ObjectEvent(action=ADD)` 에만 실을 수 있게 한다. 그래서 전량을 읽는 원본이 * 재고를 다시 말할 때마다 **들어오지 않은 것을 「들어왔다」** 고 했고, 사건 단위 값이라 자리별로 * 묶을 수도 없어 낱개로 나갔다 — 재기동 한 번에 2,665건이 「읽은 시각」으로 쌓였다. * * 돌려주는 수는 **상태에 반영된 건수**다. 라이브로 돌지 않는 트윈은 0 을 낸다 — 그 차이를 부르는 * 쪽이 알아야 한다(버려진 것을 통과로 세면 멈춘 트윈이 정상으로 보인다). * * 설계: `operato-twin/design/plans/master-data.md` */ static applyMasterData(domainId: string, id: string, elements: readonly VocabularyElement[]): number; static ingestLive(domainId: string, id: string, envelopes: CanonicalEnvelope[], source?: string): number; /** * 구간 성과 브로드캐스팅은 **없앴다**(2026-08-06). 카드가 `twinKpi` 를 직접 묻는다. * * 왜: 카드를 여러 단계(공간·트윈·구역·자리·설비)에 붙이려면 브로드캐스팅으로는 태그가 트윈당 1,200개가 되고, * **모델에 카드를 하나도 안 놓아도** 30초마다 트윈마다 저널을 계산했다. 질의로 바꾸니 보고 있는 카드 * 수만큼만 들고, 같은 (대상·창·축) 은 클라이언트가 하나로 합친다. * * 덤으로 질의만 할 수 있는 것이 둘 생겼다 — **과거 시각**(`toTime`)과 **공간 단위 합산**(여러 트윈을 * 한 번에 계산). 브로드캐스팅 루프는 트윈별이라 둘 다 못 했다. * * 축을 나눠도 폴드 비용이 같다는 실측이 근거다(`test/kpi-query-bench.test.ts`). */ /** * 몇 창마다 한 번은 **전부** 만드나 — 사건 없이 값이 바뀌는 자리에 대한 그물. * * 보장이 아니라 그물이다(위 `publishEntityData` 주석). **세는 단위는 창이지만 뜻은 시간**이므로, * 기본 주기를 200밀리초에서 1초로 늘린 날 이 수도 함께 줄였다(25창 → 5창). 그러지 않으면 그물이 * 5초에서 25초로 벌어져, 시각만으로 바뀌는 값(유효 기간 만료 같은 것)이 그만큼 늦게 보인다. */ static FULL_BROADCAST_EVERY: number; /** * **한 실행 차례에 발행할 수 있는 건수** — 이만큼 발행하면 루프를 비워 준다 (2026-08-27). * * ── 왜 상한이 필요한가 (실측으로 서버가 두 번 죽었다) ────────────────────── * 구독의 대기열에는 버퍼가 없다(`pubsub.subscribe` 가 `new Repeater(fn)` 을 버퍼 없이 만든다). * 발행하는 순간에 「다음 값을 달라」는 요청이 이미 걸려 있어야 바로 전달되고, 아니면 대기열에 * 쌓인다. 상한은 1024 이고 넘으면 예외가 난다(`@repeaterjs/repeater` 의 `MAX_QUEUE_LENGTH`). * * 값을 꺼내는 쪽은 비동기다. 그래서 **동기 반복문이 도는 중에는 한 건도 꺼내지 못한다.** 라이브러리로 * 직접 재 본 값이다 — 소비자를 붙여 두고 2,000건을 동기로 발행하면 소비자가 꺼낸 것은 1건이었고 * 나머지가 쌓여 예외가 났다. * * 그 예외는 `publish()` 호출자에게 오지 않는다. `EventTarget` 이 리스너 예외를 uncaughtException 으로 * 보내므로 try/catch 로는 잡을 수 없다(2026-08-14 에 그 가드를 붙였는데 그래서 무력했다). 막는 방법은 * **한 차례에 상한을 넘기지 않는 것** 하나다. * * 1024 보다 넉넉히 작게 둔다 — 소비자가 한 청크를 다 꺼내지 못해도 다음 청크까지 여유가 남는다. */ static PUBLISH_CHUNK: number; /** 전부 만든 횟수 — 범위를 좁히지 못한 창이 얼마나 되는지 값으로 남는다. */ static broadcastFullPasses: number; /** * 브로드캐스팅을 만든 횟수 전부 — **전부 만든 횟수의 분모.** * * 분자만 내면 「전부 만들기 1,200회」가 많은 것인지 적은 것인지 읽을 수 없다. 좁히기가 듣고 있으면 * 이 값의 `1/FULL_BROADCAST_EVERY` 쯤이 전부 만든 횟수이고, 두 값이 비슷하면 범위를 거의 못 좁힌 * 것이다(원인은 대개 「모른다」로 떨어지는 사건이다). */ static broadcastPasses: number; /** * 이 창에 건드린 물품을 모은다 — **말할 수 없으면 범위를 버린다(전부 만든다).** * * `events` 를 주지 않으면 「무엇이 바뀌었는지 모른다」다(구조 전환처럼 상태 전반이 달라지는 자리). * 한 창에서 한 번 「모른다」가 되면 그 창은 끝까지 모르는 채로 둔다 — 뒤에 온 사건으로 범위를 * 되살리면 앞 사건이 건드린 것을 빠뜨린다. */ static markItemsDirty(inst: InstanceRuntime, events?: any[]): void; /** 브로드캐스팅 병합 주기(ms) — 브로드캐스팅률 상한. 인제스트가 아무리 빨라도 이 주기로만 브로드캐스팅. */ static BROADCAST_COALESCE_MS: number; /** * ── 브로드캐스팅 주기는 **재 본 비용에 맞춘다** (2026-08-21 실측) ──────────────────── * 한 번의 브로드캐스팅은 상태 크기에 비례한다(실측: 물품 2,400 개인 트윈 하나가 4.5ms — 상태 투영 1.7ms, * payload 만들기 1.8ms, 시그니처 1.0ms). 트윈이 스무 개면 200ms 마다 90ms 가 브로드캐스팅에 들어가고, * 그 시간에는 HTTP 도 구독도 서지 못한다. * * 그래서 한 창에서 브로드캐스팅에 쓴 시간이 주기의 일정 몫을 넘으면 **주기를 늘린다**. 화면은 조금 늦게 * 갱신되고(최신-상태 채널이라 값은 마지막 것 하나뿐이므로 내용은 같다), 늘렸다는 것은 계기판이 * 말한다(`broadcastCoalesceMs`). 여유가 생기면 원래 주기로 되돌린다. * * 이것은 브로드캐스팅 비용을 **줄이는 것이 아니다** — 비용을 줄이는 것은 변경분만 만드는 일이고 그것은 별 * 작업이다. 여기서는 그때까지 호스트가 굶지 않게 상한을 둔다. */ static BROADCAST_MAX_COALESCE_MS: number; /** 주기의 몇 몫까지 브로드캐스팅에 써도 되는가 — 넘으면 주기를 늘린다(절반이면 나머지 절반은 남긴다). */ static BROADCAST_LOAD_RATIO: number; /** 지금 쓰고 있는 주기(ms) — 계기판이 이 값을 읽는다. 늘어난 채로 있으면 그것이 사실이다. */ static broadcastPeriodMs: number; /** 주기를 늘린 횟수 — 알리지 않고 늦추지 않는다. */ static broadcastBackoffs: number; private static broadcastTimer?; /** live 브로드캐스팅 coalescer — dirty 인 live 인스턴스만 주기적으로 1회 브로드캐스팅(entitySigs 로 변경 엔티티만). */ private static ensureBroadcastCoalescer; /** * 브로드캐스팅에 쓴 시간을 보고 주기를 정한다 — **늘리는 것도 줄이는 것도 값에 근거한다.** * * 한 번의 flush 가 주기의 `BROADCAST_LOAD_RATIO` 를 넘게 쓰면 주기를 두 배로(상한까지), 그 몫의 * 절반 아래로 내려오면 절반으로(원래 주기까지) 되돌린다. 문턱을 두 개 두는 이유는 하나면 경계에서 * 늘리고 줄이기를 반복하기 때문이다. */ private static adjustBroadcastPeriod; /** * dirty 인스턴스 브로드캐스팅 flush(주기 tick 또는 명시 호출) — **시뮬과 라이브 둘 다.** * * 예전에는 라이브만 봤다(`mode !== 'live'` 면 건너뜀). 시뮬은 delta 마다 곧바로 브로드캐스팅했고, 그것이 * 한 tick 에서 수백 번 반복되며 이벤트 루프를 막았다(위 `start()` 주석의 35초 틱). 브로드캐스팅을 모으는 * 규율은 모드의 성질이 아니라 **채널의 성질**이다 — 최신-상태 채널이면 중간 상태는 보낼 값이 없다. */ /** * 브로드캐스팅 한 창 — **겹쳐 돌지 않는다** (2026-08-27). * * 발행이 청크로 나뉘어 사이에 루프를 비워 주므로 한 창이 주기를 넘길 수 있다. 그때 다음 타이머가 * 겹쳐 들어오면 같은 태그가 두 순서로 나가고 시그니처 표가 엉킨다. 그래서 앞 창이 끝날 때까지 * 다음 타이머는 그대로 돌아간다 — **창을 버리는 것이 아니다.** 다음 타이머가 이어서 한다. * * 겹친 횟수를 센다. 계속 겹치면 주기보다 한 창이 오래 걸리고 있다는 뜻이고, 그것은 * `adjustBroadcastPeriod` 가 주기를 올려 스스로 풀어야 하는 사실이다. */ private static flushInFlight; static flushOverruns: number; static flushLiveBroadcasts(): void; private static flushBroadcastWindow; /** * 저널 행 한 줄 — **기록 경로가 둘이라(라이브 벌크·심 단건) 행 모양은 반드시 한 곳에서 만든다.** * 두 곳에 각자 적으면 승격 검색 키가 한쪽에만 채워지고, 반쯤 빈 색인은 "저널에는 있는데 * 검색으로는 안 나오는 이벤트" 를 만든다 — 저널에서 가장 나쁜 종류의 결함이다. */ /** 저널의 시각 축은 날짜다 — 못 읽는 값은 비운다(0 이나 지금으로 위장하지 않는다). */ private static toEventTimeValue; private static journalRow; /** * 이 트윈이 지금 어느 구조로 도는가 — 이벤트에 찍을 번호. 한 번 읽고 캐시한다(쓰기마다 조회 금지). * * 없으면 `undefined` 다 — 0 이 아니다. 구조 리비전이 생기기 전에 만들어진 트윈은 아직 리비전이 * 없고, 그 사실을 0 이라는 **유효해 보이는 번호**로 위장하면 안 된다. */ /** 구조 리비전 캐시 — `null` 은 **없다는 것을 알고 있다**는 뜻이다(모름과 구별한다, §7.1). */ private static structureRevCache; static structureRevOf(domainId: string, instanceId: string): Promise; /** 계기 한 벌 — **두 구동이 같은 것을 든다**(한쪽만 들면 그 구동은 물어도 답이 없다). */ private static newMetrics; /** * 모아 둔 저널을 **한 번에** 쓴다 — 두 구동이 같은 문을 쓴다 (§7.1). * * ── 왜 한 함수인가 ───────────────────────────────────────────────────────── * 쓰는 자리가 둘이면(주기 flush · 정지) 한쪽만 고쳐지고, 그 어긋남은 **사실이 오류 없이 사라지는** * 모양으로 나타난다. 그래서 흘리는 규칙을 여기 한 곳에 둔다. * * ── 리비전을 누가 매기나 ─────────────────────────────────────────────────── * · 라이브 — 원천은 리비전을 주지 않으므로 **호스트가** 이어 붙인다(저널 high-water 에서 시드). * · 시뮬 — **커널의 리비전**이 실려 온다(저널이 그 번호로 정렬되고 시간여행이 그것을 딛는다). * 그래서 버퍼는 두 모양을 함께 든다: 봉투만 있으면 라이브, `{ event, revision }` 이면 시뮬이다. */ private static flushJournal; /** * 커널이 매긴 리비전을 그대로 들고 벌크 저장 — 시뮬 경로. * * 예전에는 이 경로가 **델타마다 한 행씩** 저장했다(그리고 행마다 구조 리비전을 물었다). 규모에서 그것이 * 호스트를 먹었다(§7.1 실측). 여기서 구조 리비전은 **한 번만** 묻는다. */ /** * **마감된 구간 사실은 한 번만 적는다** — 같은 사실이 두 번 오면 한 사실이다 (2026-08-30). * * ── 왜 쓰는 자리에서 막나 ────────────────────────────────────────────────── * 커널이 그 사실들의 정체성을 내용에서 만든다(종류·대상·시작·끝). 그런데 저널의 기본키는 새로 * 만드는 uuid 라, **id 가 글자 하나까지 같아도 행이 따로 쌓였다.** * * 실측(2026-08-30): 발전 단가가 행 7개·서로 다른 id 4개였다. 8월 27일 단가가 세 벌 있었고, * 그 구간의 수익이 세 번 세어진다. * * 커넥터가 커서로 막는 것이 첫 방어선이고 그쪽도 고쳤다. 그런데 **커넥터가 한 번 실수하면 저널이 * 부풀고 그 위의 모든 합이 틀린다.** 정체성은 커널이 선언한 사실이므로, 어느 문으로 들어오든 * 지켜져야 한다 — 채우기 경로에만 있으면 그 보증은 「어느 문으로 왔느냐」에 걸린다. * * ── 값이 싼 이유 ─────────────────────────────────────────────────────────── * 마감된 구간 사실만 본다(시점의 관측은 그대로 지난다 — 반복이 뜻을 가진다). 그것들은 드물어서 * (한 시간에 한 줄) 한 묶음에 몇 개뿐이고, 그 구간의 그 종류만 색인으로 읽는다. */ private static readonly PERIOD_FACT_TYPES; static dropAlreadyWritten(domainId: string, instanceId: string, envelopes: any[]): Promise; static persistCarried(domainId: string, instanceId: string, items: { event: any; revision: number; }[]): Promise; /** * **대상별 사건 목록에 함께 적는다** — 조회용 파생 표 (2026-08-27). * * 저널을 쓰는 두 문(`persistBatch` · `persistCarried`) 뒤에서 부른다. 다른 자리에서 나중에 적으면 * 「저널에는 있고 그 표에는 없는 사건」이 생기고, 그것은 화면에서 오류 없이 빠진 사건이 된다. * * **저널이 먼저다.** 이 표에 적다가 실패해도 저널 쓰기를 되돌리지 않는다 — 사실의 원본은 저널이고 * 이 표는 다시 만들 수 있다. 실패는 세어 남긴다(§`subjectWriteFailures`). * * 설계: `operato-twin/design/plans/read-models.md` */ private static writeSubjects; /** * 저널 행을 넣는다 — **넣기만 한다**(2026-08-20). * * 예전에는 `save()` 였다. 그런데 `save` 는 넣은 뒤 생성 컬럼을 읽으려고 **행마다 SELECT 를 한 번 더** * 한다(시험 로그에서 그 질의가 그대로 보였다: `SELECT … FROM twin_events WHERE id = ?`). 저널은 * append-only 이고 부르는 쪽은 돌려받은 엔티티를 쓰지 않으므로 그 왕복이 순수 낭비다. * * 묶음은 **나눠서** 넣는다: 한 문에 열이 열다섯인 행을 수천 개 실으면 드라이버의 파라미터 한계에 * 걸린다(pg 는 65,535개). 500행이면 어느 드라이버에서도 안전하다. */ private static insertRows; /** live 저널 배치 기록 — 모아둔 CanonicalEnvelope 들에 startRevision+1.. 을 부여해 벌크 저장(coalescer tick 당 1회). */ static persistBatch(domainId: string, instanceId: string, envelopes: any[], startRevision: number): Promise; /** 레지스트리 upsert(도메인+instanceId 유니크). status 인자로 provision(stopped)/start(running) 공용. */ static register(domainId: string, instanceId: string, kind: string, model: TwinModelDef, status?: 'running' | 'stopped', restartPolicy?: RestartPolicy, origin?: any, meta?: { name?: string; description?: string; actor?: { id: string; }; }): Promise; /** * ── **트윈은 설비의 마스터가 아니다** (사용자 결정 2026-08-30) ───────────── * * 인원·설비·자산의 마스터는 `@things-factory/ops-master` 다. 트윈은 그것을 **당겨 와** 모델에 * 반영하고, 여기 남는 것은 그 결과의 캐시다. * * 그래서 이 함수가 하는 일에 선이 있다. * * 트윈 안에서 사람이 더한 설비 가정·시뮬레이션의 사실이다 — 트윈이 든다 (지금 이 함수) * 현장에 실제로 있는 설비 마스터의 사실이다 — 트윈이 만들지 않는다 * * 이 함수는 **트윈 제어 명령**(`resource.add`) 뒤에만 불린다. 관측에서 설비가 생기는 경로가 아니다. * 관측으로 모르는 설비가 들어오면 그것은 **마스터에 없는 것**이고, 트윈이 조용히 만들어 주면 두 * 곳이 서로 다른 설비 목록을 갖게 된다. 그때는 만들지 말고 「모르는 설비가 왔다」로 세어야 한다. * * 당겨 오는 길이 붙으면 이 주석의 위쪽 절반만 남는다. * * ── 원래 설명 ───────────────────────────────────────────────────────────── * 라이브 구조 변이(resource.add 등)를 저장된 model 에 반영 — 런타임 커널의 무버를 registry model.equipment 에 동기. * 이게 없으면 런타임엔 추가돼도(상태·저널엔 반영) 저장 model 는 원본이라 프로비저닝 편집기·재기동(loadTwinModel)이 * 추가분을 잃는다. 기존 model.equipment 항목은 보존(homeLocation 유지)하고 새 id 만 append(추가 시점 location=homeLocation). * 좌표(layout)만 다루는 register 와 달리 equipment 집합을 갱신하나, 재프로비전(purge)이 아니라 in-place 갱신이라 * 저널은 보존된다(추가는 이미 equipment 델타로 저널됨 → replay 는 id-keyed upsert 라 이중계산 없음). */ static syncBoardEquipment(domainId: string, instanceId: string): Promise; /** * 프로비저닝 — 레지스트리에 구조(model)를 등록만 하고 기동하지 않음(status='stopped'). * 재프로비전 시 구조 시그니처가 바뀌면(노드·무버 집합/속성 변경) 기존 저널을 purge 한다(ADR-0015): * model 는 replay 의 마스터라 구조가 바뀌면 과거 이벤트의 전제가 깨진다. 좌표(layout)만 바뀌면 저널 보존. */ /** * 실행 중이면 구조를 교체하지 않는다 — **판정 문장을 한 곳에 둔다.** * * 인제스트는 전량 교체라, 이벤트를 계산하고 있는 커널 밑에서 바닥을 바꾸는 셈이 된다. 지금은 거절이 * 유일한 답이지만 최종형은 아니다(`adoptStructure` 배선). 두 곳에서 같은 문장으로 거절해야 * 나중에 이 규칙을 걷어낼 때 걷어낼 것이 하나로 보인다. */ private static assertNotRunning; /** * 돌면서 공장을 전환한다 — **미러 전용.** * * 미러가 비추는 현실은 안 멈춘다. 설비 한 대가 늘었다고 트윈을 멈췄다 세우면 그 사이의 사실을 * 잃고, 라이브에서 유실은 곧 거짓이다. 그래서 미러는 **동작 중에** 새 구조를 받는다. * * 시뮬레이션은 여기 오지 않는다. 조건이 바뀐 실험은 다른 실험이므로 멈추고 다시 세우는 것이 * 옳다 — 커널이 그렇게 거절한다(`FlowEngine.adoptStructure`). * * 세 가지가 **함께** 일어나야 한다. 하나라도 빠지면 층이 어긋난다: * ① 리비전 — 이 시점 이후의 이벤트가 새 번호를 달고 다닌다(재생이 마디를 나눌 수 있게) * ② 저장 model·구조 행 — 조회가 새 구조를 본다 * ③ 커널 — 지금 계산에 들어가는 이벤트가 새 구조 위에서 계산된다 */ static adoptStructure(domainId: string, instanceId: string, model: TwinModelDef, comment?: string, origin?: any): Promise<{ rev: number; } & StructureShift>; static provision(domainId: string, instanceId: string, kind: string, model: TwinModelDef, /** * **재기동 정책은 생성 시점의 선언이다**(ADR-0029 §2) — 트윈은 정책 없이 존재할 수 없다. * * 예전에는 이 자리가 없었고 `register` 가 기본값(`sim-experiment` = 저널 초기화)을 각인했다. 그래서 * 선언을 빠뜨린 프로비저닝이 **재기동마다 이력을 지우는 트윈**을 알리지 않고 만들었다. 이제 만드는 쪽이 * 말해야 한다: 이 트윈이 자기 과거를 어떻게 대하는지는 만드는 사람이 아는 사실이다. */ restartPolicy: RestartPolicy, comment?: string, origin?: any, /** 이름·저자 — 업무키를 나눠 둔 대가로 이름을 함께 남긴다(`register` 의 `meta` 그대로). */ meta?: { name?: string; description?: string; actor?: { id: string; }; }): Promise; /** * 이 구조를 리비전으로 남기고 그 번호를 돌려준다 — **바뀌었을 때만** 새 번호가 생긴다. * * 프로비저닝은 부팅마다 다시 도는데, 그때마다 리비전이 늘면 이력이 뜻 없는 마디로 잘게 쪼개진다. * 그래서 **구조 서명이 같으면 있던 리비전을 그대로 쓴다.** */ static recordStructure(domainId: string, instanceId: string, model: TwinModelDef, comment?: string): Promise; /** * 이 트윈이 거쳐 온 구조들 — **무엇이 언제 달라졌나.** * * 리비전은 그 시절 공장을 통째로 담지만(재생에 필요하다), 사람이 보고 싶은 것은 통째가 아니라 * 차이다. 모델은 빼고 **차이 요약만** 내보낸다 — 62KB 짜리 보드를 화면에 실어 보낼 이유가 없다. */ static structureHistory(domainId: string, instanceId: string): Promise<{ rev: number; createdAt?: Date; comment?: string; events: number; diff: StructureDiff; }[]>; /** 지금 쓰이는 구조 리비전 — 이벤트에 찍을 번호. 아직 없으면 기록하며 만든다. */ static currentStructureRev(domainId: string, instanceId: string, model?: TwinModelDef): Promise; /** 구조 동일성 지문 — 좌표(layout) 등 뷰 관심사는 제외하고 커널이 보는 위상·용량·무버만. */ /** * 지문을 **64자로 줄인다** — 컬럼이 varchar(64) 다. * * 원문 지문은 자리·설비를 전부 이어 붙인 문자열이라 큰 공장에서는 수만 자가 된다. sqlite 는 * 길이를 강제하지 않아 그냥 들어가지만 **Postgres 는 거기서 터진다** — 개발에서는 멀쩡하고 * 운영에서만 죽는 종류의 실패다. 비교에만 쓰는 값이므로 해시로 충분하다. */ private static structureFingerprint; private static structureSignature; /** 레지스트리 model 로 기동(프로비전된 인스턴스 start). model 인자 없이 저장된 구조로 재기동. */ static startFromRegistry(domainId: string, instanceId: string, restartPolicy?: RestartPolicy): Promise; /** * 선언 기반 부팅(§0 프레임 ① → 부팅 거동 매핑) — 트윈이 선언한 restartPolicy 에 따라 재기동 방식을 구분한다. * 부팅 경로를 한 곳에 모아 "런타임 ≠ 현실" 범주오류를 코드로 강제한다. * - 'resync' : 현실=외부 실물 → startLive(재동기). 이벤트는 어댑터 ingest 로 유입, 커널 tick 없음. * - 'resume' : 생성 타임라인 지속 → 저널 보존 + resume(현실 이어감). * ⚠ GATE(§8 ②③): 커널 상태 직렬화/재개가 아직 없어 진짜 resume 불가 → 실 필요·실부하 시 구현. * 그때까지는 저널을 보존하되 revision 충돌을 피하려 fresh 로 기동하고, 선언과 구현의 간극을 크게 경고한다. * - 'reset' : seed 재현 → resetJournal + fresh 기동(백지 시작이 버그가 아니라 선언된 거동). * 반환: 기동된 InstanceRuntime. */ static bootDeclared(domainId: string, instanceId: string, mode: RestartPolicy): Promise; /** * 저널 초기화 — 인스턴스 이벤트 저널 purge(레지스트리·상태는 유지). * 데모 fresh-start 용: 매 부팅 seed 부터 새로 실행하는 데모에서, 복구본 폐기 후 revision 0 부터 재실행하면 * 옛 런과 revision 이 겹쳐 저널이 오염된다 → start 전에 호출해 현재 런만 남긴다. */ static resetJournal(domainId: string, instanceId: string): Promise; /** 삭제 — 정지 + 레지스트리 삭제 + 저널 purge(domain 스코프). */ static remove(domainId: string, instanceId: string): Promise; /** * 이 트윈에 대해 **메모리에 남은 것을 전부 잊는다** — 지움의 한 자리. * * 캐시가 라이프사이클과 어긋나 있었다: 저널·등록부는 지우면서 인스턴스별 캐시는 남겨, 삭제된 트윈의 * 항목이 **서버 재기동까지 살아 있었다.** 항목 하나가 하루치 KPI 그룹을 물고 있으므로 잊지 않으면 * 지운 트윈이 계속 메모리를 차지한다. TTL 은 값이 낡는 것을 막을 뿐 **없어진 주인을 지우지 않는다.** * * 잊을 것을 여기 모아 두는 이유: 캐시를 새로 만드는 사람이 지우는 자리를 찾아 헤매지 않게 한다. * 새 인스턴스별 캐시를 만들면 **이 함수에 한 줄을 더한다** — 그러지 않으면 같은 누수가 다시 생긴다. */ private static forgetInstance; /** 관리 목록 — domain 의 등록 인스턴스 전체(라이브 여부·최신 저널 revision·노드/무버 수 포함). */ static list(domainId: string): Promise; /** 운영 공간 목록(벤치 소스 선택기) — 이름·규모 포함, 벤치 사본 제외. */ static listSpaces(domainId: string): Promise<{ spaceId: string; name: string; instances: number; locations: number; equipment: number; }[]>; /** 인스턴스 목적 표식(운영/벤치) 설정 — 벤치 사본을 1급 속성으로 구별(이름 접두사 아님). */ static setPurpose(domainId: string, instanceId: string, purpose: 'operational' | 'bench', copyOf?: string): Promise; /** * 공간 구조 판독 — 완전복제(벤치 사본)의 소스. 저장물이 model(위상)·TwinSpace.content(치수·geo·표현)·TwinArea(영역)로 * 분산돼 있으므로 셋을 모아 돌려준다. 호출측이 ReferenceMaster 로 재구성 → 재식별 → ingestMaster. */ static spaceStructure(domainId: string, spaceId: string): Promise<{ name?: string; /** 설계 좌표계 범위(미터)와 지오레퍼런스 — 사본이 원본과 같은 자리에 서려면 함께 가야 한다. */ frame: { extentX?: number; extentY?: number; geo?: any; primaryRepresentationId?: string; }; areas: any[]; instances: { instanceId: string; kind?: string; model: any; }[]; } | null>; /** 공간(+표현·영역) 삭제 — 벤치 정리 등. 호출측이 바인딩 인스턴스를 먼저 제거하는 전제(가드 없음). deleteTwinSpace 리졸버와 동형. */ static deleteSpace(domainId: string, spaceId: string): Promise; /** * Face2 마스터 인제스트(ADR-0018) — 레퍼런스 시스템(실 또는 가상)의 마스터를 읽어 트윈을 생성. * 엔티티를 손배선/발명하지 않고 마스터에서 반영: 공간(Space) upsert + 인스턴스 provision(미기동). * 노드타입은 커널 카탈로그로 검증(warning). start 는 호출측(bootstrap/mutation)이 결정. * * `into.spaceId` — **이 현장에 더한다.** 한 현실을 여러 렌즈(WMS·MES·YMS)가 비추므로 새 트윈이 * 기존 공간으로 들어갈 수 있다. 아래 병합은 원래 그 경우를 위해 있었는데(N:1) 만드는 흐름에서 * 공간을 고를 방법이 없었다 — 마스터가 파생한 id 만 쓰였다. 판정은 `resolveIngestSpace`(순수). */ static ingestMaster(domainId: string, master: ReferenceMaster, /** 만들어지는 트윈의 재기동 정책 — 원본이 정하는 것이 아니라 **만드는 쪽의 선언**이다(ADR-0029 §2). */ restartPolicy: RestartPolicy, into?: { spaceId?: string; }, /** 누가 인제스트했나 — 사람이 없는 경로(부팅)는 주지 않는다(감사 기록을 지어내지 않는다). */ actor?: { id: string; }): Promise<{ instanceId: string; spaceId: string; warnings: IngestWarning[]; }>; /** 단건 상세 — 프로비저닝 에디터가 편집할 model(구조+layout) 포함. */ static detail(domainId: string, instanceId: string): Promise; /** * 라이브 바인딩 브리지(P3, ADR-0018) — 트윈 엔티티 상태를 `data(tag=엔티티id)` 채널로 publish. * 보드 컴포넌트가 tag 로 구독(board-ui provider)해 `component.data` 로 라이브 갱신. delta 시에만(희소). * tag=엔티티 id(데모=단일 인스턴스). 멀티 인스턴스/보드 재사용 시 tag 네임스페이스는 후속. */ /** 브로드캐스팅 실패 로그 조절 — 창마다 한 줄(막힌 구독자는 초당 수십 번 실패한다). */ private static publishDrops; private static readonly PUBLISH_DROP_LOG_MS; /** * 한 번의 브로드캐스팅 — **구독자 하나가 호스트를 죽이지 못하게.** * * ── 무엇이 죽였나 (2026-08-14) ───────────────────────────────────────────── * 밀린 push 가 1024를 넘으면 pubsub 이 오류를 낸다(`RepeaterOverflowError`). 그 브로드캐스팅은 타이머 콜백 * 안에서 일어나므로 예외가 잡히는 곳 없이 올라가 **프로세스가 끝났다** — 트윈 14개가 도는 호스트가 * 소비를 멈춘 구독자 하나 때문에 통째로. * * 못 보낸 것은 **사실로 남긴다**(횟수를 세고 창마다 한 줄 남긴다). 삼키면 「보냈는데 화면이 낡았다」가 * 되고, 그건 가장 찾기 어려운 부류다. */ /** * 브로드캐스팅 한 건 — **이 가드가 대기열 넘침을 막지는 못한다** (2026-08-27 측정으로 바로잡음). * * 2026-08-14 에 이 함수를 「방송이 호스트를 죽이지 못하게」라는 이름으로 붙였다. 그것이 사실이 아니다. * 대기열이 넘칠 때 나는 예외는 `EventTarget` 이 부른 리스너 안에서 난다. 런타임은 리스너 예외를 * 호출자에게 주지 않고 uncaughtException 으로 보낸다. 그래서 이 try/catch 에는 잡을 것이 없다. * 실제로 그 뒤 3주 동안 막힌 줄 알고 있었고, 2026-08-27 10:24 에 같은 예외로 다시 죽었다. * * 넘침을 막는 것은 **한 실행 차례에 발행하는 수를 상한 아래로 두는 것**이다(§`PUBLISH_CHUNK`). * * 그래도 이 가드를 남기는 이유는 둘이다. `publish` 가 직접 던지는 경우(직렬화 실패 등)를 잡고, * 못 보낸 태그의 시그니처를 되돌린다 — 남겨 두면 그 태그가 오류 없이 영원히 낡은 값을 보인다. */ private static publishGuarded; static publishEntityData(inst: InstanceRuntime): Promise; /** * 재부팅 복구 / 시간여행 — DB 저널을 replay 해 상태 재구성. * model 는 레지스트리(TwinInstance)에서, 이벤트는 TwinEvent(revision ASC)에서. * 커서 두 축(runtime-state-model §4): **시각(untilTime, 사용자 모국어·공간 공통축)** 우선, 없으면 리비전(untilRevision, 렌즈 내부). * - untilTime 주면 `eventTime ≤ T` 인 이벤트만(그 시점 watermark) — 공동배치 여러 트윈을 하나의 시각 T로 통일 해소. * - 둘 다 없으면 최신(라이브 인메모리 진실). * 이벤트는 항상 revision ASC(인과 순서)로 적용 — 시각 필터는 컷오프 집합만 정한다. */ static recover(domainId: string, instanceId: string, untilRevision?: number, untilTime?: string): Promise; /** * 공간(공동배치) 시각 범위 — 스크러버 앵커(runtime-state-model §4·§6). 그 공간 전 인스턴스 저널의 min/max eventTime. * 반환 {minTime, maxTime}(ISO) — 이벤트/시각 없으면 null. 클라 히스토리 스크러버가 이 범위를 시각축으로 그린다. * * ── 두 수를 구하려고 저널을 다 읽지 않는다 (2026-08-20 실측으로 잡음) ──────── * 여기서 인스턴스마다 `find()` 로 **행을 전부 엔티티로 하이드레이션**한 다음 JS 에서 min/max 를 * 골랐다. 그런데 이 함수는 화면 상단의 컨텍스트 띠가 **4초마다** 부른다. 실측한 개발 서버에서 * `order-check` 한 트윈이 275,882행 · `payload` 131MB 였다 — 4초마다 그 JSON 을 전부 파싱한 것이다. * * 그 결과가 이랬다: 프로세스 CPU 81~152%, 아무 일도 하지 않는 질의가 8~14초. JS 프로파일의 상위가 * TypeORM 의 `RelationIdLoader`·`RawSqlResultsToEntityTransformer`·`stringToSimpleJson`(= `payload` * 파싱)이고 GC 가 16.7% 였다. **트윈 틱도 저널 쓰기도 아니라 이 읽기였다.** * * 집계는 DB 가 한다. `(domain, instanceId, eventTime)` 인덱스가 이미 있어(`ix_twin_event_1`) * 인덱스의 **양 끝을 집는다** — 행을 하나도 실어 오지 않는다. * * ── 왜 MIN 과 MAX 를 한 문장에 넣지 않나 (실측) ───────────────────────────── * 처음에 `SELECT MIN(...), MAX(...) ... WHERE instanceId IN (...)` 한 방으로 두었더니 **21ms** 였다. * 집계가 **둘이면** 옵티마이저의 「인덱스 끝을 집는」 최적화가 걸리지 않아 인덱스 구간을 훑는다 — * 즉 비용이 여전히 **행 수에 비례**한다. 트윈마다 단일 집계로 나눠 물으면 **0.32ms**(트윈 3개 · 6왕복) * 이고, 비용이 **트윈 수에 비례**한다. 규모 기준(엔티티 10만)에서는 이 차이가 본질이다. * * 왕복은 트윈당 둘이지만 **함께 띄운다** — 원격 DB 에서 직렬로 돌면 왕복 지연이 그대로 쌓인다. * * 드라이버 다섯을 다 지나가야 하므로 raw SQL 을 쓰지 않는다(쿼리빌더의 MIN/MAX 는 이식된다). * 돌려주는 값의 **모양은 드라이버마다 다르다**(문자열·Date) — 받은 뒤에 한 번 정규화한다(`parseTime`). */ static timeRange(domainId: string, spaceId: string): Promise<{ minTime: string | null; maxTime: string | null; }>; /** 정지 훅 — 인스턴스 정지 시 부수자원 정리(예: reference-live 피드 unsubscribe). 순환 임포트 없이 결선. */ static stopHooks: ((id: string) => void)[]; static onStop(fn: (id: string) => void): void; static stop(domainId: string, id: string): Promise; /** * 같은 현장 트윈들의 **설비 상태**를 커널에 넘긴다 — 커널이 요청할 때만. * * ── 경계 (2026-08-14) ────────────────────────────────────────────────────── * 호스트가 아는 것은 **누가 같은 현장에 있는가** 하나다. 무엇을 읽고 그것을 어떻게 부하로 바꿀지는 * 커널이 정한다(계수는 트윈의 모델이 선언한다). 호스트가 kW 를 계산해 주면 트윈마다 다른 규칙이 * 생기고, 그 규칙은 어디에도 적혀 있지 않게 된다. * * 상태만 넘긴다. 스냅샷을 통째로 넘기면 상태 크기에 비례한 비용을 매 tick 치르게 된다 — * 설비 목록은 그 트윈의 커널이 이미 들고 있는 것이라 훑는 값이 싸다. */ private static feedPeerEquipment; private static tickGuarded; /** * 호스트를 굶기는 트윈을 멈춘다 — **도는 척하는 것보다 멈춘 것이 낫다.** * * 굶김 문턱을 넘는 틱이 연속 `STARVE_STREAK` 번이면 그 트윈은 구조적으로 무겁다(우연이 아니다). * 문턱 아래로 한 번만 내려와도 연속을 끊는다 — 무거운 순간 하나로 트윈을 내리지 않는다. */ private static guardStarvation; /** 멈추고 **이유를 남긴다** — 이유 없는 「정지」는 사람이 자기가 멈춘 것으로 읽는다. */ private static stopWithNote; /** 이 트윈이 스스로 멈춘 이유(있으면) — 화면이 옮겨 말한다. */ static stopNoteOf(domainId: string, id: string): StopNote | undefined; /** * 이 트윈이 **지금 실행 중인가** — 판정의 집 하나. * * "스냅샷이 있나" 로는 답이 되지 않는다: 웜스타트가 되살린 상태가 캐시에 남아 있으면 멈춘 트윈도 * 스냅샷을 낸다. 그것을 관측 중으로 읽으면 화면이 **멈춘 트윈의 작업을 0 건이라고 단언**한다 * (실제로 그렇게 나왔다 — 관측이 없는 것과 0 건은 다르다는 이 프로젝트의 규율을 화면이 어겼다). * * 목록이 쓰는 것과 **같은 술어**다(`list()` 의 `running`) — 두 벌이면 어긋난다. */ static isRunning(domainId: string, id: string): boolean; static runtime(domainId: string, id: string): TwinRuntimeType | undefined; static dispatchCommand(domainId: string, instanceId: string, command: any): Promise<{ accepted: boolean; error?: string; errorCode?: string; errorParams?: Record; }>; /** * 도메인 소유 게이트 — 인스턴스가 이 도메인 소유일 때만 true(테넌트 격리). * 없거나 다른 도메인이면 false. 노출 리졸버(per-id metrics/attentions/state/stop/forecast)가 * this.instances[id] 를 만지기 전에 반드시 이걸로 확인해야 한다. 인스턴스 id 는 추측 가능하므로 * 검증 없이 접근하면 크로스테넌트 읽기/정지가 가능해진다. */ /** * 이 런타임의 **관측 모드** — `'live'`(외부 실물을 미러) 또는 `'sim'`(커널이 실행한다). 기동 중이 아니면 없다. * * 소비처가 `instances[id].mode` 를 직접 읽던 자리를 대신한다. 레지스트리를 밖에 열면 도메인 확인이 * 자리마다 제각각이 되고, 실제로 그렇게 됐다(테넌트 격리 전수 확인, 2026-08-06). */ static modeOf(domainId: string, id: string): 'live' | 'sim' | undefined; static owns(domainId: string, id: string): boolean; /** * 이 트윈의 **중복 판정 기억을 미리 채운다** — 저널에서 읽은 정체로. * * 기억은 프로세스 안에만 있어서 재기동하면 비워진다(§`FactDeduper`). 그 뒤 backfill 을 돌리면 * 이미 저널에 있는 사실도 「처음 본 것」이 되어 다시 앉는다. 실측으로 39건이 통째로 다시 앉았다 * (2026-09-06, `mes-line-a`). * * 안 도는 트윈에는 채울 곳이 없다 — 0을 낸다(거짓이 아니라 사실이다). */ static warmDeduper(domainId: string, id: string, identities: readonly string[]): number; /** * 이 프로세스가 지금 들고 있는 트윈들 — 테넌트로 좁혀서. * * 주기 작업(규칙 판정 등)이 「무엇을 돌아야 하나」를 물을 자리다. 등록부(DB)를 읽으면 **다른 프로세스가 * 든 트윈까지 나온다** — 그것을 돌면 같은 조치가 두 벌 올라간다. 도는 것은 든 쪽이 돈다. * * `owns()` 와 같은 술어를 쓴다(`this.instances`). 두 벌이면 어긋난다. */ static ownedTwins(domainId?: string): { domainId: string; instanceId: string; }[]; /** * 이 트윈이 이 테넌트 것인가 — **떠 있든 아니든.** * * `owns()` 는 **기동 중인** 런타임만 안다. 그것을 이력 조회의 관문으로 쓰면, 꺼진 트윈의 저널을 * 읽으려 할 때 "이 테넌트에 없다" 는 답이 돌아온다 — 두 가지가 틀렸다. 첫째, 저널은 트윈이 꺼져 * 있을 때 **가장 필요한 것**이다(그게 이력의 존재 이유다). 둘째, 그 문장은 남의 것이라는 뜻이라 * 사용자가 권한 문제로 오해한다. 실제로는 그냥 실행 중이 아닐 뿐이다. * * 소유는 **등록부**가 안다. 이력·집계처럼 런타임과 무관한 질문은 이쪽에 묻는다. */ static ownsRegistered(domainId: string, instanceId: string): Promise; /** * **이 공간의 인스턴스들** — 한 현실을 여러 렌즈가 비추므로, 공간을 물으면 그 렌즈 전부를 답한다. * * 화면이 이 규칙을 손으로 짜지 않게 서버가 답한다. 예측 화면은 같은 확장을 **한 파일에서 두 번** * 복제하고 있었다(전체 목록을 받아 클라이언트에서 걸렀다) — 규칙이 흩어지면 한쪽만 고쳐진다. * * 떠 있지 않은 것도 포함한다: 공간에 무엇이 있는지는 기동 여부와 다른 사실이다. */ static instanceIdsOfSpace(domainId: string, spaceId: string): Promise; /** * **이 공간에서 봐야 할 것** — 공간의 모든 렌즈에서 신호를 모은다. * * 합집합이 그대로 뜻이 있는 유일한 렌즈다(예측은 합칠 수 없다 — P50 두 개를 더할 수 없다). * 정렬·잘라내기는 화면과 **같은 규칙**을 쓴다(`digestAttentions`) — 서버가 고른 상위 N 이 화면이 * 고를 N 과 달라지면 그 어긋남은 아무 데서도 오류로 드러나지 않는다. * * 신호마다 어느 트윈에서 왔는지(`instanceId`)를 붙인다. 공간에서 보면 같은 자리 id 가 렌즈마다 * 다른 것을 가리킬 수 있고, 조치는 결국 그 트윈에 보내야 한다. */ static attentionsOfSpace(domainId: string, spaceId: string, limit?: number): Promise<{ attentions: any[]; attentionTotal: number; severityByLocation: Record; }>; /** 라이브 커널(ForecastTwin) — forecast/divergence 예측 연산용. */ static kernel(domainId: string, id: string): any; /** 라이브 처리량 계측 스냅샷(모니터, ④-1) — 내부 누적(_acc*) 제외한 공개 지표. live 아니면 null. */ static metrics(domainId: string, id: string): any; /** * 부하 계기판만 따로 — **시뮬 트윈도 포함한다.** * * `metrics()` 는 라이브 지표(`inst.metrics`)가 있어야 무언가를 돌려준다. 그런데 이벤트 루프를 점유하는 * 것은 주로 **시뮬 틱**이고, 시뮬 인스턴스에는 그 지표가 없어 통째로 안 보였다. */ static load(domainId: string, id: string): any; /** * **한 곳에서 보는 전체 부하** — 몇 개가 실행 중이고, 누가 루프를 점유하고 있나. * * 시뮬 트윈도 포함한다(`metrics()` 는 라이브 지표가 있어야 답해서 시뮬이 통째로 안 보였다). */ static fleet(domainId?: string): any; /** 이 트윈에서 fork 가 만들어졌다 — 예측 한 번이 수십 개를 만든다. */ static countForks(domainId: string, id: string, count: number): void; /** 엔진 밖(예측 질의 등)에서 무거운 작업을 잰다 — 같은 계기판에 모인다. */ static recordLoad(domainId: string, id: string, phase: LoadPhase, tookMs: number): void; private static ingestLedgers; /** 창이 닫힐 때 부를 곳 — 영속 계층이 부팅에서 한 번 등록한다(커널 층은 그 계층을 모른다). */ private static onWindowClosed?; /** * 닫힌 창을 받을 곳을 등록한다 — **영속(B)이 붙는 유일한 문.** * * 이 패키지는 저장 계층을 모른다. 등록하는 쪽이 자기 방식으로 쓴다. 훅이 오류를 내도 장부는 계속 굴러간다 * (`rollIngestWindow` 가 감싼다) — 영속을 지키려고 관측을 멈추지 않는다. */ static onIngestWindowClosed(fn: (at: { domainId: string; instanceId: string; }, closed: IngestWindow) => void): void; /** * **모든 트윈의 창을 굴린다** — 보는 사람이 없어도 창이 닫히게. * * ── 무엇이 있었나 (2026-09-05 실측) ──────────────────────────────────────── * 창은 「다음 사건이 왔을 때 이전 창을 닫는」 모양으로만 굴렀다. 그러면 사실이 드문 트윈은 창이 * 계속 열려 있고, 표에 아무것도 안 남는다. 두 수가 그것을 정확히 말한다. * * mes-miratek 사실 46건 · 62분에 흩어짐 창 1개 * mes-line-a 사실 11건 창 48개 * * **사실 수와 창 수가 거꾸로다.** 창을 만든 것이 유입이 아니라 **누가 물어본 것**이다 — * `ingestHealthOf()` 가 굴리므로, 점검표·화면이 계속 묻는 트윈만 기록이 쌓였다. * * 그래서 **아무도 안 보는 트윈일수록 기록이 없다.** 사실이 조용히 사라지는 것을 잡으려고 만든 * 장부가 정확히 그 반대로 돌고 있었다. 실제로 137봉투가 장부 없이 지나갔다(인티그레이션 레인 실측). * * ── 왜 주기인가 ──────────────────────────────────────────────────────────── * 창이 닫히는 이유는 **시간이 지난 것**이다. 사건도 질의도 그것을 알려 주는 신호일 뿐이고, 둘 다 * 안 올 수 있다. 시간은 늘 지나므로 주기가 그 사실에 맞는 신호다. * * 조용한 트윈이 자기 침묵을 기록하게 되는 것이 이 변경의 값이다 — `expected-quiet` 판정이 그 * 기록 위에 선다. */ static rollAllIngestWindows(nowMs?: number): number; /** * 한 번의 인제스트 결과를 장부에 적는다. * * `offered` 는 **제시된 레코드 수**다(거부 여부 무관). 통과율을 서로 다른 두 계수기에서 나눠 계산하면 * 분모와 분자가 다른 것을 세게 되므로, 한자리에서 본 수를 그대로 넘긴다. */ /** * 이 트윈의 저널이 지금 몇 번까지 적혔나 — **접근자로 낸다.** * * 레지스트리를 밖에서 직접 색인하지 않게 하는 것이 이 저장소의 규율이다(§`engine-registry-access`). * 지난 기록을 채우는 길이 다음 번호를 알아야 해서 열었다. * * 트윈이 도는 중이 아니면 0 이다 — 그때는 저널만 있고 이어 붙일 번호를 커널이 들고 있지 않다. */ /** * 마감된 구간 사실의 **이름을 지을 때 쓸 범위와 선언** — 유입 문에 넘긴다. * * ── 왜 호스트가 주나 (2026-08-30) ──────────────────────────────────────── * 커널은 `new Kernel(domainId, …)` 으로 서므로 **자기가 어느 트윈인지 모른다.** 그런데 설비 번호는 * 트윈 안에서만 통하는 이름표라, 한 도메인에 현장이 둘이면 서로 다른 설비의 같은 날이 한 사실이 * 된다. 실측으로 그 일이 났다 — 두 발전소의 `002` 가 겹쳐, 여러 현장을 함께 계산하는 성과 화면에서 * 한쪽 발전량이 사라진다. * * 선언된 정체성은 **커널이 답한다**(§`FlowEngine.equipmentIdentity`) — 판정을 두 곳에 두지 않는다. * 트윈이 돌고 있지 않으면 선언을 물을 곳이 없으므로 범위만 넘긴다(그때도 겹치지는 않는다). */ static factScope(domainId: string, instanceId: string): { scopeId: string; identityOf?: (kind: 'equipment' | 'meter', localId: string) => string | undefined; }; static journalHead(domainId: string, instanceId: string): number; /** * 저널에 그만큼 적었으니 번호를 밀어 준다. * * 밀어 주지 않으면 다음에 라이브가 **같은 번호를 다시 쓴다** — 같은 시각의 두 사실을 가릴 수 없게 된다. */ static advanceJournalHead(domainId: string, instanceId: string, by: number): void; static recordIngestResult(domainId: string, instanceId: string, offered: number, rejected: { record?: unknown; errors?: string[]; }[] | undefined, nowMs?: number, /** * 매핑은 통과했는데 **커널이 받지 않은** 수(`ingestLive` 의 반환과 견주어 얻는다). * * 「통과」로 세지 않는다 — 트윈이 멈춘 사이 사실이 사라지는데 화면이 100% 라고 말하게 된다. */ undelivered?: number, /** * **저널에 안 간 어휘 요소의 수** — 로트 번호·소비기한처럼 시각축이 없는 값. * * 사라진 것이 아니라 저널로 가지 않는 것이다. 그런데 그 수를 빼 주는 자리가 없으면 * 「보낸 것 − 앉은 것」이 안 맞고 **그 차이를 사라진 것으로 읽게 된다**(2026-09-05 실측: * 커서 20 · 저널 17 · 어휘 1 을 사람이 손으로 세서 「둘이 빈다」를 찾았다). */ vocabulary?: number): void; /** * **원본에 닿지 못했다**를 적는다 — 「받은 것이 없다」와 가른다(§`recordReadFailure`). * * 이 문이 없던 동안 실 원본이 끊겨도 트윈의 조회 가능한 상태에 그 사실이 없었다. 화면이 볼 수 있는 * 것은 「새 사실이 없다」뿐이었고 그것은 「연결된 시스템에서 들어온 것이 없다」와 구별되지 않는다 — 실증 중에 원본이 * 끊기면 사용자가 원인을 찾을 수 없다. * * 로그로는 말하고 있었다(어댑터가 재시도를 경고한다). 그러나 **로그는 사람이 볼 때만 값이 있다** — * 화면이 말하려면 상태에 있어야 한다. */ static recordIngestReadFailure(domainId: string, instanceId: string, reason: string, nowMs?: number, stream?: string, nextRetryMs?: number): void; /** * **끊겼다가 돌아왔다** — 실패 기록을 지우고 그 사실을 남긴다 (2026-08-27). * * `clearIngestReadFailure` 와 나누어 둔다: 지우기만 하면 「끊긴 적이 있었다」가 화면에서 사라진다. */ static recordIngestRecovered(domainId: string, instanceId: string, afterFailures: number, downMs: number, nowMs?: number): void; /** * 읽기가 성공했다 — 단절 기록을 지운다. * * **빈 읽기도 성공이다.** 원본이 「새 것이 없다」고 답한 것은 닿았다는 뜻이므로, 그때도 부른다. * 그 둘을 같게 두면 들어온 것이 없는 연결이 끊긴 원본으로 보인다. */ static clearIngestReadFailure(domainId: string, instanceId: string): void; /** * **읽었는데 창을 넘길 수 없다**를 적는다 — 위와 **조치가 반대인** 사실이다(§`recordCursorStall`). * * 이 문이 없던 동안 이 사실이 `recordIngestReadFailure` 로 나갔다. 그래서 화면이 「원본에 닿지 * 못한다」고 말했는데 원본은 **답한** 상태였고, 그 답의 모양이 커서를 이긴 것이었다(한 시각에 한 * 페이지보다 많은 행). 사람은 원본을 의심하고 기다리는데 **기다림으로는 영원히 풀리지 않는다.** * * 조치가 반대인 두 사실을 한 이름으로 부르면 그 이름은 정보가 아니라 오해다. */ static recordIngestCursorStall(domainId: string, instanceId: string, reason: string, nowMs?: number, stream?: string): void; /** * **원본에 있는데 세우지 않은 것을 적는다** — 이유와 수(§`IngestLedger.withheld`). * * 어댑터가 주기마다 불러도 된다: 장부가 이유로 묶어 **마지막 수로 덮는다**(누적하지 않는다). * `count: 0` 은 「그 이유가 풀렸다」로 그 줄을 지운다 — 알리지 않고 그치면 낡은 수가 남는다. */ static recordIngestWithheld(domainId: string, instanceId: string, reason: string, count: number, nowMs?: number): void; /** 창을 넘겼다 — 정체 기록을 지운다(풀린 정체가 화면에 남아 있으면 그것도 거짓이다). */ static clearIngestCursorStall(domainId: string, instanceId: string): void; /** * 저널에 **적은 것**을 같은 장부에 남긴다 — 유입과 같은 10분 창에. * * ── 왜 유입 장부에 넣나 ──────────────────────────────────────────────────── * 새 장부를 만들면 「닫힌 창만 최근」·「0 과 없음을 가른다」·영속을 두 벌 지켜야 하고, 그중 한 벌만 * 고쳐지는 것이 보통이다. 유입 창은 그 규율이 이미 들어 있고 닫힐 때 행으로 남는다. * * ── 이 값이 없으면 무엇을 못 보나 ────────────────────────────────────────── * 계기(`journalRate`)는 **지금**만 답한다. 프로세스를 다시 띄우면 0 에서 시작하므로 「어제 이 시각에도 * 이랬나」·「고친 뒤로 줄었나」를 물을 자리가 없다. 2026-08-20 에 고친 것이 바로 쓰기 경로이므로, * 되돌아가는 것을 볼 수 있어야 한다. */ static recordJournalWrite(domainId: string, instanceId: string, rows: number, nowMs?: number): void; /** * 「이 트윈이 현장과 맞춰지고 있나」 — 한눈 판정 + 추이 + 사유 + 표본. * * 조회할 때 창을 한 번 굴린다: 유입이 멈추면 다음 인제스트가 없어 창이 영원히 닫히지 않는데, 그러면 * 「최근」이 옛것으로 남는다. 타이머를 두지 않는 이유는 트윈마다 타이머를 걸면 그 타이머들이 다시 * 메인 루프에 얹히기 때문이다(오늘 확인한 그 부하를 이 기능이 다시 만들 이유가 없다). */ /** * 도메인의 트윈마다 **한 줄 요약** — 목록 화면과 미니 추이용. * * ── 왜 상세와 따로인가 ───────────────────────────────────────────────────── * `ingestHealthOf` 는 사유 문구와 **레코드 원문 표본**까지 낸다. 목록에 트윈이 스무 개면 그 원문이 * 스무 벌 실려 조회가 무거워지고, 화면은 어차피 그것을 그리지 않는다. 그래서 여기서는 **숫자만** 낸다. * * 추이는 창당 숫자 넷이라 스파크라인 하나에 충분하고 가볍다. * * ── 등록된 트윈을 기준으로 훑는다 ────────────────────────────────────────── * 도는 트윈만 훑으면 「고치려고 멈춰 둔 트윈」이 목록에서 사라진다 — 사람이 방금 멈춘 그것을 보려고 * 목록을 여는데 없으면 결함으로 읽는다. 그래서 호출부(목록 화면)가 아는 instanceId 들을 받는다. */ static ingestHealthBrief(domainId: string, instanceIds: string[]): { instanceId: string; verdict: string; acceptedRatio: number | null; trend: { offered: number; rejected: number; undelivered: number; rows: number; }[]; lastAt: string | null; }[]; /** * 한 트윈의 동기화 건강. * * ── 조회가 장부를 전진시킨다(의도) ───────────────────────────────────────── * 창은 **다음 유입이 있을 때** 닫힌다. 그래서 피드가 죽으면 마지막 창이 열린 채로 남아 영원히 영속되지 * 않고, 추이에도 들어가지 않는다. 조회 시점에 한 번 굴려 주면 그 마지막 창이 닫히면서 영속 훅도 불린다 * — 즉 **죽은 피드의 마지막 구간을 잃지 않기 위해** 읽기가 굴린다. 부수효과지만 필요한 부수효과다. * * 시각은 **한 번만 읽어** 굴리기와 판정에 같은 값을 쓴다. 두 번 읽으면 그 사이에 창이 닫혀 판정이 * 굴리기 전 상태를 보는 일이 생긴다. */ static ingestHealthOf(domainId: string, instanceId: string): IngestHealthView; /** * 도는 인스턴스 전체의 계측(모니터 대시보드용) — **시뮬과 미러를 함께**. * * 예전에는 시뮬에 계기가 없어 이 목록에서 알리지 않고 빠졌다(계기가 `null` 이라 걸러졌다). 도는 트윈 * 대부분이 시뮬인 서버에서 그 목록은 「부하가 거의 없다」로 보였다. */ static allMetrics(domainId?: string): Promise; /** * 예측용 커널을 **임의 시각 T 기준**으로 재구성 — 과거-vantage 예측(백테스트)·"그때 서서 본 미래". * recover(untilTime)로 T 시점 상태를, T 이하 오더 관측을 모아 hydrate → monteCarloForecast 가 T 에서 앞으로 실행한다. * sim/live 무관(저널 기반 재구성). untilTime 생략 시 최신. 도메인 스코프(reg·journal 조회가 domainId). */ static buildForecastKernelAt(domainId: string, instanceId: string, untilTime?: string): Promise; /** 현재 전체 스냅샷 — 라이브 우선, 없으면 저널 복구 캐시. */ static snapshot(domainId: string, id: string): any; }