quad/.claude/base/state-epoch-plan.md
qwreey 56f8269236
design: M2/M3 마일스톤 순서 교체 — 반응형 코어를 먼저, 디스패치를 그 위에
`question.md`에 열려 있던 마지막 항목(마일스톤 경계)을 사용자가
**(a) 순서 교체**로 닫았다 — 이제 **M2 = 반응형 코어(Source/State/Store),
M3 = 디스패치 엔진**이다. 결정과 기각된 선택지는
`archive/question-resolved.md`의 "마일스톤 경계" 절, 경위와 시행착오는
`session/2026-08-24-02-milestone-order-swap.md`.

## 왜 (a)인가

의존이 양방향처럼 보였지만 실제로는 한 방향이었다 — 디스패치→반응형은
*본체* 의존(`setLength`가 `State<number>`를, `setOffsetSource`가
`Source<number>`를 받고 `recompute`가 `offset:Set()`을 부름)이라 우회
불가고, 반대는 *등록 표면* 의존(핸들러 셋)이라 미루면 그만이다. 옛
순서로는 디스패치 마일스톤의 `mock 대상 테스트` 체크박스조차 State 없이
불가능했고, 반응형 코어는 순수 Lua라 `luau`만으로 단독 테스트가 된다.
(b) 분할은 기존 `M2` 참조 전부를 "M2a인가 M2b인가"로 만들어 오히려 비싸고,
(c) 유지는 "순서의 소스는 `ROADMAP.md`"라는 원칙을 스스로 무효화한다.

## 그냥 맞바꾸기로는 안 끝났다

- **`Brand`/`Relate`/`LifetimeHandle`(인터페이스)이 M2 앞머리
  `### 공통 기반` 절로** — 반응형이 이 셋을 먼저 요구한다(`Source`가
  `SourceBrand`+`EpochBrand`에 등록되고, State 전파 루프가 매 발화마다
  `canExecute`를 부르며, 그 판정이 `Relate` 위에 얹힌다). 셋 다 State-free
  이자 dispatch-free라 어느 쪽에도 안 걸린다.
- **2026-08-22에 디스패치로 앞당겼던 `EpochMap`/`GateNode`/`Blocker`가
  M2로 복귀** — 앞당길 이유 자체가 순서 교체로 사라졌다. "게이팅 먼저"는
  그대로 지켜진다(게이팅이 디스패치보다 먼저 지어진다).
- **Observer/Effect 동적 경로 가드 등록과 `ObserverEffectLeafHandler`는
  M3로** — 둘 다 "핸들러를 등록한다"뿐이라 본체와 잘라내기 쉽다. 이것이
  M2가 M3에 개념상 지던 유일한 의존이고, 미뤘으므로 빌드 순서엔 역방향
  간선이 없다.
- **`EpochMap`이 State 본체보다 앞으로**(감사 5라운드 발견) — State가
  `valueEpochMap`/`emitEpochMap` 둘을 컴포지션하므로 옛 배치로는
  `State.luau`를 못 짠다. 문서 자신이 *"아래 `Source.luau`/`State.luau`가
  이걸 전제로"*라 쓰면서 그 둘이 위에 있는 형태로 증거를 남기고 있었다.

## 대가 하나 — 게이트 둘이 "바로 다음"으로 올라왔다

`question.md` 낮은 우선순위에 있던 **중간 State GC 미검증**과
**`store:GetDynamic` 위치**가 원래 반응형(옛 M3)의 게이트였는데, 반응형이
M2가 되면서 착수 직전 항목이 됐다 — 최우선 절로 승격했다. 순수 *설계*
결정 대기는 여전히 0건이다(하나는 실측 미완, 하나는 표면 위치 선택).

## 번호 재부여의 경계

라이브 문서의 `M2`/`M3` 참조 248건은 전량 새 번호로 맞췄다(코퍼스의 참조가
거의 전부 "그 내용이 사는 마일스톤"을 가리켜 기계적 맞교환으로 의미가
보존됨). **`session/`·`archive/`·`qa-request/`는 히스토리라 소급 수정하지
않았다** — 그 문서들의 `M2`/`M3`는 옛 의미(M2=디스패치, M3=반응형)이고,
이 경고를 인덱스 다섯 곳에 박아뒀다.

일괄 치환에 `\bM([23])\b`를 썼다가 Python `\w`가 유니코드라 한글이 붙은
93건(`M2는`/`M2로`)이 안 바뀌는 실수를 냈고, `git show HEAD:<경로>`로
되돌린 뒤 ASCII 경계 lookaround로 재실행했다. 그 부수로 `Relate`/
`LifetimeHandle` 참조들은 구 M2 → 신 M2로 두 번 옮겨져 **번호가 우연히
보존**됐다는 것도 드러났다(치환하면 안 되는 자리 — 감사가 잡아 되돌렸다).

