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>
This commit is contained in:
qwreey 2026-08-21 20:07:21 +09:00
parent c6fdf1b348
commit a4b517aee2
Signed by: qwreey
GPG key ID: D28DB79297A214BD
11 changed files with 362 additions and 145 deletions

View file

@ -126,6 +126,7 @@
| `question-resolved.md` | **[해소 아카이브, 2026-08-13 아홉 번째 세션 신설]** `question.md`에서 걷어낸 **결정 완료** 항목 전부(당시 32개 `[해소됨]` 마커) — 추가 프리미티브 필요성 라운드, 구현 착수 직전 감사 요약, 확정된 용어들(`State`/`Relate`/`List`/`canBound`(2026-08-14 다섯 번째 세션에 폐기됐다가 열한 번째 세션에 별도 진입점으로 재도입 — `canExecute`와 판정 로직만 공유)/`Ref`/`PreRef`/`Peek`/`isState`/`None`/`Handler`), 포탈·`State<Slot?>` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`question.md`는 이제 사용자가 답해야 할 것만 담음** — 항목이 해소되면 여기로 옮길 것 |
| `dispatch-hintvalue-model-reversed.md` | **[2026-08-13 열네 번째 세션 신설 — 옛 이름은 research/ 아래의 dispatch-redispatch-diff-plan]** 뒤집힌 **"철거 후 재구축 + `hintValue` 힌트"** 재디스패치 모델 원문 + 역전을 이끈 분석 전문(`None`/`State` 래퍼가 힌트로 새는 재현 사례, 깊은 인덱스 힌트 유실, 옛 점유 체크가 Attribute 소유권을 대신하던 구조). 지금 유효한 모델은 `base/dispatch-core-plan.md` |
| `checkpoint-handler-pattern-reversed.md` | **[역전됨, 2026-08-13 다섯 번째 세션 신설]** `AttributeGroupHandler`의 이름 소유권 충돌을 고치려고 만든 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder` 체크포인트 핸들러 패턴(같은 날 네 번째 세션 신설) — `chains`를 핸들러 identity가 아니라 재귀 깊이 인덱스로 추적하는 더 근본적인 재설계로 대체되며 같은 날 바로 불필요해짐. `State<State<T>>`도 이 재설계로 UB에서 정상 지원 대상으로 바뀜 |
| `bookkeeping-before-physical-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드]** "부기가 물리 트리 조작보다 항상 먼저 끝난다"(4라운드 `C-7` 일반 계약) — `native*` 주입 op 계층이 확정되며 폐기. **base엔 물리적으로 자리를 비워둘 수단이 없고**(밀어내는 주체는 백엔드의 삽입 연산 자신), 그래서 "빼기는 물리 먼저/넣기는 부기 먼저" 두 얼굴 대신 **"자기 자리를 정하는 것 먼저(`setOffsetSource`) / 뒤를 미는 것 나중(`setLength`→`recompute`)"** 하나로 줄었다. 배치 경로의 부기-전량-먼저는 C6가 요구하는 별개 사안이라 그대로. 현행은 `base/dispatch-core-plan.md`의 "일반 계약 — 물리와 부기의 순서" 절 |
| `bindlifetime-slot-owner-reversed.md` | **[역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`]** "`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하고 `isBoundAlive`에 세 번째 분기를 둬라"(4라운드 `D-56`) — `Dispatch.setLength`**부기 키와 생명주기 앵커를 따로 받도록** 바뀌면서 요구사항 자체가 사라짐(앵커는 언제나 물리 target, 모든 호출부가 이미 그 값을 안다). 부수로 형태 미정이던 "세 번째 분기" 항목도 같이 닫힘. 현행은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단 |
| `canexecute-inst-arg-reversed.md` | **[역전됨, 2026-08-14 다섯 번째 세션 신설]** `canExecute(inst,value)`/`unbindLifetime(inst,value)` 2-인자 시그니처와, 그 뿌리였던 **`bindLifetime``.Subscribed`를 세팅한다**는 오염(2026-08-08 다섯 번째 세션에 "재정정"으로 들어와 2026-08-09의 `canBound`까지 그 위에 세워짐) — `.Subscribed`는 전역 `:Subscribe()` 전용 필드라 leaf 경로와 무관했고, `bindLifetime`이 gcconn 참조를 `value` 쪽으로 복사해두면 `value` 하나로 생존을 물을 수 있음. 같이 폐기된 것은 `canBound(handle)`(**2026-08-14 열한 번째 세션에 별도 진입점으로 재도입 — 이 문서 하단에 addendum**)와 gcconn/gchold의 lazy 생성. 오류가 여섯 세션을 살아남은 이유(`canExecute`의 실제 호출부가 어느 문서에도 코드로 없었음)와 그 일반 교훈("계약을 정할 때 호출부를 최소 하나는 의사코드로 같이 적을 것")도 정리. 현행은 `base/lifecycle-pattern.md` |
| `tag-attribute-load-time-registration-reversed.md` | **[역전됨, 2026-08-14 열두 번째 세션 신설, 2026-08-18 재역전으로 원래 결론 쪽이 다시 현행]** "`TagHandler`/`AttributeKeyHandler`/`AttributeGroupHandler`가 quad-base 모듈 로드 시점에 스스로 등록한다"(열한 번째 세션 "네 번째, 최종 정정") — 2026-08-14 열두 번째 세션엔 `base/lifecycle-pattern.md`가 거부한 `InitNamespace`류 top-level 부작용 패턴과 같은 클래스라며 틀렸다고 보고, 등록 주체를 백엔드 팩토리로 정정했었음. **그런데 2026-08-18 구현 전 QA에서 다시 뒤집힘(`D-7`)** — 백엔드 팩토리가 등록 주체면 quad-roblox를 아예 안 붙인 상태에서 "provider가 초기화됐는지" 안내 경로 자체가 안 돌기 때문. **지금 현행은 이 문서가 역전이라 부르던 원래 결론(quad-base 자신이 로드 시점에 등록)과 같은 방향** — 상세는 `base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절, 재역전 논의 원문은 `qa-request/pre-implementation-qa-round1.md`의 "D-7" 절 |

View file

@ -0,0 +1,64 @@
# [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"
**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `C-7`에서 **모든 `raw*`가 따르는
일반 계약**으로 승격됐다가, **2026-08-21 5라운드에 `native*` 주입 op 계층이
확정되면서 폐기**됐다.
**왜 뒤집혔나**: 이 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고 비워진
공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를 비워둘
수단이 없다.** 미는 주체는 언제나 백엔드의 삽입 연산 자신이다 — 사용자 지적:
*"밀어내고 null을 넣어둘 수도 없는데, 그리고 nativeInsert 라는것 자체가
밀어내기 동작을 강제하는데, 그렇다면 length 를 나중에 설정한 다음 offset들이
무시되어야함."*
**무엇이 남았나**: "빼기는 물리 먼저 / 넣기는 부기 먼저"라는 두 얼굴 대신
**"자기 자리를 정하는 것 먼저(`setOffsetSource`) / 뒤를 미는 것 나중
(`setLength` → `recompute`)"** 하나로 줄었다. 배치 경로(`materializeSlotTree` →
`mountSlotTree`)가 부기 전량을 먼저 끝내는 것은 **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`) — 파괴/언마운트 →
`spliceArraysDown`
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는
물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 **좁은 쪽이
먼저**)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것.
- `Splice` — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어
붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). **shift/recompute를
1회로 묶는다는 최적화는 그 사이에서만** 일어난다.
- `rawMove`/`rawSwap` — `Parent`를 안 건드리므로 이 계약의 대상이 아님.
- **⚠️ 프레임 경계는 어차피 안 낀다** — `process`/`attachSlot` 체인 도중
코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이
순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이
계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로
고정한다"**가 진짜 이유다.

