From a4b517aee262ab5fb736c712cb79909a2dec2e30 Mon Sep 17 00:00:00 2001 From: qwreey Date: Fri, 21 Aug 2026 20:07:21 +0900 Subject: [PATCH] =?UTF-8?q?design:=20native*=20=EB=AC=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=A1=B0=EC=9E=91=20=EA=B3=84=EC=B8=B5=20=ED=99=95=EC=A0=95=20?= =?UTF-8?q?+=20C-7("=EB=B6=80=EA=B8=B0=EA=B0=80=20=EB=AC=BC=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EB=8B=A4=20=EB=A8=BC=EC=A0=80")=20=EC=97=AD=EC=A0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주입 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 --- .claude/README.md | 1 + .../bookkeeping-before-physical-reversed.md | 64 +++++++ .claude/base/architecture.md | 2 +- .claude/base/dispatch-core-plan.md | 142 +++++++-------- .claude/base/slot-plan.md | 163 +++++++++++------- .../pre-implementation-qa-round5-followup.md | 82 ++++++++- .claude/question.md | 11 +- .claude/session-summary.md | 7 +- ...21-02-qa-round5-and-gate-epoch-research.md | 22 ++- .claude/todos.md | 5 +- ROADMAP.md | 8 + 11 files changed, 362 insertions(+), 145 deletions(-) create mode 100644 .claude/archive/bookkeeping-before-physical-reversed.md diff --git a/.claude/README.md b/.claude/README.md index 05b6523..eaa25d4 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -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` 왕복 해소 등. 분리 직전 전문을 그대로 보존. **`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>`도 이 재설계로 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" 절 | diff --git a/.claude/archive/bookkeeping-before-physical-reversed.md b/.claude/archive/bookkeeping-before-physical-reversed.md new file mode 100644 index 0000000..d2d1df9 --- /dev/null +++ b/.claude/archive/bookkeeping-before-physical-reversed.md @@ -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번) 이 + 순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이 + 계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로 + 고정한다"**가 진짜 이유다. + diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 41076a4..859bf72 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -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`) diff --git a/.claude/base/dispatch-core-plan.md b/.claude/base/dispatch-core-plan.md index 6e8d9c6..a7fa837 100644 --- a/.claude/base/dispatch-core-plan.md +++ b/.claude/base/dispatch-core-plan.md @@ -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에서 한 프레임 순서가 깨진 채 노출될 위험)**: diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index e44d7cb..5d6930c 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -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) - if isSlot(newElement) then - attachSlot(newElement, self._mountedInst, self, index) - else + 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) + return + end Dispatch.setOffsetSource(self, index, None) - Dispatch.setLength(self, index, 1, self._mountedInst) - recompute(self, bk) - mountInst(self._mountedInst, newElement, Dispatch.getOffsetAt(self, index)) + 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) 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()`로 구현. 웹 등 다른 백엔드는 자기 방식으로 매핑. diff --git a/.claude/qa-request/pre-implementation-qa-round5-followup.md b/.claude/qa-request/pre-implementation-qa-round5-followup.md index 4bc3ae1..e9add84 100644 --- a/.claude/qa-request/pre-implementation-qa-round5-followup.md +++ b/.claude/qa-request/pre-implementation-qa-round5-followup.md @@ -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` 색인. diff --git a/.claude/question.md b/.claude/question.md index a3c425a..8cfc3e7 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -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`의 즉시 diff --git a/.claude/session-summary.md b/.claude/session-summary.md index ded2df6..89620ec 100644 --- a/.claude/session-summary.md +++ b/.claude/session-summary.md @@ -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를 막는 유일한 항목.** diff --git a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md index 3780810..16e9e74 100644 --- a/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md +++ b/.claude/session/2026-08-21-02-qa-round5-and-gate-epoch-research.md @@ -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문항) diff --git a/.claude/todos.md b/.claude/todos.md index bc7ce01..cd03214 100644 --- a/.claude/todos.md +++ b/.claude/todos.md @@ -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 착수를 막는 유일한 항목**이다. diff --git a/ROADMAP.md b/ROADMAP.md index 60ce6e7..eae9684 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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`와 같은 패턴)로 쉽게