## 검증

`quad-doc-auditor` 루프가 라운드마다 각도를 바꿔 **7라운드에서 새 발견
0건으로 수렴**(10→8→1→1→2→2→0). 가장 값이 큰 건 5라운드(구현 순서
시뮬레이션)로, 위 `EpochMap` 배치 오류를 잡았고 **순서 교체의 전제 자체도
검증**했다(새 M2 전체를 디스패치 심볼로 훑어 "가드 등록 둘 말고는 없음").

**수렴 뒤 사용자가 돌린 `/code-review high`가 5건을 더 잡았고 전부 유효** —
넷이 *"라벨은 치환됐는데 그 라벨을 설명하던 산문이 안 고쳐진"* 종류였고,
그중 하나는 `research/` 안의 **히스토리 블록**이 치환을 맞아 원래 논거가
문장 그대로 거짓이 된 것이었다(소급 수정 제외 대상을 세 폴더로만 잡은 게
샜다). `conventions.md`의 *"`/code-review`는 감사자를 대체하지 않는다"*가
또 재확인됐다. `doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
2026-08-25 00:29:15 +09:00

31 KiB

State 재계산/전파 판정 — Epoch 비교와 EpochMap 컴포지션 (2026-08-21 확정)

상태: 확정. 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤 채택 확정됨 — "gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. 채택하면 될것 같아요." 구현 마일스톤은 전부 M2(반응형 코어)다 — 부기 객체 EpochMap.luau, Epoch 인터페이스·리비전 갱신 규칙, 그걸 valueEpochMap/emitEpochMap 둘로 컴포지션하는 State 본체 통합이 전부 한 마일스톤 안이다. [2026-08-24 재확정] 2026-08-22엔 GateNode가 디스패치 쪽에 있어서 EpochMap/Epoch만 그리 앞당겨져 둘로 갈려 있었는데, 마일스톤 순서 교체(ROADMAP.md의 M2 배너)로 GateNode가 반응형으로 돌아오면서 그 분리 자체가 없어졌다.

⚠️ 이 문서는 base/source-state-plan.md의 "전파 모델 확정" 절을 대체하는 게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 판정 규칙을 얹는다. 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고, 역전 원문은 archive/always-propagate-no-dedup-superseded.md에 있다.

읽는 순서: 규칙만 필요하면 §2~§4만 보면 된다. §1은 왜 이걸 하는가 (실재하는 glitch), §5는 emit 페이로드, §6은 State 밖의 소비자, §7은 비용, §8은 구현 시 확인할 것.

히스토리: 구현 전 QA 5라운드(SS-2/SS-3)에서 나왔고, 회신 원문은 qa-request/pre-implementation-qa-round5-response.md, 여러 라운드의 정정 경위는 qa-request/pre-implementation-qa-round5-followup.md의 M·N절이 소스. Epoch/EpochMap으로 일반화한 마지막 라운드의 근거 기록은 reference/epoch-brand-composition.md.

1. 사용자가 지목한 문제 (실재함)

A ──> B ──┐
 └──> C ──┴──> D

A:Set() 한 번에 대해 옛 모델(push-invalidate + pull-recompute, 판정 주체가 invalid 플래그)에서:

  1. A가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 B 쪽 가지가 먼저 끝까지 내려간다DB를 통해 신호를 받고, 그 아래 Observer가 발화한다.
  2. 그 Observer가 D:Get()을 부른다. D는 상류를 Get()하는데, C는 아직 신호를 못 받아 invalid가 아니다C옛 캐시를 그대로 반환한다.
  3. D(B_new, C_old)라는 섞인 값을 계산해 캐시하고 invalid를 끈다.
  4. 뒤늦게 C 가지의 전파가 도착해 D가 다시 무효화되고, Observer가 또 울고, 그때서야 (B_new, C_new)로 교정된다.

(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고(그 값으로 이미 프로퍼티가 써지는 등 부작용이 나간다), (b) 같은 계산이 두 번 돈다. 리액티브 문헌에서 말하는 전형적인 glitch다.

base/source-state-plan.md의 "다이아몬드 의존성은 무엇이 푸는가" 절은 중복 재계산이 없다고만 말하는데, 그 논증은 "누군가 d:Get()을 부르는 시점"이 전파 파동이 끝난 뒤라고 암묵적으로 가정한다 — Observer가 전파 도중에 발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 아래 규칙이 이걸 고친다.

2. Epoch — 판정의 최소 인터페이스

판정에 필요한 건 둘뿐이다: identity와 "직전과 달라지는 리비전". 그걸 이름 붙인 것이 Epoch다.

type Epoch = { Revision: number }
  • 그 자체로 키가 되는 unique 테이블이다 — EpochMap[epoch] = revision 으로 들고 있는다.
  • Source가 이 인터페이스를 구조적으로 만족한다SourceState를 구조적으로 만족하는 기존 패턴(base/source-state-plan.md의 "Source가 State를 만족함" 절)과 정확히 같은 모양. Source:Set()/:Emit()이 자기 Revision갱신한다(직전과 다른 값으로 — 방향은 계약이 아니고, 아래 확정된 연산은 실제로 증가가 아니라 감소한다).
  • Revision은 공개 필드다. 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다(사용자: "그래야 타입 상 SourceEpoch를 만족해요."). Store와 달리 Source는 키가 사용자 것이 아니므로 예약 이름이 늘어도 충돌하지 않는다 — "Source 는 예약 이름이 늘어나도 됩니다(store 아님)."
  • 런타임 판별은 EpochBrand:is(x)(base/brand-plan.md). SourceSourceBrand이면서 동시에 EpochBrand에 등록된다 — 이 다중 태깅 요구가 Brand를 인스턴스 브랜드로 재작성한 직접 발단이다.
  • Source가 아니라 Epoch인가: 맵이 요구하는 건 identity + 리비전 둘뿐인데 그걸 Source로 못 박으면 계약이 실제보다 좁아진다. 사용자: "'소스를 전해주는것' 이라고 보기엔 너무 협소하고, 일반화된 형태가 아님." 일반화의 실익은 Source가 아닌 원천(외부 시계 등)이 특수 분기 없이 낀다는 것이고, 그건 EpochBrand:register(self) 한 줄로 끝난다.

계약은 "직전 값과 다르다"만 요구한다. 순서 비교(<)는 아래 규칙 어디에도 안 쓰이고 전부 ==/~=뿐이라, 단조 증가조차 계약으로는 과하다 — 실제로 아래 확정된 방식은 증가가 아니라 감소한다(그리고 0에서 한 바퀴 돈다). 규칙이 ==/~=만 쓰기 때문에 그게 문제가 안 되는 것이므로, 나중에 순서 비교를 넣고 싶어지면 이 결정부터 되짚을 것. 이름이 Revision인 것도 순서를 뜻하지 않는다 — "직전과 구별되는 표식"이라는 뜻이다.

  • 배경 — 평이한 +1이었다면 2^53에서 계약이 깨졌다. Luau 숫자는 double이라 랩어라운드가 아니라 포화한다 — 2^53을 넘으면 n + 1 == n이 되어 "다르다"는 보장이 정확히 그 지점에서 깨진다(초당 100만 Set으로 285년이라 도달 불가능하긴 하다). 아래 확정된 bit32 랩은 이 지점 자체를 없앤다. 이걸 근거로 적을 땐 "오버플로해도 다르다"가 아니라 **"도달 불가능하다"**로 적을 것 — 전자는 틀린 서술이다.

  • [2026-08-21 확정] 리비전 갱신은 bit32.bnot(-rev) 한 번이다 — 리비전은 uint32 안에 머문다.

    self.Revision = bit32.bnot(-self.Revision)
    

    [2026-08-22 실측] 이건 랩어라운드 감소다a > 0이면 a - 1, a == 0이면 4294967295로 한 바퀴 돈다. luau로 확인한 값:

    rev bit32.bnot(-rev)
    0 4294967295
    1 0
    2 1
    4294967295 4294967294

    (원리: bit32.bnot(x) == 4294967295 - (x mod 2^32)이고 a > 0에서 (-a) mod 2^32 == 2^32 - aa - 1이 된다. 시작값이 무엇이든 상관없다 — 매 호출이 직전과 다른 값을 준다는 것만 계약이다.)

    사용자 논거(2026-08-21): "그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의 연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운 라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를 모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게 가고 싶어요." — 즉 2^53 포화는 어차피 도달 불가능한 시나리오인데, 그걸 피하겠다고 값을 double 영역까지 키울 이유가 없다는 것. 이건 매 Set마다 도는 hot path다.

    • 갱신과 랩이 같은 연산 하나다. bit32.bnot은 Luau가 FASTCALL로 거는 빌트인이라, 별도의 덧셈도 마스킹도 없다 — 단항 부호 반전 하나가 붙을 뿐이다.
    • 랩이라 2^53 포화(n + 1 == n)가 아예 안 생긴다 — 위 배경 항목이 말하는 계약 파손 지점 자체가 사라진다.
    • ⚠️ [2026-08-22 정정] 대신 생기는 2^32 랩은 "똑같이 도달 불가능"이 아니다. 여기 그렇게 적어뒀는데 수치가 틀렸다 — 위 배경 항목과 같은 척도(초당 100만 Set)로 2^53은 285년이지만 2^32는 약 72분이다 (20만 배 차이). 현실적인 부하(초당 1만 Set)로도 5일 남짓이다. 그래도 위험하지 않은 이유는 도달 시간이 아니라 충돌 조건이 한 점이기 때문이다: 오판정이 나려면 어떤 맵 항목이 그 Epoch에 대해 정확히 2^32만큼 뒤처져 있어야 한다. 한 바퀴에서 하나라도 어긋나면 값이 달라 정상 판정된다. 그 항목은 emit을 받거나 :Refresh를 도는 순간 갱신되므로, "정확히 한 바퀴 동안 한 번도 안 건드려진 항목"이라야 한다. 확률적으로 무시 가능하다는 뜻이지 산술적으로 불가능하다는 뜻이 아니다 — 이 문서가 바로 위에서 "근거를 정확히 적을 것"이라 규정했으므로 같은 기준을 적용한다. (2026-08-21 커밋 전 /code-review high 발견.)
    • ⚠️ [2026-08-22 정정] 여기 한때 bit32.band(rev + 1, 0xFFFFFFFF)라 적고 "bit32가 double 덧셈 위에 fastcall을 하나 더 얹는다"는 단서를 달아뒀는데, 둘 다 틀렸다. 사용자가 말한 형태는 처음부터 bit32.bnot(-a)였고("제가 말한건, bit32.bnot(-a) 입니다"), 그건 덧셈을 얹는 게 아니라 갱신 자체를 대체한다. 그래서 "bit32라서 더 싸다"는 사용자 서술이 맞고, 그걸 반박한 에이전트 단서가 틀렸었다 — band(n + 1, mask)라는 다른 형태를 놓고 한 비교였기 때문.
  • 테이블 identity를 리비전으로 쓰는 대안은 채택 안 함(사용자 선택: "Revision 숫자로 가는걸 저는 선택하고 싶어요"). 기능적으로는 둘 다 성립하지만 테이블안은 Set 한 번마다 테이블 하나를 할당해서, 트윈처럼 매 프레임 Set하는 소스가 여럿이면 GC 압력을 만든다(quad는 GC-native 아키텍처라 이 축을 신경 써왔다).

3. EpochMap — 컴포지션 가능한 부기 객체

에포크 부기를 State에서 떼어내 재사용 가능한 객체로 만든다(사용자 제안). 그래야 노드가 아닌 소비자(leaf)도 같은 판정을 쓸 수 있다Effect가 그 첫 수요자다(§6).

type EpochSet = { [Epoch]: true }   -- 배열이 아니라 집합이다 (아래 ⚠️ 참고)

EpochMap() -> EpochMap

EpochMap:Update(Epoch | EpochSet) -> boolean  -- "뒤로 전파가 필요한가"
EpochMap:Refresh() -> boolean                 -- 자기 키 전부를 라이브로 다시 읽음
EpochMap:Sync(Epoch | EpochSet)               -- 읽지 않고 쓰기만 (반환값 없음)
EpochMap:TrackFrom(other: EpochMap)           -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움

⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 {Epoch}(배열)가 아니라 {[Epoch]: true}(집합)다. 여기 한때 {Epoch}로 적혀 있었는데, Luau에서 그건 {[number]: Epoch} 배열이라 실제로 넘어오는 게이트 배치와 타입이 다르다 — 배치는 base/gate-plan.md 4번이 확정한 withheld : { [epoch] : true }를 그대로 스왑해 넘긴 것이다. 이 표기를 믿고 ipairs로 구현하면 배치를 순회할 때 원소가 0개가 되어, 유보됐다 풀린 emit이 하류에서 전부 조용히 삼켜진다 — gate-plan.md 4번이 애초에 고치려던 바로 그 버그다. 집합이어야 하는 이유는 게이트 쪽 요구다: 유보 중 같은 Epoch가 여러 경로로 도착해도 withheld[epoch] = true가 저절로 접어주고, 게이트-게이트 unfold(같은 절)도 집합이라야 중복 없이 합쳐진다. (2026-08-21 커밋 전 /code-review high 발견.)

  • :Update가 이 객체의 전부다. 넘어온 각 Epoch에 대해 저장된 리비전과 epoch.Revision을 비교하고, 다르면 새 값으로 덮는다. 하나라도 달랐으면 true. 사용자: "애초에 Update 자체가 전부 최신 상태로 만들고, 업데이트 된게 있으면 true 를 던지는거라."
    • EpochSet을 받으므로 sync 연산이 따로 필요 없다 — 전체를 넘기면 그게 곧 sync다. (한때 에이전트가 ":Sync가 필수"라고 적었으나 Update를 "하나만 받는 것"으로 좁게 본 착오였고 철회됐다.)
    • 이건 invalid와 다른 물건이다(사용자: "이건 invalid 랑은 다른 구현이야."). 반환값의 뜻은 "내 캐시가 낡았다"가 아니라 "뒤로 전파가 필요한가" 하나다.
    • 내부 최적화: 목록을 돌 때 diff 때문에 읽기가 들어가는데, 한 번 다름을 찾으면 반환값이 이미 true로 확정되므로 나머지는 읽지 않고 쓰기만 하면 된다(사용자 제안).
  • :Refresh는 인자 없는 :Update — 자기가 이미 들고 있는 키 전부를 라이브로 다시 읽어 갱신하고, 하나라도 달랐으면 true. 아래 §4의 순회가 이걸 쓴다(순회가 훑을 대상 목록이 곧 이 맵 자신이라 인자로 받을 게 없다).
  • :Sync는 읽기를 건너뛰고 쓰기만 하는 변형이다(반환값 없음). "없다고 안되는건 아닌데, 그냥 다 안 읽고 set 만 해버리는것은 처음 셋팅에 도움은 됩니다." [2026-08-22 정정] 여기 "초기화에만 쓴다"고 적혀 있었으나 base/gate-plan.md 4번이 게이트의 flush 경로에서도 :Sync(batch)를 쓰는 것으로 확정돼 있다 — 쓰는 자리는 "반환값이 필요 없다고 이미 아는 곳" 둘이다: 노드 생성 시딩, 그리고 게이트가 실제로 전파할 때.
  • [2026-08-22 신설] :TrackFrom(other) — 새 노드 시딩이 이걸 쓴다. other가 추적 중인 키를 전부 넘겨받아 라이브 리비전으로 채운다 (other의 저장값을 복사하는 게 아니다).
    • 왜 필요한가: §4의 시딩 규칙은 "상류의 Epoch를 전부 끌어와 채운다"인데, :With(a, b)의 상류 a/bState이지 Epoch가 아니다. 그 State가 추적 중인 루트 Epoch 집합은 그 State의 valueEpochMap 안에만 있으므로, 키를 넘겨받는 연산이 없으면 시딩을 표면으로 표현할 수가 없다 (2026-08-21 커밋 전 /code-review high 발견).
    • 새 설계가 아니라 이미 확정된 동작에 이름을 붙인 것이다 — 사용자 확정 문구가 이미 *"전부 가져와서, 실제 count 로 둡니다"*였다. :Refresh도 같은 성격의 명명이다(§4의 "순회").
    • Source처럼 자기가 곧 Epoch인 상류:TrackFrom이 아니라 :Sync(dep)로 직접 넣는다 — 아래 시딩 규칙 참고.
    • 이름 근거(사용자 확정, 2026-08-22): 가칭은 Absorb였는데 "조금 상위 요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름"이라 바꿨다 — 이 연산은 other전혀 안 건드린다. 게다가 base/gate-plan.md가 이미 "흡수 집합"을 다른 뜻(emit을 붙들고 있음)으로 쓰고 있어 한 코퍼스 안에 같은 단어가 두 의미로 놓이는 문제도 있었다. TrackFrom은 이 맵의 존재 이유를 사용자가 표현한 말("'내가 뭘 추적하고 있나' 가 필요하죠")을 그대로 쓰고, From이 방향을 못박아 비파괴가 드러나며, 나중에 동적 의존성으로 생성 이후에 키를 더하는 자리가 생겨도 이름이 그대로 맞는다(그래서 SeedFrom보다 낫다). 후보 비교는 question.md가 아니라 여기서 끝났다 — 같은 자리에서 확정됐으므로 열린 항목이 아니다.
  • 키는 weak다. epoch가 죽으면 항목이 사라진다. base/relate-plan.md가 경고하는 "값이 키를 되참조하면 안 된다"는 제약은 값이 숫자라 문제없다.

4. State는 EpochMap을 둘 컴포지션한다

State
    valueEpochMap : EpochMap   -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성)
    emitEpochMap  : EpochMap   -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup)
    rawInvalid    : boolean    -- "재계산이 필요하다"는 확정 플래그

왜 둘인가 — 순회 때문이다. 사용자 정리: "'내 값이, 상류의 상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시 던져야하는지 봐야하나' 를 보는걸 나누는거죠." 순회가 없다면 "값을 최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서 맵 하나로 충분하다. 순회를 넣는 순간 그 둘이 갈라진다 — 순회는 값만 앞당기고 통지는 안 한다.

대원칙 — 무효화를 결정하는 건 언제나 리비전 비교지 emit의 도착이 아니다. emit은 "이 원천을 확인해봐" 라는 요청일 뿐이다. 그래서 emit이 통과해도 리비전이 이미 최신이면 캐시는 유효한 채로 남는다(아래 2번 규칙). 사용자 정리: "emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서, (이미 count 가 최신이면) 캐시가 유효하다." 아래 나머지 규칙은 전부 이 원칙의 따름정리다.

노드가 생길 때의 초기값 — 두 맵이 서로 다르다

  • emitEpochMap은 비운 채로 시작한다. 어떤 emit이 와도 "처음 보는 것"으로 걸린다 — 그리고 그게 맞다. 사용자: "새로 생성된 노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 없는 emit 입니다."
  • valueEpochMap은 반대로 상류가 추적 중인 Epoch를 전부 끌어와 채우고, rawInvalid = true로 시작한다. dep마다 갈린다 — dep이 Epoch (Source) :Sync(dep), dep이 State면 :TrackFrom(dep.valueEpochMap). 분기는 isEpoch로 한다.
    for _, dep in deps do
        if isEpoch(dep) then self.valueEpochMap:Sync(dep)
        else self.valueEpochMap:TrackFrom(dep.valueEpochMap) end
    end
    self.rawInvalid = true
    ``` 이쪽은 비워두면  된다  순회가 훑을
    대상 목록이   맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**
    오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘
    추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제
    count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."*
    
  • 그래서 :With에서 두 상류가 같은 Epoch에 다른 리비전을 들고 있을 때의 병합 규칙도 필요 없다 — 어차피 생성 시점의 라이브 리비전으로 통일된다.

