qa: 9라운드 손 트레이싱 실행 + Q1~Q3 확정·반영 — 체크포인트 (Q4~Q10 다음 세션)
8라운드가 써둔 지시서(-round9-brief.md)대로 커밋 9dd8213의 델타를 재트레이싱해 발견 18건(H-124~H-141)을 냈고(qa-request/pre-implementation-handtrace-round9.md), §4 문항 중 Q1~Q3를 사용자와 확정해 base/·ROADMAP에 반영했다. 결정의 소스는 -round9-followup.md(진행 표가 상태의 소스). 🔴 둘 다 luau 실측 재현. Q1 H-124 — recompute가 lengthList[i]를 되감기 판정보다 먼저 읽어, offset:Set 안의 사용자 코드가 커서 뒤 자리 수를 줄이면 sum += nil로 죽고 recomputeBlocker가 영구 On. 되감기 판정을 앞으로(continue), 읽기·누적은 되감지 않을 때만. Q2 H-125 — 재마운트 시 setOffsetSource가 slot.Offset을 바꾸는 순간 _baseObserver가 unbind 상태라 두 필드가 0으로 안 내려가 옛 베이스의 offsetCache[1]을 씀. 사용자 확정: Offset·_baseObserver를 Slot 생성자로(첫 마운트/재마운트 분기 소멸), materializeSlotTree는 blocker:On → bindLifetime → setOffsetSource, 파괴는 _destroyed 플래그 하나(핸들은 unbind만, mutate CRUD·:List·마운트 진입 error, Owned=false는 안 섬, 이중 dispose no-op). Q3 H-126/H-137/H-141 — element→index 맵이 Slot 층(slot._elemIndex)과 Dispatch 층(bk.tokens/indexOfToken)에 두 벌 있었고, 후자의 token은 사용자가 정한 적 없는 것(2026-08-25 /code-review가 발명해 사용자 인용문 옆에 앉아 있던 것). bk.indexOfElement 하나로 통일, setLength 5번째 인자 element, splice가 비운 자리는 세 배열 전부 처리. H-137 소멸. 부수: H-140(ROADMAP의 폐기된 "해제 시 slot.Offset = nil" 잔존) 정정, H-125 피해 범위를 "중첩 Slot의 Offset"으로 축소(유저 체인은 요소 인스턴스에 바인드돼 전파됨 — 실측), G각도로 "for d in seen do는 유효한 Luau가 아니다"가 거짓임을 확인, keyof<{}> 빈 Store 실측 클린. conventions.md 신설: "/code-review(그리고 메인 세션)가 내놓는 새 필드·인자· 이름·메커니즘은 발견이지 결정이 아니다" — 이 세션에서 메인 세션도 같은 실수를 세 번 했다(subject 인자 / Observer 위치 필드 = 기각된 Effect userdata 재개방 / 조회 클로저). 검증: quad-doc-auditor 6라운드(확실 1→1→3→1→1→0, base/ 본문 결함은 1라운드 이후 0건 — 나머지는 인덱스 레이어·인용처), doc-check ERROR 0. /code-review high는 Q4~Q10 반영 뒤 한 번에(같은 파일을 또 건드려 diff가 섞이므로 지금 체크포인트). Claude-Session: https://claude.ai/code/session_01F9zgJ4c4kDitAoQMm9qxKn Co-authored-by: qwreey <me@qwreey.moe>
This commit is contained in:
parent
9dd82136bd
commit
031495cc0b
16 changed files with 1923 additions and 161 deletions
File diff suppressed because one or more lines are too long
|
|
@ -38,10 +38,18 @@
|
|||
| `core.luau`/`dispatch.luau`의 부기가 **단일 `bk.invalidAfter`** | **두 필드로 갈라졌다** — `bk.offsetCacheValidUpTo`(캐시)와 `bk.offsetSetUpTo`(`:Set` 완료). 옛 단일 필드가 두 뜻을 겸한 게 되감기 신호가 지워지는 원인이었다 | `/code-review high` 4차 |
|
||||
| `d7_splice_fix.luau:14`의 splice 무효화가 `math.min(bk.invalidAfter, index)` | splice는 **`index - 1`**(커서 위치 splice가 "변경 없음"과 구분이 안 된다). `dispatch.luau:84`의 `setLength`용 `math.min(…, i)`는 **정정 대상이 아니다** — 그쪽 공식은 원래 `i`다 | `H-113` |
|
||||
| `Ref`를 쓰는 자리의 콜백 호출 | `fn(value, ref)` — 두 번째 인자가 `Ref` 자신(= `Epoch`). `:Set` 순서는 **값 → `Revision` → 콜백**, 순회는 `.Callbacks` + `.WeakCallbacks` | `H-107`/`H-108` |
|
||||
| **[2026-08-27 추가, 9라운드]** `dispatch.luau`의 `recompute`가 `lengthList[i]`를 읽고 누적한 **뒤** 되감기를 판정 | 되감기 판정이 **먼저**(`continue`), 읽기·누적은 되감지 않을 때만 — 옛 순서는 커서 뒤 자리 수가 줄면 `sum += nil` | `H-124`/Q1 |
|
||||
| **[2026-08-27 추가]** `dispatch.luau`의 `setLength`가 `box.pos`(가변 박스)로 위치를 캡처, 8라운드 전사물엔 `bk.tokens`/`indexOfToken` | **토큰 폐기** — 5번째 인자 `element`를 캡처해 `bk.indexOfElement[element]` 조회. `slot._elemIndex`도 이 맵으로 통합 | `H-141`/Q3 |
|
||||
| **[2026-08-27 추가]** `newSlot`이 `Offset`을 만들고 `materializeSlotTree` 상당이 `setOffsetSource` 뒤에 관측자를 붙임 | `Offset`·`_baseObserver`는 **생성자**에서, 순서는 `blocker:On()` → `bindLifetime` → `setOffsetSource` | `H-125`/Q2 |
|
||||
|
||||
**`d7_splice_fix.luau`의 결론(`H-102`가 토큰 역참조로 닫힌다)은 영향받지
|
||||
**`d7_splice_fix.luau`의 결론(`H-102`가 역참조 *조회*로 닫힌다)은 영향받지
|
||||
않는다** — 그 테스트는 recompute가 끝난 뒤 splice하는 시나리오라 `H-113`이
|
||||
문제 삼은 "루프 커서 위치에서의 splice" 경계를 애초에 안 밟는다. 공식만 낡았다.
|
||||
(**[2026-08-27 정정]** 여기 한때 *"토큰 역참조로 닫힌다"*였는데, 토큰은 9라운드
|
||||
`H-141`/Q3로 폐기됐다 — 지금은 **요소 캡처 + `bk.indexOfElement` 조회**다. 결론
|
||||
("캡처 말고 조회")은 그대로다. 9라운드 전사물(요소 키)은 세션 스크래치패드
|
||||
`ref9/`에 있고 근거는 `qa-request/pre-implementation-handtrace-round9.md`에
|
||||
인라인 전사돼 있다.)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -272,7 +272,7 @@ quad/
|
|||
│ ├── AttributeKey.luau # 단일 키 `AttributeKey<<T>>(name)` + 이름별 weak 캐시(동등성 보장) + 스칼라 편의 패밀리(String/Number/BooleanAttribute) — 엔진 고유 타입 패밀리(Color3Attribute류)만 백엔드 소속(`base/attribute-plan.md` "패키지 배치" 절, 2026-08-13 열네 번째 세션 재배치)
|
||||
│ ├── Tween.luau # 값 타입만(`Tween(opts)` 팩토리, `isTween`/`TweenBrand`) — 엔진 무관, 독립 Dispatch 핸들러 아님. 실제 애니메이션 처리는 quad-roblox Handlers/Property.luau 내부 분기(`base/tween-plan.md`, 2026-08-10 세션 재설계)
|
||||
│ ├── Effect.luau # `Effect(fn, ...deps)` — deps 없으면 설치1회+leaf사망시 정리, 있으면 dep마다 약하게 등록(State/Source면 `:WeakSubscribe`, `Ref`면 `:WeakCallback`)해 재실행. **[2026-08-26 `H-107`]** 등록 클로저는 dep 종류별로 **둘**(`onRefFire`/`onStateFire` — 두 콜백 계약의 자리 수가 다르다), 강한 주인은 항상 `_deps`(`base/effect-plan.md`)
|
||||
│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자, 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만
|
||||
│ ├── Slot.luau # [2026-08-24 `H-46`] 값 타입 본체 — 생성자(**[2026-08-27 Q2]** `Length`·`Offset`·`_baseObserver`를 여기서 만든다, 파괴는 `_destroyed`), 공개 CRUD, `:List`/`:Single`, `raw*` 세트, `wrapElement`/`unwrapElement`, `attachSlot` 3형제, `elementOwner`/`claimOwner`/`releaseOwner`, `dispose`, `Detach`/`KeyGone`(`base/slot-plan.md`). 다른 값 타입과 같은 대칭 — 아래 `Dispatch/Slot.luau`는 핸들러/부기만
|
||||
│ ├── Debug/init.luau # [2026-08-24 `H-47`] M1에서 **이미 커밋됨** — `InitDebug(module)`, `module.debug = false`(`base/project-setup-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)
|
||||
|
|
|
|||
|
|
@ -1344,7 +1344,7 @@ Slot1이 바뀔 때마다 Slot2에 다시 알려줘야 하는 캐스케이드
|
|||
**`Dispatch`의 두 API — 둘 다 Handler→Dispatch 등록(push) 방향**:
|
||||
|
||||
```lua
|
||||
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?)
|
||||
Dispatch.setLength(ownerKey, i, len: number | State<number>, anchor?, element?) -- [2026-08-27 9라운드 Q3] 5번째 = 그 자리의 inst|slot
|
||||
Dispatch.setOffsetSource(ownerKey, i, offset: Source<number> | None)
|
||||
Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그 자리의 절대 offset
|
||||
```
|
||||
|
|
@ -1493,6 +1493,9 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
|
|||
|
||||
**저장 위치**: `lengthList`/`sourceList`/`observers`(부모 `inst` 하나에
|
||||
귀속) + 그 owner가 지금 등록해둔 position 개수 `N`(`bk.N`으로 같이 저장)
|
||||
+ **`indexOfElement`**(**[2026-08-27, 9라운드 Q3]** 그 자리에 등록된 요소
|
||||
`inst|slot` → position 인덱스. 옛 `slot._elemIndex`가 여기로 왔고, 옛
|
||||
`tokens`/`indexOfToken`은 폐기 — 아래 `setLength` 절)
|
||||
— `Relate(parentInst)`에 lazy 생성.
|
||||
|
||||
**⭐ [2026-08-24 보강, 6라운드 손 트레이싱 `H-4`] 접두합 캐시 두 필드도 여기
|
||||
|
|
@ -1510,7 +1513,8 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
|
|||
같은 파일 안에서 어떤 자리는 가드하고 어떤 자리는 안 하는 불일치를 만든다.
|
||||
**가드를 두지 말 것.**
|
||||
- **`getBookkeeping`이 `bk`를 만들 때 `offsetCache = {}`, `offsetCacheValidUpTo = 0`,
|
||||
`offsetSetUpTo = 0`, `recomputeBlocker = Blocker()`으로 초기화한다.**
|
||||
`offsetSetUpTo = 0`, `recomputeBlocker = Blocker()`, **`indexOfElement = {}`**
|
||||
(**[2026-08-27 Q3]**)으로 초기화한다.**
|
||||
(**[2026-08-26]** `offsetCacheValidUpTo`은 같은 날 `offsetSetUpTo`에서 갈라져 나온
|
||||
필드다 — 아래 "두 필드" 절.) (`bk.N`만 `nil` 시작을
|
||||
유지한다 — 그쪽은 `or 0` 방어가 이미 자리를 잡았고 "아직 아무 자리도 등록
|
||||
|
|
@ -1518,9 +1522,12 @@ Dispatch.getOffsetAt(ownerKey, i): number -- [2026-08-21 5라운드] 그
|
|||
**⭐ [2026-08-26 추가, `/code-review high`] `recomputeBlocker`가 이 열거에
|
||||
빠져 있었다** — `H-101`이 나중에 도입했는데 생성 규칙이 어디에도 없었고,
|
||||
`H-119`가 `base/slot-plan.md`에 `bk.recomputeBlocker:IsOn()` 역참조를 네 개
|
||||
더 늘렸다. `_baseObserver`의 등록 즉시 1회 발화는 `getBlocker(slot):IsOn()`이
|
||||
참이라 `or` 단락으로 **우연히** 살아나지만, 그 우연에 기대고 있었다 —
|
||||
이 절이 `offsetSetUpTo`에 대해 잡아낸 것과 정확히 같은 종류의 nil 역참조다.
|
||||
더 늘렸다. (**[2026-08-27 갱신, 9라운드 Q2]** 한때 여기 *"`_baseObserver`의
|
||||
등록 즉시 1회 발화는 `getBlocker(slot):IsOn()`이 참이라 `or` 단락으로 우연히
|
||||
살아난다"*고 적혀 있었는데, 그 Observer는 이제 **Slot 생성자**에서 나므로 그
|
||||
1회는 콜백 머리의 `_physicalTarget == nil` 가드가 삼킨다 — `bk`에 닿지도
|
||||
않는다. `base/slot-plan.md`의 `materializeSlotTree`가 소스.) 이 절이
|
||||
`offsetSetUpTo`에 대해 잡아낸 것과 정확히 같은 종류의 nil 역참조다.
|
||||
|
||||
**[신설, 2026-08-18 구현 전 QA 3라운드] `bk.N`의 수명주기 — 두 owner
|
||||
타입(물리 `inst`, Slot 자신) 모두 같은 규칙 하나로 통일.** 이전엔
|
||||
|
|
@ -1883,9 +1890,17 @@ local function recompute(ownerKey, bk)
|
|||
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)
|
||||
|
||||
-- ⭐⭐ [2026-08-27 재배치, 9라운드 `H-124`] **되감기 판정이 `lengthList[i]`
|
||||
-- 읽기보다 먼저다.** 옛 순서(읽기·누적 → 판정)에선 `offset:Set(abs)` 안의
|
||||
-- 사용자 코드가 요소를 제거해 `i > bk.N`이 되면(커서가 마지막 자리일 때
|
||||
-- 아무 자리나 제거, 또는 `rawSplice`/`rawClear`의 다중 제거)
|
||||
-- `lengthList[i]`가 이미 `nil`이라 `sum += nil`로 죽고, 그 error가
|
||||
-- `recomputeBlocker:On()`과 `OffWithoutEmit()` 사이라 **차단기가 영원히
|
||||
-- 켜진 채 남는다**(그 owner의 레이아웃 영구 동결, `H-87` 부류). 되감으면
|
||||
-- `sum`은 어차피 `prefix[i]`로 덮이므로 읽기·누적은 되감지 않을 때만
|
||||
-- 한다(사용자: *"되감는다면 sum 이 이전걸로 구해져서 새로 계산한 sum
|
||||
-- 자체를 안 씀"*). 제거가 커서보다 **뒤**(`p > i`)면 `offsetSetUpTo`가
|
||||
-- `p-1 ≥ i`로만 내려가 판정이 거짓이고 그땐 `bk.N ≥ i`라 안전하다.
|
||||
if bk.offsetSetUpTo < i then -- 누군가 낮췄다 → 되감기
|
||||
-- ⭐ [2026-08-25] `+1`이 아니라 **그 자리부터** 다시 돈다.
|
||||
-- `prefix[j+1]`은 **옛** `lengthList[j]`로 누적된 값이라, 길이가
|
||||
|
|
@ -1903,9 +1918,13 @@ local function recompute(ownerKey, bk)
|
|||
-- 의미), `prefix[1] = 0`이라 1로 되감으면 정확하다.
|
||||
i = math.max(bk.offsetSetUpTo, 1)
|
||||
sum = prefix[i]
|
||||
else
|
||||
i += 1
|
||||
continue
|
||||
end
|
||||
-- 되감지 않을 때만 — 읽기는 여전히 `Set` **뒤**라 `H-113`의 *"`sum`은
|
||||
-- 안 낡는다"* 논증이 그대로 성립한다(재방문 때도 마찬가지).
|
||||
local v = bk.lengthList[i]
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
i += 1
|
||||
end
|
||||
-- ⭐ [2026-08-25] 커서 마감과 블로커 해제를 **`Length:Set` 앞에** 둔다.
|
||||
-- `Length:Set`은 상위 owner의 사용자 코드를 돌릴 수 있는데, 그 도중
|
||||
|
|
@ -1989,11 +2008,20 @@ end
|
|||
`offsetSetUpTo` 둘이다 — 위 "두 필드" 절.
|
||||
- **`H-102`(splice가 observer를 옮겨도 클로저에 박힌 인덱스는 안 고쳐진다)가
|
||||
이걸로 같이 닫힌다** — 아래 `setLength`의 `gatedRecompute`가 **인덱스를
|
||||
캡처하지 않고 조회**한다. `slot._elemIndex`(물리 요소 → 인덱스 역방향
|
||||
맵, 6라운드 `H-39`)와 같은 개념을 **Dispatch 층위로 격상**해 `bk`가
|
||||
소유하고, splice가 배열을 당길 때 같이 갱신한다 — 그래서
|
||||
`base/slot-plan.md`의 splice 요구 목록에 항목이 늘지 않는다.
|
||||
(사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*.)
|
||||
캡처하지 않고 조회**한다. 옛 `slot._elemIndex`(물리 요소 → 인덱스 역방향
|
||||
맵, 6라운드 `H-1`)를 **Dispatch 층위로 격상**해 `bk.indexOfElement`로 `bk`가
|
||||
소유한다 — owner가 Slot이든 물리 `inst`든 **규칙 하나**다
|
||||
(사용자: *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"*).
|
||||
**⚠️ [2026-08-27 정정, 9라운드 `H-141`/Q3] 여기 한때 *"그래서 `slot-plan.md`의
|
||||
splice 요구 목록에 항목이 늘지 않는다"*고 이어졌고, 실제 구현은 그 말과 달리
|
||||
`bk.tokens`/`bk.indexOfToken`이라는 **사용자가 정한 적 없는 신원(`token = {}`)**을
|
||||
만들어 요구 목록에 항목을 둘 늘렸다**(2026-08-25 `/code-review`가 "`len`은
|
||||
자리마다 유일하지 않다"를 잡으며 원래 키(요소)로 되돌아가는 대신 발명한 것).
|
||||
둘 다 **폐기**됐다 — 사용자: *"난 층위 상 어떠한 값이든, 마운트된 부기객체 ->
|
||||
index(기여량이 아님) 를 얻고자 했음"*. 클로저는 **요소를 캡처**하고
|
||||
`bk.indexOfElement[element]`를 조회한다 — 요소의 신원은 안 변하므로 splice가
|
||||
클로저 쪽에서 갱신할 것이 없고, 맵은 Slot 층의 `reindexFrom`이 자기 이유로
|
||||
이미 유지한다(`base/slot-plan.md`). 이제 요구 목록에 항목이 **실제로** 안 는다.
|
||||
두 필드를 `0`으로 뭉개는 안은 기각 — *"0 으로 두면, 모든 부분에
|
||||
있어 캐시가 무관해져요"*.
|
||||
|
||||
|
|
@ -2034,7 +2062,11 @@ Blocker를 `getBlocker(ownerKey)`로 조회만 한다(만들거나 켜고 끄지
|
|||
-- **생략하면 `ownerKey`** — 최상위(물리 inst가 곧 owner)에선 둘이 같은 값이라
|
||||
-- 기존 3-인자 호출부가 전부 그대로 맞고, **`ownerKey`가 Slot일 때만** 물리
|
||||
-- target을 명시적으로 넘기면 된다(그 경우에만 둘이 갈린다).
|
||||
function Dispatch.setLength(ownerKey, i, len, anchor)
|
||||
-- ⭐⭐ [2026-08-27, 9라운드 Q3] 5번째 인자 `element` — **그 자리에 등록되는 요소
|
||||
-- (`inst|slot`)**. `gatedRecompute`가 인덱스 대신 이걸 캡처하고 `bk.indexOfElement`를
|
||||
-- 조회한다(아래). 길이가 상수인 자리(`NilHandler`/`NoneHandler`의 `0`)는 지속
|
||||
-- 클로저가 안 생기므로 생략해도 된다 — 그땐 캡처한 `i`가 그대로 유효하다.
|
||||
function Dispatch.setLength(ownerKey, i, len, anchor, element)
|
||||
anchor = anchor or ownerKey
|
||||
local bk = getBookkeeping(ownerKey) -- Relate(ownerKey) 기반, lazy 생성
|
||||
local blocker = getBlocker(ownerKey) -- Relate(ownerKey) 기반, lazy 생성(아래 절 참고)
|
||||
|
|
@ -2047,13 +2079,10 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
|
|||
|
||||
bk.lengthList[i] = len
|
||||
bk.N = math.max(bk.N or 0, i) -- [2026-08-18 3라운드] N 수명주기 — "저장 위치" 절 참고
|
||||
-- ⭐ [2026-08-25] 이 자리의 유일한 토큰 — 아래 클로저가 인덱스 대신 이걸 캡처한다.
|
||||
local token = bk.tokens[i]
|
||||
if token == nil then
|
||||
token = {}
|
||||
bk.tokens[i] = token
|
||||
end
|
||||
bk.indexOfToken[token] = i
|
||||
-- ⭐ [2026-08-27, 9라운드 Q3] 요소 → 인덱스 **등록**. 자리가 밀리면(splice/move)
|
||||
-- Slot 층의 `reindexFrom`이 이 맵을 다시 쓴다 — `lengthList`가 여기서 등록되고
|
||||
-- `spliceArrays*`가 옮기는 것과 같은 분담. `None`/`nil` 자리는 안 적는다.
|
||||
if element ~= nil then bk.indexOfElement[element] = i end
|
||||
-- [2026-08-24 `H-3`] 접두합 캐시를 여기까지 당긴다 — `i` 자리의 offset은
|
||||
-- `1..i-1`의 합이라 안 바뀌고, 바뀌는 건 그 **뒤**뿐(위 무효화 표).
|
||||
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, i) -- 무효화는 둘 다
|
||||
|
|
@ -2061,20 +2090,20 @@ function Dispatch.setLength(ownerKey, i, len, anchor)
|
|||
|
||||
local function gatedRecompute()
|
||||
-- ⭐ [2026-08-25, 7라운드 `H-102`] `i`를 **캡처하지 않는다** — splice가
|
||||
-- 자리를 당기면 박힌 인덱스가 낡는다. `bk`가 소유한 역방향 맵에서
|
||||
-- 현재 인덱스를 조회한다(`slot._elemIndex`와 같은 개념을 Dispatch로
|
||||
-- 격상한 것 — 위 `recompute` 절).
|
||||
-- 자리를 당기면 박힌 인덱스가 낡는다. **요소를 캡처**하고 `bk`가 소유한
|
||||
-- 역방향 맵에서 현재 인덱스를 조회한다(위 `recompute` 절의 `H-102` 항목).
|
||||
--
|
||||
-- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 이
|
||||
-- 자리의 토큰**이다. `len`은 자리마다 유일하지 않다 — 같은
|
||||
-- `Source`/`State`가 두 자리의 길이를 몰면 역방향 맵에서 **두
|
||||
-- 자리가 한 항목으로 접혀** 엉뚱한 인덱스를 무효화하고, 앞선
|
||||
-- 자리는 영영 다시 offset을 못 받는다. `token`은 등록 시점에
|
||||
-- 만드는 유일한 테이블로 `bk.tokens[i]`에 같이 저장되고 splice가
|
||||
-- 다른 배열과 **함께** 당긴다. (`slot._elemIndex`가 물리 요소를
|
||||
-- 키로 쓰는 건 요소가 자리마다 유일해서 성립하는 것 — 그 성질을
|
||||
-- Dispatch에선 토큰이 맡는다.)
|
||||
local cur = bk.indexOfToken[token]
|
||||
-- ⚠️ [2026-08-25 정정, `/code-review high`] 키는 **`len`이 아니라 요소**다.
|
||||
-- `len`은 자리마다 유일하지 않다 — 같은 `Source`/`State`가 두 자리의
|
||||
-- 길이를 몰면 역방향 맵에서 **두 자리가 한 항목으로 접혀** 엉뚱한
|
||||
-- 인덱스를 무효화하고, 앞선 자리는 영영 다시 offset을 못 받는다.
|
||||
-- 요소는 유일하다(Slot 안에선 `claimOwner`가 이중 배치를 막고 `None`은
|
||||
-- `_elements`에 안 들어간다; `inst`의 배열 자리는 애초에 안 밀린다).
|
||||
-- **⛔ [2026-08-27, 9라운드 Q3/`H-141`]** 여기 한때 그 키가 `token`
|
||||
-- (등록 시점에 만드는 빈 테이블 + `bk.tokens`/`bk.indexOfToken`)이었다 —
|
||||
-- 사용자가 정한 적 없는 신원이라 폐기됐다. 요소를 캡처하면 신원이 안
|
||||
-- 변하므로 **클로저 쪽에서 갱신할 것이 없다**.
|
||||
local cur = if element ~= nil then bk.indexOfElement[element] else i
|
||||
bk.offsetCacheValidUpTo = math.min(bk.offsetCacheValidUpTo, cur) -- 나중 emit도
|
||||
bk.offsetSetUpTo = math.min(bk.offsetSetUpTo, cur) -- 같은 무효화가 필요
|
||||
if blocker:IsOn() then return end -- 배치 등록 중
|
||||
|
|
@ -2142,6 +2171,12 @@ Slot 이 effect 나 다른 요소들을 소유할 수가 없다 … 실제 obser
|
|||
쓰는 자리 전부)는 **기존 3-인자 그대로 두면 된다** — 거기선 `ownerKey`가 곧
|
||||
물리 target이다. 4번째 인자를 실제로 넘겨야 하는 건 **`ownerKey`가 Slot인
|
||||
자리**(`materializeSlotTree`의 등록 루프, 런타임 `rawAdd`/`rawReplace`)뿐이다.
|
||||
- **[2026-08-27, 9라운드 Q3] 5번째 `element`는 `anchor`와 축이 다르다** —
|
||||
owner 종류가 아니라 **그 자리에 지속 등록(길이 State → Observer)이 생기는가**로
|
||||
갈린다. 중첩 Slot의 `.Length`를 넘기는 자리(`materializeSlotTree` 꼬리,
|
||||
최상위 `SlotHandler` 경로 포함)는 그 Slot 자신을 넘기고, plain 요소(`rawAdd`/
|
||||
`rawReplace`)는 그 요소를 넘긴다. `NilHandler`/`NoneHandler`처럼 상수 `0`인
|
||||
자리는 생략.
|
||||
|
||||
`:Subscribe()`/`:Unsubscribe()`(독립 경로)를 안 쓰는 이유: 이 Observer는
|
||||
본질적으로 `ownerKey` 하나에 종속된 내부 배관이라, `ownerKey`(물리 inst
|
||||
|
|
@ -2369,9 +2404,15 @@ quad-web의 해당 Handler는 offset 변경 관측 시 아무것도 안 하는 n
|
|||
채워주는 입력값, 순서 계산 전용 — 서로 다른 두 `Source<number>`.
|
||||
|
||||
**`Slot.Offset`도 `Slot.Length`와 마찬가지로 공개 필드(2026-08-11
|
||||
세션 명시화)** — Slot이 마운트되는 시점(`Dispatch/Slot.luau`가
|
||||
`setOffsetSource`를 등록하는 바로 그 자리)에 같은 Source 객체를
|
||||
`self.Offset`으로도 저장. **[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
|
||||
세션 명시화)** — **⭐ [2026-08-27 정정, 9라운드 `H-125`/Q2] `Length`와 같은
|
||||
자리, 즉 `Slot` 생성자에서 `Source(0)`으로 만든다**(여기 한때 *"Slot이
|
||||
마운트되는 시점에 `setOffsetSource`를 등록하는 바로 그 자리에서 같은 Source
|
||||
객체를 `self.Offset`으로도 저장"*이라고 적혀 있었다 — 그러면 첫 마운트와
|
||||
재마운트가 갈려 재마운트 캐시가 낡는다, `base/slot-plan.md`의
|
||||
`materializeSlotTree` 절). 마운트 시점엔 **이미 있는** `slot.Offset`을
|
||||
`setOffsetSource(ownerKey, position, slot.Offset)`로 등록만 한다. 아래 `D-60`/
|
||||
`SL-75`가 말한 "마운트 전엔 `0`"이 이걸로 코드에서도 성립한다.
|
||||
**[정정, 2026-08-20 구현 전 QA 4라운드 `D-60`/`SL-75`]
|
||||
마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다** —
|
||||
사용자 판정: *"마운트 전에는 0 이긴 함. 다만 list 의 관측으로 실체화된 값이
|
||||
나오는게 offset 설정 이후라서 그 땐 0 이 아닐 수 있을 뿐"*. 즉 `Offset`은 항상
|
||||
|
|
|
|||
|
|
@ -504,6 +504,33 @@ end
|
|||
다른 곳에 다시 넣으면 `attachSlot`이 새 물리 부모로 다시 flush함. 아무도
|
||||
안 들고 있으면 그냥 GC. 지금 확실히 죽이려면 `dispose`(아래 절).
|
||||
|
||||
**⭐ [2026-08-27 신설, 9라운드 Q2] 파괴된 Slot은 재사용 불가 — `slot._destroyed`.**
|
||||
지금까지 파괴 쪽 계약이 어디에도 없었다. `destroySlotTree`는 **`_elements`를
|
||||
안 비운다**(요소만 `nativeDispose`) — 파괴된 Slot은 죽은 Instance를 든 좀비이고,
|
||||
`_mounted`를 `false`로 되돌려놓으므로 *"마운트된 Slot의 재마운트는 즉시 throw"*
|
||||
가드에도 안 걸려 재마운트하면 죽은 Instance를 다시 `Parent` 대입한다.
|
||||
|
||||
- `destroySlotTree` 꼬리에서 `slot._destroyed = true`. **"파괴됨"은 이 플래그
|
||||
하나만 말한다** — 핸들(`_baseObserver`/`_listObserver`/`_listActivated`)을
|
||||
`nil`로 지워 그 뜻을 겸하게 하지 않는다(사용자: *"두 일을 겸하는걸 만들다가
|
||||
사고가 난 적 많아"* — `invalidAfter`가 그랬다). 핸들은 `unbindLifetime`만.
|
||||
- **`attachSlot`/`materializeSlotTree` 진입과 공개 CRUD(`:Add` 등) 진입에서
|
||||
`if self._destroyed then error(..., 2) end`** — 사용자 입력 검증이므로
|
||||
`level 2`, 메시지는 영어(`base/architecture.md`의 error 계약). CRUD까지 막는
|
||||
이유: `_elements`가 안 비워지므로 죽은 Slot에 `Add`하면 **조용히** 좀비 배열이
|
||||
자란다.
|
||||
- **`Owned = false`인 Slot은 `_destroyed`가 서지 않는다** — `destroySlotTree`가 그
|
||||
분기에서 `unmountSlotTree`로 빠져 꼬리(플래그 세팅)에 안 닿는다. 그 Slot은
|
||||
요소를 만든 적이 없으니 좀비가 없고 재사용도 그대로 가능하다 — **사용자
|
||||
확정**(*"owned = false 은 state<Frame> -> slot(single) 형태가 구현되는 것이라
|
||||
맞아"*). "파괴 대신 언마운트만"이라는 그 분기의 뜻 그대로.
|
||||
- **이중 `dispose`는 얼리리턴 no-op** — teardown 경로가 겹치는 건 실재하고
|
||||
GC-native 기조와 맞는다. 플래그 세팅도 얼리리턴도 `destroySlotTree` 쪽이라
|
||||
다형 진입점 `dispose(value)`는 공짜로 물려받는다. 이름은 `_disposed`가
|
||||
아니다 — 사용자: *"`dispose` 는 형질이 다른 엔진 요소를 포함할 수 있는 것에
|
||||
대한 공동 소멸자인 네이밍. 자신이 삭제되고 그 여부는 `destroy` 가 맞아보이고,
|
||||
`dispose` 는 슈거로써 `destroy` 와 별도의 맥락에서 해석해야해."*
|
||||
|
||||
**[전면 정정, 2026-08-13 감사] `claimOwner`를 nested/top-level 두 함수로
|
||||
쪼개고, top-level은 `(inst, k)`까지 본다.** 원래는 하나의 `claimOwner`가
|
||||
"같은 ownerKey면 `false` 반환(no-op)"을 양쪽에 공유했는데, 그 분기가
|
||||
|
|
@ -705,6 +732,12 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
|
|||
**인덱스 기준**으로 재확정 — 레퍼런스만 갖고 있으면 `IndexOf`로 먼저
|
||||
인덱스를 구하면 됨(아래):
|
||||
|
||||
**⭐ [2026-08-27, 9라운드 Q2] 아래 표의 mutate 연산 전부**(`Add`~`Swap` — 조회
|
||||
`Get`/`IndexOf` 제외)**가 진입 첫 줄에 `if self._destroyed then error(..., 2) end`를
|
||||
둔다.** 의사코드가 있는 건 `:Add`/`:List`뿐이라 거기만 적혀 있지만, 규칙은 표
|
||||
전체다 — `_elements`가 파괴 뒤에도 안 비워지므로 어느 하나라도 빠지면 죽은
|
||||
Slot의 좀비 배열이 조용히 자란다(아래 "파괴된 Slot은 재사용 불가" 절).
|
||||
|
||||
| 연산 | 시그니처 | 복잡도 | 의미 |
|
||||
|---|---|---|---|
|
||||
| `Add` | `Slot:Add(element, index?): number` | O(n) | 삽입(뒤 요소 밀림), `index` 생략 시 끝에 추가 — **실제로 삽입된 인덱스를 반환** |
|
||||
|
|
@ -869,9 +902,25 @@ Remove/Extract/Move하려 해도 참조를 안 들고 있는 경우가 잦음. `
|
|||
`Slot():Add(a):Add(b):Add(c)`와 같은 일을 하는 표기일 뿐이라서:
|
||||
```lua
|
||||
function Slot(initial)
|
||||
-- [2026-08-24 `H-1`] `_elements`와 짝인 역방향 맵 `_elemIndex`도 여기서
|
||||
-- 같이 만든다(둘은 항상 함께 산다 — 위 raw* 인자 규약).
|
||||
local self = setmetatable({ _elements = {}, _elemIndex = {}, ... }, Slot_mt)
|
||||
-- ⭐⭐ [2026-08-27, 9라운드 `H-125`/Q2] **`Offset`과 `_baseObserver`도 여기서
|
||||
-- 난다 — `Length`와 같은 자리.** 마운트 시점에 만들면 첫 마운트(생성 →
|
||||
-- "등록 즉시 1회"가 우연히 캐시를 0으로)와 재마운트(`bindLifetime`만 →
|
||||
-- 바인드는 발화가 아니라 안 함)가 갈려 재마운트 캐시가 낡았다. 이제 그
|
||||
-- 분기가 없다. 둘 다 **bind/unbind로만 관리하고 제거/생성하지 않는다**
|
||||
-- (사용자: *"단순히 bind/unbind 로 관리해야지 그것 자체를 제거/생성
|
||||
-- 하는건 안 맞아보임"*). `SL-75`/`D-60`의 "마운트 전엔 `0`"이 이걸로
|
||||
-- 코드에서도 성립한다. 콜백 본문은 아래 `materializeSlotTree` 절의
|
||||
-- `makeBaseObserver`가 소스(머리에 미실체화 가드 — 이 "등록 즉시 1회"가
|
||||
-- `bk`를 eager 생성하지 않도록).
|
||||
-- **[2026-08-27, Q3] 옛 `_elemIndex`는 여기 없다** — 그 맵은
|
||||
-- `bk.indexOfElement`로 Dispatch 부기에 산다(`indexOfRaw`가 조회).
|
||||
local self = setmetatable({
|
||||
_elements = {},
|
||||
Length = Source(0),
|
||||
Offset = Source(0),
|
||||
...
|
||||
}, Slot_mt)
|
||||
self._baseObserver = makeBaseObserver(self) -- 등록 즉시 1회는 가드가 삼킨다
|
||||
if initial ~= nil then
|
||||
self._crudUsed = true -- 빈 테이블이어도 즉시 잠금(아래 참고)
|
||||
for _, v in ipairs(initial) do -- ipairs가 첫 nil에서 멈춤
|
||||
|
|
@ -1294,6 +1343,7 @@ GC-native 원칙(`lifecycle-pattern.md`)을 `:List`라는 구체적 지점에
|
|||
|
||||
```lua
|
||||
function Slot:List(data, updateFn, keyFn, opts)
|
||||
if self._destroyed then error("Slot: destroyed Slot cannot be reused", 2) end -- [2026-08-27 Q2]
|
||||
assert(not self._listed, "Slot already has :List installed")
|
||||
-- [2026-08-24 6라운드 손 트레이싱 `H-37`] **역방향 가드** — 산문(위 "CRUD API
|
||||
-- 확정" 절)이 확정해둔 대칭 가드가 의사코드에 안 옮겨져 있었다. 이게 없으면
|
||||
|
|
@ -1373,7 +1423,8 @@ function activateList(self, physicalTarget)
|
|||
-- 옛 설계는 `keyIndex[key]`를 `_elements` 인덱스로 쓰고 `raw*`에 그대로
|
||||
-- 넘겼는데, 그 값은 **사이클 도중엔 stale**이다(`rawAdd`/`rawRemove`가
|
||||
-- 배열을 시프트하는데 교체는 사이클 끝에 한 번뿐). 이제 인덱스는
|
||||
-- `indexOfRaw(self, element)`(= `slot._elemIndex` 조회)로 그때그때 구하고,
|
||||
-- `indexOfRaw(self, element)`(= `bk.indexOfElement` 조회 — **[2026-08-27 Q3]**
|
||||
-- 옛 `slot._elemIndex`는 Dispatch 부기로 갔다)로 그때그때 구하고,
|
||||
-- 여기 남는 건 **"직전 사이클에 존재했던 키"**라는 집합 용도뿐이다.
|
||||
local mounted, userdata, prevKeys = {}, {}, {}
|
||||
|
||||
|
|
@ -2203,21 +2254,68 @@ weak 키로 받음) — **Slot 자신을 owner 키로 재사용하면 최상위
|
|||
-- 근거와 대안 비교는 `reference/slot-attach-decomposition.md`.
|
||||
|
||||
-- (1) 부기만 만든다. 물리 마운트(`Parent` 대입)를 단 한 줄도 안 한다.
|
||||
-- ⭐⭐ [2026-08-27, 9라운드 `H-125`/Q2] `_baseObserver`의 콜백 — **Slot 생성자가
|
||||
-- 만든다**(위 `Slot(initial)`). 이 함수는 생성자가 부르는 팩토리일 뿐이고, 여기
|
||||
-- 두는 이유는 본문이 `bk`·`recompute`를 알아야 해서다.
|
||||
local function makeBaseObserver(slot)
|
||||
return slot.Offset:Observer(function()
|
||||
-- ⭐ 미실체화면 할 일이 없다 — 생성자의 "등록 즉시 1회"를 여기서 삼킨다.
|
||||
-- 이 가드가 없으면 그 1회가 **모든 Slot**에 대해 `getBookkeeping`을
|
||||
-- 불러 `bk` + `recomputeBlocker`를 eager 생성한다(한 번도 마운트 안 되는
|
||||
-- Slot까지). 의미도 맞다 — 미실체화 Slot의 베이스 변경엔 할 일이 없다.
|
||||
if slot._physicalTarget == nil then return end
|
||||
-- ⭐ [2026-08-26, 8라운드 `H-119`/`H-3`] 두 줄이 빠져 있었다:
|
||||
-- (1) 배치 Blocker만 보고 `recomputeBlocker`를 안 봐서 재진입 차단이
|
||||
-- 우회됐고, (2) `H-3`의 3번("베이스가 바뀐 경우라
|
||||
-- 두 필드 `0`")이 의사코드에 없었다.
|
||||
local bk = getBookkeeping(slot)
|
||||
-- [2026-08-26] 무효화는 **두 필드를 다** 내린다(캐시도 낡고 Set도
|
||||
-- 다시 해야 한다) — `dispatch-core-plan.md`의 "두 필드" 절.
|
||||
bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 -- 베이스가 바뀜 → 1번부터 전부
|
||||
if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end
|
||||
recompute(slot, bk)
|
||||
end)
|
||||
end
|
||||
|
||||
local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
||||
-- [2026-08-27, 9라운드 Q2] 파괴된 Slot은 마운트 불가(위 "파괴된 Slot은 재사용
|
||||
-- 불가" 절). `attachSlot`이 이 함수를 거치므로 여기 한 번이면 된다.
|
||||
if slot._destroyed then error("Slot: destroyed Slot cannot be mounted", 2) end
|
||||
-- [2026-08-24 6라운드 `H-2`] **앵커를 먼저 저장한다.** `setLength`의 4번째
|
||||
-- 인자(그리고 length가 State일 때 `bk.observers`의 앵커)가 실체화 시점부터
|
||||
-- 필요한데 `_mountedInst`는 `mountSlotTree`에서야 채워진다. `raw*`가 이
|
||||
-- 필드를 본다(위 "`isMounted` 이중 추적 분리" 절의 3상태).
|
||||
slot._physicalTarget = physicalTarget
|
||||
-- offset 먼저 — activateList가 updateFn에 이 값을 넘겨야 하므로(C1)
|
||||
-- [2026-08-21 5라운드 G절 검토 중 발견] **기존 Source가 있으면 재사용한다.**
|
||||
-- 매번 `Source(0)`을 새로 만들면, 언마운트가 `slot.Offset`을 일부러 보존해둔
|
||||
|
||||
-- ⭐⭐ [2026-08-27 순서 재배치, 9라운드 `H-125`/Q2] **게이트와 관측자 바인드가
|
||||
-- `setOffsetSource`(= 베이스가 바뀌는 emit) *위*로 왔다.** 옛 순서는
|
||||
-- `setOffsetSource` → `blocker:On()` → (`_baseObserver`가 있으면 bind /
|
||||
-- 없으면 생성)이었는데, 재마운트에선 그 관측자가 `unmountSlotTree`가
|
||||
-- unbind해둔 상태라 emit이 `canExecute`에 걸러져 **두 필드가 0으로 안
|
||||
-- 내려가고**, 꼬리 `recompute`가 옛 베이스의 `offsetCache[1]`을 그대로 썼다
|
||||
-- (첫 마운트는 "등록 즉시 1회"가 *우연히* 0으로 만들어 안 보였다 — 그
|
||||
-- 비대칭이 버그의 집). 이제 `Offset`/`_baseObserver`는 생성자에서 나므로
|
||||
-- 여기엔 생성 분기가 없고, 바인드는 **발화가 아니므로** 순서가 곧 계약이다.
|
||||
-- Blocker가 감싸는 게 **등록뿐**이라 "배치 등록 게이팅"이라는 정의와 범위가
|
||||
-- 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
|
||||
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
|
||||
blocker:On()
|
||||
-- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가
|
||||
-- 밀리면 내 자식들의 offset도 다시 계산돼야 한다. `_listObserver`/
|
||||
-- `_detachCleanup`과 **같은 취급**: 재마운트 땐 앵커만 새 target으로(앵커가
|
||||
-- 물리 target인 근거는 위 `C-4`). **바인드가 `blocker:On()` *뒤*인 게
|
||||
-- 중요하다** — 아래 emit이 깨운 콜백이 게이트 없이 `recompute(slot)`를
|
||||
-- 완주하면, 그 시점 `bk`는 언마운트 전 옛 부기(`Relate(slot)` 위에
|
||||
-- 살아남는다)라 옛 `N`·옛 자식 목록으로 돈다. 이 Blocker는 이 Slot의 것이고
|
||||
-- `setOffsetSource`는 **부모 owner의** blocker를 보므로 간섭이 없다.
|
||||
bindLifetime(physicalTarget, slot._baseObserver)
|
||||
-- offset — activateList가 updateFn에 이 값을 넘겨야 하므로(C1). 여기서
|
||||
-- `slot.Offset`이 새 베이스로 `Set`되고, 위 관측자가 두 필드를 0으로 내린 뒤
|
||||
-- 게이트에 걸려 돌아온다. **이미 있는 Source를 등록만 한다** — 매번
|
||||
-- `Source(0)`을 새로 만들면 언마운트가 `slot.Offset`을 일부러 보존해둔
|
||||
-- 이유(이미 렌더된 요소들이 그 Source를 **구독한 채 함께 딸려 나간다**,
|
||||
-- `SL-75`/`DC-6`)가 재마운트에서 그대로 무너진다 — 그 구독자들은 옛 객체를
|
||||
-- 계속 보고 있어 새 위치가 영원히 반영 안 됨. identity 유지가 포탈의 전제.
|
||||
local offsetSource = slot.Offset or Source(0)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource)
|
||||
slot.Offset = offsetSource
|
||||
-- `SL-75`/`DC-6`)가 무너진다. identity 유지가 포탈의 전제.
|
||||
Dispatch.setOffsetSource(ownerKey, position, slot.Offset)
|
||||
-- [2026-08-21 5라운드 G절] `recompute(slot, ...)`은 `0`이 아니라 **이
|
||||
-- `slot.Offset`**에서 시작한다 — 그래야 자식 offset이 절대값이 된다(안 그러면
|
||||
-- depth ≥ 2에서 부모 베이스만큼 어긋남). 베이스를 부기에 따로 복사해두지
|
||||
|
|
@ -2231,44 +2329,12 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
-- `Dispatch.getOffsetAt`이 성립하고, `updateFn`에 넘기는 `index`를 거기서
|
||||
-- 구할 수 있다(아래 "`:List`의 `index`도 nested-Slot 결과의" 절).
|
||||
|
||||
-- 자식 부기만, 재귀로 길이를 bottom-up 확정.
|
||||
-- Blocker가 감싸는 게 이제 **등록뿐**이라 "배치 등록 게이팅"이라는
|
||||
-- 정의와 범위가 정확히 일치함(옛 코드는 물리 마운트까지 같이 감쌌음).
|
||||
local blocker = getBlocker(slot) -- Relate(slot) 기반, lazy 생성 — 이 Slot 전용
|
||||
blocker:On()
|
||||
|
||||
-- **⭐ [2026-08-24 순서 변경, `H-2`] `activateList`가 이제 Blocker *안*에서
|
||||
-- 돈다.** 5라운드 `AS-5`가 이걸 밖에 둬도 된다고 한 근거는 *"그 안에선
|
||||
-- 게이팅할 recompute 자체가 안 일어난다"*였는데, 위 정정으로 일어나게
|
||||
-- 됐다 — 밖에 두면 population 중 아이템마다 `recompute`가 돌아 O(n²)다.
|
||||
-- (`getOffsetAt`은 `lengthList`를 직접 읽으므로 게이트 안에서도 정확하다 —
|
||||
-- Blocker가 막는 건 `recompute`지 부기 등록이 아니다.)
|
||||
|
||||
-- [2026-08-21 5라운드 G절] **깊은 전파** — 앞 형제의 길이가 변해 내 베이스가
|
||||
-- 밀리면 내 자식들의 offset도 다시 계산돼야 한다.
|
||||
-- `_listObserver`/`_detachCleanup`과 **같은 취급**: 생성 1회, 재마운트 땐
|
||||
-- 앵커만 새 target으로(앵커가 물리 target인 근거는 위 `C-4`).
|
||||
-- **생성이 `blocker:On()` *뒤*인 게 중요하다** — Observer의 "등록 즉시 1회
|
||||
-- 실행"이 여기서 곧바로 `recompute`를 태우면 아직 자식 등록이 하나도 안 된
|
||||
-- 상태를 훑게 된다. 게이트 안에서 만들면 그 1회가 그냥 삼켜지고, 아래
|
||||
-- 마지막 `recompute`가 어차피 정확한 값을 계산한다.
|
||||
if slot._baseObserver then
|
||||
bindLifetime(physicalTarget, slot._baseObserver)
|
||||
else
|
||||
slot._baseObserver = offsetSource:Observer(function()
|
||||
-- ⭐ [2026-08-26, 8라운드 `H-119`/`H-3`] 두 줄이 빠져 있었다:
|
||||
-- (1) 배치 Blocker만 보고 `recomputeBlocker`를 안 봐서 재진입 차단이
|
||||
-- 우회됐고, (2) `H-3`의 3번("베이스가 바뀐 경우라
|
||||
-- 두 필드 `0`")이 의사코드에 없었다.
|
||||
local bk = getBookkeeping(slot)
|
||||
-- [2026-08-26] 무효화는 **두 필드를 다** 내린다(캐시도 낡고 Set도
|
||||
-- 다시 해야 한다) — `dispatch-core-plan.md`의 "두 필드" 절.
|
||||
bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0 -- 베이스가 바뀜 → 1번부터 전부
|
||||
if getBlocker(slot):IsOn() or bk.recomputeBlocker:IsOn() then return end
|
||||
recompute(slot, bk)
|
||||
end)
|
||||
bindLifetime(physicalTarget, slot._baseObserver)
|
||||
end
|
||||
-- **⭐ [2026-08-24 분기, `H-2`. 같은 날 `/code-review high` 지적으로 조건 정정]**
|
||||
-- `activateList`가 **최초 population을 실제로 수행할 때만** 이 루프를
|
||||
-- 건너뛴다 — 그때는 그 안의 `rawAdd`가 이미 자리마다 등록을 마쳤으므로
|
||||
|
|
@ -2293,7 +2359,7 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
-- 평범한 요소: 자기 자리의 offset은 아무도 안 읽으므로 None,
|
||||
-- length는 상수 1. 순서는 늘 offsetSource → setLength(C4).
|
||||
Dispatch.setOffsetSource(slot, i, None)
|
||||
Dispatch.setLength(slot, i, 1, physicalTarget) -- 4번째 = 생명주기 앵커(5라운드 `C-4`)
|
||||
Dispatch.setLength(slot, i, 1, physicalTarget, element) -- 4번째 = 생명주기 앵커(5라운드 `C-4`), 5번째 = 요소(9라운드 Q3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -2318,8 +2384,9 @@ local function materializeSlotTree(slot, physicalTarget, ownerKey, position)
|
|||
|
||||
-- 자기 길이를 부모에게. 이제 **처음부터 최종값**이고(C6), 동시에
|
||||
-- 어떤 Parent 대입보다도 먼저다(C7) — 단일 함수로는 둘을 동시에
|
||||
-- 만족시킬 수 없었던 지점.
|
||||
Dispatch.setLength(ownerKey, position, slot.Length, physicalTarget)
|
||||
-- 만족시킬 수 없었던 지점. 5번째 = 이 Slot 자신(9라운드 Q3 — 길이가 State라
|
||||
-- 지속 등록이 생기는 자리, 클로저가 `bk.indexOfElement[slot]`을 조회한다).
|
||||
Dispatch.setLength(ownerKey, position, slot.Length, physicalTarget, slot)
|
||||
end
|
||||
|
||||
-- (2) 물리만 붙인다. 부기를 단 한 줄도 안 건드린다 → Blocker 불필요.
|
||||
|
|
@ -2483,7 +2550,7 @@ local function unmountSlotTree(slot)
|
|||
-- 멱등 가드다. 파괴 쪽(`destroySlotTree`)만 핸들까지 `nil`로 지운다.
|
||||
if slot._listObserver then unbindLifetime(slot._listObserver) end
|
||||
if slot._detachCleanup then unbindLifetime(slot._detachCleanup) end
|
||||
if slot._baseObserver then unbindLifetime(slot._baseObserver) end -- [2026-08-21 G절]
|
||||
unbindLifetime(slot._baseObserver) -- [2026-08-21 G절; 2026-08-27 Q2] 생성자에서 나므로 항상 있다 — 가드 없음
|
||||
-- `slot._detached`는 **안 건드린다** — 언마운트는 파괴가 아니고,
|
||||
-- 재마운트되면 그대로 이어져야 한다(`_elements`를 보존하는 것과 같은 이유).
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
|
|
@ -2497,6 +2564,9 @@ local function unmountSlotTree(slot)
|
|||
end
|
||||
|
||||
local function destroySlotTree(slot)
|
||||
-- [2026-08-27, 9라운드 Q2] 이중 파괴는 no-op — teardown 경로가 겹치는 건
|
||||
-- 실재한다(`dispose`가 이 함수를 부르므로 이중 `dispose`도 여기서 흡수).
|
||||
if slot._destroyed then return end
|
||||
-- [2026-08-21] Owned=false면 이 Slot은 요소를 만든 적이 없다 — 파괴 대신
|
||||
-- 언마운트만(위 "`Owned` 옵션" 절). `Slot:Add(state)` sugar가 그 경우.
|
||||
if slot._owned == false then
|
||||
|
|
@ -2535,14 +2605,18 @@ local function destroySlotTree(slot)
|
|||
-- 값을 재귀에 그대로 넘김) **자식 Slot만 죽고 그 inst는 살아있는 게 흔한
|
||||
-- 경우**다. 그러면 `data`가 emit될 때마다 이미 죽은 자식들에 대해
|
||||
-- reconcile이 계속 돈다.
|
||||
if slot._listObserver then
|
||||
unbindLifetime(slot._listObserver)
|
||||
slot._listObserver, slot._listActivated = nil, nil
|
||||
end
|
||||
if slot._baseObserver then -- [2026-08-21 G절] 파괴는 핸들까지
|
||||
unbindLifetime(slot._baseObserver)
|
||||
slot._baseObserver = nil
|
||||
end
|
||||
-- ⭐⭐ [2026-08-27 정정, 9라운드 Q2] **핸들은 unbind만 하고 지우지 않는다.**
|
||||
-- 여기 한때 `slot._listObserver, slot._listActivated = nil, nil`과
|
||||
-- `slot._baseObserver = nil`이 있었다 — 근거(*"안 풀면 `gchold[physicalTarget]`이
|
||||
-- observer를 계속 강하게 붙잡는다"*)는 실은 `unbindLifetime`이 하는 일이고,
|
||||
-- `nil` 대입은 slot → observer 참조 하나를 놓는 것뿐인데 slot 자신이
|
||||
-- 쓰레기라 공짜다. 대신 그 `nil`이 **"파괴됨"이라는 두 번째 뜻**을 세 필드에
|
||||
-- 겸하게 했고(`_listObserver == nil`은 원래 "`data`가 plain table",
|
||||
-- `_listActivated == nil`은 "최초 population 전"이라는 자기 뜻이 있다) —
|
||||
-- `invalidAfter`가 두 뜻을 겸하다 사고 난 것과 같은 모양이라 그만둔다.
|
||||
-- 파괴됨은 아래 `_destroyed` 하나만 말한다.
|
||||
if slot._listObserver then unbindLifetime(slot._listObserver) end
|
||||
unbindLifetime(slot._baseObserver) -- 생성자에서 났으므로 항상 있다(Q2)
|
||||
local bk = getBookkeeping(slot) -- 이 slot이 자기 자식들 위해 등록해둔 observer들
|
||||
for i, observer in pairs(bk.observers) do -- [2026-08-26] 가드 제거(위와 같은 이유)
|
||||
unbindLifetime(observer)
|
||||
|
|
@ -2552,6 +2626,10 @@ local function destroySlotTree(slot)
|
|||
-- 영원히 걸리고, `_mountedInst`가 죽은 inst를 계속 강하게 붙잡음.
|
||||
slot._mounted, slot._mountedInst = false, nil
|
||||
slot._physicalTarget = nil -- [2026-08-24 `H-2`] 같은 이유로 앵커도
|
||||
-- ⭐ [2026-08-27, 9라운드 Q2] 파괴됨은 이 플래그 하나가 말한다 — 마운트·공개
|
||||
-- CRUD·`:List` 진입이 이걸 보고 error한다(위 "파괴된 Slot은 재사용 불가" 절).
|
||||
-- `_mounted = false`만으로는 재마운트 throw 가드에 안 걸리는 게 문제였다.
|
||||
slot._destroyed = true
|
||||
-- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은
|
||||
-- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도
|
||||
-- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번.
|
||||
|
|
@ -2576,7 +2654,15 @@ end
|
|||
-- `keyIndex` 값은 전부 어긋나 있다(`[a,b]`→`[x,a,b]`가 조용히 `a,b,x`가
|
||||
-- 되고, 전체 삭제는 해시 순회 순서에 따라 `nil` 인덱싱까지 간다).
|
||||
-- * **확정된 해법 — 역방향 인덱스 맵을 raw 층이 직접 든다.**
|
||||
-- **`slot._elemIndex`**(`물리 요소 → _elements 인덱스`)를 `_elements`와
|
||||
-- **⭐ [2026-08-27 이동, 9라운드 Q3] 그 맵은 이제 `bk.indexOfElement`
|
||||
-- (Dispatch 부기)다** — 여기 한때 `slot._elemIndex`였는데, 7라운드 `H-102`가
|
||||
-- 같은 뜻의 맵을 Dispatch 층에 따로 만들어(그것도 사용자가 정한 적 없는
|
||||
-- `token`으로) **같은 맵이 두 층에** 살게 됐고, 그게 사고의 원인이었다
|
||||
-- (사용자: *"elem->index 를 누가 관리하느냐가 어디서 관리하느냐가 명확하지
|
||||
-- 않아서 자꾸 사고가 나는듯"*). 소유는 `bk` 하나, owner가 Slot이든 `inst`든
|
||||
-- 규칙 하나. raw 층은 `getBookkeeping(self).indexOfElement`를 **읽고
|
||||
-- 갱신**한다 — 아래 항목들의 "맵"은 전부 이것.
|
||||
-- (`물리 요소 → _elements 인덱스`)를 `_elements`와
|
||||
-- 같은 수명으로 두고, **자리를 밀거나 당기는 모든 연산**
|
||||
-- (`spliceArraysUp`/`spliceArraysDown`/`rawMove`/`rawSwap`/`rawReplace`/
|
||||
-- `rawAdd`)이 같이 갱신한다. detach된 요소는 `_elements` 밖이므로
|
||||
|
|
@ -2586,9 +2672,10 @@ end
|
|||
-- - **`indexOfRaw(self, element)`가 이 맵 조회가 되고, 폴백이 아니라
|
||||
-- 기본 경로로 승격된다.** `settle`은 `keyIndex[key]` 대신
|
||||
-- `indexOfRaw(self, prev)`를 쓴다.
|
||||
-- - **층 분리는 오히려 더 깨끗해진다** — 맵이 `_elements`와 같은 층에
|
||||
-- 살아서 `raw*`가 `:List`의 클로저 상태(`mounted`/`prevKeys`)를 볼
|
||||
-- 필요가 없다. **사용자 판단**(2026-08-24): *"raw* 가 층위를 알아야할
|
||||
-- - **층 분리는 오히려 더 깨끗해진다** — 맵이 `raw*`가 이미 만지는 부기
|
||||
-- (`bk`)에 살아서 `raw*`가 `:List`의 클로저 상태(`mounted`/`prevKeys`)를 볼
|
||||
-- 필요가 없다(**[2026-08-27]** 원문은 *"`_elements`와 같은 층에"*였다 —
|
||||
-- 맵이 `bk`로 가도 이 논거는 그대로다). **사용자 판단**(2026-08-24): *"raw* 가 층위를 알아야할
|
||||
-- 이유를 모르겠는 상태. 그냥 k->realElem 을 list 에선 저장하고 …
|
||||
-- realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸
|
||||
-- 같이 업데이트해주는 편이 나아보이기도. quad-base 의 현 모양에서 더
|
||||
|
|
@ -2622,7 +2709,7 @@ function rawUnmount(self, index)
|
|||
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
|
||||
end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
|
||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.indexOfElement/bk.N — 아래 참고
|
||||
-- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다.
|
||||
-- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`로
|
||||
-- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`).
|
||||
|
|
@ -2647,8 +2734,8 @@ function rawDetach(self, index)
|
|||
nativeExtract(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
|
||||
end
|
||||
|
||||
spliceArraysDown(self, index) -- `_elemIndex`/`bk.tokens`/`bk.indexOfToken`에서도
|
||||
-- 이 요소·자리가 빠진다(트리 밖이 됨) — 아래 splice 요구 목록
|
||||
spliceArraysDown(self, index) -- `bk.indexOfElement`에서도 이 요소·자리가
|
||||
-- 빠진다(트리 밖이 됨) — 아래 splice 요구 목록
|
||||
-- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다.
|
||||
-- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`로
|
||||
-- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`).
|
||||
|
|
@ -2694,10 +2781,13 @@ end
|
|||
-- `rawMove`/`rawSwap`/`rawExtract`/`rawSplice`/`rawClear`는 이름만 있었다
|
||||
-- (`rawMove`는 `reconcile`이 **직접 부르는** 함수인데도). 새 결정이 필요한 건
|
||||
-- 위 `collectLeaves` 하나였고, 나머지는 아래 규약대로 쓰면 된다:
|
||||
-- 1. **함께 치환되는 것** — `_elements`, `slot._elemIndex`(`H-1`),
|
||||
-- 1. **함께 치환되는 것** — `_elements`, `bk.indexOfElement`(`H-1`,
|
||||
-- **[2026-08-27 Q3]** 옛 `slot._elemIndex` — `reindexFrom`이 갱신),
|
||||
-- `bk.lengthList`, `bk.sourceList`, `bk.observers`. 넷 다 **position
|
||||
-- 인덱스**이고 `sourceList[i]`는 그 중첩 Slot 자신의 `slot.Offset`이라
|
||||
-- position이 아니라 **요소에 귀속**된다 → 전부 같은 순열로 움직인다.
|
||||
-- (**[2026-08-27]** 옛 `bk.tokens`/`bk.indexOfToken`은 폐기 — 이동 구간을
|
||||
-- 규약 4가 `setLength`로 다시 태우므로 요소 키 맵은 거기서 다시 쓰인다.)
|
||||
-- 2. **`bk.N`은 안 변한다** — 자리 수가 그대로이므로(`spliceArraysUp`/`Down`과
|
||||
-- 갈리는 지점).
|
||||
-- ⚠️ [2026-08-26 범위 정정, 5·6차 `/code-review high`] **`rawSplice`·
|
||||
|
|
@ -2752,16 +2842,19 @@ end
|
|||
-- 통째로 지웠었다.** 상태는 **셋**인데(미실체화/실체화/마운트) `_mounted` 경계만
|
||||
-- 코드에 남기고 **첫 경계를 안 뒀다** — 그러면 `Slot { frameA }` 생성자가
|
||||
-- (그 자체가 `:Add`를 부른다, 위 그 절) `materializeSlotTree`보다 훨씬 먼저
|
||||
-- `Dispatch.setLength(self, 1, 1, nil)` → `gatedRecompute` → `getOffsetAt` →
|
||||
-- `ownerKey.Offset:Get()`에서 **`slot.Offset`이 아직 nil이라 즉시 죽는다.**
|
||||
-- 중첩이면 더 나쁘다: `attachSlot(element, nil, …)` → `bindLifetime(nil, …)`은
|
||||
-- 이 문서가 스스로 치명적이라 적어둔 그것이다.
|
||||
-- 부기를 시도한다. (**[2026-08-27 근거 정정, 9라운드 Q2]** 여기 한때 *"`getOffsetAt`
|
||||
-- → `ownerKey.Offset:Get()`에서 `slot.Offset`이 아직 nil이라 즉시 죽는다"*가
|
||||
-- 근거였는데, `Offset`이 생성자에서 나면서 **그 근거는 거짓**이 됐다 — 남는
|
||||
-- 근거는 아래 하나다.) 중첩이면 치명적이다: `attachSlot(element, nil, …)` →
|
||||
-- `bindLifetime(nil, …)`은 이 문서가 스스로 치명적이라 적어둔 그것이다.
|
||||
-- **`_physicalTarget`이 곧 "실체화됐는가"의 판정이다.**
|
||||
function rawAdd(self, element, index, fromDetached)
|
||||
claimOwner(element, self, fromDetached) -- 이미 누가 갖고 있으면 error(detach 재마운트만 예외)
|
||||
index = index or (#self._elements + 1)
|
||||
table.insert(self._elements, index, element)
|
||||
reindexFrom(self, index) -- `_elemIndex` 갱신 — **상태와 무관하게 항상**
|
||||
reindexFrom(self, index) -- `bk.indexOfElement` 갱신 — **상태와 무관하게 항상**
|
||||
-- ([2026-08-27 Q3] 그래서 미실체화 Slot도 `:Add`만 하면
|
||||
-- `bk`가 생긴다 — `getBookkeeping`은 lazy라 안전)
|
||||
|
||||
if self._physicalTarget == nil then
|
||||
-- **아직 실체화 전: 부기의 앵커도 베이스 offset도 없다.** `_elements`
|
||||
|
|
@ -2788,7 +2881,7 @@ function rawAdd(self, element, index, fromDetached)
|
|||
if self._mounted then -- [`H-12`] 물리 op만 가른다
|
||||
nativeInsert(self._mountedInst, Dispatch.getOffsetAt(self, index), { element })
|
||||
end
|
||||
Dispatch.setLength(self, index, 1, self._physicalTarget) -- **뒤를 미는 것**은 그 다음
|
||||
Dispatch.setLength(self, index, 1, self._physicalTarget, element) -- **뒤를 미는 것**은 그 다음 (5번째 = 요소, 9라운드 Q3)
|
||||
-- [`H-19`] 명시 `recompute(self, bk)`를 **삭제했다** — `setLength`가 상수
|
||||
-- 길이에도 마지막에 `gatedRecompute()`를 부르므로 두 번 돌고 있었고,
|
||||
-- "recompute를 누가 부르는가"의 소스가 두 곳이면 `H-3`의 캐시 무효화를
|
||||
|
|
@ -2800,7 +2893,8 @@ end
|
|||
-- [신설, 2026-08-21 5라운드 `B-5`] rawReplace — 자리를 유지한 채 내용만 교체.
|
||||
-- `destroyOld`는 호출부가 정한다(공개 `Replace`는 항상 true, `:List`는 `_owned`).
|
||||
-- [2026-08-21, **정정 2026-08-24 `H-1`**] `indexOfRaw(self, element)` —
|
||||
-- **`self._elemIndex[element]` 한 번 조회**. 공개 `Slot:IndexOf`와 다른 점은
|
||||
-- **`getBookkeeping(self).indexOfElement[element]` 한 번 조회**(**[2026-08-27 Q3]**
|
||||
-- 옛 `self._elemIndex`). 공개 `Slot:IndexOf`와 다른 점은
|
||||
-- 그대로다: 그쪽은 사용자가 넘긴 **언래핑된 값**으로 찾아주는 API고(위
|
||||
-- "래핑/언래핑" 절), 이쪽은 물리 요소를 그대로 찾는다.
|
||||
-- **옛 서술("O(n) 선형 탐색하는 예외 경로용 폴백, reconcile은 `keyIndex`가
|
||||
|
|
@ -2813,8 +2907,11 @@ function rawReplace(self, index, newElement, destroyOld)
|
|||
releaseOwner(oldElement, self)
|
||||
self._elements[index] = newElement -- **시프트 없음** — 자리 수가 안 변한다
|
||||
-- [2026-08-24 `H-1`] 역방향 맵도 같이 — 자리 수는 안 변해도 **주인이 바뀐다**
|
||||
self._elemIndex[oldElement] = nil
|
||||
self._elemIndex[newElement] = index
|
||||
-- ([2026-08-27 Q3] 맵은 `bk.indexOfElement`. 아래 `setLength(…, newElement)`가
|
||||
-- 새 주인을 등록하지만, 미실체화 분기는 `setLength`를 안 부르므로 여기서 직접)
|
||||
local bk0 = getBookkeeping(self)
|
||||
bk0.indexOfElement[oldElement] = nil
|
||||
bk0.indexOfElement[newElement] = index
|
||||
|
||||
if not self._mounted then
|
||||
if destroyOld then -- 물리 트리 밖이라 native* 교체가 필요 없다
|
||||
|
|
@ -2827,7 +2924,7 @@ function rawReplace(self, index, newElement, destroyOld)
|
|||
if isSlot(newElement) then attachSlot(newElement, self._physicalTarget, self, index, false)
|
||||
else
|
||||
Dispatch.setOffsetSource(self, index, None)
|
||||
Dispatch.setLength(self, index, 1, self._physicalTarget)
|
||||
Dispatch.setLength(self, index, 1, self._physicalTarget, newElement) -- [Q3] 5번째 = 요소
|
||||
end
|
||||
end
|
||||
return
|
||||
|
|
@ -2856,7 +2953,7 @@ function rawReplace(self, index, newElement, destroyOld)
|
|||
local op = if destroyOld then nativeRemove else nativeExtract
|
||||
op(self._mountedInst, offset, { oldElement }, { newElement }) -- 한 번에
|
||||
end
|
||||
Dispatch.setLength(self, index, 1, self._physicalTarget)
|
||||
Dispatch.setLength(self, index, 1, self._physicalTarget, newElement) -- [Q3] 5번째 = 요소
|
||||
-- [`H-19`] 명시 `recompute(self, bk)` 삭제 — `setLength`에 일임(위 `rawAdd` 참고)
|
||||
end
|
||||
|
||||
|
|
@ -2887,7 +2984,7 @@ function rawRemove(self, index)
|
|||
nativeDispose(element) -- 트리 밖이라 offset이 필요 없다
|
||||
end
|
||||
|
||||
spliceArraysDown(self, index) -- _elements/_elemIndex/lengthList/sourceList/observers/bk.tokens/bk.indexOfToken/bk.N — 아래 참고
|
||||
spliceArraysDown(self, index) -- _elements/lengthList/sourceList/observers/bk.indexOfElement/bk.N — 아래 참고
|
||||
-- ⭐ [2026-08-26, 8라운드 `H-119`] 명시 호출도 재진입 게이트를 먼저 본다.
|
||||
-- 건너뛴 몫은 위 spliceArraysDown이 당겨둔 `bk.offsetSetUpTo`로
|
||||
-- 바깥 `recompute`의 되감기가 복구한다(`dispatch-core-plan.md`).
|
||||
|
|
@ -2898,17 +2995,22 @@ end
|
|||
```
|
||||
|
||||
**⭐ [2026-08-24 6라운드 손 트레이싱 `H-1`/`H-5`/`H-3`] `spliceArraysUp`/
|
||||
`spliceArraysDown`이 해야 하는 일이 셋 늘었다.**
|
||||
`spliceArraysDown`이 해야 하는 일 — 아래 목록이 소스다**(**[2026-08-27, 9라운드
|
||||
`H-132`]** 한때 "셋 늘었다"고 세어뒀는데 그 뒤 항목이 늘고 줄어 개수가 어긋났다 —
|
||||
세지 않는다).
|
||||
|
||||
- **`slot._elemIndex`는 별도 헬퍼 `reindexFrom(self, from)`이 맡는다**(`H-1`,
|
||||
**[2026-08-24 분리]**). 이 맵은 `_elements`의 역방향이라 **실체화 여부와
|
||||
무관하게 항상** 정확해야 하는데, `spliceArrays*`는 부기(`bk`)를 만지므로
|
||||
실체화된 뒤에만 부를 수 있다 — 그래서 둘을 갈랐다. `reindexFrom`은
|
||||
`from`부터 끝까지 `_elemIndex[self._elements[i]] = i`를 다시 쓰고,
|
||||
제거 경로에선 빠지는 요소를 맵에서 **뺀 뒤** 부른다.
|
||||
`_elements`를 시프트하는 자리는 **전부** 이걸 부른다(`rawAdd`의 미실체화
|
||||
얼리리턴 포함). `spliceArraysUp`/`Down`은 자기 몫으로 이걸 같이 부르되,
|
||||
하는 일은 아래 부기 항목들이다.
|
||||
- **`bk.indexOfElement`는 별도 헬퍼 `reindexFrom(self, from)`이 맡는다**(`H-1`,
|
||||
**[2026-08-24 분리, 2026-08-27 맵 이동]**). 이 맵은 `_elements`의 역방향이라
|
||||
**실체화 여부와 무관하게 항상** 정확해야 한다. `reindexFrom`은 `from`부터
|
||||
끝까지 `bk.indexOfElement[self._elements[i]] = i`를 다시 쓰고, 제거 경로에선
|
||||
빠지는 요소를 맵에서 **뺀 뒤** 부른다. `_elements`를 시프트하는 자리는
|
||||
**전부** 이걸 부른다(`rawAdd`의 미실체화 얼리리턴 포함).
|
||||
`spliceArraysUp`/`Down`은 자기 몫으로 이걸 같이 부르되, 하는 일은 아래 부기
|
||||
항목들이다. (**[2026-08-27 Q3]** 분리의 옛 근거 *"`spliceArrays*`는 `bk`를
|
||||
만지므로 실체화된 뒤에만 부를 수 있다"*는 맵이 `bk`로 가며 **소멸**했다 —
|
||||
`getBookkeeping`은 lazy라 언제 불러도 된다. 분리는 "맵은 시프트마다, 부기
|
||||
배열은 실체화 뒤"라는 **호출 시점 차이** 때문에 그대로 둔다. 따름정리:
|
||||
미실체화 Slot도 `:Add`만 하면 `bk`가 생긴다.)
|
||||
- **`spliceArraysUp`은 `lengthList[index]`에 자리표시자(`0`)를 채운다**(`H-5`).
|
||||
옛 순서는 `spliceArraysUp`이 `bk.N`을 먼저 올리고 `lengthList[index]`는
|
||||
`setLength`가 마지막에 채워서, **그 사이에 `nativeInsert`가 끼어 있었다.**
|
||||
|
|
@ -2918,6 +3020,16 @@ end
|
|||
그쪽 error 가드엔 안 걸린다). **동기 재진입이라 "체인 도중 yield 금지"
|
||||
불변식으로는 안 덮인다.** `sourceList`가 `None`으로 채워지는 것과 대칭을
|
||||
맞춰 창 자체를 없앤다.
|
||||
- **⭐⭐ [2026-08-27 신설, 9라운드 `H-126`/Q3] 비워지는 자리는 세 배열 전부
|
||||
처리한다.** `spliceArraysUp`은 `index` 자리에 `lengthList = 0` / `sourceList =
|
||||
None` / **`observers = nil`**, `spliceArraysDown`은 당긴 뒤 꼬리(`N`) 세 자리를
|
||||
`nil`로. 자리표시자 두 줄만 적혀 있고 `observers`는 말이 없어서, `t[i+1] =
|
||||
t[i]` 복사 루프로 짜면 `observers[index]`에 옛 값이 남아 `index`와 `index+1`이
|
||||
**같은 Observer**를 가리켰다 — 이어지는 `setLength(self, index, …)`가
|
||||
`oldObserver`로 그걸 `unbindLifetime`해 **밀려난 요소의 길이 관측자를
|
||||
죽인다**(그 요소가 커져도 뒤 형제가 영영 안 밀린다 — 9라운드 실측
|
||||
`vacate=false`). `Down`의 꼬리 잔여는 `bk.N` 밖이라 당장은 안 보이지만 다음
|
||||
`Up`이 그 자리를 범위 안으로 밀어 올리면 자리표시자 대신 유령 값이 들어온다.
|
||||
- **캐시를 앞으로 당긴다**(`H-3`) —
|
||||
**두 필드 다** `math.min(…, index - 1)`:
|
||||
`bk.offsetCacheValidUpTo`(캐시 유효 상한)와 `bk.offsetSetUpTo`(offset
|
||||
|
|
@ -2947,23 +3059,17 @@ end
|
|||
되감는다(`base/dispatch-core-plan.md`의 `recompute` 절). 그래서
|
||||
`{a,a,a, b,b, c,c}`에서 앞의 `a,a`가 사라졌는데 커서가 이미 `c`에
|
||||
있어도 복구된다.
|
||||
- **⭐⭐ [2026-08-25 신설, 7라운드 `H-102`] `bk.tokens`와 `bk.indexOfToken`도
|
||||
같이 밀어야 한다.** `setLength`가 만드는 `gatedRecompute` 클로저는 인덱스를
|
||||
캡처하지 않고 **자리별 토큰**으로 `bk.indexOfToken[token]`을 조회한다
|
||||
(`slot._elemIndex`와 같은 개념을 Dispatch 층위로 격상 —
|
||||
`base/dispatch-core-plan.md`). 그래서 splice가 다음 둘을 반드시 해야 한다:
|
||||
1. **`bk.tokens`를 다른 배열과 같이 당긴다**(`lengthList`/`sourceList`/
|
||||
`observers`와 완전히 같은 처리).
|
||||
2. **밀린 자리 전부에 대해 `bk.indexOfToken[token]`을 새 인덱스로
|
||||
갱신하고, 사라진 자리의 토큰 항목은 지운다.** 강한 키 테이블이라
|
||||
안 지우면 **누수**다.
|
||||
|
||||
**안 하면 `H-102`가 그대로 남는다** — 토큰은 안 낡지만 그 토큰이 가리키는
|
||||
인덱스가 낡아, 제거 뒤 `gatedRecompute`가 **엉뚱한 위치로**
|
||||
`bk.offsetCacheValidUpTo`/`bk.offsetSetUpTo`를 당긴다(앞선 자리가 영영 다시
|
||||
offset을 못 받는다).
|
||||
이 목록에 *"옮겨진 클로저의 위치 추적"*이 셋 어디에도 없던 것이 원래
|
||||
결함이었다.
|
||||
- **⛔ [2026-08-27 폐기, 9라운드 Q3/`H-141`] 여기 한때 *"`bk.tokens`와
|
||||
`bk.indexOfToken`도 같이 밀어야 한다"*(7라운드 `H-102`, 2026-08-25)는 항목이
|
||||
있었다.** `setLength`의 `gatedRecompute`가 인덱스 대신 캡처할 신원으로
|
||||
`token = {}`을 두고, splice가 (1) `bk.tokens`를 같이 당기고 (2) 밀린 자리
|
||||
전부의 `bk.indexOfToken[token]`을 갱신·사라진 항목을 지우라는 요구였다.
|
||||
**토큰은 사용자가 정한 적 없는 신원**이고(`/code-review`가 "`len`은 자리마다
|
||||
유일하지 않다"를 잡으며 요소 키로 되돌아가는 대신 발명), 같은 뜻의 맵을 두
|
||||
층에 두는 원인이었다. 이제 클로저는 **요소를 캡처**하고 `bk.indexOfElement`를
|
||||
조회하므로(위 첫 항목) 이 요구는 통째로 사라진다 — `H-102`의 결론(*"클로저에
|
||||
박힌 인덱스가 낡는다 → 캡처 말고 조회"*)은 그대로이고, 조회 대상만 원래
|
||||
사용자 지시(요소 → 인덱스)로 돌아왔다.
|
||||
|
||||
**[신설, 2026-08-18 구현 전 QA 3라운드] `spliceArraysDown`이 밀어야 하는
|
||||
배열 목록(아래)에 빠진 게 있었고, `bk.N`도 같이 줄여야 한다는 것 자체가
|
||||
|
|
@ -3091,6 +3197,9 @@ Single(element)`을 대신 삽입** — raw 반응형 요소는 전부 Slot-in-S
|
|||
-- 한 줄이 전부였고, **같은 문서가 확정해둔 가드를 하나도 하지 않았다.**
|
||||
-- 산문이 이미 정확한 알고리즘을 서술해뒀으므로 옮기기만 한 것이다.
|
||||
function Slot:Add(element, index)
|
||||
-- (0) [2026-08-27, 9라운드 Q2] 파괴된 Slot — 모든 공개 CRUD·`:List`·마운트
|
||||
-- 진입점이 같은 가드를 둔다(위 "파괴된 Slot은 재사용 불가" 절).
|
||||
if self._destroyed then error("Slot: destroyed Slot cannot be reused", 2) end
|
||||
-- (1) `:List`가 설치돼 있으면 수동 CRUD 금지 (위 "CRUD API 확정" 절)
|
||||
assert(not self._listed, "Slot: :List가 설치된 Slot엔 수동 CRUD를 쓸 수 없음")
|
||||
-- (2) index 범위 검증 — **clamp 안 함**, 범위 밖이면 error
|
||||
|
|
|
|||
|
|
@ -283,6 +283,27 @@ haiku, 일반 작업은 sonnet. 특히 소스코드를 많이 읽어야 하는
|
|||
바꾸고 그 이름이 서술하던 동작은 그대로, 새 규칙의 *근거*를 잘못 적어 그
|
||||
근거가 다른 불변식을 함의하게 만듦). **수정분 자체가 새 결함을 만든다는
|
||||
게 반복 관측됐으니, 반영이 끝난 뒤 한 번 더 돌리는 걸 기본으로 할 것.**
|
||||
- **⭐⭐ [2026-08-27 신설] `/code-review`(그리고 메인 세션 자신)가 내놓는
|
||||
*새 필드·인자·이름·메커니즘*은 발견이지 결정이 아니다 — 사용자 결정 없이
|
||||
`base/`에 넣지 말 것.** code-review는 완전한 외부자라 이 코퍼스의 결정
|
||||
이력(누가 그 책임을 갖는지, 어떤 모양이 이미 기각됐는지)을 모르고, 증상을
|
||||
닫는 가장 가까운 코드를 제안한다. 실제 사고: 2026-08-25 `/code-review`가
|
||||
*"`len`은 자리마다 유일하지 않다"*를 잡고 원래 키(요소)로 되돌아가는 대신
|
||||
`token = {}`이라는 신원을 발명해 `bk.tokens`/`indexOfToken` 두 구조를
|
||||
넣었고, 그게 사용자 인용문(*"dispatch 로 격상"*) 옆에 앉아 **승인된
|
||||
메커니즘처럼** 하루를 살았다(9라운드 `H-141`, 사용자: *"내가 등장시킨 적
|
||||
없는 token 이 나와서 당황스러움"*). 같은 날 메인 세션도 같은 실수를 세 번
|
||||
했다(`subject` 인자 / Observer에 위치 필드 — 이미 기각된 `Effect` userdata의
|
||||
재개방 / 조회 클로저). 규칙:
|
||||
1. **리뷰 발견을 반영하기 전에 (a) 그 책임의 현재 소유자와 (b) 그 모양이
|
||||
과거에 기각된 적 있는지를 먼저 grep한다** — `base/`의 "검토 후 안
|
||||
만들기로 한 것"류 목록과 `archive/`가 그 자리다.
|
||||
2. **발견이 새 필드·인자·이름·메커니즘을 요구하면 그건 사용자 문항이다** —
|
||||
`question.md`나 그 라운드의 followup에 갈래로 올리고 결정을 받는다. 증상만
|
||||
확실하고 처방이 새 개념이면 "증상 확정, 처방 미정"으로 남긴다.
|
||||
3. **기존 사용자 인용문 옆에 새 메커니즘을 붙이지 않는다** — 인용문이 승인한
|
||||
것과 다른 것을 그 옆에 적으면 다음 세션이 승인된 것으로 읽는다. 붙일
|
||||
땐 *"이 인용은 X를 승인한 것이고 Y는 리뷰 제안"*이라고 갈라 적을 것.
|
||||
- **문서가 쌓이면서 모순/중복/stale 마커가 생기기 쉬움 — 주기적으로 감사할
|
||||
것.** 다만 위 체크리스트+`doc-check.py`+`quad-doc-auditor`가 자리잡으면
|
||||
이 감사는 "기계도 서브에이전트도 못 보는 것"(설계 자체의 자기모순, 의사코드
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ M3=반응형)다. 그 교체의 부작용으로 한때 `question.md` 최우선
|
|||
[2026-08-26] 8라운드 손 트레이싱까지 처리 완료** — 7라운드 반영분을 겹쳐
|
||||
재트레이싱한 발견 17건(`H-107`~`H-123`)의 결정을 전부 `base/`에 반영했고
|
||||
(소스는 `qa-request/pre-implementation-handtrace-round8-followup.md`),
|
||||
`question.md` 최우선 절은 다시 비어 있다. 저장소 루트에
|
||||
`question.md` 최우선 절은 다시 비어 있다. **[2026-08-27] 9라운드**(그 커밋
|
||||
`9dd8213`의 델타 재트레이싱, 발견 `H-124`~`H-141`)는 **Q1~Q3가 `base/`에
|
||||
반영됐고 Q4~Q10 대기** — 소스는 `-round9-followup.md`. 게이트는 여전히 0. 저장소 루트에
|
||||
`quad-base/src/`(`New()`/`RunInit`/`AddPlugin`/`Relate`/`Debug`)/
|
||||
`quad-types/src/`/`type-version-check/src/`가 실제로 존재(`quad-roblox/src`는
|
||||
아직 빈 폴더 — M5에서 채워짐), 자세한 진행 상황은 루트 `ROADMAP.md`가
|
||||
|
|
|
|||
235
.claude/qa-request/pre-implementation-handtrace-round9-brief.md
Normal file
235
.claude/qa-request/pre-implementation-handtrace-round9-brief.md
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# 구현 전 손 트레이싱 **9라운드** 감사 지시서
|
||||
|
||||
> **이 파일이 무엇인가**: 9라운드 감사자에게 그대로 주는 지시서다. 발견
|
||||
> 보고가 아니라 그 **앞단**이고, 산출물은 별도 파일이다(§6).
|
||||
> **[2026-08-26] 지시서를 저장소에 남기는 건 이번이 처음이다** — 7·8라운드
|
||||
> 지시서는 대화에만 있었고 저장되지 않았다(8라운드 본문이 인용하는
|
||||
> "감사 지시서 §2"가 코퍼스 어디에도 없는 이유). 앞으로 라운드마다
|
||||
> `-roundN-brief.md`를 같이 남긴다.
|
||||
|
||||
당신은 Roblox 엔진용 DOMless UI 렌더러 **quad**의 설계 코퍼스를 감사한다.
|
||||
저장소 루트는 이 세션의 작업 디렉토리다. **구현(M2 = 반응형 코어) 착수
|
||||
직전**이고, 여기서 놓친 설계 결함은 구현 한참 뒤에 터져 M2/M3를 다시 짜는
|
||||
비용이 된다.
|
||||
|
||||
---
|
||||
|
||||
## §0 먼저 읽을 것 (이 순서로)
|
||||
|
||||
1. `CLAUDE.md` → `.claude/conventions.md` / `.claude/project-context.md` /
|
||||
`.claude/todos.md` — 프로젝트 관례와 현재 상태.
|
||||
2. `.claude/qa-request/pre-implementation-handtrace-round8.md` — 8라운드
|
||||
발견 원문(`H-107`~`H-123`). 특히 **§5(이상 없다고 확인한 것)** 와
|
||||
**§6(남은 의심 / 못 본 것)**.
|
||||
3. `.claude/qa-request/pre-implementation-handtrace-round8-followup.md` —
|
||||
**이 라운드의 출발점.** 8라운드 결정 Q1~Q10과, 그 반영 뒤 돌린
|
||||
`quad-doc-auditor` 11라운드 + `/code-review high` **7패스**의 기록.
|
||||
`/code-review high`의 차수별 절(1차부터 7차까지)을 반드시 정독할 것 —
|
||||
거기 적힌 **반복 실패 모드**가 당신의 사냥 목록이다(§3).
|
||||
4. `ROADMAP.md`(M2/M3 체크리스트) / `.claude/question.md` /
|
||||
`.claude/luau-test/STATUS.md`.
|
||||
5. `base/`는 레인별 필요 범위만(§2). 전량 완독은 요구하지 않는다 — 8라운드
|
||||
3차 패스가 이미 전량 완독했고 🔴가 0건이었다.
|
||||
|
||||
**이전 라운드**(1~7)는 필요할 때만 인용 자리를 부분 확인하라. 재심사
|
||||
대상이 아니다.
|
||||
|
||||
---
|
||||
|
||||
## §1 이 라운드의 전제
|
||||
|
||||
8라운드 결정 반영과 그 뒤의 code-review 7패스 수정이 **전부 단일 커밋
|
||||
`9dd8213`에 들어 있고, 그 결과물을 아무도 처음부터 트레이싱한 적이 없다.**
|
||||
|
||||
```
|
||||
git show 9dd8213 --stat
|
||||
git show 9dd8213 -- .claude/base/ ROADMAP.md
|
||||
```
|
||||
|
||||
이게 이 라운드가 볼 델타의 **완전한 정의**다. `base/` 주요 증분:
|
||||
`dispatch-core-plan +250` / `source-state-plan +160` / `slot-plan +139` /
|
||||
`lifecycle-pattern +124` / `store-plan +117` / `effect-plan +103`.
|
||||
|
||||
**왜 이 델타가 특히 위험한가** — 같은 프로젝트가 낸 실측이 셋 있다.
|
||||
|
||||
- 8라운드의 🔴 다섯(`H-107`/`108`/`109`/`112`/`119`)은 **전부** "직전
|
||||
라운드(7라운드)의 반영분이 서로 겹치는 자리"에서 나왔다. 개별 함수의
|
||||
버그가 아니라, 하루 차로 확정된 결정들이 서로를 못 본 자리다.
|
||||
- code-review 7패스의 HIGH 추이는 `1 → 1 → 0 → 1(설계 역전) → 0 → 3 → 0`
|
||||
이다. **5차가 HIGH 0인데 6차에서 HIGH 3이 나왔고 전부 5차 수정이 만든
|
||||
것**이었다. "직전 패스가 조용했다"는 수렴의 증거가 아니다. 7차 수정분은
|
||||
**아무도 안 봤다.**
|
||||
- 4차에서 설계 역전이 있었다 — `H-101`의 "새 필드를 안 만든다"가 뒤집혀
|
||||
`bk.invalidAfter`가 `bk.offsetCacheValidUpTo` / `bk.offsetSetUpTo` **둘로
|
||||
분리**됐고 40곳 넘게 치환됐다. 감사 사이클 **끝머리**에 들어온 구조
|
||||
변경이라 가장 덜 검증됐다.
|
||||
|
||||
---
|
||||
|
||||
## §2 레인 — 우선순위 A > C > B
|
||||
|
||||
리포트를 레인별로 독립적으로 쓸 것. 예산이 모자라 B를 못 해도 A/C 결과가
|
||||
그대로 쓸 수 있어야 한다.
|
||||
|
||||
### 레인 A (최우선) — `9dd8213` 델타의 상호 간섭
|
||||
|
||||
이번에 바뀐 계약들을 **서로 겹쳐서** 읽는다. 개별 문서가 자기 안에서
|
||||
일관된지가 아니라, **두 결정이 만나는 자리에서 성립하는지**를 본다.
|
||||
|
||||
이번 라운드가 바꾼 계약(전량은 followup이 소스, 여기 나열은 진입점):
|
||||
|
||||
- `Ref` 콜백이 **`fn(value, ref)`** — 2번째 인자가 곧 출처 `Epoch`.
|
||||
훅 슈가의 `guard(fn)`도 2-인자가 됐다.
|
||||
- Observer `fn`이 **세 자리** `fn(targetState, self, emitFrom)` +
|
||||
`observer._state` **강참조**.
|
||||
- `WeakSubscribe`도 `.Subscribed = true`를 세운다.
|
||||
- `Subscribe`/`WeakUnsubscribe`/`Unsubscribe`가 **전부 fail-fast**
|
||||
("해제는 건 경로로 푼다"). *idempotent* 서술은 세 번에 걸쳐 지워졌다 —
|
||||
**네 번째 사본이 남아 있는지 전수하라.**
|
||||
- 예약 키 진단 타입 함수가 `CheckReservedKeys<keyof<T>>`.
|
||||
- **부기 필드 2분할** — `offsetCacheValidUpTo`(올리는 쪽: `getOffsetAt`,
|
||||
어디서 불리든) / `offsetSetUpTo`(올리는 쪽: `recompute`만). 무효화는 둘 다
|
||||
내린다. 옛 이름 `invalidAfter`는 **의도적으로 전멸**시켰다(단, **인용문·
|
||||
절 제목·정정 배너 안에서는 옛 이름이 정본**이다).
|
||||
- 무효화 인덱스가 **세 가지 → 네 가지**(splice 계열은 `j - 1`,
|
||||
`rawMove`/`rawSwap` 계열 행 신설, `rawExtract`는 **조건부**).
|
||||
- `recompute` 재진입 게이트가 **명시 호출부 전부**로 확대(`raw*` 3형제,
|
||||
`_baseObserver`, `:List` 활성화 꼬리, `mountSlotTree` 꼬리, `Dispatch.drive`
|
||||
꼬리, 일반 배치 계약).
|
||||
- Store 생성자의 `defaults` `isSource` 화이트리스트 검증(`error` level 2),
|
||||
`isModifier` 가드가 `Source` 생성자로 이동.
|
||||
|
||||
**특히 겹쳐 볼 것**(과거에 실제로 여기서 🔴가 나왔다):
|
||||
|
||||
- `H-113`(splice `-1`) × `H-119`(`_baseObserver`가 마커를 0으로) —
|
||||
이 둘이 겹쳐 `math.max(…, 1)` 클램프가 필요해졌다. **같은 종류의
|
||||
0-하한/끝-초과 경로가 다른 조합에 또 있는가.**
|
||||
- 부기 필드 분할 × 무효화 4규칙 × `bk.N` 예외(`rawSplice`/`rawClear`/
|
||||
조건부 `rawExtract`) — 세 개를 동시에 만족하는 CRUD 시퀀스를 **실제 값으로**
|
||||
돌려라. 특히 **한 콜백에서 CRUD를 두 번**(예: `Remove` 뒤 `Add`) 하는
|
||||
2-연산 경로. 4차 HIGH가 정확히 그 형태였다.
|
||||
- `Ref` 2-인자 × 훅 슈가 × `Effect`의 dep 종류별 클로저 둘.
|
||||
- fail-fast 3종 × `Effect` cleanup × leaf 사망 경로.
|
||||
|
||||
### 레인 C — 참조 구현을 갱신해 **실제로 돌리기**
|
||||
|
||||
`.claude/audit/handtrace-round7-reference-impl/`에 7라운드가 만든 M2 코어 +
|
||||
M2→M3 경계 참조 구현과 스파이크(`spikes/`)가 있다. **그 계약은 8라운드
|
||||
이후로 낡았다.**
|
||||
|
||||
1. 그 README의 원문 대조표를 따라 **지금의 `base/` 서술로 갱신**하라
|
||||
(계약 4변경 + 부기 필드 분할 + 무효화 4규칙 + 재진입 게이트 확대).
|
||||
2. `luau` / `luau-analyze`로 돌려라.
|
||||
3. **⚠️ 참조 구현은 확정 의사코드의 *전사물*이라 그 자체가 틀렸을 수
|
||||
있다.** 재실행은 검증이 아니다 — 발견을 판정할 땐 `base/` 원문과 줄
|
||||
단위로 대조하라. 반대로 **참조 구현이 안 돌아가면 그건 거의 항상 문서의
|
||||
결함**이다(7차 code-review가 `for d in seen do`가 유효한 Luau가 아니라는
|
||||
걸 이렇게 잡았다).
|
||||
|
||||
실행 환경:
|
||||
- 저장소 테스트는 **반드시 `./scripts/test.sh`**. `luau` CLI가 심볼릭 링크를
|
||||
못 타는데 pesde 워크스페이스 링크가 전부 심볼릭이라, 그냥 `luau`로 돌리면
|
||||
스모크가 죽고 `luau-analyze`는 **조용히 통과**한다(거짓 클린).
|
||||
- 독립 스파이크는 스크래치패드에 만들어 `luau <파일>` /
|
||||
`luau-analyze <파일>`로 돌려라.
|
||||
|
||||
### 레인 B — M5+ 값 단위 트레이싱 (첫 시도)
|
||||
|
||||
8라운드 §6이 남긴 유일한 실질 공백이다. **문서 정독은 됐지만 의사코드를
|
||||
실제 값으로 돌려본 적이 한 번도 없다**:
|
||||
|
||||
- 그룹 `Attribute` 위임 체인(`attribute-plan.md`)
|
||||
- `D` 생성자 (`ui-shorthand-plan.md`)
|
||||
- 숏핸드 → `PropertyHandler` 위임 (`ui-shorthand-plan.md` ×
|
||||
`dispatch-core-plan.md`)
|
||||
- `:List` reconcile의 **실제 값 대입** (`slot-plan.md`)
|
||||
|
||||
구체적인 인스턴스 트리와 구체적인 값으로 한 사이클을 손으로 돌려라
|
||||
("계약이 이어진다"는 수준이 아니라 변수마다 값을 적으면서).
|
||||
|
||||
---
|
||||
|
||||
## §3 사냥 목록 — 이 코퍼스에서 **반복 관측된** 실패 모드
|
||||
|
||||
followup의 code-review 절이 명시적으로 남긴 것들이다. 델타를 읽을 때 이
|
||||
패턴을 **능동적으로** 찾아라.
|
||||
|
||||
1. **⭐ 토큰만 바꾸고 그 토큰이 든 문장을 안 읽음** — 이 세션에서만 **네 번**
|
||||
반복됐다(`_observers`→`_deps`, `CheckReserved`→`CheckReservedKeys`,
|
||||
`invalidAfter`→`offsetSetUpTo`, 그리고 역사 인용문 오염). 이름이 바뀌었는데
|
||||
**그 이름이 서술하던 동작·근거·불변식이 옛것 그대로**인 자리를 찾아라.
|
||||
2. **전역 치환이 인용문·절 제목·정정 배너를 오염** — 그 셋은 *과거에 무엇이라
|
||||
적혀 있었는가*를 보존하는 게 목적이다. `H-101` 인용문이 새 필드 이름으로
|
||||
바뀌어 "새 필드를 안 만든다"와 자기모순이 된 사례가 실제로 있었다.
|
||||
**반대 방향도 보라** — 되돌리다 살아 있는 서술까지 옛 이름으로 돌린 자리.
|
||||
3. **표에 행만 넣고 헤딩/산문/개수는 그대로** — "세 가지"라고 쓰인 채 표엔
|
||||
네 행. 두 번 반복됐고(2차/3차), `ROADMAP` 체크리스트에도 번졌다.
|
||||
4. **폐기 블록을 만지면 오히려 해로움** — 죽은 문단에 이름만 고치거나 날짜
|
||||
마커를 찍으면 **갓 정비된 것처럼** 보여 구현자가 더 믿는다. 폐기 배너
|
||||
아래 본문이 최근에 수정된 흔적이 있는지 보라.
|
||||
5. **한 곳에서 고친 거짓 전제가 새로 쓰는 글에서 되살아남** —
|
||||
*"Store는 `Source`를 만들지 않는다"*가 3차에 세 곳에서 고쳐졌는데 5차에
|
||||
새 블록에 다시 들어갔다. **이미 거짓으로 판정된 문장의 새 사본**을 전수하라.
|
||||
6. **같은 계약의 N번째 사본** — *idempotent* 서술이 1차·6차·7차에 걸쳐
|
||||
세 번 지워졌다. 하나를 고쳤으면 `grep`으로 전수하라.
|
||||
7. **`ROADMAP.md` 체크박스가 `base/`보다 낡음** — 7차의 2번이 그랬다.
|
||||
**구현자가 실제로 보는 자리**라 심각도가 높다.
|
||||
|
||||
---
|
||||
|
||||
## §4 각도 (8라운드와 같은 문자를 유지 — 상호참조용)
|
||||
|
||||
- **A. 반영분 재트레이싱** (최우선, 레인 A)
|
||||
- **B. 엔드투엔드 체인** — Store→State→Observer/Effect→Gate/Blocker→
|
||||
`setLength`/`recompute`, 그리고 **M5+**(레인 B)
|
||||
- **C. 확정 의사코드/타입 실측** (레인 C)
|
||||
- **D. 구현 순서 시뮬레이션** — `ROADMAP.md` M2 체크박스를 위에서부터
|
||||
실제로 짜는 시뮬레이션. **체크박스만 보고 짰을 때 성립하는가**(7차 2번이
|
||||
여기서 나왔다)
|
||||
- **E. 공개 API 오용 진단** — 사용자가 틀리게 썼을 때 조용히 통과하는가
|
||||
- **F. 코퍼스 미다룸 영역**
|
||||
- **G. Luau 사실 주장 재확인** — 문서가 "확인했다"고 적은 언어 동작을 실제로
|
||||
걸어보기
|
||||
- **H. 비용 서술 점검**
|
||||
|
||||
---
|
||||
|
||||
## §5 규칙 (엄수)
|
||||
|
||||
1. **저장소를 수정하지 마라.** 산출물은 리포트 파일 **하나**뿐이다.
|
||||
스파이크·참조 구현 갱신본은 스크래치패드에 만들고, 발견의 근거는 리포트
|
||||
항목 안에 **인라인으로 전사**하라(파일이 유실돼도 재현 가능하게).
|
||||
2. **`git stash`를 절대 쓰지 마라.** 과거 실동에서 메인 세션의 스테이지가
|
||||
반복적으로 풀렸다. 과거 상태가 필요하면 `git show <ref>:<경로>` /
|
||||
`git diff <ref> -- <경로>`.
|
||||
3. **아무것도 반영하지 마라.** 판정은 사용자가 이 리포트를 보고 한다.
|
||||
4. **8라운드까지의 결정은 뒤집지 않는 것을 기본으로 하라.** 뒤집어야 한다고
|
||||
보면, **그 결정의 어느 추론이 틀렸는지**를 항목 안에서 지목하라
|
||||
(근거 없이 "다시 생각해보니"는 안 된다).
|
||||
5. **발견 번호는 `H-124`부터** 이어서 매긴다(라운드를 가로질러 연속).
|
||||
심각도는 🔴(M2 착수 전 결정 필요) / 🟡(계약 결정) / 🟢(문서 정합·문서화).
|
||||
6. `.claude/audit/`의 참조 구현은 **참고용**이다 — 재실행이 곧 검증이 아니다
|
||||
(§2 레인 C의 ⚠️).
|
||||
7. **확신이 안 서면 발견으로 올리지 말고 §6 "남은 의심"에 적어라.**
|
||||
반대로, 확인해서 **이상 없었던 것**은 §5에 적어라 — 다음 라운드가 같은
|
||||
자리를 다시 파지 않도록.
|
||||
8. **못 본 범위를 §6에 정직하게 선언하라.** 리포트가 "감사 통과"로
|
||||
읽히면 안 된다.
|
||||
|
||||
---
|
||||
|
||||
## §6 산출물
|
||||
|
||||
`.claude/qa-request/pre-implementation-handtrace-round9.md` 하나. 구성:
|
||||
|
||||
- **머리말** — 무엇인가 / 쓴 각도 / 실제로 본 범위 / 읽는 순서
|
||||
- **요약 표** — `| 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 |`
|
||||
- **상세** — 항목마다: 무엇이 문제인가 / **어떻게 재현·트레이싱했는가**
|
||||
(값 단위로) / 어느 문서 어느 줄이 근거인가 / 그대로 구현하면 무슨 일이
|
||||
일어나는가 / (있으면) 실측 출력 전사
|
||||
- **§4 사용자 결정이 필요한 것** — 배치 회신용. 문항마다 선택지와
|
||||
**당신의 권고**를 달아라. 사용자는 이걸 위에서부터 답한다
|
||||
- **§5 이상 없다고 확인한 것**
|
||||
- **§6 남은 의심 / 못 본 것**
|
||||
|
||||
문서는 **한국어**로 쓴다(사용자가 읽는 문서). 코드·식별자는 원문 그대로.
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
# 9라운드 손 트레이싱 발견 — **사용자 결정과 반영 결과**
|
||||
|
||||
**무엇인가**: `.claude/qa-request/pre-implementation-handtrace-round9.md`의 발견
|
||||
(`H-124`~)을 사용자와 대화형으로 처리한 결과. **결정의 소스는 이 문서**이고,
|
||||
발견 원문·값 트레이스·실측 전사·"이상 없다고 확인한 것" 목록은 그 파일이
|
||||
소스다(여기서 다시 서술하지 않음).
|
||||
|
||||
**진행 방식**: 그 문서 §4가 배치 회신용으로 묶어둔 **결정 문항 Q1~Q10** 순서를
|
||||
따른다. 8라운드와 같다.
|
||||
|
||||
**⚠️ [2026-08-27 기준] 진행 중이다.** 아래 표가 어디까지 왔는지의 소스다.
|
||||
처음엔 "문항을 다 처리한 뒤 일괄 반영"으로 잡았으나, Q1~Q3가 같은 `slot-plan.md`
|
||||
구간에 몰려 있고 서로 얽혀(Q2의 생성자 이동이 Q3의 `_elemIndex` 삭제와 같은
|
||||
줄) 사용자 지시로 **Q3까지 먼저 반영**했다. Q4 이후는 결정 뒤 반영한다.
|
||||
|
||||
| 문항 | 발견 | 상태 |
|
||||
|---|---|---|
|
||||
| Q1 | `H-124` `recompute` 루프 순서 | ✅ **확정** — (a), `continue` 형태 |
|
||||
| Q2 | `H-125` 재마운트 캐시 | ✅ **확정** — 생성자 이동 + (c) 순서 + `_destroyed` |
|
||||
| Q3 | `H-126` splice 빈자리 (+ `H-137` 소멸, `H-141` 신설) | ✅ **확정** — `element → index`는 `bk`가 소유, 토큰 폐기 |
|
||||
| Q4~Q10 | `H-127`~`H-133` | ⏳ 대기 |
|
||||
| — | `H-134`~`H-140` | ⏳ 대기(레인 B·부수 발견) |
|
||||
|
||||
**[2026-08-27] Q1~Q3는 `base/`·`ROADMAP.md`에 반영했다** — 반영 중 드러난 것과
|
||||
열어둔 확인은 아래 "반영 기록" 절.
|
||||
|
||||
---
|
||||
|
||||
## Q1 — `recompute`의 되감기 판정을 `lengthList[i]` 읽기 **앞**으로 (`H-124`, 확정)
|
||||
|
||||
**결정: (a).** 되감기 판정이 먼저 오고, `local v = bk.lengthList[i]`와 `sum`
|
||||
누적은 되감기가 **없을 때만** 돈다. 형태는 `continue`.
|
||||
|
||||
**사용자 논거** — 갈래 (a)를 고르면서 코드 모양까지 정정했다:
|
||||
|
||||
> *"단순히 sum 을 안 만들어도 되는게, 되감는다면 sum 이 이전걸로 구해져서 새로
|
||||
> 계산한 sum 자체를 안 씀. v 얻고 sum 계산하는걸 else 아래 두는것도
|
||||
> 괜찮아보이는듯. 컨티뉴나 else 아래나 둘 다 괜찮은데, 맥락 상 컨티뉴를 두고
|
||||
> 아래 두는게 좋아보임."*
|
||||
|
||||
확정된 모양:
|
||||
|
||||
```lua
|
||||
local abs = Dispatch.getOffsetAt(ownerKey, i)
|
||||
bk.offsetSetUpTo = i
|
||||
if offset ~= None and offset:Get() ~= abs then
|
||||
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
|
||||
end
|
||||
if bk.offsetSetUpTo < i then -- ⭐ 되감기 판정이 **먼저**
|
||||
i = math.max(bk.offsetSetUpTo, 1)
|
||||
sum = prefix[i]
|
||||
continue
|
||||
end
|
||||
local v = bk.lengthList[i] -- 되감지 않을 때만 읽는다
|
||||
sum += (if isState(v) then v:Get() else v)
|
||||
i += 1
|
||||
```
|
||||
|
||||
- **무엇이 깨져 있었나**: 옛 순서는 `lengthList[i]` 읽기·누적이 되감기 판정보다
|
||||
**한 줄 앞**이었다. `offset:Set(abs)` 안의 사용자 코드가 요소를 제거해
|
||||
`i > bk.N`이 되면(커서가 마지막 자리일 때 아무 자리나 제거, 또는 `rawSplice`/
|
||||
`rawClear`의 다중 제거) `sum += nil`로 죽고, 그 `error`가
|
||||
`recomputeBlocker:On()`과 `OffWithoutEmit()` 사이에서 나므로 **그 owner의
|
||||
차단기가 영원히 켜진 채 남는다**(그 Slot의 레이아웃이 영구 동결 — `H-87`의
|
||||
"독 든 Blocker"와 같은 부류).
|
||||
- **`H-113`의 *"`sum`은 안 낡는다"* 논증은 유지된다** — 그 논증의 근거는
|
||||
"`lengthList[i]` 읽기가 `Set` 뒤"인데, 되감는 경우엔 그 자리를 **재방문**하며
|
||||
읽으므로 여전히 `Set` 뒤다. 오히려 옛 요소의 길이를 한 번 더 더했다가 버리는
|
||||
낭비가 사라진다.
|
||||
- **제거가 커서보다 뒤(`p > i`)면 되감기 자체가 안 걸린다** —
|
||||
`math.min(offsetSetUpTo, p-1)`에서 `p-1 ≥ i`이므로 판정이 거짓이고,
|
||||
그때는 `bk.N ≥ p-1 ≥ i`라 `lengthList[i]`가 살아 있다. 즉 이 재배치가 정상
|
||||
경로를 안 바꾼다.
|
||||
- **실측**(9라운드 참조 구현 `ref9/`): 이 처방 전엔 `d10_remove_at_tail.luau`가
|
||||
`attempt to perform arithmetic (add) on number and nil`로 죽었고, 처방 뒤
|
||||
**정상 종료 + 최종값 정확**(`s2.Offset 0 / s3.Offset 1 / O.Length 2`).
|
||||
`d11_two_ops`(2-연산 경로) · `d13_splice_at_cursor`(커서 자리 splice) ·
|
||||
`d14_baseline`(형제 offset 전파) 전부 회귀 없음.
|
||||
|
||||
**반영 대상**: `base/dispatch-core-plan.md`의 `recompute` 의사코드(+ 되감기 절의
|
||||
근거 문장 — "1로 클램프한다" 주석은 그대로 유효하다).
|
||||
|
||||
---
|
||||
|
||||
## Q2 — 재마운트: **`Offset`/`_baseObserver`를 생성자로 올리고**, 순서는 (c), 파괴는 `_destroyed` (`H-125`, 확정)
|
||||
|
||||
**결정: 문항의 (a)/(b)/(c) 중 (c)를 고르되, 사용자가 그보다 나은 형태를
|
||||
제시해 그쪽으로 확정했다** — `slot.Offset`과 `slot._baseObserver`를 **Slot
|
||||
생성자에서** 만든다. 그러면 (c)가 고치려던 순서 문제의 **분기 자체**가 없어진다.
|
||||
|
||||
### 왜 (a)가 아닌가 — 그리고 문항의 근거 하나는 틀렸다
|
||||
|
||||
- 사용자가 (a)를 반대한 근거는 *"`:List`인데 어디선가 이미 그려진 적 있다면 …
|
||||
offset 설정 결과가 전파될 방법이 없음. layoutorder 등을 설정하는 유저 함수
|
||||
부분에 문제가 있을것"*이었는데, **그 부분은 실측에서 성립하지 않았다.**
|
||||
`setOffsetSource`의 `S.Offset:Set(newBase)`는 전파 루프를 정상적으로 돌고,
|
||||
유저 체인의 말단 Observer는 **요소 자신의 인스턴스**에 `bindLifetime`돼 있어
|
||||
(언마운트는 요소를 파괴하지 않고 `unbindLifetime`도 안 한다) `canExecute`가
|
||||
참이다. 건너뛰어지는 건 `_baseObserver` 하나뿐이다.
|
||||
→ **그래서 `H-125`의 피해 범위는 "자식 서브트리 전체"가 아니라 *부기를
|
||||
경유하는 중첩 Slot의 `Offset`*으로 좁혀진다**(발견 문서에 정정 반영).
|
||||
- **(a)를 기각한 실제 이유는 소스 이원화**다. *"베이스가 바뀌면 1번부터
|
||||
무효화"*(`H-3`의 3번)의 코드 경로는 `_baseObserver` 콜백 하나인데, (a)는 같은
|
||||
규칙을 `materializeSlotTree` 진입부에 한 벌 더 둔다 — 그 절의 존재 이유가
|
||||
정확히 *"표는 산문으로만 있었고 실제 코드 경로가 하나도 없었다"*를 닫는
|
||||
것이라, 소스를 둘로 늘리는 건 후퇴다.
|
||||
|
||||
### 확정된 것 (넷)
|
||||
|
||||
**1. `Slot` 생성자가 `Offset`과 `_baseObserver`를 만든다** — `Length`와 같은 자리.
|
||||
|
||||
> **사용자**: *"slot 자체를 생성할 때 offset/observer 이 같이 생성되지 말아야할
|
||||
> 이유가 있음? 우린 stale 한 offset 을 허락하고 기본 생성에 0 이기 때문에, 초기
|
||||
> 생성에 넣는거로 해줄 순 없는거야?"* / *"baseObserver 자체도, offset 에서
|
||||
> 나므로, 초기에 생성하니, 같이 생성하면 돼. **단순히 bind/unbind 로 관리해야지
|
||||
> 그것 자체를 제거/생성 하는건 안 맞아보임.**"*
|
||||
|
||||
- **새 결정이 아니라 이미 확정된 것의 반영이다** — `SL-75`/`D-60`이
|
||||
*"마운트 전엔 `nil`이 아니라 `0`이고, 언마운트해도 `nil`로 되돌리지 않는다"*로
|
||||
확정했는데 의사코드만 lazy(`slot.Offset or Source(0)`)로 남아 산문과 어긋나
|
||||
있었다. `Length`와의 대칭도 맞는다(둘 다 공개 필드인데 하나만 생성자에서 나던
|
||||
비대칭).
|
||||
- **`H-125`가 살던 분기가 사라진다**: 옛 `materializeSlotTree`는 첫 마운트(생성
|
||||
→ "등록 즉시 1회"가 **우연히** 두 필드를 0으로)와 재마운트(`bindLifetime`만 →
|
||||
**바인드는 발화가 아니라서** 안 만듦)로 갈렸고, 그 비대칭이 버그의 집이었다.
|
||||
이제 갈래가 없다.
|
||||
|
||||
**2. `materializeSlotTree`의 순서** — 게이트와 바인드가 emit **위로**:
|
||||
|
||||
```lua
|
||||
slot._physicalTarget = physicalTarget
|
||||
local blocker = getBlocker(slot)
|
||||
blocker:On() -- ← emit 위로
|
||||
bindLifetime(physicalTarget, slot._baseObserver) -- ← emit 위로(생성 분기 없음)
|
||||
Dispatch.setOffsetSource(ownerKey, position, slot.Offset) -- 여기서 베이스가 바뀐다
|
||||
```
|
||||
|
||||
- `blocker:On()`도 같이 올려야 한다 — 안 그러면 emit이 깨운 콜백이 게이트 없이
|
||||
`recompute(slot)`를 완주하는데, 그 시점 `bk(slot)`은 **언마운트 전 옛
|
||||
부기**(`Relate(slot)` 위에 살아남는다)라 옛 `N`·옛 자식 목록으로 돈다.
|
||||
- **간섭 없음**: 이 Blocker는 그 Slot 자신의 것이고 `setOffsetSource`는 **부모
|
||||
owner의** blocker를 본다. `getOffsetAt`은 `lengthList`를 직접 읽으므로 게이트와
|
||||
무관하다(*"Blocker가 막는 건 `recompute`지 부기 등록이 아니다"*).
|
||||
- **기존 근거 유지**: *"`_baseObserver` 생성이 `blocker:On()` 뒤인 게
|
||||
중요하다"*(등록 즉시 1회가 자식 등록 전에 `recompute`를 태움)는 그대로다 —
|
||||
생성이 생성자로 갔고 **바인드도 `blocker:On()` 뒤**다.
|
||||
- `local offsetSource = slot.Offset or Source(0)`과 `slot.Offset = offsetSource`
|
||||
두 줄, 그리고 `if slot._baseObserver then … else … end` 분기가 **전부 삭제**된다.
|
||||
|
||||
**3. `_baseObserver` 콜백 머리에 미실체화 가드**:
|
||||
|
||||
```lua
|
||||
if slot._physicalTarget == nil then return end
|
||||
```
|
||||
|
||||
- 없으면 생성자의 "등록 즉시 1회"가 **모든 Slot**에 대해 `getBookkeeping`을
|
||||
강제 호출해 `bk` + `recomputeBlocker`(Blocker 객체)를 **eager 생성**한다 — 한
|
||||
번도 마운트 안 되는 Slot까지. 가드를 넣으면 실측상 `books`에 항목이 안 생긴다.
|
||||
- 의미도 맞다 — 미실체화 Slot의 베이스 변경엔 할 일이 없다.
|
||||
|
||||
**4. 파괴는 `slot._destroyed` 플래그가 말한다 — 핸들은 안 지운다.**
|
||||
|
||||
> **사용자**: *"`slot._baseObserver` 가 두 일을 하는건 위험함. 이전에도
|
||||
> `invalidAfter` 처럼, 두 일을 겸하는걸 만들다가 사고가 난 적 많아."* /
|
||||
> (이름) *"`dispose` 는 형질이 다른 엔진 요소를 포함할 수 있는 것에 대한 공동
|
||||
> 소멸자인 네이밍. 자신이 삭제되고 그 여부는 `destroy` 가 맞아보이고, `dispose`
|
||||
> 는 슈거로써 `destroy` 와 별도의 맥락에서 해석해야해."*
|
||||
|
||||
- `destroySlotTree`가 `_baseObserver`/`_listObserver`/`_listActivated`를 `nil`로
|
||||
지우던 것을 **전부 그만둔다 — `unbindLifetime`만 한다.** 그 nil이 들던 근거
|
||||
(*"안 풀면 `gchold[physicalTarget]`이 observer를 계속 강하게 붙잡는다"*)는
|
||||
실은 **`unbindLifetime`이 하는 일**이고, nil 대입은 slot → observer 참조
|
||||
하나를 놓는 것뿐인데 slot 자신이 쓰레기라 그건 공짜다. 사용자가 좀비 slot을
|
||||
계속 들고 있어도 observer는 unbind 상태라 `canExecute`가 거짓이라 발화하지
|
||||
않는다.
|
||||
- **세 필드가 겸하던 뜻을 플래그가 가져간다.** `_listObserver == nil`은 원래
|
||||
*"`data`가 reactive가 아니다(plain table)"*라는 자기 의미가 있고(그걸 놓쳐서
|
||||
`activateList` 재마운트 분기에 `bindLifetime(nil)` 버그가 났었다),
|
||||
`_listActivated == nil`은 *"아직 최초 population을 안 했다"*였다. 거기에
|
||||
"파괴됨"을 겹쳐 싣던 게 `invalidAfter`와 같은 모양이었다.
|
||||
- **`_baseObserver`의 불변식이 한 문장이 된다**: 생성자에서 나서 Slot과 함께
|
||||
죽는다, **절대 `nil`이 아니다.** `Length`/`Offset`과 같은 층위.
|
||||
- **파괴된 Slot의 재사용은 error**(`level 2`, 메시지는 영어 —
|
||||
`base/architecture.md`의 error 계약). 지금까지 **어디에도 안 적혀 있던
|
||||
자리**다 — `slot-plan.md`는 *"언마운트된 Slot은 그대로 재사용 가능"*이라고만
|
||||
하고 파괴 쪽은 침묵했으며, `destroySlotTree`가 `_mounted`를 `false`로
|
||||
되돌려놔서 *"마운트된 Slot의 재마운트는 즉시 throw"* 가드에도 안 걸렸다.
|
||||
실제로 `destroySlotTree`는 **`_elements`를 안 비운다**(요소만 `nativeDispose`)
|
||||
— 파괴된 Slot은 파괴된 Instance를 든 좀비이고, 재마운트하면 죽은 Instance를
|
||||
다시 `Parent` 대입한다.
|
||||
- **`attachSlot`/`materializeSlotTree` 진입**에 가드 — 필수.
|
||||
- **공개 CRUD 진입**(`:Add`/`:Remove`/…)에도 가드 — `_elements`가 안
|
||||
비워지므로 죽은 Slot에 `Add`하면 **조용히** 좀비 배열이 자란다.
|
||||
`_crudUsed`/`_listed` assert 옆에 한 줄.
|
||||
- **이중 `dispose`는 얼리리턴 no-op** — teardown 경로가 겹치는 건 실재하고
|
||||
GC-native 기조와 맞는다. 막는 것은 **마운트/CRUD**뿐이다.
|
||||
플래그를 세우는 것도 얼리리턴도 **`destroySlotTree` 쪽**에 두므로 다형
|
||||
진입점 `dispose(value)`는 그걸 공짜로 물려받고 별도 가드가 필요 없다.
|
||||
|
||||
### 실측
|
||||
|
||||
9라운드 참조 구현(`ref9/`)에 `none`/`(a)`/`(c)`/`ctor` 네 변형을 넣고 같은
|
||||
매트릭스를 돌렸다 — `O = { a, b, S }`, `S = { C(중첩 Slot), plain }`(베이스 2)를
|
||||
마운트 → 언마운트 → 베이스 0인 다른 owner에 재마운트:
|
||||
|
||||
```
|
||||
[none] 재마운트: S.Offset=0 C.Offset=2 (기대 0) userLO=1 (기대 1) ❌
|
||||
[a] 재마운트: S.Offset=0 C.Offset=0 userLO=1 ✅
|
||||
[c] 재마운트: S.Offset=0 C.Offset=0 userLO=1 ✅
|
||||
[ctor] 재마운트: S.Offset=0 C.Offset=0 userLO=1 ✅
|
||||
(넷 다) 앞에 z 삽입 후: S.Offset=1 C.Offset=1 userLO=2 ✅
|
||||
```
|
||||
|
||||
`ctor` 변형으로 `d14`/`d10`/`d11`/`d13` 회귀 없음. 미실체화 Slot 생성 직후
|
||||
`books`에 항목이 안 생기는 것(3번 가드), 미실체화 `rawAdd`가 그대로 얼리리턴하는
|
||||
것도 같이 확인.
|
||||
|
||||
### 반영 대상
|
||||
|
||||
- `base/slot-plan.md` — `Slot(initial)` 생성자(필드 목록에 `Offset`/`_baseObserver`
|
||||
추가) · `materializeSlotTree`(순서 + 분기 삭제 + 콜백 가드) ·
|
||||
`unmountSlotTree`/`destroySlotTree`(핸들 보존, `_destroyed` 세팅, 이중 호출
|
||||
얼리리턴) · 공개 CRUD 가드 · *"언마운트된 Slot은 그대로 재사용 가능"* 절에
|
||||
파괴 쪽 계약 신설 · **`rawAdd`의 `_physicalTarget == nil` 얼리리턴은 유지하되
|
||||
근거 문장 정정**(*"`slot.Offset`이 아직 nil이라 `getOffsetAt`에서 즉시
|
||||
죽는다"*는 이제 거짓 — 남는 근거는 중첩 Slot의
|
||||
`attachSlot(element, nil, …)` → `bindLifetime(nil, …)` 하나다).
|
||||
- `base/dispatch-core-plan.md` — `Slot.Offset` 서술(*"마운트 시점에
|
||||
`setOffsetSource`가 등록하는 그 Source를 `self.Offset`으로도 저장"*)을 생성자
|
||||
기준으로.
|
||||
- `ROADMAP.md` — M6의 `Slot.Offset` 체크박스, 그리고 아래 `H-140`.
|
||||
|
||||
### 부수 발견 — `H-140`(🟢, 이 대화에서 나옴)
|
||||
|
||||
**`ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"***. `SL-75`/`D-60`이
|
||||
전면 정정한 문장인데(`slot-plan.md:3593`이 *"옛 서술은 … 그러면 포탈이
|
||||
무너진다"*로 폐기) **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면
|
||||
포탈 구독자가 영구히 끊기고 이번 생성자 불변식도 같이 무너진다 — 9라운드
|
||||
사냥 목록 #7(*"`ROADMAP.md` 체크박스가 `base/`보다 낡음"*) 그대로다. 발견
|
||||
문서에 `H-140`으로 등록했고 처분은 정정 하나다(판단 불필요).
|
||||
|
||||
---
|
||||
|
||||
## Q3 — `element → index`는 `bk`가 소유한다; 토큰 폐기 (`H-126` + `H-137` + `H-141`, 확정)
|
||||
|
||||
**결정: 문항의 (a)/(b)를 넘어 원인을 닫았다.** Q3의 표면 증상(`spliceArraysUp`이
|
||||
비운 자리의 `observers`/`tokens`)을 파다가, **같은 뜻의 맵이 두 층에 있고 그
|
||||
중 하나(`bk.tokens`/`bk.indexOfToken`)는 사용자가 정한 적 없는 것**임이 드러났다.
|
||||
|
||||
### 무엇이 있었나 — `token`의 출처
|
||||
|
||||
- 7라운드 `H-102`의 사용자 지시는 *"이미 `slot._elemIndex`: realElem → index 를
|
||||
관리중"* / *"그것을 dispatch 로 격상시키는게 더 나아보이는 지점"* — **요소 →
|
||||
인덱스 맵을 Dispatch로 올리라**는 것이었다.
|
||||
- `base/`에 내려앉을 때 키가 `len`(그 자리의 길이 State)으로 구현됐고, 8라운드
|
||||
직전 `/code-review high`가 *"`len`은 자리마다 유일하지 않다"*를 잡으면서 **그
|
||||
자리에서 `token = {}`이 발명됐다**(2026-08-25). 원래 키(요소)로 되돌아가는
|
||||
대신 새 신원을 만든 것이고, 사용자 인용문은 그 옆에 그대로 남아 승인된
|
||||
메커니즘처럼 읽혔다.
|
||||
- **사용자**: *"내가 등장시킨 적 없는 token 이 나와서 당황스러움"* / *"난 층위 상
|
||||
어떠한 값이든, 마운트된 부기객체 -> index(기여량이 아님) 를 얻고자 했음"*.
|
||||
"기여량이 아님"이 정확히 어긋난 지점이다 — 구현은 기여량(`len`)을 키로 잡았다.
|
||||
|
||||
### 확정된 것
|
||||
|
||||
**1. `element → index` 맵은 `bk`가 소유한다 — `bk.indexOfElement`. owner가 Slot이든
|
||||
`inst`든 규칙 하나.**
|
||||
|
||||
> **사용자**: *"slot 이든 inst 든 elem -> index 는 Dispatch bk 에 있어. 클로저가
|
||||
> 필요한 지점으로 안 보여. 문제의 elem->index 를 누가 관리하느냐가 어디서
|
||||
> 관리하느냐가 명확하지 않아서 자꾸 사고가 나는듯 한데."*
|
||||
|
||||
- **`slot._elemIndex`는 삭제**한다 — 같은 뜻의 맵을 두 층에 두던 것이 사고의
|
||||
원인. `indexOfRaw(self, element)`는 `getBookkeeping(self).indexOfElement[element]`
|
||||
조회가 되고, `reindexFrom(self, from)`은 **그 맵을 갱신하는 헬퍼**가 된다
|
||||
(시프트하는 자리 전부가 부르는 것은 `H-1` 그대로).
|
||||
- **`bk.tokens`/`bk.indexOfToken`은 삭제** — `token` 개념 소멸.
|
||||
- **`None`/진짜 `nil` 자리는 기록하지 않는다** — 길이가 상수 `0`이라 지속
|
||||
등록(Observer)이 없고, `nil`은 애초에 키가 못 된다. Slot 안에서 요소는
|
||||
유일하다(`_elements`엔 `None`이 안 들어가고 `claimOwner`가 이중 배치를 막는다).
|
||||
|
||||
**2. `Dispatch.setLength(ownerKey, i, len, anchor, element)` — 5번째 인자는 그
|
||||
자리의 `inst|slot`.** `gatedRecompute`는 **요소를 캡처**해 `bk.indexOfElement[element]`를
|
||||
조회한다.
|
||||
|
||||
> **사용자**: *"Dispatch.setLength 가 이제 받아야할 것은 inst|slot 이야. 그거 이외
|
||||
> 클로저로 저걸 해줄 이유가 없어보이는데. 게다가 슬롯 아니면 nil 이 나오는것도
|
||||
> 이상해."*
|
||||
|
||||
- 요소를 캡처하면 **갱신할 것이 없다** — 요소의 신원은 안 변하고, `요소 → index`는
|
||||
`reindexFrom`이 자기 이유로 이미 정확하게 유지한다. 위치를 캡처하던 옛
|
||||
모양(splice마다 갱신)과 토큰(배열 + 역방향 맵 둘 다 갱신)이 관리하던 것이
|
||||
전부 사라진다.
|
||||
- `element`를 생략한 호출(상수 길이 자리 — plain 요소 없이 `Nil`/`None` 핸들러)은
|
||||
지속 클로저가 안 생기므로 캡처한 `i`가 그대로 유효하다. 실제로 `len`이
|
||||
State인 호출은 **중첩 Slot의 `.Length`뿐**이고 그때 `element`는 그 Slot
|
||||
자신이다.
|
||||
|
||||
**3. Q3 본문 — `spliceArraysUp`이 비운 자리는 세 배열 전부 처리한다.**
|
||||
`lengthList[index] = 0`(`H-5`) · `sourceList[index] = None` · **`observers[index] = nil`**.
|
||||
`spliceArraysDown`은 당긴 뒤 꼬리(`N`)의 세 자리를 `nil`로. 근거는 `H-126`
|
||||
실측 — 복사 루프로 짜 `observers[index]`에 옛 값이 남으면 이어지는
|
||||
`setLength(self, index, …)`의 `oldObserver` 언바인드가 **밀려난 요소의 관측자를
|
||||
죽인다**(`vacate=false`에서 `B.Offset`이 2에 멈춤).
|
||||
|
||||
### 기각된 것 (전부 이 세션에서 제가 냈다가 철회한 안)
|
||||
|
||||
- **`observer.pos` / `observer.inst`** — 프리미티브 인스턴스에 임의 페이로드를
|
||||
얹는 것으로, **검토 후 안 만들기로 한 `Effect<UD>:Userdata()`/`SetUserdata`를
|
||||
다시 여는 문**(사용자: *"그건 닫은 Effect 의 userdata 허용을 거의 여는
|
||||
셈이야"*).
|
||||
- **`setLength`에 조회 클로저 / `subject` 인자** — 소유 층위를 정하는 대신 우회하는
|
||||
새 개념. 사용자가 정한 적 없는 이름.
|
||||
- **토큰 유지(이름만 변경)** — 맵이 두 층에 남는 원인을 그대로 둔다.
|
||||
|
||||
### 소멸·신설
|
||||
|
||||
- **`H-137` 소멸** — `rawMove`/`rawSwap` 규약의 토큰 누락은 토큰이 없어지며
|
||||
발견 자체가 사라진다(규약 4가 이동 구간 전부에 `setLength`를 다시 태워
|
||||
`indexOfElement`를 다시 쓴다).
|
||||
- **`H-141` 🟡 신설** — *확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을
|
||||
승인한 것*(토큰 옆의 격상 인용, 그리고 *"splice 요구 목록에 항목이 늘지
|
||||
않는다"*는 명분을 구현이 스스로 깬 것). 9라운드 사냥 목록 #1의 인용문 판.
|
||||
|
||||
### 반영에서 열어둔 확인 (판단 요청)
|
||||
|
||||
- **`H-1`이 `reindexFrom`을 `spliceArrays*`에서 분리한 근거가 소멸한다** —
|
||||
*"`spliceArrays*`는 `bk`를 만지므로 실체화된 뒤에만 부를 수 있다"*였는데,
|
||||
`getBookkeeping`은 lazy라 절대 `nil`이 아니다. 대신 **미실체화 Slot에 `:Add`만
|
||||
해도 `bk`(+ `recomputeBlocker`)가 생긴다**. Q2의 `_baseObserver` 가드가 막은
|
||||
것(*모든* Slot의 eager `bk`)과 범위가 다르다(*요소를 넣은* Slot만). 이 정도는
|
||||
괜찮다고 보고 반영했다 — 아니면 알려줄 것.
|
||||
- **쓰기 지점이 둘이다** — 등록은 `setLength`(`bk.indexOfElement[element] = i`),
|
||||
이동은 `reindexFrom`. `lengthList`가 `setLength`(등록)/`spliceArrays*`(이동)로
|
||||
갈리는 것과 같은 모양이라 그대로 뒀다.
|
||||
|
||||
---
|
||||
|
||||
## 반영 기록 — Q1~Q3 (2026-08-27)
|
||||
|
||||
**바뀐 파일**: `base/dispatch-core-plan.md`(`recompute` 루프 재배치 / `setLength`
|
||||
5번째 인자 + `bk.indexOfElement` / `H-102` 문단 정정 + `H-141` 배너 / 저장 위치·
|
||||
초기화 열거 / `_baseObserver` 즉시 발화 주석 / `Slot.Offset` 생성 자리 / `anchor`
|
||||
절에 `element` 축 추가) · `base/slot-plan.md`(`Slot(initial)` 생성자 /
|
||||
"파괴된 Slot은 재사용 불가" 절 신설 / `Slot:Add`·`Slot:List` 가드 /
|
||||
`makeBaseObserver` + `materializeSlotTree` 순서 재배치 / `unmountSlotTree` 흔적
|
||||
가드 제거 / `destroySlotTree` 핸들 보존·`_destroyed`·이중 호출 no-op / `H-1` 블록·
|
||||
`H-29` 규약 1·`raw*` 주석·`rawAdd` 배너 근거·`rawReplace`·`indexOfRaw` 정의 /
|
||||
splice 요구 목록 재작성 + `H-102` 항목 폐기 배너 + `H-132` 개수 제거) ·
|
||||
`ROADMAP.md`(M3 `setLength` 시그니처·`getBookkeeping` 초기화 / M6 필드 목록·
|
||||
`H-140`·`_elemIndex` 언급·`Slot.Offset` 체크박스) · 발견 문서(`H-141` 등록,
|
||||
`H-137` 소멸 표기, `H-125` 범위 정정).
|
||||
|
||||
**반영 중 드러난 것 — 사용자가 바꾼 적 없는데 있던 것** (판단 요청 포함):
|
||||
|
||||
1. **`token` 자체**(`H-141`) — 위 Q3 절.
|
||||
2. **`unmountSlotTree`의 `if slot._baseObserver then` 가드** — 옛 lazy 생성의
|
||||
흔적. 생성자 불변식("항상 있다")에 맞춰 무조건 호출로. 동작 차이 없음.
|
||||
3. **`rawReplace`가 맵을 직접 쓴다** — 옛 `self._elemIndex[old] = nil; [new] = index`
|
||||
두 줄이 `bk.indexOfElement`로 그대로 옮겨졌다. `setLength(…, newElement)`도
|
||||
등록하지만 **미실체화 분기는 `setLength`를 안 부르므로** 직접 쓰기가 남는다.
|
||||
쓰기 지점이 `setLength`/`reindexFrom`/`rawReplace` 셋이 됐다 — 규칙("등록은
|
||||
`setLength`, 이동은 `reindexFrom`")에서 `rawReplace`만 예외. 괜찮은지 확인
|
||||
요청.
|
||||
4. **`Owned = false`인 Slot은 `_destroyed`가 안 선다** — `destroySlotTree`가
|
||||
그 분기에서 `unmountSlotTree`로 빠져 꼬리(플래그 세팅)에 안 닿는다. "파괴
|
||||
대신 언마운트만"이라는 그 분기의 뜻과 정합하고(요소를 만든 적 없으니 좀비도
|
||||
없다) 재사용도 그대로 가능하다 — 의도대로라고 보고 그대로 뒀다. 확인 요청.
|
||||
5. **미실체화 `:Add`가 `bk`를 만든다**(Q3 절의 열어둔 확인) — 그대로 반영.
|
||||
6. `todos.md`의 6라운드 서술(*"`slot._elemIndex` 신설"*)은 역사 기록이라
|
||||
이동 표기만 덧붙였다.
|
||||
|
||||
**[2026-08-27 사용자 확인] 위 1~5 전부 의도대로.** 원문: (1) *"length,
|
||||
elem->index 를 위치를 재설정 해준다면 괜찮아"* — `rawReplace`의 직접 쓰기는
|
||||
"자리는 그대로, 주인만 교체"라 `lengthList`·`indexOfElement` 둘 다 그 자리를
|
||||
다시 쓰는 것으로 성립. (2) *"owned = false 은 state<Frame> -> slot(single) 형태가
|
||||
구현되는 것이라 맞아"* — 그 Slot은 요소를 만든 적이 없으니 파괴가 아니라
|
||||
언마운트이고 `_destroyed`가 안 서는 게 맞다. (3) *"미실체화 add 가 bk 만드는것도
|
||||
맞아. 그래야 인덱싱 매핑을 만드니까"*. (4) *"slot._baseObserver 는 unbind 만 했고,
|
||||
nil 로 지우는것만 안 한다면 맞아"*.
|
||||
|
||||
**아직 안 한 것**: `/code-review high`, 커밋 — Q4~Q10 반영 뒤 한 번에. README
|
||||
색인과 `todos.md` 00번 갱신은 감사 1라운드 지적으로 그 자리에서 했다. 감사
|
||||
루프는 Q1~Q3 반영분에 대해 돌았고 6라운드에서 수렴했다(아래 "감사 루프" 절).
|
||||
|
||||
|
||||
## 감사 루프 (2026-08-27, Q1~Q3 반영분)
|
||||
|
||||
관례대로 `quad-doc-auditor` 한 턴에 하나, diff 범위, 라운드마다 각도 변경.
|
||||
|
||||
| 라운드 | 각도 | 새 발견 | 처분 |
|
||||
|---|---|---|---|
|
||||
| 1 | `base/` 정합성 | 확실 1 · 판단 1 | `todos.md` 00번이 *"아직 안 돌렸다"*로 남아 있던 것 갱신 / README 색인(미뤄둔 것) 그 자리에서 추가 |
|
||||
| 2 | 인덱스 레이어 + 새 문단 자기모순 | 확실 1 · 의심 1 | `dispatch-core-plan.md`의 Length/Offset 두 API 요약 선언(1347행)이 4-인자로 남은 것 갱신 / `_destroyed` 가드가 `:Add`·`:List` 의사코드에만 있어 CRUD 표 머리에 "mutate 연산 전부" 일반 규칙 한 줄 신설 |
|
||||
| 3 | `archive/`·`reference/`·`luau-test/`·`audit/` 인용처 + 인용문 역방향 + 옛 발견 번호 재인용 | 확실 3 | `audit/handtrace-round7-reference-impl/README.md` 대조표에 9라운드 행 셋 + *"토큰 역참조"* 각주 / `ROADMAP.md` 머리 배너에 9라운드 소스 한 줄 / `README.md` `slot-plan` 색인 행의 `_elemIndex`에 통합 표기 |
|
||||
| 4 | 앞 라운드 수정분 자체 + followup/발견 문서 내부 정합 | 확실 1 | `CLAUDE.md`·`project-context.md`(둘 다 `@import`)가 결정 소스로 8라운드까지만 나열 — 9라운드 한 줄 추가. 그 외 앞 수정분·두 문서 내부 정합은 줄 단위 대조로 이상 없음 |
|
||||
| 5 | 4라운드 수정분 + 델타 밖 `base/` 문서 | 확실 1 · 판단 1 | `README.md`의 `dispatch-core`/`slot-plan` 색인 행에 Q1/Q2 요약 추가 / `architecture.md`의 `Slot.luau` 한 줄에 생성자 필드·`_destroyed` 표기(판단 항목 — 8라운드 행들이 같은 밀도라 넣음) |
|
||||
| 6 | 5라운드 수정분 + 신설 규칙 문단 | **확실 0** · 의심 1 | *"`Owned=false`는 `_destroyed`가 안 선다"*가 README 요약과 followup에만 있고 `slot-plan.md` 산문엔 없던 것 — 사용자 확정 인용과 함께 명문화. **수렴**(확실 0) |
|
||||
794
.claude/qa-request/pre-implementation-handtrace-round9.md
Normal file
794
.claude/qa-request/pre-implementation-handtrace-round9.md
Normal file
|
|
@ -0,0 +1,794 @@
|
|||
# 구현 전 손 트레이싱 **9라운드** — 발견 보고
|
||||
|
||||
**무엇인가**: `qa-request/pre-implementation-handtrace-round9-brief.md`(지시서)대로
|
||||
**커밋 `9dd8213` 하나의 델타**(8라운드 결정 Q1~Q10 반영 + 그 뒤 `/code-review
|
||||
high` 7패스의 수정)를 처음부터 다시 트레이싱한 결과. 발견 번호는 `H-124`부터.
|
||||
**저장소는 이 파일 말고 아무것도 수정하지 않았다**(스파이크·참조 구현 갱신본은
|
||||
세션 스크래치패드 `ref9/`에 있고, 근거는 전부 아래 항목에 인라인 전사돼 있다).
|
||||
|
||||
**쓴 각도**(지시서 §4의 문자 유지): **A**(반영분 상호 간섭 재트레이싱, 최우선) ·
|
||||
**C**(참조 구현을 현재 계약으로 다시 전사해 `luau`로 실행) · **G**(문서가
|
||||
"확인했다"고 적은 Luau 사실 재확인) · **D**(`ROADMAP.md` M2 체크박스로 실제
|
||||
짜는 시뮬레이션) · **B**(M5+ 값 단위 트레이싱 — 이 라운드의 첫 시도, 별도 절).
|
||||
**H**(비용)와 **E**(공개 API 오용)/**F**(미다룸 영역)는 이번 델타에 걸리는
|
||||
자리만 봤다(§6).
|
||||
|
||||
**실제로 본 범위**: `git show 9dd8213 -- .claude/base/ ROADMAP.md` 전량(2180줄)
|
||||
+ 그 델타가 사는 원문 절 전문 — `dispatch-core-plan.md`(`getBookkeeping`
|
||||
열거 / `getOffsetAt` / "두 필드" / 무효화 표·배치 목록 / `H-119` 절 /
|
||||
`recompute` / `setLength` / `setOffsetSource` / 배치 게이팅 / `drive` 꼬리),
|
||||
`slot-plan.md`(`Slot:List` 꼬리 / `activateList` 머리 / `materializeSlotTree` ·
|
||||
`mountSlotTree` · `attachSlot` / `unmountSlotTree` · `destroySlotTree` /
|
||||
`rawUnmount` · `rawDetach` · `rawAdd` · `rawReplace` · `rawRemove` / `H-29` 규약
|
||||
/ `spliceArrays*` 요구 목록), `effect-plan.md`(생성자·훅·`Rerun`·
|
||||
`Subscribe`/`Unsubscribe` 절·폐기 블록), `lifecycle-pattern.md`((1)·(2) 절),
|
||||
`ref-plan.md`(콜백 계약·`WeakCallback`·`:Set` 순서·`Revision`),
|
||||
`source-state-plan.md`(전파 루프·Observer 절·`WeakSubscribe` 절·이중 바인딩
|
||||
절), `state-epoch-plan.md` §4, `store-plan.md`(`H-112`/`H-115`/`H-117` 블록),
|
||||
`typing-limits.md` §0, `lifecycle-hooks-plan.md`(`guard`), `ROADMAP.md` M2
|
||||
전량 + M8 머리. 8라운드 §5가 "이상 없다"고 닫은 자리는 다시 파지 않았다.
|
||||
|
||||
**읽는 순서**: 요약 표 → 🔴 둘(`H-124`/`H-125`, 둘 다 실측 재현) → §4 결정 문항
|
||||
→ 나머지 상세 → §5(이상 없음 — 특히 **8라운드가 남긴 캐비엇 하나가 실측으로
|
||||
닫혔다**) → §6.
|
||||
|
||||
---
|
||||
|
||||
## 요약 표
|
||||
|
||||
| 번호 | 심각도 | 한 줄 | 주 대상 | 성격 | 실측 |
|
||||
|---|---|---|---|---|---|
|
||||
| `H-124` | 🔴 | `recompute`가 `bk.lengthList[i]`를 **되감기 판정보다 먼저** 읽는다 — 커서 자리 이후로 자리 수가 줄면 `sum += nil` 크래시, 그 뒤 `recomputeBlocker`가 영구 On | `dispatch-core-plan.md` `recompute` | `H-113`×`H-119` 겹침 (A) | ✅ 재현 |
|
||||
| `H-125` | 🔴 | 재마운트(포탈/Detach 복귀)에서 `setOffsetSource`가 `slot.Offset`을 바꾸는 순간 `_baseObserver`가 **unbind 상태**라 두 필드가 0으로 안 내려가고, 꼬리 `recompute`가 **옛 베이스의 `offsetCache[1]`**을 그대로 쓴다 | `slot-plan.md` `materializeSlotTree`×`unmountSlotTree` | `H-3`×`H-119`×`C-4` 겹침 (A) | ✅ 재현 |
|
||||
| `H-126` | 🟡 | `spliceArraysUp`이 비운 자리 `index`의 `observers`/`tokens`를 비우라는 요구가 없다 — 복사 루프로 짜면 **같은 Observer·토큰이 두 자리에** 살아 `setLength`가 옆 자리 Observer를 unbind한다 | `slot-plan.md` splice 요구 목록 | `H-5`×`H-102` 겹침 (A) | ✅ 재현(구현 갈래 대조) |
|
||||
| `H-127` | 🟡 | `EffectHandle:Unsubscribe()`의 서술 순서가 "플래그 → cleanup → fail-fast"라 leaf 바인딩된 Effect에 부르면 **cleanup을 먼저 소진한 뒤 error**한다(`E-11`이 막으려던 그것). Effect 쪽 `Subscribe`/`Unsubscribe` 의사코드가 없고 "네 진입점 전량"이 Observer만 가리킨다 | `effect-plan.md`×`lifecycle-pattern.md` | fail-fast 3종×`Effect` cleanup (A) | — |
|
||||
| `H-128` | 🟡 | M2 체크리스트대로 `Effect`를 짜면 `isRef`/`Ref:WeakCallback`/`.Revision`이 필요한데 **`Ref`는 M8**이고 M2엔 그 표면의 체크박스가 없다(M8은 "M2가 전제한다"고 인정만) | `ROADMAP.md` M2×M8 | 구현 순서 (D) | — |
|
||||
| `H-129` | 🟢 | `ROADMAP.md` M2 `Effect` 체크박스가 *"`Ref`는 `:Callback`"* — `H-58` 이후 `:WeakCallback`이 정본(같은 체크박스 몇 줄 아래는 맞게 적혀 있다) | `ROADMAP.md` | 옛 문장 잔존 (사냥 #5) | — |
|
||||
| `H-130` | 🟢 | `effect-plan.md`의 ⛔ 폐기 블록(`_observers` cascade) **안**을 이 커밋이 편집했다(`.Subscribed`는 구독 경로 전용) — 죽은 문단이 갓 정비된 것처럼 보이고, `H-114`의 "`_observers` 잔재 세 곳 지웠다"와도 어긋난다 | `effect-plan.md` 490~514 | 사냥 #4 | — |
|
||||
| `H-131` | 🟢 | *"`for d in seen do`는 유효한 Luau가 아니다 — 테이블을 호출하려 든다"*(7차 #3, `effect-plan.md` 주석)는 **거짓** — 일반화 반복은 Luau가 지원하고 `--!strict`도 통과한다. `pairs`로 바꾼 것 자체는 무해, 근거만 틀림 | `effect-plan.md`×followup 7차 | Luau 사실 (G) | ✅ 실측 |
|
||||
| `H-132` | 🟢 | `slot-plan.md` 2900행의 *"…해야 하는 일이 **셋** 늘었다"* 헤딩 아래 bullet이 넷(`H-102`가 넷째를 더함) | `slot-plan.md` 2900 | 사냥 #3 | — |
|
||||
| `H-133` | 🟢 | `WeakUnsubscribe`는 **구독한 적 없는 값**에 조용히 통과한다(가드가 "강한 킵 있음"만 본다) — *"해제는 건 경로로 푼다 … 양방향 fail-fast"* 산문과 비대칭. 의도라면 명문화 | `lifecycle-pattern.md` (2) | 계약 정합 (E) | ✅ 실측 |
|
||||
| `H-134` | 🟡 | `InstanceChildHandler`(`Frame { Frame {} }`의 정적 자식)의 배열 자리 부기(`setOffsetSource`/`setLength(…, 1)`)가 **어디에도 명세돼 있지 않다** — 말단 표에 행이 없고 의사코드도 없어 `H-39`의 "전수"가 못 잡았다. `Frame { Frame{}, Slot() }`이 첫 마운트에 `H-106` 가드로 즉사 | `dispatch-core-plan.md` 말단 표 × `architecture.md` × `ROADMAP.md` M5 | 레인 B (M5) | 값 트레이스 |
|
||||
| `H-135` | 🟡 | `ui-shorthand-plan.md` 안에 숏핸드 retractor 모양이 **둘** — *"`function() end`이면 충분"*(136행) vs Tween 절 스케치 `function(hint) if hint == nil then destroyManagedChild end`(169행). 후자면 (A)-`nil`에서 이중 파괴, (B)에서 자식 파괴·재생성으로 트윈 스냅 | `ui-shorthand-plan.md` | 레인 B (M5) | 값 트레이스 |
|
||||
| `H-136` | 🟡 | `:List` **재실행** reconcile은 배치 Blocker 없이 돌아 raw op마다 `recompute` + `Length:Set`이 난다 — *"한 사이클 전체가 끝난 뒤 한 번만"*(`dispatch-core-plan.md` 2344) 서술과 모순, 최초 population만 감싼 O(n²) 논거가 재실행엔 빠져 있다 | `slot-plan.md` `activateList`/`reconcile` × `dispatch-core-plan.md` | 레인 B (M6) | 값 트레이스 |
|
||||
| `H-137` | 🟢 | `rawMove`/`rawSwap` `H-29` 규약 1의 치환 목록에 `bk.tokens`/`bk.indexOfToken`이 없다(`H-102`가 splice에만 요구) — 규약 4(이동 구간 **전부** `setLength` 재등록)에 암묵 의존, 토큰은 자리 귀속이라 치환하면 안 된다는 것도 미명시 | `slot-plan.md` `H-29` 규약 | 레인 B | — |
|
||||
| `H-138` | 🟢 | `UICorner`류 숏핸드 핸들러와 `PropertyHandler`의 매치 구분(리플렉션 거부? 우선순위?)이 명세에 없다 — 이름이 우연히 프로퍼티와 겹치면 조용히 `PropertyHandler`가 먹는다 | `ui-shorthand-plan.md` × `bind-system-plan.md` | 레인 B | — |
|
||||
| `H-141` | 🟡 | **확정의 근거로 인용된 사용자 발언이 실제로는 다른 것을 승인한 것** — `H-102`의 *"dispatch 로 격상"* 인용 옆에 사용자가 정한 적 없는 `bk.tokens`/`indexOfToken`(`token = {}`, 2026-08-25 `/code-review`가 발명)이 확정 메커니즘처럼 앉아 있었고, 같은 문단의 *"splice 요구 목록에 항목이 늘지 않는다"*는 구현이 스스로 깼다(둘 늘렸다). 같은 뜻의 맵(`slot._elemIndex`)이 Slot 층에 따로 살아 두 층 이원화 | `dispatch-core-plan.md` `H-102` 문단 × `slot-plan.md` | 사냥 #1(인용문 판) — Q3 처리 중 발견 | — |
|
||||
| `H-140` | 🟢 | `ROADMAP.md:1124`가 아직 *"해제 시 `slot.Offset = nil`"* — `SL-75`/`D-60`이 전면 정정한 문장인데 **구현자가 실제로 보는 체크박스**에 살아남았다. 그대로 짜면 포탈 구독자가 영구히 끊긴다 | `ROADMAP.md` M6 | 사냥 #7 | — |
|
||||
| `H-139` | 🟢 | `New`가 둘(모듈 팩토리 `New(): Quad` / 인스턴스 생성자 `New "Frame" {…}`)이고, `D.Frame {…}`의 파이프라인(생성 → gcconn → flatten → drive)은 네 문서에 흩어져 있어 의사코드가 한 곳에 없다 | `module-lifecycle-plan.md` / `bind-system-plan.md` | 레인 B | — |
|
||||
|
||||
---
|
||||
|
||||
## 상세
|
||||
|
||||
### `H-124` 🔴 — `recompute`의 `lengthList[i]` 읽기가 되감기 판정보다 앞이다 (A×C)
|
||||
|
||||
**무엇이 문제인가.** 확정 의사코드(`dispatch-core-plan.md` `recompute`)의 루프
|
||||
본문 순서는 이렇다:
|
||||
|
||||
```lua
|
||||
local abs = Dispatch.getOffsetAt(ownerKey, i)
|
||||
bk.offsetSetUpTo = i
|
||||
if offset ~= None and offset:Get() ~= abs then
|
||||
offset:Set(abs) -- ← 사용자 코드가 돌 수 있는 자리
|
||||
end
|
||||
local v = bk.lengthList[i] -- ← (1) 여기서 읽고
|
||||
sum += (if isState(v) then v:Get() else v) -- ← (2) 더한 다음에
|
||||
if bk.offsetSetUpTo < i then -- ← (3) 되감기를 본다
|
||||
```
|
||||
|
||||
`offset:Set(abs)` 안의 사용자 코드가 **같은 owner에서 요소를 제거**하면
|
||||
(`H-119`가 "실재하는 재진입 경로"로 확정하고 게이트를 세운 바로 그 상황),
|
||||
`rawRemove` → `spliceArraysDown`이 `bk.N`을 줄이고 두 필드를 `index - 1`로
|
||||
내린 뒤 **재진입 게이트에 걸려 `recompute`를 건너뛴다**(`H-119` 설계대로).
|
||||
제어가 (1)로 돌아왔을 때 **`i > bk.N`이면 `lengthList[i]`는 이미 `nil`**이다 —
|
||||
(2)에서 `attempt to perform arithmetic (add) on number and nil`. (3)의 되감기는
|
||||
한 줄 늦다.
|
||||
|
||||
`i > bk.N`이 되는 조건은 "커서 `i`가 마지막 자리(`i == N`)일 때 어느 자리든
|
||||
하나 제거" 또는 "`i`보다 앞·같은 자리에서 여러 개 제거(`rawSplice`/`rawClear`)".
|
||||
`H-113`(커서 자리 splice)은 `j == i < N`만 봤고, `H-119`는 "건너뛴 몫은 되감기가
|
||||
복구한다"고 했는데 **되감기에 도달하기 전에 죽는다**.
|
||||
|
||||
**어떻게 재현했나** — 참조 구현을 현재 계약(두 필드·`-1`·`H-119` 게이트·
|
||||
클램프)으로 다시 전사한 `dispatch9.luau` 위에서(`recompute`는 위 순서 그대로):
|
||||
|
||||
```lua
|
||||
-- d10_remove_at_tail.luau (요지)
|
||||
local s1, s2, s3 = L("s1"), L("s2"), L("s3") -- 각각 평범한 자식 하나(길이 1)
|
||||
local O = D.newSlot("O", { s1, s2, s3 }); D.mountTop(inst, O) -- offsets 0,1,2 / O.Length 3
|
||||
local w = s3.Offset:Observer(function(_, _, from) -- 마지막 자리(i = N = 3)의 offset을 관측
|
||||
if from == nil or fired then return end; fired = true
|
||||
D.rawRemove(O, 1) -- 그 통지 도중 O:Remove(1)
|
||||
end); q.bindLifetime(inst, w)
|
||||
D.rawAdd(s1, {name="s1_y"}) -- s1 길이 1→2 → O의 setLength Observer → recompute(O) → i=3에서 s3.Offset:Set(3) → watcher
|
||||
```
|
||||
|
||||
출력(전사):
|
||||
|
||||
```
|
||||
초기: s3.Offset 2 O.Length 3
|
||||
[watcher] s3.Offset -> 3 → O:Remove(1) 호출
|
||||
[watcher] rawRemove 반환. N = 2 offsetSetUpTo = 0
|
||||
rawAdd(s1, child) → ERROR: ./dispatch9.luau:77: attempt to perform arithmetic (add) on number and nil
|
||||
O.Length 3 | N 2 | s2.Offset 2 (기대 0) | s3.Offset 3 (기대 1)
|
||||
```
|
||||
|
||||
`rawRemove`는 게이트(`bk.recomputeBlocker:IsOn()` = true)에서 정확히 건너뛰었고
|
||||
(`spliceArraysDown`이 `offsetSetUpTo`를 `0`으로 당겨둔 것까지 설계대로), 바깥
|
||||
루프가 `lengthList[3]`(이제 `nil`)을 읽다 죽었다.
|
||||
|
||||
**그대로 구현하면.** (a) 사용자가 `slot.Offset`을 관측하는 콜백에서 형제를 지우면
|
||||
— 코퍼스가 정상 경로로 확정한 재진입 — 익명 산술 에러로 죽고, (b) `error`가
|
||||
`bk.recomputeBlocker:On()`과 `OffWithoutEmit()` 사이에서 나므로 **그 owner의
|
||||
`recomputeBlocker`가 영원히 켜진 채 남아** 이후 모든 `gatedRecompute`/명시 호출이
|
||||
조용히 건너뛰어진다(`H-87`의 "독 든 Blocker"와 같은 부류 — 그 Slot의 레이아웃이
|
||||
영구 동결). `Length`도 낡은 채(위 출력의 `O.Length 3`, 실제 2).
|
||||
|
||||
**처방 갈래**(§4 Q1): (a) 되감기 판정을 `lengthList[i]` 읽기 **앞**으로 —
|
||||
`if bk.offsetSetUpTo < i then i = math.max(…, 1); sum = prefix[i]; continue end`
|
||||
뒤에 읽기·누적·`i += 1`. 되감기가 없는 경우엔 지금과 동일하게 "Set 뒤에 읽는다"가
|
||||
유지되므로 `H-113`의 *"`sum`은 안 낡는다"* 논증도 그대로다. 제거가 `i`보다 **뒤**
|
||||
자리면 `N ≥ i`라 `nil`이 안 나온다(`p > i` 제거는 `offsetSetUpTo`를 `p-1 ≥ i`로만
|
||||
내려 되감기 없음, 그러나 `N ≥ p-1 ≥ i`). (b) 읽기 앞에 `if i > (bk.N or 0) then
|
||||
break end`만 두는 안 — 크래시는 막지만 그 자리부터 되감기 없이 끝나므로 (a)가
|
||||
필요 없어지지 않는다. **권고 (a).**
|
||||
|
||||
### `H-125` 🔴 — 재마운트에서 `_baseObserver`가 잠들어 있는 창 (A×C)
|
||||
|
||||
**무엇이 문제인가.** `H-3`이 세운 "베이스가 바뀌면 두 필드를 `0`으로"의 코드
|
||||
경로는 **`slot._baseObserver` 콜백 하나**다(`H-119`가 그 콜백에 `bk.offsetCacheValidUpTo,
|
||||
bk.offsetSetUpTo = 0, 0`을 실제로 넣었다). 그런데 재마운트 순서가:
|
||||
|
||||
```lua
|
||||
-- unmountSlotTree(slot) (slot-plan.md)
|
||||
if slot._baseObserver then unbindLifetime(slot._baseObserver) end -- ← 관측자가 잠든다
|
||||
-- … 나중에 attachSlot → materializeSlotTree(slot, newTarget, ownerKey, position)
|
||||
local offsetSource = slot.Offset or Source(0)
|
||||
Dispatch.setOffsetSource(ownerKey, position, offsetSource) -- ← 여기서 slot.Offset:Set(새 베이스)
|
||||
slot.Offset = offsetSource
|
||||
local blocker = getBlocker(slot); blocker:On()
|
||||
if slot._baseObserver then
|
||||
bindLifetime(physicalTarget, slot._baseObserver) -- ← 그 뒤에야 깨어난다(바인드는 발화가 아니다)
|
||||
```
|
||||
|
||||
`setOffsetSource`의 `source:Set(offset)`이 전파 루프를 돌 때 `_baseObserver`는
|
||||
`canExecute` 거짓(gcconn 없음, `.Subscribed` 없음)이라 **조용히 건너뛰어진다**
|
||||
(그게 `canExecute` 게이트의 정상 동작이다). 두 필드는 옛 값(`N_old`)에 그대로,
|
||||
`offsetCache[1]`은 옛 베이스. 이어서 꼬리의 `recompute(slot, bk)`가 `i = 1`에서
|
||||
`getOffsetAt(slot, 1)` → `1 <= offsetCacheValidUpTo` → **`offsetCache[1]`(옛
|
||||
베이스) 반환** → 자식 offset이 옛 베이스 + 접두합으로 "이미 맞다"고 판정돼
|
||||
`Set`이 안 난다. 그 다음 부모의 `setLength(ownerKey, position, slot.Length)`가
|
||||
부모 `recompute`를 돌려도 `slot.Offset`은 이미 새 값이라 `~=` 가드에 걸려
|
||||
`_baseObserver`는 **끝내 안 불린다**.
|
||||
|
||||
`H-3` 자신이 *"재마운트는 더 나쁘다: `bk`는 `Relate(slot)` 위에 있어 언마운트를
|
||||
넘어 살아남으므로 `offsetCache[1]`에 옛 베이스가 남는다"*라고 정확히 경고해뒀는데,
|
||||
그걸 닫는 유일한 경로가 그 순간 잠들어 있다. **파괴(`destroySlotTree`) 뒤
|
||||
재사용은 무사하다** — 거기선 `_baseObserver = nil`이라 재마운트가 새 Observer를
|
||||
만들고, 그 "등록 즉시 1회 실행"이 두 필드를 `0`으로 내린다(가드에 걸려
|
||||
`recompute`는 안 하지만 `0`은 남는다). **깨지는 건 언마운트 보존 경로**
|
||||
(`rawUnmount`/`rawDetach` → `Detach` 복귀·`State<Slot>` 교체·포탈)뿐이다.
|
||||
|
||||
**어떻게 재현했나** (`d12_remount_stale.luau`, 같은 전사물):
|
||||
|
||||
```lua
|
||||
local t1, t2 = D.newSlot("t1", {{name="t1x"}}), D.newSlot("t2", {{name="t2x"}})
|
||||
local S = D.newSlot("S", { t1, t2 })
|
||||
local O = D.newSlot("O", { {name="a"}, {name="b"}, S }) -- S의 베이스 2
|
||||
D.mountTop(inst, O) -- t1.Offset 2, t2.Offset 3
|
||||
D.rawRemove(O, 3) -- (전사물에선 unmountSlotTree로 흉내 = rawUnmount 경로)
|
||||
local O2 = D.newSlot("O2", {}); D.mountTop(inst2, O2)
|
||||
D.rawAdd(O2, S) -- → materializeSlotTree(S, inst2, O2, 1): 베이스 0
|
||||
```
|
||||
|
||||
출력(전사):
|
||||
|
||||
```
|
||||
1차 마운트: S.Offset 2 (기대 2) t1 2 (2) t2 3 (3)
|
||||
S.bk: cacheValid 2 setUpTo 2 cache[1] 2
|
||||
언마운트 후: canExecute(S._baseObserver) = false | S.Offset(보존) 2
|
||||
재마운트: S.Offset 0 (기대 0) | t1.Offset 2 (기대 0) | t2.Offset 3 (기대 1)
|
||||
S.bk: cacheValid 2 setUpTo 2 cache[1] 2 (베이스 0이어야)
|
||||
❌ 자식 offset이 옛 베이스 위에 남았다
|
||||
```
|
||||
|
||||
**도달 경로는 넷** — 포탈(언마운트→재마운트), `Detach` 복귀(레인 B가 `:List`의
|
||||
`settle` → `rawAdd(…, true)`로 실제로 밟는 것을 확인 — `H-139` 꼬리의 교차 항목),
|
||||
`State<Slot>` 교체(= 언마운트, 같은 `unmountSlotTree`), `rawUnmount`로 꺼낸 Slot의
|
||||
재배치.
|
||||
|
||||
**그대로 구현하면.** 포탈/Detach 복귀/`State<Slot>` 교체로 옮겨진 Slot의
|
||||
**중첩 Slot 자식**이 옛 베이스 위의 `Offset`을 유지한다 — `H-3`이 경고한
|
||||
*"위로는 맞고 옆으로만 틀린다"*의 재마운트 판. 그 아래로 재귀하므로 중첩
|
||||
서브트리가 통째로 옛 좌표계에 남고, 다음 계기(그 자리 앞 형제의 길이 변경)까지
|
||||
아무도 고치지 않는다.
|
||||
|
||||
> **⚠️ [2026-08-26 범위 정정 — Q2 처리 중 실측] 피해 범위는 "자식 전부"가
|
||||
> 아니라 *부기를 경유하는 중첩 Slot의 `Offset`*이다.** 처음엔 유저 체인
|
||||
> (`updateFn`이 받은 `offset`으로 `LayoutOrder`를 정하는 것)까지 안 고쳐진다고
|
||||
> 적었는데 **틀렸다** — `setOffsetSource`의 `Set`은 전파 루프를 정상적으로 돌고,
|
||||
> 유저 체인의 말단 Observer는 **요소 자신의 인스턴스**에 `bindLifetime`돼 있어
|
||||
> (언마운트는 요소를 파괴하지도 `unbindLifetime`하지도 않는다) `canExecute`가
|
||||
> 참이다. 건너뛰어지는 건 `_baseObserver` 하나뿐이다. 실측은
|
||||
> `-round9-followup.md`의 Q2 절(`d16_remount_variants.luau`, `[none]` 행에서
|
||||
> `userLO`는 정확하고 `C.Offset`만 어긋난다).
|
||||
|
||||
**처방 갈래**(§4 Q2): (a) **`materializeSlotTree` 진입부**(`setOffsetSource`
|
||||
앞)에서 `bk.offsetCacheValidUpTo, bk.offsetSetUpTo = 0, 0` — "실체화는 항상
|
||||
1번부터"라는 한 줄 불변식이 되고 첫 마운트엔 무해(이미 0). (b) `unmountSlotTree`
|
||||
꼬리에서 같은 초기화 — 잠재우는 쪽이 치우는 대칭. (c) `_baseObserver` 바인드와
|
||||
`blocker:On()`을 `setOffsetSource` **앞**으로 옮겨 그 콜백이 깨어 있게 하는 안 —
|
||||
콜백이 가드에 걸려 `0`만 남기고 돌아오므로 동작은 되지만, "관측자가 깨어 있어야
|
||||
초기화된다"는 우연에 계속 기댄다. **권고 (a)** — `_baseObserver`의 `0`은
|
||||
런타임 베이스 변경용으로 그대로 두고, 실체화 경계엔 명시 초기화를 둔다.
|
||||
|
||||
### `H-126` 🟡 — `spliceArraysUp`이 비운 자리의 `observers`/`tokens` (A×C)
|
||||
|
||||
**무엇이 문제인가.** `slot-plan.md`의 `spliceArrays*` 요구 목록은 비워지는
|
||||
자리에 대해 `lengthList[index] = 0`(`H-5`)과 `sourceList[index] = None`
|
||||
(*"`sourceList`가 `None`으로 채워지는 것과 대칭"*)만 명시한다. `bk.observers`·
|
||||
`bk.tokens`는 *"다른 배열과 같이 당긴다 / 완전히 같은 처리"*라고만 돼 있다.
|
||||
`table.insert(t, index, x)` 의미론이면 자리가 자연히 비지만, **7라운드 전사물
|
||||
`d7_splice_fix.luau`처럼 `for i … do t[i] = t[i+1]` 복사 루프로 짜면**(그 전사물의
|
||||
Down이 그렇고, Up은 아무도 안 썼다) `observers[index]`와 `tokens[index]`에 옛
|
||||
값이 **남는다** — 곧 `index`와 `index+1`이 같은 Observer·같은 토큰을 가리킨다.
|
||||
|
||||
그 뒤 `setLength(self, index, …)`는 `oldObserver = bk.observers[index]`를 보고
|
||||
**`unbindLifetime`한다** — 그게 이제 `index+1`(밀려난 요소)의 Observer다. 그리고
|
||||
`indexOfToken[token]`이 `index`로 덮여 `index+1`의 `gatedRecompute`가 엉뚱한
|
||||
자리를 무효화한다(`H-102` 그 자체).
|
||||
|
||||
**어떻게 재현했나** (`d15_spliceup_vacate.luau` — 두 구현 갈래를 같은 전사물
|
||||
위에서 대조; `vacate`가 비운 자리를 `nil`로 두느냐):
|
||||
|
||||
```
|
||||
[vacate=true] A.Length=3 후 B.Offset = 4 (기대 4) | canExecute(observers[2]) = true | indexOfToken[tokens[2]] = 2
|
||||
[vacate=false] A.Length=3 후 B.Offset = 2 (기대 4) | canExecute(observers[2]) = false | indexOfToken[tokens[2]] = 1
|
||||
```
|
||||
|
||||
`vacate=false`에선 밀려난 `A`(2번 자리)의 길이 Observer가 unbind돼 **A가
|
||||
커져도 B가 영영 안 밀린다.**
|
||||
|
||||
**그대로 구현하면.** 구현자가 어느 쪽으로 짜든 "문서대로"라고 말할 수 있는
|
||||
자리에서, 한쪽은 `:Add(x, index)`(중간 삽입) 뒤 그 자리에 있던 요소의 길이
|
||||
변경이 조용히 무시된다.
|
||||
|
||||
**처방**(§4 Q3): 요구 목록에 한 줄 — *"`spliceArraysUp`은 `observers[index]`·
|
||||
`tokens[index]`를 `nil`로 비운다(`lengthList`/`sourceList` 자리표시자와 같은
|
||||
자리)"* 또는 *"네 배열 전부 `table.insert`/`table.remove` 의미론"*. 새 결정이
|
||||
아니라 누락 명시.
|
||||
|
||||
### `H-127` 🟡 — `EffectHandle:Unsubscribe()`의 순서와 의사코드 부재 (A)
|
||||
|
||||
**무엇이 문제인가.** 이 커밋이 `Subscribe`/`Unsubscribe`/`WeakUnsubscribe`를
|
||||
전부 fail-fast로 확정하며 `lifecycle-pattern.md` (2)에 **`Observer:` 네 진입점**
|
||||
의사코드를 새로 썼고 *"아래가 네 진입점 전량이고 소스다"*라고 적었다. 그런데
|
||||
`Effect`도 같은 레지스트리·같은 `.Subscribed`를 쓰고(`isBoundAlive`가
|
||||
`isObserver(value) or isEffect(value)`), `effect-plan.md`는 `EffectHandle`의
|
||||
`:Subscribe()`/`:Unsubscribe()`를 **산문으로만** 규정한다. 그 산문의 순서가:
|
||||
|
||||
1. `handle.Subscribed = false` (향후 재실행 끊김)
|
||||
2. 직전 cleanup을 정확히 1회 호출
|
||||
3. **[2026-08-26 정정]** fail-fast — 약하게만 구독된/구독 안 한 값이면 error
|
||||
|
||||
`E-11`은 *"leaf 바인딩된 핸들에는 `:Unsubscribe()`가 아예 안 먹는다"*를
|
||||
확정했다(cleanup을 앞당기면 dedup 재디스패치에서 Effect가 조용히 죽는다는
|
||||
근거). 위 순서대로 짜면 leaf 바인딩된 Effect에 `:Unsubscribe()`를 부를 때
|
||||
**2번이 cleanup을 소진한 뒤 3번이 error** — 에러는 나지만 `E-11`이 막으려던
|
||||
피해(cleanup 앞당김, `_installed = false`)는 이미 일어난 뒤다. Observer 쪽
|
||||
의사코드는 가드가 첫 줄이라 이 문제가 없다.
|
||||
|
||||
부수로: `EffectHandle`에 `WeakSubscribe`/`WeakUnsubscribe`가 있는지, `Subscribe`가
|
||||
Observer의 것을 그대로 쓰는지(`canBound` 게이트 포함) 어디에도 없다 — 전사물은
|
||||
`Observer.Subscribe` 등을 그대로 물려받는 쪽으로 옮겼고(`core9.luau`), 그렇게
|
||||
하면 `t12` 매트릭스가 전부 기대대로 돈다(§5).
|
||||
|
||||
**처방**(§4 Q4): `effect-plan.md`에 의사코드 한 블록 — `Unsubscribe`는
|
||||
**Observer의 게이트를 먼저**(`Observer.Unsubscribe(self)` 위임 → 통과해야만)
|
||||
`_consumeCleanup()`; `Subscribe`/`WeakSubscribe`/`WeakUnsubscribe`는 Observer
|
||||
것을 그대로 재사용한다고 명시(핸들 자신만, 내부 Observer 무관 — `H-59`).
|
||||
`lifecycle-pattern.md`의 *"아래가 네 진입점 전량이고 소스다"* 문장은 "Observer의
|
||||
네 진입점, Effect는 같은 것을 재사용"으로.
|
||||
|
||||
### `H-128` 🟡 — M2의 `Effect`가 M8의 `Ref` 표면을 전제한다 (D)
|
||||
|
||||
**무엇이 문제인가.** `ROADMAP.md` M2를 위에서부터 짜는 시뮬레이션: `Effect(fn,
|
||||
...deps)` 체크박스와 그 아래 "구현 시 같이 만들 것"이 `isRef(d)` 분기 →
|
||||
`d:WeakCallback(onRefFire)` → `self._epochs:Sync(d)`(`d.Revision` 읽기)를
|
||||
요구한다. 셋 다 **`Ref.luau`의 표면**이고 `Ref.luau`는 **M8**이다. M2 "공통
|
||||
기반"엔 `Brand`/`Relate`/`LifetimeHandle`만 있고 `Ref`의 최소형 체크박스가 없다.
|
||||
M8 머리는 *"M2가 이미 이 표면을 전제한다"*라고 **인정만** 하고, M2는 이 앞으로
|
||||
참조를 어디서도 말하지 않는다. 8라운드 §5 D각도의 *"앞으로 참조 없음"*은 이
|
||||
자리를 못 봤다(그때 `Effect`의 `Ref` 분기가 `H-107`로 막 다시 쓰이던 중이었다).
|
||||
|
||||
**그대로 구현하면.** M2 구현자가 `Effect`의 `Ref` 분기를 (a) `isRef` 스텁으로
|
||||
비워두거나 (b) M8을 앞당겨 절반쯤 짜게 된다 — 어느 쪽인지 체크박스가 정하지
|
||||
않으므로 M2의 "mock 대상 테스트"가 `Ref` dep 경로를 안 돌린 채 M2가 끝난다.
|
||||
|
||||
**처방 갈래**(§4 Q5): (a) M2 공통 기반에 **`Ref` 최소형 체크박스** 신설 —
|
||||
`.Value`/`.Revision`/`:Set`/`:WeakCallback`/`:Callback`/`:Uncallback`/`isRef`
|
||||
(`Epoch`를 만족하는 데 필요한 것만; `PreRef`/`PostRef`/`:Wait`/디스패치 핸들러는
|
||||
M8 그대로). `Ref`가 `Epoch`인 것 자체가 M2의 결정(`H-58`/`H-64`/`H-70`)이라
|
||||
자연스럽다. (b) M2에선 `Ref` dep을 **명시적으로 미룬다**고 적고 M8에 "`Effect`의
|
||||
`Ref` 분기 + 그 테스트"를 체크박스로. **권고 (a)** — `Effect`의 `_epochs`
|
||||
배선이 `isEpoch` 분기로 갈리는 걸 M2 안에서 한 번은 실제로 돌려봐야 한다.
|
||||
|
||||
### `H-129` 🟢 — ROADMAP `Effect` 체크박스의 *"`Ref`는 `:Callback`"*
|
||||
|
||||
`ROADMAP.md` M2 `Effect(fn, ...deps)` 체크박스: *"각각에 맞는 구독(State/Source는
|
||||
`Observer`, `Ref`는 `:Callback`)을 걸어"*. `H-58` 이후 정본은 `:WeakCallback`
|
||||
(강한 셋에 걸면 `Ref`가 `Effect`를 영원히 붙든다 — 7차 #4가 다이어그램에서 잡은
|
||||
바로 그것). 같은 체크박스 몇 줄 아래 "구현 시 같이 만들 것"은 `:WeakCallback()`
|
||||
으로 맞게 적혀 있다. 사냥 #5(고친 사실이 옆 문장에서 되살아남)의 사례.
|
||||
|
||||
### `H-130` 🟢 — 폐기 블록 안을 이 커밋이 편집했다
|
||||
|
||||
`effect-plan.md` 468~514: ⛔⛔ 배너(*"이 문단 전체는 옛 모델이다 … `_observers`
|
||||
배열/cascade/`Subscribe` 순회는 전부 `_deps` 하나와 `_blocker`로 대체됐다"*)
|
||||
아래 죽은 문단인데, 이 커밋의 diff가 그 안 한 줄(`.Subscribed`는 전역 →
|
||||
**구독 경로** 전용)을 고쳤다. 지시서 §3-4가 경고한 정확히 그 모양이다 — 날짜
|
||||
마커는 없지만 살아 있는 문장과 같은 표기로 갱신돼 있어 "관리되는 문단"으로 읽힌다.
|
||||
같은 블록에 `handle._observers[i] = observer` 등 옛 필드가 그대로 살아 있어
|
||||
followup의 *"같은 파일의 `_observers` 잔재 표기 세 곳도 같이 지웠다"*(`H-114`)와도
|
||||
안 맞는다(배너 아래라 오독은 안 되지만 "세 곳"이 전수가 아니었다). 처분: 그
|
||||
한 줄을 되돌리거나(배너만 있고 본문은 안 만진다는 새 규칙), 블록을 `archive/`로.
|
||||
|
||||
### `H-131` 🟢 — *"`for d in seen do`는 유효한 Luau가 아니다"*는 거짓 (G)
|
||||
|
||||
followup 7차 #3과 `effect-plan.md` 생성자 주석(*"`for d in seen`은 테이블을
|
||||
호출하려 들어 죽는다"*). 실측:
|
||||
|
||||
```lua
|
||||
--!nocheck (g3_for_in_table.luau)
|
||||
local seen = { a = true }
|
||||
local ok, err = pcall(function() for d in seen do print(d) end end)
|
||||
print("for d in seen do (일반화 반복):", ok, err) --> a / true nil
|
||||
```
|
||||
```lua
|
||||
--!strict (g3b_for_in_strict.luau → luau-analyze exit 0, 진단 0건)
|
||||
local seen: { [string]: boolean } = { a = true }
|
||||
for d in seen do print(d) end
|
||||
```
|
||||
|
||||
Luau의 일반화 반복(`__iter`/테이블 직접 순회)은 런타임·`--!strict` 둘 다
|
||||
통과한다 — 7라운드 전사물 `core.luau` 자신이 `for sub in node.subs do`로 돌고
|
||||
있었다. `pairs(seen)`로 바꾼 코드는 무해하지만 **근거 문장이 틀렸고**, 그
|
||||
문장이 `base/`에 Luau 사실로 남아 있으면 다음 구현자가 일반화 반복을 피해
|
||||
돌아간다. 주석의 근거를 지우거나 "스타일 통일"로 바꿀 것.
|
||||
|
||||
### `H-132` 🟢 — "셋 늘었다" 아래 bullet 넷
|
||||
|
||||
`slot-plan.md` 2900 *"`spliceArraysUp`/`spliceArraysDown`이 해야 하는 일이 **셋**
|
||||
늘었다"* — 아래 bullet은 `_elemIndex`(2903) / 자리표시자(2912) / 캐시 당김(2921)
|
||||
/ **`bk.tokens`·`indexOfToken`(2950, 7라운드 `H-102` 신설)** 넷. 이 커밋이 그
|
||||
절의 캐시 bullet을 크게 고치면서 헤딩 개수를 안 봤다(사냥 #3, 8라운드 2차/3차와
|
||||
같은 모양). `H-126`을 반영하면 다섯이 되므로 개수 대신 "아래 목록이 소스"로.
|
||||
|
||||
### `H-133` 🟢 — `WeakUnsubscribe`의 비대칭 (E)
|
||||
|
||||
`lifecycle-pattern.md` (2) 의사코드의 `WeakUnsubscribe`는 `Subscribed[self] ~= nil`
|
||||
(강한 킵)만 막는다. 구독한 적 없는 값·이미 `WeakUnsubscribe`된 값에 부르면
|
||||
**조용히 통과**(`WeakSubscribed[self] = nil; .Subscribed = false`). 실측
|
||||
(`t12_subscribe_failfast.luau`):
|
||||
|
||||
```
|
||||
구독 안 한 값에 Unsubscribe → error: not subscribed strongly; use :WeakUnsubscribe()
|
||||
구독 안 한 값에 WeakUnsubscribe → 통과(no error)
|
||||
```
|
||||
|
||||
반면 산문은 *"양방향 대칭 가드 … 구독한 적 없는 값에 부르는 것도 error다"*
|
||||
(`Unsubscribe`에 대해서만 말하지만, "해제는 건 경로로 푼다 **하나**"라는 문장은
|
||||
약한 쪽도 포함해 읽힌다). leaf 바인딩된 Observer에 `WeakUnsubscribe`를 부르면
|
||||
`.Subscribed = false` 대입만 하고 지나간다(gcconn 경로는 안 건드려 무해). 의도된
|
||||
비대칭이면(약한 해제는 GC 대체 수단이라 관대해도 된다) 그렇게 명문화, 아니면
|
||||
`WeakSubscribed[self] == nil`도 error.
|
||||
|
||||
---
|
||||
|
||||
## 레인 B — M5+ 값 단위 트레이싱 (첫 시도)
|
||||
|
||||
*(별도 패스로 수행 — 지시서 §2 레인 B 네 항목을 실제 값으로 한 사이클씩
|
||||
돌렸다: 그룹 `Attribute` 위임 체인, `D` 생성자, 숏핸드 → `PropertyHandler`,
|
||||
`:List` reconcile `{a,b}` → `{b,a,c}` → `{b}` + 중첩 Slot 요소 + `Detach`. 발견은
|
||||
메인 패스가 원문과 대조해 확인한 뒤 `H-134`부터 번호를 붙였다.)*
|
||||
|
||||
### `H-134` 🟡 — `InstanceChildHandler`에 배열 자리 부기 명세가 없다
|
||||
|
||||
**무엇이 문제인가.** 말단 핸들러는 *"예외 없이 자기 배열 위치의
|
||||
`setOffsetSource`/`setLength`를 등록한다"*(`dispatch-core-plan.md`의 `H-39`
|
||||
블록)인데, `Frame { Frame {} }`의 자식 Instance를 받는 핸들러
|
||||
(`quad-roblox/src/Handlers/InstanceChild.luau`, `architecture.md` 306행
|
||||
*"k:number, v:Instance"*)는 (a) `dispatch-core-plan.md`의 말단 핸들러 표에
|
||||
**행이 없고**, (b) 의사코드가 코퍼스 어디에도 없어 `H-39`의 *"전수 grep"*이 잡을
|
||||
수 없었으며, (c) `ROADMAP.md` M5 체크박스(903행)는 파일 이름만 적혀 있다.
|
||||
Length/Offset 절 머리(1365행)가 *"정적 단일 자식은 상수 `1`"*이라고 하지만
|
||||
**누가** 등록하는지는 안 적혀 있다.
|
||||
|
||||
**값 단위 트레이스** — `inst = Frame { Frame{} (k=1), Slot() (k=2) }`,
|
||||
`Dispatch.drive(inst, {[1]=child, [2]=S})`:
|
||||
|
||||
1. `getBlocker(inst):On()`. `k=1`: `getHandler(inst,1,child)` →
|
||||
`InstanceChildHandler` → `process`: `child.Parent = inst`(물리) — 부기 호출이
|
||||
명세에 없으므로 **없다고 가정**. `bk(inst)` = `{lengthList={}, sourceList={},
|
||||
N=nil, offsetCacheValidUpTo=0, offsetSetUpTo=0}` 그대로.
|
||||
2. `k=2`: `SlotHandler.process` → `attachSlot(S, inst, inst, 2)` →
|
||||
`materializeSlotTree(S, inst, inst, 2)` → `Dispatch.setOffsetSource(inst, 2,
|
||||
S.Offset)` → `Dispatch.getOffsetAt(inst, 2)`:
|
||||
- `offsetCacheValidUpTo == 0` → `offsetCache[1] = 0`, `offsetCacheValidUpTo = 1`
|
||||
- `at(2) > 1` → `cur = offsetCache[1] = 0`; `for i = 1, 1`:
|
||||
**`bk.lengthList[1] == nil`** → `H-106` 가드가 `error(...)` — 첫 마운트에서 즉사.
|
||||
3. `Slot()`이 `k=1`이고 `Frame{}`가 `k=2`면 반대로 **안 터진다**(`bk.N`이 1에서
|
||||
멈춤) — `H-39`가 서술한 *"가끔 되고 가끔 터지는"* 모양 그대로다.
|
||||
|
||||
**그대로 구현하면** — 정적 자식 뒤에 Slot이 오는 가장 흔한 레이아웃이 첫
|
||||
마운트에서 죽는다. `H-39`가 넷을 고치면서 이 핸들러만 놓친 것은 의사코드가
|
||||
아예 없어서다.
|
||||
|
||||
**처방**(§4 Q8) — 말단 표에 `InstanceChildHandler | 말단 | Parent 대입 (+ 부기 —
|
||||
`setOffsetSource(inst, k, None)` → `setLength(inst, k, 1, inst)`)` 행을 넣고,
|
||||
`ROADMAP.md` M5 체크박스에 같은 두 줄을 명시. 반환 클로저는 (B)/단순 철거 시
|
||||
`setOffsetSource(None)` → `setLength(0)` 순서로 해제(`SlotHandler`의 retractor와
|
||||
같은 모양). 대안(부기를 `Dispatch.drive`가 `type(k)=="number"` 분기에서 일괄
|
||||
등록)은 이미 기각된 안(*"모든 핸들러가 `k=number`일 때 처리하도록 두는"*,
|
||||
1387행)이라 비권고. 갈래는 사실상 없다 — 확인만.
|
||||
|
||||
### `H-135` 🟡 — 숏핸드 retractor의 두 모양
|
||||
|
||||
**무엇이 문제인가.** `ui-shorthand-plan.md`의 *"`v`가 `nil`인 경우"* 절(136행)은
|
||||
*"반환 클로저가 할 일이 없어 `function() end`이면 충분"*이라 확정하는데, 같은
|
||||
문서의 *"Tween 지원"* 절 스케치(169행)는
|
||||
`return function(hint) if hint == nil then destroyManagedChild(inst, k) end end`다.
|
||||
하강 diff 계약상 `hint == nil`은 **단순 철거**(`retractFrom`) 또는 **(A) 분기에서
|
||||
새 값이 `nil`**일 때다.
|
||||
|
||||
**값 단위 트레이스** — `Frame { UICorner = cornerState }`, `cornerState:
|
||||
State<number?>`:
|
||||
|
||||
1. `drive`: `(inst,"UICorner")` index 1 = `StoreBind`(관측자 등록) → 즉시 1회 →
|
||||
`Dispatch.process(inst,"UICorner",8,2)` → index 2 = `UICornerHandler` →
|
||||
`ensureManagedChild` → `child`(`Relate[(inst,"UICorner")] = child`) →
|
||||
`Dispatch.process(child,"CornerRadius",UDim.new(0,8),1)` → `PropertyHandler`
|
||||
슬롯 `nil` → 즉시 세팅, 슬롯 `true`. 반환 R(hint 버전).
|
||||
2. `cornerState:Set(Tween{Value=12,Time=0.3})` → (A): `R(tween)` — `hint ~= nil`
|
||||
→ 아무것도 안 함 ✓ → `process` → 같은 `child` 재사용 →
|
||||
`Dispatch.process(child,"CornerRadius",Tween{Value=UDim(0,12)},1)` → PH 슬롯
|
||||
`true` → 트윈 시작 ✓.
|
||||
3. `cornerState:Set(nil)` → `getHandler(inst,"UICorner",nil)` = `UICornerHandler`
|
||||
(키 매치) → (A): **`R(nil)` → `destroyManagedChild`** → 이어서
|
||||
`process(inst,"UICorner",nil,2)` → `v == nil` → **`destroyManagedChild` 한 번
|
||||
더** — 이중 파괴(`Relate` 항목이 이미 `nil`이면 무해하지만 계약상 두 자리가
|
||||
같은 일을 한다).
|
||||
4. `State<State<number>>`에서 안쪽이 `8`(값) ↔ `innerState`로 바뀌면 index 2의
|
||||
핸들러가 `UICornerHandler` ↔ `StoreBind`로 바뀌어 **(B) 분기** →
|
||||
`retractFrom(inst,"UICorner",2)` → `R(nil)` → 자식 파괴 → 새 핸들러 설치 →
|
||||
결국 다시 `UICornerHandler`가 `ensureManagedChild`로 **새 자식 생성** →
|
||||
`(child',"CornerRadius")` 슬롯이 `nil`이라 *"첫 세팅은 스냅"* 규칙에 걸려
|
||||
진행 중이던 트윈 문맥이 사라진다. `function() end`였다면 자식이 `Relate`에
|
||||
남아 재사용되고 슬롯도 이어진다.
|
||||
|
||||
**그대로 구현하면** — 어느 스케치를 옮기느냐에 따라 자식 생존 정책이 갈린다.
|
||||
두 서술이 같은 문서에서 각각 "확정"이다.
|
||||
|
||||
**처방 갈래**(§4 Q9) — (a) `function() end`로 통일(자식 제거는 `process(nil)`
|
||||
한 경로, `Relate`가 `inst` weak라 부모가 죽으면 같이 사라짐 — `UI-11`의 논리와
|
||||
결이 같다). Tween 절의 스케치를 이에 맞춰 고친다. (b) `hint == nil` 파괴를
|
||||
유지하고 (A)-`nil` 이중 파괴와 (B) 깜빡임을 문서화. **권고 (a).**
|
||||
|
||||
### `H-136` 🟡 — `:List` 재실행 reconcile에 배치 Blocker가 없다
|
||||
|
||||
**무엇이 문제인가.** `dispatch-core-plan.md` 2344행: *"`:List` reconcile에서
|
||||
`Length` 갱신 시점: 한 사이클(여러 항목이 한꺼번에 추가/제거되는 경우 포함)
|
||||
전체가 끝난 뒤 **한 번만**"*. 그런데 `activateList`의
|
||||
`data:Observer(function() reconcile(data:Get()) end)`가 **재실행**될 때는 아무도
|
||||
`getBlocker(self):On()`을 하지 않는다 — Blocker는 최초 population
|
||||
(`materializeSlotTree` / `Slot:List`의 `_physicalTarget` 분기)만 감싼다
|
||||
(`slot-plan.md` 1342~1700 구간에 `getBlocker`/`blocker:On` 호출이 없다).
|
||||
|
||||
**값 단위 트레이스** — 마운트된 `slot`, `_elements={Fa,Fb}`, `data:Set({b,a,c})`:
|
||||
|
||||
1. `i=1 b`: `indexOfRaw(Fb)=2 ~= slotPos 1` → `rawMove(slot,2,1)` → 규약 4대로
|
||||
`setLength(slot,1,1,frame)`, `setLength(slot,2,1,frame)` → 각각
|
||||
`gatedRecompute` → `blocker:IsOn()` **거짓**, `recomputeBlocker` 거짓 →
|
||||
**`recompute` 2회**(sum 2, `Length` 불변이라 `Set`은 없음).
|
||||
2. `i=3 c`: `rawAdd(slot,Fc,3)` → `nativeInsert(frame, getOffsetAt(slot,3)=2,
|
||||
{Fc})` → `setLength(slot,3,1,frame)` → **`recompute` 3회째** → `slot.Length:
|
||||
Set(3)` → 부모 `gatedRecompute(frame)` → `recompute(frame)` 1회.
|
||||
3. `data:Set({b})`: 소멸 루프 `a` → `rawRemove(slot,2)` → `spliceArraysDown` →
|
||||
게이트 통과 → `recompute` → `Length:Set(2)` → 부모 `recompute`; `c` → 같은
|
||||
경로 → `Length:Set(1)` → 부모 `recompute`. **한 사이클에 `Length:Set` 2회,
|
||||
부모 `recompute` 2회.**
|
||||
|
||||
정합성은 깨지지 않는다(각 `recompute`가 매번 옳은 값을 쓴다). 문제는 (a)
|
||||
서술과 의사코드가 어긋나고, (b) n개 아이템 사이클에 `recompute` O(n)회 × O(n)
|
||||
= O(n²) + 부모 캐스케이드가 아이템 수만큼 난다 — 최초 population을 Blocker로
|
||||
감싼 이유(*"아이템마다 `recompute`가 돌아 O(n²)"*, `slot-plan.md` 1319행)가
|
||||
재실행엔 적용되지 않은 채 남아 있다.
|
||||
|
||||
**처방 갈래**(§4 Q10) — (a) `reconcile` 본문을 `Slot:List`의 `_physicalTarget`
|
||||
분기와 똑같이 감싼다 — 진입 시 `getBlocker(self):On()`, 끝에
|
||||
`blocker:OffWithoutEmit()` + `if not bk.recomputeBlocker:IsOn() then
|
||||
recompute(self, bk) end`. `raw*`의 명시 호출은 이미 `getBlocker(self):IsOn()`을
|
||||
보므로(`H-119`) 추가 배선이 없고, `getOffsetAt`/`physIndex`는 Blocker와 무관하게
|
||||
정확하다(최초 population과 같은 논증). `updateFn`이 도중에 던지면 Blocker가
|
||||
켜진 채 남는 것은 `materializeSlotTree`가 이미 감수하는 것과 같은 부류(`pcall`
|
||||
안 씀). (b) 2344행 서술을 "raw op마다"로 고치고 비용을 문서화. **권고 (a).**
|
||||
|
||||
### `H-137` 🟢 — `rawMove`/`rawSwap` 규약 1과 `bk.tokens`/`bk.indexOfToken`
|
||||
|
||||
`H-29` 규약 1(2697행)은 `_elements`/`_elemIndex`/`lengthList`/`sourceList`/
|
||||
`observers`만 치환 대상으로 들고, `H-102`가 splice에 요구한 `bk.tokens`/
|
||||
`bk.indexOfToken` 갱신(2950행)은 언급이 없다. 트레이스하면 **규약 4가 이동 구간
|
||||
전 위치에 `setLength`를 다시 태우는 한 정합하다**: 이동 구간 `p`마다
|
||||
`setLength(p)`가 `oldObserver = bk.observers[p]`(치환으로 옮겨온 남의 observer)를
|
||||
`unbindLifetime`하고, `token = bk.tokens[p]`(자리 귀속, 치환 안 됨)로 새 클로저를
|
||||
만들어 `indexOfToken[token] = p`를 다시 쓴다 — 구간 안의 옛 observer는 전부
|
||||
정확히 한 번 풀린다. 즉 규약 1의 `observers` 치환은 **무의미하고**, 토큰은
|
||||
치환하면 안 된다(치환해도 `setLength`가 되돌리긴 한다). 규약에 *"`bk.tokens`/
|
||||
`indexOfToken`은 자리 귀속이라 치환하지 않는다 — 규약 4가 재등록한다"*를
|
||||
명시하면 닫힌다. 규약 4를 "양끝만"으로 읽으면 깨지므로 "이동 구간 전부"도 같이
|
||||
못박을 것. (`H-126`과 같은 부류 — `tokens`/`observers`의 자리 귀속을 splice/
|
||||
move 양쪽에서 한 번에 규정하는 게 낫다.)
|
||||
|
||||
### `H-138` 🟢 — 숏핸드 핸들러와 `PropertyHandler`의 매치 구분
|
||||
|
||||
`Frame { UICorner = 8 }`에서 `getHandler(inst,"UICorner",8)`이 `UICornerHandler`를
|
||||
고르려면 `PropertyHandler.isHandlable`이 `"UICorner"`를 거부하거나(리플렉션:
|
||||
Frame의 프로퍼티가 아님) 숏핸드 우선순위가 더 높아야 한다. 어느 쪽인지
|
||||
`ui-shorthand-plan.md`/`bind-system-plan.md` 어디에도 없다. 리플렉션 거부에
|
||||
기대면 되긴 하지만, `UIScale`처럼 이름이 우연히 프로퍼티와 겹치는 경우가 생기면
|
||||
조용히 `PropertyHandler`가 먹는다. 한 줄 명시 권고.
|
||||
|
||||
### `H-139` 🟢 — `New` 둘 / `D` 파이프라인 의사코드 부재
|
||||
|
||||
`module-lifecycle-plan.md`의 `local function New(): Quad`(모듈 팩토리,
|
||||
`architecture.md` 확정 13번)와 `bind-system-plan.md`의 `New "Frame" { ... }`
|
||||
(`D/init.luau`의 인스턴스 생성자)가 같은 이름이다. 패키지가 달라 런타임 충돌은
|
||||
없지만 문서에서 `New()`/`New "Frame"`이 섞여 나온다. 또 `D.Frame {...}`가
|
||||
실제로 하는 일의 순서 — `Instance.new` → gcconn/gchold(`lifecycle-pattern.md`
|
||||
(0)) → `flatten`(`modifier-plan.md`) → `Dispatch.drive`(pre-pass 포함) → 반환 —
|
||||
는 네 문서에 흩어져 있고 한 곳에 의사코드가 없다(값 트레이스는 §5의 `D` 항목 —
|
||||
산문대로 이어 붙이면 성립한다).
|
||||
|
||||
**교차 — `H-125`의 도달 경로 하나 더.** `updateFn`이 중첩 Slot `S`를 `Detach`한
|
||||
뒤 다음 사이클에 `prev`를 돌려주면 `settle` → `rawAdd(self, S, slotPos, true)` →
|
||||
`attachSlot(S, …)` → `materializeSlotTree(S, …)`: 첫 줄 `setOffsetSource(self,
|
||||
slotPos, S.Offset)`이 `S.Offset:Set(newAbs)`를 내는데, 그 시점 `S._baseObserver`는
|
||||
`unmountSlotTree`(`rawDetach` 경유)가 `unbindLifetime`해둔 상태 — `H-125`의 창
|
||||
그대로다. `:List`에선 사용자가 의도한 정상 기능(`Detach` 캐싱)으로 도달한다.
|
||||
|
||||
---
|
||||
|
||||
## §4 ⭐ 사용자 결정이 필요한 것 (배치 회신용)
|
||||
|
||||
| 문항 | 무엇 | 선택지 | 권고 |
|
||||
|---|---|---|---|
|
||||
| **Q1** | `H-124` `recompute` 루프 순서 | (a) 되감기 판정을 `lengthList[i]` 읽기 앞으로(`continue`) / (b) `i > bk.N` 가드만 | **(a)** — (b)는 되감기 없이 끝나 `H-113`이 닫은 증상을 되살린다 |
|
||||
| **Q2** | `H-125` 두 필드 초기화 자리 | (a) `materializeSlotTree` 진입부에서 `0, 0` / (b) `unmountSlotTree` 꼬리에서 / (c) `_baseObserver` 바인드·`blocker:On()`을 `setOffsetSource` 앞으로 | **(a)** — 실체화 경계의 명시 불변식. `_baseObserver`의 `0`은 런타임 베이스 변경용으로 유지 |
|
||||
| **Q3** | `H-126` `spliceArraysUp` 비운 자리 | (a) 요구 목록에 "`observers[index]`/`tokens[index]`를 `nil`로" 명시 / (b) "네 배열 전부 `table.insert`/`remove` 의미론"으로 통일 | **(a)** — 자리표시자 두 줄과 같은 자리에 두 줄 더. 새 결정 아님 |
|
||||
| **Q4** | `H-127` `EffectHandle:Unsubscribe` | (a) 의사코드 신설: Observer 게이트 위임 **먼저**, 통과 시에만 `_consumeCleanup()`; `Subscribe`/`Weak*`는 Observer 것 재사용 명시 / (b) 산문의 번호 순서만 바꿈 | **(a)** — 네 진입점을 새로 의사코드화한 마당에 Effect만 산문이면 같은 사고가 난다 |
|
||||
| **Q5** | `H-128` M2×M8 `Ref` | (a) M2 공통 기반에 `Ref` 최소형 체크박스(`.Value`/`.Revision`/`:Set`/`:WeakCallback`/`:Callback`/`:Uncallback`/`isRef`) / (b) M2에선 `Ref` dep을 명시적으로 미루고 M8에 그 테스트 체크박스 | **(a)** — `Effect`의 `isEpoch` 분기를 M2 안에서 한 번은 돌려야 한다 |
|
||||
| **Q6** | `H-133` `WeakUnsubscribe` 비대칭 | (a) 의도된 관대함으로 명문화 / (b) `WeakSubscribed[self] == nil`도 error | 권고 없음 — 계약 취향. 지금 산문은 (b)처럼 읽히고 코드는 (a)다 |
|
||||
| **Q7** | `H-130` 폐기 블록 편집 | (a) 그 한 줄을 커밋 전 문구로 되돌리고 "배너 아래는 안 만진다"를 규칙으로 / (b) 블록을 `archive/`로 이동 | **(b)** — 이 블록은 이미 두 번(8라운드 2차 #1·이번) 사고를 냈다 |
|
||||
| **Q8** | `H-134` `InstanceChildHandler` 부기 | (a) 말단 표에 행 신설 + `setOffsetSource(inst,k,None)`→`setLength(inst,k,1,inst)` + retractor 해제 순서, ROADMAP M5 체크박스에 명시 / (b) `drive`가 `k=number` 일괄 등록(이미 기각된 안) | **(a)** — `H-39`의 "예외 없이" 규칙에 이 핸들러를 넣는 것뿐, 갈래 없음 |
|
||||
| **Q9** | `H-135` 숏핸드 retractor | (a) `function() end`로 통일, Tween 절 스케치 수정 / (b) `hint == nil` 파괴 유지 + (A)-nil 이중 파괴·(B) 스냅 문서화 | **(a)** — `Relate`가 `inst` weak라 자식 회수는 부모 사망이 맡는다(`UI-11`과 같은 결) |
|
||||
| **Q10** | `H-136` `:List` 재실행 reconcile | (a) `reconcile` 본문을 배치 Blocker로 감싼다(최초 population과 같은 모양, 끝에 게이트 확인 후 `recompute` 1회) / (b) "한 사이클 한 번만" 서술을 "raw op마다"로 고치고 O(n²) 문서화 | **(a)** — 이미 `raw*` 명시 호출이 `getBlocker(self):IsOn()`을 보므로 추가 배선 없음 |
|
||||
|
||||
🟢 `H-129`/`H-131`/`H-132`/`H-137`/`H-138`/`H-139`/`H-140`은 판단 불필요(정정·명시만).
|
||||
|
||||
**⚠️ [2026-08-27] Q1~Q3은 이미 확정·반영됐다** — 결정과 근거의 소스는
|
||||
`-round9-followup.md`이고, 그 처리 중 `H-125`의 피해 범위 정정과 새 발견
|
||||
`H-140`/`H-141`이 나왔다(위에 반영). Q1은 (a)의 `continue` 형태, Q2는 문항의
|
||||
(a)/(b)/(c)를 넘어 **`Offset`/`_baseObserver`를 Slot 생성자로 올리고 파괴는
|
||||
`_destroyed` 플래그가 말하는** 형태로, Q3는 (a)/(b)를 넘어 **`element → index`
|
||||
맵을 `bk.indexOfElement` 하나로 통일하고 `token`을 폐기**하는 형태로 닫혔다 —
|
||||
그 결과 **`H-137`은 소멸**했다(토큰이 없어졌으므로).
|
||||
|
||||
---
|
||||
|
||||
## §5 이상 없다고 확인한 것 (다음 라운드가 다시 파지 않도록)
|
||||
|
||||
**A각도 — 겹쳐 읽어 정합했던 조합**:
|
||||
|
||||
- **두 필드 분리 × 무효화 4규칙 × `bk.N` 예외 × 2-연산 경로**(4차 HIGH의 그
|
||||
모양) — 실측 `d11_two_ops.luau`: 커서 `i=3`의 `offset:Set` 안에서
|
||||
`Remove(1)` 뒤 `Add(s5)`. `Remove`가 두 필드를 `0`으로, `Add`의
|
||||
`setOffsetSource`→`getOffsetAt`이 **`offsetCacheValidUpTo`만** `4`로 올리고
|
||||
`offsetSetUpTo = 0`은 살아남아 되감기가 `1`부터 다시 돌았다. 최종
|
||||
`s2 0 / s3 2 / s4 3 / s5 4 / O.Length 5` — 전부 기대값. **분리가 그 버그를
|
||||
실제로 닫는다.**
|
||||
- **`H-113` 커서 자리 splice(`j == i < N`)** — `d13_splice_at_cursor.luau`:
|
||||
`i=2`에서 `Remove(2)` → `offsetSetUpTo = 1 < 2` → 되감기 → 밀려 들어온 `s3`이
|
||||
`Set`을 받는다. `s3 2 / s4 3 / O.Length 4` 정합. (같은 경로의 `i == N`
|
||||
변형만 `H-124`.)
|
||||
- **되감기 클램프 `math.max(…, 1)`** — `d11`에서 `offsetSetUpTo = 0`으로 떨어진
|
||||
뒤 `i = 1, sum = prefix[1] = 0`으로 정확히 재개.
|
||||
- **`H-119` 재진입 게이트 7자리** — `rawRemove`/`rawUnmount`/`rawDetach`/
|
||||
`_baseObserver`/`:List` 활성화 꼬리/`materializeSlotTree` 꼬리/`Dispatch.drive`
|
||||
꼬리 전부 `blocker:IsOn() or bk.recomputeBlocker:IsOn()`(꼬리 셋은
|
||||
`recomputeBlocker`만 — 배치 Blocker는 방금 껐으므로 정합). `d10`/`d11`/`d13`에서
|
||||
`rawRemove`의 건너뛰기가 실제로 걸리고 되감기 신호(`index-1`)가 살아 넘어오는
|
||||
것 확인.
|
||||
- **`getBookkeeping` 초기화 열거** — `offsetCache`/두 필드 `0`/`recomputeBlocker`
|
||||
전부 있고 `bk.N`만 `nil` — 전사물이 그대로 돌았다. `if bk then` 흔적 가드
|
||||
제거도 `unmountSlotTree`/`destroySlotTree`/`:List` 꼬리/`materialize` 꼬리
|
||||
전부 반영돼 있다.
|
||||
- **`Ref` 2-인자 × `Effect`의 `onRefFire`/`onStateFire` × 훅 `guard`** —
|
||||
`t11_effect_deps.luau`: `Ref` dep이 `fire(ref)`로 `Update(ref)`를 통과해
|
||||
`Rerun`(미바인드 땐 `canExecute`에 막힘, 바인드 캐치업 `Refresh()`로 1회, 같은
|
||||
값 `Set`도 새 리비전이라 1회), 다이아몬드 `A→b, A→c, Effect(fn,b,c)`에서
|
||||
`A:Set` 한 번에 `fn` 한 번, 혼합 deps(`Ref`+`State`) 독립 발화, 무인자
|
||||
`state:Observer()`가 3-인자 루프에서 생존, `destroy` 뒤 cleanup 1회·이후 `Set`
|
||||
무시. 전부 기대값.
|
||||
- **`Ref:Set` 순서(값→리비전→콜백)·두 테이블 스냅샷·교차 dedup·thread 소진** —
|
||||
`t13_ref_dedup.luau`: 같은 `fn`을 `:WeakCallback`+`:Callback`으로 걸어도 `Set`
|
||||
1회에 1번, `:Uncallback` 뒤 0번, thread 대기자가 새 리비전을 보고 소진.
|
||||
- **fail-fast 3종 매트릭스** — `t12_subscribe_failfast.luau`: `Weak→Unsubscribe`
|
||||
error / `Subscribe→WeakUnsubscribe` error / `Subscribe` 두 번 error / 미구독
|
||||
`Unsubscribe` error / `Subscribe→Unsubscribe→Subscribe` 재구독 통과 /
|
||||
`Effect:Subscribe`·`:Unsubscribe`가 내부 Observer를 안 건드리고 dep 발화가
|
||||
`.Subscribed`로만 켜지고 꺼짐 / 내부 Observer에 범용 `Unsubscribe` → error
|
||||
(5차 #5가 막으려던 침묵 살해가 실제로 막힌다). 걸린 건 `H-133`의 비대칭뿐.
|
||||
- **`WeakSubscribe`가 `.Subscribed`를 세움 × `canExecute`** — 위 `t11`의 State
|
||||
dep 경로가 이걸로 살아 있다(`H-111`의 "전량 침묵"이 실제로 안 난다).
|
||||
- **사냥 #6 — *idempotent* 네 번째 사본**: `grep -rni idempotent|멱등` 결과
|
||||
`Subscribe`/`Unsubscribe` 관련은 정정 배너 셋(`lifecycle-pattern.md` 513·516·520,
|
||||
`source-state-plan.md` 1409~1422, `effect-plan.md` 630)뿐. 나머지 idempotent는
|
||||
`Blocker`/`Gate`/`RunInit`/`_bindDestroying` 등 다른 대상. **없다.**
|
||||
- **사냥 #2 — 인용문·절 제목·정정 배너의 옛 이름**: `invalidAfter`가 남은 자리
|
||||
전부(`dispatch-core-plan.md` 1714·1763·1771·1777·1948·1953, `slot-plan.md`
|
||||
2925·2933·2943, followup `H-113` 절)가 인용문/절 제목/폐기 서술이고, 살아 있는
|
||||
코드·표·목록엔 0건. `_observers`도 폐기 배너 안(`effect-plan.md` 476·482·
|
||||
490~514, 406)에서만 — 단 그 블록 편집은 `H-130`.
|
||||
- **사냥 #7 — ROADMAP 체크박스 vs `base/`**: M2 `state:Observer(fn)`(3-자리,
|
||||
`_state`, `H-61` 이름), `Effect` "같이 만들 것"(클로저 둘, `_deps`/`_epochs`/
|
||||
`_blocker`/`_installed`/`Rerun`), `store:Of`/`CheckReservedKeys<keyof<T>>`/
|
||||
`__reservedCheck` 셋, `isModifier` 자리·`defaults` 검증, 훅 `guard` 2-인자,
|
||||
전파 루프 사본 주석, M3 (a)/(b)/(c)의 두 필드·`i-1`·`minPos-1`·`H-119` — 전부
|
||||
`base/`와 일치. 걸린 건 `H-129`(옛 문장 잔존)와 `H-128`(순서)뿐.
|
||||
- **`H-118` 재정정 뒤 `gate-plan.md` 5번 × `debounce-throttle-plan.md` 배너** —
|
||||
두 경로 표가 양쪽에 같은 내용으로 있고 `pass()`/`emit()` 역할이 서로 맞는다.
|
||||
(게이트 본체는 8라운드 §5대로 다시 안 팠다.)
|
||||
- **`isModifier` 가드 이동 × `defaults` `isSource` 검증 × `store:Of`** — 세 문서
|
||||
+ ROADMAP이 "`defaults` 경로에선 안 만든다, `Of`는 만든다, `Source` 생성자
|
||||
가드가 둘 다 덮는다"로 일치. `component-composition-plan.md`의 `or None`
|
||||
관용구는 children 배열용이지 `defaults` 값이 아니라 화이트리스트와 무관.
|
||||
- **`state-epoch-plan.md` §4 카운터 쌍** — 전사물 `core9.luau`의 `State:Get`이
|
||||
그 규칙(`cacheCurrCount ~= cacheTargetCount or Refresh()`)으로 `t11`의
|
||||
다이아몬드를 통과시켰다.
|
||||
|
||||
**G각도 — 재확인된 Luau 사실**:
|
||||
|
||||
- **⭐ `keyof<{}>` + `CheckReservedKeys` — 빈 Store가 깨끗하다**
|
||||
(`g1b_keyof_empty_clean.luau`, `luau-analyze` exit 0, 진단 0건). `keyof<{}>`는
|
||||
`never`로 타입 함수에 정상 도달하고(`keys:is("never")`로 식별 가능 —
|
||||
`g1_keyof_empty.luau`에서 `print`로 확인), 유니온이 아니라 단일 타입으로
|
||||
들어오므로 `is("union")` 분기 없이 `{keys}`로 감싸면 `is("singleton")` 검사가
|
||||
그냥 거짓 → `types.singleton(true)`. `store-plan.md`의 *"빈 Store는 아직 실측
|
||||
안 됐다"* 캐비엇과 `STATUS.md` `16`/`21` 지침의 대조군 항목은 **이 실측으로
|
||||
닫을 수 있다**(음성 대조군 `{ Of = … }`도 같은 파일에서 `"Of" is a reserved
|
||||
key`로 정확히 걸림). 단, `Source<T>`가 `*error-type*`을 품는 최종형 `T`와의
|
||||
결합은 8라운드 실측에 의존했다(§6).
|
||||
- **Luau 함수 타입은 파라미터에 반변** — `g2_arity.luau`: `(inst: string,
|
||||
ref: Ref) -> ()` 자리에 1-인자 람다(무주석/주석 둘 다) 통과, `(number,
|
||||
string) -> ()`에 `function(x: number)` 통과, 반대(더 받는 함수)는 정확히
|
||||
에러. `lifecycle-hooks-plan.md`의 주장 그대로.
|
||||
- **일반화 반복 `for d in t do`는 유효** — `H-131`(문서 쪽이 틀림).
|
||||
- `bit32.bnot(-rev)` 랩은 8라운드 실측에 의존(전사물이 같은 식을 쓰고
|
||||
`t11`/`t13`의 리비전 판정이 그 위에서 돌았다).
|
||||
|
||||
**B각도 — 레인 B가 값으로 돌려 정합했던 것**:
|
||||
|
||||
- **그룹 `Attribute` 체인** — `store = Store<<{hp: Source<number>}>>({hp =
|
||||
Source(100)})`, `Frame { Attribute(store) }` 마운트 → `store.hp:Set(50)` →
|
||||
철거: `AttributeGroupFallbackHandler`(`setOffsetSource(inst,1,None)`/
|
||||
`setLength(inst,1,0,inst)` 게이트에 막힘 → `groupClaimKeys[(inst,attr)] = 1` →
|
||||
`attr:NameMap()` = `{hp = Source#1}` → `K1 = groupKey(attr,"hp")` →
|
||||
`Dispatch.process(inst, K1, Source#1, 1)` → `StoreBind` → 즉시 1회 →
|
||||
`AttributeKeyFallbackHandler` → `nameClaims[(inst,"hp")] = K1` →
|
||||
`setAttribute(inst,"hp",100)`). `Set(50)`은 (A) 분기로 `K1` 체인만 돌고 그룹
|
||||
로직은 안 돈다(*"필드 하나만 바뀌는 흔한 경우"* 서술과 일치).
|
||||
`retractFrom(inst,1,1)` → claim 반납·`unbindLifetime` 대칭. `State<Attribute>`가
|
||||
새 그룹 객체를 내면 체인 전량 철거 후 `K1'`로 재위임, `nameClaims` 충돌 없음.
|
||||
`Frame { a, a }`는 `claimed(1) ~= 2` error(`NOOP`·길이 0 등록은 `H-103` 서술대로
|
||||
남고 무해). plain 필드·`None` 값 경로도 정합.
|
||||
- **`D` 생성자** — `D.Frame { Name = "x", MouseButton1Click = fn, D.Frame {} }`:
|
||||
`New<<Frame>> "Frame"` → `Instance.new` → (0) gcconn/gchold → `flatten`(Modifier
|
||||
없음) → `Dispatch.drive`(pre-pass 없음 → Blocker On → 단일 `for`: `k=1`
|
||||
`InstanceChildHandler`(`H-134` 제외 정합) / `"Name"` `PropertyHandler` /
|
||||
`"MouseButton1Click"` `EventHandler` → `OffWithoutEmit` → `recompute(inst)`)
|
||||
→ 반환. 단일 `for`의 "배열 먼저"가 Luau `next`의 배열 파트 선순회에 의존하는
|
||||
것은 `F-4-1`이 스파이크 `01` 재작성으로 확인하기로 해둔 자리라 새 항목으로 안
|
||||
올렸다.
|
||||
- **숏핸드 → `PropertyHandler`** — `(inst,"UICorner")`와 `(child,"CornerRadius")`가
|
||||
**별개 체인**(둘 다 index 1부터), `v:Mapped(toUDim)`이 `Tween` 껍질을 유지한 채
|
||||
`Value`만 감싸고, PH의 3-상태 슬롯(`nil` → `true` → `{Tween,Value}`)이
|
||||
`(child,"CornerRadius")`에 붙어 트윈이 공짜로 따라온다. `UIPadding`은 4개
|
||||
`(child, prop)` 체인. 재디스패치는 (A) 분기라 `child` 재사용(`H-135`는 철거
|
||||
쪽 모양만).
|
||||
- **`:List` reconcile** — `{a,b}` → `{b,a,c}` → `{b}`를 `_elements`/`_elemIndex`/
|
||||
`bk.lengthList`/`sourceList`/`N`/두 필드/`mounted`/`prevKeys`/`slotPos`/
|
||||
`physIndex` 값으로 전부 적어 돌렸다. `physIndex = getOffsetAt(self,
|
||||
candidateSlot) - offset:Get() + 1`은 정착된 `1..slotPos`만 합산(아직 처리 안 된
|
||||
옛 요소는 전부 `≥ slotPos+1`) ✓; 최초 population에서 두 필드가 splice로 0까지
|
||||
내려가도 `getOffsetAt`이 재부트스트랩 ✓; `rawMove(idx, slotPos)`는 항상
|
||||
`idx ≥ slotPos` ✓; 소멸 루프의 `indexOfRaw`가 `reindexFrom` 갱신분을 읽어
|
||||
순서 무관 정확 ✓; 중첩 Slot 요소(`spliceArraysUp` placeholder → `attachSlot` →
|
||||
S 실체화 → `setLength(self,1,S.Length)` 게이트 → 다음 아이템 `physIndex`가
|
||||
`lengthList[1]:Get()`을 라이브로 읽어 2) ✓; `mountSlotTree`의 `acc` 전진 ✓;
|
||||
`Detach`(메인 루프 → `rawDetach` → `_detached[key]`, `prevKeys` 유지 → 다음
|
||||
사이클 `prev` → 재반환은 nop / `prev` 반환은 `rawAdd(…, true)` 재마운트 /
|
||||
소멸 루프 `Detach`는 홀드, `nil`은 `releaseElement(self, nil, prev, true)`) ✓;
|
||||
`Owned = false` 래퍼(`sub:Single(v, nil, {Owned=false})`, `data =
|
||||
state:Compute(function(v) … v:Get() …)`가 `fn(self, …)` 계약 그대로, 고정 키,
|
||||
`identityUpdateFn`의 `KeyGone` 흡수, 값 교체 `rawReplace(idx, new, false)` →
|
||||
`nativeExtract`, `nil` 전이 `rawUnmount`) ✓; `settle`의 교체+리오더 겹침 순서와
|
||||
`_elemIndex` 갱신 ✓. 걸린 건 `H-136`(Blocker 부재)과 `H-125`의 도달 경로뿐.
|
||||
|
||||
**C각도 — 전사물 자체**: `core9.luau`/`dispatch9.luau`(스크래치패드)는 `base/`의
|
||||
현재 계약을 줄 단위로 옮긴 것이고, 대조군 `d14_baseline.luau`(형제 offset 전파
|
||||
`O.Length 4 / S.Offset 1 / t2 2`, `t1` 성장 뒤 `t2 4 / O.Length 6`)가 통과한다.
|
||||
7라운드 전사물의 의도적 차이 셋 중 `PeekDiffers`는 `Peek`으로 표면에 들어왔고,
|
||||
`box.pos`는 토큰 역참조로 대체됐고, 생명주기 4종은 mock(`{conn = {Connected}}`)
|
||||
으로 채웠다(`H-97`).
|
||||
|
||||
---
|
||||
|
||||
## §6 남은 의심 / 못 본 것
|
||||
|
||||
**남은 의심**(확신까지 못 간 것):
|
||||
|
||||
- **`H-125`의 `State<Slot>` 교체 경로** — 전사물은 `rawUnmount` 상당만 돌렸다.
|
||||
`SlotHandler.process`의 교체 분기가 `unmountSlotTree`를 거쳐 같은 창을 만드는지
|
||||
원문에서 확인은 했지만(같은 함수) 값으로 돌리진 않았다.
|
||||
- **`H-124` (a) 처방의 `rawClear` 결합** — `rawClear`가 splice 취급이라
|
||||
`index = 1` → 두 필드 `0`이면 되감기 → `i = 1`, `bk.N = 0` → 루프 종료로
|
||||
깨끗할 것으로 보이나 `rawClear` 의사코드가 없어 손으로만 확인.
|
||||
- **`getOffsetAt`의 `nil` 가드(`H-106`)가 `H-124` 뒤에 먼저 터질 수 있는가** —
|
||||
되감기 뒤 `getOffsetAt(i-1)`은 캐시 범위라 안 밟는다고 보지만 `rawSplice`
|
||||
다중 제거 + 캐시 `index-1`의 조합은 값으로 안 돌렸다.
|
||||
- **`AttributeGroupHandler`의 (A) 재처리 비용**(H각도) — `State<Attribute>`가
|
||||
emit할 때마다 `setLength(inst,k,0,inst)` → `gatedRecompute` → steady state에선
|
||||
`recompute(inst)` 전체 순회가 한 번 돈다(길이 0→0 불변). 정합성 문제는 아니고
|
||||
`Tag`도 같다 — 비용 서술로만 남긴다.
|
||||
- **`groupKey` 메모와 `chains[inst][K]`의 빈 배열** — 그룹 값이 살아 있는 동안
|
||||
이름별 키 객체가 강하게 남고, 철거 뒤 `chains`에 빈 리스트가 남는다.
|
||||
`inst`/그룹 값이 죽으면 같이 사라지므로 누수는 아니지만 `quad-debug`가
|
||||
`chains`를 덤프할 때 빈 항목이 보인다.
|
||||
- **`rawMove`/`rawSwap`의 물리 op 인자** — `nativeMove(target, fromOffset,
|
||||
elements, toOffset)`의 `toOffset`이 "제거 전 좌표"인지 "제거 후 좌표"인지가
|
||||
`slot-plan.md`에 없다(DOM `insertBefore` 의미론 대비). Roblox 백엔드는 offset을
|
||||
무시하므로 지금은 관측되지 않는다 — 웹 백엔드가 생길 때 정해야 한다.
|
||||
- **재실행 reconcile 중 `updateFn`이 던질 때** — `H-136` (a)를 채택하면 Blocker가
|
||||
켜진 채 남는 창이 새로 생긴다(`materializeSlotTree`와 같은 부류). 그 자리가
|
||||
이미 "실제로 물리면 그때 넣는다"로 확정돼 있어 발견으로 안 올렸다.
|
||||
- **`WeakUnsubscribe`를 leaf 바인딩된 Observer에 부르는 경우**(`H-133`) —
|
||||
`.Subscribed = false` 대입만 하고 gcconn 경로는 그대로라 무해하다고 판단했지만,
|
||||
그 값이 나중에 `:Subscribe()`될 때 `canBound`가 gcconn을 먼저 보므로 여전히
|
||||
막힌다 — 정합. 다만 "무해"를 발견으로 안 올린 이유는 이것뿐이다.
|
||||
|
||||
**못 본 것**(범위 밖 — "감사 통과"로 읽지 말 것):
|
||||
|
||||
- **`Gate`/`Blocker`/`Debounce`·`Throttle` 본체** — 8라운드 §5가 닫았고 이
|
||||
델타는 `gate-plan.md` 5번 산문만 바꿨다. 재트레이싱 안 함.
|
||||
- **`rawMove`/`rawSwap`/`rawExtract`/`rawSplice`/`rawClear`** — 의사코드가 없어
|
||||
(`H-29` 규약만) 4번째 무효화 행과 `bk.N` 예외를 **읽기로만** 확인했다. 값
|
||||
단위는 `rawRemove`/`rawAdd`뿐.
|
||||
- **`Dispatch.process`/`retractFrom` 체인, `StoreBind`** — 델타가 안 건드렸다.
|
||||
- **`CheckReservedKeys` × 최종형 `T`(`Source<T>`가 `*error-type*`을 품는 §1②
|
||||
선언)** — 8라운드 `r8-spike` 실측에 의존. 이번 `g1b`는 `Source` 타입을 단순형
|
||||
으로 뒀다.
|
||||
- **`Effect`의 `_bindDestroying` 재바인드(포탈)·`Rerun` 재진입 지연** — `t11`은
|
||||
바인드 1회 + destroy만 돌렸다.
|
||||
- **Studio 실측 전부**(이 환경 제약).
|
||||
- **레인 B가 못 본 것** — `rawSplice`/`rawClear`/`rawExtract`/`rawSwap` 본체
|
||||
(의사코드 자체가 없음 — `H-29` 규약만 대조), `Tag` 핸들러 참조 카운트,
|
||||
`OnChange`/`Event`의 `nil` 전이, `Attribute.Merged`/`Overridden` 합성 규칙,
|
||||
`D` 생성기의 타입 출력. 전부 문서 정독 수준이고 값을 돌리지 않았다.
|
||||
|
||||
---
|
||||
|
||||
*스파이크 원본: 세션 스크래치패드 `ref9/`(`core9.luau`·`dispatch9.luau`·
|
||||
`d10`~`d15`·`t11`~`t13`·`g1`~`g3`). 발견 근거는 위 항목에 코드·출력을 전사해
|
||||
두어 파일 유실과 무관하게 재현 가능하다. 저장소는 이 파일 말고 아무것도
|
||||
수정하지 않았다.*
|
||||
|
|
@ -12,7 +12,13 @@
|
|||
|
||||
---
|
||||
|
||||
## ⭐ 최우선 — **비어 있음** (2026-08-26 갱신)
|
||||
## ⭐ 최우선 — **비어 있음** (2026-08-27 갱신)
|
||||
|
||||
**[2026-08-27] 9라운드 손 트레이싱의 결정 문항 Q4~Q10이 회신 대기입니다 —
|
||||
M2 착수 게이트는 아닙니다**(🔴 둘은 Q1/Q2로 이미 닫혔고 남은 건 🟡/🟢). 문항과
|
||||
권고는 `qa-request/pre-implementation-handtrace-round9.md` §4 표, 진행 상태는
|
||||
`-round9-followup.md`의 진행 표가 소스(여기서 문항을 반복하지 않는다). 다음
|
||||
세션이 위에서부터 처리한다.
|
||||
|
||||
**[2026-08-26] 8라운드 손 트레이싱이 처리 완료됐습니다 — M2(반응형 코어)
|
||||
착수를 막는 항목이 하나도 없습니다.** 발견 17건(`H-107`~`H-123`)의 결정
|
||||
|
|
|
|||
|
|
@ -1925,3 +1925,21 @@ ref 는 그 자체로 epoch임"*), `H-118`은 소유권 문제가 아니라 `gat
|
|||
문장은 안 읽음; 한 번은 정정 배너 안의 **역사 인용문**까지 치환해 자기모순을
|
||||
만들었다). 규칙: **전역 치환은 인용문·절 제목·정정 배너를 건드리지 말 것.**
|
||||
상세는 그 세션 파일의 "검증 (커밋 전)" 절.
|
||||
|
||||
## 2026-08-27-01 — 9라운드 손 트레이싱 실행 + Q1~Q3 결정·반영 (`H-124`~`H-141`)
|
||||
|
||||
8라운드가 써둔 지시서대로 커밋 `9dd8213`의 델타를 재트레이싱해 발견 18건을
|
||||
냈고(🔴 둘 다 실측 재현 — `recompute`가 되감기 판정보다 먼저 `lengthList[i]`를
|
||||
읽어 `sum += nil` / 재마운트 시 `_baseObserver`가 unbind 상태라 옛 베이스
|
||||
캐시), 그중 Q1~Q3를 확정·반영. **Q2는 사용자가 문항을 넘어섰다** — `Offset`/
|
||||
`_baseObserver`를 Slot **생성자**로 올려 첫 마운트/재마운트 분기 자체를 없앴고,
|
||||
파괴는 `_destroyed` 플래그 하나가 말한다(*"두 일을 겸하는걸 만들다가 사고가
|
||||
난 적 많아"*). **Q3에서 `token`이 사용자가 정한 적 없는 것으로 드러났다** —
|
||||
2026-08-25 `/code-review`가 발명해 사용자 인용문 옆에 앉아 있던 것(`H-141`);
|
||||
`element → index`는 `bk.indexOfElement` 하나로 통일, `slot._elemIndex`·토큰
|
||||
폐기, `setLength` 5번째 인자 `element`. 그 대화에서 메인 세션도 새 개념을 세 번
|
||||
제안했다 철회했고, `conventions.md`에 *"새 필드·인자·이름·메커니즘은 발견이지
|
||||
결정이 아니다"* 규칙을 신설했다. 감사 6라운드(1→1→3→1→1→0)로 수렴, `/code-review
|
||||
high`는 Q4~Q10 반영 뒤로. 결정의 소스는
|
||||
`qa-request/pre-implementation-handtrace-round9-followup.md`, 경위는
|
||||
`session/2026-08-27-01-handtrace-round9-q1-q3.md`. **Q4~Q10은 다음 세션.**
|
||||
|
|
|
|||
84
.claude/session/2026-08-27-01-handtrace-round9-q1-q3.md
Normal file
84
.claude/session/2026-08-27-01-handtrace-round9-q1-q3.md
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# 2026-08-26/27 — 9라운드 손 트레이싱 실행 + Q1~Q3 결정·반영 + 감사 6라운드
|
||||
|
||||
**무엇을 했나**: `qa-request/pre-implementation-handtrace-round9-brief.md`(8라운드가
|
||||
써둔 지시서)대로 커밋 `9dd8213`의 델타를 재트레이싱해 발견 보고
|
||||
`qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`)를 냈고, 그
|
||||
§4 문항 중 Q1~Q3를 사용자와 대화형으로 확정해 `base/`·`ROADMAP.md`에 반영, 감사
|
||||
루프 6라운드(확실 0으로 수렴)까지 돌린 뒤 체크포인트 커밋. **Q4~Q10은 다음
|
||||
세션**(사용자 판단: *"clear 이후 핸드오버 세션에서 후행 결정을 하는게 맞다"*).
|
||||
결정의 소스는 `qa-request/pre-implementation-handtrace-round9-followup.md`.
|
||||
|
||||
세션 도중 모델이 두 번 바뀌었다 — Opus(사용자: *"sonnet 실수가 너무 많아서
|
||||
감사 루프가 길어져서 오히려 비용이 높아지더라"*) → Fable(아래 "실수" 절).
|
||||
|
||||
## 감사 (레인 A/C/G/D 메인, 레인 B는 포크)
|
||||
|
||||
- 레인 A는 델타 2180줄 정독 + 원문 절 전문 대조, 레인 C는 7라운드 참조 구현을
|
||||
현재 계약으로 **다시 전사**(`ref9/core9.luau`·`dispatch9.luau`)해 실행, 레인 B는
|
||||
같은 컨텍스트를 물려받은 포크에 맡겼다(값 단위 트레이스 4항목).
|
||||
- 🔴 둘 다 실측 재현: `H-124`(`recompute`가 `lengthList[i]`를 되감기 판정보다
|
||||
먼저 읽어 커서 뒤 자리 수가 줄면 `sum += nil`, 그 뒤 `recomputeBlocker` 영구
|
||||
On) / `H-125`(재마운트 시 `_baseObserver`가 unbind 상태라 두 필드가 0으로 안
|
||||
내려가 옛 베이스의 `offsetCache[1]`을 씀).
|
||||
- G각도가 **문서 쪽 거짓**을 하나 잡았다 — 7차 code-review의 *"`for d in seen
|
||||
do`는 유효한 Luau가 아니다"*는 틀렸다(일반화 반복은 런타임·strict 둘 다 통과).
|
||||
반대로 `keyof<{}>` 빈 Store는 실측 클린이라 8라운드가 남긴 캐비엇을 닫을 수 있다.
|
||||
|
||||
## 결정 (원문은 followup, 여기는 흐름)
|
||||
|
||||
**Q1** — (a)를 고르면서 사용자가 코드 모양을 정정: 되감으면 `sum`이 `prefix[i]`로
|
||||
덮이니 읽기·누적을 아예 되감지 않을 때만 — `continue` 형태.
|
||||
|
||||
**Q2** — 문항의 (a) 진입부 초기화를 사용자가 반대했고(유저 LayoutOrder 체인에
|
||||
전파가 안 될 것이라는 근거), 실측해보니 **그 근거는 성립하지 않았다**(유저
|
||||
체인은 요소 인스턴스에 바인드돼 있어 전파된다 — 깨지는 건 부기를 경유하는
|
||||
중첩 Slot의 `Offset`뿐). 그래도 (a)는 소스 이원화라 기각하고 (c)로 가려는데,
|
||||
사용자가 한 단계 더 나갔다: *"slot 자체를 생성할 때 offset/observer 이 같이
|
||||
생성되지 말아야할 이유가 있음?"* → `Offset`/`_baseObserver`를 **생성자**로. 분기
|
||||
자체가 사라진다. 이어서 `destroySlotTree`가 핸들을 `nil`로 지우던 것에 대해
|
||||
*"두 일을 겸하는걸 만들다가 사고가 난 적 많아"*(`invalidAfter`) → **`_destroyed`
|
||||
플래그**, 핸들은 unbind만. 이름은 `_disposed`가 아니다 — *"`dispose` 는 형질이
|
||||
다른 엔진 요소를 포함할 수 있는 것에 대한 공동 소멸자인 네이밍 … `destroy` 가
|
||||
맞아보이고"*. 이중 `dispose`는 no-op. 실측 `d16`에서 none/(a)/(c)/ctor 네 변형
|
||||
대조.
|
||||
|
||||
**Q3** — 표면 증상(`spliceArraysUp`이 비운 자리)을 파다가 `token`이 나왔다.
|
||||
사용자: *"내가 등장시킨 적 없는 token 이 나와서 당황스러움"*. 추적하니 7라운드
|
||||
`H-102`의 지시(*"dispatch 로 격상"*)는 요소 → 인덱스 맵을 올리라는 것이었는데,
|
||||
구현은 `len`을 키로 잡았고 `/code-review`가 그게 유일하지 않다는 걸 잡자 원래
|
||||
키로 돌아가는 대신 `token = {}`을 발명했다 — 그리고 사용자 인용문 옆에 앉아
|
||||
확정처럼 읽혔다(`H-141`). 사용자: *"난 층위 상 어떠한 값이든, 마운트된
|
||||
부기객체 -> index(기여량이 아님) 를 얻고자 했음"*. 확정: `bk.indexOfElement`
|
||||
하나(`slot._elemIndex` 삭제), `setLength(…, anchor, element)`, 등록은 `setLength`·
|
||||
이동은 `reindexFrom`·예외 `rawReplace`. `H-137` 소멸.
|
||||
|
||||
## 실수 — 같은 종류 셋, 규칙으로 승격
|
||||
|
||||
Q3 대화에서 내가 새 개념을 세 번 제안했다가 전부 철회했다: `subject` 인자 /
|
||||
`observer.pos`·`observer.inst`(**검토 후 안 만들기로 한 `Effect` userdata의
|
||||
재개방** — 사용자: *"그건 닫은 Effect 의 userdata 허용을 거의 여는 셈이야"*) /
|
||||
조회 클로저·팩토리(사용자: *"클로저가 필요한 지점으로 안 보여 … elem->index 를
|
||||
누가 관리하느냐가 어디서 관리하느냐가 명확하지 않아서 자꾸 사고가 나는듯"*).
|
||||
셋 다 한 번 grep이면 안 냈을 제안이었고, 그중 하나는 **이미 읽은** 제약을
|
||||
적용 못 한 것이었다. `conventions.md`의 `/code-review` 항목 아래에
|
||||
*"새 필드·인자·이름·메커니즘은 발견이지 결정이 아니다"* 규칙을 신설했다(사용자:
|
||||
*"code-review 가 완전 외부자라 우리 대화를 모르기에, 새로운 개념을 창조하려
|
||||
들 수도 있는 점에 대해서 기술이 필요해보이는데"*). 피드백도 남겼다(`/feedback`).
|
||||
|
||||
## 감사 루프 (Q1~Q3 반영분)
|
||||
|
||||
`quad-doc-auditor` 한 턴에 하나, 각도 순환: `base/` 정합 → 인덱스 레이어 + 새
|
||||
문단 자기모순 → archive·reference·audit 인용처 → 앞 수정분 + 두 문서 내부 정합 →
|
||||
델타 밖 `base/` → 5라운드 수정분. 확실 **1 → 1 → 3 → 1 → 1 → 0**. `base/` 본문
|
||||
결함은 1라운드 이후 0건이었고, 나머지는 전부 인덱스 레이어·인용처가 델타를 못
|
||||
따라온 것(8라운드와 같은 분포). 표는 followup의 "감사 루프" 절.
|
||||
`/code-review high`는 **아직 안 돌렸다** — Q4~Q10 반영 뒤 한 번에(체크포인트
|
||||
커밋의 이유: Q4~Q10이 같은 파일을 또 건드려 diff가 섞이면 두 라운드 몫을 구분
|
||||
못 한다).
|
||||
|
||||
## 다음 세션이 할 것
|
||||
|
||||
`qa-request/pre-implementation-handtrace-round9-followup.md`의 진행 표가 소스 —
|
||||
**Q4~Q10**(`H-127`~`H-133`)과 레인 B 몫(`H-134`~`H-136` 🟡, `H-137` 소멸,
|
||||
`H-138`~`H-140` 🟢)을 발견 문서 §4 표대로 결정 → 반영 → 감사 루프 →
|
||||
`/code-review high` → 커밋. 이번 세션의 규칙(새 개념은 문항으로)을 지킬 것.
|
||||
|
|
@ -34,6 +34,25 @@
|
|||
검증(`error` level 2), `isModifier` 가드를 `Source` 생성자로 이동.
|
||||
- **문서화 대상 등록**: quad 두 벌 공존 시 `Brand`/`None`/`Subscribed`가
|
||||
사본마다 분리된다는 사실(`research/documentation-content-map.md` §4).
|
||||
- **⭐ [2026-08-27] 9라운드를 돌렸고 Q1~Q3까지 확정·반영했다 — Q4~Q10 대기.**
|
||||
발견은 `qa-request/pre-implementation-handtrace-round9.md`(`H-124`~`H-141`,
|
||||
🔴 둘 다 실측 재현), **결정의 소스는 `-round9-followup.md`**(진행 표가
|
||||
상태의 소스). 반영된 셋: `recompute` 되감기 판정을 `lengthList[i]` 읽기
|
||||
앞으로 / `Offset`·`_baseObserver`를 Slot 생성자로 + `materializeSlotTree`
|
||||
순서 + `_destroyed` / `element → index`를 `bk.indexOfElement` 하나로(사용자가
|
||||
정한 적 없는 `token` 폐기). README 색인은 했고 `/code-review high`·커밋은
|
||||
Q4~Q10 뒤. 아래는 돌리기 전(2026-08-26) 서술:
|
||||
지시서는 `qa-request/pre-implementation-handtrace-round9-brief.md`. 스코프는
|
||||
**커밋 `9dd8213` 하나의 델타**다 — 8라운드 결정 반영과 그 뒤
|
||||
`/code-review high` **7패스의 수정이 전부 그 커밋에 들어 있고 아무도
|
||||
트레이싱한 적이 없다**(7차 수정분은 리뷰조차 안 됐다). 근거: 그 7패스의
|
||||
HIGH 추이가 `1→1→0→1→0→3→0`이라 **직전 패스가 조용한 것이 수렴의
|
||||
증거가 아님**이 같은 세션에 반증됐다(5차 HIGH 0 → 6차 HIGH 3, 전부 5차
|
||||
수정이 만든 것). 반대로 전수 재트레이싱은 수율이 낮다는 것도 실측됐다 —
|
||||
8라운드 3차 패스가 `base/`를 완독하고 🔴 0건이었다. **M2 착수 게이트는
|
||||
아니다**(위 머리말대로 게이트는 0). 돌릴지 말지는 비용 판단이고, 돌린다면
|
||||
그게 **마지막 종이 라운드**여야 한다는 게 이 지시서의 전제다 — 이후로는
|
||||
M2 구현 자체가 더 나은 감사 도구다.
|
||||
아래는 8라운드 이전(2026-08-25) 시점 서술:
|
||||
|
||||
**[2026-08-25] 7라운드까지 전부 처리 완료 — 당시 `question.md` 최우선
|
||||
|
|
@ -142,7 +161,7 @@
|
|||
`Effect`의 leaf 사망 cleanup 배선 부재(`H-11`), `:List`의 인덱스/좌표계
|
||||
결함 둘(`H-1`/`H-2`).
|
||||
|
||||
**구조가 바뀐 것 넷** — (1) `slot._elemIndex`(물리 요소 → 인덱스) 신설로
|
||||
**구조가 바뀐 것 넷** — (1) `slot._elemIndex`(물리 요소 → 인덱스; **[2026-08-27]** 9라운드 Q3로 `bk.indexOfElement`에 통합됨) 신설로
|
||||
`:List`의 `keyIndex`가 단순 키 집합으로 강등, (2) `_mounted`가 "물리
|
||||
인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`이 신설되어 부기는
|
||||
실체화 시점부터 항상 수행, (3) `Ref.Callbacks`가 해시맵 셋 + 해제 경로
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ M2(반응형 코어 — Source/State/Store)**. **⚠️ [2026-08-24] M2와 M3의
|
|||
처리 완료 — M2 착수를 막는 항목이 하나도 없다.**
|
||||
결정의 소스는
|
||||
`.claude/qa-request/pre-implementation-handtrace-round8-followup.md`
|
||||
(7라운드 몫은 `-round7-followup.md`)이고 `.claude/question.md` 최우선
|
||||
(7라운드 몫은 `-round7-followup.md`; **[2026-08-27] 9라운드 몫은
|
||||
`-round9-followup.md` — Q1~Q3 반영 완료, Q4~Q10 대기**)이고 `.claude/question.md` 최우선
|
||||
절은 비어 있다. 같은 상태를 `.claude/project-context.md`도
|
||||
서술하니 마일스톤이 넘어갈 때 두 곳을 같이 고칠 것. 진행 상황의 소스는
|
||||
항상 루트 `ROADMAP.md`.
|
||||
|
|
|
|||
46
ROADMAP.md
46
ROADMAP.md
|
|
@ -21,6 +21,9 @@ quad-v2 구현 단계 실행 계획. 설계 근거/아키텍처 자체는 여기
|
|||
> `.claude/qa-request/pre-implementation-handtrace-round7-followup.md`,
|
||||
> 그리고 **[2026-08-26] 8라운드 몫은 `-round8-followup.md`**(7라운드
|
||||
> 반영분을 겹쳐 재트레이싱한 발견 17건 — 역전 없이 누락·충돌만 닫았습니다.
|
||||
> **[2026-08-27] 9라운드 몫은 `-round9-followup.md`** — Q1~Q3(`recompute` 되감기
|
||||
> 순서 / Slot 생성자의 `Offset`·`_baseObserver`·`_destroyed` / `bk.indexOfElement`로
|
||||
> 토큰 폐기)까지 반영됐고 Q4~Q10은 대기 중입니다.
|
||||
> **어느 체크박스가 바뀌었는지는 여기서 세지 않습니다** — 해당 체크박스에
|
||||
> 각각 `H-1xx` 표시가 붙어 있으니 그게 소스입니다. M2/M3 양쪽에 걸쳐
|
||||
> 있습니다). M1까지의 산출물은
|
||||
|
|
@ -710,7 +713,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`None`은 어차피 `process`에 도착하므로 — 2026-08-18 재설계) —
|
||||
`base/dispatch-core-plan.md`의 "`None` 센티널"/"`NilHandler`" 절.
|
||||
Modifier 쪽 표면(인라인 키로 필드 지우기, `Peek` 반환 타입)은 M7
|
||||
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?)`/
|
||||
- [ ] `Dispatch.setLength(ownerKey,i,len:number|State<number>,anchor?,element?)`
|
||||
(**[2026-08-27, 9라운드 Q3]** 5번째 `element` = 그 자리의 `inst|slot` —
|
||||
`gatedRecompute`가 인덱스 대신 이걸 캡처해 **`bk.indexOfElement`**를
|
||||
조회한다. 옛 `bk.tokens`/`indexOfToken`(사용자가 정한 적 없는 `token = {}`
|
||||
신원)은 폐기. 상수 길이 자리(`Nil`/`None` 핸들러)는 생략)/
|
||||
`Dispatch.setOffsetSource(ownerKey,i,offset:Source<number>|None)`/
|
||||
**`Dispatch.getOffsetAt(ownerKey,i)`** —
|
||||
**[2026-08-21 구현 전 QA 5라운드 반영]** `setLength`의 4번째 인자
|
||||
|
|
@ -724,7 +731,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`Offset`이 **영원히 고정**된다(`sum`은 매번 새로 더하므로 위로는 맞고
|
||||
옆으로만 틀려 알아채기 특히 어렵다):
|
||||
(a) `getBookkeeping`이 `bk.offsetCache = {}`, **`bk.offsetCacheValidUpTo = 0`,
|
||||
`bk.offsetSetUpTo = 0`**, `bk.recomputeBlocker = Blocker()`으로
|
||||
`bk.offsetSetUpTo = 0`**, `bk.recomputeBlocker = Blocker()`,
|
||||
`bk.indexOfElement = {}`(**[2026-08-27 Q3]**)으로
|
||||
초기화(`nil` 시작이면 첫 `setOffsetSource`가 `nil` 비교에서 죽는다),
|
||||
(b) **캐시를 앞으로 당기는 자리**(개수는 `base/dispatch-core-plan.md`의
|
||||
무효화 표가 소스) — `setLength` 본문과 그 자리 length가
|
||||
|
|
@ -1089,10 +1097,20 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
블록이 이미 머리에 이 파일명을 적어뒀다
|
||||
- [ ] **⭐ [2026-08-24, 6라운드] 이 마일스톤에서 새로 생긴 필드·헬퍼**
|
||||
(구현 항목으로 드러나야 놓치지 않는다):
|
||||
**`slot._elemIndex`**(물리 요소→`_elements` 인덱스 역방향 맵,
|
||||
`indexOfRaw`가 이걸 O(1)로 조회하는 **기본 경로**) ·
|
||||
**`bk.indexOfElement`**(물리 요소→`_elements` 인덱스 역방향 맵,
|
||||
`indexOfRaw`가 이걸 O(1)로 조회하는 **기본 경로**. **[2026-08-27, 9라운드
|
||||
Q3]** 옛 이름 `slot._elemIndex` — 같은 뜻의 맵이 Slot 층과 Dispatch 층에
|
||||
따로 살던 것을 **Dispatch 부기 하나**로 통일했다, `base/dispatch-core-plan.md`
|
||||
`setLength` 절) ·
|
||||
**`reindexFrom(self, from)`**(`_elements`를 시프트하는 **모든** 자리가
|
||||
부른다 — 실체화 여부와 무관하게 항상) ·
|
||||
부른다 — 실체화 여부와 무관하게 항상; 갱신 대상은 위 `bk.indexOfElement`) ·
|
||||
**⭐ [2026-08-27, 9라운드 Q2] 생성자에서 나는 `Offset`/`_baseObserver`**
|
||||
(`Length`와 같은 자리 — 마운트 시점에 만들면 첫 마운트/재마운트가 갈려
|
||||
재마운트 캐시가 낡았다, `H-125`. 둘 다 bind/unbind로만 관리하고
|
||||
제거/생성하지 않는다) · **`slot._destroyed`**(파괴됨은 이 플래그 하나만
|
||||
말한다 — 핸들을 `nil`로 지워 그 뜻을 겸하게 하지 않는다. 마운트·공개
|
||||
CRUD·`:List` 진입에서 error level 2, 이중 `dispose`는 no-op,
|
||||
`base/slot-plan.md`의 "파괴된 Slot은 재사용 불가" 절) ·
|
||||
**`slot._physicalTarget`**(실체화 시점부터의 생명주기 앵커,
|
||||
`_mountedInst`는 "마운트됨"만 뜻하게 좁혀졌다) ·
|
||||
**`collectLeaves(slot)`**(중첩 Slot의 물리 리프 평탄화 —
|
||||
|
|
@ -1121,7 +1139,11 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`setOffsetSource(inst,k,None)` **먼저**, `setLength(inst,k,0)` **나중**.
|
||||
반대로 하면 `setLength` 안의 `recompute`가 죽는 중인 서브트리의 offset
|
||||
`Source`에 헛된 `:Set()`을 날림. `recompute`는 `sourceList[i]`가 `nil`
|
||||
이어도 `None`처럼 skip(방어), 해제 시 `slot.Offset = nil`.
|
||||
이어도 `None`처럼 skip(방어). (**⚠️ [2026-08-27 정정, 9라운드 `H-140`]**
|
||||
여기 한때 *"해제 시 `slot.Offset = nil`"*이 붙어 있었는데 그건 4라운드
|
||||
`SL-75`/`D-60`이 **폐기**한 문장이다 — `nil`로 갈아치우면 그 Source를
|
||||
구독 중인 다운스트림이 끊겨 포탈이 깨진다. `slot.Offset`은 생성자에서
|
||||
나서 Slot과 함께 죽는다(위 `_baseObserver` 항목과 같은 불변식).)
|
||||
- **소유권 판정을 둘로 분리** — nested(`rawAdd`)는 엄격 `claimOwner`
|
||||
(같은 owner 재클레임도 error, `Slot{a,a}` 차단, 반환값 없음),
|
||||
top-level은 `claimOwnerAt(element, inst, k)`(정확히 같은 `(inst,k)`의
|
||||
|
|
@ -1211,7 +1233,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`userdata`는 명시적으로 반환 안 하는 한 안 지워짐). 정리 루프는
|
||||
`mounted`가 아니라 직전 사이클의 키 집합 `prevKeys` 전체를 순회해야 함
|
||||
(**[2026-08-24 `H-1`]** 옛 이름은 `keyIndex`였고 인덱스 맵이었으나,
|
||||
역방향 맵 `slot._elemIndex`가 생기며 **단순 키 집합으로 강등**됐다)
|
||||
역방향 맵(지금은 `bk.indexOfElement` — 2026-08-27 Q3로 Dispatch 부기에
|
||||
통일, 그때 이름은 `slot._elemIndex`)이 생기며 **단순 키 집합으로 강등**됐다)
|
||||
(`userdata`만 살아있는 채로 key가 완전히 사라지는 케이스 커버).
|
||||
`userdata = userdata or {}` lazy-init 패턴이 Luau 제네릭에서 잘
|
||||
좁혀지는지 실측 필요. **`userdata`는 GC-native 값만 허용,
|
||||
|
|
@ -1228,9 +1251,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검
|
|||
`self._mounted` 확인 후 즉시 활성화)** (2026-08-09 일곱 번째 세션,
|
||||
`base/slot-plan.md` "`Slot:List(data, updateFn, keyFn?)`"의 "구독 시점" 절)
|
||||
**`Slot.Offset: Source<number>`도 `Slot.Length`처럼 공개 필드로
|
||||
노출 — Slot 마운트 시점에 `Dispatch.setOffsetSource`가 등록하는
|
||||
바로 그 Source를 `self.Offset`으로도 저장**(2026-08-11 세션,
|
||||
`base/dispatch-core-plan.md`의 "Slot.Length와 Slot.Offset은 별개" 절)
|
||||
노출 — `Length`와 같은 자리, 즉 생성자에서 `Source(0)`으로 만들고 마운트
|
||||
시점엔 `Dispatch.setOffsetSource`가 그 Source를 **등록만** 한다**
|
||||
(2026-08-11 세션, `base/dispatch-core-plan.md`의 "Slot.Length와 Slot.Offset은
|
||||
별개" 절. **[2026-08-27 정정, 9라운드 `H-125`/Q2]** 여기 한때 *"마운트
|
||||
시점에 `setOffsetSource`가 등록하는 바로 그 Source를 `self.Offset`으로도
|
||||
저장"*이었다 — 그러면 첫 마운트와 재마운트가 갈려 재마운트 캐시가 낡는다)
|
||||
- [ ] base `Dispatch/Slot.luau`(추상 재조정, mount/unmount/reposition 3훅) +
|
||||
quad-roblox `Handlers/Slot.luau`(실제 Parent 조작 + reposition —
|
||||
`SetSiblingIndex` 또는 `LayoutOrder` 기반이면 no-op, 구현 선택)
|
||||
|
|
|
|||
Loading…
Reference in a new issue