quad/.claude/base/state-epoch-plan.md
qwreey 168d3d8dcc
docs: 핸드오버 — Epoch/Brand 제안 승격을 다음 세션 첫 작업으로 지정
사용자 지시로 승격은 다음 세션에("이 세션은 너무 길어요"), 감사는 승격
이후에 돌린다. 이번 세션에서 알게 된 것 중 base/에 안 남아 있던 것을
전부 채웠다.

- base/ 네 문서 상단에 research/epoch-brand-composition.md를 가리키는 ⚠️
  배너 신설: state-epoch-plan(두 맵 -> EpochMap 둘), source-state-plan
  (Source가 Epoch를 구조적으로 만족 + Observer 클로저 fn(self, from)),
  brand-plan(인스턴스 브랜드 전면 재작성), gate-plan(emit 페이로드 타입).
  넷 다 "기제는 그대로 유효, 표현만 바뀐다 / 승격 전엔 이 문서가 정본"을
  명시.
-  base/effect-plan.md에 다중 의존성 Effect 갭을 미해결로 기록. 이번
  세션에 발견했는데 research 문서에만 있었다 — 제안이 기각되더라도 갭은
  남으므로 base에 있어야 한다. 해법 후보(Effect가 자기 EpochMap을 듦)와
  접은 대안(deps를 한 노드로 수렴), 그리고 "useEffect처럼 N번 허용"
  선택지까지 같이 적었다.
- base/blocker-plan.md에 HasBlockedEmit == (next(withheld) ~= nil)이라는
  것과 "빈 배치면 무통지"가 그 일반화라는 것 기록(구현 시 둘을 따로 들지
  말 것).
- todos.md에 000번 신설 — 다음 세션 첫 작업, 고칠 문서 넷과 남은 미정
  하나(bit32 랩 vs +1)까지 명시.
- question.md 1번에 리비전 증가 방식 항목 추가.
- session-summary.md의 이 세션 항목이 "Gate만 다음 세션 — M2를 막는 유일한
  항목"에서 멈춰 있던 것을 전량 갱신(같은 날 Gate/에포크가 확정됐고,
  code-review 두 번에 19건이 나왔으며, Epoch/Brand 제안이 대기 중).

doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-21 23:32:48 +09:00

320 lines
24 KiB
Markdown