emit을 받았을 때 — 두 맵을 각각 Update 하고 그 boolean으로 결정한다

local valueChanged = self.valueEpochMap:Update(from)
local emitChanged  = self.emitEpochMap:Update(from)
if valueChanged then self.rawInvalid = true end
if valueChanged or emitChanged then
    -- 뒤로 emit (받은 from을 그대로 넘긴다)
end

세 갈래로 읽으면 이렇다:

  1. valueEpochMap이 달랐다 → 값이 낡았다. rawInvalid = true를 세우고 뒤로 emit 한다(두 맵 모두 갱신됨).
  2. valueEpochMap은 같은데 emitEpochMap이 달랐다값은 이미 최신이지만 통지는 아직 안 나갔다(순회가 앞질러 흡수했거나, 게이트가 붙들고 있는 동안 하류가 Get()으로 앞당겨 읽은 경우). emitEpochMap만 갱신되고 뒤로 emit 한다 — rawInvalid는 안 건드린다.
  3. 둘 다 같다삼킨다. 다이아몬드에서 같은 리비전이 두 경로로 도착한 두 번째가 여기서 접힌다.
  • ⚠️ [2026-08-22 신설] GateNode는 이 의사코드를 그대로 쓰지 않는다. 위 코드는 emitEpochMap수신 시점에 갱신하는데, 게이트는 base/gate-plan.md 4번이 전파할 때 :Sync(batch) 갱신하는 것으로 확정돼 있다(그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중엔 아직 안 던졌으니까). 그래서 게이트에서는:
    • 판정(규칙 1~3)은 똑같이 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고 흡수 집합에도 안 들어간다(gate-plan.md 4번).
    • 다만 emitEpochMap:Update를 수신 시점에 부르지 않으므로, 유보 중에 같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책이 한 번 더 돈다. 이미 흡수 집합에 있어 무해하다(같은 절). 정책이 실제로 emit한 뒤에는 :Sync(batch)가 돌아 있으므로 그 다음 도착은 정상적으로 규칙 3에 걸린다.
    • 이 예외가 여기 기록돼 있지 않았다(2026-08-21 커밋 전 /code-review high 발견) — §4대로 구현하면 gate-plan.md 4번의 계약이 조용히 깨진다.
  • from이 하나면 판정은 O(1)이다 — 그 항목 하나만 본다. 다른 항목은 건드리지 않는다(그 Epoch들은 자기가 직접 emit 하므로).
  • from이 집합이면(게이트 배치, §5) Update가 알아서 순회하고, 하나라도 달랐으면 true를 준다 — 규칙이 그대로 성립한다.