View file

@ -291,7 +291,7 @@ quad/
├── pesde.toml # quad-base가 아니라 quad-types에만 workspace 의존
└── src/
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상엔 bindLifetime/canBound/canExecute 외에 addTag/removeTag/setAttribute도 포함(2026-08-13 열네 번째 세션)
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), disposeInst(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] mountInst(target,element,index)/unmountInst(element)**(물리 트리 조작 — base는 `Parent`를 모른다, 확정 시 이 목록에 정식 등재) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── EngineOps.luau # 주입되는 엔진 op 구현: addTag(inst,{string})/removeTag(inst,{string})=CollectionService, setAttribute(inst,name,v)=inst:SetAttribute(v==nil이면 삭제), nativeDispose(inst)=inst:Destroy()(`dispose(value)`가 `isSlot`이 아닐 때 위임, `base/slot-plan.md`), **[2026-08-21 5라운드, 이름 가칭] `native*` 물리 트리 조작 계층** — nativeInsert/nativeExtract/nativeRemove/nativeMove/nativeSwap(0-based 절대 offset + 대상 요소 배열을 받음; Roblox는 offset을 무시하고 배열을 쓰고 DOM은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백** (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
├── LifetimeHandle.luau # bindLifetime/canBound/canExecute 실제 구현 — GetPropertyChangedSignal("ClassName") 연결 트릭으로 gcconn 확보, Relate:SetWeak으로 gcconn/gchold 저장(**[정정, 2026-08-18] `SetStrong`이 아님 — 생존은 클로저 upvalue와 `gchold[1]`이 이미 보장, strong으로 잡으면 상호 강참조 누수**, `base/lifecycle-pattern.md`). `canBound`/`canExecute`는 비공개 헬퍼 하나를 공유하는 얇은 진입점(2026-08-14 열한 번째 세션). Relate 자체는 순수 Lua라 quad-roblox 쪽 재구현 없음(quad-base 그대로 재사용)
├── Handlers/
│ ├── Property.luau # 일반 프로퍼티 세팅 + `isTween(realv)` 분기(3-상태 릴레이션 슬롯 `RobloxTween|true|nil`, hasBeenSet 억제, override 정책) — 구 `Handlers/Tween.luau`(높은 우선순위 store-bind 핸들러)는 폐기(`archive/tween-special-bind-key-reversed.md`)

View file

