# 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` 쪽 가지가 먼저
   끝까지 내려간다** — `D`가 `B`를 통해 신호를 받고, 그 아래 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`다.

```lua
type Epoch = { Revision: number }
```

- **그 자체로 키가 되는 unique 테이블**이다 — `EpochMap`이 `[epoch] = revision`
  으로 들고 있는다.
- **`Source`가 이 인터페이스를 구조적으로 만족한다** — `Source`가 `State`를
  구조적으로 만족하는 기존 패턴(`base/source-state-plan.md`의 "Source가 State를
  만족함" 절)과 정확히 같은 모양. `Source:Set()`/`:Emit()`이 자기 `Revision`을
  **갱신한다**(직전과 다른 값으로 — 방향은 계약이 아니고, 아래 확정된 연산은
  실제로 증가가 아니라 **감소**한다).
- **`Revision`은 공개 필드다.** 비공개면 구조적 만족이 **타입 레벨에서 성립하지
  않는다**(사용자: *"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*).
  `Store`와 달리 `Source`는 키가 사용자 것이 아니므로 예약 이름이 늘어도
  충돌하지 않는다 — *"`Source` 는 예약 이름이 늘어나도 됩니다(store 아님)."*
- **런타임 판별은 `EpochBrand:is(x)`**(`base/brand-plan.md`). `Source`는
  `SourceBrand`이면서 동시에 `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 안에 머문다.**

  ```lua
  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 - a`라 `a - 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).

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

EpochMap() -> EpochMap

EpochMap:Update(Epoch | EpochSet) -> boolean  -- "뒤로 전파가 필요한가" (읽고, 비교하고, 덮음)
EpochMap:Peek(Epoch | EpochSet) -> boolean    -- 같은 비교를 하되 **덮지 않음** (2026-08-25 신설)
EpochMap:Refresh() -> boolean                 -- 자기 키 전부를 라이브로 다시 읽음
EpochMap:Sync(Epoch | EpochSet)               -- 읽지 않고 쓰기만 (반환값 없음)
EpochMap:TrackFrom(other: EpochMap)           -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움
```

**⭐⭐ [2026-08-25 신설, 7라운드 `H-72`] `:Peek` — 갱신하지 않고 비교만 한다.**
`GateNode`가 §4의 수신 규칙 1~3을 그대로 돌려면 `emitChanged`가 필요한데,
그걸 얻는 유일한 통로였던 `:Update`는 **정의상 읽고 나서 덮는다**. 그런데
같은 §4의 게이트 예외는 *"`emitEpochMap:Update`를 수신 시점에 부르지
않는다"*(유보 중엔 아직 안 던졌다는 맵의 뜻을 지키려고)라, **두 요구를
동시에 만족할 연산이 표면에 없었다.** 나머지 표면도 전부 쓰기를 한다 —
`:Refresh`는 자기 키를 다시 읽어 **갱신**하고, `:Sync`는 **쓰기 전용**,
`:TrackFrom`은 키를 넘겨받아 **채운다**.

```lua
-- GateNode:_receive
local valueChanged = self.valueEpochMap:Update(from)
local emitChanged  = self.emitEpochMap:Peek(from)    -- 갱신 안 함
```

`:Update`가 이미 `{읽기, 비교, 쓰기}`라 `:Peek`은 그 앞 두 개만 쓰는 것이고
내부 코드 공유가 쉽다. 플래그(`Update(from, write)`)로 두는 안은 기각 —
호출부에서 "안 쓴다"가 안 보이고 boolean 파라미터가 늘 그렇듯 읽기가
나빠진다.

**⚠️ [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`/`b`는 **State이지 `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`을 둘 컴포지션한다

> **⚠️ [2026-08-26, 8라운드 `H-114`] `rawInvalid: boolean`은 폐기된 필드다** —
> 아래 "`rawInvalid` 불린은 **캐시 카운터 쌍**으로 교체됐다" 절이 소스다.
> 그 절의 안내 문장은 *"아래 두 절"*(재계산 판정 / 재계산이 끝나면)만
> 가리켰는데, **이 구조체 선언과 바로 아래 수신 규칙은 그 절보다 위에
> 있다** — 위에서부터 읽으면 폐기된 필드로 구조체를 먼저 확정하게 된다.
> 아래 서술에서 `rawInvalid`가 나오면 전부 카운터 쌍으로 읽을 것.

```
State
    valueEpochMap  : EpochMap   -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성)
    emitEpochMap   : EpochMap   -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup)
    cacheTargetCount : number   -- ┐ 옛 `rawInvalid`. 둘이 다르면 "재계산 필요"
    cacheCurrCount   : number?  -- ┘ (시드는 target = 0, curr = nil)