재계산 판정

  • rawInvalid == true → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
  • rawInvalid == false그때만 valueEpochMap:Refresh()를 부른다. 목적은 하나뿐 — 못 받은 emit을 여기서 먼저 받아주는 것(중간 게이트에 막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). true가 나오면 rawInvalid = true로 만들고 재계산한다.
  • 순회는 emitEpochMap을 건드리지 않고, 뒤로 emit 하지도 않는다. 그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 하류까지 정상적으로 전파된다(사용자: "emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"). 이게 없으면 값은 맞는데 통지가 죽는 실패 모드가 생긴다 — 2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다 (archive/invalidate-dedup-propagation-reversed.md).

재계산이 끝나면

  • rawInvalid = false, 그리고 valueEpochMap은 자기가 읽은 상류 전부에 대해 갱신한다(발행 Epoch 항목만이 아니다). 맵의 뜻이 "내 값이 이 Epoch에 대해 최신인가"이므로, 방금 계산한 값은 정의상 모든 상류에 대해 최신이다. 사용자: "invalid 에 대한 계산을 위한 count 테이블은 단순히 전부 업데이트 하는건 맞아보입니다."
  • emitEpochMap은 안 건드린다 — 계산은 통지가 아니다.
  • 이 "전부 갱신"이 실제로 값을 하는 자리는 게이트가 붙들고 있는 동안 하류가 Get()으로 앞당겨 읽는 경우다. 그때 valueEpochMap이 앞서 있으므로, 나중에 게이트가 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 같은 값을 다시 계산하지 않는다.

