decide(slot): Slot:Single + Slot-in-Slot nesting, fix recompute off-by-one

Slot:Single confirmed as pure :List sugar (resolves the original
State<Frame?> offset-access motivation directly). Slot-in-Slot nesting
confirmed for component-composition uniformity: Dispatch.setLength/
setOffsetSource reused recursively keyed by the Slot object itself
(no new primitive), Slot.Length becomes a contribution-sum, teardown
is a flat destroySlotTree + explicit unbindLifetime instead of
recursive Clear(). Slot(initial?) constructor revived as pure :Add
sugar, with a new _crudUsed <-> _listed mutual-exclusion guard.

Also fixes a real off-by-one bug in the existing (pre-nesting)
Length/Offset recompute — offset was accumulating inclusive of its
own position instead of exclusive. A reentrancy guard was considered
and rejected: each Slot owns an independent bookkeeping table, so
nesting alone never re-enters the same one; genuine reentrancy is
now named UB under the same Source-to-State unidirectional-flow
principle already governing Source/State.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ymPLYAQmEYnTrnrdAwxpU
This commit is contained in:
qwreey 2026-08-11 13:55:31 +09:00
parent c02f52578f
commit cebef6d973
Signed by: qwreey
GPG key ID: D28DB79297A214BD
6 changed files with 473 additions and 29 deletions

View file

@ -29,9 +29,9 @@
| `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서) |
| `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 |
| `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` |
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드) |
| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인) |
| `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로) |
| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot<T>()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn<UD>(item, index: number, offset: Source<number>, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot<Instance>`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md``recompute` off-by-one 버그도 이 세션에 같이 수정됨 |
| `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정) |
| `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 |
| `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 |

View file

@ -461,6 +461,15 @@ Dispatch.setLength(inst, i, len: number | State<number>)
Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
```
**[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 —
`Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을
재사용해 **Slot 자신을 owner 키로 써서 같은 두 함수를 한 번 더
부르면, 최상위(Dispatch.drive의 리터럴 배열)와 중첩(Slot이 자기
자신의 요소들에 대해)이 완전히 같은 메커니즘으로 재귀됨** — 새 함수를
만들 필요 없음. 상세 재귀 흐름(Slot-in-Slot)은 `base/slot-plan.md`
"Slot-in-Slot 중첩" 절 참고, 이 문서는 그 절이 재사용하는 `recompute`
자체만 다룸(아래).
- **`setLength`**: 이 위치(array part의 number 인덱스 `i`)가 지금 몇 개의
실제 마운트 가능한 leaf를 기여하는지 보고. 정적 단일 자식은 상수
`1`(또는 `nil`/`None`이면 `0`), Slot은 자기 `.Length`(`State<number>`,
@ -526,22 +535,74 @@ pre-pass처럼 순서가 실제로 중요하거나 "채워짐 여부"를 엄밀
**recompute — 매번 전체 순회, `Get` 가드로 캐스케이드만 방지**:
**[정정, 2026-08-11 세션] `sum` 누적과 `offset:Set` 순서가 뒤바뀌어
있던 off-by-one 버그.** 원래 코드는 `sum += lengthList[i]`를 먼저 한
`offset:Set(sum)`을 해서, `offset[i]`가 "자기 앞의 형제들이 기여한
개수"가 아니라 **자기 자신을 포함한** 누적합이 되고 있었음 — 예를
들어 `Frame{Slot1}` 하나뿐이어도(앞에 아무것도 없는데) `Slot1.Offset`
`Slot1.Length`가 되어버려 `index+offset` 공식이 어긋남. 순서를
뒤집어(offset 먼저 Set, 그 다음에 자기 기여도를 sum에 누적) 수정 —
지금까지 실제 Luau로 돌려본 적이 없어 아무도 못 잡았던, Length/Offset
메커니즘 자체의 버그(오늘 논의한 중첩 기능과는 별개).
**[검토했다가 기각, 2026-08-11 세션] 재진입 방지 가드 — 불필요함이
재추적으로 확인됨.** 처음엔 recompute 도중 재귀 호출이 들어오는 경우를
대비해 `_recomputing`/`_dirty` 플래그로 방어하는 안을 검토했으나, 실제
호출 경로를 다시 추적한 결과 **각 Slot이 `Relate(자기 자신)`으로 독립된
`bk`를 갖기 때문에, 중첩된 Slot의 Length 변경이 상위로 전파되는 경로는
항상 서로 다른 `bk`를 거쳐 지나감** — 부모의 `recompute(parent, parentBk)`
자식의 `bk`를 건드리지 않고, 자식의 `recompute(child, childBk)`도 부모의
`bk`를 안 건드림. 즉 **nesting이 있다는 사실만으로는 같은 `(ownerKey,bk)`
재진입되는 경로 자체가 없음** — "중첩 Slot이 있으면 항상 dirty가 켜진다"는
초기 우려는 틀렸고, 가드 자체가 불필요한 걸로 확인됨. 진짜 재진입은
`updateFn` 같은 부작용이 recompute 도중 **같은** Slot에 다시 `Add`/`Remove`를
거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된
"일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로
간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음.
**결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입
가드 없음.**
**이 케이스를 명시적으로 UB로 명명(2026-08-11 세션, 사용자 제안)** —
`Source<T>``State<T>`를 "단방향"으로만 만족한다는 이미 확정된 원칙
(`base/store-semantics.md` "Source가 State를 만족함" 절 — 파생값이
자기 upstream Source로 거꾸로 쓰기를 하지 않는다는 것)과 **같은 카테고리의
위반**이라는 게 근거: `recompute`가 만드는 `offset`/`Length`는 전부
`lengthList`(그 Slot의 upstream 입력)에서 파생된 다운스트림 값인데,
계산 도중 촉발된 부작용이 **자기 자신의 `lengthList` 입력을 다시
mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `Set`
가하는 것"이 UB인 것과 동일한 이유로, "recompute 도중 발생한 부작용이
같은 Slot의 length에 다시 쓰기를 가하는 것"도 UB로 문서화 — 새 원칙이
아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한
것뿐, 그래서 별도 방어 로직도 필요 없음.
```lua
local function recompute(inst, bk)
local function recompute(ownerKey, bk)
local sum = 0
for i = 1, bk.N do
local v = bk.lengthList[i]
sum += (isState(v) and v:Get() or v)
local offset = bk.sourceList[i]
-- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함
if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set
offset:Set(sum)
end
local v = bk.lengthList[i]
sum += (isState(v) and v:Get() or v)
end
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스
end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절)
end
```
**`offset`/`sum`은 0-based *개수*이지 Lua 배열 인덱스가 아님(2026-08-11
세션 명시화).** Luau/Lua 배열은 1-based 관례지만, 여기서 계산하는
`offset[i]`는 "그 앞에 몇 개가 있는가"라는 순수 카디널 수라 자연스럽게
0에서 시작함 — `updateFn``index`(로컬 위치, 1-based Lua 관례)와
`index + offset` 공식으로 섞이는 게 의도된 것이지 인덱싱 불일치가
아님. `LayoutOrder` 자체도 0/음수가 허용되는 값이라 최종 결과에도
문제 없음 — 구현/문서화 시 "이 두 숫자는 서로 다른 기준(1-based 위치
vs 0-based 개수)"이라는 걸 명시적으로 적어둘 것.
전체 순회의 O(N) 비용은 무시 가능(`N`은 저작 시점에 고정된 배열 리터럴
길이, 보통 작음) — 진짜 비싼 건 `Set`이 트리거하는 다운스트림 리액티브
캐스케이드(그 위치에 이미 마운트된 원소들의 `LayoutOrder` 재적용)라,

