quad/.claude/archive/bookkeeping-before-physical-reversed.md
qwreey a4b517aee2
design: native* 물리 조작 계층 확정 + C-7("부기가 물리보다 먼저") 역전
주입 op 셋(mountInst/unmountInst/disposeInst)으로는 Move/Swap을 아예 표현할
수 없다는 지적에서 시작해 물리 조작 계층 전체를 재설계했다.

층위 정의(사용자): raw*는 그 Slot 스코프 안의 연산(평탄화 전, _elements
인덱스), native*는 확정된 offset/length로 표현되는 물리 트리 연산(평탄화 후,
절대 좌표).

표면 여섯:
  nativeInsert (target, offset, elements)
  nativeExtract(target, offset, elements, newElements?)   -- 빼되 살림
  nativeRemove (target, offset, elements, newElements?)   -- 빼면서 파괴
  nativeMove   (target, fromOffset, elements, toOffset)
  nativeSwap   (target, offsetA, elementsA, offsetB, elementsB)
  nativeDispose(element)                                   -- 트리 밖 값 파괴

- Replace는 별도 op이 아니라 newElements가 있는 Remove/Extract(Splice도 동일)
  — 제거+삽입을 한 호출로 합쳐 리플로우 2회와 그 사이 인덱스가 어긋난 창을
  없앤다
- 파괴/비파괴를 불리언이 아니라 이름으로 가름 — 공개 CRUD의 Remove/Extract
  어휘를 물려받고, Roblox의 "Parent=nil 없이 그 자리에서 Destroy" 융합을 연다
- 빠지는 요소는 반드시 배열로 넘김 — (target, offset, count)로 대상을 찾을 수
  있는 건 DOM뿐이고 Roblox는 자식이 순서 없는 집합이라 offset 역조회가 안 됨
- nativeSwap은 별도 — Move는 사이를 전부 밀지만 Swap은 가운데 고정
- 미주입이면 에러가 아니라 조합 폴백(addTag 계열과 갈리는 지점)
- 전제: 한 Slot의 물리 자식은 부모 안에서 연속 구간을 차지한다

그 여파로 4라운드 C-7 일반 계약이 역전됨 — "Length를 먼저 올려 밀어내고 그
공간에 넣는다"는 그림은 base에 물리적으로 자리를 비워둘 수단이 없어 성립하지
않는다. 규칙이 "빼기는 물리 먼저/넣기는 부기 먼저" 두 얼굴에서
"자기 자리를 정하는 것(setOffsetSource) 먼저 / 뒤를 미는 것(setLength→
recompute) 나중" 하나로 줄었다. 배치 경로(materializeSlotTree→mountSlotTree)의
부기-전량-먼저는 C6가 요구하는 별개 사안이라 그대로.
원문은 archive/bookkeeping-before-physical-reversed.md.

같이: getOffsetAt을 사용자 의사코드대로 단일 함수 + invalidAfter로 정정
(무효화는 min(invalidAfter, i) 하나, recompute도 그 캐시 위에 얹혀 O(N)).

doc-check.py ERROR 0. 상세는
qa-request/pre-implementation-qa-round5-followup.md의 L절.

Co-authored-by: qwreey <me@qwreey.moe>
2026-08-21 20:07:21 +09:00

4.5 KiB

[역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"

상태: 역전됨. 2026-08-20 구현 전 QA 4라운드 C-7에서 모든 raw*가 따르는 일반 계약으로 승격됐다가, 2026-08-21 5라운드에 native* 주입 op 계층이 확정되면서 폐기됐다.

왜 뒤집혔나: 이 계약은 *"Length를 먼저 올려 뒤 형제를 밀어내고 비워진 공간에 넣는다"*는 그림 위에 서 있었는데, base에는 물리적으로 자리를 비워둘 수단이 없다. 미는 주체는 언제나 백엔드의 삽입 연산 자신이다 — 사용자 지적: "밀어내고 null을 넣어둘 수도 없는데, 그리고 nativeInsert 라는것 자체가 밀어내기 동작을 강제하는데, 그렇다면 length 를 나중에 설정한 다음 offset들이 무시되어야함."

무엇이 남았나: "빼기는 물리 먼저 / 넣기는 부기 먼저"라는 두 얼굴 대신 "자기 자리를 정하는 것 먼저(setOffsetSource) / 뒤를 미는 것 나중 (setLengthrecompute)" 하나로 줄었다. 배치 경로(materializeSlotTreemountSlotTree)가 부기 전량을 먼저 끝내는 것은 C6가 요구하는 별개 사안이라 그대로다. 지금 유효한 서술은 base/dispatch-core-plan.md의 "일반 계약 — 물리와 부기의 순서" 절.


역전 전 원문

일반 계약 — 부기가 물리 트리 조작보다 항상 먼저 끝난다 (2026-08-20 구현 전 QA 4라운드 C-7 승격)

지금까지 이 규칙은 rawAdd 한 곳에만 적혀 있었다(바로 아래 "동기 순서" 문단). rawRemove/rawUnmount는 의사코드가 우연히 같은 모양이었을 뿐 계약화돼 있지 않았고, Splice는 물리 detach/attach와 부기의 선후가 아예 안 적혀 있었다. 사용자 판정으로 모든 raw*가 따르는 일반 계약으로 승격한다("각각의 동작에 따라 다른 동작 보다, 일관성 있는 동작을 제공하는게 나아보이고, 이것을 단순 일반 계약 승격으로 도달 될 수 있기 때문").

계약: 어떤 CRUD/재조정 연산이든 Length/offset 부기 갱신이 물리 트리 조작(Parent 대입/해제)보다 먼저 완료돼야 한다. 즉 "먼저 밀어내고 그 공간에 넣는다" / "먼저 비운 걸 반영하고 그 다음 당긴다".

  • 왜 일반 계약이어야 하나: 백엔드가 "밀어내기"를 물리적으로 구현해야 하는 경우(DOM insertBefore 밖의 백엔드 등), 부기가 이미 정확하다는 걸 전제할 수 있어야 자기 일을 할 수 있다. 연산마다 선후가 다르면 백엔드 작성자가 매번 다시 확인해야 한다.
  • 각 연산에 적용하면:
    • rawAdd — 부기(setOffsetSource/setLength 등록 → recompute) 완료 → 물리 마운트(mountInst). [정정, 2026-08-21 5라운드 C-2] 예전엔 여기 Length:Set(newCount)라고 적혀 있었으나 틀렸다 — 아래 문단 참고.
    • rawRemove/rawUnmount/rawDetach([2026-08-21] Detach 경로용으로 신설된 세 번째 형제 — 소유권을 유지한 채 언마운트만 한다는 점만 다르고 순서는 같음, base/slot-plan.md) — 파괴/언마운트 → spliceArraysDownrecompute가 지금 의사코드인데, 이건 물리 조작이 먼저라 계약과 어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직 트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는 물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 좁은 쪽이 먼저)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것.
    • Splice — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어 붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). shift/recompute를 1회로 묶는다는 최적화는 그 사이에서만 일어난다.
    • rawMove/rawSwapParent를 안 건드리므로 이 계약의 대상이 아님.
  • ⚠️ 프레임 경계는 어차피 안 낀다process/attachSlot 체인 도중 코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이 순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이 계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로 고정한다"**가 진짜 이유다.