5. emit 페이로드 — Epoch | EpochSet, 게이트는 안 싣는다

  • emit은 값도 리비전도 안 싣는다 — 싣는 건 "이 통지의 출처"뿐이고, 그 출처는 Epoch 하나이거나 EpochSet({[Epoch]: true})이다. 받는 쪽이 거기서 리비전을 그때그때 라이브로 읽는다. 그래서 "리비전 5의 emit" 같은 건 없다.
  • 게이트 노드 자체는 페이로드에 안 싣는다. 게이트가 유보를 풀 때 (blocker:Off() 등) 넘기는 건 그 자리에서 스왑해 떼어낸 Epoch 집합 스냅샷이다(base/gate-plan.md의 4번) — 게이트의 살아있는 withheld 테이블이 아니다(재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기 위함). 하류는 게이트 identity를 한 번도 안 쓴다 — 게이트를 에포크 경계로 만드는 안은 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목에서 기각됐다. 사용자: "하류가 Gate 노드를 받을 이유도 없거든."
  • 런타임 분기는 isEpoch로 한다 — 단일이냐 집합이냐.
  • 평범한 노드는 출처를 안 바꾼다 — 자기를 끼워넣지 않고 받은 출처를 그대로 아래로 넘긴다. GateNode만 예외로 언제나 자기 배치를 새로 낸다 (base/gate-plan.md의 4번).
    • ⚠️ 받는 쪽이 GateNode면 배치를 그대로 넘기지 않고 풀어서 자기 withheld에 합친다 — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성 스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이 된다(같은 절).
  • 게이트는 배치가 비어 있으면 애초에 통지하지 않는다(base/gate-plan.md의 8번) — 그래서 위 규칙이 빈 집합을 받는 경우는 없다.