@ -1533,53 +1533,58 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — mountInst의 삽입 위치,
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
function Dispatch.getOffsetAt(ownerKey, i)
function Dispatch.getOffsetAt(ownerKey, at)
local bk = getBookkeeping(ownerKey)
-- [2026-08-21 사용자 제안] **접두합 캐시 + "이 인덱스 초과부터 무효" 마커.**
-- 매번 1..i-1을 다시 더하면 호출당 O(i)라 reconcile 전체가 O(N²)가 된다.
-- 캐시된 접두합(`bk.offsetCache[j]` = j 자리의 절대 offset)은 **그 앞쪽이
-- 안 바뀐 한 계속 유효**하므로, 무효화는 "바뀐 자리부터 뒤"만 하면 된다.
local from = bk.offsetDirtyFrom or 1 -- 이 인덱스부터가 무효
if i < from then
return bk.offsetCache[i] -- 앞쪽은 그대로 유효 — O(1)
-- [2026-08-21 사용자 제안, 같은 날 의사코드 정정] **단일 함수 + 접두합 캐시.**
-- `bk.offsetCache[i]` = i 자리의 절대 offset, `bk.invalidAfter` = **여기까지는
-- 캐시가 유효**(그 뒤부터 다시 누적해야 함). 함수를 둘로 나누지 않는다 —
-- 이 하나가 필요한 만큼만 앞으로 이어붙이므로, 순차 호출이면 한 칸씩만
-- 늘어나 전체가 O(N)이 된다(사용자: *"그러면 알아서 순차적으로 합캐시가
-- 처리됨"*).
if bk.invalidAfter == 0 then
-- 시작점 — 1번 자리의 offset은 이 owner의 베이스 그 자체.
bk.offsetCache[1] = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
bk.invalidAfter = 1
end
-- 무효 구간만 이어붙여 다시 계산한다(유효한 마지막 자리의 값에서 출발).
local sum = if from > 1 then bk.offsetCache[from - 1] + contribution(bk, from - 1)
else (if isSlot(ownerKey) then ownerKey.Offset:Get() else 0)
for j = from, i - 1 do
bk.offsetCache[j] = sum
sum += contribution(bk, j) -- lengthList[j](State면 :Get())
if at <= bk.invalidAfter then
return bk.offsetCache[at] -- 유효 구간 — O(1)
end
bk.offsetCache[i] = sum
bk.offsetDirtyFrom = i + 1 -- 여기까지는 이제 유효
return sum
local cur = bk.offsetCache[bk.invalidAfter]
for i = bk.invalidAfter, at - 1 do
cur += contribution(bk, i) -- lengthList[i](State면 :Get())
bk.offsetCache[i + 1] = cur -- **지금 자리의 길이가 다음 자리의 offset을 정한다**
end
bk.invalidAfter = at -- 여기까지 유효해짐
return cur
end
```
**⭐ [2026-08-21 사용자 제안] `getOffsetAt`의 접두합 캐시 — 무효화 규칙**
**⭐ [2026-08-21] 캐시 무효화 — 규칙이 하나다**
`offsetCache`/`offsetDirtyFrom`이 성립하려면 **"앞이 안 바뀌면 뒤도 안 바뀐다"**는
단조성이 지켜져야 한다. 그래서 아래 셋 중 하나라도 일어나면 `offsetDirtyFrom`
그 인덱스로 내린다(더 작은 값으로만 갱신):
`bk.invalidAfter`는 **"이 인덱스까지는 캐시가 유효"**를 뜻하고, 무효화는 전부
같은 모양이다 — **`bk.invalidAfter = math.min(bk.invalidAfter, i)`**(앞으로만
당긴다):
1. **`setLength(ownerKey, i, ...)`** — `i` 자리의 기여도가 바뀌므로 `i + 1`부터
무효(그 자리 자신의 offset은 안 변한다). State 길이가 나중에 emit할 때도 같다.
2. **`spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제)** — 그 인덱스부터 전부
밀리므로 그 자리부터 무효.
3. **owner의 베이스가 바뀜**(`ownerKey.Offset` 변경 = `_baseObserver`가 도는
그 순간) — **전체 무효**(`offsetDirtyFrom = 1`).
| 무엇이 바뀌나 | 어디까지 당기나 | 왜 |
|---|---|---|
| `setLength(ownerKey, i, ...)`, 그리고 그 State가 나중에 emit할 때 | `i` | **`i` 자리의 offset은 안 바뀐다**(그건 `1..i-1`의 합) — 바뀌는 건 그 **뒤**뿐. 사용자: *"정확히 입력받은 자신 인덱스까지 당김"* |
| `spliceArraysUp`/`spliceArraysDown`(자리 삽입·삭제) | `i` | 같은 이유 — 삽입/삭제 후에도 `i` 자리의 offset은 여전히 `1..i-1`의 합이다 |
| owner의 베이스 변경(`ownerKey.Offset`이 바뀜 = `_baseObserver`가 도는 순간) | `0` | 1번 자리부터 전부 다시 |
**⚠️ 아직 안 정한 것**: `recompute`가 이미 전체를 순회하며 같은 접두합을
계산하므로, **그 순회가 캐시를 그대로 채우게 할지**(그러면 `recompute` 직후엔
캐시가 항상 완전) 아니면 두 경로를 분리해 둘지. 전자가 낭비가 없어 보이지만
`recompute``Set` 캐스케이드를 유발할 수 있어 호출 빈도가 다르다 —
구현 시 확인.
**`recompute`도 이 캐시 위에 얹힌다** — `1..N`을 순서대로 도는 함수라 매 자리에서
`getOffsetAt`이 한 칸씩만 이어붙이므로 전체가 O(N)이고, 별도 접두합 로직을 따로
두지 않는다. (그래서 "`recompute`가 캐시를 채울지 말지"라는 갈래 자체가 없어졌다 —
사용자: *"함수를 나눠야할 이유를 모르겠음. 하나로 두는게 나아보임."*)
```lua
local function recompute(ownerKey, bk)
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
local base = if isSlot(ownerKey) then ownerKey.Offset:Get() else 0
-- [2026-08-21] offset 값은 위 `getOffsetAt`(접두합 캐시)에서 받는다 —
-- 여기서 따로 누적하지 않는다. 순서대로 도는 순회라 캐시가 한 칸씩만 늘어나
-- 전체 O(N).
local sum = 0
-- [2026-08-21 5라운드 감사] `bk.N or 0`**빈 Slot 크래시 방어**.
-- `bk.N``setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
@ -1598,8 +1603,9 @@ local function recompute(ownerKey, bk)
if offset == nil then
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
end
if offset ~= None and offset:Get() ~= base + sum then -- 실제로 다를 때만 Set
offset:Set(base + sum)
local abs = Dispatch.getOffsetAt(ownerKey, i) -- 절대 offset(캐시 경유)
if offset ~= None and offset:Get() ~= abs then -- 실제로 다를 때만 Set
offset:Set(abs)
end
local v = bk.lengthList[i]
sum += (if isState(v) then v:Get() else v)
@ -1838,45 +1844,39 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
### ⭐ 일반 계약 — 부기가 물리 트리 조작보다 항상 먼저 끝난다 (2026-08-20 구현 전 QA 4라운드 `C-7` 승격)
### ⭐ 일반 계약 — 물리와 부기의 순서 (2026-08-20 `C-7`로 승격, **2026-08-21 5라운드에 재정의**)
**지금까지 이 규칙은 `rawAdd` 한 곳에만 적혀 있었다**(바로 아래 "동기 순서"
문단). `rawRemove`/`rawUnmount`는 의사코드가 우연히 같은 모양이었을 뿐
계약화돼 있지 않았고, `Splice`는 물리 detach/attach와 부기의 선후가 **아예
안 적혀 있었다.** 사용자 판정으로 **모든 `raw*`가 따르는 일반 계약으로
승격**한다(*"각각의 동작에 따라 다른 동작 보다, 일관성 있는 동작을 제공하는게
나아보이고, 이것을 단순 일반 계약 승격으로 도달 될 수 있기 때문"*).
> **🔄 [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"는
> 계약은 폐기됐다.** 원문은 `archive/bookkeeping-before-physical-reversed.md`.
> **계약**: 어떤 CRUD/재조정 연산이든 **`Length`/`offset` 부기 갱신이 물리
> 트리 조작(`Parent` 대입/해제)보다 먼저 완료**돼야 한다. 즉 "**먼저 밀어내고
> 그 공간에 넣는다**" / "**먼저 비운 걸 반영하고 그 다음 당긴다**".
**왜 뒤집혔나**: 그 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고, 비워진
그 공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를
비워둘 수단이 없다**(자리를 비워 `null`을 꽂아둘 수도 없다). 미는 주체는
언제나 백엔드의 삽입 연산 자신이다 — `native*` 계층이 들어오면서 이게
명확해졌다(사용자: *"nativeInsert 라는것 자체가 밀어내기 동작을 강제하는데,
그렇다면 length 를 나중에 설정한 다음 offset들이 무시되어야함. 일종의 웹 돔과
같은 동작을 내도록 강제하는 시스템"*).
- **왜 일반 계약이어야 하나**: 백엔드가 "밀어내기"를 물리적으로 구현해야
하는 경우(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`) — 파괴/언마운트 →
`spliceArraysDown`
`recompute`가 지금 의사코드인데, **이건 물리 조작이 먼저**라 계약과
어긋나 보인다. 다만 여기선 "빼는" 방향이라 부기를 먼저 줄이면 아직
트리에 있는 요소가 순서 계산에서 빠지는 역전이 생긴다 — **"빼기는
물리 먼저, 넣기는 부기 먼저"**가 실제로는 같은 원칙(항상 **좁은 쪽이
먼저**)의 두 얼굴이다. 문서화 시 이 대칭으로 적을 것.
- `Splice` — 제거 구간과 삽입 구간이 겹치므로 위 두 규칙을 그대로 이어
붙이면 된다(제거는 물리 먼저, 삽입은 부기 먼저). **shift/recompute를
1회로 묶는다는 최적화는 그 사이에서만** 일어난다.
- `rawMove`/`rawSwap` — `Parent`를 안 건드리므로 이 계약의 대상이 아님.
- **⚠️ 프레임 경계는 어차피 안 낀다** — `process`/`attachSlot` 체인 도중
코루틴 yield가 금지돼 있으므로(위 "Handler 작성 체크리스트" 9번) 이
순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이
계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로
고정한다"**가 진짜 이유다.
**지금의 계약 — 셋으로 줄었다**:
1. **물리 조작은 `native*`가 전담하고, 밀고 당기는 건 그 op 자신이 한다**
(DOM식 의미론을 시스템 전체가 강제).
2. **base의 offset 부기는 "배치 지시"가 아니라 계산값이다** — 이미 배치된
것을 옮기지 않는다(`base/slot-plan.md`의 웹 백엔드 문단, 5라운드 확정).
3. **순서 규칙은 "자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중"** 하나다:
- **`setOffsetSource`는 먼저** — 그 자리의 offset은 `1..i-1`의 합이라
**자기 삽입/제거로 안 변한다.** 게다가 삽입 위치 계산이 이 값이고,
Slot이면 `activateList`가 곧바로 그 값을 쓴다(C1).
- **`setLength``recompute`는 나중** — 이게 **뒤를 미는** 쪽이다.
- 그래서 `rawAdd``spliceArraysUp``setOffsetSource``nativeInsert`
`setLength``recompute` 순서다(`base/slot-plan.md`).
4. **배치 경로는 여전히 "부기 전량 먼저"**`materializeSlotTree`
`mountSlotTree` 분해는 그대로다. 그때는 삽입 위치가 전부 확정된 뒤에
물리가 몰리는 것이고, C6("부모에게 미는 길이는 최종값")가 그걸 요구한다.
위 3번과 모순이 아니다 — 3번은 **단건 경로**의 규칙이다.
- **⚠️ 프레임 경계는 여전히 안 낀다**(yield 금지) — 그래서 이 순서 변경으로
사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임
순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다.
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:

View file

@ -49,39 +49,60 @@ inst, inst, k)` 한 줄로 축약됨** — `Dispatch.setLength`/`activateList`
호출은 `attachSlot` 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만
일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고.
### ⭐ 물리 조작은 주입 op다 — base는 `Parent`를 모른다 (2026-08-21 구현 전 QA 5라운드 `C-1`)
### ⭐ 물리 조작은 주입 op다 — `native*` 계층 (2026-08-21 구현 전 QA 5라운드, 같은 날 확장)
**사용자 지적**: *"element.Parent = self._mountedInst 부분은 실제 엔진이
구현하게 되는 crud 셋을 사용하게 되어야할것이다. slot 의 해당 동작은 base
이므로 parent 를 모른다."* 맞다 — 위 경계 문단이 "실제 트리 조작은 백엔드"라고
이미 말해뒀는데, **의사코드는 계속 `element.Parent = ...` / `element:Destroy()`
Roblox 어휘를 직접 쓰고 있었다.** 이 문서의 의사코드를 전부 주입 op 호출로
바꿨다(로직은 그대로, 표기만 정정):
이미 말해뒀는데 의사코드는 계속 `element.Parent = ...` / `element:Destroy()`
Roblox 어휘를 직접 쓰고 있었다. 이 문서의 의사코드를 전부 주입 op 호출로 바꿨다.
| op | 언제 | 대응하는 경계 훅 |
|---|---|---|
| `mountInst(target, element, index)` | 요소를 물리 트리에 붙일 때(`index` = 0-based 절대 offset, 아래 항목) | mount(`Add`) |
| `unmountInst(element)` | 물리 트리에서만 떼어낼 때(파괴 아님) | unmount(`Remove`/`Extract`) |
| `disposeInst(element)` | 요소를 실제로 파괴할 때 | 이미 확정된 주입 op(아래 `dispose` 절) |
**층위 정의(사용자)**: **`raw*`는 그 Slot 스코프 안의 연산**(평탄화 전,
`_elements` 인덱스)이고, **`native*`는 확정된 offset/length로 표현되는 물리 트리
연산**(평탄화 후, 절대 좌표)이다.
- **reposition(`Move`/`Swap`)은 base가 "Parent를 안 건드린다"는 계약만
강제**하므로 별도 op이 없다(위 문단 그대로) — 백엔드가 `LayoutOrder` 정렬에
기대 no-op으로 둘 수도 있다.
- **⭐ [확정, 2026-08-21 5라운드 G절] `mountInst(target, element, index)`
index는 절대 offset(0-based)이다.** 원래는 "형제 순서는 `Offset`/`LayoutOrder`
부기가 담당하니 안 넘긴다"였는데, 사용자 지적 — *"웹에서는 어떻게 되냐가
모호함. 어디 둘지 어떻게 아느냐는것"* — 대로 **DOM류 백엔드는 삽입 위치를
알아야 하고, 정작 plain 요소는 `setOffsetSource``None`을 등록해 그 숫자가
계산조차 안 되고 있었다.** 지금은 `Dispatch.getOffsetAt(ownerKey, i)`가 발행
채널 유무와 무관하게 그 숫자를 준다(`base/dispatch-core-plan.md`의
"Length/Offset" 절). Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`
물리 순서와 분리돼 있으므로.
- **`unmountInst(element)`는 그대로 인자 없음** — 뺄 때는 자기 자신만 알면 된다.
- **0-based인 이유**: `offset`/`sum`이 이미 0-based 개수라 그쪽과 일관되고,
`updateFn``index`(1-based 지역 위치)와 헷갈리지 않게 하기 위함.
- **⚠️ 이름은 `disposeInst` 선례를 따른 가칭** — 주입 op 목록에 정식으로
추가할 때 확정할 것(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와
주입되는 엔진 op" 절).
```lua
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) -- 트리 **밖** 값 파괴
```
- **`offset`은 전부 0-based 절대 offset**(`Dispatch.getOffsetAt`이 주는 그 값).
Roblox 백엔드는 이 인자를 그냥 무시한다 — `LayoutOrder`가 물리 순서와
분리돼 있으므로.
- **⭐ 빠지는 요소는 반드시 `elements` 배열로 넘긴다** — `(target, offset, count)`만으로
대상을 찾을 수 있는 건 DOM뿐이다(`childNodes[offset]`). **Roblox는 자식이 순서
없는 집합**이고 quad의 offset은 순전히 논리값이라, 백엔드가 offset으로 인스턴스를
역으로 못 찾는다. `count``#elements`로 따라온다.
- **`Replace`는 별도 op이 아니다** — `newElements`가 있는 `nativeRemove`(파괴 교체)
또는 `nativeExtract`(비파괴 교체)다. `Splice`도 이 둘로 표현된다. 제거와 삽입을
한 호출로 합치는 이유는 **리플로우 2회와 그 사이 인덱스가 어긋난 창**을 없애기
위함(사용자: *"안 그러면 splice 가 무거워짐"*).
- **파괴/비파괴를 불리언이 아니라 이름으로 가른 이유**: 공개 CRUD의
`Remove``Extract` 어휘를 그대로 물려받고, **백엔드의 융합**을 열어주기
위해서다 — Roblox에서 `Parent = nil``Destroy()`는 그냥 `Destroy()`보다
비싸므로(사용자 지적), `nativeRemove`가 그 자리에서 바로 파괴할 수 있어야 한다.
- **`nativeSwap`이 따로 있는 이유**: `Move`는 사이 요소를 전부 밀지만 `Swap`
**가운데를 고정한 채 양끝만 교환**이라 다른 연산이다. `Move` 2회로 흉내내면
리플로우 2회 + 중간 인덱스 재계산이 필요하다.
- **`nativeInsert`를 흡수하지 않은 이유**: `nativeExtract(target, offset, {}, elements)`
표현은 되지만, **최빈 경로**(리스트 최초 채우기·단건 `Add`)가 "0개를 빼는 extract"라는
모양이 되고 `DocumentFragment`류 일괄 삽입 최적화도 그 안에 숨는다.
- **기본 구현(조합 폴백) — 미주입이 에러가 아니다.** `addTag`/`setAttribute`가
"미주입이면 명확한 에러"인 것과 갈린다: 이쪽은 조합으로 항상 정의되기 때문이다.
`nativeRemove` = `nativeExtract` + `nativeDispose` 반복, `nativeMove` =
`nativeExtract` + `nativeInsert`, `nativeSwap` = `nativeMove` 2회. 백엔드는
**이득 있는 것만 덮어쓴다.**
- **⚠️ 전제 — 한 Slot의 물리 자식은 부모 안에서 연속 구간을 차지한다.** 범위 op이
성립하는 근거가 전부 이것이다(offset이 누적합이고 중첩 Slot도 같은
`physicalTarget`을 공유하므로 구조적으로 참). quad 밖에서 그 부모에 자식을 끼워
넣는 게 UB인 진짜 이유이기도 하다(`base/dispatch-core-plan.md`의 Length/Offset 절 끝, "동적 자식 추가/제거의
유일한 정당 경로" 문단).
- **⚠️ 이름은 여전히 가칭** — 주입 op 목록에 정식 등재할 때 확정할 것
(`base/dispatch-core-plan.md`의 "base가 소유하는 핸들러와 주입되는 엔진 op" 절).
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
@ -1396,7 +1417,7 @@ function activateList(self, physicalTarget)
-- [2026-08-21 5라운드 `DE-13`] `_owned == false` 분기가 여기 있었으나
-- 제거했다 — unowned Slot은 `_detached`를 **애초에 채우지 않으므로**
-- (위 `settle`의 detach 분기) 여기 오는 건 전부 내 것이다.
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
self._detached[key] = nil
end
end
@ -2082,7 +2103,7 @@ local function mountSlotTree(slot, physicalTarget)
mountSlotTree(element, physicalTarget) -- 자식은 자기 Offset에서 다시 시작
acc += element.Length:Get()
else
mountInst(physicalTarget, element, acc) -- 주입 op(위 "물리 조작은 주입 op" 절)
nativeInsert(physicalTarget, acc, { element }) -- 주입 op(위 "native*" 절)
acc += 1
end
end
@ -2180,7 +2201,7 @@ local function unmountSlotTree(slot)
if isSlot(element) then
unmountSlotTree(element) -- 재귀 — 중첩 Slot도 똑같이 비파괴
else
unmountInst(element) -- 파괴 아님(주입 op — 위 "물리 조작은 주입 op" 절)
nativeExtract(physicalTarget, Dispatch.getOffsetAt(slot, i), { element }) -- 파괴 아님(주입 op)
end
-- releaseOwner를 **안 부름** — 자식들은 여전히 이 slot의 소유. 이게
-- destroySlotTree와의 핵심 차이(파괴는 소유권까지 반납, 언마운트는 유지).
@ -2226,7 +2247,7 @@ local function destroySlotTree(slot)
if isSlot(element) then
destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음
else
disposeInst(element)
nativeDispose(element)
end
end
-- [2026-08-21] Detach로 홀드 중인 요소도 같이 파괴 — 이것들은 `_elements`
@ -2234,7 +2255,7 @@ local function destroySlotTree(slot)
-- 잘 죽이는가**의 답이 정확히 이 줄이다(아래 "`dispose`" 절).
if slot._detached then -- [2026-08-21 5라운드 `DE-7`] lazy — 없으면 통째로 스킵
for key, element in pairs(slot._detached) do
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
slot._detached[key] = nil
end
end
@ -2300,7 +2321,8 @@ function rawUnmount(self, index)
unbindLifetime(bk.observers[index])
end
releaseOwner(element, self) -- 소유권은 반납(이제 다른 곳에 넣을 수 있음)
if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end
if isSlot(element) then unmountSlotTree(element)
else nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
recompute(self, bk)
@ -2317,7 +2339,8 @@ function rawDetach(self, index)
unbindLifetime(bk.observers[index])
end
-- releaseOwner를 **안 부름** — 이게 rawUnmount와의 유일한 차이
if isSlot(element) then unmountSlotTree(element) else unmountInst(element) end
if isSlot(element) then unmountSlotTree(element)
else nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end
spliceArraysDown(self, index)
recompute(self, bk)
@ -2331,7 +2354,7 @@ end
function releaseElement(self, index, element, wasDetached)
if wasDetached then
if self._owned ~= false then
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
if isSlot(element) then destroySlotTree(element) else nativeDispose(element) end
end
return -- 이미 물리 트리 밖 + 부기 밖이라 더 할 일 없음
end
@ -2367,11 +2390,15 @@ function rawAdd(self, element, index, fromDetached)
if isSlot(element) then
attachSlot(element, self._mountedInst, self, index) -- 자식이 자기 부기+물리를 다 함
else
Dispatch.setOffsetSource(self, index, None) -- 순서는 늘 offsetSource → setLength(C4)
Dispatch.setLength(self, index, 1, self._mountedInst)
recompute(self, bk) -- 부기 완결(C7: 물리보다 먼저)
mountInst(self._mountedInst, element, Dispatch.getOffsetAt(self, index))
-- 그 다음에야 물리 마운트(주입 op, 위 그 절)
-- [순서 재정렬, 2026-08-21] **자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중.**
-- 옛 "부기 전부 먼저"는 base가 물리적으로 자리를 비워둘 수 있다는 전제였는데
-- 그런 수단이 없다 — 미는 주체는 `nativeInsert` 자신이다(아래 `dispatch-core-plan.md`
-- "물리와 부기의 순서" 절).
Dispatch.setOffsetSource(self, index, None) -- 내 자리 offset은 1..index-1의 합이라
-- 내 삽입으로 안 변한다 → 먼저 해도 안전
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
Dispatch.setLength(self, index, 1, self._mountedInst) -- **뒤를 미는 것**은 그 다음
recompute(self, bk)
end
return index
end
@ -2387,27 +2414,40 @@ function rawReplace(self, index, newElement, destroyOld)
local oldElement = self._elements[index]
claimOwner(newElement, self) -- 새 요소 먼저 클레임(실패하면 아무것도 안 바뀜)
releaseOwner(oldElement, self)
if self._mounted then
-- 빼기는 물리 먼저(C7의 "좁은 쪽이 먼저")
if isSlot(oldElement) then unmountSlotTree(oldElement) else unmountInst(oldElement) end
end
if destroyOld then
if isSlot(oldElement) then destroySlotTree(oldElement) else disposeInst(oldElement) end
end
self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다
if not self._mounted then return end
if not self._mounted then
if destroyOld then -- 트리 밖이라 물리 op이 필요 없다
if isSlot(oldElement) then destroySlotTree(oldElement) else nativeDispose(oldElement) end
end
return
end
local bk = getBookkeeping(self)
local offset = Dispatch.getOffsetAt(self, index)
-- [2026-08-21] **원자적 교체** — 빼기와 넣기를 한 호출로 합친다(리플로우 1회,
-- 그 사이 인덱스가 어긋난 창이 없음). 파괴 여부는 op **이름**이 가른다.
if isSlot(oldElement) or isSlot(newElement) then
-- 어느 한쪽이 Slot이면 구간 길이가 1이 아니라 각자의 경로로 간다
if isSlot(oldElement) then
if destroyOld then destroySlotTree(oldElement) else unmountSlotTree(oldElement) end
else
local op = if destroyOld then nativeRemove else nativeExtract
op(self._mountedInst, offset, { oldElement })
end
if isSlot(newElement) then
attachSlot(newElement, self._mountedInst, self, index)
else
return
end
Dispatch.setOffsetSource(self, index, None)
nativeInsert(self._mountedInst, offset, { newElement })
else
local op = if destroyOld then nativeRemove else nativeExtract
op(self._mountedInst, offset, { oldElement }, { newElement }) -- 한 번에
end
Dispatch.setLength(self, index, 1, self._mountedInst)
recompute(self, bk)
mountInst(self._mountedInst, newElement, Dispatch.getOffsetAt(self, index))
end
end
function rawRemove(self, index)
@ -2421,7 +2461,10 @@ function rawRemove(self, index)
-- rawExtract가 releaseOwner를 부른다고 이미 명시하고
-- 있었는데 코드만 불일치) — 엄격 releaseOwner가
-- 들어온 뒤로는 이 누락이 실동작 차이를 만듦
if isSlot(element) then destroySlotTree(element) else disposeInst(element) end
-- [2026-08-21] 파괴 경로는 **한 번에** — 빼기와 파괴를 백엔드가 융합할 수 있다
-- (Roblox는 Parent=nil 없이 그 자리에서 Destroy가 더 싸다).
if isSlot(element) then destroySlotTree(element)
else nativeRemove(self._mountedInst, Dispatch.getOffsetAt(self, index), { element }) end
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.N — 아래 참고
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
@ -2824,7 +2867,7 @@ function dispose(value)
-- 위 elementOwner 기반 판정 재사용 — 요구 중이면 error, 아니면 재귀 파괴
...
else
disposeInst(value) -- 아래 주입 op
nativeDispose(value) -- 아래 주입 op
end
end
```
@ -2858,10 +2901,10 @@ end
막는 것이고, `unbindLifetime`은 Observer/Effect류의 GC 앵커를 조기
해제하는 것 — 축이 달라 서로 대체 불가.
**base/backend 분리 — `disposeInst`는 주입 op**(`base/dispatch-core-plan.md`
**base/backend 분리 — `nativeDispose`는 주입 op**(`base/dispatch-core-plan.md`
"base가 소유하는 핸들러와 주입되는 엔진 op" 절과 같은 패턴, `addTag`/
`removeTag`/`setAttribute`가 선례): `dispose``isSlot`이 아닌 값을
받으면 base가 시그니처만 소유하는 `disposeInst(inst: any): ()`로 위임 —
받으면 base가 시그니처만 소유하는 `nativeDispose(inst: any): ()`로 위임 —
quad-roblox는 `inst:Destroy()`로 구현. 웹 등 다른 백엔드는 자기 방식으로
매핑.

View file

@ -797,6 +797,82 @@ index 를 찾아야하던걸로 앎."*
자체가 뒤를 밀고 당긴다. 단 **백엔드 op이 아토믹한 최소 단위**여야 한다는 게
같이 확인된 요구사항.
- **`B-2`(`getOffsetAt` O(N²)) → 접두합 캐시로.** `bk.offsetCache` +
`bk.offsetDirtyFrom`("이 인덱스 초과부터 무효"), 무효화 트리거 셋을 명시
(`setLength`는 `i+1`부터 / splice는 그 자리부터 / 베이스 변경은 전체).
**남은 작은 확인**: `recompute`의 전체 순회가 그 캐시를 같이 채우게 할지.
**`bk.invalidAfter`**("여기까지는 유효"). **[같은 자리에서 사용자가 의사코드로
정정]** 함수를 둘로 나누지 않고 **단일 `getOffsetAt`이 필요한 만큼만 이어붙인다**
— 순차 호출이면 한 칸씩 늘어나 전체 O(N)이고, `recompute`도 그 위에 얹힌다.
무효화 규칙도 하나로 줄었다: `invalidAfter = min(invalidAfter, i)`
(`setLength`도 splice도 **자기 인덱스까지**만 당긴다 — `i` 자리의 offset은
`1..i-1`의 합이라 안 바뀌므로. 베이스 변경만 `0`).
---
# L. `native*` 물리 조작 계층 확정 (2026-08-21, 대화 마지막 라운드)
`mountInst`/`unmountInst`/`disposeInst` 셋으로는 **`Move`/`Swap`을 아예 표현할
수 없다**는 지적에서 시작해, 물리 조작 계층 전체가 재설계됐다.
## L-1. 층위 정의 (사용자)
> **`raw*`는 그 Slot 스코프 안의 연산**(평탄화 전, `_elements` 인덱스),
> **`native*`는 확정된 offset/length로 표현되는 물리 트리 연산**(평탄화 후, 절대 좌표).
## L-2. 확정된 표면
```lua
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`도 이 둘로 표현된다(사용자: *"count 만큼 공간을 축소 newElements
길이만큼 공간을 확장할 수 있게 한번에 처리. 안 그러면 splice 가 무거워짐"*).
- **파괴/비파괴를 불리언이 아니라 이름으로 가른다** — 공개 CRUD의
`Remove`↔`Extract` 어휘를 물려받고, **백엔드 융합**을 연다(Roblox에서
`Parent = nil``Destroy()`가 그냥 `Destroy()`보다 비싸다는 사용자 지적).
- **⭐ 빠지는 요소는 반드시 배열로 넘긴다** — 에이전트가 "count면 충분하지
않나"는 질문에 답하다 확인: `(target, offset, count)`로 대상을 찾을 수 있는 건
**DOM뿐**(`childNodes[offset]`)이고, **Roblox는 자식이 순서 없는 집합**이라
offset으로 인스턴스를 역으로 못 찾는다.
- **`nativeSwap`은 별도** — `Move`는 사이를 전부 밀지만 `Swap`은 가운데를 고정한
채 양끝만 교환이라 다른 연산(사용자 지적).
- **`nativeInsert`는 흡수하지 않는다** — 최빈 경로가 "0개를 빼는 extract"가 되는
모양을 피하고 일괄 삽입 최적화를 그 안에 숨기지 않기 위해.
- **미주입이면 에러가 아니라 조합 폴백**`addTag` 계열과 갈리는 지점.
- **⚠️ 전제**: 한 Slot의 물리 자식은 부모 안에서 **연속 구간**을 차지한다(범위
op이 성립하는 근거 전부).
## L-3. ⭐ 그 결과 `C-7` 일반 계약이 역전됐다
*"부기가 물리보다 항상 먼저"*는 **"`Length`를 먼저 올려 밀어내고 그 공간에
넣는다"**는 그림 위에 서 있었는데, **base에는 물리적으로 자리를 비워둘 수단이
없다**(사용자: *"밀어내고 null을 넣어둘 수도 없는데, nativeInsert 라는것 자체가
밀어내기 동작을 강제"*). 미는 주체는 언제나 백엔드의 삽입 연산 자신이다.
**새 계약** — 순서 규칙이 "두 얼굴"에서 하나로 줄었다:
> **자기 자리를 정하는 것(`setOffsetSource`)은 먼저 / 뒤를 미는 것
> (`setLength` → `recompute`)은 나중.**
`setOffsetSource`가 먼저여야 하는 이유는 그 자리의 offset이 `1..i-1`의 합이라
**자기 삽입으로 안 변하고**, 삽입 위치 계산과 `activateList`(C1)가 그 값을 쓰기
때문이다. 그래서 `rawAdd``spliceArraysUp``setOffsetSource`
`nativeInsert``setLength``recompute`.
**배치 경로(`materializeSlotTree` → `mountSlotTree`)는 그대로 부기 전량이
먼저** — 그건 C6가 요구하는 별개 사안이고 새 규칙과 모순되지 않는다.
역전 원문은 `archive/bookkeeping-before-physical-reversed.md`.
## L-4. 반영 파일
`slot-plan.md`(op 절 전면 재작성, `rawAdd`/`rawReplace`/`rawRemove`/`rawUnmount`/
`rawDetach`/`mountSlotTree`/`unmountSlotTree` 의사코드), `dispatch-core-plan.md`
(C-7 재정의 + 주입 op 목록), `architecture.md`(`EngineOps.luau`), `ROADMAP.md`(M5
주입 표면), `archive/`(역전 원문), `README.md` 색인.

View file

@ -192,11 +192,12 @@
**사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length
변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스
초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더
length 를 이어붙이면 될듯."* → `bk.offsetCache` + `bk.offsetDirtyFrom`으로
반영(`base/dispatch-core-plan.md`). 무효화 트리거 셋(`setLength`는 `i+1`부터,
splice는 그 자리부터, 베이스 변경은 전체)까지 명시. **남은 작은 확인 하나**
`recompute`의 전체 순회가 그 캐시를 같이 채우게 할지는 구현 시 판단(그 문서에
⚠️로 표시). 아래는 열려 있던 시점의 서술:
length 를 이어붙이면 될듯."* → `bk.offsetCache` + **`bk.invalidAfter`**("여기까지는 유효")로 반영
(`base/dispatch-core-plan.md`). **무효화 규칙은 하나**
`invalidAfter = min(invalidAfter, i)`(`setLength`도 splice도 자기 인덱스까지,
베이스 변경만 `0`). `recompute`도 이 캐시 위에 얹혀 O(N)이라
**"캐시를 누가 채우나"라는 갈래가 없어졌다**(사용자 정정: 함수를 나눌 이유가
없다). 아래는 열려 있던 시점의 서술:
`settle`이 키마다 `rawAdd`/`rawReplace`를 부르고 그 각각이 `getOffsetAt`(O(i))을
부르므로, 이미 마운트된 리스트의 데이터가 통째로 바뀌면 **O(N²)**다(최초 마운트는
`_mounted == false`라 얼리 리턴이 막아준다). `DC-9`에서 `setOffsetSource`의 즉시

View file

@ -1683,5 +1683,8 @@ lazy화, `KeyGone`엔 새 값 반환도 error, **`Owned=false`에서 `Detach`는
역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그
문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대.
감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. **`Gate`만 사용자 지시로
다음 세션 — M2를 막는 유일한 항목.**
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. 마지막 라운드에선 **`native*` 물리 조작 계층**(Slot 스코프의 `raw*`와 갈리는,
확정된 offset/length 기반 여섯 op)이 확정되고 그 여파로 **4라운드 `C-7`("부기가
물리보다 먼저")이 역전**됐다 — base엔 자리를 비워둘 수단이 없고 미는 주체는
백엔드 삽입 연산 자신이라, 규칙이 "자기 자리 먼저 / 뒤를 미는 것 나중" 하나로
줄었다. **`Gate`만 사용자 지시로 다음 세션 — M2를 막는 유일한 항목.**

View file

@ -176,9 +176,29 @@
**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일**
(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`
**접두합 캐시**(`offsetDirtyFrom`). offset 물리 재배치 질문도 "DOM은 insert가
**접두합 캐시**(`invalidAfter` — 단일 함수가 필요한 만큼만 이어붙임). offset 물리 재배치 질문도 "DOM은 insert가
알아서 밀어낸다"로 닫혔다.
## 6-3. 마지막 라운드 — `native*` 계층 확정, `C-7` 역전
주입 op 셋(`mountInst`/`unmountInst`/`disposeInst`)으로는 **`Move`/`Swap`을 아예
표현할 수 없다**는 사용자 지적에서 시작해 물리 조작 계층이 재설계됐다.
- **층위 정의가 생겼다**`raw*` = Slot 스코프(평탄화 전), `native*` = 확정된
offset/length 기반 물리 연산(평탄화 후).
- 표면은 여섯(`nativeInsert`/`Extract`/`Remove`/`Move`/`Swap`/`Dispose`).
`Replace`는 별도 op이 아니라 **`newElements`가 있는 Remove/Extract**이고,
파괴/비파괴는 **불리언이 아니라 이름**으로 가른다(Roblox의 "그 자리에서 바로
Destroy" 융합을 열기 위해).
- **⭐ 대상 요소를 배열로 넘겨야 한다** — `(target, offset, count)`로 찾을 수
있는 건 DOM뿐이고 **Roblox는 자식이 순서 없는 집합**이라 offset 역조회가 안 된다.
- **`C-7`("부기가 물리보다 항상 먼저")이 역전됐다** — base엔 물리적으로 자리를
비워둘 수단이 없고 미는 주체는 백엔드의 삽입 연산 자신이다. 규칙이
**"자기 자리를 정하는 것 먼저 / 뒤를 미는 것 나중"** 하나로 줄었다.
원문은 `archive/bookkeeping-before-physical-reversed.md`.
- 같은 라운드에서 `getOffsetAt`의 접두합 캐시도 사용자 의사코드로 정정 —
**단일 함수 + `invalidAfter`**, 무효화는 `min(invalidAfter, i)` 하나.
## 7. 남긴 파일
- `qa-request/pre-implementation-qa-round5.md` — 문항지(205문항)

View file

@ -37,8 +37,9 @@
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
`rawAdd``Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
다중 의존성) — **[2026-08-21 2차 회신으로 전량 처리 완료]**, 소스는 그
파일의 **F절**. 그중 **`Gate`(공용 게이트 노드) 설계만 사용자 지시로 다음
다중 의존성) — **[2026-08-21 전량 처리 완료]**, 소스는 그 파일의 **마지막
절**(A~L). 대화 마지막 라운드에서 **`native*` 물리 조작 계층**이 확정되며
4라운드 `C-7`("부기가 물리보다 먼저")이 역전됐다. 그중 **`Gate`(공용 게이트 노드) 설계만 사용자 지시로 다음
세션으로 미뤄졌다**(*"고칠것이 많으므로 Gate 는 다음 세션에 다루겠음 …
지금 세션 상 지식만 이전될 수 있게 두세요"*) — 재료는
`research/gate-primitive.md`에 모아뒀고 **M2 착수를 막는 유일한 항목**이다.

View file

@ -416,6 +416,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
## M5 — quad-roblox 최소 프로바이더
> **[2026-08-21 5라운드] 주입 표면이 늘었다 — `native*` 물리 트리 조작 계층.**
> `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/`nativeSwap`/
> `nativeDispose`(이름 가칭). base가 `Parent`를 모른다는 원칙을 실제로 지키기
> 위한 것이고, **미주입이면 에러가 아니라 조합 폴백**이라 최소 구현 부담은
> `nativeInsert`/`nativeExtract`/`nativeDispose` 셋이다(나머지는 이득 있을 때만
> 덮어씀 — Roblox는 `nativeRemove`를 "그 자리에서 바로 `Destroy`"로 융합하는 게
> 실익). 상세는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절.
> **⚠️ 구현 관례**: `quad-roblox`의 공개 타입은 지금부터 단일 파일
> (`src/init.luau` 또는 `types.luau`)에 몰아둘 것 — 나중에 필요해지면
> 백로그 `quad-roblox-types`(가칭, `quad-types`와 같은 패턴)로 쉽게