4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설(205문항). 범위를 셋으로 좁힘 — (1) 4라운드에 문항이 아예 없던 영역(project-setup / quad-types, 그리고 문서가 아니라 실제 커밋된 M1 코드), (2) 그 이후 확정된 것 (Detach/KeyGone/Owned/attachSlot 분해), (3) 큰 문서의 심화. 회신을 4차에 걸쳐 받아 전량 반영했고, 커밋 전 감사를 각도를 바꿔 2라운드 돌렸다. 주요 확정/역전: - slot._detached lazy화, KeyGone엔 새 값 반환도 error, Owned=false에서 Detach는 _detached에 안 들어감(rawUnmount) - Slot:Replace 신설 + rawReplace/rawAdd 의사코드 신설(문서에 정의가 없었음) - raw* 인자를 index로 전부 통일 — 오래 열려 있던 캐비엇 종결. 래핑은 raw* 바깥에서만(공개 표면 + settle), raw*는 물리 요소만 다룸 - 물리 조작을 주입 op로(mountInst/unmountInst/disposeInst, 이름 가칭) — base는 Parent를 모른다는 지적. mountInst는 0-based 절대 offset을 받음 - Dispatch.setLength에 anchor(생략 시 ownerKey) — 부기 키와 생명주기 앵커 분리, 4라운드 D-56 역전(archive로) - Dispatch.getOffsetAt 신설(pull) + 접두합 캐시(offsetDirtyFrom), setOffsetSource(None)은 얼리 리턴, None의 뜻을 "발행 채널 없음"으로 정정 - recompute가 owner 베이스에서 시작(중첩 offset이 부모 베이스를 못 받던 결함), _baseObserver로 깊은 전파, Offset Source identity 재사용(포탈), bk.N or 0(빈 Slot 크래시) - Effect(fn, ...deps) 확정 — Ref도 의존성(옛 "trailing args sugar 안 만듦" 역전), Tween:Mapped, groupClaimKeys 키 = (inst, groupValue) → k - 게이팅 먼저(M2로 앞당김) — 다만 대상이 Blocker가 아니라 공용 Gate 노드로 바뀌었고, 설계는 사용자 지시로 다음 세션(M2 착수를 막는 유일한 항목) 새 research 둘: gate-primitive.md(다음 세션이 이어받을 재료), state-epoch-validation.md(전파 중 Get이 섞인 값을 캐시하는 glitch — 정확성 결정이라 M3 전 결론 필요). 감사가 잡은 것 중 큰 것: 확정한 Owned가 Slot:List 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, effect-plan.md의 역전 배너 없는 자기모순, 그리고 손대지 않은 문서(ROADMAP 백로그·debounce-throttle-plan)가 "Gate는 M3에서"로 남아 있던 사각지대. doc-check.py ERROR 0. 상세는 qa-request/pre-implementation-qa-round5-followup.md (A~K절, 마지막이 최신). Co-authored-by: qwreey <me@qwreey.moe>
10 KiB
State 재계산 판정을 "소스 에포크" 비교로 바꾸는 안 (2026-08-21 신설)
상태: research — 사용자 제안, 에이전트 분석 완료, 세부 두 건은 2026-08-21에 추가 확정(중복 통지도 접음 / 선언 안 된 의존성 조항 기각). 채택 자체는 여전히 미정.
구현 전 QA 5라운드(SS-2/SS-3)에서 나왔고 사용자 스스로 *"더 생각해볼
이야기라 백로깅이나 리서치에 들어가야할듯"*이라 함. 회신 원문은
qa-request/pre-implementation-qa-round5-response.md.
⚠️ base/source-state-plan.md가 여전히 정본이다 — 이 문서는 아무것도
확정하지 않는다. 다만 State 내부 표현을 바꾸는 제안이라 M3(=State 구현)
착수 전에 결론이 나야 한다.
1. 사용자가 지목한 문제 (실재함)
A ──> B ──┐
└──> C ──┴──> D
A:Set() 한 번에 대해 지금 모델(push-invalidate + pull-recompute)에서:
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이고, 지금 quad 문서 어디에도 이 현상이 서술돼 있지
않다. base/source-state-plan.md의 "다이아몬드 의존성은 무엇이 푸는가" 절은
중복 재계산이 없다고만 말하는데, 그 논증은 "누군가 d:Get()을 부르는
시점"이 전파 파동이 끝난 뒤라고 암묵적으로 가정한다 — Observer가 전파 도중에
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다.
2. 사용자 제안 (최종 정리형)
각 State가 자기에게 영향을 주는 루트 Source들의 에포크를 들고 있다가,
Get() 때 그것과 실제 Source의 현재 에포크를 비교해 재계산 여부를 정한다.
State
sourceList : { [source (weak key)] : count } -- 상류에서 복사, :With에서 합침
rawInvalid : boolean -- emit이 오면 그냥 true(값싼 캐시)
invalid : rawInvalid가 true일 때만 sourceList를 훑어 "정말 계산이 필요한가" 판정
emit은 발행한 source와 그 count를 같이 전달한다(값은 여전히 안 싣는다).Source:Set()/:Emit()은 자기 count를 증가시킨다.- 캐시를 채울 때 그 시점의 각 source count를 같이 기록해두고,
Get()때 하나라도 다르면 재계산한다. - count가 싫다면
[source] -> {}처럼 유니크 테이블 identity로 같은 판정이 가능하다(사용자 대안).
3. 에이전트 분석 — 이게 실제로 무엇을 고치는가
결론부터: 문제 진단도 해법 방향도 맞다. 그리고 이건 성능 최적화가 아니라
Get()의 의미론을 바꾸는 결정이다.
- 고쳐진다 — 섞인 값. 위 3단계에서
D가C:Get()을 부르면,C도 자기sourceList에서A의 count가 자기가 기록한 것보다 앞선 걸 보고 신호가 아직 안 왔어도 스스로 재계산한다. 그래서D는 항상(B_new, C_new)를 얻는다.Get()이 "지금 이 순간의 일관된 값"을 반환한다는 보장이 처음으로 성립한다. - 고쳐진다 — 중복 재계산. 뒤늦게
C쪽 신호가 도착해rawInvalid가 켜져도, count 비교가 "이미 최신"이라 재계산이 안 일어난다. - ⭐ [2026-08-21 후속 확정] 중복 통지도 같이 접는다. 처음엔 "값만
고쳐지고 통지는 두 번 그대로"로 정리했는데, 사용자 판정으로 통지도
같은 장치로 접기로 했다: "중복 통지는 단순히, count 목록을 순회해보고
이미 모든 소스가 최신이면 무시하는게 맞아보인다. 특히 emit 은 자신 소스를
주게 되므로, 자신 소스 카운트만 빠르게 비교가 쉽다. 우리에게 중요한 점은,
앞단의 변경이 뒤로 흘렀냐일 뿐이라서 해당 방식을 택하는데 있어 문제가 없다."
- 판정은 O(1)이다 — emit이
(source, count)를 실어 오므로 그 소스 하나만 비교하면 된다(전체 목록 순회가 아님). - ⚠️ 이건 2026-08-14에 뒤집힌 옛 dedup과 다른 장치다. 그때 폐기된 건
*"이미
invalid면 더 안 내려보낸다"*로,:Get()을 안 부르는 Observer가 한 번 울고 영구히 침묵하는 실패 모드가 있었다 (archive/invalidate-dedup-propagation-reversed.md). 에포크 비교는 그 모드가 없다 — 매Set마다 카운트가 새 값이라 항상 통과하고, 접히는 건 같은 에포크가 두 경로로 도착한 두 번째뿐이다. - 그래서 노드가 기록해야 하는 카운트가 둘이다: 전파 dedup용
"이 소스의 이 에포크를 이미 내려보냈다"(
seen)와, 캐시 검증용 "이 값을 계산할 때의 에포크"(computedAt). 하나로 합치면 전파 시점에 갱신되는 쪽이 캐시를 신선한 것으로 오인하게 만든다 — 구현 시 반드시 분리할 것. - 비교는
incoming <= seen[source]면 삼킨다(단조 증가라 안전).
- 판정은 O(1)이다 — emit이
- 선례가 있다 — 값 자체를 비교하는 게 아니라 버전/에포크를 비교해 lazy하게 검증하는 건 MobX(global state version + observing 검사), Adapton류 incremental computing, "reactively" 계열 라이브러리가 쓰는 표준 기법이다. Fusion처럼 그래프를 위상정렬해 eager로 미는 방식(quad가 이미 기각한 것)의 대안으로 자주 쓰인다 — 즉 이 제안은 quad가 이미 택한 pull 모델과 결이 같다.
4. 비용
사용자 추산("해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도")에 동의한다. 덧붙일 것 둘:
sourceList의 크기는 그 노드 상류에 있는 서로 다른 루트 Source의 수다. 체인이 길어져도 안 늘고,:With로 합류할 때만 는다. UI 파생값에서 이 수가 큰 경우는 드물다.rawInvalid가 false면 훑지도 않으므로, 정말 안 바뀐 흔한 경로는 지금과 같은 비용(플래그 하나)이다.
5. 열린 질문 (채택 전에 답이 필요)
- [해소, 2026-08-21] 선언 안 된 의존성 — 별도 UB 조항을 만들지 않는다. 에이전트가 "새 모델은 더 강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 사용자가 기각: "그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을 던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고 생각한다." — 선언 안 한 Source를 클로저로 읽는 건 지금 모델에서도 똑같이 stale이고 이 변경이 악화시키는 게 없으므로, 새 조항 없이 기존 "의존성은 선언한다"는 관례 그대로 둔다.
- 동적 의존성. 조건에 따라 다른 상류를 읽는 계산이면
sourceList가 보수적 상위집합이 된다 — 틀리진 않고 재계산이 조금 더 잦아질 뿐이다. 그대로 감수할지 확인. Blocker/Gate와의 상호작용. 게이트는 통지만 막고Get()엔 영향이 없다는 게 확정 계약이므로(base/blocker-plan.md), 게이트 아래에서Get()하면 에포크 비교가 "갱신 필요"로 판정해 막아둔 값이 그대로 보인다. 지금도 같은 동작이지만, 새 모델에선 그게 더 또렷해지므로 문서에 명시할 것.:Emit()(값을 제자리에서 mutate하고 알리는 경로) — count만 올리면 되므로 그대로 동작한다. 확인만.- weak 키.
[source] -> count를 weak-key로 두면 source가 죽을 때 항목이 사라진다. 다만base/relate-plan.md가 경고하듯 값이 키를 되참조하면 안 된다 — count는 숫자라 문제없다(테이블 identity 방식을 택하면 그 테이블이 source를 참조하지 않게 할 것). Get의 계약 문구. 사용자 지적("Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분")의 방향은 사실 반대다 — 지금 모델이 "최신"을 못 지키고 있었고, 이 제안이 그걸 지키게 만든다. 채택하면base/source-state-plan.md의 전파 모델 절을 그렇게 다시 써야 한다.
6. 곁가지 — 폴링용 sugar
사용자 제안: "폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다.
옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게
Ref 로 변환해주는 등". 즉 "항상 최신 값을 필드로 들고 있는 박스"를 만드는
순수 슈가. 이건 이 문서의 결정과 독립이고(에포크를 채택하든 안 하든 쓸 수
있다), research/operator-sugar-plan.md의 콤비네이터 계열에 더 가깝다 —
채택되면 그쪽으로 옮길 것.
7. 권고
- 채택 방향에 찬성. "선제 최적화가 아니라 확정 동작으로의 승격"이라는 사용자 판단에 동의한다 — 이건 성능이 아니라 정확성 결정이고, State 내부 구조를 정하는 M3보다 뒤에 하면 되돌리는 비용이 크다.
- [2026-08-21] 에이전트가 붙였던 조건(선언 안 된 의존성을 UB로 명문화)은 사용자 기각으로 빠졌다 — §5의 1번 참고.
- 채택 시 같이 확정되는 것: 중복 통지도 접는다(§3) — 그러면 다이아몬드에서
값도 통지도 한 번씩만 간다. 대신 노드가
seen/computedAt두 카운트를 들어야 한다는 게 구현 요구사항으로 추가된다. - 실측은
luau-test에 스파이크 하나면 충분하다 — 위 다이아몬드를 그대로 짜서 (a) 지금 모델에서 섞인 값이 실제로 관측되는지, (b) 에포크 비교를 넣으면 사라지는지 대조.