발견 17건(H-107~H-123)의 사용자 결정을 base/ 전체에 반영. 7라운드 확정 중 뒤집힌 건 없고, 고친 건 전부 7라운드가 base/에 내려앉을 때 생긴 누락·충돌 (하루 차로 확정된 결정들이 서로를 못 본 자리)이다. 결정의 소스는 qa-request/pre-implementation-handtrace-round8-followup.md. 계약 변경 넷: - Ref 콜백이 fn(value, ref) — 2번째가 곧 출처 Epoch. Effect가 Update(from)에 넘길 유일한 통로였다(k(value)뿐이면 Update(nil) 크래시, 실측 재현). - Observer fn이 세 자리 fn(targetState, self, emitFrom) + observer._state 강참조. 옛 2-인자는 "self는 리시버" 계약과 정면 충돌해 무인자 state:Observer()의 내부 콜백이 즉사했다. - WeakSubscribe도 .Subscribed를 세운다. 안 그러면 Effect의 State dep 전량이 조용히 침묵. 해제는 "건 경로로 푼다"(양방향 fail-fast). - 예약 키 진단이 CheckReservedKeys<keyof<T>> — T를 통째로 넘기는 배선은 실사용 T에서 아예 안 돈다(Source<T>가 *error-type*을 품어 유효한 Store 전부에 스퓨리어스 에러). 사용자가 문항의 전제를 두 번 정정: Ref 콜백과 Observer 콜백은 이질적이라 애초에 통합 대상이 아니었고(Observer엔 자기 epoch가 없다), H-118은 소유권 문제가 아니라 gate-plan 5번의 문장이 틀린 것이었다(🟡→🟢). 커밋 전 검증 — 감사 11라운드(44건, 0건으로 수렴) + /code-review high 7라운드(42건) = 86건. 감사가 0으로 수렴한 직후 code-review가 42건을 냈고, 그중 하나가 H-101의 "새 필드를 안 만든다"를 역전시켰다: getOffsetAt의 부수효과가 splice의 되감기 신호를 지우는 경로가 실재해, 부기 필드를 offsetCacheValidUpTo(캐시)와 offsetSetUpTo(:Set 완료) 둘로 분리했다. "Set을 해줬느냐"와 "캐시가 유효하냐"를 한 값이 쥔 게 원인이었다. 그 외: splice 무효화 i-1, 명시 recompute 호출부 전부 재진입 게이트, recompute 되감기 클램프, Store defaults isSource 검증, isModifier 가드를 Source 생성자로, 훅 슈가 nil 가드, pesde.lock 커밋 확정, :Single 3-인자. 2026-08-25 session/ 원문 공백은 2026-08-19 선례대로 재구성 없이 기록만. doc-check ERROR 0 / WARN 기준선 유지. Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn Co-authored-by: qwreey <me@qwreey.moe>
39 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: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은 키를 넘겨받아 채운다.
-- 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.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을 둘 컴포지션한다
⚠️ [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로 한다.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 불린은 캐시 카운터 쌍으로 교체됐다 (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 를 놓는게 맞지 않을까?"
-- 생성 시
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.md4번이 확정한 대로 게이트는 통과시킬 때도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"이라 말하고 있으므로 결론은 안 바뀌고, 범위만 좁혀 적는다.
- **⚠️ [2026-08-25 범위 정정, 7라운드
- 동적 의존성은
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의 콤비네이터 계열에 속한다.