quad/.claude/base/state-epoch-plan.md
qwreey 0498816c10
design: Epoch/EpochMap/Brand 전면 승격 + 해소 기록 flatten
앞 세션이 컨텍스트 피로로 미뤄둔 승격(todos 000번)을 수행하고, 코퍼스에
쌓여 있던 [해소]/[정정] 층을 걷어냈다. 감사 3라운드 + /code-review high로
15건을 잡아 전부 반영했다.

승격 — base/ 넷 + 파급 넷
- state-epoch-plan.md 재작성: Epoch 인터페이스({Revision:number}, 그 자체로
  키가 되는 unique 테이블, Source가 구조적으로 만족)와 EpochMap(Update/
  Refresh/Sync/TrackFrom) 신설. State는 EpochMap을 둘 컴포지션 —
  sourceCountMap/sourceEmitMap -> valueEpochMap/emitEpochMap. §1~§8로 재편.
- brand-plan.md 전면 재작성: 공유 레지스트리 + Brand.get(객체당 태그 하나)
  -> 인스턴스 브랜드 Brand() + :register/:is, 다중 태깅 허용. 발단은 Source가
  SourceBrand이면서 동시에 EpochBrand여야 하는데 옛 모양으로 표현 불가.
  역조회는 제거(전수 조사에서 쓰는 자리 0). weak-key/테이블 아이덴티티/
  duck-typing 기각 근거/predicate 합성은 전부 유지.
- source-state-plan.md: Source가 Epoch도 구조적으로 만족(Revision은 공개여야
  타입 레벨에서 성립), Observer 클로저가 fn(self, from: (Epoch|EpochSet)?).
-  effect-plan.md: 다중 의존성 중복 발화 미해결 항목이 닫힘 —
  EffectHandle이 자기 EpochMap을 들어 첫 번째만 통과시킨다.
- gate-plan.md/architecture.md/bind-system-plan.md/ROADMAP.md 어휘 통일,
  EpochMap.luau가 M2에도 필요하다는 것 반영(GateNode가 씀).

리비전 갱신은 bit32.bnot(-rev) — 사용자 확정
- a>0이면 a-1, 0이면 4294967295인 랩어라운드 감소. 갱신과 랩이 FASTCALL
  하나로 끝난다(luau 실측). 근거: 2^53 포화는 도달 불가능한데 그걸 피하려고
  값을 double 영역까지 키울 이유가 없다, 매번 도는 hot path다.
- 에이전트가 이걸 band(rev+1, mask)로 잘못 옮기고 "그러니 bit32가 더 싼 건
  아니다"라는 틀린 단서까지 달았다가 사용자 정정("제가 말한건 bit32.bnot(-a)
  입니다"). 세 문서에 정정 경위를 남겼다.
- 따름정리: 리비전은 증가가 아니라 감소한다. ==/~= 만 쓰는 지금 규칙에서만
  무해하다는 경고를 §2에 명시.

archive / flatten
- archive/brand-shared-registry-reversed.md 신설(옛 Brand 표면 원문).
- question.md 421->208줄: 해소 항목 18건을 archive/question-resolved.md로
  이관. 그 문서가 스스로 정한 규칙("해소하면 여기서 지우고 archive로")을
  다시 어기고 있었다. 이관분의 옛 필드명은 소급 수정하지 않고 머리에 경고만.
- todos.md 000번 삭제, "M3 착수 전 필요" 목록에서 해소 항목 일곱 제거
  (실제로 열린 건 중간 State GC와 store:GetDynamic 둘뿐).
- research/ -> reference/ 이동 둘(epoch-brand-composition,
  slot-attach-decomposition). 확정된 결정의 근거 기록은 research(상의 필요)도
  archive(뒤집힘)도 아니므로, reference의 폴더 기준에 그 용도를 명문화했다.

감사 3라운드(각도: base 정합성 / 인덱스+luau-test / archive+qa-request)
- §8에 "기각된 대안 — 게이트를 에포크 경계로" 논거 신설(재작성 때 떨어뜨렸고,
  두 문서가 서로 다른 없는 §번호를 대고 있었다).
- luau-test 스파이크 22를 done/ -> rewrite-required/(옛 Brand.set/get을 직접
  구현). STATUS.md 개수와 절 제목의 하드코딩 개수 정리.
- state-epoch/source-state가 "Revision을 증가시킨다"고 적어놓고 20줄 뒤에
  "감소한다"로 반박하던 자기모순 정정.

커밋 전 /code-review high — 9건, 전부 유효(감사자가 못 보는 축)
-  {Epoch}는 Luau에서 배열인데 실제 게이트 배치는 {[Epoch]:true} 집합.
  그대로 ipairs로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다(gate-plan 4번이
  애초에 고치려던 그 버그) -> EpochSet으로 확정.
-  새 노드 시딩이 확정된 EpochMap 표면으로 표현 불가능했다(:With의 상류는
  State이지 Epoch가 아니고, 키 열거/병합 연산이 없었음) -> :TrackFrom 신설.
- GateNode 예외(emitEpochMap을 전파 시점에 갱신)가 §4에 미기록.
- 설치 발화엔 from이 없다 -> 옵셔널로, Effect의 억제 플래그가 Update보다
  먼저여야 함을 명시.
- 2^32 랩을 "똑같이 도달 불가능"이라 한 근거가 틀렸다(같은 척도로 285년 vs
  72분). 실제 안전 근거는 충돌 조건이 한 점이라는 것으로 정정.
- 그 외 :Sync 용도 충돌, isEpoch 누락, TweenTag 3곳, Effect(fn,state?) 4곳,
  § 참조 3곳.

에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정
- :Refresh 유지 — "Update는 받은 것을 처리, Refresh는 내가 받았던 걸 처리라
  표면적 의미 자체가 다르다"(오버로드로 합치지 않음).
- :Absorb -> :TrackFrom 개명 — absorb는 상위에서 제거할 것처럼 읽히고,
  gate-plan이 이미 "흡수 집합"을 다른 뜻으로 쓴다. TrackFrom은 이 맵의 존재
  이유("내가 뭘 추적하고 있나")를 그대로 쓰고 From이 비파괴를 못박는다.

Epoch/EpochMap/Brand에 열린 설계 항목 없음. doc-check.py ERROR 0.

Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D
2026-08-22 01:27:18 +09:00

30 KiB

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

상태: 확정. 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤 채택 확정됨 — "gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다. 채택하면 될것 같아요." 구현은 M3(Source/State).

⚠️ 이 문서는 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의 콤비네이터 계열에 속한다.