diff --git a/.claude/README.md b/.claude/README.md index b32f2e7..e7d6db9 100644 --- a/.claude/README.md +++ b/.claude/README.md @@ -31,9 +31,9 @@ | `architecture.md` | quad-v2 전체 아키텍처 확정 사항 요약(제일 먼저 볼 문서). **[2026-08-12 세션 신설, 같은 날 후속 세션에서 강화]** "코드 스타일 — Luau 문법 관례" 절 신설 — `if-then-else`가 공식 Luau 문법임을 명문화(환각/오타로 오인해 `and`/`or`로 되돌리는 회귀 방지), `A and B or C` 삼항 관용구는 항상-truthy 예외도 없이 전면 금지로 강화(`bind-system-plan.md`의 `retractUnder` falsy-값 버그가 실사례). `const` 바인딩은 공식 문법이나 툴링 미성숙으로 지금은 채택 보류 | | `lifecycle-pattern.md` | rbvm의 `Connected`+GC 관용구를 quad-v2가 채택하는 방식 | | `store-semantics.md` | Store는 부작용 허용이 기본. State는 Store 위의 조합 가능한 캐시 레이어로 실제로 필요함(2026-08-04 정정) — 온톨로지 핵심 메커니즘은 2026-08-04 2차 라운드에서 확정, 최신 상세는 `base/bind-system-plan.md` | -| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적 | +| `bind-system-plan.md` | pluggable key/value 핸들러 레지스트리 — `process`/`retract` 디스패치 모델, Ref, Store/State/Source 온톨로지 + 인체공학 질문 전부 확정. 디스패치 엔진은 `quad-base`가 인터페이스로 소유(2026-08-04 5차 라운드). **[2026-08-11 세션, 여섯 번째]** `Dispatch.setLength`/`setOffsetSource`의 owner 키가 물리 Instance로 한정될 필요 없음을 명시(Slot-in-Slot 재귀의 근거) — 같은 절 `recompute`의 off-by-one 버그 발견·수정(`offset`이 자기 자신을 포함해 누적되던 것), 재진입 방지 가드는 검토 후 기각(`Source⊇State` 단방향 원칙과 같은 카테고리의 UB로 명명, 각 Slot이 독립 `bk`를 가져 nesting만으로는 재진입 경로 자체가 없음을 확인). **[2026-08-12 열한 번째 세션, 전면 정정]** "핸들러 타입이 안 바뀌면 retract 없이 process가 diff"는 틀렸음 — `retract`는 store 재발행마다(핸들러 타입 무관) 항상 불림, `v`는 대체 값 자체일 수 있어 `nil`로 가정 금지. `Tag`/`Ref`/`Slot`/`Attribute` 전부 이 오류로 설계돼 있었음이 드러나 한 세션에 전부 정정(`archive/retract-always-fires-reversed.md`). **[2026-08-12 세션 후속]** `retractUnder`의 `A and B or C` 삼항 관용구 버그(`v`가 `false`일 때 `nil`로 새던 것)를 `if-then-else`로 수정한 게 계기가 되어 `and`/`or` 삼항 전면 금지 규칙으로 발전(`architecture.md` "코드 스타일" 절). **[2026-08-12 열일곱 번째 세션]** 우선순위 동률/매치 실패 처리(`HANDLER_PRIORITY_*` 상수+디버그 동률 감지, 매치실패는 즉시 error) 확정, `store.key` 레코드 필드 타이핑이 Luau `type function`으로 가능함을 스케치로 확인(`pre-implementation-audit.md` 1-3/1-4/1-10 해소). **[2026-08-12 스무 번째 세션]** Ref 사용 관례 명문화 — React `useRef`급 스코프 감각(만든 컴포넌트 자신이 쓰거나 자식에게 넘기는 용도, 경계 밖 반출·전역 장기 보관은 비권장). **[2026-08-12 스물한 번째 세션]** `:With`가 `Tag`/`Modifier`의 `:` clone 체이닝과 겉보기엔 같은 문법이지만 실제로는 정반대(clone 아니라 매번 새 State 노드)라는 혼동 경고 추가, `Compute`가 `-ed`(`Computed`)가 아닌 이유 절 신설(quad 자기 관례상 `Tag.Added`/`Modifier.Overridden`이 이미 "-ed = clone 후 즉시 확정된 값"을 선점해 lazy한 State에 재사용하면 충돌). **[2026-08-13 세션, 두 번째]** `State>`(store가 emit하는 값 자체가 또 State/Source)가 같은 `(inst,k)`에 같은 핸들러를 중복 push시켜 `retractUnder`의 첫-매치 cutoff가 안쪽 자신을 잘못 retract하는 실제 체인 파손 버그로 확인됨(손 트레이싱, `luau-test/04`가 no-op `retract` 스텁 때문에 이 증상을 못 잡던 사각지대였음도 같이 발견) — `Dispatch.process`에 중복 핸들러 즉시 error 가드 추가, "동일한 재귀적 디스패치로 처리 가능"이라던 낙관적 서술과 "Store가 Store를 저장 가능한가" 절도 정정. **[2026-08-13 세션, 네 번째]** 사각지대 손 트레이싱 라운드에서 `isHandlable` 필드를 선택적으로 허용(생략하면 스캔에 안 걸림)하고, 그런 "체크포인트" 핸들러를 명시적으로 체인에 꽂는 `Dispatch.processAs`/`Dispatch.retractSelfAndUnder`(target 자신 포함 철거) 신설 — `attribute-plan.md`의 그룹/직접쓰기 이름 소유권 충돌을 별도 레지스트리 없이 기존 재진입 가드로 흡수하는 데 씀. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 위 processAs/retractSelfAndUnder 대체]** `chains`를 핸들러 객체 identity가 아니라 **재귀 깊이 인덱스**로 추적하도록 재설계 — `Dispatch.process(inst,k,v,index)`가 핸들러 호출 *전에* 그 인덱스 점유 여부를 체크(핸들러 부작용 낭비 없음), `process`는 이제 `retract` 필드 대신 자기 retract 클로저(`(hintValue)->()`)를 반환. 같은 키 재귀는 `index+1`, 다른 키 위임은 항상 `1`부터 — 이걸로 `State>`가 UB에서 정상 지원 대상으로 재정정됨(각 재귀 단계가 다른 슬롯을 쓰니 identity 충돌 자체가 없어짐), `retractUnder`/`retractSelfAndUnder`도 `Dispatch.retractFrom(inst,k,index,v)` 하나로 통합(자기 포함/미만은 호출자가 넘기는 인덱스로 표현)되며 체크포인트 패턴 자체가 불필요해짐(`archive/checkpoint-handler-pattern-reversed.md`). 계기: `AttributeGroupHandler` 소유권 버그를 체크포인트로 고치다, 그 근본 원인(identity 기반 추적)을 되짚은 사용자 지적. **[2026-08-13 감사]** 위 재설계 의사코드에서 실제 버그 셋 발견·수정 — (1) `chains:SetStrong`이 `handler.process` *뒤*에 있어 최초 마운트에서 하위 위임 retractor가 통째로 유실되던 것(재귀가 자기 테이블을 만들었다 바깥이 덮어씀), (2) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup이 무력화되던 것, (3) `Dispatch.drive`의 진입 인덱스(`1`) 미명시. 덧붙여 retractor 안에서는 *같은* 키에 대한 `retractFrom`도 `process`와 똑같이 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 | | `module-lifecycle-plan.md` | 프로바이더 패턴, bind/store 구현 책임 분리 — 확정 | -| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`이 이미 `SlotHandler.retract`에 있는 것과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/bind-system-plan.md` "Dispatch 체인" 절 참고 | +| `slot-plan.md` | 뮤터블 자식 배열, 엄격한 단일 마운트 소유권, 재마운트 시 throw, base/roblox 패키지 경계까지 확정. **[2026-08-09 세 번째 세션]** `Add`/`Remove`/`Extract`/`Clear`/`Move`/`Swap` CRUD(복잡도 표기 포함), `isMounted` 이중 추적 분리, 요소 타입 제약(`nil`/`None`/핸들러 계층 값 금지, `Slot()` 제네릭), 키 기반 동적 컬렉션 재조정(`Slot:List(data, updateFn, keyFn?)`)까지 전부 확정 통합, base/roblox 경계에 reposition 훅 추가. **[2026-08-09 열한 번째 세션, 중간검토]** CRUD 식별 기준을 element 레퍼런스에서 인덱스 기준으로 재정정(`Remove(index)`/`Extract(index, newElement?)`/`Move(oldIndex, newIndex)`), `ExtractAll`/`Get`/`IndexOf` 신설. **[2026-08-11 세션]** `updateFn(item, index: number, offset: Source, prev: T?, userdata: UD?): (T|nil, UD?)`로 시그니처 확정(`Slot.Offset`도 `Length`처럼 공개 필드로 신설) — `LayoutOrder` 등은 Slot이 자동으로 안 세팅, `index`/`offset` raw 값만 전달하고 실제 반영은 `updateFn`이 "버림/다시 그림/source만 갱신" 세 갈래로 직접 처리(재사용 Source에 미리 `Set` 후 결국 다시 그리면 무의미한 연산이 되므로). **[2026-08-11 세션, 여섯 번째]** `Slot:Single(state, updateFn)` 확정(`:List` 위의 순수 sugar) — Slot-in-Slot 중첩도 확정, 요소 타입 제약에서 `Slot` 배제 해제(`T = Instance | Slot`), `Dispatch.setLength`/`setOffsetSource`를 Slot 자신을 owner 키로 재사용하는 재귀 `attachSlot`(새 프리미티브 없음), 파괴는 재귀 `Clear()` 대신 flat `destroySlotTree`+명시적 `unbindLifetime`. `Slot(initial?: {T})` 생성자 부활(순수 `:Add` sugar) + `_crudUsed`↔`_listed` 상호 배타 가드 신설. `base/bind-system-plan.md`의 `recompute` off-by-one 버그도 이 세션에 같이 수정됨. **[2026-08-11 세션, 일곱 번째]** 반응형 raw 요소(`Slot:Add`가 `State`/`Source`도 받음) 확정 — 새 메커니즘 아니라 `isState(element)`면 내부적으로 `Slot():Single(element)`(nested Slot)를 대신 삽입하는 순수 sugar(최초 검토했던 별도 position-keyed StoreBind 구독 안은 `None`/Length/Move-Swap 문제로 기각). `Slot:Single(state, updateFn?)`도 `updateFn` 선택 인자화(기본값 identity)로 이 sugar를 지지. `:List`의 `reconcile`도 nested-Slot을 반환하는 아이템의 `.Length`만큼 다음 형제 `index`가 건너뛰도록 `pos` 커밋 공식 수정. **[2026-08-12 열두 번째 세션]** 소유권 판정을 위치별 relate 비교에서, Slot 자신이 지금 어느 `inst`에 바인딩됐는지 직접 추적하는 `slotOwner`(slot→inst)로 전환(같은 Slot이 동시에 다른 위치에 마운트되는 경우까지 잡기 위함) — `owner==inst`면 emit 전파로 무시, 다른 inst면 즉시 error. **[2026-08-12 열세 번째 세션]** `slotOwner`/`kSlotMap`이 서로를 강하게 참조하는 두-`Relate` 상호 GC 순환 발견·수정 — 둘 다 `SetWeak`로 낮추고 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일(`attachSlot`에 `bindLifetime(physicalTarget, slot)` 추가, `destroySlotTree`에 짝인 `unbindLifetime` 추가). **[2026-08-12 열네 번째 세션]** 위 순환이 Luau에 ephemeron이 없어 실제로 GC 안 되는 게 공식 문서(luau.org/compatibility)로 확인됨 — "혹시 몰라서"가 아니라 확정된 필수 조치로 격상(`relate-plan.md`에 일반 규칙으로도 승격). **[2026-08-12 열다섯 번째 세션]** `Slot:Splice(index, removeCount, ...newElements)` CRUD 신설(구간 제거+삽입을 shift/recompute 1회로 묶는 순수 최적화, `newElements`는 의도적으로 vararg 유지 — `Tag:Added`의 `string|{string}` 전환과는 다른 이유). **[2026-08-12 열여섯 번째 세션]** `slotOwner`를 top-level/nested 이중 마운트 gap까지 잡는 `elementOwner`로 일반화, `bindLifetime`을 top-level 전용으로 축소(nested는 `_elements` 강참조로 transitively 생존). **[2026-08-13 세션]** `releaseOwner`가 소유권 불일치를 조용히 무시하던 걸 즉시 error로 강화, `bindLifetime`을 `attachSlot`의 조건 분기에서 `SlotHandler.process`(Handler 층위)로 이동해 `unbindLifetime`과 대칭을 맞춤. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `Dispatch`가 핸들러 identity 대신 인덱스로 재추적되며 `SlotHandler.process`가 `retract` 별도 필드 없이 자기 retract 클로저를 반환하는 계약으로 전환 — `kSlotMap`이 완전히 불필요해짐(어느 `process` 호출이 반환한 클로저든 `slotValue`/`inst`를 동일하게 캡처해 대칭적으로 동작하므로), `base/bind-system-plan.md` "Dispatch 체인" 절 참고. **[2026-08-13 감사]** 그 "대칭적으로 동작"이 `claimOwner`의 false가 *같은 (inst,k) 재발행*일 때만 참이었음이 드러나 소유권 판정을 둘로 분리 — nested(`rawAdd`)는 엄격 `claimOwner`(같은 owner 재클레임도 error, `Slot{a,a}`가 조용히 통과하던 것 차단), top-level은 `claimOwnerAt(element,inst,k)`으로 위치까지 봐서 `Frame{slot,slot}`을 error로 잡음. 추가로 `rawRemove`의 `releaseOwner` 누락(산문엔 있고 의사코드엔 없었음)과 `destroySlotTree`가 자식 소유권/`_mounted`를 안 되돌려 GC 타이밍 의존 오류를 내던 것도 수정. `State` 재설정 경로가 안전함(reconcile이 `rawRemove`→`rawAdd` 순서라 release→claim)은 별도 절로 확인 기록 | | `modifier-plan.md` | Modifier는 런타임 plug 아닌 정적 merge, immutable+clone 기반 체이닝 — 메커니즘 확정. **[2026-08-07 다섯 번째 세션 추가]** `:Apply(factory)` 팩토리 체이닝, `Overridden`(구 `Merge`→`Override`, 2026-08-08 세션에서 이름까지 확정) 값 결합+성능 기준, `:Peek`/`isState` 필드 읽기까지 전부 확정(`Peek`/`isState`는 이름만 용어 정리 라운드까지 잠정). **[2026-08-12 열일곱 번째 세션]** `table.clone`이 메타테이블을 참조로 공유한다는 핵심 전제(M7 "클래스별 코드 없이 제네릭 `__index` 하나로 충분" 설계의 근거)가 실제 Luau 동작으로 확인됨(`pre-implementation-audit.md` 1-11 해소). Property에 Attribute식 이름 소유권 레지스트리를 적용하는 안은 검토 후 기각(엔진이 정한 유한 프로퍼티 이름 집합은 전용 키를 못 만들어 소유권 판정 자체가 성립 안 함 — Property가 override 우선순위를 쓰는 이유) | | `purity-and-effects-plan.md` | 컴포넌트 "순수성"이 아니라 "이식성" 문제로 재정의 — 문서 경고 수준으로 확정 | | `component-composition-plan.md` | 컴포넌트=플레인 함수, State/Source 읽기·쓰기 경계, Source가 State를 구조적으로 만족 — modifier/Ref 컴포넌트 경계 통과까지 전부 확정, 남은 건 API 이름뿐. **[2026-08-07 정리]** 폐기된 `StoreSource` 프록시 설계로의 역전 이력은 본문에서 빼고 `archive/store-source-proxy-reversed.md` 포인터로 압축 | @@ -41,7 +41,7 @@ | `effect-plan.md` | **[2026-08-07 신설, 여섯 번째 세션에 확정]** `Effect(fn, state?)` — `state` 없으면 설치 1회+leaf 사망 시 확정 정리, 있으면 내부적으로 `state:Observer(...)`를 조합해 재실행+cleanup 체이닝(React `useEffect` 동형). Observer와의 관계 해소 완료 | | `ui-shorthand-plan.md` | **[2026-08-07 `research/`에서 승격]** `UICorner`/`UIPadding`/`UIScale` 인라인 편의 키 — 이름(v1 `Corner`/`PaddingAll`/`Scale`에서 Modifier 필드명과 안 겹치게 `UI` 프리픽스로 확정)·메커니즘(Handler)·패키지 배치(quad-roblox 코어)·store-bind 가능성까지 전부 확정. 이미지 라운드 트릭(`RoundSize`)은 드롭 — `archive/ui-shorthand-roundsize-dropped.md` 참고. `v=nil`이면 `process` 자신이 만든 자식 제거(`retract` 아님) | | `tag-plan.md` | **[2026-08-08 세 번째 세션 재설계, 2026-08-12 열한 번째 세션 메커니즘 정정]** `Tag(...)` — array-part 값 객체, `Modifier`와 같은 immutable clone 체이닝(`:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`), `CollectionService` 글루만 quad-roblox. `retract`가 이전 Tag가 걸었던 이름을 이름별 참조 카운트 맵에서 빼고(다른 위치가 겹쳐 쓰면 실제 `RemoveTag`는 skip), `process`가 새 Tag의 이름을 등록 — 여러 위치가 같은 이름을 겹쳐 가져도(웹 `className`류 합집합) 안전. 구 해시 파트 boolean 모델은 `archive/tag-hash-key-model-reversed.md`, 구 `assert(v==nil)` 메커니즘은 `archive/retract-always-fires-reversed.md`. **[2026-08-12 열다섯 번째 세션]** `Added`/`Removed`가 vararg가 아니라 `string | {string}`으로 정정 — `table.unpack`이 인자 목록 tail 위치에서만 완전히 펼쳐지는 Lua 문법 제약 때문에 여러 개의 독립된 동적 이름 테이블을 한 vararg 호출로 못 합치는 경우가 생김이 발견됨, `Tag(...)` 생성자 자체는 정적 리터럴 호출이라 vararg 유지. **[2026-08-13 세션]** 참조 카운트 `holders`가 Tag 객체 identity로 키잉돼 있어서 같은 Tag 객체를 여러 위치에서 재사용하면(immutable이라 흔한 관례) 한 위치만 retract돼도 다른 위치가 쓰는 태그가 지워지는 실제 버그 발견·수정 — holders를 위치(`k`) 기준으로 재키잉, `oldv==newv`면 retract 스킵하는 최적화도 추가. **[2026-08-13 세션, 다섯 번째, 전면 반영]** `TagHandler.process`가 자기 retract 클로저를 반환하는 계약으로 전환되며 `kTagMap`(위치별 마지막 Tag)이 완전히 불필요해짐(클로저가 `v`를 직접 캡처) — `tagNameMap`(이름별 위치 집합)만 남음, `base/bind-system-plan.md` "Dispatch 체인" 절 참고 | -| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md` | +| `attribute-plan.md` | **[2026-08-07 여덟 번째 세션 신설]** 단일 키 `[AttributeKey "Name"]`(구 `Attribute`) — `SetAttribute(name, nil)`이 네이티브 지우기라 `None` 센티널과 가장 깔끔하게 맞아떨어짐. **[2026-08-11 아홉 번째 세션]** 여러 Store를 한 번에 attribute로 묶는 그룹 `Attribute(...)` 프리미티브 신설(`Tag`와 동형 array-part 값 객체, `Merged`로 헤테로지니어스 Store 합성), 이름 충돌 방지로 단일 키를 `AttributeKey`로 리네임(잠정). **[같은 세션 후속]** `AttributeKey(name)`이 이름별 weak 캐시로 동등성 보장하도록 확정되며, 그룹 Handler는 자기 완결형 재구현 대신 메모이즈된 키로 기존 단일 키 경로에 재귀 위임하는 걸로 개정(중복 구현 제거). **[2026-08-12 열 번째 세션]** 그룹/직접 쓰기가 같은 이름을 동시에 관리하는 충돌을 막기 위해 그룹은 공개 캐시 대신 `rawNew(name)` 전용 키+소유권 `Relate`로 전환. **[열한 번째 세션]** `retract`가 store 재발행마다 항상 불린다는 정정에 맞춰 `AttributeKeyHandler.retract`를 손봄(이 시점엔 `v==nil` 가드 버전 — 아래 열여섯 번째 세션에서 최종 재정정됨), 그룹의 "남아있는 이름" 위임도 매번 `retractUnder`를 먼저 부르도록 정정(체인 누수 방지). **[2026-08-12 열여섯 번째 세션, 최종 재정정]** `retract`는 완전 no-op으로 굳어짐(`SetAttribute`는 오직 `process(inst,k,nil)`에서만) — Attribute는 명시적 `None`/`nil`로만 지워지고, 그룹 diff나 컴포넌트 언마운트로 이름이 조용히 사라져도 값은 자동으로 안 지워짐(`Ref`의 "Destroy 무관, 정리는 명시적으로" 철학과 통일), 단 사라진 이름의 *구독*은 끊어 자원 누수는 막음 — 위 "v==nil 가드" 버전은 이걸로 폐기. **[2026-08-13 세션, 전면 재정정]** `rawNew`+`owners` 수동 레지스트리 방식이 "그룹이 이름을 놓았다 다시 포함하면 자기 자신과 충돌"하는 실제 버그로 확인됨 — `AttributeGroupKeyHandler`라는 `isHandlable` 없는 순수 체크포인트 핸들러를 `Dispatch.processAs`로 명시 push하고 `Dispatch.retractSelfAndUnder`로 통째 철거하는 방식으로 전면 재설계, 소유권 충돌 감지도 별도 레지스트리 없이 기존 재진입 가드가 대신 잡아줌(`bind-system-plan.md` 참고). `AttributeKeyHandler`는 다시 완전 무상태로 단순화됨. **[2026-08-13 세션, 다섯 번째, 전면 재설계 — 체크포인트조차 불필요해짐]** `Dispatch`가 인덱스 기반으로 재설계되며 `AttributeGroupKeyHandler`/`processAs`/`retractSelfAndUnder`를 전부 걷어냄 — 그룹이 그냥 공개 `AttributeKey(name)`으로 항상 인덱스 1부터 `Dispatch.process`/`retractFrom`을 직접 부르면 끝(점유 체크 자체가 소유권 충돌 감지), `groupState` Relate도 필요 없어짐(반환 클로저가 이름 집합을 직접 캡처) — 중간 버전은 `archive/checkpoint-handler-pattern-reversed.md`. **[2026-08-13 감사, 정정]** 그런데 그 의사코드가 `process` 안에서 이름마다 `retractFrom(...,1,...)`을 먼저 부르고 있어 **인덱스 1이 무조건 비워지는 바람에 점유 체크가 전혀 작동하지 않았음**(그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음) — `process`는 `Dispatch.process`만 부르고 철거는 반환 클로저가 자기가 등록한 이름 전부에 대해 하도록 정정. 그룹 Handler 시그니처가 계약과 안 맞던 것(`process(inst,index,v)` 3-인자)도 같이 수정 | | `onchange-plan.md` | **[2026-08-10 세션 신설]** `OnChange(name)` — `GetPropertyChangedSignal` 바인딩 전용 DI 키, `Attribute`와 달리 제네릭 타입 파라미터 없음(콜백 타입은 인라인 명시, 이벤트 바인딩과 같은 급 트레이드오프). 전부 quad-roblox(`Handlers/OnChange.luau`), `State`은 기존 이벤트 store-bind 메커니즘 재사용. **[2026-08-11 아홉 번째 세션 후속]** `AttributeKey`와 동일한 이름별 weak 캐시로 `OnChange(a) == OnChange(a)` 동등성 보장 | | `relate-plan.md` | **[2026-08-08 신설]** `Relate` — `inst`를 weak 키로 하는 범용 릴레이션 프리미티브(`SetWeak`/`GetWeak`/`SetStrong`/`GetStrong`, 비싱글톤 생성자). 구 `base.perInstanceState(inst)` placeholder를 대체·정식 승격, `lifecycle-pattern.md`의 `bindLifetime`/`canExecute`가 그 위에 얹힘. **[2026-08-12 열세/열네 번째 세션]** 서로 다른 두 `Relate`가 서로의 키를 상대방 값으로 강하게 붙잡는 상호 순환 패턴 경고 신설 — Luau에 ephemeron 테이블이 없어(공식 확인, luau.org/compatibility) 이런 순환은 실제로 GC가 안 됨, `Slot`의 `kSlotMap`/`slotOwner`가 실제 사례이자 수정 사례 | | `tween-plan.md` | **[2026-08-12 세션, `research/`에서 승격]** 값-레벨 `Tween` 래퍼(PropertyHandler가 소비, 구 특수 bind key 모델은 `archive/tween-special-bind-key-reversed.md`). 3-상태 릴레이션 슬롯(`{Tween,Value}\|true\|nil`), `T'=T\|Tween` 타입 치환. 옵션 값 모양은 `Info: TweenInfo?` 우선+편의 필드 폴백, override는 `Tween.Cancel`(기본)/`Tween.Finish` 2값. `Animate(info)`는 `Tween` opts를 `T\|State`로 받아 `:Apply`로 꽂는 sugar. 자연완료 시 per-instance 북키핑은 정리 안 해도 됨으로 확정(목표값 도달 상태라 부작용 없음, Completed 이벤트 구독 장치는 오버엔지니어링으로 판단). `initValue`는 사용자가 직접 처리(에이전트 범위 제외) | @@ -65,7 +65,8 @@ | `framework-comparison-findings.md` | quad vs Fusion/Vide/react-lua 정직한 비교(실 소스 근거) — quad 강점, 못 고치는 트레이드오프(의도된 설계) 정리. **[2026-08-12 열여덟 번째 세션]** "고칠 만한 것" 절에 남아있던 마지막 두 항목(use-after-destroy 검증 안전망 부재, `:With` 동적 의존성 미지원)도 사용자가 "고칠 필요 없음"으로 최종 판단해 3번 절(못 고치는 트레이드오프)로 이전 — 2번 절은 이제 해소된 항목만 남음 | 하 — 배경 자료, 더 이상 사용자 판단 대기 상태 아님 | | `additional-primitives-plan.md` | **[2026-08-09 세 번째 세션, 전부 해소]** 마지막으로 남아있던 키 기반 동적 컬렉션 재조정도 `Slot:List(...)`로 확정되어 `base/slot-plan.md`로 승격 — 이 문서엔 새로 열린 설계 질문 없음, "빈 자리 아닌 것"/"문서화 백로그"/조사 소스 목록만 배경 자료로 유지 | 하 — 배경 리서치 기록용, 열린 결정 없음 | | `pre-implementation-audit.md` | M0 착수 직전 크리티컬 감사(2026-08-06 신설) — `base/` 전체를 모호성/지연결정리스크/단순화후보 세 렌즈로 재검토, 11개 우선순위1 + 11개 우선순위2 + 2개 단순화후보. **[2026-08-12 열일곱 번째 세션]** 우선순위1 11개 전원 해소 — 남은 건 `.claude/luau-test/` 스파이크 실측 확인뿐 | 상 — 설계는 전부 해소, `.claude/luau-test/` 스파이크 실측만 남음 | -| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 | +| `operator-sugar-plan.md` | **[2026-08-12 신설]** `Sum`/`Product`/`Not`/비트연산 등 `:Compute`/`:Apply`용 연산자 콤비네이터 슈가 — 메커니즘은 이미 확정된 계약(`Animate`와 동형 패턴) 재사용이라 확정, 네임스페이스 이름만 미정. **[2026-08-12 열아홉 번째 세션]** 서브 에이전트 외부 리서치로 다른 리액티브 라이브러리 선례와 대조 — `Operator`가 가장 강한 선례(Python `operator` 모듈), `Clamp`/`Min`/`Max`가 추가 후보로 부상, 비트연산·비교연산자·`Sub`/`Div`는 선례 전무로 드랍 후보, Debounce/Throttle은 `Blocker`와 다른 시간 기반 메커니즘이라 별도 질문으로 분리, `Filtered`의 Slot 안/밖 구분 판단이 ReactiveUI/SolidJS 선례로 뒷받침됨 — 최종 이름 결정은 여전히 사용자 몫. **[2026-08-13 세션, 두 번째]** Haskell 비교 리서치 중 `Alternative`(nil 대체값, coalesce류) 후보 신설 — 카탈로그 확정 규칙에 그대로 맞음, 이전엔 없던 게 확인됨 | 하 — 구현은 맨 마지막(순수 슈가, 없어도 무방, 함수 간 의존 없음), 사용자가 직접 후순위 지정 **[2026-08-13 여섯 번째 세션]** `State|T>` → `State` 평탄화 항목 신설(백로그) — `State>`가 정상 동작하게 됐지만 `retractFrom`의 힌트가 직속 1단계에만 가서 깊은 중첩에선 깜빡임 방지가 꺼진다는 게 구체적 동기, 사용자 판단으로 "UB는 아니지만 원치 않는 방향". `Operator.*`가 아니라 `state:Flatten()` 메소드로 제공하는 게 맞아 보이며, **반환 노드가 동적 의존성을 갖는다는 난점**(quad가 의도적으로 비지원하기로 한 바로 그것)이 확정 전 최대 쟁점 | +| `dispatch-redispatch-diff-plan.md` | **[2026-08-13 여섯 번째 세션 신설]** 현행 `hintValue`가 "곧 디스패치될 raw 값"이라 `None` 센티널/`State`/`Tween` 래퍼가 그대로 넘어가 말단 핸들러의 `isX(hint)` 가드를 거짓으로 만들고 깜빡임/재생성 방지를 조용히 끄는 결함을 재현·확인(사용자 제기). **채택 모델**: 래핑 핸들러의 `retractFrom` 선행 호출을 폐기하고 `Dispatch.process` 안에서 **핸들러를 먼저 비교** — 같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` 재호출, 다르면 그 자리부터 전량 철거. 이걸로 힌트 타입이 구조적으로 보장되고 깊은 체인의 힌트 유실도 사라짐(각 레벨이 자기 재프로세스에서 자기 힌트를 받으므로). `oldValue` 전달/`HandlerChanged` 마커는 둘 다 불필요로 판명 (클로저가 이미 old를 캡처, `chains`에 더할 건 비교용 `handler` 하나). **남은 열린 항목은 Attribute 이름 소유권 하나** — `question.md` 0-A | | `v1-compat-plan.md` | v1 하위호환(compat) 레이어 — `quad-roblox-v1-compat` 패키지, v2→v1 단방향 브리지(`state:Observer()`+v1 프로퍼티 재대입), v2-in-v1/v1-in-v2 두 임베딩 방향의 기술 규칙까지 확정. quad2-try의 `quad-compat`은 빈 폴더로 실제 시도된 적 없었음을 확인 | 하 — Slot이 foreign Instance를 어떻게 다루는지만 Slot 코어 구현 시점까지 미결 | ## `archive/` — 완료됐거나 완전히 뒤집힌 것, 능동 참고 불필요 diff --git a/.claude/base/architecture.md b/.claude/base/architecture.md index 3d49227..a15eb51 100644 --- a/.claude/base/architecture.md +++ b/.claude/base/architecture.md @@ -227,7 +227,7 @@ existing-instance-bind는 여전히 `research/`에 남아있고 이 구조 확 표현식 문법(공식 릴리스 노트: ) — `cond and truthyOnly or fallback` 삼항 관용구와 달리 가운데 값이 falsy(`nil`/`false`)여도 정확하게 동작함(`bind-system-plan.md`의 -`Dispatch.retractUnder` 정정 사례가 실제 버그 예시). **[강화, 2026-08-12 +`Dispatch.retractUnder`(현 `retractFrom`) 정정 사례가 실제 버그 예시). **[강화, 2026-08-12 세션 후속] `cond and x or y` 삼항 관용구는 전면 금지 — `if-then-else`만 쓸 것, 가운데 값이 항상-truthy임이 보장돼도 예외 없음.** 처음엔 그 경우만 예외적으로 허용했으나, 안전 여부와 무관하게 `if-then-else`가 diff --git a/.claude/base/attribute-plan.md b/.claude/base/attribute-plan.md index 8f050b7..10e63d5 100644 --- a/.claude/base/attribute-plan.md +++ b/.claude/base/attribute-plan.md @@ -1,5 +1,15 @@ # Attribute — 단일 키(`AttributeKey`)와 그룹(`Attribute(...)`) 두 프리미티브 +> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 +> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 +> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 +> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 +> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 +> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** +> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 +> 구현하면 옛 모델로 짜게 됨** — 반드시 +> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. + **상태**: base — 단일 키 메커니즘/`None`/`retract` 동작과 타입 파라미터화는 전부 확정(2026-08-09 열한 번째 세션, **2026-08-12 세션 후속에서 `retract` 완전 no-op화 + 그룹 청소 정책 전면 재정정 — 아래 "메커니즘"/ @@ -188,9 +198,13 @@ end 이미 그 이름의 인덱스 1을 점유 중이면, 이 직접 쓰기의 `Dispatch.process(inst,key,value,1)`가 그 자리에서 곧바로 점유 error — 그룹↔직접 쓰기 충돌도 같은 점유 체크 하나로 잡힘. -- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.retractFrom`+ - `Dispatch.process` 페어로 위임 — 전용 키 객체도, 소유권 레지스트리도 - 필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1). +- **그룹**은 아래 "메커니즘" 절에서 이름마다 `Dispatch.process`만으로 + 위임하고(철거는 반환 클로저가 전담) — 전용 키 객체도, 소유권 레지스트리도 + 필요 없음(항상 공개 `AttributeKey(name)`, 항상 인덱스 1). **`process`가 + 위임 직전에 `retractFrom`을 부르면 안 된다는 게 이 절이 성립하는 + 전제** — 그러면 점유 여부와 무관하게 인덱스 1이 비워져 아래 점유 + 체크가 무력화됨(2026-08-13 감사에서 실제 그렇게 적혀 있던 걸 정정, + "메커니즘" 절 참고). - **패키지 경계**: `AttributeKey`는 이미 quad-roblox 소속(Tag와 달리 base/roblox로 안 쪼갬, 아래 "패키지 배치" 절) — base쪽 `Attribute(...)` 값 객체 자신은 이 메커니즘을 전혀 모름. @@ -222,9 +236,15 @@ Store 필드 여러 개를 각각 `[AttributeKey<> "name"] = store.name`으 ``` Attribute(store1, store2, ..., {plain = "table도 됨"}) -- 생성자, 여러 개 받음 Attribute.Merged(a, b, ...): Attribute -- Tag.Merged와 동일 이유(헤테로지니어스 합성) +attr:NameMap(): {[string]: Source} -- 평탄화된 이름→Source 맵(아래 "메커니즘" 절이 쓰는 것) Frame { Attribute(styleStore), Attribute(stateStore) } -- 여러 개 나란히 둬도 각자 자기 키만 반영(Tag와 동일) ``` +**[2026-08-13 감사에서 추가] `:NameMap()`은 원래 "메커니즘" 절 의사코드에만 +등장하고 이 API 목록엔 빠져 있었음** — Handler가 이름 집합을 순회하려면 +반드시 필요한 공개 접근자라 여기 명시(`Tag:Names()`가 `tag-plan.md`에서 +같은 이유로 빠져 있던 것과 같은 누락). + `Attribute.Merged`가 내부적으로 하는 일은 각 Store에서 이름 붙은 `Source` 슬롯을 그대로 가져와 자기 자신의 key→Source 맵에 넣는 것 — 아래 "레이어드 Store 기각과 안 부딪히나" 참고. @@ -244,38 +264,63 @@ Dispatch 재진입 없이 직접 `SetAttribute`+수동 per-field StoreBind 구 직접 위임**(경위는 위 "이름 소유권" 절, 원문은 `archive/ checkpoint-handler-pattern-reversed.md`): -**그룹의 `process`** — `(inst, index)`(array-part 위치, `Tag`의 -`relate:GetStrong(inst,k)`와 동일 키잉 — `k`는 배열 인덱스)에서 호출되고, -반환하는 클로저가 "지금 관리 중인 이름 집합"을 직접 캡처 — **별도 `Relate` +**그룹의 `process`** — 다른 모든 핸들러와 똑같은 4-인자 계약 +`process(inst, k, v, index)`를 따름(`k`는 이 그룹 값이 놓인 array-part +위치, `index`는 그 `(inst,k)` 체인 안에서의 재귀 깊이 — **둘은 완전히 +다른 것이라 헷갈리지 말 것**. 2026-08-13 감사 전까지 이 자리 시그니처가 +`process(inst, index, v)` 3-인자로 적혀 있었고 배열 위치를 하필 `index`로 +부르고 있어서, 코퍼스에서 유일하게 계약과 안 맞는 핸들러였음). 반환하는 +클로저가 "이 호출이 등록한 이름 집합"을 직접 캡처 — **별도 `Relate` 불필요**(2026-08-13 다섯 번째 세션, 클로저가 매 호출마다 자기 자신의 이름 집합을 새로 만들어 캡처하므로 사이클을 가로질러 저장해둘 이유가 없어짐): +**[정정, 2026-08-13 감사] `process`는 `retractFrom`을 부르지 않는다 — +철거는 전적으로 반환 클로저 몫.** 최초 작성본은 `process` 안에서 이름마다 +`Dispatch.retractFrom(inst, key, 1, source)`를 먼저 부른 뒤 `process`를 +불렀는데, 이러면 **인덱스 1을 누가 점유했든 무조건 비워버리므로 뒤따르는 +`Dispatch.process`가 점유 error를 낼 수가 없음** — 위 "이름 소유권" 절이 +이 재설계의 핵심 근거로 내세운 "점유 체크가 소유권 충돌 감지를 그대로 +대신함"이 그룹↔그룹 사이에서 전혀 작동하지 않았음(그룹 B가 그룹 A의 +바인딩을 조용히 파괴하고 이김 — 이 절이 없앴다고 선언한 바로 그 +last-write-wins). 클로저가 자기가 등록한 이름 전부를 책임지고 걷어내면 +`process`는 순수하게 `Dispatch.process`만 부르면 되고, 점유 체크가 다시 +살아남: + ```lua -function AttributeGroupHandler.process(inst, index, v) +function AttributeGroupHandler.process(inst, k, v, index) local names = {} for name, source in pairs(v:NameMap()) do -- isHandlable이 이미 isAttribute(v)를 보장 - local key = AttributeKey(name) -- 공개 캐시 그대로 — 그룹 전용 키 불필요 - Dispatch.retractFrom(inst, key, 1, source) -- 그 이름 자리에 이미 뭔가 있으면 통째로 철거(신규는 no-op) - Dispatch.process(inst, key, source, 1) -- 항상 인덱스 1부터 새로 위임 — StoreBind/AttributeKeyHandler는 정상 스캔으로 쌓임 + -- 공개 캐시 키 그대로(그룹 전용 키 불필요), 항상 인덱스 1부터 위임 — + -- 이미 다른 그룹/직접 쓰기가 그 이름을 점유 중이면 여기서 즉시 점유 error + Dispatch.process(inst, AttributeKey(name), source, 1) names[name] = true end - return function(hintValue) - -- hintValue: 다음에 이 자리를 대체할 값(다른 Attribute, nil 등) — Tag/Ref와 같은 힌트 패턴 - local newNames = if isAttribute(hintValue) then hintValue:NameMap() else {} + return function() + -- 이 그룹이 등록했던 이름 전부를 철거 — 생존/소멸 구분 없이 균일. + -- hintValue를 안 봄: 생존 이름도 일단 철거하고 다음 process가 다시 + -- 등록하는 순서라(StoreBind가 retractFrom → process 순서를 보장), + -- "다음 값에 이 이름이 있나"를 미리 알 필요가 없음. for name in pairs(names) do - if newNames[name] == nil then -- 더 이상 이 그룹이 안 쓰는 이름만 - Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙) - end + Dispatch.retractFrom(inst, AttributeKey(name), 1, nil) -- SetAttribute는 안 일어남(아래 원칙) end end end ``` -- **`process`는 매번 살아있는 이름 전부를 `retractFrom`+`process` - 페어로 재위임** — 신규/생존 구분 없이 균일 처리(신규 이름은 아직 체인이 - 없어 `retractFrom`이 그냥 no-op이라 이 페어링을 통일해도 - 비용 없음). **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State +- **생존 이름도 매 사이클 철거→재등록됨(의도된 트레이드오프)** — 비용은 + 그 이름의 `StoreBind` 구독 해제+재구독, 그리고 재구독의 "등록 즉시 1회 + 실행"이 같은 값으로 `SetAttribute`를 한 번 더 쏘는 것뿐. 이 문서가 이미 + "값 비교(`:Get()`으로 old/new 비교)는 안 함"을 확정해뒀으므로(아래 항목) + 결이 같고, `SetAttribute`는 같은 값 재기록이 관측상 무해함. **`Tag`식 + `hintValue == v` 조기 반환을 여기에 넣으면 안 됨** — 클로저가 아무것도 + 안 걷어낸 상태로 다음 `process`가 같은 이름에 다시 인덱스 1을 잡으려 + 들어 자기 자신에게 점유 error를 냄. +- **그룹이 이름을 아예 놓는 경우도 같은 코드로 자연히 처리됨** — 클로저가 + `names` 전부를 걷어내고, 새 `process`가 새 이름 집합만 등록하므로 사라진 + 이름은 그냥 재등록이 안 될 뿐. 별도 diff 분기가 없어짐(이전 버전이 + `newNames`를 계산하던 로직 자체가 불필요). +- **값 비교(`:Get()`으로 old/new 비교)는 안 함** — State 계약("값은 항상 선언된 Compute 재실행 결과, 캐시 비교 금지", `store-semantics.md` "하드 경계" 절)과 어긋나고, `source`가 `State`/`Source`면 `Dispatch/StoreBind`가 알아서 언랩+구독까지 다 @@ -299,14 +344,19 @@ end 그룹과 비교해 사라진 이름을 `None`으로 명시적으로 채워 넣는 유틸)을 나중에 opt-in으로 추가하면 됨 — 그건 사용자가 고른 명시적 선택이라 모호하지 않음, 지금은 범위 밖(백로그). -- **다만 사라진 이름의 *구독*은 끊음 — 값은 안 지워도 자원은 새면 +- **다만 *구독*은 반드시 끊음 — 값은 안 지워도 자원은 새면 안 됨.** 위 "값은 안 지운다" 원칙과 별개로, 그룹이 더 이상 관리하지 않는 이름의 `(inst,key)` 체인을 그대로 두면 그 키에 걸려있던 `StoreBind` 구독이 인스턴스가 살아있는 동안 영원히 남아 원본 `Source`가 바뀔 때마다 계속 `SetAttribute`를 쏘는 실제 리소스 누수가 됨(이건 "마지막 값이 남는다"는 것과 다른 문제 — 안 죽는 구독 자체가 - 문제). 그래서 반환하는 클로저는 사라진 이름에 한해 - `Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`만 부름 — + 문제). 그래서 반환하는 클로저는 **자기가 등록했던 이름 전부**에 대해 + `Dispatch.retractFrom(inst, AttributeKey(name), 1, nil)`을 부름 + (**[정정, 2026-08-13 감사]** 원래는 "사라진 이름에 한해"였으나, 그 + 선별을 하려면 `process` 쪽이 생존 이름을 `retractFrom`으로 강제 + 회수해야 해서 점유 체크가 무력화됐음 — 위 "메커니즘" 절 참고. 전부 + 걷어내고 새 `process`가 다시 등록하는 쪽이 점유 체크를 살리면서 + 코드도 더 단순함) — **`Dispatch.process`는 절대 안 부르므로**(이 클로저 안에서 새 등록을 트리거하는 건 체인 추적을 꼬는 UB, `bind-system-plan.md` 일반 규칙) 그 이름 아래가 전부 자기 자신의 클로저만 타고 끝나 `SetAttribute`는 @@ -319,7 +369,10 @@ end **`AttributeChanged`/`GetAttributeChangedSignal` 남발 걱정 없음** — 키 집합이 안 바뀌는 한 diff 로직 자체가 안 돎. -**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합 diff"뿐** — 실제 +**그룹 Handler에 남는 자기 로직은 사실상 "이름 집합을 순회하며 위임하고, +클로저가 같은 집합을 걷어내는 것"뿐**(**[정정, 2026-08-13 감사]** 예전엔 +"이름 집합 diff"라고 적었으나, 위 재정정으로 diff 자체가 없어짐 — +`process`는 새 집합을 전부 등록, 클로저는 옛 집합을 전부 철거) — 실제 `SetAttribute` 호출/`None` 처리/store-bind 구독은 전부 기존 단일 키 경로가 그대로 담당. `TagHandler`가 `CollectionService` 호출을 직접 하는 것과 달리, 여기서는 그 실행 자체를 위임한다는 점이 다름(Attribute만 diff --git a/.claude/base/bind-system-plan.md b/.claude/base/bind-system-plan.md index 1566cbb..35e4a5e 100644 --- a/.claude/base/bind-system-plan.md +++ b/.claude/base/bind-system-plan.md @@ -1,6 +1,18 @@ # Bind 시스템 — pluggable key/value 핸들러 (base로 승격됨) -**상태**: base — 핵심 디스패치 모델(`process`/`retract`, 핸들러 4종 계약, +> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 +> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 +> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 +> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 +> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 +> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** +> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 +> 구현하면 옛 모델로 짜게 됨** — 반드시 +> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. + +**상태**: base — 핵심 디스패치 모델(`process` + 그가 반환하는 retract +클로저, 핸들러 3종 계약 — 2026-08-13 다섯 번째 세션에 별도 `retract` +필드가 반환값으로 합쳐지기 전엔 `process`/`retract` 4종이었음, Signal 미채택, Ref 역할)과 소스 트리 상 패키지 경계(디스패치 엔진은 `quad-base`가 인터페이스로 소유, `quad-roblox`는 실제 구현만)까지 전부 2026-08-04 세션에서 확정되어 `research/`에서 승격됨(`base/architecture.md`의 @@ -227,8 +239,14 @@ retract 클로저를 반환하는 1-메소드 계약으로 합쳐짐 — 이 절 이 클로저는 오직 청소(구조적 팝, 내부 자원 해제)만 전담하고 새 등록을 트리거하면 안 됨 — 새 등록은 항상 바깥의 StoreBind/그룹 로직이 클로저 호출이 다 끝난 *뒤에* 별도로 `process`를 부르는 - 순서로만 일어나야 함. `Dispatch.retractFrom`을 (다른 키에 대해) - 이 클로저 안에서 부르는 건 문제없음 — 금지되는 건 오직 `process`. + 순서로만 일어나야 함. **`Dispatch.retractFrom`은 "다른 키에 대해서만" + 허용** — `Attribute` 그룹이 자기가 위임했던 `AttributeKey(name)`들을 + 걷어내는 게 정확히 이 경우. **같은 `(inst,k)`에 대해 이 클로저 안에서 + `retractFrom`을 부르는 것도 `process`와 똑같이 금지(UB)** — 지금 + 돌고 있는 바깥 `retractFrom`의 루프가 `#list`를 이미 캡처한 채 + 꼬리부터 내려오는 중이라, 그 도중에 같은 list를 다시 훑으면 같은 + retractor가 두 번 불리거나 건너뛰어짐(2026-08-13 감사에서 명시화 — + 원래는 "다른 키에 대해"라는 괄호로만 암시돼 있었음). - **자기 자신의 하위 위임까지 클로저 안에서 수동으로 다시 정리할 필요 없음** — 위 "핸들러 계약" 절 참고, `Dispatch.retractFrom`의 순회 구조 자체가 항상 깊은 인덱스부터 정리하고 나서 얕은 인덱스로 @@ -362,7 +380,15 @@ end 뮤테이션해서 결과를 낸다"는 어감이라 기각 — 사용자 판단). `inst`와 flatten된 props 테이블을 받아 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중으로 두 패스 순회하며 각 `(k,v)`에 - `Dispatch.process`를 호출하는 게 이 함수의 본체. + `Dispatch.process(inst, k, v, 1)`을 호출하는 게 이 함수의 본체. + **진입 인덱스는 항상 `1`**(2026-08-13 감사에서 명시화 — 인덱스 도입 + 후에도 이 자리만 인자가 안 적혀 있었음) — `drive`는 그 키의 체인을 + 처음 여는 자리이므로 "다른 키로 위임할 때는 그 키의 재귀 깊이와 + 무관하게 항상 1부터"(아래 "Dispatch 체인" 절)라는 규칙의 가장 기본 + 사례. 같은 키가 두 번 나올 수 없는 테이블 순회라 여기서 점유 충돌이 + 날 일도 없음(단, 그룹 `Attribute`가 배열 파트에서 먼저 점유해둔 + 이름을 해시 파트 직접 쓰기가 다시 건드리면 그건 정상적으로 점유 + error — `base/attribute-plan.md` "이름 소유권" 절). - **`v=nil`이 구체적으로 뭘 뜻하는지는 핸들러마다 다름, `None` 자신은 "리셋"이 아님** — 일반 프로퍼티는 "`nil`로 셋하는 것도 그냥 셋 동작"이라 사실상 그대로 두는 것과 다름없고, UICorner 같은 숏핸드 핸들러는 만들어둔 @@ -472,15 +498,29 @@ Observer 구독)와, A가 재귀로 위임한 핸들러 B의 생명주기가 ** ```lua -- Dispatch/init.luau local chains = Relate() -- {[inst(weak)] = {[k] = {[index] = retractor}(strong)}} +local NOOP = function() end function Dispatch.process(inst, k, v, index) - local list = chains:GetStrong(inst, k) or {} + -- [순서 주의, 2026-08-13 감사] 아래 두 줄(list 확보 + chains 등록)은 반드시 + -- h.process 호출 *전에* 끝나야 함 — h.process가 내부에서 재귀 + -- Dispatch.process(inst,k,...,index+1)를 부르는 게 정상 경로이고(StoreBind/ + -- NoneHandler), 그때 chains에 이 list가 아직 안 들어가 있으면 재귀 호출이 + -- `or {}`로 자기만의 새 테이블을 만들어 저장해버린 뒤 바깥이 그걸 덮어써서 + -- 하위 위임 retractor가 통째로 유실됨(최초 마운트에서 항상 발생). + local list = chains:GetStrong(inst, k) + if not list then + list = {} + chains:SetStrong(inst, k, list) + end if list[index] ~= nil then error("Dispatch: (inst,k)의 이 인덱스는 이미 점유돼 있음 — 먼저 retract할 것") end + -- 점유 마커를 먼저 박는 이유: (1) h.process가 재귀하는 동안 list가 구멍 + -- 없는 시퀀스로 유지돼야 `#list`가 정의됨(hole 있는 테이블의 `#`는 Lua가 + -- 보장 안 함), (2) 핸들러가 같은 index로 재진입하는 버그도 이 가드에 걸림. + list[index] = NOOP local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존 규칙대로 즉시 error list[index] = h.process(inst, k, v, index) -- 반환값 = retractor 클로저, 생략 불가 - chains:SetStrong(inst, k, list) end function Dispatch.retractFrom(inst, k, index, v) @@ -551,6 +591,37 @@ end 엔진 `RemoveTag` 호출만 skip한다 — 상세는 `base/tag-plan.md` "메커니즘" 절 참고. `hintValue`는 "계약상 항상 주어지지만 안 쓰는 핸들러가 있어도 됨" 정도로 이해할 것. +- **`hintValue`는 "직속 위임 1단계"에만 보장됨 — 깊은 인덱스는 항상 `nil` + (2026-08-13 여섯 번째 세션 명시화).** 위 `retractFrom` 의사코드의 + `retractor(if i == index then v else nil)`이 그대로 계약임: 힌트를 받는 + 건 **호출자가 지목한 `index` 자리 하나뿐**이고, 그보다 깊은(꼬리 쪽) + 인덱스들은 전부 `nil`을 받음. 결과: + - **`State`/`State`/`State`(한 겹)은 힌트가 확실히 + 전달됨** — StoreBind가 인덱스 1, 실제 핸들러가 인덱스 2이고 + StoreBind는 `retractFrom(inst,k,index+1=2,realv)`를 부르므로 + `i == index`가 정확히 그 핸들러에 걸림. 즉 `Tag`의 `Contains` 힌트, + `Ref`/`Slot`의 identity 비교 같은 깜빡임/재생성 방지가 **흔한 + 경로에서는 유실 없이 동작**함. + - **두 겹 이상(`State>` 등)에서 바깥이 재발행하면 안쪽 + 핸들러는 `nil` 힌트를 받음** — 구조적으로 불가피(바깥 단계는 안쪽 + State가 결국 어떤 값을 내놓을지 모름). 동작은 정상이지만 힌트 기반 + 최적화만 꺼짐. + - **[검토 후 기각] `chains`에 핸들러도 같이 저장해두고, 깊은 인덱스의 + 핸들러가 새 값에 매치될 핸들러와 같으면 값을 한 겹 풀어 힌트로 + 내려보내는 안**(사용자 제시). 두 가지 이유로 기각: (1) `process`와 + retractor 호출이 **1:1이 아님** — 전체 철거(`retractFrom(inst,k,1,nil)`) + 처럼 뒤따르는 `process`가 아예 없는 경로가 정상적으로 존재해서, 그 + 경우엔 애초에 내려보낼 "새 값"이 없음(사용자 스스로 지적한 한계). + (2) 더 근본적으로, 값을 한 겹 풀려면 teardown 도중에 + `innerState:Get()`을 **투기적으로** 호출해야 하는데 — State는 + pull-recompute 계약(`base/store-semantics.md`)이라 이 호출이 실제 + 재계산을 유발할 수 있고, 곧이어 `process`가 다시 `:Get()`을 부르면 + 같은 사이클에 이중 계산이 됨. 또 그렇게 얻은 값은 어디까지나 추정이라 + 실제 `process` 시점의 값과 다를 수 있음("값 비교/캐싱 금지" 원칙과 + 같은 결). **대신 채택할 방향은 값 층에서의 평탄화**(`State>` + → `State` 콤비네이터, `research/operator-sugar-plan.md` 백로그) — + 체인을 한 겹으로 유지하면 위 첫 번째 항목의 보장이 그대로 적용되므로 + Dispatch 층에 특수 배관을 넣을 이유가 없어짐. - **순환은 UB, 방어 로직 없음** — Handler 간 순환 참조(A가 B를 부르고 B가 다시 A로 돌아오는 것, 또는 값 자체가 결국 자기 자신을 가리켜 무한히 깊어지는 인덱스)는 재귀 호출이 안 끝나 바로 스택오버플로가 @@ -582,6 +653,73 @@ end 무엇에 연결됐는가" 그래프도 이 `chains` 구조를 그대로 읽으면 됨 — quad-debug 착수 시점에 새로 설계할 필요 없음. +### Handler 작성 체크리스트 — 새 모델에서 실제로 반복된 실수들 (2026-08-13 여섯 번째 세션 신설) + +**왜 이 절이 있는가**: 인덱스 기반 재설계 직후 작성된 의사코드 +(`Dispatch` 자신, `Ref`, `Tag`, `Slot`, `Attribute`)에서 **같은 세션 +안에 버그 4건**이 나왔고, 그중 셋이 서로 다른 문서에 있으면서도 +**같은 종류의 착각**에서 나왔음. 새 Handler를 짜거나 기존 걸 고칠 때 +이 목록을 먼저 훑을 것 — 전부 "그럴듯해 보이는데 틀린" 것들이라 +리뷰로 잡기 어렵다. + +**1. 클로저는 early-return해도 체인에서 *소비*된다.** +`Dispatch.retractFrom`은 저장된 retractor를 호출하고 **항상** +`list[i] = nil`로 지움 — 그 클로저가 `hintValue == v`로 아무 일도 안 하고 +돌아왔더라도 마찬가지. 그러므로: +- **매 `process` 호출은 "이 자리를 무르는 책임"을 온전히 새로 짊어진 + 클로저를 반환해야 한다.** "이번엔 내가 실제로 한 일이 없으니 no-op을 + 돌려주자"는 거의 항상 버그 — 다음 사이클에 진짜 교체가 올 때 정리할 + 주체가 사라짐. (`SlotHandler`에서 실제로 이 함정에 빠졌었음.) +- 반대로 "아무 일도 안 했으니 무를 것도 없다"가 **진짜로** 맞으려면, + 그 자리가 무를 자원을 애초에 아무도 안 갖고 있어야 함(일반 + PropertyHandler처럼). + +**2. `process`가 재귀 위임 전에 `retractFrom`을 부르는 건 *같은 키에 +자기 아래*일 때만이다.** +`StoreBind`가 `retractFrom(inst,k,index+1,realv)`를 부르는 건 자기가 +직전 사이클에 만든 하위 체인을 자기가 치우는 것 — 정당함. 반면 **다른 +키로 위임하면서 그 키의 인덱스 1을 미리 `retractFrom`으로 비우는 것은 +거의 항상 버그**: 그 자리를 누가 점유했든 지워버리므로 +`Dispatch.process`의 점유 체크(= 소유권 충돌 감지)가 통째로 무력화됨. +`AttributeGroupHandler`가 정확히 이걸로 그룹↔그룹 충돌을 조용히 +넘기고 있었음. **다른 키의 정리는 그 키를 등록했던 클로저가 한다.** + +**3. `hintValue`는 `nil`이 아니고, 타입도 보장 안 되고, 깊은 인덱스엔 +안 온다.** 셋 다 별개의 함정: +- `nil`이라고 가정하고 `assert(v == nil)`을 쓰면 안 됨(이미 한 번 전면 + 정정된 이력, `archive/retract-always-fires-reversed.md`). +- 내용(메소드/필드)을 보려면 `isTag(hintValue)` 같은 가드부터 — + 그 자리 핸들러 *타입*이 바뀌는 경우 엉뚱한 값이 옴. +- 깊이 2 이상에는 `nil`이 옴(위 "`hintValue`는 직속 위임 1단계에만 + 보장됨" 항목) — 힌트가 있으면 최적화, 없으면 정직하게 전부 정리하는 + 코드여야 함. 힌트를 **정확성**의 근거로 삼으면 안 됨. + +**4. "이전 값"을 알고 싶으면 클로저 캡처, "여러 위치/사이클을 가로지르는 +누적 상태"만 `Relate`.** 이 경계를 헷갈리면 양방향으로 틀림: +- 불필요한 `Relate`: `process`가 만든 걸 그 클로저가 정리하는 단발성 + handoff는 upvalue 캡처로 끝(옛 `kSlotMap`/`kTagMap`이 이걸로 삭제됨). +- 부족한 `Relate`: `Tag`의 `tagNameMap`(여러 위치가 한 이름을 공유), + `Ref`의 spurious 재바인딩 dedup처럼 **자기 클로저 수명 밖의 정보**는 + 캡처로 대체 불가. +- 그리고 `Relate`에 쓴 걸 클로저에서 지울 땐 **"내가 실제로 물러날 + 때만"** 지울 것 — 조건 밖에서 무조건 지우면 dedup이 무력화됨 + (`RefLeafHandler`가 정확히 이 버그였음). + +**5. `Dispatch`를 통해서만 진입한다.** +`handler.process(...)`를 직접 부르면 점유 체크와 `chains` 기록이 통째로 +빠져 나중에 정리가 안 되거나 정합성이 깨짐(위 "Dispatch 체인" 절). +마찬가지로 클로저 안에서는 `Dispatch.process` 금지, **같은 키**에 대한 +`Dispatch.retractFrom`도 금지(진행 중인 루프가 `#list`를 이미 캡처). + +**6. 인덱스는 "같은 키 안의 재귀 깊이"다.** +같은 키로 재귀하면 `index + 1`, **다른 키로 위임하면 그 키에서 다시 +`1`부터**, `Dispatch.drive`의 최초 진입도 `1`. 배열 파트의 위치(`k`)와 +이 `index`는 완전히 다른 것 — `AttributeGroupHandler`가 배열 위치를 +`index`라고 이름 붙였다가 시그니처 자체가 계약과 어긋난 전례가 있음. + +**7. 반환 생략 금지.** 정리할 게 없어도 `function() end`. `nil`을 +반환하면 `retractFrom`이 `attempt to call a nil value`로 크래시. + ### Length/Offset — 여러 Slot이 형제로 섞일 때 순서 보장 (2026-08-09 여섯 번째 세션) **문제(`base/slot-plan.md`의 "여러 Slot이 섞일 때 순서 보장" 열린 질문, @@ -654,6 +792,19 @@ Dispatch.setOffsetSource(inst, i, offset: Source | None) "`nil`/`None`이면 `0`" 규칙과 항상 같이 감, 둘 중 하나만 반영되면 길이 합계와 실제 순서 계산이 어긋남). +**해제(그 자리가 더 이상 기여하지 않게 될 때)는 `setOffsetSource(...,None)` +→ `setLength(...,0)` 순서로 (2026-08-13 여섯 번째 세션, 사용자 지적).** +별도 unregister API는 없고 `0`/`None` 재등록이 곧 해제인데, **순서가 +반대면 위험함**: `setLength`가 끝에서 `recompute`를 돌리므로, 먼저 +부르면 그 `recompute`가 아직 남아있는 옛 `Source`(지금 막 떼어내는 +서브트리의 것)에 `:Set()`을 날려 죽는 중인 다운스트림을 헛되이 +캐스케이드시킴. `setOffsetSource(None)`을 먼저 하면 아래 `recompute`의 +`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. 값이 +틀려지는 문제는 아니지만(자기 length가 줄어도 자기 offset은 그대로라 +갱신될 일 자체가 없음) **invalid한 Source가 순회 대상에 남아있는 것 +자체가 위험**하므로 순서를 계약으로 고정. 상세·부수 방어 조치는 +`base/slot-plan.md`의 "구현상 바뀌어야 하는 것" 절 참고. + **둘 다 array part의 모든 number 인덱스에 대해 반드시 호출 — 생략은 UB (2026-08-09 여섯 번째 세션 확정).** `retract` 필드 생략 불가와 같은 톤 — 이건 **Handler 구현체 작성자만 지키는 계약**이고 일반 컴포넌트 작성자는 @@ -726,8 +877,12 @@ local function recompute(ownerKey, bk) for i = 1, bk.N do local offset = bk.sourceList[i] -- offset은 실제 Source이거나 None(참여 안 함) — None은 truthy라 - -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함 - if offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set + -- `if offset then`만으로는 안 걸러짐, 명시적으로 배제해야 함. + -- [방어, 2026-08-13 여섯 번째 세션] `nil`도 같이 배제 — 정상 + -- 상태에선 항상 None으로 채워지는 게 계약이지만(위 "None을 쓰는 + -- 이유"), 해제/재마운트가 얽히는 전이 구간에서 `nil`이 관측돼도 + -- 크래시 대신 skip이어야 함. 등록 쪽의 "반드시 None" 의무는 그대로. + if offset ~= nil and offset ~= None and offset:Get() ~= sum then -- 실제로 다를 때만 Set offset:Set(sum) end local v = bk.lengthList[i] @@ -1193,8 +1348,13 @@ function RefLeafHandler.process(inst, k, v, index) if hintValue ~= v then v:Set(nil) -- 매 :Set()마다 콜백 재통지되는 기존 Ref 규칙(위 "해소됨 — -- 반복 재설정 가능" 항목)을 그대로 재사용, 새 알림 경로 아님 + -- [정정, 2026-08-13 감사] relate 정리는 반드시 이 분기 *안*에 있어야 + -- 함 — 밖에 두면 spurious 재발행(hintValue == v)에서도 기록이 + -- 지워져, 곧바로 이어지는 process가 `old ~= v`를 항상 참으로 보고 + -- `v:Set(inst)`를 재실행함(콜백 헛 재통지). 즉 아래 dedup 항목이 + -- 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. + if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end end - if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end end end ``` @@ -1516,9 +1676,11 @@ SyntheticEvent만 주는 것과 같은 모양). `Dispatch.process(inst,k,realv)`를 재귀 호출) + "재실행 래핑이 `retract`도 같이 호출한다"(Slot이 이미 이 조합을 씀, 같은 절)는 두 메커니즘이 이미 있음. 이벤트 핸들러가 할 일은 딱 하나: `process`에서 `:Connect()`한 Connection을 -per-instance 저장소에 기억해두고, `retract`에서 그걸 `:Disconnect()`하는 -것 — 새 디스패치 메커니즘 발명 필요 없이 기존 4종 계약(`isHandlable`/ -`priority`/`process`/`retract`)만 제대로 구현하면 됨. +`process`의 로컬 변수로 들고, 반환하는 retract 클로저가 그걸 upvalue로 +캡처해 `:Disconnect()`하는 것 — 새 디스패치 메커니즘 발명 필요 없이 기존 +계약(`isHandlable`/`priority`/`process`)만 제대로 구현하면 됨(**[2026-08-13 +다섯 번째 세션]** 예전엔 별도 `retract` 필드 + per-instance `Relate` +저장소였으나, 클로저 캡처로 저장소 자체가 불필요해짐). **`false`로 disconnect, `nil` 아님.** `nil`은 Lua 테이블에서 "키가 아예 없음"과 구별이 안 됨(`pairs`에서도 안 보임) — "명시적으로 꺼짐"이라는 diff --git a/.claude/base/component-composition-plan.md b/.claude/base/component-composition-plan.md index 3457d0e..791b9d4 100644 --- a/.claude/base/component-composition-plan.md +++ b/.claude/base/component-composition-plan.md @@ -78,7 +78,9 @@ Svelte `Writable extends Readable`와 같은 모양), `store.key`는 Store 핸들러는 `State` 하나만 받아도 Source 인스턴스가 서브타입 호환으로 자동 통과된다(`Source | State` 유니온 불필요, `isHandlable`/ -`priority`/`process`/`retract` 4종 계약에 5번째 항목 추가 불필요). 런타임에 +`priority`/`process` 3종 계약에 항목 추가 불필요 — 2026-08-13 다섯 번째 +세션에 `retract`가 `process` 반환값으로 합쳐지기 전엔 4종이라 적혀 +있었음). 런타임에 "이게 Source면 역방향 쓰기까지 걸고 싶다"처럼 구분하고 싶은 경우는 `isSource`류 판별자로(`isObserver`와 동일한 패턴). diff --git a/.claude/base/lifecycle-pattern.md b/.claude/base/lifecycle-pattern.md index 98a726e..f8c42af 100644 --- a/.claude/base/lifecycle-pattern.md +++ b/.claude/base/lifecycle-pattern.md @@ -309,3 +309,12 @@ Roblox 엔진 자체가 Destroy 시 Tag/Attribute/실행 중인 Tween을 전부 철회한다"는 의미로 가장 정확 — `process`/`retract` 쌍으로 자연스럽게 대구를 이룸.) 대부분의 문서에서 이 이름으로 갱신됨 — 잔여 "cleanup" 표기가 남은 문서가 있을 수 있으며, 그 확인/정리는 진행 중. + +**[2026-08-13 다섯 번째 세션] `retract`는 더 이상 Handler의 *필드*가 +아니라 `process`가 반환하는 클로저의 *역할 이름*이다.** 개념/이름 +자체는 그대로 유효하고(이 절의 결론은 안 바뀜), 다만 코드에서 +`handler.retract(...)`를 찾으면 안 됨 — `local retractor = +handler.process(inst,k,v,index)` 형태로 받아서 `Dispatch`가 `chains`에 +보관했다가 부름(`base/bind-system-plan.md` "핸들러 계약"/"Dispatch 체인" +절). 이 문서가 계속 쓰는 "retract 시점"/"retract가 불린다"는 표현은 +전부 그 클로저가 호출되는 시점을 가리킴. diff --git a/.claude/base/module-lifecycle-plan.md b/.claude/base/module-lifecycle-plan.md index 4a37f17..dc75470 100644 --- a/.claude/base/module-lifecycle-plan.md +++ b/.claude/base/module-lifecycle-plan.md @@ -102,9 +102,11 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 - ~~넘버 바인드/프로바이더 인터페이스의 정확한 함수 시그니처(base가 요구하는 provider 인터페이스 계약)는 아직 미정~~ **[해소됨]** — 그 "provider 인터페이스"가 곧 Handler 계약: `isHandlable(inst,key,value)`/ - `priority`/`process(inst,key,value)`/`retract(inst,key,value)` 4종, - `retract`는 no-op이라도 필드 생략 불가까지 확정. `base/bind-system-plan.md` - "핸들러 계약" 절. + `priority`/`process(inst,key,value,index)` **3종**(**[정정, 2026-08-13 + 다섯 번째 세션]** 원래 별도 `retract(inst,key,value)` 필드가 있던 4종 + 계약이었으나, `process`가 자기 retract 클로저 `(hintValue) -> ()`를 + 반환하는 1-메소드로 합쳐짐), 정리할 게 없어도 `function() end` + 반환 생략 불가까지 확정. `base/bind-system-plan.md` "핸들러 계약" 절. - ~~**네이밍 미정(2026-08-04 보강)**: "프로바이더"라고 불러온 개념을 정확히 뭐라고 부를지("provider" vs "processor" vs 그냥 "plug") 아직 안 정함~~ **[해소됨]** — **`Handler`로 확정**, 위 항목이 가리키는 계약의 정식 이름. @@ -116,8 +118,8 @@ init하려 하면 오류, 없는데 뭔가 생성해서 bind하려 해도 오류 늬앙스인데 Handler는 실제로 값을 공급하는 게 아니라 처리/반응하는 쪽이라 의미가 안 맞고 React `Context.Provider`류 맥락(context) 패턴과도 헷갈릴 수 있음, `Plug`는 "동적으로 꽂힌다"는 어감은 맞지만 "값을 - 처리한다"는 의미가 빠져 있음 — `Handler`가 계약 4종 - (`isHandlable`/`priority`/`process`/`retract`) 전체를 가장 정확히 + 처리한다"는 의미가 빠져 있음 — `Handler`가 계약 전체 + (`isHandlable`/`priority`/`process`, 위 항목 참고)를 가장 정확히 담는다는 결론. - **[해소됨, 2026-08-12 열일곱 번째 세션]** provider(팩토리)가 아직 한 번도 실행 안 된 상태에서 dispatch가 호출되면 어떻게 되는지 diff --git a/.claude/base/onchange-plan.md b/.claude/base/onchange-plan.md index 7639f47..fc4bf61 100644 --- a/.claude/base/onchange-plan.md +++ b/.claude/base/onchange-plan.md @@ -43,14 +43,19 @@ `GetPropertyChangedSignal` 자체가 Roblox 엔진 API라 base에 둘 이유가 없음 — Tag처럼 백엔드 무관한 값/API 레이어가 따로 있는 경우와 다름. -- **`process(inst,k,v)`**: `inst:GetPropertyChangedSignal(name):Connect(function() - v(inst[name]) end)`. **`retract(inst,k,v)`**: 그 Connection을 - `:Disconnect()`. 일반 `Handlers/Event.luau`와 같은 결(Connection - 관리뿐, 새 메커니즘 없음). +- **`process(inst,k,v,index)`**: `inst:GetPropertyChangedSignal(name):Connect( + function() v(inst[name]) end)` 후 **그 Connection을 `:Disconnect()`하는 + 클로저를 반환**. 일반 `Handlers/Event.luau`와 같은 결(Connection + 관리뿐, 새 메커니즘 없음). **[정정, 2026-08-13 다섯 번째 세션]** 원래는 + 별도 `retract(inst,k,v)` 필드가 Disconnect를 담당한다고 적혀 있었으나, + Handler 계약이 `process` 1-메소드로 합쳐지며 그 로직이 반환 클로저로 + 이동 — `connection`은 `process`의 로컬 변수를 클로저가 upvalue로 그대로 + 캡처하므로 별도 `Relate` 저장/재조회가 필요 없음(`bind-system-plan.md` + "핸들러 내부 상태 저장" 절). - **`State` 지원 — 새 메커니즘 없음.** 이미 확정된 "이벤트도 store-bind 가능 — `false`로 disconnect" 메커니즘(`bind-system-plan.md`)이 - `OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`/`retract`만 - 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 + `OnChange` 키에도 그대로 적용됨 — `OnChangeHandler`는 `process`(와 그 + 반환 클로저)만 구현하면 되고, `v`가 State/Source면 범용 `Dispatch/StoreBind.luau`가 알아서 언랩+재귀 재-dispatch해서 `process`를 다시 호출해줌. `OnChange` 전용 분기 불필요. - **`OnChange(name)`도 `AttributeKey`와 같은 이름별 weak 캐시 적용 diff --git a/.claude/base/relate-plan.md b/.claude/base/relate-plan.md index 9800c5b..ecb94bd 100644 --- a/.claude/base/relate-plan.md +++ b/.claude/base/relate-plan.md @@ -62,7 +62,12 @@ weak여야 함 — 그런데 그 안에 담기는 값은 경우에 따라 **강 `SetWeak`로 낮추고, 실제 GC 앵커는 `bindLifetime`/`unbindLifetime` 하나로 통일**할 것(구체 사례는 `base/slot-plan.md` "Slot과 Store 바인드의 관계" 절 참고 — `kSlotMap`(inst→slot)/`slotOwner`(slot→inst)가 -정확히 이 패턴이었음). +정확히 이 패턴이었음. **[2026-08-13 다섯 번째 세션 이후]** 그 두 `Relate`는 +지금은 존재하지 않음 — `kSlotMap`은 Handler 계약이 클로저 반환으로 바뀌며 +불필요해져 삭제됐고 `slotOwner`는 `elementOwner`(전부 `SetWeak`)로 +일반화됐으므로, Slot 쪽엔 이 위험 패턴이 더 이상 남아있지 않음. 이 절은 +**일반 규칙**으로 계속 유효하고, Slot은 그 규칙이 실제로 적용됐던 +역사적 사례로만 인용). ## API (확정) @@ -86,6 +91,45 @@ relate:GetWeak(inst: any, key: any): any? 간에 겹칠 걱정이 없음(모듈 하나가 감당할 key 개수는 보통 한두 개뿐이라 `Relate()`를 여러 개 만드는 비용은 무시할 만함). +## 언제 `Relate`를 쓰고 언제 쓰면 안 되는가 — 체크리스트 (2026-08-13 여섯 번째 세션 신설) + +Handler 계약이 "`process`가 자기 retract 클로저를 반환"으로 바뀌면서 +(`base/bind-system-plan.md` "핸들러 계약" 절) **`Relate`가 필요한 범위가 +크게 줄었음** — 이 전환기에 양방향으로 실수가 나왔어서 기준을 못박아 둠. + +**쓰지 말 것 — 클로저 캡처로 충분한 경우**: 이 `process` 호출이 만든 +것을 **그 호출이 반환한 클로저가** 나중에 정리하는 단발성 handoff. +`local observer = ...` / `local connection = ...`를 로컬로 두고 반환 +클로저가 upvalue로 캡처하면 끝 — `relate:SetStrong(inst,k,x)` 후 +`relate:GetStrong(inst,k)`로 되찾아오는 왕복이 통째로 불필요. +2026-08-13 다섯 번째 세션에 `kSlotMap`(위치별 마지막 Slot), +`kTagMap`(위치별 마지막 Tag), Attribute의 `groupState`가 전부 이 이유로 +삭제됨. 새로 `Relate`를 하나 만들고 싶어지면 **먼저 "이거 그냥 클로저가 +캡처하면 되는 것 아닌가?"를 물어볼 것.** + +**써야 할 것 — 하나의 클로저 수명을 넘어서는 상태**: +- **여러 위치가 하나의 자원을 공유**할 때의 참조 카운트 — `Tag`의 + `tagNameMap`(이름 → 그 이름을 걸고 있는 위치 집합). +- **여러 `process` 호출을 가로지르는 dedup 기록** — `Ref`의 "이 자리에 + 마지막으로 바인딩한 Ref"(클로저의 `hintValue`는 *다음* 값이지 *이전* + 값이 아니라서 캡처로 대체 불가). +- **소유권/멤버십 전역 판정** — `Slot`의 `elementOwner`. +- **"언제까지 실행돼도 되는가"** — `bindLifetime`/`canExecute` + (`base/lifecycle-pattern.md`). 애초에 클로저 수명과 무관한 질문. + +**쓸 때 같이 지킬 것**: +- **정리 조건을 실제 정리와 묶을 것.** `Relate` 엔트리를 지우는 코드가 + "실제로 물러날 때"라는 조건 **밖**에 있으면, spurious 재발행에서 + 기록만 날아가 dedup이 조용히 무력화됨 — `RefLeafHandler`가 실제로 이 + 버그를 냈음(`bind-system-plan.md` "`Ref`의 retract" 절). +- **weak하다고 "언젠간 알아서 사라진다"에 기대지 말 것.** 값이 `SetWeak` + 이어도 **언제 사라지는지는 GC 타이밍**이라, 그 전에 같은 키를 다시 + 쓰려는 코드가 비결정적으로 실패함 — `Slot`의 `destroySlotTree`가 자식 + 소유권을 명시적으로 반납하지 않고 GC에 맡기고 있던 게 이 사례 + (`base/slot-plan.md` "요소 소유권" 절). **명시적으로 만든 기록은 + 명시적으로 지울 것.** +- 서로 다른 두 `Relate`의 상호 강참조 순환 금지 — 위 "위험한 패턴" 절. + ## 실제 구조 (확정, 2026-08-08 세션) ``` diff --git a/.claude/base/slot-plan.md b/.claude/base/slot-plan.md index c6c49cd..b487fea 100644 --- a/.claude/base/slot-plan.md +++ b/.claude/base/slot-plan.md @@ -1,5 +1,15 @@ # Slot — 뮤터블 자식 배열, 엄격한 단일 마운트 소유권 (base로 승격됨) +> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 +> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 +> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 +> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 +> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 +> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** +> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 +> 구현하면 옛 모델로 짜게 됨** — 반드시 +> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. + **상태**: base — 설계 방향(소유권 귀속, 재마운트 시 throw, retract=폐기)과 소스 트리 상 패키지 경계까지 확정되어 `research/`에서 승격됨(`base/ architecture.md`의 "구현 착수: 소스 트리 구조 확정" 절 참고). 원본: @@ -229,7 +239,12 @@ process가 diff 담당"이라는 전제 자체가 틀렸음** — 실제로는 적용하는 것이라 더 정확함(위치 비교로는 "이 Slot이 동시에 다른 위치에도 마운트돼 있는가"를 못 잡음). -**[GC 주의, 2026-08-12 열세 번째 세션] `kSlotMap`/`slotOwner` 둘 다 +**[GC 주의, 2026-08-12 열세 번째 세션 — 아래 문단은 이 결정에 이르게 된 +역사적 근거이고, 거기 나오는 `kSlotMap`/`slotOwner`는 지금은 둘 다 +존재하지 않음: `kSlotMap`은 2026-08-13 다섯 번째 세션에 Handler 계약이 +클로저 반환으로 바뀌며 삭제, `slotOwner`는 2026-08-12 열여섯 번째 세션에 +`elementOwner`로 일반화. 결론(= 전부 weak, 실제 앵커는 `bindLifetime` +하나)만 아래 코드에 그대로 살아있음]** `kSlotMap`/`slotOwner` 둘 다 Slot을 `SetStrong`으로 저장하면 안 됨 — `kSlotMap[inst][k]=slot`(강)과 `slotOwner[slot]=inst`(강)가 동시에 있으면 **서로 다른 두 `Relate`가 맞물려 서로를 살려주는 순환**이 생김(`inst`가 살아있어야 `slotOwner`가 @@ -266,21 +281,33 @@ local elementOwner = Relate() -- element(Slot이든 plain 마운트 가능 값 local OWNER = "__owner" -- sentinel key(Relate는 항상 3-인자 SetWeak/2-인자 GetWeak 이후 -- 값이라 outer key 하나당 값 하나만 저장하고 싶어도 key가 필요함 — -- lifecycle-pattern.md의 GCCONN/GCHOLD와 같은 패턴, base/relate-plan.md 참고) +local OWNER_POS = "__ownerPos" -- [2026-08-13 감사 신설] owner 안에서의 위치(top-level만 씀, 아래 참고) +-- nested(`rawAdd`) 전용 — 엄격, 이미 누가 갖고 있으면 같은 owner여도 error local function claimOwner(element, ownerKey) + if elementOwner:GetWeak(element, OWNER) ~= nil then + error("이 요소는 이미 마운트돼 있음 — 다중 마운트 금지") + end + elementOwner:SetWeak(element, OWNER, ownerKey) +end + +-- top-level(`SlotHandler`) 전용 — "같은 (inst,k) 자리의 spurious 재발행"만 +-- false, 그 외 중복은 전부 error. 반환값 = 실제로 새로 클레임했는가. +local function claimOwnerAt(element, inst, k) local current = elementOwner:GetWeak(element, OWNER) - if current == ownerKey then - return false -- 이미 같은 owner — no-op, 재확인만 + if current == inst and elementOwner:GetWeak(element, OWNER_POS) == k then + return false -- 정확히 이 자리가 이미 들고 있음 — 재확인만, no-op end if current ~= nil then error("이 요소는 이미 다른 곳에 마운트돼 있음 — 다중 마운트 금지") end - elementOwner:SetWeak(element, OWNER, ownerKey) + elementOwner:SetWeak(element, OWNER, inst) + elementOwner:SetWeak(element, OWNER_POS, k) return true end local function releaseOwner(element, ownerKey) - -- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claimOwner가 항상 + -- [정정, 2026-08-13 세션] 불일치를 조용히 무시하지 않음 — claim이 항상 -- 먼저 성공해야만 이 element를 이 ownerKey가 들고 있을 수 있으므로, 여기서 -- 불일치가 관측되는 것 자체가 호출측(rawRemove/rawExtract/SlotHandler.process가 반환한 클로저)의 -- 소유권 bookkeeping이 어딘가 깨졌다는 뜻 — Dispatch의 "매치 실패는 조용한 @@ -290,10 +317,11 @@ local function releaseOwner(element, ownerKey) error("releaseOwner: 이 element는 이 ownerKey가 소유하고 있지 않음 — 호출측 소유권 추적이 깨졌음") end elementOwner:SetWeak(element, OWNER, nil) + elementOwner:SetWeak(element, OWNER_POS, nil) end function SlotHandler.process(inst, k, slotValue, index) - if claimOwner(slotValue, inst) then + if claimOwnerAt(slotValue, inst, k) then -- [정정, 2026-08-13 세션] bindLifetime을 attachSlot 밖, 여기(top-level Handler)로 -- 이동 — 반환하는 클로저 쪽 unbindLifetime과 같은 층위(Handler)로 대칭. 중첩 -- Slot은 여전히 anchor 불필요(자신을 담는 outer의 `_elements`(plain strong @@ -303,14 +331,13 @@ function SlotHandler.process(inst, k, slotValue, index) bindLifetime(inst, slotValue) attachSlot(slotValue, inst, inst, k) end - -- claimOwner가 false여도(이미 같은 Slot이 이 자리를 차지 중인 spurious 재발행) - -- 아래 클로저는 항상 똑같이 올바르게 동작함 — attachSlot을 두 번 안 부르는 - -- 것만 위에서 갈렸을 뿐, "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 어느 - -- 쪽 process 호출이 반환하든 slotValue/inst가 같아서 동일함(2026-08-13 다섯 - -- 번째 세션 — 이래서 별도 `kSlotMap`이 더 이상 필요 없음: 나중에 이 인덱스가 - -- 통째로 철거될 때 실제로 불리는 건 항상 *가장 최근* process 호출이 반환한 - -- 클로저인데, 그게 어느 호출에서 왔든 캡처된 slotValue/inst는 늘 참값이라 - -- "누가 진짜로 attach했는지"를 별도로 기억해둘 필요 자체가 없음). + -- 여기 도달했다는 건 "이 (inst,k) 자리가 slotValue를 소유 중"이 보장된 것 — + -- 방금 새로 클레임했든, 직전 사이클의 클레임이 spurious 재발행을 거쳐 그대로 + -- 살아있든 둘 중 하나(다른 소유자였다면 claimOwnerAt이 이미 error). 어느 쪽이든 + -- "이 자리가 결국 다른 값으로 바뀔 때 할 일"은 slotValue/inst가 같아서 동일하므로 + -- 클로저도 하나로 통일 — 여기서 no-op 클로저를 반환하면 안 됨: retractFrom은 + -- 클로저를 early-return시키든 말든 체인에서 항상 *소비*하므로, spurious 사이클에서 + -- no-op을 심어두면 그 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐. return function(hintValue) if slotValue == hintValue then return end -- 같은 Slot 재발행 → no-op destroySlotTree(slotValue) -- 재귀 파괴(자식 정리), 폐기·옮기지 않음 — slot 자신의 GC 앵커는 안 건드림(top-level 전용) @@ -320,6 +347,51 @@ function SlotHandler.process(inst, k, slotValue, index) end ``` +**[전면 정정, 2026-08-13 감사] `claimOwner`를 nested/top-level 두 함수로 +쪼개고, top-level은 `(inst, k)`까지 본다.** 원래는 하나의 `claimOwner`가 +"같은 ownerKey면 `false` 반환(no-op)"을 양쪽에 공유했는데, 그 분기가 +**두 개의 서로 다른 상황을 구분 못 해서** 양쪽 경로에 각각 버그를 +만들고 있었음: + +- **top-level**: `Frame { slot, slot }`(같은 Slot을 같은 inst의 두 위치에)에서 + `k=2`가 `current == inst`라 조용히 `false`를 받아 attach를 건너뛰는데, + **그러고도 파괴적인 클로저를 반환**했음 — 나중에 철거될 때 + `destroySlotTree`가 두 번 돌고 `unbindLifetime` 짝이 어긋나며 + `releaseOwner`가 (이미 해제된 상태라) error로 터짐. 구 설계는 + `kSlotMap`에 안 적힌 자리는 `retract`가 자연히 no-op이라 우연히 막혀 + 있었는데, `kSlotMap` 제거가 이 방어를 같이 걷어낸 회귀였음. 이제 + `claimOwnerAt`이 `k=2`에서 곧바로 error를 내므로 그 상태 자체가 안 + 만들어짐 — **클로저를 두 갈래로 쪼개는 방식으로는 못 고침**: `retractFrom`은 + 클로저가 early-return하든 말든 체인에서 항상 소비하므로, spurious + 사이클에서 no-op 클로저를 심으면 다음 진짜 교체 때 이전 서브트리를 + 정리할 주체가 사라져 오히려 더 큰 누수가 됨(감사 중 실제로 그 방향을 + 먼저 써봤다가 되돌림). +- **nested**: `rawAdd`는 애초에 `claimOwner`의 반환값을 안 봄(아래 "요소 + 소유권" 절 호출부) — 그래서 `local a = Slot{}; Slot { a, a }`가 조용히 + 통과해 `_elements = {a, a}`가 되고, `attachSlot`이 같은 Slot에 대해 두 번 + 불려 부모 `lengthList`에 같은 Slot이 두 번 계산되고 `slot.Offset`도 + 두 번째 호출이 덮어써 첫 번째 `Source`가 고아가 됨. + +**해법의 근거 — nested엔 "재클레임"이라는 개념이 애초에 없다.** `:List`의 +`reconcile`은 요소를 바꿀 때 항상 `rawRemove(prev)`(→`releaseOwner`) 다음에 +`rawAdd(result)`(→`claimOwner`) 순서로 부르고(아래 "구현" 절 의사코드), +`rawMove`/`rawSwap`은 클레임을 아예 안 건드림 — 즉 nested에서 "이미 내가 +갖고 있는 걸 다시 클레임"하는 정당한 경로가 하나도 없으므로 **무조건 +error가 맞음**. 반대로 top-level은 store 재발행마다 같은 Slot으로 +`process`가 다시 불리는 게 정상 경로라 그 케이스만 구분해줘야 하고, +그러려면 owner 하나로는 부족해서 위치(`k`)까지 봐야 함. + +**위치를 키에 넣어도 안전한 이유(사용자 확인)** — 여기서 쓰는 `k`는 +"바깥 컨테이너 안에서의 인덱스"고, 이건 nested Slot의 `Length`가 변해도 +바뀌지 않음. 실제 물리 배치의 변동은 전부 `offset`이 흡수하도록 설계돼 +있음(`base/bind-system-plan.md` "Length/Offset" 절의 `recompute`가 그 +증거 — `lengthList`/`sourceList`는 위치별 배열이고 순서 계산만 +누적합으로 함). top-level의 `k`는 특히 props 배열 리터럴의 위치라 +저작 시점에 고정. **nested는 `Move`/`Swap`/`Splice`/`Remove`가 +`_elements` 인덱스를 실제로 밀고 당기지만, 위 결론대로 nested는 +위치를 아예 안 쓰므로(엄격 `claimOwner`) 무관** — 그래서 +`OWNER_POS`는 top-level 전용이고 `rawAdd` 경로는 건드릴 필요가 없음. + `attachSlot` 자체가 quad-roblox 소속이라 `inst`를 아는 건 자연스러움 — `elementOwner`가 굳이 `inst`의 정체를 몰라도(예: 다른 백엔드에서 중간 표현 테이블이어도) 무관하게 동작함, 그냥 "지금 이 자리를 차지한 값이 @@ -346,12 +418,34 @@ Slot뿐 아니라 **모든 마운트 가능 element(plain Instance 포함)**의 ```lua -- rawAdd(self, element, index) 안, "이미 마운트" 에러 체크 자리 -claimOwner(element, self) -- self = 담는 Slot. 이미 다른 곳 소유면 여기서 error +claimOwner(element, self) -- self = 담는 Slot. 이미 누가(같은 self 포함) 소유 중이면 여기서 error -- rawRemove(self, index)/rawExtract 안, 요소를 내보내는 자리 releaseOwner(element, self) + +-- destroySlotTree(slot) 안, 자식들을 파괴하기 직전(위 "파괴" 절) +releaseOwner(element, slot) ``` +**[2026-08-13 감사] `claimOwner`는 반환값이 없음 — 성공 아니면 error다.** +예전 버전은 "이미 같은 owner면 `false` 반환"이었고 `rawAdd` 호출부는 그 +반환값을 아예 안 봐서, `local a = Slot{}; Slot { a, a }`가 조용히 통과했음 +(위 "Slot과 Store 바인드의 관계" 절의 전면 정정 참고). nested 경로엔 +재클레임이 정당한 경우가 하나도 없으므로(reconcile은 항상 `rawRemove`→ +`rawAdd` 순서, `rawMove`/`rawSwap`은 클레임 미접촉) 무조건 error가 맞음 — +top-level만 `claimOwnerAt`으로 spurious 재발행을 구분함. + +**[2026-08-13 감사] 소유권 반납은 GC에 맡기면 안 됨.** `elementOwner`는 +값도 `SetWeak`이라 "파괴된 Slot이 아무에게도 참조되지 않게 되면 소유권 +기록도 저절로 사라진다"가 원리적으로는 맞지만, **그게 언제인지가 GC +타이밍에 달려 있어서** 그 전에 같은 element를 다른 곳에 넣으려 하면 +"이미 마운트돼 있음" error가 비결정적으로 터짐(사용자가 직접 들고 있는 +nested `Slot`을 파괴 후 재사용하는 경로가 정확히 이 케이스). 그래서 +`rawRemove`/`rawExtract`뿐 아니라 **`destroySlotTree`도 자기 자식들의 +`releaseOwner`를 명시적으로 부름** — 원래 이 함수는 소유권을 전혀 안 +건드리고 있었음. top-level Slot 자신의 반납은 `SlotHandler.process`가 +반환한 클로저가 담당(층위 분리는 `unbindLifetime`과 동일한 원칙). + `ownerKey`가 `inst`(top-level)든 `Slot`(nested)든 `elementOwner`는 타입을 신경 안 써서 하나의 레지스트리로 충분 — `outerSlot`이 값으로 들어가도 `elementOwner` 자체는 아무것도 강하게 안 붙잡고(전부 @@ -1298,6 +1392,9 @@ Slot=그 `.Length`)이 됨. plain 요소만 있는 흔한 경우엔 항상 합== ```lua local function destroySlotTree(slot) for i, element in ipairs(slot._elements) do + -- [정정, 2026-08-13 감사] 소유권 반납을 먼저 — 아래 "소유권 반납은 + -- GC에 맡기면 안 됨" 참고 + releaseOwner(element, slot) if isSlot(element) then destroySlotTree(element) -- 재귀는 "파괴"에만, choreography 없음 else @@ -1310,6 +1407,10 @@ local function destroySlotTree(slot) unbindLifetime(slot._mountedInst, observer) end end + -- [정정, 2026-08-13 감사] 마운트 상태도 되돌림 — 안 그러면 파괴된 Slot이 + -- `_mounted == true`로 남아 "마운트된 Slot의 재마운트는 즉시 throw"(위 절)에 + -- 영원히 걸리고, `_mountedInst`가 죽은 inst를 계속 강하게 붙잡음. + slot._mounted, slot._mountedInst = false, nil -- [2026-08-12 열여섯 번째 세션, 스코프 정정] slot 자신의 unbindLifetime은 -- 여기서 안 부름 — attachSlot이 최상위에서만 bindLifetime하므로 짝도 -- 최상위 파괴 지점(SlotHandler.process가 반환하는 클로저, 위)에서만 한 번. @@ -1326,6 +1427,11 @@ function rawRemove(self, index) if bk.observers[index] then unbindLifetime(self._mountedInst, bk.observers[index]) -- outer가 이 위치 위해 등록해둔 observer end + releaseOwner(element, self) -- [정정, 2026-08-13 감사] 원래 이 줄이 의사코드에서 + -- 빠져 있었음(산문 쪽 "요소 소유권" 절은 rawRemove/ + -- rawExtract가 releaseOwner를 부른다고 이미 명시하고 + -- 있었는데 코드만 불일치) — 엄격 releaseOwner가 + -- 들어온 뒤로는 이 누락이 실동작 차이를 만듦 if isSlot(element) then destroySlotTree(element) else element:Destroy() end spliceArraysDown(self, index) -- _elements/lengthList/sourceList 전부 한 칸씩 당김 @@ -1483,6 +1589,246 @@ return Slot { 쓸 수 있음** — Slot-in-Slot 중첩(위 절)과 이 반응형 raw 요소가 서로 독립적으로 조합됨을 보여주는 예. +### `State` 재설정 시 소유권이 안전한가 — 확인됨 (2026-08-13 감사, 사용자 질문) + +**질문**: `Slot { state }`에서 그 state가 다른 Slot으로 재설정되면 +소유권/마운트 상태가 정확히 갈리는가. 그리고 이걸 "래퍼가 불변이라 +괜찮다"로 설명할 게 아니라 **Slot-in-Slot 자체가 일반적으로 안전해야** +하는 것 아닌가(사용자 판단: 후자가 맞음). + +**결론: 후자가 맞고, 실제로 성립함.** 위 sugar 때문에 구조는 항상 +`outer._elements[i] = sub`(래퍼 Slot, 이 위치에 **영구 고정**) → +`sub:Single(state)` → 그 `:List`의 `reconcile`이 안쪽만 교체 — 이고, +`reconcile`은 `result ~= prev`일 때 **`rawRemove(self, prev)` 다음에 +`rawAdd(self, result, pos)`** 순서로 부름(위 "구현" 절). 즉 소유권이 +**반납 → 재클레임** 순서로 정확히 갈림: + +| 시점 | `elementOwner[innerA]` | `elementOwner[innerB]` | +|---|---|---| +| 최초 reconcile | `sub` | — | +| state가 B로 재설정, `rawRemove(sub, innerA)` | `nil`(releaseOwner) | — | +| 이어서 `rawAdd(sub, innerB, pos)` | `nil` | `sub`(claimOwner) | + +바깥(`outer`) 입장에선 `_elements[i]`가 계속 `sub`라 아무 일도 안 +일어나고, 소유권 판정도 `sub` 아래 한 레벨에서만 갈림 — **래퍼가 +불변이라는 사실에 기대는 게 아니라, nested CRUD의 release→claim 규율 +자체가 안전성을 만듦.** 그래서 `Slot { Slot { Slot } }`처럼 손으로 +중첩한 경우에도 정확히 같은 규칙 하나로 동작함(래퍼 sugar는 그저 그 +일반 메커니즘의 사용자일 뿐). + +**단 이 결론은 위 "요소 소유권" 절의 2026-08-13 감사 수정 셋을 전제함** — +(1) nested `claimOwner`가 엄격(같은 owner 재클레임도 error)이라 +`Slot { a, a }`가 실제로 막히고, (2) `rawRemove`가 `releaseOwner`를 +실제로 부르고(의사코드에서 빠져 있었음), (3) `destroySlotTree`가 자식 +소유권을 GC에 안 맡기고 명시적으로 반납. 셋 중 하나라도 빠지면 이 표의 +중간 단계가 어긋남. + +### [전면 정정, 2026-08-13 여섯 번째 세션 후속, 사용자 결정] `State` 교체는 **파괴가 아니라 언마운트** — `state`와 완전히 동일 + +아래 두 절("`State`가 `nil`이 됐다 돌아오는 경우", "포탈")은 당시 +설계(reconcile이 `rawRemove`=파괴를 씀)를 정확히 서술한 것이지만, 그 +설계 자체가 이 정정으로 **뒤집힘**. 두 절은 문제 제기 과정으로만 남겨두고, +현재 유효한 규칙은 이 절이다. + +**결정(사용자)**: `State`이 다른 값으로 교체될 때 이전 Slot은 +**파괴되지 않고 언마운트만 된다.** 근거: + +1. **`state`가 이미 그렇게 동작함** — `store.child:Set(otherFrame)`을 + 해도 이전 `Frame`을 quad가 `Destroy()`해주지 않음, 그냥 트리에서 + 내려올 뿐. `State`만 다르게(파괴로) 동작할 이유가 없음. **"이전 + 값을 지울지는 그 값을 만든 쪽이 정한다"**는 이미 `Ref`("Destroy와 무관")/ + `Attribute`("명시적 `None`으로만 지움")에서 확정된 quad 전역 철학과도 + 같은 결. +2. **비파괴 추출은 이미 지원되는 개념** — `Extract`/`ExtractAll`/`Splice`가 + 전부 비파괴로 확정돼 있음(위 "CRUD API 확정" 절). "제거 = 파괴"만 + reconcile이 임의로 골랐던 것이라, 그 선택을 되돌리는 것뿐 새 능력이 + 아님. +3. **뽑아냈으면 더 이상 leaf의 소유가 아님** — 소유권이 반납된 + (`releaseOwner`) 상태이므로, 그 Slot을 계속 쓸지 버릴지는 그걸 들고 + 있는 코드의 몫. + +**그러면 안 지운 Slot은 언제 죽는가 — "들고 있다 죽으면 같이 소멸"** +(GC-native, 이 프로젝트의 기본 원칙 그대로): 아무도 참조를 안 들고 있으면 +그냥 GC됨. 명시적으로 지금 죽이고 싶으면 `dispose`(아래). + +**부수 효과 — 이미 파괴된 대상에 재마운트하려는 시도가 자연히 막힘.** +Slot이 마운트될 때 **자기 하위 요소들까지 `bindLifetime`으로 물리 target에 +묶고, 실제 동작 전에 `canExecute`를 확인**하도록 하면(`base/lifecycle-pattern.md`), +"nested로 마운트해둔 뒤 물리 Instance를 Destroy하고, 그 다음 Slot을 뽑아 +다른 데 쓰려는" 경로가 별도 방어 로직 없이 걸러짐 — 이미 있는 +`bindLifetime`/`canExecute` 게이트를 한 층 더 촘촘히 적용하는 것뿐, +새 메커니즘이 아님. + +**`Set`으로 덮어쓰기 *전에* 이전 값을 직접 `Destroy()`하는 건 UB.** +`state`에서 `frame:Destroy()`를 먼저 하고 `Set(other)`을 부르는 +것과 정확히 같은 문제 — quad는 그 값이 이미 죽었다는 걸 모른 채 언마운트 +경로를 탐. 순서는 항상 **`Set`(언마운트) → 그 다음 정리**. + +#### `dispose(any)` — quad가 만든 것을 안전하게 지우는 유일한 경로 (신설, 사용자 제안) + +위 UB를 "조심하세요"로만 두지 않기 위해, **base 레벨 탑레벨 유틸 +`dispose(value)`** 를 제공하는 방향으로 확정: + +- 의미(**[정정, 사용자 확정]** 최초안은 "마운트돼 있으면 먼저 떼어낸 뒤 + 파괴"였으나 더 단순하게 확정): **대상이 아직 어느 트리에 의해 살아있길 + 요구되고 있으면 파괴를 거부하고 즉시 `error`.** 떼어내주지 않음 — + 떼어내는 건 `Set`(언마운트)의 몫이고, `dispose`는 그 뒤에 부르는 것. + 아무도 요구하지 않는 상태면 실제로 파괴(Slot이면 하위까지 재귀). +- **왜 거부가 맞는가(사용자)**: "실제로 클리어 하거나 Destroy 해도 + 로블록스엔 에러 안 나는데, quad에선 데이터 구조가 깨지는 일이니까요." + 엔진은 조용히 넘어가지만 quad의 `_elements`/`lengthList`/`sourceList`/ + `elementOwner`는 그 순간 어긋남 — 그래서 **quad가 관리 중인 값을 + 안전하게 지우는 유일한 경로가 `dispose`**이고, 그 경로가 "지금 지우면 + 안 되는 상태"를 잡아주는 게 존재 이유. 위 "`Set` 전에 직접 `Destroy()` + 하는 건 UB" 항목이 `dispose`를 쓰면 UB가 아니라 **명확한 에러**가 됨. +- **이게 성립하는 이유 — "이 값이 지금 어디 마운트돼 있는가"를 이미 + 알고 있음.** `a = Frame{}; Frame{a}; Frame{a}`를 error로 잡기로 이미 + 확정했고(위 "핵심 제약: 소유권 귀속과 단일 마운트"), 그 판정을 위해 + `elementOwner`가 element → owner를 들고 있음 — `dispose`는 그 정보를 + 거꾸로 읽으면 되므로 **새 부기가 필요 없음**. 사용자 지적: "이미 두 + 곳에 넣는 게 에러나도록 하기로 했으니, 어디 마운트되었냐가 따져지고, + 그래서 이미 가능한 일". +- 네이밍/시그니처(`dispose(any)`가 맞는지, 타입을 어떻게 좁힐지), + Slot 외의 대상(Instance/Observer/Effect)까지 커버하는 범위, + `unbindLifetime`과의 역할 분담은 **미확정** — `.claude/question.md`에 + 올림. 여기서는 "언마운트로 바꾼 대신 명시적 파괴 수단을 제공한다"는 + 방향만 확정. + +#### 구현상 바뀌어야 하는 것 + +`reconcile`이 교체/소멸 시 `rawRemove`(파괴) 대신 **비파괴 경로**를 타야 +하고, 그 경로는 `destroySlotTree`가 지금 하는 일 중 **파괴만 빼고 나머지는 +그대로 해야 함**(자식 observer `unbindLifetime`, 옛 owner에 등록해둔 +`Dispatch.setLength`/`setOffsetSource` 해제, `_mounted`/`_mountedInst` +복원, `releaseOwner`). + +**[정정, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에 +대해 그냥 **`Dispatch.setOffsetSource(ownerKey, position, None)` + +`Dispatch.setLength(ownerKey, position, 0)`을 다시 부르면 끝**. +이건 이미 확정된 관용구 그대로임(`base/bind-system-plan.md`의 "실제 +마운트를 하지 않는 위치는 `None`을 등록 — `setLength`도 짝을 맞춰 `0`"). +즉 **해제 = 0/`None`으로 재등록**이고 별도 unregister 함수가 없어도 됨 — +앞서 "이게 실제 작업량"이라고 적었던 판단은 과했음. + +**⚠️ 순서가 중요함 — `setOffsetSource`를 먼저, `setLength`를 나중에 +(2026-08-13 여섯 번째 세션, 사용자 지적).** 마운트할 때와 달리 **해제할 +때는 이 순서를 반드시 지켜야 함**: + +- `Dispatch.setLength`는 끝에서 `recompute`를 돌리고, `recompute`는 + `sourceList`를 순회하며 각 자리의 `offset:Set(sum)`을 호출함 + (`base/bind-system-plan.md` "Length/Offset" 절). +- 그래서 **`setLength(0)`을 먼저 부르면**, 그 안의 `recompute`가 도는 + 시점에 해제 중인 자리의 `sourceList[i]`엔 **아직 옛 Slot의 offset + `Source`가 그대로 남아 있음** → 지금 막 떼어내는 서브트리의 Source에 + `:Set()`이 날아가고, 그 Source를 구독하던 (이제 곧 없어질) 자식들의 + `LayoutOrder` 계산이 헛되이 캐스케이드됨. 사용자 표현대로 "**invalid한 + offset source 자체가 있다는 것부터 위험**". +- `setOffsetSource(None)`을 먼저 부르면 그 자리는 `recompute`의 + `offset ~= None` 가드에 걸려 곧바로 제외되므로, 뒤이은 `setLength(0)`의 + `recompute`가 그 Source를 아예 안 건드림. +- **자기 자신의 offset은 어차피 안 바뀜**(자기 length가 줄어든다는 건 + 자기 앞 형제들의 누적합은 그대로라는 뜻) — 그래서 이 순서 문제는 + "내 offset이 틀리게 계산된다"가 아니라 **"죽는 중인 Source에 쓰기가 + 날아간다"** 쪽임. 사용자도 "물론 별 상관 없어요"라고 했듯 실제 값 + 오류로 이어지진 않지만, 방어적으로 순서를 고정. + +**추가 방어 조치**: +- **해제 시 `slot.Offset = nil`도 같이** — 이 문서의 "`Slot.Offset`은 + 마운트 시점에 세팅되고 마운트 전엔 `nil`" 규칙과 짝을 맞춤. 안 그러면 + 떼어낸 Slot이 옛 owner 기준의 stale한 `Offset`을 계속 공개해, 그걸 + 읽는 사용자 코드가 조용히 틀린 값을 씀. +- **`recompute`는 `sourceList[i]`가 `None`이든 `nil`이든 "참여 안 함"으로 + 똑같이 관대하게 넘어갈 것** — 정상 상태에선 항상 `None`으로 채워지는 게 + 계약이지만(`nil`은 배열에 구멍을 냄), 해제/재마운트가 얽히는 전이 + 구간에서 `nil`이 관측돼도 크래시 대신 skip이어야 함. 이건 계약 완화가 + 아니라 **순수 방어** — 등록 쪽은 여전히 `None`을 쓸 의무가 있음. + +**`state>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된 +것"으로 수용**(사용자 판단) — `state>`와 같은 범주로, +평탄화 도구(`research/operator-sugar-plan.md`)가 처리할 요소이고 실사용 +케이스가 드묾. Dispatch/Slot에 별도 배관을 넣지 않음. + +### `State`가 `nil`이 됐다 돌아오는 경우 — 소유권은 정상, 단 **Slot은 파괴된다** (2026-08-13 여섯 번째 세션, 사용자 질문 — **위 정정으로 뒤집힘, 문제 제기 기록으로만 보존**) + +**질문**: +```lua +local stateSlot: State = Source() +Frame { Slot { stateSlot } } +``` +에서 `stateSlot`이 `nil`로 지워졌다 다시 나타나도 문제 없는가. + +**소유권 bookkeeping은 정상** — `:Single`이 감싼 `:List`의 `reconcile`이 +`data`가 빈 배열이 되면 직전 사이클 key(`true`)가 `seen`에 없으므로 +`rawRemove(sub, prev)`를 부르고(→ `releaseOwner`), 다시 값이 오면 +`rawAdd`(→ `claimOwner`)를 부름. 위 감사 수정(=`rawRemove`가 실제로 +`releaseOwner`를 부르고, `destroySlotTree`가 `_mounted`/`_mountedInst`도 +되돌림) 이후로는 재클레임이 깨끗하게 됨. + +**하지만 그 사이에 Slot 자체가 파괴됨 — 이게 실질적인 답이다.** +`reconcile`이 쓰는 건 `rawRemove`(= 제거 **+ 파괴**)이지 `rawExtract`(= +비파괴)가 아님(위 "구현" 절, "제거는 항상 파괴 확정이라 `Extract` 아닌 +`Remove` 경로"). 그래서 `nil`이 된 순간 `destroySlotTree(slotA)`가 돌아 +**slotA의 자식 Instance들이 `:Destroy()`됨**. 다시 나타날 때 같은 +`slotA` 객체를 넣으면 `_elements`엔 이미 죽은 Instance 참조가 남아있는 +상태로 재마운트되는 것 — 껍데기만 살아있다. 이건 이미 확정된 정책 +("retract/재바인드되는 slot은 옮겨지지 않고 그냥 폐기된다", 위 "확정" +절)의 직접적인 귀결이고 이번 감사로 바뀐 게 아님. + +**따라서 실용적 관용구는 "Slot을 껐다 켜는" 게 아니라, Slot은 계속 두고 +그 *내용*을 비우는 것**(`Slot:Clear()`, 또는 `:List`의 `data`를 빈 +배열로) — 또는 매번 새로 만든 Slot을 `Set`하는 것. 같은 Slot 객체를 +`nil`↔`slotA`로 왕복시키는 코드는 두 번째 등장부터 조용히 빈/깨진 +서브트리를 냄. + +### 포탈(`Extract`로 살아있는 Slot을 다른 곳으로 옮기기) — **해결됨**(위 "언마운트" 정정으로), 아래는 그 결론에 이른 과정 + +**질문**: `stateSlot:Get()`으로 Slot을 뽑아두고 → `Set()`으로 다른 Slot을 +넣고 → 뽑아둔 Slot을 다른 곳에 넣을 수 있는가. 가능하다면 "포탈" +(위 "확정" 절에서 오버엔지니어링으로 미뤄둔 React portal류)이 이걸로 +해결됨. + +**현재 설계로는 불가** — 위 절과 같은 이유 하나 때문임: `reconcile`이 +교체 시 `rawRemove`(파괴)를 부르므로, `Get()`으로 뽑아둔 레퍼런스는 +`Set()` 직후 **이미 파괴된 Slot**이 됨. 막는 게 소유권 규칙이 아니라 +"제거 = 파괴"라는 reconcile의 선택 하나라는 점이 중요함. + +**그런데 나머지 부품은 이미 다 있음** — 그래서 사용자 지적대로 이 +방향은 실현 가능성이 높음: +- `Extract`/`ExtractAll`/`Splice`가 이미 **비파괴 제거**로 확정돼 있음 + (위 "CRUD API 확정" 절) — 필요한 시맨틱이 이미 공개 API에 존재. +- `claimOwner`/`releaseOwner`가 이미 소유권을 정확히 이양함 — 뽑힌 + Slot은 owner 없는 상태가 되고, 다른 곳에서 `Add`하면 깨끗이 클레임됨. +- `attachSlot`은 **재마운트를 구조적으로 이미 지원** — `_mounted`/ + `_mountedInst`를 새 target으로 세팅하고 `_elements`를 새 물리 부모로 + flush하는 순수 구조 로직이라, 자식이 파괴만 안 됐다면 그대로 옮겨감. + (`destroySlotTree`가 `_mounted`를 되돌리도록 한 이번 감사 수정이 + 마침 이 경로의 전제 조건이기도 함.) + +**남는 숙제(그래서 지금 확정 안 하고 열어둠)**: +1. **어느 경로가 비파괴가 되어야 하는가** — `reconcile` 전체를 + `rawExtract`로 바꾸면 "안 쓰는 서브트리가 조용히 안 죽는" 누수가 되기 + 쉬움. 사용자가 명시적으로 고르는 opt-in(예: `:Single`/`:List`의 + 옵션, 또는 "이 Slot은 portal 대상"이라는 표식)이 맞아 보임 — Attribute + 자동 unset을 opt-in 유틸로 미뤄둔 것과 같은 결. +2. **`destroySlotTree`가 하는 나머지 일들의 짝** — 자식 observer + `unbindLifetime`, `Dispatch.setLength`/`setOffsetSource`로 옛 owner에 + 등록해둔 항목 정리. 비파괴 경로는 이것들을 "해제 후 새 owner에 재등록" + 해야 하는데, 지금 `attachSlot`은 등록만 하고 해제하는 짝이 없음. +3. **`Extract` 후 재마운트 전까지의 중간 상태** — 소유자 없이 물리 + 트리에서도 떨어진 Slot이 얼마나 오래 떠 있어도 되는지(GC 앵커가 + `bindLifetime` 하나뿐인데 top-level에서 뽑히면 그 앵커도 풀림 → + 사용자가 레퍼런스를 안 들고 있으면 그냥 GC됨. 이건 오히려 자연스러운 + 동작일 수 있음). + +**결론**: "포탈은 별도 메커니즘이 필요하다"는 기존 전제는 **틀렸음** — +`Extract` 비파괴 경로 하나로 나옴. **[사용자 결정, 위 "언마운트" 정정]** +게다가 opt-in이 아니라 **기본 동작**이 됨: `State` 교체는 원래부터 +`state`와 같이 언마운트여야 했다는 판단이라, "포탈"은 별도 +기능이 아니라 그 결정의 자연스러운 귀결일 뿐. 위 숙제 1번(어디에 +opt-in할지)은 그래서 사라졌고, **2번(등록의 해제 짝)만 실제 작업으로 +남음** — 3번(중간 상태)은 "아무도 안 들고 있으면 GC, 명시적으로 지우려면 +`dispose`"로 답이 나옴. + **실측 필요 — 새 항목 아님, 기존 실측 목록에 흡수**: `State`/`Slot` 자기 참조 제네릭 타입체크는 이미 M0/M6 실측 목록에 있던 것과 같은 급이라 별도 항목 추가 안 함. diff --git a/.claude/base/tag-plan.md b/.claude/base/tag-plan.md index b5d8a4f..5efd46a 100644 --- a/.claude/base/tag-plan.md +++ b/.claude/base/tag-plan.md @@ -1,5 +1,15 @@ # Tag — array-part 값 객체, `CollectionService` 얇은 래퍼 +> **⚠️ [2026-08-13 여섯 번째 세션] 이 문서의 `hintValue`/`retractFrom` 선행 +> 호출 서술은 곧 교체될 예정 — 아직 반영 안 됨.** 힌트가 `None` 센티널이나 +> `State`/`Tween` 래퍼로 오염돼 말단 핸들러의 `isX(hint)` 가드를 거짓으로 +> 만들고 깜빡임/재생성 방지를 조용히 끄는 결함이 확인됐고, 대체 모델 +> (**래핑 핸들러의 `retractFrom` 선행 호출 폐기 + `Dispatch.process`가 +> 핸들러를 먼저 비교**)까지 거의 확정됐음 — 다만 `question.md` **0-Z** +> (Attribute 이름 소유권) 하나가 남아 아직 옮기지 않음. **여기 적힌 대로 +> 구현하면 옛 모델로 짜게 됨** — 반드시 +> `research/dispatch-redispatch-diff-plan.md`를 먼저 읽을 것. + **상태**: base — 2026-08-08 세 번째 세션에서 값 모양을 전면 재설계(구 모델은 `archive/tag-hash-key-model-reversed.md`에 원문·역전 이유 보존). 2026-08-12 열한 번째 세션에 `TagHandler`의 `process`/`retract` 메커니즘을 @@ -23,6 +33,7 @@ Tag(name1, name2, ...) -- 생성자, 가변인자. Tag() 빈 tag:Added(name: string | {string}): Tag -- clone 후 이름(들) 추가, 원본 안 건드림 tag:Removed(name: string | {string}): Tag -- clone 후 이름(들) 제거 tag:Contains(name): boolean -- 멤버십 확인 +tag:Names(): iterator -- 담고 있는 이름 순회(아래 "메커니즘" 절이 쓰는 것) tag:Apply(factory): U -- factory(self) 체이닝 설탕(Modifier와 동일 패턴) Tag.Merged(tag1, tag2, ...): Tag -- 여러 Tag의 합집합(무손실). Modifier의 Overridden(필드 단위 덮어쓰기, 손실 있음)와 @@ -95,8 +106,9 @@ nil`/`or None`(and/or 삼항)으로 적었으나, `Tag(...)`가 항상-truthy라 1. **`retract`는 실제로 store 재발행마다(핸들러 타입이 안 바뀌어도) 항상 불림** — `bind-system-plan.md`의 "확정된 디스패치 모델" 절이 처음부터 - 말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 `Dispatch.retractUnder`를 - 부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음 + 말해온 대로 `StoreBind`가 재-dispatch 전에 무조건 + `Dispatch.retractFrom`(2026-08-13 다섯 번째 세션 전까지의 이름은 + `retractUnder`)을 부르기 때문. "Tag(A)→Tag(B)는 retract 안 불림"이라는 옛 서술은 틀렸음 (상세 근거는 `bind-system-plan.md` 일반 retract 계약 절, `archive/ retract-always-fires-reversed.md`). 2. **서로 다른 배열 위치의 두 `Tag(...)`가 같은 이름을 겹쳐 가질 수 @@ -186,6 +198,16 @@ end 이름은 `AddTag`가 no-op으로 재확인만 됨, 소유 목록엔 `B`가 새로 등록). 결과적으로 실제 `RemoveTag`/`AddTag` 호출은 진짜 변경된 이름에만 일어남 — 스타일 깜빡임 방지라는 원래 목적은 그대로 달성. + **[범위 한정, 2026-08-13 감사] 이 깜빡임 방지는 "이 Tag를 바로 위에서 + 직속으로 위임한 한 단계"에서만 성립함** — `Dispatch.retractFrom(inst, + k, index, v)`는 힌트 `v`를 정확히 `index` 자리에만 넘기고 그보다 깊은 + 인덱스에는 `nil`을 넘기기 때문(`bind-system-plan.md` "Dispatch 체인" + 절의 `retractFrom` 의사코드). 그래서 `State>`에서 **바깥** + store가 재발행하면 TagHandler(더 깊은 인덱스)는 `hintValue=nil`을 + 받아 이름 전부를 실제로 `RemoveTag`했다가 새 체인이 다시 `AddTag`함. + 구조상 불가피함(바깥 단계는 안쪽이 결국 어떤 Tag를 내놓을지 모름) + 이고, 흔한 경로(`State` 한 겹)는 영향 없음 — 실사용에서 문제가 + 되면 그때 힌트를 깊은 인덱스까지 전파하는 안을 재검토. - **`Tag(A)→nil`**: `A`의 클로저가 `hintValue=nil`로만 불림(값이 `Tag`가 아니게 돼 `process`는 매치 자체가 안 됨) — `hintIsTag=false`라 힌트가 항상 거짓이 되어 `A`가 걸었던 이름 전부가 무조건 실제로 `RemoveTag`됨 diff --git a/.claude/base/tween-plan.md b/.claude/base/tween-plan.md index 613cd8d..8189ed1 100644 --- a/.claude/base/tween-plan.md +++ b/.claude/base/tween-plan.md @@ -67,8 +67,10 @@ Tween(opts: {Value: T, Time: number?, Style: Enum.EasingStyle?, ...}) -> Tween` — Modifier/State/Source에 새 타입 기계 불필요 @@ -179,7 +184,7 @@ State>`가 나옴 — Modifier/State/Source/StoreBind 코드엔 해석하는 코드는 여전히 PropertyHandler 하나에만 존재. **핸들러 계층 UB 체크와도 안 부딪힘** — `Tween`는 `Ref`/`Observer`/ -`Slot`류처럼 `process`/`retract`를 가진 dispatch 참가자가 아니라 `None`/ +`Slot`류처럼 dispatch 참가자(`process`를 가진 Handler에 매칭되는 값)가 아니라 `None`/ `Tag`처럼 순수 raw 데이터 값(별도 `TweenTag` Brand)이라, Modifier 필드/ `State`가 막는 "핸들러 계층 값" 규칙(`base/modifier-plan.md`)에 안 걸림 — 그 문서가 원래 Tween을 Slot/Tag/Attribute와 같은 "dispatch diff --git a/.claude/base/ui-shorthand-plan.md b/.claude/base/ui-shorthand-plan.md index 44bc647..44dd2b0 100644 --- a/.claude/base/ui-shorthand-plan.md +++ b/.claude/base/ui-shorthand-plan.md @@ -53,9 +53,10 @@ Roblox Instance 이름과 맞춘 `UICorner`/`UIPadding`(+`UIPaddingOffset`)/ 이미 있는 pluggable Handler로 그대로 커버됨. `UICorner`/`UIPadding`/ `UIScale` 같은 특수 키를 인식하는 Handler(`isHandlable`이 그 키를 매칭)가 -"이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v)`에 +"이름 붙은 자식을 찾거나 만들고 프로퍼티 세팅"을 `process(inst, k, v, index)`에 구현 — v1의 하드코딩 if/elseif 대신 정식 핸들러 계약(`isHandlable`/ -`priority`/`process`/`retract`)을 따르는 것만 다름. `modifier-plan.md`가 +`priority`/`process`, 2026-08-13 다섯 번째 세션 전까진 `retract`가 별도 +필드였음)을 따르는 것만 다름. `modifier-plan.md`가 이미 예시로 든 `mod:UICorner(8)`은 이 특수 키를 flatten해서 props에 꽂아넣는 사탕 문법일 뿐, 실제 처리는 이 Handler가 함 — Modifier를 안 거치고 `Frame { UICorner = 8 }`처럼 순수 인라인 키로 직접 써도(v1처럼) 동일하게 @@ -80,7 +81,7 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 않음. 사용자가 직접 만든 `UICorner`를 quad가 멋대로 건드리는 부작용을 피하기 위함. -### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, `retract`는 관여 안 함 (2026-08-07 여덟 번째 세션) +### `v`가 `nil`인 경우 — `process`가 직접 자식 제거, 반환 클로저는 관여 안 함 (2026-08-07 여덟 번째 세션) `modifier-plan.md`의 `None` 센티널(`base/bind-system-plan.md`의 `NoneHandler` 재귀 재디스패치 절 참고)이 최종적으로 이 Handler의 @@ -89,14 +90,16 @@ Modifier 타입의 메소드 목록에 끼워 넣도록 챙기면 됨, 새로 일반 프로퍼티 핸들러와 달리 이 숏핸드는 실제 Instance를 만들어 붙이는 쪽이라 "`nil` = 셋 안 함"이 곧 "만들어둔 게 있으면 치운다"는 뜻이 됨. -- **이건 `retract`가 아니라 `process` 자신의 로직** — **[정정, 2026-08-12 - 열한 번째 세션]** `retract`는 store 재발행마다 항상 불리지만(핸들러 - 타입이 안 바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분 - 참고), 이 Handler는 `process(inst,k,v)` 자체가 `v`가 `nil`이든 숫자든 - 전부 완결적으로 처리하므로(있으면 지우거나 만들거나) `retract`가 할 일이 - 없어 no-op이면 충분 — 일반 프로퍼티 핸들러가 `retract`이 필요 없는 것과 - 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) 바뀌면 `process`가 - 다시 자식을 만들면 그만이라 `retract` 쪽에 별도로 구현할 게 없음. +- **이건 반환 클로저가 아니라 `process` 자신의 로직** — **[정정, 2026-08-12 + 열한 번째 세션, 2026-08-13 다섯 번째 세션에 클로저 반환 계약으로 서술 + 갱신]** 그 클로저는 store 재발행마다 항상 불리지만(핸들러 타입이 안 + 바뀌어도, `bind-system-plan.md` 일반 retract 계약 절 정정분 참고), 이 + Handler는 `process(inst,k,v,index)` 자체가 `v`가 `nil`이든 숫자든 + 전부 완결적으로 처리하므로(있으면 지우거나 만들거나) 반환 클로저가 할 일이 + 없어 `function() end`이면 충분 — 일반 프로퍼티 핸들러가 no-op 클로저를 + 반환하는 것과 같은 이유. 값이 나중에 다시 숫자로(`2`→`nil`→`3`처럼) + 바뀌면 `process`가 다시 자식을 만들면 그만이라 클로저 쪽에 별도로 + 구현할 게 없음. - **캐비엇**: 이 왔다갔다가 잦으면(예: 반응형 State가 `nil`과 숫자 사이를 자주 토글) 매번 Instance 생성/제거 비용이 그대로 듦 — Tween처럼 무거운 API는 아니지만 공짜도 아니므로, 잦은 토글이 예상되는 값을 이 숏핸드에 diff --git a/.claude/luau-test/03-recursive-store-bind-dispatch.luau b/.claude/luau-test/03-recursive-store-bind-dispatch.luau index 62b1115..40ec1e0 100644 --- a/.claude/luau-test/03-recursive-store-bind-dispatch.luau +++ b/.claude/luau-test/03-recursive-store-bind-dispatch.luau @@ -8,8 +8,8 @@ 디스패치를 실제로 짜보기(store-bind 핸들러 하나 + isHandlable 우선순위 스캔 포함)". - 여기서는 다단 체인(retractUnder)까지는 다루지 않음 — 그건 - 04-dispatch-chain-retractUnder.luau가 별도로 다룸(단일 owner 슬롯 + 여기서는 다단 체인(retractFrom)까지는 다루지 않음 — 그건 + 04-dispatch-chain-retractFrom.luau가 별도로 다룸(단일 owner 슬롯 추적이 왜 깨지는지까지 포함). 이 파일은 "재귀 자체가 도는가", "우선순위 스캔이 맞는 핸들러를 고르는가", "None -> nil 재디스패치가 다음 핸들러로 자연히 좁혀지는가"까지만 검증. diff --git a/.claude/luau-test/04-dispatch-chain-retractFrom.luau b/.claude/luau-test/04-dispatch-chain-retractFrom.luau new file mode 100644 index 0000000..db9f43e --- /dev/null +++ b/.claude/luau-test/04-dispatch-chain-retractFrom.luau @@ -0,0 +1,306 @@ +--[[ + 검증 대상: 인덱스 기반 `Dispatch` 체인(2026-08-13 다섯 번째 세션 전면 + 재설계, `.claude/base/bind-system-plan.md` "Dispatch 체인" 절)이 다단 + 재귀 위임에서 실제로 정확한지. + + 새 모델의 요점(옛 모델과 뭐가 다른지): + - `chains[inst][k]`는 **핸들러 배열이 아니라 `{[index] = retractor}`**. + index = 같은 키 안에서의 재귀 깊이(같은 키 재귀는 index+1, 다른 + 키로 위임하면 그 키에서 다시 1부터). + - Handler 계약이 `process`/`retract` 2-메소드에서 **`process`가 자기 + retract 클로저를 반환하는 1-메소드**로 축소. + - `Dispatch.retractFrom(inst,k,index,v)` 하나가 옛 + `retractUnder`/`retractSelfAndUnder` 둘을 대체(자기 포함/미만은 + 호출자가 넘기는 인덱스로 표현). + + **이 파일은 2026-08-13 감사에서 전면 재작성됨.** 이전 버전 + (`04-dispatch-chain-retractUnder.luau`)은 핸들러 **객체 identity**로 + 체인을 추적하던 옛 설계와, 그 위에 얹혔던 "같은 (inst,k)에 같은 핸들러 + 중복 push면 즉시 error" 가드를 검증하고 있었는데 — 그 가드는 인덱스 + 기반 재설계로 **없어졌고**, `State>`는 UB가 아니라 정상 지원 + 대상으로 재정정됨. 즉 옛 파일은 설계와 정반대를 테스트하고 있었음. + + 검증 항목: + A. 3단 체인(StoreBind -> StoreBind -> Property)이 인덱스 1/2/3으로 + 안 겹치고 쌓이는가 (= `State>` 정상 동작) + B. 안쪽 store 재발행 시 index 3만 갈리고 1/2는 유지되는가 + C. 바깥 store 재발행 시 index 2/3이 **깊은 쪽부터** 정리되는가 + D. `retractFrom`의 hint(`v`)가 정확히 target index에만 전달되는가 + E. **[감사 회귀 테스트]** `chains:SetStrong`을 `handler.process` + *뒤에* 두면 최초 마운트에서 하위 retractor가 유실되는가 + (음성 대조군 — 이 버그가 실재함을 증명, 2026-08-13 감사에서 발견) + + 실행: `luau 04-dispatch-chain-retractFrom.luau` + + 캐비엇: 아래 `subscribe(fn)`은 이 스파이크 전용 단순화 — 실제로는 + `state:Observer(fn)` + `bindLifetime`/`unbindLifetime` 조합 + (`.claude/base/lifecycle-pattern.md`). 다만 **이 스파이크의 retractor는 + no-op 스텁이 아니라 실제로 구독을 끊는다** — 옛 04번이 no-op print + 스텁이라 체인 파손을 못 잡는 사각지대였던 걸 그대로 반복하지 않기 위함. +]] + +local log = {} +local function say(fmt, ...) + local line = string.format(fmt, ...) + table.insert(log, line) + print(line) +end + +--========================================================================== +-- 아주 작은 Store/State 스텁 +--========================================================================== + +local StoreMT = {} +StoreMT.__index = StoreMT + +local function Store(value, name) + return setmetatable({ _v = value, _subs = {}, _name = name }, StoreMT) +end + +local function isState(v) + return type(v) == "table" and getmetatable(v) == StoreMT +end + +function StoreMT:Get() + return self._v +end + +-- 구독. 반환값 = 구독 해제 함수(실제 구현의 unbindLifetime 자리) +function StoreMT:subscribe(fn) + local token = {} + self._subs[token] = fn + return function() + self._subs[token] = nil + end +end + +function StoreMT:Set(v) + self._v = v + for _, fn in pairs(self._subs) do + fn() + end +end + +function StoreMT:subCount() + local n = 0 + for _ in pairs(self._subs) do + n += 1 + end + return n +end + +--========================================================================== +-- Dispatch — 인덱스 기반. storeFirst 플래그로 올바른 순서/버그 순서를 전환 +--========================================================================== + +local NOOP = function() end + +local function makeDispatch(opts) + local storeChainBeforeProcess = opts.storeChainBeforeProcess + local chains = {} -- {[inst] = {[k] = {[index] = retractor}}} + local D = {} + local handlers = {} + + function D.addHandler(h) + table.insert(handlers, h) + table.sort(handlers, function(a, b) + return a.priority > b.priority + end) + end + + function D.getHandler(inst, k, v) + for _, h in ipairs(handlers) do + if h.isHandlable(inst, k, v) then + return h + end + end + error(string.format("Dispatch: 매치되는 핸들러 없음 (k=%s, v=%s)", tostring(k), tostring(v))) + end + + function D.process(inst, k, v, index) + local byKey = chains[inst] + if not byKey then + byKey = {} + chains[inst] = byKey + end + local list = byKey[k] + if not list then + list = {} + if storeChainBeforeProcess then + byKey[k] = list -- 올바른 순서: handler.process 호출 전에 등록 + end + end + if list[index] ~= nil then + error(string.format("Dispatch: (inst,%s) 인덱스 %d는 이미 점유됨", tostring(k), index)) + end + list[index] = NOOP -- 점유 마커(재귀 중 hole 방지 + 같은 index 재진입 가드) + local h = D.getHandler(inst, k, v) + list[index] = h.process(inst, k, v, index) + if not storeChainBeforeProcess then + byKey[k] = list -- 버그 순서: 재귀가 만든 별도 테이블을 덮어씀 + end + end + + function D.retractFrom(inst, k, index, v) + local byKey = chains[inst] + local list = byKey and byKey[k] + if not list then + return + end + for i = #list, index, -1 do + local retractor = list[i] + if retractor then + retractor(if i == index then v else nil) + end + list[i] = nil + end + end + + function D.depth(inst, k) + local byKey = chains[inst] + local list = byKey and byKey[k] + return if list then #list else 0 + end + + return D +end + +--========================================================================== +-- 핸들러 두 개 — StoreBind(재귀 위임) / Property(말단) +--========================================================================== + +local applied = {} -- inst.k -> 마지막으로 실제 세팅된 값 +local hintSeen = {} -- 각 핸들러가 자기 클로저에서 받은 hintValue 기록 + +local function makeHandlers(D) + local StoreBind = { + priority = 100, + isHandlable = function(_, _, v) + return isState(v) + end, + } + + function StoreBind.process(inst, k, state, index) + local unsubscribe = state:subscribe(function() + local realv = state:Get() + say(" [StoreBind idx=%d] %s 재발행 -> retractFrom(idx=%d) 후 재위임", index, state._name, index + 1) + D.retractFrom(inst, k, index + 1, realv) + D.process(inst, k, realv, index + 1) + end) + -- 등록 즉시 1회(실제 Observer 계약과 동일) + local realv = state:Get() + D.retractFrom(inst, k, index + 1, realv) + D.process(inst, k, realv, index + 1) + + return function(hintValue) + table.insert(hintSeen, string.format("StoreBind(%s)@%d hint=%s", state._name, index, tostring(hintValue))) + say(" [StoreBind idx=%d] retractor 호출 — %s 구독 해제 (hint=%s)", index, state._name, tostring(hintValue)) + unsubscribe() + end + end + + local Property = { + priority = 1, + isHandlable = function() + return true -- 최하위 폴백 + end, + } + + function Property.process(inst, k, v, index) + applied[inst .. "." .. k] = v + say(" [Property idx=%d] %s.%s = %s", index, inst, k, tostring(v)) + return function(hintValue) + table.insert(hintSeen, string.format("Property@%d hint=%s", index, tostring(hintValue))) + say(" [Property idx=%d] retractor 호출 (hint=%s)", index, tostring(hintValue)) + end + end + + D.addHandler(StoreBind) + D.addHandler(Property) +end + +--========================================================================== +-- 시나리오 실행기 +--========================================================================== + +local function runScenario(label, storeChainBeforeProcess) + say("") + say("=====================================================") + say("%s (chains 저장 순서: %s)", label, if storeChainBeforeProcess then "process 前(올바름)" else "process 後(버그 재현)") + say("=====================================================") + + applied, hintSeen = {}, {} + local D = makeDispatch({ storeChainBeforeProcess = storeChainBeforeProcess }) + makeHandlers(D) + + local inner = Store("v1", "inner") + local outer = Store(inner, "outer") + + say("") + say("[1] 최초 마운트 — outer(State>)를 인덱스 1로 진입") + D.process("Frame", "Text", outer, 1) + say(" -> 체인 깊이 = %d (기대: 3 — StoreBind/StoreBind/Property)", D.depth("Frame", "Text")) + say(" -> Frame.Text = %s (기대: v1)", tostring(applied["Frame.Text"])) + say(" -> outer 구독 %d개 / inner 구독 %d개 (기대: 1 / 1)", outer:subCount(), inner:subCount()) + + say("") + say("[2] 안쪽 store 재발행 — index 3만 갈리고 1/2는 유지돼야 함") + inner:Set("v2") + say(" -> 체인 깊이 = %d (기대: 3)", D.depth("Frame", "Text")) + say(" -> Frame.Text = %s (기대: v2)", tostring(applied["Frame.Text"])) + say(" -> outer 구독 %d개 / inner 구독 %d개 (기대: 1 / 1 — 둘 다 살아있어야)", outer:subCount(), inner:subCount()) + + say("") + say("[3] 바깥 store 재발행(새 inner2) — index 2/3이 깊은 쪽부터 정리돼야 함") + local inner2 = Store("w1", "inner2") + outer:Set(inner2) + say(" -> 체인 깊이 = %d (기대: 3)", D.depth("Frame", "Text")) + say(" -> Frame.Text = %s (기대: w1)", tostring(applied["Frame.Text"])) + say( + " -> outer %d / inner(옛) %d / inner2 %d (기대: 1 / 0 / 1 — 옛 inner 구독이 끊겨야)", + outer:subCount(), + inner:subCount(), + inner2:subCount() + ) + + say("") + say("[4] 옛 inner를 다시 건드려도 아무 일도 없어야 함(구독이 끊겼으므로)") + inner:Set("STALE") + say(" -> Frame.Text = %s (기대: w1 — STALE로 안 바뀌어야)", tostring(applied["Frame.Text"])) + + say("") + say("[5] 전체 철거 — retractFrom(index=1)") + D.retractFrom("Frame", "Text", 1, nil) + say(" -> 체인 깊이 = %d (기대: 0)", D.depth("Frame", "Text")) + say(" -> outer %d / inner2 %d (기대: 0 / 0)", outer:subCount(), inner2:subCount()) + + say("") + say("[hint 전달 기록] retractFrom(idx,v)의 v가 target index에만 가는지:") + for _, l in ipairs(hintSeen) do + say(" %s", l) + end +end + +runScenario("A~D. 정상 설계", true) +runScenario("E. 음성 대조군 — chains 저장을 process 뒤로 옮기면", false) + +say("") +say("=====================================================") +say("판정 가이드") +say("=====================================================") +say([[ + * 첫 번째(정상) 시나리오에서 [1]의 체인 깊이가 3이 아니면 — 인덱스 + 누적 자체가 틀린 것. base 문서의 index+1 규칙부터 다시 볼 것. + * [3]에서 "inner(옛) 구독 0"이 안 나오면 — 깊은 인덱스부터 정리하는 + retractFrom 순회가 깨진 것. [4]에서 STALE이 반영되는 것으로도 같은 + 증상이 드러남. + * 두 번째(음성 대조군) 시나리오에서 [1]의 체인 깊이가 **1**로 나오면 — + 2026-08-13 감사가 지적한 버그가 재현된 것: 재귀 위임이 자기만의 + list 테이블을 만들어 저장했다가 바깥 process가 그걸 덮어써서 index + 2/3의 retractor가 통째로 유실됨. 이어서 [3]에서 옛 inner 구독이 + 안 끊기고 [4]에서 STALE이 반영되면 그 유실이 실제 동작 차이로 + 이어진다는 확인. + * 대조군이 정상 시나리오와 똑같이 나온다면(= 버그가 재현 안 되면) + 이 스파이크의 모델링이 실제 구현과 어긋난 것이니, 결론을 내리기 + 전에 스파이크 쪽을 먼저 의심할 것. +]]) diff --git a/.claude/luau-test/04-dispatch-chain-retractUnder.luau b/.claude/luau-test/04-dispatch-chain-retractUnder.luau deleted file mode 100644 index f556269..0000000 --- a/.claude/luau-test/04-dispatch-chain-retractUnder.luau +++ /dev/null @@ -1,253 +0,0 @@ ---[[ - 검증 대상: Dispatch가 (inst,k)별 핸들러 체인을 배열로 소유하고, - retractUnder(inst,k,keep,v)가 꼬리부터 keep 앞까지 정리하는 설계 - (.claude/base/bind-system-plan.md "Dispatch 체인" 절)가 다단 체인 - (A->B->C)에서 실제로 정확한지 검증. - - 배경: 2026-08-08 세 번째 세션 — "전역 소유자 슬롯 하나"로 추적하는 - 1차 설계가 재귀/래핑 핸들러(A가 B로 위임하는데 A 자신도 나중에 - 재계산되는 경우)에서 깨지는 걸 반례로 확인하고 체인 방식으로 교체함. - CLAUDE.md는 "M2/M4 스파이크 검증 목록에 chains/retractUnder가 다단 - 체인에서 실제로 정확히 동작하는지가 새로 추가됨(추론만으로 확정된 것)" - 이라고 명시 — 아직 실제 Luau로 돌려본 적 없음. 이 파일이 그 검증. - - 시나리오: StoreA(바깥 store) -> StoreBind가 잡아서 그 값을 다시 - Dispatch.process로 재귀 -> 그 값이 또 다른 Store(StoreB, "이중 store" - 케이스를 흉내)일 때 두 번째 StoreBind가 또 잡아서 재귀 -> 최종적으로 - PropertyHandler가 실제 세팅. 즉 A(StoreBind)->B(StoreBind again)->C(Property) - 3단 체인. ("Store가 Store를 담지 않는다"가 설계상 확정이라 이 자체는 - UB에 가까운 입력이지만, 체인 메커니즘이 다단에서 실제로 버티는지는 - 그것과 별개로 확인해둘 가치가 있어 일부러 스트레스 테스트로 씀.) - - 실행: `luau 04-dispatch-chain-retractUnder.luau` - - 참고(2026-08-09 세션 갱신 반영): 03번과 동일하게 아래 `subscribe(fn)`은 - 이 스파이크 전용 단순화 — 실제로는 `state:Observer(fn)` + - `bindLifetime`/`unbindLifetime` 조합(`.claude/base/lifecycle-pattern.md`)을 - 쓴다. `retract`가 할 일이 "구독 해제"라는 본질은 같아서 체인/ - retractUnder 로직 검증엔 영향 없음. - - **[2026-08-13 세션 정정]** 바로 위 문단의 마지막 문장은 3단계~4단계 - (store-in-store, 즉 `State>`에 대응하는 시나리오)에 대해서는 - 틀렸음이 드러남 — 이 스파이크의 `retract`는 print만 하는 완전 no-op이라 - "자기 자신을 스스로 retract하는" 버그가 실제로 일어나도 아무 부작용 - 없이 넘어가버려서, 겉보기엔(체인 길이만 보면) 3~4단계가 정상으로 - 보임. 실제로는 같은 싱글톤 `StoreBindA` 핸들러가 같은 `(inst,k)`에 - 두 번 push되고(`list={StoreBindA,StoreBindA}`), `retractUnder`의 - 457행 "첫 매치 cutoff" 로직이 안쪽 재계산 시점에도 항상 **바깥쪽** - 인덱스를 cutoff로 잡아버려 안쪽 자기 자신이 잘못 retract되는 게 - `.claude/base/bind-system-plan.md` "확정된 디스패치 모델" 절 - 2026-08-13 항목에서 손으로 트레이싱해 확인됨 — 실제 `bindLifetime` - 기반 구현이었다면 안쪽 State 구독이 등록 직후 끊겨 이후 갱신이 - 조용히 무시됐을 것. 이 파일이 "다단 체인이 정상"이라고 결론 내린 건 - 이 스텁의 사각지대 때문이었지 실제로 검증된 게 아니었음 — 그래서 - 아래에 같은 `(inst,k)`에 같은 핸들러가 중복 push되면 즉시 error하는 - 가드(같은 세션에 base 문서 pseudocode에도 추가됨)를 넣고, 3단계에서 - 이 가드가 실제로 걸리는지 확인하는 걸로 대체. -]] - -local StoreTag = {} -local function isStoreLike(v) - return type(v) == "table" and v[StoreTag] == true -end -local function makeStore(initial) - local self = { [StoreTag] = true, value = initial, subscribers = {} } - function self.get(_self) - return self.value - end - function self.set(_self, v) - self.value = v - for _, fn in self.subscribers do - fn(v) - end - end - function self.subscribe(_self, fn) - table.insert(self.subscribers, fn) - end - return self -end - --- Relate 대용 (weak-key까지는 이 스파이크에서 안 다룸, 순수 로직 검증이 목적 — --- weak-key/GC 쪽은 07-relate-weak-table-gc.luau가 따로 다룸) -local chains = {} -- [inst] = { [k] = { handler, handler, ... } } -local function chainFor(inst, k) - chains[inst] = chains[inst] or {} - chains[inst][k] = chains[inst][k] or {} - return chains[inst][k] -end - -local Dispatch = {} -local handlers = {} - -function Dispatch.addHandler(h) - table.insert(handlers, h) - table.sort(handlers, function(a, b) - return a.priority > b.priority - end) -end - -function Dispatch.getHandler(inst, k, v) - for _, h in handlers do - if h.isHandlable(inst, k, v) then - return h - end - end - return nil -end - -function Dispatch.process(inst, k, v) - local h = Dispatch.getHandler(inst, k, v) - if not h then - print(string.format(" (매치 없음: %s.%s = %s)", tostring(inst), tostring(k), tostring(v))) - return - end - local list = chainFor(inst, k) - -- [2026-08-13 세션 신설] 같은 (inst,k)에 같은 핸들러 객체가 이미 체인에 - -- 있으면 error — State>류 재진입 디스패치를 조용히 깨지게 - -- 두지 않고 즉시 실패시킴 (base/bind-system-plan.md 동시 반영) - for _, existing in list do - if existing == h then - error( - string.format( - "Dispatch: handler '%s' already active for %s.%s — re-entrant dispatch (e.g. State>) is not supported", - h.name, - tostring(inst), - tostring(k) - ) - ) - end - end - table.insert(list, h) - print(string.format(" [chain push] %s.%s <- %s (체인 길이=%d)", tostring(inst), tostring(k), h.name, #list)) - h.process(inst, k, v) -end - --- .claude/base/bind-system-plan.md의 pseudo code 그대로 옮김 -function Dispatch.retractUnder(inst, k, keep, v) - local list = chainFor(inst, k) - local cutoff = 0 - if keep then - for i, h in list do - if h == keep then - cutoff = i - break - end - end - end - for i = #list, cutoff + 1, -1 do - local retractedHandler = list[i] - local passedValue = (i == cutoff + 1) and v or nil - print( - string.format( - " [retractUnder] %s.%s: %s.retract(v=%s) 호출, 체인에서 제거", - tostring(inst), - tostring(k), - retractedHandler.name, - tostring(passedValue) - ) - ) - retractedHandler.retract(inst, k, passedValue) - list[i] = nil - end -end - --- 핸들러: StoreBind (self 식별을 위해 핸들러 테이블 자기 자신을 process 안에서 캡처) -local function makeStoreBindHandler(name, priority) - local self - self = { - name = name, - priority = priority, - isHandlable = function(inst, k, v) - return isStoreLike(v) - end, - process = function(inst, k, store) - local function reprocess(realv) - print( - string.format( - " [%s] %s.%s 재계산 -> retractUnder(keep=self) 먼저, 그 다음 재귀 process", - name, - tostring(inst), - tostring(k) - ) - ) - Dispatch.retractUnder(inst, k, self, realv) - Dispatch.process(inst, k, realv) - end - store:subscribe(reprocess) - reprocess(store:get()) - end, - retract = function(inst, k, v) - print(string.format(" [%s.retract] 나 자신(구독) 정리", name)) - end, - } - return self -end - -Dispatch.addHandler(makeStoreBindHandler("StoreBindA", 900)) -Dispatch.addHandler({ - name = "PropertyHandler", - priority = 0, - isHandlable = function() - return true - end, - process = function(inst, k, v) - print(string.format(" [PropertyHandler] 실제 세팅: %s.%s = %s", tostring(inst), tostring(k), tostring(v))) - end, - retract = function(inst, k, v) - print(string.format(" [PropertyHandler.retract] 이전 값 무름")) - end, -}) - -print('=== 1단계: StoreA(값="hello") 바인드 ===') -local storeA = makeStore("hello") -Dispatch.process("Frame1", "Text", storeA) -print(" 현재 체인 길이:", #chainFor("Frame1", "Text")) - -print() -print("=== 2단계: StoreA 값을 다른 일반 값으로 바꿈(체인이 A 밑을 정확히 정리하는가) ===") -storeA:set("world") -print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개여야 정상)") - -print() -print("=== 3단계 [2026-08-13 세션 재작성]: StoreA 값을 store로 다시 바꿔서") -print(" (State>에 대응) 재진입 디스패치를 유도 — 이제 가드가") -print(" 즉시 error해야 정상 ===") -local storeB = makeStore("nested") -local ok, err = pcall(function() - storeA:set(storeB) -end) -print(" error로 막혔는가:", not ok) -print(" 에러 메시지:", tostring(err)) -print( - " 현재 체인 길이:", - #chainFor("Frame1", "Text"), - "(가드가 push 전에 error하므로 1이어야 정상 — StoreBindA만 남고 오염 없음)" -) - -print() -print("=== 4단계: 가드가 걸린 뒤에도 같은 자리에 정상 값으로 다시 바인드하면") -print(" 문제없이 복구되는가(에러가 체인을 영구 오염시키지 않는가) ===") -storeA:set(42) -print(" 현재 체인 길이:", #chainFor("Frame1", "Text"), "(A->Property 2개로 정상 복구되어야 함)") - ---[[ - 확인 포인트 (이게 이 파일의 핵심 목적): - 1. 2단계에서 storeA:set("world") 이후 체인 길이가 정확히 2(A, Property)로 - 돌아오는가 — retractUnder가 이전 PropertyHandler를 정리하고 새로 - push했는가, 아니면 계속 누적돼서 체인이 무한정 길어지는가? - (누적되면 버그 — 체인이 GC 안 되는 메모리 누수이자 논리 오류) - 2. **[2026-08-13 세션 정정]** 3단계는 원래 "A(StoreBindA) 자신은 살아남고 - 그 밑만 정리되는가"를 확인하려 했으나, 이 스파이크의 `retract`가 - print만 하는 no-op이라 실제 자기-retract 버그(같은 싱글톤 핸들러가 - 같은 (inst,k)에 중복 push되고, retractUnder의 첫-매치 cutoff가 안쪽 - 재계산 시점에 바깥쪽 인덱스를 잡아 안쪽이 스스로를 retract하는 것 — - 상세 트레이싱은 `.claude/base/bind-system-plan.md` "확정된 디스패치 - 모델" 절 2026-08-13 항목)를 이 스텁으로는 절대 못 잡는다는 게 드러남. - 그래서 이제 3단계는 "같은 핸들러 중복 push를 Dispatch.process가 - push 전에 잡아 즉시 error하는가"를 확인 — `ok`가 `false`고 체인 - 길이가 오염 없이 1로 남는 게 정상. - 3. 4단계: 가드가 발동한 뒤에도 같은 (inst,k)에 정상적으로 새 값을 - 바인드할 수 있는가(에러가 chains 자료구조를 영구히 깨뜨리지 - 않는가) — 체인 길이가 다시 2(A, Property)로 정상 복구돼야 함. - 4. 스택 오버플로 없이 전부 정상 종료되는가. -]] diff --git a/.claude/luau-test/19-ownership-refcount-relate-patterns.luau b/.claude/luau-test/19-ownership-refcount-relate-patterns.luau index 8fbd266..ddb3fd3 100644 --- a/.claude/luau-test/19-ownership-refcount-relate-patterns.luau +++ b/.claude/luau-test/19-ownership-refcount-relate-patterns.luau @@ -1,4 +1,28 @@ --[[ + *** [2026-08-13 감사] B/C 섹션은 폐기·변경된 설계를 검증 중 — 돌리기 전에 + *** 아래대로 먼저 고칠 것. A 섹션은 그대로 유효(단 `kTagMap`은 이제 없음, + *** 클로저가 `v`를 직접 캡처하므로 `tagNameMap` 하나만 남음 — 참조 카운트 + *** 로직 자체는 안 바뀌어서 A의 검증 내용은 그대로 성립). + *** + *** B) `rawNew`+`owners` 수동 레지스트리는 2026-08-13 하루 안에 두 번 + *** 뒤집혀 완전히 사라짐. 지금 설계는 그룹이 **공개 `AttributeKey(name)`** + *** 으로 항상 인덱스 1에 `Dispatch.process`만 부르고(retractFrom은 + *** 부르지 않음 — 부르면 점유 체크가 무력화됨), 철거는 반환 클로저가 + *** 자기가 등록한 이름 전부에 대해 수행. 소유권 충돌은 별도 레지스트리가 + *** 아니라 **`Dispatch.process`의 인덱스 점유 체크**가 잡음. + *** → B는 "그룹 A가 점유한 이름을 그룹 B가 잡으면 error가 나는가"를 + *** 인덱스 점유 모델로 다시 써야 함(`base/attribute-plan.md` + *** "이름 소유권"/"메커니즘" 절). + *** + *** C) `claimOwner`가 "같은 owner면 no-op(false)"이던 3분기가 감사에서 + *** 버그로 판정돼 둘로 쪼개짐 — nested(`rawAdd`)는 **엄격**(같은 owner + *** 재클레임도 error, `Slot{a,a}`를 막기 위함), top-level만 + *** `claimOwnerAt(element, inst, k)`으로 **위치까지** 봐서 spurious + *** 재발행만 false. `destroySlotTree`/`rawRemove`의 `releaseOwner` + *** 호출도 새로 추가됨. + *** → C는 이 두 함수를 각각 검증하도록 다시 써야 함 + *** (`base/slot-plan.md` "요소 소유권 — `elementOwner`" 절). + 검증 대상: "retract는 항상 불림" 전면 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 가지 소유권/참조카운트 추적 로직이 실제로 짜인 대로 동작하는지 — 전부 여러 위치/여러 사이클에 걸쳐 상태가 정확히 갱신되는지가 핵심이라 diff --git a/.claude/luau-test/README.md b/.claude/luau-test/README.md index 294b998..53211c3 100644 --- a/.claude/luau-test/README.md +++ b/.claude/luau-test/README.md @@ -49,7 +49,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `01-two-pass-array-hash-order.luau` | 배열 파트(children/Ref) 먼저, 해시 파트(프로퍼티/이벤트) 나중이라는 두 패스 순회 계약 | `bind-system-plan.md` "props 순회 순서", ROADMAP M0-4 | | `02-none-sentinel-vs-nil-holes.luau` | **[2026-08-09 커밋 f198fd9 반영해 전면 재작성]** 순서가 중요한 배열(PreRef pre-pass, sourceList)은 `None` 소진이 맞고, 순서가 안 중요하고 재사용이 필요한 배열(Ref 콜백/대기자)은 `nil`+슬롯 재사용이 맞다는 최종 구분 + `None`을 잘못 쓰면 배열이 무한정 자라는 버그의 정량적 재현 | `bind-system-plan.md` "왜 None이 아니라 nil인가"(2026-08-09 열한 번째 세션 최종 정정), ROADMAP M0-4 | | `03-recursive-store-bind-dispatch.luau` | `process`/`retract` 재귀 재-dispatch 기본 모델, 우선순위 스캔 | `bind-system-plan.md` "확정된 디스패치 모델", ROADMAP M0-3 | -| `04-dispatch-chain-retractUnder.luau` | `Dispatch` 체인 + `retractUnder`가 다단(A→B→C) 재-dispatch에서 정확한지, **[2026-08-13 재작성]** 같은 `(inst,k)`에 같은 핸들러가 중복 push되는 경우(`State>`류) `Dispatch.process`의 신규 가드가 즉시 error하고 이후 정상 복구되는지 | `bind-system-plan.md` "Dispatch 체인" + "확정된 디스패치 모델" 2026-08-13 항목, 2026-08-08 세 번째 세션 / 2026-08-13 세션 | +| `04-dispatch-chain-retractFrom.luau` | **[2026-08-13 감사에서 전면 재작성 + 파일명 변경]** 인덱스 기반 `chains`/`Dispatch.retractFrom`이 다단 재귀 위임에서 정확한지 — 3단 체인이 인덱스 1/2/3으로 안 겹치고 쌓이는지(= `State>` **정상 동작**, UB 아님), 안/바깥 store 재발행 시 깊은 인덱스부터 정리되는지, hint가 target 인덱스에만 가는지 + **음성 대조군**: `chains:SetStrong`을 `handler.process` 뒤에 두면 최초 마운트에서 하위 retractor가 유실되는 버그 재현. 옛 버전은 핸들러 identity 기반 추적과 "중복 push 즉시 error" 가드를 검증했는데 그 가드는 다섯 번째 세션 재설계로 **없어져서** 설계와 정반대를 테스트하고 있었음 | `bind-system-plan.md` "Dispatch 체인"(2026-08-13 다섯 번째 세션 재설계) + 2026-08-13 감사 | | `05-store-state-diamond-propagation.luau` | push-invalidate/pull-recompute가 다이아몬드 의존성에서 중복 재계산 없이 동작하는지 | ROADMAP M0-1 | | `06-component-boundary-nil-hole-props.luau` | `props.Modifier or None` 관용구가 컴포넌트 경계 nil-hole을 막는지 + `Params` 타입 체크 | `component-composition-plan.md` "필수 관용구", ROADMAP M0-5 | | `07-relate-weak-table-gc.luau` | `Relate`의 lazy 서브테이블 생성 + weak-key GC가 실제로 동작하는지 | `relate-plan.md` "M2 착수 시 실측 확인" | @@ -64,7 +64,7 @@ ROADMAP 항목 근거인지, 어떻게 실행하는지, 실행 후 뭘 확인해 | `16-type-store-key-typefunction.luau` (타입체크 전용) | `Store`가 `T`의 각 필드를 `Source`로 감싼 타입을 Luau `type function`(`types.newtable`/`:setproperty`/`ty:properties()`)으로 실제 합성 가능한지, 결과가 구조적으로 `Source` 필드를 만족하는지 | `bind-system-plan.md` "`store.key` 레코드 필드 타이핑" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-10 | | `17-modifier-index-tableclone-chaining.luau` | Modifier의 제네릭 `__index`+`table.clone` 체이닝 — 임의 필드 이름에 대해 즉석 setter가 만들어지는지, `table.clone`이 메타테이블을 참조로 공유해 여러 단계 clone에서도 체이닝이 안 끊기는지, 원본이 mutate 안 되는지, 형제 분기끼리 오염 안 되는지 | `modifier-plan.md` "런타임은 클래스별 코드 없이 base에 딱 하나만 있으면 됨" 절 + "`table.clone`의 정확한 동작 — 확인됨" 절(2026-08-12 열일곱 번째 세션), `pre-implementation-audit.md` 1-11 | | `18-relate-mutual-cycle-gc.luau` | **[2026-08-13 신규]** 서로 다른 두 `Relate`가 서로의 키를 상대방의 강한 값으로 제공하는 상호 순환은 Luau에 ephemeron이 없어 GC가 못 푼다는 주장(지금까지 공식 문서 인용으로만 뒷받침됨) — 음성 대조군(순환 재현)과 양성 대조군(한쪽을 weak-value로 낮추면 풀리는지) 둘 다 실측 | `relate-plan.md` "위험한 패턴" 절(2026-08-12 열세/열네 번째 세션), `slot-plan.md`의 `kSlotMap`/`slotOwner`/`elementOwner` 실사례 | -| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규]** "retract는 항상 불림" 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 소유권/참조카운트 알고리즘(A: Tag `kTagMap`/`tagNameMap` 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`, B: Attribute `rawNew`+`owners` 소유권 — 충돌 시 error + 사이클 간 캐시 키 재사용, C: Slot `elementOwner`의 `claimOwner`/`releaseOwner` — 같은 owner 재클레임은 no-op, 다른 owner는 error)가 실제로 정확히 갈리는지 | `tag-plan.md` "메커니즘" 절, `attribute-plan.md` "이름 소유권" 절, `slot-plan.md` "요소 소유권 — `elementOwner`" 절(전부 2026-08-12 세션들, 03/04/11번과는 다른 새 알고리즘 모양이라 별도 실측 필요하다고 판단) | +| `19-ownership-refcount-relate-patterns.luau` | **[2026-08-13 신규]** "retract는 항상 불림" 정정(2026-08-12 열한 번째 세션) 이후 새로 생긴 세 소유권/참조카운트 알고리즘(A: Tag `kTagMap`/`tagNameMap` 참조 카운트 — 여러 위치가 같은 이름을 겹쳐 가져도 마지막 홀더가 빠질 때만 실제 `RemoveTag`, B: ~~Attribute `rawNew`+`owners` 소유권~~ — **[2026-08-13 감사] 이 섹션은 폐기된 설계를 검증 중이라 재작성 필요**: `rawNew`/`owners` 수동 레지스트리는 두 번 뒤집혀 사라졌고, 지금은 그룹이 공개 `AttributeKey(name)`으로 항상 인덱스 1에 위임하고 `Dispatch.process`의 **점유 체크**가 소유권 충돌을 대신 잡음 — 검증 대상도 그쪽으로 바뀌어야 함, C: Slot `elementOwner`의 `claimOwner`/`releaseOwner` — **[2026-08-13 감사] 이 섹션도 갱신 필요**: nested는 엄격 `claimOwner`(같은 owner 재클레임도 error), top-level만 `claimOwnerAt`으로 `(inst,k)`까지 봐서 spurious 재발행을 구분)가 실제로 정확히 갈리는지 | `tag-plan.md` "메커니즘" 절, `attribute-plan.md` "이름 소유권" 절, `slot-plan.md` "요소 소유권 — `elementOwner`" 절(전부 2026-08-12 세션들, 03/04/11번과는 다른 새 알고리즘 모양이라 별도 실측 필요하다고 판단) | | `20-slot-splice-index-arithmetic.luau` | **[2026-08-13 신규]** `Slot:Splice(index, removeCount, ...newElements)`의 shift+recompute 1회 계산이, `Extract`/`Add` 반복으로 재현한 참조 구현과 항상 같은 결과를 내는지 — 제거/삽입 길이가 다를 때(delta 양수/음수) 뒤 요소가 밀리는 방향과 양을 헷갈리는 off-by-one 위험(이 프로젝트가 `Dispatch.recompute`에서 실제로 냈던 것과 같은 클래스의 버그)을 경계값 케이스로 검증 | `slot-plan.md` "확정" CRUD 표 + "`Splice` 신설" 절(2026-08-12 열다섯 번째 세션), `bind-system-plan.md`의 `recompute` off-by-one 수정 사례(2026-08-11 여섯 번째 세션) | ## 공통 유틸리티 diff --git a/.claude/question.md b/.claude/question.md index cc6e8ba..d65ea73 100644 --- a/.claude/question.md +++ b/.claude/question.md @@ -10,6 +10,132 @@ ## 지금 열려있는 것 (우선순위순) +### 0-Z. ⭐ **최우선 — Attribute 이름 소유권을 무엇으로 판정할 것인가** (2026-08-13 여섯 번째 세션, 사용자가 다음 세션 심층 분석으로 이관) + +**이게 지금 유일하게 `base/` 반영을 막고 있는 결정.** 아래 0-A의 +재디스패치 모델은 나머지가 전부 확정됐고, 이 항목 하나만 정해지면 +`bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/`attribute-plan.md`를 +한 번에 옮기면 됨. + +**문제**: 새 모델(핸들러 선비교)에서는 그룹 A가 잡아둔 +`AttributeKey("foo")` 인덱스 1에 그룹 B가 들어와도 **양쪽 다 `StoreBind`에 +매치되므로 "같은 핸들러"로 판정돼 조용히 갈아탐.** 그리고 나중에 A의 +클로저가 자기 이름들을 `retractFrom`할 때 B의 바인딩을 대신 철거함 +(교차 오염). 예전 "조용한 last-write-wins"가 그대로 돌아옴 — 이번 감사에서 +고쳤던 바로 그 증상. 즉 **Dispatch의 점유 체크가 대신 잡아주던 걸 이제 +Attribute가 직접 해야 함.** + +**사용자 방향(2026-08-13, 심층 분석은 다음 세션)**: +> "Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요. +> 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때 +> 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며 +> 심층 분석해보겠습니다." + +- **"이전 결정을 다시 가져온다"** = 2026-08-13 네 번째 세션의 이름별 + claimant `Relate`(당시 이름 `owners`). **당시 기각 사유는 새 모델에서 + 구조적으로 소멸함** — 그때 버그는 "소유권 반납이 `process`의 `v==nil` + 분기에만 있어서, 그룹이 이름을 통째로 놓는 경로가 그 분기를 안 타 + 옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상 불리므로 거기서 + 반납**하면 그 구멍이 안 생김. 사용자의 "본인 retract 처리를 수행할 때 + 무언가 하면 될듯"이 정확히 이 지점. +- **"막 깊게 Key → Group 필요한 것 같지는 않다"** — 키에서 그룹으로 + 거슬러 올라가는 양방향 레지스트리까지는 필요 없고, 이름 → 현재 + claimant 단방향이면 충분할 것이라는 방향. +- 원문 맥락과 기각된 두 중간안(`rawNew` 전용 키, `AttributeGroupKeyHandler` + 체크포인트)은 `archive/checkpoint-handler-pattern-reversed.md`, + 분석은 `research/dispatch-redispatch-diff-plan.md` 5절. + +**대안 후보(정리해둠)**: (a) 이름별 claimant `Relate`를 Attribute에 +국소적으로 — 권고, (b) UB로 두고 문서로만 금지 — 증상이 "조용한 오작동 + +교차 오염"이라 다른 UB들(즉시 스택오버플로/즉시 error)보다 나빠서 비권장, +(c) `Dispatch`에 claimant 개념 일반화 — 이번에 걷어낸 방향이라 반대. + +### 0-A. `hintValue` 폐기 → process 하강 중 핸들러 비교 (2026-08-13 여섯 번째 세션, **Attribute 건 외 확정**) + +**검토 결과 사용자 지적이 맞음 — 현행 `hintValue`엔 실제 결함이 있음.** +힌트가 "그 자리에 곧 디스패치될 raw 값"이라 `None` 센티널이나 `State`/ +`Tween` 같은 래퍼가 그대로 넘어갈 수 있고, 그러면 말단 핸들러의 +`isTag(hint)` 가드가 거짓이 되어 **깜빡임/재생성 방지가 조용히 꺼짐** +(정확성은 유지돼서 지금까지 안 드러났음). 상세 재현·분석·제안은 +`research/dispatch-redispatch-diff-plan.md`. + +**후속 라운드에서 모델은 거의 확정됨** — 래핑 핸들러가 `retractFrom`을 +선행 호출하는 걸 폐기하고, `Dispatch.process` 안에서 **핸들러를 먼저 +비교**해 (같으면 그 자리 클로저에 새 값을 넘기고 자기 `process` 재호출, +다르면 그 자리부터 아래를 전량 철거). 이걸로 (a) 힌트의 타입이 +구조적으로 보장되고(같은 핸들러일 때만 값이 넘어가므로), (b) 깊은 체인의 +힌트 유실도 사라지며(각 레벨이 자기 재프로세스에서 자기 힌트를 받음), +(c) `oldValue`를 따로 넘기자던 보완안은 불필요해짐(사용자 지적: +"클로저라 이미 본인이 알지 않아요?" — 맞음, `chains`에 추가로 저장할 건 +비교용 `handler` 하나뿐), (d) `HandlerChanged` 마커도 불필요(핸들러가 +바뀌었다는 건 retractor가 `nil` 힌트로 불린다는 사실로 이미 표현됨). + +**남은 열린 항목은 Attribute 이름 소유권 하나뿐 — 위 0-Z로 분리해 +최우선 배치**(사용자가 다음 세션에 직접 스케치하며 심층 분석하기로). +그 하나 외에는 이 항목에 결정할 게 없음. + +**실행 규모**: `base/`의 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ +`attribute-plan.md` 의사코드 재작성 — 0-Z 하나만 정해지면 한 번에 옮기면 +됨(어디를 어떻게 고칠지는 `research/dispatch-redispatch-diff-plan.md` 6절에 +파일별로 적어둠). **그때까지 `base/`의 현행 `hintValue` 서술이 유효** — +아직 안 옮겼다는 걸 잊고 base만 읽으면 옛 모델로 구현하게 되니 주의. + +### 0-B. `dispose(any)` — 시그니처/범위 (2026-08-13 여섯 번째 세션 신설, 사용자 제안) + +`State` 교체를 파괴가 아니라 **언마운트**로 확정하면서(`state`와 +동일, `base/slot-plan.md`), 명시적 파괴 수단으로 base 탑레벨 `dispose(value)`를 +제공하기로 방향 확정. "이 값이 지금 어디 마운트돼 있는가"는 이미 +`elementOwner`가 들고 있어(다중 마운트 error 판정용) 새 부기가 필요 없음. + +**[확정, 사용자] 시맨틱은 "거부"** — 대상이 아직 어느 트리에 의해 +살아있길 요구되고 있으면 **파괴를 거부하고 즉시 error**. 떼어내주지 +않음(떼어내는 건 `Set`=언마운트의 몫, `dispose`는 그 뒤). 근거: 엔진은 +`Destroy`/`Clear`에 에러를 안 내지만 quad의 `_elements`/`lengthList`/ +`sourceList`/`elementOwner`는 그 순간 어긋나므로, **quad가 관리 중인 값을 +안전하게 지우는 유일한 경로**가 이것이고 "지금 지우면 안 되는 상태"를 +잡아주는 게 존재 이유. 이걸로 "`Set` 전에 직접 `Destroy()`"가 UB에서 +명확한 에러로 바뀜. + +**미확정**: 시그니처(`dispose(any)`가 맞는지, 타입을 어떻게 좁힐지), +대상 범위(Slot 외에 Instance/Observer/Effect까지 커버하는지), +`unbindLifetime`과의 역할 분담. + +### 0-C. 포탈 — `Extract` 비파괴 경로로 해결되는가 (2026-08-13 여섯 번째 세션 신설, 사용자 제안 — **해결됨**) + +**질문(사용자)**: `stateSlot:Get()`으로 Slot을 뽑아두고 → `Set()`으로 다른 +Slot을 넣고 → 뽑아둔 Slot을 다른 곳에 넣는 게 되는가. 되면 "포탈"이 +이걸로 해결됨. + +**조사 결과**: 지금은 **안 됨** — `:List`의 `reconcile`이 교체 시 +`rawRemove`(제거 **+ 파괴**)를 부르므로 뽑아둔 레퍼런스가 이미 파괴된 +Slot이 됨. 막는 게 소유권 규칙이 아니라 "제거 = 파괴"라는 reconcile의 +선택 하나뿐이라는 게 핵심. + +**그런데 나머지 부품은 이미 다 있음**: `Extract`/`ExtractAll`/`Splice`가 +이미 비파괴로 확정돼 있고, `claimOwner`/`releaseOwner`가 소유권을 정확히 +이양하며, `attachSlot`은 재마운트를 구조적으로 이미 지원함(자식이 파괴만 +안 됐다면 새 물리 부모로 그대로 flush). 즉 **"포탈은 별도 메커니즘이 +필요하다"는 기존 전제가 틀렸을 가능성이 큼.** + +**[해결, 사용자 결정]** opt-in이 아니라 **기본 동작**으로 확정 — +`State` 교체는 원래부터 `state`와 같이 언마운트여야 했다는 +판단이라, 포탈은 별도 기능이 아니라 그 결정의 귀결. 안 지운 Slot은 +아무도 안 들고 있으면 GC되고, 지금 죽이려면 `dispose`(위 0-B). +**[해소, 사용자 지적] "해제 짝"이라는 새 API는 필요 없음** — 옛 owner에 +대해 그냥 `setLength(ownerKey, position, 0)` + `setOffsetSource(ownerKey, +position, None)`을 다시 부르면 됨(이미 확정된 "마운트 안 하는 위치는 +`0`/`None` 등록" 관용구 그대로). 즉 **해제 = 0/`None`으로 재등록**. +`state>`류로 offset이 밀리는 문제는 `state>`와 +같은 범주로 **"그냥 확인된 것"으로 수용**(평탄화 도구가 처리, 케이스 드묾). +상세는 `base/slot-plan.md` "`State` 교체는 파괴가 아니라 언마운트" 절. + +**관련(같은 절, 위 결정으로 함께 해소)**: `State`를 +`nil`↔`slotA`로 왕복시키는 코드가 두 번째 등장부터 깨진 서브트리를 내던 +문제도 언마운트 전환으로 사라짐. 대신 **`Set`으로 덮어쓰기 *전에* +이전 값을 직접 `Destroy()`하는 건 UB**(`state`에서 먼저 +`frame:Destroy()`하고 `Set`하는 것과 같은 문제) — 순서는 항상 +`Set`(언마운트) → 그 다음 정리. + ### 0. 추가 프리미티브 필요성 — 사용자 요청, 대부분 수렴(2026-08-06~07) 사용자 질문: "다른 독립 프리미티브나 종속 파생 데이터는 뭐가 더 필요할 것 @@ -199,7 +325,8 @@ context-rejected.md`. 아래는 그중 **아직 실제로 열려있는 것만** `LifetimeHandle`/`Relate` 인터페이스(타입만)를 `ROADMAP.md` M2로 옮기고, quad-roblox 실 구현만 M8에 남김 — 우선순위1-9 해소. - **[해소됨]** retract 시 "이전 핸들러" 추적 책임 소재 — Dispatch 체인 - (`chains`)+`Dispatch.retractUnder`로 2026-08-08 세 번째 세션에 이미 + (`chains`)+`Dispatch.retractFrom`(2026-08-08 세 번째 세션엔 `retractUnder`라는 + 이름이었음, 2026-08-13 다섯 번째 세션에 인덱스 기반으로 재설계되며 개명)로 이미 해소(`pre-implementation-audit.md` 1-2, `bind-system-plan.md` "Dispatch 체인" 절). **[해소됨, 2026-08-09 세션]** `:Compute`의 `previous` 인자 오버엔지니어링 의심도 기각(`bind-system-plan.md` "previous" 절, diff --git a/.claude/research/debug-tooling-plan.md b/.claude/research/debug-tooling-plan.md index 0cb3776..b329305 100644 --- a/.claude/research/debug-tooling-plan.md +++ b/.claude/research/debug-tooling-plan.md @@ -140,12 +140,13 @@ instance로 직접 트윈되어버리는지" 같은 걸 구분하고 싶음. 이 Instance에 직접 `TweenService:Create()`를 건 것을 구분하려면 **핸들러 자신만 아는 맥락**이 필요. -**제안**: `isHandlable`/`priority`/`process`/`retract` 4종 계약에 선택적 -5번째 훅을 추가 — `describe(inst, k, v): DebugInfo?`(가칭, 기본 미구현 +**제안**: `isHandlable`/`priority`/`process` 3종 계약(2026-08-13 다섯 번째 +세션에 `retract`가 `process` 반환값으로 합쳐지기 전엔 4종)에 선택적 +훅 하나를 추가 — `describe(inst, k, v): DebugInfo?`(가칭, 기본 미구현 = no-op과 동일 효과). quad-debug가 트레이스 이벤트를 기록할 때 해당 키를 처리한 핸들러에게 `describe`가 있으면 호출해서 사람이 읽을 수 있는 부가 정보(예: Tween 핸들러라면 "store-bind 유발" vs "직접 세팅"인지, 어떤 store -key에서 왔는지)를 이벤트에 덧붙임. `bind-system-plan.md`가 이미 "4종 계약은 +key에서 왔는지)를 이벤트에 덧붙임. `bind-system-plan.md`가 이미 "계약은 지금 확정이지만 실제 구현하며 부족한 지점이 보이면 그때 hook 추가(점진적 확장)"라고 열어둔 것과 정확히 맞아떨어지는 케이스 — 새 원칙이 아니라 이미 예견된 확장. diff --git a/.claude/research/dispatch-redispatch-diff-plan.md b/.claude/research/dispatch-redispatch-diff-plan.md new file mode 100644 index 0000000..1a8f283 --- /dev/null +++ b/.claude/research/dispatch-redispatch-diff-plan.md @@ -0,0 +1,240 @@ +# 재디스패치 = 하강 diff — `retractFrom` 선행 폐기, 핸들러 비교를 클로저 호출 앞에 (설계안) + +**상태**: research — 2026-08-13 여섯 번째 세션에 사용자가 제기하고 방향을 +제시, 같은 세션 후속 라운드에서 모델이 거의 확정됨. **`base/` 반영 전 +남은 열린 항목은 아래 5절의 하나뿐**(Attribute 이름 소유권). 그 하나만 +정해지면 `bind-system-plan.md`/`tag-plan.md`/`slot-plan.md`/ +`attribute-plan.md`를 한 번에 옮기면 됨. + +> **문서 이력**: 최초엔 `dispatch-hint-to-oldvalue-plan.md`라는 이름으로 +> "힌트 대신 `oldValue`를 넘기자"는 보완안을 담고 있었으나, 사용자가 +> **"이전 값인 oldValue는 처음부터 클로저라 이미 본인이 알지 않아요?"** +> 라고 지적해 그 보완안이 통째로 불필요함이 드러남(아래 3-2) — 파일명도 +> 바꿈. + +## 1. 현행 `hintValue`의 실제 결함 (사용자 지적, 확인됨) + +현행 계약: `Dispatch.retractFrom(inst,k,index,v)`가 `index` 자리 retractor에 +`v`를 넘김. 그 `v`는 **"그 자리에 곧 디스패치될 raw 값"**이다. + +**문제: 그 raw 값이 핸들러가 이해하는 의미 값이라는 보장이 전혀 없음.** + +### 1-1. 힌트로 `None` 센티널이 그대로 넘어감 (사용자 제기, 재현됨) + +`[AttributeKey "foo"] = state`, state가 `5` ↔ `None`을 오갈 때: + +| 사이클 | 체인 | +|---|---| +| `None` | `StoreBind@1` → `NoneHandler@2` → `AttributeKeyHandler@3` | +| `5` | `StoreBind@1` → `AttributeKeyHandler@2` | + +`5` → `None`으로 갈 때 StoreBind는 `retractFrom(inst,k,2,None)`을 부르고, +인덱스 2의 `AttributeKeyHandler` retractor가 **`hintValue = None`** 을 받음. +Attribute는 클로저가 no-op이라 무해하지만, `TagHandler`였다면 +`isTag(None)`이 거짓이라 "이 자리가 Tag이길 그만둔다"로 오판해 이름 전부를 +`RemoveTag`함. + +### 1-2. 래핑이 한 겹만 끼어도 힌트가 무의미해짐 + +같은 이유로 힌트가 `State`, `Tween`, 그 외 미래에 추가될 어떤 래퍼여도 +말단 핸들러는 그걸 해석 못 함. 앞서 문서화했던 "깊이 2 이상에선 `nil`" +문제는 이 결함의 **한 특수 케이스**였을 뿐 — 진짜 문제는 깊이가 아니라 +**"힌트의 타입이 계약으로 정해져 있지 않다"**는 것. + +### 1-3. 그래서 힌트 기반 최적화가 "가끔 조용히 꺼진다" + +`Tag`의 `Contains` skip, `Ref`/`Slot`의 identity 비교는 전부 힌트가 +자기 타입일 때만 동작 — 위 경로들에선 소리 없이 전량 정리로 퇴화함. +**정확성은 유지되지만(그래서 지금까지 안 드러남) 깜빡임/재생성 방지라는 +존재 이유가 무너짐.** 특히 `Slot`은 "가드가 없으면 마운트된 서브트리 +전체가 파괴됐다 재생성"이라 파급이 큼. + +## 2. 채택 모델 — 재디스패치는 "철거 후 재구축"이 아니라 "하강 diff" + +사용자 정리: + +> "차라리 process 를 쭉 진행해, 이전거랑 자신 것이랑 다르면 nil 또는, +> 그걸 나타내는 HandlerChanged 등의 무언가로 아래를 전부 죽이고, 같으면 +> 값을 계속 넣어가며 전파해야할듯, 같은것이면 본인 인덱스에 대해서만 +> 후처리를 해." +> +> "retract 계약이 지금 보면, 일단 시도해보는데, 이전이 내 핸들러면 내가 +> 처리, 아니고 다르면 아래쪽을 그냥 retract 처리해서 전부 제거" + +즉 **래핑 핸들러가 재-dispatch 전에 `retractFrom`을 먼저 때리는 것을 +폐기**하고, 그냥 `Dispatch.process`를 아래로 내려보냄. 비교는 +`Dispatch.process` 안에서 일어남: + +```lua +-- chains[inst][k][index] = { handler = h, retractor = fn } +function Dispatch.process(inst, k, v, index) + local list = <확보 + chains에 등록> -- 기존 순서 규칙 그대로(h.process 前) + local slot = list[index] + local h = Dispatch.getHandler(inst, k, v) -- 매치 실패는 기존대로 즉시 error + + if slot ~= nil and slot.handler == h then + -- 같은 핸들러: 아래를 안 건드리고, 이 자리 클로저에 새 값을 넘겨 + -- 스스로 전이를 처리하게 함. v는 h.isHandlable(v)가 참임이 보장됨. + slot.retractor(v) + slot.retractor = h.process(inst, k, v, index) + else + -- 다른 핸들러(또는 빈 자리): 이 자리부터 아래를 전부 철거 후 새로 설치 + Dispatch.retractFrom(inst, k, index, nil) + list[index] = { handler = h, retractor = h.process(inst, k, v, index) } + end +end +``` + +`StoreBind`/`NoneHandler`는 이제 그냥: + +```lua +Dispatch.process(inst, k, realv, index + 1) -- retractFrom 선행 호출 없음 +``` + +### 2-1. 이걸로 동시에 풀리는 것들 + +- **힌트의 타입이 보장됨** — 클로저에 값이 넘어가는 건 **오직 핸들러가 + 같을 때뿐**이고, "같다"는 건 `getHandler(inst,k,v) == slot.handler` + 로 판정한 것이므로 `v`는 정의상 그 핸들러의 `isHandlable`을 만족함. + 1-1/1-2의 `None`/래퍼 오염이 **구조적으로 불가능**해짐. 지금의 + "`isX(hintValue)` 가드 필수" 일반 규칙 자체가 힌트의 타입 미보장을 + 메우던 임시방편이었음이 드러남 — 그 규칙도 같이 없앨 수 있음. +- **깊은 체인의 힌트 유실도 사라짐** — 힌트를 위에서 아래로 전파하는 게 + 아니라, **각 레벨이 자기 재프로세스에서 자기 힌트를 받음**. + `State>`에서 바깥이 새 inner State를 내놓아도: index 2는 + StoreBind끼리 같으니 자기 클로저가 구독을 갈아타고, 재위임으로 내려간 + index 3은 TagHandler끼리 같으니 **진짜 `Tag` 객체를 힌트로 받아** + `Contains` skip이 정상 동작. (앞 라운드에 "구조상 불가피"라고 적었던 + 건 틀렸음 — 철거 선행 모델에서만 불가피했던 것.) +- **`StoreBind` 구독 갈아타기 문제 없음**(사용자 지적: "다만 뭐가 문제인지 + 모르겠어요") — "같은 핸들러면 **유지**"가 아니라 "같은 핸들러면 **자기 + 클로저 → 자기 process**"라, 옛 구독 해제와 새 구독이 그 안에서 끝남. + 검토 중 제가 문제로 짚었던 것은 "유지"로 잘못 읽은 데서 나온 것. +- **최상위는 애초에 안 돌아감** — 값이 안 바뀌면 Observer가 안 뛰므로 + 재프로세스 자체가 없음. + +### 2-2. 두 종류의 retract가 계약상 갈림 (사용자 정리) + +> "새 프로세싱으로 인한 retract처리와, 단순 retract는 다르다" + +- **단순 retract**(언마운트/전체 철거, `retractFrom`): 뒤따르는 `process`가 + 없음. 힌트는 항상 `nil`. 핸들러는 자기 기여를 무조건 전부 걷어냄. +- **재프로세싱**: 핸들러가 같으면 그 자리 클로저가 **새 값을 힌트로** + 받고, 다르면 그 자리부터 아래가 단순 retract 됨. + +### 2-3. 전제 계약 — `inst` 부작용은 말단 핸들러만 (사용자 명시) + +> "inst 에 실질적 처리를 가하는 동작은 항상 말단 핸들러 노드다" +> "중간 노드는 단순 언워랩만 한다" + +**기존 핸들러 전부가 이미 만족함**(확인함): + +| 핸들러 | 위치 | `inst` 부작용 | +|---|---|---| +| `StoreBind` | 중간 | 없음(구독 + 재위임만) | +| `NoneHandler` | 중간 | 없음(재위임만) | +| `PropertyHandler` | 말단 | 프로퍼티 세팅 | +| `TagHandler` | 말단 | `AddTag`/`RemoveTag` | +| `AttributeKeyHandler` | 말단 | `SetAttribute` | +| `SlotHandler` | 말단 | 마운트 | +| `RefLeafHandler` | 말단 | `Ref:Set` | +| `UICornerHandler` | 말단 | 자식 Instance 생성/제거 | +| `AttributeGroupHandler` | 자기 체인에선 말단 | 없음(다른 키로 위임) | + +새 제약을 거는 게 아니라 **이미 성립하는 성질을 계약으로 승격**하는 것. + +## 3. 검토 중 정리된 것 + +### 3-1. `HandlerChanged` 마커는 불필요 + +"핸들러가 바뀜"은 **그 자리 retractor가 `nil` 힌트로 불린다는 사실 자체**로 +이미 표현됨. 별도 마커 값을 만들면 그것도 결국 "힌트로 넘어오는 정체불명의 +값"이 되어 1-1과 같은 문제를 되풀이함. + +### 3-2. `oldValue`를 넘기자는 보완안 — 철회(사용자 지적) + +> "이전 값인 oldValue 는 처음부터 클로저라 이미 본인이 알지 않아요?" + +맞음. 클로저는 자기 `process` 호출의 `v`를 upvalue로 캡처하고 있고, +힌트로 새 값을 받으므로 **old/new를 이미 둘 다 갖고 있음.** 문제였던 건 +오직 힌트의 타입 보장이고, 그건 2-1의 핸들러 선비교로 해결됨 — +`chains`에 값을 따로 저장할 이유가 없음. (`chains`에 추가로 저장해야 +하는 건 **비교용 `handler` 하나뿐**.) + +### 3-3. 중간(재위임) 핸들러에 붙는 작은 계약 + +같은 핸들러로 재프로세스될 때, **재위임하는 핸들러는 반드시 다시 +재위임해야 함.** 안 그러면 아래 인덱스가 고아로 남음(아무도 안 지움). +`StoreBind`/`NoneHandler`는 항상 재위임하므로 지금은 위반 사례가 없지만, +"조건부로만 재위임하는" 핸들러를 새로 만들면 그 자리에서 +`Dispatch.retractFrom(inst,k,index+1,nil)`을 직접 불러 아래를 정리해야 함. + +## 4. 부수 효과 — Slot의 "해제 짝"은 애초에 없어도 됨 (사용자 지적) + +`base/slot-plan.md`가 "`attachSlot`이 등록한 `Dispatch.setLength`/ +`setOffsetSource`에 대응하는 해제 짝이 없다"를 언마운트 전환의 실제 +작업량으로 꼽았는데, **새 함수가 필요 없음**: + +> "옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각. +> retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...) +> setOffsetSource(...None...) 될 수 있어요." + +이건 이미 확정된 관용구 그대로임 — `bind-system-plan.md`의 "실제 마운트를 +하지 않는 위치는 `None`을 등록, `setLength`도 짝을 맞춰 `0`". 즉 **해제 = +0/`None`으로 재등록**이고, 별도 unregister API가 아예 필요 없음. + +**`state>`류로 offset이 밀리고 당겨지는 문제는 "그냥 확인된 +것"으로 수용**(사용자 판단) — `state>`와 같은 범주이고, 평탄화 +도구(`research/operator-sugar-plan.md`)로 처리할 요소이며 실사용 케이스가 +드묾. Dispatch에 별도 배관을 넣지 않음. + +## 5. 남은 열린 항목 — Attribute 이름 소유권 (**하나뿐, 사용자 확인 필요**) + +사용자 판단: + +> "이 방식으로 가면, 자연히 Attribute 의 소유권 충돌은 처리되네요. +> 이미 처리된 인덱스에 대해 다시 프로세스 되는것 자체가 UB가 아니고, +> 오류 처리가 필요한 곳이면 직접 처리하면 되니까요." + +**앞부분(재프로세스가 UB 아님)은 동의 — 뒷부분("자연히 처리됨")은 +추적해보니 그렇지 않음.** 구체적으로: + +그룹 A가 `Dispatch.process(inst, AttributeKey("foo"), sourceA, 1)`로 +등록해둔 상태에서 그룹 B가 같은 이름에 `sourceB`로 들어오면: +인덱스 1의 현재 핸들러는 `StoreBind`이고 `sourceB`도 `StoreBind`에 +매치되므로 **"같은 핸들러"로 판정되어 조용히 갈아탐.** 그리고 나중에 +그룹 A의 클로저가 자기 이름들을 `retractFrom`할 때 **그룹 B의 바인딩을 +대신 철거**함(교차 오염). 즉 예전 "조용한 last-write-wins"가 그대로 +돌아옴 — 이번 감사에서 고쳤던 바로 그 증상. + +**즉 "직접 처리하면 되니까"가 맞고, 그 '직접 처리'를 실제로 무엇으로 +할지가 유일하게 남은 결정.** 후보: + +- **(a) 이름별 claimant `Relate`를 Attribute 쪽에 둠** — 2026-08-13 + 네 번째 세션에 `owners`라는 이름으로 만들었다가 기각됐던 그것. + **당시 기각 사유는 지금은 해당 없음**: 그때 버그는 "소유권 반납이 + `process`의 `v==nil` 분기에만 있어서 그룹이 이름을 통째로 놓는 경로가 + 그 분기를 안 타 옛 소유권이 안 지워짐"이었는데, 지금은 **클로저가 항상 + 불리고 거기서 반납**하면 되므로 그 구멍이 구조적으로 없음. 실질 6줄 + 정도. +- **(b) 감지 포기, 문서로만 금지** — 사용자 코드 실수이므로 UB로 두는 것. + 다만 증상이 "조용한 오작동 + 교차 오염"이라 다른 UB들(즉시 + 스택오버플로/즉시 error)보다 훨씬 나쁨. +- **(c) `Dispatch`에 claimant 개념을 일반화** — 이번에 걷어낸 방향이라 + 다시 넣는 건 반대. + +**권고: (a).** 기각 사유가 새 모델에서 소멸했고, 소유권 판정을 필요로 +하는 유일한 핸들러에만 국소적으로 두는 게 "Dispatch는 diff만 한다"는 +이번 방향과도 맞음. + +## 6. 반영 범위 (확정 시) + +- `bind-system-plan.md` — `Dispatch.process` 의사코드, "Dispatch 체인" 절, + "핸들러 계약"(힌트 타입 보장 추가, `isX(hintValue)` 가드 규칙 삭제), + "Handler 작성 체크리스트" 3번 항목, `StoreBind` 예시(선행 `retractFrom` + 삭제), `NoneHandler` 예시, "hintValue는 직속 1단계에만" 항목 삭제. +- `tag-plan.md` — 힌트가 항상 `Tag`임이 보장되므로 `isTag(hintValue)` + 가드 삭제, 깊은 중첩 캐비엇 삭제. +- `slot-plan.md` — 4절대로 "해제 짝 필요" 서술 정정, 언마운트 경로에 + `setLength(0)`/`setOffsetSource(None)` 명시. +- `attribute-plan.md` — 5절 결정 반영, `process`/클로저 모양 재확정. diff --git a/.claude/research/operator-sugar-plan.md b/.claude/research/operator-sugar-plan.md index 4e10cbd..a4f8916 100644 --- a/.claude/research/operator-sugar-plan.md +++ b/.claude/research/operator-sugar-plan.md @@ -291,6 +291,79 @@ Attribute는 이미 "겹치면 error"로 소유 코드가 명확히 갈리는 재검토. 지금은 이름도 모양도 구체화 안 함 — 착수 시점에 이 문서의 `Animate`/`Sum` 패턴을 그대로 참고. +## 열린 질문 — 중첩 State 평탄화 `State>` → `State` (2026-08-13 여섯 번째 세션, 사용자 제시, 백로그) + +**배경**: 2026-08-13 다섯 번째 세션의 인덱스 기반 `Dispatch` 재설계로 +`State>`가 UB에서 **정상 지원 대상**이 됐음(각 재귀 단계가 +다른 인덱스를 써서 슬롯 충돌이 없어짐, `base/bind-system-plan.md` +"Dispatch 체인" 절). 하지만 **동작한다고 해서 권장 방향인 건 아님** +(사용자 판단: "UB는 아니지만 우리가 원치 않는 방향인건 맞습니다"). + +**왜 원치 않는가 — 단순 취향이 아니라 실제 기능 손실이 있음**: +`Dispatch.retractFrom(inst,k,index,v)`는 힌트 `v`를 정확히 `index` +자리에만 넘기고 더 깊은 인덱스엔 `nil`을 넘김. 그래서 `State` +(한 겹, StoreBind@1 → TagHandler@2)에서는 재발행 시 TagHandler가 +`hintValue=newTag`를 **확실히** 받아 깜빡임 방지가 동작하지만, +`State>`(StoreBind@1 → StoreBind@2 → TagHandler@3)에서 +**바깥** store가 재발행하면 TagHandler는 `nil`을 받아 `RemoveTag`→ +`AddTag` 왕복이 실제로 일어남. 깊이가 늘수록 힌트 기반 최적화 +(`Tag`의 `Contains`, `Ref`/`Slot`의 identity 비교)가 전부 무력화됨. + +**아이디어(착수 안 함)**: 중첩을 Dispatch 층에서 감내하는 대신, 값 +층에서 **평탄화하는 콤비네이터**를 제공 — `State>`를 받아 +`State`를 돌려주는 join/flatten(하스켈 모나드 `join`, RxJS +`switchAll`/`switchMap`에 대응). 그러면 체인이 항상 한 겹으로 유지돼 +힌트도 안 잃고, 사용자 의도("안쪽 값이 바뀌면 그걸 따라간다")도 더 +직접적으로 표현됨. + +- 2026-08-13 두 번째 세션의 Haskell 비교에서 이미 **"Monad bind/join이 + `StoreBind`/`Slot:Single`/`NoneHandler`에 각자 따로 재구현돼 있는 + 미일반화 후보"**로 식별해뒀던 것과 같은 자리 — 그 세션은 "착수 안 함" + 으로 남겼고, 이번에 구체적 동기(힌트 유실)가 붙은 것. +**모양(2026-08-13 여섯 번째 세션 후속, 사용자 구체화)**: 이 카탈로그의 +다른 항목들과 달리 **`Operator.*` 네임스페이스가 아니라 `State`의 +메소드로 제공되어야 할 것으로 보임** — `state:Flatten()` 또는 +`state:Flat()`(이름 미정). 이유: `Sum`/`Not`류는 인자를 받아 값을 만드는 +순수 함수라 자유 함수가 자연스럽지만, 평탄화는 **특정 State 노드 하나를 +받아 그것을 따라가는 새 노드**를 만드는 것이라 `:Compute`/`:With`와 +같은 층위의 체이닝 메소드가 맞음. + +- 대상 타입은 `State | T>` — 즉 **안쪽이 State일 수도, 그냥 값일 + 수도 있는 섞인 경우까지 흡수**해야 함(사용자 명시). 동작: 바깥 State의 + 변경을 리슨하다가, 흘러나온 값이 `T`면 그대로 `State`로 내보내고, + `State`면 그 안쪽을 따라가는 `State`를 내보냄. + +**⚠️ 핵심 난점 — 반환 노드가 동적 의존성을 가짐(사용자 지적).** +이게 이 항목을 단순 슈가로 못 만드는 이유이자, 백로그에서 따로 더 +파야 하는 지점: + +- quad는 **암묵적 자동 추적을 기각**했고(`base/bind-system-plan.md`), + 의존성은 `:With`로 **정적으로** 선언하게 돼 있음. 게다가 "`:With`의 + 동적 의존성 미지원"은 2026-08-12 열여덟 번째 세션에 **의도된 + 트레이드오프로 확정**됨(`research/framework-comparison-findings.md`) — + State immutable 가정과 정면으로 부딪힌다는 이유. +- 그런데 평탄화 노드는 본질적으로 **안쪽 State가 바뀔 때마다 구독 + 대상을 갈아타야** 함 = 의존성 집합이 런타임에 변함. 즉 이 도구는 + 방금 그 "의도적 비지원" 결정의 **유일한 정당한 예외**를 요구함. +- 그래서 확정 전에 답해야 할 것: + 1. 동적 의존성을 **이 노드 안에만 가둘 수 있는가** — 바깥에서 보면 + 여전히 평범한 `State` 하나(정적 의존성 1개)이고, 구독 갈아타기는 + 노드 내부 구현 디테일로 숨겨지는가? 숨겨진다면 "동적 With 미지원" + 결정과 실제로는 안 부딪힘(그 결정은 *사용자가 선언하는* 의존성 + 목록에 대한 것이므로). + 2. 안쪽 State 교체 시 **옛 구독 해제 타이밍**과 `bindLifetime` 귀속 — + 이 노드는 `inst`에 안 묶인 순수 값 계층이라 `:Subscribe()` 계열 + 규칙을 따라야 하는지, 아니면 다운스트림이 살아있는 동안만 유지되는 + 별도 규칙이 필요한지. + 3. 그래서 결국 **`:Apply` 위의 순수 슈가가 아니라 진짜 새 프리미티브** + 인지 — 지금 판단으로는 상태를 갖는 노드라 후자에 가까움. 그렇다면 + 이 문서(순수 슈가 카탈로그)가 최종 거처가 아닐 수도 있음. + +**우선순위**: 백로그. `State>`가 이제 정상 동작하므로 이게 +없다고 막히는 건 없고, 힌트 유실도 흔한 경로(한 겹)엔 영향이 없음. +다만 위 난점 때문에 **다른 카탈로그 항목들보다 설계 비용이 확실히 큼** — +착수 시 "슈가 하나 추가"로 접근하지 말 것. + ## 우선순위 **맨 마지막.** 없어도 quad는 기능상 완전하고, 함수 간 의존이 없어 나중에 diff --git a/.claude/research/pre-implementation-audit.md b/.claude/research/pre-implementation-audit.md index ab2c2e0..a4bcedf 100644 --- a/.claude/research/pre-implementation-audit.md +++ b/.claude/research/pre-implementation-audit.md @@ -79,8 +79,10 @@ store.color }`처럼 애니메이션 없이 그냥 반응형으로 값만 바뀌 ### 1-2. retract 시 "이전에 실제로 매치됐던 핸들러"를 누가 추적하는지 불명 — [해소됨, 2026-08-08 세 번째 세션] **해소**: `Dispatch`가 `(inst,k)`별 핸들러 체인(순서 있는 배열, `chains`)을 -직접 소유하고, `Dispatch.retractUnder(inst,k,keep,v)`가 꼬리부터 `keep` -앞까지 정리해주는 걸로 확정 — 아래 원래 제안(`Dispatch/StoreBind.luau`가 +직접 소유하고, `Dispatch.retractFrom(inst,k,index,v)`가 꼬리부터 `index` +까지 정리해주는 걸로 확정(2026-08-08 확정 당시 이름은 `retractUnder`이고 +체인이 핸들러 배열이었음 — 2026-08-13 다섯 번째 세션에 인덱스 기반으로 +재설계되며 개명, 결론 자체는 유지) — 아래 원래 제안(`Dispatch/StoreBind.luau`가 "마지막 선택된 핸들러"를 직접 들고 있는 방식)은 재귀/래핑 핸들러가 여러 단계(A→B→C)로 겹칠 때 자기 자신의 상태와 위임한 핸들러의 상태가 슬롯 하나를 두고 충돌하는 문제가 있어 기각되고, 대신 Dispatch 자신이 diff --git a/.claude/session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md b/.claude/session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md new file mode 100644 index 0000000..3965b13 --- /dev/null +++ b/.claude/session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md @@ -0,0 +1,490 @@ +# 2026-08-13 여섯 번째 세션 — c33ae04 커밋 전체 감사, 인덱스 재설계 의사코드의 실제 버그 4건 + 전파 누락 정리 + +사용자 요청: "c33ae04 커밋이 정확한지 봐줘. 많은게 바뀌여서 정확한지 검토해야함. +너가 직접 봐야할듯. 전체 문서에 잘못된 부분이 있는지, 로직이 이상한게 있는지. +더 나은 로직이 있는지 전체 감사 처리를 해줘" — 서브에이전트 위임이 아니라 +메인 컨텍스트에서 직접 읽으라는 명시적 지시라 그렇게 진행. + +## 총평 + +재설계 **방향 자체는 옳음** — 인덱스 기반 재추적, `State>` UB 해소, +`retractUnder`/`retractSelfAndUnder` 통합, 체크포인트 패턴 철회, `Relate` +대량 정리 전부 근거가 맞고 `archive/checkpoint-handler-pattern-reversed.md`도 +정확함. 문제는 **그 결정에 맞춰 새로 쓴 의사코드들이 손 트레이싱을 안 +거쳤다는 것** — 네 번째 세션이 "합성 시나리오를 pseudocode에 손으로 대입해 +버그 찾기" 라운드였는데, 정작 그 결과로 다섯 번째 세션이 새로 쓴 코드에는 +같은 방법을 안 돌린 채 커밋됨. + +## 발견된 실제 버그 4건 + +### 1. `Dispatch.process`가 하위 위임 retractor를 통째로 유실 (치명) + +`bind-system-plan.md`의 `chains` 의사코드가 `chains:SetStrong(inst,k,list)`를 +`h.process` **뒤**에 두고 있었고, list 확보도 `chains:GetStrong(...) or {}` +였음. `h.process`가 내부에서 재귀 `Dispatch.process(...,index+1)`을 부르는 게 +정상 경로(StoreBind/NoneHandler)인데, 최초 마운트 시점엔 chains에 아직 +아무것도 없어 **재귀 호출이 `or {}`로 자기만의 새 테이블을 만들어 저장한 뒤 +바깥이 그걸 덮어씀.** + +`Frame { Text = state }` 트레이싱: + +| 단계 | chains | +|---|---| +| process(idx1) StoreBind 진입, list={} (미저장) | 없음 | +| ㄴ observer 즉시 1회 → process(idx2) Property | `{[2]=noop}` (별도 테이블) | +| StoreBind 반환, `list[1]=r1`, SetStrong(list) | **`{[1]=r1}`** — 앞의 것 유실 | + +증상: 이후 첫 재발행에서 `retractFrom(inst,k,2,...)`가 `#list==1`이라 아무것도 +안 부름. Property(no-op)면 무해하지만 **인덱스 2가 Slot이면 이전 서브트리가 +파괴 안 된 채 새 서브트리가 마운트(자식 중복)**, Ref면 이전 Ref가 stale하게 +남음. 즉 이 재설계의 핵심인 다단 체인 정리가 최초 마운트 경로에서 통째로 +깨져 있었음. + +수정: `SetStrong`을 `h.process` 위로 hoist. 추가로 `h.process` 호출 전에 +no-op 점유 마커를 박도록 함 — 재귀 중 list에 구멍이 생기면 `#list`가 +Lua에서 미정의이고(hole 있는 테이블), 같은 index 재진입 버그도 가드에 +안 걸리기 때문. + +### 2. Attribute 그룹의 소유권 충돌 감지가 실제로는 절대 안 걸림 (치명) + +`attribute-plan.md` "이름 소유권" 절은 "점유 체크가 소유권 충돌 감지를 +그대로 대신함"을 이번 재설계의 핵심 근거로 선언하는데, 정작 "메커니즘" +절 코드가 + +```lua +Dispatch.retractFrom(inst, key, 1, source) -- 인덱스 1을 무조건 비움 +Dispatch.process(inst, key, source, 1) -- → 점유 error가 날 수가 없음 +``` + +이라 **그룹↔그룹 사이에서 조용한 last-write-wins가 그대로 남아 있었음** +(그룹 B가 그룹 A의 바인딩을 파괴하고 이김, 나중에 A의 클로저가 돌면 B의 +바인딩을 대신 철거). 이 절이 없앴다고 선언한 바로 그 문제. + +(그룹↔직접 리터럴 쓰기 방향은 정상 작동했음 — 배열파트가 먼저라 그룹이 +점유하고, 해시파트 직접 쓰기는 `retractFrom` 없이 `process`만 부르므로.) + +수정: `retractFrom`을 `process`에서 빼고 **반환 클로저가 자기가 등록한 이름 +전부를 철거**하도록 이동. 생존 이름도 매 사이클 철거→재등록되는 비용이 +생기지만(구독 해제+재구독, 같은 값 `SetAttribute` 1회) 이 문서가 이미 +"값 비교는 안 함"을 확정해둬서 결이 같음. **`Tag`식 `hintValue == v` 조기 +반환을 여기 넣으면 안 됨** — 클로저가 아무것도 안 걷어낸 채 다음 `process`가 +같은 인덱스를 잡으려 들어 자기 자신에게 점유 error를 냄. + +### 3. `SlotHandler`가 claim 실패에도 파괴적 클로저를 반환 (치명) + +`claimOwner`의 `current == ownerKey → return false` 분기가 **두 개의 서로 다른 +상황을 구분 못 함**: +- 같은 `(inst,k)`의 spurious 재발행 (정상, no-op이어야) +- **같은 `inst`의 다른 위치** (`Frame { slot, slot }`) — error여야 하는데 false + +후자에서 attach는 건너뛰면서 파괴적 클로저는 그대로 반환해서, 철거 시 +`destroySlotTree` 두 번 + `unbindLifetime` 짝 어긋남 + `releaseOwner`가 +(이번 세션에 새로 넣은 엄격 버전이라) error로 터짐. 구 설계는 `kSlotMap`에 +안 적힌 자리의 `retract`가 자연히 no-op이라 우연히 막혀 있었고, `kSlotMap` +제거가 그 방어를 같이 걷어낸 회귀. + +**감사 중 오답 하나 — 기록해둠**: 처음엔 "claim 실패 시 no-op 클로저를 +반환"으로 고치려 했는데 이건 더 나쁨 — `retractFrom`은 클로저가 +early-return하든 말든 체인에서 **항상 소비**하므로, spurious 사이클에서 +no-op을 심으면 다음 진짜 교체 때 이전 서브트리를 정리할 주체가 사라짐. +원래의 단일 파괴적 클로저 구조가 맞고, 고칠 곳은 `claimOwner`뿐이었음. + +### 4. `Ref` retractor가 자기 dedup을 스스로 무력화 + +```lua +if hintValue ~= v then v:Set(nil) end +if relate:GetStrong(inst, k) == v then relate:SetStrong(inst, k, nil) end -- 무조건 +``` + +spurious 재발행에서 `Set(nil)`은 건너뛰지만 relate를 지워서, 이어지는 +`process`의 `old ~= v`가 항상 참 → `v:Set(inst)` 재실행 → 콜백 헛 재통지. +바로 아래 산문이 약속한 "spurious면 둘 다 스킵"이 성립을 안 했음. +사용자가 이 건은 즉시 확인해줌("4. 는 확인했어요 맞습니다"). +수정: relate 정리를 `if hintValue ~= v` 블록 안으로. + +## 사용자 제기 — Slot-in-Slot / `state` 소유권 + +> "slot in slot 을 생각해보면 a=Slot {}; Slot{a,a} 도 UB 여야할텐데, +> Slot{state} 또한 가능합니다. ... Slot in slot 도 안전해야하는데, +> 저는 후자가 맞는듯 합니다." + +조사 결과 **후자가 맞고 실제로 성립하지만, 추가 수정 셋이 필요했음**: + +- **N1**: `rawAdd`가 `claimOwner`의 반환값을 아예 안 봄 → `Slot { a, a }`가 + 조용히 통과해 `_elements={a,a}`, `attachSlot`이 두 번 불려 부모 + `lengthList`에 같은 Slot이 두 번 계산되고 `slot.Offset`도 두 번째가 + 덮어써 첫 `Source`가 고아가 됨. + → **nested엔 "재클레임"이라는 개념이 애초에 없음**(reconcile은 항상 + `rawRemove`→`rawAdd` 순서, `rawMove`/`rawSwap`은 클레임 미접촉)이라 + 엄격 error가 맞음. top-level만 `claimOwnerAt(element,inst,k)`으로 위치까지 + 봐서 spurious를 구분. +- **N2 (위치 키잉이 안전한 이유, 사용자 확인)**: "바깥 slot 안에서의 인덱스는 + 여전할거예요. 오직, flatten 되어진 상태에서의 위치만 달라질 뿐이고 ... + offset/index 구현이 그걸 증명해요" — 물리 배치 변동은 전부 `offset`이 + 흡수하도록 설계돼 있고, top-level의 `k`는 props 배열 리터럴 위치라 더욱 + 고정. nested는 `Move`/`Swap`/`Splice`가 인덱스를 밀지만 위 결론대로 + 위치를 안 쓰므로 무관. +- **N3**: `rawRemove` 의사코드에 `releaseOwner`가 아예 없었음(산문 쪽은 + 있다고 명시 — 코드/산문 불일치). +- **N4**: `destroySlotTree`가 자식 `releaseOwner`를 안 하고 `_mounted`/ + `_mountedInst`도 안 되돌림 → `elementOwner` 값이 weak라 "언젠간 사라지지만 + **언제인지가 GC 타이밍에 달려서**" 그 전에 같은 element를 재사용하면 + "이미 마운트됨" error가 비결정적으로 터짐. + +`state` 자체는 구조가 `outer._elements[i] = sub`(래퍼 Slot, 영구 고정) +→ `sub:Single(state)` → `:List` reconcile이 안쪽만 교체이고, reconcile이 +`rawRemove(prev)` 다음 `rawAdd(result)` 순서라 소유권이 release→claim으로 +정확히 갈림. **즉 "래퍼가 불변이라 괜찮다"가 아니라 nested CRUD의 +release→claim 규율 자체가 안전성을 만듦** — 그래서 손으로 중첩한 +`Slot{Slot{Slot}}`도 같은 규칙 하나로 동작. 이 결론을 `slot-plan.md`에 +표와 함께 별도 절로 기록. + +## 그 외 수정 + +- `AttributeGroupHandler.process(inst, index, v)` — 코퍼스에서 유일하게 계약 + (`process(inst,k,v,index)` 4-인자)과 안 맞던 시그니처. 배열 위치를 하필 + `index`로 불러 새 `index` 파라미터와 충돌하기까지 했음. +- `Dispatch.drive`의 진입 인덱스(`1`)가 어디에도 안 적혀 있었음. +- retractor 안에서 **같은 키**에 `retractFrom`을 부르는 것도 `process`처럼 + 금지(진행 중인 루프가 `#list`를 이미 캡처)임을 명문화 — 원래는 "다른 + 키에 대해서는 문제없음"이라는 괄호로만 암시됐음. +- 깊은 체인에서 hint 유실: `retractFrom`은 `v`를 `i == index`에만 넘기므로 + `State>`의 바깥 재발행에선 TagHandler가 `nil` 힌트를 받아 + RemoveTag→AddTag 깜빡임. 구조상 불가피(바깥은 안쪽 값을 모름)해서 + `tag-plan.md`의 깜빡임 방지 주장을 "직속 위임 1단계 한정"으로 범위 축소. +- 미문서화 접근자 `Tag:Names()`/`Attribute:NameMap()`을 각 API 절에 추가. + +## 전파 누락 정리 (커밋이 안 건드린 것들) + +- **`ROADMAP.md`가 전혀 갱신 안 됨** — CLAUDE.md가 "구현 순서의 소스"라고 + 못박은 문서인데 M2 `Dispatch/init.luau` 항목이 아예 2026-08-08 이전 모델 + ("이전 담당자와 다르면 그 `retract`")이었고, `chains`도 핸들러 배열, + `retractUnder`도 그대로. M2/M4/M7 전부 새 모델로 갱신 + 위 버그 1을 다시 + 내지 않도록 "구현 시 반드시 지킬 것" 체크리스트 추가. +- **base 안에서 계약 개수가 모순** — `store-semantics.md`는 이번에 3종으로 + 고쳤는데 `module-lifecycle-plan.md`/`component-composition-plan.md`는 + 4종, `ui-shorthand-plan.md`/`onchange-plan.md`/`tween-plan.md`/ + `lifecycle-pattern.md`는 별도 `retract` 필드 전제. 전부 갱신 + (`onchange-plan.md`는 단순 이름이 아니라 Connection Disconnect 로직이 + 반환 클로저로 이동해야 하는 실질 변경이었음). +- `luau-test/04`가 **설계와 정반대를 검증 중**이었음 — 파일명부터 + `retractUnder`이고, 다섯 번째 세션이 **없앤** "중복 핸들러 즉시 error" + 가드를 테스트하는데 `luau-test/README.md`는 "[2026-08-13 재작성] 신규 + 가드가 즉시 error하는지"라고 최신인 척 적어놨음. `04-dispatch-chain- + retractFrom.luau`로 전면 재작성 — 인덱스 기반 3단 체인, 깊은 쪽부터 + 정리, hint 전달 범위 + **버그 1을 재현하는 음성 대조군**(`SetStrong`을 + `process` 뒤로 옮기면 체인 깊이가 1로 무너지는지)을 포함. 옛 04의 + no-op retract 스텁이 사각지대였던 전례를 반복하지 않도록 이번엔 + retractor가 실제로 구독을 끊음. +- `luau-test/19` B/C 섹션이 폐기된 설계(`rawNew`+`owners`, 3분기 + `claimOwner`)를 검증 중 — 파일 헤더에 무엇을 어떻게 다시 써야 하는지 + 배너로 명시(재작성 자체는 미착수). +- `slot-plan.md`의 GC 주의 문단/`relate-plan.md`가 `kSlotMap`/`slotOwner`를 + 현재 설계처럼 서술하던 것을 역사 표시로 정정(둘 다 지금은 없음 — 일반 + 규칙은 계속 유효). +- `.claude/README.md`의 `SlotHandler.retract` 언급 제거 + 세 행에 이번 + 감사 결과 반영. + +## 남은 것 + +- `luau-test/19` B/C 섹션 재작성(헤더에 지시만 남김). +- 스파이크 실측 자체는 여전히 미실행(이 환경에 `luau` 바이너리 없음) — + 새로 쓴 `04`의 음성 대조군이 실제로 깊이 1로 무너지는지 확인하면 + 버그 1의 재현·수정이 실측으로 닫힘. + +## 후속 라운드 (같은 세션, 사용자 추가 질문) + +### `State`의 사라짐/재등장, 그리고 포탈 + +사용자 질문: `stateSlot: State`를 `Slot { stateSlot }`에 넣고 +지웠다 나타나게 하면 문제 없는가. 그리고 `Get()`으로 뽑아두고 `Set()`으로 +갈아끼운 뒤 뽑은 걸 다른 데 넣을 수 있는가 — 되면 포탈이 해결됨. + +- **소유권 bookkeeping은 정상**(이번 감사 수정 이후) — reconcile이 + `rawRemove`(→`releaseOwner`) / `rawAdd`(→`claimOwner`)로 깨끗이 갈림. +- **그러나 Slot 자체가 파괴됨** — reconcile이 쓰는 게 `rawRemove`(제거 + **+ 파괴**)라 `nil`이 되는 순간 `destroySlotTree`가 자식 Instance를 + `:Destroy()`함. 같은 Slot 객체를 `nil`↔`slotA`로 왕복시키면 두 번째 + 등장부터 껍데기만 재마운트됨. 기존 "폐기, 옮기지 않음" 정책의 직접 + 귀결이지 이번 감사로 바뀐 게 아님. +- **포탈은 현재 불가, 그러나 부품은 이미 다 있음** — 막는 건 소유권 + 규칙이 아니라 "제거 = 파괴"라는 reconcile의 선택 하나뿐이고, + `Extract`/`ExtractAll`/`Splice`가 이미 비파괴로 확정돼 있고 + `attachSlot`이 재마운트를 구조적으로 이미 지원함(이번에 넣은 + `destroySlotTree`의 `_mounted` 복원이 마침 그 전제 조건이기도 함). + **"포탈은 별도 메커니즘이 필요하다"는 기존 전제가 틀렸을 가능성이 + 큼** — 남는 실제 작업은 (1) 비파괴를 어디에 opt-in으로 열지, (2) + `attachSlot`의 등록(`setLength`/`setOffsetSource`/자식 observer)에 + 대응하는 **해제 짝**이 지금 없다는 것. 확정은 사용자 몫이라 + `question.md` 0-A로 올리고 `slot-plan.md`에 상세 기록. + +### `State`는 힌트를 확실히 받는가 — 받음 + +`retractFrom`은 힌트를 `i == index` 자리에만 넘김. 한 겹 +(`StoreBind@1` → `TagHandler@2`)에서는 StoreBind가 +`retractFrom(inst,k,2,realv)`를 부르므로 정확히 그 핸들러에 걸림 — +**유실 없음**. 두 겹 이상에서 바깥이 재발행할 때만 안쪽이 `nil`을 받음. +이 계약을 `bind-system-plan.md`에 명시적으로 못박음(원래는 의사코드에만 +있었음). + +### 사용자 제안 — 핸들러 identity를 저장해두고 값을 한 겹 풀어 내려보내기 + +기각. 사용자 스스로 지적한 이유(=`process`와 retractor가 1:1이 아니고 +retract만 나는 경로가 정상적으로 존재해서 내려보낼 "새 값"이 없는 +경우가 있음)에 더해, 더 근본적인 이유가 있음: 값을 한 겹 풀려면 +teardown 도중 `innerState:Get()`을 **투기적으로** 호출해야 하는데, +State는 pull-recompute라 실제 재계산을 유발하고 곧이어 `process`가 다시 +`:Get()`을 불러 같은 사이클에 이중 계산이 됨. 게다가 그렇게 얻은 값은 +추정이라 실제 `process` 시점 값과 다를 수 있음("값 비교/캐싱 금지" +원칙과 충돌). + +**대신 채택 방향(사용자 제시)**: 값 층에서의 평탄화 +(`State>` → `State`). 체인이 한 겹으로 유지되면 위 보장이 +그대로 적용되므로 Dispatch에 특수 배관이 필요 없어짐. 백로그로만 등록 +(`research/operator-sugar-plan.md`) — 2026-08-13 두 번째 세션의 Haskell +비교가 이미 "Monad join이 미일반화 후보"로 짚어둔 자리에 구체적 동기가 +붙은 것. `State>`는 UB는 아니지만 권장 방향도 아니라는 사용자 +판단을 그 문서에 명시. + +### 재발 방지 조치 (사용자 요청) + +"새로운 모델로 인해 생겨날 수 있는 흔한 실수들이 재발하지 않도록 잘 +정리해주세요. Relate 나 Dispatch/Handler 전반에서 더 실수가 나오지 +않도록 조치가 필요해보여요." + +- `bind-system-plan.md`에 **"Handler 작성 체크리스트 — 새 모델에서 실제로 + 반복된 실수들"** 절 신설(7항목). 이번 세션 버그 4건이 서로 다른 문서에 + 있으면서도 같은 종류의 착각에서 나왔다는 관찰이 출발점 — 특히 (1) + "클로저는 early-return해도 체인에서 소비된다"(no-op 반환 유혹), (2) + "다른 키로 위임하며 그 키를 미리 `retractFrom`하지 말 것"(점유 체크 + 무력화), (3) `hintValue`의 세 가지 함정(nil 아님/타입 미보장/깊은 + 인덱스엔 안 옴)이 실제로 밟힌 것들. +- `relate-plan.md`에 **"언제 `Relate`를 쓰고 언제 쓰면 안 되는가"** 절 + 신설 — 클로저 캡처로 충분한 경우 vs 진짜 필요한 경우의 경계, 그리고 + "정리 조건을 실제 정리와 묶을 것"(Ref 버그), "weak라고 GC에 기대지 + 말 것"(Slot 버그) 두 규칙. + +## 세 번째 라운드 (같은 세션) — 사용자 설계 결정 3건 + +### A. `State` 교체는 파괴가 아니라 **언마운트** (확정, 앞 라운드 결론을 뒤집음) + +앞 라운드에서 "reconcile이 `rawRemove`(파괴)를 쓰므로 포탈 불가"라고 +보고했더니, 사용자가 **설계 자체를 뒤집음**: "state에서 slot 빼내면 빠질 +수 있는게 나은듯. 애초에 스플라이싱을 지원하는데?" + +근거: +- **`state`가 이미 그렇게 동작함** — 다른 값으로 바꿔도 이전 + Frame을 quad가 지우지 않고 unmount만 함. `State`만 다를 이유가 + 없음. `Ref`("Destroy 무관")/`Attribute`("명시적 `None`으로만") 철학과도 + 같은 결. +- 비파괴 추출(`Extract`/`ExtractAll`/`Splice`)은 **이미 지원되는 개념**. +- "뽑아냈으면 리프의 소유가 아닌 게 맞아보임. 명시 `:Destroy()`를 하도록 + 유도하는 게 이로운 것 같음." +- "슬롯은 '들고 있다 죽으면' 같이 소멸한다" — GC-native 그대로. + +부수 효과(사용자 지적): 하위 요소까지 `bindLifetime`하고 `canExecute`를 +확인하게 하면, "nested로 마운트 후 Instance를 제거하고 그 Slot을 뽑아 +쓰려는" 경로가 **별도 방어 없이 자연히 막힘** — 기존 게이트를 한 층 더 +촘촘히 적용하는 것뿐. + +**그래서 포탈은 별도 기능이 아니라 이 결정의 귀결이 됨** — 앞 라운드에서 +"opt-in으로 열지"를 열린 질문으로 뒀는데, 기본 동작이 되면서 그 질문 +자체가 사라짐. **남은 실제 작업은 하나**: `attachSlot`이 등록하는 것들 +(자식 observer `bindLifetime`, 옛 owner의 `setLength`/`setOffsetSource`)에 +대응하는 **해제 짝이 지금 없음**. + +곁들여 확정된 UB: **`Set`으로 덮어쓰기 *전에* 이전 값을 직접 +`Destroy()`하는 것**(= `state`에서 먼저 `frame:Destroy()`하고 +`Set`하는 것과 같은 문제). 순서는 항상 `Set`(언마운트) → 그 다음 정리. + +### B. `dispose(any)` 신설 방향 (사용자 제안) + +위 UB를 "조심하세요"로만 두지 않기 위해, base 탑레벨 `dispose(value)`를 +제공 — "이 값을 지금 확실히 없앤다, 아직 마운트돼 있으면 먼저 안전하게 +떼어낸 뒤 파괴". 사용자 논증이 깔끔했음: **"이미 `a=Frame{}; Frame{a} +Frame{a}`가 에러나도록 하기로 했으니, 어디 마운트되었냐가 따져지고, +그래서 이미 가능한 일"** — `elementOwner`를 거꾸로 읽으면 되므로 새 +부기가 필요 없음. 시그니처/대상 범위/`unbindLifetime`과의 분담은 +`question.md` 0-B로. + +### C. `hintValue` 폐기 제안 — 검토 결과 **사용자 지적이 맞음** + +사용자 제기: "process 상 newv(hint) 처리가 단일 네스팅이면 +None -> AttributeKey 같은걸 탄다던가 하면 머리아픈데. 괜찮은게 진짜 +맞나 검토가 필요해." + +**재현됨.** `[AttributeKey "foo"] = state`가 `5` ↔ `None`을 오갈 때 +체인 모양이 `StoreBind→NoneHandler→AttributeKey`(None) ↔ +`StoreBind→AttributeKey`(5)로 바뀌고, `5 → None` 전이에서 인덱스 2의 +`AttributeKeyHandler` retractor가 **`hintValue = None`** 을 받음. +Attribute는 no-op이라 무해하지만 `TagHandler`였다면 `isTag(None)`이 +거짓이라 이름 전부를 `RemoveTag`함. + +**진짜 문제는 깊이가 아니라 "힌트의 타입이 계약으로 정해져 있지 +않다"는 것**이었음 — 앞 라운드에서 문서화한 "깊이 2 이상에선 `nil`"은 +이 결함의 한 특수 케이스일 뿐이었다는 게 이번에 드러남. 힌트가 `None`/ +`State`/`Tween` 등 래퍼일 수 있어서, `Tag`의 `Contains` skip과 +`Ref`/`Slot`의 identity 비교가 **정확성은 유지한 채 조용히 꺼짐**(그래서 +지금까지 안 드러났음). `Slot`은 "가드 없으면 서브트리 전체 파괴 후 +재생성"이라 파급이 큼. + +**사용자 제안 방향**: 철거 후 힌트와 재구축 대신, process를 쭉 진행하며 +각 자리에서 "이전 핸들러 vs 새 값에 매치될 핸들러"를 비교 — 다르면 +그 아래 전부 죽이고, 같으면 값을 계속 전파하며 자기 인덱스에 대해서만 +후처리. 전제 계약: **"`inst`에 실질적 처리를 가하는 건 항상 말단 핸들러, +중간 노드는 언워랩만"**. 결론적으로 **"새 프로세싱으로 인한 retract와 +단순 retract는 다르다"**. + +**검토 결과**: +- 전제 계약은 **기존 핸들러 9개 전부에서 이미 성립**(표로 확인) — 새 + 제약이 아니라 이미 있는 성질의 승격이라 채택 비용이 낮음. +- 단, **"같은 핸들러면 유지"는 `StoreBind`에 그대로는 틀림** — 인덱스 2가 + `StoreBind`인 채 바깥이 새 inner State를 내놓으면 "같은 핸들러"지만 + 옛 State에 구독돼 있어 갈아타야 함. → "유지"가 아니라 **"그 인덱스에 + `process`를 다시 호출(retractor는 안 부름)"** 이어야 함. +- 그러면 핸들러가 이전 값을 알아야 하는데 → **힌트 대신 `oldValue`를 + 넘기자**는 보완 제안. `chains`가 retractor 옆에 `(handler, value)`를 + 같이 저장하면 됨. `oldValue`는 *그 핸들러가 직전에 실제로 매치한* + 값이라 **타입이 구조적으로 보장**되어 `None`/래퍼 오염이 원천 차단되고, + 방향도 맞아서(`hintValue`는 "다음", `oldValue`는 "이전") + `RefLeafHandler`의 dedup용 `Relate`도 없앨 수 있음. `isX(v)` 방어 + 가드 규칙 자체가 힌트의 타입 미보장을 메우던 임시방편이었음이 드러남. +- **주의**: 같은 인덱스 재프로세싱을 허용하려면 점유 체크를 갈라야 하는데, + **그 가드가 곧 Attribute 그룹의 소유권 충돌 감지**라 약해지지 않는지 + 반드시 같이 확인해야 함(이번 감사에서 정확히 그 지점이 한 번 무너진 + 전례가 있음). + +**실행은 보류** — `base/` 4개 문서 의사코드 전면 재작성 규모라, 같은 날 +두 차례 급하게 쓴 의사코드에서 버그가 나온 전례를 감안해 +`research/dispatch-hint-to-oldvalue-plan.md`로 먼저 정리하고 확정 대기 +(`question.md` 0-A). 그때까지 `base/`의 현행 `hintValue` 서술이 유효. + +### D. 평탄화 백로그 상세화 (사용자: "백로그에서 더 자세히 다루도록 업데이트만 하자") + +`state:Flatten()`/`Flat()` — `Operator.*` 자유 함수가 아니라 **State의 +메소드**로 제공되어야 함(특정 노드를 따라가는 새 노드를 만드는 것이라 +`:Compute`/`:With`와 같은 층위). 대상은 `State | T>`(섞인 +경우까지 흡수). **핵심 난점(사용자 지적): 반환 노드가 동적 의존성을 +가짐** — quad가 암묵적 자동 추적을 기각했고 "동적 `:With` 미지원"을 +2026-08-12 열여덟 번째 세션에 의도된 트레이드오프로 확정해뒀는데, 이 +도구는 그 유일한 정당한 예외를 요구함. 확정 전 답할 것: 동적 의존성을 +노드 내부에 가둬 바깥에선 평범한 `State` 하나로 보이게 할 수 있는가, +옛 구독 해제 타이밍과 `bindLifetime` 귀속, 그래서 순수 슈가가 아니라 +진짜 새 프리미티브인지(현재 판단은 후자). 착수 안 함. + +## 네 번째 라운드 (같은 세션) — 사용자 반문으로 모델 확정, 내 제안 두 개 철회 + +### `dispose`는 "떼어낸 뒤 파괴"가 아니라 **"거부하고 error"** + +> "dispose 는 정확히 Frame 이든, slot이든 어느 트리에 의해 살아 있는게 +> 요구된다면 Destroy 거부한다, 에러를 낸다고 보면 되겠네요. 실제로 +> 클리어 하거나 Destroy 해도 그냥 로블록스엔 에러 안 나는데, quad에선 +> 데이터 구조가 깨지는 일이니까요." + +내가 쓴 "먼저 안전하게 떼어낸 뒤 파괴"보다 훨씬 단순하고 맞음 — 떼어내는 +건 `Set`(언마운트)의 몫이고 `dispose`는 그 뒤에 부르는 것. 이걸로 +"`Set` 전에 직접 `Destroy()`"가 UB에서 **명확한 에러**로 바뀜. + +### Slot의 "해제 짝"은 애초에 필요 없었음 + +> "옛 오너가 setLength/setOffsetSource 를 그냥 실행해도 된다는 생각. +> retract에서 hint 를 보고 Slot이 아니면 그냥 setLength(...0...) +> setOffsetSource(...None...) 될 수 있어요." + +맞음 — 이미 확정된 "마운트 안 하는 위치는 `0`/`None` 등록" 관용구 그대로라 +**해제 = 0/`None`으로 재등록**이고 새 API가 필요 없음. 앞 라운드에서 +"이게 언마운트 전환의 실제 작업량"이라고 꼽은 판단은 과했음. +`state>`류 offset 밀림은 `state>`와 같은 범주로 +**"그냥 확인된 것"으로 수용**(평탄화 도구가 처리, 케이스 드묾). + +### 디스패치 모델 확정 — 내 반론 두 개가 다 틀렸음 + +**(1) "같은 핸들러면 `StoreBind`가 구독을 못 갈아탄다"** — 내가 사용자 +제안을 "같으면 **유지**"로 잘못 읽은 데서 나온 반론이었음. 실제 제안은 +"같으면 **내가 처리**"(= 그 자리 클로저 호출 → 자기 `process` 재호출)라 +옛 구독 해제와 새 구독이 그 안에서 끝남. 사용자: "다만 뭐가 문제인지 +모르겠어요" — 문제 없었음. + +**(2) `oldValue`를 따로 넘기자는 보완안** — 사용자: "이전 값인 oldValue 는 +처음부터 클로저라 이미 본인이 알지 않아요?" 맞음. 클로저는 자기 `v`를 +캡처하고 힌트로 새 값을 받으므로 old/new를 이미 둘 다 갖고 있음. 진짜 +문제였던 힌트의 **타입 보장**은 "핸들러 비교를 클로저 호출 *앞*에 둔다"는 +것만으로 해결됨(같은 핸들러일 때만 값이 넘어가고, 그 값은 정의상 +`isHandlable`을 만족) — `chains`에 추가할 건 비교용 `handler` 하나뿐. + +**부수 발견**: 이 모델이면 **깊은 체인의 힌트 유실도 같이 사라짐.** 힌트를 +위에서 아래로 전파하는 게 아니라 각 레벨이 자기 재프로세스에서 자기 +힌트를 받으므로, `State>`에서 바깥이 새 inner를 내놔도 index 3의 +TagHandler가 **진짜 `Tag`를 힌트로 받아** `Contains` skip이 살아남. 앞 +라운드에 "구조상 불가피"라고 적었던 건 철거-선행 모델에서만 참이었음. +`HandlerChanged` 마커도 불필요(핸들러가 바뀐 건 retractor가 `nil` 힌트로 +불린다는 사실로 이미 표현됨). + +### 유일하게 이견 — Attribute 소유권은 "자연히" 처리되지 않음 + +사용자는 "이 방식으로 가면 자연히 Attribute 의 소유권 충돌은 처리되네요" +라고 봤으나, 추적 결과 **그렇지 않음**: 그룹 A가 잡아둔 이름에 그룹 B가 +들어오면 인덱스 1의 핸들러가 양쪽 다 `StoreBind`라 "같은 핸들러"로 판정돼 +조용히 갈아타고, 나중에 A의 클로저가 B의 바인딩을 대신 철거함(교차 오염) — +이번 감사에서 고친 바로 그 증상이 되돌아옴. 다만 사용자의 나머지 절반 +("오류 처리가 필요한 곳이면 직접 처리하면 되니까요")은 맞고, 그 '직접 +처리'를 뭘로 할지가 유일한 결정 사항. **권고: 이름별 claimant `Relate`를 +Attribute 쪽에 국소적으로 둠** — 네 번째 세션에 `owners`로 만들었다 +기각됐던 그것인데, **당시 기각 사유("소유권 반납이 `v==nil` 분기에만 있어 +안 지워짐")가 새 모델에선 구조적으로 소멸**(클로저가 항상 불리고 거기서 +반납). 문서: `research/dispatch-redispatch-diff-plan.md`(파일명도 +`dispatch-hint-to-oldvalue-plan.md`에서 바꿈, `oldValue`가 철회됐으므로). + +## 다섯 번째 라운드 (같은 세션, 마무리) — 해제 순서 주의 + Attribute 이관 + +### `setOffsetSource(None)` → `setLength(0)` 순서 고정 (사용자 지적) + +> "setLength 먼저 수행하고 setOffsetSource 수행하기 보단 setOffsetSource 를 +> 먼저 날려야할듯 합니다 - 물론 별 상관 없어요. 자기 자신의 length 가 +> 줄어든다는 의미는, 자기 자신 offset은 여전하다는건데, 그래서 업데이트 +> 될 일이 없긴합니다. 다만, 여전히 setLength 로 인해 다시 slot 들의 +> offset들이 재계산 될 때 invalid 한 offset source 자체가 있다는것 부터 +> 위험합니다. 방어적으로 처리하세요." + +`setLength`는 끝에서 `recompute`를 돌리고 `recompute`는 `sourceList`를 +순회하며 `offset:Set(sum)`을 호출함 — 그래서 **`setLength(0)`을 먼저 +부르면 그 시점에 해제 중인 자리의 `sourceList[i]`엔 아직 옛 Slot의 offset +`Source`가 남아 있어**, 지금 막 떼어내는 서브트리의 Source에 `:Set()`이 +날아가고 그걸 구독하던 (곧 없어질) 자식들의 `LayoutOrder` 계산이 헛되이 +캐스케이드됨. `setOffsetSource(None)`을 먼저 하면 `recompute`의 +`offset ~= None` 가드에 바로 걸려 그 Source를 아예 안 건드림. + +값이 틀려지는 문제는 아님(자기 length가 줄어도 **자기 앞 형제들의 +누적합은 그대로**라 자기 offset은 갱신될 일 자체가 없음) — 위험한 건 +"invalid한 Source가 순회 대상에 남아있다"는 것 자체. 방어적으로 순서를 +계약으로 고정하고, 추가 조치 둘을 같이 넣음: +- 해제 시 `slot.Offset = nil`(이 문서의 "마운트 전엔 `nil`" 규칙과 짝), +- `recompute`가 `sourceList[i]`의 `nil`도 `None`과 똑같이 skip(전이 + 구간에서 관측돼도 크래시 대신 넘어가도록 — 등록 쪽의 "반드시 `None`" + 의무는 그대로). + +`base/bind-system-plan.md`(Length/Offset 절, `recompute` 의사코드)와 +`base/slot-plan.md`(언마운트 절) 양쪽에 반영. + +### Attribute 소유권 — 다음 세션 심층 분석으로 명시 이관 + +> "Attribute 소유권은 아마 이전 결정을 다시 가져오는게 맞아보이긴 하네요. +> 막 깊게 Key -> Group 필요한것 같지는 않고, 본인 retract 처리를 수행할 때 +> 무언가 하면 될듯 한데. 이 부분은 나중에 제가 물리적으로 스케치 해보며 +> 심층 분석해보겠습니다. 당장은 이 세션 중 나온 내용, 지식이 누락 없게 +> stale 없게 처리해주시고. ... 이미 세션이 길고, 이 부분을 더 깊게 파기엔 +> 다른 부분을 누락할 위험이 있어요." + +방향은 이미 잡혀 있음(이름별 claimant `Relate` 부활, 단방향이면 충분, +반납은 클로저 안에서) — 다만 확정은 사용자가 직접 스케치한 뒤로. +`question.md`에 **0-Z(최우선)** 로 분리하고, CLAUDE.md "지금 할 일"에 +0번 항목으로 올려 핸드오버. + +**핸드오버에서 가장 중요한 한 가지**: **`base/`의 현행 `hintValue` 서술은 +아직 옛 모델(철거 선행)이고, 새 모델은 `research/ +dispatch-redispatch-diff-plan.md`에만 있음** — base만 읽고 구현하면 옛 +모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의 파일별 목록대로 4개 base +문서를 한 번에 옮길 것. 반대로 **Slot의 언마운트/`dispose`/해제 순서는 +이미 base에 확정 반영됨**(재디스패치 모델과 독립적인 결정이라 먼저 들어감) +— 이 비대칭을 헷갈리지 말 것. + diff --git a/CLAUDE.md b/CLAUDE.md index 94324a6..55991f3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,6 +110,25 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 ## 지금 할 일 (우선순위순) +0. **⭐ 최우선 — `.claude/question.md` 0-Z(Attribute 이름 소유권) 결정.** + 2026-08-13 여섯 번째 세션에 `Dispatch` 재디스패치 모델이 "하강 diff"로 + 다시 정리되면서(`research/dispatch-redispatch-diff-plan.md`), **그 + 모델에서 유일하게 안 풀린 것이 Attribute 그룹의 이름 소유권 충돌 + 감지**임. 사용자가 "이전 결정(이름별 claimant `Relate`)을 다시 가져오는 + 게 맞아 보이나, 다음 세션에 직접 물리적으로 스케치하며 심층 분석"으로 + 명시 이관 — 그전까지 아래 항목들보다 우선. + **핸드오버 시 반드시 알아야 할 것**: + - **`base/`의 현행 `hintValue` 서술은 아직 옛 모델(철거 선행)이다.** + 새 모델은 `research/dispatch-redispatch-diff-plan.md`에만 있음 — + base만 읽고 구현하면 옛 모델로 짜게 됨. 0-Z가 정해지면 그 문서 6절의 + 파일별 반영 목록대로 `bind-system-plan.md`/`tag-plan.md`/ + `slot-plan.md`/`attribute-plan.md`를 **한 번에** 옮길 것. + - 반대로 **`base/slot-plan.md`의 "언마운트/`dispose`/해제 순서"는 이미 + 확정 반영됨**(재디스패치 모델과 독립적인 결정이라 먼저 들어감). + - 이 세션에서 같은 날 두 차례 급하게 쓴 의사코드가 각각 버그를 냈다는 + 사실 자체가 교훈 — 0-Z 반영도 서두르지 말고 손 트레이싱을 거칠 것 + (`bind-system-plan.md` "Handler 작성 체크리스트" 절이 그 산물). + 1. **구현 시작 — 루트 `ROADMAP.md`의 M0부터.** 설계 단계는 2026-08-04 로드맵 인수인계 라운드로 종료. `research/pre-implementation-audit.md` 우선순위1은 2026-08-12 열일곱 번째 세션에 마지막 넷(1-3/1-4/1-10/1-11)까지 전부 @@ -131,8 +150,12 @@ modifier/Ref의 컴포넌트 경계 통과 방식) 논의도 2026-08-04 세션 대신 `retractFrom`+`process`가 반환하는 클로저) — 기존 `luau-test/04` (다단 체인 스트레스 테스트)는 옛 모델(핸들러 identity 기반 `chains`) 전제로 쓰여 있어서, 돌려보기 전에 새 모델에 맞춰 먼저 다시 써야 함 - (`session/2026-08-13-05-dispatch-index-based-redesign.md` "남는 것" - 참고) — 아직 안 함, 다음 세션 우선 항목. + — **[2026-08-13 여섯 번째 세션에 완료]** `04-dispatch-chain- + retractFrom.luau`로 전면 재작성됨(인덱스 기반 3단 체인 + 같은 세션 + 감사가 발견한 `chains:SetStrong` 순서 버그를 재현하는 음성 대조군 + 포함). 대신 `luau-test/19`의 B/C 섹션이 폐기된 설계(`rawNew`+ + `owners`, 3분기 `claimOwner`)를 검증 중인 게 새로 드러나 재작성 + 대기 — 무엇을 어떻게 고쳐야 하는지는 그 파일 헤더 배너에 적어둠. 2. **용어 정리 — 1차 제안 이후 대부분 확정, 소수만 남음.** 최신 소스는 `.claude/question.md` 1번(개수 반복 안 함, 항목 추가/해소될 때마다 여기가 stale해지는 패턴이 반복됐어서). **[2026-08-13 정정]** `State`는 @@ -849,3 +872,47 @@ handoff용 저장이 불필요해짐 — `Relate`는 여러 위치/사이클을 `attribute-plan.md`/`slot-plan.md`/`architecture.md`/`store-semantics.md`/ `modifier-plan.md` 전부 반영, `archive/checkpoint-handler-pattern-reversed.md` 신설. + +**2026-08-13 여섯 번째 세션 — c33ae04 커밋 전체 감사(버그 4건), Slot +언마운트 전환, 재디스패치 모델 재설계** +(`session/2026-08-13-06-commit-audit-dispatch-redesign-bugs.md`) +사용자 지시로 직전 커밋을 메인 컨텍스트에서 직접 정독 — 인덱스 재설계 +**방향은 옳지만 새로 쓴 의사코드가 손 트레이싱을 안 거친 채 커밋**됐음이 +드러나 실제 버그 4건 발견·수정: (1) `Dispatch.process`의 `chains:SetStrong`이 +`h.process` 뒤에 있어 최초 마운트에서 하위 retractor가 통째로 유실, +(2) Attribute 그룹이 `process`에서 `retractFrom`을 선행 호출해 점유 +체크(=소유권 충돌 감지)가 전혀 작동 안 함, (3) `SlotHandler`가 claim +실패에도 파괴적 클로저를 반환해 `Frame{slot,slot}`에서 이중 파괴, +(4) `Ref` retractor가 spurious 재발행에서도 `relate`를 지워 dedup 무력화. +Slot 소유권은 nested=엄격 `claimOwner`/top-level=`claimOwnerAt(inst,k)`로 +분리하고 `rawRemove`의 `releaseOwner` 누락·`destroySlotTree`의 GC 타이밍 +의존 오류도 수정. `ROADMAP.md`가 2026-08-08 이전 모델로 남아있던 것, base +내 "3종 vs 4종 계약" 모순, `luau-test/04`가 없어진 가드를 검증하던 것도 +정리(`04`는 인덱스 모델 + 버그 (1) 재현 음성 대조군으로 재작성). +재발 방지로 `bind-system-plan.md`에 "Handler 작성 체크리스트"(7항목), +`relate-plan.md`에 "언제 `Relate`를 쓰고 언제 쓰면 안 되는가" 신설. + +**이어진 사용자 설계 결정(같은 세션, 최종 상태만)**: +- **`State` 교체 = 파괴가 아니라 언마운트**(`state`와 동일, + 비파괴 추출은 `Splice`로 이미 지원) — **포탈이 별도 기능이 아니라 이 + 결정의 귀결이 됨**. 해제는 `setOffsetSource(None)` → `setLength(0)` + **순서 고정**(반대면 죽는 중인 서브트리의 offset `Source`에 헛된 `:Set()`이 + 날아감) + `recompute`의 `nil` 관대 처리/`slot.Offset = nil` 방어. + 별도 unregister API는 불필요. `base/slot-plan.md`에 **확정 반영 완료**. +- **`dispose(value)` 신설** — "트리가 아직 살아있길 요구하면 파괴를 거부하고 + error"(엔진은 조용히 넘어가지만 quad 자료구조가 깨지므로). 시그니처/범위는 + `question.md` 0-B. +- **재디스패치를 "하강 diff"로 재설계** — 래핑 핸들러의 `retractFrom` 선행 + 호출을 폐기하고 `Dispatch.process`가 **핸들러를 먼저 비교**(같으면 그 자리 + 클로저에 새 값 넘기고 자기 process 재호출, 다르면 아래 전량 철거). 계기는 + 힌트가 `None`/래퍼로 오염돼 깜빡임 방지가 조용히 꺼지는 결함(사용자 제기). + 이 모델이면 **힌트 타입이 구조적으로 보장되고 깊은 체인 힌트 유실까지 + 사라짐**. 내가 낸 반론("StoreBind 구독 갈아타기")과 보완안(`oldValue` 전달)은 + 둘 다 사용자 지적으로 철회 — 클로저가 이미 old를 캡처하고 있음. + **아직 `research/dispatch-redispatch-diff-plan.md`에만 있음, base 미반영.** +- **평탄화**(`state:Flatten()`)는 백로그 상세화만 — 반환 노드의 **동적 + 의존성**이 최대 쟁점(`research/operator-sugar-plan.md`). +- **남은 결정 하나: Attribute 이름 소유권** — 새 모델에선 양쪽 다 + `StoreBind`라 "같은 핸들러"로 판정돼 조용히 갈아탐. 사용자가 "이전 + 결정(claimant `Relate`)을 다시 가져오는 게 맞아 보이나 다음 세션에 직접 + 스케치하며 심층 분석"으로 이관 — `question.md` **0-Z(최우선)**. diff --git a/ROADMAP.md b/ROADMAP.md index e6adbf5..324d254 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,8 +24,8 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 `base/store-semantics.md` "Source가 State를 만족함" 절 — `State`가 `Source`를 참조하지 않는 단방향 의존으로 두면 위험한 상호 재귀는 피할 수 있어 보이나 실제 검증 전엔 확정 아님) -- [ ] `process`/`retract` 재귀 재-process 디스패치를 실제로 짜보기(store-bind - 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) +- [ ] `process`(+반환 retractor 클로저) 재귀 재-process 디스패치를 실제로 + 짜보기(store-bind 핸들러 하나 + `isHandlable` 우선순위 스캔 포함) - [ ] props 순회의 "배열 파트 먼저, 해시 파트 나중" 두 패스 계약이 실제 Luau 테이블에서 관찰한 대로 동작하는지 확인, `PreRef` pre-pass + 일반 `Ref`의 위치 기반 순서까지 최소 스파이크로 검증 @@ -64,20 +64,24 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M2 — 디스패치 엔진 - [ ] `Dispatch/init.luau` — `Dispatch.getHandler(inst,k,v): Handler?`(순수 - 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v)`(오케 - 스트레이터: getHandler → 이전 담당자와 다르면 그 `retract` → 새 - 핸들러의 `.process`) / `Dispatch.addHandler(handler)`(레지스트리 - 등록, quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, - flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 `process` 호출 — - `bind-system-plan.md`의 `None` 센티널 절, 2026-08-07 여덟 번째 세션에 - 네이밍 확정). "이 키를 지금 누가 담당 중인가" bookkeeping은 - `Dispatch.drive`가 아니라 `Dispatch.process` 호출 자체 내부에서 - 갱신할 것(재귀 재-process 시에도 자연히 갱신되게 — 안 그러면 재귀 - 재디스패치를 쓰는 케이스(`StoreBind`, `NoneHandler`)에서 매 - 사이클 불필요한 `retract`가 반복 호출될 위험) + 스캔, `isHandlable`+`priority`) / `Dispatch.process(inst,k,v,index)` + (오케스트레이터: 그 인덱스 점유 여부 체크 → getHandler → 매치된 + 핸들러의 `.process`를 불러 **그 반환값(retractor 클로저)을 + `chains`의 그 인덱스에 저장**) / `Dispatch.retractFrom(inst,k,index,v)` + (아래 항목) / `Dispatch.addHandler(handler)`(레지스트리 등록, + quad-roblox가 팩토리 뮤테이션 시점에 호출) / `Dispatch.drive(inst, + flattened)`(배열→해시 두 패스 순회하며 각 `(k,v)`에 + `Dispatch.process(inst,k,v,1)` 호출 — `bind-system-plan.md`의 `None` + 센티널 절, 2026-08-07 여덟 번째 세션에 네이밍 확정). + **[2026-08-13 다섯 번째 세션 전면 재설계]** "이전 담당자와 다르면 + 그 `retract`"라는 옛 diff 모델은 폐기 — 정리 책임은 전적으로 + 재귀/래핑 핸들러(`StoreBind`/`NoneHandler`)가 재-dispatch 전에 + 스스로 `retractFrom`을 부르는 쪽에 있고, `Dispatch.process`는 + diff를 하지 않음 - [ ] `Handler.luau`(핸들러 계약 타입: `isHandlable(inst,k,v)`/`priority`/ - `process`/`retract` — `isHandlable`도 `inst`를 받도록 확정, 2026-08-07 - 여덟 번째 세션 정정) + `process(inst,k,v,index) -> (hintValue)->()` **3종** — `isHandlable`도 + `inst`를 받도록 확정(2026-08-07 여덟 번째 세션), 별도 `retract` 필드는 + `process` 반환값으로 합쳐짐(2026-08-13 다섯 번째 세션)) - [ ] `Brand.luau`(공유 weak-key 레지스트리, `Brand.set(x,tag)`/ `Brand.get(x)` — `isState`뿐 아니라 `isObserver`/`isEffect`/`isTag`/ `isAttributeKey`/`isAttribute`/`isTween`/`isBlocker`/`isSource`/ @@ -133,10 +137,12 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 경로(`bindLifetime`/`unbindLifetime`)로 등록, `:Subscribe()` 아님 (2026-08-09 여섯 번째 세션, `base/bind-system-plan.md` "Length/Offset" 절 — `base/slot-plan.md` "여러 Slot이 섞일 때 순서 보장" 해소) -- [ ] 핸들러 계약 검증: `retract` 필드가 없는 핸들러를 등록하면 리뷰/린트에서 - 걸러내기(no-op이라도 필드 자체는 항상 정의 — `Dispatch.process`가 핸들러 - 교체 시 nil 체크 없이 호출, `base/bind-system-plan.md` "핸들러 계약" - 절, 2026-08-08 세션) +- [ ] 핸들러 계약 검증: `process`가 retractor 클로저를 **반환하지 않는** + 핸들러를 등록하면 리뷰/린트에서 걸러내기(정리할 게 없어도 항상 + `function() end`를 반환 — `Dispatch.retractFrom`이 nil 체크 없이 + 호출, `base/bind-system-plan.md` "핸들러 계약" 절, 2026-08-08 세션 + / **2026-08-13 다섯 번째 세션에 별도 `retract` 필드가 `process` + 반환값으로 합쳐지며 대상만 바뀜**) - [ ] 우선순위 동률/매치 실패 처리(2026-08-12 열일곱 번째 세션 확정, `base/bind-system-plan.md` "우선순위 동률/매치 실패 처리" 절) — `HANDLER_PRIORITY_HIGH`/`_NORMAL`/`_LOW` 등 목적별 우선순위 상수, @@ -149,13 +155,25 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 leaf 매칭 Handler, `StoreBind.luau`와 같은 층위(범용/엔진무관) — quad-base 소속으로 확정(2026-08-08 두 번째 세션, `base/ bind-system-plan.md` "Dispatch는 프리미티브가 아니다" 절) -- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={handler,handler,...} - (strong 순서 배열)}}`) + `Dispatch.retractUnder(inst,k,keep,v)` — - 재귀 재-dispatch(StoreBind/NoneHandler)의 retract를 다단 - 체인까지 정확히 전파(2026-08-08 세 번째 세션, `base/ - bind-system-plan.md` "Dispatch 체인" 절 — `pre-implementation-audit.md` - 1-2번 "이전 핸들러 추적" 항목 해소). `Dispatch.process`가 매치될 - 때마다 체인에 push하는 것도 이 항목에 포함 +- [ ] `chains`(Relate 기반, `{[inst(weak)]={[k]={[index]=retractor}}}` — + **핸들러 배열이 아니라 재귀 깊이 인덱스→retractor 클로저 맵**) + + `Dispatch.retractFrom(inst,k,index,v)` — 재귀 재-dispatch(StoreBind/ + NoneHandler)의 정리를 다단 체인까지 정확히 전파(2026-08-08 세 번째 + 세션 신설, **2026-08-13 다섯 번째 세션 인덱스 기반 전면 재설계** — + `base/bind-system-plan.md` "Dispatch 체인" 절, + `pre-implementation-audit.md` 1-2번 "이전 핸들러 추적" 항목 해소). + `Dispatch.process(inst,k,v,index)`가 반환 클로저를 그 인덱스에 + 저장하는 것도 이 항목에 포함. **구현 시 반드시 지킬 것**: + - `chains:SetStrong(inst,k,list)`는 `handler.process` 호출 **전에** — + 뒤에 두면 재귀 위임이 자기 테이블을 만들었다가 바깥이 덮어써 + 하위 retractor가 통째로 유실됨(2026-08-13 감사에서 잡힌 버그) + - `handler.process` 호출 전에 그 인덱스에 no-op 점유 마커를 박아 + `list`를 구멍 없는 시퀀스로 유지(hole 있는 테이블의 `#`는 Lua가 + 보장 안 함) + 같은 index 재진입도 가드에 걸리게 + - 점유 체크는 `getHandler`/`handler.process`보다 **먼저**(핸들러 + 부작용 낭비 없음) + - 다른 키로 위임할 땐 항상 `index=1`, 같은 키 재귀는 `index+1`; + `Dispatch.drive`의 진입도 항상 `1` - [ ] mock 대상 테스트 ## M3 — Store/State/Source @@ -213,11 +231,16 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 ## M4 — 첫 end-to-end 반응형 업데이트 - [ ] `Dispatch/StoreBind.luau`(재귀 재실행 로직, 엔진 무관 — 재-dispatch - 전 `Dispatch.retractUnder(inst,k,self,realv)` 호출 필수, `base/ - bind-system-plan.md` "Dispatch 체인" 절) + 전 `Dispatch.retractFrom(inst,k,index+1,realv)` 호출 필수, 그 다음 + `Dispatch.process(inst,k,realv,index+1)`. `base/bind-system-plan.md` + "Dispatch 체인" 절) - [ ] mock 대상으로 "store 값 바꾸면 `process`가 다시 호출된다" + - "이전 값이 다른 타입이면 이전 핸들러의 `retract`가 정확히 불린다" - 확인 + "이전 값이 다른 타입이면 이전 `process`가 반환했던 retractor 클로저가 + 정확히 불린다" 확인 + **`State>`(값이 또 State/Source)가 + 인덱스 N/N+1로 안 겹치고 정상 동작하는지**(2026-08-13 다섯 번째 + 세션에 UB→정상 지원으로 재정정) + **최초 마운트 직후 첫 재발행에서 + 인덱스 2의 retractor가 실제로 불리는지**(위 M2의 `SetStrong` 순서 + 버그가 정확히 여기서 증상으로 나타남) ## M5 — quad-roblox 최소 프로바이더 @@ -471,20 +494,26 @@ Luau 코드로 부딪혀본 적 없는 세 가지**를 던지는 코드로 검 - [ ] `Attribute.luau`(quad-base — 그룹 값 타입+API: `Attribute(store1, store2, ...)`/`Merged`, `Tag`와 동형 array-part 값 객체, `base/attribute-plan.md`) -- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 process/retract, 이전 - 이름 집합과의 diff만 자체 로직, 실제 `SetAttribute`/store-bind - 구독은 메모이즈된 `AttributeKey(name)`로 `Dispatch.process`/ - `retractUnder`에 재귀 위임(단일 키 경로 재사용, 중복 구현 없음), - `base/attribute-plan.md`) +- [ ] `Handlers/Attribute.luau`(quad-roblox — 그룹 `process`가 이름마다 + 공개 `AttributeKey(name)`로 `Dispatch.process(inst,key,source,1)`만 + 부르고, 반환 클로저가 **자기가 등록한 이름 전부**에 + `Dispatch.retractFrom(inst,key,1,nil)`. 실제 `SetAttribute`/store-bind + 구독은 전부 단일 키 경로 재사용(중복 구현 없음). + **`process` 안에서 `retractFrom`을 먼저 부르면 안 됨** — 인덱스 1이 + 무조건 비워져 소유권 충돌 점유 체크가 무력화됨(2026-08-13 감사), + `base/attribute-plan.md` "메커니즘" 절) - [ ] `Tag.luau`(quad-base — 값 타입+immutable clone 체이닝: `Tag(...)`/ `:Added`/`:Removed`/`:Contains`/`:Apply`/`Merged`, `base/tag-plan.md` — 2026-08-08 세 번째 세션 array-part 값 객체로 재설계, 구 해시 파트 모델은 `archive/tag-hash-key-model-reversed.md`) -- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` process/retract - 글루만, `isHandlable`은 `isTag(v)`. `retract`는 이제 의미 있음(값이 - Tag가 아니게 되면 전체 삭제), 같은 Tag끼리 바뀌는 diff는 `process`가 - 자기 `Relate` 저장분과 비교해서 처리 — 전체 삭제 후 재생성 금지(랙 - 유발), `base/tag-plan.md` 참고) +- [ ] `Handlers/Tag.luau`(quad-roblox — `CollectionService` 글루만, + `isHandlable`은 `isTag(v)`. **`AddTag`는 온전히 `process`, `RemoveTag`는 + 온전히 반환 클로저** — 이름별 홀더 집합(`tagNameMap`, 위치 `k` 기준 + 참조 카운트)이 비었을 때만 실제 `RemoveTag`, 그마저도 `hintValue`가 + 그 이름을 `Contains`하면 skip해 깜빡임 방지(전체 삭제 후 재생성 + 금지). `process` 쪽 별도 diff 없음, `kTagMap`도 불필요(클로저가 `v`를 + 직접 캡처) — 2026-08-12 열한 번째 / 2026-08-13 네·다섯 번째 세션, + `base/tag-plan.md` "메커니즘" 절) ## M11 — Tween