View file

@ -31,6 +31,10 @@ unmount(`Remove`) 둘이 아니라 **reposition(`Move`/`Swap`)까지 셋** —
같은 자리에서 `self._listed``activateList(self,inst)`도 트리거해야 함 —
`:List``data:Observer(fn)` 구독을 Slot 마운트 시점까지 lazy하게 미루는
것도 이 mount 훅의 책임(아래 "`Slot:List(...)`"의 "구독 시점" 절 참고).
**[2026-08-11 세션, superseded] 이 mount 훅은 이제 `attachSlot(slotValue,
inst, inst, k)` 한 줄로 축약됨** — `Dispatch.setLength`/`activateList`
호출은 `attachSlot` 내부로 옮겨감(로직은 그대로, 재귀 가능하도록만
일반화됨), 상세는 아래 "Slot-in-Slot 중첩" 절 참고.
**추가로 필요해진 핸들러**: Slot과는 별개로, `k`가 number이고 `v`가 이미
만들어진 Instance인 경우(중첩 인스턴스를 자식으로 직접 넣는 경우, 예:
@ -40,12 +44,13 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "
## 개념
뮤터블 자식 배열. `Slot<T>()`(빈 인스턴스, 인자 없는 바닥 생성자 — 다른
독립 프리미티브의 `Type(args)` 관습과 동일하되, 무인자라 `T`를 추론할
수 없어 tbox 명시적 제네릭 적용 `Slot<<Instance>>()`로 지정)로 만들고,
`Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD로 조작하면 실제
바인드된 children이 그에 맞춰 갱신됨 — 정확한 시그니처는 아래 "CRUD API
확정" 절 참고(`get`/`set`은 드롭).
뮤터블 자식 배열. `Slot<T>(initial?)`(다른 독립 프리미티브의 `Type(args)`
관습과 동일 — `initial` 생략 시 빈 인스턴스, `T`를 추론할 수 없어 tbox
명시적 제네릭 적용 `Slot<<Instance>>()`로 지정. `initial`을 주면
`:Add`를 반복 호출하는 sugar일 뿐, 상세는 아래 "CRUD API 확정" 절의
생성자 항목 참고)로 만들고, `Add`/`Remove`/`Extract`/`Clear`/`Move`/
`Swap` CRUD로 조작하면 실제 바인드된 children이 그에 맞춰 갱신됨 —
정확한 시그니처는 아래 "CRUD API 확정" 절 참고(`get`/`set`은 드롭).
### 요소 타입 제약 (2026-08-09 세 번째 세션)
@ -81,6 +86,13 @@ InstanceChild.luau`. Slot은 "뮤터블 배열"을 다루고 이 핸들러는 "
기본값(`T` 생략 시) 없이 항상 명시를 요구하는지, `quad-base`에선
`any`로 기본값을 두는지는 tbox 제네릭 적용 문법 확정 시 같이 정할 것
(이 문서 "자식으로 넘기는 클래스 스토어" 절의 기존 미결과 같은 갈래).
**[2026-08-11 세션] `Slot<T>` 자신도 이제 예외적으로 허용 — 실제로는
`T = Instance | Slot<Instance>`(자기 참조 제네릭).** 컴포넌트 결합
시 "결과가 Instance든 Slot(멀티루트)이든 구분 없이 다른 Slot에
넣을 수 있어야 한다"는 요구 때문 — 상세 근거/메커니즘은 아래
"Slot-in-Slot 중첩" 절 참고. 자기 참조 제네릭이 Luau에서 실제로
타입체크되는지는 다른 재귀 타입 케이스들과 같은 급으로 실측 필요
(`.claude/luau-test/`, M0/M6 착수 시 확인).
## 핵심 제약: 소유권 귀속과 단일 마운트
@ -286,13 +298,48 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를
공유. **`Get`/`IndexOf`는 순수 읽기라 이 가드 대상 아님** — `:List`
설치돼 있어도 자유롭게 호출 가능.
**[2026-08-11 세션] 역방향 가드 신설 — `_crudUsed``_listed` 대칭.**
`:List`의 가드는 원래 "`:List`가 이미 설치돼 있으면 수동 CRUD
금지"만 있었고, 반대로 "수동 CRUD를 이미 썼으면 나중에 `:List`
설치 금지"는 없었음 — 이 상태로는 `Slot():Add(x); ...; slot:List(...)`
같은 코드가 조용히 통과해서, `:List`의 reconcile이 `x`의 존재를 전혀
모른 채(자기 `mounted`/`keyIndex`가 비어있는 상태로 시작) 새 요소를
추가하려다 `x`와 충돌(Length 이중 계산, index 꼬임 등)하는 gap이
있었음. 모든 mutate CRUD(`Slot(initial)`이 호출하는 `:Add` 포함)가
`self._crudUsed = true`를 세팅하고, `:List`/`:Single`(내부적으로
`:List` 호출)이 설치 시 `assert(not self._crudUsed, ...)`를 추가로
확인 — 한 Slot은 평생 "수동 CRUD" 아니면 "`:List`/`:Single`" 둘 중
하나로만 고정됨.
- **재진입성**(Observer/store-bind 재실행 콜백 안에서 `Add`/`Clear`를
다시 호출) — 별도 가드 불필요. CRUD는 평범한 동기 테이블 뮤테이션 +
Dispatch 호출일 뿐이라 "일반적 무한루프는 방어 안 함, provider 버그로
간주"라는 기존 원칙이 그대로 적용됨.
- **`Slot()` 생성자**: 인자 없는 빈 생성자로 확정 — 초기 children을
가변인자로 받는 옵션도 검토했으나, "명시적으로 `Add`해야 들어간다"
쪽이 이 프로젝트의 "매직 없이 명시적" 기조와 더 맞음.
간주"라는 기존 원칙이 그대로 적용됨. `recompute` 자체의 재진입(같은
Slot의 length를 자기 계산 도중 다시 건드리는 것)도 같은 톤으로 UB —
`base/bind-system-plan.md`의 "Length/Offset" 절, `Source⊇State`
단방향 원칙과 같은 카테고리로 명명됨(2026-08-11 세션).
- **`Slot(initial?: {T})` 생성자 — [정정, 2026-08-11 세션] "인자 없는
빈 생성자로 확정"을 뒤집고 초기 배열을 받는 옵션 생성자를 다시 엶.**
단, 새 마운트 로직이 아니라 **순수하게 `:Add`를 반복 호출하는
sugar**로만 존재 — "명시적으로 Add해야 들어간다"는 원래 취지(매직
없이 명시적)가 실제로는 안 깨짐, `Slot{a,b,c}`가 정확히
`Slot():Add(a):Add(b):Add(c)`와 같은 일을 하는 표기일 뿐이라서:
```lua
function Slot(initial)
local self = setmetatable({...}, Slot_mt)
if initial ~= nil then
self._crudUsed = true -- 빈 테이블이어도 즉시 잠금(아래 참고)
for _, v in ipairs(initial) do -- ipairs가 첫 nil에서 멈춤
self:Add(v) -- → "중간 nil은 UB, 그 뒤 무시"가 공짜로 성립
end
end
return self
end
```
**`initial ~= nil`이면(빈 테이블 `{}`이어도) 즉시 `_crudUsed = true`** —
`Slot({})`은 상태상 `Slot():Add(x):Remove(1)`과 동일(결과는 비어있지만
"수동 CRUD를 썼다"는 의도는 이미 커밋됨)이라, 인자를 아예 안 준
`Slot()`(진짜 `nil`)만 나중에 `:List`/`:Single`을 설치할 수 있는 상태로
남음 — 아래 "CRUD ↔ List/Single 상호 배타" 절 참고.
### 원시 최소화 원칙 정정 — `Move`/`Swap` 공개 API로 추가 (같은 세션 후속)
@ -871,7 +918,11 @@ GC에 위임" 원칙 그대로.
Slot은 CRUD/`:List` 여부와 무관하게 `.Length: State<number>`를 항상
노출 — 지금 실제로 마운트된 요소 개수(사용자가 직접 CRUD로 넣든 `:List`
reconcile이 넣든 동일). 두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은
reconcile이 넣든 동일). **[2026-08-11 세션] Slot-in-Slot 중첩 허용
이후로는 정확히 "요소별 기여도의 합"** — plain 요소는 1, nested Slot
요소는 그 Slot 자신의 `.Length`(재귀) — 상세는 "Slot-in-Slot 중첩" 절
참고. plain 요소만 쓰는 흔한 경우엔 항상 합==개수라 체감 차이 없음.
두 용도를 겸함: (1) 사용자가 "n개 검색됨" 같은
UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
순서 보장(위 "여러 Slot이 섞일 때 순서 보장" 참고)에 내부적으로 읽는 바로
그 값 — 별도 두 State가 아니라 하나. `:List`의 filter 탈락이 실제
@ -887,15 +938,191 @@ UI에 직접 관측, (2) `Dispatch.setLength(inst, i, slot.Length)`가 형제
넣는 것) 자식을 추가/제거하면 `Length`/형제 순서 계산이 그 변화를 몰라
조용히 어긋남 — 별도 방어 로직 없음, 문서 경고로만 남김.
## 백로그 — `Slot():Single(state, updateFn?)` (2026-08-09 여섯 번째 세션, 미착수)
## `Slot:Single(state, updateFn)` — 확정 (2026-08-11 세션, `:List` 위의 순수 sugar)
`:List`의 key-map(`mounted`/`userdata`/`keyIndex`) 없이 "0개 아니면 1개"만
다루는 더 가벼운 편의 메소드 제안(예: `state<Frame?>`를 조건부로 마운트하는
관용구를 더 명시적으로 표현) — `.Length`는 그냥 0/1이고 나머지(offset 소비,
LayoutOrder 바인딩)는 일반 Slot과 완전히 같은 프로토콜. 아직 상세 설계
안 함, `.claude/question.md`에 백로그로만 반영.
기존 "백로그, 미착수"에서 실제 설계까지 완료됨 — 새 reconcile 로직
없이 **`:List`를 정확히 0/1개짜리 배열로 감싸는 sugar**:
**문서화 프레이밍(2026-08-11, `research/documentation-content-map.md`
반영)**: `:Single`도 Slot의 "요소가 자유롭게 생기고 사라짐"이라는 본질과
같은 것 — 1개 아니면 0개의 동적 렌더링일 뿐 별도 개념 아님. 실제 설계
착수 시 이 프레이밍을 그대로 따를 것.
```lua
function Slot:Single(state, updateFn)
local data = isState(state)
and state:Compute(function(v) return v == nil and {} or { v } end)
or (state == nil and {} or { state })
return self:List(data, function(item, index, offset, prev, ud)
return updateFn(item, offset, prev, ud) -- index는 항상 상수라 안 넘김
end, function() return true end) -- 고정 key
end
```
- **`index`를 안 주는 이유**: Single은 형제가 자기 하나뿐이라 `index`
항상 상수(1 또는 존재 안 함)라 의미가 없음 — 나머지(`offset`/`prev`/
`userdata`, 세 갈래 반환 규칙)는 `:List`와 100% 동일 규칙 재사용.
- **key를 고정값(`true`)으로 두는 게 핵심**`state`가 A값에서 B값으로
바뀌어도 같은 key라 `prev`가 유지되고, `updateFn`이 "새로 그릴지/
그대로 쓸지"를 스스로 판단 가능(값 자체를 key로 쓰면 매번 다른 item
취급돼서 파괴+재생성이 강제됨 — 원하는 동작이 아님).
- **원래 동기(`State<Frame?>`가 offset을 못 받는 문제)를 이걸로 완전히
해결** — `updateFn``offset`을 직접 받으므로, "offset을 얻으려고
컴포넌트가 Slot을 리턴하는" 우회가 애초에 필요 없어짐. Slot-in-Slot
중첩(아래 절)의 정당화 근거는 이것과 별개 — 컴포넌트 결합 시 결과
타입이 뭐든(Instance/Slot) 균일하게 다룰 수 있어야 한다는 요구.
- `mounted`/`userdata`/`keyIndex` 전부 `:List`가 이미 갖고 있는 걸
그대로 재사용, 코드 중복 없음. `:List`와 마찬가지로 `self._crudUsed`
체크 대상(내부적으로 `:List`를 호출하므로 자동 적용).
## Slot-in-Slot 중첩 — 확정 (2026-08-11 세션)
**동기 — Slot을 원시 최소 요구가 아니라 컴포넌트 결합의 균일성 문제로
접근.** 카테고리 헤더+아이템 그룹(`outer:Add(header); outer:Add(itemsSlot)`)이
구체적 동기로 제기됐지만, 더 근본적인 이유는 **컴포넌트 결합** — `local
result = SomeComponent(props)`가 `Instance`를 리턴하든 `Slot`(멀티루트
워크어라운드, `base/component-composition-plan.md`)을 리턴하든, 호출부가
`outerSlot:Add(result)`를 분기 없이 그냥 부를 수 있어야 함. 지금까지
"요소 타입 제약"이 `Slot`을 암묵적으로 배제하고 있어서(`T = Instance`
단순화), 정확히 이 컴포지션 케이스가 막혀 있었음.
### 요소 타입 — `Slot` 허용
위 "요소 타입 제약" 절 갱신대로 `isMountable``isSlot(v)`을 더 이상
배제하지 않음 — 나머지(Ref/PreRef/Observer/Effect/Modifier 금지,
nil/None 금지)는 그대로.
### 재귀 메커니즘 — 새 프리미티브 없이 `Dispatch.setLength`/`setOffsetSource`를 Slot 자신 키로 재사용
`base/bind-system-plan.md`의 "Length/Offset" 절이 이미 확정해둔 두 함수는
owner 키(`inst`)가 물리 Instance일 필요가 없음(`Relate`가 아무 테이블이나
weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위 마운트와
중첩 마운트가 완전히 같은 함수 호출**이 됩니다.
```lua
-- quad-base, Slot.luau — 재귀적 "attach" 하나로 최상위/중첩 마운트 통합
local function attachSlot(slot, physicalTarget, ownerKey, position)
slot._mounted = true
slot._mountedInst = physicalTarget
Dispatch.setLength(ownerKey, position, slot.Length) -- slot.Length는 State<number>, 기존 로직 그대로
local offsetSource = Source(0)
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
slot.Offset = offsetSource
if slot._listed then
activateList(slot, physicalTarget) -- 기존 :List lazy activation, 안 바뀜
end
-- attach 전에 이미 들어와있던 요소들 flush(이미 채워둔 Slot을 나중에
-- 마운트하는 흔한 패턴이 원래도 전제하고 있던 것 — 새 개념 아님)
for i, element in ipairs(slot._elements) do
if isSlot(element) then
attachSlot(element, physicalTarget, slot, i) -- 재귀, ownerKey가 이제 slot 자신
else
element.Parent = physicalTarget -- quad-roblox 글루가 실제 수행
end
end
end
```
**최상위 마운트(`Dispatch/Slot.luau`)는 이제 이 함수 호출 한 줄:**
```lua
-- process(inst, k, slotValue)
attachSlot(slotValue, inst, inst, k) -- ownerKey = 물리 inst 자신
```
**이미 마운트된 outer에 nested Slot을 나중에 `Add`하는 경우(런타임에
카테고리 추가):**
```lua
-- rawAdd 안, element가 Slot이고 self가 이미 마운트돼 있을 때
if isSlot(element) and self._mounted then
attachSlot(element, self._mountedInst, self, index)
end
-- self가 아직 마운트 전이면 _elements에만 들어가고, self가 나중에
-- attachSlot될 때 위 flush 루프가 처리
```
`recompute`가 owner가 Slot이면 그 `.Length`에도 합계를 반영하도록
확장됐으므로(`base/bind-system-plan.md` 참고) — `Slot.Length`는 더
이상 raw 개수가 아니라 **"요소별 기여도의 합"**(plain=1, nested
Slot=그 `.Length`)이 됨. plain 요소만 있는 흔한 경우엔 항상 합==개수라
체감 차이 없음.
### 파괴 — 재귀적 `Clear()` 금지, flat teardown
**재귀적으로 `Clear()`(요소별 `Remove` 반복)를 하면 죽는 서브트리
내부에서 불필요한 shift+recompute가 요소 수만큼 반복되어 비용이 커짐 —
대신 순수 파괴 walk만 하고, outer 쪽 recompute는 자기 위치 하나에
대해서만 한 번 돎:**
```lua
local function destroySlotTree(slot)
for i, element in ipairs(slot._elements) do
if isSlot(element) then
destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음
else
element:Destroy()
end
end
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
if bk then
for i, observer in pairs(bk.observers) do
unbindLifetime(slot._mountedInst, observer)
end
end
end
function rawRemove(self, index)
local element = self._elements[index]
local bk = getBookkeeping(self)
if bk.observers[index] then
unbindLifetime(self._mountedInst, bk.observers[index]) -- outer가 이 위치 위해 등록해둔 observer
end
if isSlot(element) then destroySlotTree(element) else element:Destroy() end
spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김
recompute(self, bk) -- outer 자기 자신 레벨에서 딱 1회만
end
```
**왜 `unbindLifetime`이 꼭 필요한지**: `bindLifetime`은 물리 target
인스턴스 생명주기에 걸려있는데, 죽는 건 "이 nested Slot 하나"고 물리
target(공유 부모)은 계속 살아있으니 GC가 자동으로 안 치워줌 — 명시적으로
안 풀면 카테고리가 자주 추가/삭제되는 UI에서 조용히 새는 옵저버가
쌓임. 반대로 물리 target 자체가 죽는 경우(최상위 Destroy)는 지금처럼
GC가 전부 한 번에 정리하니 손 안 대도 됨 — 이 구분은 새 원칙이 아니라
"GC 정리는 물리 target 생명주기 단위"라는 기존 원칙이 nested Slot에서
처음으로 그 경계 바깥의 케이스(target은 살아있는데 논리 서브트리만
죽는 경우)를 만나서 드러난 것뿐.
- **Length 변경은 정확히 offset 변경으로만 전파됨, 별도 채널 없음**
수정된 `recompute`(`base/bind-system-plan.md` 참고)를 보면 `:Set()`
호출되는 대상은 (a) 뒤 형제들의 offset, (b) owner가 Slot이면 그
`.Length` 딱 둘뿐. Length 값 자체는 읽히기만 함(`:Get()`/Observer
트리거) — (b)로 올라간 `.Length`도 한 단계 위에서는 그냥 또 다른
`lengthList` 항목이라 같은 패턴이 재귀될 뿐, 새 전파 채널이 아님.
### "위치 이전 기억"은 base 책임 아님 — backend가 필요하면 `Relate`
Slot-in-Slot 자체는 순수 숫자(Length/Offset) 계산만 재귀적으로 하고,
"나 이전에 물리적으로 어디에 있었지" 같은 backend 종속적 위치 정보는
전혀 안 다룸 — 필요한 backend(예: DOM `insertBefore` 기반)가 자기
`Relate`로 알아서 저장해야 할 몫. web 백엔드는 `insertBefore`/
`removeChild`가 물리적으로 밀고/당겨주므로, "지금 이 위치의 물리적
이전 형제가 누구인지"만 삽입 시점에 알면 되고 이미 배치된 형제들의
프로퍼티를 재작성할 필요가 없음 — 기존 "DOM류 물리 순서 백엔드에도
같은 base 메커니즘이 그대로 재사용됨"(2026-08-09 여섯 번째 세션) 확정과
정합적, 중첩이 생겨도 이 결론은 안 바뀜.
**기각된 대안 — DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로
매핑하는 안.** 검토했으나 기각 — React `<></>`(Fragment)가 존재하는
이유와 정확히 같은 이유로 Slot도 **의도적으로 wrapper 없는** 그룹핑
도구라, 논리적 중첩을 물리 `<div>` 중첩으로 매핑하면 이 wrapper-less
원칙 자체가 깨짐(flexbox/grid 등에서 직계 형제를 기대하는 CSS가
깨질 수 있음). 어느 backend든 nested Slot의 리프는 항상 flat하게
같은 물리 부모의 자식이어야 함 — 그래서 위 숫자 기반 메커니즘이
Roblox뿐 아니라 web에도 그대로 필요.
### 0-based 개수 vs 1-based Lua 인덱스
`offset`/`sum`은 0-based 개수(카디널 수)고, `_elements`/`updateFn`의
`index`는 1-based Lua 배열 관례 — `index + offset` 공식이 이 둘을
의도적으로 섞는 것. 상세는 `base/bind-system-plan.md`의 "0-based
개수" 절 참고.

View file

@ -34,9 +34,13 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
- Untrack/Suspense/Error Boundary/Readonly는 조사 결과 새 프리미티브 없이
기존 설계·Lua 자체 기능으로 이미 충분한 것으로 판단(`research/
additional-primitives-plan.md` "빈 자리 아닌 것" 절).
- **[백로그, 2026-08-09 여섯 번째 세션 추가, 미착수]** `Slot():Single(state,
updateFn?)` — `:List`의 key-map 없이 "0개 아니면 1개"만 다루는 가벼운
편의 메소드. `base/slot-plan.md` "백로그 — `Slot():Single(...)`" 절.
- **[해소됨, 2026-08-11 세션]** `Slot:Single(state, updateFn)``:List`
0/1개짜리 배열로 감싸는 순수 sugar로 확정(`index` 없이 `offset`/
`prev`/`userdata`만 전달, 고정 key로 `prev` 재사용 보장). `base/
slot-plan.md`의 "`Slot:Single(...)`" 절. 같은 세션에 **Slot-in-Slot
중첩도 확정**(요소 타입 제약에서 `Slot` 배제 해제, `Dispatch.setLength`/
`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`) —
`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절.
### 1. 용어 정리 (사용자 요청, 진행 중)

115
CLAUDE.md
View file

@ -2995,3 +2995,118 @@ slot-plan.md`(`:Single` 백로그 절에 이 프레이밍 적용 메모 추가)
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
우선) — 이번 세션은 문서화 톤 결정이라 M0 착수 우선순위엔 영향 없음.
## 2026-08-11 여섯 번째 세션 — `Slot:Single` 확정, Slot-in-Slot 중첩 확정,
Length/Offset `recompute` off-by-one 버그 발견·수정
`Slot():Single(state, updateFn?)` 백로그(2026-08-09 여섯 번째 세션,
"`State<Frame?>`가 offset을 못 받아서 위쪽 Slot의 offset/length를 써야
했다"는 동기)를 실제로 설계하다가, 더 큰 질문(Slot을 다른 Slot 안에
넣을 수 있는가)까지 라이브로 풀어낸 긴 세션. 다섯 갈래로 정리:
**1. `Slot:Single(state, updateFn)``:List` 위의 순수 sugar로 확정.**
`state`를 0/1개짜리 배열로 감싸(`:Compute`) `:List`에 위임, 고정
key(`true`)로 `prev` 재사용을 보장, `index`는 상수라 안 넘김. 원래
동기(offset 접근)를 이걸로 완전히 해결 — "offset을 얻으려고 컴포넌트가
Slot을 리턴하는" 우회가 필요 없어짐. `base/slot-plan.md`
"`Slot:Single(...)`" 절.
**2. Slot-in-Slot 중첩 확정 — 동기는 카테고리 헤더가 아니라 컴포넌트
결합의 균일성.** 사용자가 직접 짚은 진짜 이유: `SomeComponent(props)`
`Instance`를 리턴하든 `Slot`(멀티루트 워크어라운드)을 리턴하든
`outerSlot:Add(result)`가 분기 없이 동작해야 함 — 지금까지 "요소 타입
제약"이 `Slot`을 암묵적으로 배제하고 있어서 정확히 이 케이스가 막혀
있었음. **핵심 발견 — 메커니즘은 그대로 재사용, 새 프리미티브 불필요:**
`Dispatch.setLength`/`setOffsetSource`의 첫 인자(`inst`)가 물리
Instance일 필요가 없다는 것(`Relate`가 아무 테이블이나 weak 키로 받음)을
재사용해, **Slot 자신을 owner 키로 같은 두 함수를 한 번 더 부르면
최상위 마운트와 중첩 마운트가 완전히 같은 함수 호출**이 됨 — 재귀
`attachSlot(slot, physicalTarget, ownerKey, position)` 하나로 통합.
`Slot.Length`는 raw 개수에서 "요소별 기여도의 합"(plain=1, nested
Slot=그 `.Length`)으로 의미 변경.
- **타입 레벨로 확장하려던 "모든 instance 처리를 Slot에 위임"은 기각**
리터럴 배열(`Dispatch.drive`)의 요소 타입 규칙(Ref/PreRef/Observer
허용)이 Slot의 요소 타입 규칙(같은 값들 금지)과 정반대라, 타입을
진짜로 통합하면 "만들어진 방식에 따라 행동이 다른 Slot"이라는 숨은
분기가 생김 — **메커니즘(setLength/setOffsetSource/recompute)만
공유하고 타입/CRUD 표면은 분리 유지**로 스케일 확정(사용자 확인:
"그게 더 엔지니어링 비용이 싸고 좋은 구현").
- **파괴는 재귀적 `Clear()`가 아니라 flat `destroySlotTree`** — 사용자가
직접 비용 문제 지적("clear된 다음 length 바뀌고 위치변경 전파되는
구조는 안 됨"): 재귀 `Clear()`(요소별 Remove 반복)는 죽는 서브트리
내부에서 불필요한 shift+recompute가 요소 수만큼 반복됨 — 대신 순수
파괴 walk(`.Destroy()`만)+`unbindLifetime` walk로 바꾸고, outer 쪽
recompute는 자기 위치 하나에 대해서만 1회. `unbindLifetime`이 왜 꼭
필요한지도 새로 드러남 — `bindLifetime`은 물리 target 생명주기에
걸려있어 target이 살아있는 채로 논리 서브트리만 죽는 경우(카테고리
삭제 등) GC가 자동으로 안 치워줌, 명시적 호출 필요(물리 target 자체가
죽는 경우는 기존처럼 GC가 전부 처리).
- **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음(2026-08-09 세 번째 세션 결정 정정), 단 새 마운트
로직이 아니라 `:Add` 반복 호출 sugar(`ipairs`의 "첫 nil에서 멈춤"
동작이 "중간 nil UB, 그 뒤 무시"를 공짜로 구현). **`initial ~= nil`이면
빈 테이블이어도 즉시 `_crudUsed = true`**(사용자 지적: `Slot({})`
상태상 `add():remove(1)`과 동일이라 결과가 비어있어도 "CRUD를 썼다"는
의도는 이미 커밋됨) — `Slot()`(진짜 `nil`)만 나중에 `:List`/`:Single`
설치 가능. **`_crudUsed``_listed` 상호 배타 가드도 신설** — 기존엔
`:List` 설치 후 수동 CRUD만 막았지 반대(수동 CRUD 후 `:List` 설치)는
안 막아서, `:List`의 reconcile이 기존 요소를 모른 채 충돌하는 gap이
있었음(사용자 발견).
- **DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로 매핑하는 안은
기각** — 제가 처음 낸 "web은 물리 nesting을 지원하니 이 메커니즘이
아예 필요 없을 수도"라는 제안을 사용자가 직접 반박: React `<></>`
존재하는 이유와 정확히 같은 이유로 Slot도 의도적으로 wrapper 없는
그룹핑 도구라, div 매핑은 그 원칙 자체를 깨버림. 숫자 기반 메커니즘은
web에도 그대로 필요하되, `insertBefore`/`removeChild`가 물리적으로
밀고/당겨주므로 이미 배치된 형제 프로퍼티 재작성은 불필요(기존
2026-08-09 여섯 번째 세션 확정과 정합적, 사용자가 세션 도중 직접
재확인). "물리적으로 이전에 어디 있었는지" 같은 backend 종속 위치
정보는 base 책임이 아니라 필요한 backend가 자기 `Relate`로 저장할
몫 — 새 설계 불필요, 이미 확정된 base/backend 경계 그대로.
**3. `Dispatch.setLength`/`setOffsetSource`/`recompute`의 owner 키가
물리 Instance로 한정될 필요 없다는 걸 `base/bind-system-plan.md`
명시.** "Length/Offset" 절에 짧은 절 신설 — 이게 위 재귀 메커니즘 전체의
근거.
**4. `recompute`의 off-by-one 버그 발견·수정 — 중첩과 무관한, 기존
Length/Offset 메커니즘 자체의 버그.** 구체 숫자로 흐름을 검증하려다
발견: 원래 코드가 `sum += lengthList[i]`를 먼저 하고 `offset:Set(sum)`
나중에 해서, `offset[i]`가 "자기 앞의 형제들이 기여한 개수"가 아니라
**자기 자신을 포함한** 누적합이 되고 있었음(예: `Frame{Slot1}` 하나뿐이어도
`Slot1.Offset``Slot1.Length`가 되어버림) — 순서를 뒤집어(offset 먼저
Set, 그 다음 sum에 자기 기여도 누적) 수정. 지금까지 실제 Luau로 돌려본
적이 없어 아무도 못 잡았던 버그. 카테고리 헤더 예시(7개 리프)로 수정된
공식을 검증, 정확히 저작 순서대로 LayoutOrder 1..7이 나옴을 확인.
**`offset`/`sum`은 0-based 개수, `index`는 1-based Lua 관례**라는
점도 명시적으로 콜아웃(둘을 섞는 `index+offset` 공식이 의도된 것이지
인덱싱 불일치가 아님).
**5. Reentrancy 가드 검토 후 기각 — 제가 처음 제안한 `_recomputing`/
`_dirty` 플래그가 불필요함을 사용자가 정확히 캐치.** "nested Slot이
있으면 항상 dirty가 켜져서 불필요한 for문이 한 번 더 돈다"는 사용자
지적을 계기로 호출 경로를 다시 추적 — **각 Slot이 `Relate(자기 자신)`으로
독립된 `bk`를 가지므로, 중첩 Slot의 Length 변경이 상위로 전파되는 경로는
항상 서로 다른 `bk`를 거쳐 지나감**, 즉 nesting이 있다는 사실만으로는
같은 `(ownerKey,bk)`가 재진입되는 경로 자체가 없음이 확인됨 — 제가
가드의 동기 자체를 잘못 짚었던 것. 진짜 재진입(부작용이 recompute
도중 같은 Slot에 다시 Add/Remove)은 이미 확정된 "일반적 재진입/무한루프
방어 안 함, 사용자 코드 버그로 간주" 원칙 그대로 두면 되므로, 가드
없이 off-by-one만 고친 순수 버전으로 최종 확정. **같은 세션 후속으로
이 케이스에 명시적 이름을 붙임(사용자 제안)** — `Source⊇State`
"단방향"(파생값이 자기 upstream Source로 거꾸로 안 쓴다) 원칙과 같은
카테고리 위반으로 프레이밍: recompute가 만드는 `offset`/`Length`는
`lengthList`(upstream 입력)에서 파생된 다운스트림 값인데, 부작용이
자기 자신의 `lengthList`를 다시 mutate하는 게 그 반대 방향 쓰기라
"State가 자기 Source에 Set을 가하는 것"과 동일한 UB로 명명 —
새 원칙이 아니라 이미 있는 단방향 흐름 원칙의 재적용.
**반영된 파일**: `base/slot-plan.md`(요소 타입 제약/`Slot(initial)`/
CRUD 가드/`Slot:Single`/"Slot-in-Slot 중첩" 신규 절/`Slot.Length`),
`base/bind-system-plan.md`(owner 키 일반화/`recompute` 버그 수정),
`ROADMAP.md`(M6 체크박스 다수 추가), `.claude/question.md`(`Slot:Single`
백로그 해소 표시).
**다음 세션이 할 일**: 안 바뀜(`ROADMAP.md` M0부터, luau-test 결과 확인
우선) — 이번 세션도 순수 설계 확정이라 M0 착수 우선순위 자체는 그대로.
`Slot<T>`의 자기 참조 제네릭 실측이 luau-test 확인 목록에 새로 추가됨.

View file

@ -293,6 +293,43 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
- [x] **`Slot:Single(state, updateFn)` 확정** — `:List`를 0/1개짜리
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
slot-plan.md` "`Slot:Single`" 절).
- [x] **Slot-in-Slot 중첩 확정** — 요소 타입 제약에서 `Slot` 배제 해제
(`T = Instance | Slot<Instance>`, 자기 참조 제네릭은 실측 필요).
`Dispatch.setLength`/`setOffsetSource`를 물리 inst 대신 **Slot
자신을 owner 키**로 재사용하는 재귀 `attachSlot`으로 최상위/중첩
마운트 통합(새 프리미티브 없음). `Slot.Length`가 raw 개수에서
"요소별 기여도의 합"으로 의미 변경. 파괴는 재귀적 `Clear()`
아니라 flat `destroySlotTree`(파괴 walk + `unbindLifetime` walk,
outer 쪽 recompute는 1회만) — 물리 target이 살아있는 채로 논리
서브트리만 죽는 경우 명시적 `unbindLifetime` 필요(GC-native 정리의
예외 케이스). DOM 백엔드가 nested Slot을 실제 `<div>` 중첩으로
매핑하는 안은 기각(Fragment와 같은 이유로 wrapper-less 유지 필요) —
숫자 기반 메커니즘이 web에도 그대로 필요하나, `insertBefore`/
`removeChild`가 물리적으로 밀고 당겨줘서 이미 배치된 형제 재작성은
불필요(2026-08-11 세션, `base/slot-plan.md` "Slot-in-Slot 중첩" 절).
- [x] **`Slot(initial?: {T})` 생성자로 확장** — "인자 없는 빈 생성자로
확정"을 뒤집음, `:Add` 반복 호출 sugar일 뿐(새 마운트 로직 없음).
`initial ~= nil`이면(빈 테이블도) 즉시 `_crudUsed = true` — 상태상
`Add→Remove`와 동일하므로. **`_crudUsed``_listed` 상호 배타
가드 신설** — 기존엔 `:List` 설치 후 수동 CRUD만 막았지 반대(수동
CRUD 후 `:List` 설치)는 안 막아서 `:List`의 reconcile이 기존
요소를 모른 채 충돌하는 gap이 있었음(2026-08-11 세션, `base/
slot-plan.md` "CRUD API 확정" 절).
- [x] **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/
bind-system-plan.md` "Length/Offset" 절) — `sum` 누적과
`offset:Set` 순서가 뒤바뀌어 `Offset`이 자기 자신을 포함해버리던
버그(예: 유일한 자식인데도 `Offset`이 0이 아니게 됨) 수정. 재진입
방지 가드는 검토 후 기각 — 각 Slot이 `Relate(자기 자신)`으로
독립된 `bk`를 가져서 nesting만으로는 같은 `bk`가 재진입되는 경로
자체가 없음이 재추적으로 확인됨. 진짜 재진입(부작용이 recompute
도중 같은 Slot의 length에 다시 쓰기)은 `Source⊇State`의 "단방향"
원칙과 같은 카테고리의 위반으로 **명시적 UB 명명**(방어 로직 없음,
기존 "일반적 재진입 방어 안 함" 원칙과 정합). `offset`/`sum`은
0-based 개수, `index`는 1-based Lua 관례라는 것도 명시.
## M7 — Modifier