```

**⭐ 왜 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 상태로
하여금 사용 가능하다' 를 보는 테이블과, '내가 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`로 한다.
  ```lua
  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으로 결정한다

```lua
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` 불린은 **캐시 카운터 쌍**으로 교체됐다 (2026-08-25, 7라운드 `H-85`)

**문제**: 아래 "재계산이 끝나면"이 확정한 `rawInvalid = false`가 **재계산
*도중* 도착한 무효화를 지운다.** `fn` 실행 중에 상류가 `Set`되면 규칙 1이
`rawInvalid = true`를 세우는데, `fn`이 반환한 직후 꼬리가 그걸 무조건
`false`로 덮는다 — 캐시가 **다음 `Set`까지 영구 stale**이고, 한 세대를
조용히 건너뛰는 형태라 관측이 어렵다. 재계산 중 상류 쓰기는 흔하다(다른
Store 필드에 결과를 적어두는 관용구, 헬퍼의 lazy 초기화, `fn`이 yield하는
사이 타이머가 `Set`하는 경우 — yield 금지 불변식은 `Dispatch.process`/
`attachSlot` 체인 **안**에만 적용된다).

**확정**: `rawInvalid: boolean`을 **`cacheTargetCount` / `cacheCurrCount`**
두 필드로 바꾼다. **사용자 확정**: *"state 는 epoch 를 구현해선 안 돼.
중간이지, 초기 값 컨테이너 계층은 아니거든. 따라서 cache count 를 넣을것을
추천해. `cacheTargetCount`, `cacheCurrCount` 를 놓는게 맞지 않을까?"*

```lua
-- 생성 시
self.cacheTargetCount = 0
self.cacheCurrCount   = nil                     -- 아직 계산된 적 없음 → 항상 다름

-- 무효화 (아래 규칙 1의 `rawInvalid = true` 자리)
if valueChanged then
    self.cacheTargetCount = bit32.bnot(-self.cacheTargetCount)
end

-- 재계산
local gen = self.cacheTargetCount               -- fn 직전 스냅샷
self.cache = self.fn(self, self.cache, ...)
for _, d in self.deps do d:_track(self.valueEpochMap) end
self.cacheCurrCount = gen                       -- ⭐ 성공했을 때만

-- 재계산 판정 (아래 `rawInvalid == true` 자리)
if self.cacheCurrCount ~= self.cacheTargetCount then 재계산 end
```

- **재계산 도중 도착한 무효화**가 `cacheTargetCount`를 앞서게 만들어
  다음 `Get`이 반드시 재계산한다.
- **`fn`이 던지면** `cacheCurrCount`가 안 갱신되므로 *계산된 적 없는
  캐시를 유효하다고 확신*하는 일이 없다 — **사용자 지적**: *"특히 에러가
  난다고 하면, 다시 계산 안하고 이전 결과를 다시 쓰겠네?"* (그래서
  "`rawInvalid = false`를 `fn` 앞으로 옮긴다"는 한 줄짜리 대안은
  **불충분하다**.)
- 증가는 §2가 `Source.Revision`에 확정한 **`bit32.bnot(-n)` 랩** 그대로다 —
  비교가 `~=`뿐이라 랩이 무해한 것도 같고, uint32 안에 머무르므로 평이한
  `+1`이 갖는 2^53 포화 지점 자체가 없다.
- **⚠️ 초기값은 `curr = nil`이어야 한다 — 두 숫자를 나란히 두면 안 된다.**
  이 갱신은 **감소**다(§2의 실측 표: `1 → 0 → 4294967295`). 그래서 한때
  적어뒀던 `target = 1, curr = 0`은 **첫 무효화에서 `target`이 `0`이 되어
  `curr`와 같아진다** — 계산된 적 없는 캐시를 "유효"로 판정해 다음 `Set`이
  올 때까지 그 값을 그대로 돌려준다(2026-08-25 `/code-review high` 발견).
  `curr = nil`이면 어떤 숫자와도 다르고 **갱신 방향이 바뀌어도 안 깨진다.**
  필드 타입은 `number?`이고, 첫 재계산 이후로는 계속 숫자다.
- **State는 여전히 `Epoch`를 구현하지 않는다** — 이 카운터는 **자기 재계산
  부기**이지 남이 키로 삼는 리비전이 아니다(§4가 State dep에 대해
  `TrackFrom(dep.valueEpochMap)`을 쓰는 것과 일관).
- **[2026-08-26 정정, `H-114`] §4의 `rawInvalid`는 *전부* 이 카운터 비교로
  읽을 것** — 여기 한때 *"아래 두 절(`재계산 판정` / `재계산이 끝나면`)"*
  이라고만 적혀 있었는데, 이 절보다 **위**에 있는 구조체 선언과 수신 규칙도
  같은 대상이다(그쪽엔 배너를 달아뒀다).

### 재계산이 끝나면

- **`rawInvalid = false`**(위 카운터로는 `cacheCurrCount = gen`), 그리고 **`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가
깨워도 **첫 번째만 `true`** 라 `fn`이 한 번만 돈다.

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