6. State 밖의 소비자도 같은 판정을 쓴다 — Effect

EpochMap을 떼어낸 실익이 여기서 나온다. Effect가 자기 EpochMap을 하나 들고 각 의존성의 내부 Observer가 그걸 Update하면, 한 파동에 여러 dep가 깨워도 첫 번째만 truefn이 한 번만 돈다.

이건 A → b, A → c, Effect(fn, b, c)에서 접어줄 공통 하류가 없어 State 층 dedup이 못 닫던 갭이다 — Effect가 자기 맵을 들면 그 지점이 곧 공통 하류가 된다. 계약 전량은 base/effect-plan.md의 "Effect(fn, ...deps)" 절이 소스.

그래서 Observer 클로저는 출처를 인자로 받는다fn(self, from: (Epoch | EpochSet)?). :Computefn(self, ...)와 같은 모양이고(설치 발화에는 출처가 없어 nil이다), 값이 아니라 핸들과 메타데이터만 넘기므로 "값을 안 실어주는 구독" 계약은 안 깨진다. base/source-state-plan.md의 "state:Observer(fn)" 절이 소스.

7. 비용

사용자 추산("해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도")에 동의한다. 덧붙일 것 둘:

  • 두 맵의 크기는 그 노드 상류에 있는 서로 다른 루트 Epoch의 수다. 체인이 길어져도 안 늘고, :With로 합류할 때만 는다. UI 파생값에서 이 수가 큰 경우는 드물다.
  • 훑는 쪽(rawInvalid == false, 즉 "안 바뀐 것 같다")이 흔한 경로다. 그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 재계산은 안 도는 경로다. 반대로 rawInvalid == true면 순회를 아예 건너뛰고 바로 재계산한다.
  • 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로 컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.

