Compare commits
35 commits
871c582771
...
068971fd56
| Author | SHA1 | Date | |
|---|---|---|---|
| 068971fd56 | |||
| f650a1cfd6 | |||
| 471f64f9ee | |||
| 9622ccd3e9 | |||
| 0498816c10 | |||
| 168d3d8dcc | |||
| 782e576218 | |||
| 0ebf14405f | |||
| c1c046fabe | |||
| a55e726808 | |||
| 46287ee393 | |||
| 3b46d5ef68 | |||
| e2b85bca55 | |||
| 930e45bfad | |||
| 356a308ce0 | |||
| cb838d3172 | |||
| c58c97a877 | |||
| 76cf74e80d | |||
| 9627558046 | |||
| 4c395cd383 | |||
| a4b517aee2 | |||
| c6fdf1b348 | |||
| d5e5e1a1b9 | |||
| 4622fbeec8 | |||
| 9b7f847014 | |||
| 448b961e7f | |||
| 6fd93c6dc2 | |||
| 1e6cb0111d | |||
| f36bdcbe5d | |||
| 9642cf8f18 | |||
| 41b96e6917 | |||
| e80aa11c4f | |||
|
|
e290499fce | ||
|
|
2b48bd0bf3 | ||
|
|
4474b69ab1 |
53 changed files with 16522 additions and 847 deletions
File diff suppressed because one or more lines are too long
60
.claude/archive/always-propagate-no-dedup-superseded.md
Normal file
60
.claude/archive/always-propagate-no-dedup-superseded.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# [역전됨, 2026-08-21] "emit은 항상 전파된다 / 중복 통지는 접지 않는다"
|
||||
|
||||
**무엇이 뒤집혔나**: `base/source-state-plan.md`의 "전파 모델 확정" 절과
|
||||
"다이아몬드 의존성은 무엇이 푸는가" 절이 확정해두던 두 서술 —
|
||||
(1) emit은 자기 `invalid` 상태와 무관하게 **항상** 전파된다, (2) quad가 추가로
|
||||
접지 않는 것은 **중복 통지**뿐이다.
|
||||
|
||||
**무엇으로 바뀌었나**: `base/state-epoch-plan.md`(소스 에포크 비교)를 채택하면서
|
||||
**같은 소스의 같은 에포크가 두 번째로 도착하면 접는다**로 바뀌었다. 다이아몬드에서
|
||||
값도 통지도 한 번씩만 간다.
|
||||
|
||||
**⚠️ 2026-08-14의 역전을 되돌린 게 아니다.** 그때 폐기된
|
||||
(`archive/invalidate-dedup-propagation-reversed.md`) 것은 *"이미 `invalid`면 더 안
|
||||
내려보낸다"*는 **플래그 기반** dedup이고, 그건 `:Get()`을 안 부르는 Observer가
|
||||
**한 번 울고 영구히 침묵**하는 실패 모드가 있었다. 에포크 비교엔 그 모드가 없다 —
|
||||
매 `Set`마다 카운트가 새 값이라 항상 통과하고, 접히는 건 같은 에포크가 두 경로로
|
||||
도착한 두 번째뿐이다. **`invalid`로 접는 것은 지금도 금지**다.
|
||||
|
||||
**역전 이유**: 플래그가 아니라 에포크로 판정하면 (a) DFS 전파 도중 `Get()`이
|
||||
섞인 값을 캐시하는 glitch가 없어지고, (b) 그 판정이 중복 통지까지 O(1)로 접는다.
|
||||
성능이 아니라 **정확성** 동기다 — 경위는 `base/state-epoch-plan.md` §1(실재하는
|
||||
glitch)과 §4(수신 규칙). **[2026-08-22 정정]** 여기 `§1·§3`으로 적혀 있었으나
|
||||
그 문서가 `Epoch`/`EpochMap` 승격 때 §1~§8로 재편되며 옛 §3("이게 실제로
|
||||
무엇을 고치는가")은 규칙 본문으로 흡수됐다.
|
||||
|
||||
---
|
||||
|
||||
## 역전 전 원문 (2026-08-14~2026-08-21)
|
||||
|
||||
### "전파 모델 확정" 절에서
|
||||
|
||||
> - **⭐ emit(무효화 신호)은 구독자에게 *항상* 전파된다 — 자기 `invalid`
|
||||
> 상태와 무관.** 신호를 받은 State는 자기 `invalid` 플래그를 세우고,
|
||||
> **이미 `invalid`였더라도 그대로 아래로 전파한다.**
|
||||
> - **`invalid` 플래그의 역할은 "내 캐시가 낡았다"는 표시 하나뿐** —
|
||||
> 전파를 제어하는 장치가 **아님**.
|
||||
> - **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐** —
|
||||
> 지금은 `Blocker`가 유일하고 … **평범한 State는 절대 신호를 삼키지
|
||||
> 않는다.**
|
||||
|
||||
### "다이아몬드 의존성은 무엇이 푸는가" 절에서
|
||||
|
||||
> 즉 **중복 재계산을 막는 주체는 pull-recompute + 캐시**이지, 전파를
|
||||
> 중간에 끊는 게 아니다. Vide가 `todo.md`에 미해결로 남긴 "다이아몬드 중복
|
||||
> **재평가**"는 이 캐시 구조로 풀리고, quad가 추가로 접지 않는 것은
|
||||
> **중복 *통지***뿐이다 — `d` 아래의 `Observer`는 한 사이클에 두 번 울 수
|
||||
> 있고, 그건 의도된 동작이다(통지를 접으려면 `Blocker` 같은 명시적 게이트를
|
||||
> 쓸 것).
|
||||
>
|
||||
> - **순회 비용이 실측에서 문제가 되면** 그때 "**한 번의 전파 파동
|
||||
> 안에서만** 같은 노드를 두 번 방문하지 않는다"는 최적화를 넣을 수 있음
|
||||
> (방문 집합/에포크 카운터). 단 그건 **파동 단위**여야 하고, 지금처럼
|
||||
> 시간에 걸쳐 유지되는 `invalid` 플래그로 하면 안 됨 … 이 최적화는
|
||||
> 의미론에 안 보이는 순수 구현 사항이라 지금 결정할 필요 없음.
|
||||
|
||||
**마지막 문단에 대한 주석**: "파동 단위여야 한다"는 경고는 **`invalid` 플래그로
|
||||
하지 말라**는 뜻이었고, 채택된 에포크 모델은 시간에 걸쳐 유지되는 카운트를 쓰지만
|
||||
**소스별 에포크**라 그 함정에 안 걸린다(새 `Set`은 새 에포크). 또 이 최적화는
|
||||
"의미론에 안 보이는 순수 구현 사항"이 아니었다 — 실제로는 `Get()`이 섞인 값을
|
||||
돌려주던 것을 고치는 **의미론 변경**이었다.
|
||||
57
.claude/archive/bindlifetime-slot-owner-reversed.md
Normal file
57
.claude/archive/bindlifetime-slot-owner-reversed.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# [역전됨, 2026-08-21] `bindLifetime`의 첫 인자가 Slot일 수 있다는 요구사항
|
||||
|
||||
**상태**: 역전됨. 2026-08-20 구현 전 QA 4라운드 `D-56`에서 확정돼
|
||||
`base/lifecycle-pattern.md`의 `(1-1)` 절로 들어갔다가, **2026-08-21 구현 전 QA
|
||||
5라운드 `C-4`에서 통째로 뒤집혔다.**
|
||||
|
||||
**왜 뒤집혔나**: 사용자 지적 — *"애초에 Slot 이 effect 나 다른 요소들을 소유할
|
||||
수가 없다 … 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을
|
||||
유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로
|
||||
둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?"* 검토 결과 **부기 키와
|
||||
생명주기 앵커를 한 인자가 겸하고 있던 게 원인**이었고, `Dispatch.setLength`가
|
||||
`anchor`를 따로 받도록 바꾸는 것만으로 이 요구사항 전체가 사라졌다. 지금 유효한
|
||||
결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단과
|
||||
`base/lifecycle-pattern.md`의 `(1-1)` 포인터.
|
||||
|
||||
**부수 효과**: 이 절이 요구하던 `isBoundAlive`의 "세 번째 분기"(gcconn도
|
||||
`.Subscribed`도 없는 Slot-owned 바인딩을 판정하는 분기)는 **형태가 미정인 채로
|
||||
열려 있던 항목**이었는데, 역전과 함께 필요 자체가 없어졌다.
|
||||
|
||||
---
|
||||
|
||||
## 역전 전 원문
|
||||
|
||||
#### (1-1) ⚠️ 첫 인자가 물리 Instance가 아닐 수도 있다 — 백엔드가 반드시 핸들링할 것 (2026-08-20 구현 전 QA 4라운드 `D-56`)
|
||||
|
||||
**위 구현 스케치는 `inst`가 항상 Roblox Instance라고 가정하고 `InstData`에서
|
||||
gcconn/gchold를 찾는데, 실제 호출부 중엔 `inst` 자리에 `Slot`이 오는 경로가
|
||||
이미 있다.** `Dispatch.setLength(ownerKey, i, len)`이 그것 —
|
||||
`base/dispatch-core-plan.md`의 "`setLength` 구현" 절이 `bindLifetime(ownerKey,
|
||||
observer)`를 부르는데, 그 `ownerKey`는 Slot-in-Slot 중첩에서 **Slot 자신**이다
|
||||
(`base/slot-plan.md`의 "재귀 메커니즘" 절 — `attachSlot`이 `ownerKey`로 자기
|
||||
자신을 넘겨 최상위/중첩을 같은 함수로 통합한 그 설계).
|
||||
|
||||
**사용자 판정(2026-08-20)**: *"ownerKey 가 Slot일 수도 있음. 각 엔진의
|
||||
bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong
|
||||
으로 gchold 비슷한걸 수행하면 됨."*
|
||||
|
||||
- **계약 두 개(위 절)는 그대로 유지된다** — 바뀌는 건 "그 계약을 무엇으로
|
||||
구현하는가"뿐. 물리 Instance면 gcconn 트릭이 두 계약을 다 만족시키고,
|
||||
Slot이면 **Slot 자신이 살아있는 동안 `value`를 붙잡는 강참조**(Slot 안의
|
||||
필드든, `Relate(slot)`에 `SetStrong`이든)와 **`value`가 그 Slot의 생존을
|
||||
되물을 수 있는 근거**를 백엔드가 제공하면 된다.
|
||||
- **왜 gcconn을 못 쓰는가**: gcconn 트릭은
|
||||
`inst:GetPropertyChangedSignal("ClassName")`에 의존하므로 엔진 객체가 아닌
|
||||
값(Slot은 평범한 Lua 테이블)엔 걸 수가 없다. Slot은 대신 **자기 자신이
|
||||
reachable한가**가 곧 생존이라, `Relate(slot)`가 weak-keyed인 것만으로
|
||||
"Slot이 죽으면 기록도 같이 사라진다"가 성립한다.
|
||||
- **`isBoundAlive`의 판정 분기도 이 경로를 알아야 함** — 지금 코드는
|
||||
gcconn이 없으면 곧바로 `.Subscribed` 폴백으로 떨어지는데, Slot-owned
|
||||
바인딩은 gcconn도 `.Subscribed`도 없어서 **살아있는데 `canBound`가 참으로
|
||||
잘못 나온다**(= 이중 바인딩 가드가 이 경로에선 안 걸림). 백엔드 구현이
|
||||
세 번째 분기를 추가하거나, Slot 쪽 홀더 존재 자체를 판정 근거로 삼아야 함.
|
||||
- **⚠️ 정확한 형태는 아직 미확정 — M2/M3 구현 시 확정할 것.** "Slot 안"(필드)
|
||||
이냐 "바깥"(`Relate`)이냐, `isBoundAlive`의 세 번째 분기를 어떤 모양으로
|
||||
둘지가 열려 있다. 지금 확정된 건 **"첫 인자가 Instance라고 가정하면 안
|
||||
된다"는 요구사항 자체**뿐.
|
||||
|
||||
64
.claude/archive/bookkeeping-before-physical-reversed.md
Normal file
64
.claude/archive/bookkeeping-before-physical-reversed.md
Normal 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번) 이
|
||||
순서가 어긋나도 사용자에게 보일 프레임이 그 사이에 없다. 그래서 이
|
||||
계약은 "안 지키면 깜빡인다"가 아니라 **"백엔드가 전제할 수 있게 하나로
|
||||
고정한다"**가 진짜 이유다.
|
||||
|
||||
89
.claude/archive/brand-shared-registry-reversed.md
Normal file
89
.claude/archive/brand-shared-registry-reversed.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# [역전됨] `Brand` — 공유 레지스트리 + `Brand.get(x) -> tag` (객체당 태그 하나)
|
||||
|
||||
> **역전일**: 2026-08-21. **대체된 곳**: `base/brand-plan.md`(인스턴스 브랜드로
|
||||
> 전면 재작성). **역전 근거 원문**: `reference/epoch-brand-composition.md` §1의
|
||||
> 6번 / §3.
|
||||
>
|
||||
> **뒤집힌 것은 API 표면 하나뿐이다** — weak-key 레지스트리를 쓴다는 것,
|
||||
> 태그가 문자열이 아니라 테이블 아이덴티티라는 것, duck-typing을 안 쓰는
|
||||
> 두 근거, 포함 관계를 predicate 합성으로 표현한다는 것은 **전부 그대로
|
||||
> 살아 있다**(`base/brand-plan.md`가 소스). 아래는 그 표면의 옛 모양만
|
||||
> 보존한 것.
|
||||
|
||||
## 무엇이 뒤집혔나
|
||||
|
||||
**옛 모양** — 모듈 하나가 공유 레지스트리 하나를 들고, 객체마다 태그를
|
||||
**정확히 하나** 붙인다. 판별은 그 태그를 되읽어 비교한다.
|
||||
|
||||
```lua
|
||||
local Brand = {}
|
||||
local registry = setmetatable({}, {__mode = "k"})
|
||||
|
||||
function Brand.set(x, tag) registry[x] = tag end
|
||||
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값
|
||||
|
||||
-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
|
||||
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
|
||||
StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag,
|
||||
ModifierTag =
|
||||
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
|
||||
|
||||
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
|
||||
Brand.set(newHandle, ObserverTag)
|
||||
```
|
||||
|
||||
`isX`는 그 위에 얇게 얹혔다:
|
||||
|
||||
```lua
|
||||
local function isSource(x)
|
||||
return Brand.get(x) == SourceTag
|
||||
end
|
||||
local function isState(x)
|
||||
return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족
|
||||
end
|
||||
|
||||
local function isPreRef(x)
|
||||
return Brand.get(x) == PreRefTag
|
||||
end
|
||||
local function isPostRef(x)
|
||||
return Brand.get(x) == PostRefTag
|
||||
end
|
||||
local function isRef(x)
|
||||
-- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류
|
||||
return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag
|
||||
end
|
||||
```
|
||||
|
||||
`None` 처리도 이 모양에 묶여 있었다 — *"`None` 자체를 레지스트리에
|
||||
평범하게 태깅하는 건 무방(사용자가 허용) — 그러면 특수 분기 없이도
|
||||
`Brand.get(None)`이 답을 준다. 즉 '범용 introspection 창구'를 지키고
|
||||
싶으면 특수 분기가 아니라 평범한 등록으로 지킨다."*
|
||||
|
||||
## 왜 뒤집혔나
|
||||
|
||||
**표현할 수 없는 관계가 실재했다.** `Epoch` 인터페이스가 도입되면서
|
||||
`Source`는 `SourceBrand`이면서 **동시에** `EpochBrand`여야 하는데,
|
||||
"객체당 태그 하나" 모델로는 이걸 못 적는다. `Source`가 아닌 원천(외부
|
||||
시계 등)이 `Epoch`로 참여하려면 다중 태깅이 필수다.
|
||||
|
||||
사용자 제안(2026-08-21): *"본인이 거기 속하면, 본인이 직접 해당 브랜드를
|
||||
가져와 등록하면 … `isXXXX`에서 각각의 구현을 넣을 필요가 없어짐. 따라서
|
||||
외부 확장도 쉬워진다."*
|
||||
|
||||
## 같이 버려진 것 — `Brand.get`의 역조회
|
||||
|
||||
옛 모양은 "이 값이 대체 무엇인가"를 되묻는 **범용 introspection 창구**를
|
||||
겸했다. 새 모양엔 그게 없다(브랜드마다 자기 집합만 안다).
|
||||
|
||||
**대가가 아니었다** — 사용자 확인: *"확실히 의미가 없어진것 같습니다
|
||||
필요하진 않아요."* 코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 **멤버십
|
||||
질문**이고, 역조회를 하는 자리는 전수 조사에서 하나도 없었다.
|
||||
|
||||
## 같이 검토됐다 철회된 우려 — "포함 관계가 코드에 드러난다"는 성질
|
||||
|
||||
에이전트가 "자기 등록 방식이면 `isRef`/`isState` 같은 포함 관계가 흩어진다"고
|
||||
대가로 짚었으나 **착오였고 철회됐다.** 사용자 지적: *"여전합니다.
|
||||
`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거
|
||||
똑같습니다."* — 자기 등록이 **여러 브랜드에 등록하기를 강제하는 게 아니므로**,
|
||||
각 타입은 자기 브랜드에만 등록하고 포함 관계는 지금처럼 predicate 합성으로
|
||||
한 곳에 쓰면 된다. 2026-08-09에 세운 성질이 그대로 유지된다.
|
||||
|
|
@ -921,3 +921,299 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만**
|
|||
툴링 체인(pesde types emit 등)의 지원 시점은 외부 사정이고 관측 수단이
|
||||
에이전트에 없다. 그래서 질문을 닫고 `HUMAN_TODO.md` 8번(사용자가 시점을
|
||||
파악하거나, 가능해질 때 에이전트에 알림)으로 옮겼다.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## [해소 배치 이관, 2026-08-21] `question.md`가 들고 있던 해소 항목 16건
|
||||
|
||||
**왜 옮겼나**: `question.md`는 스스로 *"항목을 해소하면 여기서 지우고
|
||||
`archive/question-resolved.md`에 근거와 함께 옮길 것 — 다시 쌓이면 같은
|
||||
문제가 반복됨"*이라고 규정해뒀는데, 2026-08-21 기준 파일의 절반이 다시
|
||||
`[해소]` 마커로 차 있었다(2026-08-21 하루에만 여러 건이 닫힌 결과). 예고된
|
||||
그대로 "필터해서 읽기 힘듦"이 재발한 것이라 이 규칙대로 일괄 이관한다.
|
||||
**결정 내용은 하나도 안 바뀜 — 읽는 자리만 옮긴 것**이고, 지금 유효한
|
||||
설계는 언제나 `base/`가 소스다.
|
||||
|
||||
**⚠️ 아래 항목 중 `Epoch`/`EpochMap` 관련 서술은 이관 시점 표현 그대로다** —
|
||||
같은 날 `Epoch`/`EpochMap`/`Brand` 승격으로 **필드 이름이 바뀌었다**
|
||||
(`sourceCountMap` → `valueEpochMap`, `sourceEmitMap` → `emitEpochMap`,
|
||||
"소스"/"count" → "`Epoch`"/"리비전"). 결정 자체는 그대로이고 표현만
|
||||
바뀌었으니, 현재형 규칙은 `base/state-epoch-plan.md`를 볼 것.
|
||||
|
||||
- **[해소됨, 2026-08-18] `DI` → `D`(Declarative) 확정** — 원문과 근거는
|
||||
`archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과
|
||||
완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare
|
||||
요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게
|
||||
유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는
|
||||
"문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로
|
||||
보완하기로 같이 확정. 코퍼스 반영 완료 —
|
||||
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절.
|
||||
|
||||
- **[해소됨, 2026-08-19] `PopOnly` → `Detach` 확정** — 원문과 근거는
|
||||
`archive/question-resolved.md`. 요지: 이미 있는
|
||||
`Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데,
|
||||
`Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히
|
||||
reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게
|
||||
구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도
|
||||
같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면
|
||||
callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라
|
||||
**패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의
|
||||
"`nil` 리턴은 파괴가 기본" 절.
|
||||
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 `CR-3`] M2가 M3의 `Blocker.luau`에
|
||||
구조적으로 의존하던 순서 문제 — "게이팅 먼저"로 결정.** 사용자 판정:
|
||||
*"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가
|
||||
필요함."* 즉 로드맵 순서를 유지하지 않고 게이팅을 M2로 앞당긴다. **다만
|
||||
앞당기는 대상이 `Blocker` 그 자체가 아니라 그 아래의 공용 `Gate` 노드로
|
||||
바뀌었고**(같은 라운드 `DT-4`), 그 표면/이름이 미정이라 **새 열린 항목으로
|
||||
이어진다 — 바로 아래 `Gate` 항목.** 아래는 해소 전 서술: 이대로 각주만 두고
|
||||
로드맵 순서를 유지할지,
|
||||
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
|
||||
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
|
||||
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
|
||||
`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작
|
||||
`Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직
|
||||
없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔
|
||||
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
|
||||
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의
|
||||
"ROADMAP.md 마일스톤 정합성" 절.
|
||||
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 H절] `mountInst`의 삽입 위치 + 중첩
|
||||
offset 결함 — `Dispatch.getOffsetAt(ownerKey, i)` 신설로 확정.**
|
||||
`setOffsetSource(None)`은 얼리 리턴하고, 숫자가 필요한 쪽이 `getOffsetAt`을
|
||||
직접 부른다(사용자안 — 병렬 배열을 안 늘리는 pull 방식). 베이스는 `isSlot`
|
||||
분기의 정체가 "베이스가 있나"임을 명확히 했고(베이스는 따로 저장하지 않고
|
||||
Slot의 `.Offset`을 그대로 읽는다 — 최상위 물리 inst는 항상 0), 중첩 Slot은
|
||||
자기 `Offset`을 관측해 깊은 전파를 한다. 반영하다 **재마운트가 `Offset` Source를 새로 만들던 결함**도
|
||||
같이 잡았다(identity 재사용). 상세는
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 H절.
|
||||
아래는 열려 있던 시점의 서술: 둘이 한 덩어리다:
|
||||
(a) `setOffsetSource`의 `None`이 "아무것도 안 차지함"과 "발행 채널 없음"
|
||||
두 뜻을 겸하고 있어 **plain 요소의 offset 숫자가 계산조차 안 된다**(그래서
|
||||
DOM류 백엔드가 삽입 위치를 알 방법이 없다 — 사용자 지적), (b) `recompute`가
|
||||
`sum = 0`에서 시작해 `ownerKey.Offset`을 안 읽으므로 **depth ≥ 2에서 중첩
|
||||
Slot의 자식 offset이 부모 베이스만큼 어긋난다**(이번에 발견, 지금까지 depth 1만
|
||||
써서 안 드러났음). 제안은 `bk.offsetList` 신설(항상 숫자 계산) +
|
||||
`mountInst(target, element, index)` + `recompute`의 `base` 시드 + 중첩 Slot이
|
||||
자기 `Offset`을 관측해 재계산하는 구독 하나 —
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 G절이 소스.
|
||||
|
||||
- **[해소, 2026-08-21] offset이 바뀌면 이미 배치된 물리 노드를 옮겨야 하는가 —
|
||||
아니오.** **사용자 확정**: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
|
||||
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"* — DOM류는 삽입/삭제
|
||||
자체가 뒤 형제를 물리적으로 밀고 당기므로, **이미 놓인 노드를 다시 옮길 일이
|
||||
없다.** offset 숫자는 그 자리가 **다음에** insert/remove할 때 쓰는 것뿐이라는
|
||||
기존 서술이 그대로 맞다. 이게 성립하려면 백엔드 op이 **아토믹한 최소 단위**
|
||||
(`mountInst`/`unmountInst`/reposition)여야 한다는 게 같이 확인된 요구사항.
|
||||
아래는 열려 있던 시점의 서술: `mountInst(target, element,
|
||||
index)`가 **삽입 시점의 위치만** 받는 일회성 호출이라, 이미 마운트된 요소의
|
||||
물리 위치는 offset이 바뀌어도 갱신되지 않는다. Roblox는 `LayoutOrder`가
|
||||
프로퍼티라 `updateFn`이 반응형으로 처리하면 되지만 **DOM은 물리 순서 자체가
|
||||
배치**다. `dispatch-core-plan.md`는 "quad-web의 offset 핸들러는 no-op이고 숫자는
|
||||
*다음에* 스스로 insert/remove할 때만 쓴다"고 적어뒀는데, 앞 형제의 길이가 변해
|
||||
뒤 형제들의 offset이 밀리는 흔한 경우에 **이미 놓인 노드를 옮길지**가 그 서술만
|
||||
으론 안 갈린다. 선택지는 (a) 옮긴다(백엔드가 offset 변경을 관측해 재배치),
|
||||
(b) 안 옮긴다(그러면 DOM에선 순서가 실제로 어긋남), (c) `:List`가 재조정 때
|
||||
필요한 것만 명시적으로 다시 `mountInst`. quad-web이 실제로 생길 때까지 미룰 수
|
||||
있으나, **계약을 지금 정해두지 않으면 M6 구현이 (b)를 전제로 굳는다.**
|
||||
|
||||
- **[해소, 2026-08-21] `:List` 재조정의 `getOffsetAt` 비용 — 접두합 캐시로.**
|
||||
**사용자 제안 채택**: *"getOffsetAt 은 compute 된걸 캐시해도 될듯. length
|
||||
변경되는 뒤는 캐시가 무효화되도록 shouldRecomputeAfter 등을 둬서 특정 인덱스
|
||||
초과부는 offset 다시 계산하고, 해당 값 위치 자체는 유효하므로 그것을 통해 더
|
||||
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`의 즉시
|
||||
계산이 O(N²)인 걸 "배치 등록 1회니 감수"로 판단했지만 **이건 매 reconcile이라
|
||||
빈도가 다르다.** 해법은 있다 — reconcile이 `pos`처럼 **절대 offset도 러닝
|
||||
누적**으로 들고 다니면 O(n)(그게 `mountSlotTree`가 이미 하는 방식). 그렇게
|
||||
할지, 아니면 실측 전엔 그냥 둘지 판단 필요.
|
||||
|
||||
- **[해소, 2026-08-21 같은 날] 게이트가 유보했다 내보내는 emit이 싣는 출처 —
|
||||
`emit(self)` + 흡수 집합.** `GateNode`가 흡수한 소스를 `withheld` 집합에
|
||||
들고 있다가, 풀 때(=flush) **집합을 그 자리에서 떼어내 새 테이블로 스왑하고**
|
||||
자기를 출처로 그 배치를 하류에 넘긴다. 하류는 출처가 게이트면 그 집합의 소스들에 평소 규칙을
|
||||
적용한다. **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라
|
||||
노드이기 때문. `base/gate-plan.md`의 4번, `base/state-epoch-plan.md` §4
|
||||
(이관 당시엔 §2였으나 그 문서가 §1~§8로 재편됨).
|
||||
|
||||
- **[해소, 2026-08-21 같은 날] State 에포크 — 새 노드의 두 맵 초기값과
|
||||
`:With` 병합.** `sourceEmitMap`은 **비우고**(어떤 emit이 와도 "처음 보는
|
||||
것"으로 걸리는 게 맞다 — 새 노드는 개념적으로 emit을 받아본 적이 없다),
|
||||
`sourceCountMap`은 **전부 끌어와 실제 count로 채운 뒤 `rawInvalid = true`**
|
||||
(비워두면 순회가 훑을 목록 자체가 없어 "유효하다"로 오판한다 — *"'내가 뭘
|
||||
추적하고 있나' 가 필요하죠"*). 그래서 `:With` 병합 규칙은 **필요 없어졌다.**
|
||||
재계산 시 갱신 범위(전부 갱신)도 확정. `base/state-epoch-plan.md`의 §2·§5 7번.
|
||||
|
||||
- **[해소, 2026-08-21 같은 날] `Gate` — 소스 없는 emit(빈 배치)은 **아무것도
|
||||
안 한다**.** 에이전트 권고("빈 배치 = 무조건 통지")는 **사용자 기각** —
|
||||
*"쌓아둔것 자체가 없는데 뒤로 넘긴다는건 이상합니다 … 표면적으로 보면 State
|
||||
중간에 Emit 을 추가하는 격"*. 새 규칙도 아니다: `blocker-plan.md`가 이미
|
||||
"`HasBlockedEmit`이 false면 아무 것도 안 함(idempotent)"으로 확정해뒀고,
|
||||
`withheld`는 그 플래그의 일반화다. **따름정리로 `Effect(fn, ...deps)`의 설치
|
||||
구간 억제는 `Gate` 소비자가 아니게 됐다** — `Effect` 내부 플래그로 처리하고,
|
||||
`effect-plan.md`에 있던 "`Gate`보다 뒤" 순서 제약도 사라졌다.
|
||||
`base/gate-plan.md`의 7·8번.
|
||||
- **같이 제기됐던 "재진입 계약"은 열린 항목이 아니었다**(사용자 지적으로
|
||||
2026-08-21 정리) — `blocker-plan.md`의 재진입은 **같은 인스턴스 중첩**을
|
||||
말하는 것이지 정책의 `emit()` 호출과 무관하고, 끝나지 않는 되먹임은
|
||||
2026-08-04 확정 원칙대로 **UB**이며, 유한한 재진입은 flush 진입 시 스왑으로
|
||||
이미 안전하다. `gate-plan.md`의 6번.
|
||||
|
||||
- **[해소, 2026-08-21] 공용 게이트 노드의 이름과 표면 — `state:Gate(setup)`
|
||||
메소드 + `GateNode`로 확정.** 탑레벨 프리미티브는 안 만들고, `Blocker`는
|
||||
`state:Block(blocker)` 안에서 그 배선을 쓴다(사용자: *"Gate 는 따로
|
||||
프리미티브 없이 state:Gate( (emit) -> ()->() ) 처럼 선언되고 마치 Compute
|
||||
처럼 GateNode(ComputeNode 처럼) 생성된다"*). `Get()`엔 영향 없음(통지만
|
||||
막음)까지 확정. **[2026-08-21 정정]** 여기 "남은 것은 사용자 판단이 아니라
|
||||
구현 시 정할 것들"이라 적었으나, 두 번째 `/code-review high`가 빈 배치
|
||||
emit을 잠시 사용자 판단 항목으로 되돌렸다 — **같은 날 해소돼(바로 위 항목)
|
||||
원래 서술로 돌아왔다.** 구현 시
|
||||
정하면 되는 건 생명주기와 M2 범위뿐 — `base/gate-plan.md`가
|
||||
소스. 아래는 열려 있던 시점의 서술: 위 항목의 결정("게이팅 먼저")에 따라 base에 만들 것이
|
||||
`Blocker`가 아니라 **상류 emit을 가로채 정책이 통과 여부를 정하는 공용 게이트
|
||||
노드**로 확정됐다(`Blocker`/`Debounce`/`Throttle`이 그 위의 정책). 사용자
|
||||
스케치는 `Gate(function(emit) return function() ... end end)` 2단 구조이고,
|
||||
**공개 API로 낸다**(사용자: *"이 API가 비공개일 이유는 없어보인다"*). 남은 것 —
|
||||
(a) **이름**(사용자: *"프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의
|
||||
문제"*, 에이전트 권고는 `Gate` 그대로 — `gate`는 이미 장치를 가리키는 명사),
|
||||
(b) M2에 `Gate`만 넣을지 `Blocker`까지 넣을지, 그리고 생명주기 계약.
|
||||
**[2026-08-21 해소]** 여기 있던 "`:Apply` 팩토리인가"와 "`Blocker`가 그 위에
|
||||
어떻게 얹히는가"는 닫혔다 — **사용자 확정으로 `Gate`는 `:Apply`가 아니라
|
||||
`:With`류 State 메소드**(*"state 의 전파를 손대는 작업이라 with 처럼 다른
|
||||
노드가 나는게 맞음"*)이고, 그러면 `Blocker`는 이미 확정된
|
||||
`state:Block(blocker)` 메소드가 내부에서 그걸 부르면 되므로 배선 문제 자체가
|
||||
없어진다. 상세는 `base/gate-plan.md`.
|
||||
|
||||
- **[해소, 2026-08-21] State 재계산/전파 판정을 "소스 에포크 비교"로 바꿀지 —
|
||||
채택 확정**(사용자: *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
|
||||
채택하면 될것 같아요."*). 규칙 전량은 `base/state-epoch-plan.md`, 구현은 M3.
|
||||
아래는 열려 있던 시점의 서술: 사용자 제안: 각 State가 자기 상류 루트
|
||||
`Source`들의 카운트를 들고 있다가 `Get()` 때 비교해 재계산 여부를 정한다.
|
||||
동기는 성능이 아니라 **정확성** — DFS 전파 도중 Observer가 `Get()`을 부르면
|
||||
아직 신호를 못 받은 다른 가지의 옛 캐시가 섞여 들어가는 glitch가 지금 모델에
|
||||
실재한다. 에이전트 분석 결과 진단·방향 모두 타당하고 선례도 있다
|
||||
(MobX/Adapton류 버전 검증). **[2026-08-21 갱신 — 여기 있던 "중복 통지는 안
|
||||
고쳐지고 선언 안 된 의존성을 UB로 명문화해야 한다"는 서술은 같은 날 둘 다
|
||||
뒤집혔다]**: 중복 통지도 **같은 장치로 접고**, UB 조항은 사용자 기각으로
|
||||
빠졌다. 이어진 3·4차 정정으로 **기제는 사실상 다 정해졌다** — 순회는
|
||||
`rawInvalid`가 **false**일 때만 돌고(목적은 "못 받은 emit 받기"), emit은
|
||||
count 없이 **발행 source만** 싣고, 순회가 발견한 변경은 **테이블 둘**로
|
||||
처분한다(`sourceCountMap`은 순회가 앞당겨 올리고, `sourceEmitMap`은 상류의
|
||||
진짜 emit을 기다림 — *"emit 바로 안하고 상류가 emit 해줄 때 까지
|
||||
기다립니다"*). 그래서 통지가 죽지도 게이트를 새지도 않아 `source = nil`
|
||||
규약도 필요 없어졌다. **남은 판단은 채택 여부 자체 하나**이고, State 내부 표현을
|
||||
바꾸는 결정이라 M3 뒤로 미루면 되돌리는 비용이 크다. 상세는
|
||||
`base/state-epoch-plan.md`.
|
||||
|
||||
- **[해소, 2026-08-21 구현 전 QA 5라운드 `C-4`] `Dispatch.setLength`의 Observer
|
||||
앵커 — 물리 target으로 확정(4라운드 `D-56` 역전).** `setLength`가
|
||||
`(ownerKey, i, len, anchor)`로 4번째 인자를 받아 **부기 키와 생명주기 앵커를
|
||||
분리**한다. 그래서 `bindLifetime`은 항상 물리 Instance만 상대하고,
|
||||
`isBoundAlive`의 세 번째 분기(형태 미정으로 열려 있던 것)도 **필요 자체가
|
||||
없어졌다.** 역전 원문은 `archive/bindlifetime-slot-owner-reversed.md`, 지금
|
||||
결론은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절 뒤 문단. 아래는
|
||||
열려 있던 시점의 서술: 그때 확정(4라운드 `D-56`)은
|
||||
"`bindLifetime`의 첫 인자가 Slot일 수 있으니 백엔드가 그 경우를 핸들링하라"인데,
|
||||
사용자가 그 전제 자체에 의문을 제기했다: *"애초에 Slot 이 effect 나 다른
|
||||
요소들을 소유할 수가 없다 … 실제 observer/effect 는 실제 inst 에 불림 …
|
||||
우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할
|
||||
부분."* 대안은 **부기 키(`ownerKey`)와 생명주기 앵커(물리 `physicalTarget`)를
|
||||
분리**하는 것 — 그러면 `bindLifetime`은 항상 Instance만 받고,
|
||||
`isBoundAlive`의 세 번째 분기(지금 ⚠️ 미정)도 통째로 불필요해진다. 상세와
|
||||
트레이싱은 `qa-request/pre-implementation-qa-round5-followup.md`.
|
||||
|
||||
- **[해소, 2026-08-21] `Detach` 홀드 중 키가 사라졌을 때의 처분** —
|
||||
선택지 (c)로 확정: `updateFn`을 **`KeyGone`으로 한 번 더 불러 처분을
|
||||
묻는다**. 같이 확정된 것 — detach된 요소는 `userdata`가 아니라
|
||||
**`slot._detached` 필드**가 보유하고(그래야 `destroySlotTree` walk가
|
||||
닿고 소유권도 유지됨), owner가 죽으면 `activateList`가 설치한 `Effect`가
|
||||
정리한다. 원래 갭이
|
||||
치명적이었던 이유는 gcconn 트릭 때문에 detach된 quad-제작 Instance가
|
||||
**GC 폴백조차 없이 영구히 남기** 때문. 상세는 `base/slot-plan.md`의
|
||||
"Detach된 요소는 `slot._detached`가 보유한다"/"`KeyGone`" 절.
|
||||
|
||||
- **[해소, 2026-08-21] `attachSlot` 책임 분해** — **(B) 분해 채택으로 확정**,
|
||||
`base/slot-plan.md`에 반영 완료(`materializeSlotTree`/`mountSlotTree`/얇은
|
||||
`attachSlot`). 근거 기록은 `reference/slot-attach-decomposition.md`.
|
||||
아래는 그 열려 있던 시점의 서술: 한
|
||||
함수가 부모 등록(offset/length) / `:List` 실체화 / 마운트 상태 전이 / 배치
|
||||
게이팅 / 자식 배치 / 재귀를 다 지고 있어서, **"부모에게 알리는 길이의
|
||||
최종값은 flush가 끝나야 정해진다"와 "부기가 물리 조작보다 먼저"가 동시에
|
||||
만족되지 않는다**(지금은 후자를 지키고 전자를 포기 — 부모 `recompute`가
|
||||
1회 헛돎). 사용자 판단으로 확장 논의 대기 — 책임 목록/순서 제약 출처/분해
|
||||
후보 넷은 `reference/slot-attach-decomposition.md`. **M6(`:List`) 착수 전
|
||||
필요**, 선행으로 아래 `Detach` 보관 위치가 먼저 닫히는 게 나음.
|
||||
|
||||
- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면
|
||||
error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정
|
||||
(제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인
|
||||
array-part 값 객체" 절.
|
||||
|
||||
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
|
||||
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
|
||||
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
|
||||
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
|
||||
하므로), **[2026-08-20 QA 4라운드] 이름도 `groupClaimKeys`로 확정**.
|
||||
**[해소, 2026-08-21 QA 5라운드 `AT-1`] 키도 `(inst, groupValue) → k`로 확정**
|
||||
(사용자: *"group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을
|
||||
필요가 없고, 그건 key->name 이 유일성을 검증해준다"*), `nameClaims`보다
|
||||
**위치 claim을 먼저** 본다 — `base/attribute-plan.md`의 "이름 소유권" 절.
|
||||
**이 항목은 닫혔다.**
|
||||
|
||||
- **`Gate`(2순위, 2026-08-21 신설)**: `Blocker`/`Debounce`/`Throttle` 아래의
|
||||
공용 게이트 노드 이름. 사용자 지적 — *"프리미티브 명을 Gater? 뭔가 이상하게
|
||||
들어간다는게 약간의 문제"* — 코퍼스가 `Blocker`/`Modifier`/`Observer`처럼
|
||||
`-er`를 많이 쓰는데 `Gater`는 영어로 어색하다. 에이전트 권고는 **`Gate`
|
||||
그대로**(`gate`는 이미 행위자가 아니라 **장치**를 가리키는 명사라 `-er`가
|
||||
불필요 — `Source`/`Ref`/`Slot`/`Tween`도 같은 계열), 대안 후보는
|
||||
`Valve`/`Relay`. **[2026-08-21 해소] `Gate`로 확정**(탑레벨 생성자를 안
|
||||
만들고 `state:Gate(setup)` 메소드로 가면서 `Gater` 문제 자체가 사라짐 —
|
||||
메소드 자리에서는 `:With`/`:Compute`와 나란히 자연스럽다). 노드 타입 이름은
|
||||
`GateNode`. `base/gate-plan.md`의 1번이 소스.
|
||||
|
||||
## [해소됨, 2026-08-21] `Epoch`의 리비전 증가 방식 — `bit32` 랩으로 확정
|
||||
|
||||
**결론**: `self.Revision = bit32.bnot(-self.Revision)`.
|
||||
리비전은 uint32 안에 머문다. 정본은 `base/state-epoch-plan.md` §2.
|
||||
**[2026-08-22 정정]** 이 항목을 처음 적을 때 에이전트가 형태를
|
||||
`bit32.band(rev + 1, 0xFFFFFFFF)`로 잘못 옮겼다 — 사용자가 말한 건 처음부터
|
||||
`bit32.bnot(-a)`였고(*"제가 말한건, bit32.bnot(-a) 입니다"*), 그건 랩어라운드
|
||||
**감소**를 **연산 하나로** 한다(`a > 0`이면 `a - 1`, `0`이면 `4294967295`).
|
||||
|
||||
**사용자 논거**: *"그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의
|
||||
연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운
|
||||
라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를
|
||||
모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게
|
||||
가고 싶어요."* — `2^53` 포화는 어차피 도달 불가능한 시나리오인데 그걸
|
||||
피하겠다고 값을 double 영역까지 키울 이유가 없다는 것. 매 `Set`마다 도는
|
||||
hot path다.
|
||||
|
||||
**에이전트가 달았다가 철회한 단서**: "`bit32`라서 더 싸다는 건 아니다 —
|
||||
`n + 1`은 어느 쪽이든 double 덧셈이고 `bit32`는 fastcall을 하나 더 얹는다"고
|
||||
적었으나, **이건 `band(rev + 1, mask)`라는 다른 형태를 놓고 한 비교라
|
||||
틀렸다**(2026-08-22 정정). `bit32.bnot(-a)`는 덧셈 위에 얹히는 게 아니라
|
||||
**갱신 자체를 대체**하는 단일 FASTCALL이므로, "native call이라 아주 빠르다"는
|
||||
사용자 서술이 맞다.
|
||||
|
||||
**따름정리**: 이 방식은 **단조 증가가 아니라 랩어라운드 감소**다. 지금 규칙이
|
||||
`==`/`~=`만 쓰기 때문에 무해하지만, 순서 비교를 넣고 싶어지면 이 결정부터
|
||||
되짚어야 한다.
|
||||
|
||||
아래는 열려 있던 시점의 서술:
|
||||
|
||||
- **`Epoch`의 리비전 필드 증가 방식(3순위, 2026-08-21 신설)**: `bit32` 랩
|
||||
(uint32 랩어라운드라 double 포화가 없음, 사용자가 염두에 둔 쪽) vs 평이한
|
||||
`+1`(할당·연산 더 쌈, 포화가 `2^53`이라 `bit32`의 `2^32` 랩보다 충돌 거리가
|
||||
오히려 넓음). **둘 다 도달 불가능이라 실질 위험은 없고 취향 문제다.**
|
||||
**이게 `Epoch`/`EpochMap` 승격 후 남은 유일한 미정 항목이다** —
|
||||
`base/state-epoch-plan.md` §2, 근거 기록은
|
||||
`reference/epoch-brand-composition.md` §4의 4번.
|
||||
(필드 이름 `Revision`과 타입 `Epoch`는 확정, 승격 시 `base/`에 반영됨.)
|
||||
|
|
|
|||
|
|
@ -261,15 +261,16 @@ quad/
|
|||
│ ├── pesde.toml
|
||||
│ └── src/
|
||||
│ ├── Source.luau # 값의 근원, 단일 지점. Source가 State를 구조적으로 만족(`__index` 델리게이션)
|
||||
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행) 전부 여기 소속
|
||||
│ ├── State.luau # 캐시만 하는 non-owning 핸들, state(state) 분기, `:With`/`:Compute`/`:Observer`(등록 즉시 1회 실행)/`:Gate`(`GateNode`, `ComputeNode`와 같은 층위 — `base/gate-plan.md`) 전부 여기 소속
|
||||
│ ├── EpochMap.luau # 재사용 가능한 Epoch 부기 객체(`:Update`/`:Refresh`/`:Sync`/`:TrackFrom`) — `State.luau`에 묻지 않고 별도 모듈, `GateNode`/`State`/`Effect`가 전부 씀(`base/state-epoch-plan.md`)
|
||||
│ ├── Store.luau # source 집합체, dot-access로 Source 그대로 반환
|
||||
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`), State/Source와 밀접 연관돼 같은 위치
|
||||
│ ├── Blocker.luau # 값 기반 emit 지연/합치기(`base/blocker-plan.md`) — 위 `state:Gate`의 `GateNode` 위에 얹히는 **정책**, 바닥부터 짜지 않음
|
||||
│ ├── Modifier.luau # flatten-before-dispatch, immutable 체이닝, 제네릭 `__index` 필드 setter 합성 + `:Apply`/`:Peek`/`Overridden`(`base/modifier-plan.md`)
|
||||
│ ├── Tag.luau # 값 타입+immutable clone 체이닝(`Tag(...)`/`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`/`:Names`) — 참조 카운트 Handler는 Dispatch/Tag.luau(아래), 엔진 호출은 주입된 addTag/removeTag(`base/tag-plan.md`)
|
||||
│ ├── Attribute.luau # 그룹 값 타입+API(`Attribute(store1, store2, ...)`/`Merged`/`:NameMap`, `Tag`와 동형) — Handler는 Dispatch/Attribute.luau(아래) (`base/attribute-plan.md`)
|
||||
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치)
|
||||
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenTag`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
|
||||
│ ├── Effect.luau # `Effect(fn, state?)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
|
||||
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
|
||||
│ ├── Effect.luau # `Effect(fn, ...deps)` — state 없으면 설치1회+leaf사망시 정리, 있으면 State.Observer를 조합해 재실행(`base/effect-plan.md`)
|
||||
│ ├── Dispatch/
|
||||
│ │ ├── init.luau # process 엔진 — `chains`(inst,k별 인덱스 배열, 슬롯마다 {handler, retractor}) + 하강 diff(핸들러가 같으면 그 자리 클로저에 새 값을 넘기고 재process, 다르면 그 자리부터 retractFrom) + 3-인자 `retractFrom(inst,k,index)` (`dispatch-core-plan.md` "Dispatch 체인" 절, 2026-08-08 신설 → 2026-08-13 다섯 번째 세션 인덱스화 → 같은 날 열네 번째 세션 하강 diff)
|
||||
│ │ ├── Handler.luau # 핸들러 계약 타입(isHandlable/priority/process — process가 자기 retract 클로저를 반환)
|
||||
|
|
@ -290,8 +291,8 @@ quad/
|
|||
└── quad-roblox/
|
||||
├── 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`) (`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는 엔진 op" 절)
|
||||
├── RobloxFactory.luau # BaseModule 뮤테이션, 재호출 가드(같은 팩토리=무시/다른=에러) — 주입 대상 목록은 아래 EngineOps.luau 줄이 소스 — 여기서 다시 나열하지 않는다(**[2026-08-22]** 예전엔 addTag/removeTag/setAttribute까지만 적혀 있어 native*/setTimeout이 빠져 있었음). bindLifetime/canBound/canExecute도 같은 경로로 주입됨
|
||||
├── 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은 둘 다 씀). 미주입이면 에러가 아니라 **조합 폴백**. **[2026-08-22 추가] 시간 op 둘** — setTimeout(func, delay) -> Timeout / clearTimeout(t), Roblox는 task.delay/task.cancel로 배선(**인자 순서가 반대라 주의**); `Debounce`/`Throttle`이 얹힐 때 필요하고 그 전엔 미주입이어도 무방(`base/debounce-throttle-plan.md`). **이 줄이 주입 op 전체 목록의 단일 소스다** — 다른 문서는 개수를 세지 말고 여기를 가리킬 것 (`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`)
|
||||
|
|
@ -322,7 +323,7 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
|
|||
프리미티브 타입 자신의 공개 어휘**라는 것:
|
||||
1. 프리미티브 타입 생성자, `Type(args)` 스타일: `Source(default)`/
|
||||
`Ref(default)`/`Store({defaults})`/`Modifier()`/`Relate()`/
|
||||
`Effect(fn, state?)`/`PreRef(default)`/`PostRef(default)`.
|
||||
`Effect(fn, ...deps)`/`PreRef(default)`/`PostRef(default)`.
|
||||
2. 그 인스턴스의 콜론 메서드: `state:Get()`/`:With(...)`/`:Compute(fn)`/
|
||||
`:Observer(fn)`/`:Apply(factory)`/`:Peek(key)`, `source:Set(v)`/`:Emit()`,
|
||||
`ref:Set(v)`/`:Callback(fn)`/`:Wait(thread?)`, `observer:Subscribe()`/
|
||||
|
|
@ -354,9 +355,11 @@ existing-instance-bind는 **[2026-08-14 세션] 기각되어 `archive/`로
|
|||
`isPostRef`/`isModifier`/`isObserver`/... `Brand` 절), 생명주기 게이트(`canExecute`/
|
||||
`bindLifetime`, `base/lifecycle-pattern.md`), 그리고 **프리미티브가
|
||||
아닌** 내부 엔진/레지스트리의 네임스페이스 멤버(`Dispatch.process`/
|
||||
`getHandler`/`addHandler`/`drive`, `Brand.set`/`get`) — 이 셋은 "타입
|
||||
`getHandler`/`addHandler`/`drive`, `Brand()`의 `:register`/`:is`) — 이 셋은 "타입
|
||||
고유의 어휘"가 아니라 여러 타입에 걸쳐 쓰이거나(`isX`류) 프리미티브
|
||||
자체가 아닌 것(Dispatch/Brand는 `Type(args)` 생성자가 없는 내부 엔진)의
|
||||
자체가 아닌 것(Dispatch는 `Type(args)` 생성자가 없는 내부 엔진이고,
|
||||
`Brand`는 생성자가 있지만 사용자 표면이 아닌 base 내부 유틸 — 사용자에게
|
||||
노출되는 건 `isX` wrapper들이다)의
|
||||
구성원이라 PascalCase 대상이 아님. Handler 계약 필드(`isHandlable`/
|
||||
`priority`/`process` — 2026-08-13 다섯 번째 세션에 `retract`가 `process`의
|
||||
반환값으로 합쳐지기 전엔 4종이었음)도 여기 속함 — 이건 애초에 "함수"라기보다
|
||||
|
|
@ -449,10 +452,15 @@ push-invalidate(신호만)/
|
|||
pull-recompute(`Get()` 시점) — Fusion식 eager 노드 없이도 다이아몬드
|
||||
의존성 중복 재계산 문제가 풀림(**[2026-08-14 보강]** 푸는 주체는
|
||||
**노드별 캐시**임을 명시 — `invalid`는 "내 캐시가 낡았다" 표시일 뿐이고
|
||||
**emit 전파는 자기 `invalid` 상태와 무관하게 항상 일어남**. 전파를 늦추는
|
||||
건 `Blocker` 같은 명시적 게이트뿐. 한때 `source-state-plan.md`가 "이미
|
||||
`invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
|
||||
`archive/invalidate-dedup-propagation-reversed.md`). State는 쓰기 대상이 아니고, 값을 쓰는
|
||||
**emit 전파는 자기 `invalid` 상태와 무관함**. 한때 `source-state-plan.md`가
|
||||
"이미 `invalid`면 전파 중단"으로 서술했으나 `Observer` 계약과 모순돼 역전됨 —
|
||||
`archive/invalidate-dedup-propagation-reversed.md`. **[2026-08-21 갱신]**
|
||||
전파를 접는 판정은 이제 `invalid`가 아니라 **`Epoch` 리비전 비교**가 한다 —
|
||||
같은 `Epoch`의 같은 리비전이 두 경로로 도착하면 두 번째는 접히고(다이아몬드에서
|
||||
값도 통지도 한 번), DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch도 같이
|
||||
사라진다. 그 부기는 재사용 가능한 **`EpochMap`**으로 떼어져 있어 State가 아닌
|
||||
소비자(`Effect`)도 같은 판정을 쓴다. 규칙 전량은 `base/state-epoch-plan.md`,
|
||||
명시적 게이트는 `base/gate-plan.md`(`state:Gate`)와 그 위의 `Blocker`). State는 쓰기 대상이 아니고, 값을 쓰는
|
||||
경로는 `source:Set(value)`(Source가 State보다 넓은 인터페이스를 가짐 —
|
||||
`:Get()`/`:With`/`:Compute` 위에 `:Set`/`:Emit` 추가; [정정, 2026-08-07]
|
||||
읽기는 `:Get()` 하나로 통일 — 프로퍼티 읽기 표기는 Ref의 `.Value` 전용으로 좁혀짐. **[표기 정정, 2026-08-18]** 여기 소문자 `.value`로 적혀 있었음). 값 하나만
|
||||
|
|
|
|||
|
|
@ -239,9 +239,28 @@ end
|
|||
그룹 `Attribute` 값은 여러 곳에서 쓸 수 있어야 한다.
|
||||
- **확정된 방향**: 위치별 claim 레지스트리를 하나 더 둬서 **같은 그룹
|
||||
객체가 같은 위치 집합을 이중 점유하는 것만** 잡는다.
|
||||
- **미정 — 구현 전에 정할 것**: 그 claim의 키를 무엇으로 할지
|
||||
(`(inst, groupValue) → k`인지 `groupKey` 단위인지), 그리고 기존
|
||||
`nameClaims`와 어떻게 공존하는지. `question.md`에 올려둠.
|
||||
- **[해소, 2026-08-20 구현 전 QA 4라운드 `AT-11`] 이름은 `groupClaimKeys`로
|
||||
확정**(사용자: *"groupClaimKeys 정도로 확정. 더 나은 답이 있다면 적어주길
|
||||
바람. 다만 충분하다고 생각하는 이름임."*). `nameClaims`(이름 → 그 이름을
|
||||
잡은 키 객체)와 나란히 놓이는 두 번째 레지스트리이고, 이름 자체가
|
||||
"그룹이 claim한 키들"을 가리켜 역할이 드러남.
|
||||
- **✅ [해소, 2026-08-21 구현 전 QA 5라운드 `AT-1`] 키는 `(inst, groupValue) → k`로
|
||||
확정.** **사용자 판정**: *"(inst, groupValue) → k 이면 충분하다. group 에 따라
|
||||
key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name
|
||||
이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만
|
||||
위함임."*
|
||||
- **왜 이걸로 충분한가**: 이 레지스트리가 잡아야 하는 건 **같은 그룹 객체가
|
||||
같은 `inst`의 두 위치에 놓이는 것** 하나뿐이다. 서로 다른 그룹끼리의 충돌은
|
||||
`groupKey(v, name)`가 그룹별로 다른 키 객체를 만들고 그 키의 이름 claim을
|
||||
`nameClaims`가 검증하므로 이미 걸린다 — 여기서 다시 볼 필요가 없다.
|
||||
- **판정**: `process` 시점에 `groupClaimKeys[inst][groupValue]`를 보고, 비어
|
||||
있으면 `k`를 기록하고 통과, **이미 다른 `k`가 있으면 error**. retract에서
|
||||
자기 `k`일 때만 지운다(위 "해제 → 재클레임 순서" 항목의 순서 보장 위에서
|
||||
성립).
|
||||
- **`nameClaims`와의 순서**: 위치 claim이 **먼저**다 — 같은 그룹의 두 번째
|
||||
위치는 이름 claim까지 갈 것 없이 그 자리에서 거부되어야 하고, 그래야
|
||||
`nameClaims`에 절반만 기록되는 중간 상태가 안 생긴다.
|
||||
- 이걸로 `Frame { a, a }` 갭(구 `AT-11`/5라운드 `AT-2`)도 **같이 닫힌다.**
|
||||
- **`Tag`는 왜 다른가**: `Tag`는 같은 객체를 여러 위치에서 재사용하는 게
|
||||
**정상 관례**이고 위치(`k`) 기준 참조 카운트로 안전하다(`base/tag-plan.md`)
|
||||
— 자원이 "이름 집합"이라 겹쳐도 합집합이면 되기 때문. 그룹
|
||||
|
|
@ -298,9 +317,16 @@ Store 필드 여러 개를 각각 `[AttributeKey<<T>> "name"] = store.name`으
|
|||
|
||||
```
|
||||
Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음
|
||||
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] plain 테이블의 값은 raw T뿐 아니라
|
||||
-- State<T>/Source<T>도 그대로 됨 — {count = 3, label = someSource, live = state}
|
||||
-- 새 배선이 아니라, 이 값들이 결국 단일 키 경로로 위임돼 StoreBind가 언랩하기 때문
|
||||
Attribute.Merged(a, b, ...): Attribute -- 합성, 이름이 겹치면 error
|
||||
Attribute.Overridden(a, b, ...): Attribute -- 합성, 이름이 겹치면 조용히 뒤가 이김
|
||||
attr:NameMap(): {[string]: Source<any>} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것)
|
||||
-- [명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 자신의 이름 겹침 정책:
|
||||
-- Attribute(a, b)에서 a와 b가 같은 이름을 가지면 **뒤에 온 인자가 이긴다**
|
||||
-- (= Overridden과 같은 정책, error 아님). Merged의 "겹치면 error"는 그 함수만의
|
||||
-- 정책이고 생성자엔 적용되지 않음 — 겹침을 막고 싶으면 Attribute.Merged를 쓸 것
|
||||
Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일)
|
||||
```
|
||||
|
||||
|
|
@ -324,6 +350,15 @@ Overridden 을 제공하면 될것 같음. 전자는 에러를 내주고, 후자
|
|||
시점 1회 체크라 싸다). 이 문서의 다른 결정들(이름 claim 충돌 = 즉시
|
||||
error)과 결이 같음.
|
||||
- `Attribute.Overridden(a, b, ...)` — 겹치면 **조용히 뒤가 이김**.
|
||||
- **[명시 추가, 2026-08-20 구현 전 QA 4라운드] 생성자 `Attribute(a, b, ...)`
|
||||
자신도 "뒤가 이김"이다** — 사용자 확인 요청(*"그리고 뒤에 오는기 이기는 것
|
||||
또한, 잘 명시되어있나 봐야함"*)으로 점검한 결과, 위 API 목록이 `Merged`/
|
||||
`Overridden`의 정책만 적어두고 **생성자 자체의 겹침 정책은 어디에도 안
|
||||
적혀 있었다**. `Frame { Tag("a"), Tag("a") }`처럼 배열에 여러 개 나란히
|
||||
놓는 것과 달리 생성자는 인자들을 **하나의 이름 맵으로 평탄화**하므로 겹침
|
||||
판정이 반드시 필요하고, 배열 순서 규칙(`base/modifier-plan.md`의 "Merge
|
||||
우선순위" — 나중 것이 우선)과 결을 맞춰 **뒤가 이기는 것**으로 명시한다.
|
||||
겹침을 실수로 보고 싶으면 `Attribute.Merged`를 명시적으로 쓰면 된다.
|
||||
- **⚠️ 이 이름 쌍의 의미가 코퍼스 전체에서 재정렬된다** — 지금까지
|
||||
`Merged`(=무손실 합집합, `Tag`)와 `Overridden`(=필드 단위 덮어쓰기,
|
||||
`Modifier`)은 **연산의 종류**를 가르는 이름이었는데, `Attribute`에선
|
||||
|
|
@ -400,12 +435,22 @@ end
|
|||
그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회
|
||||
실행"이 같은 값으로 `setAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미
|
||||
"값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목)
|
||||
결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **체인이
|
||||
그룹 전용이 된 지금은 이론상 "생존 이름은 그냥 다시 `Dispatch.process`만
|
||||
불러 하강 diff에 맡기는" 최적화도 가능하지만**(다른 소유자를 건드릴
|
||||
위험이 없어졌으므로), 그러려면 옛 이름 집합을 `(inst,위치)`별로 또
|
||||
들고 있어야 해서 부품이 늘어남 — **기본은 균일 철거 유지**, 최적화는
|
||||
실제로 비용이 문제될 때 재검토.
|
||||
결이 같고, `setAttribute`는 같은 값 재기록이 관측상 무해함. **[전면 정정, 2026-08-20 구현 전 QA 4라운드 `AT-20`] "체인이 그룹
|
||||
전용이 된 지금은 이론상 최적화도 가능하지만 부품이 늘어나서 안 한다"는
|
||||
옛 서술은 틀렸다 — 애초에 최적화가 성립하지 않는다.** 사용자 판정:
|
||||
*"생존 이름에 대해서 최적화 불가함. 실 value 자체가 바뀌고 같은 값인지
|
||||
비교를 해야하는데, 그러면 값을 진짜 까봐야 하고, 이전 값을 알아야하기
|
||||
때문. 이름 목록의 변경으로 최적화가 되는 요소가 아님."*
|
||||
- **`Tag`와 결정적으로 다른 지점**: `Tag`는 자원이 **이름 집합**이라
|
||||
"이름이 그대로면 할 일 없음"이 성립하지만, `Attribute`는 이름이 그대로여도
|
||||
**그 이름에 붙은 값이 바뀌었을 수 있다** — 이름 목록 비교만으로는 아무것도
|
||||
못 건너뛴다.
|
||||
- **값을 비교하려면 `:Get()`을 해야 하고 그건 금지**(위 "값 비교(`:Get()`으로
|
||||
old/new 비교)는 안 함" 항목) — State 계약상 캐시 비교를 하면 안 되고,
|
||||
비교하려면 이전 값까지 따로 들고 있어야 한다. 즉 "부품이 늘어난다"가
|
||||
아니라 **설계 원칙 위반이라 애초에 못 한다**.
|
||||
- 그래서 **균일 철거→재등록이 유일한 경로**이고, 비용(재구독 + 같은 값
|
||||
`setAttribute` 1회)은 수용한다.
|
||||
- **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가
|
||||
자기 키 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진
|
||||
이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없음.
|
||||
|
|
|
|||
|
|
@ -56,7 +56,8 @@ Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치
|
|||
store-bind 가능(`None`/`nil`로 disconnect) → **`base/event-plan.md`**. 단 이벤트
|
||||
*네이밍* 관례는 인스턴스 생성과 한 절에 섞여 있어 아래 "인스턴스 생성 /
|
||||
이벤트 네이밍 인체공학" 절에 그대로 있음.
|
||||
- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`,
|
||||
- **`Brand`** — 런타임 nominal 타입 판별 통합 메커니즘(**[2026-08-21 재작성]**
|
||||
인스턴스 브랜드 `Brand()` + `:register`/`:is`, 다중 태깅 허용,
|
||||
`isState`를 branded 타입 전부로 일반화) → **`base/brand-plan.md`**.
|
||||
- **`Tag` / `Attribute` 특수 키** → **`base/tag-plan.md`** /
|
||||
**`base/attribute-plan.md`**. 이 문서가 예전에 다루던 타입 파라미터화 문제
|
||||
|
|
|
|||
|
|
@ -18,12 +18,17 @@
|
|||
|
||||
**store 개발(M3)과 밀접하게 연관됨** — `state:Block(blocker)`가 State
|
||||
위에 얹히는 메소드이므로 `base/source-state-plan.md`의 Source/State
|
||||
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함. 별도 파일로 두되
|
||||
State와 같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것.
|
||||
온톨로지, 특히 push-invalidate/pull-recompute 전파 모델(`base/source-state-plan.md` "전파 모델 확정" 절)을 전제로 함.
|
||||
**⚠️ [2026-08-22 정정] 구현 마일스톤은 M3가 아니라 M2다** — 여기 "State와
|
||||
같은 마일스톤(`ROADMAP.md` M3)에서 함께 구현할 것"이라고 적혀 있었으나,
|
||||
`Dispatch.drive`의 배치 등록이 `Blocker`를 호출하므로 "게이팅 먼저" 결정에
|
||||
따라 `Blocker.luau` 체크박스가 M2로 이동했다. 별도 파일로 두는 것은 그대로.
|
||||
**그리고 이제 `Blocker`는 바닥부터 짜는 게 아니라 공용 `GateNode`
|
||||
(`base/gate-plan.md`) 위에 얹는 정책이다** — 노드를 다시 만들지 말 것.
|
||||
|
||||
**[2026-08-14 위치 명문화]** `Blocker`는 **quad에서 emit(무효화 신호) 전파를
|
||||
지연시킬 수 있는 유일한 요소**임. 평범한 State는 신호를 받으면 자기
|
||||
`invalid` 상태와 무관하게 **항상** 아래로 전파하고(`base/source-state-plan.md`
|
||||
`invalid` 상태를 이유로는 **절대 전파를 접지 않고**(`base/source-state-plan.md`
|
||||
"전파 모델 확정" 절), 그 흐름을 붙잡아둘 수 있는 건 명시적으로 배선된
|
||||
게이트뿐 — 지금은 `Blocker`가 유일하고, 시간 기반 게이트(`base/
|
||||
debounce-throttle-plan.md`, 설계 확정·구현은 아직)가 추가되면 같은 자리에
|
||||
|
|
@ -67,9 +72,28 @@ gated state의 동작:
|
|||
`OffWithoutEmit()`은 "밀린 전파를 버리며 끈다" — 어느 쪽이든 대기
|
||||
상태(`HasBlockedEmit`)는 항상 깨끗하게 리셋됨.
|
||||
|
||||
**⭐ [2026-08-21] `HasBlockedEmit`은 게이트 흡수 집합의 특수형이다.**
|
||||
`GateNode`가 드는 `withheld`(이번에 유보한 소스들)에 대해
|
||||
`HasBlockedEmit == (next(withheld) ~= nil)`이고, 아래 "밀린 전파가 없으면
|
||||
아무 것도 안 함(idempotent)"이 게이트 층위에서는 **"빈 배치면 통지 자체를
|
||||
안 한다"**로 일반화된다(`base/gate-plan.md`의 8번). 구현 시 두 개를 따로
|
||||
들지 말 것.
|
||||
|
||||
**⭐ [2026-08-21 신설] `state:Block(blocker)`는 `state:Gate(setup)` 위에
|
||||
얹힌다.** 위 "gated state의 동작"은 `Blocker`만의 특수 노드가 아니라
|
||||
`base/gate-plan.md`가 확정한 **`GateNode`**(`ComputeNode`와 같은 층위)의
|
||||
정책 하나다 — `Block`이 내부에서 `self:Gate(policy)`를 부르고, 그 `policy`가
|
||||
`blocker.IsBlocked`를 보고 `emit()`을 부를지 `HasBlockedEmit`만 세울지
|
||||
정한다. `Debounce`/`Throttle`도 같은 자리에 다른 정책으로 들어간다.
|
||||
**공개 표면(`Blocker()` 생성자와 `state:Block`)은 안 바뀐다** — 배선만
|
||||
공용 노드를 쓰는 것.
|
||||
|
||||
**`:Get()`엔 영향 없음** — 블록은 emit **전파**만 지연시킨다. 블록 중이라도
|
||||
누군가 명시적으로 `:Get()`하면 그 순간의 실제 값을 정상적으로 계산해서
|
||||
준다 — `base/source-state-plan.md`의 "Source 값을 직접 mutate한 뒤 전파 — `:Emit()`" 절("`Get()`은 라이브 레퍼런스를 준다" 캐비엇)과 일치.
|
||||
**[2026-08-21] 이 계약은 `base/state-epoch-plan.md`가 의존하는 전제다** — 그
|
||||
문서 §5의 3번이 "게이트를 에포크 경계로 만드는" 대안을 기각한 이유가 정확히
|
||||
이걸 뒤집지 않기 위해서다. 바꾸려면 그쪽도 같이 봐야 한다.
|
||||
|
||||
## 사용 예시
|
||||
|
||||
|
|
@ -139,6 +163,16 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
|
|||
충분하다고 판단됐기 때문(처음 제안됐던 "`HasBlocked`"는 이 논의
|
||||
과정에서 자연스럽게 불필요해짐 — `qa-request/pre-implementation-qa-round2.md`
|
||||
"RC-1" 절에 논의 경위 기록).
|
||||
**[보강, 2026-08-20 구현 전 QA 4라운드 `BK-9`] "영원히 안 만든다"로 못박은
|
||||
건 아니다 — 지금 사용 케이스가 없을 뿐인 백로그다.** 사용자 판정: *"있는게
|
||||
어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임 … 나중에 사용 필요 요구가
|
||||
나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가
|
||||
신설될 가능성이 존재하지 않는다고 못 박기는 이름."* 즉 (a) 구현 난이도가
|
||||
낮고, (b) 신설된다면 이름은 `HasBlocked`가 아니라 **`HasBlockedState`**
|
||||
쪽이 될 가능성이 높으며(그 Blocker에 걸린 gated state 중 대기 중인 게
|
||||
있는가 = "블록된 state가 있는가"라 이름이 더 정직함), (c) 실제 요구가
|
||||
나오기 전엔 안 만든다 — `conventions.md`의 "드문 오용이나 가상의 미래
|
||||
요구까지 방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙 그대로.
|
||||
|
||||
## 재진입(네스팅) — 의도적으로 미지원, 강한 문서화 필수
|
||||
|
||||
|
|
@ -157,7 +191,9 @@ blocker:Off() -- onunblock 핸들 실행 → HasBlockedEmit 확인 → 딱 한
|
|||
**base 내부 용례에도 이 규칙이 그대로 적용된 실제 사례(2026-08-18)** —
|
||||
위 "`state:Block()` 없이 직접 쓰는 두 번째 용례" 절의 Length/Offset
|
||||
배치 게이팅에서, 중첩된 Slot(부모 Slot 안의 자식 Slot)이 `attachSlot`을
|
||||
재귀할 때마다 **그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
|
||||
재귀할 때마다(**[2026-08-21] 분해 후 정확히는 그 안의
|
||||
`materializeSlotTree`** — 물리 마운트 쪽은 Blocker가 필요 없다)
|
||||
**그 자식 Slot 자신의 owner 키로 새 `Blocker`를 만든다** —
|
||||
부모 Slot의 Blocker를 재사용하지 않음(사용자 확정: *"중첩마다 별도
|
||||
Blocker (권장)"*). 부모/자식이 같은 Blocker를 공유했다면, 자식의
|
||||
`OffWithoutEmit()`이 부모가 아직 배치 중인데도 그 자리에서 즉시 꺼버려
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@
|
|||
|
||||
> **[2026-08-13 아홉 번째 세션] `bind-system-plan.md`에서 분리됨.**
|
||||
> 자기 완결적인 유틸이라 디스패치 코어와 같은 파일에 있을 이유가 없었음.
|
||||
> **내용은 옮기기만 했고 결정은 하나도 안 바뀜.**
|
||||
>
|
||||
> **[2026-08-21 전면 재작성] 공유 레지스트리 + `Brand.get(x) -> tag`(객체당
|
||||
> 태그 하나)에서 **인스턴스 브랜드**(`Brand()` + `:register`/`:is`, 다중 태깅
|
||||
> 허용)로 바뀜.** 역전 원문은 `archive/brand-shared-registry-reversed.md`,
|
||||
> 근거 기록은 `reference/epoch-brand-composition.md`. **뒤집힌 건 API 표면
|
||||
> 하나뿐**이고 weak-key 레지스트리·테이블 아이덴티티·duck-typing 기각 근거·
|
||||
> predicate 합성은 전부 그대로다.
|
||||
|
||||
**상태**: base — 동작/구현 방식은 확정, **이름 `Brand` 자체만 용어 정리
|
||||
대기**(`question.md` 1번).
|
||||
|
|
@ -22,62 +28,117 @@ Store인가/Tag인가" 판별, 또는 PropertyHandler의 `process` 내부에서
|
|||
통합(component-composition-plan.md 4번 절이 이미 "`isSource`류 판별자로
|
||||
(`isObserver`와 동일한 패턴)"라고 이 방향을 예견해뒀던 것과 맞아떨어짐).
|
||||
|
||||
**구현: 공유 weak-key 레지스트리 하나 + 테이블 아이덴티티를 태그로
|
||||
사용(문자열 아님).**
|
||||
**존재 이유 한 줄(2026-08-20 구현 전 QA 4라운드 `B-4`, 사용자 정리)**:
|
||||
**`Brand`는 데이터 타입에 부작용을 남기지 않고 런타임 명시 타이핑을 하기
|
||||
위한 것이다.** 값 자체엔 아무것도 안 심고(외부 weak 레지스트리), 판별이
|
||||
읽기 부작용도 안 만든다 — 아래 duck-typing 기각 근거 두 개가 정확히 이
|
||||
한 줄에서 나온다.
|
||||
|
||||
```
|
||||
local Brand = {}
|
||||
local registry = setmetatable({}, {__mode = "k"})
|
||||
## ⭐ 구현 — 인스턴스 브랜드, 브랜드마다 자기 weak 집합 하나 (2026-08-21 확정)
|
||||
|
||||
function Brand.set(x, tag) registry[x] = tag end
|
||||
function Brand.get(x) return registry[x] end -- nil이면 quad가 모르는 값
|
||||
**`Brand()`가 브랜드 객체 하나를 만든다.** 그 객체가 weak-key 집합 하나를
|
||||
들고, 값은 **자기가 속한 브랜드에 스스로 등록**한다.
|
||||
|
||||
-- 각 브랜드는 고유 테이블(빈 테이블이어도 됨) — 문자열 리터럴 아님
|
||||
local ObserverTag, EffectTag, TagTag, AttributeTag, TweenTag, BlockerTag,
|
||||
StateTag, SourceTag, StoreTag, SlotTag, RefTag, PreRefTag, PostRefTag,
|
||||
ModifierTag =
|
||||
{}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
|
||||
```lua
|
||||
local function Brand()
|
||||
local members = setmetatable({}, {__mode = "k"})
|
||||
return {
|
||||
register = function(self, x) members[x] = true end,
|
||||
is = function(self, x) return members[x] == true end,
|
||||
}
|
||||
end
|
||||
|
||||
-- 각 타입이 자기 브랜드를 하나씩 소유
|
||||
local ObserverBrand, EffectBrand, TagBrand, AttributeBrand, TweenBrand,
|
||||
BlockerBrand, StateBrand, SourceBrand, StoreBrand, SlotBrand,
|
||||
RefBrand, PreRefBrand, PostRefBrand, ModifierBrand, EpochBrand =
|
||||
Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand(),
|
||||
Brand(), Brand(), Brand(), Brand(), Brand(), Brand(), Brand()
|
||||
|
||||
-- 각 타입의 모든 생성 지점(Observer(...), Source(...), :With(...), Tag(...) 등)에서:
|
||||
Brand.set(newHandle, ObserverTag)
|
||||
ObserverBrand:register(newHandle)
|
||||
```
|
||||
|
||||
**문자열 대신 테이블 아이덴티티를 태그로 쓰는 이유(사용자 제안)** —
|
||||
Luau의 인터닝된 문자열 비교도 이미 사실상 O(1) 포인터 비교라 성능 차는
|
||||
무시할 만하지만, **오타 안전성**이 실질적 이득: 태그가 오타난 문자열
|
||||
리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데, 테이블
|
||||
레퍼런스는 잘못된 변수를 참조하면 즉시 드러나거나 최소한 진짜 다른 값이
|
||||
되어 헷갈릴 여지가 없음.
|
||||
|
||||
**`isX`는 `Brand`를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** —
|
||||
단순 항등인 경우(`isObserver(x) = Brand.get(x) == ObserverTag`)와, 상위
|
||||
관계(subtype)가 있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우
|
||||
(`isState`/`isRef`)로 갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를
|
||||
"집합 멤버십"(`t == A or t == B`, 플랫한 셋 체크)으로 구현하던 방식을
|
||||
"더 구체적인 predicate를 먼저 정의하고 그 위에 얹는" 합성 방식으로
|
||||
재정리 — 동작은 동일하지만, 어느 predicate가 다른 predicate를 내포하는지
|
||||
(포함 관계의 방향)가 코드 모양 자체에 드러나게 함:
|
||||
**⭐ 다중 태깅이 이 설계의 존재 이유다.** 한 값이 **여러 브랜드에 동시에**
|
||||
속할 수 있다 — 실제로 그게 필요한 자리가 있다:
|
||||
|
||||
```lua
|
||||
-- Source는 Source이면서 동시에 Epoch다 (base/state-epoch-plan.md)
|
||||
SourceBrand:register(source)
|
||||
EpochBrand:register(source)
|
||||
```
|
||||
|
||||
옛 모양(`Brand.get(x) -> tag`, 객체당 태그 하나)으로는 이걸 표현할 수 없었고,
|
||||
그게 재작성의 직접 발단이다 — `archive/brand-shared-registry-reversed.md`.
|
||||
|
||||
**부수 이득 — 외부 확장이 열린다.** `Source`가 아닌 원천(외부 시계 등)이
|
||||
`Epoch`로 참여하고 싶으면 `EpochBrand:register(self)` 한 줄이면 되고,
|
||||
`isEpoch` 구현을 고칠 필요가 없다. 사용자 논거: *"본인이 거기 속하면, 본인이
|
||||
직접 해당 브랜드를 가져와 등록하면 … `isXXXX`에서 각각의 구현을 넣을 필요가
|
||||
없어짐. 따라서 외부 확장도 쉬워진다."*
|
||||
|
||||
**역조회는 없다** — "이 값이 대체 무엇인가"를 되묻는 창구는 제공하지 않는다.
|
||||
코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 **멤버십 질문**뿐이고, 역조회를 하는
|
||||
자리는 전수 조사에서 하나도 없었다(사용자 확인: *"확실히 의미가 없어진것 같습니다
|
||||
필요하진 않아요."*).
|
||||
|
||||
**브랜드 아이덴티티가 곧 테이블 레퍼런스다 — 문자열 태그를 안 쓰는 이유는
|
||||
그대로 유효(사용자 제안)**: Luau의 인터닝된 문자열 비교도 이미 사실상 O(1)
|
||||
포인터 비교라 성능 차는 무시할 만하지만, **오타 안전성**이 실질적 이득 —
|
||||
태그가 오타난 문자열 리터럴("Oberver")이면 등록/조회 양쪽이 조용히 어긋나는데,
|
||||
잘못된 브랜드 **변수**를 참조하면 즉시 드러나거나 최소한 진짜 다른 집합이
|
||||
되어 헷갈릴 여지가 없음. 새 모양에선 이게 더 강해진다 — 브랜드가 값이라
|
||||
`nil`을 인덱싱하면 그 자리에서 에러가 난다.
|
||||
|
||||
**메소드 이름이 소문자인 건 이 유틸의 기존 관례를 잇는 것**(`Brand.set`/
|
||||
`Brand.get`이 그랬다). quad 공개 표면의 PascalCase 메소드 관례(`:Get`/`:Set`/
|
||||
`:With`)와 다른데, `Brand`는 사용자가 직접 부르는 프리미티브가 아니라 base
|
||||
내부 유틸이고 사용자에게 노출되는 건 `isX` wrapper들이다 — 이름 자체가
|
||||
용어 정리 대기 항목이므로 케이싱도 그때 같이 본다(`question.md` 1번).
|
||||
|
||||
## `isX` wrapper — 포함 관계는 predicate 합성으로 (2026-08-09 열한 번째 세션)
|
||||
|
||||
**`isX`는 브랜드를 직접 노출 안 하고 각자 얇은 wrapper로 감쌈** — 단순
|
||||
항등인 경우(`isObserver(x) = ObserverBrand:is(x)`)와, 상위 관계(subtype)가
|
||||
있어 **더 구체적인 브랜드 체크 위에 OR로 얹는** 경우(`isState`/`isRef`)로
|
||||
갈림. **[정정, 2026-08-09 열한 번째 세션]** 후자를 "집합 멤버십"(플랫한 셋
|
||||
체크)으로 구현하던 방식을 "더 구체적인 predicate를 먼저 정의하고 그 위에
|
||||
얹는" 합성 방식으로 재정리 — 동작은 동일하지만, 어느 predicate가 다른
|
||||
predicate를 내포하는지(포함 관계의 방향)가 코드 모양 자체에 드러나게 함:
|
||||
|
||||
```lua
|
||||
local function isSource(x)
|
||||
return Brand.get(x) == SourceTag
|
||||
return SourceBrand:is(x)
|
||||
end
|
||||
local function isState(x)
|
||||
return isSource(x) or Brand.get(x) == StateTag -- Source가 State를 구조적으로 만족
|
||||
return isSource(x) or StateBrand:is(x) -- Source가 State를 구조적으로 만족
|
||||
end
|
||||
|
||||
local function isPreRef(x)
|
||||
return Brand.get(x) == PreRefTag
|
||||
return PreRefBrand:is(x)
|
||||
end
|
||||
local function isPostRef(x) -- [2026-08-14 아홉 번째 세션] PostRef 확정
|
||||
return Brand.get(x) == PostRefTag
|
||||
return PostRefBrand:is(x)
|
||||
end
|
||||
local function isRef(x)
|
||||
-- PreRef/PostRef가 Ref 런타임을 재사용 = 둘 다 Ref의 한 종류
|
||||
return isPreRef(x) or isPostRef(x) or Brand.get(x) == RefTag
|
||||
return isPreRef(x) or isPostRef(x) or RefBrand:is(x)
|
||||
end
|
||||
```
|
||||
|
||||
**⭐ 다중 태깅이 가능해져도 포함 관계는 계속 이 합성으로 쓴다.** *"`Source`를
|
||||
`StateBrand`에도 같이 등록하면 `isState`가 한 줄이 되지 않나"*는 하지 않는다 —
|
||||
등록 지점이 여러 곳에 흩어지면 "어느 브랜드에 등록하는 걸 빠뜨렸나"가 조용한
|
||||
버그가 되고, 포함 관계가 코드 모양에서 사라진다. **각 타입은 자기 브랜드에만
|
||||
등록하고, 포함 관계는 predicate 한 곳에 쓴다.** 사용자 확인: *"여전합니다.
|
||||
`PreRefBrand` 가 존재할테니. 거기에 `is()` 를 해서, 코드에 전부 드러나는거
|
||||
똑같습니다."*
|
||||
|
||||
- **예외는 "구조적 인터페이스"뿐** — `Source`를 `EpochBrand`에 같이 등록하는
|
||||
건 포함 관계가 아니라 **서로 다른 축의 계약을 동시에 만족**하는 것이라
|
||||
합성으로 표현할 수가 없다(`isEpoch`가 `isSource`를 알아야 할 이유가 없고,
|
||||
`Source`가 아닌 원천도 참여해야 한다). 이게 다중 등록의 정당한 용례다.
|
||||
|
||||
**정정 — `isSource`는 별도로 필요함, 다섯 번째 세션의 "불필요" 서술을
|
||||
뒤집음(2026-08-07 여덟 번째 세션).** 그때는 "State면 충분한 용도"만
|
||||
염두에 뒀지만, `Source`는 State보다 진짜로 더 많은 능력(`:Set`/`:Emit`)을
|
||||
|
|
@ -89,7 +150,7 @@ end
|
|||
문서가 서로 모순돼 있었음). `base/modifier-plan.md`의 "`isState(x): boolean` 필요" 절에 있던 "별도 `isSource` 불필요" 서술은
|
||||
`session/2026-08-07-08-none-sentinel-dispatch-brand.md`에서 이미 정정됨.
|
||||
|
||||
**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 태그 목록에서 빠져있던 것
|
||||
**갭 보강 — `isRef`/`isPreRef`/`isModifier`가 목록에서 빠져있던 것
|
||||
추가(2026-08-07 열 번째 세션), 이후 `isRef`/`isPreRef` 관계 자체가
|
||||
재정정됨(2026-08-09 열한 번째 세션).** 처음엔 `isRef`/`isPreRef`를
|
||||
`isObserver`와 같은 단순 항등으로 두고 서로 배타적인 형제 브랜드로
|
||||
|
|
@ -98,9 +159,9 @@ end
|
|||
**`PreRef`도 "Ref 런타임을 그대로 재사용하는" 관계라 같은 포함
|
||||
방향(상위=Ref, 하위=PreRef)으로 다뤄야 일관적**이라는 지적으로 뒤집힘.
|
||||
|
||||
- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`Brand.get(x) ==
|
||||
PreRefTag`), **`isRef(x)`는 그 위에 `Brand.get(x)==RefTag`를 OR로
|
||||
얹은 상위 개념** — 즉 이제 **`isRef(preRefInstance)`는 `true`.**
|
||||
- **`isPreRef(x)`가 가장 구체적인 항등 체크**(`PreRefBrand:is(x)`),
|
||||
**`isRef(x)`는 그 위에 `RefBrand:is(x)`를 OR로 얹은 상위 개념** — 즉
|
||||
**`isRef(preRefInstance)`는 `true`.**
|
||||
- **`(v=Ref)` children 배열 leaf 매치 핸들러(`Dispatch/Leaf.luau`)는
|
||||
이제 `isHandlable`을 `isRef(v) and not isPreRef(v) and not isPostRef(v)`로
|
||||
명시적으로 좁혀야 함**(**[2026-08-14 아홉 번째 세션]** `PostRef` 확정으로
|
||||
|
|
@ -109,52 +170,60 @@ end
|
|||
명시적으로 말해야 하는 모양으로 바뀜(두 pre-pass 소진이 이미 걸러줘
|
||||
정상 경로에선 거의 안 걸리지만, `base/ref-plan.md`의 두 동적 경로 가드
|
||||
Handler와 이 조합이 같이 "일반 Ref 경로를 절대 타면 안 됨"을 보장).
|
||||
`isModifier`도 같은 단순 항등(`Brand.get(x) == ModifierTag`, 상위 개념
|
||||
없음).
|
||||
`isModifier`도 같은 단순 항등(`ModifierBrand:is(x)`, 상위 개념 없음).
|
||||
- **`PostRef`도 `PreRef`와 완전히 같은 포함 방향** — `Ref` 런타임을 그대로
|
||||
재사용하고 브랜드 태그만 다르므로 `isRef(postRefInstance)`도 `true`.
|
||||
재사용하고 브랜드만 다르므로 `isRef(postRefInstance)`도 `true`.
|
||||
즉 `isRef`는 이제 `{Ref, PreRef, PostRef}` 셋을 통과시키는 상위 개념이고,
|
||||
`isPreRef`/`isPostRef`가 각각 가장 구체적인 항등 — `PreRef`/`PostRef`
|
||||
사이엔 포함 관계가 없음(서로 배타적인 형제).
|
||||
|
||||
**같은 이유로 `isSlot`/`isEffect`도 명시(2026-08-09 세션)** —
|
||||
`Brand.get(x) == SlotTag`/`Brand.get(x) == EffectTag`인 단순 항등
|
||||
predicate, 태그 자체는 원래부터 목록에 있었지만(`SlotTag`) `isX`
|
||||
wrapper로 명시적으로 안 적혀 있던 것을 `base/modifier-plan.md`의
|
||||
`SlotBrand:is(x)`/`EffectBrand:is(x)`인 단순 항등 predicate, 브랜드
|
||||
자체는 원래부터 목록에 있었지만 `isX` wrapper로 명시적으로 안 적혀 있던
|
||||
것을 `base/modifier-plan.md`의
|
||||
"Modifier 필드에 핸들러 계층 값(Ref/PreRef/PostRef/Observer/Effect/Slot/Modifier)이
|
||||
들어오면 즉시 error" 절이 필요로 해서 이번에
|
||||
같이 적음.
|
||||
|
||||
**[정정, 2026-08-18 구현 전 QA] `Brand`는 아무 의존성도 갖지 않는다 —
|
||||
`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 `Brand.get(x)`가 범용
|
||||
introspection 창구 역할까지 겸하려면 `None`도 빠지면 안 되므로 *"`Brand.get`이
|
||||
내부적으로 `x == None`을 먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에
|
||||
레지스트리 조회로 폴백하며, `isNone`이 그 특수 분기의 구현체가 된다고 했다.
|
||||
사용자 판정: *"Brand 는 None 을 참조할 필요는 없음. Brand 자체는 아에
|
||||
의존성 없고, None 도 테깅되는건 맞으나, isNone 대신 필요한 곳에서 v ==
|
||||
None 하면 되는 일, 혹은 isNone 구현 자체를 그렇게 해주면 되는 일."*
|
||||
`None`을 위한 특수 분기를 두지 않는다.** 옛 서술은 판별 창구가 범용
|
||||
introspection을 겸하려면 `None`도 빠지면 안 되므로 *"내부적으로 `x == None`을
|
||||
먼저 확인하는 특수 분기를 하나 두고"* 그 뒤에 레지스트리 조회로 폴백하며,
|
||||
`isNone`이 그 특수 분기의 구현체가 된다고 했다. 사용자 판정: *"Brand 는 None 을
|
||||
참조할 필요는 없음. Brand 자체는 아에 의존성 없고, None 도 테깅되는건 맞으나,
|
||||
isNone 대신 필요한 곳에서 v == None 하면 되는 일, 혹은 isNone 구현 자체를
|
||||
그렇게 해주면 되는 일."*
|
||||
|
||||
- **`Brand → None` 의존을 만들지 않는다** — 특수 분기를 넣는 순간 가장
|
||||
밑바닥 유틸이어야 할 `Brand`가 다른 프리미티브를 참조하게 된다.
|
||||
- **`isNone`은 그냥 `v == None`** — 그런 이름의 함수를 두더라도 구현이
|
||||
레퍼런스 비교 한 줄이면 된다. 싱글턴이라 그게 제일 싸고 정확하다는 판단
|
||||
자체는 그대로 유효.
|
||||
- **`None` 자체를 레지스트리에 평범하게 태깅하는 건 무방**(사용자가
|
||||
허용) — 그러면 특수 분기 없이도 `Brand.get(None)`이 답을 준다. 즉
|
||||
"범용 introspection 창구"를 지키고 싶으면 **특수 분기가 아니라 평범한
|
||||
등록**으로 지킨다. 등록을 안 하기로 하면 `None`은 그 창구에서 빠지는
|
||||
것을 받아들인다 — 어느 쪽이든 `Brand` 쪽 코드는 그대로다.
|
||||
- **`None`을 `NoneBrand`에 평범하게 등록하는 것 자체는 무방**(사용자가
|
||||
허용) — 다만 **[2026-08-21]** 역조회 창구가 없어졌으므로 등록의 유일한
|
||||
효용은 `NoneBrand:is(x)`뿐이고, 그건 `v == None`이 더 싸다. 어느 쪽이든
|
||||
`Brand` 쪽 코드는 그대로다.
|
||||
|
||||
**duck-typing(예: `type(x) == "table" and x.Compute ~= nil`)을 쓰지 않는
|
||||
이유**: `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈 수 있는 임의의
|
||||
값(테이블, Roblox userdata 등)이라 — 우연히 비슷한 모양의 필드/메소드를
|
||||
가진 `T`에 false positive가 나거나, 일부 Roblox userdata는 정의 안 된 키
|
||||
인덱싱 자체에서 에러를 던지므로 duck-typing이 `pcall`로 감싸야 하는 지저분한
|
||||
엔지니어링이 되거나 최악의 경우 그냥 엔진이 죽는 상황까지 생길 수 있음.
|
||||
이유 — 서로 독립된 두 가지(2026-08-20 `B-4`에서 분리 명시)**:
|
||||
|
||||
1. **정확성: false positive.** `Peek`가 돌려주는 `T`는 Modifier 필드에 들어갈
|
||||
수 있는 **임의의 사용자 값**이다. 사용자가 우연히 `Compute`라는 필드를 가진
|
||||
테이블을 넣으면 quad가 그걸 `State`로 오인한다. 브랜드는 quad가 만든 값에만
|
||||
찍히므로 이 오인이 원천적으로 없다.
|
||||
2. **안전성/비용: 인덱싱 자체가 터질 수 있음.** 일부 Roblox userdata는 **정의
|
||||
안 된 키를 인덱싱하는 것만으로 에러를 던진다** — duck-typing을 하려면 판별
|
||||
코드를 전부 `pcall`로 감싸야 하고, 그건 "판별은 부작용 없이 빠르게"라는
|
||||
`isHandlable` 계약(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)과
|
||||
정면으로 부딪힌다. 최악의 경우 엔진이 죽는 상황까지 있다.
|
||||
|
||||
weak-key 조회는 포인터 해싱 한 번이라 `pcall`도, 오인도 없다. 브랜드마다
|
||||
집합이 갈려도 **조회 비용은 그대로 한 번**이고, `isState`처럼 OR로 합성된
|
||||
predicate만 최대 브랜드 수만큼 조회한다(전부 2~3개).
|
||||
weak-key 레지스트리는 rbvm 네임스페이스 추적(`base/lifecycle-pattern.md`)과
|
||||
같은 이미 확정된 패턴 재사용이라 새 아이디어 아님 — weak 키라 등록된 값이
|
||||
GC되면 레지스트리 엔트리도 자동으로 사라짐(살려두는 목적의 강참조
|
||||
레지스트리인 Observer의 `:Subscribe` 레지스트리와는 반대 성격).
|
||||
GC되면 엔트리도 자동으로 사라짐(살려두는 목적의 강참조 레지스트리인
|
||||
Observer의 `:Subscribe` 레지스트리와는 반대 성격).
|
||||
|
||||
**Luau 타입 narrowing은 자동으로 안 됨 — 명시적 `::` 캐스팅 필요(사용자
|
||||
확인, Luau가 원래 그렇게 동작함).** `isX(v)`가 참이어도 Luau 컴파일러가
|
||||
|
|
@ -164,6 +233,10 @@ State<any> ... end`처럼 런타임 검증 뒤 명시적 캐스팅을 붙이는
|
|||
패턴. 여전히 duck-typing/`pcall`보다 훨씬 안전하니 가치는 있음, 다만
|
||||
"자동 narrowing"을 기대하면 안 됨.
|
||||
|
||||
**이름은 전부 가칭 — `Brand`/`ObserverTag`류 포함 용어 정리 대상,
|
||||
`.claude/question.md`에 반영.**
|
||||
**마일스톤**: **커밋된 M1 코드는 아직 `Brand`를 안 쓴다**(`quad-base/src`는
|
||||
`init.luau`/`Relate.luau`/`Debug`뿐, 2026-08-21 확인) — 이 재작성의 전환
|
||||
비용은 문서뿐이었다. 실제 구현은 각 프리미티브가 만들어지는 마일스톤에서
|
||||
같이 간다.
|
||||
|
||||
**이름은 전부 가칭 — `Brand`/`ObserverBrand`류 포함 용어 정리 대상,
|
||||
`.claude/question.md`에 반영.**
|
||||
|
|
|
|||
|
|
@ -194,11 +194,17 @@ leading/trailing/통과 후 창 재개방은 **완전히 동일**. 그래서 공
|
|||
타이머를 (재)시작하려면 그 순간을 알아야 하는데 알 방법이 없어서, 결국
|
||||
게이트 노드 내부 훅이 필요함.
|
||||
|
||||
**그래서 권하는 구현 방향**: `Blocker`의 게이티드 노드를 내부 공용
|
||||
`Gate` 노드로 한 겹 일반화하고(= "상류 신호를 받되 전파 여부를 정책이
|
||||
결정하는 State 노드"), `Blocker`/`Debounce`/`Throttle`이 그 위의 서로 다른
|
||||
정책으로 얹히는 형태. 새 노드 종류를 하나 더 만드는 게 아니라, 이미 하나
|
||||
있는 걸 두 번째 사용처가 생겼으니 이름 붙여 꺼내는 것뿐.
|
||||
**그래서 권하는 구현 방향**: `Blocker`의 게이티드 노드를 공용 게이트 노드로
|
||||
한 겹 일반화하고(= "상류 신호를 받되 전파 여부를 정책이 결정하는 State 노드"),
|
||||
`Blocker`/`Debounce`/`Throttle`이 그 위의 서로 다른 정책으로 얹히는 형태.
|
||||
새 노드 종류를 하나 더 만드는 게 아니라, 이미 하나 있는 걸 두 번째 사용처가
|
||||
생겼으니 이름 붙여 꺼내는 것뿐.
|
||||
|
||||
**[2026-08-21 실현 — 이 권고가 확정됐다]** 그 노드는 `state:Gate(setup)`가
|
||||
만드는 **`GateNode`**이고 **M2**에서 구현된다(`base/gate-plan.md`). 다만
|
||||
"내부 공용"은 아니게 됐다 — **공개 표면**이다. `Debounce`/`Throttle` 쪽
|
||||
관용구는 안 바뀐다: `Debounce{...}`가 돌려주는 팩토리가 내부에서
|
||||
`s:Gate(policy)`를 부르므로 `state:Apply(Debounce{...})`가 그대로 성립한다.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -287,14 +293,20 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
|
|||
|
||||
### 그래서 정정 후 그림 (권고)
|
||||
|
||||
- **emit(무효화 신호)은 구독자에게 항상 전파된다.** State가 이미
|
||||
`invalid`인지는 전파 여부와 무관.
|
||||
- **emit(무효화 신호)은 구독자에게 전파된다.** State가 이미 `invalid`인지는
|
||||
전파 여부와 무관. **[2026-08-21 갱신]** "항상"은 빠졌다 — `Epoch` 리비전
|
||||
비교를 채택하면서 **같은 `Epoch`의 같은 리비전이 두 번째로 도착하면 접힌다**
|
||||
(`base/state-epoch-plan.md`). `invalid`로 접는 것이 금지인 건 그대로.
|
||||
- 다이아몬드 중복 *재계산*은 **pull-recompute가 이미 구조적으로**
|
||||
막음(`architecture.md`의 서술이 맞음) — 별도 장치 불필요.
|
||||
- 한 번의 변경이 여러 경로로 같은 노드에 닿는 **파동 내 중복 순회**가
|
||||
- ~~한 번의 변경이 여러 경로로 같은 노드에 닿는 **파동 내 중복 순회**가
|
||||
실측에서 문제가 되면, 그때 **파동 단위**(방문 집합/에포크 카운터)로
|
||||
접으면 됨 — 이건 의미론에 안 보이는 **순수 구현 최적화**이고, 지금처럼
|
||||
영속 플래그로 하면 안 됨.
|
||||
영속 플래그로 하면 안 됨.~~ **[2026-08-21 실현·정정]** 실제로 채택된 건
|
||||
파동 단위가 아니라 **소스별 영속 에포크**이고(그래서 "영속이면 안 된다"는
|
||||
경고는 `invalid` 플래그에만 해당했던 것으로 좁혀짐), 순수 최적화도 아니라
|
||||
`Get()`이 섞인 값을 돌려주던 것을 고치는 **의미론 변경**이었다 —
|
||||
`base/state-epoch-plan.md`.
|
||||
|
||||
### 이 문서에 미치는 영향 — 세 개가 사라짐
|
||||
|
||||
|
|
@ -318,6 +330,11 @@ quad의 전파 모델은 `base/source-state-plan.md`의 "전파 모델 확정"
|
|||
> 위로 올라가서 받아와서 계산 처리된 게 들어오고 cache가 쓰인 다음
|
||||
> `invalid`가 꺼짐.
|
||||
|
||||
**[2026-08-21 후속]** 위는 2026-08-14 시점 확정 원문이다. 그 뒤 `Epoch` 리비전
|
||||
비교를 채택하면서 "항상"에 예외가 하나 생겼다 — **같은 `Epoch`의 같은 리비전이
|
||||
두 번째로 도착하면 접힌다**(`base/state-epoch-plan.md`). `invalid`로 접는 것이
|
||||
금지라는 이 문단의 요지는 그대로다.
|
||||
|
||||
그리고 "다 다시 써야 한다"는 지시로 **코퍼스 전체 정정을 같은 세션에
|
||||
수행함**. 정정된 모델:
|
||||
|
||||
|
|
@ -562,7 +579,8 @@ h.Value:Flush()
|
|||
> 일부가 아님** — 순수 `luau` CLI엔 `task.delay`/`task.wait`가 아예 없고,
|
||||
> 애초에 이벤트 루프 자체가 없음. 즉 **quad-base는 "동작하는 기본 스케줄러"를
|
||||
> 제공할 수가 없음**(제공할 원시 재료가 없음). 그래서 base가 갖는 건
|
||||
> 알고리즘 + 인터페이스이고, 미배선 상태에선 엔진 op 3개와 같은 관례대로
|
||||
> 알고리즘 + 인터페이스이고, 미배선 상태에선 `addTag`/`setAttribute` 계열
|
||||
> 엔진 op과 같은 관례대로
|
||||
> **명확한 에러를 내는 스텁**이어야 함. `Throttle` 역시 trailing 때문에
|
||||
> "나중에 처리해준다"가 반드시 필요하므로 **디바운스와 똑같이 이 배선에
|
||||
> 의존함** — 스로틀만 타이머 없이 되는 게 아님.
|
||||
|
|
@ -612,8 +630,12 @@ clearTimeout(handle: Timeout): ()
|
|||
인자를 받지만, 게이트의 콜백은 게이트당 하나씩 만들어져 재사용되는
|
||||
안정된 클로저(`onWindowEnd`)라 호출마다 새로 만들 필요가 없음. 즉
|
||||
varargs로 아낄 할당이 애초에 없어서 표면만 넓히는 셈.
|
||||
- **미주입 백엔드에서는 base 스텁이 명확한 에러** — 엔진 op 3개와 동일한
|
||||
관례. 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을
|
||||
- **미주입 백엔드에서는 base 스텁이 명확한 에러** — `addTag`/`setAttribute`
|
||||
계열 엔진 op과 동일한 관례(**[2026-08-22]** 여기 "엔진 op 3개"라고 세어
|
||||
놨었는데, 정작 이 문서가 추가하는 `setTimeout`/`clearTimeout` 자신이 그
|
||||
개수를 늘리는 쪽이라 자기모순이었다. 주입 op 전체 목록의 소스는
|
||||
`base/architecture.md`의 `EngineOps.luau` 줄. **주의**: `native*` 계층은
|
||||
이 관례의 예외로, 미주입이 에러가 아니라 **조합 폴백**이다). 순수 Luau엔 `task`도 이벤트 루프도 없어 base가 "적당한 기본값"을
|
||||
만들어낼 수 없음.
|
||||
- **`Debounce`/`Throttle` 둘 다 이 배선이 있어야 동작함** — 스로틀도
|
||||
trailing 발화가 "창 끝에 다시 처리"라 태스크가 필수. 배선 안 된
|
||||
|
|
@ -916,8 +938,9 @@ function Throttle(opts: ThrottleOptions) return makeGate(false, opts) end
|
|||
|
||||
**(A) emit-gate가 붙는 자리(확정, 4절)**: `gate`는 다른 평범한 State와
|
||||
똑같이 **매 `onUpstreamSignal` 진입 시 자기 `invalid`를 즉시 세운다** —
|
||||
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 "emit은 항상 전파된다"
|
||||
규칙이 게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`가
|
||||
`source-state-plan.md`의 "전파 모델 확정" 절이 정한 전파 규칙(**[2026-08-21]**
|
||||
"항상"이 아니라 "같은 에포크의 두 번째만 접는다" — `base/state-epoch-plan.md`)이
|
||||
게이트 자신에게도 그대로 적용됨. 위 코드의 `gate:passThrough()`가
|
||||
실제로 미루는 건
|
||||
**다운스트림 통지(전파)뿐**이지 invalid 세팅이 아님 — 그래서 창이 열려
|
||||
있는 동안 `gate:Get()`을 불러도 항상 최신값이 계산됨(캐시가 stale한
|
||||
|
|
@ -1100,9 +1123,14 @@ additional-primitives-plan.md`가 원래 "안 만들어도 된다"고 판단했
|
|||
막힘).
|
||||
|
||||
**의존성**: State 코어(`ROADMAP.md` M3) + 백엔드 주입 표면(`setTimeout`/
|
||||
`clearTimeout`) + `Blocker`(gated state) + `Ref`. 전부 M3/M8 안에서
|
||||
확정되는 것들이라 그 이후 언제든 얹을 수 있다. **`Blocker` 구현
|
||||
시점(M3)에 게이트 노드를 공용으로 빼두는 것만은 여전히 그 시점에 해야
|
||||
함** — 1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번
|
||||
설계하게 됨. 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
|
||||
`clearTimeout`) + `Blocker`(gated state) + `Ref`. **[2026-08-22 정정]**
|
||||
각각 **M2**(게이트/`Blocker`) / **M3**(State 코어) / **M8**(`Ref`)에서
|
||||
확정되는 것들이라 그 이후 언제든 얹을 수 있다(옛 표기는 "전부 M3/M8" —
|
||||
`Blocker.luau`가 M2로 옮겨지기 전 기준). **[정정, 2026-08-21 구현 전 QA 5라운드] 그 "게이트 노드를
|
||||
공용으로 빼는" 작업은 M3가 아니라 M2로 앞당겨졌다** — 사용자 결정
|
||||
"게이팅 먼저"(`Dispatch.drive`의 배치 등록이 이미 그 게이팅에 의존하므로).
|
||||
1절에서 봤듯 같은 노드를 공유하므로 따로 하면 같은 걸 두 번 설계하게 되는
|
||||
것은 그대로이고, 바뀐 건 **언제**뿐이다. 표면/이름은 아직 미정 —
|
||||
`base/gate-plan.md`가 소스(이 문서의 1절이 그 일반화를 처음
|
||||
권고한 자리로 거기 인용돼 있다). 프리미티브 자체(`Debounce`/`Throttle` 함수)는 그 위에 아무
|
||||
때나 나중에 얹으면 된다.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,48 @@ v1의 `ProcessQuadProperty`(`.claude/initreq/quad/src/class.lua:134-214`)는
|
|||
"Dispatch 체인" 절 참고) — 클로저는 **오직 자기 자신의 직접
|
||||
자원**(Observer 구독 등)만 정리하면 됨.
|
||||
|
||||
> **[용어 풀이, 2026-08-20 구현 전 QA 4라운드 `D-3`] "깊은 인덱스"가 뭘
|
||||
> 뜻하는가 — 그리고 설치와 철거는 실제로 반대 방향이 맞다.**
|
||||
> "깊다"는 **인덱스 숫자가 크다**는 뜻이다(트리 깊이가 아니라 **같은
|
||||
> `(inst,k)` 체인 안에서의 재귀 깊이**). `State<State<Tag>>`를 예로 들면:
|
||||
>
|
||||
> ```
|
||||
> (inst, k) 체인
|
||||
> index 1 : StoreBind ← 바깥 State를 구독. "얕음"
|
||||
> index 2 : StoreBind ← 안쪽 State를 구독(바깥이 재귀로 만든 것)
|
||||
> index 3 : TagHandler ← 최종 Tag를 실제로 반영. "깊음"
|
||||
> ```
|
||||
>
|
||||
> - **설치(`Dispatch.process`)는 1 → 2 → 3 순** — 사용자가 짚은 그대로.
|
||||
> 각 레벨이 값을 한 겹 벗겨 `index + 1`로 재귀하므로 인덱스가 커지는
|
||||
> 방향으로 진행한다.
|
||||
> - **철거(`Dispatch.retractFrom`)는 그 반대인 3 → 2 → 1 순이 맞다** —
|
||||
> 아래 "Dispatch 체인" 절의 의사코드가 `for i = #list, index, -1`로
|
||||
> **꼬리부터 역순**으로 돈다. 즉 사용자가 되물은 *"달라질 때 5, 4, 3, 2
|
||||
> … 순이 되는건 아니지?"*의 답은 **"맞다, 그 순서가 된다"** 이다.
|
||||
> - **왜 반대여야 하는가**: index 2의 `StoreBind`가 index 3의
|
||||
> `TagHandler`를 *만들어낸* 주체다. 만든 쪽을 먼저 지우면 만들어진
|
||||
> 쪽을 정리할 주체가 사라진다 — 스택을 쌓은 역순으로 푸는 것과 같은
|
||||
> 이유(LIFO). 그래서 각 핸들러의 retractor는 자기가 재귀로 만든 하위
|
||||
> 인덱스를 쫓아갈 필요가 없다: **자기 차례가 왔을 땐 이미 아래가 다
|
||||
> 비어 있다.**
|
||||
> - **주의 — "3 → 2 → 1"은 한 `(inst,k)` 체인 안에서의 이야기다.**
|
||||
> 서로 다른 키(`(inst,k1)` vs `(inst,k2)`)는 완전히 별개 배열이고
|
||||
> 서로의 순서와 무관하다(아래 "인덱스의 의미" 항목).
|
||||
> - **⭐ [2026-08-20 `B-1`] "스택을 역순으로 푼다"는 `retractFrom`에만
|
||||
> 해당한다 — (A) 분기는 스택을 푸는 게 아니라 그 자리를 *교체*하는
|
||||
> 것이다.** 사용자 정리: *"같은 핸들러의 process로 retract 가 교체되는건,
|
||||
> 말 그대로 교체라 stack down 이 아니고 retractFrom 는 stack down 을
|
||||
> 수행한다."* 둘을 같은 말로 묶어 읽으면 안 된다:
|
||||
> - **(A) 분기(같은 핸들러 재프로세스)** — `slot.retractor(v)` 하나만
|
||||
> 불리고 **아래(index+1 이하)는 전혀 안 건드린다.** 그 자리 하나가
|
||||
> 새 클로저로 갈아끼워질 뿐이라 순서 개념 자체가 없다.
|
||||
> - **`retractFrom`(단순 철거)** — 그때만 꼬리부터 목표 인덱스까지
|
||||
> **스택을 역순으로 푼다.**
|
||||
> 위 "자기 아래는 이미 정리된 뒤"라는 보장도 **`retractFrom` 경로의
|
||||
> 이야기**다 — (A) 분기에서 클로저가 불릴 땐 아래가 그대로 살아 있고,
|
||||
> 그게 바로 "깜빡임 없이 갈아끼우기"가 성립하는 이유다.
|
||||
|
||||
디스패치는 등록된 핸들러를 우선순위 순으로 스캔하며 `isHandlable`을 호출,
|
||||
첫 매치가 처리(Fusion의 SpecialKey 우선순위 스캔과 유사하되 4단계 고정이 아니라
|
||||
열린 레지스트리). tbox의 `TUnion` 런타임 체커가 이미 이 "순서대로 스캔, 첫 매치
|
||||
|
|
@ -152,10 +194,16 @@ src/schema/union.luau:48-68`) — 에러 메시지는 즉시 문자열로 만들
|
|||
`base/module-lifecycle-plan.md`(모듈 표면)에도 반영이 필요하고,
|
||||
다중 인스턴스화(`New()`, `base/architecture.md` "확정된 결정" 13번) 시
|
||||
이 플래그가 인스턴스별인지 전역인지는 그때 같이 정한다.
|
||||
`Dispatch.listHandlers()`도 같은 디버그 표면에 속하는지(=플래그와 무관하게
|
||||
항상 호출 가능한지) 구현 시 정할 것.
|
||||
그리고 `Dispatch.listHandlers()`류
|
||||
함수로 현재 등록된 전체 핸들러(이름/priority)를 덤프할 수 있게 함.
|
||||
**[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`] `Dispatch.listHandlers()`는
|
||||
`Quad.debug`와 무관하게 항상 호출 가능하다** — 옛 서술은 "같은 디버그 표면에
|
||||
속하는지 구현 시 정할 것"으로 열어뒀으나, 사용자 판정으로 닫힘: *"listHandlers
|
||||
는 항상 실행 가능. 유저가 필요하면 수행 시 목록들을 단순 반환해주고 출력하고
|
||||
싶다면 출력하는 용도임."* 즉 이 함수는 **아무것도 출력하지 않고 목록을 반환만
|
||||
하는 순수 조회**이고, 찍을지 말지는 호출한 쪽이 정한다 — 게이팅이 필요한 건
|
||||
"라이브러리가 스스로 콘솔에 쓰는" 동작(동률 경고 print)뿐이라 조회 함수는
|
||||
애초에 그 대상이 아니다.
|
||||
그래서 `Dispatch.listHandlers()`는 현재 등록된 전체 핸들러(이름/priority)를
|
||||
**반환**하는 함수로 둔다.
|
||||
구현 비용이 거의 없고 실제 개발 중 디버깅에 바로 도움되는 항목이라
|
||||
M2(Dispatch 엔진) 착수 시 기본 기능으로 같이 넣음 — 런타임 플러그인인
|
||||
`quad-debug`(후순위, `research/debug-tooling-plan.md`)와는 다른 층위의,
|
||||
|
|
@ -336,15 +384,40 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
|
|||
테이블을 `pairs`/제네릭 `for`로 순회하면 실제로 배열 파트가 해시 파트보다
|
||||
먼저 나옴(`for i, v in {a=1, 2, b=3} do print(i,v) end` → `1 2`, `a 1`,
|
||||
`b 3` 순서 — 사용자가 직접 확인). 이 관찰된 동작에 그냥 얹혀가지 않고,
|
||||
**base 드라이버가 명시적으로 두 패스로 나눠 돌기로 계약화**한다 — 숫자
|
||||
키(children)를 먼저 index 순서대로 처리하고, 그 다음 나머지 키를 처리.
|
||||
이유: (1) 다른 백엔드(`quad-web` 등)가 병합된 props를 Lua 테이블이 아닌
|
||||
다른 자료구조로 표현할 수도 있어서 "Lua 테이블의 우연한 내부 동작"에
|
||||
기대면 이식성이 깨짐, (2) 어차피 숫자 키(children/Ref)와 문자열
|
||||
키(프로퍼티/이벤트)를 다른 의미로 취급해야 하니 구분 비용이 이미 드는
|
||||
참에 순서까지 명시적으로 고정하는 게 거의 공짜. **결과적으로 배열
|
||||
슬롯에 놓인 어떤 값이든 모든 프로퍼티/이벤트 세팅보다 항상
|
||||
먼저 처리된다는 게 base 자체의 보장**이 됨.
|
||||
**base 드라이버가 이 순서를 계약으로 보장**한다 — 배열 슬롯에 놓인 어떤
|
||||
값이든 모든 프로퍼티/이벤트 세팅보다 항상 먼저 처리된다.
|
||||
|
||||
**⚠️ [구현 방식 정정, 2026-08-21 구현 전 QA 4라운드 `F-4-1`] "계약으로
|
||||
보장한다"와 "루프를 두 번 돈다"는 다른 얘기다 — 실제 구현은 일반화 `for`
|
||||
**한 번**이다.** 옛 서술은 "명시적으로 두 패스로 나눠 돌기로 계약화"라고
|
||||
적어 **구현까지 2회 순회로 못박은 것처럼** 읽혔고, 실제로 M0 스파이크
|
||||
`01`도 숫자 `for` + 일반화 `for` 두 루프로 짜여 있었다. 사용자 판정으로
|
||||
단일 순회로 정정: *"ipairs, pairs 를 따로 사용하게 되는게 아닌 단순
|
||||
일반화 for 로써 얻어지는게 맞는 상태라면, 맞는 구현이다 … `__pairs`/
|
||||
`__ipairs` 직접 구현체를 담은 ud 등을 받는 `flattened` 는 없고, luau
|
||||
테이블만 사용하는게 맞음."*
|
||||
- **`flattened`는 항상 평범한 Luau 테이블이다** — props는 사용자가 쓴
|
||||
Lua 테이블 리터럴에서 오고, `flatten`도 그걸 제자리에서 뮤테이션할 뿐
|
||||
(`base/modifier-plan.md`의 "flatten의 정확한 형태" 절). 메타테이블로
|
||||
순회를 갈아끼운 userdata 같은 게 들어올 경로가 **없다.**
|
||||
- **그래서 일반화 `for k, v in flattened do`가 배열 → 해시 순서를 그대로
|
||||
준다** — 두 층위는 `type(k) == "number"`로 가르면 된다. 순회 1회 절약.
|
||||
- **옛 근거 (1)은 과했다** — "다른 백엔드가 props를 Lua 테이블이 아닌
|
||||
자료구조로 표현할 수도"는 **`inst`에는 해당해도 `flattened`에는 해당하지
|
||||
않는다**(백엔드가 뭐든 사용자는 Luau로 props를 쓴다). 근거 (2)(숫자/문자열
|
||||
키를 어차피 다르게 취급해야 함)는 그대로 유효하고, 그건 **한 루프 안의
|
||||
분기**로 충분하다.
|
||||
- **계약 자체는 안 바뀐다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
|
||||
base가 보장하는 것이고, 백엔드가 자기 드라이버를 짜더라도 지켜야 한다.
|
||||
바뀐 건 quad-base 자신의 구현이 그 보장을 **몇 번의 순회로 얻는가**뿐.
|
||||
- **`nil`-hole 위험은 어느 방식이든 동일** — 구멍이 생기면 숫자 키 일부가
|
||||
해시 파트로 밀려 순서가 섞이는데, `#flattened`를 쓰던 옛 방식도 똑같이
|
||||
깨진다. 방어는 그대로 `02`/`06` 스파이크의 nil-hole 규율(`None` 관용구)에
|
||||
맡긴다.
|
||||
- **⚠️ 스파이크 `01` 재작성 필요** — `luau-test/rewrite-required/01-two-pass-array-hash-order.luau`가
|
||||
두 루프 버전이라 지금 계약의 구현과 안 맞는다. 단일 일반화 `for` 버전으로
|
||||
재작성하면서 **"배열 파트 전체가 해시보다 먼저 + 배열 안에서는 index
|
||||
순서"** 를 그대로 확인할 것.
|
||||
**[정정, 2026-08-18 구현 전 QA] `PreRef`/`PostRef`는 이 보장 위에서
|
||||
성립하는 게 아니다** — 옛 서술은 `ref-plan.md`의 "PreRef" 절이 "이 보장
|
||||
위에서 성립"한다고 적었는데, 실제로는 **두 패스 순회보다 더 위의 별도
|
||||
|
|
@ -353,10 +426,44 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절
|
|||
postref 는 정확히는 다른, 더 위에 있는 for 문에서 처리되고"*). 두 보장은
|
||||
**서로 독립**이다 — `PreRef`가 먼저 도는 건 배열 파트 우선 규칙 때문이
|
||||
아니라 pre-pass가 따로 있기 때문. 일반 `Ref`(pre-pass 대상이 아닌 것)가
|
||||
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효. **M0 스파이크에서 실제
|
||||
Luau로 이 순회 동작 자체를 검증할 것**(지금까지 추론/관찰만으로 확정된
|
||||
항목 — `research/pre-implementation-audit.md`가 짚은 "실제 Luau로
|
||||
부딪혀본 적 없는 것" 범주와 같은 급이라 신중하게 다룸).
|
||||
프로퍼티보다 먼저 처리되는 것은 위 보장 그대로 유효.
|
||||
**[실측 완료, 2026-08-19 M0 — 이후 `F-4-1`로 무효화, 지금은
|
||||
`rewrite-required/`]** `luau-test/01-two-pass-array-hash-order.luau`가
|
||||
두 패스 드라이버를 최소 재현해 "array pass 전체가 항상 hash pass보다 먼저,
|
||||
array pass 안에서는 index 순서 정확" 을 확인함 — "M0에서 검증할 것"이던
|
||||
항목은 닫혔다.
|
||||
|
||||
**⚠️ [혼동 방지, 2026-08-20 구현 전 QA 4라운드 `D-10`] 이 실측은 "Luau가
|
||||
이 순서를 주는가"를 확인한 게 아니다.** 사용자 지적대로 **Luau의 일반화된
|
||||
반복 `for`는 이미 배열 파트를 먼저 훑고 해시 파트로 넘어간다** — 그건
|
||||
의심한 적이 없고, 2026-08-07에 사용자가 REPL로 직접 확인한 관찰이기도
|
||||
하다(`for i,v in {a=1, 2, b=3} do end` → `1,2` 다음 `a,b`).
|
||||
**그런데도 base 드라이버가 이 순서를 *계약*으로 들고 있는 이유는 순서를
|
||||
못 믿어서가 아니다** — **⚠️ [2026-08-21 `F-4-1` 이후 정정]** 여기 한때
|
||||
이유가 둘("이식성" + "구분 비용이 이미 듦")이라고 적혀 있었으나, **위
|
||||
`F-4-1` 정정 문단이 이식성 논거를 이미 기각했다**(`flattened`는 항상
|
||||
사용자가 Luau로 쓴 평범한 테이블이라, 다른 백엔드가 와도 이 자료구조는
|
||||
안 바뀐다 — 백엔드에 따라 달라지는 건 `inst`뿐). 남는 이유는 하나다:
|
||||
**숫자 키와 문자열 키를 어차피 다른 의미로 처리해야 하므로, 순서까지
|
||||
계약으로 고정하는 건 거의 공짜다.** 그리고 그 계약을 얻는 데 필요한
|
||||
구현은 **두 패스가 아니라 한 루프 안의 `type(k) == "number"` 분기**다.
|
||||
|
||||
**⚠️ [2026-08-22 정정] `F-4-1` 이후로는 "base가 언어 동작에 안 기댄다"고
|
||||
말할 수 없다.** 여기 한때 *"스파이크 `01`이 검증한 건 '우리가 짠 두 패스
|
||||
드라이버가 계약대로 도는가'이지 언어 동작이 아니다"*, *"base는 이 우연한
|
||||
동작에 기대지 않고 명시적으로 강제한다"*라고 적혀 있었는데, **단일 일반화
|
||||
`for` 구현은 정확히 그 언어 동작에 기대는 구현이다.** 지금 정확한 서술은:
|
||||
- base는 배열→해시 순서를 **계약으로 약속**한다(다른 백엔드가 자기
|
||||
드라이버를 짜도 지켜야 한다).
|
||||
- quad-base 자신은 그 약속을 **Luau 일반화 `for`의 순회 순서에 기대어**
|
||||
지킨다 — 그게 `F-4-1`이 확정한 구현이다.
|
||||
- 그래서 **재작성될 `01`이 검증할 것은 언어 동작 그 자체**다("일반화 `for`가
|
||||
배열 파트 전체를 해시보다 먼저, 배열 안에서는 index 순서로 주는가").
|
||||
옛 `01`은 두 루프 드라이버를 최소 재현한 것이라 이 질문을 안 물었다.
|
||||
- **⚠️ 따라서 `nil`-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안
|
||||
된다** — 구멍이 나면 계약 위반이 아니라 **전제 위반**이라 계약이
|
||||
지켜줄 수가 없다. 구체적인 깨짐 방식과 방어 위치는 위 "`nil`-hole
|
||||
위험은 어느 방식이든 동일" 항목이 소스.
|
||||
|
||||
### `None` 센티널 — StoreBind와 같은 재귀 재디스패치 패턴 재사용 (2026-08-07 여덟 번째 세션, 예시는 2026-08-10 세션에 StoreBind로 정정)
|
||||
|
||||
|
|
@ -460,9 +567,12 @@ end
|
|||
디스패치 드라이버"라고 불러왔던 걸 그대로 동사화(`apply`는 "Dispatch를
|
||||
뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와
|
||||
flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시
|
||||
파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에
|
||||
`Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체.
|
||||
**[2026-08-14 아홉 번째 세션] 이 두 패스 앞뒤에 `Ref` 계열 훅 처리가
|
||||
파트(프로퍼티/이벤트) 나중이라는 **순서 계약대로** 각 `(k,v)`에
|
||||
`Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체
|
||||
(**[2026-08-21 `F-4-1`]** 그 계약을 얻는 구현은 두 루프가 아니라
|
||||
**단일 일반화 `for` + `type(k) == "number"` 분기** — 위 `F-4-1` 정정
|
||||
문단).
|
||||
**[2026-08-14 아홉 번째 세션] 이 본체 루프 앞뒤에 `Ref` 계열 훅 처리가
|
||||
붙음** — 앞에는 `PreRef`/`PostRef`를 한 번에 훑는 pre-pass(`PreRef`는
|
||||
그 자리에서 fire, `PostRef`는 이 호출에만 로컬인 `postRefList`에 적재만
|
||||
하고 둘 다 전용 센티널로 소진), 뒤에는 그 `postRefList`를 순회하며 각
|
||||
|
|
@ -646,14 +756,28 @@ end
|
|||
Dispatch 바깥에도 적용됨** — `dispose(value)`(`base/slot-plan.md`)는
|
||||
Dispatch 핸들러가 아니라 독립 탑레벨 유틸이지만, `isSlot`이 아닌 값은
|
||||
`elementOwner` 같은 순수 부기 판정 뒤에 마지막 한 줄만
|
||||
`disposeInst(inst: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) —
|
||||
2026-08-14 열 번째 세션에 같은 원칙으로 확정.
|
||||
`nativeDispose(element: any): ()`로 위임(quad-roblox는 `inst:Destroy()`) —
|
||||
**[2026-08-21 5라운드, 같은 날 이름 확정] 같은 계열이 `native*` 물리 트리
|
||||
조작 계층으로 정리됐다**(base는 `Parent`를 모른다는 지적에서 나옴).
|
||||
**⚠️ [2026-08-22 정정] 여기 한때 `disposeInst`/`mountInst(target, element,
|
||||
index)`/`unmountInst(element)`로 적히고 "이름은 아직 가칭이라 정식 등재는
|
||||
확정 시점에 한다"고 미뤄져 있었으나, 이름은 같은 날 `native*`로
|
||||
확정됐다** — `nativeInsert`/`nativeExtract`/`nativeRemove`/`nativeMove`/
|
||||
`nativeSwap`/`nativeDispose`. 시그니처와 조합 폴백 규칙의 소스는
|
||||
`base/slot-plan.md`의 "물리 조작은 주입 op다" 절이고, 주입 op 전체
|
||||
목록의 소스는 `base/architecture.md`의 소스 트리 안 `EngineOps.luau`
|
||||
줄이다 — 여기서 다시 나열하지 않는다.
|
||||
같은 "base 소유 + op 주입" 원칙은 2026-08-14 열 번째 세션에 확정.
|
||||
- **backend 소유**: `Property`/`Event`/`OnChange`(Reflection·시그널 같은
|
||||
엔진 개념 자체가 로직), `InstanceChild`, `Slot`의 실제 부모 조작
|
||||
(재조정 알고리즘은 base `Dispatch/Slot.luau`, 물리 마운트만 backend) —
|
||||
이들은 "한 줄 op"으로 줄어들지 않으므로 그대로 backend.
|
||||
|
||||
**주입되는 엔진 op**:
|
||||
**Tag/Attribute가 쓰는 주입 op**(**⚠️ [2026-08-22] 이건 주입 op *전체
|
||||
목록*이 아니다** — 이 절이 다루는 Tag/Attribute 경로에 필요한 셋일 뿐이고,
|
||||
`native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`은 여기 없다. 전체
|
||||
목록의 소스는 위에서 지정한 `base/architecture.md`의 `EngineOps.luau` 줄
|
||||
하나다 — 여기에 다시 쌓지 말 것):
|
||||
|
||||
```lua
|
||||
addTag(inst: any, names: {string}): () -- 웹은 className을 한 번에 갱신
|
||||
|
|
@ -915,6 +1039,28 @@ end
|
|||
`NoneHandler`는 항상 재위임하므로 지금 위반 사례는 없지만, "조건부로만
|
||||
재위임하는" 핸들러를 새로 만들면 재위임을 건너뛰는 그 자리에서
|
||||
`Dispatch.retractFrom(inst, k, index + 1)`을 직접 불러 아래를 정리해야 함.
|
||||
|
||||
**[예시 추가, 2026-08-20 구현 전 QA 4라운드 `B-3`]** 규칙만으론 뭐가
|
||||
위험한지 안 드러난다는 지적을 받아 가상의 위반 사례를 같이 적어둠:
|
||||
|
||||
```lua
|
||||
-- ⚠️ 이런 핸들러를 새로 만들면 고아 체인이 생긴다
|
||||
function MaybeWrapHandler.process(inst, k, v, index)
|
||||
if v.enabled then
|
||||
Dispatch.process(inst, k, v.inner, index + 1) -- 재위임함
|
||||
end
|
||||
-- v.enabled가 false면 아무것도 안 함 ← 여기가 문제
|
||||
return function() end
|
||||
end
|
||||
```
|
||||
|
||||
- 1차 사이클 `v.enabled == true` → index+1에 하위 체인이 설치됨.
|
||||
- 2차 사이클에 **같은 핸들러**로 `v.enabled == false`가 오면 (A) 분기다 —
|
||||
(A)는 정의상 **아래를 안 건드리므로** `retractFrom`이 안 불린다.
|
||||
- 그런데 이번엔 재위임을 안 했으니 **index+1의 옛 하위 체인이 그대로
|
||||
남는다.** 아무도 안 지우고, 옛 값에 대한 구독/부작용이 계속 산다.
|
||||
- **해법**: `else` 자리에서
|
||||
`Dispatch.retractFrom(inst, k, index + 1)`을 직접 부른다.
|
||||
- **`HandlerChanged` 같은 마커 값은 두지 않음** — "핸들러가 바뀜"은 **그
|
||||
자리 retractor가 `nil`로 불린다는 사실 자체**로 이미 표현됨. 별도 마커를
|
||||
만들면 그것도 결국 "인자로 넘어오는 정체불명의 값"이 되어 옛 모델의
|
||||
|
|
@ -1143,9 +1289,13 @@ Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드
|
|||
**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**:
|
||||
|
||||
```lua
|
||||
Dispatch.setLength(inst, i, len: number | State<number>)
|
||||
Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
||||
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?)
|
||||
Dispatch.setOffsetSource(ownerKey, i, offset: Source<number> | None)
|
||||
Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 자리의 절대 offset
|
||||
```
|
||||
**[2026-08-21 5라운드]** `anchor`는 생명주기 앵커(생략 시 `ownerKey`, 자세한
|
||||
건 아래 "`setLength` 구현" 절), `getOffsetAt`은 발행 채널(`Source`) 없이
|
||||
숫자만 필요한 쪽(물리 삽입 위치 등)이 쓰는 pull 경로.
|
||||
|
||||
**[2026-08-11 세션] 첫 인자(`inst`)는 물리 Instance일 필요가 없음 —
|
||||
`Relate`가 weak table 기반이라 아무 테이블이나 키로 가능.** 이 사실을
|
||||
|
|
@ -1204,8 +1354,13 @@ Dispatch.setOffsetSource(inst, i, offset: Source<number> | None)
|
|||
강제하지 않음), 반응형이 필요하면 자기 `userdata` 안에 직접 `Source`를
|
||||
만들어 `Frame { LayoutOrder = layoutOrder:With(offset):Compute(fn) }`처럼
|
||||
써넣으면 됨 — 새 메커니즘 불필요. 상세는 `base/slot-plan.md`의
|
||||
`Slot:List` 절 참고. **실제 마운트를 하지 않는 위치는 `None`을 등록** — 순서 계산에
|
||||
참여할 게 없다는 명시적 선언. 대상은 일반 `Ref`뿐 아니라 **그 배열
|
||||
`Slot:List` 절 참고. **⭐ [정정, 2026-08-21 G절] `None`은 "발행 채널이 없다"는 뜻이다** —
|
||||
옛 서술은 "실제 마운트를 하지 않는 위치"였는데, **plain 요소도 `None`을
|
||||
등록**(마운트는 하지만 그 자리의 offset을 반응형으로 받아볼 소비자가 없음)하므로
|
||||
정확하지 않았다. **순서 계산에 참여하는지는 `setLength`가 답한다**(0이면 안
|
||||
차지). 숫자가 필요하면 채널 유무와 무관하게 `Dispatch.getOffsetAt(ownerKey, i)`을
|
||||
부르면 된다. 아래 "짝을 맞춰 `0`" 규칙은 **값이 정말 없는 자리**(Ref/`nil`)에만
|
||||
해당한다 — plain 요소는 `None` + `setLength(1)`이 정상이다. 대상은 일반 `Ref`뿐 아니라 **그 배열
|
||||
위치의 값 자체가 `None`인 모든 경우**(예: `props.Ref or None` 관용구로
|
||||
캐우칭된 미전달 Ref) — `setLength`도
|
||||
같은 위치엔 짝을 맞춰 `0`으로 등록해야 함(위 `setLength` 항목의
|
||||
|
|
@ -1265,7 +1420,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
|
|||
동일하게 적용"* — 즉:
|
||||
- `Dispatch.setLength`가 이전에 등록된 적 없는 더 큰 position `i`를
|
||||
등록할 때마다 `bk.N`이 `i`로 늘어난다(`Dispatch.drive`의 배열 파트
|
||||
순회, `attachSlot`의 flush 배치, Slot의 런타임 단건 `rawAdd` 전부 이
|
||||
순회, `materializeSlotTree`의 등록 배치, Slot의 런타임 단건 `rawAdd` 전부 이
|
||||
하나의 규칙) — **`Dispatch.setOffsetSource`는 `bk.N`을 건드리지
|
||||
않는다**, 호출 순서가 항상 `setOffsetSource(i)` → `setLength(i)`라서
|
||||
(아래 "`setLength` 구현" 절) `bk.N`을 `setLength`에서만 올려야
|
||||
|
|
@ -1280,7 +1435,7 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
|
|||
케이스가 아니라 같은 규칙의 특수한 안정 상태다.
|
||||
|
||||
**이게 배치 등록 중 크래시(`RC-1`)를 다시 불러오지 않는 이유**: 배치
|
||||
등록 중(`Dispatch.drive`/`attachSlot`의 flush)엔 아래 "배치 등록을
|
||||
등록 중(`Dispatch.drive`/`materializeSlotTree`의 등록 루프)엔 아래 "배치 등록을
|
||||
안전하게 만드는 Blocker 게이팅" 절의 `blocker:IsOn()` 게이트가
|
||||
`recompute` 호출 자체를 막는다 — 이 게이트는 `bk.N`을 전혀 보지 않으므로,
|
||||
배치 도중 `bk.N`이 최종 크기보다 작은 채로 계속 늘어나는 중이어도
|
||||
|
|
@ -1345,6 +1500,13 @@ Slot의 자식 개수는 생애주기 내내 바뀐다(그게 Slot의 존재 이
|
|||
거는 것처럼 순수하게 사용자 코드가 만드는 경우뿐인데, 이건 이미 확정된
|
||||
"일반적인 재진입/무한루프는 방어 안 함, provider/사용자 코드 버그로
|
||||
간주"(2026-08-04) 원칙 그대로 두면 됨 — 별도 가드를 만들 근거가 없음.
|
||||
**⭐ [2026-08-21 5라운드 `DC-14`] 게다가 그 마지막 경로조차 사용자에겐
|
||||
막혀 있다** — `updateFn`이 도는 Slot은 정의상 `_listed`이고, `_crudUsed` ↔
|
||||
`_listed` 상호 배타 가드(`base/slot-plan.md`) 때문에 그 Slot의 공개 CRUD가
|
||||
이미 error다(사용자 지적: *"외부 입장에서는 그럴 방법이 없어보인다. crud 가
|
||||
list 시에는 더이상 불가능해지기 때문"*). 즉 같은 `(ownerKey, bk)` 재진입은
|
||||
**정상 API로는 만들 수 없다** — `updateFn` 안에서 *다른* Slot을 건드리는 건
|
||||
다른 `bk`라 무관하다.
|
||||
**결론: `recompute`는 off-by-one만 고친 순수 버전으로 유지, 재진입
|
||||
가드 없음.**
|
||||
|
||||
|
|
@ -1361,25 +1523,122 @@ mutate**하는 게 바로 그 반대 방향 쓰기. "State가 자기 Source에 `
|
|||
아니라 이미 있는 단방향 흐름 원칙을 recompute라는 구체 지점에 적용한
|
||||
것뿐, 그래서 별도 방어 로직도 필요 없음.
|
||||
|
||||
**⭐ [2026-08-21 구현 전 QA 5라운드 G절, 사용자 확정] 이 절이 두 번 고쳐졌다.**
|
||||
물리 삽입 op(당시 가칭 `mountInst`, 확정 이름은 `nativeInsert`)에 삽입 위치를
|
||||
어떻게 주느냐는 질문에서 결함 둘이 드러났고,
|
||||
사용자가 제시한 방향으로 정리됐다:
|
||||
|
||||
1. **`sourceList`의 `None`은 "발행 채널 없음"만 뜻한다** — 예전엔 "실제 마운트를
|
||||
하지 않는 위치"라고 정의해놓고 정작 plain 요소를 `None` + `setLength(1)`로
|
||||
등록하고 있었다(그래서 그 자리의 offset 숫자가 **계산조차 안 됐고**, DOM류
|
||||
백엔드가 삽입 위치를 알 방법이 없었다). **참여 여부는 `lengthList`가 이미
|
||||
표현하므로** `sourceList`는 "반응형으로 받아볼 채널이 있나"만 답하면 된다.
|
||||
2. **숫자가 필요한 쪽은 `Dispatch.getOffsetAt(ownerKey, i)`로 직접 뽑는다**
|
||||
(사용자 제안: *"setOffsetSource 에선 source 를 받으면 그건 set 해주지만,
|
||||
아니면 그냥 얼리리턴에 None 으로만 둬주고, getOffsetAt 은 직접 호출하는걸로"*)
|
||||
— 모든 자리에 숫자를 밀어 넣는 네 번째 병렬 배열을 만들지 않고 **pull로**
|
||||
둔다("관측해야 실체화된다" 원칙과 같은 결).
|
||||
3. **`sum`이 owner의 자기 offset에서 시작한다** — 예전엔 `0`이라 **depth ≥ 2에서
|
||||
중첩 Slot의 자식 offset이 부모 베이스만큼 어긋났다**(depth 1만 쓰던 동안
|
||||
드러나지 않았음). **베이스를 따로 저장하지 않는다** — Slot이면 자기
|
||||
`.Offset`이 곧 그 값이고(부모가 먼저 설정해주므로 이미 정확), 최상위 물리
|
||||
inst엔 베이스라는 개념이 없어 항상 0이다. 한때 `bk.base` 필드에 복사해두는
|
||||
안을 적었다가 **사용자 지적으로 걷어냈다**: *"bk.base 가 왜 필요한거임? …
|
||||
이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데, 부모 slot 의 offset 읽는게
|
||||
이미 정확해 … 최상위에선 애초에 base자체가 없지 않아? 항상 0 일텐데."*
|
||||
같은 값을 두 곳에 두면 갈라진다는, 이 코퍼스가 반복해서 물린 패턴 그대로다.
|
||||
**`isSlot` 분기가 남는 건 타입 분기라서가 아니라 검사할 다른 방법이 없어서다**
|
||||
— `ownerKey.Offset`을 그냥 인덱싱해 확인하는 duck-typing은 Roblox userdata에서
|
||||
정의 안 된 키 인덱싱이 에러를 던질 수 있어 금지돼 있다(`base/brand-plan.md`의
|
||||
duck-typing 기각 근거).
|
||||
4. **깊은 전파를 위해 중첩 Slot은 자기 `Offset`을 관측한다** — 앞 형제의 길이가
|
||||
변해 자기 베이스가 밀리면 자기 자식들의 offset도 다시 계산돼야 한다(사용자:
|
||||
*"자식 slot 의 offset 을 다시 설정해주기 위함이구나. offset의 깊은 전파를
|
||||
위한거군"*).
|
||||
|
||||
```lua
|
||||
-- [신설, 2026-08-21 G절] 그 자리의 **절대 offset(0-based)** 을 그때그때 계산해 반환.
|
||||
-- 발행 채널(Source) 유무와 무관하게 누구나 부를 수 있다 — nativeInsert의 삽입 위치,
|
||||
-- setOffsetSource의 즉시 계산이 둘 다 이걸 쓴다.
|
||||
function Dispatch.getOffsetAt(ownerKey, at)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
-- [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
|
||||
if at <= bk.invalidAfter then
|
||||
return bk.offsetCache[at] -- 유효 구간 — O(1)
|
||||
end
|
||||
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] 캐시 무효화 — 규칙이 하나다**
|
||||
|
||||
`bk.invalidAfter`는 **"이 인덱스까지는 캐시가 유효"**를 뜻하고, 무효화는 전부
|
||||
같은 모양이다 — **`bk.invalidAfter = math.min(bk.invalidAfter, i)`**(앞으로만
|
||||
당긴다):
|
||||
|
||||
| 무엇이 바뀌나 | 어디까지 당기나 | 왜 |
|
||||
|---|---|---|
|
||||
| `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`도 이 캐시 위에 얹힌다** — `1..N`을 순서대로 도는 함수라 매 자리에서
|
||||
`getOffsetAt`이 한 칸씩만 이어붙이므로 전체가 O(N)이고, 별도 접두합 로직을 따로
|
||||
두지 않는다. (그래서 "`recompute`가 캐시를 채울지 말지"라는 갈래 자체가 없어졌다 —
|
||||
사용자: *"함수를 나눠야할 이유를 모르겠음. 하나로 두는게 나아보임."*)
|
||||
|
||||
```lua
|
||||
local function recompute(ownerKey, bk)
|
||||
-- [2026-08-21 G절] `0`이 아니라 이 owner의 베이스에서 시작한다.
|
||||
-- 베이스는 별도로 저장하지 않는다 — Slot이면 자기 `.Offset`이 곧 그 값이고
|
||||
-- (부모가 먼저 설정해두므로 이미 정확하다), 최상위 물리 inst엔 베이스가
|
||||
-- 아예 없어 항상 0이다. 위 `getOffsetAt`과 같은 식.
|
||||
-- [2026-08-21] offset 값은 위 `getOffsetAt`(접두합 캐시)에서 받는다 —
|
||||
-- 여기서 따로 누적하지 않는다. 순서대로 도는 순회라 캐시가 한 칸씩만 늘어나
|
||||
-- 전체 O(N).
|
||||
local sum = 0
|
||||
for i = 1, bk.N do
|
||||
-- [2026-08-21 5라운드 감사] `bk.N or 0` — **빈 Slot 크래시 방어**.
|
||||
-- `bk.N`은 `setLength`가 처음 불릴 때 생기므로(`bk.N = math.max(bk.N or 0, i)`),
|
||||
-- 요소가 하나도 없는 Slot(`Slot()` 직후, 데이터가 빈 `:List` 등)은 `N`이 `nil`인
|
||||
-- 채로 `materializeSlotTree` 끝의 recompute에 도달한다 — `for i = 1, nil`은
|
||||
-- 그 자리에서 터진다. 빈 Slot은 완전히 정상적인 상태라 이건 방어가 아니라 계약.
|
||||
for i = 1, bk.N or 0 do
|
||||
local offset = bk.sourceList[i]
|
||||
-- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라
|
||||
-- offset은 실제 Source이거나 None(발행 채널 없음) — None은 truthy라
|
||||
-- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함.
|
||||
-- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상
|
||||
-- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는
|
||||
-- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도
|
||||
-- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로.
|
||||
if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set
|
||||
offset:Set(sum)
|
||||
-- [전면 정정, 2026-08-20 QA 4라운드 `C-6`] `nil`은 skip이 아니라 error.
|
||||
-- 도달 경로가 없다는 게 재추적 결론이므로(bk.N=실제 개수, 배치 중엔
|
||||
-- Blocker 게이팅, 해제는 None, spliceArraysDown은 압축), nil이 보이면
|
||||
-- 부기가 깨진 것 — 조용히 건너뛰면 위치 하나가 순서 계산에서 빠지는
|
||||
-- 추적 어려운 오작동이 된다. 상세는 base/slot-plan.md의 "추가 방어 조치".
|
||||
if offset == nil then
|
||||
error("Dispatch.recompute: sourceList[" .. i .. "]가 nil — 부기가 깨졌음(계약상 None이어야 함)")
|
||||
end
|
||||
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)
|
||||
end
|
||||
if isSlot(ownerKey) and ownerKey.Length:Get() ~= sum then
|
||||
ownerKey.Length:Set(sum) -- ownerKey가 물리 inst가 아니라 Slot 자신인 재귀 케이스
|
||||
ownerKey.Length:Set(sum) -- **Length엔 base를 안 더한다** — 길이는 위치와 무관
|
||||
end -- (`base/slot-plan.md`의 "Slot-in-Slot 중첩" 절)
|
||||
end
|
||||
```
|
||||
|
|
@ -1416,7 +1675,13 @@ Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지
|
|||
그건 호출하는 배치 쪽 책임):
|
||||
|
||||
```lua
|
||||
function Dispatch.setLength(ownerKey, i, len)
|
||||
-- [시그니처 변경, 2026-08-21 구현 전 QA 5라운드 `C-4`] 4번째 인자 `anchor` 신설 —
|
||||
-- **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 분리**한다. 아래 절 참고.
|
||||
-- **생략하면 `ownerKey`** — 최상위(물리 inst가 곧 owner)에선 둘이 같은 값이라
|
||||
-- 기존 3-인자 호출부가 전부 그대로 맞고, **`ownerKey`가 Slot일 때만** 물리
|
||||
-- target을 명시적으로 넘기면 된다(그 경우에만 둘이 갈린다).
|
||||
function Dispatch.setLength(ownerKey, i, len, anchor)
|
||||
anchor = anchor or ownerKey
|
||||
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
|
||||
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
|
||||
|
||||
|
|
@ -1437,7 +1702,8 @@ function Dispatch.setLength(ownerKey, i, len)
|
|||
|
||||
if isState(len) then
|
||||
local observer = len:Observer(gatedRecompute) -- 등록 즉시 1회 실행도 게이팅됨
|
||||
bindLifetime(ownerKey, observer) -- ownerKey 생명주기에 귀속, Subscribe 아님
|
||||
bindLifetime(anchor, observer) -- **물리 target**의 생명주기에 귀속, Subscribe 아님
|
||||
-- (ownerKey는 부기 키일 뿐 — 아래 절)
|
||||
bk.observers[i] = observer
|
||||
else
|
||||
gatedRecompute() -- 상수 길이도 같은 게이트를 통과 — setLength 자신은 recompute를 직접 안 부름
|
||||
|
|
@ -1445,6 +1711,39 @@ function Dispatch.setLength(ownerKey, i, len)
|
|||
end
|
||||
```
|
||||
|
||||
**⭐ [2026-08-21 구현 전 QA 5라운드 `C-4`] 부기 키와 생명주기 앵커는 별개다 —
|
||||
4라운드 `D-56`의 결론을 되돌린다.**
|
||||
|
||||
4라운드는 "`ownerKey`가 Slot일 수 있으니 **백엔드의 `bindLifetime`이 Slot을
|
||||
첫 인자로 받는 경우를 핸들링**하고, `isBoundAlive`에 세 번째 분기를 둬라"로
|
||||
결론냈었다. 5라운드에서 사용자가 그 전제 자체에 의문을 제기했고(*"애초에
|
||||
Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 observer/effect 는
|
||||
실제 inst 에 불림 … 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지
|
||||
다시 생각해봐야할 부분"*), 검토 결과 **되돌리는 쪽이 맞다**:
|
||||
|
||||
- 이 Observer가 살아야 하는 기간은 "이 Slot이 **그 물리 트리에 마운트돼
|
||||
있는 동안**"이고, 그건 `physicalTarget`이 정확히 표현한다. Slot 자신의
|
||||
생존은 부모의 `_elements` 강참조가 이미 보장한다.
|
||||
- **`setLength`가 불리는 모든 자리에서 물리 target을 이미 알고 있다** —
|
||||
`Dispatch.drive`(=`inst`), `materializeSlotTree`(=`physicalTarget`),
|
||||
런타임 단건 `rawAdd`/`rawReplace`(=`self._mountedInst`).
|
||||
- 그래서 **`bindLifetime`의 첫 인자는 항상 물리 Instance**로 되돌아가고,
|
||||
`base/lifecycle-pattern.md`가 지고 있던 백엔드 요구사항(비-Instance 첫 인자
|
||||
핸들링)과 `isBoundAlive`의 **세 번째 분기가 통째로 불필요**해진다(그건
|
||||
아직 형태가 미정인 채 열려 있던 항목이었다). 옛 결론 원문은
|
||||
`archive/bindlifetime-slot-owner-reversed.md`.
|
||||
- **포탈(언마운트→재마운트)에서도 자연히 맞는다** — `unmountSlotTree`가
|
||||
`bk.observers`를 `unbindLifetime`하고, 재마운트 시 `materializeSlotTree`가
|
||||
새 `physicalTarget`을 앵커로 다시 등록한다.
|
||||
- **`getBookkeeping(ownerKey)`/`getBlocker(ownerKey)`는 그대로 Slot을 키로
|
||||
쓴다** — 그건 `Relate`의 weak 키일 뿐 생명주기 앵커가 아니다.
|
||||
- **`anchor`는 `len`이 State일 때만 실제로 쓰인다**(상수 길이는 Observer를
|
||||
안 만들므로). **생략 시 `ownerKey`로 폴백**하므로 최상위 호출부
|
||||
(`Dispatch.drive`, `ProcessedPreRefHandler`/`NilHandler` 등 `inst`를 owner로
|
||||
쓰는 자리 전부)는 **기존 3-인자 그대로 두면 된다** — 거기선 `ownerKey`가 곧
|
||||
물리 target이다. 4번째 인자를 실제로 넘겨야 하는 건 **`ownerKey`가 Slot인
|
||||
자리**(`materializeSlotTree`의 등록 루프, 런타임 `rawAdd`/`rawReplace`)뿐이다.
|
||||
|
||||
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
|
||||
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
|
||||
또는 Slot 자신)가 죽을 때 같이 죽어야 함 — `:Subscribe()`는 명시적
|
||||
|
|
@ -1472,8 +1771,8 @@ end
|
|||
**해법의 핵심 — recompute를 배치가 끝날 때까지 미루고, offset은 그
|
||||
자리에서 직접 계산한다(사용자 설계, 2026-08-18)**:
|
||||
|
||||
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `attachSlot`이 자기
|
||||
자신의 `_elements`를 flush하는 자리 — 아래 "적용 지점" 참고)이 그
|
||||
1. **배치를 여는 쪽(`Dispatch.drive` 최상위, 또는 `materializeSlotTree`가
|
||||
자기 자신의 `_elements`를 등록하는 자리 — 아래 "적용 지점" 참고)이 그
|
||||
owner 전용 `Blocker`를 `Relate(ownerKey)`에 lazy 생성하고 배치 시작
|
||||
전에 `:On()`한다.** 이 Blocker는 `state:Block()`을 거치지 않고
|
||||
**직접** 쓰인다 — `base/blocker-plan.md`의 "`state:Block()` 없이
|
||||
|
|
@ -1491,29 +1790,41 @@ end
|
|||
실체화되며 `Slot.Offset`을 곧바로 읽어 쓰는 자리(`activateList`)가
|
||||
배치 중이라도 항상 최신값을 보게 됨.
|
||||
4. 배치가 끝나면(`Dispatch.drive`의 배열 파트 순회 전체, 또는
|
||||
`attachSlot`의 flush 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을
|
||||
`materializeSlotTree`의 등록 루프 전체가 끝나면) `blocker:OffWithoutEmit()`을
|
||||
부르고, **그 직후 딱 한 번** `recompute(ownerKey, bk)`를 명시적으로
|
||||
호출한다. 이 시점엔 `bk.N`개 position이 전부 등록돼 있어 안전하고,
|
||||
`ownerKey`가 Slot이면 이 한 번의 recompute가 `ownerKey.Length`(위
|
||||
재귀 케이스)도 같이 확정시킨다.
|
||||
- **⭐ [2026-08-21 5라운드 `DC-11`] 이 마지막 호출이 실제로 하는 일은
|
||||
"offset 채우기"가 아니다.** offset은 3번의 즉시 계산이 등록 시점마다
|
||||
이미 정확히 넣어뒀고(position `i`의 offset은 `1..i-1`의 길이 합인데
|
||||
그것들은 `i`보다 먼저 등록되므로), 이 호출에서 `offset:Get() ~= sum`
|
||||
가드에 걸려 대부분 아무것도 안 쓴다. 실제 역할은 둘 —
|
||||
**(a) `ownerKey`가 Slot이면 `ownerKey.Length`(= 기여도 합) 확정**
|
||||
(사용자 추측대로 이게 주 목적), **(b) 등록된 뒤에 값이 바뀐 길이가
|
||||
있으면 그 뒤 형제들의 offset 교정**. 그래서 `ownerKey`가 물리 `inst`인
|
||||
`Dispatch.drive` 경로에선 (a)가 없어 사실상 검증 패스에 가깝지만,
|
||||
O(N) 순회에 `Set`이 거의 없으므로 분기해서 빼지 않고 그냥 항상 부른다.
|
||||
|
||||
**`setOffsetSource`의 즉시 계산(2026-08-18 신설)** — 등록되는 그 자리에서
|
||||
`bk.lengthList[1..i-1]`을 합산해 곧바로 `:Set`한다(단 `source == None`이면
|
||||
스킵 — 참여 안 하는 자리는 계산할 게 없음):
|
||||
**`setOffsetSource`의 즉시 계산(2026-08-18 신설, 2026-08-21 G절에 정리)** —
|
||||
등록되는 그 자리에서 **`Dispatch.getOffsetAt(ownerKey, i)`**(= 베이스 +
|
||||
`bk.lengthList[1..i-1]` 합)를 구해 곧바로 `:Set`한다. `source == None`이면
|
||||
**얼리 리턴** — 발행할 채널이 없으니 계산할 이유가 없고, 그 자리 숫자가
|
||||
필요한 쪽(예: `nativeInsert`의 삽입 위치)은 `getOffsetAt`을 직접 부른다:
|
||||
|
||||
```lua
|
||||
-- [정리, 2026-08-21 G절] 합산 루프가 `Dispatch.getOffsetAt`으로 빠지면서
|
||||
-- 이 함수는 "등록 + (채널이 있으면) 즉시 1회 발행"만 남는다.
|
||||
function Dispatch.setOffsetSource(ownerKey, i, source)
|
||||
local bk = getBookkeeping(ownerKey)
|
||||
bk.sourceList[i] = source
|
||||
if source ~= None then
|
||||
local sum = 0
|
||||
for j = 1, i - 1 do
|
||||
local v = bk.lengthList[j] -- 배치가 순서대로 처리되므로 1..i-1은 항상 이미 등록돼 있음
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
end
|
||||
if source:Get() ~= sum then
|
||||
source:Set(sum)
|
||||
end
|
||||
if source == None then
|
||||
return -- 발행 채널이 없는 자리 — 계산할 이유가 없다. 숫자가 필요하면
|
||||
-- 그때 `Dispatch.getOffsetAt(ownerKey, i)`을 직접 부른다.
|
||||
end
|
||||
local offset = Dispatch.getOffsetAt(ownerKey, i) -- 배치가 순서대로 처리되므로
|
||||
if source:Get() ~= offset then -- 1..i-1은 항상 이미 등록돼 있음
|
||||
source:Set(offset)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
|
@ -1525,7 +1836,13 @@ position의 length가 바뀌면(배치가 끝난 뒤 steady state에서) 그보
|
|||
전체 순회가 필요하다 — 그 경로는 안 바뀜(위 `recompute` 코드 그대로).
|
||||
|
||||
**적용 지점 — `Dispatch.drive`와 `attachSlot`, 각각 자기 owner 키로
|
||||
별도 Blocker**: 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
|
||||
별도 Blocker**
|
||||
(**[2026-08-21] `attachSlot` 쪽은 이제 정확히는 그 안의
|
||||
`materializeSlotTree`다** — `attachSlot`이 "부기만 만드는 재귀"와 "물리만
|
||||
붙이는 재귀" 둘로 분해되면서 Blocker가 **등록 쪽 하나만** 감싸게 됐고,
|
||||
그래서 "배치 *등록* 게이팅"이라는 이 절의 정의와 실제 범위가 정확히
|
||||
일치하게 됐다. 옛 코드는 물리 마운트까지 같이 감싸고 있었음.
|
||||
`base/slot-plan.md`의 "재귀 메커니즘" 절이 소스): 이 배치 패턴이 실제로 크래시 위험이 있는 자리는 정확히
|
||||
둘뿐이다(사용자 확인, 2026-08-18) — (a) `Dispatch.drive`가 최상위
|
||||
`inst`의 배열 파트를 순회할 때, (b) `attachSlot`이 **자기 자신의**
|
||||
`_elements`를 flush할 때(`base/slot-plan.md`의 "재귀 메커니즘" 절 —
|
||||
|
|
@ -1554,13 +1871,75 @@ yield 금지(2026-08-18 신설, 사용자 확정).** 이 배치 게이팅 전체
|
|||
아니라(이미 확정된 "일반적인 재진입/무한루프는 방어 안 함" 원칙과 같은
|
||||
톤), 이 계약을 어기면 UB라는 걸 문서로 못박아두는 것.
|
||||
|
||||
**동기 순서 — offset 갱신이 마운트보다 먼저 끝나야 함(안 그러면 Roblox의
|
||||
실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출될 위험)**:
|
||||
Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/LayoutOrder
|
||||
갱신이 동기적으로 여기서 끝남) 다음에 `element.Parent = target`(→ 이제
|
||||
트리에 보이는 시점엔 다운스트림이 이미 정합적) 순서로 호출. `Length:Set`
|
||||
자체도 이전 카운트와 실제로 다를 때만 호출(no-op 캐스케이드 방지, 위
|
||||
`Get` 가드와 같은 원칙을 호출부에서도 적용).
|
||||
### ⭐ 일반 계약 — 물리와 부기의 순서 (2026-08-20 `C-7`로 승격, **2026-08-21 5라운드에 재정의**)
|
||||
|
||||
> **🔄 [역전됨, 2026-08-21] "부기가 물리 트리 조작보다 항상 먼저 끝난다"는
|
||||
> 계약은 폐기됐다.** 원문은 `archive/bookkeeping-before-physical-reversed.md`.
|
||||
|
||||
**왜 뒤집혔나**: 그 계약은 *"`Length`를 먼저 올려 뒤 형제를 밀어내고, 비워진
|
||||
그 공간에 넣는다"*는 그림 위에 서 있었는데, **base에는 물리적으로 자리를
|
||||
비워둘 수단이 없다**(자리를 비워 `null`을 꽂아둘 수도 없다). 미는 주체는
|
||||
언제나 백엔드의 삽입 연산 자신이다 — `native*` 계층이 들어오면서 이게
|
||||
명확해졌다(사용자: *"nativeInsert 라는것 자체가 밀어내기 동작을 강제하는데,
|
||||
그렇다면 length 를 나중에 설정한 다음 offset들이 무시되어야함. 일종의 웹 돔과
|
||||
같은 동작을 내도록 강제하는 시스템"*).
|
||||
|
||||
**지금의 계약 — 셋으로 줄었다**:
|
||||
|
||||
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 금지) — 그래서 이 순서 변경으로
|
||||
사용자에게 보이는 중간 상태가 생기지 않는다. 옛 계약이 내세웠던 "한 프레임
|
||||
순서가 깨진 채 노출될 위험"은 그때 이미 근거에서 빠져 있었다.
|
||||
|
||||
**⚠️ [2026-08-22 정정] 여기 있던 "동기 순서 — offset 갱신이 마운트보다
|
||||
먼저 끝나야 함" 문단은 위 역전으로 폐기됐다.** 그 문단은 *"Slot의 `rawAdd`는
|
||||
부기를 먼저 완결하고(`setOffsetSource`/`setLength` 등록 → `recompute`) 그
|
||||
다음에 물리 마운트를 호출한다"*고 적어, 바로 위 3번(자기 자리 먼저 →
|
||||
`nativeInsert` → 뒤를 미는 것 나중)과 **정반대**였다. 실제 의사코드도 3번
|
||||
쪽이다(`base/slot-plan.md`의 `rawAdd`). 이번 세션이 그 문단의 옛 op 이름만
|
||||
`native*`로 바꾸고 순서 주장은 지나쳐서 남아 있던 것.
|
||||
그 문단이 근거로 들던 "한 프레임 순서가 깨진 채 노출될 위험"은 **위
|
||||
⚠️ 문단이 이미 기각**했다 — 프레임 경계가 안 끼므로 중간 상태 자체가
|
||||
사용자에게 안 보인다.
|
||||
|
||||
**⭐ [전면 정정, 2026-08-21 구현 전 QA 5라운드 `C-2`] `rawAdd`가
|
||||
`self.Length:Set(newCount)`를 직접 부른다는 옛 서술은 틀렸다.** 사용자
|
||||
지적(*"rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?"*)을 파고들다
|
||||
확인된 것 셋:
|
||||
|
||||
1. **`newCount`(개수)는 더 이상 `Length`의 정의가 아니다** — `Length`는
|
||||
"요소별 기여도의 합"(plain=1, nested Slot=그 `.Length`)이라, 중첩이 있는
|
||||
순간 개수로 `Set`하면 틀린 값이 된다.
|
||||
2. **쓰는 주체가 둘이 되면 안 된다** — `recompute`가 이미
|
||||
`ownerKey.Length:Set(sum)`으로 확정 기록을 한다.
|
||||
3. **그 자리의 `Get() ~= newCount` 가드는 아무것도 안 거른다** — `rawAdd`에선
|
||||
카운트가 **항상** 달라지기 때문. 가드가 값을 하는 건 `recompute`의 전체
|
||||
순회 쪽뿐이다(위 `Get` 가드 문단).
|
||||
|
||||
**확정**: `Length`는 **`recompute`만 쓴다.** `rawAdd`/`rawReplace`는 자기 자리
|
||||
부기를 등록하고 `recompute`를 한 번 부를 뿐이다.
|
||||
**⚠️ [2026-08-22 정정]** 여기 *"그게 `Parent` 대입 앞에 오므로 '부기가 물리보다
|
||||
먼저'는 그대로 지켜진다"*(+ 사용자 인용 *"recompute 가 length 를 잘 처리해놓고
|
||||
나서 빈 공간에 들어가므로"*)라고 적혀 있었으나, 그건 **같은 라운드에 역전된
|
||||
옛 C-7 일반 규칙**을 전제한 서술이다 — 지금 단건 경로는 `setOffsetSource` →
|
||||
`nativeInsert` → `setLength` → `recompute`라 `recompute`가 물리 삽입보다
|
||||
**뒤**다(위 "지금의 계약" 3번). 이 절의 결론(`Length`를 쓰는 주체는
|
||||
`recompute` 하나뿐)은 그 순서와 무관하게 그대로 유효하다.
|
||||
의사코드는 `base/slot-plan.md`의 `rawAdd`/`rawReplace`가 소스.
|
||||
|
||||
**`:List` reconcile에서 `Length` 갱신 시점**: 한 사이클(여러 항목이
|
||||
한꺼번에 추가/제거되는 경우 포함) 전체가 끝난 뒤 **한 번만** — 사이클
|
||||
|
|
@ -1569,7 +1948,11 @@ Slot의 `rawAdd`는 `self.Length:Set(newCount)`(→ 다운스트림 offset/Layou
|
|||
**웹 백엔드(quad-web, 아직 없음) — 같은 `lengthList`/`sourceList`/
|
||||
`recompute`를 그대로 재사용, 다른 건 "offset 변경 시 무엇을 하는가"뿐**:
|
||||
DOM의 `insertBefore`류는 물리적으로 삽입하면 뒤 형제가 자연히 밀려나므로,
|
||||
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음 —
|
||||
`offset`이 바뀌었다고 이미 마운트된 원소를 실제로 옮길 필요가 없음
|
||||
(**[2026-08-21 5라운드 재확인]** `nativeInsert`가 삽입 위치를 받게 된 뒤에도 이
|
||||
결론은 그대로다 — 사용자 확정: *"애초에 offset 바뀌여도 상관 없는게 위에서 넣고
|
||||
빼면 insert 같은거로 일어나서 뒤로 밀린다는거였긴함"*. 단 그게 성립하려면
|
||||
백엔드 op이 **아토믹한 최소 단위**여야 한다) —
|
||||
quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 no-op이고,
|
||||
`offset` 숫자는 그 위치가 **다음에** 스스로 insert/remove할 때 어느
|
||||
물리 인덱스에서 해야 하는지를 위해서만 부기됨. base 레벨 로직은 완전히
|
||||
|
|
@ -1588,7 +1971,20 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
|
|||
**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11
|
||||
세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가
|
||||
`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를
|
||||
`self.Offset`으로도 저장, 마운트 전엔 `nil`. 위 정정대로 이 값을
|
||||
`self.Offset`으로도 저장. **[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
|
||||
마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다** —
|
||||
사용자 판정: *"마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이
|
||||
나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐"*. 즉 `Offset`은 항상
|
||||
읽을 수 있는 `Source<number>`이고 마운트 전/언마운트 후엔 잠정값(`0` 또는 마지막
|
||||
값)을 들고 있을 뿐이다. `nil`로 갈아치우면 그 Source를 이미 구독 중인
|
||||
다운스트림이 끊겨 포탈이 깨진다 — 근거는 `base/slot-plan.md`의 "추가 방어 조치"
|
||||
항목. **⭐ [2026-08-21 5라운드 `DC-6`, 사용자 정밀화] 더 정확히는, 언마운트
|
||||
시점에 그 Slot이 이미 렌더해둔 요소들이 `LayoutOrder` 등을 위해 이 Source를
|
||||
**계속 구독한 채로 함께 딸려 나간다**는 게 핵심이다. 그 상태에서 재마운트
|
||||
때 `slot.Offset`에 **다른 Source 객체**를 넣으면, 딸려 나갔던 요소들은 여전히
|
||||
옛 객체를 보고 있어 새 위치가 반영되지 않는다 — 포탈이 그 지점에서 깨진다.
|
||||
그래서 언마운트는 값이 stale하게 남는 걸 감수하고 **객체 identity를 유지**하고,
|
||||
재마운트 시 `setOffsetSource`의 즉시 계산이 같은 객체에 새 값을 `Set`한다. 위 정정대로 이 값을
|
||||
`LayoutOrder` 등에 실제로 반영하는 건 Slot 자신이 하지 않으므로,
|
||||
`:List`의 `updateFn`이 이 값을 받아 쓰거나(아래 `base/slot-plan.md`
|
||||
참고) 수동 CRUD 사용자가 직접 `slot.Offset`을 읽어 자기 원소 프로퍼티를
|
||||
|
|
@ -1602,9 +1998,10 @@ store-bind — 그 외 방식은 UB로 확정(2026-08-10 세션).** `Length`/`Of
|
|||
카운팅은 그 위치를 담당하는 Handler(`Dispatch/Slot.luau`, store-bind
|
||||
프로퍼티 핸들러)가 `Dispatch.setLength`/`Dispatch.setOffsetSource`를
|
||||
호출해줘야만 정합적으로 유지됨 — 이 두 API를 부르지 않고 quad가 관리하는
|
||||
부모 Instance에 자식을 끼워 넣는 경로(예: 사용자 코드가 `newInst.Parent =
|
||||
부모 Instance에 자식을 끼워 넣는 경로(예: **사용자 코드**가 `newInst.Parent =
|
||||
parentInst`를 직접 호출해 Slot이 마운트해둔 부모 밑에 자식을 몰래
|
||||
추가/제거하는 것)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게
|
||||
추가/제거하는 것 — base 의사코드 쪽의 `Parent` 직접 조작은 2026-08-21에
|
||||
전부 주입 op로 정정됐다, `base/slot-plan.md`의 "물리 조작은 주입 op다" 절)는 `lengthList`/`sourceList`가 그 변화를 전혀 모르게
|
||||
만들어 카운트·형제 순서 계산이 조용히 어긋남 — 별도 방어 로직 없는 UB.
|
||||
`Slot`이든 `state<Frame>`이든 둘 다 이미 이 두 API를 정확히 호출하는
|
||||
유일한 정당 경로로 확정돼 있음(위 `setLength`/`setOffsetSource` 절
|
||||
|
|
|
|||
|
|
@ -22,8 +22,10 @@ mount/unmount 전용 유스케이스가 있고, 실제 leaf 생명주기 바인
|
|||
합의됨.
|
||||
|
||||
```
|
||||
Effect(fn, state?) -> EffectHandle
|
||||
Effect(fn, ...deps) -> EffectHandle
|
||||
```
|
||||
**[2026-08-21 5라운드 `C-6`]** 옛 시그니처는 `Effect(fn, state?)`(의존성 하나)였다 —
|
||||
아래 "`Effect(fn, ...deps)`" 절이 소스.
|
||||
|
||||
**`state` 생략 시**: `fn()`을 즉시 1회 실행, 리턴값(`nil | () -> ()`)은
|
||||
이 Effect가 바인드된 leaf가 죽을 때 정확히 1회 호출. 재실행 없음
|
||||
|
|
@ -45,17 +47,17 @@ leaf가 죽을 때 **마지막 cleanup을 한 번 더 호출**. 결과적으로
|
|||
`useEffect(fn, [dep])`와 동형(설치+재실행 사이/최종 cleanup 전부 같은
|
||||
반환 계약 하나로 처리).
|
||||
|
||||
- **다수 의존성은 `:With(...)`로 먼저 하나의 State로 묶어서 넘길 것** —
|
||||
React식 별도 deps 배열을 새로 만들지 않음, quad가 이미 가진 다중 의존성
|
||||
결합 관용구(`base/source-state-plan.md` "`:With` + `:Compute`" 절)를
|
||||
그대로 재사용해 같은 일 하는 두 번째 경로를 안 만듦. **`Effect(fn, a, b,
|
||||
c)`처럼 trailing args로 바로 받는 sugar는 의도적으로 안 만듦**(2026-08-11
|
||||
세션, `source-state-plan.md` "`:Compute(fn, ...)` — 추가 의존성을 trailing
|
||||
args로 직접 받는 sugar" 절 참고) — `Compute`와 달리 Effect/Observer는
|
||||
자기 자신이 결과를 담는 State 노드가 아니라서, 의존성이 둘 이상이면 그걸
|
||||
합칠 **새 노드**(`:With`가 만드는 것)가 실제로 필요함. 그 비용을 sugar로
|
||||
감추지 않고 `Effect(fn, state:With(a,b,c))`처럼 코드에 그대로 드러내는
|
||||
게 의도된 선택.
|
||||
- **🔄 [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-6`] "다수 의존성은 `:With`로
|
||||
묶어서 넘길 것 / trailing args sugar는 안 만듦"(2026-08-11 세션) — 뒤집혔다.**
|
||||
지금은 `Effect(fn, ...deps)`가 의존성을 **여러 개 직접 받고 각각에 구독을
|
||||
건다**(아래 "`Effect(fn, ...deps)`" 절이 소스). 옛 근거("의존성이 둘 이상이면
|
||||
합칠 새 노드가 실제로 필요하니 그 비용을 sugar로 감추지 말자")가 무너진
|
||||
이유는 둘 — (1) **`Ref`는 State가 아니라 `:With`로 합칠 수가 없어서**, 그
|
||||
모델에선 `Ref`가 Effect의 의존성이 될 방법이 **아예 없었다**(실제 갭),
|
||||
(2) 각 의존성에 구독을 따로 걸면 **합치는 노드 자체가 안 생긴다** — 감출
|
||||
비용이 애초에 없다. 옛 서술이 인용하던
|
||||
`source-state-plan.md`의 "`:Compute(fn, ...)`" 선례는 이제 **따르는 쪽**의
|
||||
근거가 됐다(인자 모양을 그 관용구 그대로 맞춤).
|
||||
- **`fn`은 커링 스타일도 권장(2026-08-07 여섯 번째 세션, 사용자 제안)** —
|
||||
`Effect(makeLogger("mount"), state)`처럼 팩토리 함수가 실제 `fn(state)`를
|
||||
만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링 관용구(`modifier-plan.md`
|
||||
|
|
@ -155,7 +157,19 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
짝이다 — leaf 바인딩된 핸들에는 적용되지 않는다.** 아래 확장된 의미는
|
||||
**`:Subscribe()`로 등록한 핸들에 대해서만** 성립한다. `:Subscribe()`를
|
||||
부른 적 없는(=leaf 바인딩된) 핸들에 `:Unsubscribe()`를 지원하면 안 되거나,
|
||||
최소한 그 경로에서 cleanup을 앞당기면 안 된다. 사용자 판정: *"subscribe
|
||||
최소한 그 경로에서 cleanup을 앞당기면 안 된다.
|
||||
**[강화, 2026-08-20 구현 전 QA 4라운드 `E-11`] "안 되거나/최소한"이 아니라
|
||||
Observer와 정확히 같은 규칙으로 통일한다 — leaf 바인딩된 핸들에는
|
||||
`:Unsubscribe()`가 아예 안 먹는다.** 사용자 지적: *"옵저버에선 leaf
|
||||
바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는
|
||||
Unsubscribe 안 먹어야 하는거 아님?"* — 맞다. `Observer`의
|
||||
`:Unsubscribe()`가 전역 경로 전용이고 leaf 해제는 `unbindLifetime`이
|
||||
담당한다는 게 이미 확정된 규칙인데(`base/source-state-plan.md`의 "이중
|
||||
바인딩 금지" 절), `Effect`만 애매하게 열어두면 두 프리미티브의 규칙이
|
||||
갈린다. **`State<Effect>` 재-dispatch와의 상호작용도 이 통일로 같이
|
||||
닫힌다** — leaf 바인딩된 핸들엔 `:Unsubscribe()`가 아예 안 먹으므로,
|
||||
아래 dedup 시나리오(값이 안 바뀌어 retract가 no-op인데 cleanup만
|
||||
앞당겨져 Effect가 조용히 죽는 것)가 발생할 경로 자체가 없어진다. 사용자 판정: *"subscribe
|
||||
한게 아니면 unsubscribe 는 지원하면 안 되거나, 적어도 리프 바운딩에선
|
||||
그래선 안 됨 … subscribe 는 unsubscribe 의 짝이라고 생각함."*
|
||||
- **왜 위험한가**: leaf 바인딩 + `State<Effect>`/`State<Observer>`
|
||||
|
|
@ -164,15 +178,28 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
dedup" 절의 `old ~= v`). 그런데 `:Unsubscribe()`가 cleanup을 미리
|
||||
실행해버리면 뒤이은 재-dispatch에서 **dedup 때문에 재바인딩이 안
|
||||
일어나** 그 Effect가 조용히 죽은 채로 남는다 — 의도한 동작이 아님.
|
||||
- **⚠️ 같이 확인해야 할 별건(미해결)**: 그 dedup 경로에서 **retract가
|
||||
아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안 하는지** 대칭이
|
||||
실제로 성립하는지 확인 필요(사용자가 괄호로 남긴 것).
|
||||
`ObserverEffectLeafHandler` 의사코드 기준으론 `process`의
|
||||
`if old ~= v then bindLifetime(...) end`와 클로저의
|
||||
`if nextValue ~= v then unbindLifetime(...) end`가 짝을 이루지만,
|
||||
**`EffectHandle`은 내부 Observer로 cascade까지 해야 하므로** 그
|
||||
cascade가 dedup 분기 안에 제대로 들어가 있는지는 별도 확인 대상이다.
|
||||
M3 착수 전 확인할 것.
|
||||
- **✅ [해소, 2026-08-21 — 구현 전 QA 4라운드 `E-10` 결론을 5라운드 `EF-3`에서
|
||||
실제로 반영] dedup 경로의 process/retract 대칭은 성립한다.** 4라운드에
|
||||
결론이 났는데 **이 문서에 반영이 누락돼 "미해결"로 남아 있던 것**을
|
||||
5라운드가 잡아냈다(그 자체가 followup의 "반영 완료" 표를 신뢰 소스로
|
||||
쓰면 안 된다는 사례 — 소스는 항상 `base/` 본문).
|
||||
- **성립하는 이유**: 핸들러가 **이전 값(`old`)을 `Relate`로 직접 들고
|
||||
있고**, `process`의 `if old ~= v then ... end`와 클로저의
|
||||
`if nextValue ~= v then ... end` **두 분기 안에서만** bind/unbind가
|
||||
일어난다. 값이 같으면 retract도 아무것도 안 하고(= `old`를 지우지
|
||||
않는다) `process`도 조회해서 같으면 그대로 넘어간다 — 양쪽이 같은
|
||||
비교식을 쓰므로 한쪽만 도는 상태가 안 생긴다. **사용자 서술**(2026-08-20):
|
||||
*"relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup
|
||||
이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면
|
||||
dedup 되어야하는듯."*
|
||||
- **⭐ 단, 내부 Observer cascade도 그 분기 *안*에 있어야 한다**
|
||||
(5라운드 `EF-5`, 확인됨) — `EffectHandle`은 자기 자신뿐 아니라
|
||||
`handle._observer`까지 같이 bind/unbind해야 하는데, 그 cascade가 dedup
|
||||
분기 **밖**에 있으면 handle과 내부 Observer의 바인딩 상태가 갈린다
|
||||
(handle은 그대로인데 Observer만 풀리는 식). 구현 시 이 한 줄을 반드시
|
||||
같은 `if` 안에 둘 것.
|
||||
- **[2026-08-21 기준]** 남은 건 **구현 시 회귀 확인**뿐이고, 설계상 열린
|
||||
항목이 아니다.
|
||||
- **`:Subscribe()`한 핸들에서는 `:Unsubscribe()`가 Observer의 것을 그냥
|
||||
위임하지 않는다 — Effect 계층에서 의미가 확장됨.** Observer의
|
||||
`:Unsubscribe()`는 "미래 재실행만
|
||||
|
|
@ -209,6 +236,81 @@ quad의 반응형 그래프/cleanup 인체공학만 재사용하는 경우)로
|
|||
여전히 `:Subscribe()`(전역 경로)와 `bindLifetime`(leaf 부착 포함,
|
||||
inst-scoped 경로)을 **같이** 쓰는 것뿐.
|
||||
|
||||
## ⭐ `Effect(fn, ...deps)` — 여러 의존성을 직접 받는다, `Ref`도 포함 (2026-08-21 구현 전 QA 5라운드 `C-6` 확정)
|
||||
|
||||
**갭이 실재했다**: 지금 `Effect`는 `state` 하나만 받고, 여럿을 엮으려면
|
||||
`:With`로 합쳐 하나의 State로 만들어야 한다. 그런데 **`Ref`는 State가 아니라**
|
||||
(`:Callback`만 있고 emit이 없다) `:With`로 합칠 수가 없어서, **오늘은 `Ref`가
|
||||
Effect의 의존성이 될 방법이 아예 없다.** 사용자 제기: *"Effect 가 지금은 Ref에
|
||||
대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로
|
||||
합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐."*
|
||||
|
||||
**확정된 계약**(전부 사용자 확인, 2026-08-21):
|
||||
|
||||
- **`Effect(fn, ...deps)`가 의존성을 여러 개 받고, 각각에 맞는 구독을 건다** —
|
||||
State/Source면 `Observer`, `Ref`면 `:Callback`. `:With`로 합치지 않는다.
|
||||
- **인자 모양은 `:Compute(fn, ...deps)`의 선례 그대로** — trailing deps를
|
||||
**lazy 위치 인자**로 콜백에 넘긴다(`base/source-state-plan.md`의 "trailing
|
||||
deps를 `fn`에 lazy positional 인자로도 노출" 절). 새 규칙이 아니다.
|
||||
- **최소 1회는 실행된다 — React `useEffect`와 동일.** 아직 안 채워진 `Ref`가
|
||||
섞여 있어도 그대로 돈다(사용자: *"최초 1회에서 어차피 if 로 확인해내게
|
||||
될것이므로 괜찮음"*). "전부 채워질 때까지 대기"는 안 한다.
|
||||
- **`Ref` 의존성의 발화 시점은 `Set`될 때뿐**이다(Ref는 반복 재설정이
|
||||
가능하므로 그때마다). 채워지지 않은 상태는 발화가 아니다.
|
||||
- **최초 1회를 한 번만 돌리는 장치**: 의존성마다 구독을 걸면 각 구독의 "등록
|
||||
즉시 1회 실행"이 N번 발화하므로, 설치 구간 동안 발화를 눌러뒀다가 마지막에
|
||||
한 번만 실행한다. **[2026-08-21 확정] 이건 `Effect` 내부 플래그로 한다 —
|
||||
게이트도 `Blocker`도 안 쓴다.** 한때 *"`Blocker`의 "`state:Block()` 없이
|
||||
직접 쓰는" 용례를 그대로 재사용"*이라 적고 정확한 모양을 `Gate` 설계에
|
||||
걸어뒀는데, `Gate`가 **빈 배치일 땐 통지를 안 하는 것**으로 확정되면서
|
||||
성립하지 않는 게 확인됐다(`base/gate-plan.md`의 8번) — 설치 구간엔 어떤
|
||||
`Set`도 안 일어나 게이트에 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가
|
||||
없다. 설치 중 발화를 누르는 플래그 하나면 되고 새 메커니즘이 필요 없다.
|
||||
- **⭐ [2026-08-21 해소] 의존성들이 공통 상류를 공유해도 한 파동에 `fn`은 한 번만
|
||||
돈다 — `Effect`가 자기 `EpochMap`을 하나 든다.** 갭은 실재했다: `A → b`,
|
||||
`A → c`, `Effect(fn, b, c)`에서 `A:Set()` 한 번에 `b`가 자기 observer를,
|
||||
`c`가 자기 observer를 **각각 정당하게** 깨워 `fn`이 두 번 돌았다. State 층
|
||||
dedup으론 안 접힌다 — `b`와 `c`는 서로 다른 노드라 접어줄 **공통 하류가
|
||||
없고**, 둘 다 §4의 1번 규칙에 정당하게 걸린다(`base/state-epoch-plan.md`).
|
||||
위의 "설치 구간 억제"도 이건 안 덮는다(그건 등록 시점만).
|
||||
- **확정된 해법**: `EffectHandle`이 `EpochMap`을 **하나** 들고, 각 내부
|
||||
Observer의 클로저가 받은 `from`으로 그걸 `Update`한다. **`true`일 때만
|
||||
`fn`을 부른다.** `Effect`가 곧 그 dep들의 **공통 하류**가 되므로, 한
|
||||
파동에 몇 개가 깨우든 첫 번째만 통과한다.
|
||||
```lua
|
||||
-- 각 dep의 내부 Observer가 공통으로 거는 클로저
|
||||
function(self, from)
|
||||
if handle._installing then return end -- 설치 구간 억제 (아래)
|
||||
if handle._epochs:Update(from) then
|
||||
handle:Rerun() -- 직전 cleanup 호출 후 fn 재실행
|
||||
end
|
||||
end
|
||||
```
|
||||
**⚠️ 억제 플래그가 `Update`보다 먼저여야 한다** — 등록 시점의 즉시 1회
|
||||
실행에는 `from`이 없어서(`nil`, `base/source-state-plan.md`의
|
||||
"`state:Observer(fn)`" 절) `Update(nil)`이 들어가게 된다. 순서를 뒤집으면
|
||||
설치 발화가 맵을 건드려 **그 파동의 첫 진짜 emit이 접힐** 수 있다
|
||||
(2026-08-21 커밋 전 `/code-review high` 발견).
|
||||
- **`Ref` 의존성은 이 맵에 안 낀다** — `Ref`는 `Epoch`가 아니고
|
||||
`:Callback`으로 발화하므로 `from`이 없다. `Ref` 쪽 발화는 그대로 매번
|
||||
`fn`을 돌린다(`Ref`는 반복 재설정마다 도는 게 계약이고, 공통 상류 문제
|
||||
자체가 없다).
|
||||
- **검토했다 접은 대안**: deps를 하나의 파생 노드로 수렴시켜 다이아몬드
|
||||
dedup에 태우기 — 노드가 늘고 "N deps → N observers" 구조를 바꿔야 해서
|
||||
위 안보다 못하다. `useEffect`처럼 "N번 돌아도 무방"으로 계약을 느슨하게
|
||||
두는 선택지도 있었으나, 접는 비용이 맵 하나뿐이라 채택 안 함.
|
||||
- 근거 기록은 `reference/epoch-brand-composition.md`(이 갭이 `EpochMap`
|
||||
분리의 직접 발단이었다).
|
||||
- **leaf dedup/cascade가 전부를 덮어야 한다** — 의존성이 N개면 내부 Observer도
|
||||
N개라, `EffectHandle`의 bind/unbind cascade와 dedup 분기가 **그 전부**를
|
||||
같이 처리해야 한다(위 `E-10`/`EF-5`와 같은 함정). 사용자 확인: *"어차피
|
||||
모든 옵져버들이 내부에 들어가 있을것이므로 가능하다."*
|
||||
|
||||
**우선순위**: 새 코어 메커니즘이 아니라 `Effect` 표면 확장이므로 M3의
|
||||
`Effect` 구현과 같이 간다. **[2026-08-21]** 여기 있던 "억제 장치 때문에
|
||||
`Gate`보다 뒤"라는 순서 제약은 **없어졌다** — 억제가 `Effect` 내부 플래그로
|
||||
확정돼 `Gate`에 안 걸린다.
|
||||
|
||||
## 해결됨 — Effect/Observer 관계 (2026-08-07 여섯 번째 세션, 이전 미해결 절 대체)
|
||||
|
||||
**과거 미해결이었던 두 질문 모두 확정**:
|
||||
|
|
|
|||
303
.claude/base/gate-plan.md
Normal file
303
.claude/base/gate-plan.md
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
# `Gate` — emit을 가로채는 게이트 노드 (2026-08-21 확정)
|
||||
|
||||
**상태**: **표면 확정.** `Blocker`/`Debounce`/`Throttle`이 공유하는 게이팅
|
||||
메커니즘을 `state:Gate(setup)` **메소드**로 확정했다 — *"Gate 는 따로 프리미티브
|
||||
없이 `state:Gate( (emit) -> ()->() )` 처럼 선언되고 마치 Compute 처럼
|
||||
GateNode(ComputeNode 처럼) 생성된다 그리고 Blocker 는 해당 내부 배선을 따른다
|
||||
← 동의합니다 해당 방법대로 확정하면 됩니다."* 구현은 **M2**("게이팅 먼저"
|
||||
결정, `ROADMAP.md`). **남은 것은 아래 "아직 안 정한 것"의 생명주기와 M2 범위뿐이고, 둘 다
|
||||
구현 시 정하면 되는 것들이다 — 사용자 판단이 필요한 항목은 없다** — `/code-review high`가 잡았던 4번(유보된 emit이 싣는 출처)은 같은
|
||||
날 흡수 집합으로 닫혔고, **`setup` 시그니처는 안 바뀌었다.**
|
||||
(**[2026-08-22 표기 정정]** 여기와 4번 제목에 `emit(self)`라 적혀 있었으나
|
||||
그건 `Epoch` 일반화 **이전** 표기다 — 지금 싣는 건 떼어낸 `EpochSet`
|
||||
스냅샷이고 게이트 노드 자신은 안 싣는다, 바로 아래 ⚠️ 문단이 소스.)
|
||||
|
||||
**⚠️ 처음 방향이 한 번 바뀌었다.** 신설 당시엔 *"공용 `Gate` 프리미티브를 꺼내고
|
||||
`Blocker`가 그걸 컴포지션한다"*였는데, 확정된 형태는 **프리미티브를 따로 안
|
||||
만들고 State 메소드 하나로 끝낸다**이다 — *"Gate 프리미티브를 만들고 Blocker 가
|
||||
컴포지션 하는걸 생각했는데, 그럴 필요가 없네요. `:Gate` 는 또 Apply 에서 쓸만한
|
||||
표면을 주기도 하구요."* 아래 본문 중 "공개 프리미티브로 꺼낸다"류 서술은 그
|
||||
이전 시점 표현이니 이 배너 기준으로 읽을 것.
|
||||
|
||||
**⚠️ [2026-08-21 반영 완료] emit 페이로드는 `Epoch | EpochSet`이다**
|
||||
(`EpochSet = { [Epoch]: true }` — **배열이 아니라 집합**,
|
||||
`base/state-epoch-plan.md` §3) — 하류가
|
||||
게이트 identity를 한 번도 안 쓰므로 게이트 노드 자체는 안 싣고 **떼어낸
|
||||
`Epoch` 집합 스냅샷**만 넘긴다(근거 기록은
|
||||
`reference/epoch-brand-composition.md`). 아래 4번의 기제(흡수 집합, flush 시
|
||||
스왑, 게이트-게이트 unfold, 빈 배치 무통지)는 그대로다.
|
||||
|
||||
**한 줄**: `Blocker`가 쓰던 "게이티드 State 노드"를 한 겹 일반화해서, **상류
|
||||
emit을 가로채 내려보낼지 말지를 정책이 정하는** 노드를 **`state:Gate(setup)`
|
||||
공개 메소드**로 낸다(탑레벨 생성자가 아니라 — 위 배너 참고).
|
||||
`Blocker`/`Debounce`/`Throttle`이 그 위에 얹히는 서로 다른 정책이 된다.
|
||||
|
||||
## 왜 지금인가 — 두 갈래가 같은 자리를 가리켰다
|
||||
|
||||
1. **`CR-3`(마일스톤 순서)** — `Dispatch.drive`의 배치 등록이 Blocker 게이팅을
|
||||
전제하므로 M2가 M3의 `Blocker.luau`에 구조적으로 의존한다. 사용자 결정:
|
||||
**게이팅을 먼저 만든다.** (이건 그 결정에 이르게 된 *당시* 상태 서술이다 —
|
||||
**[2026-08-22] 지금은 `Blocker.luau` 자체가 M2에 있다**, 아래 9번.
|
||||
**⚠️ 다만 그걸로 순환이 닫힌 건 아니다** — `state:Gate`는 State 메소드이고
|
||||
`GateNode`/`Blocker`는 State 위에 얹히므로, M2로 옮겨도 M2→M3 참조는
|
||||
그대로 남는다. 그 사실은 `.claude/question.md` 2번이 별도 미결로 다룬다.)
|
||||
2. **`DT-4`(Debounce/Throttle)** — 공개 `Blocker` API 위에는 시간 기반 게이트를
|
||||
못 얹는다. `base/debounce-throttle-plan.md`가 이미 "게이티드 노드를 내부 공용
|
||||
`Gate`로 일반화하고 그 위에 정책을 얹으라"고 권고해뒀고, Blocker를
|
||||
만들 때 같이 해두지 않으면 같은 설계를 두 번 하게 된다(항목 1과 마찬가지로
|
||||
**당시** 서술은 "M3에서 Blocker를 만들 때"였다 — **[2026-08-22]** 지금은
|
||||
`Blocker.luau`도 M2다, 아래 9번).
|
||||
|
||||
**사용자 논거(`DT-4`)** — Blocker + Observer 조합으로는 왜 안 되는가:
|
||||
*"스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐
|
||||
후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer
|
||||
가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가
|
||||
되면 이를 전혀 못 지키기 때문."* → 게이트가 **emit 경로 자체에 끼어들어야**
|
||||
하고, 바깥에서 관측만 해서는 순서를 보장할 수 없다.
|
||||
|
||||
**공개 여부**: 사용자 판단 — *"이 API가 비공개일 이유는 없어보인다."* 즉
|
||||
내부 배관이 아니라 **공개 표면**으로 낸다. **[2026-08-21]** 다만 그 표면은
|
||||
탑레벨 프리미티브가 아니라 **State 메소드 `:Gate`** 다(아래 2번).
|
||||
|
||||
## 제안된 모양 (사용자 스케치 그대로)
|
||||
|
||||
```lua
|
||||
Gate(function(emit)
|
||||
-- 이 `emit`은 setup 밖으로 캡처해 **언제든** 부를 수 있다(타이머 콜백 등).
|
||||
return function()
|
||||
-- 상류 emit이 도착할 때마다 호출된다.
|
||||
-- 여기서 `emit()`을 부를지 말지는 정책 마음.
|
||||
end
|
||||
end)
|
||||
```
|
||||
|
||||
- `setup(emit) -> onUpstreamEmit` 2단 구조. 바깥 함수는 게이트 인스턴스가
|
||||
만들어질 때 1회, 반환된 함수는 상류 emit마다.
|
||||
- `Blocker`는 이 위의 정책 하나가 된다 — "켜져 있으면 `emit()`을 안 부르고
|
||||
플래그만 세워두고, 꺼질 때 한 번 부른다"(`HasBlockedEmit`이 그 플래그).
|
||||
- `Debounce`/`Throttle`도 정책 — 타이머를 걸고 창이 끝날 때 `emit()`.
|
||||
|
||||
## 아직 안 정한 것 (사용자 판단 필요)
|
||||
|
||||
1. **[2026-08-21 해소] 이름 — `Gate`.** 원래 걸림돌은 *"프리미티브 명을
|
||||
Gater? 뭔가 이상하게 들어간다"*였는데, **탑레벨 프리미티브를 안 만들기로
|
||||
하면서 문제 자체가 사라졌다** — 이름이 놓이는 자리가 `Blocker()` 같은
|
||||
생성자가 아니라 `state:Gate(...)` **메소드**이고, 메소드 자리에서 `Gate`는
|
||||
`:With`/`:Compute`/`:Observer`와 나란히 자연스럽다. 노드 타입 이름은
|
||||
`ComputeNode`와 짝을 맞춘 **`GateNode`**.
|
||||
2. **[2026-08-21 해소] `:Apply`가 아니라 State의 메소드다 — `state:Gate(setup)`.**
|
||||
**확정 형태**:
|
||||
|
||||
```lua
|
||||
-- setup: (emit: () -> ()) -> (onUpstreamEmit: () -> ())
|
||||
local gated = state:Gate(setup) -- ComputeNode처럼 GateNode를 하나 만든다
|
||||
```
|
||||
|
||||
`Blocker`는 **그 위에 얹히는 별개 프리미티브**로, `state:Block(blocker)`가
|
||||
내부에서 이 배선을 그대로 쓴다. 탑레벨 `Gate(...)` 생성자는 **안 만든다.**
|
||||
`:Gate`가 메소드라고 `:Apply`와 배타적인 것도 아니다 — 사용자 지적대로
|
||||
*"`:Gate` 는 또 Apply 에서 쓸만한 표면을 주기도"* 하므로,
|
||||
`Debounce{...}` 같은 유저랜드 팩토리가 내부에서 `s:Gate(policy)`를 부르는
|
||||
형태로 `state:Apply(Debounce{...})` 관용구가 그대로 성립한다.
|
||||
|
||||
**사용자 확정**: *"gate 는 apply 불가하다고 판단함. 순수 슈가가 아니기 때문,
|
||||
state 의 전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."*
|
||||
- **정확한 경계**: `Apply`는 `factory(self)`일 뿐이라(`base/source-state-plan.md`의
|
||||
"`state:Apply(factory)`" 절) 팩토리가 노드를 만드는 것 **자체는** 금지가
|
||||
아니다 — 확정 예시의 `capAt(100)`도 `:With` 노드를 만든다. 갈리는 지점은
|
||||
**누가 프리미티브인가**다: `:With`/`:Compute`처럼 **전파 경로에 새 종류의
|
||||
노드를 끼우는 것은 State의 메소드**, 그 프리미티브들을 조합한 **유저랜드
|
||||
팩토리는 `:Apply`**. `Gate`는 전자다.
|
||||
- **그래서 `Debounce`/`Throttle`은 `:Apply` 그대로 둔다** —
|
||||
`Debounce{...}`가 돌려주는 팩토리가 내부에서 `s:Gate(policy)`를 부르면
|
||||
되므로 `base/debounce-throttle-plan.md`의 확정 관용구는 안 건드려도 된다.
|
||||
- **`Blocker` 배선 문제도 같이 사라진다.** `base/blocker-plan.md`가 이미
|
||||
**`state:Block(blocker) -> state`(새 gated state 반환)** 라는 **메소드**로
|
||||
확정해뒀으므로, `Block`이 내부에서 `self:Gate(blocker의 정책)`을 부르는
|
||||
것으로 끝난다 — `blocker` 객체를 `Apply`에 넘길 일이 없다.
|
||||
- **`__call`은 안 쓴다.** 사용자도 *"이상적이여 보이지는 않음"*이라 했고,
|
||||
타입 쪽 근거가 하나 더 있다 — `__call` 테이블이 Luau에서 `(State<T>) -> U`
|
||||
함수 타입 자리에 그대로 들어가는지가 불확실하다(들어가지 않는 쪽이 유력).
|
||||
`Apply`를 쓸 이유 자체가 없어졌으므로 확인할 필요도 없어졌지만, 혹시
|
||||
되살아나면 `luau-test` 스파이크 한 개로 판정할 것.
|
||||
- **2단 구조는 그대로 유효하다** — 사용자 관찰(*"Gate 의 callback 으로 얻어진
|
||||
emit과, 리턴해낸 클로저가 호출되는걸로 배선은 가능"*) 대로, 바깥 함수가
|
||||
**그 노드의 `emit`을 캡처**하고 반환 클로저가 상류 emit마다 정책을 태운다.
|
||||
`Blocker`처럼 **여러 노드가 공유하는 정책**은 공유 상태를 바깥 객체가 들고,
|
||||
노드별 `emit`만 2단 구조로 받아 등록하면 된다.
|
||||
3. **[2026-08-21 확정] `Get()`과의 관계 — 값이 아니라 통지만 막는다.**
|
||||
`Blocker`가 이미 그렇고(`base/blocker-plan.md`의 "`:Get()`엔 영향 없음"),
|
||||
`Debounce`/`Throttle`도 emit-gate로 확정돼 있어(`base/debounce-throttle-plan.md`
|
||||
§4) `Gate`도 같은 계약으로 통일한다. 공개 계약 문구: **"게이트를 통과하지
|
||||
않은 값도 `:Get()`으로는 보인다."**
|
||||
`base/state-epoch-plan.md`가 이 계약에 **의존**한다 — 그 문서가 "게이트를
|
||||
에포크 경계로 만드는" 대안을 기각한 이유가 정확히 이 계약을 뒤집지 않기
|
||||
위해서다 — 그 문서 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목.
|
||||
4. **[2026-08-21 해소] 게이트가 유보했다 내보내는 emit — 흡수 집합
|
||||
스냅샷.** 문제는 실재했다: 확정 `setup`은 `(emit: () -> ()) -> (() -> ())`라
|
||||
양쪽 다 출처를 안 받는데 `base/state-epoch-plan.md`의 수신 규칙은 전부
|
||||
`[epoch]` 키로 판정하므로, `blocker:Off()`가 묶어뒀던 배치 emit이 아무
|
||||
원천도 못 지목한 채 도착해 **하류에서 삼켜진다**(2026-08-21
|
||||
`/code-review high` 발견).
|
||||
|
||||
**확정 기제**(사용자 안, 에이전트가 냈던 (a) `nil` 전체 확인 / (b) 소스마다
|
||||
개별 emit은 기각 — 각각 O(1) 판정을 깨거나 `Blocker`의 "정확히 1회"를 깬다):
|
||||
|
||||
- `GateNode`가 **자기를 거쳐간 `Epoch` 집합**을 들고 있는다 —
|
||||
`withheld : { [epoch] : true }`, **weak key**(`EpochMap`과 같은 이유,
|
||||
`state-epoch-plan.md` §3).
|
||||
- **⭐ [2026-08-21 단순화] 통과와 유보를 구분하지 않는다.** 상류 emit이
|
||||
오면 **정책을 실행하기 전에 무조건** 그 출처를 `withheld`에 넣고, 그
|
||||
다음 정책을 실행한다. 정책이 `emit()`을 부르면 게이트가 하류에 전파하고,
|
||||
안 부르면 집합에 그대로 쌓인다.
|
||||
- **⚠️ [2026-08-21 `/code-review high`] "무조건"은 *정책의 통과/유보와
|
||||
무관하게*라는 뜻이지 *수신 규칙을 건너뛴다*는 뜻이 아니다.** 게이트도
|
||||
평범한 노드처럼 `state-epoch-plan.md` §4의 규칙 1~3을 **먼저** 적용하고,
|
||||
3번(둘 다 같음)으로 삼켜진 emit은 **정책도 안 돌고 집합에도 안
|
||||
들어간다.** 안 그러면 다이아몬드(`A→B→G`, `A→C→G`)에서 `A:Set()` 한
|
||||
번에 정책이 두 번 돌아, `Throttle`의 leading 통과 직후 두 번째 emit이
|
||||
`pending`을 세워 **이미 전달한 변경에 대한 유령 trailing emit**이
|
||||
나간다.
|
||||
- **⭐⭐ 정책이 받는 `emit`은 "이 값을 내보내라"가 아니라 "쌓인 걸 지금
|
||||
흘려보내라"(flush)** — 페이로드를 정책이 정하지 않는다. 그래서 배치를
|
||||
떼어내는 것도 **그 핸들 안**에서 일어난다.
|
||||
`base/debounce-throttle-plan.md`가 이미 같은 것을 `gate:passThrough()`
|
||||
("invalid 세팅 + 아래로 1회 전파")로 부르고 있다.
|
||||
- **전파 페이로드는 `withheld` 자체가 아니라 flush 진입 시점에 떼어낸
|
||||
스냅샷이다.** **[2026-08-21 정정]** 여기 한때 "전파가 반환된 뒤에
|
||||
`table.clear`"라고 적었는데 그게 틀렸다 — 전파가 스택에 남아 있는 동안
|
||||
제어가 같은 게이트로 되돌아오면(하류 Observer가 상류를 `:Set()`,
|
||||
`debounce-throttle-plan.md`의 `onWindowEnd` 주석이 이미 대비하는 경우)
|
||||
중첩 flush의 `clear`가 돌아 **바깥 전파의 남은 갈래가 빈 집합**을 받는다.
|
||||
**모델이 아니라 그 의사코드가 문제였다** — `emit()`이 flush인 이상 들어가는
|
||||
순간 떼어내는 게 원래 모양이고, 그러면 그 경로 자체가 없다:
|
||||
```
|
||||
local batch = self._withheld
|
||||
self._withheld = {} -- 새 테이블. clear가 아니다
|
||||
emitDownstream(self, batch) -- 떼어낸 batch를 페이로드로 넘긴다
|
||||
```
|
||||
하류가 순회하는 것은 `gate._withheld`가 아니라 **받은 `batch`** 다.
|
||||
중첩 파동은 새 테이블에 쌓이므로 바깥 전파와 안 섞인다. 같은 리비전이
|
||||
중첩으로 두 번 도달하는 경우는 애초에 문제가 아니다 — 하류 맵이 이미
|
||||
최신이라 규칙 3으로 삼켜진다.
|
||||
- **그래서 게이트는 언제나 자기(와 그 배치)를 출처로 낸다** — 그냥
|
||||
통과시킬 때도 상류 출처를 그대로 넘기지 않는다. 사용자: *"후행 노드들은 한개가 지연된거로
|
||||
생각이 될 수 있겠지만, 사실 여기서 지연과 비지연을 구분할 이유가
|
||||
없습니다."* 하류가 보는 차이는 집합의 원소가 하나냐 여럿이냐뿐이고 판정
|
||||
규칙은 완전히 같다.
|
||||
- 하류가 **평범한 노드**면 그냥 `EpochMap:Update(batch)`가 집합을 순회하고,
|
||||
하나라도 걸리면 **받은 배치를 그대로** 더 아래로 넘긴다
|
||||
(`state-epoch-plan.md` §4 — 단일이든 집합이든 같은 규칙이다).
|
||||
- **⭐ [2026-08-21 신설] 하류가 또 다른 게이트면 — 받은 집합을 풀어
|
||||
자기 `withheld`에 합친다.** 게이트가 게이트 emit을 받는 경우가 정의돼
|
||||
있지 않던 구멍이었다(사용자 발견). **출처를 그대로 넘기면 안 된다** —
|
||||
상류 게이트가 넘기는 배치는 **그 전파에만 쓰이는 일회성 스냅샷**이라,
|
||||
하류 게이트가 그 참조만 들고 유보했다가 나중에 풀면 이미 지나간 배치를
|
||||
내보내게 된다. 그래서 수신 시점에 **풀어서 옮겨 담아야** 한다:
|
||||
```
|
||||
-- 출처가 Epoch 하나면 그 하나를, 배치면 그 배치 전부를 편다
|
||||
for epoch in unfold(from) do
|
||||
self._withheld[epoch] = true
|
||||
end
|
||||
-- 그 다음 평소대로 정책 실행
|
||||
```
|
||||
게이트가 몇 겹으로 겹쳐도 각 층이 자기 집합을 들고 있으므로 어느 층이
|
||||
먼저 풀리든 정보가 안 샌다.
|
||||
- **게이트의 `emitEpochMap`은 수신 때가 아니라 실제로 전파할 때** 갱신한다
|
||||
(집합 전체에 대해 한꺼번에, `:Sync(batch)`). **이건 `state-epoch-plan.md`
|
||||
§4 의사코드의 유일한 예외이고, 그 문서에 예외로 기록돼 있다.** 그래야 "내가 하류로 던진
|
||||
리비전"이라는 맵의 뜻이 게이트에서도 참이 된다 — 유보 중 같은 리비전이
|
||||
다른 경로로 또 오면 규칙 2로 걸려 정책을 한 번 더 태우는데, 이미 집합에
|
||||
있으므로 무해하다.
|
||||
- **⭐ [2026-08-21 `/code-review high`] emit 없이 푸는 경로는 집합을
|
||||
*버려야* 한다.** `blocker:OffWithoutEmit()`은 정의상 "밀린 전파를 버리며
|
||||
끈다"(`base/blocker-plan.md`)이므로, 그 경로도 **`withheld`를 비운다**
|
||||
(전파는 안 하고 새 테이블로 스왑). 안 그러면 `Dispatch.drive`의 배치
|
||||
게이팅이 매 프레임 `On()` → … → `OffWithoutEmit()`을 도는 동안 집합이
|
||||
**단조 증가**하고, 나중에 아무 `Epoch`나 한 번 통과하는 순간 **버리기로
|
||||
했던 옛 원천들이 같이 실려 나가** 하류가 폐기된 통지로 무효화된다.
|
||||
- 그렇게 비우고 나면 하류의 `emitEpochMap`은 뒤에 남지만, 그 `Epoch`의
|
||||
다음 진짜 emit이 규칙 1/2로 걸려 **스스로 낫는다** — 별도 조치 불필요.
|
||||
|
||||
**⭐ 그래서 `setup` 시그니처는 안 바뀐다.** 집합을 채우는 건 정책이 아니라
|
||||
**노드**이고, 노드는 정책이 뭘 하는지 들여다볼 필요조차 없다(위 단순화).
|
||||
정책은 소스를 몰라도 되고, `Throttle`처럼 나중에 타이머에서 `emit()`을
|
||||
부르는 경우도 그대로 동작한다 — 그때 쌓여 있던 집합이 그대로 나간다.
|
||||
|
||||
5. **생명주기.** 게이트 노드가 잡는 자원(타이머/플래그)이 언제 죽는가 —
|
||||
지금 설계대로면 다운스트림이 다 죽으면 GC(팩토리는 weak 추적,
|
||||
`debounce-throttle-plan.md` 5-4). `Gate` 자체에 `Flush`/`Cancel` 같은 표면을
|
||||
둘지, 그건 정책(Debounce)만의 것으로 둘지.
|
||||
6. **[2026-08-21 정리 — 열린 항목 아님] 재진입.** 여기 한때 *"`onUpstreamEmit`
|
||||
안에서 같은 게이트의 `emit()`을 재귀적으로 부르는 경우"*라고 적혀 있었는데
|
||||
**잘못 옮긴 서술이었다**(사용자 지적). `blocker-plan.md`의 "재진입(네스팅)"
|
||||
절이 말하는 건 **같은 `Blocker` 인스턴스를 중첩해 `On()`/`Off()` 하는 것**
|
||||
이고, 정책이 `emit()`을 호출하는 것과는 무관하다 — 애초에 정책이 flush를
|
||||
부르는 건 재귀가 아니라 **평범한 통과 경로**다. 지금 계약은 셋으로 정리된다:
|
||||
- **끝나지 않는 되먹임은 UB.** `base/dispatch-core-plan.md`의 2026-08-04
|
||||
확정 원칙 그대로 — *"일반적인 재진입/무한루프는 방어 안 함,
|
||||
provider/사용자 코드 버그로 간주"*. 게이트가 따로 가드를 두지 않는다.
|
||||
- **유한한 재진입은 지원한다.** 전파 도중 소비자가 동기적으로 상류를
|
||||
`:Set()`하는 건 이미 대비된 경우이고(`debounce-throttle-plan.md`의
|
||||
`onWindowEnd` 주석), 게이트 쪽에서 그걸 안전하게 만드는 장치가 위 4번의
|
||||
**flush 진입 시 스왑**이다.
|
||||
- **같은 게이트/`Blocker` 인스턴스를 중첩해 쓰지 않는다** — `Blocker`의
|
||||
기존 규칙이 그대로 적용된다(겹치는 배치는 새 인스턴스).
|
||||
7. **[2026-08-21 해소] `Effect(fn, ...deps)`의 최초 1회 억제는 `Gate`
|
||||
소비자가 **아니다**.** 한때 이 용례까지 게이트가 커버해야 한다고 적어뒀으나,
|
||||
위 8번(빈 배치는 통지 안 함)으로 **성립하지 않는 게 확인됐다** — 설치 구간엔
|
||||
어떤 `Set`도 안 일어나 쌓이는 소스가 없으므로 게이트가 내보낼 것 자체가 없다.
|
||||
`Effect`가 **자기 내부 플래그로** 설치 중 발화를 누르고 마지막에 한 번
|
||||
직접 실행하면 되고, 새 메커니즘이 필요 없다. `base/effect-plan.md`의 그
|
||||
항목에 달려 있던 "⚠️ `Gate` 설계에 딸려 있다"도 같이 해소됐다. 아래는
|
||||
원 서술:
|
||||
2026-08-21 5라운드 `C-6`에서 확정된 다중 의존성 `Effect`는, 의존성마다 구독을
|
||||
걸면 각 구독의 "등록 즉시 1회 실행"이 N번 발화하므로 **설치 구간 동안 발화를
|
||||
눌러뒀다가 마지막에 한 번만 실행**해야 한다(`base/effect-plan.md`의 그 절).
|
||||
즉 `Gate`(또는 `Blocker`의 직접 사용)가 **"설치 구간을 감싸 최초 발화를 한
|
||||
번으로 접는" 용례까지 커버해야** 한다 — 설계할 때 이 소비자를 같이 볼 것.
|
||||
8. **[2026-08-21 해소] 소스 없는 emit(빈 배치) — 아무것도 안 한다.**
|
||||
`/code-review high`가 "정책이 상류 신호와 무관하게 flush를 부르면 빈 배치가
|
||||
나가 하류가 조용히 삼킨다"를 문제로 제기했고, 에이전트는 "빈 배치 = 무조건
|
||||
통지"를 권고했다. **사용자 기각**:
|
||||
|
||||
> *"빈 배치면 이미 하류로 한번 다 던져서 더 던질게 없다는 의미입니다. 마치
|
||||
> 두번 흘러들어온 같은 카운트의 emit 과 유사한데요. 그건 전파 안 합니다. …
|
||||
> 애초에 Gate 는 중간에 emit 을 할 수 있는 핸들을 노출하는, `Source:Emit`
|
||||
> 같은걸 주는 요소도 아니고, 쌓아두다 뒤로 넘기는건데, 쌓아둔것 자체가
|
||||
> 없는데 뒤로 넘긴다는건 이상합니다."*
|
||||
|
||||
**확정: `next(withheld) == nil`이면 통지 자체를 안 한다.** 그래야 다른
|
||||
State와 동작이 같아진다 — 빈 배치를 흘리는 건 표면적으로 **State 중간에
|
||||
`Source:Emit`을 추가하는 격**이고, `Gate`는 그런 요소가 아니다.
|
||||
- **새 규칙이 아니라 기존 계약의 일반화다.** `base/blocker-plan.md`는 이미
|
||||
*"이미 `HasBlockedEmit`이 false면 `emit` 값과 무관하게 아무 것도 안 함
|
||||
(idempotent)"*이라고 확정해뒀다 — 즉 `HasBlockedEmit`은
|
||||
`next(withheld) ~= nil`의 특수형이다. `Debounce`/`Throttle`도
|
||||
`if pending`일 때만 `passThrough()`를 부른다
|
||||
(`base/debounce-throttle-plan.md`의 `onWindowEnd`).
|
||||
- **3번 항목의 "`emit`은 언제든 부를 수 있다"는 그대로 유효하다** — 언제
|
||||
불러도 되지만, 쌓인 게 없으면 그 호출은 no-op이라는 뜻으로 읽는다.
|
||||
- **따름정리: `Effect(fn, ...deps)`의 설치 구간 억제는 `Gate` 소비자가
|
||||
아니다** — 아래 7번 참고.
|
||||
|
||||
9. **M2 범위 — [2026-08-22 해소] `Gate`와 `Blocker` 둘 다 M2다.**
|
||||
`Dispatch.drive`의 배치 등록이 실제로 쓰는 건
|
||||
`blocker:On()`/`OffWithoutEmit()`/`IsOn()`이므로(배치 게이팅 절) 최소한
|
||||
그 세 메서드가 도는 형태까지는 M2에 필요한데, 그렇다고 `Blocker`만
|
||||
M3에 남겨두면 M2가 다시 뒤 마일스톤을 참조하게 된다. 같은 날 `ROADMAP.md`
|
||||
전반 점검에서 `EpochMap.luau`/`GateNode`/`Blocker.luau` **체크박스가 전부
|
||||
M2로 이동**했다(사용자 판단). `Blocker`는 `GateNode`를 다시 만들지 말고
|
||||
그 위의 정책으로 얹을 것.
|
||||
**⚠️ 이 이동이 M2↔M3 의존을 없애지는 않는다** — 셋 다 State 위에
|
||||
얹히므로 M2는 여전히 `Source.luau`/`State.luau`를 필요로 한다. 마일스톤
|
||||
경계를 어떻게 그을지는 `.claude/question.md` 2번(사용자 회신 대기).
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- `base/blocker-plan.md` — 현행 `Blocker` 확정(이 문서가 일반화하려는 대상).
|
||||
- `base/debounce-throttle-plan.md` — "공개 `Blocker` API 위엔 못 얹음" 절이
|
||||
`Gate` 일반화를 처음 권고한 자리, 그리고 정책 쪽 설계 전량.
|
||||
- `base/dispatch-core-plan.md` — "배치 등록을 안전하게 만드는 Blocker 게이팅"
|
||||
절이 M2가 실제로 요구하는 표면.
|
||||
- `ROADMAP.md` M2/M3.
|
||||
|
|
@ -134,8 +134,8 @@ value)`류 base 유틸을 문서가 `inst`라고만 부르는 것과 같은 관
|
|||
|
||||
### `OnDestroyed(fn)`
|
||||
|
||||
`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, state?)`가
|
||||
`state` 생략 시 "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히
|
||||
`Effect(function() return fn end)`를 반환하는 팩토리. `Effect(fn, ...deps)`가
|
||||
**deps 생략 시** "설치 시 즉시 1회 실행 + 반환값이 leaf 사망 시 정확히
|
||||
1회 호출되는 cleanup"이라는 기존 계약(`base/effect-plan.md` 28행)을
|
||||
그대로 재사용 — 다만 여기서는 **설치 단계에서 실행되는 함수가 `fn`
|
||||
자신이 아니라 `function() return fn end`라는 래퍼**라는 점에 주의.
|
||||
|
|
@ -210,7 +210,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
|
|||
> 이르게 된 조사/논거를 그대로 보존한 것.
|
||||
|
||||
`base/dispatch-core-plan.md`의 "확정된 디스패치 모델" 절이 계약하는
|
||||
두 패스(배열 파트 먼저, 해시 파트 나중) 기준으로 현재 base가 제공하는
|
||||
본체 루프의 배열→해시 순서 계약(**[2026-08-22 용어]** 이 문서가 쓰는
|
||||
"두 패스"는 그 본체 루프의 옛 이름이다 — 구현은 단일 일반화 `for`,
|
||||
`base/ref-plan.md`의 같은 용어 각주와 `base/dispatch-core-plan.md`의
|
||||
`F-4-1` 정정 문단) 기준으로 현재 base가 제공하는
|
||||
훅들의 타이밍을 정리하면:
|
||||
|
||||
| 훅 | 시점 |
|
||||
|
|
@ -243,10 +246,10 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
|
|||
걸리는 시점"을 `PreRef`와 동일하게 맞춤, 실제 콜백 fire와 시점이
|
||||
갈리는 건 아래 항목뿐).
|
||||
- **`ProcessedPostRefHandler`는 `ProcessedPreRefHandler`와 완전히
|
||||
대칭**: 정상 두 패스가 `ProcessedPostRef`를 매치해
|
||||
대칭**: 정상 본체 루프가 `ProcessedPostRef`를 매치해
|
||||
`setLength(0)`/`setOffsetSource(None)`을 등록하고 no-op retract를
|
||||
반환 — 새 비대칭 규칙이 필요 없음. **[정정] 이전 초안은 "PostRef는
|
||||
소진 전 원본 값이 정상 두 패스의 매치 대상이어야 한다"고
|
||||
소진 전 원본 값이 정상 본체 루프의 매치 대상이어야 한다"고
|
||||
잘못 짚었었는데, pre-pass에서 미리 소진해두면 그 비대칭 자체가 안
|
||||
생김** — `PreRef`의 "동적 경로 가드" Handler(정상 스캔에서
|
||||
`isPreRef(v)`를 잡아 즉시 error)와 짝이 되는 `PostRef`용 가드 Handler도
|
||||
|
|
@ -284,6 +287,34 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
|
|||
것보다는 여전히 먼저** 불림. 이 캐비엇의 정본 서술은 `base/ref-plan.md`의
|
||||
"`PostRef`" 절 "보장 범위" 항목.
|
||||
|
||||
**[풀어쓰기, 2026-08-20 구현 전 QA 4라운드 `LH-8`]** 위 두 문단이 너무
|
||||
압축돼 있어 그것만 읽고는 판단이 안 된다는 지적이 있어서, 같은 내용을
|
||||
코드로 다시 적는다. `Frame { Frame { TextLabel {} , OnRendered(fn) } }`에서
|
||||
안쪽 `Frame`의 `fn`이 불릴 때:
|
||||
|
||||
```
|
||||
바깥 Frame { ... } ← ⑤ 이 호출은 아직 시작도 안 함
|
||||
└ 안쪽 Frame { ... } ← ④ 이 drive가 지금 끝나는 중
|
||||
├ TextLabel {} ← ① 이미 완성돼 안쪽 Frame에 붙어 있음
|
||||
└ OnRendered(fn) ← ③ fn이 여기서 불림
|
||||
(프로퍼티/이벤트) ← ② 이미 전부 세팅됨
|
||||
```
|
||||
|
||||
- **①② = "자기 아래"** — 안쪽 `Frame`의 자식(그리고 그 서브트리 전체)과
|
||||
자기 프로퍼티/이벤트. `fn`이 불릴 때 **전부 끝나 있다.**
|
||||
- **⑤ = "자기 위"** — 안쪽 `Frame`이 바깥 `Frame`의 자식이 되는 일.
|
||||
Lua 표현식 평가 순서상 **안쪽 `Frame{...}` 호출이 완전히 끝나야** 바깥
|
||||
`Frame`의 props 테이블이 완성되므로, `fn`이 불리는 시점엔 바깥 `Frame`은
|
||||
아직 존재하지도 않는다. 조상 체인 전체가 마찬가지다.
|
||||
- **그래서 "화면에 올라간 뒤"가 아니다** — 화면에 올라가려면 루트까지의
|
||||
조상 체인이 다 이어져야 하는데 그건 ⑤ 이후 일이다. React
|
||||
`componentDidMount`(DOM 삽입 **후**)를 기대하면 어긋난다.
|
||||
- **원래 뭐가 헷갈렸나**: 처음엔 경계를 "이 인스턴스의 프로퍼티만이냐(a),
|
||||
자식 서브트리까지냐(b)"로 놓고 "(a) 메커니즘으론 (b)를 못 준다"고
|
||||
판단했는데, 배열 파트 루프가 각 자식을 **동기적으로 끝내고** 넘어가므로
|
||||
①이 공짜로 따라온다 — (a)/(b)는 애초에 갈리는 지점이 아니었고, 실제로
|
||||
갈리는 건 ①②(자기 아래)와 ⑤(자기 위)였다.
|
||||
|
||||
**착수 시점 선택지 — (a) 확정.** 원래 (a)/(b)/(c) 셋을 열어뒀었고
|
||||
((b)는 일반 `Ref`로 근사, (c)는 계속 스코프 아웃), 사용자가 **(a)**
|
||||
(위 `PostRef` 스케치대로 두 패스 뒤 `postRefList` 소비)를 선택함.
|
||||
|
|
@ -336,6 +367,26 @@ construction에 재사용**하는 것("이미 한 번 fire된 PreRef 객체를
|
|||
기대하는 것과 미묘하게 다른 시점"**을 가리키므로, 문서화 시 위 ①
|
||||
절의 ⚠️ 항목을 반드시 같이 노출할 것(이름을 바꾸는 대신 문서로
|
||||
대응하기로 한 것 — 이름의 친숙함이 주는 이득이 더 크다는 판단).
|
||||
**[백로그 신설, 2026-08-20 구현 전 QA 4라운드]** 진짜
|
||||
`componentDidMount`(= 조상 체인까지 이어져 화면에 실제로 올라간 뒤)에
|
||||
해당하는 훅은 **지금 만들지 않되 백로그로 남긴다.** 사용자 메모:
|
||||
*"OnRendered 라는 이름이 componentDidMount 같은거 구현 가능하면 좋긴
|
||||
하겠는데 별로 애매한가 생각중... 화면 그려지기 전에 애니메이션이 된다던가
|
||||
하지 않게 하는 방안이 있음 좋아보임. 하지만 나중에 얹어져도 좋을
|
||||
이야기이고, 당장은 사용사례가 안 보이므로 추가 프리미티브에 백로깅만 하고,
|
||||
나중에 필요하다는 의견이 나오면 재생각 해볼 예정."*
|
||||
- **쓸모 있는 시나리오**: "화면에 그려지기 전에 애니메이션이 시작돼버리는"
|
||||
것을 막는 것 — 진입 애니메이션(`base/tween-plan.md`의 `initValue`,
|
||||
루트 `HUMAN_TODO.md` 10번)과 맞닿는 자리다.
|
||||
- **왜 지금 안 만드는가**: `PostRef`와 달리 **자기 위**를 알아야 하므로
|
||||
`Dispatch.drive` 한 번의 콜스택 안에서 표현할 수가 없고(부모의 drive가
|
||||
자식을 붙인 뒤에야 알 수 있음), 별도 전파 경로가 필요하다 — "공짜"가
|
||||
전혀 아니다. 그런데 구체적 사용 사례가 아직 안 보이므로
|
||||
`conventions.md`의 "드문 오용이나 가상의 미래 요구까지 방어/최적화하려고
|
||||
구조를 복잡하게 만들지 않는다" 원칙대로 보류.
|
||||
- **요구가 나오면 재검토** — 그때 `OnRendered`와 이름을 어떻게 가를지도
|
||||
같이 정해야 한다(지금 `OnRendered`가 이미 그 이름을 점유 중이라
|
||||
혼동 위험이 있음).
|
||||
|
||||
## 패키지 배치
|
||||
|
||||
|
|
|
|||
|
|
@ -40,8 +40,32 @@ end
|
|||
```
|
||||
|
||||
연결 해제 시 `data.Signal = nil`만 하면 됨(`Connection.Dispose`) — 자료구조를
|
||||
바로 지우거나 재구성하지 않음. quad-v2도 이 모양을 그대로 채택: 라이프타임
|
||||
홀더는 "내가 아직 살아있게 하는 뒷받침 참조"가 nil인지만 확인하면 됨.
|
||||
바로 지우거나 재구성하지 않음.
|
||||
|
||||
> **⚠️ [전면 정정, 2026-08-20 구현 전 QA 4라운드 `LP-1`] quad는 이 rbvm 코드를
|
||||
> 채택하지 않는다 — quad에서 `Connected`는 "계산된 속성"이 아니라 그냥 Roblox
|
||||
> `RBXScriptConnection`의 네이티브 필드다.** 옛 서술("quad-v2도 이 모양을 그대로
|
||||
> 채택: 라이프타임 홀더는 '내가 아직 살아있게 하는 뒷받침 참조'가 nil인지만
|
||||
> 확인하면 됨")은 rbvm의 프록시 계층 사정을 quad에 잘못 옮긴 것이었다. 사용자
|
||||
> 판정: *"Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시
|
||||
> 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로
|
||||
> 참조를 만들 이유도 없음."*
|
||||
>
|
||||
> **quad의 실제 판정은 두 상태뿐**(`isBoundAlive`, 아래 "(1)" 코드 블록):
|
||||
> 1. **gcconn 자체가 없음** — 아직 바인드 안 됐거나, 이미 GC돼서 weak 릴레이션
|
||||
> 항목이 비워진 상태. `BindData:GetWeak(value, "gcconn")`이 `nil`.
|
||||
> 2. **gcconn은 있는데 `.Connected == false`** — `inst`가 방금 Destroy됐고
|
||||
> 아직 GC는 안 된 구간. 엔진이 Destroy 시점에 모든 커넥션을 끊어주므로
|
||||
> quad가 아무것도 안 해도 이 값이 저절로 뒤집힌다.
|
||||
>
|
||||
> **quad가 `Signal = nil`처럼 직접 참조를 끊는 자리는 없다** — 유일하게 "직접
|
||||
> 끊는" 동작인 `unbindLifetime`도 `gchold[value] = nil`과 `BindData` 항목 제거일
|
||||
> 뿐 커넥션 자체를 만지지 않는다. rbvm에서 실제로 가져오는 건 **"gcconn 트릭으로
|
||||
> Instance 수명에 값을 매단다"는 관용구 하나**이고, `Connected`를 계산 속성으로
|
||||
> 만드는 구현은 가져오지 않는다.
|
||||
|
||||
rbvm에서 실제로 재사용하는 부분은 아래 "(0)"/"(1)" 절의 gcconn/gchold
|
||||
관용구이고, 위 `__index` 계산 속성 코드는 **참고용 원본 인용**으로만 남긴다.
|
||||
|
||||
### 2. Instance 파괴는 `Instance.Destroying` 훅 하나로만 관측
|
||||
|
||||
|
|
@ -50,6 +74,14 @@ rbvm은 실제 Roblox Instance의 파괴를 감지하는 지점을 단 하나로
|
|||
플래그를 그 콜백에서만 true로 뒤집음. `AncestryChanged`나 폴링 방식은 안 씀.
|
||||
quad-v2도 동일: 인스턴스 라이프사이클 훅 지점은 `Destroying` 하나로 통일.
|
||||
|
||||
**[구체화, 2026-08-20 구현 전 QA 4라운드 `LP-2`] "예상보다 적을 수 있다"가 아니라
|
||||
지금은 정확히 한 곳뿐이다 — `Effect`.** 아래 "2026-08-04 검증 라운드에서 보강된
|
||||
내용" 절이 "이 훅을 쓰는 지점이 예상보다 적을 수 있다"고만 열어뒀던 걸 사용자가
|
||||
확정해줌(*"당장은 Effect 뿐임"*). `Effect`의 leaf-death cleanup(`base/effect-plan.md`)이
|
||||
이 훅을 쓰는 유일한 소비자이고, 그 위의 슈가 `OnDestroyed`(`base/lifecycle-hooks-plan.md`)도
|
||||
결국 같은 경로다. 나머지(Observer 게이팅, Tag/Attribute 정리, Tween 취소)는 전부
|
||||
gcconn `Connected` 판정이나 엔진 자체 정리로 커버되어 이 훅을 안 씀.
|
||||
|
||||
### 3. 정리(`retract`)는 기본적으로 GC에 위임, 예외적으로만 즉시(eager)
|
||||
|
||||
rbvm 전역에 약한 테이블(weak table, `__mode = "k"/"v"/"kv"`)로 private 데이터를
|
||||
|
|
@ -324,6 +356,21 @@ function canExecute(value)
|
|||
end
|
||||
```
|
||||
|
||||
#### (1-1) ✅ [역전됨, 2026-08-21 구현 전 QA 5라운드 `C-4`] 첫 인자는 **항상 물리 Instance**다
|
||||
|
||||
여기 있던 절(4라운드 `D-56`)은 *"`Dispatch.setLength`의 `ownerKey`가 Slot일 수
|
||||
있으니 백엔드의 `bindLifetime`이 비-Instance 첫 인자를 핸들링하고,
|
||||
`isBoundAlive`에 세 번째 분기를 둬야 한다"*였다. **5라운드에서 뒤집혔다** —
|
||||
`setLength`가 **부기 키(`ownerKey`)와 생명주기 앵커(`anchor`)를 따로 받도록**
|
||||
바뀌면서, 앵커는 언제나 물리 target이 된다(모든 호출부가 이미 그 값을 알고
|
||||
있다). 그래서:
|
||||
|
||||
- **`bindLifetime`/`unbindLifetime`/`isBoundAlive`는 예전처럼 물리 Instance만
|
||||
상대한다** — 백엔드에 추가 요구사항이 없고, `isBoundAlive`의 **세 번째
|
||||
분기도 필요 없다**(형태 미정인 채 열려 있던 항목이 이걸로 닫혔다).
|
||||
- 근거와 트레이싱은 `base/dispatch-core-plan.md`의 "`setLength` 구현" 절
|
||||
바로 뒤 문단, 역전 전 원문은 `archive/bindlifetime-slot-owner-reversed.md`.
|
||||
|
||||
**`bindLifetime`이 `value`와 맺는 계약은 정확히 둘**(이 둘이 위 구현의 전부):
|
||||
|
||||
1. **바인딩이 유효한 동안 `value`는 최소한 `inst`만큼은 산다** — `gchold[value]`
|
||||
|
|
@ -513,9 +560,16 @@ quad는 자신이 만든 instance를 항상 끝까지 들고 있어서 이런
|
|||
Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 알아서
|
||||
정리해준다 — 라이브러리가 따로 처리할 필요가 없음. Roblox 이외의 엔진에서
|
||||
이런 정리가 필요하다면 그건 그 엔진의 `quad-X` 서브패키지가 책임질 문제(base
|
||||
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 `[Event
|
||||
"Destroying"]`을 직접 바인드해서 처리하면 되는 구조라, 라이브러리가 강제로
|
||||
제공할 필요도 없음.
|
||||
관심사 아님). 사용자가 커스텀 Destroy-time 처리가 필요하면 **`Effect`(그리고 그 슈가
|
||||
`OnDestroyed`)를 쓰면 되는 구조**라, 라이브러리가 강제로 제공할 필요도 없음.
|
||||
**[정정, 2026-08-20 구현 전 QA 4라운드 `LP-4`]** 옛 서술은 여기 정상 경로를
|
||||
`[Event "Destroying"]`을 직접 바인드하는 것으로 적었는데, 그건 사용자가 엔진
|
||||
이벤트를 손으로 다루라는 뜻이 되어 quad가 이미 제공하는 프리미티브를 우회하는
|
||||
안내였다 — 사용자 판정: *"Effect 임. 그리고 그 슈거인 OnDestroyed 존재"*.
|
||||
`Effect(fn)`이 반환하는 cleanup이 leaf 사망 시 정확히 1회 불린다는 계약
|
||||
(`base/effect-plan.md`)이 정확히 이 용도이고, `OnDestroyed(fn)`은 그걸 감싼
|
||||
순수 팩토리다(`base/lifecycle-hooks-plan.md`). `[Event "Destroying"]`을 직접
|
||||
바인드하는 것도 물론 막히진 않지만 권장 경로가 아니다.
|
||||
|
||||
## 이름: `cleanup` → `retract`
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,70 @@ cascade 문제가 그대로 오는데, 이건 이미 확정된 "Store 바인드
|
|||
아닌 값은 flatten을 그냥 통과함 — `component-composition-plan.md`의
|
||||
"필수 관용구" 절 참고).
|
||||
|
||||
### flatten의 정확한 형태 — in-place 뮤테이션 + `ProcessedModifier` 소진 (2026-08-20 구현 전 QA 4라운드 `M-2` 확정)
|
||||
|
||||
**여기 있던 갭**: 위 문단은 "Modifier 항목의 필드를 뽑아 merge한다"고만 적고
|
||||
**뽑아낸 그 배열 자리를 어떻게 하는지를 한 번도 안 적었다.** 그냥 지우면
|
||||
배열에 구멍이 생기고, 그건 `PreRef` pre-pass가 `ProcessedPreRef`로 소진해야
|
||||
했던 바로 그 이유(구멍 하나로 테이블 전체가 해시 취급이 되어 배열 파트
|
||||
순서 보장을 잃음, `base/ref-plan.md`)에 정확히 걸린다.
|
||||
|
||||
**확정(사용자 판정)**: `Pre`/`PostRef`와 **완전히 같은 방식** — 전용 센티널
|
||||
`ProcessedModifier`로 그 자리를 소진하고, 전담 nop Handler
|
||||
`ProcessedModifierHandler`가 정상 `Dispatch.process` 경로에서 캐치해
|
||||
`setOffsetSource(None)`/`setLength(0)`을 등록한다. **새 규칙이 하나도 안
|
||||
늘어난다.**
|
||||
|
||||
**그리고 flatten은 새 테이블을 만들지 않는다 — 입력을 제자리에서 뮤테이션한다**
|
||||
(사용자 판정: *"flatten은 클론을 할 필요도 없음. derive 에 들어갈 것 자체가
|
||||
그 자체로 소비되는 테이블이라 그 이후 사용하는 경우가 존재하지 않고, 새
|
||||
테이블을 만드는 비용 자체를 지불할 필요가 없음"*). props 테이블 리터럴은
|
||||
그 호출 한 번을 위해 만들어져 소비되고 끝이므로 원본 보존 의무가 없고,
|
||||
`Pre`/`PostRef` 소진이 이미 `flattened`를 제자리에서 갈아치우는 것과 같은
|
||||
취급이다.
|
||||
|
||||
```lua
|
||||
-- 사용자 제시 형태(반복 방향만 아래 ⚠️대로 정정)
|
||||
function flatten(input)
|
||||
for i = #input, 1, -1 do -- ⚠️ 역순 — 아래 참고
|
||||
local value = input[i]
|
||||
if not isModifier(value) then
|
||||
continue
|
||||
end
|
||||
input[i] = ProcessedModifier -- 구멍 대신 센티널로 소진
|
||||
|
||||
for key, modValue in value... do
|
||||
if input[key] ~= nil then
|
||||
-- 이미 누가 차지한 자리는 건드리지 않음.
|
||||
-- 언셋은 `None`(실재값)이므로 `~= nil` 확인 하나로 충분 —
|
||||
-- 인라인 `None`도 "이미 차지함"으로 잡혀 modifier를 이김.
|
||||
continue
|
||||
end
|
||||
input[key] = modValue
|
||||
end
|
||||
end
|
||||
return input
|
||||
end
|
||||
```
|
||||
|
||||
- **왜 `~= nil` 하나로 인라인 우선이 성립하나**: 인라인 해시 키는 flatten이
|
||||
돌기 **전에 이미 테이블에 들어 있다**. 그래서 "이미 값이 있으면 건너뛴다"가
|
||||
곧 2번 절의 "인라인 키는 modifier보다 무조건 우선" 규칙이 된다 — 별도
|
||||
분기가 필요 없음. 명시적 unset(`None`)도 실재값이라 같은 검사에 잡힌다.
|
||||
- **⚠️ 반복은 반드시 역순(`#input` → `1`)이어야 한다.** "이미 있으면
|
||||
건너뛴다"는 **먼저 쓴 쪽이 이기는** 규칙이라, 정방향으로 돌면 배열
|
||||
**앞쪽** modifier가 이겨 2번 절의 "배열 순서상 **나중** modifier가 우선"과
|
||||
정반대가 된다. 역순으로 돌면 마지막 modifier가 먼저 써서 이긴다 —
|
||||
인라인 우선은 어느 방향이든 그대로 성립(인라인은 루프 시작 전에 이미
|
||||
들어 있으므로).
|
||||
- **배열 파트를 훑으며 해시 키를 같이 쓰는 것은 안전** — 숫자 `for`의 상한이
|
||||
루프 진입 시 한 번만 평가되고, 해시 파트 추가는 그 순회에 영향을 주지
|
||||
않는다.
|
||||
- **`ProcessedModifier`의 공개 표면 위치**는 `Detach`/`None`과 같은 판단을
|
||||
따른다 — 다만 이건 사용자가 볼 일이 전혀 없는 **내부 센티널**이라
|
||||
최상위로 재노출할 이유가 없어 보인다(`ProcessedPreRef`/`ProcessedPostRef`가
|
||||
그렇듯). 구현 시 그 둘과 같은 자리에 둘 것.
|
||||
|
||||
관련: 이미 마운트된 Instance에 재바인드할 때 Default→실값 flatten을 다시
|
||||
해야 하는지/clone이 필요한지는 별개 문제였는데, **[2026-08-14 세션] 그
|
||||
재바인드 기능 자체가 기각**되어 질문이 없어짐
|
||||
|
|
@ -84,6 +148,20 @@ Lua 테이블 리터럴은 배열 파트/해시 파트 사이에 소스 텍스
|
|||
가능" 절)과 같은 발상이지만, 처리 위치가 다름 — merge 단계가 아니라
|
||||
**디스패치 단계**에서 풀린다:
|
||||
|
||||
- **⚠️ [명시 추가, 2026-08-20 구현 전 QA 4라운드 `M-5`] `None`만이 명시적
|
||||
unsetter다 — `mod:TextColor3(nil)`은 "지운다"가 아니라 "그 필드가 아예 없는
|
||||
새 Modifier를 만든다"이다.** 사용자 지적: *"mod:TextColor3(nil) mod 그룹에서
|
||||
제거된걸 생성하게됨. None 이 오직 명시적 'unsetter' 임."* 둘의 관측 가능한
|
||||
차이는 **`Overridden`/flatten에서 갈린다**:
|
||||
- `mod:TextColor3(nil)` → 그 필드가 **없는** Modifier. 나중에
|
||||
`Modifier.Overridden(other, mod)`로 합치면 `other`의 `TextColor3`가 **그대로
|
||||
살아남는다**(덮어쓸 값 자체가 없으므로).
|
||||
- `mod:TextColor3(None)` → 그 필드가 **`None`이라는 실재값으로 채워진**
|
||||
Modifier. 같은 `Overridden`에서 `other`의 값을 **이긴 뒤**, 디스패치 단계의
|
||||
`NoneHandler`가 실제로 지운다.
|
||||
- `:Peek(key)`도 이 둘을 구별해서 돌려준다(`nil` vs `None`) — 위
|
||||
"`:Peek`의 반환 타입" 항목이 `T | State<T> | None | nil`인 이유가 정확히
|
||||
이것. 구현은 이미 이렇게 되겠지만 **문서화에서 이 구분을 반드시 짚을 것**.
|
||||
- **`{ TextColor3 = None, mod }`도, `mod:TextColor3(None)`도 둘 다 지원.**
|
||||
Modifier setter/Overridden/인라인 props 테이블은 `None`을 그냥 평범한 raw
|
||||
값으로 저장·교체할 뿐 특별 취급이 전혀 없음 — 애초에 문제였던 건 "`nil`이
|
||||
|
|
@ -152,7 +230,15 @@ mutable하게 구현하면 같은 modifier 레퍼런스를 공유하는 형제
|
|||
current*1.2 end)`(변환 함수) 둘 다 지원 — 한 줄로 끝내고 싶을 때는 리터럴,
|
||||
이전 값을 바탕으로 계산하고 싶을 때는 변환 함수 하나로 충분.
|
||||
|
||||
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).** 애초에 getter가
|
||||
**Getter는 만들지 않기로 확정(2026-08-06 후속 세션).**
|
||||
**[관계 명시, 2026-08-20 구현 전 QA 4라운드 `M-9`] 다만 아래 9번 절의
|
||||
`:Peek(key)`가 사실상 getter 역할을 한다** — 사용자 지적(*"사실 Peek 가
|
||||
게터라고 봐도 되긴 함"*). 이 절이 "안 만든다"고 한 건 **setter와 짝을 이루는
|
||||
필드별 getter**(`:FontSize()` 무인자 호출로 값을 꺼내는 모양, 또는 dot-access
|
||||
겸용)이고, `:Peek`은 그것과 달리 **키를 인자로 받는 단일 범용 접근자**이며
|
||||
설계 동기도 다르다(setter 인체공학이 아니라 `Apply` 팩토리가 현재 상태를
|
||||
읽어야 해서). 두 서술이 모순이 아니라는 걸 문서화 시 같이 밝힐 것 —
|
||||
"getter가 없다"만 읽으면 `:Peek`의 존재와 부딪히는 것처럼 보인다. 애초에 getter가
|
||||
필요했던 유일한 이유가 "현재 값을 꺼내서 여러 줄에 걸쳐 계산한 뒤 리터럴로
|
||||
다시 넣는" 멀티라인 스타일이었는데, `:FontSize(function(old) ... end)`
|
||||
변환 함수 하나가 그 케이스를 인라인으로 완전히 커버함 — 별도 `:Get(key)`/
|
||||
|
|
|
|||
|
|
@ -253,8 +253,10 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
|
|||
게 기본이어야 함. 켜는 건 명시적 opt-in.
|
||||
- **다중 인스턴스화 시 인스턴스별인지 전역인지는 미정** — 위 "모듈 스코핑"
|
||||
절과 같이 정할 것.
|
||||
- `Dispatch.listHandlers()`류 조회 함수가 같은 디버그 표면에 속하는지도
|
||||
같이 정할 것(조회는 부작용이 없으니 항상 열어둬도 무방해 보임).
|
||||
- **[해소, 2026-08-20 구현 전 QA 4라운드 `D-8`/`ML-9`] `Dispatch.listHandlers()`는
|
||||
이 플래그와 무관하게 항상 호출 가능하다** — 순수 조회(목록 **반환**만 하고
|
||||
스스로 출력하지 않음)라 게이팅 대상이 아니다. 출력할지는 호출부가 정한다.
|
||||
상세는 `base/dispatch-core-plan.md`의 "우선순위 동률/매치 실패 처리" 절.
|
||||
|
||||
## Quad는 스크립트인가 라이브러리인가 (확정, 참고용)
|
||||
|
||||
|
|
@ -307,8 +309,13 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
|
|||
- base 유틸(per-instance 상태 저장소, 생명 바인드 유틸)이 인터페이스만 두고
|
||||
실제 구현은 백엔드 팩토리(`RobloxFactory(BaseModule)`류)가 뮤테이션으로
|
||||
주입한다는 패턴이 확정됨. **[2026-08-13 열네 번째 세션] 주입 대상 목록에
|
||||
엔진 op 3개가 추가됨** — `addTag(inst,{string})`/`removeTag(inst,{string})`/
|
||||
`setAttribute(inst,name,v)`(`v==nil`이면 삭제). `Tag`/`Attribute`의 부기
|
||||
Tag/Attribute용 엔진 op이 추가됨** — `addTag(inst,{string})`/
|
||||
`removeTag(inst,{string})`/`setAttribute(inst,name,v)`(`v==nil`이면 삭제).
|
||||
**[2026-08-22 정정] 여기 "엔진 op 3개"라고 세어놨으나 그 셋이 전부가
|
||||
아니다** — 이후 `native*` 물리 조작 계층과 `setTimeout`/`clearTimeout`이
|
||||
같은 팩토리 뮤테이션 경로에 추가됐다. **주입 op 전체 목록의 소스는
|
||||
`base/architecture.md`의 소스 트리 안 `EngineOps.luau` 줄** — 여기서
|
||||
세지 않는다. `Tag`/`Attribute`의 부기
|
||||
알고리즘이 통째로 quad-base로 옮겨오면서, 엔진에 실제로 손대는 마지막
|
||||
한 줄만 이 경로로 주입받게 됨(`base/dispatch-core-plan.md` "base가
|
||||
소유하는 핸들러와 주입되는 엔진 op" 절). **`TagHandler`/
|
||||
|
|
@ -324,8 +331,11 @@ print**(`base/dispatch-core-plan.md`의 "핸들러 계약" 절)이고, 앞으로
|
|||
"base가 소유하는 핸들러와 주입되는 엔진 op" 절이 소스. 이 문서의 일반
|
||||
원칙 "등록/구현은 팩토리 뮤테이션 시점"의 **명시적 예외**이고, 예외인
|
||||
이유는 이 핸들러들이 "아무도 자리를 안 가져갔을 때"를 위한 것이라
|
||||
누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).** `addTag`/`removeTag`/`setAttribute`만 백엔드 팩토리가
|
||||
뮤테이션으로 채우는 타입 계약. 아직 아무 팩토리도 안 채운 슬롯의
|
||||
누군가 자리를 가져가는 시점에 등록되면 자기 목적을 못 이루기 때문).**
|
||||
즉 이 경로에서 백엔드 팩토리가 뮤테이션으로 채우는 건 **핸들러가 아니라
|
||||
엔진 op 쪽**이다(**[2026-08-22 정정]** 여기 "`addTag`/`removeTag`/
|
||||
`setAttribute`**만**"이라고 셋으로 못박혀 있었으나 위와 같은 이유로
|
||||
그 셋이 전부가 아니다). 아직 아무 팩토리도 안 채운 슬롯의
|
||||
기본값은 quad-base가 명시적으로 에러내는 스텁으로 미리 채워둠(조용한
|
||||
no-op 추측 아님 — base가 임의 엔진의 "맞는 기본 동작"을 알 수 없어서).
|
||||
더 명확한 메시지나 진짜 원자적 실패(부기 mutation 0회)를 원하는
|
||||
|
|
|
|||
|
|
@ -150,10 +150,22 @@ Instance를 직접 받으므로 — `base/dispatch-core-plan.md` "확정된 디
|
|||
원래 값을 직접 받음, 소진 안 함, 계속 유지)로 분기하면 됨 — 대기자/콜백을
|
||||
서로 다른 배열로 나눌 필요 없이 값 타입 하나로 분기 가능
|
||||
(`type(v) == "thread"` → 대기자, `type(v) == "function"` → 콜백,
|
||||
`nil` → 빈 슬롯이라 스킵). 새 콜백/대기자 등록은 `table.insert`가
|
||||
아니라 **비어있는(=`nil`인) 첫 슬롯을 선형 탐색해 재사용**하는
|
||||
등록 함수로 함(아래 참고) — 소진된 슬롯이 실제로 비므로 등록이 그
|
||||
자리를 되찾아 쓸 수 있음.
|
||||
`nil` → 빈 슬롯이라 스킵). **[정정, 2026-08-20 구현 전 QA 4라운드 `R-11`]
|
||||
새 콜백/대기자 등록은 그냥 `table.insert`를 쓴다** — 옛 서술은 "`table.insert`가
|
||||
아니라 비어있는 첫 슬롯을 선형 탐색해 재사용하는 등록 함수"였는데, 사용자
|
||||
판정으로 뒤집힘: *"table.insert 자체가 가장 처음 nil 이 등장하는 인덱스에
|
||||
넣어주기에 table.insert 가 맞음 … None 으로 바꾸면 무한정 불어나지만,
|
||||
nil이면 그렇지 않음."*
|
||||
- 즉 소진으로 생긴 구멍을 `table.insert`가 알아서 되찾아 쓰므로, 별도
|
||||
선형 탐색 등록 함수를 손으로 만들 이유가 없다. 아래 "왜 `None`이 아니라
|
||||
`nil`인가" 절이 들던 "`table.insert`의 `#t`가 구멍 있는 테이블에서
|
||||
미정의"라는 회피 근거도 이 배열에 대해서는 실무상 성립하지 않는 것으로
|
||||
정정 — 그 절의 결론(`nil` 소진)은 그대로이고 **우회 방법만 단순해짐**.
|
||||
- **⚠️ 실측 대상(M0/M8)**: 구멍 있는 테이블에서 `#t`가 반환하는 border는
|
||||
Lua 명세상 "어떤 border든" 이므로, "항상 첫 `nil` 자리"가 Luau 구현에서
|
||||
실제로 그렇게 나오는지는 스파이크로 확인할 것 — `luau-test`에 등록
|
||||
함수/소진을 반복하는 케이스를 추가. 결과가 다르면 이 항목만 되돌리면
|
||||
되고(옛 선형 탐색 버전), 나머지 설계엔 영향이 없다.
|
||||
- **왜 `None`이 아니라 `nil`인가(2026-08-09 열한 번째 세션, 최종 정정)
|
||||
— 2026-08-07 열 번째 세션에 `None`으로 바꿨던 것은 이 배열에는 안
|
||||
맞는 처방이었음, 되돌림.** `None`을 도입한 원래 근거(구멍 있는
|
||||
|
|
@ -375,7 +387,7 @@ Frame의 children 배열에 각각 리터럴로 놓으면 — `Ref`는 항상 ch
|
|||
**children 배열에 놓는 Ref에 `{phase="created"|"mounted"}` 옵션으로 두
|
||||
타이밍을 고르게 하던 것 자체를 없앤다.** `base/dispatch-core-plan.md`의
|
||||
"확정된 디스패치 모델" 절에
|
||||
새로 추가된 두 패스 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
|
||||
새로 추가된 순서 보장(배열 파트는 index 순서대로, 그 다음 해시 파트)
|
||||
덕분에, 같은 인스턴스 안에서 **일반 `Ref`를** 다른 children보다 앞/뒤
|
||||
어디에 놓느냐가
|
||||
이미 "그 형제가 마운트되기 전/후"를 그대로 결정함 — 각 자식은 자기
|
||||
|
|
@ -430,12 +442,22 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
단순 위치 기반 순서만 따르면 그보다 앞선 형제(다른 child)가 먼저
|
||||
마운트되면서 그 형제가 부모에 Parent될 때 부모의 `ChildAdded`류가
|
||||
동기 발화할 수 있어 PreRef가 막으려는 문제가 그대로 재현됨. 그래서
|
||||
base 드라이버는 위 두 패스(배열→해시) 루프를 돌기 **전에** 별도의
|
||||
base 드라이버는 위 본체 루프(배열→해시 순서 계약)를 돌기 **전에** 별도의
|
||||
작은 pre-pass로 배열 파트를 훑어 `PreRef` 항목만 먼저 전부 fire하고,
|
||||
그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 두
|
||||
패스로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로
|
||||
범위가 좁고, "확정된 디스패치 모델" 절의 두 패스 계약과 별개로 그
|
||||
그 다음 나머지(children/일반 Ref/프로퍼티/이벤트)를 평소처럼 본체
|
||||
루프로 처리하면 됨 — 이 pre-pass는 오직 `PreRef` 타입만 골라내므로
|
||||
범위가 좁고, "확정된 디스패치 모델" 절의 배열→해시 순서 계약과 별개로 그
|
||||
앞에 얹히는 것.
|
||||
|
||||
**⚠️ [2026-08-22 용어] 이 문서가 여러 곳에서 쓰는 "두 패스"는 이
|
||||
본체 루프의 옛 이름이다.** `Dispatch.drive`의 실제 구현은 **단일 일반화
|
||||
`for` + `type(k) == "number"` 분기** 한 번이고(`F-4-1`,
|
||||
`base/dispatch-core-plan.md`), "배열 파트 전체가 해시 파트보다 먼저"는
|
||||
그 루프가 지키는 **계약**이지 루프를 두 번 돈다는 뜻이 아니다. 아래
|
||||
"두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은 **시점** 표기는 그대로
|
||||
유효하다 — pre-pass와 `postRefList` 소비 루프가 본체 루프의 앞뒤에
|
||||
붙는다는 뜻이고, 그건 이번 정정과 무관하다. **다만 "두 패스로
|
||||
처리한다"처럼 구현을 단정하는 용법은 폐기**다.
|
||||
- **복수 `PreRef` 간 순서(2026-08-07 아홉 번째 세션 확정, 2026-08-14
|
||||
아홉 번째 세션 재확인) — 새 규칙 불필요, 배열 index 순서 그대로
|
||||
보장.** 같은 인스턴스에 `PreRef`가 여럿 있으면, 이 pre-pass는 위
|
||||
|
|
@ -489,10 +511,10 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
누가 그 등록을 실제로 호출하는가"가 문서 어디에도 없는 갭이었음
|
||||
(2026-08-14 첫 번째 세션 조사에서 발견). `ProcessedPreRef`로 소진처를
|
||||
분리하면 이 갭이 구조적으로 사라짐 — 아래 `ProcessedPreRefHandler`
|
||||
참고. (2) 그 다음에야 비로소 평소의 배열→해시 두 패스가
|
||||
참고. (2) 그 다음에야 비로소 평소의 배열→해시 본체 루프가
|
||||
**같은 테이블**을 다시 순회 — 이때 `ProcessedPreRef`로 소진된 슬롯은
|
||||
**정상 `Dispatch.process` 경로를 그대로 탄다**(아래
|
||||
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 두 패스
|
||||
`ProcessedPreRefHandler`가 매치, **[정정] 예전엔 `None`이라 본체
|
||||
루프 자신이 `if v == None then continue end`로 직접 건너뛰고 어떤
|
||||
Handler도 안 거쳤으나, 지금은 일부러 정상 경로를 태워 Length/Offset
|
||||
등록 책임을 기존 계약에 특수 취급 없이 그대로 얹음**). **[정정,
|
||||
|
|
@ -544,7 +566,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
- **[전면 정정, 2026-08-18 구현 전 QA] 배열 파트의 `None`도
|
||||
`Dispatch.process`를 탄다 — 옛 "명확화(2026-08-09 열한 번째 세션)"는
|
||||
전제가 거짓이었음.** 그 서술은 *"배열 파트의 `None`은 애초에
|
||||
`Dispatch.process` 자체를 절대 안 탄다(두 패스 루프가
|
||||
`Dispatch.process` 자체를 절대 안 탄다(본체 루프가
|
||||
`if v == None then continue end`로 걸러냄)"*, 따라서 *"`k=number`
|
||||
조합으로 `NoneHandler`가 실제로 매치되는 경우는 없음"*이라고 했는데,
|
||||
리터럴 `Frame{None}`만 보면 맞아 보여도 **`Frame{ State<Slot|None> }`
|
||||
|
|
@ -582,8 +604,11 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
문서화까지 검토할 것.
|
||||
- **pre-pass는 어디 사는가 — `Dispatch.drive(inst, flattened)` 자신,
|
||||
새 함수 불필요(2026-08-07 아홉 번째 세션, 사용자 제안 검토 후 확정).**
|
||||
`Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 두 패스를
|
||||
도는 함수로 확정돼 있으므로, 그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로
|
||||
`Dispatch.drive`가 이미 `(inst, flattened)`를 받아 배열→해시 순서로
|
||||
도는 함수로 확정돼 있으므로(**[2026-08-22 정정]** 여기 "두 패스를 도는
|
||||
함수"라고 적혀 있었으나 그 순서는 **계약**이고 구현은 단일 일반화
|
||||
`for`다 — `base/dispatch-core-plan.md`의 `F-4-1` 정정 문단),
|
||||
그 앞에 좁은 pre-pass 한 줄을 얹는 것만으로
|
||||
충분 — `Handler.process`와 이름이 겹치는 새 `Dispatch.process(inst,
|
||||
flatten, prerefs)`류 함수를 따로 만들 필요가 없음(그 이름은 이미
|
||||
다른 뜻으로 쓰이는 `Dispatch.process(inst,k,v)` 오케스트레이터와 겹쳐서
|
||||
|
|
@ -628,7 +653,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
`PreRef`는 pre-pass가 fire와 동시에 해당 슬롯을 소진(**[정정,
|
||||
2026-08-14 두 번째 세션] `None`이 아니라 `ProcessedPreRef` 처리**,
|
||||
위 "호이스팅의 실제 구현" 절)해 **이 가드 Handler(`isPreRef(v)`만
|
||||
매치)에는 다시 노출되지 않게** 하므로(정상 두 패스 스캔 자체엔
|
||||
매치)에는 다시 노출되지 않게** 하므로(정상 본체 루프 스캔 자체엔
|
||||
`ProcessedPreRefHandler`를 통해 여전히 노출됨 — "스캔에 안 걸림"이
|
||||
아니라 "이 가드에 안 걸림"이 정확한 설명), 이 Handler가 실제로
|
||||
매치되는 경우는 오직 "타입이 막았어야 했는데 어떻게든 동적으로
|
||||
|
|
@ -660,7 +685,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
슬롯을 만났는데 그 객체가 이미 `_fired`면, fire하지 않고 그 자리에서
|
||||
즉시 `error("PreRef는 1회용 — 이미 다른 construction에 쓰인
|
||||
PreRef를 재사용할 수 없음, 매번 새로 만들 것")`. 위 "동적 경로 가드"
|
||||
Handler(정상 두 패스에서 매치)와는 별개 코드 경로 — 이 가드는
|
||||
Handler(정상 본체 루프에서 매치)와는 별개 코드 경로 — 이 가드는
|
||||
pre-pass 자신 안에, `_fired`가 아닌 정상 fire는 그대로 통과.
|
||||
- **관용구**: `Slot:List`의 `updateFn`처럼 반복 호출되는 자리에서
|
||||
`PreRef`가 필요하면 **호출마다 새 `PreRef()`를 만들 것** — 클로저에
|
||||
|
|
@ -781,7 +806,7 @@ flatten된 값은 해시 파트(프로퍼티 키)로 존재하게 되고, Store
|
|||
갈리는 건 아래 3번뿐).
|
||||
- `postRefList`는 **`Relate` 같은 별도 저장소가 아님** — 이 함수
|
||||
콜스택 안에서만 살면 되므로 그냥 로컬 테이블.
|
||||
2. **정상 두 패스**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`로
|
||||
2. **정상 본체 루프**(배열 → 해시)가 평소대로 돎. `ProcessedPostRef`로
|
||||
소진된 슬롯은 `ProcessedPreRef`와 **완전히 대칭적으로** 정상
|
||||
`Dispatch.process` 경로를 타고 아래 전담 Handler에 매치됨.
|
||||
3. **두 패스가 끝난 뒤 `Dispatch.drive`가 `postRefList`를 순회하며 각
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -94,6 +94,18 @@ RefSource라는 별도 타입은 폐기**하는 쪽으로 수렴.
|
|||
불필요 — Source 객체 자체가 저장소 역할을 함. 이 모델은 이전에
|
||||
검토했던 "State를 weak table로 캐싱" 절충안보다 더 싸다(래퍼 생성/
|
||||
캐싱 단계 자체가 사라짐).
|
||||
- **⭐ [2026-08-21 확정] `Source`는 `Epoch` 인터페이스도 같은 방식으로
|
||||
구조적으로 만족한다** — `type Epoch = { Revision: number }`이고, `:Set()`/
|
||||
`:Emit()`이 그 `Revision`을 **갱신한다**(직전과 다른 값으로 — `Epoch`
|
||||
계약이 요구하는 건 "다르다"뿐이라 방향은 계약이 아니고, 확정된 연산
|
||||
`bit32.bnot(-rev)`는 실제로 **감소**한다). **`Revision`은 공개 필드여야
|
||||
한다** — 비공개면 구조적 만족이 타입 레벨에서 성립하지 않는다(사용자:
|
||||
*"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*). `Store`와 달리
|
||||
`Source`는 키가 사용자 것이 아니라 예약 이름이 늘어도 충돌하지 않는다.
|
||||
런타임 판별은 `SourceBrand`와 **동시에** `EpochBrand`에 등록하는 것으로
|
||||
하고(다중 태깅, `base/brand-plan.md`), `State`를 만족하는 관계와 달리 이건
|
||||
포함 관계가 아니라 **다른 축의 계약**이라 predicate 합성으로는 표현되지
|
||||
않는다. 계약 전량은 `base/state-epoch-plan.md` §2가 소스.
|
||||
- **이 서브타입 관계는 `quad2-try`에서 기각한 컴포넌트/클래스 OOP 상속과는
|
||||
다른 층위.** 그때 금지한 건 사용자가 짜는 컴포넌트 계층 구조(`Class:Extend()`류
|
||||
매직)였고, 지금은 두 프리미티브 타입 사이의 구조적 서브타이핑(런타임
|
||||
|
|
@ -187,23 +199,26 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
|
|||
- `Source`는 값이 바뀌면 구독 중인 State들에게 **"무효화됐다"는 신호만
|
||||
쏜다** — 새 값 자체는 신호에 안 실림("state는 세터를 내보내기보다
|
||||
업데이트 됐다는 신호만 쏜다" — 사용자 확정 문구).
|
||||
- **⭐ emit(무효화 신호)은 구독자에게 *항상* 전파된다 — 자기 `invalid`
|
||||
상태와 무관.** 신호를 받은 State는 자기 `invalid` 플래그를 세우고,
|
||||
**이미 `invalid`였더라도 그대로 아래로 전파한다.**
|
||||
- **`invalid` 플래그의 역할은 "내 캐시가 낡았다"는 표시 하나뿐** —
|
||||
전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면 상류로 올라가
|
||||
재계산하고, 그 결과를 캐시에 넣고, `invalid`를 끈다.
|
||||
- **emit 전파를 늦추거나 흡수할 수 있는 건 명시적인 게이트 요소뿐** —
|
||||
지금은 `Blocker`(`base/blocker-plan.md`)가 유일하고,
|
||||
`base/debounce-throttle-plan.md`의 시간 기반 게이트가 같은 자리에
|
||||
들어감(설계 확정, 구현은 아직). **평범한 State는 절대 신호를 삼키지
|
||||
않는다.**
|
||||
- **[2026-08-14 정정 — 중요]** 이 자리엔 원래 "이미 `invalid`였다면 그
|
||||
아래로 더 전파하지 않는다(다이아몬드 중복 워크 방지)"라고 적혀 있었으나
|
||||
**틀린 서술이라 뒤집힘**. 그대로 두면 `:Get()`을 호출하지 않는
|
||||
`Observer`(아래 "`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가
|
||||
**한 번 울고 영구히 침묵**하게 됨. 원문·역전 근거·영향 범위는
|
||||
`archive/invalidate-dedup-propagation-reversed.md` 참고.
|
||||
- **⭐ [2026-08-21 재확정 — 판정 주체가 `invalid`에서 `Epoch` 리비전으로 바뀜]
|
||||
emit은 자기 `invalid` 상태와 **무관하게** 전파되고, 접히는 것은 오직
|
||||
**같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때**뿐이다.** 판정 규칙
|
||||
전량은 **`base/state-epoch-plan.md`가 소스** — 여기선 이 절의 다른 서술과
|
||||
어긋나지 않게 요지만 적는다. 여기 있던 "emit은 *항상* 전파된다"는 무조건
|
||||
서술의 역전 원문은 `archive/always-propagate-no-dedup-superseded.md`.
|
||||
- **`invalid`(구현 이름 `rawInvalid`) 플래그의 역할은 "내 캐시가 낡았다"는
|
||||
표시 하나뿐** — 전파를 제어하는 장치가 **아님**. `:Get()`이 호출되면
|
||||
상류로 올라가 재계산하고, 그 결과를 캐시에 넣고, 플래그를 끈다.
|
||||
- **⚠️ `invalid`로 전파를 접는 것은 지금도 금지다.** 2026-08-14에 그
|
||||
방식이 폐기된 이유는 `:Get()`을 호출하지 않는 `Observer`(아래
|
||||
"`state:Observer(fn)`" 절이 명시적으로 허용하는 사용법)가 **한 번 울고
|
||||
영구히 침묵**하기 때문이고, 그 실패 모드는 지금도 유효하다(원문은
|
||||
`archive/invalidate-dedup-propagation-reversed.md`). 리비전 비교엔 그
|
||||
모드가 없다 — 매 `Set`이 **새 리비전**이라 항상 통과하고, 접히는 건
|
||||
**다이아몬드에서 같은 리비전이 두 경로로 도착한 두 번째**뿐이다.
|
||||
- **emit 전파를 늦추거나 흡수할 수 있는 건 위 dedup 외엔 명시적인 게이트
|
||||
요소뿐** — `state:Gate(setup)`(`base/gate-plan.md`)와 그 위에 얹히는
|
||||
`Blocker`(`base/blocker-plan.md`), `base/debounce-throttle-plan.md`의 시간
|
||||
기반 정책. **평범한 State는 그 외의 이유로 신호를 삼키지 않는다.**
|
||||
- 실제 재계산은 `:Get()`이 호출되는 시점에만 일어남 —
|
||||
"필요할 때 계산" 원칙(사용자 확정). Fusion의 `timeliness="eager"` 노드/
|
||||
생성순 정렬 장치는 만들지 않음 — quad엔 그런 다단계 즉시 재계산이 필요한
|
||||
|
|
@ -213,7 +228,7 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
|
|||
State 스스로 "지금 나를 보는 eager 소비자가 있나" 같은 부기가 전혀
|
||||
필요 없음.
|
||||
- `emit`은 이 무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함
|
||||
("emit 필요 여부" 열린 질문은 이걸로 해소). 항상 전파해도 부담이 작은
|
||||
("emit 필요 여부" 열린 질문은 이걸로 해소). 전파가 잦아도 부담이 작은
|
||||
이유이기도 함 — 신호 하나가 트리를 훑는 비용이지 재계산 비용이 아님.
|
||||
|
||||
### 다이아몬드 의존성은 무엇이 푸는가 (2026-08-14 명확화)
|
||||
|
|
@ -231,18 +246,23 @@ Handler가 애초에 다른 층위. 관련해서 Handler를 담는 엔진(`Dispa
|
|||
그대로 반환**.
|
||||
|
||||
즉 **중복 재계산을 막는 주체는 pull-recompute + 캐시**이지, 전파를
|
||||
중간에 끊는 게 아니다(`base/architecture.md`의 전파 모델 요약과 같은
|
||||
`invalid`로 끊는 게 아니다(`base/architecture.md`의 전파 모델 요약과 같은
|
||||
이야기). Vide가 `todo.md`에 미해결로 남긴 "다이아몬드 중복 **재평가**"는
|
||||
이 캐시 구조로 풀리고, quad가 추가로 접지 않는 것은 **중복 *통지***뿐이다
|
||||
— `d` 아래의 `Observer`는 한 사이클에 두 번 울 수 있고, 그건 의도된
|
||||
동작이다(통지를 접으려면 `Blocker` 같은 명시적 게이트를 쓸 것).
|
||||
이 캐시 구조로 풀린다.
|
||||
|
||||
- **순회 비용이 실측에서 문제가 되면** 그때 "**한 번의 전파 파동
|
||||
안에서만** 같은 노드를 두 번 방문하지 않는다"는 최적화를 넣을 수 있음
|
||||
(방문 집합/에포크 카운터). 단 그건 **파동 단위**여야 하고, 지금처럼
|
||||
시간에 걸쳐 유지되는 `invalid` 플래그로 하면 안 됨 — 그게 위 정정의
|
||||
핵심. 이 최적화는 의미론에 안 보이는 순수 구현 사항이라 지금 결정할
|
||||
필요 없음.
|
||||
**⭐ [2026-08-21 역전] 중복 *통지*도 이제 접힌다.** 여기엔 원래 "quad가 추가로
|
||||
접지 않는 것은 중복 통지뿐이고, `d` 아래 `Observer`가 한 사이클에 두 번 우는
|
||||
것은 의도된 동작"이라고 적혀 있었으나, `base/state-epoch-plan.md` 채택으로
|
||||
**두 번째 신호는 삼켜진다** — 그 신호가 나르는 리비전을 `d`가 이미 봤기
|
||||
때문이다. 그래서 위 1단계는 "`d`가 두 번 받는다"가 아니라 **"두 번째는
|
||||
`d`에서 멈춘다"**가 된다. 역전 원문과 이게 2026-08-14 역전을 되돌린 게 아닌
|
||||
이유는 `archive/always-propagate-no-dedup-superseded.md`.
|
||||
|
||||
**부수로 같이 고쳐진 것 — 섞인 값(glitch).** 옛 모델에선 전파가 DFS라
|
||||
`b` 가지가 먼저 끝까지 내려가고, 그 아래 Observer가 `d:Get()`을 부르면 `c`는
|
||||
아직 신호를 못 받아 **옛 캐시를 반환**해 `d`가 `(b_new, c_old)`를 캐시했다.
|
||||
리비전 비교는 `c`가 신호 없이도 스스로 낡음을 알아채므로 이 창이 없다 —
|
||||
상세와 재현 시나리오는 `base/state-epoch-plan.md` §1.
|
||||
|
||||
**전역 원칙으로 명문화: "관측해야 실체화된다" (2026-08-04 세션)**
|
||||
|
||||
|
|
@ -369,8 +389,11 @@ lazy State 핸들로 통일, 아래 "`:With`/`:Compute` — self 인자도 lazy
|
|||
`Blocker` 참고.** 위 `:With`+`:Compute`만으로는 "state1, state2를 연달아
|
||||
Set하면 결합된 파생값이 두 번 재계산/재대입된다"는 문제(즉시 pull하는
|
||||
store-bind 소비자 기준)는 안 풀림 — 이건 별도 확정 프리미티브
|
||||
`base/blocker-plan.md`가 다룸(State 개발과 같은
|
||||
마일스톤, `ROADMAP.md` M3에서 함께 구현). lexical `Batch(fn)`으로 풀려던
|
||||
`base/blocker-plan.md`가 다룸(**[2026-08-22 정정]** 여기 "State 개발과 같은
|
||||
마일스톤, `ROADMAP.md` M3에서 함께 구현"이라 적혀 있었으나 `Blocker.luau`는
|
||||
**M2**로 이동했고, 바닥부터 짜는 게 아니라 공용 `GateNode`
|
||||
(`base/gate-plan.md`) 위의 정책이다 — 마일스톤 소속의 소스는
|
||||
`blocker-plan.md`의 정정 배너와 `ROADMAP.md` M2). lexical `Batch(fn)`으로 풀려던
|
||||
초기 시도는 코루틴 yield 위에서 구조적으로 위험해 기각됨 —
|
||||
`archive/batch-rejected.md` 참고.
|
||||
|
||||
|
|
@ -912,6 +935,26 @@ retract/Destroy되면 자동으로 정리됨.
|
|||
두는 것만으로 최초 적용까지 공짜로 됨(별도의 "설치 시 1회 적용" 코드를
|
||||
따로 안 짜도 됨). `state:Observer()`(인자 없는 "항상 관측" 유틸)도
|
||||
이 규칙을 그대로 따름 — 호출 즉시 한 번 관측이 트리거됨.
|
||||
- **⭐ [2026-08-21 확정] `fn`의 시그니처는
|
||||
`fn(self, from: (Epoch | EpochSet)?)` — `:Compute`와 같은 모양이다.** 사용자: *"Compute 와 유사하게 나올 수
|
||||
있다 봐요. self 를 넘겨주고, 그 뒤에 epoch|{epoch} 를 주는게 맞아보입니다."*
|
||||
위 "`:With`/`:Compute` — self 인자도 lazy 핸들로 통일" 절과 같은 결이다.
|
||||
- `self`는 이 Observer가 붙은 State의 **lazy 핸들**(값이 아님).
|
||||
- `from`은 **이 통지의 출처**다 — `Epoch` 하나이거나 `Epoch`들의 **집합**
|
||||
(`{[Epoch]: true}`, 게이트가 유보를 풀며 떼어낸 스냅샷). 값도 리비전도
|
||||
안 실린다. 분기는 `isEpoch`로. 계약은 `base/state-epoch-plan.md` §5가 소스.
|
||||
- **⚠️ [2026-08-22 신설] 등록 시점의 즉시 1회 실행에는 `from`이 없다** —
|
||||
그건 통지가 아니라 설치라 출처가 존재하지 않는다. 그래서 `from`은
|
||||
**옵셔널**이고, 이때만 `nil`이다(2026-08-21 커밋 전 `/code-review high`
|
||||
발견 — 한때 non-optional로 적혀 있었다). `fn`이 `from`을 실제로 쓰는
|
||||
소비자라면 `nil`을 "설치 발화"로 분기해야 한다.
|
||||
- **이건 "값을 안 실어주는 구독" 계약을 안 깬다** — 넘기는 건 값이 아니라
|
||||
**핸들과 메타데이터**뿐이다.
|
||||
- 인자 없는 `state:Observer()`(항상 관측 유틸)도 그대로 성립한다 — 넘겨줄
|
||||
`fn`이 없으니 인자 얘기가 아예 안 나온다.
|
||||
- **`from`을 실제로 쓰는 첫 소비자는 `Effect`다** — 자기 `EpochMap`을
|
||||
들고 각 내부 Observer가 그걸 `Update`해서 다중 의존성 중복 발화를
|
||||
접는다(`base/effect-plan.md`의 "`Effect(fn, ...deps)`" 절).
|
||||
- **값을 안 실어줌 — 반드시 `Get()`을 다시 해야 함.** 기존 "emit은
|
||||
무효화 신호 하나로 좁혀짐 — 값을 안 실어보내므로 저렴함" 원칙(위
|
||||
"전파 모델 확정" 절)이 그대로 적용됨: `fn`은 "뭔가
|
||||
|
|
@ -921,15 +964,18 @@ retract/Destroy되면 자동으로 정리됨.
|
|||
`:With`한 값에 따라 갈리는 경우가 있어서(위 "포지셔널 인자 지양" 절의
|
||||
`noprint` 예시처럼 계산 자체를 통째로 생략하고 싶을 수 있음) — `Get()`
|
||||
호출 여부를 작성자가 직접 결정하게 열어둔 것.
|
||||
- **⚠️ 이 허용이 전파 모델의 "emit은 항상 전파된다"에 의존한다
|
||||
(2026-08-14 명시).** `fn`이 `:Get()`을 안 하면 상류 State는 계속
|
||||
`invalid`로 남는데, 만약 "이미 `invalid`면 전파를 멈춘다"는 규칙이
|
||||
있으면 **이 Observer는 두 번째 변경부터 영원히 안 울림**. 실제로
|
||||
2026-08-14 이전까지 위 "전파 모델 확정" 절에 그런 문장이 있었고,
|
||||
이 계약과 정면 충돌하는 상태로 방치돼 있었음
|
||||
(`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은
|
||||
같이 움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면
|
||||
반드시 이 항목부터 확인할 것.
|
||||
- **⚠️ 이 허용이 전파 규칙에 의존한다(2026-08-14 명시, 2026-08-21 갱신).**
|
||||
의존하는 명제는 **"`Set` 한 번은 새 리비전이라 항상 통과한다"** 하나다 —
|
||||
dedup이 접는 건 *같은* `Epoch`의 *같은* 리비전이 두 번째로 도착한
|
||||
것뿐이라, 이 Observer는 매 변경마다 정확히 한 번 운다
|
||||
(`base/state-epoch-plan.md` §4). **`invalid`로 전파를 접는 최적화는
|
||||
지금도 금지**다 — `fn`이 `:Get()`을 안 하면 상류 State는 계속
|
||||
`invalid`로 남으므로, 그런 규칙이 있으면 **이 Observer는 두 번째
|
||||
변경부터 영원히 안 울린다**(2026-08-14 이전에 실제로 그 문장이 위
|
||||
"전파 모델 확정" 절에 있었고 이 계약과 충돌한 채 방치됐었다 —
|
||||
`archive/invalidate-dedup-propagation-reversed.md`). **두 서술은 같이
|
||||
움직여야 함** — 전파를 접는 최적화를 다시 넣고 싶어지면 반드시 이
|
||||
항목부터 확인할 것.
|
||||
- **`fn`을 커링 스타일로 짜는 것도 모듈화 관용구로 권장(2026-08-07 여섯
|
||||
번째 세션)** — `state:Observer(makeLogger("x"))`처럼 팩토리가 실제
|
||||
`fn`을 만들어 반환하는 패턴, `Modifier`의 `Boldify(10)` 커링(`modifier-plan.md`
|
||||
|
|
|
|||
443
.claude/base/state-epoch-plan.md
Normal file
443
.claude/base/state-epoch-plan.md
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
# State 재계산/전파 판정 — `Epoch` 비교와 `EpochMap` 컴포지션 (2026-08-21 확정)
|
||||
|
||||
**상태**: **확정.** 사용자 제안으로 시작해 같은 날 여러 라운드에 걸쳐 다듬은 뒤
|
||||
**채택 확정**됨 — *"gate 와 epoch 가 제가 만족할만한 정도로 올라왔습니다.
|
||||
채택하면 될것 같아요."*
|
||||
**구현 마일스톤은 둘로 갈린다** — **[2026-08-22 정정]** 여기 "구현은 M3"라고만
|
||||
적혀 있었다. 부기 객체 `EpochMap.luau`와 `Epoch` 인터페이스·리비전 갱신
|
||||
규칙은 **M2**(`GateNode`가 `emitEpochMap`을 쓰므로 — `ROADMAP.md` M2),
|
||||
그걸 `valueEpochMap`/`emitEpochMap` 둘로 컴포지션하는 **State 본체 통합은
|
||||
M3**다.
|
||||
|
||||
**⚠️ 이 문서는 `base/source-state-plan.md`의 "전파 모델 확정" 절을 대체하는
|
||||
게 아니라 그 절이 정한 push-invalidate/pull-recompute 위에 **판정 규칙**을
|
||||
얹는다.** 그 절이 원래 갖고 있던 두 서술은 이 채택으로 뒤집혔고, 역전 원문은
|
||||
`archive/always-propagate-no-dedup-superseded.md`에 있다.
|
||||
|
||||
**읽는 순서**: 규칙만 필요하면 **§2~§4**만 보면 된다. §1은 왜 이걸 하는가
|
||||
(실재하는 glitch), §5는 emit 페이로드, §6은 State 밖의 소비자, §7은 비용,
|
||||
§8은 구현 시 확인할 것.
|
||||
|
||||
**히스토리**: 구현 전 QA 5라운드(`SS-2`/`SS-3`)에서 나왔고, 회신 원문은
|
||||
`qa-request/pre-implementation-qa-round5-response.md`, 여러 라운드의 정정
|
||||
경위는 `qa-request/pre-implementation-qa-round5-followup.md`의 M·N절이 소스.
|
||||
`Epoch`/`EpochMap`으로 일반화한 마지막 라운드의 근거 기록은
|
||||
`reference/epoch-brand-composition.md`.
|
||||
|
||||
## 1. 사용자가 지목한 문제 (실재함)
|
||||
|
||||
```
|
||||
A ──> B ──┐
|
||||
└──> C ──┴──> D
|
||||
```
|
||||
|
||||
`A:Set()` 한 번에 대해 옛 모델(push-invalidate + pull-recompute, 판정 주체가
|
||||
`invalid` 플래그)에서:
|
||||
|
||||
1. `A`가 구독자에게 무효화 신호를 전파한다. 순회가 DFS라 **`B` 쪽 가지가 먼저
|
||||
끝까지 내려간다** — `D`가 `B`를 통해 신호를 받고, 그 아래 Observer가 발화한다.
|
||||
2. 그 Observer가 `D:Get()`을 부른다. `D`는 상류를 `Get()`하는데, **`C`는 아직
|
||||
신호를 못 받아 `invalid`가 아니다** → `C`는 **옛 캐시를 그대로 반환**한다.
|
||||
3. `D`는 `(B_new, C_old)`라는 **섞인 값**을 계산해 캐시하고 `invalid`를 끈다.
|
||||
4. 뒤늦게 `C` 가지의 전파가 도착해 `D`가 다시 무효화되고, Observer가 또 울고,
|
||||
그때서야 `(B_new, C_new)`로 교정된다.
|
||||
|
||||
즉 **(a) 한 사이클 안에서 잘못된 값이 한 번 관측되고**(그 값으로 이미 프로퍼티가
|
||||
써지는 등 부작용이 나간다), **(b) 같은 계산이 두 번 돈다.** 리액티브 문헌에서
|
||||
말하는 전형적인 **glitch**다.
|
||||
|
||||
`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절은
|
||||
**중복 재계산이 없다**고만 말하는데, 그 논증은 *"누군가 `d:Get()`을 부르는
|
||||
시점"이 전파 파동이 끝난 뒤*라고 암묵적으로 가정한다 — Observer가 전파 도중에
|
||||
발화한다는 사실과 겹쳐 읽으면 그 가정이 깨진다. 아래 규칙이 이걸 고친다.
|
||||
|
||||
## 2. `Epoch` — 판정의 최소 인터페이스
|
||||
|
||||
**판정에 필요한 건 둘뿐이다: identity와 "직전과 달라지는 리비전".** 그걸
|
||||
이름 붙인 것이 `Epoch`다.
|
||||
|
||||
```lua
|
||||
type Epoch = { Revision: number }
|
||||
```
|
||||
|
||||
- **그 자체로 키가 되는 unique 테이블**이다 — `EpochMap`이 `[epoch] = revision`
|
||||
으로 들고 있는다.
|
||||
- **`Source`가 이 인터페이스를 구조적으로 만족한다** — `Source`가 `State`를
|
||||
구조적으로 만족하는 기존 패턴(`base/source-state-plan.md`의 "Source가 State를
|
||||
만족함" 절)과 정확히 같은 모양. `Source:Set()`/`:Emit()`이 자기 `Revision`을
|
||||
**갱신한다**(직전과 다른 값으로 — 방향은 계약이 아니고, 아래 확정된 연산은
|
||||
실제로 증가가 아니라 **감소**한다).
|
||||
- **`Revision`은 공개 필드다.** 비공개면 구조적 만족이 **타입 레벨에서 성립하지
|
||||
않는다**(사용자: *"그래야 타입 상 `Source` 가 `Epoch`를 만족해요."*).
|
||||
`Store`와 달리 `Source`는 키가 사용자 것이 아니므로 예약 이름이 늘어도
|
||||
충돌하지 않는다 — *"`Source` 는 예약 이름이 늘어나도 됩니다(store 아님)."*
|
||||
- **런타임 판별은 `EpochBrand:is(x)`**(`base/brand-plan.md`). `Source`는
|
||||
`SourceBrand`이면서 동시에 `EpochBrand`에 등록된다 — 이 다중 태깅 요구가
|
||||
`Brand`를 인스턴스 브랜드로 재작성한 직접 발단이다.
|
||||
- **왜 `Source`가 아니라 `Epoch`인가**: 맵이 요구하는 건 identity + 리비전
|
||||
둘뿐인데 그걸 `Source`로 못 박으면 계약이 실제보다 좁아진다. 사용자:
|
||||
*"'소스를 전해주는것' 이라고 보기엔 너무 협소하고, 일반화된 형태가 아님."*
|
||||
일반화의 실익은 **`Source`가 아닌 원천(외부 시계 등)이 특수 분기 없이
|
||||
낀다**는 것이고, 그건 `EpochBrand:register(self)` 한 줄로 끝난다.
|
||||
|
||||
**계약은 "직전 값과 다르다"만 요구한다.** 순서 비교(`<`)는 아래 규칙 어디에도
|
||||
안 쓰이고 전부 `==`/`~=`뿐이라, 단조 증가조차 계약으로는 과하다 — 실제로
|
||||
**아래 확정된 방식은 증가가 아니라 감소한다**(그리고 0에서 한 바퀴 돈다).
|
||||
규칙이 `==`/`~=`만 쓰기 때문에 그게 문제가 안 되는 것이므로, **나중에 순서
|
||||
비교를 넣고 싶어지면 이 결정부터 되짚을 것.** 이름이 `Revision`인 것도
|
||||
순서를 뜻하지 않는다 — "직전과 구별되는 표식"이라는 뜻이다.
|
||||
|
||||
- **배경 — 평이한 `+1`이었다면 `2^53`에서 계약이 깨졌다.** Luau 숫자는
|
||||
double이라 랩어라운드가 아니라 **포화**한다 — `2^53`을 넘으면 `n + 1 == n`이
|
||||
되어 "다르다"는 보장이 정확히 그 지점에서 깨진다(**초당 100만 `Set`으로
|
||||
285년**이라 도달 불가능하긴 하다). 아래 확정된 `bit32` 랩은 **이 지점 자체를
|
||||
없앤다.** 이걸 근거로 적을 땐 "오버플로해도 다르다"가 아니라 **"도달
|
||||
불가능하다"**로 적을 것 — 전자는 틀린 서술이다.
|
||||
- **⭐ [2026-08-21 확정] 리비전 갱신은 `bit32.bnot(-rev)` 한 번이다 — 리비전은
|
||||
uint32 안에 머문다.**
|
||||
|
||||
```lua
|
||||
self.Revision = bit32.bnot(-self.Revision)
|
||||
```
|
||||
|
||||
**[2026-08-22 실측] 이건 랩어라운드 감소다** — `a > 0`이면 `a - 1`,
|
||||
`a == 0`이면 `4294967295`로 한 바퀴 돈다. `luau`로 확인한 값:
|
||||
|
||||
| `rev` | `bit32.bnot(-rev)` |
|
||||
|---|---|
|
||||
| 0 | 4294967295 |
|
||||
| 1 | 0 |
|
||||
| 2 | 1 |
|
||||
| 4294967295 | 4294967294 |
|
||||
|
||||
(원리: `bit32.bnot(x) == 4294967295 - (x mod 2^32)`이고 `a > 0`에서
|
||||
`(-a) mod 2^32 == 2^32 - a`라 `a - 1`이 된다. 시작값이 무엇이든
|
||||
상관없다 — 매 호출이 직전과 다른 값을 준다는 것만 계약이다.)
|
||||
|
||||
**사용자 논거**(2026-08-21): *"그건 luau 에서 native call 이라 아주
|
||||
빨라요. 반면 double 의 연산이 느린편인데, 희소 수준이 아니라, 사실상
|
||||
만나는걸 수년간 보기 어려운 라운드되어 동일해 무시되는 경우를 막기 위해
|
||||
double 까지 올려야할 이유를 모르겠어요. 매번 도는 코드인지라, 값 싸게
|
||||
native call + num 연산으로 가볍게 가고 싶어요."* — 즉 **`2^53` 포화는
|
||||
어차피 도달 불가능한 시나리오인데, 그걸 피하겠다고 값을 double 영역까지
|
||||
키울 이유가 없다**는 것. 이건 매 `Set`마다 도는 hot path다.
|
||||
- **갱신과 랩이 같은 연산 하나다.** `bit32.bnot`은 Luau가 FASTCALL로
|
||||
거는 빌트인이라, 별도의 덧셈도 마스킹도 없다 — 단항 부호 반전 하나가
|
||||
붙을 뿐이다.
|
||||
- 랩이라 **`2^53` 포화(`n + 1 == n`)가 아예 안 생긴다** — 위 배경 항목이
|
||||
말하는 계약 파손 지점 자체가 사라진다.
|
||||
- **⚠️ [2026-08-22 정정] 대신 생기는 `2^32` 랩은 "똑같이 도달 불가능"이
|
||||
아니다.** 여기 그렇게 적어뒀는데 **수치가 틀렸다** — 위 배경 항목과 같은
|
||||
척도(초당 100만 `Set`)로 `2^53`은 285년이지만 **`2^32`는 약 72분**이다
|
||||
(20만 배 차이). 현실적인 부하(초당 1만 `Set`)로도 5일 남짓이다.
|
||||
**그래도 위험하지 않은 이유는 도달 시간이 아니라 충돌 조건이 한 점이기
|
||||
때문이다**: 오판정이 나려면 **어떤 맵 항목이 그 `Epoch`에 대해 정확히
|
||||
`2^32`만큼 뒤처져** 있어야 한다. 한 바퀴에서 하나라도 어긋나면 값이 달라
|
||||
정상 판정된다. 그 항목은 emit을 받거나 `:Refresh`를 도는 순간 갱신되므로,
|
||||
"정확히 한 바퀴 동안 한 번도 안 건드려진 항목"이라야 한다. 확률적으로
|
||||
무시 가능하다는 뜻이지 **산술적으로 불가능하다는 뜻이 아니다** — 이 문서가
|
||||
바로 위에서 "근거를 정확히 적을 것"이라 규정했으므로 같은 기준을 적용한다.
|
||||
(2026-08-21 커밋 전 `/code-review high` 발견.)
|
||||
- **⚠️ [2026-08-22 정정] 여기 한때 `bit32.band(rev + 1, 0xFFFFFFFF)`라
|
||||
적고 "`bit32`가 double 덧셈 위에 fastcall을 하나 더 얹는다"는 단서를
|
||||
달아뒀는데, 둘 다 틀렸다.** 사용자가 말한 형태는 처음부터
|
||||
`bit32.bnot(-a)`였고(*"제가 말한건, bit32.bnot(-a) 입니다"*), 그건
|
||||
**덧셈을 얹는 게 아니라 갱신 자체를 대체한다.** 그래서 "`bit32`라서
|
||||
더 싸다"는 사용자 서술이 맞고, 그걸 반박한 에이전트 단서가 틀렸었다 —
|
||||
`band(n + 1, mask)`라는 **다른 형태**를 놓고 한 비교였기 때문.
|
||||
- **테이블 identity를 리비전으로 쓰는 대안은 채택 안 함**(사용자 선택:
|
||||
*"Revision 숫자로 가는걸 저는 선택하고 싶어요"*). 기능적으로는 둘 다
|
||||
성립하지만 테이블안은 **`Set` 한 번마다 테이블 하나를 할당**해서, 트윈처럼
|
||||
매 프레임 `Set`하는 소스가 여럿이면 GC 압력을 만든다(quad는 GC-native
|
||||
아키텍처라 이 축을 신경 써왔다).
|
||||
|
||||
## 3. `EpochMap` — 컴포지션 가능한 부기 객체
|
||||
|
||||
**에포크 부기를 State에서 떼어내 재사용 가능한 객체로 만든다**(사용자 제안).
|
||||
그래야 **노드가 아닌 소비자(leaf)도 같은 판정을 쓸 수 있다** — `Effect`가
|
||||
그 첫 수요자다(§6).
|
||||
|
||||
```lua
|
||||
type EpochSet = { [Epoch]: true } -- 배열이 아니라 집합이다 (아래 ⚠️ 참고)
|
||||
|
||||
EpochMap() -> EpochMap
|
||||
|
||||
EpochMap:Update(Epoch | EpochSet) -> boolean -- "뒤로 전파가 필요한가"
|
||||
EpochMap:Refresh() -> boolean -- 자기 키 전부를 라이브로 다시 읽음
|
||||
EpochMap:Sync(Epoch | EpochSet) -- 읽지 않고 쓰기만 (반환값 없음)
|
||||
EpochMap:TrackFrom(other: EpochMap) -- other가 추적 중인 키를 넘겨받아 라이브 리비전으로 채움
|
||||
```
|
||||
|
||||
**⚠️ [2026-08-22 정정] 여러 개를 넘길 때는 `{Epoch}`(배열)가 아니라
|
||||
`{[Epoch]: true}`(집합)다.** 여기 한때 `{Epoch}`로 적혀 있었는데, Luau에서
|
||||
그건 `{[number]: Epoch}` 배열이라 **실제로 넘어오는 게이트 배치와 타입이
|
||||
다르다** — 배치는 `base/gate-plan.md` 4번이 확정한 `withheld : { [epoch] : true }`를
|
||||
그대로 스왑해 넘긴 것이다. 이 표기를 믿고 `ipairs`로 구현하면 배치를 순회할 때
|
||||
**원소가 0개**가 되어, 유보됐다 풀린 emit이 하류에서 전부 조용히 삼켜진다 —
|
||||
`gate-plan.md` 4번이 애초에 고치려던 바로 그 버그다. **집합이어야 하는 이유는
|
||||
게이트 쪽 요구**다: 유보 중 같은 `Epoch`가 여러 경로로 도착해도
|
||||
`withheld[epoch] = true`가 저절로 접어주고, 게이트-게이트 unfold(같은 절)도
|
||||
집합이라야 중복 없이 합쳐진다. (2026-08-21 커밋 전 `/code-review high` 발견.)
|
||||
|
||||
- **`:Update`가 이 객체의 전부다.** 넘어온 각 `Epoch`에 대해 저장된 리비전과
|
||||
`epoch.Revision`을 비교하고, 다르면 새 값으로 덮는다. **하나라도 달랐으면
|
||||
`true`.** 사용자: *"애초에 Update 자체가 전부 최신 상태로 만들고, 업데이트
|
||||
된게 있으면 true 를 던지는거라."*
|
||||
- **`EpochSet`을 받으므로 sync 연산이 따로 필요 없다** — 전체를 넘기면
|
||||
그게 곧 sync다. (한때 에이전트가 "`:Sync`가 필수"라고 적었으나 `Update`를
|
||||
"하나만 받는 것"으로 좁게 본 착오였고 철회됐다.)
|
||||
- **⭐ 이건 `invalid`와 다른 물건이다**(사용자: *"이건 invalid 랑은 다른
|
||||
구현이야."*). 반환값의 뜻은 "내 캐시가 낡았다"가 아니라 **"뒤로 전파가
|
||||
필요한가"** 하나다.
|
||||
- **내부 최적화**: 목록을 돌 때 diff 때문에 읽기가 들어가는데, **한 번
|
||||
다름을 찾으면 반환값이 이미 `true`로 확정**되므로 나머지는 읽지 않고
|
||||
쓰기만 하면 된다(사용자 제안).
|
||||
- **`:Refresh`는 인자 없는 `:Update`다** — 자기가 이미 들고 있는 키 전부를
|
||||
라이브로 다시 읽어 갱신하고, 하나라도 달랐으면 `true`. 아래 §4의 **순회**가
|
||||
이걸 쓴다(순회가 훑을 대상 목록이 곧 이 맵 자신이라 인자로 받을 게 없다).
|
||||
- **`:Sync`는 읽기를 건너뛰고 쓰기만 하는 변형**이다(반환값 없음). *"없다고
|
||||
안되는건 아닌데, 그냥 다 안 읽고 set 만 해버리는것은 처음 셋팅에 도움은
|
||||
됩니다."* **[2026-08-22 정정]** 여기 "초기화에만 쓴다"고 적혀 있었으나
|
||||
`base/gate-plan.md` 4번이 게이트의 flush 경로에서도 `:Sync(batch)`를 쓰는
|
||||
것으로 확정돼 있다 — 쓰는 자리는 **"반환값이 필요 없다고 이미 아는 곳"**
|
||||
둘이다: 노드 생성 시딩, 그리고 게이트가 실제로 전파할 때.
|
||||
- **⭐ [2026-08-22 신설] `:TrackFrom(other)` — 새 노드 시딩이 이걸 쓴다.**
|
||||
`other`가 추적 중인 키를 전부 넘겨받아 **라이브 리비전으로** 채운다
|
||||
(`other`의 저장값을 복사하는 게 아니다).
|
||||
- **왜 필요한가**: §4의 시딩 규칙은 "상류의 `Epoch`를 전부 끌어와 채운다"인데,
|
||||
`:With(a, b)`의 상류 `a`/`b`는 **State이지 `Epoch`가 아니다.** 그 State가
|
||||
추적 중인 루트 `Epoch` 집합은 그 State의 `valueEpochMap` 안에만 있으므로,
|
||||
키를 넘겨받는 연산이 없으면 시딩을 **표면으로 표현할 수가 없다**
|
||||
(2026-08-21 커밋 전 `/code-review high` 발견).
|
||||
- **새 설계가 아니라 이미 확정된 동작에 이름을 붙인 것**이다 — 사용자
|
||||
확정 문구가 이미 *"전부 가져와서, 실제 count 로 둡니다"*였다. `:Refresh`도
|
||||
같은 성격의 명명이다(§4의 "순회").
|
||||
- `Source`처럼 **자기가 곧 `Epoch`인 상류**는 `:TrackFrom`이 아니라
|
||||
`:Sync(dep)`로 직접 넣는다 — 아래 시딩 규칙 참고.
|
||||
- **이름 근거(사용자 확정, 2026-08-22)**: 가칭은 `Absorb`였는데 *"조금 상위
|
||||
요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름"*이라 바꿨다 — 이
|
||||
연산은 `other`를 **전혀 안 건드린다**. 게다가 `base/gate-plan.md`가 이미
|
||||
"흡수 집합"을 **다른 뜻**(emit을 붙들고 있음)으로 쓰고 있어 한 코퍼스 안에
|
||||
같은 단어가 두 의미로 놓이는 문제도 있었다. `TrackFrom`은 이 맵의 존재
|
||||
이유를 사용자가 표현한 말(*"'내가 뭘 추적하고 있나' 가 필요하죠"*)을 그대로
|
||||
쓰고, `From`이 방향을 못박아 비파괴가 드러나며, 나중에 동적 의존성으로
|
||||
생성 이후에 키를 더하는 자리가 생겨도 이름이 그대로 맞는다(그래서
|
||||
`SeedFrom`보다 낫다). 후보 비교는 `question.md`가 아니라 여기서 끝났다 —
|
||||
같은 자리에서 확정됐으므로 열린 항목이 아니다.
|
||||
- **키는 weak다.** `epoch`가 죽으면 항목이 사라진다. `base/relate-plan.md`가
|
||||
경고하는 "값이 키를 되참조하면 안 된다"는 제약은 값이 숫자라 문제없다.
|
||||
|
||||
## 4. State는 `EpochMap`을 둘 컴포지션한다
|
||||
|
||||
```
|
||||
State
|
||||
valueEpochMap : EpochMap -- "내 값이 이 Epoch에 대해 최신인가" (값 유효성)
|
||||
emitEpochMap : EpochMap -- "이 Epoch의 이 리비전을 하류로 이미 던졌는가" (전파 dedup)
|
||||
rawInvalid : boolean -- "재계산이 필요하다"는 확정 플래그
|
||||
```
|
||||
|
||||
**⭐ 왜 둘인가 — 순회 때문이다.** 사용자 정리: *"'내 값이, 상류의 상태로
|
||||
하여금 사용 가능하다' 를 보는 테이블과, '내가 emit을 받을 때, 그걸 다시
|
||||
던져야하는지 봐야하나' 를 보는걸 나누는거죠."* 순회가 없다면 **"값을
|
||||
최신화했다"와 "통지를 내려보냈다"가 항상 같은 스텝에서 같이 일어나서** 맵
|
||||
하나로 충분하다. 순회를 넣는 순간 그 둘이 갈라진다 — **순회는 값만 앞당기고
|
||||
통지는 안 한다.**
|
||||
|
||||
**⭐ 대원칙 — 무효화를 결정하는 건 언제나 리비전 비교지 emit의 도착이 아니다.**
|
||||
emit은 **"이 원천을 확인해봐"** 라는 요청일 뿐이다. 그래서 emit이 통과해도
|
||||
리비전이 이미 최신이면 **캐시는 유효한 채로 남는다**(아래 2번 규칙). 사용자
|
||||
정리: *"emit 자체가 invalid 하게 만드는 직접 트리거는 아니라서, (이미 count 가
|
||||
최신이면) 캐시가 유효하다."* 아래 나머지 규칙은 전부 이 원칙의 따름정리다.
|
||||
|
||||
### 노드가 생길 때의 초기값 — 두 맵이 서로 다르다
|
||||
|
||||
- **`emitEpochMap`은 비운 채로 시작한다.** 어떤 emit이 와도 "처음 보는 것"으로
|
||||
걸린다 — 그리고 그게 맞다. 사용자: *"새로 생성된 노드에서 들어온 emit 은,
|
||||
개념적으로 해당 노드가 한번도 받아본적 없는 emit 입니다."*
|
||||
- **`valueEpochMap`은 반대로 상류가 추적 중인 `Epoch`를 전부 끌어와 채우고,
|
||||
`rawInvalid = true`로 시작한다.** dep마다 갈린다 — **dep이 `Epoch`면**
|
||||
(`Source`) `:Sync(dep)`, **dep이 State면** `:TrackFrom(dep.valueEpochMap)`.
|
||||
분기는 `isEpoch`로 한다.
|
||||
```lua
|
||||
for _, dep in deps do
|
||||
if isEpoch(dep) then self.valueEpochMap:Sync(dep)
|
||||
else self.valueEpochMap:TrackFrom(dep.valueEpochMap) end
|
||||
end
|
||||
self.rawInvalid = true
|
||||
``` 이쪽은 비워두면 안 된다 — 순회가 훑을
|
||||
대상 목록이 곧 이 맵이라, 비어 있으면 **"훑을 게 없으니 유효하다"**로
|
||||
오판한다. 사용자: *"이건 상류가 주는대로 lazy 할 순 없는게, '내가 뭘
|
||||
추적하고 있나' 가 필요하죠. 따라서 다음을 제안합니다: 전부 가져와서, 실제
|
||||
count 로 둡니다. 그러고 `rawInvalid` 를 true 로 두세요."*
|
||||
- 그래서 **`:With`에서 두 상류가 같은 `Epoch`에 다른 리비전을 들고 있을 때의
|
||||
병합 규칙도 필요 없다** — 어차피 생성 시점의 라이브 리비전으로 통일된다.
|
||||
|
||||
### emit을 받았을 때 — 두 맵을 각각 `Update` 하고 그 boolean으로 결정한다
|
||||
|
||||
```lua
|
||||
local valueChanged = self.valueEpochMap:Update(from)
|
||||
local emitChanged = self.emitEpochMap:Update(from)
|
||||
if valueChanged then self.rawInvalid = true end
|
||||
if valueChanged or emitChanged then
|
||||
-- 뒤로 emit (받은 from을 그대로 넘긴다)
|
||||
end
|
||||
```
|
||||
|
||||
세 갈래로 읽으면 이렇다:
|
||||
|
||||
1. **`valueEpochMap`이 달랐다** → 값이 낡았다. `rawInvalid = true`를 세우고
|
||||
**뒤로 emit** 한다(두 맵 모두 갱신됨).
|
||||
2. **`valueEpochMap`은 같은데 `emitEpochMap`이 달랐다** → **값은 이미 최신이지만
|
||||
통지는 아직 안 나갔다**(순회가 앞질러 흡수했거나, 게이트가 붙들고 있는 동안
|
||||
하류가 `Get()`으로 앞당겨 읽은 경우). `emitEpochMap`만 갱신되고 **뒤로
|
||||
emit** 한다 — `rawInvalid`는 안 건드린다.
|
||||
3. **둘 다 같다** → **삼킨다.** 다이아몬드에서 같은 리비전이 두 경로로 도착한
|
||||
두 번째가 여기서 접힌다.
|
||||
|
||||
- **⚠️ [2026-08-22 신설] `GateNode`는 이 의사코드를 그대로 쓰지 않는다.**
|
||||
위 코드는 `emitEpochMap`을 **수신 시점에** 갱신하는데, 게이트는
|
||||
`base/gate-plan.md` 4번이 **전파할 때 `:Sync(batch)`로** 갱신하는 것으로
|
||||
확정돼 있다(그래야 "내가 하류로 던진 리비전"이라는 맵의 뜻이 게이트에서도
|
||||
참이 된다 — 유보 중엔 아직 안 던졌으니까). 그래서 게이트에서는:
|
||||
- 판정(규칙 1~3)은 **똑같이** 먼저 돈다. 규칙 3으로 삼켜지면 정책도 안 돌고
|
||||
흡수 집합에도 안 들어간다(`gate-plan.md` 4번).
|
||||
- 다만 `emitEpochMap:Update`를 수신 시점에 부르지 않으므로, **유보 중에
|
||||
같은 리비전이 다른 경로로 또 오면 규칙 2로 걸려 정책이 한 번 더 돈다.**
|
||||
이미 흡수 집합에 있어 무해하다(같은 절). 정책이 실제로 emit한 뒤에는
|
||||
`:Sync(batch)`가 돌아 있으므로 그 다음 도착은 정상적으로 규칙 3에 걸린다.
|
||||
- **이 예외가 여기 기록돼 있지 않았다**(2026-08-21 커밋 전 `/code-review high`
|
||||
발견) — §4대로 구현하면 `gate-plan.md` 4번의 계약이 조용히 깨진다.
|
||||
- **`from`이 하나면 판정은 O(1)이다** — 그 항목 하나만 본다. **다른 항목은
|
||||
건드리지 않는다**(그 `Epoch`들은 자기가 직접 emit 하므로).
|
||||
- **`from`이 집합이면**(게이트 배치, §5) `Update`가 알아서 순회하고, 하나라도
|
||||
달랐으면 `true`를 준다 — 규칙이 그대로 성립한다.
|
||||
|
||||
### 재계산 판정
|
||||
|
||||
- **`rawInvalid == true`** → 그냥 재계산한다. 순회할 이유가 없다(이미 확정).
|
||||
- **`rawInvalid == false`** → **그때만 `valueEpochMap:Refresh()`를 부른다.**
|
||||
목적은 하나뿐 — **못 받은 emit을 여기서 먼저 받아주는 것**(중간 게이트에
|
||||
막혀 있었다거나 전파 파동이 아직 이 가지에 안 닿았다거나). `true`가 나오면
|
||||
`rawInvalid = true`로 만들고 재계산한다.
|
||||
- **⭐ 순회는 `emitEpochMap`을 건드리지 않고, 뒤로 emit 하지도 않는다.**
|
||||
그래서 나중에 진짜 emit이 도착하면 위 2번으로 걸려 **하류까지 정상적으로
|
||||
전파된다**(사용자: *"emit 바로 안하고 상류가 emit 해줄 때 까지 기다립니다"*).
|
||||
이게 없으면 **값은 맞는데 통지가 죽는** 실패 모드가 생긴다 — 2026-08-14에
|
||||
폐기된 옛 dedup의 "영구 침묵"과 같은 계열이다
|
||||
(`archive/invalidate-dedup-propagation-reversed.md`).
|
||||
|
||||
### 재계산이 끝나면
|
||||
|
||||
- **`rawInvalid = false`**, 그리고 **`valueEpochMap`은 자기가 읽은 상류
|
||||
전부에 대해 갱신한다**(발행 `Epoch` 항목만이 아니다). 맵의 뜻이 "내 값이 이
|
||||
`Epoch`에 대해 최신인가"이므로, 방금 계산한 값은 정의상 **모든** 상류에 대해
|
||||
최신이다. 사용자: *"invalid 에 대한 계산을 위한 count 테이블은 단순히 전부
|
||||
업데이트 하는건 맞아보입니다."*
|
||||
- **`emitEpochMap`은 안 건드린다 — 계산은 통지가 아니다.**
|
||||
- 이 "전부 갱신"이 실제로 값을 하는 자리는 **게이트가 붙들고 있는 동안 하류가
|
||||
`Get()`으로 앞당겨 읽는 경우**다. 그때 `valueEpochMap`이 앞서 있으므로,
|
||||
나중에 게이트가 풀며 보내는 통지는 위 2번(통지만)으로 떨어져 **같은 값을
|
||||
다시 계산하지 않는다.**
|
||||
|
||||
## 5. emit 페이로드 — `Epoch | EpochSet`, 게이트는 안 싣는다
|
||||
|
||||
- **emit은 값도 리비전도 안 싣는다 — 싣는 건 "이 통지의 출처"뿐**이고, 그
|
||||
출처는 `Epoch` 하나이거나 `EpochSet`(`{[Epoch]: true}`)이다. 받는 쪽이 거기서 리비전을
|
||||
**그때그때 라이브로** 읽는다. 그래서 "리비전 5의 emit" 같은 건 없다.
|
||||
- **게이트 노드 자체는 페이로드에 안 싣는다.** 게이트가 유보를 풀 때
|
||||
(`blocker:Off()` 등) 넘기는 건 **그 자리에서 스왑해 떼어낸 `Epoch` 집합
|
||||
스냅샷**이다(`base/gate-plan.md`의 4번) — 게이트의 살아있는 `withheld`
|
||||
테이블이 아니다(재진입이 나도 바깥 전파가 빈 집합을 순회하지 않게 하기
|
||||
위함). 하류는 게이트 identity를 **한 번도 안 쓴다** — 게이트를 에포크
|
||||
경계로 만드는 안은 §8의 "기각된 대안 — 게이트를 에포크 경계로" 항목에서
|
||||
기각됐다. 사용자: *"하류가 Gate 노드를 받을 이유도 없거든."*
|
||||
- **런타임 분기는 `isEpoch`로 한다** — 단일이냐 집합이냐.
|
||||
- **평범한 노드는 출처를 안 바꾼다** — 자기를 끼워넣지 않고 받은 출처를 그대로
|
||||
아래로 넘긴다. **`GateNode`만 예외로 언제나 자기 배치를 새로 낸다**
|
||||
(`base/gate-plan.md`의 4번).
|
||||
- **⚠️ 받는 쪽이 `GateNode`면 배치를 그대로 넘기지 않고 풀어서 자기
|
||||
`withheld`에 합친다** — 배치는 상류 게이트가 이번 전파에만 쓰는 일회성
|
||||
스냅샷이라, 참조만 들고 있다가 나중에 풀면 그 배치가 이미 지나간 것이
|
||||
된다(같은 절).
|
||||
- **게이트는 배치가 비어 있으면 애초에 통지하지 않는다**(`base/gate-plan.md`의
|
||||
8번) — 그래서 위 규칙이 빈 집합을 받는 경우는 없다.
|
||||
|
||||
## 6. State 밖의 소비자도 같은 판정을 쓴다 — `Effect`
|
||||
|
||||
`EpochMap`을 떼어낸 실익이 여기서 나온다. **`Effect`가 자기 `EpochMap`을 하나
|
||||
들고** 각 의존성의 내부 Observer가 그걸 `Update`하면, 한 파동에 여러 dep가
|
||||
깨워도 **첫 번째만 `true`** 라 `fn`이 한 번만 돈다.
|
||||
|
||||
이건 `A → b`, `A → c`, `Effect(fn, b, c)`에서 **접어줄 공통 하류가 없어**
|
||||
State 층 dedup이 못 닫던 갭이다 — `Effect`가 자기 맵을 들면 그 지점이 곧
|
||||
공통 하류가 된다. 계약 전량은 `base/effect-plan.md`의 "`Effect(fn, ...deps)`"
|
||||
절이 소스.
|
||||
|
||||
**그래서 `Observer` 클로저는 출처를 인자로 받는다** —
|
||||
`fn(self, from: (Epoch | EpochSet)?)`. `:Compute`의 `fn(self, ...)`와 같은
|
||||
모양이고(설치 발화에는 출처가 없어 `nil`이다),
|
||||
값이 아니라 **핸들과 메타데이터**만 넘기므로 "값을 안 실어주는 구독" 계약은
|
||||
안 깨진다. `base/source-state-plan.md`의 "`state:Observer(fn)`" 절이 소스.
|
||||
|
||||
## 7. 비용
|
||||
|
||||
사용자 추산(*"해시 for은 이미 빠르고, Source가 … 수 자체가 적다. 2~4개에 대해
|
||||
인덱싱 하는 정도"*)에 동의한다. 덧붙일 것 둘:
|
||||
|
||||
- 두 맵의 크기는 **그 노드 상류에 있는 서로 다른 루트 `Epoch`의 수**다.
|
||||
체인이 길어져도 안 늘고, `:With`로 합류할 때만 는다. UI 파생값에서 이 수가
|
||||
큰 경우는 드물다.
|
||||
- **훑는 쪽(`rawInvalid == false`, 즉 "안 바뀐 것 같다")이 흔한 경로**다.
|
||||
그래도 훑는 대상이 위 크기(2~4)라 상수 시간에 가깝고, 정작 비싼 **재계산은
|
||||
안 도는** 경로다. 반대로 `rawInvalid == true`면 순회를 아예 건너뛰고 바로
|
||||
재계산한다.
|
||||
- 맵 하나당 객체 하나가 늘지만(State당 둘), 옛 모양도 테이블 둘이었으므로
|
||||
컴포지션으로 바뀌며 늘어난 비용은 메소드 디스패치뿐이다.
|
||||
|
||||
## 8. 구현 시 확인할 것
|
||||
|
||||
- **역전된 두 서술은 `archive/always-propagate-no-dedup-superseded.md`에 있다** —
|
||||
"emit은 자기 `invalid`와 무관하게 **항상** 전파된다"와 "quad가 접지 않는 것은
|
||||
중복 *통지*뿐이다". 지금 계약은 **"`invalid`로는 절대 안 접고, 같은 `Epoch`의
|
||||
같은 리비전이 두 번째로 도착했을 때만 접는다"**이다. 이 구분을 흐리면
|
||||
2026-08-14에 폐기된 "영구 침묵" 버그로 되돌아간다.
|
||||
- **선언 안 된 의존성에 대한 UB 조항은 안 만든다.** 에이전트가 "새 모델은 더
|
||||
강한 약속을 하니 예외를 UB로 못 박아야 한다"고 제안했으나 **사용자가 기각**:
|
||||
*"그건 아니다. 이 동작으로 인해 이제 정말로 항상 state 는 get 이 최신을
|
||||
던지는게 맞다. 상류의 상태를 물어보므로 그러함. 이전과 다른게 없다고
|
||||
생각한다."* — 선언 안 한 Source를 클로저로 읽는 건 **옛 모델에서도 똑같이
|
||||
stale**이었고 이 변경이 악화시키는 게 없다.
|
||||
- **동적 의존성**은 `valueEpochMap`이 보수적 상위집합이 된다 — 틀리진 않고
|
||||
재계산이 조금 더 잦아질 뿐이다.
|
||||
- **`Source:Emit()`**(값을 제자리에서 mutate하고 알리는 경로)은 `Revision`만
|
||||
갱신하면 그대로 동작한다(`:Set()`과 같은 연산 — §2).
|
||||
- **`blocker:OffWithoutEmit()`처럼 emit 없이 푸는 경로**에서 `emitEpochMap`이
|
||||
뒤에 남아도, 그 `Epoch`의 **다음 진짜 emit**이 §4의 1번(둘 다 다름)으로
|
||||
걸려 정상화된다 — 별도 조치가 필요 없다.
|
||||
- **`luau-test` 스파이크 하나**: §1의 다이아몬드를 그대로 짜서 (a) 리비전 비교
|
||||
없이는 섞인 값이 실제로 관측되는지, (b) 비교를 넣으면 사라지는지 대조.
|
||||
- **기각된 대안 — 게이트를 에포크 경계로.** 게이트가 **자기 자신을 하나의
|
||||
`Epoch`로** 내세우고(자기 `Revision`은 유보를 풀 때만 갱신) 하류가 상류
|
||||
원천 대신 **그 게이트만** 추적하게 하는 안. 배치를 나를 필요가 없어져
|
||||
페이로드가 단순해지지만, **`base/gate-plan.md` 3번의 공개 계약을
|
||||
정면으로 깬다** — *"게이트를 통과하지 않은 값도 `:Get()`으로는 보인다."*
|
||||
- **깨지는 경로**: 게이트가 붙들고 있는 동안 하류가 `:Get()`을 부르면
|
||||
§4의 재계산 판정이 `valueEpochMap:Refresh()`를 돈다. 그 맵이 추적하는
|
||||
게 게이트 하나뿐이면 게이트의 `Revision`은 아직 안 올랐으므로 **"나는
|
||||
최신"으로 오판하고 옛 캐시를 반환**한다. 지금처럼 원천 `Epoch`를 직접
|
||||
추적해야 순회가 상류의 실제 리비전을 보고 스스로 낡음을 알아챈다.
|
||||
- 즉 게이트는 **emit만 가로채지 값을 가리지 않는다**는 성질이 이 문서의
|
||||
핵심 보장(`:Get()`은 항상 최신)과 한 몸이다. `Blocker`
|
||||
(`base/blocker-plan.md`의 "`:Get()`엔 영향 없음")와
|
||||
`base/debounce-throttle-plan.md` §4도 같은 계약 위에 서 있으므로,
|
||||
이걸 뒤집으면 셋이 같이 무너진다.
|
||||
- **기각된 대안 — 순회가 만든 emit을 `rawEmit`으로 흘리고 `nil`을 던지는 안.**
|
||||
구조 위생은 좋았으나(상류 emit과 내부 발생 emit이 같은 진입점) 이 문제의
|
||||
해법으로는 못 쓴다 — (a) 막고 있는 게이트는 보통 **순회하는 노드 자신이
|
||||
아니라 상류**에 있어 누출이 그대로 남고, (b) `nil` emit은 받는 쪽마다 전체
|
||||
순회를 강제해 같은 "영구 침묵"이 하류에서 연쇄로 재발한다. 지금 안은 순회가
|
||||
애초에 emit을 안 하므로 두 문제가 **생기지 않는다.**
|
||||
- **곁가지 — 폴링용 슈가.** 사용자 제안: *"폴링을 위해서는 Apply(Realtime())
|
||||
같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수
|
||||
있게 해주는것. 단순하게 `Ref<T>` 로 변환해주는 등"*. **이 문서의 결정과
|
||||
독립**이고 `research/operator-sugar-plan.md`의 콤비네이터 계열에 속한다.
|
||||
|
|
@ -185,7 +185,7 @@ State<T | Tween<T>>`가 나옴 — Modifier/State/Source/StoreBind 코드엔
|
|||
|
||||
**핸들러 계층 UB 체크와도 안 부딪힘** — `Tween<T>`는 `Ref`/`Observer`/
|
||||
`Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/
|
||||
`Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/
|
||||
`Tag`처럼 순수 raw 데이터 값(별도 `TweenBrand`)이라, Modifier 필드/
|
||||
`State<Modifier>`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에
|
||||
안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch
|
||||
참가자" 그룹으로 분류해뒀던 건 부정확했던 것으로 이번에 정정(아래
|
||||
|
|
@ -373,7 +373,7 @@ quad-roblox 레벨 편의 함수라 base 계약에 영향 없음.
|
|||
## 패키지 경계 — `Tag`가 이미 밟은 것과 같은 분리 (2026-08-10 세션 확정)
|
||||
|
||||
- **quad-base**: `Tween.luau` — 값 타입(`Tween(opts)` 팩토리, `isTween`
|
||||
predicate/`TweenTag` Brand)만. 엔진 무관.
|
||||
predicate/`TweenBrand` Brand)만. 엔진 무관.
|
||||
- **quad-roblox**: `Handlers/Property.luau`(기존 프로퍼티 세팅 로직에
|
||||
`isTween` 분기 + 3-상태 릴레이션 저장 + override 정책 추가) +
|
||||
`Animate.luau`(편의 콤비네이터, 신규).
|
||||
|
|
@ -449,6 +449,37 @@ Tween(opts: {
|
|||
}) -> Tween<T>
|
||||
```
|
||||
|
||||
## `Tween<T>:Mapped(fn)` — 값만 갈아끼운 새 `Tween`을 반환 (2026-08-20 구현 전 QA 4라운드 `UI-8` 신설, 이름은 2026-08-21 5라운드 확정)
|
||||
|
||||
**동기**: `base/ui-shorthand-plan.md`의 숏핸드가 스칼라를 자식 프로퍼티 타입으로
|
||||
감싸야 하는데(`UICorner = 8` → `CornerRadius = UDim.new(0, 8)`), 값이
|
||||
`Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만** 적용해야 한다.
|
||||
그 문서가 `mapTweenValue(v, wrap)`라는 로컬 헬퍼로 적어뒀던 것을, 사용자 판정으로
|
||||
**`Tween` 자신의 공개 메소드로 승격**한다: *"그냥 펑터 구조를 그대로 줘도
|
||||
무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성."*(이름은 이후
|
||||
`Mapped`로 확정 — 아래 마지막 항목)
|
||||
|
||||
```lua
|
||||
tween:Mapped(fn: (T) -> U): Tween<U> -- opts를 clone하고 Value만 fn(Value)로 교체해 새 Tween 반환
|
||||
```
|
||||
|
||||
- **타입이 안전하게 성립한다** — `Tween<T>`는 immutable raw 값이고 `Value` 외의
|
||||
필드는 값 타입과 무관한 옵션(`Time`/`Style`/…)이라, `Value`만 `U`로 바꾼
|
||||
`Tween<U>`를 만드는 건 타입 레벨에서 깨끗하다.
|
||||
- **`Tween<T>`가 immutable이라는 기존 확정과 일관** — `:Mapped`는 원본을 안 건드리고
|
||||
`table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다(`Tag`/`Modifier`의
|
||||
clone 체이닝과 같은 계열).
|
||||
- **부수 효과 — 기본 `Tween` 정의를 만들어두고 재사용하는 패턴이 열린다.**
|
||||
`local FAST = Tween{Value = 0, Time = 0.15}` 같은 상수를 두고
|
||||
`FAST:Mapped(function() return targetPos end)`처럼 옵션만 재사용할 수 있다. 다만
|
||||
**사용 케이스가 넓지는 않을 것**으로 봄 — 어차피 내부 구현에 필요해서 만드는
|
||||
것이고, 외부에 보이는 게 무해하니 같이 공개하는 것뿐(사용자 판단).
|
||||
- **✅ 이름 — `Mapped`로 확정(2026-08-21 구현 전 QA 5라운드 `TW-2`).**
|
||||
코퍼스의 `-ed` 관례("clone 후 즉시 확정된 값"은 `Added`/`Removed`/`Overridden`처럼
|
||||
과거분사, `base/source-state-plan.md`의 "네이밍 — `Compute`가 `-ed`가 아닌 이유"
|
||||
절)를 그대로 적용한 결과 — `Tween`은 lazy가 아니라 즉시 확정되는 raw 값이므로
|
||||
`Mapped`가 맞다(**사용자 확정**: *"Mapped 로 확정"*).
|
||||
|
||||
## 네임스페이스드 객체 (더 이상 유효한 관심사 아님)
|
||||
|
||||
기존 모델(핸들러가 대상을 이름으로 찾아야 하는 가능성)을 염두에 두고
|
||||
|
|
|
|||
|
|
@ -96,11 +96,21 @@ ref 저장보단 비쌈. spring 등으로 움직일 수도 있다 생각하면
|
|||
디버깅 가시성(`research/debug-tooling-plan.md`)과 "사용자가 만든
|
||||
`UICorner`를 건드리지 않는다"는 위 판정에 여전히 필요하다. **이름은
|
||||
표시·판정용, `Relate`는 조회용**으로 역할이 갈린다.
|
||||
- **⚠️ 확인 필요 — `inst`-키 `Relate`의 전제**: `inst`를 키로 쓰는
|
||||
`Relate` 전체가 "Instance 생성 시점에 gcconn/gchold를 심어 userdata
|
||||
동일성을 고정한다"는 셋업 위에서만 성립한다(`base/lifecycle-pattern.md`).
|
||||
숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 그 셋업을 거치는지
|
||||
구현 시 확인할 것 — 안 거치면 여기서만 조용히 미아가 된다.
|
||||
- **[해소, 2026-08-20 구현 전 QA 4라운드 `UI-5`] 자식도 반드시 gcconn/gchold
|
||||
셋업을 거친다 — 안 거치면 이 숏핸드 자체가 애초에 동작을 못 한다.** 원래
|
||||
"구현 시 확인할 것"으로 열어뒀던 항목인데, 사용자 판정으로 닫힘: *"애초에
|
||||
똑같이 process 로 위임하는 이상, gcconn/gchold 없으면 옵저버 바인딩 부터
|
||||
실패함. 일반 요소처럼 똑같이 UI...{} 처럼 생성되어도 되고, 어떤 방식으로든
|
||||
gcconn/gchold 가 셋업되는게 맞음."*
|
||||
- **논거**: 아래 "Tween 지원" 절대로 이 숏핸드는 자식 프로퍼티를 직접 쓰지
|
||||
않고 `Dispatch.process(child, prop, v, 1)`로 위임하는데, 그 값이
|
||||
`State`면 `StoreBind`가 `bindLifetime(child, observer)`를 부른다 — 그
|
||||
시점에 `child`에 gcconn/gchold가 없으면 **거기서 곧바로 실패**한다.
|
||||
즉 "조용히 미아가 되는" 시나리오가 아니라 **즉시 드러나는 전제 조건**이고,
|
||||
숏핸드가 자식을 만드는 경로도 일반 인스턴스 생성과 같은 셋업을 타야 한다.
|
||||
- **따라서 `ensureManagedChild`는 자식을 만들 때 일반 인스턴스 생성과
|
||||
동일한 경로**(gcconn/gchold 셋업 포함)를 써야 한다 — 별도 확인 항목이
|
||||
아니라 구현이 반드시 만족해야 하는 계약.
|
||||
- **부수 요구 — 숏핸드가 만든 자식의 프로퍼티 세팅도 `Dispatch`에 위임**:
|
||||
*"각 숏핸드가 만들어낸 요소의 프로퍼티 세팅은 새로운
|
||||
dispatch.process(target,k,v) 로 위임해 tween 등이 자연스럽게 가능."*
|
||||
|
|
@ -179,7 +189,14 @@ end
|
|||
State 레이어를 먼저 다 풀어내므로 이 Handler가 실제로 보는 `v`는
|
||||
`number` 아니면 `Tween<number>` 둘 중 하나.
|
||||
|
||||
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.** 숏핸드는
|
||||
**한 가지 진짜로 필요한 부품 — `wrap`을 Tween 위로 들어올리기.**
|
||||
**[승격, 2026-08-20 구현 전 QA 4라운드 `UI-8`] 아래 로컬 헬퍼 `mapTweenValue`는
|
||||
`Tween` 자신의 공개 메소드 `:Mapped(fn)`으로 올라갔다**(이름은 2026-08-21 5라운드 `TW-2`에서 확정) —
|
||||
`base/tween-plan.md`의 "`Tween<T>:Mapped(fn)`" 절이 소스. 이 문서에 로컬 헬퍼로
|
||||
두면 같은 변환이 다른 숏핸드/백엔드에서 또 복제되므로, 값 타입 자신이 제공하는
|
||||
게 맞다는 사용자 판단. 아래 스케치는 그 메소드가 하는 일을 풀어 쓴 것으로만
|
||||
읽을 것(`isTween(v)` 분기는 호출부에 남고, `Tween`이면 `v:Mapped(wrap)`, 아니면
|
||||
`wrap(v)`): 숏핸드는
|
||||
"스칼라를 받아 자식 프로퍼티 타입으로 감싸는" 변환을 갖고 있음(`UICorner = 8`
|
||||
→ `CornerRadius = UDim.new(0, 8)`, 열린 질문 절의 룩업 테이블 `wrap=fn`).
|
||||
`v`가 `Tween<number>`면 그 변환을 **`Tween`을 벗기지 않고 `.Value`에만**
|
||||
|
|
@ -215,14 +232,24 @@ PropertyHandler의 "첫 세팅은 애니메이션 없이 즉시"(`prev == nil`)
|
|||
상황 — 계속 애니메이션되길 원하면 자식이 살아있도록 `nil`로 내리지 말고
|
||||
값만 바꿀 것.
|
||||
|
||||
**자식을 없앨 때의 정리 책임은 이 Handler에 있음** — `v`가 `nil`이 되거나
|
||||
retractor가 `nil` 힌트로 불려 자식을 파괴할 때, 실행 중인 엔진 Tween이
|
||||
남아있을 수 있으므로 `Dispatch.retractFrom(child, prop, 1)`을 같이
|
||||
부르는 게 정석(자식 Instance를 `Destroy`하면 엔진 트윈도 같이 죽고
|
||||
`chains`도 weak-keyed라 결국 GC되지만, "즉시" 끊는 건 명시적 호출뿐).
|
||||
`retractor` 안에서 **다른 키**에 대한 `retractFrom`을 부르는 건 허용된
|
||||
경로임(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은
|
||||
`(inst,k)`에 대한 재진입).
|
||||
**[정정, 2026-08-20 구현 전 QA 4라운드 `UI-11`] 자식을 파괴할 때
|
||||
`Dispatch.retractFrom(child, prop, 1)`을 "정석"으로 요구하지 않는다 — 실익이
|
||||
없다.** 옛 서술은 "실행 중인 엔진 Tween이 남아있을 수 있으므로 같이 부르는 게
|
||||
정석"이었는데, 사용자 판정: *"자식 파괴 시 사실 Tween 은 엔진에 의해 자동
|
||||
멈춤/무효/삭제 처리되고, 트윈 자체가 retract 되어도 아무것도 안 하는 nop 라
|
||||
의미가 없을것이다."*
|
||||
|
||||
- **두 겹으로 무의미하다**: (a) Roblox 엔진이 Destroy 시점에 그 인스턴스에
|
||||
걸린 Tween을 알아서 정리하고(`base/lifecycle-pattern.md`의 "retract는 Destroy 시점에 필요 없는 이유가
|
||||
엔진 레벨에서 한 번 더 보강됨" 절), (b) `PropertyHandler`가 반환하는 retractor는 애초에
|
||||
몸체가 no-op이라(`base/tween-plan.md`의 "왜 `retract`가 더 이상 필요
|
||||
없는가" 절) 불러봐야 하는 일이 없다.
|
||||
- **그래도 호출이 금지되는 건 아니다** — `retractor` 안에서 **다른 키**에
|
||||
대한 `retractFrom`을 부르는 것 자체는 여전히 허용된 경로다
|
||||
(`base/dispatch-core-plan.md`의 retract 계약 — 금지된 건 같은 `(inst,k)`에
|
||||
대한 재진입). 다만 이 자리에서 **필요하지 않다**는 것.
|
||||
- `chains`는 `child`에 대해 weak-keyed라 자식을 버리면 결국 GC된다 — 명시적
|
||||
정리가 필요한 자원이 이 자리엔 없다.
|
||||
|
||||
## store-bind — 이 숏핸드도 지원
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
| 폴더 | 뜻 | 누가 처리 |
|
||||
|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요**(**[2026-08-13 13차 세션] 현재 비어 있음** — 마지막 한 건이던 `08`이 해소돼 `done/`으로 감) | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정) — **`16`은 [2026-08-15] 통과로 `done/`에 있음**(아래 참고) | 에이전트 |
|
||||
| `rewrite-required/` | 스파이크가 낡음 — 코드가 깨졌거나(`13`/`15`), **설계가 바뀌어 옛 모델을 검증 중**(`04`/`19`, 2026-08-13 14차 세션 하강 diff / `10`, **[2026-08-14 5차 세션]** `canExecute` 1-인자 재정정 / `05`, **[2026-08-14 8차 세션]** "emit은 항상 전파" 정정 → 재작성 통과했다가 **[2026-08-21]** 소스 에포크 채택으로 다시 합류) — **`16`은 [2026-08-15] 통과로 `done/`에 있음**(아래 참고) | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림 — **[2026-08-14 5차 세션] 스파이크는 0건**(`10`이 `rewrite-required/`로 감), GC 헬퍼만 남음 | 사용자 or MCP 연결 후 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | — |
|
||||
|
||||
|
|
@ -44,10 +44,16 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
|
||||
| 환경 | 필요한 것 | 해당 파일 |
|
||||
|---|---|---|
|
||||
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 13(런타임 부분), 17, 18, 19, 20 |
|
||||
| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13(타입 부분), 14, 15, 16 |
|
||||
| **순수 Luau CLI** (`luau`) | [luau-lang/luau 릴리즈](https://github.com/luau-lang/luau/releases)의 `luau` 인터프리터, 또는 `lune` | 01, 02, 03, 04, 05, 06(런타임 부분), 07, 11, 17, 18, 19, 20, 22 |
|
||||
| **Luau 타입체커** (`luau-analyze` 또는 `luau-lsp`) | 같은 릴리즈에 포함된 `luau-analyze`, 또는 `luau-lsp analyze`/에디터 인라인 진단 | 06(타입 부분), 08, 09, 12, 13, 14, 15, 16, 21, 23 |
|
||||
| **Roblox Studio** | 별도 계정으로 로그인(`HUMAN_TODO.md` 1번, `SAFETY.md` 준수) | 10 |
|
||||
|
||||
**[2026-08-21 정정]** 이 표가 `21`/`22`/`23`을 빠뜨린 채 `13`을 "런타임 부분/
|
||||
타입 부분"으로 쪼개 적고 있었다 — `13`의 런타임 절반은 2026-08-19에 `22`로
|
||||
분리돼 나갔으므로 지금 `13`은 **순수 타입 스파이크**다. **각 파일이 어느
|
||||
환경에서 도는지는 파일 맨 위 주석이 소스**이고, 이 표는 그걸 환경별로 묶어
|
||||
보여주는 편의 색인일 뿐이다 — 파일이 늘면 여기도 같이 고칠 것.
|
||||
|
||||
**16은 특히 `type function`이라는 비교적 최근/계속 진화 중인 Luau 기능을
|
||||
쓰므로, luau-analyze 버전이 오래되면 아예 문법 자체를 못 알아볼 수 있음**
|
||||
— 그 경우는 실패가 아니라 "이 Luau 버전에서 type function 자체가 아직
|
||||
|
|
@ -71,7 +77,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `ref-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 |
|
||||
| `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `dispatch-core-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 |
|
||||
| `04-dispatch-chain-retractFrom.luau` | **[⚠️ 2026-08-13 열네 번째 세션: 하강 diff 확정으로 낡음 → `rewrite-required/`]** 아래는 옛 모델 기준 설명 — **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State<State<T>>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `dispatch-core-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 |
|
||||
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 완료 → `done/`]** 현행 모델("emit은 자기 invalid 상태와 무관하게 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 다시 짜서 통과 — 핵심 회귀 방지 장치는 `:Get()`을 안 부르는 Observer가 다이아몬드에서 source 변경마다 경로 수(2)만큼 계속 우는지(옛 모델이면 두 번째부터 침묵) | ROADMAP M0-1 |
|
||||
| `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지. **[2026-08-19 재작성 → 2026-08-21 다시 `rewrite-required/`]** 2026-08-19엔 당시 모델("emit은 항상 전파, 중복 재계산은 `:Get()` 시점 캐시로만 막힘")로 짜서 통과했으나, **소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드 두 번째 통지가 접히게 되어** 핵심 assert가 정반대가 됨 — 이제 Observer는 변경당 **1회**만 울어야 한다. 상태의 소스는 `STATUS.md` | ROADMAP M0-1 |
|
||||
| `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 |
|
||||
| `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" **[2026-08-13 보강]** 4번 섹션 신설 — `_countEntries()`(테스트 전용) + weak-value canary로 **"inst가 죽으면 중첩 StrongMap 안의 payload까지 연쇄 GC되는가"를 직접 검증**(원래는 sanity check만 하고 헤더의 핵심 주장은 미검증이었음). 파일이 스스로 적어둔 "weak table 엔트리를 셀 표준 API가 없다"는 전제도 틀렸음 — outer가 `__mode="k"`라 GC 후 `pairs`에서 사라짐 |
|
||||
| `08-type-source-satisfies-state.luau` (타입체크 전용) | `Source<T>`가 `State<T>`를 구조적으로 만족하는 제네릭 타입이 솔버에서 안전한지 | `base/source-state-plan.md` "Source가 State를 만족함", ROADMAP M0-2 |
|
||||
|
|
@ -88,7 +94,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해
|
|||
| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규, 같은 날 B/C 전면 재작성 — 지금은 현행 설계 기준]** 세 소유권/참조카운트 알고리즘 검증. **A**: Tag `tagNameMap` 참조 카운트(여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`. 옛 `kTagMap`은 클로저 캡처로 대체돼 삭제됨). **B**: Attribute 이름 소유권 — 공개 `AttributeKey(name)` 캐시 + `Dispatch.process`의 인덱스 1 **점유 체크**가 충돌을 잡는지(옛 `rawNew`+`owners` 수동 레지스트리는 폐기). **C**: Slot 소유권 — nested 엄격 `claimOwner`(같은 owner 재클레임도 error) vs top-level `claimOwnerAt(inst,k)`(정확히 같은 자리 재발행만 no-op). **셋 다 음성 대조군 포함** — 옛 로직이 `Slot{a,a}`/`Frame{slot,slot}`을 조용히 통과시키는 걸 재현. **[2026-08-13 열네 번째 세션] 0-Z가 확정되며 B 섹션이 낡음 → `rewrite-required/`** — 이제 "그룹 전용 키 + `AttributeKeyHandler`의 이름 claim"을 검증해야 함(A/C는 그대로 유효) | `tag-plan.md` "메커니즘", `attribute-plan.md` "이름 소유권", `slot-plan.md` "요소 소유권" |
|
||||
| `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `dispatch-core-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) |
|
||||
| `21-type-store-undeclared-key-rejected.luau` (타입체크 전용) | **[2026-08-19 신규]** `Store<{field: T}>`로 선언 안 된 이름에 dot-access하면 `type function`이 합성한 결과 타입(`ProcessStoreType`, `16`과 동일)에 그 프로퍼티가 없어 타입 시간에 거부되는지 — `store-plan.md`가 "아마 그럴 것"으로만 적어뒀던 걸 M0에서 실측. 통과: 미선언 키 접근 2건이 정확히 `TypeError`로 걸림 | `store-plan.md` "Store = Source들의 이름 붙은 모음" 절의 "[확인 요구, 2026-08-18 구현 전 QA]" 항목, `todos.md` 00번 |
|
||||
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지 | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 `Brand` 절 |
|
||||
| `22-runtime-ref-preref-postref-brand.luau` | **[2026-08-19 신규]** 구 `13`의 런타임(B) 절반을 분리한 것 — `isPreRef`/`isPostRef`가 같은 층위의 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)인지, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라내는지. **[2026-08-21] `rewrite-required/`로 이동** — 파일이 직접 구현해 쓰는 `Brand.set`/`Brand.get`이 인스턴스 브랜드 재작성으로 역전된 옛 API가 됐다(검증 대상 자체는 그대로 유효, 상태와 재작성 지침은 `STATUS.md`가 소스) | `ref-plan.md`의 "`PostRef`" 절, `brand-plan.md`의 "⭐ 구현 — 인스턴스 브랜드" 절 |
|
||||
| `23-type-quadtypes-checkversion-addplugin.luau` (타입체크 전용) | **[2026-08-19 신규, 같은 날 후속으로 재작성]** 실제 `quad-types`/`quad-base`/`type-version-check`를 `require`해서 `CheckedQuad<T, Pattern>`(글롭/캐럿 버전 패턴 체크, `type-version-check` 위에 얹힘)이 `AddPlugin<Self,P>` 체이닝과 맞물려 동작하는지 — 양성(버전 일치 + 2단 체이닝 + 이전 확장 필드 보존), 음성(버전 불일치 → 강제 참조 시점에 정확히 `TypeError`). `type function`을 거친 값은 패스스루라도 이후 제네릭 self 체이닝이 깨진다는 걸 이 스파이크가 재작성 과정에서 직접 발견. 재작성 과정에서 `export type function`(cross-package 필수)과 2개 이상 명시 제네릭 인스턴스화의 이중 꺾쇠(`Foo<<A,B>>`) 요구도 추가로 실측 확인 | `quad-types-plan.md`, `typing-limits.md` §6 |
|
||||
|
||||
## 공통 유틸리티
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
# 스파이크 상태판 — **폴더가 곧 상태**
|
||||
|
||||
> 마지막 갱신: 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
|
||||
> 마지막 갱신: **2026-08-21** — `Brand`가 **인스턴스 브랜드**로 전면
|
||||
> 재작성되면서(`base/brand-plan.md`) 옛 `Brand.set`/`Brand.get`을 직접 구현해
|
||||
> 쓰던 `22`가 `done/` → `rewrite-required/` 이동(검증 대상인 `isRef`/`isPreRef`
|
||||
> 포함 관계 자체는 그대로). 같은 날 `Epoch`/`EpochMap` 승격도 있었으나 그건
|
||||
> 이미 `rewrite-required/`에 있는 `05`의 지침에 반영돼 있다. 직전 갱신도
|
||||
> 같은 날 — QA 4라운드 `F-4-1`로 `Dispatch.drive`의
|
||||
> props 순회가 **단일 일반화 `for`**로 정정되면서, 두 루프 버전을 검증하던
|
||||
> `01`이 낡아 `done/` → `rewrite-required/` 이동(검증 대상인 순서 계약
|
||||
> 자체는 그대로). 같이 **만들어야 할 스파이크** 절 신설 — 아직 파일이
|
||||
> 없는 실측 항목(`R-11`의 `table.insert` 구멍 재사용, 중간 State GC)을
|
||||
> 여기 모은다. 직전 갱신은 2026-08-19 — 신규 `quad-types` 패키지(`CheckedQuad<T, Pattern>`
|
||||
> 버전 패턴 체크 + `AddPlugin<Self,P>` 체이닝) 검증용 `23` 신규 추가 →
|
||||
> `done/` 직행, 같은 날 후속으로 `type-version-check` 분리에 맞춰 재작성.
|
||||
> 그 과정에서 `type function`을 거친 값은 패스스루라도 이후
|
||||
|
|
@ -35,9 +45,9 @@
|
|||
| 폴더 | 뜻 | 개수 | 누가 처리 |
|
||||
|---|---|---|---|
|
||||
| `review-required/` | **설계가 걸림 — 사람 결정 필요** | **0** | ⭐ 사용자 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 4 | 에이전트 |
|
||||
| `rewrite-required/` | 스파이크가 낡음(코드가 깨졌거나, 설계가 바뀌어 옛 모델을 검증 중) | 7 | 에이전트 |
|
||||
| `not-run/` | 이 환경에서 못 돌림(Studio 전용) | 0(+헬퍼 1) | 사용자 or MCP 연결 후 에이전트 |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 19 | — |
|
||||
| `done/` | 통과 or 판정 끝, 더 할 일 없음 | 16 | — |
|
||||
|
||||
**폴더를 옮기는 게 곧 상태 갱신** — 스파이크를 고치거나 돌렸으면 파일을
|
||||
해당 폴더로 `git mv`하고 아래 표의 줄도 같이 옮길 것. 파일별 "무엇을 왜
|
||||
|
|
@ -62,7 +72,10 @@
|
|||
`rewrite-required/`에 그대로 둠 — 재작성 대상이지 사람 결정 대상이
|
||||
아님(계약 자체는 위에서 이미 확정됨).
|
||||
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음 (4건)
|
||||
## 🟠 `rewrite-required/` — 스파이크가 낡음
|
||||
|
||||
(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다. 예전엔 이 제목이
|
||||
개수를 들고 있다가 실제와 어긋난 적이 있다.)
|
||||
|
||||
**[2026-08-13 열네 번째 세션] 앞의 두 건은 "코드가 깨진" 게 아니라 "설계가
|
||||
바뀐" 경우** — `question.md` 0-A/0-Z 확정으로 재디스패치가 **하강 diff**가
|
||||
|
|
@ -77,11 +90,27 @@
|
|||
`10`은 **Studio 전용이라 재작성해도 이 환경에서는 못 돌린다** — 재작성
|
||||
후 다시 `not-run/`으로 내려가 사용자/MCP를 기다리는 자리다.
|
||||
|
||||
**[2026-08-21] `01`과 `05`가 합류** — 둘 다 같은 "설계가 바뀐" 유형이다.
|
||||
통과 상태로 `done/`에 두면 `01`은 구현이 안 하는 두 루프 순회를, `05`는
|
||||
**이제 접히는 중복 통지가 안 접힌다는 것**을 "검증됨"으로 오독하게 된다.
|
||||
|
||||
**[2026-08-21 후속] `22`도 같은 이유로 합류** — `Brand`가 **인스턴스
|
||||
브랜드**로 전면 재작성되면서(`base/brand-plan.md`) 이 스파이크가 직접 구현해
|
||||
쓰는 `Brand.set`/`Brand.get`/`XxxTag`가 **역전된 옛 API**가 됐다
|
||||
(`archive/brand-shared-registry-reversed.md`). **검증 대상 자체는 그대로
|
||||
유효하다** — `isPreRef`/`isPostRef`가 서로 배타적 형제이고 둘 다 `isRef`엔
|
||||
`true`라는 포함 관계는 재작성 후에도 안 바뀌었다. 옮기는 이유는 결론이
|
||||
틀려서가 아니라, 통과 상태로 `done/`에 두면 **구현자가 그 파일의 `Brand`
|
||||
구현을 참고 모델로 오독**하기 때문이다.
|
||||
|
||||
| 파일 | 상태 | 무엇을 고쳐야 하나 |
|
||||
|---|---|---|
|
||||
| `01-two-pass-array-hash-order.luau` | 옛 형태 기준으로는 ✅ 통과였음 | 숫자 `for` + 일반화 `for` **두 루프**로 짜여 있는데, 구현은 **단일 일반화 `for`**로 정정됨(`base/dispatch-core-plan.md`의 "props 순회 순서" 절, QA 4라운드 `F-4-1`) — Luau의 일반화 `for`가 배열 파트를 먼저 다 돌고 해시 파트로 넘어간다는 것 자체를 **한 루프로** 검증하도록 다시 쓸 것. **검증 대상(순서 계약)은 그대로**라 결론이 바뀌는 건 아님 |
|
||||
| `05-store-state-diamond-propagation.luau` | 2026-08-19 재작성분은 그 시점 모델 기준 ✅ 통과였음 | **[2026-08-21] 모델이 또 바뀌었다** — 소스 에포크 비교 채택(`base/state-epoch-plan.md`)으로 다이아몬드에서 **두 번째 통지가 접힌다**. 그래서 이 스파이크의 핵심 assert("`:Get()`을 안 부르는 Observer가 변경당 경로 수(2)만큼 운다")가 **정반대**가 됐다 — 이제 **변경당 1회**여야 한다. **살릴 것**: `invalid` 기반 dedup이면 두 번째 변경부터 침묵하는 것을 잡는 음성 대조군(그 금지는 지금도 유효). **새로 넣을 것**: DFS 도중 `Get()`이 섞인 값을 캐시하던 glitch가 에포크로 사라지는지(그 문서 §1의 시나리오) |
|
||||
| `04-dispatch-chain-retractFrom.luau` | 옛 모델 기준으로는 ✅ 통과였음 | (1) `chains` 슬롯이 `{handler, retractor}`가 되고 `Dispatch.process`가 핸들러를 먼저 비교하는 **하강 diff**로 재작성, (2) `retractFrom`은 **3-인자**(힌트 인자 없음), (3) "힌트가 target 인덱스에만 간다"를 검증하던 부분은 **정반대**로 뒤집힘 — 이제 각 레벨이 자기 값을 받는지를 검증해야 함. **살릴 것**: `chains:SetStrong` 순서 음성 대조군(그 버그는 새 모델에서도 그대로 유효) |
|
||||
| `19-ownership-refcount-relate-patterns.luau` | A/C ✅ 유효, **B 섹션이 낡음** | B가 검증하던 "공개 `AttributeKey(name)` + 인덱스 1 점유 체크"가 폐기됨 — **그룹 전용 키 + `AttributeKeyHandler`의 이름 claim**으로 재작성하고, 음성 대조군도 "두 그룹이 같은 이름 → 즉시 error", "그룹↔직접 쓰기 → 즉시 error"로 바꿀 것(0-Z 확정 내용). A/C는 손댈 것 없음 |
|
||||
| `15-type-compute-trailing-deps-typepack.luau` | **파싱 실패**(SyntaxError) | 음성 대조군의 타입 표기가 `TypeError`가 아니라 `SyntaxError`로 걸려 **파일 전체가 아무것도 검증 못 함** — 대조군을 별도 파일/블록으로 격리 |
|
||||
| `22-runtime-ref-preref-postref-brand.luau` | 옛 `Brand` API 기준으로는 ✅ 통과였음 | **[2026-08-21] `Brand`가 인스턴스 브랜드로 재작성됨** — 파일 안의 `Brand.set(x, tag)`/`Brand.get(x)`/`XxxTag` 변수를 `Brand()` + `SomeBrand:register(x)`/`SomeBrand:is(x)`로 바꿔 쓸 것(`base/brand-plan.md`). **검증 대상(`isPreRef`/`isPostRef` 배타 + 둘 다 `isRef`엔 `true`, Leaf 핸들러 흉내)은 그대로**라 assert는 손댈 게 없다. **새로 넣을 것**: 다중 태깅이 실제로 되는지 — 한 값을 두 브랜드에 등록하고 양쪽 `:is`가 다 `true`인지(`Source`가 `SourceBrand`+`EpochBrand`인 자리, `base/state-epoch-plan.md` §2) |
|
||||
| `10-roblox-studio-checks.server.luau` (Studio 전용) | 미실행 + **A 섹션이 옛 모델** | A가 옛 2-인자 `canExecute(inst,value)`와 `bindLifetime`의 `.Subscribed` 세팅을 검증 중 — **`bindLifetime`이 gcconn을 `value` 쪽 릴레이션에 복사하는 모델**로 재작성할 것(`base/lifecycle-pattern.md`). **[2026-08-14 열한 번째 세션 재정정, 2026-08-18 방향 정정]** 이중 바인딩 게이트는 `canBound(value)`(`if not canBound(v) then error(...) end` — `canBound` 참 = "지금 묶어도 됨") — `canExecute`는 State emit 전파 게이팅 전용으로 분리됨, 둘 다 비공개 헬퍼 `isBoundAlive`를 공유하는 1-인자 진입점이지만 **서로의 부정**(`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절). **살릴 것**: "ClassName 신호 미발화 / Destroy 시 `Connected` 즉시 전환" 검증(새 모델에서 더 중요해짐), gcconn/gchold를 **Instance 생성 시점**에 만드는 것으로 바꿀 것(옛 lazy 생성 폐기). B/C 섹션은 손댈 것 없음 |
|
||||
|
||||
## ⚪ `not-run/` — 이 환경에서 못 돌림
|
||||
|
|
@ -93,26 +122,33 @@
|
|||
|---|---|
|
||||
| `gc-trigger-helper.server.luau` | 스파이크가 아니라 **헬퍼** — Studio에 `collectgarbage()`가 없어서 GC를 강제 트리거하는 기법. `10`을 돌릴 때 같이 씀 |
|
||||
|
||||
## ✅ `done/` — 통과 or 판정 끝 (19건)
|
||||
## ✅ `done/` — 통과 or 판정 끝
|
||||
|
||||
**런타임 14개 전원 통과**(crash 0 / FAIL 0) — **[열네 번째 세션] `04`/`19`는
|
||||
(개수는 위 표와 폴더가 소스 — 여기서 다시 세지 않는다.)
|
||||
|
||||
**지금 `done/`에 있는 런타임 스파이크는 `02`/`03`/`06`/`07`/`11`/`17`/`18`/`20`,
|
||||
전원 통과**(crash 0 / FAIL 0). 나머지는 타입 스파이크다.
|
||||
**[2026-08-21] `22`는 여기서 빠졌다** — `Brand` 인스턴스 브랜드 재작성으로
|
||||
파일이 쓰는 `Brand.set`/`Brand.get`이 옛 API가 되어 `rewrite-required/`로
|
||||
이동(검증 대상 자체는 유효, 위 표의 재작성 지침 참고).
|
||||
**[2026-08-21 정정]** 여기 "런타임 12개"라고 적혀 있었는데, 그 산술이 이미
|
||||
`rewrite-required/`로 나간 `04`/`10`/`19`까지 포함한 옛 총계에서 이어져 온
|
||||
것이라 실제와 안 맞았다(두 번째 `/code-review high` 발견). — **[열네 번째 세션] `04`/`19`는
|
||||
검증 대상 설계가 바뀌어 `rewrite-required/`로 이동했고, [2026-08-19]
|
||||
`05`는 현행 모델로 재작성해 다시 여기로 돌아왔고, 신규 `22`(구 `13`
|
||||
런타임 절반, PostRef까지 확장)가 합류**:
|
||||
`05`는 현행 모델로 재작성해 잠시 돌아왔다가 **[2026-08-21] 소스 에포크
|
||||
채택으로 다시 나갔으며**, 신규 `22`(구 `13` 런타임 절반, PostRef까지
|
||||
확장)가 합류**:
|
||||
|
||||
| 파일 | 확인된 것 |
|
||||
|---|---|
|
||||
| `01-two-pass-array-hash-order` | 배열 파트 전체 → 해시 파트 순. `Dispatch.drive` 두 패스 계약과 `PreRef` 호이스팅의 전제 |
|
||||
| `02-none-sentinel-vs-nil-holes` | `nil` 소진 시 `#t` 50→49로 무너짐 / `None`은 항상 50. 반대로 Ref 콜백 배열은 `None` 쓰면 죽은 슬롯 1000개 잔존 — **두 배열의 규칙이 서로 반대여야 함**이 정량 확인 |
|
||||
| `03-recursive-store-bind-dispatch` | StoreBind 재귀 재-dispatch, `None`→`nil` 흐름, 무한재귀 없이 종료 |
|
||||
| `05-store-state-diamond-propagation` | **[2026-08-19 재작성]** emit은 자기 invalid 상태와 무관하게 항상 전파(다이아몬드 두 경로 모두 끝까지 도달), 재계산은 `:Get()` 시점 캐시로 1회만, `:Get()`을 안 부르는 Observer는 source 변경마다 경로 수만큼(2) 계속 발화 — 옛(역전된) 모델이면 2번째 변경부터 침묵해야 하는데 안 그럼을 확인 |
|
||||
| `06-component-boundary-nil-hole-props` | `or None` 없으면 앞쪽 nil-hole로 슬롯 소실, 관용구 쓰면 항상 보존 |
|
||||
| `07-relate-weak-table-gc` | **연쇄 GC 확정**(아래 별도 절) — GC-native 아키텍처의 핵심 전제 |
|
||||
| `11-modifier-illegal-value-error` | Modifier 필드/Source에 핸들러 계층 값 넣으면 즉시 error — 16개 케이스 전원 |
|
||||
| `17-modifier-index-tableclone-chaining` | 제네릭 `__index` + `table.clone` 체이닝, 메타테이블 참조 공유, 형제 분기 무오염 |
|
||||
| `18-relate-mutual-cycle-gc` | **두 `Relate` 상호 순환은 실제로 GC 안 됨**(아래 별도 절) |
|
||||
| `20-slot-splice-index-arithmetic` | `Splice` 산술 11개 경계 케이스 전부 참조 구현과 일치 |
|
||||
| `22-runtime-ref-preref-postref-brand` | **[2026-08-19 신규]** `isPreRef`/`isPostRef`가 서로 배타적 형제(둘 다 `isRef`엔 `true`, 서로에겐 `false`)임을 확인, Leaf 핸들러 흉내(`isRef(v) and not isPreRef(v) and not isPostRef(v)`)가 Ref/PreRef/PostRef 셋을 정확히 갈라냄 |
|
||||
|
||||
**타입 스파이크 중 판정이 끝나 더 할 일 없는 것**:
|
||||
|
||||
|
|
@ -157,3 +193,18 @@ inst 5개만 살린 상태 → 살아남은 payload 5 / 엔트리 5 (기대치
|
|||
```
|
||||
추측이 아니라 **실제로 GC가 안 됨** — `Slot`의 두-`Relate` 수정이 필수
|
||||
조치였음이 입증.
|
||||
|
||||
|
||||
## 🔵 만들어야 할 스파이크 — 아직 파일이 없음 (2026-08-21 신설)
|
||||
|
||||
폴더가 곧 상태인 이 문서에서 **"아직 파일조차 없는 실측 항목"**은 어느
|
||||
폴더로도 표현되지 않아 그냥 잊혔다. 실제로 QA 4라운드 followup(H-7)이
|
||||
"실측으로 남은 것"의 소스로 이 문서를 지목했는데 여기 항목이 없었다.
|
||||
앞으로 이 절이 그 소스다 — 파일을 만들면 `not-run/` 또는 실행 결과에 따라
|
||||
해당 폴더로 옮기고 여기서 지운다.
|
||||
|
||||
| 검증할 것 | 왜 | 출처 |
|
||||
|---|---|---|
|
||||
| `table.insert`가 배열 중간의 구멍을 재사용하는가 | `Ref` 콜백 배열이 죽은 슬롯을 `None`으로 두는 설계의 전제. 재사용하지 않으면 슬롯이 무한 증가한다 | QA 4라운드 `R-11`, `base/ref-plan.md` |
|
||||
| 중간 State가 상류 strong / 하류 weak 불변식으로 실제로 살아남는가 | `State → State → State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도 없어 전파가 조용히 끊길 수 있음. **M3 착수 전 필요** | `base/source-state-plan.md`의 "미해결 — 중간 State가 살아남는가" 절, `question.md` 3번 |
|
||||
| `Visible = false`인 GuiObject의 `AbsoluteSize`/`AbsolutePosition`이 갱신되는가 | `quad-roblox-fastscroll` 설계의 선행 실측. **Studio 필요** — 만들면 `not-run/`행 | `research/fastscroll-plan.md` |
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ Roblox 엔진에서 동작하는 DOMless UI 렌더러 **quad**를 처음부터
|
|||
지속 가능성 — 빠른 이터레이션보다 정확성/설계 정합성이 우선. 작업 기간은
|
||||
길게 잡음.
|
||||
|
||||
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, M2(디스패치
|
||||
엔진)부터 착수 예정**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
|
||||
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음) — 저장소 루트에
|
||||
**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩) 완료, 다음은 M2(디스패치
|
||||
엔진)**(마일스톤이 넘어갈 때 루트 `CLAUDE.md` 머리말도
|
||||
같이 고칠 것 — 같은 상태를 두 곳이 서술하고 있음). **⚠️ 단 M2 착수를 막는
|
||||
순서 문제가 하나 열려 있음** — M2↔M3 양방향 의존, 소스는
|
||||
`.claude/question.md` 2번(설계 결정이 아니라 마일스톤 경계 문제라
|
||||
"설계 게이트는 없다"는 아래·`todos.md` 서술과 모순되지 않음). 저장소 루트에
|
||||
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
|
||||
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
|
||||
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가
|
||||
|
|
@ -41,6 +44,9 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션
|
|||
- `.claude/reference/` — **[2026-08-07 신설]** base처럼 확정된 건 아니지만
|
||||
base 문서가 근거로 인용하는 온디맨드 참고 자료(v1 내부 동작 스냅샷,
|
||||
Fusion/Vide 비교 리서치) — 항상 읽을 필요는 없고 인용될 때만 열어볼 것.
|
||||
**[2026-08-21 확장]** 확정된 결정의 **근거 기록**(그 결정이 왜 그렇게
|
||||
났는지)도 여기 둠 — `research/`를 떠났지만 `archive/` 대상은 아닌 것들.
|
||||
어떤 문서가 있는지는 `.claude/README.md`가 소스.
|
||||
- `.claude/research/` — 아직 착수 전, 사용자와 상의 필요한 설계 논의. 전부
|
||||
후순위. **어떤 문서가 있는지·우선순위가 뭔지는 여기서 세지도 나열하지도
|
||||
않고 `.claude/README.md`의 `research/` 표로 미룸**(개수뿐 아니라 파일명
|
||||
|
|
|
|||
2441
.claude/qa-request/pre-implementation-handtrace-round6.md
Normal file
2441
.claude/qa-request/pre-implementation-handtrace-round6.md
Normal file
File diff suppressed because it is too large
Load diff
1373
.claude/qa-request/pre-implementation-qa-round4-followup.md
Normal file
1373
.claude/qa-request/pre-implementation-qa-round4-followup.md
Normal file
File diff suppressed because it is too large
Load diff
288
.claude/qa-request/pre-implementation-qa-round4-response.md
Normal file
288
.claude/qa-request/pre-implementation-qa-round4-response.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
### D-3 — retractor는 자기 자원만 정리
|
||||
retractor는 자기 하위 위임까지 쫓아가 정리할 필요가 없다 —
|
||||
`Dispatch.retractFrom`이 **항상 깊은 인덱스부터 얕은 쪽으로** 정리하므로 이
|
||||
클로저가 불릴 시점엔 자기 아래는 이미 정리된 뒤다. → **예/아니오**
|
||||
-> 방향이 그게 맞나? 방향을 더 서술해주길 바람. 실제 process 에선 retract 가 인덱스 1 부터 5, 6, ... 순으로 작동함. 그런데 달라질 때 5, 4, 3, 2 ... 순이 되는건 아니지? '깊은 인덱스' 라는게 무슨 의미?
|
||||
|
||||
### D-8 — `Quad.debug` 플래그
|
||||
동률 경고 print는 모듈 표면의 불리언 `Quad.debug`(기본 `false`)가 참일 때만
|
||||
찍는다. `Quad.debug`는 새 공개 API 표면이라 `module-lifecycle-plan.md`에도
|
||||
반영이 필요하고, 다중 인스턴스화 시 인스턴스별인지 전역인지는 그때 정한다.
|
||||
`Dispatch.listHandlers()`가 이 플래그와 무관하게 항상 호출 가능한지도 구현 시
|
||||
정한다. → **예/아니오**
|
||||
-> listHandlers 는 항상 실행 가능. 유저가 필요하면 수행 시 목록들을 단순 반환해주고 출력하고 싶다면 출력하는 용도임.
|
||||
|
||||
### D-10 — 두 패스 순회 계약
|
||||
`Dispatch.drive`는 Lua 테이블의 우연한 순회 순서에 기대지 않고 **명시적으로 두
|
||||
패스**(배열 파트 먼저, 해시 파트 나중)로 돈다. 이유는 (1) 다른 백엔드의
|
||||
이식성, (2) 어차피 구분 비용이 드니 순서 고정이 거의 공짜. **M0 스파이크에서
|
||||
실제 Luau로 순회 동작을 검증**할 항목이다. → **예/아니오**
|
||||
-> 루아우의 일반화된 반복 for 이 이를 그냥 지원함. 어떻게 실제로 이해했는지 확인 필요
|
||||
|
||||
### D-32 — 재위임 핸들러의 (A) 분기 의무
|
||||
재위임하는 핸들러는 (A) 분기에서도 **반드시 다시 재위임**해야 한다. 조건부로만
|
||||
재위임하는 핸들러를 만들면 건너뛰는 자리에서 `Dispatch.retractFrom(inst,k,index+1)`을
|
||||
직접 불러야 한다. → **예/아니오**
|
||||
-> 무슨말인지 확인 필요
|
||||
|
||||
### D-56 — `setLength`의 생명주기 경로
|
||||
`setLength`가 만드는 Observer는 `:Subscribe()`가 아니라 `bindLifetime(ownerKey,
|
||||
observer)`로 묶인다 — `ownerKey`가 죽을 때 같이 죽어야 하는 내부 배관이기 때문.
|
||||
`setLength` 자신은 `recompute`를 직접 부르지 않고 항상 `gatedRecompute`를
|
||||
경유하며, Observer의 "등록 즉시 1회 실행"으로 촉발되는 최초 호출도 예외 없이 이
|
||||
게이트를 통과한다. → **예/아니오**
|
||||
-> 맞음. 그런데 ownerKey 가 Slot일 수도 있음. 각 엔진의 bindLifetime 은 이를 잘 핸들링 해줘야함. 즉, Slot안에, 또는 바깥에 SetStrong 으로 gchold 비슷한걸 수행하면 됨.
|
||||
|
||||
### D-58 — 마운트보다 offset 갱신이 먼저
|
||||
`rawAdd`는 `self.Length:Set(newCount)`(다운스트림 offset/LayoutOrder 갱신이 여기서
|
||||
동기적으로 끝남) → `element.Parent = target` 순서로 호출해야 한다 — 안 그러면
|
||||
Roblox의 실시간 `UIListLayout` reflow에서 한 프레임 순서가 깨진 채 노출된다.
|
||||
-> 추가적으로 보면, Length 설정도 마운트 전임. 액티베이션으로 length 가 결정되었을 때 그걸 넣어주고 나서 최종 마운트를 함.
|
||||
|
||||
### D-60 — `Slot.Length`와 `Slot.Offset`은 별개
|
||||
`Length`는 Slot이 스스로 노출하는 출력값(지금 실제 마운트된 개수, "n개 검색됨"
|
||||
UI에 그대로 써도 됨)이고 `Offset`은 Dispatch가 등록받아 `recompute`가 채우는
|
||||
입력값이다 — 서로 다른 두 `Source<number>`다. `Offset`은 마운트 전엔 `nil`이다.
|
||||
-> 마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이 나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐
|
||||
|
||||
### LP-1 — `Connected`는 계산 속성
|
||||
`Connected`는 저장되는 bool이 아니라 "내가 아직 살아있게 하는 뒷받침 참조가
|
||||
nil인지"를 확인하는 **계산된 속성**이고, 해제는 그 참조를 `nil`로 만드는
|
||||
것뿐이다(자료구조를 즉시 재구성하지 않음). → **예/아니오**
|
||||
-> 무슨 말인지 모르겠음. Connected 는 단순히 RBXScriptConnect 안의 속성이고, Destroy 수행 시 모든 커넥션이 죽으니 자연스럽게 Connected 가 false 이 되는것 뿐임. nil로 참조를 만들 이유도 없음. gc 되어 아에 gcconn 이 없거나, 아직 gc 는 안 되었는데 Destroy 직후라 false 이거나 한것 뿐임.
|
||||
|
||||
### LP-2 — Instance 파괴 관측 지점
|
||||
Instance 파괴 관측 지점은 `Destroying` **하나로 통일**하고 `AncestryChanged`나
|
||||
폴링은 안 쓴다. 다만 실제로는 이 훅을 쓰는 지점이 예상보다 적을 가능성이 크다.
|
||||
-> 당장은 Effect 뿐임.
|
||||
|
||||
### LP-4 — 엔진 레벨 보강
|
||||
Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 정리해주므로
|
||||
라이브러리가 따로 처리할 필요가 없고, 커스텀 Destroy-time 처리가 필요한
|
||||
사용자는 `[Event "Destroying"]`을 직접 바인드하면 된다.
|
||||
-> Effect 임. 그리고 그 슈거인 OnDestroyed 존재
|
||||
|
||||
### BR-7 — duck-typing 기각 근거
|
||||
duck-typing을 안 쓰는 이유는 (a) 우연히 비슷한 모양의 값에 false positive,
|
||||
(b) 일부 Roblox userdata는 정의 안 된 키 인덱싱 자체에서 에러를 던져 `pcall`로
|
||||
감싸야 하거나 최악의 경우 엔진이 죽는 상황까지 생김 — 둘 다다. → **예/아니오**
|
||||
-> 더 설명좀 필요
|
||||
|
||||
### M-2 — flatten의 판별 수단
|
||||
flatten은 배열을 훑으며 `isModifier(v)`가 참인 항목만 필드를 뽑아 merge하고
|
||||
나머지는 전혀 안 건드리고 배열 파트에 그대로 남긴다(그래서 `None`은 flatten을
|
||||
그냥 통과한다). → **예/아니오**
|
||||
-> 이것도 이 이후 ProcessedModifier / ProcessedModifierHandler 를 만들면 될듯.
|
||||
Post/Pre Ref 와 유사히 처리 가능하다고 생각함.
|
||||
|
||||
### M-5 — `None`은 raw 저장 계층에만 있는 실재 센티널
|
||||
`{ TextColor3 = None, mod }`도 `mod:TextColor3(None)`도 둘 다 지원되고, Modifier
|
||||
setter/`Overridden`/인라인 props는 `None`을 그냥 평범한 raw 값으로 저장·교체할
|
||||
뿐 특별 취급이 전혀 없다. 실제 "지우기"는 디스패치 단계의 `NoneHandler`가
|
||||
담당한다.
|
||||
-> 예, 다만 mod:TextColor3(nil) mod 그룹에서 제거된걸 생성하게됨. None 이 오직 명시적 'unsetter' 임. 이미 그렇게 구현되겠지만, 문서화에 유의를 두지 않았다면 확인해두어야할 부분.
|
||||
|
||||
### M-9 — Setter는 리터럴과 변환 함수 둘 다
|
||||
`:FontSize(value)`와 `:FontSize(function(current) ... end)` 둘 다 지원하고,
|
||||
**Getter는 안 만든다**(변환 함수 하나가 getter가 필요했던 유일한 케이스를
|
||||
인라인으로 커버). `old`는 항상 "현재 저장된 그대로" 넘어간다(plain이면 raw 값,
|
||||
State면 State 핸들 그 자체).
|
||||
-> 사실 Peek 가 게터라고 봐도 되긴 함, 그래서 애매한 질문이였음.
|
||||
|
||||
### BK-9 — `HasBlocked` 신설 안 함
|
||||
`IsBlocked`/`HasBlockedEmit` 필드는 그대로 유지하고 Blocker 자신의 새 최상위
|
||||
플래그(`HasBlocked`)는 **신설하지 않는다** — `OffWithoutEmit()`이 각 gated
|
||||
state의 기존 `HasBlockedEmit`을 리셋해주는 것으로 충분하다.
|
||||
-> 있는게 어렵지 않다고 보긴 하나, 사용 케이스가 없었을 뿐임. 단순 백로깅 상태로 두어도 되나, 당장 개발에 필요 없음. 나중에 사용 필요 요구가 나오면 그 때 구현하여도 될 요소로 보임. 아마 HasBlockedState 로 하나가 신설될 가능성이 존재하지 않는다고 못 박기는 이름.
|
||||
|
||||
### E-10 — ⚠️ 미해결 항목 확인: dedup 경로의 대칭
|
||||
그 dedup 경로에서 retract가 아무것도 안 한 뒤 `process` 쪽도 정말 아무것도 안
|
||||
하는지 대칭이 실제로 성립하는지는 **아직 확인 안 된 항목**이고, 특히
|
||||
`EffectHandle`의 내부 Observer cascade가 dedup 분기 안에 제대로 들어가 있는지는
|
||||
별도 확인 대상이다(M3 착수 전).
|
||||
-> 확인해봐야한다 생각함. 이로 인해 relate 로 effect 핸들러 쪽에서 old 값을 직접 들고 있어야 하고 dedup 이면 retract 에서 old 를 안 지워주고 process 로 조회해보고 같으면 deup 되어야하는듯.
|
||||
|
||||
### E-11 — `:Subscribe()`한 핸들의 `:Unsubscribe()` 의미 확장
|
||||
Observer의 `:Unsubscribe()`는 "미래 재실행만 끊는다"로 충분하지만, Effect의
|
||||
계약은 "생애주기가 끝나는 시점에 마지막 cleanup이 정확히 1회"이므로
|
||||
`:Unsubscribe()`도 "지금 끝났다"는 신호로 취급해 (1) 내부 Observer 구독을 끊고
|
||||
(2) 직전 cleanup을 정확히 1회 호출하며 (3) 이후 leaf가 실제로 죽어도 중복
|
||||
호출되지 않는다. → **예/아니오**
|
||||
-> 뭔가 애매함. 옵저버에선 leap 바인딩에 Unsubscribe 못 하는것 처럼, Effect 또한 리프 바인딩에 있어서는 Unsubscribe 안 먹어야 하는거 아님? State<Effect> 오고 emit 당함 process 다시 나는데 문제가 안 되는거임? '(3) 이후 leaf가 실제로 죽어도 중복 호출되지 않는다.' 가 좀 이상해보임.
|
||||
|
||||
### R-11 — 분기와 소진
|
||||
`type(v) == "thread"`면 대기자로 보고 resume 후 `[i] = nil`로 소진,
|
||||
`"function"`이면 콜백으로 보고 호출만 하고 소진 안 함, `nil`이면 빈 슬롯이라
|
||||
스킵한다. 새 등록은 `table.insert`가 아니라 **빈 슬롯을 선형 탐색해 재사용**한다.
|
||||
-> 아니요. table.insert 자체가 가장 처음 nil 이 등장하는 인덱스에 넣어주기에 table.insert 가 맞음. 단순 for 문에서는 해시 슬롯이든 어레이 슬롯이든 상관 없이 전부 for 가능해서, 여기선 callback 들 순서가 큰 상관 없어서 중간에 nil이 허용됨. 그리고 그 중간중간에 table.insert 가 잘 넣어주는게 맞음. None 으로 바꾸면 무한정 불어나지만, nil이면 그렇지 않음.
|
||||
|
||||
### SL-4 — 핸들러 계층 값 금지의 근거
|
||||
`Dispatch/Leaf.luau`가 처리하는 leaf 케이스는 **그 컴포넌트가 지금 만들고 있는
|
||||
Instance 자기 자신을 가리키는 self-ref 캡처**라 `inst`가 고정돼야 의미가
|
||||
성립하는데, **Slot은 이미 존재하는 부모에 나중에 독립적으로 붙는 동적 리스트라
|
||||
그 전제 자체가 없다** — Slot 안의 Ref가 무엇을 가리켜야 하는지 정의가 안 된다.
|
||||
대체 경로(`slot:Add(Frame { Ref = myRef })`, 여기서 `Frame`은 `Ref`라는 named
|
||||
파라미터를 받는 **컴포넌트 함수**)가 있어 능력 손실도 없다.
|
||||
-> 맞고, 틀린건 없는데 Frame {} 로 두면 리프 노드로 보일 가능성이 있어보임. 실 문서상 그렇게 있다면 MyComponent 정도로 바꿔주는게 혼선의 여지가 없음.
|
||||
|
||||
### SL-5 — `isMounted` 이중 추적 분리
|
||||
Slot 컨테이너 자신은 `self._mounted` 필드 하나로, 개별 element는 전역 멤버십
|
||||
(`elementOwner`)으로 추적한다. `self._mounted`의 트리거 시점은 **Instance
|
||||
`Parent` 대입 완료가 아니라 `Dispatch.process`가 이 Slot에 대해 실제로 호출된
|
||||
순간**이다 — 다른 모든 "마운트됨" 판정이 dispatch-process 시점 기준이라 여기만
|
||||
post-effect 기준이면 일관성이 깨진다.
|
||||
-> 더 풀어 서술해주길 바람. 판단 보류
|
||||
|
||||
### SL-40, SL-43 SL-45
|
||||
slot 에서 키가 사라지면 nil 로 updateFn 호출되는거 맞지? 근데 데이터 자체가 nil 일수도 있다는 생각. Detach 와 유사한 'KeyGone' 등의 상태지정 enum/싱글톤을 제공해버리고, T|KeyGone 하는게 안전해보인다는 생각.
|
||||
'`updateFn`이 새 값을 반환하면 밀려난 `prev`는 **언마운트만**' 는 이상한듯. 새 값으로 밀려난 prev 는 dispose 되는게 맞음. updateFn 은 직접 destroy 를 호출 못함(reconcile 중 빼는게 안 되니까)
|
||||
그래서 지울 방법이 존재하지 않고, 지워주는게 맞다고 봄.
|
||||
Detach 홀드 중 키 소멸은 KeyGone으로 처리되면 해결될듯. updateFn 은 위에서 if v == KeyGone then ... 처리하는게 있는게 나아보임.
|
||||
소멸 루프가 `keyIndex`를 순회하는 것 - 또한, 단순히 KeyGone 을 주고, userdata 를 지울지 말지는 유저가 결정하게 위임해버리는게 가장 깔끔하다고 보이는 부분임. 이것으로 SL-45 도 닫아짐.
|
||||
|
||||
### SL-48 — Destroy 이후가 공짜로 해결되는 이유
|
||||
`inst`가 Destroy되면 gcconn이 죽어 `canExecute`가 거짓이 되고, `gchold`가
|
||||
`Relate(inst)` 아래 있어 그 안에 붙잡힌 Observer/클로저(`mounted`/`userdata`/
|
||||
`keyIndex` 포함)가 전부 GC 대상이 된다 — 명시적으로 구독을 끊는 새 코드가 필요
|
||||
없다.
|
||||
-> 더 나아가 slot in slot 에서도 유효한가 생각해보아야함. 아마 그런것으로 알고있음. 피지컬 홀더랑 오너가 다르거든.
|
||||
|
||||
### SL-51 — `:Single`은 `:List` 위의 순수 sugar
|
||||
`:Single`은 `:List`를 0/1개짜리 배열로 감싸는 sugar이고, **key를 고정값으로
|
||||
두는 게 핵심**이다(값 자체를 key로 쓰면 매번 다른 item 취급돼 파괴+재생성이
|
||||
강제됨). `index`를 안 넘기는 이유는 형제가 자기 하나뿐이라 항상 상수라서다.
|
||||
-> 주의할 점이 보임. state<Frame> -> slot {frame} 형태가 될 때 이전 state 에서 변경으로 다른게 와도, slot 이 이전 frame 을 destroy 해버리면 안 됨. 뽑는게 안되면 안된다는건데, list 슈거에서 Detach 가 사용중인지 확인이 필요해보임. 또, Slot 안에 State 가 오면 이를 Slot으로 감싸주는게 유효할텐데, 거기서 안전한지도 봐야함(SL-66 에서 사용되는걸로 보이긴 하나, 안전 유무는 별도의 문제로 보임)
|
||||
|
||||
### SL-58 — 배치 밖 단독 재마운트의 엣지 케이스
|
||||
`state<Slot>` 값이 steady state에서 교체될 때는 부모 Blocker가 이미 꺼져 있어
|
||||
부모의 `gatedRecompute`가 아직 flush 안 끝난 `slot.Length`로 한 번 계산할 수
|
||||
있지만, flush가 끝나면 자기 교정된다 — 크래시도 영구 오류도 아닌 한 프레임짜리
|
||||
낭비라 **손대지 않기로** 했다.
|
||||
-> offset 을 먼저 설정해 주어, 계산된 오프셋을 받은 다음 리스트의 액티베이션 이후 Length 를 확정된걸 setLength 하고 나서 마운팅 처리를 하는데, 이 경우 length 가 여러번 계산되어 낭비가 나진 않는거로 알고있음. 단순 위치계산 루프가 한번 돌지만, offset 이 전부 같아 set 안 일어나고 가벼운거로 아는데, 아님?
|
||||
|
||||
### SL-59 — 재귀적 `Clear()` 금지
|
||||
죽는 서브트리 내부에서 요소 수만큼 shift+recompute가 반복되므로, 순수 파괴 walk만
|
||||
하고 outer 쪽 recompute는 자기 위치 하나에 대해 한 번만 돈다. → **예/아니오**
|
||||
-> 실 상황에 대한 설명 더 필요함. 이 글만 보아서는 어떤 상황인지 정확히 판단 어려워보임. 판단 보류.
|
||||
|
||||
### SL-63 — Length 변경은 offset 변경으로만 전파
|
||||
`recompute`가 `:Set()`하는 대상은 (a) 뒤 형제들의 offset, (b) owner가 Slot이면 그
|
||||
`.Length` 둘뿐이고, Length 값 자체는 읽히기만 한다 — 새 전파 채널이 아니다.
|
||||
-> (b) 가 뭔가 이상함. 오너의 length 를 직접 설정하지는 않을것임. 자신 length 를 변경하면, 자동으로 observer 에 등록된것으로 인해 length 가 업데이트 되는 방식일텐데, 그렇지 않음? 최종 리컴퓨팅 결과가 length 가 되는거 아니였음? 그걸 위해 blocker 로 전부 블록 하고도 레이아웃이 멀쩡하지만, 아래에서 offset/length 확인 순회 도는거 아녔음?
|
||||
|
||||
### SL-72 — `dispose` 범위에서 Observer/Effect 제외
|
||||
Observer/Effect는 생존이 gcconn만으로 판정되고 "죽는 순간 트리 부기가 어긋나는"
|
||||
문제가 원천적으로 없어 dispose 대상이 아니다 — 조기에 끊으려면
|
||||
`unbindLifetime`으로 충분하다.
|
||||
-> 우린 조기에 끊는걸 명시적으로 unbindLifetime 로 지원하지 않음. 그건 유저에게 드러나는 표면이 아니고, State<Observer?> 를 사용하는게 적절.
|
||||
|
||||
### SL-74 — ⚠️ 미해결 항목 확인: `SetAndDispose`
|
||||
`Get()` → `Set(new)` → 옛 값 `dispose`라는 3단계가 불편하다는 지적에서 나온
|
||||
`source:Apply(SetAndDispose(new))` 또는 `source:SetAndDispose(new)` 후보가 열려
|
||||
있고, 전자는 `Apply`가 `State`가 아니라 **`Source`를 넘겨주는 함수**여야 하므로
|
||||
`state:Apply` 시그니처에 영향이 갈 수 있어 **M3 착수 전 방향만이라도** 정해야
|
||||
한다.
|
||||
-> 타입 문제 때문에 Apply 를 오버라이딩 해서 source 타입을 함수에 건내주는건 못함. 그럼 source -> state 가 안전히 성립 못해서, Apply 라는 이름을 그대로 쓰지는 못함. 따라서 영향이 안 가고, 그냥 SetAndDispose() 로만 Set() 와 세트로 주는게 나아보이고, 그걸로 확정지어야할것 같다는 생각임.
|
||||
|
||||
### SL-75 — 해제 = 0/`None` 재등록
|
||||
별도 unregister API는 필요 없고 `setOffsetSource(None)` → `setLength(0)` 재등록이
|
||||
곧 해제다. 순서가 중요한 이유는 값이 틀려져서가 아니라 **죽는 중인 Source에
|
||||
쓰기가 날아가기 때문**이고, 해제 시 `slot.Offset = nil`도 같이 해야 stale한
|
||||
Offset을 공개하지 않는다.
|
||||
-> 아님. nil 로 만들면 안 되는게, 포탈로 옮기는게 안 됨. 이미 offset 을 들고 가 바운딩 했다면 큰 문제가 생김. 그냥 stale하게 있는게 맞고, 나중에 offset이 멀쩡히 다시 설정되는게 옳음. 언마운트 시 offset stale 은 단순히 맞는 행동이고, 처음 생성 시 0 인것과 유사 동작임.
|
||||
|
||||
### SL-76 — `recompute`는 `nil`도 관대하게 skip
|
||||
정상 상태에선 항상 `None`이 계약이지만 해제/재마운트 전이 구간에서 `nil`이
|
||||
관측돼도 크래시 대신 skip이어야 한다 — 계약 완화가 아니라 순수 방어이고 등록
|
||||
쪽은 여전히 `None`을 쓸 의무가 있다.
|
||||
-> 말을 더 정리해주길 바람. 애초에 해제에서 nil이 관측 될 일이 없다고 생각하는데, 그게 아니라면 다시 더 자세히 말을 해주길 바람. 이것만으로는 판단이 어려움
|
||||
|
||||
### SL-78 — nested-Slot 결과의 Length만큼 건너뛰기
|
||||
`updateFn`이 nested Slot을 반환하면 그 아이템은 물리적으로 `result.Length`개를
|
||||
차지하므로 `pos`가 그만큼 건너뛴다. 남는 캐비엇은 `index`가 raw 스냅샷이라 nested
|
||||
Slot의 Length가 outer reconcile 없이 나중에 바뀌면 이후 형제들의 `index`가 갱신
|
||||
안 된다는 것이고, 이건 "`index`는 raw number" 설계의 당연한 연장이라 실시간
|
||||
정확성이 필요하면 `updateFn`이 직접 처리해야 한다. → **예/아니오**
|
||||
-> 의미를 모르겠음. 애초에 slot 내의 index 와, length 로 구해진 offset 은 다른 개념인데, 너무 섞어 말하는것 아닌지 생각해보길 바람. 또, Length 업데이트는 상위 slot 이 observe 하기에 형제 slot 갱신에 무관한데, 그 이야기가 아닌것임? LayoutOrder 내에서 index 만 쓰는게 아니다를 말하고 싶은건지, 더 자세히 말해주길 바람.
|
||||
|
||||
### AT-11 — ⚠️ 열린 항목 확인: `Frame { a, a }`
|
||||
같은 그룹 객체를 두 위치에 놓으면 `groupKey(v, name)`이 그룹 객체별·이름별
|
||||
메모이즈라 **완전히 같은 키**가 나와 claim 체크를 통과하고, 두 위치가 하나의
|
||||
체인을 공유하다가 `k=1` retract가 `k=2`의 바인딩까지 철거한다. `Ref`처럼
|
||||
`bindLifetime`으로 막을 수 **없는** 이유는 그룹 Attribute 값은 여러 곳에서 쓸 수
|
||||
있어야 하기 때문이고, 그래서 **위치별 claim 레지스트리를 하나 더** 두기로 방향은
|
||||
확정됐으나 **키를 무엇으로 할지와 `nameClaims`와의 공존은 미정**이다.
|
||||
-> groupClaimKeys 정도로 확정. 더 나은 답이 있다면 적어주길 바람. 다만 충분하다고 생각하는 이름임.
|
||||
|
||||
### AT-13 — 해제→재클레임 순서는 Dispatch가 보장
|
||||
같은 핸들러 재프로세스는 `retractor(v)` → `process`, 핸들러가 바뀌면
|
||||
`retractFrom` → `process`라 어느 경로든 옛 claim 반납이 먼저다.
|
||||
-> 정확히는 같은 핸들러 재프로세스는 retractor 를 process 에서 굴리고 자기 작업을 함. 따라서 process → calls retractor(v) → process new one 이 맞는걸로 보이는데. 단순 축약상 정보유실인것 뿐이지, 실제 구현은 저렇게 알고 있는게 맞음?
|
||||
|
||||
### AT-20 — 생존 이름도 매 사이클 철거→재등록
|
||||
클로저는 인자(새 값)를 안 보고 자기가 등록한 키 전부를 균일하게 철거하며, 비용은
|
||||
`StoreBind` 재구독과 같은 값 `setAttribute` 한 번뿐이다. 그룹 전용 체인이 된
|
||||
지금은 최적화도 가능하지만 옛 이름 집합을 또 들고 있어야 해 부품이 늘어나므로
|
||||
**기본은 균일 철거 유지**다.
|
||||
-> 생존 이름에 대해서 최적화 불가함. 실 value 자체가 바뀌고 같은 값인지 비교를 해야하는데, 그러면 값을 진짜 까봐야 하고, 이전 값을 알아야하기 때문. 이름 목록의 변경으로 최적화가 되는 요소가 아님.
|
||||
|
||||
### TW-12 — `CanAnimate`
|
||||
생략하면 기본 `true`이고, `false`로 resolve되면 `Tween`으로 안 감싸고
|
||||
`self:Get()`을 그대로 반환한다 — reduceMotion류 접근성 우회가 이 필드 하나로
|
||||
표현된다. 케이싱은 나머지 필드와 맞춰 `CanAnimate`(PascalCase)다.
|
||||
-> 단순 boolean 으로만 설명했는데, 정확히는 CanAnimate: state<boolean> | boolean | nil 이다. 이미 그럴것으로 보이고, 아니라면 정정해야함. 다른것과 똑같게, 필요 시 바로 Get()
|
||||
|
||||
### TW-16 — `initValue`는 에이전트 범위 밖
|
||||
초기 진입 애니메이션은 필요해지면 **사용자가 직접 코드베이스+문서를 만지기로**
|
||||
확정됐고, 에이전트는 임의로 착수하지 않는다.
|
||||
-> 틀리진 않았는데, Human todo 에 언급이 없음.
|
||||
|
||||
### UI-5 — ⚠️ 확인 필요 항목
|
||||
숏핸드가 만드는 **자식**도 quad가 만든 Instance이므로 gcconn/gchold 셋업을
|
||||
거치는지 구현 시 확인해야 하고, 안 거치면 여기서만 조용히 미아가 된다.
|
||||
-> 애초에 똑같이 process 로 위임하는 이상, gcconn/gchold 없으면 옵저버 바인딩 부터 실패함. 일반 요소처럼 똑같이 UI...{} 처럼 생성되어도 되고, 어떤 방식으로든 gcconn/gchold 가 셋업되는게 맞음. 확인했고 해소된 요소가 될듯.
|
||||
|
||||
### UI-8 — `mapTweenValue`가 필요한 이유
|
||||
`v`가 `Tween<number>`면 `wrap` 변환을 **`Tween`을 벗기지 않고 `.Value`에만**
|
||||
적용해야 하므로, `table.clone` 후 `Value`만 교체해 `Tween(opts)`로 다시 만든다.
|
||||
`wrap`이 항등인 키도 분기 없이 이 헬퍼를 거친다.
|
||||
-> 그냥 펑터 구조를 그대로 줘도 무방한듯. :Map 정도로써 새 Tween 을 새 관측된 Value 로 형성. Tween<T>:Map(T) -> Tween<T> 가 타입 상 안전히 가능하다. 이는 Tween<T> 에 대한 기본 정의를 두고 다른곳에서 Map 해서 재사용하는 구현도 가능케 할수도 있게 보이긴 하나, 사용 케이스가 넓지는 않을것. 단 외부에 보이는게 무해하고, 어차피 내부 구현 상 필요하므로 같이 만들어도 좋아보임. 혹은 Mapped 의 immutable 의 ed 형태를 써도 좋아보임.
|
||||
|
||||
### UI-11 — 자식 파괴 시 `retractFrom` 호출
|
||||
자식을 파괴할 때 실행 중인 엔진 Tween이 남아있을 수 있으므로
|
||||
`Dispatch.retractFrom(child, prop, 1)`을 같이 부르는 게 정석이고, retractor 안에서
|
||||
**다른 키**에 대한 `retractFrom`은 허용된 경로다. → **예/아니오**
|
||||
-> 자식 파괴 시 사실 Tween 은 엔진에 의해 자동 멈춤/무효/삭제 처리되고, 트윈 자체가 retract 되어도 아무것도 안 하는 nop 라 의미가 없을것이다.
|
||||
|
||||
### ML-5 — 멱등 가드는 `module`을 키로 하는 `Relate`
|
||||
각 `InitXxx`가 파일 스코프에 `Relate()` 하나를 두고 `module`을 키로 "이 인스턴스에
|
||||
이미 Init됐는지"를 기록한다. `require` 캐시로는 부족한 이유는 그게 **파일**
|
||||
단위인데 `New()`는 여러 `module` 테이블을 만들 수 있어서다. → **예/아니오**
|
||||
-> 새로운 커밋에서 이것이 달라짐.
|
||||
|
||||
### ML-9
|
||||
`Quad.debug: boolean`(기본 `false`)은 라이브러리 자체의 디버그 스위치이고 지금
|
||||
게이팅하는 건 핸들러 우선순위 동률 경고 print다. 기본이 `false`인 이유는
|
||||
라이브러리가 사용자 콘솔에 아무것도 안 찍는 게 기본이어야 하기 때문이고,
|
||||
다중 인스턴스화 시 인스턴스별인지 전역인지와 `listHandlers()`가 이 표면에
|
||||
속하는지는 **미정**이다.
|
||||
-> D-8 가 해소시킴
|
||||
|
||||
### LH-8 — 스코프 판단이 틀렸던 것
|
||||
"(a) 자기 프로퍼티만 / (b) 서브트리 전체" 중 "(a) 메커니즘은 (b)를 못 준다"는
|
||||
판단이 틀렸음이 드러났다 — 배열 파트 루프가 각 자식의 마운트를 동기적으로
|
||||
끝내므로 (a) 메커니즘이 사실상 (b) 스코프를 공짜로 준다. 진짜 경계는
|
||||
(a)/(b)가 아니라 **"자기 아래 vs 자기 위"**였다.
|
||||
-> 사람이 너무 이해하기 어려운 표현임. 틀린말은 아닌듯 한데, 너무 어려워서 이것만 보고 판단은 안될듯. 풀어 보여줘야함.
|
||||
|
||||
+
|
||||
`OnRendered`라는 이름이 `componentDidMount` 같은거 구현 가능하면 좋긴하겠는데 별로 애매한가 생각중... 화면 그려지기 전에 애니메이션이 된다던가 하지 않게 하는 방안이 있음 좋아보임. 하지만 나중에 얹어져도 좋을 이야기이고, 당장은 사용사례가 안 보이므로 추가 프리미티브에 백로깅만 하고, 나중에 필요하다는 의견이 나오면 재생각 해볼 예정.
|
||||
|
||||
+
|
||||
destroySlotTree 가 소유를 명시적으로 지워야할 이유가 있냐 의문. Destroy 된 요소는 다른곳에 원래 마운트 못하는게 보통 엔진 정상이고, 또, 릴리즈 안 되어 다른곳에 마운트 막혀도 상관 없고, 그게 정상 동작일 수 있어보임. 정확한 형태가 어떤지 알아봐야할 상황.
|
||||
|
||||
+
|
||||
Length 를 통해 '먼저 밀어내고 나서' 그 공간에 넣는다가 지금 관행인데, 다른 crud 와 list 도 이게 통하는지 봐야함. 만약 밀어내고 당기지 않은 상태에서 그 공간에 넣는다 하면, 밀어내는걸 구현해야하는 백엔드에서 골치아파짐. 지금 어떤 상황인지 확인해볼것
|
||||
|
||||
+
|
||||
지금 명시적으로 Attribute(store1, store2, ..., {plain = "table도 됨"}) 로만 되어있고, plain= 에 state/source/T 가 올 수 있음을 안 알려주는 모양으로 나오는데, 그렇게 적어도 된다 생각함. 엔지니어링 비용이 없고, 그냥 이미 그렇게 구현되도록 만들 계획이였다고 생각함. 그리고 뒤에 오는기 이기는 것 또한, 잘 명시되어있나 봐야함.
|
||||
3045
.claude/qa-request/pre-implementation-qa-round4.md
Normal file
3045
.claude/qa-request/pre-implementation-qa-round4.md
Normal file
File diff suppressed because it is too large
Load diff
1273
.claude/qa-request/pre-implementation-qa-round5-followup.md
Normal file
1273
.claude/qa-request/pre-implementation-qa-round5-followup.md
Normal file
File diff suppressed because it is too large
Load diff
124
.claude/qa-request/pre-implementation-qa-round5-response.md
Normal file
124
.claude/qa-request/pre-implementation-qa-round5-response.md
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# 구현 전 QA 5라운드 — 사용자 회신 원문 (2026-08-21)
|
||||
|
||||
**이 파일은 회신 원문 그대로의 기록이다** — 처리 결과의 소스는
|
||||
`pre-implementation-qa-round5-followup.md`. 4라운드와 같은 구성
|
||||
(문항지 / 회신 원문 / 처리 결과 3파일).
|
||||
|
||||
---
|
||||
|
||||
### DE-7
|
||||
_detached 를 모든 slot 이 가져야하나는 의문이 듦. 내부적으로 getOrSet 해서 detached 를 리턴해주는 유틸을 만들고, nilable 해도 되지 않나라는 생각. destroySlotTree 가 _detached 를 인덱스 해보고 nil 아닌지 보고 돌리는게 더 나아보이는데, 테이블 생성 비용을 모든 slot 이 가져야하나는 의문. List 슬롯에서만 작동하는것인데, 너무 광범위하지 않은가? if 확인으로 nil 이면 스킵이 훨씬 싸게 먹히지 않는가?
|
||||
순수 구현 상 아무 문제 없겠지만, 단순 최적화 문제. 최적화에 드는 비용이 거의 없는데, 안 할 이유가 보이진 않음.
|
||||
|
||||
+
|
||||
아무것도 없는데 Detach 를 보내면 어떻게 되느냐. prev 없음을 유저가 추적해야하는가, 아니면 detach 를 그냥 prev == nil 일 때 던지면 무시해주느냐
|
||||
prev 가 마운트 상태에서(Detach 안 함) 다시 prev 를 던지는것은 이번 변경으로 인해서 여전히 문제 없이 잘 작동하는가
|
||||
|
||||
### DE-9
|
||||
error 하면 된다. KeyGone 을 받은 요소는 오직 데이터의 파괴 또는 detach 를 통해 다시 나오는 경우를 위한 캐싱 이외의 새로운 마운트나 생성을 거부한다.
|
||||
|
||||
### DE-11
|
||||
맞다. 문서화에서만 유의하면 되는 부분. 단순 삽입/삭제가 빈번한 경우를 위한 최적화 일 뿐. 그 이상의 동작을 돕지 않는다.
|
||||
|
||||
### DE-13
|
||||
_detachCleanup 이 정확히 어떤건지 모르겠음. _detached 에 들어가게 될 대상을 말하는것인지? 아니면 detach 된 요소들이 나중에 정리되어야할 때인지?
|
||||
나중에 detach 했던 요소들을 청소하는것이라면, 소유한것이면 죽이는게 맞긴 하다.
|
||||
다만 주의해야할 부분이 보인다. 애초에 unowned 의 state 로 받은것은 더이상 가지고 있지 않는다. detach 에 들어가있지도 않는다. 외부로 반출된 것이라 다시 들고와서 자기 자신에 붙이지 않음. 즉 detach 가 unowned 에 대해서 수행되면 prev 가 나중에 nil 이 되는것이다. 그래야 state<Frame> 에서 내부를 교체했을 때, 이전 요소를 안 건들이게 되는것이라 생각하는데, 내 생각이 잘못 흐른건지 검토해달라.
|
||||
|
||||
### DE-17
|
||||
주의 할 점은, state 가 slot 에 바로 안 오기 때문에, updateFn 이 state 를 던져도 싱글 slot화 된다. 그리고 owned = false 가 되는건 이 싱글 슬롯 안 1번째 객체에 대해서 적용이다.
|
||||
나중에 새로운 state<Frame> 같은게 나온다면, 이전거는 prev 로 updateFn 이 받으니 그걸 어떻게 처리할지는 updateFn 의 몫. 특히 slot 래핑 안 된 상태로 받아야하는가도 생각해보아야한다. prev 는 이전에 던진 그대로 주는것이므로. 그리고 이전과 같은 state 를 던지면 멱등으로써 새로운 slot 을 만들지 않고 그대로 두는것도 여전해야한다.
|
||||
|
||||
### DE-22
|
||||
의도가 맞다.
|
||||
|
||||
### AS-5
|
||||
activateList 도 결국 상위에 setlength 를 하는것 아닌가? 게이트가 blocker 없이는 호출되지 않는지 확인 필요
|
||||
|
||||
### DC-6
|
||||
더 정확히는, 슬롯을 뽑아냈을 때, 이전에 렌더된 요소들은 여전히 layout order 등을 위해 offset을 연결해두고 있다. 이 상황에서 아에 다른 값을 slot.Offset 으로 쓴다는것 자체가 나중에 포탈에서 깨지는 부분을 생성한다
|
||||
|
||||
### DC-11
|
||||
필요 이유를 모르겠음. length 업데이트를 위한것임?
|
||||
|
||||
### DC-14
|
||||
사실, 외부 입장에서는 그럴 방법이 없어보인다. crud 가 list 시에는 더이상 불가능해지기 때문.
|
||||
|
||||
### DC-19
|
||||
rawAdd 에서도 필요한가는 모르겠음. 목적이 다르지 않나?
|
||||
|
||||
### SS-2, SS-3
|
||||
단순히 각 state 에, 이전 emit 을 발생시킨 발행 unique table 를 넣는건 어떤지 고민중. source 에서만 발행되고 emit 상 전파된다.
|
||||
지금 보이는 문제로는
|
||||
A -> B --> D
|
||||
-> C -|
|
||||
상황에서 결국 dfs 로 순회되어서 D 에 먼저 A 소스의 emit 신호가 온다. C는 아직 받기 전. invalid 하지 않아서 바로 캐시가 읽히고, 그대로 D에 캐시에 쓰인다. C의 변경으로 다시 emit 되긴 하지만, 이걸 잡을만한 방법이 없어보이진 않는다.
|
||||
이런 문제를 전부 쉽게 푸는 방법이 존재하는데, emit 시 unique 한 테이블 하나를 전파하고 그 테이블 안에 count 값과 invalid 플래그가 들어간다.. 각 source 는 해당 테이블을 가지고 있다가 emit 된다면 invalid=false count=최신으로 값을 로 바꾼다.
|
||||
with 에서는 여럿 받은 곳에 대한걸 관리하도록 둔다.
|
||||
-> 생각하다 위쪽껀 이상했음. 이러면 모든 발행된 unique 를 들고 count 를 관리해야한다
|
||||
아니면 반대로 count 부분만 담는 테이블을 만들고 연결하는건 어떤가?
|
||||
unique 자신의 실제 인덱스와, source 의 count 와 비교해서 일치해지는가를 본다.
|
||||
만일 파이프 뒷 요소로 get 되어진 경우 최상위 소스에서 해당 유니크를 가져와 덮어도 된다.
|
||||
-> bfs 도 생각했는데, 순서를 꼬아두면 문제가 생긴다.
|
||||
재정리: 차라리 이렇게?
|
||||
State()
|
||||
sourceList <- {
|
||||
[source:weak] -> count
|
||||
} weak 로 영향받는 source 들을 담는다. 앞단 요소에서 복사, with 시 합친다
|
||||
rawInvalid <- 단순 emit 시에 바로 false 된다. 캐싱
|
||||
invalid <- rawInvalid 캐시를 보고 true 라면 sourceList 를 보고 계산 필요 상태인지 확인한다
|
||||
더 생각해볼 이야기라 백로깅이나 리서치에 들어가야할듯.
|
||||
Get 이 항상 최신 상태를 가져온다라는 말이 여기서 무력화되는 부분이라 생각이 필요해보인다.
|
||||
어차피 emit 단순히 전파는 false 이라 연산 비용이 없어 잘 전파되고. 복합 state 상태를 잘 관리해주는게 나아보인다.
|
||||
대부분 옵저빙을 하여 state 를 읽지, 폴링해서 get 하는 경우도 잘 없으므로 문제 있는 구현으로 안 보인다.
|
||||
폴링을 위해서는 Apply(Realtime()) 같은 슈거를 주면 된다. 옵져버로 항상 get 하고 value 를 실시간으로 읽을 수 있게 해주는것. 단순하게 Ref<T> 로 변환해주는 등의 작업을 하는 슈거를 줘도 된다.
|
||||
비용은 다소 한정적으로 보인다. 해시 for은 이미 빠르고, Source가 중간중간 느는게 아니라 처음 시작점이라 수 자체가 적다. 2~4개에 대해 인덱싱 하는 정도라 충분히 가벼워보이는 단일 for 로 해결이 되는것으로 보임.
|
||||
emit 은 이제, 발행시킨 source 와 count 를 전달하기만 하면 된다. count 를 쓰기 싫다면 단일 테이블을 써도 되는 부분. [source:weak] -> {}
|
||||
선제 최적화라기 보단 확실히 정해진 동작으로 승격하는 일로 보이는데 어떻게 생각하는가?
|
||||
|
||||
### LC-3, LC-4
|
||||
무슨말인지 모르겠다. 애초에 Slot effect 나 다른 요소들을 소유할 수가 없다.
|
||||
심지어 내 생각으로는 slot owned 가 canBound 에 들어갈 이유가 있나? 모르겠다
|
||||
왜냐면 실제 observer/effect 는 실제 inst 에 불림. slot in slot 에서 slot 을 유지하는건 이미 slot 의 강참조 배열이 해결해주는데, 우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지 다시 생각해봐야할 부분인듯?
|
||||
|
||||
### EF-3
|
||||
예
|
||||
|
||||
### AT-1
|
||||
(inst, groupValue) → k 이면 충분하다. group 에 따라 key 가 따로 생성되므로 다른 그룹에 대해서는 잡을 필요가 없고, 그건 key->name 이 유일성을 검증해준다. {a,a} 는 단순 그룹이 이미 할당된 키가 있나를 보기만 위함임. AT-2 도 같이 닫는다
|
||||
|
||||
### TW-2
|
||||
Mapped 로 확정 CR-2 도 해결됨
|
||||
|
||||
### DT-4
|
||||
그렇다. 이러면 스로틀/디바운싱에 Observer 를 걸어야하는데, 이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이 먼저이냐가 문제되기 때문에 Blocker/Observer 가지고는 구현 못 한다. 순서를 보존해야한다는 전재가 생기는데 중간이 비어 해시가 되면 이를 전혀 못 지키기 때문.
|
||||
Gate 가 emit 에 중간에 가로채서 넘길지 말지 처리를 해줄 수 있게하는 방법을 제공하는게 맞다. 그리고 이 API가 비공개일 이유는 없어보인다.
|
||||
|
||||
### CR-3
|
||||
게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다. 실질적 모양 정의가 필요함
|
||||
생각 상 새로운 state 가 나오지는 않고, Blocker 와 유사히 동작한다. 내부 배선 상 이렇게 되면 되는거 아닌가 생각중.
|
||||
Gate(function(emit)
|
||||
^-- 위 emit 은 언제든 사용 가능
|
||||
return function () <- 상위 emit 시 발생. 위 emit 을 쓸지 말지는 자유
|
||||
end
|
||||
end)
|
||||
다만 프리미티브 명을 Gater? 뭔가 이상하게 들어간다는게 약간의 문제.
|
||||
|
||||
### CR-4
|
||||
부모가 죽기 전까진 detached 정리가 안되니 그건 맞다.
|
||||
|
||||
+
|
||||
Effect 가 지금은 Ref에 대해서 수행될 수가 없다. 단순히 Effect(, ...) 를 만들고 ... 요소를 With 으로 합치는게 아니라 여러 요소에 대해서 Observe/Callback 하는게 어떻겠냐는 생각이 드는 지점. 처음 분기는 Blocker() 의 다른 사용법을 통해 막는다. 그게 더 나은 구현으로 보이는중.
|
||||
|
||||
+
|
||||
확인할 부분이 있다. state<Slot> -> Slot single { Slot } 모양이 될 때 부모 Slot 이 length 를 잘 따라가는가? 아마 그렇다고 보는데, 정확한지 봐야한다.
|
||||
|
||||
+
|
||||
뽑는것은 자유롭지만, Slot 이 들고있다 죽는건 죽어야하는데. 그 처리가 영향을 받았는지 궁금함. 즉
|
||||
Frame {
|
||||
Slot {
|
||||
State(Frame) <- 여기 바인딩은 상위 Frame 이 죽으면 같이 죽음.
|
||||
}
|
||||
}
|
||||
이건 Effect 쪽에서 뭔가 처리해줄 수 있는게 아닌게, 엔진 자체가 recursive 호출로 전부 죽이는게 일반적이기에 우리가 빼줄 수 있는 요소도 아니고, 같이 죽는게 의도 동작이기 때문.
|
||||
따라서 State 에서 무언가 마운트 된 요소를 뽑아낼 때, 부모가 죽었다면 이미 죽은 요소가 된다. 이건 의도 동작인데, 언급이 되어있나 모르겠음.
|
||||
1302
.claude/qa-request/pre-implementation-qa-round5.md
Normal file
1302
.claude/qa-request/pre-implementation-qa-round5.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -12,7 +12,11 @@
|
|||
|
||||
---
|
||||
|
||||
## ⭐ 최우선 — **없음** (2026-08-14 열한 번째 세션 기준)
|
||||
## ⭐ 최우선 — 설계 결정은 **없음**, 다만 순서 문제 하나 (2026-08-22 갱신)
|
||||
|
||||
> **[2026-08-22] 아래 2번(M2/M3 마일스톤 경계)이 M2 착수를 막습니다.**
|
||||
> 그건 "무엇을 확정할까"가 아니라 "어떤 순서로 짤까"라 성격이 달라서
|
||||
> 이 절이 아니라 2번에 뒀습니다 — **설계 결정 대기는 여전히 0건**입니다.
|
||||
|
||||
> **M0 착수를 막던 항목이 전부 해소됐습니다.** `0-Y`(`:Compute(fn)`의
|
||||
> lazy 핸들 계약)는 열세 번째 세션에, `0-Z`(Attribute 이름 소유권)와
|
||||
|
|
@ -40,27 +44,16 @@
|
|||
`base/lifecycle-pattern.md`의 "`canBound` vs `canExecute`" 절,
|
||||
`archive/canexecute-inst-arg-reversed.md` 하단 addendum 참고.)
|
||||
|
||||
- **[해소됨, 2026-08-18] `DI` → `D`(Declarative) 확정** — 원문과 근거는
|
||||
`archive/question-resolved.md`. 요지: `DI`가 "Dependency Injection"과
|
||||
완전히 겹쳐 실제 오해 전례가 있었고, `D`는 Instance 전용이 아닌 declare
|
||||
요소 전반으로 확장 가능하며 `D.FrameModifier`류 타입 프리픽스도 짧게
|
||||
유지된다. 미뤄뒀던 유일한 사유(한 글자 식별자의 검색성/자기설명력)는
|
||||
"문서에서 처음 나올 때 항상 `D`(Declarative)로 풀어쓴다"는 표기 규약으로
|
||||
보완하기로 같이 확정. 코퍼스 반영 완료 —
|
||||
`base/bind-system-plan.md`의 "인스턴스 생성 / 이벤트 네이밍 인체공학" 절.
|
||||
- **[해소됨, 2026-08-19] `PopOnly` → `Detach` 확정** — 원문과 근거는
|
||||
`archive/question-resolved.md`. 요지: 이미 있는
|
||||
`Extract`(호출자가 직접 부르는 명령형 추출)와 동사가 겹치면 헷갈리는데,
|
||||
`Detach`는 "화면(부모 계층)에서만 떼어낼 뿐 관리 주체는 여전히
|
||||
reconcile"이라는 뜻이라 `Extract`의 "소유권을 통째로 넘긴다"와 자연스럽게
|
||||
구분되고, `nil`(파괴)과의 대비도 더 직접적으로 드러남. 공개 표면 위치도
|
||||
같이 확정 — `Slot`이 함수(팩토리)라 `Slot.Detach`처럼 붙이려면
|
||||
callable-table+메타테이블이 새로 필요해서 과함, `None`과 같은 선례를 따라
|
||||
**패키지 최상위 export**로. 코퍼스 반영 완료 — `base/slot-plan.md`의
|
||||
"`nil` 리턴은 파괴가 기본" 절.
|
||||
- **`Slot`(2순위)**: Vue의 "slot"(콘텐츠 주입 지점)과 이름은 같지만 의미가
|
||||
다름(quad의 Slot은 자식 배열 재조정 프리미티브) — Vue 배경 있는 사람이
|
||||
헷갈릴 수 있음.
|
||||
- **`Owned`(3순위, 2026-08-21 신설)**: `:List`/`:Single`의 설치 시점
|
||||
플래그(기본 `true`, `false`면 어떤 경로로도 파괴 안 함).
|
||||
`elementOwner`/`claimOwner`/`releaseOwner`와 같은 뿌리라 골랐지만
|
||||
**잠정 이름**이다 — 형용사라 옵션 테이블 키로는 자연스러운데, 실제로
|
||||
묻는 건 "이 Slot이 요소의 수명을 책임지는가"라서 `OwnsElements`처럼
|
||||
주어를 드러내는 쪽이 나을 수도 있음. `base/slot-plan.md`의
|
||||
"소유권은 설치 시점에 정해진다" 절.
|
||||
- **`canExecute`(3순위, 사소함)**: 실제로 "이 값이 아직 살아있나" 확인인데
|
||||
이름이 범용 권한 체크처럼 들림 — `isAlive` 쪽이 더 직접적이라는 제안이
|
||||
있었으나, **(2026-08-08 재검토)** `isAlive`는 top-level `isX` 계열
|
||||
|
|
@ -86,13 +79,18 @@
|
|||
안 바꾸고 대기열에만 올림(의사코드는 새로 쓰는 자리부터 `nextValue`를
|
||||
쓰기 시작했음).
|
||||
- **`Brand`(3순위, 사소함, 2026-08-07 여덟 번째 세션 추가)**: 런타임
|
||||
nominal 타입 판별 통합 메커니즘(`Brand.set`/`Brand.get`, `isState`를
|
||||
branded 타입 전부로 일반화) — `brand-plan.md`의 `Brand`
|
||||
절에서 동작/구현 방식은 확정, "OOP 인스턴스의 클래스명을 얻는 느낌"을
|
||||
전달할 더 나은 이름이 있는지가 열린 질문(사용자가 직접 제기) — `Tag`는
|
||||
이미 quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
|
||||
nominal 타입 판별 통합 메커니즘 — `base/brand-plan.md`에서 동작/구현
|
||||
방식은 확정, 이름만 열린 질문(사용자가 직접 제기). `Tag`는 이미
|
||||
quad-roblox의 `CollectionService` 래퍼로 쓰여서 이름 충돌, 후보로
|
||||
"type namespace"류를 사용자가 검토했으나 미확정. **(2026-08-08 재확인)**
|
||||
사용자가 다시 짚었지만 여전히 미정.
|
||||
- **[2026-08-21 갱신] 표면이 바뀌어서 "OOP 인스턴스의 클래스명을 얻는
|
||||
느낌"이라는 원래 요구는 이제 안 맞는다** — 인스턴스 브랜드로 재작성되며
|
||||
역조회(`Brand.get`)가 없어졌고, 지금 하는 일은 **집합 멤버십**
|
||||
(`SomeBrand:is(x)`)이다. 이름 후보도 그 방향으로 다시 볼 것.
|
||||
- **메소드 케이싱도 같이 볼 것** — `:register`/`:is`가 소문자인데 quad
|
||||
공개 표면 관례는 PascalCase다(`:Get`/`:Set`). base 내부 유틸이라 지금은
|
||||
기존 `Brand.set`/`Brand.get` 관례를 이었지만, 이름을 정할 때 같이 정리.
|
||||
- **`Tag`/`Added`/`Removed`/`Merged`(3순위, 사소함, 2026-08-08 세 번째
|
||||
세션 array-part 값 객체 재설계 때 확정된 API 표면)**: `base/tag-plan.md`가
|
||||
"열린 질문 없음, 값 모양/메커니즘/retract/패키지 배치 전부 확정, 이름
|
||||
|
|
@ -117,19 +115,43 @@
|
|||
- `Store`/`Source`/`Modifier`/`process`/`retract`/`isHandlable`은 업계
|
||||
선례와 잘 맞거나 이미 신중하게 결정된 이름들이라 특별한 문제 없음.
|
||||
|
||||
## 2. M2/M3 마일스톤 경계 — **M2 착수 전에 답이 필요** (2026-08-22 신설)
|
||||
|
||||
**M2(디스패치 엔진)와 M3(Store/State/Source)의 의존이 양방향이라,
|
||||
`ROADMAP.md` 순서대로면 M2를 끝까지 짤 수 없습니다.**
|
||||
|
||||
- **M2 → M3 (본체 의존)**: `Dispatch.setLength`가
|
||||
`len: number | State<number>`를, `Dispatch.setOffsetSource`가
|
||||
`Source<number>`를 받고, `recompute`가 `offset:Set()`을 부릅니다
|
||||
(`base/dispatch-core-plan.md`의 "Length/Offset" 절). 2026-08-22에 M2로
|
||||
옮긴 `GateNode`/`Blocker`도 State 위에 얹힙니다. 즉 **M2는
|
||||
`Source.luau`/`State.luau` 없이는 구현이 안 됩니다.**
|
||||
- **M3 → M2 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로 가드가
|
||||
`Dispatch.addHandler` + `Handler.luau` 계약을 씁니다 — 이건 레지스트리
|
||||
등록 표면만 있으면 되므로 M2 **전체**를 요구하지 않습니다.
|
||||
**⚠️ 다만 "M2 앞머리 두 항목(`Dispatch/init.luau` + `Handler.luau`)이면
|
||||
된다"고는 말할 수 없습니다** — `Dispatch/init.luau`에는 `Dispatch.drive`가
|
||||
들어 있고, `dispatch-core-plan.md`가 *"적용 지점 — `Dispatch.drive`와
|
||||
`attachSlot`, 각각 자기 owner 키로 별도 Blocker"*라고 확정해 `drive`도
|
||||
게이팅을 씁니다. 즉 그 첫 항목 자체가 State-free가 아닙니다. 선택지 (b)로
|
||||
쪼갠다면 `drive`를 어느 쪽에 두느냐가 경계선이 됩니다.
|
||||
|
||||
**선택지**:
|
||||
- **(a) M2와 M3의 순서를 바꾼다** — 반응형(Source/State/EpochMap/Gate/
|
||||
Blocker)을 먼저 짜고 그 위에 디스패치를 올림. 얕은 쪽(가드 Handler)만
|
||||
뒤로 미루면 됨. 지금까지의 결정 흐름("게이팅 먼저")과 방향이 같음.
|
||||
- **(b) M2를 둘로 쪼갠다** — `Dispatch.getHandler`/`process`/`retractFrom`/
|
||||
`Handler`/`Brand`/`Relate`/`chains`까지가 M2a, State가 필요한
|
||||
Length/Offset·게이팅은 M3 뒤의 M2b로.
|
||||
- **(c) 지금 구조를 두고 구현 시 알아서 오간다** — 로드맵은 "순서"가
|
||||
아니라 "묶음"으로만 읽음.
|
||||
|
||||
**[2026-08-22 기준] 이 항목이 M2 착수를 막습니다** — `.claude/todos.md`
|
||||
0번이 "M2 착수를 막는 설계 항목은 없다"고 하는 것은 **설계** 얘기이고,
|
||||
이건 설계가 아니라 **순서** 문제라 별개입니다.
|
||||
|
||||
## 3. 낮은 우선순위 — 열려 있지만 급하지 않음
|
||||
|
||||
- **[신설, 2026-08-18 구현 전 QA 3라운드] M2가 M3의 `Blocker.luau`에
|
||||
구조적으로 의존하게 됨 — 이대로 각주만 두고 로드맵 순서를 유지할지,
|
||||
`Blocker.luau`(또는 최소 표면 `On`/`Off`/`IsOn`/`OffWithoutEmit`)를 M2로
|
||||
앞당길지, M2/M3 경계 자체를 재검토할지.** `RC-1`의 Blocker 게이팅 해법
|
||||
때문에 `ROADMAP.md` M2의 `Dispatch.setLength`/`setOffsetSource` 체크박스가
|
||||
`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`을 호출하는데, 정작
|
||||
`Blocker.luau` 자체는 M3 체크박스에 있다 — 로드맵 순서대로면 M2가 아직
|
||||
없는 걸 참조하게 된다. 지금은 M2 체크박스에 이 사실만 각주로 남겨둔
|
||||
임시 조치(가장 보수적인 선택, 마일스톤 재편은 안 함) — **M2 착수 전
|
||||
필요**. 상세는 `qa-request/pre-implementation-qa-round3.md`의
|
||||
"ROADMAP.md 마일스톤 정합성" 절.
|
||||
- **`Operator` 콤비네이터 슈가 네임스페이스 이름+포함 범위(2026-08-12 신설,
|
||||
같은 날 후속으로 외부 리서치 완료)** — `Sum`/`Product`/`Not`/비트연산 등
|
||||
`:Compute`/`:Apply`용 슈가 함수 모음의 이름. 흔한 단어라 top-level
|
||||
|
|
@ -164,31 +186,6 @@
|
|||
`getDynamic(store, name)` — "특정 프리미티브에 안 묶인 범용 유틸은 소문자
|
||||
탑레벨"이라는 기존 네이밍 규칙에는 오히려 더 맞는다. **M3/M4 착수 전
|
||||
필요**, `base/store-plan.md`의 "타입 추론 문제" 절.
|
||||
- **[신설, 2026-08-18 커밋 전 `/code-review high`] `Detach`로 홀드 중이던
|
||||
요소의 키가 데이터에서 사라지면 어떻게 처분하는가** — 지금 의사코드대로면
|
||||
`mounted[key]`가 이미 `nil`이라 파괴 대상이 아니고, 소멸 루프가
|
||||
`userdata[key]`까지 지워서 **파괴되지도 `updateFn`에게 되돌려지지도 않고
|
||||
참조만 끊긴다**. 같은 절의 표("키가 사라지면 파괴")와도, "버릴 시점은
|
||||
`updateFn`이 정한다"와도 어긋남. 선택지 (a) 소멸 루프가 `userdata`의
|
||||
`old`까지 확인해 파괴, (b) 지금 동작(참조만 끊고 GC)을 정식화하고 표를
|
||||
고침, (c) `updateFn`을 마지막으로 한 번 더 불러 처분을 물음. **[정정,
|
||||
2026-08-18 `/code-review high`] M6(`:List`가 있는 마일스톤) 착수 전
|
||||
필요** — M8(`Ref`) 아님, `base/slot-plan.md`의 "`nil` 리턴은 파괴가
|
||||
기본" 절.
|
||||
- **[신설, 2026-08-18 구현 전 QA] 그룹 `Attribute`의 위치별 claim 설계** —
|
||||
같은 그룹 객체를 두 위치에 놓는 경우(`Frame { a, a }`)를 잡으려면 위치별
|
||||
claim 레지스트리가 하나 필요하다는 **방향은 확정**됐고(`Ref`처럼
|
||||
`bindLifetime`을 재사용할 수는 없음 — 그룹 값은 여러 곳에서 쓸 수 있어야
|
||||
하므로), **키를 무엇으로 할지**(`(inst, groupValue) → k`인지 `groupKey`
|
||||
단위인지)와 기존 `nameClaims`와의 공존 방식이 미정 —
|
||||
`base/attribute-plan.md`의 "이름 소유권" 절.
|
||||
- **[신설, 2026-08-18 구현 전 QA] `SetAndDispose` 류 편의 콤비네이터** —
|
||||
`Get()` → `Set(new)` → 옛 값 `dispose`의 3단계를 매번 손으로 쓰는 게
|
||||
불편하다는 사용자 지적에서 나옴. `source:Apply(SetAndDispose(new))`
|
||||
(단 이때 `Apply`는 `State`가 아니라 `Source`를 넘겨야 함)와
|
||||
`source:SetAndDispose(new)` 콜론 메서드 중 어느 쪽인지, 그리고 이번
|
||||
범위인지 백로그인지 미정 — **M3 착수 전 방향만이라도** 정할 것
|
||||
(`state:Apply` 시그니처에 영향), `base/slot-plan.md`의 `dispose` 절.
|
||||
- **[신설, 2026-08-18 구현 전 QA] 중간 State GC 미검증** — `State → State →
|
||||
State → Observer` 체인에서 중간 노드를 강하게 붙잡는 주체가 문서 어디에도
|
||||
없어 전파가 조용히 끊길 수 있음. 방향(상류 strong / 하류 weak)은 사용자가
|
||||
|
|
@ -201,10 +198,6 @@
|
|||
문서화만** 했는데(`base/attribute-plan.md` "메커니즘" 절), 원자적
|
||||
롤백(그룹 `process`에만 국소적인 unwind)을 넣을지는 열어둠. 지금 결정
|
||||
불필요 — M10 구현 시점에 판단.
|
||||
- **[해소됨, 2026-08-18] `Attribute.Merged`의 이름 중복** — `Merged`(겹치면
|
||||
error)와 `Overridden`(겹치면 뒤가 이김)을 **둘 다 제공**하는 것으로 확정
|
||||
(제3안). 근거·파급은 `base/attribute-plan.md`의 "채택안 — `Tag`와 동형인
|
||||
array-part 값 객체" 절.
|
||||
- **`quad-debug` 세부 API 이름** — `research/debug-tooling-plan.md` 참고.
|
||||
채널 실현 가능성(BindableEvent/Function이 플러그인↔Play 중 게임 경계를
|
||||
넘는지)까지 사용자가 Studio에서 직접 실측 검증 완료 — 기술적 불확실성은
|
||||
|
|
|
|||
|
|
@ -43,9 +43,14 @@ Store/Slot/Tween/bind-dispatch 설계 결정에 근거로 인용될 때만 열
|
|||
— quad Store가 이 naive BFS 방식을 그대로 베끼면 안 되는 이유.
|
||||
**[2026-08-14 보강]** quad가 이걸 피하는 방식은 "전파를 중간에 끊는 것"이
|
||||
아니라 **애초에 push 시점에 계산을 안 하는 것**(pull-recompute + 노드별
|
||||
캐시) — 신호는 두 경로로 두 번 도착해도 계산은 `:Get()` 때 한 번뿐.
|
||||
즉 quad도 중복 *통지*는 접지 않고 중복 *재평가*만 안 일어남
|
||||
캐시) — 신호가 두 경로로 도착해도 계산은 `:Get()` 때 한 번뿐.
|
||||
(**[2026-08-21]** 이제 신호 자체도 두 번 안 온다 — 아래 갱신 참고.)
|
||||
즉 quad도 중복 *재평가*는 안 일어남
|
||||
(`base/source-state-plan.md`의 "다이아몬드 의존성은 무엇이 푸는가" 절).
|
||||
**[2026-08-21 갱신]** 여기 있던 "중복 *통지*는 접지 않는다"는 뒤집혔다 —
|
||||
소스 에포크 비교를 채택해 **두 번째 통지도 접힌다**
|
||||
(`base/state-epoch-plan.md`, 역전 원문은
|
||||
`archive/always-propagate-no-dedup-superseded.md`).
|
||||
- **정리 모델**: 의존성 엣지(`parents`)와 구조적 소유(`owner`/`owned`)를 같은
|
||||
`Node`에서 두 개의 별도 관계로 분리 — CHANGELOG 0.2.0에서 "destroy가 더 이상
|
||||
reactive dependent까지 타고 내려가지 않고 owned만" 으로 명시적으로 고침(초기
|
||||
|
|
|
|||
187
.claude/reference/epoch-brand-composition.md
Normal file
187
.claude/reference/epoch-brand-composition.md
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# `Epoch` 인터페이스 + `EpochMap` 컴포지션, 그리고 `Brand` 인스턴스화 — 결정 근거 기록 (2026-08-21)
|
||||
|
||||
**상태**: **[2026-08-21] 전량 확정·승격 완료.** 이 문서는 이제 "왜 그렇게
|
||||
정했나"의 근거 기록이고, **지금 유효한 설계는 `base/`가 소스**다 —
|
||||
`base/state-epoch-plan.md`(`Epoch`/`EpochMap`/State의 두 맵),
|
||||
`base/brand-plan.md`(인스턴스 브랜드), `base/source-state-plan.md`(`Source`가
|
||||
`Epoch`를 구조적으로 만족 + `Observer` 클로저 시그니처),
|
||||
`base/effect-plan.md`(`Effect`가 자기 `EpochMap`을 듦).
|
||||
**[2026-08-21 `research/` → `reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한
|
||||
설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로
|
||||
(`slot-attach-decomposition.md`와 같은 처리).
|
||||
|
||||
**[2026-08-21] 열린 항목 없음** — 마지막까지 남았던 리비전 증가 방식도
|
||||
같은 날 `bit32` 랩으로 확정됐다(§4의 4번).
|
||||
|
||||
**⚠️ [2026-08-22] 아래 본문의 `{Epoch}` 표기는 제안 당시 표기 그대로다** —
|
||||
승격 후 실측에서 그게 Luau의 **배열**(`{[number]: Epoch}`)이라 실제 게이트
|
||||
배치(`{[Epoch]: true}` 집합)와 안 맞는 게 드러나 **`EpochSet`으로
|
||||
확정**됐다(`base/state-epoch-plan.md` §3). 마찬가지로 `Observer` 클로저의
|
||||
`from`은 **옵셔널**이다(설치 발화엔 출처가 없다). 정본은 언제나 `base/`.
|
||||
|
||||
발단은 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 도는" 갭
|
||||
(`base/effect-plan.md`의 다중 deps 절, 2026-08-21 대화에서 에이전트가 제기).
|
||||
|
||||
아래는 그 결론에 이른 제안·평가·회신을 그대로 보존한 것 — 원래 서술은
|
||||
"승격 대기 중인 제안"이었다.
|
||||
|
||||
## 1. 제안 (사용자 원문 요지)
|
||||
|
||||
1. **에포크 부기를 State에서 떼어내 컴포지션 가능한 객체로.** 가칭
|
||||
`EpochMap()`, 표면은 `EpochMap:Update(Epoch|{Epoch}) -> boolean`이고
|
||||
그 boolean이 **"뒤로 전파가 필요한가"** 를 뜻한다. *"이건 invalid 랑은
|
||||
다른 구현이야."*
|
||||
2. **`Observer`는 emit의 주체를 클로저 인자로 흘려준다**(값이 아니라 출처).
|
||||
3. **`Effect`는 자기 `EpochMap`을 하나 갖고**, 각 dep의 observer 등록 함수
|
||||
안에서 그걸 `Update` 한다 → 한 파동에 여러 dep가 깨워도 **첫 번째만
|
||||
`true`** 라 `fn`이 한 번만 돈다.
|
||||
4. **emit 페이로드에서 게이트를 뺀다.** `emit(from: Source|{Source})`이면
|
||||
충분하다 — *"하류가 Gate 노드를 받을 이유도 없거든."* 런타임 분기는
|
||||
`isSource`로.
|
||||
5. **더 나아가 `Source`가 아니라 `Epoch`로 일반화**한다.
|
||||
`type Epoch = { Count: number }`이고 **그 자체로 키로 쓸 수 있는 unique
|
||||
테이블**. `emit(Epoch|{Epoch})`, `Update(Epoch|{Epoch})`.
|
||||
*"'소스를 전해주는것' 이라고 보기엔 너무 협소하고, 일반화된 형태가 아님."*
|
||||
6. **그러려면 `Brand`가 다중 태깅을 허용해야 한다** — 나아가 `Brand` 자체를
|
||||
**인스턴스화 가능**하게: `Brand()`가 weak `[obj] = true|nil` 하나를 들고,
|
||||
`SomeBrand:register(obj)` / `SomeBrand:is(obj)`. *"본인이 거기 속하면,
|
||||
본인이 직접 해당 브랜드를 가져와 등록하면 … `isXXXX`에서 각각의 구현을
|
||||
넣을 필요가 없어짐. 따라서 외부 확장도 쉬워진다."*
|
||||
|
||||
## 2. 에이전트 평가 — 방향은 옳다
|
||||
|
||||
- **`EpochMap` 분리는 이미 코퍼스가 발견한 구분을 형식화한다.** 같은 날
|
||||
`state-epoch-plan.md`가 맵을 둘로 가른 이유가 정확히 "값 유효성"과 "전파
|
||||
dedup"이 **비대칭으로 움직인다**는 것이었다(승격 후 정본은
|
||||
`base/state-epoch-plan.md` §4의 "왜 둘인가 — 순회 때문이다" 문단).
|
||||
후자만 떼어내 재사용 가능한 객체로 만들면, **노드가 아닌 소비자(leaf)도
|
||||
같은 판정을 쓸 수 있다** — 지금 State에만 있어서 못 쓰던 것.
|
||||
- **다중 dep `Effect` 갭이 이걸로 정확히 닫힌다.** `A → b`, `A → c`,
|
||||
`Effect(fn, b, c)`에서 `b`/`c`는 서로 다른 노드라 **접어줄 공통 하류가
|
||||
없어** 에포크 dedup이 못 도왔다. `Effect`가 자기 `EpochMap`을 들면
|
||||
그 지점이 곧 공통 하류가 된다. 대안으로 검토했던 "deps를 하나의 파생
|
||||
노드로 수렴시키기"보다 낫다 — 노드를 더 안 만들고, `effect-plan.md`가
|
||||
확정한 "의존성 N개면 내부 Observer도 N개" 구조를 안 건드린다.
|
||||
- **게이트를 페이로드에서 빼는 것도 맞다.** 하류는 게이트 identity를 **한
|
||||
번도 안 쓴다**(에포크 경계로 만드는 안은 `base/state-epoch-plan.md` §8의
|
||||
"기각된 대안 — 게이트를 에포크 경계로" 항목에서 기각됨). 배치가 "그 전파에만 쓰는 일회성 스냅샷"이라는 성질도
|
||||
그대로 유지된다.
|
||||
- **`Epoch`로의 일반화가 실제로 계약을 정확하게 만든다.** 맵이 `Source`에서
|
||||
요구하는 건 **identity + 단조 증가 카운터** 둘뿐이다. 그걸 이름 붙이면
|
||||
`state-epoch-plan.md`가 `루트 Source들의 에포크`라 부르던 것이 그냥 `Epoch`들이 되어
|
||||
서술도 짧아지고, Source가 아닌 원천(외부 시계 등)도 특수분기 없이 낀다.
|
||||
- **`Brand` 인스턴스화 — 다중 태깅 필요는 지금 실재한다.** `Source`가
|
||||
`SourceBrand`이면서 동시에 `EpochBrand`여야 하는데, 현행
|
||||
`Brand.get(x) -> tag`는 **객체당 태그 하나**라 표현이 안 된다
|
||||
(`base/brand-plan.md`). 게다가 지금 서브타입은 손으로 쓴 OR 체인
|
||||
(`isState = isSource(x) or Brand.get(x) == StateTag`)인데, 자기 등록
|
||||
방식이면 `StateBrand:is(x)` **한 번**으로 끝나 조회 수도 준다.
|
||||
|
||||
## 3. 대가 — [2026-08-21] 둘 다 사용자 회신으로 해소됨
|
||||
|
||||
에이전트가 대가로 짚었던 둘은 **둘 다 실제 비용이 아니었다.**
|
||||
|
||||
- **`Brand.get(x)` 역조회 상실** — *"확실히 의미가 없어진것 같습니다 필요하진
|
||||
않아요."* 코퍼스가 실제로 쓰는 건 전부 `isX` 형태의 멤버십 질문이고, "이
|
||||
값이 대체 뭔가"를 되묻는 자리는 없다.
|
||||
- **"포함 관계가 코드 모양에 드러난다"는 성질이 흩어진다** — **에이전트 착오,
|
||||
철회.** 사용자 지적: *"여전합니다. `PreRefBrand` 가 존재할테니. 거기에
|
||||
`is()` 를 해서, 코드에 전부 드러나는거 똑같습니다."* 자기 등록 방식이
|
||||
**여러 브랜드에 등록하기를 강제하는 게 아니므로**, 지금처럼 각 타입이
|
||||
자기 브랜드에만 등록하고 포함 관계는 predicate 합성으로 한 곳에 쓰면 된다:
|
||||
```lua
|
||||
local function isRef(x)
|
||||
return PreRefBrand:is(x) or PostRefBrand:is(x) or RefBrand:is(x)
|
||||
end
|
||||
```
|
||||
`base/brand-plan.md`가 2026-08-09에 세운 성질이 그대로 유지된다.
|
||||
|
||||
## 4. 결정 상태
|
||||
|
||||
1. **[해소] `Epoch`는 `Source`가 구조적으로 만족한다** — 사용자: *"전자를
|
||||
처음부터 생각했고"*. `Source`가 `State`를 구조적으로 만족하는 기존 패턴과
|
||||
같은 모양.
|
||||
2. **[해소] 리비전 필드는 공개로 둔다** — *"`Source` 는 예약 이름이 늘어나도
|
||||
됩니다(store 아님) 그냥 공개 필드로 두는게 맞는게, 그래야 타입 상 `Source`
|
||||
가 `Epoch`를 만족해요."* **근거가 정확하다** — 필드가 비공개면 구조적
|
||||
만족이 타입 레벨에서 성립하지 않는다. `Store`와 달리 `Source`는 키가 사용자
|
||||
것이 아니므로 예약 이름이 늘어도 충돌하지 않는다.
|
||||
- **이름**: 사용자가 `Count` 대신 `token`/`revision` 제안. 공개 필드는
|
||||
코퍼스 관례가 PascalCase(`Slot.Length`/`Slot.Offset`/`Ref.Value`/
|
||||
`blocker.IsBlocked`)이므로 **`Revision`**(숫자안) 또는 **`Token`**
|
||||
(테이블안)을 권고.
|
||||
3. **[해소] 계약은 "직전 값과 다르다"만 요구한다** — 순서 비교(`<`)는 지금
|
||||
규칙 어디에도 안 쓰이고 전부 `==`/`~=`뿐이라, 단조 증가조차 계약으로는
|
||||
과하다.
|
||||
- **⚠️ 다만 사용자 근거 한 줄은 정정이 필요하다**: *"오버플로가 나든 뭐가
|
||||
되든, 이전값과 다르다는 보장될거예요."* — Luau 숫자는 double이라
|
||||
**랩어라운드가 아니라 포화**한다. `2^53`을 넘으면 `n + 1 == n`이 되어
|
||||
**다르다는 보장이 정확히 그 지점에서 깨진다.** 실제로는 도달 불가능하고
|
||||
(초당 100만 `Set`으로 285년), 그래서 **문제로 보지 않는다** — 근거만
|
||||
"오버플로해도 다르다"가 아니라 **"도달 불가능하다"**로 적어둘 것.
|
||||
4. **[해소] 숫자 리비전으로 간다** — 사용자 선택(*"Revision 숫자로 가는걸
|
||||
저는 선택하고 싶어요"*). 아래는 판단 재료였던 대조: 사용자 대안: *"아니면 안전하게 그냥
|
||||
테이블을 비교자로 씁시다. 별로 무겁지 않다고 보여요."* 비교가 identity
|
||||
동등성이라 **기능적으로는 둘 다 성립한다.**
|
||||
- **테이블**: 포화 문제 자체가 없음. 대신 **`Set` 한 번마다 테이블 하나를
|
||||
할당**한다 — 트윈처럼 매 프레임 `Set`하는 소스가 여럿이면 GC 압력이
|
||||
생긴다(quad는 GC-native 아키텍처라 이 축을 신경 써왔다).
|
||||
- **숫자**: 할당 0, 비교도 더 쌈. 위험은 도달 불가능한 `2^53`뿐.
|
||||
- **에이전트 권고도 숫자(`Revision`)** — 트윈처럼 매 프레임 `Set`하는
|
||||
소스가 여럿이면 테이블안은 GC 압력을 만든다.
|
||||
- **[해소, 2026-08-21] `bit32.bnot(-rev)`로 확정.** 사용자가 인용한
|
||||
`bit32.bnot(-1) == 0`, `bit32.bnot(-0) == 4294967295`,
|
||||
`bit32.bnot(-4294967295) == 4294967294`가 **예시가 아니라 연산 자체**
|
||||
였다 — `bit32.bnot(-a)`는 `a > 0`이면 `a - 1`, `0`이면 `4294967295`인
|
||||
**랩어라운드 감소**이고, 갱신과 랩이 **FASTCALL 하나**로 끝난다.
|
||||
랩이라 double 포화(`n + 1 == n`)가 안 생긴다. 충돌 거리 자체는
|
||||
오히려 짧아지지만(`2^53` vs `2^32`) **둘 다 도달 불가능이라 정확성은
|
||||
동률**이고, 사용자가 hot path 비용을 근거로 골랐다: *"매번 도는
|
||||
코드인지라, 값 싸게 native call + num 연산으로 가볍게 가고 싶어요."*
|
||||
- **⚠️ 에이전트가 한 번 잘못 옮겼다** — 형태를 `band(rev + 1, mask)`로
|
||||
적고 "덧셈 위에 fastcall이 하나 더 얹힌다"고 단서까지 달았는데,
|
||||
사용자 정정(*"제가 말한건, bit32.bnot(-a) 입니다"*)으로 둘 다 틀린
|
||||
게 확인됐다. 실측·정본은 `base/state-epoch-plan.md` §2.
|
||||
5. **[해소] State는 `EpochMap`을 둘 컴포지션하고, `:Sync`는 필수 연산이
|
||||
아니다 — 에이전트 착오 정정.** 에이전트가 "재계산 후 전부 최신으로
|
||||
맞추려면 `Update` 외의 연산이 필요하다"고 적었으나, **`Update`가
|
||||
`Epoch|{Epoch}`를 받으므로 전체 deps를 넘기면 그게 곧 sync다**(사용자:
|
||||
*"애초에 Update 자체가 전부 최신 상태로 만들고, 업데이트 된게 있으면
|
||||
true 를 던지는거라"*). 에이전트가 `Update`를 "하나만 받는 것"으로 좁게
|
||||
본 탓이다.
|
||||
- **초기화도 같은 연산 하나로 끝난다** — `:With`/`:Compute`의 deps를
|
||||
전부 `Update`하고 **반환값은 안 보고** `rawInvalid = true`로 둔다.
|
||||
이미 확정된 노드 생성 규칙(`base/state-epoch-plan.md` §4의 "노드가
|
||||
생길 때의 초기값" 절)과 정확히
|
||||
같은 동작이다.
|
||||
- **내부 최적화(사용자 제안)**: `Update`가 목록을 돌 때 diff 때문에
|
||||
읽기가 들어가는데, **한 번 다름을 찾으면 반환값이 이미 `true`로
|
||||
확정**되므로 나머지는 읽지 않고 쓰기만 하면 된다.
|
||||
- **`:Sync`는 순수 최적화로만 둘 수 있다** — 읽기를 아예 건너뛰고 쓰기만
|
||||
하는 변형(반환값 없음). *"없다고 안되는건 아닌데, 그냥 다 안 읽고 set
|
||||
만 해버리는것은 처음 셋팅에 도움은 됩니다."*
|
||||
6. **[방향 확정] `Observer` 클로저 인자는 `:Compute`와 같은 모양** —
|
||||
`fn(self, from: Epoch|{Epoch})`. 사용자: *"Compute 와 유사하게 나올 수
|
||||
있다 봐요. self 를 넘겨주고, 그 뒤에 epoch|{epoch} 를 주는게 맞아보입니다."*
|
||||
`base/source-state-plan.md`의 "`:With`/`:Compute` — self 인자도 lazy
|
||||
핸들로 통일" 절과 같은 결이고, 값이 아니라 **핸들과 메타데이터**만
|
||||
넘기므로 *"값을 안 실어주는 구독"* 계약도 안 깨진다.
|
||||
- 반영 시 같이 손볼 것: 그 계약 문단과 인자 없는 `state:Observer()` 유틸,
|
||||
그리고 `base/effect-plan.md`의 내부 Observer 등록부(여기서 `Effect`가
|
||||
자기 `EpochMap`을 `Update`한다).
|
||||
7. **[해소] `Brand` 전환 범위와 마일스톤** — 역조회가 불필요하고 포함 관계
|
||||
성질도 유지되므로 **인스턴스 브랜드로 전면 전환**이면 된다. **커밋된 M1
|
||||
코드는 `Brand`를 아직 안 쓴다**(`quad-base/src`는 `init.luau`/`Relate.luau`/
|
||||
`Debug`뿐, 2026-08-21 확인) — 전환 비용은 **문서뿐**이다. `Brand`라는 이름
|
||||
자체는 여전히 용어 정리 대기(`question.md` 1번).
|
||||
|
||||
## 관련 문서 (전부 승격 반영 완료)
|
||||
|
||||
- `base/state-epoch-plan.md` — `Epoch`/`EpochMap`과 State의 두 맵, 수신 규칙.
|
||||
- `base/brand-plan.md` — 인스턴스 브랜드(옛 단일 레지스트리는
|
||||
`archive/brand-shared-registry-reversed.md`).
|
||||
- `base/source-state-plan.md` — `Source`가 `Epoch`를 구조적으로 만족,
|
||||
`state:Observer(fn)`의 `fn(self, from)` 계약.
|
||||
- `base/effect-plan.md` — `Effect`가 자기 `EpochMap`을 들어 다중 deps 중복
|
||||
발화를 접는다(이 제안이 닫은 갭).
|
||||
- `base/gate-plan.md` — 배치 페이로드(4번), 빈 배치 무통지(8번).
|
||||
431
.claude/reference/slot-attach-decomposition.md
Normal file
431
.claude/reference/slot-attach-decomposition.md
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
# `attachSlot` 책임 분해 — 결정 근거 기록 (2026-08-21 확정)
|
||||
|
||||
**상태**: **[2026-08-21] 결론 확정 — (B) 분해 채택, `base/slot-plan.md`에
|
||||
반영 완료.** 이 문서는 이제 "왜 그렇게 정했나"의 근거 기록이고, **지금 유효한
|
||||
설계는 `base/slot-plan.md`의 "재귀 메커니즘" 절**(`materializeSlotTree` /
|
||||
`mountSlotTree` / 얇은 `attachSlot`)이 소스다.
|
||||
**[2026-08-21 `research/` → `reference/` 이동]** 확정된 뒤엔 "상의가 더 필요한
|
||||
설계"가 아니라 "다른 문서가 근거로 인용하는 온디맨드 자료"이므로.
|
||||
|
||||
**사용자 확정 근거**(2026-08-21): *"함수 분해는 확정해도 좋을것 같음. 이게
|
||||
하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던 부분이고, 지금
|
||||
적절한 방향으로 이동하지 않으면 계속 실수에 의한 시간/기술비용이 축적될것
|
||||
같음. 지금 의사코드를 건들이는 비용이, 추후 실수가 누적되는 비용보다 싸다고
|
||||
생각함."*
|
||||
|
||||
아래는 그 결론에 이른 조사/논거를 그대로 보존한 것 — 원래 서술은 "논의 전
|
||||
준비 자료"였다.
|
||||
|
||||
**⚠️ [2026-08-21 후속] 아래 2절의 제약 `C7`("부기 갱신이 물리 트리 조작보다
|
||||
먼저")은 그 뒤 *일반 계약으로서는* 폐기됐다** — 같은 날 `native*` 주입 op 계층이
|
||||
확정되면서(`archive/bookkeeping-before-physical-reversed.md`), "자기 자리를
|
||||
정하는 것 먼저 / 뒤를 미는 것 나중" 하나로 줄었다. **이 문서의 결론은 그대로
|
||||
유효하다** — 배치 경로(`materializeSlotTree` → `mountSlotTree`)가 부기를 먼저
|
||||
끝내는 것은 `C7`이 아니라 **`C6`가 요구하는 별개 사안**이고, 그 역전 문서도
|
||||
그렇게 명시한다. 다만 아래 표만 단독으로 읽고 `C7`을 지금도 유효한 일반
|
||||
규칙으로 옮겨 쓰지 말 것 — 지금 유효한 서술은
|
||||
`base/dispatch-core-plan.md`의 "일반 계약 — 물리와" 절이 소스다.
|
||||
|
||||
**왜 생겼나**: 2026-08-21 구현 전 QA 4라운드 `F-4-3`에서 `Dispatch.setLength`를
|
||||
flush 루프 앞에 둘지 뒤에 둘지가 갈렸는데, 사용자가 그 자리를 고르는 문제가
|
||||
아니라고 짚었다 — *"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고
|
||||
attachSlot 되는게 맞을지도. **attachSlot 의 기능이 너무 다양해진게
|
||||
문제같음.** 이 부분에 있어서는 확장 논의를 하게 준비해두자."*
|
||||
|
||||
정본은 `base/slot-plan.md`의 "재귀 메커니즘" 절 — **이 문서가 그 확정을
|
||||
대체하지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
## 1. `attachSlot`이 지금 하는 일 — 책임 일곱 개
|
||||
|
||||
`base/slot-plan.md`의 의사코드를 책임 단위로 쪼개면 이렇다(코드는 그 문서가
|
||||
소스, 여기선 라벨만 붙임):
|
||||
|
||||
| 라벨 | 하는 일 | 층위 |
|
||||
|---|---|---|
|
||||
| **R1** | `offsetSource` 생성 → `Dispatch.setOffsetSource(ownerKey, position, ...)` → `slot.Offset` 공개 | **부모에 대한 자기 등록** |
|
||||
| **R2** | `slot._listed`면 `activateList` — `:List` reconcile이 `_elements`를 채움(물리 마운트 없음) | **내용 실체화** |
|
||||
| **R3** | `slot._mounted = true`, `slot._mountedInst = physicalTarget` | **자기 상태 전이** |
|
||||
| **R4** | `Dispatch.setLength(ownerKey, position, slot.Length)` | **부모에 대한 자기 등록** |
|
||||
| **R5** | `getBlocker(slot):On()` … `:OffWithoutEmit()` + 마지막 `recompute` | **배치 게이팅** |
|
||||
| **R6** | flush 루프 — 각 요소의 부기 등록(`setOffsetSource`/`setLength`) **+ 물리 마운트(`Parent` 대입)** | **자식 배치** |
|
||||
| **R7** | 중첩 Slot 요소에 대해 `attachSlot` 재귀 | **재귀** |
|
||||
|
||||
**서로 다른 축이 넷 섞여 있다** — (a) 부모에게 나를 알리는 일(R1/R4),
|
||||
(b) 내 내용을 만드는 일(R2), (c) 내 자식을 실제로 붙이는 일(R6), (d) 상태
|
||||
플래그와 배치 게이팅(R3/R5). 사용자가 "기능이 너무 다양해졌다"고 한 게 이것.
|
||||
|
||||
**호출부는 셋**:
|
||||
- `SlotHandler.process`(최상위) — `bindLifetime` 후 `attachSlot(slotValue, inst, inst, k)`
|
||||
- R7(중첩) — flush 루프 안에서 재귀
|
||||
- `rawAdd`(런타임 단건) — 이미 마운트된 Slot에 나중에 nested Slot을 `Add`할 때
|
||||
|
||||
---
|
||||
|
||||
## 2. 순서 제약과 그 출처 — 왜 지금 모양이 됐나
|
||||
|
||||
**이 제약들은 전부 실제로 밟은 버그에서 나왔다.** 분해안을 평가할 때 하나라도
|
||||
깨면 그 버그가 되돌아온다.
|
||||
|
||||
| # | 제약 | 왜 | 출처 |
|
||||
|---|---|---|---|
|
||||
| **C1** | `slot.Offset`(R1)이 `activateList`(R2)보다 **먼저** | `:List`의 `updateFn`이 `offset`을 인자로 받는 계약 | `slot-plan.md` `:List` 파라미터 |
|
||||
| **C2** | `activateList`(R2)는 `_mounted == false`인 상태에서 돌아야 함 → **R2가 R3보다 먼저** | 아니면 reconcile의 `rawAdd`가 매 항목마다 즉시 물리 마운트 + `setLength`를 태워 (a) Blocker 없이 `recompute`가 돌고 (b) nested Slot에 `attachSlot`이 **두 번** 불림 | `RC-3`/`RC-4`(QA 3라운드) |
|
||||
| **C3** | `attachSlot`이 **반환될 때는** `_mounted == true`여야 함 | 런타임 `rawAdd`가 이 플래그로 "지금 붙일까 `_elements`에만 넣을까"를 가름 | `slot-plan.md` `rawAdd` |
|
||||
| **C4** | `setOffsetSource`가 `setLength`보다 **먼저** | `setLength` 끝의 `gatedRecompute`가 죽는 중인 Source에 `:Set`을 날림 | `dispatch-core-plan.md` 해제 순서 계약 |
|
||||
| **C5** | R5의 Blocker가 R6 **전체**를 감쌈 | 없으면 등록마다 `recompute`가 돌아 O(N²) | `RC-1` 해결(QA 2라운드) |
|
||||
| **C6** | R4가 넘기는 `slot.Length`의 **최종값**은 R5/R7이 끝나야 정해짐 | 중첩 Slot 요소의 `.Length`는 그 요소의 `attachSlot`이 돌아야 확정됨 | 구조적 |
|
||||
| **C7** | 부기 갱신이 물리 트리 조작보다 **먼저** | 백엔드가 "내가 물리적으로 밀어낼 때 부기는 이미 정확하다"를 전제할 수 있어야 함 | `dispatch-core-plan.md` 일반 계약(QA 4라운드 `C-7`) |
|
||||
|
||||
### ⭐ C6와 C7이 정면으로 부딪힌다 — 이게 `F-4-3`의 근본
|
||||
|
||||
- **C7을 지키려면** R4(부모에게 내 길이 알리기)가 R6(자식 물리 마운트)보다
|
||||
먼저여야 한다.
|
||||
- **C6를 지키려면** R4는 R6/R7이 끝난 **뒤**여야 최종값을 알 수 있다.
|
||||
|
||||
**지금은 C7을 지키고 C6를 포기했다** — R4가 `slot.Length`(값이 아직 `0`인
|
||||
State **객체**)를 넘기고, flush가 끝나 `recompute`가 실제 값을 넣으면 부모가
|
||||
Observer로 다시 반응해 스스로 교정한다. 대가는 **배치 밖 재마운트에서 부모
|
||||
`recompute`가 2회 도는 것**(뒤에 형제가 있으면 그 offset들이 두 번 `Set`됨).
|
||||
|
||||
**단일 함수로는 둘 다 만족할 수 없다** — 한 함수 안에서 R4의 자리가 하나뿐이기
|
||||
때문. 그래서 이건 "어느 줄에 놓을까"가 아니라 **분해 문제**다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 분해 후보
|
||||
|
||||
### (A) 현행 유지 — 단일 `attachSlot`
|
||||
|
||||
- C7 지킴, C6 포기(자기 교정 1회).
|
||||
- **비용**: 배치 밖 재마운트마다 부모 `recompute` 1회 낭비. 크래시도 영구
|
||||
오류도 아님(`SL-58`에 이미 "손대지 않기로" 기록됨).
|
||||
- **문제**: 사용자가 지적한 "기능이 너무 다양함"은 그대로 남는다.
|
||||
|
||||
### (B) 2단 분리 — `prepare` / `mount` ⭐ 사용자 제안에 가장 가까움
|
||||
|
||||
*"리스트 액티베이션을 먼저 하고 length 를 얻어 밀고 attachSlot 되는게 맞을지도"*
|
||||
를 그대로 구조화하면 이 모양이 된다. **핵심은 R6를 부기(R6a)와 물리(R6b)로
|
||||
쪼개는 것.**
|
||||
|
||||
```lua
|
||||
-- 개념 스케치. 이름/시그니처 전부 가칭
|
||||
local function prepareSlot(slot, physicalTarget, ownerKey, position)
|
||||
-- R1
|
||||
local offsetSource = Source(0)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
|
||||
slot.Offset = offsetSource
|
||||
|
||||
-- R2 (여전히 _mounted == false — C2)
|
||||
if slot._listed then activateList(slot, physicalTarget) end
|
||||
|
||||
-- R5 + R6a + R7-prepare : 부기만, 물리 마운트 없음
|
||||
local blocker = getBlocker(slot)
|
||||
blocker:On()
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then
|
||||
prepareSlot(element, physicalTarget, slot, i) -- 재귀 → element.Length 확정
|
||||
Dispatch.setLength(slot, i, element.Length)
|
||||
else
|
||||
Dispatch.setOffsetSource(slot, i, None)
|
||||
Dispatch.setLength(slot, i, 1)
|
||||
end
|
||||
end
|
||||
blocker:OffWithoutEmit()
|
||||
recompute(slot, bk) -- 여기서 slot.Length가 **최종값**으로 확정 (C6 만족)
|
||||
end
|
||||
|
||||
local function mountSlot(slot, physicalTarget)
|
||||
slot._mounted = true -- R3 (C3)
|
||||
slot._mountedInst = physicalTarget
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then mountSlot(element, physicalTarget) -- R7-mount
|
||||
else element.Parent = physicalTarget end -- R6b
|
||||
end
|
||||
end
|
||||
|
||||
-- 호출부(최상위)
|
||||
prepareSlot(slotValue, inst, inst, k)
|
||||
Dispatch.setLength(inst, k, slotValue.Length) -- R4 — 이제 최종값 (C6 + C7 동시 만족)
|
||||
mountSlot(slotValue, inst)
|
||||
```
|
||||
|
||||
- **C6와 C7을 둘 다 만족한다** — 부모에게 넘기는 길이가 처음부터 최종값이고,
|
||||
그 등록이 어떤 `Parent` 대입보다도 먼저다.
|
||||
- **각 함수의 일이 하나로 좁아진다** — `prepareSlot` = "부기를 정확하게
|
||||
만든다", `mountSlot` = "그 부기대로 트리에 붙인다".
|
||||
- **`_mounted`의 의미가 정직해진다** — 지금은 "`activateList`는 지났고 flush는
|
||||
아직"이라는 어정쩡한 중간 시점인데, 분리하면 문자 그대로 "mount 단계를
|
||||
지났는가"가 된다.
|
||||
- **비용**: `_elements` 순회가 2회로 늘고, 호출부가 **셋 다** 두 함수를 순서대로
|
||||
불러야 한다(빠뜨리면 half-attached 상태).
|
||||
|
||||
### (C) 3단 분리 — `register` / `activate` / `mount`
|
||||
|
||||
R1(부모 등록)까지 따로 떼는 안. `Dispatch.drive`의 최상위 배열 파트도 같은
|
||||
3단으로 맞추면 "배치 등록 → 실체화 → 마운트"라는 하나의 모양이 코퍼스 전체에
|
||||
반복된다.
|
||||
|
||||
- **이득**: `Dispatch.drive`와 `attachSlot`이 지금 서로 비슷한데 미묘하게
|
||||
다른 구조(전자는 Blocker + 본체 루프, 후자는 Blocker + flush)인 걸 하나로
|
||||
수렴시킬 수 있음.
|
||||
- **비용**: 단계가 하나 더 늘고, 호출부가 셋 → 셋 × 3단이 됨. (B)의 이득
|
||||
대부분을 (B)만으로 이미 얻으므로 **추가 이득이 뭔지가 논의 대상**.
|
||||
|
||||
### (D) 최소 변경 — 분리 없이 문서만
|
||||
|
||||
`attachSlot` 안을 R1~R7 주석 블록으로 명시하고 각 제약(C1~C7)을 그 자리에
|
||||
달아둠. 코드는 그대로.
|
||||
|
||||
- **이득**: 위험 0. **비용**: 근본 문제(C6/C7 충돌, 책임 과다)는 안 풀림.
|
||||
|
||||
---
|
||||
|
||||
## 4. 어떤 분해든 같이 정해야 하는 것
|
||||
|
||||
1. **`activateList`의 `data:Observer(fn)` `bindLifetime`은 어느 단계인가.**
|
||||
지금은 `activateList` 안에서 `bindLifetime(inst, observer)`를 부르는데,
|
||||
(B)에서 그건 prepare 단계다 — 아직 아무것도 물리적으로 안 붙은 시점에
|
||||
`physicalTarget`에 생명주기를 묶는 게 맞는지.
|
||||
2. **호출부를 감싸는 얇은 `attachSlot`을 남길지.** 남기면 `SlotHandler.process`/
|
||||
`rawAdd`가 지금처럼 한 줄로 끝나고 "한쪽만 부르는" 오용도 막힌다. 대신
|
||||
"결국 다시 한 함수"라 분해의 이득이 반쯤 희석된다.
|
||||
3. **`Dispatch.drive`도 같은 모양으로 맞출지**(후보 (C)와 직결).
|
||||
4. **prepare만 하고 mount 안 한 중간 상태를 어떻게 다룰지** — 방어할지, UB로
|
||||
둘지. 코퍼스 기조상 UB + 문서화가 자연스러워 보이지만 확인 필요.
|
||||
5. **`_mounted` 소비처가 새 정의로도 맞는지** — (a) `:List()`가 마운트 이후에
|
||||
불릴 때 즉시 `activateList`하는 분기, (b) `rawAdd`의 "붙일까 말까" 분기.
|
||||
(B)에서는 `_mounted`가 mount 단계에서 켜지므로, prepare와 mount 사이에
|
||||
`:List()`가 불리는 경로가 있는지 따져야 한다.
|
||||
6. **`Detach` 정리용 `Effect`(QA 4라운드 `F-3`)를 어디에 설치할지** — 그건
|
||||
`physicalTarget`에 묶이므로 mount 단계가 자연스럽다. **`F-3`이 먼저
|
||||
닫히는 게 순서상 낫다** — 그 결정이 이 분해의 요구사항을 하나 더 얹는다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 지금 상태 요약
|
||||
|
||||
> **[2026-08-21 갱신] 사용자 판단으로 (B) 방향 + "공개 표면은 안 쪼갬"까지
|
||||
> 좁혀졌다** — 구체안은 **6절**, "물리 마운트가 일괄로 밀리는" 차이에 대한
|
||||
> 검토와 판단 갱신은 **7절**이 최신이고, 이 절과 3절은 거기까지 온 경로다.
|
||||
|
||||
- **확정된 건 없다.** `base/slot-plan.md`의 단일 `attachSlot`이 여전히 정본.
|
||||
- **급하지 않다** — (A)로 두어도 동작은 맞고(자기 교정), M2/M3 착수를 막지
|
||||
않는다. 다만 **M6(`:List`) 구현 전에는 정해두는 게 낫다** — 그 시점에
|
||||
`activateList`/`rawAdd`/`attachSlot`을 실제로 짜기 때문.
|
||||
- **선행 항목**: 위 4-6번대로 `F-3`(`Detach` 보관 위치 + `KeyGone`)이 먼저
|
||||
닫히면 이 논의의 요구사항이 완전해진다.
|
||||
|
||||
---
|
||||
|
||||
## 6. (B)를 실제로 쪼갠다면 — 구체안 (2026-08-21, 사용자 질문에 대한 답)
|
||||
|
||||
**사용자 판단**: *"자식중 slot 이 있으면 activateList 로 실체화 하고 길이를
|
||||
구해서 대입해주는게 맞는듯. 즉 말했던것인 B. 그런데 쪼개야할지 의문이 들긴 함.
|
||||
attachSlot 이라는 이름이 하는 일로써 정당해보이긴 함 … 혹시 쪼갠다면 어떻게
|
||||
쪼갤것 같아?"*
|
||||
|
||||
### 6-1. 먼저 — **공개 표면은 안 쪼개도 된다**
|
||||
|
||||
`attachSlot`이라는 이름이 정당하다는 판단에 동의한다. **쪼개야 하는 건
|
||||
호출부에 보이는 함수가 아니라 재귀 자체다.**
|
||||
|
||||
- (B)가 요구하는 건 **"부기는 bottom-up으로 다 만든 뒤, 물리는 top-down으로
|
||||
붙인다"** 인데, 그러려면 재귀가 **두 번** 돌아야 한다(한 번은 길이를
|
||||
올리려고, 한 번은 붙이려고).
|
||||
- 재귀를 두 번 돌리려면 진입점이 둘이거나 모드 파라미터가 필요하다 —
|
||||
**그게 "쪼갠다"의 실체**이고, 공개 `attachSlot`을 없애자는 뜻이 전혀 아니다.
|
||||
- 그래서 제안은 **`attachSlot`은 이름·시그니처·호출부 전부 그대로 두고, 몸통을
|
||||
비공개 재귀 헬퍼 둘로 나누는 것**. `attachSlot`의 몸통은 두 줄이 된다.
|
||||
|
||||
### 6-2. 구체안 — `materializeSlotTree` / `mountSlotTree`
|
||||
|
||||
이름은 코퍼스가 이미 쓰는 `unmountSlotTree`/`destroySlotTree`의 `...SlotTree`
|
||||
접미사(= 재귀 walk)를 그대로 따랐다.
|
||||
|
||||
```lua
|
||||
-- 비공개 재귀 1: 부기만 만든다. 물리 마운트 없음.
|
||||
local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
||||
-- R1 — offset 먼저(C1: activateList가 updateFn에 넘겨야 함)
|
||||
local offsetSource = Source(0)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
|
||||
slot.Offset = offsetSource
|
||||
|
||||
-- R2 — _mounted는 여전히 false (C2: RC-3/RC-4)
|
||||
if slot._listed then activateList(slot, physicalTarget) end
|
||||
|
||||
-- R5 + R6a + R7 — 자식 부기만, 재귀로 길이를 bottom-up으로 확정
|
||||
local blocker = getBlocker(slot)
|
||||
blocker:On()
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then
|
||||
materializeSlotTree(element, physicalTarget, slot, i) -- 자기 길이를 slot[i]에 등록하고 옴
|
||||
else
|
||||
Dispatch.setOffsetSource(slot, i, None) -- C4 순서
|
||||
Dispatch.setLength(slot, i, 1)
|
||||
end
|
||||
end
|
||||
blocker:OffWithoutEmit()
|
||||
recompute(slot, bk) -- 여기서 slot.Length가 최종값으로 확정
|
||||
|
||||
-- R4 — 자기 길이를 부모에게. 이제 **최종값**이다 (C6)
|
||||
Dispatch.setLength(ownerKey, position, slot.Length)
|
||||
end
|
||||
|
||||
-- 비공개 재귀 2: 물리만 붙인다.
|
||||
local function mountSlotTree(slot, physicalTarget)
|
||||
slot._mounted = true -- R3 (C3)
|
||||
slot._mountedInst = physicalTarget
|
||||
for i, element in ipairs(slot._elements) do
|
||||
if isSlot(element) then mountSlotTree(element, physicalTarget)
|
||||
else element.Parent = physicalTarget end -- R6b
|
||||
end
|
||||
end
|
||||
|
||||
-- 공개 진입점 — 이름/시그니처/호출부 전부 그대로
|
||||
local function attachSlot(slot, physicalTarget, ownerKey, position)
|
||||
materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
||||
mountSlotTree(slot, physicalTarget)
|
||||
end
|
||||
```
|
||||
|
||||
**핵심은 `setLength`가 `materializeSlotTree`의 *끝*으로 간 것** — 자기
|
||||
길이를 부모에게 알리는 일이 자기 서브트리 부기가 다 끝난 뒤이므로 **처음부터
|
||||
최종값**이고, 그러면서도 **어떤 `Parent` 대입보다도 먼저**다. 나머지 재귀
|
||||
단계에서 자연스럽게 대칭이 된다(중첩 슬롯은 자기 `materialize` 끝에서
|
||||
`slot[i]`에 자기 길이를 등록하므로, 부모 루프가 따로 등록해줄 필요가 없다).
|
||||
|
||||
### 6-3. C1~C7 재점검 — 전부 유지된다
|
||||
|
||||
| 제약 | 어디서 지켜지나 |
|
||||
|---|---|
|
||||
| C1 (`Offset` → `activateList`) | `materializeSlotTree` 앞 두 줄 |
|
||||
| C2 (`activateList`는 `_mounted == false`) | `_mounted`는 `mountSlotTree`에서만 켜짐 — **오히려 지금보다 더 확실해짐**(중간에 켜질 자리가 아예 없음) |
|
||||
| C3 (반환 시 `_mounted == true`) | `attachSlot`이 `mountSlotTree`를 부르고 끝남 |
|
||||
| C4 (`setOffsetSource` → `setLength`) | 중첩: 자식 materialize의 첫 줄(offset) → 마지막 줄(length). 평범: 루프 안 두 줄 |
|
||||
| C5 (Blocker가 자식 배치 전체를 감쌈) | `blocker:On()` … `OffWithoutEmit()`이 루프를 감쌈. 자식의 `setLength`가 부모 blocker에 막혀 스킵되는 것도 그대로 |
|
||||
| **C6 (최종값으로 등록)** | ✅ **이제 만족** — `recompute` 다음 줄이라 최종값 |
|
||||
| **C7 (부기가 물리보다 먼저)** | ✅ **이제 만족** — 모든 `Parent` 대입이 `mountSlotTree`에 몰려 있고 그건 부기가 다 끝난 뒤 |
|
||||
|
||||
**부수 이득**: 지금은 배치 밖 재마운트에서 부모 `recompute`가 **2회**
|
||||
돌았는데(`Length=0`으로 한 번, 확정 후 한 번 — `SL-58`) **1회로 준다.**
|
||||
|
||||
### 6-4. 비용과 남는 것
|
||||
|
||||
- **`_elements` 순회가 2회**로 는다. 배열 walk 두 번이라 할당도 없고 실측에서
|
||||
문제될 규모로 보이진 않지만, 명시해둘 비용.
|
||||
- **비공개 함수가 둘 는다.** 다만 호출부(셋: `SlotHandler.process`, 런타임
|
||||
`rawAdd`, 재귀)는 **하나도 안 바뀐다** — "한쪽만 부르는" 오용 위험이
|
||||
구조적으로 없다(공개 진입점이 여전히 하나).
|
||||
- **안 고쳐지는 것**: `materializeSlotTree`는 여전히 "내 부모에게 등록"(R1/R4)과
|
||||
"내 자식 배치"(R5/R6a)를 같이 한다. 그것까지 떼려면 부모 등록을 호출부로
|
||||
올려야 하는데, 그러면 호출부 셋이 전부 두 줄이 되고 순서 실수를 열어주므로
|
||||
**과한 분해로 보인다.**
|
||||
- **비대칭 하나**: `mountSlotTree` ↔ `unmountSlotTree`는 정확한 거울상인데,
|
||||
`materializeSlotTree`의 거울상(부기 철거)은 별도 함수가 아니라 호출부의
|
||||
`setOffsetSource(None)` → `setLength(0)` 관용구다. 이름만 보고 짝을 찾으면
|
||||
헷갈릴 수 있어 문서화 시 짚을 것.
|
||||
|
||||
### 6-5. 그래서 쪼개야 하나 — 내 판단
|
||||
|
||||
**쪼개는 쪽을 약하게 추천한다.** 근거는 셋인데 어느 하나도 결정적이진 않다:
|
||||
|
||||
1. **C6/C7을 둘 다 만족시키는 유일한 방법**이고, 지금은 둘 중 하나를 포기하고
|
||||
있다(자기 교정 1회). 값이 틀려지진 않지만 "일반 계약을 세워놓고 자기가
|
||||
예외"인 상태가 남는다.
|
||||
2. **`_mounted`의 의미가 정직해진다** — 지금은 "`activateList`는 지났고 flush는
|
||||
아직"이라는 중간 시점이라 `RC-3`/`RC-4`가 그 미묘함에서 나왔다. 분해하면
|
||||
문자 그대로 "mount 단계를 지났는가"가 되고, 그 버그 클래스가 구조적으로
|
||||
사라진다.
|
||||
3. `attachSlot` 몸통이 두 줄이 되어 **읽는 사람이 "무엇을 언제 하는가"를 두
|
||||
이름만으로** 파악한다.
|
||||
|
||||
**반대로 안 쪼갤 이유**도 정당하다 — 동작이 지금도 맞고(자기 교정), 순회가
|
||||
하나 늘며, 확정된 의사코드를 건드리는 변경이다. **M6 착수 시점에 실제로 짜보며
|
||||
정해도 늦지 않다.**
|
||||
|
||||
---
|
||||
|
||||
## 7. "물리 마운트가 일괄로 밀린다"는 차이 — 손해가 아니라 이득 (2026-08-21)
|
||||
|
||||
**사용자 관찰**: *"얼마나 nested 든, 하나 마운트되고 하나 마운트되고… 각
|
||||
객체들이 `Parent = inst` 되어가며 피지컬에 붙어가는 일자 진행인데, 이건 관측
|
||||
이후 일괄 등록이라는 차이가 있는듯 … 그러나 이런 사소한, 문제를 야기하기 어려운
|
||||
동작을 원래와 동치시키기 위해 디버깅이 어려운 함수를 만드는건 옳지 않아보임."*
|
||||
|
||||
**결론에 동의한다. 그리고 그 "차이"는 감수하는 비용이 아니라 개선이다.**
|
||||
|
||||
### 7-1. 마운트 **순서**는 안 바뀐다 — 바뀌는 건 부기와 섞이느냐뿐
|
||||
|
||||
`outer { A, inner { B, C }, D }` 기준:
|
||||
|
||||
| | 현행(인터리브) | 분해(일괄) |
|
||||
|---|---|---|
|
||||
| `Parent` 대입 순서 | A → B → C → D | A → B → C → D (**동일**) |
|
||||
| 각 대입 시점의 부기 상태 | 부분적 — A가 붙을 때 `inner.Length == 0`, outer의 뒤 offset은 아직 stale | **전부 완결** |
|
||||
|
||||
두 방식 다 `_elements`를 깊이 우선으로 같은 순서로 훑으므로 **관측 가능한
|
||||
마운트 순서는 동일**하다. 달라지는 건 "그 시점에 부기가 얼마나 완성돼
|
||||
있는가"뿐이다.
|
||||
|
||||
### 7-2. 그 차이를 실제로 관측하는 주체가 있고, 분해 쪽이 더 정확하다
|
||||
|
||||
Roblox의 `Parent` 대입은 **동기적으로 `ChildAdded`/`DescendantAdded`/
|
||||
`AncestryChanged`를 발화**시킨다. 즉 사용자 핸들러가 마운트 도중에 실제로
|
||||
끼어들어 상태를 읽는다.
|
||||
|
||||
- **현행**: A의 `ChildAdded`가 뜰 때 `inner.Length`는 아직 `0`이고, `outer`의
|
||||
뒤 형제 offset도 아직 안 밀려 있다 — **완성 전 스냅샷**을 보게 된다.
|
||||
- **분해**: 첫 `ChildAdded`가 뜰 때 **서브트리 전체의 `Length`/`Offset`이 이미
|
||||
최종값**이다.
|
||||
|
||||
`slot.Length`를 구독하는 사용자 State/Observer("n개 검색됨" 라벨 등)도 같다 —
|
||||
분해 쪽이 **한 번, 최종값으로** 발화한다(현행은 `0` → 최종으로 두 번).
|
||||
`PostRef`가 "자기 아래는 전부 끝난 뒤"를 보장하는 것과 결이 같아진다.
|
||||
|
||||
**즉 원래 동작을 보존할 이유가 약한 정도가 아니라, 원래 동작 쪽이 덜 정확하다.**
|
||||
|
||||
### 7-3. "합치는 거대 함수"는 애초에 목적을 못 이룬다
|
||||
|
||||
*"액티베이션 재귀와 실측시 마운트를 합치는 거대한 함수를 만드는게 아닌이상
|
||||
해결하지 못할 문제"* — 맞는데, **그 거대 함수를 만들어도 안 풀린다.** 두
|
||||
변형을 다 따져보면:
|
||||
|
||||
- **완전 병합(측정하면서 그 자리에서 마운트)** — 자식의 최종 길이는 그 자식의
|
||||
재귀가 **끝나야** 알 수 있으므로, 자식을 마운트하는 시점엔 아직 자기 길이를
|
||||
모른다. **C6를 못 지킨다** — 그게 정확히 지금 코드(= `Length=0`으로 등록 후
|
||||
자기 교정)다. 새로 얻는 게 없다.
|
||||
- **부분 병합(자식마다 `materialize` → 바로 `mount` → 다음 형제)** — 실제로
|
||||
가능하고 인터리브도 유지된다. 하지만 **자기 길이를 부모에게 등록하는 건
|
||||
여전히 루프가 다 끝난 뒤**라, 고치려던 부모 레벨 C7 위반이 그대로 남는다.
|
||||
구조만 복잡해지고 목적은 못 이룬다.
|
||||
|
||||
**그래서 실질적 선택지는 둘뿐이다** — (A) 인터리브 + 자기 교정(현행),
|
||||
(B) 분해 + 일괄. "인터리브하면서 최종값도 아는" 제3의 안은 구조적으로 없다.
|
||||
|
||||
### 7-4. 부수 이득 둘
|
||||
|
||||
1. **Blocker의 목적이 선명해진다** — `mountSlotTree`는 부기를 전혀 안
|
||||
건드리는 순수 walk라 Blocker가 필요 없다. 결과적으로 Blocker가
|
||||
`materializeSlotTree` **하나만** 감싸게 되고, "이건 배치 *등록*을 게이팅하는
|
||||
물건"이라는 정의와 코드 모양이 일치한다(지금은 물리 마운트까지 같이 감싸고
|
||||
있어 이름과 범위가 어긋난다).
|
||||
2. **백엔드가 갈아끼울 seam이 생긴다** — `mountSlotTree`가 "부기는 끝났고
|
||||
붙이기만 하면 되는" 순수 함수라, 일괄 삽입이 유리한 백엔드(웹의
|
||||
`DocumentFragment` 등)가 **이 함수 하나만** 자기 방식으로 바꿔 끼울 수 있다.
|
||||
인터리브 구조에서는 부기 호출 사이사이에 물리 조작이 박혀 있어 불가능하다.
|
||||
|
||||
### 7-5. 판단 갱신 — 약한 추천에서 **추천**으로
|
||||
|
||||
6-5절은 "약하게 추천"이었는데, 위 7-2(관측 정확도)와 사용자가 짚은 **구현
|
||||
실수 위험**을 더하면 근거가 한 단계 올라간다:
|
||||
|
||||
- 안 쪼개면 C1~C7 일곱 제약이 **한 함수 안에서 줄 순서로만** 지켜진다 —
|
||||
`RC-1`/`RC-3`/`RC-4`가 전부 그 줄 순서를 잘못 잡아서 난 버그였다. 분해하면
|
||||
그중 C2/C3/C7이 **함수 경계로 강제**되어 줄 순서 실수로 깨질 수 없게 된다.
|
||||
- 남는 반대 근거는 "확정된 의사코드를 건드린다" 하나인데, 위 위험과 견주면
|
||||
약하다.
|
||||
|
||||
**단 여전히 M6 착수 전까지 시간이 있고, 실제로 짜보며 확정해도 늦지 않다** —
|
||||
지금 확정할 필요는 없다는 판단은 안 바뀐다.
|
||||
|
|
@ -1609,3 +1609,183 @@ Luau 함정을 발견해 `typing-limits.md` §6으로 승격.
|
|||
(`type function`은 outer local 참조 불가, cross-package엔 `export type
|
||||
function` + 이중 꺾쇠 제네릭 인스턴스화 필요). 핸드오버 감사 2라운드로
|
||||
구 시그니처 잔존/개수 하드코딩 8건 발견·수정 후 커밋.
|
||||
|
||||
## 2026-08-19 — 구현 전 QA 4라운드 문항지 작성 (회신 대기)
|
||||
|
||||
원문: `session/2026-08-19-04-qa-round4-questionnaire.md`
|
||||
|
||||
사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을 계속 …
|
||||
서브에이전트는 쓰지 말아줘")으로 `base/` 확정 주장 전체를 한 맥락에서 읽으며
|
||||
"예가 나와야 정상인 문항"으로 전수 문항화. **설계 결정도 정정도 하나도 안
|
||||
내리고** 문항지(`qa-request/pre-implementation-qa-round4.md`)만 남긴 채 회신
|
||||
대기로 끝난 세션.
|
||||
|
||||
## 2026-08-19 — 핸드오버 준비, `session-summary.md`/`ROADMAP.md` stale 대청소
|
||||
|
||||
원문: `session/2026-08-19-09-handover-prep-roadmap-status-sync.md`
|
||||
|
||||
`quad-roblox-types` 언급 확인 요청에서 시작했으나 훨씬 큰 공백 둘을 발견 —
|
||||
이 색인에 당일 세션 5개(04~08)가 통째로 빠져 있었고,
|
||||
`CLAUDE.md`/`project-context.md`/`ROADMAP.md`가 "구현 아직 시작 전"이라는
|
||||
낡은 전제를 깔고 있었다(실제로는 M0/M1이 이미 완료·커밋됨). 둘 다 즉시
|
||||
반영하고 감사 라운드로 재검증.
|
||||
|
||||
## 2026-08-20 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합
|
||||
|
||||
원문: `session/2026-08-20-01-qa-round4-response-processing.md`
|
||||
|
||||
업스트림 12커밋(pesde 전환, mise/selene, RunInit 재설계, quad-types/
|
||||
type-version-check 신설, M0 스파이크)을 먼저 rebase로 병합한 뒤, 사용자
|
||||
회신을 (a) 바로 반영 / (b) 설명 보강 후 재질문 / (c) 사용자 판단 필요 /
|
||||
(d) 조사해서 답이 나옴으로 갈라 (a)만 `base/`에 반영. 나머지는
|
||||
`pre-implementation-qa-round4-followup.md`로 정리해 남김.
|
||||
|
||||
## 2026-08-21 — `Detach` 보존 주체/`KeyGone`/`Owned` 확정, `attachSlot` 분해
|
||||
|
||||
원문: `session/2026-08-21-01-detach-keygone-owned-and-attachslot-decomposition.md`
|
||||
|
||||
QA 4라운드의 마지막 열린 항목이 닫힌 세션. gcconn 트릭 때문에 detach된
|
||||
quad-제작 Instance는 **GC 폴백이 없다**는 걸 근거로 보존 주체를
|
||||
`userdata` → **`slot._detached` 필드**로 뒤집고, 키 소멸 처분을 **`KeyGone`
|
||||
센티널**로 확정(재-`Detach`는 nop, `prev` 반환은 재마운트). `Detach`(사이클
|
||||
단위)와 **`Owned`**(설치 단위)를 직교 축으로 분리해 `state<Frame>` 의미론
|
||||
충돌도 해소. 같이 **`attachSlot`을 `materializeSlotTree`+`mountSlotTree`로
|
||||
분해** — "부모에게 미는 길이는 최종값"과 "부기가 물리보다 먼저"가 한 함수
|
||||
안에선 동시 만족 불가라는 진단이 근거였고, 사용자가 "지금 의사코드를
|
||||
건들이는 비용이 추후 실수가 누적되는 비용보다 싸다"로 확정.
|
||||
|
||||
## 2026-08-21-02 — QA 5라운드 전량 처리 + `Gate` 표면·State 에포크 확정, `Epoch`/`Brand` 제안
|
||||
|
||||
원문: `session/2026-08-21-02-qa-round5-and-gate-epoch-research.md`
|
||||
|
||||
4라운드 종결 때 "안 만든다"고 했던 5라운드를 사용자 요청으로 신설 —
|
||||
**4라운드에 문항이 없던 영역**(`project-setup-plan.md`/`quad-types-plan.md`,
|
||||
그리고 문서가 아닌 **실제 커밋된 M1 코드**), **그 이후 확정된 것**
|
||||
(`Detach`/`KeyGone`/`Owned`/`attachSlot` 분해), **큰 문서의 심화**로 범위를
|
||||
좁힌 205문항. 같은 세션에 회신까지 받아 14건 즉시 반영 — `slot._detached`
|
||||
lazy화, `KeyGone`엔 새 값 반환도 error, **`Owned=false`에서 `Detach`는
|
||||
`_detached`에 안 들어감**, 조상 파괴 시 unowned도 같이 죽는다는 계약 신설,
|
||||
`groupClaimKeys` 키 확정, `Tween<T>:Mapped` 확정, 4라운드가 빠뜨렸던 `E-10`
|
||||
실반영, **"게이팅 먼저"(M2로 앞당김)**. 새로 열린 두 갈래는 `research/`로 —
|
||||
공용 **`Gate`** 노드(emit을 가로채는 정책 노드, `Blocker`/`Debounce`가 그 위)와
|
||||
**State 에포크 검증**(DFS 전파 중 `Get()`이 섞인 값을 캐시하는 glitch를
|
||||
정확성 문제로 다룸). **2차 회신으로 되물은 6건도 같은 세션에 전부 확정** —
|
||||
`Slot:Replace` 신설(교체가 시프트 2회 → 0회), 물리 조작을 주입 op로
|
||||
(`mountInst`/`unmountInst`, base는 `Parent`를 모른다), `rawAdd` 의사코드 신설,
|
||||
`rawAdd`의 `Length:Set` 제거, **래핑/언래핑 한 쌍**(`wrapElement`/`unwrapElement`),
|
||||
**`setLength`에 `anchor` 인자**(4라운드 `D-56` 역전 → `isBoundAlive` 세 번째
|
||||
분기 항목까지 닫힘), `Effect(fn, ...deps)`(`Ref`도 의존성). **3·4차에선 `mountInst`가 삽입 위치를 못 받는다는 지적에서
|
||||
시작해 offset 부기 모델이 정리됨** — `None`의 뜻을 "발행 채널 없음"으로 좁히고
|
||||
`Dispatch.getOffsetAt` 신설(pull), 그 과정에 **중첩 offset이 부모 베이스를 못
|
||||
받던 결함**(depth ≥ 2에서 통째로 밀림)과 **재마운트가 `Offset` Source를 새로
|
||||
만들던 결함**(포탈이 깨지는 자리)까지 발견·수정. **커밋 전 감사 2라운드가 또 실질적인 걸 잡았다** — 확정한 `Owned`가
|
||||
`Slot:List` 시그니처에 배선이 안 돼 코드에 도달 못 하던 것, `effect-plan.md`가
|
||||
역전 배너 없이 자기모순이던 것, 그리고 **손대지 않은 문서**(`ROADMAP` 백로그
|
||||
문단·`debounce-throttle-plan.md`)가 "Gate는 M3에서"로 남아 있던 사각지대.
|
||||
감사 회신 자리에서 **`raw*`의 index 통일**(오래 열린 캐비엇 종결), **래핑은
|
||||
`raw*` 바깥**, **`getOffsetAt` 접두합 캐시**까지 확정. 마지막 라운드에선 **`native*` 물리 조작 계층**(Slot 스코프의 `raw*`와 갈리는,
|
||||
확정된 offset/length 기반 여섯 op)이 확정되고 그 여파로 **4라운드 `C-7`("부기가
|
||||
물리보다 먼저")이 역전**됐다 — base엔 자리를 비워둘 수단이 없고 미는 주체는
|
||||
백엔드 삽입 연산 자신이라, 규칙이 "자기 자리 먼저 / 뒤를 미는 것 나중" 하나로
|
||||
줄었다. **`Gate`만 사용자 지시로 다음 세션 — M2를 막는 유일한 항목.**
|
||||
|
||||
**같은 세션 후반 — 리서치로 신설했던 둘이 그 자리에서 확정됐다.**
|
||||
`Gate`는 **탑레벨 프리미티브 없이 `state:Gate(setup)` 메소드 + `GateNode`**로,
|
||||
State의 재계산/전파 판정은 **소스 에포크 비교 채택**으로 닫히며 두 문서가
|
||||
`research/` → `base/`로 승격(`base/gate-plan.md`, `base/state-epoch-plan.md`).
|
||||
그 여파로 `source-state-plan.md`의 확정 서술 둘("emit은 **항상** 전파 / quad가
|
||||
접지 않는 것은 중복 *통지*뿐")이 역전돼
|
||||
`archive/always-propagate-no-dedup-superseded.md`로 갔다 — **2026-08-14의
|
||||
`invalid` 기반 dedup 금지를 되돌린 게 아니라는 것**을 세 곳에 못박았고,
|
||||
스파이크 `05`는 핵심 assert가 정반대가 되어 `rewrite-required/`로 돌아갔다.
|
||||
|
||||
**에포크 기제는 네 라운드에 걸쳐 사용자 정정으로 다듬어졌다** — 순회 조건이
|
||||
`rawInvalid == false`로 뒤집히고, emit은 count 없이 출처만 싣고, 순회가 값만
|
||||
앞당기고 통지는 상류 emit을 기다리게 하려고 맵이
|
||||
`sourceCountMap`/`sourceEmitMap` 둘로 갈렸다(에이전트가 한 번 철회했던 분리가
|
||||
**다른 근거로** 되살아난 것). 새 노드의 두 맵은 **비대칭 초기화**
|
||||
(emit 맵은 비우고, count 맵은 실제 값으로 채운 뒤 `rawInvalid = true`).
|
||||
게이트는 흡수 집합을 들고 **flush 시 스왑**해 배치를 떼어 넘기며, 게이트가
|
||||
게이트 emit을 받으면 **풀어서 자기 집합에 합치고**, **빈 배치면 통지 자체를
|
||||
안 한다**(기존 `HasBlockedEmit` 계약의 일반화). 그 따름정리로
|
||||
`Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠졌다.
|
||||
|
||||
**`/code-review high`를 두 번 돌려 19건이 나왔고 전부 유효했다** — 그중
|
||||
재진입 시 빈 배치가 새던 것과 `OffWithoutEmit`이 흡수 집합을 안 비우던 것은
|
||||
실제 유실 경로였다. 반대로 리뷰가 제기한 것 중 **에이전트 서술 자체가
|
||||
틀렸던 것도 셋** — "Gate 재진입 계약"은 `Blocker`의 인스턴스 중첩 규칙을
|
||||
잘못 옮긴 것이었고, "다중 소스 배치의 중복 계산"은 동기 전파를 놓친 것이었고,
|
||||
"빈 배치 = 무조건 통지" 권고는 State 층에 `Source:Emit`을 추가하는 격이라
|
||||
기각됐다.
|
||||
|
||||
**마지막으로 사용자가 `Epoch`/`EpochMap`/`Brand` 재구성을 제안**했다 — 에포크
|
||||
부기를 State에서 떼어 컴포지션하고, emit 페이로드를 `Epoch` 인터페이스
|
||||
(`{Revision: number}`, 그 자체로 키)로 일반화하고, 그러려면 `Brand`를
|
||||
**인스턴스 브랜드**(다중 태깅)로 바꾸는 안. 발단은 **다중 의존성 `Effect`에서
|
||||
한 파동에 `fn`이 두 번 도는 갭**(공통 하류가 없어 에포크 dedup이 못 접는다)이고,
|
||||
`Effect`가 자기 `EpochMap`을 들면 그게 공통 하류가 되어 닫힌다. **사실상 전량
|
||||
확정됐으나 세션 길이 때문에 `base/` 승격은 다음 세션으로 미뤄졌다** —
|
||||
`reference/epoch-brand-composition.md`가 소스, `todos.md` 000번이 진입점.
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-21 (세 번째) — `Epoch`/`EpochMap`/`Brand` 전면 승격, 그리고 해소 기록 flatten
|
||||
|
||||
원문: `session/2026-08-21-03-epoch-brand-promotion-and-flatten.md`
|
||||
|
||||
앞 세션이 미뤄둔 승격을 실제로 수행했다. **`Epoch`**(`{Revision: number}`,
|
||||
그 자체로 키가 되는 unique 테이블 — `Source`가 구조적으로 만족하고
|
||||
`EpochBrand`에도 등록됨)와 **`EpochMap`**(`:Update(Epoch|{Epoch}) -> boolean`이
|
||||
"뒤로 전파가 필요한가"를 답함, `:Refresh`/`:Sync`)이 `base/`에 들어갔고,
|
||||
State는 그걸 **둘** 컴포지션한다(`sourceCountMap`/`sourceEmitMap` →
|
||||
`valueEpochMap`/`emitEpochMap`). **`Brand`는 인스턴스 브랜드로 전면
|
||||
재작성**됐다 — `Brand()` + `:register`/`:is`, **다중 태깅 허용**, 역조회
|
||||
`Brand.get`은 제거(옛 표면은 `archive/brand-shared-registry-reversed.md`).
|
||||
그 부수로 **`effect-plan.md`의 다중 의존성 중복 발화 미해결 항목이
|
||||
닫혔다**(`EffectHandle`이 자기 `EpochMap`을 들어 첫 번째만 통과시킴).
|
||||
**마지막 미정이던 리비전 갱신 방식도 같은 세션에 `bit32.bnot(-rev)`로
|
||||
확정**됐다 — 사용자 논거는 *"2^53 포화는 어차피 도달 불가능한데 그걸
|
||||
피하겠다고 값을 double 영역까지 키울 이유가 없다, 매번 도는 코드라 값싸게
|
||||
가고 싶다"*. **에이전트가 이 형태를 `band(rev + 1, mask)`로 잘못 옮기고
|
||||
"그러니 `bit32`가 더 싼 건 아니다"라는 단서까지 붙였다가 사용자에게
|
||||
정정당했다**(*"제가 말한건, bit32.bnot(-a) 입니다"*) — 사용자가 근거로 든
|
||||
REPL 출력 셋이 예시가 아니라 **연산 자체**였다. `luau` 실측 결과
|
||||
`bit32.bnot(-a)`는 `a > 0`이면 `a - 1`, `0`이면 `4294967295`인 **랩어라운드
|
||||
감소**이고 갱신과 랩이 **FASTCALL 하나**로 끝난다. 즉 사용자 서술이 맞았고
|
||||
에이전트 단서가 틀렸다. 따름정리로 리비전은 **증가가 아니라 감소**하며,
|
||||
`==`/`~=`만 쓰는 지금 규칙에서만 무해하다는 경고를 `base/`에 남겼다.
|
||||
**`Epoch`/`EpochMap`/`Brand`에 열린 설계 항목은 없다.**
|
||||
|
||||
커밋 전 `/code-review high`가 **9건을 더 냈고 전부 유효**했다(감사자 3라운드가
|
||||
수렴한 뒤였다 — 둘이 보는 축이 다르다는 `conventions.md` 서술의 재확인).
|
||||
치명적인 둘은 **표기가 실제 메커니즘과 안 맞던 것**이다: (1) `{Epoch}`가
|
||||
Luau에선 **배열**인데 실제 게이트 배치는 `{[Epoch]: true}` **집합**이라, 그대로
|
||||
`ipairs`로 구현하면 유보됐다 풀린 emit이 전부 삼켜진다 → `EpochSet`으로 확정,
|
||||
(2) 새 노드 시딩("상류의 `Epoch`를 전부 끌어와")이 **확정된 `EpochMap` 표면으로
|
||||
표현 불가능**했다(`:With`의 상류는 State이지 `Epoch`가 아니고, 키 열거/병합
|
||||
연산이 없었음) → `:TrackFrom` 신설. 그 외 `GateNode` 예외 미기록, 설치 발화의
|
||||
`from`이 non-optional, `2^32` 랩을 "똑같이 도달 불가능"이라 한 **틀린 수치
|
||||
근거**(같은 척도로 285년 vs 72분 — 실제 안전 근거는 도달 시간이 아니라 충돌
|
||||
조건이 한 점이라는 것), 옛 이름 잔재(`TweenTag` 3곳/`Effect(fn, state?)` 4곳),
|
||||
`§` 참조 3곳.
|
||||
|
||||
**에이전트가 이름 붙인 연산 둘은 사용자 검토로 확정**됐다 — `:Refresh`는 그대로
|
||||
(*"Update 에 인자 없는건 좀 아니야 … 리프레시는 내가 받았던걸 처리하겠다는거라
|
||||
표면적 의미 자체가 다르지"*), `:Absorb`는 **`:TrackFrom`으로 개명**
|
||||
(*"absorb 는 … 상위 요소에서 제거할것만 같은 이름"* + `gate-plan.md`가 이미
|
||||
"흡수 집합"을 다른 뜻으로 씀). 이 세션의 반복 교훈은 하나 — **승격은 문장을
|
||||
옮기는 작업이 아니라 표기가 가리키는 것이 실제로 성립하는지 확인하는
|
||||
작업**이다(`bit32` 형태, `{Epoch}` 타입, 시딩 표현 셋 다 같은 유형이었다).
|
||||
|
||||
**확정된 결정의 근거 기록이 갈 자리를 정했다 — `reference/`.**
|
||||
`slot-attach-decomposition.md`와 `epoch-brand-composition.md` 둘 다
|
||||
`research/`(아직 상의 필요)도 `archive/`(뒤집혔거나 기각됨)도 아니라
|
||||
"`base/`가 근거로 인용하는 온디맨드 자료"이므로. 폴더 기준 자체에 이 용도를
|
||||
명문화했다.
|
||||
|
||||
**flatten** — 사용자 지적("재정정 기록이 쌓인 부분")대로 세 군데를 걷어냈다.
|
||||
`question.md`가 스스로 정한 규칙(해소되면 archive로 옮김)을 어기고 다시
|
||||
절반이 `[해소]`로 차 있어 **16건을 일괄 이관**(421→208줄), `todos.md`의
|
||||
"M3 착수 전 필요" 목록도 절반 넘게 해소 항목이라 실제로 열린 둘만 남겼다.
|
||||
이관한 히스토리의 원문은 소급해 고치지 않고 **머리에 "그 뒤 이름이 바뀌었다"
|
||||
경고만** 달았다.
|
||||
|
|
|
|||
71
.claude/session/2026-08-19-04-qa-round4-questionnaire.md
Normal file
71
.claude/session/2026-08-19-04-qa-round4-questionnaire.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# 2026-08-19-04 — 구현 전 QA 4라운드 문항지 작성 (회신 대기)
|
||||
|
||||
**한 줄**: 사용자 요청으로 `base/` 확정 전체를 "예가 나와야 정상인 문항"으로
|
||||
전수 문항화해 `qa-request/pre-implementation-qa-round4.md`를 만들었다.
|
||||
**설계 결정은 하나도 안 내렸고, 정정도 하나도 안 했다** — 회신 대기 상태.
|
||||
|
||||
## 사용자 요청 원문
|
||||
|
||||
> 요즘 변경이 엄청 많고, 틀려서 정정한게 엄청 많아서, 모든 부분에 있어서 내
|
||||
> 심사를 좀 받아야할듯. 예 가 되어야하는 질문들을 계속, 모든 확정 부분에
|
||||
> 있어서 해줘. 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등, 모든
|
||||
> 부분에 있어서 내가 검토해줄게. 우선 틀린게 있으면, 기록만 해둬. 정정은
|
||||
> 나중에 틀린 부분을 모아서 한번에 내가 어디가, 어떻게 왜 틀렸는지, 원래 뭐가
|
||||
> 맞는지 기술해서 Markdown 서식으로 보고해줄게. 따라서, 아니요가 된 부분만
|
||||
> 모아서 하나의 문서로 만들어줘. 일종의 pre implementation QA 라고 봐도 될듯.
|
||||
> 파일은 원하는 위치에 둬. 이전 라운드 처럼, qa-request 안에 정리해도 될거야.
|
||||
> 서브에이전트는 쓰지 말아줘. 한 맥락에서 자연스럽게 흐르는 전체 구조를 보는게
|
||||
> 필요해보임.
|
||||
|
||||
## 작업 방식
|
||||
|
||||
- **서브에이전트 금지 지시를 그대로 따름** — `.claude/base/` 26개 문서
|
||||
(약 14,900줄)를 메인 컨텍스트에서 의존성 순서로 직접 읽었다. `conventions.md`의
|
||||
"소스코드를 많이 읽어야 하는 리서치는 Agent로 위임" 관례에 대한 **이번
|
||||
요청 한정 명시적 예외**(사용자가 "한 맥락에서 자연스럽게 흐르는 전체 구조"를
|
||||
이유로 지정).
|
||||
- 읽는 순서는 1라운드가 쓴 의존성 순서를 재사용: architecture → source-state →
|
||||
store → bind-system → dispatch-core → lifecycle-pattern → relate → brand →
|
||||
modifier → blocker/effect/event/onchange/purity → ref → slot → tag →
|
||||
attribute → tween → ui-shorthand → component-composition → module-lifecycle →
|
||||
lifecycle-hooks → fallback → typing-limits → debounce-throttle.
|
||||
- **컨텍스트 유실 대비로 섹션별 증분 기록** — 문서 몇 개를 읽을 때마다 그
|
||||
구간의 문항을 파일에 append했다(전부 읽고 나서 한 번에 쓰면 중간에 컨텍스트가
|
||||
잘릴 때 통째로 날아감).
|
||||
|
||||
## 산출물
|
||||
|
||||
`.claude/qa-request/pre-implementation-qa-round4.md` — 문서 코드 26개
|
||||
(`A`/`S`/`ST`/`BS`/`D`/`LP`/`RE`/`BR`/`M`/`SL`/`BK`/`E`/`EV`/`OC`/`R`/`T`/`AT`/
|
||||
`TW`/`CC`/`UI`/`ML`/`DT`/`LH`/`FB`/`PE`/`TL`)로 나눈 전수 문항지. 문항 수와
|
||||
문서별 분포는 그 파일 자신이 소스(여기서 반복 안 함 — `conventions.md`의
|
||||
"개수·목록·상태는 소스를 하나만 둘 것").
|
||||
|
||||
**문항을 쓸 때 지킨 것**:
|
||||
- 각 문항은 `예/아니오`로 답할 수 있는 **단정문** — "무엇을 확정했나"가 아니라
|
||||
"이렇게 확정한 게 맞나"를 묻는 모양.
|
||||
- **근거까지 같이 넣음.** 결론만 물으면 사용자가 "왜 그렇게 정했더라"를 다시
|
||||
찾아야 하고, 근거가 틀렸는데 결론만 맞는 경우를 못 잡는다. 실제로 코퍼스엔
|
||||
"결론은 그대로인데 근거가 뒤집힌" 사례가 여럿 있다(`Tag`의 holders 키잉,
|
||||
`Blocker` 게이팅의 필요 이유가 크래시→비용으로 바뀐 것 등).
|
||||
- **⚠️로 열려 있다고 적힌 항목은 "확정이 맞나"가 아니라 "아직 열려 있다는 인식이
|
||||
맞나"를 묻는 문항으로 별도 표기**(`S-38`/`ST-3`/`ST-8`/`E-10`/`SL-45`/`SL-74`/
|
||||
`AT-3`/`AT-11`/`UI-5`/`ML-9`).
|
||||
- 문항 자체가 문서를 오독했을 가능성도 회신 대상으로 명시(회신 안내 절).
|
||||
|
||||
## 이번 세션이 **안** 한 것 (중요)
|
||||
|
||||
- **정정 0건** — 사용자 지시("우선 틀린게 있으면 기록만 해둬")대로 `base/`를
|
||||
하나도 안 고쳤다. 읽는 도중 "이건 좀 이상한데" 싶은 게 있어도 문항으로만
|
||||
만들었고 임의 판단을 넣지 않았다.
|
||||
- **감사 루프 안 돌림** — `quad-doc-auditor`는 "중대 변경 후"에 도는 것인데
|
||||
이번 변경은 신규 파일 1개 + 인덱스 2줄이라 대상이 아니다. `doc-check.py`는
|
||||
돌려 ERROR 0 확인(신규 파일이 처음 낸 절 참조 불일치 1건은 그 자리에서 고침).
|
||||
|
||||
## 인덱스 레이어 반영
|
||||
|
||||
- `.claude/README.md`의 `qa-request/` 행에 4라운드 항목 추가(**회신 대기** 상태
|
||||
명시 — 1~3라운드는 "반영 완료"라 성격이 다름).
|
||||
- `.claude/todos.md` 00번을 "1·2·3라운드 완료 + 4라운드 회신 대기"로 갱신.
|
||||
- `question.md`는 **안 건드림** — 아직 사용자 판단이 나온 게 없어서 추가할
|
||||
항목이 없다.
|
||||
104
.claude/session/2026-08-20-01-qa-round4-response-processing.md
Normal file
104
.claude/session/2026-08-20-01-qa-round4-response-processing.md
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
# 2026-08-20-01 — QA 4라운드 회신 1차 처리 + 업스트림 스캐폴딩 병합
|
||||
|
||||
**한 줄**: 사용자가 남긴 `pre-implementation-qa-round4-response.md`를 받아
|
||||
판단이 명확한 것 20여 건을 `base/`에 반영하고, 나머지를
|
||||
`pre-implementation-qa-round4-followup.md`로 정리했다. 같은 세션에 업스트림
|
||||
12커밋(M0 검증 + M1 스캐폴딩)을 pull해 문항지 커밋을 그 위로 rebase했다.
|
||||
|
||||
## 순서
|
||||
|
||||
1. **업스트림 병합 먼저** — `git fetch` 결과 `origin/main`이 12커밋 앞서
|
||||
있었음(pesde 전환, mise/selene, RunInit 재설계, quad-types/type-version-check
|
||||
신설, M0 스파이크 21~23 추가, ROADMAP/CLAUDE.md stale 정정). 전 세션
|
||||
산출물(QA 4라운드 문항지)을 먼저 커밋한 뒤 `git rebase origin/main` —
|
||||
**충돌 없이 통과**(문항지가 신규 파일이고, 인덱스 2줄만 수정했기 때문).
|
||||
2. **회신 읽고 분류** — 회신은 문항 번호별로 자유서술이라, (a) 바로 반영,
|
||||
(b) 설명 보강 후 재질문, (c) 사용자 판단 필요, (d) 조사해서 답이 나옴으로
|
||||
갈랐다.
|
||||
3. **(a)만 문서에 반영** — (b)/(c)는 손대지 않고 followup에만 적었다.
|
||||
|
||||
## 반영한 것 중 설계가 실제로 바뀐 것
|
||||
|
||||
목록 전체는 followup A절이 소스(여기서 반복 안 함). 그 중 "표현 정정"이 아니라
|
||||
**동작이 달라지는** 것만:
|
||||
|
||||
- **`SL-75`/`D-60` — 언마운트 시 `slot.Offset = nil` 금지.** 옛 서술은
|
||||
"마운트 전 상태로 복원"이었는데, 사용자가 **포탈이 깨진다**고 지적. 이유가
|
||||
깔끔했다 — `updateFn`이 `layoutOrder:With(offset):Compute(...)`처럼 그
|
||||
Source를 파생 그래프에 엮어둔 상태에서 필드를 갈아치우면, 그 구독은 옛
|
||||
Source를 계속 보는데 Slot은 새 Source를 등록하게 되어 **영영 갈라진다.**
|
||||
재마운트가 "값이 다시 채워지는 일"이 아니라 "연결이 끊긴 채 조용히 멈추는
|
||||
일"이 되는 것. 부수로 `Slot.Offset`의 마운트 전 기본값도 `nil` → `0`.
|
||||
- **`E-11` — leaf 바인딩된 Effect엔 `:Unsubscribe()`가 아예 안 먹음.**
|
||||
옛 서술의 "(3) 이후 leaf가 실제로 죽어도 중복 호출되지 않는다"가 사용자
|
||||
눈에 이상해 보였는데, 따져보니 **성립할 수 없는 문장**이었다 — 이중 바인딩
|
||||
게이트가 `:Subscribe()`(전역)와 `bindLifetime`(leaf)을 상호 배타로 막아두므로
|
||||
`:Subscribe()`된 핸들은 애초에 leaf에 붙어 있을 수가 없고, "그 leaf가 죽는"
|
||||
사건 자체가 없다. Observer와 같은 규칙으로 통일하는 것으로 정리.
|
||||
- **`AT-20` — 생존 이름 최적화는 "안 하는" 게 아니라 "못 하는" 것.**
|
||||
옛 서술은 "이론상 가능하지만 부품이 늘어나서 기본은 균일 철거"였는데,
|
||||
사용자 지적대로 **이름이 같아도 값이 바뀌었을 수 있어** 이름 목록 비교만으로는
|
||||
아무것도 못 건너뛴다. 값을 비교하려면 `:Get()`이 필요하고 그건 State 계약
|
||||
위반 — 즉 설계 원칙상 애초에 막힌 길이다. `Tag`가 되는 이유(자원이 이름
|
||||
집합이라 겹쳐도 합집합)와의 대조까지 같이 적었다.
|
||||
- **`D-56` — `bindLifetime`의 첫 인자가 Instance가 아닐 수 있다.**
|
||||
`Dispatch.setLength(ownerKey, ...)`의 `ownerKey`가 Slot-in-Slot에선 Slot
|
||||
자신인데, 구현 스케치는 `InstData`에서 gcconn을 찾는 걸 전제하고 있었다.
|
||||
gcconn 트릭은 엔진 객체에만 걸 수 있으므로 **백엔드가 별도 경로를 제공해야
|
||||
한다**는 요구사항으로 신설. `isBoundAlive`에 세 번째 분기가 필요하다는 것
|
||||
(지금은 gcconn 없으면 `.Subscribed` 폴백으로 떨어져 **살아있는데 `canBound`가
|
||||
참으로 잘못 나옴**)까지 같이 짚었다.
|
||||
|
||||
## 사용자가 새로 찾아낸 갭 — 문서에 아예 없던 것 둘
|
||||
|
||||
1. **`Attribute(a, b, ...)` 생성자의 이름 겹침 정책이 어디에도 없었다.**
|
||||
`Merged`(error)/`Overridden`(뒤가 이김)만 적혀 있었고, 생성자가 인자들을
|
||||
하나의 이름 맵으로 평탄화하는데 겹치면 어떻게 되는지가 공백. 배열 순서
|
||||
규칙과 결을 맞춰 **뒤가 이김**으로 명시했다.
|
||||
2. **flatten이 소진한 Modifier 자리를 어떻게 하는지가 없었다**(`M-2`).
|
||||
"`isModifier(v)`인 항목만 필드를 뽑아 merge"만 적혀 있고 그 자리를 지우는지
|
||||
센티널로 채우는지가 공백 — 그냥 지우면 `PreRef` pre-pass가 `ProcessedPreRef`로
|
||||
소진하는 바로 그 이유(sparse 테이블)에 걸린다. 사용자는 `ProcessedModifier`
|
||||
센티널을 제안했고, 나는 **flatten이 어차피 새 테이블을 만드는 순수 변환이라
|
||||
압축(compact)이 더 싸다**고 봐서 두 안을 followup C-3에 나란히 올렸다.
|
||||
|
||||
## followup으로 넘긴 것 중 가장 큰 둘
|
||||
|
||||
- **`C-1` `KeyGone` 센티널** — 사용자 제안(키 소멸 시 `updateFn`을 한 번 더
|
||||
불러 처분을 묻기)이 `SL-45`의 어정쩡한 상태를 정확히 메운다. 다만 반환값
|
||||
의미/`userdata` 수명/소멸 루프 순회 대상/`index` 인자 넷이 안 정해지면 구현이
|
||||
못 나가서 그대로 물었다.
|
||||
- **`C-2` "밀려난 `prev`는 dispose" vs `state<Frame>` 의미론** — 회신의 두
|
||||
답변(`SL-43`, `SL-51`)이 서로 반대를 가리켰다. 따져보니 **둘 다 맞고 갈리는
|
||||
축이 "누가 그 요소를 만들었는가"**였다 — `:List`의 `updateFn`이 만든 것은
|
||||
reconcile이 지워줘야 하고(만든 쪽이 자기 손으로 못 지움), `Slot:Add(state)`
|
||||
sugar로 들어온 것은 사용자 소유라 죽이면 안 된다. 지금 설계엔 후자를 표현할
|
||||
방법이 없어서(`Detach`는 교체와 같이 못 씀) 선택지 셋을 제시했고,
|
||||
**per-installation 소유권 옵션**을 추천했다 — 갈리는 기준이 per-cycle이
|
||||
아니라 설치 단위 속성이기 때문.
|
||||
|
||||
## 조사만으로 닫은 것
|
||||
|
||||
- **`E-10`(dedup 대칭)** — 의사코드를 다시 따라가니 `bindLifetime`(내부
|
||||
Observer cascade 포함)이 `old ~= v` 분기 안에, `unbindLifetime`이
|
||||
`nextValue ~= v` 분기 안에 있어 이미 대칭이었다. 사용자가 말한
|
||||
"relate로 old를 들고 있다가 process에서 조회해 같으면 dedup"이 정확히 지금
|
||||
코드다. 설계 갭 없음, 구현 시 회귀 확인만.
|
||||
- **`D-10`** — 사용자 지적("Luau 일반화 for가 그냥 지원함")이 맞고, 문항이
|
||||
"M0에서 순회 동작을 검증"이라 적어 언어 동작을 의심하는 것처럼 읽힌 게
|
||||
문제였다. 스파이크 `01`이 검증한 건 언어가 아니라 **우리가 짠 두 패스
|
||||
드라이버**이고, 두 패스를 명시하는 진짜 이유는 이식성이다. 업스트림에서
|
||||
이미 통과했으므로 base의 "M0에서 검증할 것"도 닫았다.
|
||||
- **`SL-76`** — 사용자 의문("해제에서 nil이 관측될 일이 없다")대로 지금
|
||||
설계에서 도달 경로를 못 찾았다. `bk.N` 정의 확정 + Blocker 게이팅 +
|
||||
`spliceArraysDown`의 압축이 각각 그 경로를 막는다. 그래서 "관대한 skip"을
|
||||
유지/삭제/error 승격 중 고르는 문제로 바꿔 올렸고 **error 승격을 추천**했다.
|
||||
|
||||
## 안 한 것
|
||||
|
||||
- **감사 루프 안 돌림** — 이번 변경이 크긴 하지만 아직 **회신 처리가 절반**
|
||||
(B/C절이 남음)이라, 감사는 그 라운드까지 끝난 뒤 한 번에 도는 게 맞다고 봄.
|
||||
`doc-check.py`는 매 편집 후 돌려 ERROR 0 유지.
|
||||
- **5라운드 문항지** — 업스트림이 들여온 신규 문서 둘(`project-setup-plan.md`,
|
||||
`quad-types-plan.md`)과 이번에 내용이 늘어난 `architecture.md`/`typing-limits.md`는
|
||||
문항이 아예 없다. followup D절에 적어두고 **사용자 지시 대기**.
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
# 2026-08-21-01 — `Detach` 보존 주체 / `KeyGone` / `Owned` 확정, `attachSlot` 분해
|
||||
|
||||
**한 줄**: QA 4라운드 followup의 마지막 열린 항목(`F-3`)이 사용자 회신으로
|
||||
전량 닫히면서, `:List`의 요소 소유권 모델(`slot._detached` + `KeyGone` +
|
||||
`Owned`)과 `attachSlot`의 책임 분해(`materializeSlotTree`/`mountSlotTree`)가
|
||||
확정돼 `base/slot-plan.md`에 반영됐다.
|
||||
|
||||
**소스 관계**: 지금 유효한 설계는 항상 `base/slot-plan.md`가 소스이고,
|
||||
처리 경과 요약은 `qa-request/pre-implementation-qa-round4-followup.md`의
|
||||
H절, 분해 근거는 `reference/slot-attach-decomposition.md`. 이 파일은 그
|
||||
결정들이 **어떤 논의를 거쳐 나왔는지**의 원문 기록이다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 발단 — `F-3`을 제안하게 된 관측
|
||||
|
||||
3차 처리(G절)까지 오면서 `Detach`(옛 `PopOnly`) 경로에 두 개의 구멍이
|
||||
남아 있었다.
|
||||
|
||||
1. **보존 주체가 `userdata`였다.** 기존 문서는 `updateFn`이
|
||||
`Detach, { old = ..., source = ... }`를 반환하면 "보존은 반환한 userdata가
|
||||
담당"한다고 확정해뒀다.
|
||||
2. **키가 데이터에서 사라졌을 때의 처분이 미결**이었다(`question.md` 3번,
|
||||
`ROADMAP.md` M6에 ⚠️로 박혀 있었음).
|
||||
|
||||
2번을 파다가 1번이 함께 무너졌다. 결정적인 근거는 **gcconn 트릭**이다 —
|
||||
`base/lifecycle-pattern.md`가 확정해둔 대로 quad가 만든 모든 Instance는
|
||||
`GetPropertyChangedSignal("ClassName")` 커넥션의 클로저가 자기 자신과
|
||||
`gchold`를 캡처하므로 **참조를 놓는 것만으로는 회수되지 않는다.** 즉
|
||||
detach된 요소를 `userdata` 안에 넣어두고 `:List`가 그 키를 잊어버리면
|
||||
**GC 폴백이 아예 없이 영구 누수**다. `userdata`는 `:List`에게 opaque라
|
||||
"이 안에 뭐가 들었으니 죽여라"를 알 수 없다.
|
||||
|
||||
그래서 세 가지를 묶어 제안했다: (1) 보존은 Slot 필드가 한다, (2) owner가
|
||||
죽을 때의 정리는 `Effect`가 한다, (3) 키가 사라지면 `updateFn`에게 한 번
|
||||
더 묻는다.
|
||||
|
||||
## 2. 사용자 회신 — 두 가지를 더 얹었다
|
||||
|
||||
> "Detach 요소는 slot 안에 보관하는게 내 생각이였어서 (2) 제안에 동의. ud
|
||||
> 에 넣는거로는 최종 처분이 불가하다에 동의함. 이미 detach 인데 또 detach 를
|
||||
> 보내도록 하면 nop하게 두고, detach 를 다시 안 보내고 prev 를 사용하게
|
||||
> 된다면 재마운트 해주는거 괜찮은 아이디어같음. base에 전부 반영해줘."
|
||||
|
||||
여기서 **"이미 detach인데 또 detach → nop"**과 **"`prev`를 그대로 반환 →
|
||||
재마운트"**가 확정됐다. 이 둘은 원래 제안에 없던 것으로, 보존 주체가
|
||||
`slot._detached`로 옮겨간 순간 **`ud`에 홀드할 필요 자체가 없어진다**는
|
||||
따름정리를 사용자가 먼저 짚은 것이다 — reconcile이 `prev`로 그대로
|
||||
돌려주니까 사용자 코드가 `old`를 `ud`에 담아 다니지 않아도 된다.
|
||||
|
||||
## 3. 반영 — `settle()`이 두 경로를 강제로 합친다
|
||||
|
||||
구현상 가장 중요한 판단은 **정상 사이클과 소멸 루프가 같은 처분 함수를
|
||||
공유하게 만든 것**이다. `settle(key, result, detach, pos)` 하나가
|
||||
`wasMounted`/`wasDetached`를 함께 보고 분기한다:
|
||||
|
||||
- `detach`면 → `rawDetach`(언마운트하되 **소유권 유지**), `_detached`로 이동.
|
||||
이미 `_detached`에 있으면 `wasMounted == nil`이라 자연히 nop.
|
||||
- `result == prev`인데 `wasDetached`면 → `_detached`에서 빼고 `rawAdd` = 재마운트.
|
||||
- `result == nil`이면 → `releaseElement(self, prev, wasDetached ~= nil)`.
|
||||
|
||||
두 경로가 갈라질 수 없게 만든 이유는 명확하다. 이 코퍼스에서 반복적으로
|
||||
났던 버그(`RC-1`/`RC-3`/`RC-4`)가 전부 **"비슷한 두 경로 중 한쪽만 고쳤다"**
|
||||
계열이었다.
|
||||
|
||||
`rawRemove`(소유권 해제 + 파괴) / `rawUnmount`(소유권 해제 + 파괴 안 함) /
|
||||
**`rawDetach`(소유권 유지 + 파괴 안 함)** 3형제로 분화한 것도 같은 이유 —
|
||||
"파괴하는가"와 "소유권을 놓는가"가 원래 한 축에 뭉쳐 있었는데, `Detach`는
|
||||
정확히 **파괴 안 하면서 소유권은 유지**하는 조합이라 기존 둘로는 표현이
|
||||
안 됐다.
|
||||
|
||||
## 4. `Owned` — `Detach`와 직교하는 두 번째 축
|
||||
|
||||
`C-2`(`:List`의 "밀려난 prev는 dispose"가 `state<Frame>` 의미론과 충돌)를
|
||||
닫은 것이 이 축이다. 정리하면:
|
||||
|
||||
- **`Detach`** = 사이클 단위. "내 건데 잠깐 빼둠."
|
||||
- **`Owned`** = 설치 단위. "애초에 내 게 아님."
|
||||
|
||||
이 둘이 직교한다는 걸 명시하고 나니 충돌이 사라졌다 — 값 교체 시 파괴는
|
||||
`Owned = true`일 때 **맞다**(그 요소는 `updateFn`이 만들었고, `:List`가
|
||||
안 지우면 아무도 못 지운다). 사용자가 `state<Frame>`에 담아 넘긴 요소는
|
||||
`Owned = false`로 설치되고 어떤 경로로도 파괴되지 않는다.
|
||||
|
||||
## 5. `attachSlot` 분해 — 사용자가 비용 논거로 확정
|
||||
|
||||
3차 처리에서 `F-4-3`(`setLength` 위치)을 물었을 때, 사용자가 문제를 더 크게
|
||||
재정의했다: "attachSlot 의 기능이 너무 다양해진게 문제같음."
|
||||
|
||||
핵심 진단은 **C6과 C7이 한 함수 안에서 동시에 만족될 수 없다**는 것이었다:
|
||||
|
||||
- **C6**: 부모에게 미는 길이(`setLength(ownerKey, position, ...)`)는
|
||||
flush가 끝나야 정해지는 **최종값**이어야 한다.
|
||||
- **C7**: 부기는 물리 트리 조작보다 항상 먼저 끝나야 한다.
|
||||
|
||||
한 함수엔 `setLength` 슬롯이 하나뿐이라, 앞에 두면 C6 위반(부모가 1회
|
||||
헛돎), 뒤에 두면 C7 위반이다. **함수를 둘로 쪼개면 각자 하나씩 만족**한다.
|
||||
|
||||
사용자가 분해안(B)을 검토하고 낸 판단이 결정적이었다:
|
||||
|
||||
> "오히려 안 쪼갤 이유가 정당하지 않아보이는게, 안 쪼갰을 때 구현 상 실수가
|
||||
> 많아질 위험이 보임."
|
||||
|
||||
> "이게 하나의 큰 복잡한 복합 함수라 여러 session 간의 실수가 발생하던
|
||||
> 부분이고, 지금 적절한 방향으로 이동하지 않으면 계속 실수에 의한
|
||||
> 시간/기술비용이 축적될것 같음. 지금 의사코드를 건들이는 비용이, 추후
|
||||
> 실수가 누적되는 비용보다 싸다고 생각함."
|
||||
|
||||
이건 `conventions.md`의 "드문 오용이나 가상의 미래 요구까지
|
||||
방어/최적화하려고 구조를 복잡하게 만들지 않는다" 원칙과 **충돌하지 않는다** —
|
||||
이 분해가 방어하는 건 가상의 미래 요구가 아니라 **이미 세 번 관측된 버그
|
||||
클래스**(`RC-1`/`RC-3`/`RC-4`, 전부 "줄 순서를 잘못 잡아서")이기 때문이다.
|
||||
분해하면 그 순서 제약이 줄 순서가 아니라 **함수 경계로 강제**된다.
|
||||
|
||||
### 사용자가 스스로 답한 우려
|
||||
|
||||
같은 회신에서 사용자가 유일한 우려를 제기하고 스스로 결론까지 냈다:
|
||||
|
||||
> "다만 이렇게 된다면 얼마나 nested 든, 하나 마운트되고 하나 마운트되고...
|
||||
> 각 객체들이 Parent = inst 되어가며 피지컬에 붙어가는 일자 진행인데, 이건
|
||||
> 관측 이후 일괄 등록이라는 차이가 있는듯. 하지만 이미 리스트 액티베이션이
|
||||
> 자신의 길이를 정확히 알려면 재귀를 뛰어야하는 부분이고 … 이런 사소한,
|
||||
> 문제를 야기하기 어려운 동작을 원래와 동치시키기 위해 디버깅이 어려운
|
||||
> 함수를 만드는건 옳지 않아보임."
|
||||
|
||||
확인해보니 **`Parent` 대입 순서 자체는 안 바뀐다** — 바뀌는 건
|
||||
`ChildAdded` 핸들러가 볼 때 서브트리의 `Length`/`Offset`이 이미 최종값이라는
|
||||
점뿐이고, 옛 코드는 거기서 **미완성 스냅샷**을 보여줬다. 즉 이 변화는
|
||||
동치성 손실이 아니라 **엄밀히 더 정확해지는 방향**이다. 분석 원문은
|
||||
`reference/slot-attach-decomposition.md` 7절.
|
||||
|
||||
## 6. 이 세션이 안 한 것
|
||||
|
||||
- **5라운드 문항지를 만들지 않았다** — D절 회신의 "이후 stale 만 잡는것으로
|
||||
끝낼 수 있어보임"에 따름.
|
||||
- **스파이크 두 건은 미실행**(`01` 재작성 = 단일 generalized `for`,
|
||||
`table.insert` 구멍 재사용 = `R-11`). 상태의 소스는 `luau-test/STATUS.md`.
|
||||
- 이 분해로 `Dispatch.drive`도 같은 모양(부기/물리 분리)으로 맞출지는
|
||||
**일부러 미뤘다** — 지금 그게 아프다는 증거가 없다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 반영 후 감사 — 트레이싱 각도가 문서 대조로는 안 보이던 크래시를 잡았다
|
||||
|
||||
H절 반영을 커밋한 뒤 사용자 요청으로 `quad-doc-auditor`를 **6라운드** 돌렸다
|
||||
(관례대로 한 턴에 하나씩, 라운드마다 각도를 바꿈). 발견 추이:
|
||||
|
||||
| 라운드 | 각도 | 확실 발견 |
|
||||
|---|---|---|
|
||||
| 1 | `base/slot-plan.md` 내부 문장 정합성 | 4 |
|
||||
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS) | 6 |
|
||||
| 3 | `archive/` + 바깥 `base/` + `reference/` | 2 |
|
||||
| 4 | **의사코드 시나리오 손 트레이싱** | **3 (실제 크래시)** |
|
||||
| 5 | 그 수정분의 회귀 트레이싱 | 3 |
|
||||
| 6 | 수렴 확인 | **0** |
|
||||
|
||||
**이 표에서 배울 것은 "단조 감소하지 않았다"는 점이다.** 1~3라운드는 전부
|
||||
"고친 결정이 다른 자리에 안 옮겨졌다"는 **문서 정합성** 유형이었고, 각도를
|
||||
트레이싱으로 바꾼 4라운드에서야 **실행이 깨지는 결함**이 나왔다. 문서 대조를
|
||||
몇 번 더 돌렸어도 이건 안 나왔을 것이다 — 세 건 다 각각의 문장은 정확했고,
|
||||
**서로 다른 두 문서의 정확한 문장이 한 실행 경로에서 만나면 죽는** 종류였기
|
||||
때문이다.
|
||||
|
||||
### 4라운드가 잡은 것의 공통 구조
|
||||
|
||||
셋 다 **`Detach`가 신설한 새 경로가 기존 불변식과 부딪히는데 그쪽이 안
|
||||
고쳐진 것**이다. 특히 `I-1`이 뼈아프다:
|
||||
|
||||
- `rawDetach`는 **일부러** `releaseOwner`를 안 부른다 — 소유권 유지가 이번
|
||||
세션 설계의 핵심이었다.
|
||||
- `claimOwner`는 **일부러** 같은 owner의 재클레임도 error다 — 2026-08-13
|
||||
감사가 `Slot { a, a }`를 막으려고 엄격하게 만든 것이다.
|
||||
- 둘 다 각자 맞는데, **"`prev`를 그대로 돌려주면 재마운트"라는 이 세션이
|
||||
확정한 권장 패턴이 정확히 그 교차점을 지난다.** 그대로 구현했으면
|
||||
문서가 권장하는 사용법이 첫 실행에서 죽었다.
|
||||
|
||||
`claimOwner`의 옛 논증 문단이 *"nested에서 '이미 내가 갖고 있는 걸 다시
|
||||
클레임'하는 정당한 경로가 하나도 없으므로 무조건 error가 맞음"*이라고
|
||||
단정하고 있었는데, 이번 세션이 그 "하나도 없는" 경로를 만들어놓고 그
|
||||
문단을 안 읽은 것이다. 5라운드가 그 문단 자체를 따로 잡아냈다.
|
||||
|
||||
### 내가 감사 결론을 한 번 넘어선 곳
|
||||
|
||||
`I-2`의 멱등 가드는 사용자가 고른 안이지만, 그것만으로는 반쪽이었다.
|
||||
`:List`의 `data:Observer`는 `bindLifetime(inst, observer)`로 **물리 target에
|
||||
앵커**돼 있는데 `unmountSlotTree`가 푸는 건 `bk.observers`뿐이라, 가드만
|
||||
넣으면 포탈 재마운트 후 **옛 target이 죽는 순간 살아있는 Slot의 `:List`가
|
||||
조용히 반응을 멈춘다.** `_listObserver` 핸들을 보관해 앵커만 옮기는 방식으로
|
||||
마저 닫았고, 이건 `_detachCleanup`이 이미 받고 있던 처리와 같은 모양이라
|
||||
새 패턴을 만든 것도 아니다. 5라운드가 그 수정의 **파괴 경로 누락**(
|
||||
`destroySlotTree`에는 안 넣은 것)을 다시 잡았다 — 비대칭 처리를 하나
|
||||
추가하면 그 짝을 반드시 같이 훑어야 한다는 걸 다시 확인.
|
||||
|
||||
### 사용자가 판단한 것 (선택지 3개 제시 → 전부 추천안 채택)
|
||||
|
||||
1. **재마운트 경로** → `claimOwner`에 `fromDetached` 예외(대안: 전용
|
||||
`rawReattach` 신설 / `rawDetach`가 소유권을 놓게 변경).
|
||||
2. **`activateList` 재실행** → 멱등 가드(대안: 인자로 전달 / 전용 경로에서만).
|
||||
3. **`materializeSlotTree` 중 예외 시 Blocker 잔류** → **문서화만 하고
|
||||
`pcall` 안 씀.** 근거는 `conventions.md`의 "드문 오용이나 가상의 미래
|
||||
요구까지" 절 — 아직 실제로 밟은 적 없는 경로이고, 에러 경계는
|
||||
`base/fallback-plan.md`가 담당한다. 이건 이번 분해가 만든 창이 아니라
|
||||
옛 단일 `attachSlot`에도 있었을 구조적 갭이다.
|
||||
|
||||
### 부수 정리
|
||||
|
||||
2라운드가 `luau-test/STATUS.md`의 자기 규칙 위반을 잡았다 — "폴더가 곧
|
||||
상태"라면서 재작성 대상 스파이크 `01`을 `done/`에 둔 채 ⚠️ 마커만 달아둔
|
||||
것. `git mv`로 실제 이동시키고 개수를 맞췄으며, 같이 **`만들어야 할 스파이크`
|
||||
절을 신설**했다. "아직 파일조차 없는 실측 항목"은 어느 폴더로도 표현이 안 돼
|
||||
구조적으로 잊히는 자리였고, 실제로 followup H-7이 그 소스로 STATUS.md를
|
||||
지목했는데 정작 항목이 없었다.
|
||||
|
||||
### 감사 종료 후 하나 더 — 사용자가 다른 에이전트와 상의해 가져온 두 건
|
||||
|
||||
`_detachCleanup` Effect 설치를 `mountSlotTree` → `activateList`로 이관,
|
||||
그리고 `activateList(self, inst)`의 2번째 인자를 `physicalTarget`으로
|
||||
리네이밍. 상세는 followup `I-7`.
|
||||
|
||||
**여기서 짚어둘 것은 상호작용 하나다.** 이관 제안의 근거가
|
||||
*"`materializeSlotTree`가 `_listed`면 재마운트 때도 `activateList`를 다시
|
||||
부르니 lifecycle이 보존된다"*였는데, **그건 오늘 오후에 내가 멱등 가드를
|
||||
넣기 전의 동작**이고 사실 그게 `I-2`의 버그였다. 가드가 있는 지금은 두 번째
|
||||
호출이 early return하므로, 가드 분기가 `_detachCleanup`도 같이 재앵커하지
|
||||
않으면 포탈 재마운트 후 detached가 영영 안 치워진다. 결론(이관)은 맞지만
|
||||
근거가 한 세대 낡아 있었고, 그 자리를 메워 반영했다.
|
||||
|
||||
부수적으로 `_listObserver`와 `_detachCleanup`이 **같은 범주로 통일**됐다 —
|
||||
"`activateList`가 소유하고 물리 target에 앵커되는 자원"(생성 1회 / 언마운트는
|
||||
앵커만 해제하고 핸들 보존 / 재마운트는 가드가 재앵커 / 파괴만 `nil`).
|
||||
오늘 5라운드가 잡은 게 "비대칭 처리를 하나 추가하면 그 짝을 반드시 같이
|
||||
훑어야 한다"였는데, 아예 비대칭을 없애는 쪽으로 정리된 셈이다.
|
||||
|
|
@ -0,0 +1,403 @@
|
|||
# 2026-08-21-02 — 구현 전 QA 5라운드(문항지 → 회신 → 1차 처리), `Gate`/에포크 리서치 신설
|
||||
|
||||
**요약**: 사용자 요청으로 **QA 5라운드 문항지(205문항)** 를 만들고, 같은
|
||||
세션에 회신을 받아 **1차 처리까지** 끝냈다. 즉시 반영 14건, 되물은 것 7건,
|
||||
그리고 **새 research 문서 둘**(`gate-primitive.md`,
|
||||
`state-epoch-validation.md`)이 나왔다. 처리 결과의 소스는
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`.
|
||||
|
||||
## 1. 5라운드가 왜 생겼나 — 4라운드 처리 때의 판단을 뒤집음
|
||||
|
||||
4라운드 종결 시점엔 사용자 지시(*"이후 stale 만 잡는것으로 끝낼 수
|
||||
있어보임"*)로 **5라운드를 안 만들기로** 했고 그 문장이 세 곳
|
||||
(`todos.md` 00번, `README.md` qa-request 행, 4라운드 followup H-7)에
|
||||
적혀 있었다. 같은 날 사용자가 5라운드를 요청 — *"4차에서 예로 넘어갔던건
|
||||
스킵하고, 새로운 부분들이나 다른 깊은 부분을 예가 나와야 정상인 질문들을
|
||||
쌓아보자."* → 세 곳 전부 정정하고 문항지를 신설했다.
|
||||
|
||||
**범위를 셋으로 좁힌 게 이번 라운드의 설계**: (1) 4라운드에 문항이 아예
|
||||
없던 영역(`project-setup-plan.md`/`quad-types-plan.md`, 그리고 **문서가
|
||||
아니라 실제 커밋된 M1 코드**), (2) 4라운드 회신 **이후** 확정된 것
|
||||
(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의
|
||||
심화(예: `debounce-throttle-plan.md`는 1100줄인데 4라운드 문항이 9개뿐이었음).
|
||||
|
||||
## 2. 문항지 작성 중 이미 잡힌 것
|
||||
|
||||
문항을 쓰는 과정 자체가 감사가 됐다:
|
||||
|
||||
- **`EF-3`** — 4라운드 followup이 "반영 완료"로 적은 `E-10`(dedup 대칭)이
|
||||
`effect-plan.md`에 **실제로는 안 들어가 있었다**. → 일반 교훈으로
|
||||
`CR-1`("followup 표를 신뢰 소스로 쓰면 안 된다, 소스는 `base/` 본문")을
|
||||
문항화했고, 사용자가 "예"로 확인해 이번에 실제로 반영했다.
|
||||
- **`DE-9`** — `KeyGone` 소멸 루프가 새 값을 반환받으면 `settle`의 교체
|
||||
분기로 들어가 `rawAdd(self, result, 0)`(범위 밖 인덱스)로 터진다.
|
||||
- **`IM-1`** — `architecture.md`는 "지금은 `New()` 미노출 싱글톤"이라는데
|
||||
실제 코드는 `module.New = New` + `return New()`.
|
||||
- **`CR-2`/`CR-9`** — `Tween:Map` vs `Mapped` 이름과 `isBoundAlive`의 세 번째
|
||||
분기가 **결정이 필요한데 어느 추적 목록에도 없었다.**
|
||||
- **`DC-9`** — Blocker 게이팅이 `recompute`를 O(N²)→O(N)으로 줄였는데
|
||||
`setOffsetSource`의 즉시 계산이 그 자체로 O(N²)라 상쇄된다.
|
||||
|
||||
## 3. 회신으로 확정된 것 (즉시 반영)
|
||||
|
||||
- **`slot._detached`는 lazy** — 모든 Slot이 빈 테이블을 미리 갖지 않는다
|
||||
(사용자: *"테이블 생성 비용을 모든 slot 이 가져야하나는 의문 … if 확인으로
|
||||
nil 이면 스킵이 훨씬 싸게 먹히지 않는가?"*). `getDetached(slot)` getOrCreate.
|
||||
- **`prev` 없이 `Detach`를 반환해도 nop** — 사용자가 prev 유무를 추적할
|
||||
의무 없음.
|
||||
- **`KeyGone`엔 `nil`/`None`/`Detach`만** — prev도 새 값도 error(*"KeyGone 을
|
||||
받은 요소는 오직 … 캐싱 이외의 새로운 마운트나 생성을 거부한다"*).
|
||||
- **⭐ `Owned = false`에서 `Detach`는 `_detached`에 안 들어간다** — 남의 것에
|
||||
소유권을 유지하는 건 모순이라 `rawUnmount`로 처리하고 다음 `prev`는 `nil`.
|
||||
부수로 unowned에선 `Detach`와 `nil`이 같은 동작이 되고 `_detachCleanup`의
|
||||
`_owned` 분기가 도달 불가가 되어 삭제됐다.
|
||||
- **조상이 죽으면 unowned 요소도 엔진 재귀 파괴로 같이 죽는다** — `Owned=false`가
|
||||
약속하는 건 "quad가 안 죽인다"뿐. 이 계약이 코퍼스에 없어서 신설.
|
||||
- **`groupClaimKeys` 키 = `(inst, groupValue) → k`**, `nameClaims`보다 위치
|
||||
claim이 먼저. `Frame { a, a }` 갭이 이걸로 닫힘.
|
||||
- **`Tween<T>:Mapped`** 확정(`-ed` 관례).
|
||||
- **⭐ "게이팅 먼저"** — M2가 M3의 `Blocker`에 의존하던 순서 문제를 로드맵
|
||||
순서 유지가 아니라 **앞당기는 쪽**으로 결정. 단 앞당기는 대상이 `Blocker`가
|
||||
아니라 그 아래 공용 `Gate` 노드로 바뀌었다(아래 4절).
|
||||
|
||||
## 4. 새로 열린 것 — research 문서 둘
|
||||
|
||||
- **`research/gate-primitive.md`** — `DT-4`에서 사용자가 정리: 시간 기반
|
||||
게이트를 공개 `Blocker` API 위에 못 얹는 이유가 **순서 보존**이다
|
||||
(*"이 옵저버의 emit 이 먼저이냐 후행 Blocker 로 생성된 요소의 emit 이
|
||||
먼저이냐가 문제"*). 그래서 emit을 **가로채는** 공용 노드가 필요하고,
|
||||
공개 API로 낸다. 사용자 스케치는 `Gate(function(emit) return function()
|
||||
... end end)`. 남은 건 이름(`Gater`가 어색하다는 지적 — 에이전트 권고는
|
||||
`Gate` 그대로), `:Apply` 팩토리 여부, `Blocker`의 얹힘 방식, M2 범위.
|
||||
- **`research/state-epoch-validation.md`** — `SS-2`/`SS-3`에서 사용자가
|
||||
제기한 **glitch**: DFS 전파 중 Observer가 `Get()`을 부르면 아직 신호를 못
|
||||
받은 다른 가지의 옛 캐시가 섞여 들어간다. 제안은 각 State가 상류 루트
|
||||
Source들의 **에포크(count)** 를 들고 `Get()` 때 비교하는 것. 에이전트
|
||||
분석: 진단·방향 타당(MobX/Adapton류 선례), **정확성 결정이지 최적화가
|
||||
아님**, 다만 중복 *통지*는 안 고쳐지고 "선언 안 된 의존성"을 UB로 명문화
|
||||
해야 함. **M3(State 구현) 전에 결론 필요.**
|
||||
|
||||
## 5. 2차 회신 — C절 전량 확정 (같은 세션)
|
||||
|
||||
되물은 6건이 그 자리에서 전부 닫혔다(처리 전량은 followup **F절**):
|
||||
|
||||
- **`Slot:Replace` 신설**(`B-5`) — 사용자가 "교체는 제거+삽입이 아니라 replace가
|
||||
나아 보인다"고 제시. `:List`의 교체가 `spliceArraysDown`+`spliceArraysUp`
|
||||
쌍(시프트 2회·`recompute` 2회)에서 **`rawReplace` 한 번(시프트 0)** 으로
|
||||
바뀌었고, 그 부수로 `C-7`(`:Single` 교체 시 Length가 잠깐 줄었다 느는 것)이
|
||||
같이 사라졌다.
|
||||
- **에포크 안에서 중복 통지도 접기로**(`B-7`) — emit이 `(source, count)`를
|
||||
실어오므로 판정이 O(1). **2026-08-14에 폐기된 옛 dedup과 다른 장치**임을
|
||||
문서에 못 박았다(그건 `invalid` 플래그 기반이라 Observer 영구 침묵 모드가
|
||||
있었고, 에포크는 매 `Set`마다 새 값이라 그 모드가 없다). 구현 요구로
|
||||
**`seen`/`computedAt` 두 카운트 분리**가 추가됐다. 에이전트가 붙였던 "선언
|
||||
안 된 의존성을 UB로 명문화" 조건은 **사용자 기각**(*"이전과 다른게 없다"*).
|
||||
- **물리 조작은 주입 op**(`C-1`) — *"slot 의 해당 동작은 base 이므로 parent 를
|
||||
모른다"*. 의사코드 전체의 `element.Parent = ...`/`element:Destroy()`를
|
||||
`mountInst`/`unmountInst`/`disposeInst`로 정정(9곳)하고 경계 절을 신설.
|
||||
**`rawAdd` 의사코드도 이때 처음으로 문서에 들어갔다.**
|
||||
- **`rawAdd`의 `Length:Set` 제거**(`C-2`) — `Length`는 `recompute`만 쓴다.
|
||||
- **래핑/언래핑을 Slot 전체 연산으로**(`C-3`) — `wrapElement`/`unwrapElement`
|
||||
한 쌍 + 래퍼의 `_wrapped` 역참조. 사용자가 예상한 대로 `wrappers[key]` /
|
||||
`mounted[key]` 분리가 **필요 없어졌다**.
|
||||
- **`setLength`에 `anchor` 인자 신설**(`C-4`) — 부기 키와 생명주기 앵커 분리.
|
||||
4라운드 `D-56`이 역전돼 `archive/bindlifetime-slot-owner-reversed.md`로 갔고,
|
||||
형태 미정으로 열려 있던 **`isBoundAlive` 세 번째 분기 항목이 같이 닫혔다.**
|
||||
- **`Effect(fn, ...deps)` 확정**(`C-6`) — `Ref`도 의존성이 될 수 있고, 최소
|
||||
1회 실행(useEffect 동일), trailing lazy 위치 인자, `Ref`는 `Set`될 때만 발화.
|
||||
- **`Gate`만 다음 세션으로** — *"고칠것이 많으므로 … 지금 세션 상 지식만
|
||||
이전될 수 있게 두세요."* 그래서 `research/gate-primitive.md`는 재료만 모아둔
|
||||
상태로 두고 `question.md`/`todos.md`에 **M2를 막는 유일한 항목**으로 남겼다.
|
||||
|
||||
## 6. (1차 시점 기록) 되물었던 것
|
||||
|
||||
파급 큰 순서로: **`rawAdd`의 `Length:Set` 제거**(지금 서술이 "기여도 합"
|
||||
정의와 충돌하고 `recompute`와 이중 기록), **`updateFn`이 State를 반환할 때의
|
||||
래핑/`prev` identity**(래핑이 공개 `Slot:Add`에만 있어 reconcile 경로엔
|
||||
없다는 실제 갭), **`setLength`의 Observer 앵커를 물리 target으로 되돌리기**
|
||||
(사용자 문제 제기 — *"우리가 왜 slot 을 소유 대상으로 둘 수 있게 한거였는지"*,
|
||||
되돌리면 4라운드 `D-56`의 백엔드 요구사항과 `isBoundAlive` 세 번째 분기가
|
||||
통째로 불필요해짐), `rawAdd` 의사코드 초안 승인(문서에 정의가 아예 없었음),
|
||||
`Gate` 이름·표면, `Effect`의 다중 의존성(`Ref` 포함) 안.
|
||||
|
||||
## 6-1. 3·4차 — `mountInst`의 삽입 위치에서 시작해 offset 모델이 정리됨
|
||||
|
||||
2차까지 끝난 뒤 사용자가 `mountInst`/`unmountInst`가 index를 안 받는 걸 문제
|
||||
삼았다(*"웹에서는 어떻게 되냐가 모호함. 어디 둘지 어떻게 아느냐는것"*). 이
|
||||
질문이 결국 offset 부기 모델 전체를 정리하게 만들었다:
|
||||
|
||||
- **`None`이 두 뜻을 겸하고 있었다** — 정의는 "실제 마운트를 하지 않는 위치"인데
|
||||
**plain 요소가 `None` + `setLength(1)`로 등록**되고 있었다. 그래서 그 자리의
|
||||
offset 숫자가 계산조차 안 됐고, DOM류 백엔드가 삽입 위치를 알 방법이 없었다.
|
||||
→ `None`의 뜻을 **"발행 채널 없음"**으로 좁히고(참여 여부는 `lengthList`가 답),
|
||||
숫자가 필요한 쪽은 **`Dispatch.getOffsetAt(ownerKey, i)`로 pull**한다.
|
||||
- **⭐⭐ 그 자리를 파다 별개 결함 발견 — 중첩 offset이 부모 베이스를 못 받았다.**
|
||||
`recompute`가 `sum = 0`으로 시작하고 `ownerKey.Offset`을 읽는 자리가 없어서,
|
||||
**depth ≥ 2에서 자식 offset이 부모 베이스만큼 통째로 밀려 있었다**(depth 1만
|
||||
쓰던 동안 로컬==절대라 안 드러남). → `base`를 시드하고, 중첩 Slot이 자기
|
||||
`Offset`을 관측해 자식 offset을 다시 미는 구독을 추가.
|
||||
- **⭐ 반영하다 하나 더 — 재마운트가 `Offset` Source를 새로 만들고 있었다.**
|
||||
언마운트가 `slot.Offset`을 일부러 보존하는 이유(`SL-75`/`DC-6`: 이미 렌더된
|
||||
요소들이 그 Source를 **구독한 채 딸려 나감**)가 재마운트에서 무너지고 있었음
|
||||
→ `slot.Offset or Source(0)`으로 identity 재사용.
|
||||
- **에이전트 제안(`bk.offsetList` push)은 사용자안(pull + `getOffsetAt`)에
|
||||
밀려 폐기**됐고, 이어서 **`bk.base` 필드도 사용자 지적으로 걷어냈다**
|
||||
(*"이건 slot 안의 slot.offset 이랑 기능이 겹칠텐데"* — 같은 값을 두 곳에 두는
|
||||
중복 상태). `isSlot` 분기는 남는데, 그건 타입 분기가 아니라 **duck-typing이
|
||||
금지돼 있어서**(Roblox userdata의 미정의 키 인덱싱 에러, `brand-plan.md`)
|
||||
브랜드 검사가 유일한 안전 경로이기 때문.
|
||||
|
||||
**이 라운드의 패턴**: 세 결함 전부 *"사용자가 표면적인 질문 하나를 던졌더니 그
|
||||
아래에서 나왔다"* — `mountInst`의 인자 하나가 offset 모델의 두 결함을 끌어냈다.
|
||||
|
||||
## 6-2. 커밋 전 감사 2라운드 — 실제 결함 다수
|
||||
|
||||
핸드오버 준비로 `quad-doc-auditor`를 각도를 바꿔 두 번 돌렸다(1: diff와 그걸
|
||||
인용하는 base / 2: 인덱스 레이어·히스토리). **둘 다 실질적인 걸 잡았다.**
|
||||
|
||||
- **⭐ `Owned`가 코드에 도달하지 못하고 있었다** — `settle`/`destroySlotTree`/
|
||||
`releaseElement`가 `self._owned`를 9곳 넘게 읽는데 `Slot:List`/`Slot:Single`
|
||||
의사코드가 **`opts` 인자를 안 받고 있었다.** 확정한 옵션이 **문서 안에서
|
||||
배선이 끊긴 채** 있었던 셈.
|
||||
- **⭐ `effect-plan.md`가 자기 자신과 모순** — 최상단 시그니처와 "trailing args
|
||||
sugar는 의도적으로 안 만듦" 확정 문단이 그대로인 채 `Effect(fn, ...deps)`
|
||||
절이 추가돼 **역전 배너 없이 정반대 두 서술이 공존**하고 있었다.
|
||||
- **⭐ 손대지 않은 문서의 사각지대** — `ROADMAP.md` 백로그 문단과
|
||||
`debounce-throttle-plan.md`가 "Gate 추출은 M3에서"라고 여전히 말하고 있었다
|
||||
(이번에 M2로 앞당겨졌는데 diff에 안 잡히는 파일이라 그대로 남음). 이건
|
||||
`conventions.md`가 경고하는 "변경한 세션 자신은 자기가 뭘 안 건드렸는지
|
||||
모른다"의 교과서적 사례.
|
||||
- **⭐ 인덱스에 옛 결론이 남음** — State-에포크의 "중복 통지는 안 고쳐진다 /
|
||||
UB 명문화 필요"가 같은 날 정반대로 확정됐는데 `question.md`/`README.md`만
|
||||
안 따라왔다.
|
||||
- 그 외: `indexOfRaw` 미정의, `mountSlotTree`의 전제 미명시, `Replace`의
|
||||
`destroyOld` 미서술, `Gate` 이름이 용어 대기열에 없음, 주입 op 목록 미갱신,
|
||||
M2 체크박스 문장 깨짐 등.
|
||||
|
||||
**그리고 감사 회신 자리에서 결정 셋이 더 확정됐다** — `raw*`를 **index로 통일**
|
||||
(오래 열려 있던 캐비엇 종결), **래핑은 `raw*` 바깥**, 그리고 `getOffsetAt`의
|
||||
**접두합 캐시**(`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문항)
|
||||
- `qa-request/pre-implementation-qa-round5-response.md` — 회신 원문
|
||||
- `qa-request/pre-implementation-qa-round5-followup.md` — **처리 결과의 소스**
|
||||
(절이 라운드마다 쌓임 — **마지막 절이 최신**)
|
||||
- `archive/bindlifetime-slot-owner-reversed.md` — 역전된 `D-56` 원문
|
||||
- `research/gate-primitive.md`, `research/state-epoch-validation.md`
|
||||
|
||||
## 8. 후속 — State 에포크 안 3차 정정 (같은 날)
|
||||
|
||||
사용자가 `research/state-epoch-validation.md`를 직접 읽고 기제 서술 세 건을
|
||||
정정했다: (1) `sourceList` 순회는 `rawInvalid`가 **false**일 때만 돈다(문서는
|
||||
반대로 적고 있었다, 목적은 "못 받은 emit 받기"), (2) `emit`은 count 없이
|
||||
**발행 source만** 싣는다, (3) 에이전트가 요구했던 **`seen`/`computedAt` 두
|
||||
카운트 분리는 철회** — count 갱신과 `rawInvalid = true`가 같은 스텝이라
|
||||
캐시 오인 경로가 없다. 부수로 "순회가 발견한 변경을 뒤로 emit 할 것인가"가
|
||||
열렸는데, 다이아몬드 쪽은 사용자가 스스로 안전으로 정정했고 게이트 쪽만
|
||||
**해제 emit이 `source = nil`을 싣는 규약**으로 남았다(게이트는 보통 최종단에
|
||||
쓰므로 채택을 막지 않는다는 판단). 상세는 그 문서의 §2·§5와
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 M절.
|
||||
|
||||
## 9. 후속 — 순회의 count 갱신 문제 + `Gate`는 `:Apply`가 아니다 (같은 날)
|
||||
|
||||
**(1) 순회가 count를 올리면 통지가 죽는다.** 사용자 지적 — 순회로 발견해
|
||||
count를 최신으로 올려두면 뒤늦게 온 진짜 emit이 삼켜져 하류가 그 에포크를
|
||||
영영 못 받는다(2026-08-14에 폐기된 옛 dedup의 "영구 침묵"과 같은 계열).
|
||||
사용자 해법은 **(b) 순회도 emit 한다**이고, 게이트 경우엔 그 emit을 해제
|
||||
시점까지 민다는 것. 에이전트는 **(c) 순회는 `rawInvalid`만 세우고 count는
|
||||
안 올린다**를 대안으로 냈다 — 원인이 정확히 "순회가 count를 올리는 것"이라
|
||||
그것만 안 하면 통지 죽음도 게이트 누출도 `nil` emit 규약도 안 생긴다.
|
||||
대가는 emit이 올 때까지 `Get()`마다 재계산, 그리고 `OffWithoutEmit` 경로에서
|
||||
count가 안 따라잡는 것. 또 (b)에서 "게이트까지 민다"를 실제로 구현하려면
|
||||
순회하는 노드가 상류 게이트를 알아야 하는데 `sourceList`가 루트로 평탄화돼
|
||||
있어 불가능하고, 유일하게 깔끔한 기제(게이트를 에포크 경계로)는
|
||||
`blocker-plan.md`의 "`:Get()`엔 영향 없음" 확정 계약을 뒤집는다는 걸 짚었다.
|
||||
**미결** — `research/state-epoch-validation.md` §5의 3번이 소스.
|
||||
|
||||
**(2) `Gate`는 `:Apply`가 아니라 State 메소드.** 사용자 확정 — *"state 의
|
||||
전파를 손대는 작업이라 with 처럼 다른 노드가 나는게 맞음."* 경계를 정확히
|
||||
적어두면, `Apply`가 노드를 못 만드는 게 아니라(확정 예시 `capAt(100)`도
|
||||
`:With` 노드를 만든다) **프리미티브는 메소드 / 유저랜드 조합 팩토리는
|
||||
`:Apply`**라는 층위 구분이다. 부수 결론 셋 — `Debounce`/`Throttle`의
|
||||
`state:Apply(...)` 관용구는 그대로 유효(팩토리가 내부에서 `:Gate`를 부르면
|
||||
됨), `Blocker` 배선 문제는 이미 확정된 `state:Block(blocker)` 메소드로
|
||||
자동 해소, `__call`은 안 씀(사용자 선호 + Luau 함수 타입 자리 통과 여부
|
||||
불확실). `research/gate-primitive.md`의 2번이 해소로 갱신됨.
|
||||
|
||||
## 10. 후속 — 순회 처분을 테이블 둘로 확정 (같은 날)
|
||||
|
||||
9절에서 열어둔 (b)/(c)를 사용자가 **제3안으로 닫았다**: 판정 기준을 둘로
|
||||
나눠 `sourceCountMap`(값 유효성, 순회가 앞당겨 올림)과 `sourceEmitMap`(전파
|
||||
dedup, 상류의 진짜 emit을 기다림)을 따로 둔다. 순회는 emit을 안 하므로
|
||||
게이트 누출이 아예 안 생기고, 뒤늦은 emit은 "count는 같은데 emit 기록이
|
||||
다름"으로 걸려 정상 전파된다. (c)의 유일한 약점(`Get()`마다 재계산)도
|
||||
사라진다 — 순회가 `sourceCountMap`을 실제로 올리기 때문. 같이 나온
|
||||
`rawEmit`+`nil` 안은 구조 위생(상류 emit과 내부 emit의 진입점 통일)만
|
||||
살리고 해법으로는 안 쓴다(막는 게이트는 보통 상류에 있어 자기 `rawEmit`을
|
||||
태워도 누출이 남고, `nil` emit은 하류마다 전체 순회를 강제해 연쇄한다).
|
||||
M절에서 철회했던 두-카운트 분리가 **다른 근거로 되살아난** 셈이다.
|
||||
이제 이 안은 기제가 다 정해졌고 **남은 건 채택 여부 자체**다.
|
||||
|
||||
## 11. 종결 — `Gate` 표면 확정 + 에포크 채택, 두 문서 `base/` 승격 (같은 날)
|
||||
|
||||
사용자가 `Gate`를 **탑레벨 프리미티브 없이 `state:Gate(setup)` 메소드 +
|
||||
`GateNode`**로 확정하고(*"Blocker 는 해당 내부 배선을 따른다"*), 이어서
|
||||
State 에포크도 **채택**했다(*"gate 와 epoch 가 제가 만족할만한 정도로
|
||||
올라왔습니다"*). `research/gate-primitive.md` → `base/gate-plan.md`,
|
||||
`research/state-epoch-validation.md` → `base/state-epoch-plan.md`로 승격.
|
||||
|
||||
에포크 채택으로 `source-state-plan.md`의 두 확정 서술("항상 전파" / "중복
|
||||
통지는 안 접음")이 역전돼 `archive/always-propagate-no-dedup-superseded.md`로
|
||||
옮겼다. **2026-08-14의 `invalid` 기반 dedup 금지를 되돌린 게 아니라는 것**을
|
||||
역전 문서·`source-state-plan.md`·`README.md` 세 곳에 모두 못박았다 — 이
|
||||
구분이 흐려지면 "영구 침묵" 버그로 되돌아간다.
|
||||
|
||||
부수로 스파이크 `05-store-state-diamond-propagation`이 다시
|
||||
`rewrite-required/`로 갔다(다이아몬드 Observer가 이제 변경당 1회만 울어야
|
||||
해서 핵심 assert가 정반대). 처리 전량은
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 O절.
|
||||
|
||||
## 12. `/code-review high` — 12건 전부 유효, 그중 3건은 실제 설계 구멍
|
||||
|
||||
O절 커밋 직후 사용자가 돌린 리뷰에서 12건이 나왔고 전부 유효했다. 특히
|
||||
**게이트가 유보했다 내보내는 emit이 어느 source를 싣는가**는 M절이 이미
|
||||
짚어뒀는데(*"`nil` 규약만 Gate 설계와 같이 확정하면 된다"*) 표면 확정 때
|
||||
같이 안 닫힌 것으로, 그대로 두면 `blocker:Off()`의 배치 통지가 하류에서
|
||||
삼켜진다 — `setup` 시그니처에 영향이 있어 **M2 착수 전 항목**으로 되돌렸다
|
||||
(같은 날 "M2를 막는 항목 없음"이라 적었던 `todos.md` 00번도 정정).
|
||||
|
||||
교훈이 하나 더 있다: 이번에도 **감사자 각도가 아니라 diff 각도에서만 보이는
|
||||
것**이 다수였다(개수 하드코딩, 배너 vs 본문, 같은 파일 안의 모순 문장,
|
||||
방금 옮긴 파일을 가리키는 새 텍스트). `conventions.md`의 "`/code-review`는
|
||||
감사자를 대체하지 않는다" 항목이 다시 확인된 셈. 전량은
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`의 P절.
|
||||
|
||||
## 13. `/code-review` 3건에 대한 사용자 회신 — 2건 확정, 1건은 근거가 반증됨
|
||||
|
||||
게이트 emit의 출처는 **`emit(self)` + 흡수 집합**으로 닫혔다. 에이전트가
|
||||
권고한 (c)를 사용자가 더 단순화한 형태 — 게이트에 자체 count를 주는 대신
|
||||
`withheld : {[source]=true}`만 들고 있다가 풀 때 자기를 출처로 emit 하고,
|
||||
**동기 전파**라 반환 뒤 `table.clear`하면 하류 전원이 집합을 본다. 검토
|
||||
결과 **`setup` 시그니처는 안 바뀐다** — 집합을 채우는 건 정책이 아니라
|
||||
노드이기 때문(P절이 "M2 표면에 영향"이라 적은 건 기우였다).
|
||||
|
||||
에포크 쪽은 "재계산 때 count 전부 갱신"만 확정되고, 그걸 제기한 에이전트
|
||||
근거는 **사용자가 반증**했다 — 전파가 동기라 `A:Set()` 파동이 끝난 뒤에야
|
||||
`Z:Set()`이 시작되므로 통지가 두 번 나는 건 중복이 아니라 맞는 동작이다.
|
||||
그 과정에서 대원칙 하나가 명문화됐다: **무효화를 결정하는 건 언제나 count
|
||||
비교지 emit의 도착이 아니다.** 전량은 followup의 Q절.
|
||||
|
||||
## 14. 마지막 단순화 두 건 — 게이트 통과/유보 미구분, 새 노드 맵 비대칭 초기화
|
||||
|
||||
사용자가 Q절 기제를 한 번 더 줄였다. 게이트는 **정책 실행 전에 무조건**
|
||||
출처를 `withheld`에 넣고, 통과시킬 때조차 **자기를 출처로** 전파한다 —
|
||||
"지연과 비지연을 구분할 이유가 없다". 그래서 Q절이 넣었던 "정책이 emit을
|
||||
불렀는지 노드가 되짚는다"는 감지 로직이 통째로 사라졌다.
|
||||
|
||||
새 노드의 두 맵은 **비대칭**으로 초기화한다: `sourceEmitMap`은 비우고
|
||||
(새 노드는 개념적으로 emit을 받아본 적이 없으므로 `nil`이 곧 "변경"),
|
||||
`sourceCountMap`은 상류에서 전부 끌어와 실제 count로 채운 뒤
|
||||
`rawInvalid = true`(순회가 훑을 목록이 곧 이 맵이라 비워두면 "유효"로
|
||||
오판). 이걸로 `:With` 병합 규칙이 필요 없어지며 `/code-review` Med-3이
|
||||
닫혔다. 전량은 followup의 R절.
|
||||
|
||||
## 15. 게이트가 게이트 emit을 받는 경우 — 미정의였고, 풀어 담는 것으로 확정
|
||||
|
||||
사용자가 마지막으로 잡은 구멍. "받은 출처를 그대로 넘긴다"는 규칙이 하류가
|
||||
또 게이트일 때 깨진다 — 상류 게이트가 전파 직후 집합을 비우므로 하류 게이트가
|
||||
나중에 풀면 빈 집합을 내보내 변경이 증발한다. 수신 시점에 **풀어서 자기
|
||||
`withheld`에 합치는** 것으로 확정. 겹겹이 쌓여도 각 층이 자기 집합을 들고
|
||||
있어 어느 층이 먼저 풀리든 정보가 안 샌다. 부수로 게이트의 `sourceEmitMap`은
|
||||
수신 때가 아니라 **실제 전파 때** 갱신하는 것으로 못박았다. 전량은 S절.
|
||||
|
||||
## 16. 두 번째 `/code-review high` — 7건 전부 유효, 유실 경로 둘
|
||||
|
||||
`e2b85bc` 직후 돌린 리뷰. 특히 둘이 실제 유실이었다: (1) `withheld`를
|
||||
페이로드로 그대로 넘기면 **재진입**(전파 중 Observer가 `Set`) 시 중첩 전파의
|
||||
`clear`가 바깥 전파를 빈 집합으로 만들어 변경이 증발한다 → 전파 직전에
|
||||
새 테이블로 **스왑**해 배치를 떼어내는 것으로 수정. (2) `OffWithoutEmit()`이
|
||||
집합을 안 비워, 매 프레임 `On()`/`OffWithoutEmit()`을 도는 `Dispatch.drive`
|
||||
배치 게이팅에서 집합이 단조 증가하고 나중에 폐기분이 같이 실려 나간다 →
|
||||
그 경로도 비우도록 확정, `withheld`도 weak key로 명시.
|
||||
|
||||
부수로 "무조건 `withheld`에 넣는다"가 수신 규칙을 건너뛴다는 뜻으로 읽히던
|
||||
모호함(다이아몬드에서 `Throttle` 유령 trailing emit)과 STATUS의 런타임 개수,
|
||||
followup D절의 옛 `research/` 현재형 서술을 고쳤다. 재진입 계약과 "소스 없는
|
||||
emit(빈 배치)"은 사용자 판단 항목으로 승격 — 후자는 `Effect(fn, ...deps)`
|
||||
설치 구간 억제 용례에 직접 걸린다. 전량은 T절.
|
||||
|
||||
## 17. 사용자 반문 — "재진입 계약"은 없던 항목, `emit`은 flush
|
||||
|
||||
사용자가 T절을 되짚어 에이전트 서술 오류 둘을 잡았다. (1) `gate-plan.md`
|
||||
6번의 "정책 안에서 `emit()` 재귀호출"은 `blocker-plan.md`의 재진입 절을
|
||||
**잘못 옮긴 것** — 그 절은 같은 인스턴스 중첩(`On()`/`Off()`)을 말하고,
|
||||
정책이 flush를 부르는 건 평범한 통과 경로다. 무한 되먹임은 2026-08-04
|
||||
확정 원칙대로 UB이고 유한 재진입은 이미 지원(`debounce-throttle-plan.md`의
|
||||
`onWindowEnd` 주석)이라, 이 항목은 열린 게 아니라 **정리 대상**이었다.
|
||||
(2) 정책이 받는 `emit`은 페이로드를 정하지 않는 **flush 트리거**라 배치
|
||||
스왑이 그 핸들 안에서 일어난다 — T절의 H-1은 모델의 구멍이 아니라
|
||||
에이전트가 적은 "전파 후 `table.clear`" 의사코드의 결함이었다. 수정은
|
||||
그대로 유효하고 서술만 고쳤다.
|
||||
|
||||
그래서 `Gate`에 남은 **사용자 판단은 빈 배치 emit 하나뿐**이다. 전량은 U절.
|
||||
|
||||
## 18. 빈 배치 emit — 에이전트 권고 기각, 기존 Blocker 계약의 일반화로 확정
|
||||
|
||||
마지막 열린 항목. 에이전트는 "빈 배치 = 무조건 통지"를 권고했으나 사용자가
|
||||
기각했다 — 빈 배치는 "더 던질 게 없다"는 뜻이고 그걸 흘리는 건 State 층에
|
||||
`Source:Emit`을 추가하는 격이다. 확정은 **통지 자체를 안 함**이고, 확인해보니
|
||||
새 규칙도 아니었다: `blocker-plan.md`의 "`HasBlockedEmit`이 false면 아무 것도
|
||||
안 함(idempotent)"이 이미 같은 계약이고 `withheld`가 그 플래그의 일반화다.
|
||||
|
||||
따름정리로 `Effect(fn, ...deps)`의 설치 구간 억제가 **`Gate` 소비자에서
|
||||
빠졌다** — 설치 구간엔 `Set`이 없어 쌓일 게 없으므로 게이트가 내보낼 것이
|
||||
없고, `Effect` 내부 플래그면 충분하다. `effect-plan.md`의 "`Gate`보다 뒤"
|
||||
순서 제약도 같이 사라졌다. 이로써 `Gate`에 사용자 판단 항목은 남지 않았다.
|
||||
전량은 V절.
|
||||
|
||||
## 19. `Epoch`/`EpochMap`/`Brand` 제안, 그리고 핸드오버 (세션 종료)
|
||||
|
||||
에이전트가 "다중 의존성 `Effect`에서 한 파동에 `fn`이 두 번 돈다"는 갭을
|
||||
제기하자(`A → b`, `A → c`일 때 접어줄 공통 하류가 없어 에포크 dedup이
|
||||
못 접는다), 사용자가 그걸 계기로 더 큰 재구성을 제안했다 — 에포크 부기를
|
||||
State에서 떼어 `EpochMap`으로 컴포지션하고, emit 페이로드를 `Epoch`
|
||||
인터페이스로 일반화하고, 그러려면 `Brand`를 인스턴스 브랜드(다중 태깅)로
|
||||
바꾸는 것. `Effect`가 자기 `EpochMap`을 들면 **그 지점이 공통 하류가 되어**
|
||||
갭이 닫힌다.
|
||||
|
||||
두 차례 회신으로 사실상 전량 확정됐다. 그 과정에서 에이전트 주장 셋이
|
||||
반증됐다 — (a) "포함 관계가 생성자로 흩어진다"는 자기 등록이 다중 등록을
|
||||
강제하지 않으므로 틀렸고, (b) "재계산 후 sync 연산이 따로 필요하다"는
|
||||
`Update`가 `{Epoch}`를 받는다는 걸 놓친 것이었으며, (c) "오버플로해도
|
||||
다르다"는 사용자 근거는 double이 랩이 아니라 **포화**한다는 점에서
|
||||
정정됐다(도달 불가능이라 결론은 유지).
|
||||
|
||||
**승격은 사용자 지시로 다음 세션** — *"전면 승격을 하고 싶지만, 이 세션은
|
||||
너무 길어요."* 감사도 승격 이후에 돌리기로 했다. 핸드오버로 `base/` 네
|
||||
문서(`state-epoch-plan`/`source-state-plan`/`effect-plan`/`brand-plan`)
|
||||
상단에 이 제안을 가리키는 ⚠️ 배너를 달았고, `todos.md`에 **000번**을 신설해
|
||||
다음 세션의 첫 작업으로 지정했다. 다중 dep `Effect` 갭 자체도
|
||||
`effect-plan.md`에 미해결로 기록했다 — 제안이 기각되더라도 갭은 남으므로.
|
||||
|
|
@ -0,0 +1,336 @@
|
|||
# 2026-08-21-03 — `Epoch`/`EpochMap`/`Brand` 전면 승격 + 해소 기록 flatten
|
||||
|
||||
**요지**: 앞 세션(`2026-08-21-02-qa-round5-and-gate-epoch-research.md`)이
|
||||
컨텍스트 피로로 미뤄둔 승격을 실제로 수행하고, 그 과정에서 코퍼스 곳곳에 쌓여
|
||||
있던 `[해소]`/`[정정]` 층을 걷어냈다. 지금 유효한 설계는 전부 `base/`가 소스 —
|
||||
이 파일은 "무엇을 어디로 옮겼나"의 기록이다.
|
||||
|
||||
## 1. 사용자 지시
|
||||
|
||||
두 갈래였다.
|
||||
|
||||
1. **질문**: *"slot-attach-decomposition.md 은 이제 확정 이야기이기에, 리서치
|
||||
대상이 아니지 않나요? 또, 이것이 base에 적용되어 있습니까?"*
|
||||
2. **작업 지시**: `state-epoch-plan.md`/`source-state-plan.md`/`effect-plan.md`/
|
||||
`brand-plan.md` 넷을 전면 승격하고, *"이 승격을 하며 더이상 필요 없어진
|
||||
요소들은 archive 하세요. 특히 재정정 기록이 쌓인 부분이 있는데, 풀어가며
|
||||
archive 해서 flatten 해야할 필요가 보입니다."*
|
||||
|
||||
## 2. 1번 질문에 대한 답 — 둘 다 예
|
||||
|
||||
- **`base/slot-plan.md`에 이미 반영돼 있다** — `materializeSlotTree` /
|
||||
`mountSlotTree` / 두 줄짜리 `attachSlot` 래퍼가 그 문서의 "재귀 메커니즘"
|
||||
절에 의사코드로 들어가 있고, 그 문서가 근거로 이 파일을 네 곳에서 인용한다.
|
||||
- **`research/`에 있을 이유는 없다** — `.claude/README.md`의 폴더 기준상
|
||||
`research/`는 "아직 착수 전, 상의 필요"다.
|
||||
|
||||
**어디로 옮길지**가 실제 판단이었다. `archive/`는 기준이 "뒤집혔거나 기각됨"
|
||||
이라 채택된 결정의 근거 기록엔 안 맞고, `reference/`의 기준("결정 자체가
|
||||
아니라 다른 문서가 근거로 인용하는 온디맨드 자료")엔 정확히 맞는다. 그래서
|
||||
**`reference/`로 옮기고 폴더 기준 자체에 그 용도를 명문화**했다
|
||||
(`README.md` 폴더 기준표 + `project-context.md`). 선례도 있다 —
|
||||
`quad-v1-architecture.md`/`comparison-fusion-vide.md`가 `base/`에서 같은
|
||||
이유로 이동해온 것.
|
||||
|
||||
같은 처리를 `epoch-brand-composition.md`에도 적용했다(승격이 끝나면 성격이
|
||||
똑같아지므로).
|
||||
|
||||
## 3. 승격 — 무엇이 어떻게 바뀌었나
|
||||
|
||||
### `base/state-epoch-plan.md` (재작성)
|
||||
|
||||
- **`Epoch` 인터페이스 신설** — `type Epoch = { Revision: number }`, 그 자체로
|
||||
키가 되는 unique 테이블. `Source`가 구조적으로 만족하고 `EpochBrand`에
|
||||
등록된다. "루트 `Source`의 에포크"라는 좁은 서술이 전부 여기로 일반화됨.
|
||||
- **`EpochMap` 신설** — `:Update(Epoch|{Epoch}) -> boolean`(반환값의 뜻은
|
||||
"뒤로 전파가 필요한가"), `:Refresh()`, `:Sync()`. 부기가 State에서 떨어져
|
||||
나와 재사용 가능한 객체가 됐다.
|
||||
- **`:Refresh`는 이 세션이 이름만 붙인 것**이다 — "순회"라는 연산 자체는
|
||||
이미 확정돼 있었고(`rawInvalid == false`일 때 자기 맵을 훑는다), 맵이
|
||||
키를 소유하므로 인자 없는 형태가 자연스러워서 그렇게 적었다. 새 설계가
|
||||
아니다.
|
||||
- **State의 두 맵이 `EpochMap` 둘이 됐다** — `sourceCountMap` →
|
||||
`valueEpochMap`, `sourceEmitMap` → `emitEpochMap`. 이름을 바꾼 이유는
|
||||
"source"가 더 이상 정확하지 않아서다.
|
||||
- **문서 구조를 §1~§8로 다시 짰다.** 옛 §3(에이전트 분석)과 §5(열린 질문)에
|
||||
결론과 정정 경위가 섞여 있었는데, 결론은 규칙 본문(§2~§5)으로 올리고
|
||||
경위는 걷어냈다(원문은 `qa-request/pre-implementation-qa-round5-followup.md`
|
||||
M·N절과 `reference/epoch-brand-composition.md`에 있다).
|
||||
|
||||
### `base/brand-plan.md` (전면 재작성)
|
||||
|
||||
- **인스턴스 브랜드** — `Brand()`가 브랜드마다 weak 집합 하나를 들고,
|
||||
`SomeBrand:register(x)` / `SomeBrand:is(x)`. **다중 태깅**이 이 재작성의
|
||||
존재 이유다(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
|
||||
- **역조회 `Brand.get`은 없어졌다.**
|
||||
- **유지된 것**: weak-key 레지스트리, 테이블 아이덴티티, duck-typing 기각
|
||||
근거 둘, 포함 관계 predicate 합성, `Brand`가 `None`에 의존하지 않는다는
|
||||
결정, Luau narrowing 주의.
|
||||
- **새로 명시한 것 하나** — 다중 태깅이 가능해져도 **포함 관계는 계속
|
||||
predicate 합성으로 쓴다**(`Source`를 `StateBrand`에도 등록하지 않는다).
|
||||
등록 지점이 흩어지면 조용한 버그가 되고 포함 관계가 코드에서 사라지기
|
||||
때문. 정당한 다중 등록은 `Epoch`처럼 **다른 축의 계약**뿐이다.
|
||||
- 옛 표면은 `archive/brand-shared-registry-reversed.md`.
|
||||
|
||||
### `base/source-state-plan.md`
|
||||
|
||||
- "Source가 State를 만족함" 절에 **`Epoch`도 같은 방식으로 만족**한다는 항목
|
||||
추가(`Revision`이 공개여야 하는 이유 포함).
|
||||
- `state:Observer(fn)` 절에 **`fn(self, from: Epoch | {Epoch})`** 시그니처
|
||||
확정 추가. "값을 안 실어주는 구독" 계약이 안 깨지는 이유도 같이.
|
||||
- 승격 대기 ⚠️ 배너 제거, 그 절들의 "에포크" 표현을 "리비전"으로 통일.
|
||||
|
||||
### `base/effect-plan.md`
|
||||
|
||||
- **⚠️ 미해결이던 다중 의존성 중복 발화가 닫혔다** — `EffectHandle`이
|
||||
`EpochMap`을 하나 들고 각 내부 Observer가 받은 `from`으로 `Update`,
|
||||
`true`일 때만 `fn`을 돌린다. `Effect`가 곧 deps의 공통 하류가 된다.
|
||||
- **`Ref` 의존성은 이 맵에 안 낀다**(이 세션이 명시) — `Ref`는 `Epoch`가
|
||||
아니고 `:Callback`으로 발화해 `from`이 없다. 공통 상류 문제 자체가 없다.
|
||||
|
||||
### `base/gate-plan.md`
|
||||
|
||||
승격 대기 배너를 반영 완료로 바꾸고, 페이로드/`withheld`/`emitEpochMap`
|
||||
표현을 `Epoch` 어휘로 통일. **기제는 하나도 안 바뀌었다.**
|
||||
|
||||
## 4. flatten — 걷어낸 것들
|
||||
|
||||
사용자가 지목한 "재정정 기록이 쌓인 부분"은 실제로 세 군데였다.
|
||||
|
||||
1. **`question.md`가 다시 해소 항목으로 절반이 찼다.** 그 문서 스스로
|
||||
*"항목을 해소하면 여기서 지우고 `archive/question-resolved.md`에 근거와
|
||||
함께 옮길 것 — 다시 쌓이면 같은 문제가 반복됨"*이라 규정해뒀는데,
|
||||
2026-08-21 하루에 여러 건이 닫히면서 예고대로 재발했다. **16건을 일괄
|
||||
이관**하고(`Gate` 이름 항목 포함), 파일이 421줄 → 208줄이 됐다.
|
||||
- 이관분 중 `Epoch` 관련 서술은 **이관 시점 표현 그대로 뒀고**, 대신
|
||||
이관 섹션 머리에 "필드 이름이 그 뒤 바뀌었다"는 경고를 붙였다. 히스토리
|
||||
문서의 원문을 소급해 고치지 않는다는 관례를 지키면서 오독만 막는 처리.
|
||||
2. **`todos.md`의 "M3 착수 전에 결론이 필요한 항목 목록"이 절반 넘게 `[해소]`
|
||||
였다.** 실제로 열려 있는 건 둘뿐(중간 State GC 미검증, `store:GetDynamic`)
|
||||
인데 해소 항목 일곱이 섞여 있어 "지금 할 일"로 안 읽혔다. 걷어냄.
|
||||
3. **`todos.md` 000번**(다음 세션 첫 작업 = 이 승격)은 완료됐으므로 삭제하고,
|
||||
00번 안에 승격 완료 사실만 남겼다.
|
||||
|
||||
## 5. 마지막 미정도 같은 세션에 닫힘 — 리비전 증가는 `bit32` 랩
|
||||
|
||||
승격 보고 직후 사용자가 그 자리에서 정했다: *"리비전 증가는 bit32 로
|
||||
두고싶어요. 그건 luau 에서 native call 이라 아주 빨라요. 반면 double 의
|
||||
연산이 느린편인데, 희소 수준이 아니라, 사실상 만나는걸 수년간 보기 어려운
|
||||
라운드되어 동일해 무시되는 경우를 막기 위해 double 까지 올려야할 이유를
|
||||
모르겠어요. 매번 도는 코드인지라, 값 싸게 native call + num 연산으로 가볍게
|
||||
가고 싶어요."*
|
||||
|
||||
논거의 핵심은 **"`2^53` 포화는 어차피 도달 불가능한 시나리오인데, 그걸
|
||||
피하겠다고 값을 double 영역까지 키울 이유가 없다"**이고, 매 `Set`마다 도는
|
||||
hot path라는 것.
|
||||
|
||||
**⚠️ 에이전트가 형태를 잘못 옮겼고, 사용자가 그 자리에서 정정했다.**
|
||||
에이전트는 이걸 `bit32.band(rev + 1, 0xFFFFFFFF)`로 적고, 거기에 "그러니
|
||||
`bit32`라서 증가가 더 싼 건 아니다 — `n + 1`은 어느 쪽이든 double 덧셈이고
|
||||
`bit32`는 fastcall을 하나 더 얹는다"는 단서까지 달았다. 사용자 정정:
|
||||
|
||||
> *"잠시만요. 제가 말한건, bit32.bnot(-a) 입니다"* — 그리고 REPL 출력 셋을
|
||||
> 그대로 제시했다(`bit32.bnot(-1)` → `0`, `bit32.bnot(-0)` → `4294967295`,
|
||||
> `bit32.bnot(-4294967295)` → `4294967294`).
|
||||
|
||||
**즉 그 세 줄은 예시가 아니라 연산 자체였다.** `luau`로 재확인한 결과:
|
||||
|
||||
```
|
||||
bump(a) = bit32.bnot(-a)
|
||||
0 → 4294967295 | 1 → 0 | 2 → 1 | 4294967295 → 4294967294
|
||||
```
|
||||
|
||||
`a > 0`이면 `a - 1`, `0`이면 `4294967295`인 **랩어라운드 감소**이고, **갱신과
|
||||
랩이 FASTCALL 하나로** 끝난다. 그래서:
|
||||
|
||||
- **에이전트가 붙였던 단서는 틀렸다** — `band(rev + 1, mask)`라는 **다른
|
||||
형태**를 놓고 한 비교였다. `bnot(-a)`는 덧셈 위에 얹히는 게 아니라 갱신
|
||||
자체를 대체하므로, *"native call 이라 아주 빨라요"*라는 사용자 서술이 맞다.
|
||||
- **따름정리는 방향만 바뀌어 유효하다** — 단조 증가가 아닌 게 아니라 아예
|
||||
**감소**한다. 지금 규칙이 `==`/`~=`만 쓰기 때문에 무해하지만, 순서 비교를
|
||||
넣고 싶어지면 이 결정부터 되짚어야 한다. `Revision`이라는 이름이 순서를
|
||||
뜻하지 않는다는 것도 같이 적어뒀다.
|
||||
|
||||
**교훈**: 사용자가 근거로 든 REPL 출력을 "그 함수가 랩한다는 예시"로만 읽고
|
||||
연산 형태를 에이전트가 임의로 재구성했다. `conventions.md`의 "사용자 발언을
|
||||
근거로 인용할 때는 결론만 적지 말고 논거까지 남길 것" 항목이 겨냥하는 실패에
|
||||
가깝다 — 이 경우엔 논거를 남기긴 했는데 **형태를 바꿔 남겼다.**
|
||||
|
||||
**그래서 `Epoch`/`EpochMap`/`Brand`에 열린 설계 항목은 하나도 남지 않았다.**
|
||||
|
||||
`doc-check.py` ERROR 0 유지(WARN 30건은 전부 이 세션 이전부터 있던 것 —
|
||||
축약 파일명 인용과 날짜 없는 시한부 주장).
|
||||
|
||||
|
||||
## 6. 감사 루프 기록
|
||||
|
||||
`conventions.md`의 "핸드오버 준비하고 커밋해" 절차대로 **한 턴에 하나씩**,
|
||||
라운드마다 각도를 바꿔 돌렸다.
|
||||
|
||||
### 1라운드 — `base/` 정합성 + diff 범위
|
||||
|
||||
발견 넷 중 **하나는 stale**(감사자가 `bit32` 편집 이전 스냅샷을 읽어
|
||||
"리비전 증가 방식이 아직 미정"이라 보고했으나 다섯 곳 전부 이미 갱신돼
|
||||
있었음 — 감사자가 도는 도중 메인 세션이 같은 파일을 고치면 생기는 일이라,
|
||||
**감사 리포트를 받으면 항상 현재 파일로 재확인할 것**). 나머지 셋은 유효:
|
||||
|
||||
1. **`state-epoch-plan.md`가 "§8에서 기각됨"으로 실재하지 않는 내용을
|
||||
가리켰다** — 재작성하면서 "게이트를 에포크 경계로" 기각 논거를 떨어뜨렸고,
|
||||
`gate-plan.md`와 `reference/`는 또 **다른** 번호(§5-3)를 대고 있었다.
|
||||
→ §8에 실제 논거를 쓰고 세 포인터를 절 제목으로 통일. **`doc-check.py`는
|
||||
산문 속 `§번호`를 검증하지 못한다** — 문서를 재작성할 때 §번호 포인터는
|
||||
손으로 훑어야 한다.
|
||||
2. `ROADMAP.md` M7이 `isState`/`isSource`를 아직 "공유 레지스트리 기반"으로
|
||||
서술 → 멤버십 기반으로.
|
||||
3. **스파이크 `22`가 역전된 `Brand.set`/`Brand.get`을 직접 구현한 채
|
||||
`done/`에 있었다** → `rewrite-required/`로 이동. 검증 대상(포함 관계)은
|
||||
유효하므로 결론이 틀려서가 아니라 **구현자가 그 파일의 `Brand` 구현을
|
||||
참고 모델로 오독**하는 걸 막기 위함. `05`와 같은 처리.
|
||||
|
||||
### 2라운드 — 인덱스 레이어 + `luau-test` + 1라운드 이후 diff
|
||||
|
||||
여섯 건, 전부 유효:
|
||||
|
||||
1. **`state-epoch-plan.md`가 자기 문서 안에서 모순**했다 — §2가 "`Revision`을
|
||||
**증가**시킨다"라고 적어놓고 20줄 뒤에 "확정된 방식은 증가가 아니라
|
||||
**감소**한다"라고 반박. `bit32.bnot(-a)` 정정을 반영하면서 앞쪽 문장을
|
||||
안 고친 것. → 방향을 안 담는 "갱신한다"로.
|
||||
2. `source-state-plan.md`에 **같은 문장이 복붙돼 있었다** — 같이 정정.
|
||||
3. `README.md`의 `effect-plan.md` 행이 dedup 대칭을 아직 "미확인"으로 서술
|
||||
(본문은 `EF-3`에서 이미 "성립함"으로 확정). 세션 전부터 있던 stale.
|
||||
4. `STATUS.md`의 `✅ done/` 절이 **같은 파일 위쪽과 어긋났다** — 배너와 표는
|
||||
`22` 이동을 반영했는데 절 제목("17건")과 본문 나열("런타임 9건 …/`22`")은
|
||||
옛 상태. → 하드코딩 개수를 빼고 폴더/표를 소스로.
|
||||
5. `luau-test/README.md`의 "실행 환경 세 갈래" 표가 `21`/`22`/`23`을
|
||||
빠뜨리고 `13`을 아직 "런타임/타입 부분"으로 쪼개 적고 있었다(런타임
|
||||
절반은 2026-08-19에 `22`로 분리돼 나갔다). → 실제와 맞추고, 파일 헤더가
|
||||
소스임을 명시.
|
||||
6. `reference/slot-attach-decomposition.md`의 제약 `C7`이 그 뒤 `native*`
|
||||
계층 확정으로 **일반 계약으로서는 폐기**됐는데 문서에 아무 표시가
|
||||
없었다. 결론 자체는 유효하지만(배치 경로가 부기를 먼저 끝내는 건 `C6`
|
||||
요구사항이라 별개) 표만 떼어 읽으면 오독하므로 상단에 캐비엇 배너.
|
||||
`reference/comparison-fusion-vide.md`가 이미 같은 형태의 캐비엇을 달고
|
||||
있어 선례를 따랐다.
|
||||
|
||||
**교훈 하나** — 4·5번은 둘 다 **"같은 문서 안에서 위쪽만 갱신되고 아래쪽이
|
||||
남은"** 형태다. 개수를 제목에 박아두면 정확히 이렇게 갈라지므로, 이번에
|
||||
`STATUS.md`의 절 제목 두 곳에서 하드코딩 개수를 뺐다.
|
||||
|
||||
### 3라운드 — `archive/` 배너 정합성 + `qa-request/`의 "반영 완료" 주장
|
||||
|
||||
**확실한 모순 발견 0건 — 수렴.** 감사자가 대조한 것: `archive/`의 배너
|
||||
포인터 넷(`brand-shared-registry-reversed` / `question-resolved`의 이관 섹션 /
|
||||
`always-propagate-no-dedup-superseded` / `invalidate-dedup-propagation-reversed` /
|
||||
`bookkeeping-before-physical-reversed`), 2라운드가 고친 여섯 자리, 그리고
|
||||
`round4/5-followup.md`가 소스라고 선언한 항목들.
|
||||
|
||||
특히 **2라운드에서 내가 새로 쓴 `C7` 캐비엇**(*"배치 경로가 부기를 먼저
|
||||
끝내는 건 `C7`이 아니라 `C6`가 요구하는 별개 사안"*)이
|
||||
`base/dispatch-core-plan.md`의 "일반 계약 — 물리와" 절 4번과 정확히 대응함이
|
||||
확인됐다 — 새로 쓴 서술이 기존 확정과 어긋나지 않는지가 이 라운드의 핵심
|
||||
질문이었다.
|
||||
|
||||
유일한 지적은 문체였다 — §8에 "`Revision`만 **올리면**"이라는 방향 어감이
|
||||
남아 있던 것(§2가 이미 "방향은 계약이 아니다"로 커버하고 있어 모순은 아님).
|
||||
같이 정리했다.
|
||||
|
||||
**수렴 판정**: 3라운드에서 새 발견 0건이므로 `conventions.md`의 루프 종료
|
||||
조건을 만족한다. 총 소비는 서브에이전트 3패스(각도: `base/` 정합성 →
|
||||
인덱스 레이어+`luau-test` → `archive/`+`qa-request/`).
|
||||
|
||||
## 7. 커밋 전 `/code-review high` — 감사 3라운드가 못 본 축에서 9건
|
||||
|
||||
`conventions.md`가 명문화한 그대로였다 — **감사자와 code-review는 보는 축이
|
||||
다르다.** 감사자 3라운드가 수렴(0건)한 뒤 사용자가 `/code-review high`를
|
||||
돌리자 **9건이 더 나왔고 전부 유효**했다. 감사자는 코퍼스 전체의 **의미론적
|
||||
정합성**(A 문서 결정 ↔ B 문서 서술)을 보고, code-review는 **diff 자체의
|
||||
결함**(이번에 새로 쓴 서술 안의 모순, 새 표면이 기존 계약과 충돌하는가)을
|
||||
본다.
|
||||
|
||||
### 구현을 실제로 막았을 것들
|
||||
|
||||
1. **⭐ `{Epoch}` 표기가 실제 배치 모양과 달랐다.** Luau에서 `{Epoch}`는
|
||||
**배열**(`{[number]: Epoch}`)인데, 실제로 넘어오는 게이트 배치는
|
||||
`gate-plan.md` 4번이 확정한 `withheld : { [epoch] : true }` — **집합**이다.
|
||||
이 표기를 믿고 `ipairs`로 구현하면 배치 순회에서 **원소가 0개**가 되어
|
||||
유보됐다 풀린 emit이 전부 조용히 삼켜진다. `gate-plan.md` 4번이 애초에
|
||||
고치려던 바로 그 버그였다. → `type EpochSet = { [Epoch]: true }`로 확정.
|
||||
**집합이어야 하는 이유는 게이트 쪽 요구**다(흡수·unfold가 저절로 접혀야
|
||||
함). 이건 사용자 원 제안의 `Epoch|{Epoch}` 표기를 에이전트가 그대로
|
||||
옮기면서 **Luau 타입으로서 뭘 뜻하는지 확인 안 한** 결과다.
|
||||
2. **⭐ 새 노드 시딩이 확정된 `EpochMap` 표면으로 표현 불가능했다.**
|
||||
"상류의 `Epoch`를 전부 끌어와 채운다"인데 `:With(a, b)`의 상류는
|
||||
**State이지 `Epoch`가 아니다.** 상류가 추적 중인 루트 집합은 그 State의
|
||||
`valueEpochMap` 안에만 있는데 §3 표면엔 **키 열거도 병합도 없었다.**
|
||||
→ `:TrackFrom(other)` 신설(가칭 `Absorb`, 같은 날 개명 — §8 참고).
|
||||
`:Refresh`와 같은 성격으로 **이미 확정된 동작에 이름을 붙인 것**이지
|
||||
새 설계가 아니다(사용자 확정 문구가 이미 *"전부 가져와서, 실제 count 로
|
||||
둡니다"*였다). dep이 `Epoch`면 `:Sync`, State면 `:TrackFrom`으로 갈리는
|
||||
것도 같이 적었다.
|
||||
3. **`GateNode` 예외가 §4에 기록돼 있지 않았다.** §4 의사코드는
|
||||
`emitEpochMap:Update(from)`을 **수신 시점에 무조건** 부르는데,
|
||||
`gate-plan.md` 4번은 게이트가 **전파할 때** `:Sync(batch)`로 갱신하는
|
||||
것으로 확정돼 있다. §4대로 구현하면 그 계약이 조용히 깨진다.
|
||||
4. **설치 시 즉시 1회 발화에 `from`이 없다.** `fn(self, from)`을
|
||||
non-optional로 선언해뒀는데 등록 시점 발화엔 출처가 없다 —
|
||||
`Effect`의 확정 클로저가 `Update(nil)`을 부르게 된다. → 옵셔널로 바꾸고,
|
||||
`Effect`의 억제 플래그가 `Update`보다 **먼저** 와야 함을 명시(순서를
|
||||
뒤집으면 설치 발화가 맵을 건드려 그 파동의 첫 진짜 emit이 접힌다).
|
||||
|
||||
### 근거·표기 정확성
|
||||
|
||||
5. **`2^32` 랩이 "똑같이 도달 불가능"이라는 근거가 틀렸다.** 같은 척도(초당
|
||||
100만 `Set`)로 `2^53`은 285년인데 **`2^32`는 약 72분**이다(20만 배 차이).
|
||||
실제로 안전한 이유는 **도달 시간이 아니라 충돌 조건이 한 점**이라는 것
|
||||
("정확히 `2^32`만큼 뒤처진 항목"이라야 하고, 한 바퀴 중 한 번이라도
|
||||
건드려지면 갱신됨). 이 문서가 바로 위 항목에서 *"근거를 정확히 적을 것"*
|
||||
이라 스스로 규정해놓고 20줄 뒤에 어긴 셈이라 그대로 정정했다.
|
||||
6. `:Sync`를 "초기화에만 쓴다"고 적었으나 `gate-plan.md`가 flush 경로에서도
|
||||
쓴다 → "반환값이 필요 없다고 이미 아는 곳 둘"로 정정.
|
||||
7. `ROADMAP.md`의 `Brand.luau` predicate 목록에 `isEpoch` 누락.
|
||||
8. **역전된 옛 이름 잔재** — `TweenTag`(3곳)와 `Effect(fn, state?)`(4곳).
|
||||
후자는 code-review가 짚은 것보다 실제로 더 많았다
|
||||
(`architecture.md`/`lifecycle-hooks-plan.md`/`ROADMAP.md`/`README.md`).
|
||||
9. **절 재편에 안 따라온 `§` 참조 3곳** — 1라운드가 같은 유형을 잡았는데도
|
||||
남아 있었다. `doc-check.py`가 `§번호`를 검증 못 하는 사각지대다.
|
||||
|
||||
**교훈**: 1·2번은 둘 다 **"사용자 제안의 표기를 그대로 옮겼는데 그게 실제
|
||||
메커니즘과 안 맞는" 유형**이다. 승격은 문장을 옮기는 작업이 아니라 **표기가
|
||||
가리키는 것이 실제로 성립하는지 확인하는 작업**이라는 게 이번의 교훈.
|
||||
|
||||
## 8. `:Refresh`/`:TrackFrom` — 에이전트가 이름 붙인 둘, 사용자 검토로 확정
|
||||
|
||||
`/code-review`까지 처리한 뒤, **에이전트가 임의로 이름 붙인 연산 둘을
|
||||
사용자에게 명시적으로 올렸다.** 동작 자체는 회신에 이미 확정돼 있었지만 표면
|
||||
이름은 에이전트 판단이었기 때문 — `conventions.md`의 "애매하면 임의로 정하지
|
||||
말고 그 자리에서 사용자에게 보고할 것"에 해당한다.
|
||||
|
||||
**`:Refresh` — 그대로 확정.** 사용자가 `:Update`와 합치지 않는 이유를 직접
|
||||
정리했다: *"Update 에 인자 없는건 좀 아니야. 뭔가 받아서 받은것들에 대해서
|
||||
처리하겠다는건데, 리프레시는 아무래도 내가 받았던걸 처리하겠다는거라
|
||||
표면적 의미 자체가 다르지."* — **인자를 받아 그것을 처리하는 연산**과
|
||||
**자기가 이미 들고 있는 것을 처리하는 연산**은 표면적 의미가 달라서 오버로드로
|
||||
합치면 안 된다는 것. 표면이 하나 줄어드는 것보다 이 구분이 값이 크다.
|
||||
|
||||
**`:Absorb` → `:TrackFrom`으로 개명.** 사용자 지적: *"absorb 는 조금 상위
|
||||
요소꺼를 흡수해서 상위 요소에서 제거할것만 같은 이름이긴 하네."* 맞다 — 이
|
||||
연산은 `other`를 **전혀 안 건드린다.** 덧붙여 `base/gate-plan.md`가 이미
|
||||
"흡수 집합"을 **다른 뜻**(emit을 붙들고 있음)으로 쓰고 있어, 한 코퍼스 안에
|
||||
같은 단어가 두 의미로 놓이는 문제도 있었다.
|
||||
|
||||
후보를 넷 올렸고(`TrackFrom`/`SeedFrom`/`Include`/`Adopt`) 권장안이 채택됐다.
|
||||
`TrackFrom`을 고른 근거 셋:
|
||||
|
||||
1. **이 맵의 존재 이유를 사용자가 표현한 말이 "추적"이었다** —
|
||||
*"'내가 뭘 추적하고 있나' 가 필요하죠"*(§4의 시딩 규칙 근거). 코퍼스가 이
|
||||
맵을 설명하는 말과 메소드 이름이 일치한다.
|
||||
2. **`From`이 방향을 못박아 비파괴가 드러난다** — `Absorb`가 실패한 지점.
|
||||
3. **`SeedFrom`보다 오래 간다** — 동적 의존성으로 "생성 이후에 키를 더하는"
|
||||
자리가 생겨도 이름이 그대로 맞다. `Seed`는 그때 거짓말이 된다.
|
||||
|
||||
배제한 것도 근거가 있다 — `Extend`/`Inherit`은 `quad2-try`의 `Base:Extends`
|
||||
OOP 상속이 **확인된 죽은 접근**이라 그 어휘를 되살리면 오독을 부르고,
|
||||
`Extract`는 코퍼스에서 이미 "소유권을 통째로 넘긴다"는 뜻이다.
|
||||
|
||||
**두 이름 다 `question.md`에 안 올린다** — 같은 자리에서 확정됐으므로 열린
|
||||
항목이 아니다.
|
||||
234
.claude/session/2026-08-22-01-roadmap-milestone-review.md
Normal file
234
.claude/session/2026-08-22-01-roadmap-milestone-review.md
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
# 2026-08-22-01 — `ROADMAP.md` 전반 점검: 마일스톤 경계 재편과 stale 정리
|
||||
|
||||
사용자 요청: *"마일스톤 전체가 이제 손봐야할 부분이 많다고 생각해. 전반을
|
||||
확인해볼래?"* — 직전 세션(`2026-08-21-03`)이 `Epoch`/`EpochMap`/`Brand`를
|
||||
승격하며 커밋 메시지에 마일스톤 관련 항목 셋을 남긴 게 발단.
|
||||
|
||||
**이 세션은 커밋하지 않았다** — 사용자 지시(*"이거 다 하고 내가 직접
|
||||
코드리뷰 트리거해줄게 커밋 하지 말아줘"*). 작업 트리에 17개 파일이 그대로
|
||||
남아 있고, 사용자가 `/code-review`를 돌린 뒤 그 결과까지 반영해서 커밋하는
|
||||
게 다음 단계다.
|
||||
|
||||
## 1. 무엇을 했나
|
||||
|
||||
`ROADMAP.md` 전문(1074줄)을 읽고 `base/`의 현재 확정과 대조했다. 발견을
|
||||
셋으로 갈라 보고했다 — A(내용 모순), B(순서/소속), C(상태 표시 신뢰성).
|
||||
사용자가 B와 C-3에 대해 각각 "실제로 옮긴다", "설계 확정은 별도 표기로
|
||||
분리"를 선택했고 그대로 실행했다.
|
||||
|
||||
### A. 모순 — 그대로 읽으면 반대로 구현되는 것
|
||||
|
||||
- **M2 첫 체크박스가 자기 자신을 부정**했다. 불릿 머리는 하강 diff인데
|
||||
꼬리가 *"`Dispatch.process`는 diff를 하지 않음"*(2026-08-13 다섯 번째
|
||||
세션 모델, **같은 날 열네 번째 세션에 폐기**)으로 끝났다.
|
||||
- **`Dispatch.drive`의 순회 계약이 옛 버전**이었다. QA 4라운드 `F-4-1`로
|
||||
"두 패스 구현"이 폐기되고 단일 일반화 `for`로 정정됐는데 ROADMAP 두 곳이
|
||||
안 따라왔다. 더 나쁜 건 **`dispatch-core-plan.md` 자신이 모순**이었다는
|
||||
것 — `F-4-1` 정정(§390)이 "이식성 논거는 과했다"고 명시적으로 기각한
|
||||
근거를 34줄 뒤 `D-10` 문단(§440)이 여전히 현재형으로 주장했다.
|
||||
- **물리 조작 주입 op 이름이 두 벌** 돌았다. `slot-plan.md`/`architecture.md`는
|
||||
`native*`로 넘어갔는데 ROADMAP M6와 `dispatch-core-plan.md`는 가칭
|
||||
`mountInst`/`unmountInst`/`disposeInst`를 썼다. 뿌리는
|
||||
`dispatch-core-plan.md`가 *"이름은 아직 가칭이라 정식 등재하는 건 그
|
||||
확정 시점에 한다"*로 멈춰 있던 것 — 확정이 났는데 그 문단이 안 열렸다.
|
||||
- **"주입되는 엔진 op 3개"** 하드코딩. `architecture.md`의 `EngineOps.luau`
|
||||
줄을 단일 소스로 지정하고 개수를 지웠다.
|
||||
|
||||
### B. 순서/소속 — 각주로 덮어온 자리 (사용자 선택: "실제로 옮긴다")
|
||||
|
||||
M2가 실제로 필요로 하는 모듈 넷이 전부 뒤 마일스톤에 체크박스로 있었고,
|
||||
**옮기는 대신 각주가 붙어 있었다**:
|
||||
|
||||
| 모듈 | 있던 곳 | 옮긴 곳 |
|
||||
|---|---|---|
|
||||
| `GateNode` / `state:Gate` | **체크박스 자체가 없음**(각주 안에만) | M2 |
|
||||
| `EpochMap.luau` | M3 | M2 |
|
||||
| `Blocker.luau` | M3 | M2 |
|
||||
| `None` + `Dispatch/None.luau` | M7 | M2 |
|
||||
|
||||
`GateNode`가 특히 나빴다 — M2 체크박스를 훑는 구현자에게 **항목으로 보이지
|
||||
않았다**. 전례가 있다: `LifetimeHandle` 인터페이스도 같은 이유로 M8→M2
|
||||
실제 이동을 했었다(각주가 아니라).
|
||||
|
||||
### C. 상태 표시
|
||||
|
||||
- **M0이 전부 `[x]`인데 검증 스파이크 둘이 무효화**돼 있었고(`01`: 두 패스→
|
||||
단일 for, `05`: Epoch 리비전 채택), 재작성 작업이 어느 체크박스에도
|
||||
없었다. `### 재검증 대기` 절을 신설해 7건 + 파일 없는 실측 2건을 모았다.
|
||||
- **머리 blockquote가 `[2026-08-19 기준]`**에서 멈춰 있었다.
|
||||
- **`[x]`의 의미가 마일스톤마다 달랐다** — M0/M1은 "실제로 짜서 통과",
|
||||
M6/M11은 "설계 확정"(코드 0줄). `initValue` 항목은 아예 스코프 배제가
|
||||
`[x]`였다. 사용자 선택대로 M6/M11에 `### 확정된 것 — 코드 아님, 구현 전
|
||||
필독` 절을 만들어 10개 항목을 옮기고, 체크박스는 "짜야 할 코드"만 남겼다.
|
||||
|
||||
## 2. ⭐ 이동하다 드러난 것 — M2와 M3의 의존이 양방향이다
|
||||
|
||||
**이게 이 세션의 실질적 산출물이다.** `EpochMap`/`Gate`/`Blocker`를 M2로
|
||||
옮기려다 확인한 것:
|
||||
|
||||
- **M2 → M3 (본체 의존)**: `Dispatch.setLength`가 `len: number | State<number>`를,
|
||||
`setOffsetSource`가 `Source<number>`를 받고 `recompute`가 `offset:Set(abs)`/
|
||||
`ownerKey.Length:Set(sum)`을 부른다(`dispatch-core-plan.md:1279-1280`,
|
||||
`:1621`). 즉 **M2는 `Source.luau`/`State.luau` 없이는 구현이 안 된다.**
|
||||
M2로 옮긴 `GateNode`/`Blocker`도 State 위에 얹히므로 마찬가지.
|
||||
- **M3 → M2 (얕은 의존)**: `state:Observer`/`Effect`의 동적 경로 가드가
|
||||
`Dispatch.addHandler` + `Handler.luau` 계약을 쓴다 — M2 앞머리 두 항목이면
|
||||
충족.
|
||||
|
||||
선택지 셋((a) M2/M3 순서 교체 / (b) M2를 M2a·M2b로 분할 / (c) 유지)을
|
||||
`question.md` 2번으로 신설하고 **유일한 소스**로 지정했다. `ROADMAP.md` M2
|
||||
배너 / `todos.md` 00번 / `CLAUDE.md` / `project-context.md` /
|
||||
`HUMAN_TODO.md` 11번은 전부 가리키기만 한다.
|
||||
|
||||
**중요한 구분**: `todos.md`가 오래 유지해온 "M2 착수를 막는 **설계** 항목은
|
||||
없다"는 여전히 참이다 — 이건 설계가 아니라 **순서** 문제다. 그 구분을
|
||||
안 하면 두 서술이 모순으로 읽히므로 다섯 곳 전부에 명시했다.
|
||||
|
||||
## 3. 감사 루프 — 8라운드, 30건
|
||||
|
||||
`conventions.md`의 절차대로 `quad-doc-auditor`를 **한 턴에 하나씩**, 라운드마다
|
||||
각도를 바꿔가며 돌렸다. 발견 추이: **9 → 5 → 7 → 3 → 3 → 1 → 1 → 0**.
|
||||
|
||||
라운드별 각도: (1) diff 범위, (2) 인덱스 레이어 + `base/` 프리미티브,
|
||||
(3) `archive/`·`reference/`·`qa-request/`, (4) **내 수정 자체를 의심**,
|
||||
(5) 통독 시점 + `architecture.md`/`source-state-plan.md`/`HUMAN_TODO.md`,
|
||||
(6) 아직 안 본 `base/` 17개 + `session-summary.md`, (7) "이름만 고치고 의미는
|
||||
안 고친" 각도, (8) 마일스톤 소속 주장 전수.
|
||||
|
||||
### ⭐ 이 루프에서 배운 것 — 내가 고치면서 새 결함을 3건 만들었다
|
||||
|
||||
1. **하드코딩 "넷"** — 배너에 *"검증 스파이크 **넷**이 재작성 대기"*라고
|
||||
쓰면서 **같은 문장에서** "개수·현황의 소스는 `STATUS.md`, 여기서 세지
|
||||
않음"이라 선언했다. 실제로는 7개.
|
||||
2. **`debounce-throttle-plan.md`의 M3 누락** — "전부 M3/M8"에서 `Blocker`만
|
||||
M2로 옮기다 **State 코어(M3)가 통째로 빠졌다.**
|
||||
3. **⭐ `nativeInsert` 치환 시 순서 주장 미검토** — 3라운드에서
|
||||
`dispatch-core-plan.md`의 `mountInst`를 `nativeInsert`로 바꾸면서
|
||||
**이름만 갈고 그 이름이 든 문장이 무슨 주장을 하는지 안 읽었다.** 그
|
||||
문단은 역전된 옛 C-7(*"부기를 먼저 완결하고 그 다음 물리 마운트"*)을
|
||||
현재형으로 주장하고 있었고, 지금 단건 경로는 정반대다
|
||||
(`setOffsetSource` → `nativeInsert` → `setLength` → `recompute`,
|
||||
`slot-plan.md`의 `rawAdd` 의사코드가 소스). **역전 배너를 스스로 달아둔
|
||||
바로 그 절 안에** 옛 주장이 살아 있었다.
|
||||
|
||||
셋 다 `conventions.md` 핸드오버 체크리스트 2번(*"배너를 달았으면 그 배너가
|
||||
부정하는 문장을 같은 커밋에서 고쳤는지 확인"*)이 경고하는 패턴이다.
|
||||
**일괄 치환은 그 자체가 이 실패 모드를 만든다** — 치환한 줄이 속한 문단을
|
||||
안 읽으면, 옛 주장이 새 어휘를 입고 살아남는다. 앞으로 용어/이름 치환을
|
||||
할 땐 치환 지점마다 **문단 단위로** 읽을 것.
|
||||
|
||||
### 감사자가 잡은 것 중 성격이 두드러진 것
|
||||
|
||||
- **`ROADMAP.md` 맨 위 헤드라인 배너에만 M2 블로커 경고가 빠져 있었다** —
|
||||
`CLAUDE.md`/`project-context.md`/`todos.md`/`question.md` 넷은 다 넣어놓고,
|
||||
정작 그 넷이 "진행 상황의 소스"로 지목하는 문서의 첫 배너를 빠뜨렸다.
|
||||
ROADMAP만 읽는 사람이 M2를 바로 시작할 수 있는 상태였다.
|
||||
- **`architecture.md` 소스 트리에 `EpochMap.luau`가 아예 없었다** — ROADMAP
|
||||
M2가 "별도 모듈로 낼 것"이라 명시하는데, "다음 세션이 실제로 만들 구조"라
|
||||
자임하는 정본에서 빠져 있었다.
|
||||
- **`source-state-plan.md`가 `Blocker`를 "M3에서 함께 구현"이라 서술** —
|
||||
`blocker-plan.md`엔 정정 배너를 달았는데 정작 **그 사실을 처음 서술한
|
||||
본문**은 안 고쳤던, 체크리스트 2번 그대로의 사례.
|
||||
- **`HUMAN_TODO.md`만 인덱스 레이어 동기화에서 빠졌다** — *"`question.md`엔
|
||||
이제 '결정 대기' 절 자체가 없음"*이라 단정 중이었다.
|
||||
- **`두 패스`가 코퍼스 전반에 남아 있었다** — `ref-plan.md`가 "본체 루프"로
|
||||
고친 **같은 문장 안에서** 다시 "두 패스로 처리"라고 썼다. 구현 단정
|
||||
용법은 전부 "본체 루프"로 바꾸고, `ref-plan.md`/`lifecycle-hooks-plan.md`에
|
||||
용어 각주를 신설했다. **"두 패스보다 먼저"/"두 패스가 전부 끝난 뒤" 같은
|
||||
*시점* 표기는 의도적으로 남겼다** — `PostRef` 절 제목이기도 하고 인용
|
||||
대상이라, 바꾸면 절 참조가 깨진다.
|
||||
|
||||
## 4. `/code-review high` — 감사 0건 뒤에 9건 더
|
||||
|
||||
감사 루프가 0건으로 수렴한 **뒤에** 사용자가 `/code-review high`를 돌렸고
|
||||
**9건이 더 나왔다. 전부 유효했다.** `conventions.md`가 2026-08-18에 실측으로
|
||||
기록해둔 것("code-review는 감사자를 대체하지 않는다 — 둘이 보는 축이 다르다")이
|
||||
이번에도 그대로 재현됐다.
|
||||
|
||||
**⭐ 가장 나빴던 것 — 내가 새로 쓴 체크박스가 역전 전 표기를 구현 지시로
|
||||
복제했다.** M2의 `GateNode` 항목에 *"내보내는 emit은 `emit(self)` + 흡수
|
||||
집합"*이라 적었는데, 확정 계약은 정반대다 — `state-epoch-plan.md` §5는
|
||||
*"게이트 노드 자체는 페이로드에 안 싣는다 … 하류는 게이트 identity를 한 번도
|
||||
안 쓴다"*이고, 넘기는 건 스왑해 떼어낸 `EpochSet` 스냅샷뿐이다. `emit(self)`는
|
||||
`Epoch` 일반화 **이전** 표기인데 `gate-plan.md`의 배너와 4번 제목에 잔존해
|
||||
있었고, 내가 그걸 그대로 베껴 **M2를 짜는 사람이 게이트 노드를 출처로 싣게
|
||||
만드는 지시**로 승격시켰다. 뿌리인 `gate-plan.md` 두 곳까지 같이 고쳤다.
|
||||
|
||||
나머지 여덟 건의 성격:
|
||||
|
||||
- **`F-4-1` 정정이 자기 절 안의 다음 문장을 거짓으로 만들었다.** 구현을 단일
|
||||
일반화 `for`로 확정해놓고, 아래 문단은 여전히 *"base는 이 우연한 동작에
|
||||
기대지 않고 명시적으로 강제한다"*고 주장했다. **단일 `for`는 정확히 그
|
||||
언어 동작에 기대는 구현이다.** 따라서 재작성될 `01`이 검증할 것도 "우리
|
||||
드라이버가 계약대로 도는가"가 아니라 **언어 동작 그 자체**로 바뀐다. 그리고
|
||||
`nil`-hole 방어를 "계약이 보장하니 불필요"로 생략하면 안 된다는 따름정리도
|
||||
같이 적었다.
|
||||
- **`gate-plan.md`가 마일스톤 순환을 "해소"로 서술**했다. `Blocker`를 M2로
|
||||
옮겼다고 순환이 닫히지 않는다 — `state:Gate`는 State 메소드라 M2→M3 참조가
|
||||
그대로 남는다. `question.md` 2번이 인정한 바로 그 사실인데 gate-plan만
|
||||
읽으면 닫힌 것으로 읽혔다.
|
||||
- **"M2 앞머리 두 항목이면 충족"이 부정확**했다. `Dispatch/init.luau`에
|
||||
`Dispatch.drive`가 들어 있고 그 자신이 배치 등록을 Blocker로 게이팅한다
|
||||
(`dispatch-core-plan.md`의 "적용 지점" 확정). 선택지 (b)로 쪼갠다면
|
||||
`drive`를 어느 쪽에 두느냐가 경계선이 된다 — 사용자가 (a)/(c)를 고를 때
|
||||
결합도를 실제보다 얕게 볼 뻔했다.
|
||||
- **"M0 체크박스는 전부 닫혔다"가 같은 편집이 M0에 넣은 미체크 8개와 충돌.**
|
||||
새로 선언한 `[x]` 규약상 그것들은 실제 열린 작업이다.
|
||||
- **단일 소스로 지정한 자리가 정작 불완전**했다 — `architecture.md`의
|
||||
`EngineOps.luau` 줄에 `setTimeout`/`clearTimeout`이 없었고, 인접
|
||||
`RobloxFactory.luau` 줄의 주입 대상 목록도 `native*` 없이 멈춰 있었다.
|
||||
"개수 하드코딩을 지우고 소스를 하나로"라는 의도가 그 소스가 불완전해서
|
||||
성립하지 않았다. 소스 쪽을 실제로 채웠다.
|
||||
- **근거로 제시한 문서를 열면 확인이 안 되는 주장** — `None.luau`가
|
||||
소스 트리에 "같은 위치로 있다"고 적었으나 트리엔 `Dispatch/None.luau`만
|
||||
있고 탑레벨 센티널도 `Brand.luau`도 줄이 없었다.
|
||||
- **새로 세운 `[x]` 규약이 같은 문서에서 이미 깨져 있었다** — M3의
|
||||
`store.key` 타입 추론 확인이 코드가 아닌데 `[x]`였다. 배너가 위반 사례를
|
||||
"M6/M11"로 한정 열거해서 감사에서 빠졌다. 규약 문구를 일반화하고 그
|
||||
항목도 뺐다.
|
||||
- **재작성 지시가 없는 경로를 가리켰다** — `luau-test/done/01-...`인데 실제
|
||||
파일은 `rewrite-required/`에 있다. 하필 *"⚠️ 스파이크 `01` 재작성 필요"*
|
||||
지시문 자체가 그랬다.
|
||||
|
||||
**이 세션의 교훈을 한 줄로**: 감사 0건은 "코퍼스가 스스로와 정합한다"는
|
||||
뜻이지 "이번에 새로 쓴 문장이 옳다"는 뜻이 아니다. 특히 **다른 문서에서
|
||||
문장을 베껴 새 체크박스를 만들 때, 그 문장이 이미 역전된 표기인지를
|
||||
확인해야 한다** — 감사자는 "베껴온 곳과 원본이 같다"를 보고 정합으로
|
||||
판정한다(실제로 `emit(self)`가 gate-plan에도 남아 있었으므로 둘은 "일치"했다).
|
||||
|
||||
## 5. code-review 뒤 감사 2라운드 — 그리고 실측된 패턴 하나
|
||||
|
||||
사용자 요청으로 code-review 수정분에 감사를 두 라운드 더 돌렸다.
|
||||
|
||||
- **1라운드 3건, 전부 "직전 수정이 만든 것"** — `todos.md`가 `emit(self)`를
|
||||
"사용자 확정"으로 인용하면서 **내가 방금 그 표기를 부정하는 배너를 단
|
||||
문서**를 근거로 가리키던 것, "주입 op 전체 목록의 소스는 `architecture.md`"라고
|
||||
선언한 **6줄 아래에 그 목록이 그대로 있던** 것(게다가 `native*`도 시간
|
||||
op도 없는 불완전한 셋이라, 단일 소스를 세우려던 편집이 오히려 "목록이 두
|
||||
곳"을 고착시킬 뻔했다), `nil`-hole 문단 중복.
|
||||
- **2라운드 1건 — 이번엔 내 수정이 만든 게 아니라 앞 라운드들이 놓친 기존
|
||||
자리였다.** `module-lifecycle-plan.md`가 *"주입 대상 목록에 엔진 op 3개가
|
||||
추가됨"* + *"`addTag`/`removeTag`/`setAttribute`**만** 채우는 타입 계약"*으로
|
||||
같은 하드코딩을 들고 있었다. 스윕해보니 `debounce-throttle-plan.md`도 두
|
||||
곳에서 "엔진 op 3개"를 관례 비유로 쓰고 있었는데, **그 문서 자신이
|
||||
`setTimeout`/`clearTimeout`을 추가해 그 개수를 늘리는 주체**라 자기모순이었다.
|
||||
|
||||
### ⭐ 실측된 패턴 — 개수 하드코딩은 "한 곳을 고치면 끝"이 아니다
|
||||
|
||||
"주입 op 3개"라는 같은 사실이 **네 문서**(`ROADMAP.md`, `dispatch-core-plan.md`,
|
||||
`module-lifecycle-plan.md`, `debounce-throttle-plan.md`)에 흩어져 있었고,
|
||||
`conventions.md` 체크리스트 4번이 예고한 그대로 전부 갈라져 있었다. 처음
|
||||
둘만 고치고 "단일 소스로 정리했다"고 선언한 게 이 세션인데, **선언 자체가
|
||||
나머지 둘을 못 찾았다.** 개수를 지울 땐 그 개수의 **모든 사본**을 grep으로
|
||||
전수 찾아야 하고, "소스는 X다"라고 선언한 뒤엔 **X가 실제로 완전한지**와
|
||||
**다른 사본이 남았는지**를 같은 자리에서 확인해야 한다.
|
||||
|
||||
## 6. 남은 것
|
||||
|
||||
- **`question.md` 2번(M2/M3 마일스톤 경계) 사용자 회신** — M2 착수 전 필요.
|
||||
`HUMAN_TODO.md` 11번이 사용자용 진입점.
|
||||
- **사용자가 직접 `/code-review`를 돌린 뒤 그 결과 반영 + 커밋.**
|
||||
`conventions.md`가 이미 실측으로 기록해둔 대로(2026-08-18) code-review는
|
||||
감사자가 못 보는 축을 본다 — 이번 변경은 (a) 마일스톤 경계 이동,
|
||||
(b) 여러 문서에 걸친 용어/이름 일괄 치환, (c) 확정 문서의 순서 계약 정정이라
|
||||
정확히 그 축에 걸린다.
|
||||
206
.claude/todos.md
206
.claude/todos.md
|
|
@ -5,8 +5,151 @@
|
|||
(`.claude/question.md`, `luau-test/STATUS.md` 등).
|
||||
|
||||
|
||||
00. **⭐⭐ [2026-08-18 신설, 같은 날 완료] 구현 전 QA — 1·2·3라운드
|
||||
전부 `base/`에 반영 완료.** 1라운드는 사용자가 `base/` 확정 문서 전체를
|
||||
00. **⭐⭐ [2026-08-18 신설] 구현 전 QA — [2026-08-21] 1~5라운드 전부 `base/`
|
||||
반영 완료. ⭐ 같은 날 마지막에 `Gate`와 State 에포크까지 확정되면서
|
||||
**M2 착수를 막는 설계 항목은 더 이상 없다.**** `Gate`는
|
||||
`state:Gate(setup)` + `GateNode`로(`base/gate-plan.md`), State의
|
||||
재계산/전파 판정은 **`Epoch` 리비전 비교** 채택으로(`base/state-epoch-plan.md`
|
||||
— **[2026-08-22 정정]** 구현 마일스톤은 갈린다: `EpochMap`/`Epoch`
|
||||
인터페이스는 **M2**, State 본체 통합은 **M3**) 닫혔다. 두 문서 모두 `research/`가 아니라 **`base/`**에 있다.
|
||||
**[2026-08-21 후속] `Epoch`/`EpochMap`/`Brand` 승격도 같은 날 완료** —
|
||||
부기가 재사용 가능한 `EpochMap`으로 떨어져 나오고, 판정 인터페이스가
|
||||
`Source`에서 `Epoch`로 일반화되고, `Brand`가 인스턴스 브랜드로 재작성됐다
|
||||
(근거 기록은 `reference/epoch-brand-composition.md`, 옛 `Brand` 표면은
|
||||
`archive/brand-shared-registry-reversed.md`). 그 부수로 `effect-plan.md`의
|
||||
다중 의존성 중복 발화 미해결 항목도 닫혔다. **[같은 날] 마지막 미정이던
|
||||
리비전 증가 방식도 `bit32` 랩으로 확정** — `Epoch`/`EpochMap`/`Brand`에
|
||||
열린 설계 항목은 **하나도 없다**(`base/state-epoch-plan.md` §2).
|
||||
**[2026-08-21 경위]** 같은 날 `/code-review high`가 "게이트가 유보했다
|
||||
내보내는 emit이 어느 출처를 싣는지"가 안 정해진 걸 잡아 한때 M2 항목으로
|
||||
되돌아갔으나, **사용자가 그 자리에서 흡수 집합 스냅샷으로
|
||||
확정**했다(`setup` 시그니처는 안 바뀜 — `base/gate-plan.md` 4번.
|
||||
**[2026-08-22 표기 정정]** 여기 `emit(self)` + 흡수 집합이라 적혀
|
||||
있었으나 그건 `Epoch` 일반화 **이전** 표기다 — 게이트 노드 자신은
|
||||
페이로드에 안 싣는다). 같이
|
||||
제기됐던 에포크 쪽 세 자리도 **전량 확정**됐다(재계산 시 리비전 전부 갱신 /
|
||||
새 노드는 `emitEpochMap`은 비우고 `valueEpochMap`은 실제 리비전으로 채운 뒤
|
||||
`rawInvalid = true` / 그래서 `:With` 병합 규칙은 불필요) —
|
||||
`base/state-epoch-plan.md` §4. **[같은 날 두 번째 `/code-review high`]**
|
||||
7건이 더 나왔고 전부 유효했는데(재진입 시 빈 배치가 새어 변경이 증발하던
|
||||
것, `OffWithoutEmit`이 흡수 집합을 안 비우던 것 등), 그중 사용자 판단으로
|
||||
올라갔던 둘도 같은 날 닫혔다 — "재진입 계약"은 애초에 **잘못 옮긴
|
||||
서술**이었고, "빈 배치 emit"은 **아무것도 안 하는 것**으로 확정(그 따름정리로
|
||||
`Effect`의 설치 구간 억제가 `Gate` 소비자에서 빠지며 `effect-plan.md`의
|
||||
순서 제약도 사라짐). **다시, M2 착수를 막는 설계 항목은 없다.**
|
||||
|
||||
**⚠️ [2026-08-22 신설] 다만 설계가 아니라 *순서*가 막고 있다.**
|
||||
`ROADMAP.md` 전반 점검에서 **M2와 M3의 의존이 양방향**인 게 드러났다 —
|
||||
`Dispatch.setLength`/`setOffsetSource`/`recompute`가 `State<number>`/
|
||||
`Source<number>`를 쓰므로 M2는 `Source.luau`/`State.luau` 없이 구현이
|
||||
안 되고, 반대로 M3의 `Observer`/`Effect` 동적 경로 가드는 M2의
|
||||
`Dispatch.addHandler`를 쓴다. 선택지 셋(M2/M3 순서 교체 / M2를 둘로
|
||||
분할 / 지금 구조 유지)은 **`question.md` 2번이 소스** — 여기서 반복하지
|
||||
않는다. 같은 점검에서 `EpochMap`/`GateNode`/`Blocker`/`None`이 M3·M7에서
|
||||
**M2로 실제 이동**했고(각주로만 예고돼 있던 것), `Dispatch.drive`가
|
||||
두 패스가 아니라 단일 일반화 `for`라는 `F-4-1` 정정과 물리 조작 주입 op
|
||||
이름 `native*` 확정이 `ROADMAP.md`/`dispatch-core-plan.md`에 반영됐다.
|
||||
M0 체크박스가 검증했던 스파이크 중 재작성 대기인 것들도
|
||||
`ROADMAP.md`의 "재검증 대기" 절로 모았다(현황의 소스는 여전히
|
||||
`luau-test/STATUS.md`).
|
||||
그 외 남은 것은 판단이 아니라 구현 시 정할 것들 — `Gate`의
|
||||
생명주기·재진입 계약, 스파이크 `05` 재작성(`luau-test/STATUS.md`).
|
||||
**[2026-08-22 해소]** 여기 있던 "M2에 `Blocker`까지 넣을지"는 같은 날
|
||||
`ROADMAP.md` 전반 점검에서 **둘 다 M2**로 확정되며 닫혔다.
|
||||
|
||||
**⭐⭐ [2026-08-22 신설] 6라운드 — 손 트레이싱, 사용자 회신 대기.**
|
||||
사용자 요청으로 최근 확정 5개 영역(`Effect(fn, ...deps)` / `Gate`·`Blocker` /
|
||||
State 전파(`rawInvalid`·emit 지연) / Slot의 `native*`·offset·length·mount /
|
||||
`Brand`·`Epoch`·`EpochMap`)을 실제 값으로 돌려봤고, **의사코드 그대로
|
||||
구현하면 크래시하거나 조용히 어긋나는 것들이 나왔다.** 문항지가 아니라
|
||||
발견 보고이고 **`base/`는 아직 한 줄도 안 고쳤다** — 소스는
|
||||
`qa-request/pre-implementation-handtrace-round6.md`(발견 수·심각도·개별
|
||||
항목은 여기서 세지 않는다). **M2/M3 구현에 직접 걸리므로 다음 세션의 첫
|
||||
작업으로 볼 것.**
|
||||
|
||||
**⭐ [2026-08-23] 같은 파일에 2차 패스가 이어붙었다** — 사용자 요청으로
|
||||
1차가 안 본 영역(디스패치 코어 전체 / 라이프타임 유틸 / `Ref`·`Tag`·
|
||||
`Attribute`·UI 숏핸드 핸들러 / Slot의 `raw*` 계층)까지 넓혀 다시 돌린
|
||||
것. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 갈래 선택이 필요한 항목이
|
||||
1차보다 늘었고(어느 것인지는 그 문서의 두 "회신 방법" 절이 소스),
|
||||
그중 하나는 **`Effect`의 leaf 사망 cleanup을 발화시키는 배선이 어느
|
||||
의사코드에도 없다**는 것이라 `Effect`/`OnDestroyed`/`slot._detached`
|
||||
정리가 통째로 걸린다 — M3 `Effect` 구현 전에 반드시 닫을 것.
|
||||
|
||||
**⭐⭐ [2026-08-23] 같은 파일에 3차 패스도 이어붙었다 — 발견 번호는
|
||||
`H-21`~`H-38`.** 1·2차가 한 번도 안 연 문서 전체(Store/State/Source 코어,
|
||||
Modifier·컴포넌트 합성, 이벤트·라이프사이클·에러 격리, Tween·시간 게이트,
|
||||
타입 계약과 **실제 커밋된 M1 코드**)와 문서 경계를 가로지르는 통합
|
||||
시나리오를 돌렸다. 여기서도 **`base/`는 한 줄도 안 고쳤다.** 1·2차와
|
||||
달리 **추론으로 끝내지 않고 로컬 `luau`/`luau-analyze`로 직접 재현**했고,
|
||||
그 부수로 **기존 `H-2`의 크래시 주장이 틀렸다는 것도 드러났다**(결론은
|
||||
유효하지만 결과가 크래시가 아니라 조용한 영구 고아 — 그 문서 3차 패스
|
||||
머리의 정정 절이 소스). **M2 착수 전에 정해야 하는 것이 하나 늘었다** —
|
||||
`New(): Quad`가 닫힌 타입이라 M2가 붙일 `quad.Dispatch` 접근이 그대로는
|
||||
타입에러라는 것(`H-25`, `luau-analyze`로 재현). 개별 항목·심각도·갈래는
|
||||
여기서 세지 않는다 — 그 문서의 세 "회신 방법" 절이 소스.
|
||||
|
||||
**⭐⭐ [2026-08-24] 4차 패스도 이어붙었다 — 발견 번호는 `H-39`~`H-54`.**
|
||||
이번엔 문서 단위가 아니라 **축을 바꿔서** 훑었다(핸들러 레지스트리 전수 /
|
||||
`ref-plan.md`·`attribute-plan.md` 심층 / **`luau-test` 스파이크 실제 재실행** /
|
||||
프리미티브 조합 매트릭스 / `reference`·`archive`·로드맵 M3~M9 / **엔진·언어
|
||||
사실 주장 전수 검증**). 여기서도 **`base/`는 한 줄도 안 고쳤다.**
|
||||
**⭐ M2에 직접 걸리는 게 하나 나왔다** — 배열 자리를 차지하는 말단 핸들러
|
||||
4종(`TagHandler`/`AttributeGroupHandler`/`RefLeafHandler`/
|
||||
`ObserverEffectLeafHandler`)이 `setLength`/`setOffsetSource`를 **아예 등록하지
|
||||
않아** `Frame { Tag("x"), Child{} }` 같은 흔한 배치가 첫 마운트에
|
||||
`recompute`의 명시적 error로 죽는다(`H-39`, **세 축에서 독립 발견** 후
|
||||
전수 grep으로 재확인). 부수로 **`H-21`의 전제가 공식 문서로 확인**됐고,
|
||||
반대로 **`PreRef`의 존재 근거가 `Workspace.SignalBehavior`에 조건부**임이
|
||||
드러났다(`H-42` — 신규 템플릿 place는 이미 `Deferred`가 기본).
|
||||
**스파이크 쪽은 깨끗하다** — `done/` 16개 전원이 `STATUS.md` 주장과 실행
|
||||
결과가 일치했고 GC 스파이크도 반복 실행에서 안 흔들렸으며 설계 드리프트도
|
||||
0건이었다. 개별 항목·심각도·갈래는 여기서 세지 않는다 — 그 문서의 네
|
||||
"회신 방법" 절이 소스.
|
||||
|
||||
**4라운드 — [2026-08-21] 종결.** 문항지는
|
||||
`.claude/qa-request/pre-implementation-qa-round4.md`, 사용자 회신 원문은
|
||||
`-response.md`, 처리 결과 전량은 **`-followup.md`가 소스**(여기서 목록을
|
||||
세지 않음 — 마지막 H절이 최신). 4차 처리로 `F-3`이 전량 확인되며
|
||||
**`Detach` 보존 주체(`userdata` → `slot._detached`)**, **`KeyGone` 센티널**,
|
||||
**`Owned` 설치 플래그**, 그리고 **`attachSlot` 분해**
|
||||
(`materializeSlotTree` + `mountSlotTree`, 근거는
|
||||
`reference/slot-attach-decomposition.md`)가 전부 확정·반영됐다.
|
||||
**[2026-08-21 정정] 5라운드 문항지를 만들었다** — 4라운드 처리 때는 사용자
|
||||
지시("이후 stale 만 잡는것으로 끝낼 수 있어보임")로 안 만들기로 했으나, 같은
|
||||
날 사용자가 5라운드를 요청("4차에서 예로 넘어갔던건 스킵하고, 새로운
|
||||
부분들이나 다른 깊은 부분")해 `qa-request/pre-implementation-qa-round5.md`를
|
||||
신설했다(**회신 대기**). 범위는 셋 — (1) 4라운드에 문항이 아예 없던 영역
|
||||
(`base/project-setup-plan.md`/`base/quad-types-plan.md`, 그리고 문서가 아니라
|
||||
**실제 커밋된 M1 코드**), (2) 4라운드 회신 이후 새로 확정된 것
|
||||
(`Detach`/`_detached`/`KeyGone`/`Owned`/`attachSlot` 분해), (3) 큰 문서의 심화.
|
||||
문항 수와 분포는 그 문서 자신이 소스.
|
||||
**[2026-08-21 후속] 5라운드 회신 도착·처리 완료**(라운드 수는 여기서 안 셈) — 회신 원문은
|
||||
`-round5-response.md`, 처리 결과의 소스는 **`-round5-followup.md`**.
|
||||
즉시 반영된 것은 `slot._detached` lazy화, `KeyGone`에 새 값 반환도 error,
|
||||
**`Owned = false`에서 `Detach`는 `_detached`에 안 들어감**, 조상 파괴 시
|
||||
unowned 요소도 같이 죽는다는 계약 신설, `groupClaimKeys` 키 확정
|
||||
(`(inst, groupValue) → k`), `Tween<T>:Mapped` 이름 확정, 4라운드가 반영을
|
||||
빠뜨렸던 `E-10` dedup 대칭 결론 실반영, 그리고 **"게이팅 먼저"(M2로 앞당김)**.
|
||||
**아직 회신 대기인 것은 그 followup의 C절**(`rawAdd` 의사코드 승인,
|
||||
`rawAdd`의 `Length:Set` 제거, `updateFn`이 State를 반환할 때의 래핑/`prev`,
|
||||
`setLength` 앵커를 물리 target으로 되돌리기, `Gate` 이름·표면, `Effect`
|
||||
다중 의존성) — **[2026-08-21 전량 처리 완료]**, 소스는 그 파일의 **마지막
|
||||
절**(A~L). 대화 마지막 라운드에서 **`native*` 물리 조작 계층**이 확정되며
|
||||
4라운드 `C-7`("부기가 물리보다 먼저")이 역전됐다. `Gate` 설계는 한때
|
||||
다음 세션으로 미뤄졌으나(*"고칠것이 많으므로 Gate 는 다음 세션에
|
||||
다루겠음"*) **[2026-08-21 같은 날 확정]** 사용자가 이어서 표면을 정해
|
||||
`base/gate-plan.md`가 `research/`를 떠났다 — 위 00번 머리말이 소스.
|
||||
아래는 4라운드 회신 전 서술:
|
||||
|
||||
**(원 서술) 4라운드 문항지 작성 경위.** 사용자 요청("모든 확정 부분에 있어서 예가 되어야하는 질문들을
|
||||
계속 … 표면적 타입계약부터, 실제 내부 구현 계획과 동작 원리 등")으로
|
||||
`base/` 전 문서를 한 맥락에서 읽으며 확정 주장을 전수 문항화한 것.
|
||||
**아직 아무것도 정정하지 않았음** — 사용자가 "아니오"인 항목을 회신하면
|
||||
그때 `base/`에 반영하고 그 문서를 1라운드처럼 근거 기록으로 재편한다.
|
||||
문항 수/분포/읽는 순서는 그 문서 자신이 소스(여기서 세지 않음).
|
||||
|
||||
**1~3라운드(완료)**: 1라운드는 사용자가 `base/` 확정 문서 전체를
|
||||
문항으로 재심사한 결과(원본 문답과 사용자 답변 원문은
|
||||
`.claude/qa-request/pre-implementation-qa-round1.md`가 소스), 확정으로
|
||||
적혀 있는데 실제로는 틀린 항목이 여러 건 나왔고 **같은 날 전부 정정
|
||||
|
|
@ -38,44 +181,21 @@
|
|||
재편 여부는 열림 — `pre-implementation-qa-round3.md`의 "ROADMAP.md
|
||||
마일스톤 정합성" 절 참고).
|
||||
|
||||
**아래는 M3 착수 전에 결론이 필요한 항목 목록**(M0/M2는 여전히 막혀
|
||||
있지 않음, 0번 항목 참고 — **단, M2가 M3의 `Blocker.luau`를 선당겨야
|
||||
하는지는 별개로 열려 있음, 바로 아래 첫 항목**) — 대부분 `question.md`
|
||||
3번에도 올라가
|
||||
있고(**[정정, 2026-08-18 `/code-review high`] 사용자 판단이 필요한
|
||||
항목만 그렇다 — 아래 "dedup 경로" 대칭 확인은 판단이 아니라 구현 시
|
||||
검증 작업이라 `question.md`엔 없음, 여기 목록이 소스**), 각 `base/`
|
||||
문서에도 ⚠️로 표시돼 있다:
|
||||
- **M2가 M3의 `Blocker.luau`에 의존하게 된 순서 문제**(`ROADMAP.md`
|
||||
M2 체크박스 각주) — 지금은 각주만 달아둔 임시 조치, `Blocker.luau`
|
||||
(또는 최소 표면)를 M2로 앞당길지 로드맵 순서를 유지할지 **M2 착수
|
||||
전 필요**. `qa-request/pre-implementation-qa-round3.md`의
|
||||
"ROADMAP.md 마일스톤 정합성" 절.
|
||||
**아래는 M3 착수 전에 결론이 필요한 항목 목록** — **설계** 게이트
|
||||
얘기다. M0은 아예 막혀 있지 않고, M2도 설계로는 안 막혀 있으나
|
||||
**[2026-08-22] 마일스톤 순서 문제 하나가 M2를 막는다**(위 00번의
|
||||
⚠️ 문단 + `question.md` 2번). 둘 다 `question.md` 3번에도 있고 각 `base/` 문서에도
|
||||
⚠️로 표시돼 있다. **[2026-08-21 정리]** 여기 쌓여 있던 `[해소]` 항목들
|
||||
(`Blocker.luau` 마일스톤 순서, 그룹 `Attribute` 위치 claim 키,
|
||||
`SetAndDispose`, `PopOnly`→`Detach`, `KeyGone` 처분, `Store` 미선언 키
|
||||
타입 에러, dedup 경로 대칭)은 **전부 `archive/question-resolved.md`와
|
||||
각 `base/` 문서로 옮겼다** — 목록이 절반 넘게 해소 항목으로 차 있어
|
||||
"지금 할 일"로 읽히지 않던 것을 걷어낸 것.
|
||||
- **중간 State GC 미검증**(`base/source-state-plan.md`) — 상류 strong /
|
||||
하류 weak 불변식을 명문화할지 + `luau-test` 실측. **M3 착수 전 필요.**
|
||||
- **그룹 `Attribute`의 위치별 claim 설계**(`base/attribute-plan.md`) —
|
||||
방향은 확정, 키 설계가 미정. M10 착수 전 필요.
|
||||
- **`SetAndDispose` 방향**(`base/slot-plan.md`) — `state:Apply`
|
||||
시그니처에 영향이 갈 수 있어 M3 착수 전 방향만이라도.
|
||||
- **dedup 경로의 process/retract 대칭 확인**(`base/effect-plan.md`
|
||||
`:Unsubscribe()` 절) — M3 착수 전 확인.
|
||||
- **[2026-08-19 해소]** `PopOnly` 이름 — **`Detach`로 확정**(공개 표면
|
||||
위치도 `None`과 같은 최상위 export로 같이 확정). 원문은
|
||||
`archive/question-resolved.md`.
|
||||
- **`Detach`(구 `PopOnly`) 홀드 중 키가 사라졌을 때의 처분**
|
||||
(`base/slot-plan.md`) — 지금 의사코드대로면 파괴도 반환도 안 되고
|
||||
참조만 끊김. **[정정, 2026-08-18 `/code-review high` — `ROADMAP.md`의
|
||||
M6 `Detach` 체크박스와 대조해 발견] M6(`:List`가 있는 마일스톤) 착수
|
||||
전 필요** — M8(`Ref`) 아님, 이전엔 마일스톤을 잘못 적어 M6를 그냥
|
||||
지나칠 위험이 있었음.
|
||||
- **`store:GetDynamic`을 콜론 메소드로 둘지 탑레벨 함수로 둘지**
|
||||
(`base/store-plan.md`) — 콜론이면 `GetDynamic`이 모든 Store의 예약 키가
|
||||
됨(lazy `__index`와 충돌). M3/M4 착수 전 필요.
|
||||
- **[2026-08-19 해소]** `Store` 미선언 키가 실제로 타입 에러가
|
||||
나는지 — **예, 확인됨**(`luau-test/done/21-type-store-undeclared-key-rejected.luau`,
|
||||
`ProcessStoreType`이 합성한 레코드 타입은 인덱서가 없어 미선언 키
|
||||
접근이 정확히 `TypeError`로 거부됨). `base/store-plan.md`의 "Store =
|
||||
Source들의 이름 붙은 모음" 절의 "확인 요구" 표시도 해소로 갱신 필요.
|
||||
|
||||
0. **⭐ M0 착수를 막는 결정은 이제 없음 (2026-08-14 열한 번째 세션 기준).**
|
||||
`question.md`의 최우선 항목이 **전부 비었음** — `0-Y`(`:Compute` lazy
|
||||
|
|
@ -147,12 +267,11 @@
|
|||
stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는
|
||||
2026-08-12 스무 번째 세션에 현재 이름 그대로 유지로 이미 확정됐음(이
|
||||
목록이 "위험도 높음, 1순위 open"으로 stale하게 남아있던 걸 발견해 수정)
|
||||
— 아직 진짜로 열려있는 것만 짚으면(**[2026-08-18] `DI`→`D`는 확정·반영
|
||||
완료로 목록에서 빠짐**, **[2026-08-19] `PopOnly`→`Detach`도 확정·반영
|
||||
완료로 목록에서 빠짐**): `Slot`(2순위),
|
||||
`canExecute`(3순위 — `isAlive`는 검토 후 기각, `can` 계열 접두 유지
|
||||
방향으로 기울었으나 구체 대안 미정), `Brand`(3순위), `Tag`/`Added`/
|
||||
`Removed`/`Merged`(3순위), `Attribute`/`AttributeKey`(3순위).
|
||||
— **[2026-08-21] 여기 있던 이름 나열은 지웠다.** 바로 위 문장이 이미
|
||||
"`question.md` 1번이 최신 소스"라고 선언해놓고 다음 줄에서 목록을 다시
|
||||
나열하고 있었고, 예고대로 실제로 갈라졌다(2026-08-21에 추가된 `Owned`와
|
||||
그 전부터 있던 `hintValue`가 둘 다 빠져 있었음 — 감사가 발견).
|
||||
**열린 항목이 뭔지는 `question.md` 1번을 열어볼 것.**
|
||||
3. **[2026-08-14 세션에 해소]** 오래 열려 있던 "이미 생성된 인스턴스
|
||||
재바인드"는 **기각**되어 `archive/existing-instance-bind-rejected.md`로
|
||||
이전됨 — 더 이상 상의할 스코프 항목이 아님.
|
||||
|
|
@ -187,8 +306,9 @@
|
|||
기능 갭**에서 시작됨(그 문서 13절). 다만 제어 핸들 설계까지 닫히고 나니
|
||||
실제로 quad-base에 새 코어 메커니즘을 추가하지 않는 **순수 슈가**로
|
||||
확인돼(같은 절), 위 항목들과 우선순위는 다시 같아짐 — M0/M3를 막지
|
||||
않고, **M3에서 `Blocker`를 구현할 때 게이티드 노드를 공용 `Gate`로
|
||||
빼두는 것만은 그 시점에 해야 함**(따로 하면 같은 설계를 두 번 함).
|
||||
않고, **그 게이티드 노드는 [2026-08-21] `state:Gate`로 확정돼 M2에서
|
||||
만들어진다**(`base/gate-plan.md`) — `Debounce`/`Throttle`은 그 위의
|
||||
정책으로 얹으면 되고, 같은 설계를 두 번 할 일은 없어졌다.
|
||||
주입 op 2개(`setTimeout`/`clearTimeout`)가 백엔드 팩토리 표면에
|
||||
추가될 예정이라는 것도 M1 설계 시 인지. 남은 열린 질문 없음(구
|
||||
`question.md` 3번, 전량 해소로 항목 자체가 빠짐).
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
# CLAUDE.md
|
||||
|
||||
Roblox 엔진용 DOMless UI 렌더러 **quad**를 처음부터 다시 짜는 프로젝트.
|
||||
**[2026-08-19 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, M2(디스패치
|
||||
엔진)부터 착수 예정** — 같은 상태를 `.claude/project-context.md`도
|
||||
**[2026-08-22 기준] M0(스파이크 검증)/M1(스캐폴딩)까지 완료, 다음은
|
||||
M2(디스패치 엔진)** — 다만 **⚠️ M2 착수를 막는 항목이 하나 있음**:
|
||||
M2와 M3의 의존이 양방향이라(M2의 Length/Offset 배관이 `State`/`Source`를
|
||||
씀) 지금 순서로는 M2를 끝까지 짤 수 없다. 설계 결정이 아니라 **마일스톤
|
||||
순서** 문제이고, 선택지와 근거의 소스는 `.claude/question.md` 2번(사용자
|
||||
회신 대기). 같은 상태를 `.claude/project-context.md`도
|
||||
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
|
||||
항상 루트 `ROADMAP.md`.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@
|
|||
올렸었음**(0-Z) — **[2026-08-13 열네 번째 세션] 그 0-Z도 해소되어 지금은
|
||||
사람이 결정해야 M0가 열리는 항목이 없음**(0-Y는 열세 번째 세션에 해소).
|
||||
|
||||
## ⭐ 11. **[2026-08-22 신설] M2 착수를 막는 마일스톤 순서 결정** — 사용자 답변 필요
|
||||
|
||||
**설계 결정이 아니라 순서 문제**라서 에이전트가 임의로 정하면 안 되는 자리다.
|
||||
`ROADMAP.md` 전반 점검에서 **M2(디스패치 엔진)와 M3(Store/State/Source)의
|
||||
의존이 양방향**인 게 드러났다 — M2의 Length/Offset 배관이 `State<number>`/
|
||||
`Source<number>`를 쓰므로 **M2는 State/Source 없이 구현이 안 되고**, 반대로
|
||||
M3의 동적 경로 가드는 M2의 `Dispatch.addHandler`를 쓴다(그쪽은 얕음).
|
||||
|
||||
**선택지 (a) M2/M3 순서 교체 / (b) M2를 둘로 분할 / (c) 지금 구조 유지**의
|
||||
근거와 상세는 **`.claude/question.md` 2번이 소스** — 여기서 반복하지 않는다.
|
||||
답이 나오면 `ROADMAP.md`의 마일스톤 경계와 `question.md` 2번을 같이 닫으면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 1. Roblox Studio에 MCP로 연결 (테스트 자동화용)
|
||||
|
||||
Roblox가 2026-02부터 Studio에 **MCP 서버를 내장**했음 — 예전처럼 Rust로 직접
|
||||
|
|
@ -172,11 +186,37 @@ Debounce/Throttle 작업에 쓴 워크트리는 **사용자 확인 후 정리
|
|||
설계/구현 상세는 `.claude/base/quad-types-plan.md`의
|
||||
"`type-version-check`" 절.
|
||||
|
||||
## 10. **[2026-08-20 신설, 안 막음]** Tween 초기 진입 애니메이션(`initValue`) — 에이전트 작업 범위 밖
|
||||
|
||||
`base/tween-plan.md`가 **"필요해지면 사용자가 직접 코드베이스+문서를 만진다,
|
||||
에이전트는 임의로 착수하지 말 것"**으로 확정해둔 항목인데, **여기 HUMAN_TODO에는
|
||||
그 언급이 없어서 사람 쪽 할 일 목록에서 빠져 있었다**(2026-08-20 구현 전 QA
|
||||
4라운드 `TW-16`에서 사용자가 지적 — *"틀리진 않았는데, Human todo 에 언급이
|
||||
없음"*). 지금 보강.
|
||||
|
||||
- **무엇인가**: 다이얼로그가 아래에서 위로 슬라이드-인하는 것처럼 **첫 마운트에도
|
||||
애니메이션을 원하는 경우**. 지금은 "첫 세팅은 무조건 애니메이션 없이 즉시
|
||||
스냅"(3-상태 릴레이션 슬롯의 `prev == nil` 분기)이 기본이라 이게 안 된다 —
|
||||
그 기본값은 "엔진 기본값에서 목표값으로 날아오는 진입 애니메이션 버그"를
|
||||
막으려고 일부러 넣은 것이라, 우회하려면 그 억제 동작과의 상충을 같이 설계해야
|
||||
한다.
|
||||
- **왜 에이전트가 안 하는가**(그 문서의 근거 그대로): Tween 정보가 부족한
|
||||
에이전트가 다루기엔 `hasBeenSet` 억제 동작과의 상충 판단이 미묘하고, 반대로
|
||||
Tween 자체가 다른 base 요소와 깊게 안 얽혀 있어(거의 전부
|
||||
`Handlers/Property.luau` 한 파일 + 릴레이션 슬롯) 사용자가 직접 처리하는 데
|
||||
범위상 문제가 없다.
|
||||
- **지금 상태**: 미확정("필요성 낮은 쪽으로 기움", 완전 폐기는 아님). **M0/M2를
|
||||
막지 않으므로 급하지 않고**, 실제로 진입 애니메이션이 필요해지는 시점에
|
||||
사용자가 착수하면 된다. 설계 맥락은 `base/tween-plan.md`의 "초기 진입
|
||||
애니메이션(`initValue`)" 절.
|
||||
|
||||
## 3. `.claude/question.md`의 **나머지** 항목 검토 (급하지 않음)
|
||||
|
||||
디자인 결정 중 Lua/Roblox 엔진에 대한 깊은 경험이 필요한 것들은 합리적 기본값으로
|
||||
진행하면서 `.claude/question.md`에 모아두는 중. 깨어있을 때 훑어보고 기본값이
|
||||
마음에 안 드는 것만 답해주면 됨 — **[2026-08-14 열한 번째 세션 기준]
|
||||
마음에 안 드는 것만 답해주면 됨 — **[2026-08-22 갱신] 설계 결정 대기는
|
||||
여전히 0건**이지만, **마일스톤 순서 결정 하나가 새로 열렸다**(`question.md`
|
||||
2번, 위 11번 항목이 소스). 아래는 그 전 서술: **[2026-08-14 열한 번째 세션 기준]
|
||||
`question.md`엔 이제 "결정 대기" 절 자체가 없음**(비어서 헤딩째로 삭제 —
|
||||
마지막 남았던 0-W
|
||||
`Ref` 이중 배치도 이 세션에 해소 — `base/ref-plan.md` "이중 배치 방지"
|
||||
|
|
|
|||
635
ROADMAP.md
635
ROADMAP.md
|
|
@ -7,10 +7,35 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
|
|||
**2026-08-04 세션에 준비만 해둔 상태로 신설, 이후 여러 세션에 걸쳐 설계가
|
||||
확정될 때마다 각 마일스톤 체크박스가 계속 갱신돼왔음.**
|
||||
|
||||
> **✅ [2026-08-19 기준] M0 스파이크 4개 전부 통과, M1 스캐폴딩도 대부분
|
||||
> 완료(quad-base/quad-roblox 폴더+pesde.toml, 루트 default.project.json/
|
||||
> .luaurc, mock 테스트 하네스, `New()`/`RunInit`/`AddPlugin` 골격 — 아래
|
||||
> M0/M1 체크박스 참고). 다음은 M2(디스패치 엔진) 착수.** M1 착수 도중
|
||||
> **✅ [2026-08-22 기준] M0/M1의 *원래* 체크박스는 전부 닫혔고 다음은
|
||||
> M2(디스패치 엔진)** — 단 M0에는 **[2026-08-22] "재검증 대기" 미체크
|
||||
> 항목들이 새로 붙었습니다**(설계가 바뀌어 무효화된 스파이크들, 아래 그
|
||||
> 절이 소스). 그건 아직 열린 작업이므로 "M0 완료"로 읽고 넘어가면 안 됩니다. — **⚠️ 다만 M2를 바로 착수하면 안 됩니다: M2와 M3의 의존이
|
||||
> 양방향이라 이 순서로는 M2를 끝까지 짤 수 없습니다**(설계가 아니라
|
||||
> 마일스톤 **순서** 문제 — 아래 M2 배너와 `.claude/question.md` 2번,
|
||||
> 사용자 회신 대기). M1까지의 산출물은
|
||||
> quad-base/quad-roblox 폴더+pesde.toml, 루트
|
||||
> default.project.json/.luaurc, mock 테스트 하네스, `New()`/`RunInit`/
|
||||
> `AddPlugin` 골격. **다만 M0의 검증 스파이크 여러 개가 설계 변경으로
|
||||
> 재작성 대기 상태입니다** — 아래 "재검증 대기" 절 참고(개수·현황의
|
||||
> 소스는 `.claude/luau-test/STATUS.md`, 여기서도 그 절에서도 세지 않음).
|
||||
>
|
||||
> **[2026-08-22] 이 문서에 이번에 반영된 것**: `Dispatch.drive`가 두
|
||||
> 패스가 아니라 단일 일반화 `for`(`F-4-1`) / 물리 조작 주입 op 이름이
|
||||
> `native*`로 확정 / `EpochMap`·`GateNode`·`Blocker`·`None`이 M2로 이동 /
|
||||
> M2↔M3 의존이 양방향이라는 것(M2 헤더 배너) / `[x]` 표기 의미 분리
|
||||
> (바로 아래).
|
||||
>
|
||||
> **⚠️ `[x]`의 의미** — 체크박스는 **"짜야 할 코드"만** 담습니다.
|
||||
> "설계가 확정됐다"/"타입을 실측해봤다"는 사실은 체크박스가 아니라 각
|
||||
> 마일스톤의 **`### 확정된 것`** 절(항목이 여럿일 때) 또는
|
||||
> **`- **[확정된 것 — 코드 아님]**`** 불릿(하나뿐일 때)에 둡니다. 예전엔
|
||||
> 둘이 같은 목록에 섞여 있어 `[x]`만 보고는 코드가 있는지 알 수
|
||||
> 없었습니다 — M0/M1의 `[x]`는 실제 구현이고, M3/M6/M11에 섞여 있던
|
||||
> `[x]`는 설계 확정·실측이었습니다. **새 항목을 추가할 때도 이 구분을
|
||||
> 지킬 것.**
|
||||
|
||||
> M1 착수 도중
|
||||
> wally→pesde 전환이 확정돼(`base/project-setup-plan.md`) M1 체크박스의
|
||||
> `wally.toml` 표기도 `pesde.toml`로 정정. 부수로 M3가 의존하는
|
||||
> `quad-types`/`type-version-check` 두 워크스페이스 멤버도 이 과정에서
|
||||
|
|
@ -40,13 +65,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [x] Store/State push-invalidate → pull-recompute propagation을 실제로
|
||||
짜보기(다이아몬드 의존성 케이스 포함 — **[2026-08-14 정정]** 확인할
|
||||
것은 "이미 invalid면 전파 중단되는지"가 **아니라** 그 반대:
|
||||
**emit은 자기 invalid 상태와 무관하게 항상 전파되고**, 중복 재계산은
|
||||
`:Get()` 시점 캐시로만 막히는지. 특히 `:Get()`을 안 부르는
|
||||
**emit은 자기 invalid 상태와 무관하게 전파되고**, 중복 재계산은
|
||||
`:Get()` 시점 캐시로만 막히는지(**[2026-08-21]** 그 뒤 "항상"에서
|
||||
"같은 `Epoch`의 같은 리비전이 두 번째로 도착했을 때만 접힘"으로 좁혀졌다 — 아래 참고). 특히 `:Get()`을 안 부르는
|
||||
`Observer`가 매 변경마다 계속 울리는지 — 옛 모델에선 두 번째부터
|
||||
침묵했음(`archive/invalidate-dedup-propagation-reversed.md`).
|
||||
스파이크 `05-store-state-diamond-propagation.luau`는 **[2026-08-19
|
||||
재작성 완료, `done/`]** 현행 모델("emit은 항상 전파 + `:Get()`
|
||||
시점 캐시로만 dedup")로 재검증 통과)
|
||||
재작성 완료]** 그 모델("emit은 항상 전파 + `:Get()` 시점 캐시로만
|
||||
dedup")로 재검증 통과 — **[2026-08-21] 그 모델이 다시 바뀌어
|
||||
`rewrite-required/`로 되돌아갔다**(`Epoch` 리비전 비교 채택으로 다이아몬드
|
||||
Observer가 이제 변경당 **1회**만 울어야 함, `base/state-epoch-plan.md`))
|
||||
- [x] Source가 State를 구조적으로 만족하는 제네릭 타입(`:Compute<U>(self:
|
||||
Source<T>, ...) -> State<U>`류, self 타이핑 + State 참조 혼합)이
|
||||
Luau 솔버에서 안전하게 추론되는지 확인(2026-08-06 세 번째 세션,
|
||||
|
|
@ -59,9 +87,13 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [x] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로
|
||||
짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함 —
|
||||
`luau-test/done/03-recursive-store-bind-dispatch.luau` 통과)
|
||||
- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제
|
||||
- [x] props 순회의 "배열 파트 먼저, 해시 파트 나중" **계약**이 실제
|
||||
Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass +
|
||||
일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증
|
||||
(**[2026-08-21 정정]** 이 계약을 **두 패스로 구현**한다는 서술은
|
||||
`F-4-1`로 폐기 — 구현은 단일 일반화 `for`, 계약은 그대로. 그래서
|
||||
두 루프로 짜인 스파이크 `01`이 `rewrite-required/`로 갔다 — 아래
|
||||
"재검증 대기" 참고)
|
||||
(2026-08-07 세 번째 세션, `base/ref-plan.md` "`phase` 옵션
|
||||
폐기 → 위치로 표현, `PreRef` 신설" 절) — **PreRef pre-pass의 소진은
|
||||
`nil`이 아니라 실재하는 센티널로(2026-08-07 열 번째 세션 정정, 사용자가
|
||||
|
|
@ -70,7 +102,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
"구멍 있는 테이블 순회" 자체를 검증할 필요는 없어짐(같은 절 "왜
|
||||
`nil`이 아니라 `None`인가" 참고). **[정정, 2026-08-14 두 번째 세션]
|
||||
소진 값은 이제 `None`이 아니라 전용 센티널 `ProcessedPreRef`** —
|
||||
정상 두 패스가 그 자리를 `ProcessedPreRefHandler`로 매치해
|
||||
정상 본체 루프가 그 자리를 `ProcessedPreRefHandler`로 매치해
|
||||
`Dispatch.setLength(0)`/`setOffsetSource(None)`을 등록하도록 재설계됨
|
||||
(`base/ref-plan.md` "PreRef" 절, `base/dispatch-core-plan.md`
|
||||
"Length/Offset" 절) — 아래 `PreRef` pre-pass/동적 경로 가드
|
||||
|
|
@ -94,6 +126,42 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
진행 — **[2026-08-19] 전부 통과, M1 진행 중**(개수는 `luau-test/STATUS.md`가
|
||||
소스, 여기서 세지 않음).
|
||||
|
||||
### 재검증 대기 — 위 `[x]`가 검증했던 스파이크 일부가 무효화됨
|
||||
|
||||
**⚠️ [2026-08-22 신설]** M0의 *원래* 체크박스는 전부 닫혔지만, 그 뒤 설계가 바뀌면서
|
||||
**당시 통과했던 스파이크 몇 개가 지금 계약을 검증하지 않는 상태**가 됐다.
|
||||
`rewrite-required/`로 되돌아간 것들이고, **어느 마일스톤 체크박스에도
|
||||
없어서 그냥 잊히기 쉬운 자리**라 여기 모은다. **무엇이 지금 어느 폴더에
|
||||
있는지의 소스는 항상 `.claude/luau-test/STATUS.md`** — 여기서 세지 않는다.
|
||||
|
||||
- [ ] **`01`(props 순회 순서)** — 두 루프로 짜여 있어 지금 계약의 구현
|
||||
(단일 일반화 `for`, `F-4-1`)과 안 맞음. 재작성하면서 "배열 파트 전체가
|
||||
해시보다 먼저 + 배열 안에서는 index 순서"를 그대로 확인할 것
|
||||
- [ ] **`05`(다이아몬드 전파)** — `Epoch` 리비전 비교 채택으로 다이아몬드
|
||||
Observer가 이제 변경당 **1회**만 울어야 함(옛 "emit은 항상 전파" 모델을
|
||||
검증 중). `base/state-epoch-plan.md` 기준으로 재작성
|
||||
- [ ] **`04`(Dispatch 체인 retractFrom)** — 하강 diff 확정으로 무효화.
|
||||
**M2 착수 시 같이 처리**하는 게 자연스러움
|
||||
- [ ] **`19`(소유권/참조카운트 Relate 패턴)** — **B 섹션만** 낡음
|
||||
(공개 `AttributeKey(name)` + 인덱스 1 점유 체크가 폐기되고 그룹 전용
|
||||
키 + `AttributeKeyHandler`의 이름 claim으로 바뀜, `0-Z` 확정).
|
||||
A/C 섹션은 손댈 것 없음 — **Attribute 소관이므로 M10 착수 시 같이
|
||||
처리**(**[2026-08-22 정정]** 여기 "M6"라고 적었으나 B 섹션이 검증하는
|
||||
건 Slot이 아니라 Attribute 이름 소유권이다)
|
||||
- [ ] **`22`(Ref/PreRef/PostRef 브랜드)** — `Brand`가 인스턴스 브랜드로
|
||||
전면 재작성되며 옛 `Brand.set`/`Brand.get` 구현에 의존하던 부분이
|
||||
깨짐(검증 대상인 `isRef`/`isPreRef` 포함 관계 자체는 그대로).
|
||||
**M8 착수 시 같이 처리**
|
||||
- [ ] **`15`(`:Compute` trailing deps 타입팩)** — 이형 다중 deps를 제네릭
|
||||
타입 팩으로 표현 가능한지 미실측(안 되면 동종 dep 1개로 한정).
|
||||
**M3 착수 시 같이 처리**
|
||||
- [ ] **`10`(Roblox Studio 확인)** — `bindLifetime`/`canExecute`/
|
||||
`unbindLifetime` 재정정으로 무효화. **Studio 작업이라
|
||||
`HUMAN_TODO.md` 1번(계정 분리)이 선행**
|
||||
- [ ] **아직 파일이 없는 실측 항목** — `R-11`의 `table.insert` 구멍 재사용,
|
||||
**중간 State GC**(`base/source-state-plan.md`, 상류 strong / 하류 weak
|
||||
불변식 — `.claude/todos.md`가 "M3 착수 전 필요"로 지정)
|
||||
|
||||
## M1 — 실제 스캐폴딩
|
||||
|
||||
- [x] `quad-base/`, `quad-roblox/` 폴더 + 각 `pesde.toml`(**[2026-08-19
|
||||
|
|
@ -128,6 +196,23 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
> 분리 신설), 뒤집힌 옛 모델은
|
||||
> `.claude/archive/dispatch-hintvalue-model-reversed.md`.
|
||||
|
||||
> **⚠️ [2026-08-22] M2는 이름과 달리 "디스패치만"이 아닙니다 — 반응형
|
||||
> 하부구조 일부가 여기 있습니다.** `EpochMap`/`GateNode`/`Blocker`/`None`
|
||||
> 체크박스가 M3·M7에서 옮겨왔습니다(각 항목의 이동 표시 참고). 그동안
|
||||
> 각주로만 예고돼 있어 M2 체크리스트를 훑는 구현자에게 **항목으로 보이지
|
||||
> 않던** 것을 고친 것이고, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
|
||||
> 것과 같은 처리입니다.
|
||||
>
|
||||
> **⚠️ 다만 이동으로 드러난 더 큰 문제가 하나 남아 있습니다 — M2와 M3의
|
||||
> 의존이 양방향이라, 이 순서대로면 M2를 끝까지 짤 수 없습니다.**
|
||||
> 요지만: **M2는 `Source.luau`/`State.luau` 없이는 구현할 수 없고**
|
||||
> (Length/Offset 배관이 `State<number>`/`Source<number>`를 쓰고,
|
||||
> `Dispatch.drive` 자신도 배치 등록을 Blocker로 게이팅합니다),
|
||||
> 반대 방향은 핸들러 등록 표면만 요구하는 얕은 의존입니다.
|
||||
> **어느 쪽으로 가를지(순서 교체 / M2 분할 / 유지)는 사용자 결정
|
||||
> 대기이고, 근거와 선택지의 소스는 `.claude/question.md` 2번입니다** —
|
||||
> 여기서 반복하지 않습니다.
|
||||
|
||||
|
||||
- [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수
|
||||
스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)`
|
||||
|
|
@ -137,20 +222,29 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
즉시 error) / **3-인자** `Dispatch.retractFrom(inst,k,index)`
|
||||
(아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록,
|
||||
quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst,
|
||||
flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에
|
||||
flattened)`(**일반화 `for` 한 번**으로 순회하며 각 `(k,v)`에
|
||||
`Dispatch.process(inst,k,v,1)` 호출 — `dispatch-core-plan.md`의 `None`
|
||||
센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정).
|
||||
**[2026-08-13 다섯 번째 세션 전면 재설계]** "이전 담당자와 다르면
|
||||
그 `retract`"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로
|
||||
재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch 전에
|
||||
스스로 `retractFrom`을 부르는 쪽에 있고, `Dispatch.process`는
|
||||
diff를 하지 않음
|
||||
**[2026-08-21 구현 전 QA 4라운드 `F-4-1`] 순회는 두 패스가 아니라
|
||||
단일 일반화 `for`다** — "배열 파트 전체가 해시 파트보다 먼저"는 여전히
|
||||
base가 보장하는 **계약**이지만, `flattened`가 항상 평범한 Luau
|
||||
테이블이라 일반화 `for` 한 번이 그 순서를 그대로 주고 두 층위는
|
||||
`type(k) == "number"` 분기로 가른다(`base/dispatch-core-plan.md`의
|
||||
`F-4-1` 정정 문단). 옛 "명시적 두 패스" 서술은 구현까지 2회 순회로
|
||||
못박은 것처럼 읽혀 정정됨 — 그 때문에 스파이크 `01`도 재작성 대기
|
||||
(`luau-test/STATUS.md`)
|
||||
- [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/
|
||||
`process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도
|
||||
`inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는
|
||||
`process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션))
|
||||
- [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/
|
||||
`Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
|
||||
- [ ] `Brand.luau`(**[2026-08-21 재작성]** 인스턴스 브랜드 — `Brand()`가
|
||||
브랜드마다 weak-key 집합 하나를 들고 `:register(x)`/`:is(x)`,
|
||||
**다중 태깅 허용**(`Source`가 `SourceBrand`이면서 동시에 `EpochBrand`).
|
||||
옛 공유 레지스트리 + `Brand.get(x) -> tag`는
|
||||
`archive/brand-shared-registry-reversed.md`. **[2026-08-22] `isEpoch`도
|
||||
여기 포함** — `Epoch` 인터페이스 확정으로 `EpochBrand`가 생겼고
|
||||
`base/state-epoch-plan.md`/`base/source-state-plan.md`가 "런타임 분기는
|
||||
`isEpoch`로"라고 확정했다 — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/
|
||||
`isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/
|
||||
`isStore`/`isSlot`/`isRef`/`isPreRef`/`isModifier`(2026-08-07 열 번째
|
||||
세션 추가 — 원래 태그 목록에서 빠져있었음. **[정정, 2026-08-09
|
||||
|
|
@ -170,6 +264,46 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
참고) 전부의 기반. `isNone`만 예외로 레지스트리 없이 `x == None`
|
||||
항등 비교 — `brand-plan.md`의 `Brand` 절, 2026-08-07 여덟
|
||||
번째 세션 신설)
|
||||
- [ ] **[2026-08-22 M3에서 이동] `EpochMap.luau`** — 재사용 가능한 에포크
|
||||
부기 객체(`:Update(Epoch|EpochSet) -> boolean`이 "뒤로 전파가
|
||||
필요한가"를 답함, `:Refresh`/`:Sync`/`:TrackFrom`. `EpochSet =
|
||||
{[Epoch]: true}`로 **배열이 아니라 집합**). `State.luau`에 묻지 말고
|
||||
별도 모듈로 낼 것 — `GateNode`(아래)와 `State`(M3)/`Effect`(M3)가
|
||||
전부 같은 것을 쓴다. `Epoch` 인터페이스 자체(`{ Revision: number }`)와
|
||||
리비전 갱신(`bit32.bnot(-rev)`)도 여기서 확정 — `base/state-epoch-plan.md`
|
||||
- [ ] **[2026-08-22 M3에서 이동] `state:Gate(setup)` + `GateNode`** —
|
||||
emit을 가로채 유보했다가 한 번에 내보내는 공용 게이트 노드
|
||||
(`ComputeNode`와 같은 층위, 탑레벨 `Gate(...)` 프리미티브는 안 만듦).
|
||||
유보 배치는 `withheld : { [epoch] : true }`(집합), flush 때 테이블을
|
||||
통째로 갈고, **내보내는 emit이 싣는 건 그렇게 떼어낸 `EpochSet`
|
||||
스냅샷뿐이다 — 게이트 노드 자신은 안 싣는다**(하류가 게이트 identity를
|
||||
한 번도 안 쓴다, `base/state-epoch-plan.md` §5). **빈 배치는
|
||||
아무것도 안 함**. `emitEpochMap`을 쓰므로 위 `EpochMap.luau`가 선행 —
|
||||
`base/gate-plan.md`. **"게이팅 먼저" 결정으로 M3에서 앞당겨진 항목**
|
||||
(사용자: *"게이팅 먼저. 게이팅을 base 에 만들 준비를 해야한다"*)
|
||||
- [ ] **[2026-08-22 M3에서 이동] `Blocker.luau`** — 위 `GateNode` 위에
|
||||
얹히는 **정책**(다시 노드를 만들지 말 것). 여러 Source를 한꺼번에
|
||||
바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive
|
||||
(`base/blocker-plan.md`). 아래 `Dispatch.setLength`/`setOffsetSource`의
|
||||
배치 등록이 `:On()`/`:IsOn()`/`:OffWithoutEmit()` **세 메서드**를
|
||||
호출하므로(`base/gate-plan.md` 9번이 소스 — Blocker 인스턴스를 lazy
|
||||
조회하는 `getBlocker(ownerKey)`는 Blocker 메서드가 아니라 Dispatch
|
||||
쪽 헬퍼다) **최소한 그 셋이 도는 형태까지는 M2에 필요**
|
||||
- [ ] **[2026-08-22 M7에서 이동] `None.luau`(센티널) + `Dispatch/None.luau`
|
||||
(`NoneHandler` + `NilHandler`)** — `None`은 modifier 전용 값이 아니라
|
||||
디스패치 배관이라 여기 소속(`architecture.md`의 소스 트리에 있는 건
|
||||
핸들러 쪽 `Dispatch/None.luau`뿐 — **[2026-08-22] 탑레벨 센티널
|
||||
`None.luau`와 `Brand.luau`는 그 트리에 아직 줄이 없다**, M2 구현 시
|
||||
트리도 같이 채울 것).
|
||||
`NoneHandler`는 배열/해시 구분 없이 `Dispatch.process(inst,k,nil,index+1)`
|
||||
**재귀만** 하고(선행 `retractFrom` 없음 — 하강 diff), 실제 정리는
|
||||
`NilHandler`(`isHandlable`이 `type(k) == "number" and v == nil`일 때만
|
||||
매치하는 말단)가 `Dispatch.setLength(inst,k,0)` +
|
||||
`Dispatch.setOffsetSource(inst,k,None)` 등록으로 맡는다.
|
||||
**`Dispatch.drive`에 `None` 스킵 분기는 없다**(반응형 값이 내놓는
|
||||
`None`은 어차피 `process`에 도착하므로 — 2026-08-18 재설계) —
|
||||
`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절.
|
||||
Modifier 쪽 표면(인라인 키로 필드 지우기, `Peek` 반환 타입)은 M7
|
||||
- [ ] `Relate.luau`(전체가 quad-base, 순수 Lua — `base/relate-plan.md`) —
|
||||
`Relate()` 비싱글톤 생성자, `:SetWeak`/`:GetWeak`/`:SetStrong`/`:GetStrong`.
|
||||
`inst`(첫 인자)는 항상 weak, `StrongMap`/`WeakMap` 서브테이블은 lazy
|
||||
|
|
@ -181,8 +315,10 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
함수 타입 계약, 실 구현 없음 — quad-roblox 실 구현은 M8) — 원래
|
||||
M8에만 있었으나 M4(StoreBind의 `Connected` 확인)/M6(Slot의
|
||||
`canExecute`)이 이미 이 인터페이스를 전제로 서술돼 있어 로드맵
|
||||
순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9,
|
||||
`question.md` 2번 — 2026-08-07 네 번째 세션에 반영).
|
||||
순서가 역전돼 있었음(`pre-implementation-audit.md` 우선순위1-9 —
|
||||
2026-08-07 네 번째 세션에 반영. **[2026-08-22 정정]** 여기 있던
|
||||
`question.md` 번호 참조는 그 항목이 해소되며 이미 깨져 있었고,
|
||||
지금 그 번호는 다른 항목이 쓰고 있어서 지웠다).
|
||||
**[정정, 2026-08-14 다섯 번째 세션] `unbindLifetime`/`canExecute`는
|
||||
`inst`를 안 받는다** — 옛 2-인자 시그니처(`(inst, value)`)는 오염이었음.
|
||||
`bindLifetime`이 바인딩 시점에 `inst`의 gcconn 참조를 `value` 쪽
|
||||
|
|
@ -207,9 +343,19 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
다시 갈라짐, 판정 로직은 공유하는 비공개 헬퍼 하나 — M3 체크박스
|
||||
참고**), children 배열 leaf 부착이 실제로는 `bindLifetime` 호출이라
|
||||
이 게이트를 그대로 탐
|
||||
- [ ] `Dispatch.setLength(inst,i,len:number|State<number>)`/
|
||||
`Dispatch.setOffsetSource(inst,i,offset:Source<number>|None)` —
|
||||
array part 형제 순서 보장(Length/Offset 누적합→`LayoutOrder` 리액티브
|
||||
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/
|
||||
`Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/
|
||||
**`Dispatch.getOffsetAt(ownerKey,i)`** —
|
||||
**[2026-08-21 구현 전 QA 5라운드 반영]** `setLength`의 4번째 인자
|
||||
`anchor`(생명주기 앵커, 항상 물리 Instance — 부기 키와 분리, **생략 시
|
||||
`ownerKey`로 폴백**이라 최상위 호출부는 3-인자 그대로),
|
||||
`setOffsetSource`는 `None`이면 얼리 리턴(그 `None`은 "발행 채널 없음"이지
|
||||
"참여 안 함"이 아니다 — 참여 여부는 `setLength`가 답),
|
||||
숫자가 필요한 쪽(예: 물리 삽입 위치)은 `getOffsetAt`으로 pull.
|
||||
`recompute`는 owner의 베이스(Slot이면 자기 `.Offset`, 최상위면 0)에서
|
||||
시작하고 중첩 Slot은 자기 `Offset`을 관측해 자식 offset을 다시 민다.
|
||||
**이 셋이 하는 일**: array part 형제 순서 보장(Length/Offset 누적합→
|
||||
`LayoutOrder` 리액티브
|
||||
바인딩), array part 모든 number 인덱스에 대해 둘 다 호출 필수(생략
|
||||
UB, Handler 구현체 작성자만의 계약) — `recompute`는 leaf-lifetime
|
||||
경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님
|
||||
|
|
@ -223,15 +369,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
게이팅" 절. **[정정, 2026-08-18 구현 전 QA 3라운드] 그 크래시 자체는
|
||||
`bk.N`의 정의(그때그때 실제 개수로 확정, 같은 문서 "저장 위치" 절)가
|
||||
바뀌며 사라졌음** — 지금 이 두 함수 구현이 여전히 `Blocker`
|
||||
(`getBlocker`/`:On()`/`:IsOn()`/`:OffWithoutEmit()`)를 호출하는 이유는
|
||||
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감. **다만
|
||||
호출하는 건 여전히 사실이라 — `Blocker.luau`는 아래 M3 체크박스에
|
||||
있는데 이 항목은 M2 소속이라, 로드맵 순서대로면 M2가 아직 없는
|
||||
`Blocker`를 참조하게 됨.** M2 착수 전 `Blocker`의 최소 표면
|
||||
(`On`/`Off`/`IsOn`/`OffWithoutEmit`)을 M3보다 먼저(또는 M2와 병행)
|
||||
만들 필요가 있는지 사용자 판단 필요 —
|
||||
`qa-request/pre-implementation-qa-round3.md`의 "ROADMAP.md 마일스톤
|
||||
정합성" 절.
|
||||
(`:On()`/`:IsOn()`/`:OffWithoutEmit()` 세 메서드 + 그 인스턴스를
|
||||
lazy 조회하는 Dispatch 쪽 헬퍼 `getBlocker(ownerKey)`)를 호출하는 이유는
|
||||
크래시 방지가 아니라 배치 등록 비용(O(N²)→O(N)) 절감.
|
||||
**[2026-08-22] 그래서 필요한 `GateNode`/`Blocker`/`EpochMap`은 이제
|
||||
이 마일스톤에 체크박스로 있다**(위 세 항목) — 예전엔 M3에 있는 걸
|
||||
각주로 가리키기만 했다. 결정 경위("게이팅 먼저", 그리고 앞당기는
|
||||
대상이 `Blocker` 자체가 아니라 그 아래 공용 `Gate` 노드로 바뀐 것)는
|
||||
`qa-request/pre-implementation-qa-round5-followup.md`와
|
||||
`base/gate-plan.md`가 소스 — 여기서 반복하지 않는다.
|
||||
- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는**
|
||||
핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상
|
||||
`function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이
|
||||
|
|
@ -300,7 +446,7 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`inst`를 인자로 받을 수 없는 이유(State는 자기가 어느 Instance에
|
||||
걸렸는지 모름). `state:Observer(fn)`의 "등록 즉시 1회 실행"은
|
||||
`bindLifetime` 이전에 동기적으로 일어나므로 이 게이팅과 무관
|
||||
- [x] `store.key` dot-access 타입 추론 확인 — Luau `type function`
|
||||
- **[확정된 것 — 코드 아님]** `store.key` dot-access 타입 추론 확인 — Luau `type function`
|
||||
(`WrapStore`/`ProcessStoreType`)으로 `Store<T>`가 `T`의 각 필드를
|
||||
`Source`로 감싼 레코드 타입을 합성 가능함을 확인(2026-08-12 열일곱
|
||||
번째 세션, `base/typing-limits.md` §5) — **[2026-08-15 실측 완료]**
|
||||
|
|
@ -321,9 +467,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`luau-test`의 `15-type-compute-trailing-deps-typepack.luau`로
|
||||
이형 다중 deps를 제네릭 타입 팩으로 표현 가능한지만 실측 필요(안
|
||||
되면 동종 타입 dep 1개로 한정)
|
||||
- [ ] `Blocker.luau`(`base/blocker-plan.md` 참고 — 여러 Source를
|
||||
한꺼번에 바꿔도 파생값 재계산/재대입이 한 번만 되게 하는 primitive,
|
||||
State와 밀접히 연관돼 있어 같은 마일스톤에서 개발)
|
||||
> **[2026-08-22 이동] `EpochMap.luau` / `state:Gate` + `GateNode` /
|
||||
> `Blocker.luau`는 M2로 옮겼습니다** — 셋 다 M2가 실제로 호출하는데
|
||||
> 각주로만 예고돼 있어서, `LifetimeHandle` 인터페이스를 M8→M2로 옮겼던
|
||||
> 전례대로 체크박스 자체를 옮겼습니다. M3는 그 위에 State/Source 본체를
|
||||
> 얹기만 하면 됩니다.
|
||||
- [ ] **[2026-08-21 5라운드 — 채택 확정, 같은 날 `Epoch`로 일반화]** State의
|
||||
재계산/전파 판정은 **`Epoch` 리비전 비교**다(`base/state-epoch-plan.md`)
|
||||
— `invalid` 플래그가 아니다. 아래 `Source.luau`/`State.luau`가 이걸
|
||||
전제로 짜여야 한다: `Source`가 `type Epoch = { Revision: number }`를
|
||||
구조적으로 만족하고(`EpochBrand`에도 등록), 부기는 재사용 가능한
|
||||
**`EpochMap`**(`:Update(Epoch|EpochSet) -> boolean`, `:Refresh`, `:Sync`,
|
||||
`:TrackFrom` — `EpochSet = {[Epoch]: true}`, **배열 아님**)
|
||||
으로 떼어내며, State가 그걸 **둘** 컴포지션한다 — `valueEpochMap`(값
|
||||
유효성)/`emitEpochMap`(전파 dedup). emit은 값도 리비전도 안 싣고
|
||||
**출처(`Epoch`나 그 집합)만** 싣고, 순회는 `rawInvalid == false`일 때만
|
||||
돌며 **값만 앞당기고 통지는 상류 emit을 기다린다**. 다이아몬드 중복
|
||||
통지가 접히므로 스파이크 `05`도 그에 맞춰 재작성해야 한다
|
||||
(`luau-test/STATUS.md`).
|
||||
- [ ] `state:Apply(factory)`(`base/source-state-plan.md` "`state:Apply(factory)`"
|
||||
절, 2026-08-07 일곱 번째 세션) — `factory(self)`를 체이닝 문법으로
|
||||
부르는 순수 설탕, `factory: (State<T>) -> U): U`로 열린 타입. Source도
|
||||
|
|
@ -334,10 +495,14 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
경로 가드**(`{priority = HANDLER_PRIORITY_FALLBACK, isHandlable = v
|
||||
is Observer, process = error(...)}`, `k` 타입 안 가림, 2026-08-14
|
||||
열한 번째 세션 — `PreRef`와 같은 패턴)도 같이 등록
|
||||
- [ ] `Effect(fn, state?)`(`base/effect-plan.md`) — `state` 생략 시 설치
|
||||
1회+leaf 사망 시 확정 정리, `state` 지정 시 내부적으로
|
||||
`state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React
|
||||
`useEffect` 동형). Observer 구현 이후에 착수(의존 관계).
|
||||
- [ ] `Effect(fn, ...deps)`(`base/effect-plan.md`, **[2026-08-21 5라운드
|
||||
`C-6`]** 옛 시그니처는 `Effect(fn, state?)`) — deps 생략 시 설치
|
||||
1회+leaf 사망 시 확정 정리, deps 지정 시 **각각에 맞는 구독**
|
||||
(State/Source는 `Observer`, `Ref`는 `:Callback`)을 걸어
|
||||
재실행+cleanup 체이닝(React `useEffect` 동형). **`EffectHandle`이
|
||||
`EpochMap`을 하나 들어** 공통 상류로 인한 중복 발화를 접고, 설치 구간
|
||||
억제 플래그가 그 `Update`보다 먼저 와야 함
|
||||
(`base/state-epoch-plan.md`). Observer 구현 이후에 착수(의존 관계).
|
||||
`EffectHandle:Subscribe()`/`:Unsubscribe()`도 추가(leaf 없이 쓰는
|
||||
모듈/스크립트 레벨 Effect) — `:Unsubscribe()`는 Observer와 달리
|
||||
마지막 cleanup을 1회 트리거해야 함(2026-08-07 일곱 번째 세션).
|
||||
|
|
@ -387,6 +552,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`와 같은 패턴)로 쉽게
|
||||
|
|
@ -423,13 +596,152 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
> "Handler 작성 체크리스트" 1번). 클로저가 받는 값이 항상 `Slot`이거나
|
||||
> `nil`임이 계약으로 보장된다는 점만 새로 추가됨.
|
||||
|
||||
### 확정된 것 — 코드 아님, 구현 전 필독
|
||||
|
||||
아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로
|
||||
두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다
|
||||
(**[2026-08-22]**).
|
||||
|
||||
- **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
|
||||
세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
|
||||
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`도
|
||||
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
|
||||
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨**, 위 M2 항목 참고.
|
||||
- **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
|
||||
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
|
||||
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
|
||||
열다섯 번째 세션 신설 — **[2026-08-13 5차 감사에서 추가] `Splice`가
|
||||
이 체크리스트에 누락돼 있었음, `luau-test/20`으로 산술 실측 통과됨**)
|
||||
— 에러 조건까지 전부 확정
|
||||
(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
|
||||
`isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
|
||||
명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
|
||||
**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
|
||||
인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
|
||||
(O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
|
||||
indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
|
||||
받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
|
||||
안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
|
||||
맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
|
||||
구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
|
||||
신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)` —
|
||||
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
|
||||
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
|
||||
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
|
||||
`IndexOf`는 순수 읽기라 가드 대상 아님).
|
||||
**[2026-08-21 5라운드]** `Replace(index, newElement)` 추가(그 자리 교체
|
||||
+ 이전 것 **파괴** — `Extract`의 파괴 짝, `Remove` ↔ `Extract`와 같은 축)와
|
||||
`rawReplace`/`rawAdd` 의사코드 확정, 그리고 **래핑/언래핑 한 쌍**
|
||||
(`State`를 요소로 받으면 내부적으로 `:Single` 래퍼 Slot이 되는데,
|
||||
`Get`/`IndexOf`/`Extract`가 돌려주는 값과 `:List`의 `prev`는 전부
|
||||
**언래핑된 원래 값**이다). base/roblox 경계에
|
||||
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
|
||||
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
|
||||
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
|
||||
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
|
||||
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
|
||||
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
|
||||
옛 `question.md` 1번 용어정리 항목은 해소되어
|
||||
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
|
||||
Slot 타입.
|
||||
- **`Slot:Single(state, updateFn?)` 확정** — `:List`를 0/1개짜리
|
||||
배열로 감싸는 순수 sugar, `index` 없이 `offset`/`prev`/`userdata`만
|
||||
전달, 고정 key로 `prev` 재사용 보장(2026-08-11 세션, `base/
|
||||
slot-plan.md` "`Slot:Single`" 절). **[2026-08-11 일곱 번째 세션]**
|
||||
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
|
||||
raw 요소 항목 참고.
|
||||
- **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 중첩" 절).
|
||||
**[2026-08-21 5라운드]** 그 web 경로가 실제로 삽입 위치를 알 수 있도록
|
||||
물리 조작이 **`native*` 주입 op 계층**으로 정리됐고(M5 배너가 목록,
|
||||
시그니처는 `base/slot-plan.md`의 "물리 조작은 주입 op다" 절 —
|
||||
**[2026-08-22 정정]** 여기 확정 전 가칭 `mountInst`/`unmountInst`/
|
||||
`disposeInst`가 남아 있었음), 중첩 offset이 부모 베이스를 못 받던 결함과
|
||||
재마운트가 `Offset` Source를 새로 만들던 결함도 같이 수정됐다.
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨 — 위 M2 항목 참고(`base/slot-plan.md` "재귀 메커니즘" 절).**
|
||||
**[재설계, 2026-08-21] `attachSlot`은 비공개 재귀 둘로 분해됨** —
|
||||
`materializeSlotTree`(부기만, Blocker가 감싸는 건 이제 여기뿐) +
|
||||
`mountSlotTree`(물리 `Parent` 대입만, Blocker 불필요), 그리고 그 둘을
|
||||
순서대로 부르는 **두 줄짜리 공개 `attachSlot`**(이름/시그니처/호출부
|
||||
전부 그대로). 이걸로 "부모에게 알리는 길이가 최종값"과 "부기가 물리보다
|
||||
먼저"가 처음으로 **동시에** 만족되고, 배치 밖 재마운트의 부모
|
||||
`recompute`가 2회→1회로 준다. 순서 제약이 줄 순서가 아니라 **함수
|
||||
경계로 강제**되므로 `RC-1`/`RC-3`/`RC-4` 같은 "줄 순서를 잘못 잡아서"
|
||||
나던 버그 클래스가 구조적으로 사라짐. 근거 기록은
|
||||
`reference/slot-attach-decomposition.md`.
|
||||
**관측 가능한 변화 하나**: `Parent` 대입 순서는 그대로지만 물리 마운트가
|
||||
"부기 완료 후 일괄"이 되어, `ChildAdded` 핸들러가 볼 때 서브트리 전체의
|
||||
`Length`/`Offset`이 이미 최종값이다(옛 코드는 미완성 스냅샷을 보여줬음).
|
||||
- **`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 확정" 절).
|
||||
- **`recompute` off-by-one 버그 수정**(2026-08-11 세션, `base/
|
||||
dispatch-core-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 관례라는 것도 명시.
|
||||
- **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
|
||||
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
|
||||
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
|
||||
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
|
||||
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`을
|
||||
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
|
||||
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
|
||||
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
|
||||
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
|
||||
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
|
||||
`_elements`엔 `None`이 절대 안 들어감(비어있는 nested Slot이 자연히
|
||||
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
|
||||
`:Single`의 `updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
|
||||
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
|
||||
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
|
||||
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
|
||||
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
|
||||
생긴 이유). **부수 발견(사용자)**: `:List`의 `reconcile`이
|
||||
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`를
|
||||
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
|
||||
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
|
||||
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
|
||||
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
|
||||
|
||||
### 짜야 할 것
|
||||
|
||||
- [ ] **[2026-08-13 여섯 번째 세션 — 이 세션의 Slot 결정 전부, 구현 전 필독]**
|
||||
- **`State<Slot>` 교체 = 파괴가 아니라 언마운트**(`state<Frame>`와 동일).
|
||||
비파괴 경로 `unmountSlotTree`를 `destroySlotTree`와 별도로 구현 —
|
||||
차이는 딱 둘: 실제 `Destroy()`를 안 하고, 자식 `releaseOwner`도 안 함
|
||||
(자식은 계속 그 slot 소유라 통째로 재마운트 가능 = 포탈).
|
||||
**쓰는 자리**: `SlotHandler.process`가 반환하는 클로저, 그리고
|
||||
`:List`의 `reconcile` 중 **값 교체와 `Detach` 경로만**.
|
||||
`:List`의 `reconcile` 중 **`Owned = false` 설치와 `Detach` 경로**
|
||||
(**[재정정, 2026-08-21]** 값 교체는 `Owned = true`면 파괴가 맞다 —
|
||||
`updateFn`이 만든 걸 자기 손으로 못 지우기 때문. `state<Frame>`
|
||||
의미론은 `Owned = false`가 담당).
|
||||
**여전히 파괴인 것**: 명시적 `Remove`/`Clear`/`dispose`, 그리고
|
||||
**[재정정, 2026-08-18 구현 전 QA] `:List`에서 `updateFn`이
|
||||
`nil`/`None`을 반환하거나 키가 데이터에서 사라진 경로**(2026-08-13의
|
||||
|
|
@ -459,51 +771,15 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
내지만 quad 자료구조가 깨지므로, quad가 관리 중인 값을 안전하게
|
||||
지우는 유일한 경로. 마운트 위치는 `elementOwner`가 이미 알고 있어
|
||||
새 부기 불필요. `isSlot(value)`면 그 경로, 아니면 백엔드가 주입하는
|
||||
`disposeInst(inst): ()`(`addTag`/`removeTag`/`setAttribute`와 같은
|
||||
"base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임.
|
||||
`nativeDispose(element): ()`(`addTag`/`removeTag`/`setAttribute`와 같은
|
||||
"base 소유+op 주입" 패턴, quad-roblox는 `inst:Destroy()`)로 위임
|
||||
(**[2026-08-22 정정]** 여기 `disposeInst`라 적혀 있었으나 이름은
|
||||
2026-08-21에 `native*` 계층으로 확정됨 — M5 배너 참고).
|
||||
**`Observer`/`Effect`는 범위 밖**(GC-native `bindLifetime`/
|
||||
`unbindLifetime`만으로 충분, 트리 부기 없음) — 2026-08-14 열 번째
|
||||
세션에 `question.md` 0-B 해소, 정본은 `base/slot-plan.md`
|
||||
"`dispose(value)`" 절
|
||||
|
||||
- [x] **"여러 Slot이 형제로 섞일 때 순서 보장" 해소**(2026-08-09 여섯 번째
|
||||
세션) — `Dispatch.setLength`/`setOffsetSource` 메커니즘, `base/
|
||||
dispatch-core-plan.md` "Length/Offset" 절. `Slot.Length: State<number>`도
|
||||
이때 확정(CRUD/`:List` 여부 무관 항상 노출, 순서 계산과 "n개 검색됨"
|
||||
UI 둘 다 겸함) — 구현 시 이 두 API를 `:List`/CRUD의 `raw*`가 호출.
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨**, 위 M2 항목 참고.
|
||||
- [x] **Slot의 `Add`/`Remove`/`Extract`/`ExtractAll`/`Clear`/`Move`/`Swap`/
|
||||
`Get`/`IndexOf`/`Splice` CRUD 의미론 확정** (2026-08-09 세 번째 세션,
|
||||
2026-08-09 열한 번째 세션에 식별 기준 재정정, `Splice`는 2026-08-12
|
||||
열다섯 번째 세션 신설 — **[2026-08-13 5차 감사에서 추가] `Splice`가
|
||||
이 체크리스트에 누락돼 있었음, `luau-test/20`으로 산술 실측 통과됨**)
|
||||
— 에러 조건까지 전부 확정
|
||||
(`base/slot-plan.md` "CRUD API 확정"). "재마운트 시 즉시 throw"도
|
||||
`isMounted` 이중 추적 분리로 개별 element/Slot 컨테이너 기준이
|
||||
명확히 갈림(같은 문서 "`isMounted` 이중 추적 분리" 절).
|
||||
**[정정, 2026-08-09 열한 번째 세션] 식별 기준을 element 레퍼런스에서
|
||||
인덱스 기준으로 전환** — `Remove(index)`/`Extract(index, newElement?)`
|
||||
(O(n) 또는 O(1))/`Move(oldIndex, newIndex)`(O(n))/`Swap(indexA,
|
||||
indexB)`(O(1)) 전부 인덱스, `Add(element, index?)`만 element를 직접
|
||||
받음(새로 넣는 대상이라 참조가 당연히 있음). 호출부가 `Add` 리턴값을
|
||||
안 담고 흘려버리는 경우가 흔해 레퍼런스 기준이 오히려 실사용과 안
|
||||
맞았음 — 레퍼런스만 있으면 `IndexOf(element): number?`로 인덱스를
|
||||
구하면 됨. `ExtractAll(): {T}`(Clear의 비파괴 버전), `Get(index): T?`
|
||||
신설(`get`/`set` 드롭했던 걸 재추가). `Extract(index, newElement?)` —
|
||||
`newElement` 지정 시 O(1) 제자리 교체(이전 element 반환), 기존엔
|
||||
교체하려면 Extract+Add 이중 O(n) 시프트가 필요했던 문제 해결. 공개
|
||||
mutate 메소드 전부 "가드 확인 + `raw*` 위임" 얇은 wrapper(`Get`/
|
||||
`IndexOf`는 순수 읽기라 가드 대상 아님). base/roblox 경계에
|
||||
mount/unmount 외 reposition 훅 추가됨. **`Slot<T>()` 제네릭화, 요소
|
||||
타입 제약 확정** — `nil`/`None` 둘 다 raw 요소로 금지(Slot 안엔
|
||||
실제 마운트 가능한 `T`만), 핸들러 계층 값(Ref/PreRef/Observer/
|
||||
Effect/Modifier)은 self-ref 컨텍스트가 없어 의미 불성립이라 즉시
|
||||
error(`Modifier` 필드와 같은 판별 메커니즘 재사용) — `D.InstSlot =
|
||||
Slot<<Instance>>`(**[2026-08-18]** `D` 네임스페이스 이름 확정 —
|
||||
옛 `question.md` 1번 용어정리 항목은 해소되어
|
||||
`archive/question-resolved.md`로 이전됨)가 quad-roblox의 사실상 유일한
|
||||
Slot 타입.
|
||||
- [ ] `Slot:List(data, updateFn, keyFn?)` — 키 기반 동적 컬렉션 재조정,
|
||||
`keyFn(item, index) -> key` 생략 시 원본 `data` 배열 위치(raw index)를
|
||||
그대로 key로 사용(중간 삽입/삭제 시 identity 보존 안 됨, 캐스케이드
|
||||
|
|
@ -513,16 +789,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
(filter/toggle 지원 — 첫 반환값 `nil` 시 실제 파괴, `Visible` 토글
|
||||
아님, 200+ 항목에서 lazy하지 않은 문제 회피), `prev` 그대로 반환하면
|
||||
저비용 재사용 경로.
|
||||
**[2026-08-18 신설, 이름 2026-08-19 확정] `Detach` 반환 경로** —
|
||||
`updateFn`이 `Detach, { old = ..., source = ... }`를 반환하면 그
|
||||
자리는 **파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠지고,
|
||||
보존은 반환한 userdata가 담당(다음 사이클에 거기서 `old`를 꺼내
|
||||
반환하면 재마운트). `Instance.new`/`Destroy` 비용을 아끼는 filter용
|
||||
경로. 공개 표면은 `None`과 같이 패키지 최상위 export(`base/
|
||||
slot-plan.md`의 "`nil` 리턴은 파괴가 기본" 절).
|
||||
**⚠️ "키가 데이터에서 사라졌을 때 `Detach`로 홀드 중이던 요소를
|
||||
어떻게 처분하는가"는 미결**(이름과 무관한 별개 항목) — 착수 전 결론
|
||||
필요(같은 절, `question.md` 3번). 파라미터 순서는 반환값 순서(`prev`류 먼저,
|
||||
**[2026-08-18 신설, 이름 2026-08-19 확정, 2026-08-21 보존 주체 정정]
|
||||
`Detach` 반환 경로** — `updateFn`이 `Detach`를 반환하면 그 자리는
|
||||
**파괴하지 않고 `Parent = nil`로만 내려와** Slot에서 빠진다.
|
||||
**보존 주체는 `userdata`가 아니라 `slot._detached` 필드**(Slot 필드여야
|
||||
`destroySlotTree` walk가 닿고 소유권도 유지됨 — `ud`로는 최종 처분이
|
||||
불가능). reconcile이 `prev`로 그대로 돌려주므로 `ud`에 담을 필요 없이
|
||||
**그대로 반환하면 재마운트**되고, 이미 detach 상태에서 또 `Detach`면
|
||||
**nop**. 언마운트는 소유권을 유지하는 `rawDetach`를 씀(`rawUnmount`
|
||||
아님). `Instance.new`/`Destroy` 비용을 아끼는 filter용 경로. 공개
|
||||
표면은 `None`과 같이 패키지 최상위 export(`base/slot-plan.md`의
|
||||
"Detach된 요소는 `slot._detached`가 보유한다" 절).
|
||||
**[2026-08-21 해소] "키가 사라졌을 때 홀드 중이던 요소의 처분"은
|
||||
`KeyGone` 센티널로 확정** — `updateFn(KeyGone, 0, offset, prev, ud)`로
|
||||
한 번 더 물어 처분을 받고, owner가 죽으면 `activateList`가 건
|
||||
`Effect`가 `_detached`를 전부 정리한다(같은 문서의 "`KeyGone`" 절).
|
||||
**`Owned` 옵션 신설** — `:List`/`:Single`의 설치 시점 플래그(기본
|
||||
`true`), `false`면 어떤 경로로도 파괴하지 않고 언마운트만(사용자가
|
||||
`state`에 담아 넘긴 요소용, `Slot:Add(state)` sugar가 이걸로 설치). 파라미터 순서는 반환값 순서(`prev`류 먼저,
|
||||
`userdata`류 나중)와 맞춤(2026-08-11 세션 정정, 원래 `userdata`가
|
||||
`prev`보다 앞이었음).
|
||||
**`updateFn`의 `index`는 `keyFn`의 raw `index`(원본 `data` 배열
|
||||
|
|
@ -567,72 +851,6 @@ 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`" 절). **[2026-08-11 일곱 번째 세션]**
|
||||
`updateFn`이 선택 인자로 완화됨(기본값 identity) — 아래 반응형
|
||||
raw 요소 항목 참고.
|
||||
- [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 중첩" 절).
|
||||
**`recompute` 트리거 모델의 크래시(`RC-1`)는 Blocker 게이팅으로
|
||||
해결됨 — 위 M2 항목 참고, `attachSlot`이 자기 flush 루프를 자기
|
||||
Blocker로 감싸는 형태로 반영됨(`base/slot-plan.md` "재귀 메커니즘" 절).**
|
||||
- [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/
|
||||
dispatch-core-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 관례라는 것도 명시.
|
||||
- [x] **반응형 raw 요소 — `State<T>`/`Source<T>`도 Slot 요소로 허용**
|
||||
(2026-08-11 일곱 번째 세션, 같은 세션에 정정) — `Slot:Add`가 받는
|
||||
실제 타입은 `T | State<T> | Source<T>`(임의 깊이 조합 가능).
|
||||
**[정정] 최초 검토한 "position-keyed StoreBind 구독 + Length를
|
||||
Compute로 파생" 안은 기각**(nilable 지원하려면 배열 파트 `None`을
|
||||
다시 끌어들여야 하고, Length 계산에 예외가 생기고, `Move`/`Swap`이
|
||||
인덱스-구독 동기화 부담을 짐 — `:List`가 element 아닌 `key` 기준인
|
||||
이유와 정면 충돌) — **새 메커니즘 없이 순수 `:Single` sugar로
|
||||
확정**: `isState(element)`면 그 자리에 내부적으로 `Slot():
|
||||
Single(element)`(updateFn 생략 시 identity 기본값)를 대신 삽입.
|
||||
`_elements`엔 `None`이 절대 안 들어감(비어있는 nested Slot이 자연히
|
||||
Length 0 기여), raw 직접 전달 요소에만 여전히 non-nil 요구.
|
||||
`:Single`의 `updateFn`도 이 sugar가 성립하도록 선택 인자로 완화
|
||||
(`Slot:Single(state, updateFn?)`, 기본값 identity). `:Single`/`:List`와는
|
||||
대체 관계가 아니라 같은 메커니즘 위의 다른 `updateFn`일 뿐 — raw
|
||||
`State<T>` 요소(identity)는 coarse swap, `updateFn` 직접 지정 시
|
||||
`prev`/`userdata` patch-reuse + `offset` 접근(`:Single`이 애초에
|
||||
생긴 이유). **부수 발견(사용자)**: `:List`의 `reconcile`이
|
||||
nested-Slot 결과를 반환하는 아이템 다음 형제의 압축 `index`를
|
||||
그 결과의 `.Length`만큼 건너뛰도록 `pos` 커밋 공식도 같이 수정
|
||||
(`pos = candidateIndex - 1 + (isSlot(result) and result.Length:Get()
|
||||
or 1)`) — 안 그러면 멀티루트 아이템 다음 형제의 LayoutOrder가
|
||||
겹침. `base/slot-plan.md` "반응형 raw 요소" 절.
|
||||
|
||||
## M7 — Modifier
|
||||
|
||||
- [ ] `Modifier()`(빈 인스턴스 바닥 생성자, 2026-08-07 열 번째 세션
|
||||
|
|
@ -659,29 +877,21 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
- [ ] `:Apply(factory)` 팩토리 함수 체이닝(`modifier-plan.md` 8번, 예약 키
|
||||
`Apply`가 제네릭 `__index` 필드 setter와 안 겹치는지 확인)
|
||||
- [ ] `:Peek<<T>>(key): T|State<T>|nil` 필드 읽기 접근자 +
|
||||
`isState(x)`/`isSource(x): boolean`(`Brand` 공유 레지스트리 기반 —
|
||||
`modifier-plan.md` 9번, `brand-plan.md`의 `Brand` 절, M2의
|
||||
`Brand.luau`에 이미 구현돼 있어야 함)
|
||||
- [ ] 인라인 키/setter로 modifier 필드를 명시적으로 지우는 `None` 센티널
|
||||
(이름 확정, `modifier-plan.md` 2-1번, `Peek` 반환 타입에 `None` 추가) +
|
||||
이를 `nil`로 재디스패치하는 base 내장 `NoneHandler`
|
||||
(`dispatch-core-plan.md`의 `None` 센티널 절, M2 dispatch 엔진의
|
||||
"이전 매치 핸들러 추적" 항목과 함께 구현 — `StoreBind` 핸들러와
|
||||
동일한 재귀 재디스패치 패턴이라 새 메커니즘 아님) — `None` 센티널
|
||||
자체는 확정 완료. **[2026-08-13 열네 번째 세션 갱신]** `NoneHandler`가
|
||||
쓰는 재-dispatch 배관에서 **선행 `retractFrom` 호출은 폐기됨** —
|
||||
그냥 `Dispatch.process(inst,k,nil,index+1)` 한 줄
|
||||
(`base/dispatch-core-plan.md`).
|
||||
**[2026-08-18 구현 전 QA 재설계]** `Dispatch.drive`의 `None` 스킵
|
||||
분기는 **없앤다**(반응형 값이 내놓는 `None`은 어차피 `process`에
|
||||
도착하므로) — `NoneHandler`는 배열/해시 구분 없이 **재귀만** 하고,
|
||||
실제 정리는 아래 `NilHandler`가 맡는다
|
||||
- [ ] **[2026-08-18 신설]** `NilHandler` — `isHandlable`이
|
||||
`type(k) == "number" and v == nil`일 때만 매치하는 말단 핸들러.
|
||||
`Dispatch.setLength(inst,k,0)` + `Dispatch.setOffsetSource(inst,k,None)`
|
||||
등록이 이 핸들러의 일이고 재귀는 안 함(`State<Slot|nil>`도 정상
|
||||
동작해야 한다는 사용자 요구, `base/dispatch-core-plan.md`의
|
||||
"`NilHandler`" 절)
|
||||
`isState(x)`/`isSource(x): boolean`(**[2026-08-21 갱신]** 인스턴스
|
||||
브랜드 멤버십 기반 — `isSource(x)`는 `SourceBrand:is(x)`, `isState`는
|
||||
그 위에 `StateBrand:is(x)`를 OR로 얹은 상위 개념. 옛 "공유 레지스트리 +
|
||||
`Brand.get(x) == SourceTag`" 서술은 역전됨,
|
||||
`archive/brand-shared-registry-reversed.md`. `modifier-plan.md` 9번,
|
||||
`brand-plan.md`의 "`isX` wrapper" 절, M2의 `Brand.luau`에 이미
|
||||
구현돼 있어야 함)
|
||||
> **[2026-08-22 이동] `None` 센티널 + `Dispatch/None.luau`(`NoneHandler`/
|
||||
> `NilHandler`)는 M2로 옮겼습니다** — `None`은 modifier 전용 값이 아니라
|
||||
> quad-base 디스패치 배관이고(`architecture.md`의 소스 트리에서도
|
||||
> `Dispatch/None.luau`), M0의 `props.Modifier or None` 관용구 · M2의
|
||||
> `Dispatch.drive` · M6의 `setOffsetSource(..., None)`이 전부 이미 전제합니다.
|
||||
> M7에서는 **Modifier 쪽 표면만** 다룹니다 — 인라인 키/setter로 필드를
|
||||
> 지우는 용법과 `Peek` 반환 타입에 `None`을 추가하는 것(`modifier-plan.md`
|
||||
> 2-1번).
|
||||
- [ ] 프로퍼티류 필드 타입에 `T' = T | Tween<T>` 치환 반영(타입 생성
|
||||
스크립트가 `Position: UDim2` 자리를 `UDim2 | Tween<UDim2>`로 만들면
|
||||
끝, Modifier 런타임/`__index` 자체엔 변경 없음 — `modifier-plan.md`
|
||||
|
|
@ -705,8 +915,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
살아있으면 그 자리에서 즉시 error) — `base/ref-plan.md` "이중 배치
|
||||
방지" 절
|
||||
- [ ] `PreRef`/`PostRef` pre-pass — 새 `Dispatch.*` 함수 없이
|
||||
`Dispatch.drive(inst, flattened)` 자신이 두 패스(배열→해시) 루프
|
||||
전에 배열 파트를 **한 번** 훑어, `PreRef`는 그 자리에서 fire하고
|
||||
`Dispatch.drive(inst, flattened)` 자신이 **본체 루프 전에** 배열
|
||||
파트를 **한 번** 훑어(**[2026-08-22 정정]** 여기 "두 패스(배열→해시)
|
||||
루프 전에"라고 적혀 있었으나 본체는 단일 일반화 `for`다 — `F-4-1`), `PreRef`는 그 자리에서 fire하고
|
||||
`PostRef`는 로컬 `postRefList`에 push만 함(Dispatch.process/getHandler
|
||||
우회하는 raw 루프, `flatten` 함수에는 얹지 않음 — 재바인드 시 flatten
|
||||
재호출 가능성과 충돌하므로 기각). **복수 `PreRef`/`PostRef`의 계열 안
|
||||
|
|
@ -717,9 +928,9 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
PreRef`류 조합이 반례). fire/수집된 슬롯은 그 자리에서 소진(**[정정, 2026-08-14 두
|
||||
번째 세션] `None`이 아니라 전용 센티널 `ProcessedPreRef`/
|
||||
`ProcessedPostRef` 처리** — 아래 `Processed*Handler` 항목이 그 자리를
|
||||
정상 두 패스로 마저 처리)
|
||||
정상 본체 루프로 마저 처리)
|
||||
— `base/ref-plan.md` "PreRef" 절 / "`PostRef`" 절
|
||||
- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 두 패스 뒤
|
||||
- [ ] **[2026-08-14 아홉 번째 세션 신설]** `PostRef.luau` + 본체 루프 뒤
|
||||
`postRefList` 소비 루프 — `PreRef.luau`와 같은 방식(`Ref` 런타임
|
||||
재사용 + 브랜드 태그만 다름, children 배열 리터럴 전용, Modifier/Store
|
||||
타입 차단, `_fired` 1회용 가드). `Dispatch.drive`가 해시 파트까지
|
||||
|
|
@ -811,13 +1022,18 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
명시, 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장
|
||||
(`AttributeKey`와 동일 기법), `base/onchange-plan.md`, 2026-08-10
|
||||
세션 확정·2026-08-11 아홉 번째 세션 후속(캐시))
|
||||
- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau` —
|
||||
주입되는 엔진 op 3개**: `addTag(inst,{string})`/`removeTag(inst,{string})`
|
||||
- [ ] **[2026-08-13 열네 번째 세션 재배치] `quad-roblox/EngineOps.luau`의
|
||||
Tag/Attribute 몫** — `addTag(inst,{string})`/`removeTag(inst,{string})`
|
||||
(`CollectionService`), `setAttribute(inst,name,v)`(`v==nil`이면 삭제).
|
||||
`RobloxFactory`가 `BaseModule`에 주입(`bindLifetime`/`canExecute`와
|
||||
같은 패턴) — 아래 base 핸들러들이 이걸 호출함
|
||||
(`base/dispatch-core-plan.md` "base가 소유하는 핸들러와 주입되는
|
||||
엔진 op" 절)
|
||||
엔진 op" 절). **[2026-08-22 정정] 여기 "주입되는 엔진 op 3개"라고
|
||||
적혀 있었으나 이 파일이 담는 op은 그 셋이 전부가 아니다** — M5의
|
||||
`native*` 계층이 같은 파일에 들어오고 백로그의 `setTimeout`/
|
||||
`clearTimeout`도 예정돼 있다. **주입 op 전체 목록의 소스는
|
||||
`base/architecture.md`의 소스 트리 안 `EngineOps.luau` 줄** — 여기서
|
||||
세지 않는다.
|
||||
- [ ] `quad-base/AttributeKey.luau`(단일 키 `AttributeKey<<T>>(name)` +
|
||||
이름별 weak 캐시로 동등성 보장 + 스칼라 편의 패밀리
|
||||
`String`/`Number`/`BooleanAttribute` — 엔진 고유 타입 패밀리
|
||||
|
|
@ -892,24 +1108,35 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`Tween<T>` 래퍼 모델로 전환 — 상세는 `base/tween-plan.md`(전면
|
||||
재작성), 구 모델은 `archive/tween-special-bind-key-reversed.md`.
|
||||
|
||||
### 확정된 것 — 코드 아님, 구현 전 필독
|
||||
|
||||
아래는 **설계가 확정됐다**는 사실이지 짠 코드가 아니다 — 체크박스로
|
||||
두면 `[x]`가 "구현 완료"와 구분이 안 돼서 문서 머리 규약대로 분리했다
|
||||
(**[2026-08-22]**).
|
||||
|
||||
- **override 정책 확정 완료**(2026-08-12 세션, `base/tween-plan.md`
|
||||
"확정: `Tween{...}` 최종 모양" 절) — 검토했던 4가지가 **`Tween.Cancel`
|
||||
(기본)/`Tween.Finish` 2값으로 압축**됨(로블록스 `TweenBase` API 현실상
|
||||
나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두
|
||||
"정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정.
|
||||
**구현 시 순서 주의**: 이전 트윈 정리 → 그 다음 새 값 세팅
|
||||
- **트윈 옵션 값 모양 확정 완료**(2026-08-12 세션, `base/tween-plan.md`)
|
||||
— `Info: TweenInfo?` 우선 + 편의 필드(`Time`/`Style`/...) 폴백,
|
||||
기본값은 로블록스 `TweenInfo.new()` 자체 기본값과 일치. 옵션 필드는
|
||||
전부 plain만(State 불가)
|
||||
- **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정**
|
||||
(2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님
|
||||
|
||||
### 짜야 할 것
|
||||
|
||||
- [ ] `quad-base/Tween.luau`(값 타입만 — `Tween(opts)` 팩토리, `isTween`/
|
||||
`TweenTag` Brand, `Value: T` plain만 받고 State 재귀 없음)
|
||||
`TweenBrand`, `Value: T` plain만 받고 State 재귀 없음)
|
||||
- [ ] `Handlers/Property.luau`에 `isTween(realv)` 분기 추가(기존
|
||||
`Handlers/Tween.luau` 독립 핸들러는 폐기) + 3-상태 릴레이션 슬롯
|
||||
(`RobloxTween | true | nil` — `nil`=첫 세팅, `true`=세팅됨/트윈
|
||||
없음, 엔진 객체=활성 트윈) + 첫 세팅은 무조건 애니메이션 없이
|
||||
스냅(hasBeenSet 억제) + 활성 트윈 정리는 override 정책 완료 후에만
|
||||
새 값 세팅(순서 뒤바뀌면 트윈 다음 프레임이 방금 세팅한 값을 덮어씀)
|
||||
- [x] **override 정책 확정 완료**(2026-08-12 세션, `base/tween-plan.md`
|
||||
"확정: `Tween{...}` 최종 모양" 절) — 검토했던 4가지가 **`Tween.Cancel`
|
||||
(기본)/`Tween.Finish` 2값으로 압축**됨(로블록스 `TweenBase` API 현실상
|
||||
나머지가 관찰상 Cancel과 동일). Tween→plain 전환도 두 옵션 모두
|
||||
"정리 후 즉시 덮어쓰기"로 수렴해 5번째 옵션 불필요로 확정.
|
||||
**구현 시 순서 주의**: 이전 트윈 정리 → 그 다음 새 값 세팅
|
||||
- [x] **트윈 옵션 값 모양 확정 완료**(2026-08-12 세션, `base/tween-plan.md`)
|
||||
— `Info: TweenInfo?` 우선 + 편의 필드(`Time`/`Style`/...) 폴백,
|
||||
기본값은 로블록스 `TweenInfo.new()` 자체 기본값과 일치. 옵션 필드는
|
||||
전부 plain만(State 불가)
|
||||
- [ ] `quad-roblox/Animate.luau` — **시그니처도 이미 확정 완료**(2026-08-12
|
||||
두 번째/세 번째 세션, `base/tween-plan.md`): `Tween` opts(`Value` 제외)를
|
||||
`T|State<T>`로 받아 각 필드를 resolve한 뒤 `Tween{...}`을 반환하는
|
||||
|
|
@ -917,9 +1144,6 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
아님, `research/operator-sugar-plan.md` "왜 `:Apply`인가"). `CanAnimate`
|
||||
필드 포함(`false`면 `Tween`으로 안 감싸고 plain 값 그대로). M11은
|
||||
**구현만** 하면 됨
|
||||
- [x] **`initValue`(진입 애니메이션) — 에이전트 범위 제외로 확정**
|
||||
(2026-08-12 세션, 사용자가 직접 처리하기로) — 재검토 항목 아님
|
||||
|
||||
## 특정 마일스톤에 안 묶이고 병행 가능
|
||||
|
||||
- [ ] 용어 정리 스윕 — `State`/`Slot` 등(`PerInstanceState`는 `Relate`로
|
||||
|
|
@ -947,9 +1171,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
시간 기반 전파 게이트 `Debounce`/`Throttle`(`base/debounce-throttle-plan.md`)
|
||||
— 제어 핸들 설계까지 닫히면서 quad-base에 새 코어 메커니즘을
|
||||
추가하지 않는 **순수 슈가**로 확인됨(`Blocker`의 gated state + `Ref` +
|
||||
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **M3에서 `Blocker`를
|
||||
구현할 때 게이티드 노드를 공용 `Gate`로 빼두는 것만은 그 시점에 할
|
||||
것**(둘이 같은 노드를 공유하므로 따로 하면 같은 설계를 두 번 함).
|
||||
아래 주입 op 2개 위에 전부 얹힘, 그 문서 13절). **[정정, 2026-08-21 5라운드]
|
||||
그 공용 `Gate` 추출은 M3가 아니라 M2로 앞당겨졌다** — "게이팅 먼저"
|
||||
결정(위 M2 각주)으로 `Dispatch.drive`의 배치 등록이 쓰는 게이팅부터
|
||||
만들기로 했고, 그때 `Blocker`/`Debounce`/`Throttle`이 공유할 노드를
|
||||
같이 빼둔다(따로 하면 같은 설계를 두 번 함). **[2026-08-21] 표면 확정 —
|
||||
`state:Gate(setup)` 메소드**, `base/gate-plan.md`.
|
||||
프리미티브 자체는 그 위에 나중에 얹으면 되고 M0/M3를 막지 않음.
|
||||
주입 op 2개(`setTimeout(func, delay) -> Timeout` / `clearTimeout`,
|
||||
Roblox는 `task.delay`/`task.cancel`로 배선 — **인자 순서가 반대라
|
||||
|
|
|
|||
Loading…
Reference in a new issue