decide(tag,slot): Tag:Added/Removed vararg fix, Slot:Splice CRUD 신설

Tag:Added/:Removed가 문서상 단일 name만 받던 불일치를 vararg로 정정
(self-return 최적화는 멤버십을 매번 먼저 읽어야 해서 기각). Slot:Splice
CRUD 신설 — 구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화.
Slot-in-Slot relate 범위/Animate 반환타입/Slot retract 파괴는 기존
문서와 일치 확인만.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
qwreey 2026-08-12 16:56:47 +09:00
parent 81ff240a59
commit 94978c78f4
Signed by: qwreey
GPG key ID: D28DB79297A214BD
4 changed files with 98 additions and 10 deletions

View file

@ -10,6 +10,9 @@ quad의 진짜 개선점.** **[2026-08-09 세 번째 세션]** CRUD 의미론
(`pre-implementation-audit.md` 1-7/1-8) 완전 확정, `research/ (`pre-implementation-audit.md` 1-7/1-8) 완전 확정, `research/
additional-primitives-plan.md`가 다루던 키 기반 동적 컬렉션 재조정도 additional-primitives-plan.md`가 다루던 키 기반 동적 컬렉션 재조정도
`Slot:List(...)` 메소드로 이 문서에 승격·통합 완료 — 아래 참고. `Slot:List(...)` 메소드로 이 문서에 승격·통합 완료 — 아래 참고.
**[2026-08-12 열다섯 번째 세션]** `Splice(index, removeCount,
...newElements)` CRUD 신설 — 구간 제거+삽입을 shift/recompute 1회로
묶는 순수 최적화, 아래 "CRUD API 확정" 절 참고.
## base/roblox 패키지 경계 (2026-08-04, 5차 라운드 확정) ## base/roblox 패키지 경계 (2026-08-04, 5차 라운드 확정)
@ -324,6 +327,7 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
| `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 | | `Remove` | `Slot:Remove(index)` | O(n) | 제거 **+ 파괴**(retract/Destroy) — `Extract(index):Destroy()`와 동치, 흔한 경로라 별도 이름으로 유지 |
| `Extract` | `Slot:Extract(index, newElement?)` | O(n) 또는 O(1) | `newElement` 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). `newElement` 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환 | | `Extract` | `Slot:Extract(index, newElement?)` | O(n) 또는 O(1) | `newElement` 생략 — 제거만(파괴 안 함), 뒤 요소가 당겨져 빈 자리를 메움(O(n)). `newElement` 지정 — 그 자리를 즉시 교체(뒤 요소 안 건드림, O(1)), 이전 element를 반환 |
| `ExtractAll` | `Slot:ExtractAll(): {T}` | O(n) | 전체 추출(파괴 안 함) — `Clear`의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환 | | `ExtractAll` | `Slot:ExtractAll(): {T}` | O(n) | 전체 추출(파괴 안 함) — `Clear`의 비파괴 버전, 추출된 element 배열(순서 보존)을 반환 |
| `Splice` | `Slot:Splice(index, removeCount, ...newElements): {T}` | O(n) | 한 위치에서 `removeCount`개를 비파괴 추출(반환)하고 그 자리에 `newElements`를 삽입 — shift+recompute 1회로 통합 |
| `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op | | `Clear` | `Slot:Clear()` | O(n) | 전체 `Remove`(전부 파괴) — 빈 Slot에 호출해도 no-op |
| `Move` | `Slot:Move(oldIndex, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** | | `Move` | `Slot:Move(oldIndex, newIndex)` | **O(n)** | 제자리 재배치 — 옛/새 위치 사이 요소들이 밀림/당겨짐(배열 splice와 동일 의미), **Parent 안 건드림** |
| `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 인덱스의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** | | `Swap` | `Slot:Swap(indexA, indexB)` | **O(1)** | 두 인덱스의 요소를 맞교환, 나머지 안 건드림, **Parent 안 건드림** |
@ -349,6 +353,20 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일 정확히 같아서(교체도 "그 자리 걸 빼내고 새 걸 넣는" 것의 원자적 버전일
뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이 뿐). `newElement`에도 `Add`와 같은 검증(이미 마운트/타입 제약)이
똑같이 적용됨. 똑같이 적용됨.
- **`Splice` 신설(2026-08-12 열다섯 번째 세션)** — 한 구간을 제거하고
동시에 새 요소들을 그 자리에 넣는 흔한 배치 갱신(`:List` 없이 수동
CRUD로 큰 구간을 통째로 교체하는 경우)을 `Extract`/`Add`를 요소 수만큼
반복 호출하면, 그때마다 개별 `raw*` 호출이 각자 shift+`recompute`를
돌려 O(n) 비용이 반복 횟수만큼 곱으로 커짐 — `Splice`는 이걸 시프트
1회 + `recompute` 1회로 묶는 순수 최적화(새 능력 추가 아님, `Extract`
반복+`Add` 반복으로도 결과는 항상 재현 가능). **비파괴**(`Extract`처럼
제거분을 파괴하지 않고 반환) — 실제 물리적 detach/reattach(Roblox
`Parent` 조작, `AncestryChanged` 발화 등)를 언제 어떻게 할지는 base가
정하지 않고 quad-roblox 등 백엔드 Handler 엔드포인트가 처리(기존
"base는 추상 재조정 로직, backend는 실제 트리 조작"이라는 패키지
경계 원칙 그대로 재적용, 새 분리 아님). 제거된 구간이 뒤 요소를 당기고
삽입된 구간이 다시 밀어내는 게 순수하게 겹치면 상쇄되는 부분이 있어
`Extract 반복 + Add 반복`보다 실제 이동 계산량도 더 적음.
- **`Get`/`IndexOf` 신설, 원래 "YAGNI"로 뺐던 것을 재추가.** 처음엔 - **`Get`/`IndexOf` 신설, 원래 "YAGNI"로 뺐던 것을 재추가.** 처음엔
"`:List`가 자기 key→element 맵을 따로 들고 있어 Slot 내부 상태 조회가 "`:List`가 자기 key→element 맵을 따로 들고 있어 Slot 내부 상태 조회가
불필요"하다고 판단해 드롭했으나, 위 인덱스 기준 전환과 맞물려 다시 불필요"하다고 판단해 드롭했으나, 위 인덱스 기준 전환과 맞물려 다시
@ -375,16 +393,22 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
에러. 에러.
- `Extract(index, newElement)`: `newElement``Add`와 동일한 검증 - `Extract(index, newElement)`: `newElement``Add`와 동일한 검증
(이미 마운트/타입 제약) 적용. (이미 마운트/타입 제약) 적용.
- `Splice(index, removeCount, ...newElements)`: `index``Add`와 같은
범위(1..현재 개수+1), `removeCount``index`부터 실제 남은 개수를
못 넘으면 에러(음수도 에러) — clamp 안 함, 나머지 CRUD와 같은
fail-fast 톤. `newElements` 각각에 `Add`와 동일한 검증(이미 마운트/
타입 제약) 적용, 검증은 실제 mutate 전에 전부 먼저 통과해야 함
(일부만 적용된 채 중간에 에러나는 반쪽 상태 방지).
- `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖이면 에러 — 단 - `Swap`: `indexA`/`indexB` 중 하나라도 범위 밖이면 에러 — 단
`Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op. `Swap(i, i)`(같은 인덱스)는 위치가 안 바뀌므로 에러 없이 no-op.
- **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십 - **`Move`/`Swap`은 반환값 없음(void)** — 내부 재배치만 수행, 멤버십
weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/ weak-set을 안 건드림(요소가 Slot을 떠난 적이 없으므로) — 그래서 `Add`/
`Remove`/`Extract`보다 저렴함. `Remove`/`Extract`보다 저렴함.
- **공개 CRUD 중 실제로 mutate하는 것(`Add`/`Remove`/`Extract`/ - **공개 CRUD 중 실제로 mutate하는 것(`Add`/`Remove`/`Extract`/
`ExtractAll`/`Clear`/`Move`/`Swap`)은 "가드 확인 + `raw*` 위임"의 얇은 `ExtractAll`/`Splice`/`Clear`/`Move`/`Swap`)은 "가드 확인 + `raw*` 위임"의
wrapper** — `self._listed`(`:List`가 설치돼 있으면 수동 CRUD 금지)만 얇은 wrapper** — `self._listed`(`:List`가 설치돼 있으면 수동 CRUD 금지)만
확인하고 실제 로직은 `rawAdd`/`rawRemove`/`rawExtract`/`rawClear`/ 확인하고 실제 로직은 `rawAdd`/`rawRemove`/`rawExtract`/`rawSplice`/
`rawMove`/`rawSwap`에 있음 — 이 `raw*` 함수들이 `:List`의 reconcile이 `rawClear`/`rawMove`/`rawSwap`에 있음 — 이 `raw*` 함수들이 `:List`의 reconcile이
가드 없이 직접 호출하는 바로 그 함수(아래 "`Slot:List`" 절의 "구현" 가드 없이 직접 호출하는 바로 그 함수(아래 "`Slot:List`" 절의 "구현"
참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를 참고). 공개 메소드에 로직이 따로 있는 게 아니라 전부 이 한 세트를
공유. **`Get`/`IndexOf`는 순수 읽기라 이 가드 대상 아님** — `:List` 공유. **`Get`/`IndexOf`는 순수 읽기라 이 가드 대상 아님** — `:List`

View file

@ -4,7 +4,9 @@
모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존). 모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존).
2026-08-12 열한 번째 세션에 `TagHandler``process`/`retract` 메커니즘을 2026-08-12 열한 번째 세션에 `TagHandler``process`/`retract` 메커니즘을
참조 카운트 기반으로 전면 정정(옛 버전은 `archive/ 참조 카운트 기반으로 전면 정정(옛 버전은 `archive/
retract-always-fires-reversed.md`). 새 결정만 반영, 열린 질문 없음. retract-always-fires-reversed.md`). 2026-08-12 열다섯 번째 세션에
`Added`/`Removed`를 단일 이름에서 vararg로 정정(아래 값 모양 절). 새
결정만 반영, 열린 질문 없음.
## 왜 재설계됐나 ## 왜 재설계됐나
@ -18,8 +20,8 @@ retract-always-fires-reversed.md`). 새 결정만 반영, 열린 질문 없음.
``` ```
Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈 값도 유효 Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈 값도 유효
tag:Added(name): Tag -- clone 후 이름 추가, 원본 안 건드림 tag:Added(name, ...): Tag -- clone 후 이름(들) 추가, 원본 안 건드림
tag:Removed(name): Tag -- clone 후 이름 제거 tag:Removed(name, ...): Tag -- clone 후 이름(들) 제거
tag:Contains(name): boolean -- 멤버십 확인 tag:Contains(name): boolean -- 멤버십 확인
tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴) tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴)
Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의 Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의
@ -31,9 +33,21 @@ Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifie
`Added`/`Removed`가 `-ed` 어미인 이유는 **`Add`/`Remove`로 쓰면 뮤테이션 `Added`/`Removed`가 `-ed` 어미인 이유는 **`Add`/`Remove`로 쓰면 뮤테이션
API처럼 보이기 때문** — 실제로는 항상 `table.clone` 후 반환(Modifier API처럼 보이기 때문** — 실제로는 항상 `table.clone` 후 반환(Modifier
3번 절과 동일한 immutable 확정 이유: 형제 서브트리 오염 방지). `Tag(a,b)` 3번 절과 동일한 immutable 확정 이유: 형제 서브트리 오염 방지).
자체가 `Tag():Added(a):Added(b)`의 sugar라고 생각하면 됨 — 별도 런타임
경로 아님. **[정정, 2026-08-12 열다섯 번째 세션] `Added`/`Removed`도 vararg —
`Tag(a,b)``Tag():Added(a,b)` 한 번의 clone sugar, `Tag():Added(a):Added(b)`
처럼 반복 clone하는 게 아님.** 처음엔 단일 `name`만 받고 생성자의
vararg는 반복 `Added` 호출의 sugar로 서술했으나, 이러면 이름 여러 개를
한 번에 걸 때(`Tag(a,b,c)`도 결국 clone 1회면 충분한데) 이름 개수만큼
`table.clone`+해싱이 반복되는 손해가 남 — 태그가 이미 걸려있는 경우 self를
그냥 리턴하는 최적화도 검토했으나(2026-08-12 열다섯 번째 세션) 그러려면
매번 먼저 멤버십을 읽어야 해서 오히려 전반적으로 더 비쌈(해싱이 한 번
더 나고 Set에서도 한 번 더 남) — **기각, `Added`는 항상 새 clone을
반환하는 현재 동작 유지**. 대신 여러 이름을 한 번의 clone으로 처리하는
vararg만 추가해 흔한 다중 추가 케이스의 비용을 줄임. `Tag(name1, name2,
...)` 생성자도 이제 정확히 `Tag():Added(name1, name2, ...)`(단일 clone)와
동치.
**children 배열 슬롯(array-part)에 직접 놓임** — `Frame { Tag("selected") }`. **children 배열 슬롯(array-part)에 직접 놓임** — `Frame { Tag("selected") }`.
정적으로 여러 개 놓아도(`Frame { Tag("a"), Tag("b") }`) 각자 독립적으로 정적으로 여러 개 놓아도(`Frame { Tag("a"), Tag("b") }`) 각자 독립적으로

View file

@ -0,0 +1,39 @@
# 2026-08-12 열다섯 번째 세션 — Slot-in-Slot relate 범위 확인, `Tag:Added` vararg, `Slot:Splice` 신설
사용자가 지난 며칠간 확정한 설계를 4개 항목으로 재확인, 1개는 문서 불일치를
발견해 수정, 2개는 새 CRUD/API를 추가.
## 확인만 하고 넘어간 것 (문서와 일치, 변경 없음)
1. **Slot-in-Slot의 `slotOwner`/`kSlotMap` relate는 최상위 마운트에만
걸림.** `attachSlot`의 중첩 재귀(`slot-plan.md` "Slot-in-Slot 중첩" 절)는
`Dispatch.process`를 다시 안 타고 직접 재귀 호출되므로, `SlotHandler.process`
안에서만 세팅되는 이 relate는 중첩 자식 Slot엔 전혀 안 걸림 — 완전히
별개 처리 확인(중첩 Slot의 중복마운트 방지는 별도의 전역 element
weak-set이 담당, 서로 안 얽힘).
2. **`Animate`의 실제 반환 타입은 `State<Tween<T> | T>`.** `CanAnimate`
거짓이면 `Tween`으로 안 감싸고 plain `v`를 그대로 반환하는 분기가 이미
`tween-plan.md`에 있음 — 확인만.
3. **Slot의 retract는 전부 파괴, 포탈 없음.** `Extract`/`ExtractAll`로
미리 빼낸 것만 예외 — 이미 확정된 그대로.
## 수정한 것
4. **`Tag:Added`/`:Removed`가 문서상 단일 `name`만 받고 있었음 —
vararg로 정정.** 원래 `Tag(a,b)``Tag():Added(a):Added(b)`(clone 2회)의
sugar로 서술했는데, 사용자가 태그 여러 개를 한 번에 걸 때 이름 개수만큼
clone+해싱이 반복되는 손해를 지적 — self-return 최적화(이미 걸려있으면
그냥 self 반환)도 검토했으나 매번 먼저 멤버십을 읽어야 해서 오히려
더 비싸 기각, 대신 `Added(name, ...)`/`Removed(name, ...)`로 vararg를
지원해 여러 이름을 단일 clone으로 처리하도록 변경(`tag-plan.md`).
## 추가한 것
5. **`Slot:Splice(index, removeCount, ...newElements): {T}` 신설.**
`ExtractAll`은 이미 있었지만, 한 구간만 비파괴 제거+삽입하는 배치
연산은 없어서 `Extract`/`Add`를 요소 수만큼 반복하면 그때마다 개별
shift+`recompute`가 돌아 비용이 곱으로 커지는 문제가 있었음 — 시프트
1회+recompute 1회로 묶는 순수 최적화로 추가(새 능력이 아니라 기존
CRUD 반복으로도 재현 가능한 결과, 비용만 다름). 비파괴(제거분은 파괴
안 하고 반환) — 실제 물리 detach/reattach는 기존 base/roblox 패키지
경계 그대로 backend Handler 몫(`slot-plan.md`).

View file

@ -627,3 +627,14 @@ unbindLifetime만 있고 slot 자신의 앵커/해제가 빠져 있었음).
규칙으로 명문화 — 앞으로 비슷한 설계에서 Slot 사례를 매번 재발굴하지 규칙으로 명문화 — 앞으로 비슷한 설계에서 Slot 사례를 매번 재발굴하지
않도록. 열한~열네 번째 세션에 걸친 "retract는 항상 불림" 정정과 그 않도록. 열한~열네 번째 세션에 걸친 "retract는 항상 불림" 정정과 그
파생 GC 이슈 시리즈가 이걸로 마무리됨. 파생 GC 이슈 시리즈가 이걸로 마무리됨.
**2026-08-12 열다섯 번째 세션 — Slot-in-Slot relate 범위 확인, `Tag:Added`
vararg, `Slot:Splice` 신설** (`session/2026-08-12-15-slot-in-slot-relate-scope-tag-splice-additions.md`)
사용자가 최근 확정 설계 4개를 재확인 질문 — 3개(Slot-in-Slot의
`slotOwner`/`kSlotMap` relate가 최상위 마운트에만 걸림, `Animate` 반환
타입이 `State<Tween<T>|T>`, Slot retract는 전부 파괴·포탈 없음)는 문서와
일치해 확인만. 1개(`Tag:Added`/`:Removed`가 문서상 단일 `name`만 받던 것)는
불일치 발견해 vararg로 정정(self-return 최적화는 매번 멤버십을 먼저 읽어야
해서 오히려 더 비싸 기각, `tag-plan.md`). 추가로 `Slot:Splice(index,
removeCount, ...newElements)` CRUD 신설 — 구간 제거+삽입을 shift/recompute
1회로 묶는 순수 최적화(`slot-plan.md`).