**그래서 `Observer` 클로저는 출처를 인자로 받는다** —
`fn(targetState, self, emitFrom: (Epoch | EpochSet)?)`
(**[2026-08-26 정정, 8라운드 `H-109`]** 여기 한때 2-인자 `fn(self, from)`으로
적혀 있었는데, 그때의 `self`는 **리시버 State의 lazy 핸들**을 뜻했고 전파 루프
의사코드는 같은 자리에 Observer 값을 넘기고 있었다 — Observer 핸들이 가운데
자리로 들어와 세 자리가 됐다). 1번 자리는 `:Compute`의 `fn(self, ...)`와 같은
모양이고(설치 발화에는 출처가 없어 `emitFrom`이 `nil`이다),
값이 아니라 **핸들과 메타데이터**만 넘기므로 "값을 안 실어주는 구독" 계약은
안 깨진다. `base/source-state-plan.md`의 "`state:Observer(fn)`" 절이 소스.

## 7. 비용

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

- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 `Epoch`의 수**다.
  체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
  큰 경우는 드물다.
- **훑는 쪽(`rawInvalid == false`, 즉 "안 바뀐 것 같다")이 흔한 경로**다.
  그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 **재계산은
  안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 건너뛰고 바로
  재계산한다.
- 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로
  컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
- **⭐ [2026-08-25 추가, 7라운드 `H-92`] 전파 루프의 구독자 스냅샷은 여기
  셈에 안 들어가 있었다.** `base/source-state-plan.md`의 "전파 루프 — 확정
  의사코드" 절이 확정한 대로, 순회 중 등록이 미정의라 매 발화마다 구독자
  집합을 배열로 복사한다 — 즉 **파동이 지나는 노드 수만큼 배열 하나씩**이
  할당된다. §2가 테이블 리비전 방식을 기각한 근거가 *"`Set` 한 번마다
  테이블 하나를 할당"*이었으므로 이 비용은 정직하게 적어둔다. **정확성
  문제는 아니고**(스냅샷 자체는 필수) 이 문서의 결정을 뒤집지도 않는다 —
  테이블 리비전은 **노드마다 상시** 새 테이블을 만드는 것이고 이건 **발화
  경로에만** 생기는 임시 배열이다.
- **게이트 통과 모드의 할당**(7라운드 `H-69`): `base/gate-plan.md` 4번이
  확정한 대로 게이트는 통과시킬 때도 `withheld`에 넣었다가 flush에서
  새 weak 테이블로 스왑하므로 **emit마다 테이블 하나**를 쓴다. 역시
  정확성 문제가 아니라 구현 시 최적화 여지(통과 모드면 스왑 없이 넘기기)로
  적어둔다.

## 8. 구현 시 확인할 것

- **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다** —
  "emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은
  중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 `Epoch`의
  같은 리비전이 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면
  2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다.
- **선언 안 된 의존성에 대한 UB 조항은 안 만든다.** 에이전트가 "새 모델은 더
  강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 **사용자가 기각**:
  *"그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을
  던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고
  생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이
  stale**이었고 이 변경이 악화시키는 게 없다.
  - **⚠️ [2026-08-25 범위 정정, 7라운드 `H-91`] 위 인용의 *"항상 state 는
    get 이 최신"*은 **선언한 의존성에 대해서**다.** 문장 그대로 읽으면
    코퍼스에 **의도적 반례**가 있다 — `base/tween-plan.md`의 `Animate`가
    *"`info.Style`이 State여도 이 내부 `:Compute`의 trailing deps로 안
    넘어가므로 구독 목록에 안 걸림"*이라며 **미선언 읽기를 설계로** 쓴다.
    바로 위 문장이 이미 "선언 안 한 것은 옛 모델에서도 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`의 콤비네이터 계열에 속한다.