8. 구현 시 확인할 것

  • 역전된 두 서술은 archive/always-propagate-no-dedup-superseded.md에 있다 — "emit은 자기 invalid와 무관하게 항상 전파된다"와 "quad가 접지 않는 것은 중복 통지뿐이다". 지금 계약은 **"invalid로는 절대 안 접고, 같은 Epoch의 같은 리비전이 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면 2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다.
  • 선언 안 된 의존성에 대한 UB 조항은 안 만든다. 에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 사용자가 기각: "그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고 생각한다." — 선언 안 한 Source를 클로저로 읽는 건 옛 모델에서도 똑같이 stale이었고 이 변경이 악화시키는 게 없다.
  • 동적 의존성valueEpochMap이 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다.
  • Source:Emit()(값을 제자리에서 mutate하고 알리는 경로)은 Revision만 갱신하면 그대로 동작한다(:Set()과 같은 연산 — §2).
  • blocker:OffWithoutEmit()처럼 emit 없이 푸는 경로에서 emitEpochMap이 뒤에 남아도, 그 Epoch다음 진짜 emit이 §4의 1번(둘 다 다름)으로 걸려 정상화된다 — 별도 조치가 필요 없다.
  • luau-test 스파이크 하나: §1의 다이아몬드를 그대로 짜서 (a) 리비전 비교 없이는 섞인 값이 실제로 관측되는지, (b) 비교를 넣으면 사라지는지 대조.
  • 기각된 대안 — 게이트를 에포크 경계로. 게이트가 자기 자신을 하나의 Epoch 내세우고(자기 Revision은 유보를 풀 때만 갱신) 하류가 상류 원천 대신 그 게이트만 추적하게 하는 안. 배치를 나를 필요가 없어져 페이로드가 단순해지지만, base/gate-plan.md 3번의 공개 계약을 정면으로 깬다"게이트를 통과하지 않은 값도 :Get()으로는 보인다."
    • 깨지는 경로: 게이트가 붙들고 있는 동안 하류가 :Get()을 부르면 §4의 재계산 판정이 valueEpochMap:Refresh()를 돈다. 그 맵이 추적하는 게 게이트 하나뿐이면 게이트의 Revision은 아직 안 올랐으므로 "나는 최신"으로 오판하고 옛 캐시를 반환한다. 지금처럼 원천 Epoch를 직접 추적해야 순회가 상류의 실제 리비전을 보고 스스로 낡음을 알아챈다.
    • 즉 게이트는 emit만 가로채지 값을 가리지 않는다는 성질이 이 문서의 핵심 보장(:Get()은 항상 최신)과 한 몸이다. Blocker (base/blocker-plan.md의 ":Get()엔 영향 없음")와 base/debounce-throttle-plan.md §4도 같은 계약 위에 서 있으므로, 이걸 뒤집으면 셋이 같이 무너진다.
  • 기각된 대안 — 순회가 만든 emit을 rawEmit으로 흘리고 nil을 던지는 안. 구조 위생은 좋았으나(상류 emit과 내부 발생 emit이 같은 진입점) 이 문제의 해법으로는 못 쓴다 — (a) 막고 있는 게이트는 보통 순회하는 노드 자신이 아니라 상류에 있어 누출이 그대로 남고, (b) nil emit은 받는 쪽마다 전체 순회를 강제해 같은 "영구 침묵"이 하류에서 연쇄로 재발한다. 지금 안은 순회가 애초에 emit을 안 하므로 두 문제가 생기지 않는다.
  • 곁가지 — 폴링용 슈가. 사용자 제안: "폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 Ref<T> 로 변환해주는 등". 이 문서의 결정과 독립이고 research/operator-sugar-plan.md의 콤비네이터 계열에 속한다.