사용자 요청("같이 하나하나 처리해나가보자. 질문 모드로 계속 물어보며")으로
발견 보고 `H-1`~`H-54`를 문항지로 만들지 않고 **갈래 선택이 필요한 것만 급한
순서로** 물어 전량 결정하고 `base/` 24개 문서에 반영했다. 결정과 근거는
`qa-request/pre-implementation-handtrace-round6-followup.md`가 소스이고,
진행 경위와 사용자 발언 원문은
`session/2026-08-24-01-handtrace-round6-resolution.md`.
**M2/M3를 막던 것이 전부 닫혔다** — 말단 핸들러 4종의 `setLength`/
`setOffsetSource` 미등록(`H-39`), `New(): Quad`가 닫힌 타입이라 `quad.Dispatch`가
타입에러인 것(`H-25`), `Effect`의 leaf 사망 cleanup 배선 부재(`H-11`),
`:List`의 좌표계 결함 둘(`H-1`/`H-2`).
## 구조가 바뀐 것 넷
- **`slot._elemIndex`**(물리 요소→인덱스 역방향 맵) 신설로 `indexOfRaw`가 O(1)
기본 경로가 되고, `:List`의 `keyIndex`는 **단순 키 집합(`prevKeys`)**으로
강등. 사용자 역제안 — *"raw* 가 층위를 알아야할 이유를 모르겠는 상태 …
realElem->index 해시맵을 만들어주고, index 밀고 당기는 동작에서 이걸 같이
업데이트해주는 편이"*. 맵을 `_elements`와 같은 층에 두니 층 분리가 오히려
깨끗해졌다
- **`_mounted`가 "물리 인스턴스 유무"만 뜻하게 좁혀지고 `slot._physicalTarget`
신설** — 상태가 셋이 됐다(미실체화/실체화/마운트). `raw*`는 부기를 실체화
시점부터 항상 하고 `native*`만 가른다. 그래야 최초 population 중
`getOffsetAt`이 성립해 `updateFn`의 `index`를 거기서 뽑을 수 있다
- **`Ref.Callbacks`가 해시맵 셋 + `:Uncallback`**(사용자 발견) — 해제가 O(1)이
되고 `ref-plan.md`의 `#t` border 실측 항목이 폐기됐다
- **`blocker:Policy(emit)` 노출** — `Debounce`/`Throttle`이 emit을 안 쥐고 자기
Blocker를 On/Off만 하는 정책이 된다. `Gate`엔 `Flush`/`Cancel`을 안 둔다
요소 타입 검증은 블랙리스트에서 **주입 술어 `isInst` 기반 화이트리스트**로
뒤집혔고(`H-40`), 주입 op이 둘 늘었다(`isInst`/`onDestroying` — 조합 폴백이
불가능해 미주입이면 에러).
## 사용자가 에이전트 갈래를 뒤집은 자리가 여럿
`H-1`(세 갈래가 전부 차선), `H-2`(*"부기 확정에서 length 를 확정해도 되는거
아님?"* — 부기와 물리 마운트를 분리하라는 되물음), `H-40`(브랜드 판정이
2026-08-21 인스턴스 브랜드 재작성 이후 성립 불가임을 지적), `H-33`(제 중첩
합성안이 unblock 시 디바운스 창을 새로 시작시켜 창이 안 끝난다는 지적).
`Ref.Callbacks` 해시맵화와 `Ref` 콜백의 `canExecute` 확인은 사용자가 먼저 발견.
## 반영 후 재검토 — `/code-review high` 7건 + 감사 9라운드 34건
**`/code-review high` 7건 중 셋이 이번 반영이 만든 회귀**였다 — 상태가 셋이
됐다고 산문에 쓰고 코드엔 경계 하나만 남겨 `Slot { frameA }` 생성자가
크래시하던 것, `native*`를 `_mounted`로 가리면서 그게 곧 파괴였다는 걸 놓쳐
영구 누수를 만든 것, "`:List`와 CRUD는 상호배타"라며 승인받은 분기가
**재마운트 경로를 안 봐서** 포탈을 깬 것. 셋 다 코퍼스 정합성 각도로는
구조적으로 안 보이는 종류라 *"`/code-review`는 감사자를 대체하지 않는다"*가
실측으로 재확인됐다. 같은 리뷰가 `H-11`의 두 결정이 서로 모순임을 잡아
재결정했다 — *"`EffectHandle`이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가
실재하지 않았고**, 사용자 판단으로 `bindLifetime`/`unbindLifetime`이 `isEffect`를
보고 직접 처리하는 것으로 바뀌었다.
**`quad-doc-auditor` 감사 루프는 9라운드에서 새 발견 0건으로 수렴**(라운드별
5→7→2→2→3→9→2→4→0, 각도와 목록은 followup의 E절). 한 턴에 하나씩 돌리고
라운드마다 각도를 바꿨다. 가장 많이 잡은 6라운드(9건)는 *"이 체크박스로 코드를
짜면 무엇이 나오는가"*를 물은 라운드였고, 그때 `ROADMAP.md`의 미완료 항목이
대거 stale인 게 드러났다(폐기된 `pos` 공식이 "확정"으로, 접두합 캐시 무효화
계약이 통째로 부재, `bindLifetime`이 "둘만 한다"고 적혀 `H-11`과 직접 모순).
**반복된 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만 고쳤다"**:
배너를 달고 그 배너가 부정하는 문장을 안 고침(1라운드), `base/` 19개를 바꾸고
`.claude/README.md`를 한 줄도 안 고침(2라운드), 그 README를 고칠 때 11행 중
6행만(7·8라운드). 셋 다 핸드오버 체크리스트가 명시적으로 경고하는 항목이라,
규율이 없어서가 아니라 지켰는지 스스로 확인하지 않아서 생긴 실패다.
## 백로그 하나
`Fallback`/`Traceback` 중 생성된 부분 트리의 회수(`H-26`) — 그 둘이 슈가라
구현 시점에 같이 다룬다. 같이 확인된 것: `dispatch-core-plan.md`가 잔여 부기를
인스턴스 GC가 정리한다고 적은 문장은 **gcconn 불멸성과 양립하지 않는 틀린
안전망 주장**이라 삭제했다.
**M2 착수를 막는 설계 항목은 이제 없다** — 남은 건 `question.md` 2번(M2↔M3
양방향 의존, 마일스톤 순서)뿐이다. `doc-check.py` ERROR 0.
Co-authored-by: qwreey <me@qwreey.moe>
Claude-Session: https://claude.ai/code/session_01Jjrec9xAS7TZstMx5gi3cm
375 lines
26 KiB
Markdown
375 lines
26 KiB
Markdown
# 6라운드 손 트레이싱 — 처리 결과
|
|
|
|
**상태**: **[2026-08-24] 전량 처리·반영 완료.** 사용자와 대화형으로 하나씩
|
|
결정했고(`H-1`~`H-54`), 그 결정을 각 `base/` 문서에 **전부 반영했다**.
|
|
이 문서는 **결정과 근거의 기록**이고, 지금 유효한 설계는 항상 `base/`가 소스다.
|
|
|
|
원 발견 보고는 `pre-implementation-handtrace-round6.md`.
|
|
|
|
**반영된 문서**: `slot-plan.md` / `dispatch-core-plan.md` / `source-state-plan.md` /
|
|
`effect-plan.md` / `ref-plan.md` / `gate-plan.md` / `blocker-plan.md` /
|
|
`debounce-throttle-plan.md` / `tag-plan.md` / `attribute-plan.md` /
|
|
`modifier-plan.md` / `onchange-plan.md` / `tween-plan.md` / `typing-limits.md` /
|
|
`fallback-plan.md` / `quad-types-plan.md` / `store-plan.md` /
|
|
`module-lifecycle-plan.md` / `architecture.md`, 그리고 루트 `ROADMAP.md`와
|
|
`.claude/todos.md` / `luau-test/STATUS.md`.
|
|
|
|
## A. 처리 완료 (결정됨)
|
|
|
|
### `H-39` — 말단 핸들러 4종의 `setLength`/`setOffsetSource` 미등록
|
|
**결정: 넷 다 똑같이 등록**(갈래 1 + 2-(a)). `TagHandler`/
|
|
`AttributeGroupHandler`/`RefLeafHandler`/`ObserverEffectLeafHandler`의
|
|
`process` 맨 앞에서 `setOffsetSource(inst,k,None)` → `setLength(inst,k,0)`.
|
|
`AttributeGroupHandler`도 예외로 두지 않는다 — Tag/Attribute를 "Length/Offset
|
|
비참여 카테고리"로 재정의하면 `bk.N`의 의미가 바뀌어 파급이 크다.
|
|
|
|
### `H-25` — `New(): Quad`가 닫힌 타입
|
|
**결정: `quad-types`의 `Quad`를 마일스톤마다 갱신**(갈래 2). M2가
|
|
`Dispatch` 필드와 그 타입 재수출을 `quad-types`에 추가하는 걸 `ROADMAP.md`
|
|
M2 체크리스트 항목으로 명시한다. quad-roblox(M5)도 같은 경로로 본다.
|
|
|
|
### `H-1` — `keyIndex`가 사이클 도중 stale
|
|
**결정: 사용자 역제안 채택 — 역방향 인덱스 맵을 raw 층에 둔다.**
|
|
- `:List`는 `k → realElem`만 들고, 배열 인덱스를 직접 들지 않는다.
|
|
- **`slot._elemIndex`** 신설(`realElem → index`), `_elements`와 같은 수명.
|
|
자리를 밀고 당기는 모든 연산(`spliceArraysUp`/`Down`, `rawMove`,
|
|
`rawSwap`, `rawReplace`, `rawAdd`)이 같이 갱신한다. detach된 요소는
|
|
`_elements` 밖이므로 맵에서도 빠진다.
|
|
- **raw\*의 index 시그니처는 유지**(5라운드 결정 그대로). 대신
|
|
`indexOfRaw(self, element)`가 선형 탐색이 아니라 이 맵 조회가 되고,
|
|
"폴백이 아니라 기본 경로"로 승격된다.
|
|
- 근거: 층 분리가 오히려 깨끗해지고(raw\*가 `:List` 클로저를 안 봄),
|
|
시프트는 이미 O(n)이라 맵 갱신이 점근 비용을 안 올리며, 조회가 O(1)이
|
|
된다. `claimOwnerAt`이 같은 요소의 이중 배치를 이미 error로 막으므로
|
|
키 유일성이 보장된다.
|
|
- **부수 효과**: `:List`의 `keyIndex`가 인덱스 맵에서 **단순 키 집합**으로
|
|
내려앉는다(소멸 루프의 "직전 사이클 키" 용도만 남음) → `H-16`이 같이
|
|
사라진다.
|
|
|
|
### `H-2` — `pos`(리프 카운터)를 배열 인덱스로 겸용
|
|
**결정: 카운터를 분리하고, `updateFn`의 `index`는 `getOffsetAt`으로 구한다.**
|
|
사용자 원 디자인 초안대로 — activation이 순차이므로 그 시점 length는
|
|
확정돼 있다. 이를 성립시키기 위해 초기 population 경로를 같이 고친다:
|
|
- **`_mounted`는 "물리 인스턴스가 있는가"만 가리킨다.** `rawAdd`의
|
|
얼리리턴을 **`native*` 호출만 가리는 분기**로 좁히고, 부기
|
|
(`spliceArraysUp`/`setOffsetSource`/`setLength`/중첩이면 실체화)는
|
|
마운트 전에도 수행한다.
|
|
- **`slot._physicalTarget` 신설** — `materializeSlotTree` 머리에서 저장.
|
|
`setLength`의 앵커/`bk.observers` 앵커가 마운트 전에도 필요하기 때문.
|
|
`_mountedInst`는 지금대로 "마운트됨"만 뜻한다(미실체화/실체화/마운트 3상태).
|
|
- **`_listed`면 `materializeSlotTree`의 자식 실체화 루프를 건너뛴다** —
|
|
`activateList`가 이미 전부 등록하므로 이중 등록이 된다. `:List`와 CRUD가
|
|
상호배타라 정확하다.
|
|
- **`blocker:On()`을 `activateList` 앞으로 옮긴다** — 5라운드 `AS-5`의
|
|
근거("그 안에선 게이팅할 recompute 자체가 안 일어난다")가 사라지므로.
|
|
population 중 아이템마다 recompute가 도는 O(n²)를 막는다.
|
|
`_baseObserver` 생성은 여전히 `blocker:On()` 뒤.
|
|
|
|
### `H-5` — `spliceArraysUp`이 `bk.N`을 먼저 올려 여는 창
|
|
**결정: `spliceArraysUp`이 `lengthList[index]`에 자리표시자를 채운다.**
|
|
`sourceList`가 `None`으로 채워지는 것과 대칭. 동기 재진입(`ChildAdded`)이
|
|
그 창에서 `sum += nil`로 터지던 것이 닫힌다.
|
|
|
|
### `H-6` — `unmountSlotTree`의 미정의 `physicalTarget` + offset 어긋남
|
|
**결정: `physicalTarget`은 `slot._mountedInst`를 로컬로 먼저 뽑아 쓴다**
|
|
(지우는 대입보다 위에서 읽는 순서 유지). 곁가지는 **역순 순회로 변경** —
|
|
뒤에서부터 빼면 앞쪽 offset이 안 밀려 매번 정확하다. 백엔드 계약을
|
|
좁히지 않아도 닫힌다.
|
|
|
|
### `H-7` — `Effect`의 `Ref` 의존성을 뗄 방법이 없음
|
|
**결정: `Ref`에 콜백 해제 경로를 추가**(갈래 a).
|
|
|
|
### (파생) `Ref.Callbacks`를 해시맵 셋으로
|
|
**결정: `{[callback/thread] = true}`로 변경**(사용자 제안). 해제가 O(1)이
|
|
되어 `H-7`이 요구하는 연산과 맞고, `type(v) == "thread"` 분기는 키 타입
|
|
검사로 그대로 성립한다.
|
|
- **같은 콜백/thread의 중복 등록은 dedup을 계약화**(여러 번 등록해도 한 번
|
|
발화). 해제가 `t[fn] = nil` 하나로 끝나야 하기 때문.
|
|
- **`ref-plan.md`의 ⚠️ 실측 대상(구멍 있는 테이블에서 `#t` border가 항상
|
|
첫 `nil`인가 — M0/M8 스파이크)이 폐기된다** — 해시맵엔 border 개념이 없다.
|
|
- `table.insert` 재사용 근거(`R-11`)와 "`None`이 아니라 `nil`" 논의도 이
|
|
자료구조 변경에 맞춰 다시 쓴다.
|
|
|
|
### `H-23` — 전파 도중 새 구독자가 붙으면 누락/이중 발화 (실측)
|
|
**결정: 순회 전 스냅샷**(갈래 1). State 구독자 집합과 `Ref.Callbacks`
|
|
**둘 다** 배열로 복사한 뒤 그 배열을 순회한다. "이번 파동 중에 붙은
|
|
구독자는 다음 파동부터 참여"를 계약으로 명시. 스냅샷 사이에 죽은 구독자는
|
|
기존 `canExecute` 게이트가 걸러준다.
|
|
|
|
### `H-11` — `Effect`의 leaf 사망 cleanup을 부르는 배선이 없음
|
|
**결정(1번 소항목): `EffectHandle` 쪽이 자기 `bindLifetime` 직후에
|
|
`Destroying`을 건다.** `bindLifetime`이 값 타입을 가리지 않는다는 기존
|
|
원칙을 유지한다. 2·3번 소항목(=`unbindLifetime`이 cleanup을 부르는가 /
|
|
cleanup을 어디 보관하는가)은 아직 미확정.
|
|
|
|
### `H-12` — `rawRemove`/`rawUnmount`/`rawDetach`에 마운트 전 분기 부재
|
|
**결정: `native*` 호출(및 `_mountedInst`를 쓰는 줄)만 `_mounted`로 가드**해
|
|
`rawAdd`와 대칭을 맞춘다. `H-2`의 "부기는 마운트 전에도 한다"와 같은 규칙을
|
|
네 함수가 공유한다.
|
|
|
|
### `H-13` — `Effect(fn, ...deps)` 역전이 정본 문서에 미반영
|
|
**결정: `Observer`는 기각 유지하되 근거를 새로 쓴다.** `source-state-plan.md`가 승격해둔 일반 원칙(없던 노드를 새로
|
|
만들어야 하면 sugar를 안 붙인다)은 **`:Compute` 한정**으로
|
|
좁히고, `Effect`의 역전(`C-6`)을 그 문서에 반영한다. `Observer` 기각의 새 근거는
|
|
"Observer는 리시버 State 하나에 붙는 구독이고, 여럿을 엮는 건 `Effect`가 대신한다".
|
|
|
|
### `H-14` — `Effect`의 `fn` 시그니처
|
|
**결정: `Effect(fn: (self: Effect) -> (() -> ())?, ...deps)`.**
|
|
- `fn`은 **`self`(Effect 핸들) 하나만 받는다.** `...deps`는 **의존성 선언일 뿐
|
|
`fn`에 안 넘어간다** — dep 값은 사용자가 클로저로 직접 읽는다.
|
|
- 사용자 논거: *"Observer 처럼 바로 상위 state 가 있는게 아니라 Effect 를 주는게
|
|
맞아보이고, Compute 랑은 완전 다름. 난 그냥 `...deps` 넣는게 compute 처럼
|
|
그냥 넣을 수 있게 하자는거였을 뿐임."*
|
|
- 그 따름정리로 **`Ref` dep의 `.Value` vs State dep의 `:Get()` 비대칭 문제가
|
|
사라진다**(아무것도 안 넘기므로). 문서의 *"trailing deps를 lazy 위치 인자로
|
|
콜백에 넘긴다"*와 옛 `fn(state)` 표기는 **둘 다 삭제**.
|
|
|
|
### `H-17` — `Dispatch.drive`의 Blocker 범위
|
|
**결정: "`drive` 전체"로 넓혀 적고 계약화.** `F-4-1`의 단일 일반화 `for`에선
|
|
배열 파트 종료 시점이 관측되지 않고 `postRefList` 소비는 애초에 해시 파트보다
|
|
뒤이므로, 실제 범위를 문장에 맞춘다. 추가로 **`PostRef` 콜백은 게이트가 켜진
|
|
채로 실행되며, 그 안에서 일어난 Length 변화는 직후의 명시적 `recompute` 한
|
|
번으로 정리된다**를 계약으로 명시(지금은 우연히 맞고 있을 뿐).
|
|
|
|
### `H-18` + `H-45` — attribute 이름을 그룹 사이에서 옮길 때 emit 순서 의존
|
|
**결정: UB로 못박는다.** 사용자 논거: *"state<attribute> 로 그 경로가 열리는데
|
|
어떤 방식으로 process 를 걸어도 외부에 바꿀지 말 지 알 방법이 없음. 외부에서
|
|
먼저 retract 가 나도록 유도하는게 아닌 한 알 방법이 없어보임. 애초에 싱크이기도
|
|
하고, 또 이걸 허용하면 process/retract 계약의 전체에 대한 예외들이 생기고
|
|
오버 엔지니어링으로 보임."* `H-45`(단건 `AttributeKey` ↔ 그룹)도 같은 결정으로
|
|
닫힌다.
|
|
|
|
### `H-19` — `recompute`가 두 번 도는 것
|
|
**결정: `setLength`에 일임하고 `rawAdd`/`rawReplace`의 명시 `recompute` 호출을
|
|
삭제.** "자리의 길이가 바뀌면 `setLength`가 책임지고 `recompute`를 태운다"로
|
|
소스를 하나로 만든다 → `H-3`의 `invalidAfter` 당김도 `setLength` 한 자리에만
|
|
두면 된다.
|
|
|
|
### `H-21` — `unwrapElement`가 Instance에서 크래시
|
|
**결정: `isSlot` 가드**(갈래 1). `if isSlot(el) then return el._wrapped or el end
|
|
return el`. 래퍼는 항상 Slot이고 `isSlot`은 Brand의 weak-key 조회라
|
|
Instance/userdata/number 전부에 안전하다.
|
|
|
|
### `H-22` — 기본 identity `updateFn`이 `KeyGone`을 되돌려 항상 error
|
|
**결정: `identityUpdateFn`이 `KeyGone`을 흡수**(갈래 1) —
|
|
`if item == KeyGone then return nil end`. 사용자가 직접 쓴 identity에도 같은
|
|
처리가 필요하다는 건 문서로 안내한다.
|
|
|
|
### `H-24` — `Tween<T>:Mapped`의 재귀 제네릭 타입 누수
|
|
**결정: 단순 정정.** `typing-limits.md`의 영향 범위에 `Tween<T>:Mapped` 행을
|
|
추가하고, `tween-plan.md`의 `Mapped` 절에 "인라인 제네릭 메소드가 아니라
|
|
`typeof(named function)`으로 선언할 것" 각주. 기존 완화책이 그대로 통한다.
|
|
|
|
### `H-26` — 부분 생성 후 예외로 생긴 Instance가 영구히 안 죽음
|
|
**결정: 둘로 나눈다.**
|
|
1. **기본 경로(예외가 밖으로 나가 아무것도 안 그려지는 경우)는 서술 정정으로
|
|
끝낸다.** 같이 **`dispatch-core-plan.md`에서 잔여 부기가 인스턴스 GC로
|
|
정리된다고 적은 문장을 고친다** — gcconn 불멸성과 양립하지 않는 **틀린 안전망
|
|
주장**이다.
|
|
2. **`Fallback`/`Traceback` 중 생성된 부분 트리는 백로그.** 사용자 판단:
|
|
*"의도적으로 error 를 사용하고자 하는 경우 항상 컴포넌트들이 쌓이거든. 이건
|
|
후행에서 더 다뤄보도록 백로깅해줘. fallback/traceback 자체가 슈거라서, 그
|
|
때 가서 생각해도 될듯."*
|
|
|
|
### `H-29` — 정의 없는 `raw*` 다섯
|
|
**결정: 작성 + `collectLeaves(slot)` 헬퍼 신설.** 재귀 리프 수집 헬퍼를 두고
|
|
`Move`/`Swap`/`Extract`/`Splice`가 공유한다(`native*`의 "빠지는 요소는 반드시
|
|
elements 배열로" 계약을 그대로 지킴). 작성 시 같이 명시할 것: 함께 치환되는
|
|
배열(`lengthList`/`sourceList`/`bk.observers`/`_elemIndex`), `bk.N`은 안 변함,
|
|
`recompute`는 `setLength`에 일임(`H-19`).
|
|
|
|
### `H-32`/`H-33`/`H-49` — `Debounce`/`Throttle`과 `Gate`의 관계
|
|
**결정: 정책 합성 구조를 확정.**
|
|
- **`Gate` 노드에 `Flush`/`Cancel` 표면을 두지 않는다.** 확정된
|
|
`state:Gate(setup)`(State 하나만 반환)이 그대로 유지된다.
|
|
- **`Blocker`가 자기 정책을 값으로 낸다 — `blocker:Policy(emit) -> onUpstreamEmit`.**
|
|
`state:Block(b)`는 그 위의 얇은 래퍼가 된다.
|
|
- **`Debounce`/`Throttle`은 emit을 아예 안 쥔다.** 자기 `Blocker`를 사적으로
|
|
갖고 **언제 `On()`/`Off()`할지만** 정하며, 실제 발화/보류는 Blocker에 위임한다:
|
|
```lua
|
|
state:Gate(function(emit)
|
|
local b = Blocker()
|
|
local pass = b:Policy(emit)
|
|
return function() -- 상류 emit 도착 (동기)
|
|
...타이머 리셋 / b:On() / b:Off() 시점 판단만...
|
|
pass() -- 그 시점 상태로 Blocker가 판정
|
|
end
|
|
end)
|
|
```
|
|
동기 실행이라 같은 호출 안에서 정책이 바꾼 Blocker 상태를 `pass()`가 본다.
|
|
- **Blocker는 `Debounce` 설정당 하나가 아니라 적용 핸들당 하나**다 — `Debounce{}`
|
|
커링 결과는 여러 곳에 적용될 수 있으므로 Apply 시점에 생성된다.
|
|
- **`pending`은 Blocker의 `HasBlockedEmit`으로 흡수**한다(중복 상태를 안 만든다).
|
|
`Trailing=false`는 `OffWithoutEmit()`, `Flush`는 `Off()`로 매핑되어 `H-32`의
|
|
새는 경로가 구조적으로 사라진다.
|
|
- 합성 순서(누가 상류인가)는 손으로 중첩해 표현한다 — **`state:Gate(p1, p2, ...)`
|
|
가변인자 슈가는 두지 않는다.**
|
|
- **기각된 대안(기록)**: "블로커를 바깥에 중첩"(`blocker:Policy(debounceOnEmit)`)은
|
|
unblock 시 흘러나온 emit이 디바운스 창을 새로 시작시켜 창이 안 끝난다.
|
|
|
|
### `H-38` — reconcile의 예외 원자성
|
|
**결정: 키 집합을 증분적으로 갱신**(마지막 일괄 교체 폐지). `settle` 직후에
|
|
키를 넣고 소멸 루프에서 뺀다. 계약은 "reconcile은 원자적이지 않지만 **중단된
|
|
지점까지는 정합하다**". `H-31`(duplicate key)의 선행 검증 패스와 함께 그 실패
|
|
모양을 닫는다.
|
|
|
|
### `H-39`~`H-42`, `H-46`, `H-52` (4차)
|
|
- **`H-40`** — 빠진 가드 넷을 전부 의사코드에 반영. 타입 검증은 **화이트리스트로
|
|
뒤집되 base가 `T`를 아는 방식이 아니라 백엔드 주입으로** 한다: **`isInst`를
|
|
백엔드 계약(주입 op)에 추가**하고 quad-roblox가 `typeof(v) == "Instance"`로
|
|
채운다. 판정 순서는 `isSlot` → 구조 처리 / `isState` → 래퍼 Slot으로 풀어
|
|
재귀 / 그 외 → `isInst(v)`가 거짓이면 error. **관문은 `wrapElement` 하나**
|
|
(공개 CRUD와 `:List`의 `settle`이 둘 다 통과하므로 State가 나중에 이상한 값이
|
|
되는 경우도 같은 자리에서 걸린다). 사용자 논거: 브랜드 기반 판정은 불가 —
|
|
*"이제 brand 는 각각 따로 생성되어서 있는지 없는지 보는건 결국 전부 봐야한다는
|
|
의미"*.
|
|
- **파생: `Splice`의 `T | {T}` 기각은 유지하되 근거를 정정한다.** *"근거 정정.
|
|
근데 실질적으로 저 splice 는 T 가 slot/state 일 수도 있어서, isSlot/state 도
|
|
봐야함. 당연하게도 `Splice(..., comp())` 패턴은 흔할 수 있어서 그래. 그리고
|
|
여전히 splice 는 소수의 요소를 다루는게 많고, 테이블 생성 비용을 지불할
|
|
의미가 없다는 근거는 여전해."*
|
|
- **`H-42`** — **문서만 정정**. "동기 발화"를 `SignalBehavior.Immediate` 조건부로
|
|
명시(Deferred에선 이 레이스가 없고 `PreRef`는 무해하게 불필요). 그 뒤의 큰
|
|
질문(구조 존속)은 **유지**로 결론 — Immediate place가 살아있는 한 필요하고
|
|
불필요해질 때의 비용이 싸다.
|
|
- **`H-46`** — **top-level `Slot.luau` 신설**(다른 값 타입과 대칭,
|
|
`slot-plan.md`가 이미 적어둔 파일명과도 일치). `Dispatch/Slot.luau`는
|
|
핸들러/부기만.
|
|
- **`H-52`** — **대칭적으로 가드 추가**(`type(k)=="number"` + 동적 경로 가드),
|
|
`RefLeafHandler`가 2026-08-18에 받은 수정과 같은 모양.
|
|
|
|
### 일괄 단순 정정 (사용자 확인: "전부 그대로 반영")
|
|
`H-3`(캐시 무효화 3규칙을 `setLength`/splice/`_baseObserver`에 실제 배치) ·
|
|
`H-4`(`bk` 스펙에 `offsetCache`/`invalidAfter` 추가, `0`/`{}` 초기화 명시) ·
|
|
`H-8`(`_observers` 배열화 + cascade/Subscribe/Unsubscribe 순회) ·
|
|
`H-9`(`withheld` 스왑도 weak-key로) · `H-10`(`sum` 주석 정정) ·
|
|
`H-20`(`process(inst, k, v, index)` 표기 통일) · `H-27`(`OnChange`의 `v == nil`
|
|
얼리리턴) · `H-28`+`H-43`(`dispose`의 소유권 가드를 `isSlot` 분기 밖으로) ·
|
|
`H-30`+`H-31`(선행 일괄 검증 패스) · `H-34`+`H-44`(`native*` 계약 보강 —
|
|
Roblox 백엔드는 `nativeMove`/`nativeSwap`을 덮어써야 함, ROADMAP M6 줄 갱신) ·
|
|
`H-35`(`ProcessedModifierHandler` 의사코드 + 색인 두 곳) · `H-36` · `H-37` ·
|
|
`H-41`(`groupClaimKeys` 배선) · `H-47`~`H-51` · `H-53` · `H-54`.
|
|
|
|
## B. 답할 게 없는 항목
|
|
|
|
- **`H-15`** — 2차 패스에서 이미 철회(오탐).
|
|
- **`H-16`** — `H-1` 해법의 부수로 소멸(`keyIndex`가 인덱스 맵이 아니게 됨).
|
|
- **`H-2`의 크래시 주장 정정** — 3차 패스의 실측(`table.insert(t, 0, x)`는
|
|
Luau에서 안 터짐)대로 "크래시"가 아니라 "조용한 영구 고아"로 본문을 고친다.
|
|
결론과 처방은 그대로.
|
|
|
|
## C. 백로그로 넘긴 것
|
|
|
|
- **`H-26`의 2번** — `Fallback`/`Traceback` 중 생성된 부분 트리의 회수.
|
|
그 둘이 슈가라 구현 시점에 다룬다.
|
|
|
|
## D. 반영 후 `/code-review high` (2026-08-24) — 7건, 전부 유효
|
|
|
|
반영을 커밋하기 전에 사용자가 `/code-review high`를 돌렸고 **7건이 나왔으며
|
|
전부 유효했다.** 그중 셋(1·2·3)은 **이번 반영이 새로 만든 회귀**다 — 감사자가
|
|
보는 축(코퍼스 정합성)으로는 안 보이고 diff 자체를 읽어야 보이는 종류였다.
|
|
|
|
1. **`rawAdd`가 미실체화 가드를 통째로 잃었다**(높음). `H-2` 재작성이
|
|
`if not self._mounted then return index end`를 지우면서 **3상태 중 첫
|
|
경계에 아무것도 안 뒀다.** `Slot { frameA }` 생성자가 `:Add`를 부르는데
|
|
그건 `materializeSlotTree`보다 훨씬 먼저라 `slot.Offset`이 `nil`이고,
|
|
`setLength` → `gatedRecompute` → `getOffsetAt`의 `ownerKey.Offset:Get()`에서
|
|
즉시 죽는다. 중첩이면 `bindLifetime(nil, …)`까지 간다.
|
|
→ **`self._physicalTarget == nil`이면 `_elements`/`_elemIndex`만 갱신하고
|
|
리턴**하도록 복원. `rawReplace`의 미마운트 분기에도 같은 가드를 넣었다.
|
|
같이 정리된 것: `_elemIndex` 갱신을 `reindexFrom(self, from)` 헬퍼로 떼어
|
|
**실체화 여부와 무관하게 항상** 돌게 했다(`spliceArrays*`는 부기만 담당).
|
|
2. **`rawRemove`가 마운트 전 창에서 요소를 안 죽인다**(높음). `H-12`가
|
|
`nativeRemove`를 `_mounted`로 가렸는데 **`nativeRemove`가 곧 파괴**였다
|
|
(백엔드 융합). `else` 분기가 비어 있어 요소가 `_elements`에서 빠지고
|
|
아무도 안 죽인다 — gcconn 때문에 GC도 안 되므로 **영구 누수**다.
|
|
→ `else nativeDispose(element)` 추가(`rawReplace`가 이미 하던 대로).
|
|
3. **`_listed` 분기가 포탈 재마운트를 깬다**(중간). 재마운트에선
|
|
`activateList`가 `_listActivated` 멱등 가드에 걸려 앵커만 옮기고 리턴하므로,
|
|
자식 루프까지 건너뛰면 보존된 `_elements` 안의 중첩 Slot이 **다시 실체화되지
|
|
않는다**(`_physicalTarget`이 `nil`인 채 `_mounted`만 켜지고 `_baseObserver`가
|
|
죽은 옛 target에 매달린다). → 조건을
|
|
**`slot._listed and not slot._listActivated`**로 좁히고, `else` 가지에서
|
|
재마운트 시 `activateList`(앵커만)를 부른 뒤 루프를 돌게 했다.
|
|
4. **`H-11`의 두 결정이 서로 모순**(중간). "`bindLifetime`은 값 타입을 안
|
|
가린다"를 지키기로 해놓고 "`unbindLifetime`이 `Destroying`을 끊는다"를
|
|
같이 정했는데, 후자는 정확히 그 분기를 요구한다. 게다가 *"`EffectHandle`
|
|
쪽이 자기 `bindLifetime` 직후에 건다"*는 **그 호출부가 실재하지 않는다**
|
|
(핸들은 남이 자기를 bind하는 걸 관측 못 한다). `Effect` 바인드 경로가
|
|
둘이라(leaf 핸들러 / `activateList`의 `_detachCleanup` 직접 바인드) 핸들러
|
|
층에 둬도 안 덮인다.
|
|
→ **재결정(사용자, 2026-08-24): `bindLifetime`/`unbindLifetime`이
|
|
`isEffect`를 보고 직접 처리한다.** *"Destroying 자체가 엔진이 아는 요소이기
|
|
때문에, 엔진이 처리하는 곳에 두긴 해야합니다. 옵져버는 바로 생성되기
|
|
때문에, bind 상 옵져버 목록을 가져와 자신이 재귀하고, bindLifetime 이
|
|
처리하는게 나아보입니다."* 근거로 들었던 "게이트는 값 타입을 안 가린다"는
|
|
**인용이 틀렸다** — 그 절은 `canBound` 판정이 두 진입점에서 같다는 얘기지
|
|
부수 배선 얘기가 아니고, 실제로 그 함수는 이미 `_observers`로 cascade한다.
|
|
의사코드는 `base/lifecycle-pattern.md`에 반영.
|
|
- **⭐ 같은 자리에서 사용자가 추가 지적**: `Effect`의 **`Ref` 콜백도
|
|
`canExecute`를 거쳐야 한다.** 해제 경로만으로는 창이 남는다 —
|
|
`unbindLifetime`으로 조용히 끊긴 상태(포탈 언마운트)는 `Destroying`이 안
|
|
도는데도 `canExecute`가 거짓이다. **해제는 누수를, 게이팅은 발화를** 막는다.
|
|
→ `Effect`가 거는 `Ref` 콜백은 본문 맨 앞에서 `canExecute(handle)`를
|
|
확인하고 거짓이면 리턴한다. 이로써 두 dep 경로가 같은 게이트를 공유한다.
|
|
(`H-7` 처리 때 "그 대안은 채택하지 않았다"고 적었던 서술을 정정했다 —
|
|
택일이 아니라 둘 다 필요하다.)
|
|
5. **`Block`/`Gate` 동치 표기가 호출 불가**(중간). `state:Gate(b:Policy)`는
|
|
문법 오류이고 `state:Gate(b.Policy)`는 언바운드 메소드라 `emit`이 `self`
|
|
자리에 들어가 게이트가 영영 안 열린다.
|
|
→ `state:Gate(function(emit) return b:Policy(emit) end)`로 두 문서 정정.
|
|
6. **`settle`의 교체 분기가 새 자리로 안 옮긴다**(낮음). `rawReplace`는 자리를
|
|
유지하는데 `slotPos`를 무시해서, 값 교체와 리오더가 같은 사이클에 겹치면 그
|
|
요소만 옛 자리에 남는다. → 교체 후 `idx ~= slotPos`면 `rawMove`.
|
|
7. **`prevKeys[key] = true`가 `settle` 뒤에 있어 `H-38` 계약이 약해진다**(낮음).
|
|
`settle`이 커밋 후 던지면 그 키가 집합에 안 들어가 다음 사이클 소멸 루프가
|
|
못 물어 영구 고아가 된다 — `H-38`이 고치려던 바로 그 모양. → `settle` **앞**으로.
|
|
|
|
**교훈(기록)**: `conventions.md`가 *"`/code-review`는 감사자를 대체하지 않는다"*
|
|
라고 적어둔 그대로였다. 이번엔 감사자를 돌리기 **전에** code-review가 먼저
|
|
돌았는데, 잡힌 7건 중 셋이 "이번 diff가 만든 새 결함"이라 코퍼스 정합성
|
|
각도로는 애초에 안 보이는 것들이었다.
|
|
|
|
## E. 반영 후 `quad-doc-auditor` 감사 루프 (2026-08-24) — 9라운드, 34건
|
|
|
|
`/code-review high` 뒤에 감사 루프를 돌렸다. `conventions.md`의 절차대로
|
|
**한 턴에 하나씩**(병렬 금지) 돌리고 **라운드마다 각도를 바꿨으며**, 새 발견이
|
|
**0건인 라운드가 나올 때까지** 반복했다. 라운드별 발견: **5 → 7 → 2 → 2 → 3 →
|
|
9 → 2 → 4 → 0**.
|
|
|
|
| 라운드 | 각도 | 발견 |
|
|
|---|---|---|
|
|
| 1 | `base/` 문서들끼리 정합성(옛 주장 잔존) | 5 |
|
|
| 2 | 인덱스 레이어(README/ROADMAP/question/todos/STATUS/archive/reference) | 7 |
|
|
| 3 | **이번에 새로 쓴 서술 자체**의 내부 모순·인용 검증·정의/사용 일치 | 2 |
|
|
| 4 | 이번에 **안 바뀐** 문서가 조용히 틀려졌는가 | 2 |
|
|
| 5 | **어휘 추적** — 뜻이 바뀐 낱말을 옛 뜻으로 쓰는 산문 | 3 |
|
|
| 6 | `ROADMAP.md`의 **미완료 체크박스**를 구현자 눈으로 | 9 |
|
|
| 7 | 6라운드 픽스 검증 + `agents`/`tools`/followup 사각 | 2 |
|
|
| 8 | **완결성** — "N개 중 일부만 고친 자리" 세기 | 4 |
|
|
| 9 | 수렴 확인 + 전 코퍼스 스윕 | **0** |
|
|
|
|
**각도를 바꾼 게 실제로 작동했다.** 가장 많이 잡은 6라운드(9건)는 문서
|
|
정합성이 아니라 *"이 체크박스로 코드를 짜면 무엇이 나오는가"*를 물은
|
|
라운드였고, 그때 `ROADMAP.md`의 미완료 항목들이 대거 stale인 게 드러났다 —
|
|
M6엔 폐기된 `pos` 공식이 "확정"으로 남아 있었고, M2엔 접두합 캐시 무효화
|
|
계약이 통째로 없었으며, M8은 `bindLifetime`이 *"둘만 한다"*고 적어 `H-11`의
|
|
Effect 분기와 직접 모순이었다. 말단 핸들러 4종의 부기 의무(`H-39`)는 어느
|
|
체크박스에도 없었다.
|
|
|
|
**반복해서 드러난 실패 패턴은 하나 — "고쳐야 할 자리가 N개인데 일부만
|
|
고쳤다".** 1라운드는 *배너를 달아놓고 그 배너가 부정하는 위쪽 문장을 안 고친*
|
|
것(핸드오버 체크리스트 2번), 2라운드는 *`base/` 19개를 바꾸고 `.claude/README.md`를
|
|
한 줄도 안 고친* 것(체크리스트 6번), 7·8라운드는 *그 README를 고칠 때 11개 행
|
|
중 6개만 고치고 다섯을 빠뜨린* 것이었다. 8라운드를 아예 **완결성 축**(새 이름
|
|
하나당 나타나야 할 자리 넷 — 정의 문서/소스 트리/ROADMAP 체크박스/README 행 —
|
|
을 열거해 세기)으로 잡은 게 그래서였고, 실제로 4건이 더 나왔다.
|
|
|
|
**같이 채운 절차적 공백 둘**: `conventions.md`가 요구하는
|
|
`session/2026-08-24-01-handtrace-round6-resolution.md` 원문과
|
|
`session-summary.md` 항목을 남겼다(설계 결정이 대량으로 오간 세션인데 기록이
|
|
없었다). 그리고 이 감사에서 파생된 새 결정이 셋 있어 `base/`에 함께 반영했다 —
|
|
**주입 op `onDestroying(inst, fn)` 신설**(`_bindDestroying` 의사코드를 쓰다
|
|
드러남, base는 `Instance`를 모르므로), **`EffectHandle`의 필드 다섯**
|
|
(`_destroyConn`/`_refDeps`/`_refCallbacks` 등), **`quad-types`의 `Quad` 갱신을
|
|
M3/M6/M7/M8/M10에도 항목화**(확정이 "마일스톤마다"였는데 M2에만 있었다).
|