/* * 라이브에서 **커널이 낸 사실**이 사라지지 않는다 — 저널에 남고, 인입은 두 번 적히지 않는다. * * ── 실측 (2026-08-14) ─────────────────────────────────────────────────────── * 에너지 트윈에 커넥터를 붙여 표본을 흘렸다. 저널에 `energy.measured` 48건이 쌓였는데 **구간 마감은 * 0건**이었다. 커널은 15분 경계에서 마감 사실을 내고 있었지만(`emitOp`) 라이브 경로에 그것을 구독하는 * 곳이 없어서 **커널 안에서 사라졌다.** 저널을 읽는 성과 화면의 전력 타일은 영원히 나오지 않는다. * * sim 은 `runtime.subscribe` 로 같은 일을 한다 — 라이브에만 그 배선이 없었다. * * 이 시험이 지키는 것 둘: * ① 라이브 기동은 커널 사건을 구독한다(그 사실이 저널로 간다) * ② 인입 봉투는 **한 번만** 적힌다 — `apply()` 가 그것을 구독자에게 되흘리기 때문이다 * * 엔진은 DB·shell 을 물고 있어 단위 시험이 불러올 수 없다. 그래서 **배선의 모양**을 소스로 지킨다 — * 실제 거동은 실서버에서 확인했고(그때 이 결함이 드러났다), 여기서는 그 배선이 사라지지 않게 한다. */ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') /** 그 정적 메서드의 본문만 — 다음 메서드 선언까지. `async` 도 찾는다(빠뜨리면 있는 메서드를 없다고 한다). */ function methodOf(name: string): string { const at = [`static ${name}(`, `static async ${name}(`].map(sig => src.indexOf(sig)).find(i => i >= 0) ?? -1 assert.ok(at >= 0, `${name} 가 없다 — 이름이 바뀌었으면 이 검사도 함께 고친다`) const next = src.indexOf('\n static ', at + 10) return src.slice(at, next < 0 ? undefined : next) } test('라이브 기동이 커널 사건을 구독한다 — 판정이 저널로 가는 유일한 길', () => { const body = methodOf('startLive') assert.match(body, /kernel\.onEvent\?\.\(/, '구독이 없으면 커널의 마감·피크·제안이 사라진다') assert.match(body, /pendingJournal/, '구독한 사실은 저널 배치로 가야 한다') /* 예전에는 `unsub: () => {}` 로 아무것도 구독하지 않았다 — 그 모양으로 되돌아가지 않게 못 박는다. */ assert.equal( /unsub: \(\) => \{\}[\s\S]{0,80}$/.test(body.trim()), false, 'startLive 가 아무것도 구독하지 않는 모양으로 되돌아갔다' ) }) test('인입 봉투는 저널에 한 번만 적힌다 — apply 가 그것을 되흘린다', () => { const start = methodOf('startLive') const ingest = methodOf('ingestLive') /* 표시하고(ingest) 걸러낸다(구독). 둘 중 하나만 있으면 인입이 두 번 적히거나 표시가 무의미해진다. */ assert.match(ingest, /applying\?\.add\(/, '인입 봉투를 표시해야 한다') assert.match(start, /applying\.has\(/, '표시된 봉투는 구독에서 건너뛰어야 한다') }) test('식별자가 아니라 **객체 동일성**으로 가른다 — 어댑터의 id 규칙에 기대지 않게', () => { const start = methodOf('startLive') assert.match(start, /WeakSet/, '약한 참조로 표시한다(따로 비울 것이 없다)') /* eventId 비교는 어댑터가 id 를 어떻게 만드는지에 기대게 되어 약하다 — 그 방식으로 되돌아가지 않게. */ assert.equal(/applying\.has\(e\.eventId\)/.test(start), false, 'id 비교로 되돌아갔다') }) test('커널이 낸 사실도 브로드캐스팅을 예약한다 — 피크·마감은 상태를 바꾼다', () => { const body = methodOf('startLive') assert.match(body, /inst\.dirty = true/, '상태가 바뀌었으므로 다음 주기에 브로드캐스팅해야 한다') assert.match(body, /ensureBroadcastCoalescer\(\)/, '브로드캐스팅은 주기로 합친다(사건마다 브로드캐스팅하면 폭발한다)') }) /* * ── 구조 전환도 브로드캐스팅한다 (2026-08-18) ─────────────────────────────────────── * 화면이 보는 것 상당수가 구조에서 파생된다(계약 대비 판정·주목 신호·자리 색). 구조 전환이 알리지 않고 * 끝나면 지도 레일은 다음 계측 표본이 올 때까지 옛 화면을 들고 있고, 헤더 배지는 폴링으로 먼저 알아 * 「배지엔 있고 목록엔 없는」 어긋난 화면이 된다(실화면에서 봤다). */ test('구조 전환은 상태 브로드캐스팅을 예약한다 — 다음 표본까지 화면이 옛것을 들고 있지 않게', () => { const body = methodOf('adoptStructure') assert.match(body, /inst\.dirty = true/, '구조가 바뀐 순간이 상태가 바뀐 순간이다') assert.match(body, /ensureBroadcastCoalescer\(\)/, '새 경로가 아니라 있는 병합 규율에 얹는다') /* 커널 전환보다 먼저 브로드캐스팅하면 구독자가 옛 스냅샷을 되물어 간다 — 순서가 규율이다. */ assert.ok( body.indexOf('inst.kernel.adoptStructure(model)') < body.indexOf('inst.dirty = true'), '커널을 갈고 나서 브로드캐스팅한다' ) }) /* * ── 자를 것을 알면서 다 읽어 오지 않는다 (2026-08-18 실측) ─────────────────── * 저널을 전량 읽어 온 뒤 JS 에서 걸렀다. 27만 건짜리 트윈에서 한 시간 전 상태를 물으면 7.4초가 걸렸고, * 그 대부분이 「필요 없는 행을 읽어 오는 시간」이었다. 시각·리비전 상한은 SQL 이 아는 조건이다. * * 시간 측정은 도는 서버에서 크게 흔들리므로(같은 질의 596~1547ms) **배선**을 지킨다. */ test('저널 상한은 SQL 에서 자른다 — 읽고 나서 버리지 않는다', () => { const body = methodOf('recover') assert.match(body, /eventTime: LessThanOrEqual\(new Date\(cutoffMs\)\)/, '시각 상한은 질의 조건이다') assert.match(body, /revision: LessThanOrEqual\(untilRevision\)/, '리비전 상한도 질의 조건이다') /* 시각이 비어 있는 옛 행은 함께 가져온다 — SQL 로 자르면 그 행들이 빠져 예전과 다른 결과가 된다. */ assert.match(body, /eventTime: IsNull\(\)/, '시각 없는 레거시 행을 잃지 않는다') /* 읽어 온 뒤 다시 거르지 않는다 — 두 벌이 되면 어느 쪽이 진짜 조건인지 흐려진다. */ assert.equal(/rows\.filter\(/.test(body), false, '읽고 나서 또 거르지 않는다') }) test('과거 화면의 「지금」은 잘라 낸 뒤의 마지막 사실이다 — 미래 이벤트를 쓰지 않는다', () => { const body = methodOf('recover') /* 예전에는 전량(rows)의 마지막 시각을 썼다 — 물어본 시점보다 뒤의 이벤트라 과거 화면이 미래를 기준으로 「늦었다」를 판정할 수 있었다. 이제 rows 는 이미 잘린 것이다. */ assert.match(body, /const wanted = rows/, '자른 것과 계산하는 것이 같아야 한다') }) /* * ── 재개점에서 이어서 계산한다 (2026-08-18) ────────────────────────────────────── * 계산한 결과는 이미 남기고 있었지만, 그 지점의 **재개점**(리듀서 내부 상태 전부 + 가동 누적기)이 없어서 * 이벤트가 하나만 늘어도 0부터 다시 계산해야 했다. 동치는 커널 시험이 증명한다(0부터 == 재개점 + 꼬리). * 여기서는 **호스트가 그 씨앗을 실제로 쓰는지**를 지킨다 — 배선이 사라지면 지름길은 오류 없이 사라진다. */ test('폴드가 재개점을 쓰고, 새 재개점을 함께 남긴다', () => { const body = methodOf('recover') assert.match(body, /replayFrom\(model, resume\.fold\.reducer/, '씨앗이 있으면 꼬리만 계산한다') assert.match(body, /revision: MoreThan\(from\)/, '읽는 것도 그 뒤만') assert.match(body, /oee\.restore\(resume\.fold\.oee\)/, '가동 누적기도 이어받는다(없으면 가용률이 작아진다)') assert.match(body, /replayWithCheckpoint\(/, '씨앗이 없을 때도 새 재개점을 만든다') assert.match(body, /keep\(withNow\(out\.state\), \{ reducer: out\.checkpoint, oee: oee\.serialize\(\) \}\)/, '재개점을 함께 저장한다') }) test('씨앗을 좁게 쓴다 — 캐시가 사실을 이기지 않는다', () => { const body = methodOf('recover') /* 지금을 물었을 때만·구조가 그대로일 때만. 시간여행은 목표 이전 재개점이 필요하고(사슬은 다음 단계), 구조가 바뀌면 그 경계에서 갈라 계산해야 한다. */ assert.match(body, /asOfNowRead && tip && cachedForResume && cachedForResume\.fold/, '지금 읽기에만 쓴다') assert.match(body, /\(cachedForResume\.structureRev \?\? null\) === tip\.structureRev/, '구조가 같을 때만 쓴다') assert.match(body, /segments\.length <= 1/, '구조가 갈린 폴드에는 쓰지 않는다') }) /* * ── 재개점 **사슬** (2026-08-18) ───────────────────────────────────────────── * 최신 재개점 하나로는 과거를 도울 수 없다 — 그것은 언제나 목표보다 뒤에 있다. 그래서 지점을 여러 개 * 남기고, 과거를 물으면 **목표 직전** 지점에서 계산한다. 지점은 각각 상태 전체를 들고 있어 무겁기 때문에 * 눈금(리비전 간격)을 넘을 때만 남기고 최근 몇 개만 든다 — 더 과거는 0부터 계산한다(알려진 한계). */ test('과거 읽기는 목표 직전 지점에서 계산한다 — 사슬을 쓴다', () => { const body = methodOf('recover') assert.match(body, /this\.chainSeedFor\(domainId, instanceId, \{/, '과거 씨앗을 사슬에서 고른다') assert.match(body, /structureCount <= 1/, '구조가 갈린 트윈에서는 쓰지 않는다') assert.match(body, /revision: And\(MoreThan\(from\), LessThanOrEqual\(untilRevision\)\)/, '씨앗 뒤 · 목표 이내만 읽는다') assert.match(body, /this\.keepChainPoint\(domainId, instanceId, \{/, '지점을 남긴다') }) test('시각으로 물으면 지점의 시각도 목표 이내여야 한다 — 짐작하지 않는다', () => { const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') const at = src.indexOf('private static async chainSeedFor(') const body = src.slice(at, src.indexOf('\n private static async keepChainPoint(', at)) assert.match(body, /if \(!Number\.isFinite\(t\) \|\| t > target\.timeMs\) continue/, '시각을 모르는 지점은 쓰지 않는다') assert.match(body, /if \(!value\?\.fold\?\.reducer\) continue/, '재개점 없는 지점은 씨앗이 아니다') }) test('사슬은 눈금을 넘을 때만 자라고, 최근 몇 개만 든다', () => { const src = readFileSync(fileURLToPath(new URL('../server/engine/twin-engine.ts', import.meta.url)), 'utf-8') const at = src.indexOf('private static async keepChainPoint(') const body = src.slice(at, src.indexOf('\n /** 이 트윈 저널의 끝 리비전', at)) assert.match(body, /value\.revision - newest < this\.CHAIN_STRIDE\) return/, '눈금을 넘지 않으면 남기지 않는다') assert.match(body, /slice\(-this\.CHAIN_KEEP\)/, '최근 몇 개만 든다') /* 공용 캐시에 삭제 API 가 없다는 사실을 숨기지 않는다 — 버린 지점은 TTL 까지 남는다. */ assert.match(body, /values expire with the cache TTL/, '버린 지점의 운명을 말한다') })