# State 재계산/전파 판정 — 소스 에포크 비교 (2026-08-21 확정)
**상태**: **확정.** 사용자 제안으로 시작해 같은 날 네 라운드에 걸쳐 다듬은 뒤
**채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
채택하면 될것 같아요."* 구현은 **M3(Source/State)**.
**⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는
게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을
얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고(아래 §3),
역전 원문은 `archive/always-propagate-no-dedup-superseded.md`에 있다.
**⚠️ [2026-08-21] 이 문서의 구조를 바꾸는 제안이 `research/`에 대기 중이다** —
두 맵을 `EpochMap`으로 컴포지션하고 `Source` 대신 **`Epoch` 인터페이스**로
일반화하는 안(`research/epoch-brand-composition.md`). **사실상 전량 확정됐고
승격만 남았다**(같은 날 세션이 길어져 다음 세션으로 미룸). 여기 적힌 규칙 자체는
그대로 성립하고 표현만 바뀐다 — 승격 전엔 이 문서가 정본.
**읽는 순서**: 규칙만 필요하면 **§2**만 보면 된다. §1은 왜 이걸 하는가(실재하는
glitch), §3은 무엇이 고쳐지는가, §5는 세부 계약, §7은 구현 시 확인할 것.
**히스토리**: 구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고, 회신 원문은
`qa-request/pre-implementation-qa-round5-response.md`, 네 라운드의 정정 경위는
`qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스.
## 1. 사용자가 지목한 문제 (실재함)
```
A ──> B ──┐
└──> C ──┴──> D
```
`A:Set()` 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서:
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**이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지
않다. `base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은
**중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다.
## 2. 사용자 제안 (최종 정리형) — **[2026-08-21 3차 정정본]**
각 State가 **자기에게 영향을 주는 루트 `Source`들의 에포크(count)** 를 들고
있다가, 그것과 실제 Source의 현재 count를 비교해 재계산/전파 여부를 정한다.
**[2026-08-21] 마지막 정정으로 테이블이 둘로 갈렸다** — 아래 "왜 둘인가" 참고.
```
State
sourceCountMap : { [source (weak key)] : count }
-- "내 값이 이 소스에 대해 최신인가" (값 유효성)
sourceEmitMap : { [source (weak key)] : count }
-- "이 소스의 이 에포크를 내가 하류로 이미 던졌는가" (전파 dedup)
rawInvalid : boolean
-- "재계산이 필요하다"는 확정 플래그
```
**⭐ [2026-08-21 확정] 노드가 생길 때의 초기값은 두 맵이 서로 다르다.**
- **`sourceEmitMap`은 비운 채로 시작한다.** `nil ~= source.count`라 어떤 emit이
와도 "처음 보는 것"으로 걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된
노드에서 들어온 emit 은, 개념적으로 해당 노드가 한번도 받아본적 없는
emit 입니다."*
- **`sourceCountMap`은 반대로 상류에서 전부 끌어와 실제 count로 채우고,
`rawInvalid = true`로 시작한다.** 이쪽은 비워두면 안 된다 — 순회가 훑을
대상 목록이 곧 이 맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**로
오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘
추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제
count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."*
- 그래서 **`:With`에서 두 상류가 같은 소스에 다른 count를 들고 있을 때의 병합
규칙도 필요 없다** — 어차피 생성 시점의 라이브 count로 통일된다.
**⭐ 대원칙 — 무효화를 결정하는 건 언제나 count 비교지 emit의 도착이 아니다.**
emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도
count가 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자
정리(2026-08-21): *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서,
(이미 count 가 최신이면) 캐시가 유효하다."* 이 문서의 나머지 규칙은 전부 이
원칙의 따름정리다.
- `Source:Set()`/`:Emit()`은 자기 count를 증가시킨다.
- **`emit`은 값을 안 싣고 count도 안 싣는다 — 싣는 건 "이 통지의 출처" 하나**다.
받는 쪽이 거기서 count를 **그때그때 라이브로** 읽는다. 그래서 "에포크 5의
emit" 같은 건 없다. 출처로 올 수 있는 것은 **둘**이다
(**[2026-08-21 확장]** — 원래는 `Source`뿐이었다):
- **`Source`** — 그 소스 하나를 확인하라.
- **게이트 배치** — 게이트가 유보를 풀며 **떼어낸 소스 집합**을 확인하라
(`base/gate-plan.md`의 4번). 게이트가 `blocker:Off()` 등으로 풀 때 쓴다.
**게이트의 살아있는 `withheld` 테이블이 아니라 그 자리에서 스왑해 떼어낸
스냅샷**이다 — 재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기
위함(같은 절).
**평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로
아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 자신을 출처로 새로 낸다**
(`base/gate-plan.md`의 4번).
- **emit을 받았을 때** — 출처가 `Source`면 판정은 O(1)이다, 그 항목 하나만 본다:
1. `sourceCountMap[source] ~= source.count` → 둘 다 `source.count`로 갱신하고
`rawInvalid = true`를 세운 다음 **뒤로 emit** 한다.
2. 같은데 `sourceEmitMap[source] ~= source.count` → **값은 이미 최신이지만
통지는 아직 안 나갔다**(아래 순회가 앞질러 흡수했거나, 게이트가 붙들고
있는 동안 하류가 `Get()`으로 앞당겨 읽은 경우). `sourceEmitMap`
갱신하고 **뒤로 emit** 한다 — `rawInvalid`는 안 건드린다.
3. 둘 다 같으면 → **삼킨다.**
- **다른 소스 항목은 건드리지 않는다** — 그 소스들은 자기가 직접 emit 하므로.
- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의
8번) — 빈 배치를 흘리는 건 "쌓인 게 없는데 뒤로 넘기는" 꼴이라 State 층에
`Source:Emit`을 추가하는 것과 같아진다. 그래서 아래 규칙이 빈 집합을 받는
경우는 없다.
- **출처가 게이트 배치면** 그 집합을 순회하며 **각 소스에 위 1~3을
그대로 적용**하고, 하나라도 1번이나 2번에 걸렸으면 **받은 출처(그 게이트)를
그대로** 뒤로 넘긴다. 전부 3번이면 삼킨다 — 다이아몬드에서 같은 해제 통지가
두 번 도착해도 두 번째가 접히는 건 소스 emit과 똑같다.
- **⚠️ 단, 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기
`withheld`에 합친다** — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성
스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이
된다(`base/gate-plan.md`의 4번).
- **재계산 판정**:
- `rawInvalid == true` → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
- `rawInvalid == false`**그때만 `sourceCountMap`을 훑는다.** 목적은 하나뿐 —
**못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에 막혀 있었다거나
전파 파동이 아직 이 가지에 안 닿았다거나). 다른 항목이 발견되면
**`sourceCountMap`만** 갱신하고 `rawInvalid = true`로 만든다.
- **⭐ 순회는 `sourceEmitMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.**
그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로
전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*).
- **재계산이 끝나면 `rawInvalid = false`, 그리고 `sourceCountMap`은 자기가 읽은
상류 전부에 대해 갱신한다**(**[2026-08-21 확정]** — 발행 소스 항목만 갱신하는
게 아니다). 맵의 뜻이 "내 값이 이 소스에 대해 최신인가"이므로, 방금 계산한
값은 정의상 **모든** 상류에 대해 최신이다. `sourceEmitMap`**안 건드린다**
계산은 통지가 아니다.
- 이게 실제로 갈리는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로
앞당겨 읽는 경우**다. 그때 `sourceCountMap`이 앞서 있으므로, 나중에 게이트가
풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을 다시 계산하지
않는다.**
- count가 싫다면 `[source] -> {}` 처럼 **유니크 테이블 identity**로 같은 판정이
가능하다(사용자 대안).
**⭐ 왜 테이블이 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의
상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시
던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을
최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 카운트
하나로 충분하다 — 실제로 2026-08-21 2차 정정에서 그렇게 정리했었다. 순회를
넣는 순간 그 둘이 갈라진다(순회는 값만 앞당기고 통지는 안 한다). 그래서 이
분리는 **한 번 철회됐던 `seen`/`computedAt` 분리가, 다른 이유로 되살아난
것**이다 — 옛 근거("전파 시점에 갱신된 count가 캐시를 신선한 것으로 오인시킨다")는
여전히 틀렸고, 지금 근거는 **순회가 값과 통지를 비대칭으로 앞당긴다**는 것이다.
**⚠️ [2026-08-21 `/code-review high`] 아래 희소 구현 메모는 "둘 다 상류에서
복사"와 그대로는 안 맞는다** — 아래 §5의 7번이 소스. 두 맵을 명시적으로 다
들고 시작하는 게 안전하고, 희소화는 그 규칙이 정해진 뒤 얹을 것.
**구현 메모 — `sourceEmitMap`은 희소 테이블로 두면 된다.** emit이 count를 안
싣고 받는 쪽이 라이브로 읽으므로, 이 테이블에 실제로 필요한 정보는 **"순회가
앞질러 흡수해서 아직 안 던진 소스가 무엇인가"** 뿐이다. 평상시엔 비어 있고
순회가 발견했을 때만 채워지는 `pending` 집합으로 구현해도 위 세 규칙이 그대로
성립한다(1번에서 지우고, 2번에서 있으면 던지고 지운다).
## 3. 에이전트 분석 — 이게 실제로 무엇을 고치는가
**결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라
`Get()`의 의미론을 바꾸는 결정이다.**
- **고쳐진다 — 섞인 값.** 위 3단계에서 `D``C:Get()`을 부르면, `C`도 자기
`sourceCountMap`에서 `A`의 count가 자기가 기록한 것보다 앞선 걸 보고 **신호가
아직 안 왔어도 스스로 재계산**한다. 그래서 `D`는 항상 `(B_new, C_new)`
얻는다. **`Get()`이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로
성립**한다.
- **고쳐진다 — 중복 재계산.** 뒤늦게 `C` 쪽 신호가 도착해도 `sourceCountMap`
"이미 최신"이라 `rawInvalid`가 켜지지 않고, 따라서 재계산도 안 일어난다
(§2의 2번/3번 규칙 — **[2026-08-21 정정]** 여기 "`rawInvalid`가 켜져도"라고
적혀 있었으나 §2 규칙상 켜지지 않는다).
- **⭐ [2026-08-21 확정] 중복 *통지*도 같이 접는다 — `source-state-plan.md`
"접지 않는다"고 확정해뒀던 것의 역전이다**(역전 원문은
`archive/always-propagate-no-dedup-superseded.md`). 처음엔 "값만
고쳐지고 통지는 두 번 그대로"로 정리했는데, **사용자 판정으로 통지도
같은 장치로 접기로 했다**: *"중복 통지는 단순히, count 목록을 순회해보고
이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를
주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은,
앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."*
- **판정은 O(1)이다** — emit이 **어느 source가 발행했는지**를 실어 오므로
**그 소스 하나만** 비교하면 된다(전체 목록 순회가 아님).
- **⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다.** 그때 폐기된 건
*"이미 `invalid`면 더 안 내려보낸다"*로, `:Get()`을 안 부르는 Observer가
**한 번 울고 영구히 침묵**하는 실패 모드가 있었다
(`archive/invalidate-dedup-propagation-reversed.md`). 에포크 비교는 그
모드가 없다 — 매 `Set`마다 카운트가 **새 값**이라 항상 통과하고, 접히는 건
**같은 에포크가 두 경로로 도착한 두 번째**뿐이다.
- **⚠️ [2026-08-21 철회 후 재도입] 여기 있던 "카운트를 `seen`/`computedAt`
둘로 분리하라"는 요구는 한 번 철회됐다가 **다른 근거로 되살아났다**
지금 형태는 `sourceCountMap`/`sourceEmitMap`이고, 근거는 원래 적었던
"전파 시점 갱신이 캐시를 오인시킨다"(틀림)가 아니라 **순회가 값만 앞당기고
통지는 안 한다**는 비대칭이다. §2의 "왜 테이블이 둘인가" 문단이 소스.
- 비교는 두 맵 모두 `source.count`와 같으면 삼킨다(단조 증가라 안전).
- **선례가 있다** — 값 자체를 비교하는 게 아니라 **버전/에포크를 비교해
lazy하게 검증**하는 건 MobX(global state version + observing 검사),
Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준
기법이다. Fusion처럼 **그래프를 위상정렬해 eager로 미는 방식**(quad가 이미
기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한
pull 모델과 **결이 같다**.
## 4. 비용
사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해
인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘:
- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 Source의 수**다.
체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
큰 경우는 드물다.
- **[2026-08-21 정정]** 순회 조건이 뒤집혔으므로 비용 구도도 뒤집힌다 —
**훑는 쪽이 흔한 경로**(`rawInvalid == false`, 즉 "안 바뀐 것 같다")다.
그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼
**재계산은 안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예
건너뛰고 바로 재계산한다.
## 5. 열린 질문 (채택 전에 답이 필요)
1. **[해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다.**
에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고
제안했으나 **사용자가 기각**: *"그건 아니다. 이 동작으로 인해 이제 정말로
항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함.
이전과 다른게 없다고 생각한다."* — 선언 안 한 Source를 클로저로 읽는 건
**지금 모델에서도 똑같이 stale**이고 이 변경이 악화시키는 게 없으므로,
새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다.
2. **동적 의존성.** 조건에 따라 다른 상류를 읽는 계산이면 `sourceCountMap`
보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다.
그대로 감수할지 확인.
3. **[2026-08-21 해소] 순회가 발견한 변경을 어떻게 처분하는가 — 두 테이블로
나눠 "값은 앞당기고 통지는 기다린다"로 확정.**
**문제**(사용자 지적): 순회가 count를 최신으로 올려두면 뒤늦게 도착한 진짜
emit이 삼켜져 **하류가 그 에포크를 영영 못 받는다.** 값은 맞는데 통지가
죽는, 2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열의 실패 모드다
(`archive/invalidate-dedup-propagation-reversed.md`).
**확정 해법**(사용자 제안, §2가 소스): 판정 기준을 **둘로 나눈다**
*"'내 값이, 상류의 상태로 하여금 사용 가능하다' 를 보는 테이블과, '내가
emit을 받을 때, 그걸 다시 던져야하는지 봐야하나' 를 보는걸 나누는거죠."*
순회는 `sourceCountMap`만 올리고 **emit은 안 한다**. 나중에 진짜 emit이
오면 count는 같지만 `sourceEmitMap`이 달라 **그때 정상적으로 전파**된다
(*"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*). 결과:
- 통지가 죽지 않는다.
- 순회가 emit을 안 하므로 **게이트를 새게 하는 경로가 아예 없다**
`source = nil` 규약도, "게이트를 에포크 경계로" 같은 계약 반전도 필요 없다.
- `Get()`은 순회 덕에 항상 신선하고, 순회가 `sourceCountMap`을 실제로
올리므로 **다음 `Get()`이 같은 차이를 재발견해 또 재계산하는 낭비도 없다**
(에이전트가 냈던 대안 (c)의 유일한 약점이 여기서 사라진다).
**같이 검토된 대안 — `rawEmit`을 태우고 `nil`을 던지는 안**(사용자 제안 1안):
순회가 만든 emit을 **상류가 부르는 것과 같은 내부 진입점(`rawEmit`)** 으로
흘려서, 그 노드에 `Gate`가 껴 있으면 자연히 게이트를 타게 하고, 순회가 찾은
변경은 여러 소스일 수 있으니 하류엔 `source = nil`을 던진다는 안.
**구조 위생 쪽은 채택할 만하다** — 상류 emit과 내부 발생 emit이 같은 진입점을
쓰면 게이트가 붙은 노드에서 두 경로가 갈리지 않는다. 다만 **이 문제의 해법으로는
위 2안이 더 낫다**:
- 막고 있는 게이트는 보통 **순회하는 노드 자신이 아니라 상류**에 있다. 자기
`rawEmit`을 태워봐야 상류 게이트는 안 물어보므로 **누출이 그대로 남는다.**
- `nil` emit은 받는 쪽마다 **전체 순회를 강제**하고, 그 순회가 다시 count를
올리면 같은 "영구 침묵" 문제가 하류에서 재발한다(연쇄).
- 2안은 순회가 애초에 emit을 안 하므로 두 문제가 **생기지 않는다.**
**부수 확인**: `blocker:OffWithoutEmit()`처럼 emit 없이 푸는 경로에서
`sourceEmitMap`이 뒤에 남아도, 그 소스의 **다음 진짜 emit**이 위 1번(둘 다
다름)으로 걸려 정상화된다 — 별도 조치가 필요 없다.
4. **`:Emit()`(값을 제자리에서 mutate하고 알리는 경로)** — count만 올리면 되므로
그대로 동작한다. 확인만.
5. **weak 키.** `[source] -> count`를 weak-key로 두면 source가 죽을 때 항목이
사라진다. 다만 `base/relate-plan.md`가 경고하듯 **값이 키를 되참조하면 안
된다** — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이
source를 참조하지 않게 할 것).
6. **[2026-08-21 반영 완료] `Get`의 계약 문구.** 사용자 지적(*"Get 이 항상 최신
상태를 가져온다라는 말이 여기서 무력화되는 부분"*)의 방향은 사실 **반대**였다
— 옛 모델이 "최신"을 못 지키고 있었고 이 안이 그걸 지키게 만든다.
`base/source-state-plan.md`의 전파 모델 절은 **채택과 함께 그렇게 다시
썼다.**
7. **[2026-08-21 전량 해소] 두 맵의 초기값·병합·재계산 시 갱신 범위.**
- **(a) 재계산 후 `sourceCountMap` 갱신 범위** — **자기가 읽은 상류 전부를
갱신한다**(사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히
전부 업데이트 하는건 맞아보입니다"*). §2에 반영.
- ⚠️ 이 항목을 제기할 때 든 근거("`A:Set(); Z:Set()`이면 같은 값을 두 번
계산한다")는 **틀렸었다.** 전파는 동기라 `A:Set()`의 파동이 **완전히
끝난 뒤에** `Z:Set()`이 시작되므로, 그 사이 재계산은 `Z`의 옛 값을 읽는
게 맞고 통지가 두 번 나는 것도 맞다(사용자 정정: *"싱크라 set 의 emit 이
전부 전파 된 다음 z:set 이라 두번 나는게 맞긴 해요"*). 전부 갱신이
실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가 `Get()`으로
앞당겨 읽는 경우**뿐이다.
- **(b) 새 노드의 두 맵 초기값** — `sourceEmitMap`**비우고**,
`sourceCountMap`**전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**.
§2의 "노드가 생길 때의 초기값" 문단이 소스.
- **(c) `:With` 병합 규칙** — (b)로 인해 **필요 없어졌다.**
## 6. 곁가지 — 폴링용 sugar
사용자 제안: *"폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다.
옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게
Ref<T> 로 변환해주는 등"*. 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는
순수 슈가. **이건 이 문서의 결정과 독립**이고(에포크를 채택하든 안 하든 쓸 수
있다), `research/operator-sugar-plan.md`의 콤비네이터 계열에 더 가깝다 —
채택되면 그쪽으로 옮길 것.
## 7. 구현 시 확인할 것 (채택 확정 후 남은 실무 항목)
- **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다** —
"emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은
중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 소스의 같은
에포크가 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면 2026-08-14에
폐기된 "영구 침묵" 버그로 되돌아간다(§3).
- **선언 안 된 의존성에 대한 UB 조항은 안 만든다** — §5의 1번(사용자 기각).
- **`luau-test` 스파이크 하나**: §1의 다이아몬드를 그대로 짜서 (a) 에포크 없이는
섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조.
- **`sourceEmitMap`은 희소 `pending` 구현으로 시작해도 된다** — §2의 구현 메모.
- 곁가지였던 폴링 슈가는 §6 그대로 — 이 결정과 독립이고 `operator-sugar-plan.md`
계열로 남는다.