`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
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 플래그)에서:
A가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라B쪽 가지가 먼저 끝까지 내려간다 —D가B를 통해 신호를 받고, 그 아래 Observer가 발화한다.- 그 Observer가
D:Get()을 부른다.D는 상류를Get()하는데,C는 아직 신호를 못 받아invalid가 아니다 →C는 옛 캐시를 그대로 반환한다. D는(B_new, C_old)라는 섞인 값을 계산해 캐시하고invalid를 끈다.- 뒤늦게
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가 이 인터페이스를 구조적으로 만족한다 —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 안에 머문다.self.Revision = bit32.bnot(-self.Revision)[2026-08-22 실측] 이건 랩어라운드 감소다 —
a > 0이면a - 1,a == 0이면4294967295로 한 바퀴 돈다.luau로 확인한 값:revbit32.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).
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.md4번이 게이트의 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가 아니라 여기서 끝났다 — 같은 자리에서 확정됐으므로 열린 항목이 아니다.
- 왜 필요한가: §4의 시딩 규칙은 "상류의
- 키는 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
세 갈래로 읽으면 이렇다:
valueEpochMap이 달랐다 → 값이 낡았다.rawInvalid = true를 세우고 뒤로 emit 한다(두 맵 모두 갱신됨).valueEpochMap은 같은데emitEpochMap이 달랐다 → 값은 이미 최신이지만 통지는 아직 안 나갔다(순회가 앞질러 흡수했거나, 게이트가 붙들고 있는 동안 하류가Get()으로 앞당겨 읽은 경우).emitEpochMap만 갱신되고 뒤로 emit 한다 —rawInvalid는 안 건드린다.- 둘 다 같다 → 삼킨다. 다이아몬드에서 같은 리비전이 두 경로로 도착한 두 번째가 여기서 접힌다.
- ⚠️ [2026-08-22 신설]
GateNode는 이 의사코드를 그대로 쓰지 않는다. 위 코드는emitEpochMap을 수신 시점에 갱신하는데, 게이트는base/gate-plan.md4번이 전파할 때:Sync(batch)로 갱신하는 것으로 확정돼 있다(그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중엔 아직 안 던졌으니까). 그래서 게이트에서는:- 판정(규칙 1~3)은 똑같이 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고
흡수 집합에도 안 들어간다(
gate-plan.md4번). - 다만
emitEpochMap:Update를 수신 시점에 부르지 않으므로, 유보 중에 같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책이 한 번 더 돈다. 이미 흡수 집합에 있어 무해하다(같은 절). 정책이 실제로 emit한 뒤에는:Sync(batch)가 돌아 있으므로 그 다음 도착은 정상적으로 규칙 3에 걸린다. - 이 예외가 여기 기록돼 있지 않았다(2026-08-21 커밋 전
/code-review high발견) — §4대로 구현하면gate-plan.md4번의 계약이 조용히 깨진다.
- 판정(규칙 1~3)은 똑같이 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고
흡수 집합에도 안 들어간다(
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가
깨워도 첫 번째만 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당 둘), 옛 모양도 테이블 둘이었으므로 컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
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.md3번의 공개 계약을 정면으로 깬다 — "게이트를 통과하지 않은 값도: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)nilemit은 받는 쪽마다 전체 순회를 강제해 같은 "영구 침묵"이 하류에서 연쇄로 재발한다. 지금 안은 순회가 애초에 emit을 안 하므로 두 문제가 생기지 않는다. - 곁가지 — 폴링용 슈가. 사용자 제안: "폴링을 위해서는 Apply(Realtime())
같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수
있게 해주는것. 단순하게
Ref<T>로 변환해주는 등". 이 문서의 결정과 독립이고research/operator-sugar-plan.md의 콤비네이터 계열에 속한다.