quad/.claude/base/state-epoch-plan.md
qwreey eb321e1747
qa: 7라운드 발견 52건 처리·반영 — Store 재설계 시도와 철회, 타입 함수 원칙 신설
`qa-request/pre-implementation-handtrace-round7.md`의 `H-55`~`H-106`을
사용자와 대화형으로 처리하고 `base/`에 전량 반영했다. 결정의 소스는
`-followup.md`(개수·개별 항목은 여기서 세지 않는다).

## 처분

확정 39 / 무효·소멸 4(`H-73`~`H-76`) / 기각 1(`H-77`) / 범위 축소 2 /
다른 항목으로 흡수 6.

**부수로 `question.md` 최우선 절이 비었다** — 중간 State GC는 `_hold`
불변식(하류 → 상류 강함)으로, 동적 키 표면 위치는 `store:Of<<T>>(name)`
하나로 닫혔다. **M2 착수를 막는 항목이 없다.**

## 구조가 바뀐 것

- `Ref`가 `Epoch`를 만족(`.Revision` + `EpochBrand`) — 포탈 캐치업 비대칭과
  같은 `Ref` 중복 dep이 같이 닫힘
- `Weak*` 등록 표면 신설(`Ref:WeakCallback` / `Observer:WeakSubscribe`) —
  Weak 쪽이 프리미티브고 강한 쪽이 "GC 킵"을 얹은 것
- `Effect`: dep 등록이 생성자 한 곳으로, 강한 주인은 `_deps` 하나,
  억제는 사적 `Blocker`, `bindLifetime`은 핸들 하나에만 적용.
  `:Rerun()` 정의 신설(재진입은 지연 재실행), `_installed` 신설
- 전파 루프 의사코드 확정 — 구독자 집합의 원소는 Observer **값**이고
  **자식 State 노드는 `canExecute`를 안 탄다**(그대로 짜면 파생 State
  아래가 전부 침묵했다)
- `rawInvalid` → `cacheTargetCount`/`cacheCurrCount` 카운터 쌍
- `recompute` 재진입 차단 + `invalidAfter` 되감기, `gatedRecompute`가
  인덱스를 캡처하지 않고 토큰으로 조회
- `emit(commit) -> boolean`, `EpochMap:Peek` — 정책이 흡수 집합을
  버리지도 읽지도 못하던 것을 닫음
- error 계약(`level` 이분, 메시지는 영어)과 예외 계약(`pcall`로 안 감쌈)을
  `architecture.md`에 신설

## Store — 같은 날 재설계하고 철회했다

`H-75`/`H-76`으로 `WrapStore`/`ProcessStoreType`이 폐기되자 그 자리를
"`store.key`는 값, `store:Of(k)`가 프리미티브"로 채웠다가 **같은 날
철회**했다(`archive/store-value-field-redesign-withdrawn.md`). 살아남은 건
**명시적 초기화** 하나다. 최종형은 타입 인자에 `Source<T>`를 직접 쓰고
`store.key`는 평범한 레코드 필드이며 **타입 함수를 안 쓴다**.

철회 이유 중 하나가 원칙으로 승격됐다 — **"타입 함수는 타입이 못 잡는
문제를 에러로 띄우는 정도 이상으로 가지 않는다"**(`typing-limits.md` §0).
`index<>`/`keyof<>`도 Luau가 predefine한 타입 함수라 같은 함정을 갖는다.

## 툴체인

**`luau` CLI가 심볼릭 링크를 못 탄다**(디렉토리·파일 둘 다)는 것이 최소
재현으로 밝혀졌다 — pesde 워크스페이스 링크가 전부 심볼릭이라 스모크 2개가
안 돌았고 `luau-analyze`는 **조용히 통과**했다("거짓 클린"). `scripts/relink.sh`
+ `scripts/test.sh` 신설, 이제 스모크 셋 전부 PASS.

## 검증

`/code-review high` 2회(12건 + 14건)와 감사 8패스(12/6/6/4/13/3/6/0건)를
각도를 바꿔가며 돌렸고 전부 반영했다 — 마지막 패스가 무발견으로 수렴.
각 패스의 각도와 대표 발견은 `-followup.md`의 검증 절이 소스.
`doc-check.py` ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>

Claude-Session: https://claude.ai/code/session_012oLwATeQdq9TCFdENPutFG
2026-08-25 23:40:19 +09:00

546 lines
38 KiB
Markdown

# 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`을 둘 컴포지션한다
```
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`로 한다.
```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)`을 쓰는 것과 일관).
- 아래 두 절(`재계산 판정` / `재계산이 끝나면`)의 `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(self, from: (Epoch | EpochSet)?)`. `:Compute``fn(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당 둘), 옛 모양도 테이블 둘이었으므로
컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
- **⭐ [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`의 콤비네이터 계열에 속한다.