사용자 요청으로 최근 확정된 5개 영역을 실제 값으로 손 트레이싱했다 — `Effect(fn, ...deps)` / `Gate`·`Blocker` / State 전파(`rawInvalid`·emit 지연) / Slot의 `native*`·offset·length·mount / `Brand`·`Epoch`·`EpochMap`. 4·5라운드가 "예/아니오 문항지"였던 것과 달리 2·3라운드와 같은 성격이라 파일명도 `handtrace`로 갈랐고, 발견 번호는 `H-n`. **`base/`는 한 줄도 안 고쳤다** — 전부 발견 보고이고 사용자 회신 대기. ## 🔴 셋 (크래시하거나 조용히 어긋남) - `H-1` `:List`의 `keyIndex`가 사이클 **도중**엔 stale인데 `rawMove`/ `rawReplace`/`releaseElement`/`rawDetach`의 live `_elements` 인덱스로 쓰인다. 앞에 하나 끼우면 순서가 조용히 뒤집히고, 리스트 비우기는 소멸 루프의 해시 순회 순서에 따라 `_elements[i] == nil`로 터진다. 5라운드가 "raw\*를 전부 index 기준으로 통일"한 근거였던 *"그 값은 이미 `keyIndex`가 들고 있다"*가 여기서 깨지므로 **그 결정의 전제를 다시 봐야 한다** — 세 갈래(라이브 인덱스 맵 / `indexOfRaw` 기본 경로화 / reconcile 2패스)를 적어뒀고 이게 유일한 사용자 판단 항목 - `H-2` `pos`가 리프 개수와 `_elements` 배열 인덱스를 겸한다. `updateFn`이 중첩 Slot(멀티루트 결과)을 반환하면 첫 사이클에 바로 `table.insert` position out of bounds - `H-3` `getOffsetAt`의 접두합 캐시를 **아무도 무효화하지 않는다**. `invalidAfter = min(...)` 규칙이 표로만 존재하고 `setLength`/ `gatedRecompute`/`_baseObserver`/`spliceArrays*` 어느 의사코드에도 없다. 형제 길이가 변해도 뒤 형제 offset이 고정되고(`Length`만 맞고 offset은 틀리는 형태), 포탈 재마운트는 옛 베이스가 든 캐시를 그대로 물려받는다 ## 🟡🟢 일곱 `bk.invalidAfter`/`offsetCache`가 부기 스펙에 없고 초기값 `nil`이면 비교에서 터짐 / `spliceArraysUp`이 `bk.N`을 먼저 올려 스스로 금지한 창을 열고 그 안에 `nativeInsert`가 들어 있음 / `unmountSlotTree`가 미정의 `physicalTarget` 참조 (+ 순차 extract의 offset 스큐) / `Effect`의 `Ref` 의존성은 해제 API도 `canExecute` 게이팅도 없어 죽은 leaf에서 계속 발화 + 누수 / `EffectHandle._observer`가 아직 단수라 N-deps에서 2번째 이후 재실행이 죽음 / 게이트 `withheld`가 flush 스왑에서 weak를 잃음 / `recompute`의 `sum` 시작값 주석이 stale. ## 이상 없다고 확인한 것 Epoch/EpochMap 판정 규칙(§1 다이아몬드 실제 추적), 게이트 flush 스냅샷과 `:Sync(batch)` 예외, `Effect`의 EpochMap dedup + `_installing` 순서, `materializeSlotTree`/`mountSlotTree` 분해(depth 2 부기·물리 양쪽), 단건과 배치의 순서가 반대인 것, `Brand` 다중 태깅 — 그 문서 마지막 절이 소스이고, 다시 트레이싱할 필요 없다. 인덱스 레이어는 `.claude/README.md`(qa-request 행)와 `.claude/todos.md` 00번 갱신. `doc-check.py` ERROR 0. Claude-Session: https://claude.ai/code/session_01TiW21rnti9SbLgF6twtn6D Co-authored-by: qwreey <me@qwreey.moe>
21 KiB
구현 전 손 트레이싱 6라운드 — 최근 확정분 전수 추적, 발견 10건
상태: [2026-08-22 작성] 사용자 회신 대기. 아무것도 고치지 않았다 —
base/는 한 줄도 안 건드린 상태이고, 아래는 전부 발견 보고다. 판단이
필요한 항목이 섞여 있어서(특히 H-1) 임의로 반영하지 않았다.
왜 이 라운드가 있는가: 사용자 요청 — "Effect 의 다인자 Ref 허용 변경분, Gate/Blocker 의 새 형태, State 의 전파모델(invalid 의 새 전략, emit 지연과 전파의 새 전략), Slot 의 새로운 native와 offset/length/mount 전략, 새로운 Brand와 Epoch/EpochMap에 대해 손 트레이싱을 시도해봐요. 뭔가 문제가 되는 것들이 나오면 알려주세요."
성격이 4·5라운드와 다르다. 4·5라운드는 "예/아니오로 답할 문항지"였는데,
이번엔 2·3라운드와 같은 손 트레이싱이다 — 확정된 의사코드를 실제 값으로
한 줄씩 돌려보고 어긋나는 지점만 적는다. 그래서 문항 번호가 아니라 발견
번호(H-n)를 쓴다.
추적 대상 5개 영역(사용자가 지목한 그대로):
Effect(fn, ...deps) / Gate·Blocker / State 전파(rawInvalid·emit 지연) /
Slot의 native*·offset·length·mount / Brand·Epoch·EpochMap.
읽는 순서: 🔴 셋(H-1~H-3)이 실제로 크래시하거나 조용히 어긋나는
것이고 나머지는 그보다 가볍다. 마지막 "이상 없다고 확인한 것" 절도 같이
볼 것 — 다시 트레이싱할 필요가 없는 자리를 적어뒀다.
| 번호 | 심각도 | 한 줄 | 주 대상 |
|---|---|---|---|
H-1 |
🔴 | :List의 keyIndex가 사이클 도중엔 stale인데 live 배열 인덱스로 쓰임 |
base/slot-plan.md |
H-2 |
🔴 | pos(리프 개수)를 _elements 배열 인덱스로 겸용 — 중첩 Slot 반환 시 즉시 크래시 |
base/slot-plan.md |
H-3 |
🔴 | getOffsetAt의 접두합 캐시를 아무도 무효화하지 않음 |
base/dispatch-core-plan.md |
H-4 |
🟡 | bk.invalidAfter/bk.offsetCache가 부기 스펙에 없음 + 초기값 nil 비교 |
base/dispatch-core-plan.md |
H-5 |
🟡 | spliceArraysUp이 bk.N을 먼저 올려, 금지하기로 한 창을 연다 |
base/slot-plan.md |
H-6 |
🟡 | unmountSlotTree가 정의되지 않은 physicalTarget을 참조 |
base/slot-plan.md |
H-7 |
🟡 | Effect의 Ref 의존성은 떼어낼 방법이 없음(해제 API·게이팅 부재) |
base/effect-plan.md, base/ref-plan.md |
H-8 |
🟡 | EffectHandle._observer가 아직 단수 — N-deps 확정과 불일치 |
base/effect-plan.md |
H-9 |
🟢 | 게이트 withheld가 첫 flush 스왑에서 weak를 잃음 |
base/gate-plan.md |
H-10 |
🟢 | recompute의 sum 시작값 주석이 stale — 그대로 구현하면 Length가 틀림 |
base/dispatch-core-plan.md |
🔴 H-1 — :List reconcile의 keyIndex가 사이클 도중엔 stale인데 live _elements 인덱스로 쓰인다
어디: base/slot-plan.md의 settle(keyIndex[key]를 rawDetach/
releaseElement/rawMove/rawReplace의 index 인자로 넘기는 네 자리)과
reconcile의 소멸 루프.
무엇이 어긋나나: keyIndex = newKeyIndex 교체는 reconcile의 끝에서
한 번만 일어난다. 그런데 사이클 도중의 rawAdd/rawRemove는
table.insert/spliceArraysDown으로 배열을 시프트하므로, 그 뒤에 처리되는
키들의 keyIndex 값은 전부 한 칸씩(또는 그 이상) 어긋난다. raw* 인자
규약 절이 "그 값은 이미 keyIndex가 들고 있다(그 키가 지금 차지한 압축
위치 = _elements 인덱스)" 라고 단정한 게 정확히 여기서 깨진다.
트레이스 A — 앞에 하나 끼우기(가장 흔한 케이스)
data = [a, b] → _elements = {A, B}, keyIndex = {ka=1, kb=2}.
다음 사이클 data = [x, a, b]:
| 스텝 | 키 | 판정 | 결과 |
|---|---|---|---|
| 1 | kx |
새 키 → rawAdd(self, X, 1) |
_elements = {X, A, B} |
| 2 | ka |
result == prev, keyIndex[ka]=1 ≠ pos=2 → rawMove(self, 1, 2) |
인덱스 1은 이제 X → {A, X, B} |
| 3 | kb |
keyIndex[kb]=2 ≠ pos=3 → rawMove(self, 2, 3) |
인덱스 2는 X → {A, B, X} |
최종이 x, a, b여야 하는데 a, b, x가 된다. rawMove가 Parent를 안
건드리는 경로라 물리 순서는 recompute가 만드는 offset을 통해 어긋난다 —
에러가 안 나고 조용히 틀린다.
트레이스 B — 전체 삭제(크래시)
data = [a, b, c] → data = []. 소멸 루프가 pairs(keyIndex)를 도는데
해시 순회라 순서가 임의다. kb가 먼저 나오면:
settle(kb, nil, false, 0)→releaseElement(self, 2, B, false)→rawRemove(self, 2)→spliceArraysDown→_elements = {A, C}- 이어서
kc→releaseElement(self, 3, C, false)→rawRemove(self, 3)→local element = self._elements[3]=nil→releaseOwner(nil, self)또는nativeRemove(..., { nil })에서 터진다.
즉 필터도 중첩도 없는 평범한 "리스트 비우기"가 순회 순서에 따라 크래시한다.
왜 지금까지 안 잡혔나: 2·3라운드 트레이싱은 RC-1/RC-3/RC-4처럼
마운트 순서 쪽을 봤고, 5라운드의 raw* index 통일은 "settle이 index를
어디서 구하나"만 물었지 그 값이 언제 유효한가는 안 물었다.
⭐ 판단이 필요하다 — 세 갈래 중 어느 쪽으로 갈지:
- 라이브 인덱스 맵 유지 — 모든
raw*가 자리 수를 바꿀 때keyIndex(또는 전용 맵)를 같이 갱신. 정확하지만raw*가:List의 클로저 상태를 알아야 해서 층이 섞인다. settle에 element를 넘기고indexOfRaw를 기본 경로로 — 5라운드가 "폴백이지 기본 경로가 아니다"라고 못박은 걸 되돌리는 것. O(n)이 붙지만 층 분리는 유지된다.- reconcile을 2패스로 — 제거/detach를 전부 먼저 처리해 배열을 압축한 뒤, 삽입/이동을 하는 forward pass. 시프트가 한 방향으로만 일어나 인덱스 추론이 가능해진다.
어느 쪽이든 raw*를 전부 index 기준으로 통일한 5라운드 결정의 전제를
다시 봐야 한다.
🔴 H-2 — pos(리프 개수)를 _elements 배열 인덱스로 겸용한다
어디: base/slot-plan.md의 reconcile —
pos = candidateIndex - 1 + (if isSlot(result) then result.Length:Get() else 1)
로 전진시킨 pos를 그대로 settle(key, result, detach, pos)에 넘기고,
settle은 그걸 rawAdd(self, element, pos) / rawMove(..., pos) /
newKeyIndex[key] = pos로 쓴다.
무엇이 어긋나나: pos는 물리 리프 개수 기준(중첩 Slot 하나가
.Length만큼 전진)인데, _elements는 중첩 Slot 하나당 한 칸이다. 두
좌표계가 한 변수에 겹쳐 있다.
트레이스 — 첫 사이클에 바로 터진다
빈 :List, 첫 아이템의 updateFn이 Length 3짜리 중첩 Slot S를 반환
(멀티루트 컴포넌트 결과 — 문서가 명시적으로 지원하는 형태):
pos = 0,candidateIndex = 1result = S,isSlot→pos = 1 - 1 + 3 = 3settle(k1, S, false, 3)→rawAdd(self, S, 3)→table.insert(self._elements, 3, S)인데#_elements == 0→position out of bounds로 그 자리에서 터진다.
터지지 않는 변형(앞에 이미 요소가 있는 경우)에서도 newKeyIndex[key] = pos가
배열 인덱스가 아니게 되므로 다음 사이클의 H-1 경로와 합쳐져 더 나빠진다.
주의: base/slot-plan.md의 ":List의 index도 nested-Slot 결과의"
절은 updateFn에 넘기는 index(=candidateIndex) 얘기이고, 그 결론
자체는 옳다. 문제는 같은 카운터를 배열 인덱스로도 재사용한 것 — 리프
카운터와 슬롯 카운터를 분리해야 한다(예: pos는 리프용으로 두고,
_elements 위치는 별도 slotPos를 매 생존 아이템마다 +1).
🔴 H-3 — getOffsetAt의 접두합 캐시가 어디에서도 무효화되지 않는다
어디: base/dispatch-core-plan.md의 "Length/Offset — 여러 Slot이 형제로
섞일 때 순서 보장" 절, Dispatch.getOffsetAt 의사코드와 그 아래 무효화 표.
무엇이 어긋나나: 표는 무효화 규칙(invalidAfter = math.min(invalidAfter, i),
베이스 변경이면 0)을 정확히 정의해뒀는데, 코퍼스 전체에서
bk.invalidAfter에 실제로 대입하는 코드는 getOffsetAt 안의 = 1/= at
둘뿐이다(전수 grep으로 확인). Dispatch.setLength, gatedRecompute,
slot._baseObserver의 콜백, spliceArraysUp/spliceArraysDown 어느
의사코드도 캐시를 당기지 않는다. 즉 규칙이 산문으로만 존재하고 코드
경로가 없다.
트레이스 A — 형제 길이 변경
Frame { SlotA(Length 2), SlotB(Length 3) }:
- 등록 후
bk.offsetCache = {0, 2},bk.invalidAfter = 2,SlotB.Offset = 2 SlotA에 요소가 하나 늘어Length2 → 3, emit →bk.observers[1]→gatedRecompute→recompute(Frame, bk)recompute의i = 2:Dispatch.getOffsetAt(Frame, 2)→at(2) <= invalidAfter(2)→ 캐시된2를 반환(정답은 3)offset:Get() ~= abs가드에 걸려Set이 안 일어남 →SlotB.Offset이 2에 고정된 채SlotA가 0..2를 점유
sum(→ ownerKey.Length)은 lengthList에서 매번 새로 더하므로 위로는
맞고 옆으로만 틀린다 — 알아채기 특히 어려운 형태다.
트레이스 B — 베이스 변경 / 포탈 재마운트
slot._baseObserver도recompute만 부르고 캐시를0으로 안 당긴다 → 앞 형제가 커져 내 베이스가 밀려도 자식 offset 전체가 옛 베이스 기준.- 재마운트는 더 나쁘다:
bk는Relate(slot)위에 있어 언마운트를 넘어 살아남으므로invalidAfter > 0이고offsetCache[1]엔 옛 베이스가 들어 있다.materializeSlotTree의setOffsetSource즉시 계산이slot.Offset에 새 값을 넣어도, 그 시점_baseObserver는 아직unbindLifetime상태(재앵커 전)라canExecute가 거짓이어서 발화조차 안 한다.
필요한 것: 표의 세 규칙을 실제 호출부에 배치. 최소한 (a) setLength
본문과 그 Observer 콜백, (b) spliceArraysUp/Down, (c) _baseObserver
콜백 — 셋 다 recompute 전에 invalidAfter를 당겨야 한다.
🟡 H-4 — bk.invalidAfter/bk.offsetCache가 부기 스펙에 없고, 초기값이 nil이면 비교에서 터진다
base/dispatch-core-plan.md의 "저장 위치" 문단은 lengthList/sourceList/
observers/N만 열거한다 — offsetCache/invalidAfter가 빠져 있다.
그리고 getOffsetAt은 if bk.invalidAfter == 0 then으로 시작하는데,
bk.N이 nil로 시작하는 규칙(그래서 recompute가 bk.N or 0으로 방어)과
같은 lazy 생성이면 invalidAfter도 nil이다 → nil == 0은 거짓 → 다음 줄
at <= bk.invalidAfter에서 attempt to compare number with nil. 첫
position의 setOffsetSource가 바로 이 경로다(그 함수가 getOffsetAt을
부르므로 fresh bk에서 반드시 밟는다).
getBookkeeping이 invalidAfter = 0, offsetCache = {}로 초기화한다는 것을
"저장 위치" 문단에 명시하면 닫힌다.
🟡 H-5 — spliceArraysUp이 bk.N을 먼저 올려, 금지하기로 한 창을 연다
base/dispatch-core-plan.md의 bk.N 수명주기 문단은 이렇게 못박아뒀다 —
setOffsetSource는 bk.N을 건드리지 않는다, 그래야
"lengthList[i]가 아직 안 채워진 채로 bk.N만 먼저 커지는 창이 안 생긴다".
그런데 base/slot-plan.md의 rawAdd는:
spliceArraysUp(self, index) -- 주석: "_elements 외 배열들을 한 칸 밀고 bk.N 증가"
Dispatch.setOffsetSource(self, index, None)
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
Dispatch.setLength(self, index, 1, self._mountedInst)
bk.N은 첫 줄에서 이미 올라가고 lengthList[index]는 마지막 줄에서야
채워지므로 그 창이 실재하며, 그 안에 물리 op이 들어 있다. Roblox의
Parent 대입은 ChildAdded/DescendantAdded를 동기 발화시키므로, 그
핸들러가 같은 owner에 손대면 recompute가 lengthList[index] == nil을 읽어
sum += nil로 터진다(sourceList[index]는 None으로 채워져 있어 그쪽
error 가드엔 안 걸린다).
이건 yield가 아니라 동기 재진입이라 "체인 도중 yield 금지" 불변식으로는
안 덮인다. 셋 중 하나가 필요하다 — (a) bk.N 규칙 문단을 "splice도 올린다"로
정정하고 그 창을 UB로 명시, (b) spliceArraysUp이 lengthList[index]에
자리표시자를 채우게 함, (c) nativeInsert를 setLength 뒤로 옮김(단
"자기 자리 먼저 / 뒤를 미는 것 나중" 계약과 충돌하므로 그쪽을 다시 봐야 함).
🟡 H-6 — unmountSlotTree가 정의되지 않은 physicalTarget을 참조한다
base/slot-plan.md의 "파괴 — 재귀적 Clear() 금지, flat teardown" 절:
local function unmountSlotTree(slot) -- 인자는 slot 하나뿐
...
nativeExtract(physicalTarget, Dispatch.getOffsetAt(slot, i), { element })
physicalTarget이 어디에도 안 묶여 있다. slot._mountedInst여야 하고,
같은 함수가 아래에서 slot._mountedInst = nil로 지우므로 읽는 순서도
같이 지켜야 한다(루프가 그 대입보다 위라 지금 배치로는 문제없지만, 로컬로
먼저 뽑아두는 게 안전).
곁가지(같은 절): 이 루프는 요소를 하나씩 빼면서 매번
Dispatch.getOffsetAt(slot, i)(=부기 offset)을 넘기는데, 앞을 뺄 때마다 뒤가
물리적으로 당겨지므로 두 번째부터는 실제 물리 위치와 다른 값이 넘어간다.
native* 계약이 *"빠지는 요소는 반드시 elements 배열로 넘긴다"*로 확정돼
있어 Roblox 백엔드는 무해하지만, offset을 신뢰하는 백엔드(DOM에서
childNodes[offset]으로 찾는 최적화 등)에선 어긋난다. nativeExtract의
offset이 제거 경로에선 의미가 없다는 걸 계약으로 못박든지, 역순
순회(뒤에서부터)로 바꾸든지 정해야 한다.
🟡 H-7 — Effect의 Ref 의존성은 떼어낼 방법이 없다
base/effect-plan.md의 "Effect(fn, ...deps) — 여러 의존성을 직접 받는다"
절이 Ref를 의존성으로 허용하고, Ref면 :Callback으로 구독한다고
확정했다. 그런데 base/ref-plan.md는 콜백을 이렇게 확정해뒀다 —
type(v) == "thread"인 대기자만 발화 후 [i] = nil로 소진하고, 일반 콜백
함수는 "소진 안 함, 계속 유지". 그리고 콜백 해제 API가 없다
(:Uncallback 류 없음), canExecute 게이팅도 안 걸린다(그건 Observer
쪽 배관이다).
트레이스: Effect(fn, someRef)를 children 배열 leaf로 놓는다. 그 leaf가
죽는다 →
- Observer 쪽은
bindLifetimecascade가 끊겨canExecute가 거짓이 된다. ✅ Ref쪽은ref.Callbacks에 클로저가 영구히 남는다. 그 클로저가EffectHandle을 강참조하므로 → 누수, 그리고 이후ref:Set마다 이미 죽은 leaf에 대해 직전 cleanup +fn을 계속 실행한다.
같은 절이 *"leaf dedup/cascade가 전부를 덮어야 한다"*고 요구하는데, Ref
쪽은 그걸 만족시킬 수단 자체가 아직 없다. 필요한 건 둘 중 하나 —
(a) Ref에 콜백 해제 경로를 추가, 또는 (b) Effect가 거는 Ref 콜백이
자기 핸들의 canExecute를 먼저 확인하고(거짓이면 자기 자신을 Callbacks에서
nil로 소진) 넘어가게 하는 관용구를 계약으로 못박기.
🟡 H-8 — EffectHandle._observer가 아직 단수다
base/effect-plan.md의 "보강 — EffectHandle의 내부 Observer 바인딩 세부"
문단 전체가 단수 전제로 쓰여 있다 — handle._observer = observer,
bindLifetime(inst, handle._observer) cascade, :Subscribe()도
handle._observer.
N-deps 확정(같은 문서 아래 절)과 정면으로 어긋난다. 그대로 구현하면 2번째
이후 dep의 Observer에는 canExecute 판정 근거가 아예 안 실려 그 Observer의
재실행이 통째로 죽는다 — 그 문단 자신이 옛날에 경고한 바로 그 실패 모드다.
필드를 handle._observers(배열)로 바꾸고 cascade/Subscribe/Unsubscribe를
전부 순회로 고치면 된다(새 결정 없음, 단순 반영 누락).
🟢 H-9 — 게이트 withheld가 첫 flush 스왑에서 weak를 잃는다
base/gate-plan.md 4번이 withheld를 weak key로 확정했는데, flush
의사코드는 self._withheld = {}로 평범한 테이블을 만든다
(OffWithoutEmit의 "새 테이블로 스왑"도 같다). 첫 flush 이후로는 그 게이트가
죽은 Epoch를 붙잡을 수 있다. setmetatable({}, {__mode = "k"})로 만드는
헬퍼 하나면 닫힌다.
🟢 H-10 — recompute의 sum 시작값 주석이 stale하다
base/dispatch-core-plan.md의 recompute 의사코드 위 주석은 여전히
*"0이 아니라 이 owner의 베이스에서 시작한다"*인데, 바로 아래 코드는
local sum = 0이고 지금은 그게 맞다 — 접두합이 getOffsetAt으로
빠지면서 sum은 Length 전용이 됐고, 같은 함수 끝의 주석이
*"Length엔 base를 안 더한다"*로 이미 그렇게 말한다. 주석을 믿고 구현하면
Length에 베이스가 더해져 상위 전체의 길이 합이 틀어진다. 주석 한 줄
정정.
이상 없다고 확인한 것 (다시 트레이싱하지 말 것)
Epoch/EpochMap판정 규칙 —base/state-epoch-plan.md§1의 다이아몬드(A→B→D,A→C→D)를 실제 값으로 돌렸다. 규칙 1/2/3 +valueEpochMap:Refresh()가 섞인 값 관측과 이중 재계산을 둘 다 막는다. 전파 도중 하류가D:Get()을 부르면C가Refresh로 스스로 낡음을 알아채 재계산하고, 뒤늦게 도착한C쪽 emit은 규칙 3으로 접힌다.- 게이트가 붙들고 있는 동안 하류가 앞당겨 읽는 경로 — 재계산이 끝나며
valueEpochMap을 전부 갱신해두므로, 나중에 게이트가 푸는 통지는 규칙 2 (통지만)로 떨어져 같은 값을 다시 계산하지 않는다.GateNode가emitEpochMap을 수신이 아니라 flush 시점에:Sync(batch)로 갱신하는 예외도 다이아몬드에서 정책이 한 번 더 도는 것 말고 부작용이 없다. blocker:OffWithoutEmit()후emitEpochMap이 뒤에 남는 것 — 그Epoch의 다음 진짜 emit이 규칙 1로 걸려 자가 치유된다. 별도 조치 불필요가 맞다.Effect의EpochMapdedup +_installing순서 —A → b,A → c,Effect(fn, b, c)에서A:Set()한 번에fn이 정확히 한 번 돈다. 억제 플래그가Update보다 먼저라 설치 발화의from = nil이 맵을 오염시키지도 않는다.Ref콜백의 발화 계약 —:Callback이 "이미 채워져 있으면 등록 즉시 1회"이고 함수 콜백은 소진되지 않으므로,Effect의 "최소 1회 실행" 및 "Ref는Set될 때마다 발화"가 둘 다 성립한다(떼어내는 쪽만H-7).materializeSlotTree→mountSlotTree분해 — depth 2 트리 ({plainA, SlotInner{p1, p2}, plainB})로 부기와 물리를 양쪽 다 돌렸고, 등록 순서(setOffsetSource→ 실체화 →setLength)와 물리 삽입 위치 (acc)가 정확히 맞는다. 중첩 Slot을 런타임에Add하는 경로도 추적했고nativeInsert가 뒤 형제를 미는 결과가 부기 offset과 일치한다.- 단건
rawAdd의 순서가 배치 경로와 반대인 것 — plain 분기는nativeInsert→setLength, 중첩 Slot 분기는attachSlot(부기 먼저) → 물리다.native*가 미는 주체라는 계약 아래에선 둘 다 맞다(모순 아님). activateList를blocker:On()밖에서 부르는 것 —rawAdd의_mounted == false얼리리턴이 실제로 그걸 안전하게 만든다(게이팅할recompute자체가 안 일어남).Brand인스턴스 브랜드 + 다중 태깅 —Source를SourceBrand와EpochBrand에 동시 등록하는 것과, 포함 관계를 predicate 합성으로 남기는 것 사이에 충돌이 없다.isEpoch분기(노드 생성 시딩의:Syncvs:TrackFrom)도 이 표면으로 정확히 표현된다.
회신 방법
H-1만 선택이 필요하고(위 세 갈래), 나머지는 "맞다/아니다"만 알려주면
그대로 반영하겠다. H-5·H-6은 어느 쪽으로 정정할지도 같이 정해야 한다.
반영은 이 파일이 아니라 각 base/ 문서에 하고, 처리 결과는 4·5라운드와 같은
방식으로 -followup.md에 쌓는다.